Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
495e2229f5 | ||
|
|
37ef4897ae | ||
|
|
06e549cb27 | ||
|
|
c96741794a | ||
|
|
e14d0906ea | ||
|
|
d5be16127b | ||
|
|
b7aef40266 | ||
|
|
fafdaa2617 | ||
|
|
7213068d91 | ||
|
|
05dbe8c5c1 | ||
|
|
806352f8f6 | ||
|
|
34a7af2847 | ||
|
|
635c64d773 | ||
|
|
64d9c54657 | ||
|
|
aec7f0f068 | ||
|
|
4023b21365 | ||
|
|
e14fc884a3 | ||
|
|
b3a84c5bb8 | ||
|
|
9e6254108a | ||
|
|
ce87958b77 | ||
|
|
802014fea9 | ||
|
|
e22acf4913 | ||
|
|
c14c64d6c0 | ||
|
|
6e3e5594eb | ||
|
|
36ff3742b4 | ||
|
|
fd6a07bb58 | ||
|
|
1921d76319 | ||
|
|
d302151a8d | ||
|
|
5b6f4381b0 | ||
|
|
cae49fcd09 | ||
|
|
ee292ffd10 | ||
|
|
dd4713fa81 | ||
|
|
a8727ddf0b | ||
|
|
2faeab665a | ||
|
|
62b3b92e69 | ||
|
|
ec6a707fe4 | ||
|
|
ac5b377750 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: amd-apu-vulkan-tenant-onboarding
|
||||||
|
description: "Add a ggml/Vulkan inference tenant to an AMD APU (gfx1151 / Strix Halo iGPU) that already serves another engine: APU memory census, RADV heap gate, render-node resolution, build-side glslc/ICD deps, co-tenancy measurement."
|
||||||
|
---
|
||||||
|
|
||||||
|
Use when a ggml-based engine (llama.cpp, qwentts.cpp, anything built on ggml) must move from CPU to the **iGPU** of an AMD APU (gfx1151 / Strix Halo / Ryzen AI Max) that **already serves another engine on the same card**. Answers two questions only a booted host can: what gate the second tenant must clear, and which files the build needs to clear it.
|
||||||
|
|
||||||
|
## 1. The usual VRAM reasoning is wrong on an APU
|
||||||
|
|
||||||
|
`/sys/class/drm/cardN/device/mem_info_vram_total` reports a small **carve-out** (2 GiB class), and `rocm-smi`'s "dedicated" figure is not the working set either. The real working set lives in **GTT / system pages**: `/proc/meminfo` → `GTT` and `System pages`. A second GPU tenant therefore consumes host RAM from the same pool the first tenant's weights and the OS use — no supplementary groups, no separate aperture to reserve.
|
||||||
|
|
||||||
|
- Price the tenant from a **booted** footprint, never from `weights + KV + buffers`: on an APU the compute buffers and any projector ride on GTT, which is host RAM; check `free` *after* the load, not before.
|
||||||
|
- Never subtract a "dedicated" figure from it to find headroom.
|
||||||
|
- Unheld fabric clock (`pp_dpm_mech` = `auto`) is a hazard only when the NPU joins too; a pure iGPU pair is unaffected — check it before adding a third tenant.
|
||||||
|
- Swap on a host with GPU tenants protects co-tenants; paging a KV pool is worse than the OOM it prevents.
|
||||||
|
|
||||||
|
## 2. The gate that decides feasibility: the RADV heap a container sees
|
||||||
|
|
||||||
|
`rocm-smi` and `free` cannot see this number; only a container off the **engine's own base image** can:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one-off container, no model, no load, no compute — safe beside production
|
||||||
|
podman run --rm --device /dev/dri --group-add keep-groups <engine-base> bash -c \
|
||||||
|
'apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq --no-install-recommends mesa-vulkan-drivers vulkan-tools glslang-tools >/dev/null 2>&1; vulkaninfo --text'
|
||||||
|
```
|
||||||
|
|
||||||
|
Read `VkPhysicalDeviceMemoryProperties` → the `DEVICE_LOCAL` heap size. On a hybrid Strix Halo box Mesa splits the aperture (~1/3 : 2/3), so `DEVICE_LOCAL` is the budget the ggml tool will actually allocate against — measured 77.33 GiB *with the first tenant loaded*, against a 2 GiB DRM carve-out. Two traps, both measured:
|
||||||
|
|
||||||
|
- `vulkaninfo --batch` does not exist in Ubuntu 24.04's `vulkan-tools` (prints help, exits 1). Every `vulkaninfo` parse needs `--text`.
|
||||||
|
- The container may have **no NVIDIA ICD**, so Vulkan index `0` *is* the iGPU — but do not trust ordering by index: `--text` and read `deviceName` + `driverID` (`RADV_GFX1151` vs `NVIDIA`). A hand-picked `/dev/dri/renderDN` is worse than useless on a hybrid host: **`renderD128` there is the NVIDIA card and `renderD129` the amdgpu iGPU**; resolve the node with `readlink -f /sys/class/drm/renderD*/device/driver` and pass `/dev/dri` wholesale, letting the driver pick by name.
|
||||||
|
- A rootless container that drops its supplementary groups enumerates **nothing**. `--group-add keep-groups` (quadlet: `GroupAdd=keep-groups`) plus the host udev rule that makes the render nodes group-writable is the whole requirement; `--device /dev/dri` alone is not enough.
|
||||||
|
|
||||||
|
## 3. Build-side deps: the configure fails before the device ever matters
|
||||||
|
|
||||||
|
ggml's `src/ggml-vulkan/CMakeLists.txt` requires, in order: `find_package(Vulkan COMPONENTS glslc REQUIRED)` then `find_package(SPIRV-Headers CONFIG REQUIRED)`, and it compiles its shaders with **glslc**, never `glslangValidator`. On Ubuntu 24.04 (measured against the digest-pinned `ubuntu:24.04` base, 2026-10-19):
|
||||||
|
|
||||||
|
- `glslang-tools` ships `glslangValidator` and **NOT `glslc`** → `command -v glslc` empty, `find_package(Vulkan … glslc REQUIRED)` fails, and the Vulkan backend silently never exists.
|
||||||
|
- The package is the standalone **`glslc`** (universe, `2023.8-1build1`). Working build-stage set: `libvulkan-dev glslc spirv-headers` — `libvulkan-dev` provides `vulkan/vulkan.h` + the `VulkanHeaders`/`VulkanLoader` CMake configs `find_package(Vulkan)` resolves to, `spirv-headers` ships `/usr/share/cmake/SPIRV-Headers/SPIRV-HeadersConfig.cmake`. A configure probe in the pinned base prints `Found Vulkan: … found components: glslc` then `Including Vulkan backend`.
|
||||||
|
- **The archive's glslc is old.** 2023.8 compiles `GL_KHR_cooperative_matrix` (the path ggml's vectored quantized matmul needs) but reports `cooperative_matrix2`, `integer_dot_product`, `bfloat16`, `float_e2m1/e4m3` as unsupported, so those shader families are compiled out. If a Vulkan prefill reads as inexplicably slow, suspect the **shader compiler before the device**; the fix is a pinned glslang build passed as `-DVulkan_GLSLC_EXECUTABLE=<path>`, not a different runtime.
|
||||||
|
- Runtime stage needs the loader **and an ICD**: `libvulkan1` + `mesa-vulkan-drivers`. Loader-without-ICD enumerates zero devices, and ggml-based servers die in device lookup — often *after* reading the weights, so the journal reads like a model fault.
|
||||||
|
- Ship **no NVIDIA ICD** on purpose: one Vulkan device in the enumeration is what makes the *name* `Vulkan0` safe to write into config. Prefer the name over an index in every flag.
|
||||||
|
|
||||||
|
## 4. Prove enumeration without allocating anything
|
||||||
|
|
||||||
|
Do not boot a scratch second engine beside production to ask which devices exist — a second model load is a real GPU tenant, and fleet law stops that. Instead run the binary with a device name that cannot exist and read its error; it names what it enumerated, with no allocation and no model resident:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run --rm --device /dev/dri --group-add keep-groups --entrypoint /app/tts-server <image> \
|
||||||
|
--model /dev/null --codec /dev/null --device NoSuchDevice 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. The rollback must be a flag, not a rebuild
|
||||||
|
|
||||||
|
Put the device in the env file as a value the entrypoint passes only when set, and make **set-but-empty** mean "pass no flag at all" — the binary's own default (CPU, always built) is the rollback. `: "${VAR:=default}"` is the WRONG form: `:=` fires on an empty value too, so the documented rollback line `VAR=` is silently rewritten back to the GPU and the only way out is a rebuild. Use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if [[ ! -v QWENTTS_DEVICE ]]; then QWENTTS_DEVICE=Vulkan0; fi
|
||||||
|
```
|
||||||
|
|
||||||
|
(`podman`'s `--env-file` passes `VAR=` through as set-and-empty, and `source` does the same, so both readers agree.) Pin it in a test: empty device ⇒ the flag is absent from argv.
|
||||||
|
|
||||||
|
## 6. Co-tenancy measurement (only after the tenant is live)
|
||||||
|
|
||||||
|
The gate is the **first tenant's** performance with a real request from the second in flight — not the second tenant's own speed. And price the tenant from a booted footprint, never from weights+KV+buffers: on an APU the compute buffers and any projector ride on GTT, which is host RAM; check `free` after the load, not before.
|
||||||
@@ -1,49 +1,62 @@
|
|||||||
---
|
---
|
||||||
name: dankclaw-pihole-lan-dns-record
|
name: dankclaw-pihole-lan-dns-record
|
||||||
description: "Add/repair a LAN DNS record for a new *.franksdank.cloud service on Dankclaw's Pi-hole v6 (hosts/custom.list path), plus the public Porkbun A record step and client cache gotchas."
|
description: "Add/repair a LAN DNS record for a new *.franksdank.cloud service on Dankclaw's Pi-hole v6 (hosts/custom.list + repo addn-hosts, plus the public Porkbun A record side) — and triage \"LAN names stopped resolving\" including the FTL boot-time record-load failure."
|
||||||
---
|
---
|
||||||
|
|
||||||
Use when a new `*.franksdank.cloud` service resolves publicly/LAN-needs a local address, on Dankclaw (Pi-hole v6 container, quadlet `pihole.container`).
|
# Dankclaw Pi-hole LAN DNS record
|
||||||
|
|
||||||
## Ground truth layout (verified 2026-10-11)
|
## What this fixes
|
||||||
|
|
||||||
- Quadlet: `~/.config/containers/systemd/pihole.container`; volumes `/home/dank/Containers/pihole/{etc-pihole,etc-dockers,addn-hosts}`.
|
A new `*.franksdank.cloud` service resolves publicly (via Porkbun → `franksdank.cloud` A record → Caddy on Dankclaw `192.168.0.143`) but **does not resolve on the LAN**. Pi-hole is the LAN DNS server (`dankclaw_default` bridge, published port 53) and serves local records only from files it imports.
|
||||||
- **LAN records live in `/home/dank/Containers/pihole/etc-pihole/hosts/custom.list`** — plain hosts format: `192.168.0.143 gitea.franksdank.cloud`. This is the boring, working path.
|
|
||||||
- `pihole.toml` `[[dns.records]]` array is EMPTY on this host — do not expect v6 UI-style records there.
|
|
||||||
- `gravity.db` exists but has NO `domain` table (v6 layout) — do not insert local records into SQLite.
|
|
||||||
|
|
||||||
## Procedure (30 s, proven)
|
## Live truth
|
||||||
|
|
||||||
```bash
|
- **The repo owns the records.** `dankclaw/pihole/addn-hosts` (hosts-style lines: `192.168.0.143 <name>`) is copied by `deploy-remote.sh` to `/home/dank/Containers/pihole/addn-hosts` and bind-mounted **read-only** into the container at `/etc/pihole/hosts/addn-hosts` (`Volume=/home/dank/Containers/pihole/addn-hosts:/etc/pihole/hosts/addn-hosts:ro` in `dankclaw/pihole/quadlet/pihole.container`). Add the name there, in the repo, then deploy — that is the durable path. `tests/pihole.bats` validates every line (IPv4, LAN range, name suffix, Caddyfile cross-check).
|
||||||
ssh dankclaw 'echo "192.168.0.143 <name>.franksdank.cloud" | sudo tee -a /home/dank/Containers/pihole/etc-pihole/hosts/custom.list >/dev/null \
|
- `/home/dank/Containers/pihole/etc-pihole/hosts/custom.list` also exists on the host (older mechanism, mode `640` owned by the container uid, root reads it with `sudo`). It is **not** the repo's owner file — do not hand-edit it as the fix.
|
||||||
&& podman exec pihole pihole restartdns'
|
- `pihole.toml` carries a `dns.hosts = [ "192.168.0.143 …", … ]` array too (FTL's own in-config records). Both sources exist; if LAN names fail, neither is being applied.
|
||||||
ssh dankclaw 'getent hosts <name>.franksdank.cloud' # must resolve
|
- Pi-hole runs as a rootless quadlet named `pihole` — query it with `podman exec pihole …`, never `sudo podman` (per-user storage law).
|
||||||
dig +short <name>.franksdank.cloud @192.168.0.143 # from any LAN box
|
- `pihole.franksdank.cloud` resolves through Pi-hole itself (loop/edge), so a vhost record pointing at `192.168.0.143` is correct for every Caddy-fronted service.
|
||||||
|
|
||||||
|
## Procedure (repo-first)
|
||||||
|
|
||||||
|
1. Append the line to `dankclaw/pihole/addn-hosts`:
|
||||||
|
```
|
||||||
|
192.168.0.143 <name>.franksdank.cloud
|
||||||
|
```
|
||||||
|
One record per vhost; keep it in sync with the Caddyfile (the bats seam cross-checks the two).
|
||||||
|
2. `./tests/run.sh` (the `pihole` seam validates the file), commit, push.
|
||||||
|
3. Deploy so the copy step runs: `./scripts/deploy.sh dankclaw --service pihole`.
|
||||||
|
4. Verify from the box: `getent hosts <name>.franksdank.cloud` → `192.168.0.143`.
|
||||||
|
5. Public edge record (optional; skip when every subscriber is LAN-only): Porkbun `POST /api/v3/json` with `apikey`/`secretapikey`/`command=add`/`type=A`/`hostname=<name>`/`data=76.49.118.136` (see `dankclaw-porkbun-edge`).
|
||||||
|
|
||||||
|
Client-side proof (avoid the resolver trap):
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo resolvectl flush-caches # systemd-resolved otherwise keeps the negative cache
|
||||||
|
curl -sv --max-time 5 https://<name>.franksdank.cloud 2>&1 | grep 'Connected to'
|
||||||
```
|
```
|
||||||
|
|
||||||
Hosts files are read-only-readable only via sudo (dir owned by uid 100999). `sudo -n` works on dankclaw.
|
Expected: `Connected to <name>.franksdank.cloud (192.168.0.143)` — LAN route, not the public IP.
|
||||||
|
|
||||||
## API maze — DON'T re-explore
|
## Triage: every LAN name broke at once (2026-10-19 law)
|
||||||
|
|
||||||
- FTL API base is `/api` at the web port (8083) **with `Host: pi.hole` header**; `/admin/api/*` → bad_request hint; bare 127.0.0.1 without the vhost → 404 `not_found`.
|
Signature: **all** `*.franksdank.cloud` return NXDOMAIN / Playwright `ERR_NAME_NOT_RESOLVED` while **external names still resolve**. The external-OK/local-fail split is the tell: FTL is answering external names from cache and holds **no local records at all** after a container/host restart — the mounts and files are intact and readable inside the container (`ls -l /etc/pihole/hosts/`, `grep -c . …/addn-hosts`), `dns.hosts` in `pihole.toml` is complete. The mechanism is unestablished; do not go hunting for a lost file.
|
||||||
- Login: `POST /api/login {"password": <WEBPASSWORD from /home/dank/Containers/pihole/env>}` → session in `X-Cors` header/cookie.
|
|
||||||
- `POST /api/apps/domains/custom` → **404 on this build** (FTL version predates it); `/api/customdns/custom` also absent. There is no container sqlite3. Use custom.list.
|
|
||||||
|
|
||||||
## Public record (off-LAN reachability)
|
```
|
||||||
|
ssh dankclaw 'getent hosts grafana.franksdank.cloud' # empty => FTL, not the client
|
||||||
Porkbun API, keys in `/home/dank/Containers/caddy/env` (`PORKBUN_API_KEY`, `PORKBUN_API_SECRET_KEY`):
|
ssh dankclaw 'podman exec pihole pihole restartdns reload' # fixes it in seconds
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sL -X POST https://api.porkbun.com/api/json3/domain/api/create/franksdank.cloud \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"apikey\":\"$K\",\"secretapi\":\"$S\",\"name\":\"<name>\",\"type\":\"A\",\"content\":\"71.227.227.172\",\"ttl\":\"600\"}"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `json3` path 302-redirects → **`-L` required**; no-`-s` to see status.
|
Distinguish before acting: a **single** client failing while others work is FTL rate-limiting that client — `podman exec pihole grep Rate-limiting /var/log/pihole/FTL.log`; a `10.0.2.x` client address is a pasta container's DNS forwarder (SNAT), not a machine. Full causal page: `serverAppConfigs-docs/Infrastructure/Pi-hole.md` (**Records** pitfall + Failure modes table).
|
||||||
- Porkbun edge (openresty) intermittently 503s — transient; retry later, don't burn turns. Public record is OPTIONAL when LAN-only subscribers exist.
|
|
||||||
- Wildcard `*.franksdank.cloud` TLS is already covered: Caddy + Porkbun DNS-01 (caddy-porkbun image), so a new subdomain needs NO new cert work — just the Caddyfile site block + reload.
|
|
||||||
|
|
||||||
## Client-side cache gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Workstation (systemd-resolved): negative cache survives Pi-hole changes; `sudo resolvectl flush-caches` needs a TTY (faillock-sensitive — no `-S` guessing). Non-interactive proof: `curl --resolve <name>.franksdank.cloud:443:192.168.0.143 https://<name>.franksdank.cloud/...`; cache self-expires (SOA negative TTL).
|
- Do NOT insert local records into SQLite. v6's `gravity.db` `domain` table has NO `type=2` custom-DNS rows in this build.
|
||||||
- Dankclaw/Dankserver resolve via Pi-hole directly — verify there first.
|
- `pihole restartdns reload` re-imports the hosts files **and** `dns.hosts`; that is the remedy for the boot-load failure above. Plain `pihole restartdns` also restarts the resolver — brief LAN-wide DNS blip.
|
||||||
|
- `dig @<ip> <name>` from the workstation bypasses systemd-resolved — use it to check FTL's answer directly (`+short @192.168.0.143`).
|
||||||
|
|
||||||
|
## Cross-reference
|
||||||
|
|
||||||
|
- Fleet DNS flow: `dankclaw/Caddyfile` (Caddy binds `:443` on the host, serves `*.franksdank.cloud` vhosts)
|
||||||
|
- Pi-hole service page: `serverAppConfigs-docs/Infrastructure/Pi-hole.md`, flow page `Infrastructure/LAN-DNS.md`
|
||||||
|
- Public edge: managed skill `dankclaw-porkbun-edge`
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: dankvoice-bats-serialization
|
||||||
|
description: "Run dankvoice bats suites without hangs or false failures: serialize dist rebuilds, never edit src mid-run, and rebuild before committing the tracked bundle."
|
||||||
|
---
|
||||||
|
|
||||||
|
Use when running the dankvoice `bats tests/` suite on the shared box, when a suite run hangs or times out, or when the "committed dist is byte-identical to a fresh build" pin test fails after a commit.
|
||||||
|
|
||||||
|
## The two serialization hazards
|
||||||
|
|
||||||
|
### 1. `setup()` rebuilds `dist/` from `src/`
|
||||||
|
Every test runs `cd "$REPO" && bun run build` in `setup()`. Consequences:
|
||||||
|
|
||||||
|
- **Never edit `src/` while a suite is running.** The next test's `setup()` compiles whatever is on disk at that instant, so an in-flight edit gets attributed to the run and produces a failure with no owner. Finish the gate, then write.
|
||||||
|
- **Two suites on the same checkout race on `dist/`.** Serialize: run one, wait for its report, then start the next. Multiple worktrees of the repo each build their own `dist/` and do not collide.
|
||||||
|
- Suite runtime is ~85 s for 55 tests; a backgrounded job with a 600 s deadline is the right shape (`timeout: 600`, `async: true`). Never poll it — `setup()` output lands in the conversation when the job ends.
|
||||||
|
|
||||||
|
### 2. `dist/` is tracked, and a pin test enforces it
|
||||||
|
One test rebuilds and compares against the committed bundle. Therefore the commit order is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build # BEFORE staging
|
||||||
|
git add -A && git commit -m '…'
|
||||||
|
bats tests/ # gate AFTER the commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Committing `src/` changes without rebuilding first fails the pin test (`not ok … byte-identical to a fresh build`) — the tracked bundle is the *previous* state. If that happens on an unpushed branch, fix it by amend rather than a follow-up commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build && git add dist && git commit --amend --no-edit
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify freshness any time the pin is in doubt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build >/dev/null 2>&1 && git status --porcelain dist # empty = committed == fresh build
|
||||||
|
```
|
||||||
|
|
||||||
|
## If a backgrounded gate job vanishes
|
||||||
|
A crash can kill the job before its report; `read proc://bg_N` returning "not found" means it is gone, not that it failed. Reconstruct what landed before resuming — `git log --oneline -3`, `git status --porcelain`, then the freshness check above — and rerun the suite; the commit may well have succeeded while the gate never ran.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- `DV_HOLD` is seconds (`sleep "${DV_HOLD:-0.1}"`), so `DV_HOLD=3` really holds the stub for three seconds.
|
||||||
|
- Isolation tests must not count shared state: any assertion that sweeps `$TMPDIR` or `~/.omp/cache` observes every other live session on the box (each narrating session creates `dankvoice-*` scratch dirs). Point `TMPDIR` at a private directory instead — see the `cutting off mid-utterance leaves no orphaned audio behind` test.
|
||||||
|
- `bun run build` does not typecheck. Run `bunx tsc --noEmit` separately when moving code between modules; the bundler happily emits a bundle that references a deleted identifier.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: dankvoice-bundle-swap-red-proof
|
||||||
|
description: "Prove a dankvoice behavior test is red against the pre-change bundle by pointing DV_EXT at git show HEAD:dist/index.js, with the driver extracted from the bats heredoc into a pinned scratch directory. Use before claiming a new bats assertion is red-capable."
|
||||||
|
---
|
||||||
|
|
||||||
|
Prove a new `tests/behavior.bats` assertion is **red-capable** — that it fails against the *previous* build, not just passes against the new one — without stashing, branching, or editing `src/`.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`tests/behavior.bats` is a black-box suite: the plugin bundle is loaded through `DV_EXT`, so a *different bundle* is the red side. Stashing the fix would break the `dist` byte-identity pin test and the shared worktree.
|
||||||
|
|
||||||
|
## Working recipe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/dankvoice # or the feature worktree: ~/Projects/wt-dv-*
|
||||||
|
bun run build # current src = the green side
|
||||||
|
git show HEAD:dist/index.js > /tmp/old-dist.js # the red side (pre-change bundle)
|
||||||
|
|
||||||
|
W=/tmp/dv-redproof # PINNED name; see traps below
|
||||||
|
rm -rf "$W" && mkdir -p "$W/bin"
|
||||||
|
cat > "$W/player.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" >> "$DV_STUB_LOG"
|
||||||
|
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
|
||||||
|
sleep "${DV_HOLD:-0.1}"
|
||||||
|
SH
|
||||||
|
chmod +x "$W/player.sh"
|
||||||
|
ln -s "$W/player.sh" "$W/bin/pw-play"
|
||||||
|
printf '#!/usr/bin/env bash\nprintf "116\\talsa_output.usb-KT.0\\taudio/sink\\t*\\n"\n' > "$W/bin/wpctl"
|
||||||
|
chmod +x "$W/bin/wpctl"
|
||||||
|
# The driver lives inside the bats file as a heredoc: extract it verbatim.
|
||||||
|
end=$(grep -n '^TS$' tests/behavior.bats | cut -d: -f1)
|
||||||
|
sed -n "86,$((end - 1))p" tests/behavior.bats > "$W/driver.ts"
|
||||||
|
|
||||||
|
export DV_EXT="$PWD/dist/index.js" DV_STUB="$W/player.sh" DV_STUB_LOG="$W/player.log" \
|
||||||
|
DV_COPY="$W/played.wav" DV_OMPCONFIG=/dev/null DANKVOICE_CONFIG="$W/settings.json" \
|
||||||
|
DV_MODELS="$W/models.yml" DV_WPCTL=x DV_SELECT=""
|
||||||
|
for ext in /tmp/old-dist.js "$PWD/dist/index.js"; do
|
||||||
|
rm -f "$W/player.log" "$DANKVOICE_CONFIG"
|
||||||
|
DV_EXT="$ext" bun "$W/driver.ts" <scenario> 2>&1 | grep -E '^PLAYS|^CALLS|^NOTIFY|^SETTINGS'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
The scenario name is the driver's third argv (`speak`, `auto`, `repeat`, `humanizecycle`, `badurl`, …); the bats `-f` test body's env (`DV_PLAYER_BIN`, `DV_SETTINGS_JSON`, `DANKVOICE_VERBOSITY`, …) is passed the same way. `DV_SELECT` holds the menu answers, `|`-separated, consumed in order.
|
||||||
|
|
||||||
|
## Traps, each one cost a turn
|
||||||
|
|
||||||
|
- **The scratch directory must be pinned by name.** `ls -dt /tmp/tmp.*` picks up *other sessions'* temp dirs, and then `player.sh` and `driver.ts` live in different directories — the run silently does the wrong thing.
|
||||||
|
- **The stub must append to `$DV_STUB_LOG`.** The driver counts player invocations from that file; a stub that only `sleep`s reports `PLAYS 0` for *both* bundles and the whole proof reads as "fix does nothing".
|
||||||
|
- **`DV_EXT` is required.** Missing it yields `Cannot find package 'undefined'` from the driver, which looks like a broken driver, not a missing env var.
|
||||||
|
- **`DV_SETTINGS_JSON` supports `__PORT__`** (the driver substitutes the fixture port), so a preseed can name a prefixed route — needed whenever the test is about *which* endpoint got resolved.
|
||||||
|
- **Never run this while a `bats` run is in flight** in the same repo: `setup()` runs `bun run build`, so two runs clobber `dist/` and the pin test.
|
||||||
|
|
||||||
|
## Reading the result
|
||||||
|
|
||||||
|
Red side must show the *defect* (`PLAYS 0`, `644`, a chat `CALL` with the wrong prefix, `Invalid URL`), green side the fix — assert on printed markers only (`PLAYS`, `CALLS`, `CALL <url>`, `NOTIFY`, `SETTINGS`). `CALLS` counts `/audio/speech` calls only; the rewrite call appears as its own `CALL /…/chat/completions … sys=<full system prompt>` line, which is how prompt-level knobs (verbosity, heckle) are observed.
|
||||||
|
|
||||||
|
Prefer this over a synthetic leak: if the check already caught a real leftover/failure once, that event *is* the sensitivity proof — say so in the commit message instead of faking one.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: dankvoice-menu-knob-workflow
|
||||||
|
description: "Add a persisted /say knob to dankvoice and test its menu row through the bats driver (DV_SELECT picks, INPUT stub, dist-pin rebuild gate)."
|
||||||
|
---
|
||||||
|
|
||||||
|
Adding a setting to `dankvoice` (`~/Projects/dankvoice`, omp speech extension) means five touchpoints plus one behavioral test. Skip one and TypeScript or a silent drop catches it; skip the test and the row ships dead.
|
||||||
|
|
||||||
|
## The five touchpoints (order matters)
|
||||||
|
|
||||||
|
1. `src/settings.ts` — field on `Settings` (optional) **and** a type-guarded line in `readSettings`. A field without a guard line is written to disk and never read back; tsc stays clean, so nothing else catches it.
|
||||||
|
2. `src/config.ts` — `Config` field (required, defaulted), resolution line, entry in the returned object, and the knob documented in the file header comment (that header is the README's pointer for "full knob list").
|
||||||
|
- Convention: explicit env wins over settings (`env.X === "1" ? true : env.X === "0" ? false : (settings.x ?? false)`); booleans use `1`/`0`, enumerations use the literal value.
|
||||||
|
3. `src/index.ts` — `persistSettings()` key list (a knob missing there never survives the session) **and** the getter in the `new Speaker({...})` deps object.
|
||||||
|
4. `src/utterance.ts` — getter on `SpeakerSettings` if the Speaker reads it at speak time.
|
||||||
|
5. `src/menu.ts` — the row. Rows carry their own `run`; never dispatch on `choice === options[N]`.
|
||||||
|
|
||||||
|
Gate at the call site, not the prompt, when a mode must not apply to operator-typed text: `Speaker.speak(ctx, text, thinking, auto)` — `auto=true` only from `message_end`/`agent_end`; `/say <text>` passes nothing. Prompt-only gating leaks.
|
||||||
|
|
||||||
|
## Test the row behaviorally, never as a config assertion
|
||||||
|
|
||||||
|
`tests/behavior.bats` drives the built `dist/index.js` against a mock harness. Selects and typed text both come from one pipe-separated queue: `DV_SELECT="row label|sub choice|typed text"`, consumed in call order; a consumed-exhausted `select` returns `options[0]`, so always name the row by its **rendered label** (`"Verbosity: normal (change)"`, `"Intro: none (change)"`).
|
||||||
|
|
||||||
|
- `ui.input(title, placeholder)` in the driver mock logs `INPUT <title> <placeholder>` and takes the next queue entry. If the mock lacks `input`, no typed value was ever testable — add the stub before writing the test.
|
||||||
|
- Scenario pattern that proves a pick works (`case "knobs"`): `say.handler("menu")` → `say.handler("on")` → two `agentEnd(answer(...))` with `settle()` between. Then assert on observable output, not on the settings file:
|
||||||
|
- prompt-shaping knob: `[[ "$output" == *"ONE spoken sentence"* ]]` (directive reached the rewrite request)
|
||||||
|
- framing: `[[ "$output" == *"input=Update, chief!"* ]]`
|
||||||
|
- throttle/framing counting: `[[ "$output" == *"CALLS 1"* ]]`
|
||||||
|
- A guard test whose negative case would pass trivially must also assert its premise, e.g. truncation: assert `CALLS 1` so "no outro" cannot be a green caused by nothing ever being cut.
|
||||||
|
|
||||||
|
Red first, in a batch: write all the new `@test` blocks, run `bats tests/behavior.bats -f "<pattern>"`, confirm the new ones fail, then implement.
|
||||||
|
|
||||||
|
## Gates and gotchas
|
||||||
|
|
||||||
|
- **Two `bats` runs at once corrupt each other**: `setup()` runs `bun run build`, so a concurrent run rebuilds `dist/` mid-suite and the byte-identical pin test fails spuriously. Serialize.
|
||||||
|
- **Edit `src/` → rebuild before commit**: the pin test compares committed `dist/` to a fresh build. Sequence is `bun run build && bunx tsc --noEmit && bats -f <pattern>`, then `git add -A` (dist included). A failure there is normally a stale bundle, not a bad assertion.
|
||||||
|
- `VAR=value some_function args` in bats does reach the child process, so knob tests need no `export`.
|
||||||
|
- `edit` tool: inserting after a line whose content you know but that the current tag never displayed gets refused — the refusal echoes the real line; if it matches intent, re-issue the identical edit instead of re-reading.
|
||||||
|
- Ship: branch `feat/<issue#>-<slug>` off fresh `origin/main`, one commit per knob, PR targets `main`, merge only on Frank's explicit OK (`do=merge`, `merge_style=no-ff`), then `omp plugin upgrade dankvoice` and `cmp` installed `dist/index.js` against the merged one.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: dankvoice-silence-triage
|
||||||
|
description: "Triage silent or late dankvoice auto-speak on the fleet: arming key, /say grammar, endpoint/sink, event granularity, humanizer latency, plus the black-box bats driver."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Triage "dankvoice is silent" on the fleet
|
||||||
|
|
||||||
|
Symptom that recurs: auto-speak only speaks when `/say <text>` is typed
|
||||||
|
explicitly. Work the gates in this order; each has an objective check.
|
||||||
|
|
||||||
|
1. **Armed?** `cat ~/.omp/cache/dankvoice.json | jq .auto` — must be `true`
|
||||||
|
(or `DANKVOICE_AUTO=1`). Arming is read at **startup only** and `/say on`
|
||||||
|
never persists, so a session started before the key was added is silent even
|
||||||
|
though fresh sessions work. `/say start` is *not* a subcommand: unknown
|
||||||
|
arguments are spoken verbatim (it read out the word "start"). `/say on` is
|
||||||
|
the verb; `/say status` must print `auto-speak on`.
|
||||||
|
2. **Endpoint + sink?** `/say status` must show a real endpoint (not
|
||||||
|
`NO ENDPOINT`) and the saved sink must exist:
|
||||||
|
`pw-dump | grep -o 'alsa_output\.[^"]*' | sort -u`.
|
||||||
|
3. **Did it synthesize?** `pgrep -a pw-play` during a turn: one process per
|
||||||
|
segment, `--target <sink>`, wav under `/tmp/dankvoice-*/say-<epochms>.wav`.
|
||||||
|
One temp **directory per utterance chain** — count dirs to count utterances,
|
||||||
|
pids to count segments.
|
||||||
|
4. **Event granularity (the structural cause).** `agent_end` fires once per
|
||||||
|
*prompt* when the loop settles and is skipped when the harness maps a
|
||||||
|
non-terminal settle (`willContinue`) — long runs with background jobs or
|
||||||
|
subagents therefore narrate nothing. Mid-run narration needs `turn_end`
|
||||||
|
(fires per assistant message + tool results). Verify delivery, don't infer
|
||||||
|
it: skill `omp-extension-event-probe`.
|
||||||
|
5. **Latency is not silence.** Narration passes through the humanizer
|
||||||
|
(frank-zealot) before synthesis, so an utterance lands several seconds after
|
||||||
|
its turn; under endpoint load that stretches to tens of seconds.
|
||||||
|
`/say humanize off` = mechanical voice, near-instant. Judge cadence from
|
||||||
|
probe-log timestamps, never from when audio starts.
|
||||||
|
|
||||||
|
## Design rules the plugin now encodes (dank/dankvoice#2→#3)
|
||||||
|
|
||||||
|
- Narrate `turn_end` per turn **plus** `agent_end` for the settled answer;
|
||||||
|
dedupe identical text per run (reset on `agent_start`) so the answer speaks once.
|
||||||
|
- A turn landing while the player is alive is **dropped** — no queue, no
|
||||||
|
interrupting; a tool loop would cut every sentence off after a word.
|
||||||
|
Busy test: `state.child !== undefined && state.child.exitCode === null`.
|
||||||
|
- Tool-only turns (no prose, no narration-flagged thinking) stay silent.
|
||||||
|
- `print`/`json` modes stay silent, so benches never chatter.
|
||||||
|
- Settings writes must **round-trip the file** (`{...onDisk, ...owned keys}`).
|
||||||
|
Rebuilding from owned keys deleted `"auto"` on any menu choice and silently
|
||||||
|
un-armed every later session — the classic cause of "it worked yesterday".
|
||||||
|
|
||||||
|
## Black-box test harness (repo `dank/dankvoice`)
|
||||||
|
|
||||||
|
`tests/behavior.bats` drives the built artifact through an in-process driver:
|
||||||
|
`const say = DV_EXT(ctx, cfg)[0]`, handlers via `const events = new Map();
|
||||||
|
const pi = { on: (n,h)=>events.set(n,h) }`, then `events.get("turn_end")?.({
|
||||||
|
turnIndex:0, message:{role:"assistant",content:[{type:"text",text:"…"}]},
|
||||||
|
toolResults:[]}, ctx)`. Assert on `CALL`/`PLAYS`/`NOTIFY` lines and
|
||||||
|
`SETTINGS <file contents>`; stubs are `DV_PLAYER_CMD` (logs voice+input, honours
|
||||||
|
`DV_HOLD`) and `DV_REWRITE_URL` (404 → falls back to mechanical text). `setup()`
|
||||||
|
runs `bun run build` per test → serialize suites on the shared `dist/`.
|
||||||
|
`tests/packaging.bats` requires `dist/` **committed** (compares to HEAD), so
|
||||||
|
build → commit → the drift pin goes green.
|
||||||
@@ -1,36 +1,35 @@
|
|||||||
---
|
---
|
||||||
name: docs-dedupe-verify-campaign
|
name: docs-dedupe-verify-campaign
|
||||||
description: "Run an exhaustive docs dedupe + fact-verification campaign in dank/command-center: scout cluster ledgers, evidence-overridden merges, live fleet probes, log compaction law, and landing via Gitea PR."
|
description: "Run an exhaustive docs dedupe + fact-verification campaign in dank/command-center: scout cluster ledgers, override scouts with evidence, gate every commit, and sweep scaffolding before PR."
|
||||||
---
|
---
|
||||||
|
|
||||||
Campaign procedure for deduping + fact-checking `omp-docs/` in dank/command-center (proven on Refs #29, 2026-10-19: 8 commits, -2,229 lines, 0 lint/coverage/bats failures).
|
# Exhaustive docs dedupe + verification campaign (dank/command-center)
|
||||||
|
|
||||||
## 1. Recon
|
## Frame
|
||||||
- Fresh branch off `origin/main` in a new worktree (`git worktree add ~/.omp/wt/<name> -b docs/<issue>-<slug> origin/main`); the session worktree's `git checkout -b` is policy-blocked.
|
- Read `omp-docs/references/okf-spec.md` + root `omp-docs/index.md` first; inventory with `find omp-docs -name '*.md' | wc -l`.
|
||||||
- Run `scripts/kb-lint.sh omp-docs` baseline; write a throwaway index-coverage checker (each dir's `index.md` must link every subdir/file) — mechanical, no scout needed.
|
- Run `scripts/kb-lint.sh omp-docs` baseline; index-coverage gate is `scripts/kb-coverage.py omp-docs` (each dir's `index.md` must link every sibling file/subdir).
|
||||||
- Open the Gitea issue first (multi-moving-parts law).
|
- Branch off fresh `origin/main` in a NEW worktree (`git -C ~/.omp worktree add ~/.omp/wt/<name> -b docs/<issue>-slug origin/main`) — workspace policy blocks `git checkout -b` in session worktrees.
|
||||||
|
- Open the Gitea issue first (multi-moving-parts law); branch name `docs/<issue#>-<slug>`.
|
||||||
|
|
||||||
## 2. Scout fan-out (overlap detection)
|
## Scout fleet (overlap detection)
|
||||||
- One `task` batch, `agent: scout`, one task per doc cluster + one cross-cutting sweep (cap 2 concurrent; rest queue).
|
- One `task` call, `tasks[]` batch, agent `scout`, read-only; one cluster per task (skills trees, llama trees, pi vs packages, niri, sleep/power, core, gitea, gitea+cross-cutting sweep). Cap 2 concurrent; queue rest.
|
||||||
- Each scout writes a ledger to `local://dedupe/<cluster>.md`: inventory table (file, lines, purpose, verdict KEEP/MERGE/DELETE/UPDATE), overlaps, facts-to-keep ledger, inbound links, staleness, claims-needing-verification.
|
- Ledger format per cluster → `local://dedupe/<cluster>.md`: inventory table (file | lines | verdict KEEP|MERGE|DELETE|VENDOR|UPDATE), overlaps, facts-to-keep, inbound links, staleness.
|
||||||
- Scouts are LEADS, not truth. Observed failures: fabricated ledger entries (claims about text that does not exist in any file), a DELETE verdict on a generated artifact that is under test (`tools/niri/keybindings.md` is generated by `scripts/gen-keybinds.sh` and tested by `tests/gen-keybinds.bats` — keep), and a KEEP based on misreading a changelog leftover. Re-derive every consequential verdict from the files/repo yourself.
|
- **Scouts lie.** Verified misses in the 2026-10-19 campaign: fabricated ledger entries ("peer-reviewed BrowseComp" claim that exists in no file), a DELETE verdict for a generated artifact that is checked in by design AND under test (`tools/niri/keybindings.md`, see `scripts/gen-keybinds.sh` + `tests/gen-keybinds.bats`), and a KEEP for a leftover the scout misread its own changelog about. NEVER apply a scout verdict without re-checking the file, the tests, and the changelog yourself.
|
||||||
|
|
||||||
## 3. Verify fleet facts live (never trust doc dates)
|
## Fact verification
|
||||||
- Read-only BatchMode probes: `ssh -o BatchMode=yes dankclaw '...'` (podman ps/inspect, systemctl status, curl localhost, os-release, cpuinfo). `sudo -n` only has systemctl verbs — use `podman exec <ctr> grep/cat` for in-container files.
|
- Fleet facts: read-only probes over `ssh -o BatchMode=yes dankclaw|dankserver` (podman ps/inspect/top, stat, /etc/os-release, curl localhost APIs, `podman exec <c> <tool> --version`). Live state beats docs even when docs claim recent verification — the 2026-10-18 "audit" still drifted (zealot 27B→9B, build 10754→10777, container census 14→32).
|
||||||
- Update docs with dated drift banners ("probed 2026-10-19") for policy divergence; NEVER silently re-apply a policy — flag as operator decision (e.g. SearXNG engine policy).
|
- External claims: web_search/web read yourself (no subagents per Frank's rule). Kill fabrications outright (invented distro `FreeToken`, nonexistent `ornith-llama-cpp-setup` tree, unattributable "44.5% peer-reviewed CUA") — delete/drop with an explicit note, never keep "plausible" numbers.
|
||||||
|
- Policy drift (live ≠ tuned docs): flag in the doc + PR body as an operator decision; NEVER silently re-apply policy.
|
||||||
|
|
||||||
## 4. Verify external claims via direct web research (no subagents)
|
## Log compaction (log.md)
|
||||||
- Version/protocol/benchmark claims: primary source only (upstream README, raw.githubusercontent, /api/v1/version, arxiv). If a number cannot be re-attributed to a published benchmark, drop it with an explicit note in the doc.
|
- Entry law: dated entry = what changed + durable lesson + concept-page pointer; narratives recoverable via `git log --follow`. Record the law at the top of the log. Tag historical-era entries.
|
||||||
- Fabrication hunt: invented distros/projects/model names (found: "FreeToken" distro, ornith-llama-cpp-setup tree, Intel CPU on an AMD box) — delete the artifact, not just the line.
|
|
||||||
|
|
||||||
## 5. Log compaction law
|
## Gates + landing
|
||||||
- Changelog entry = date + what changed + durable lesson + concept-page pointer, few lines. Full narratives live in git (`git log --follow`). Record the law at the top of `log.md`. Never re-expand an entry in place. Keep pi-era entries tagged "(pi era)".
|
- Gate every commit: `bash scripts/kb-lint.sh omp-docs && python3 scripts/kb-coverage.py omp-docs && bats tests/`. Pre-commit hook prints `error: command not found: caddy` on Caddyfile-touching commits — warning only, commit still lands.
|
||||||
|
- Before opening the PR: `git diff --stat origin/main..HEAD | grep -i 'tmp\|scratch\|\.bak'` — scaffolding merged under a `.tmp-` name in the 2026-10-19 campaign and needed a follow-up PR to promote.
|
||||||
|
- PR via API (skill `gitea-api-pr-operations` for token-extraction + merge payload); `xdg-open` the PR the moment it opens; comment the campaign ledger on the issue.
|
||||||
|
- After Frank's merge OK: propagate deployment `git -C ~/.omp fetch && git merge --ff-only origin/main`; reap campaign worktree (`git worktree remove --force`) + delete branch local/remote; close issue with landing comment.
|
||||||
|
|
||||||
## 6. Gates + landing
|
## Line-edit op syntax trap (bit three times)
|
||||||
- Gate every commit: `bash scripts/kb-lint.sh omp-docs` + index-coverage + `bats tests/` (67 tests). Pre-commit hook prints `error: command not found: caddy` on Caddyfile-touching commits — warning only, commit still lands.
|
- Valid ops: `PUT N.=M:` (body `+text` lines), `CUT N.=M` / `CUT N.=M @reg`, `PUT <N @reg`, `PUT >N:` / `PUT <N:`. There is NO `PUT N+=:` form — it lands in the file as a literal garbage line and shifts the insert to the wrong position. For gap-insert use a lone `PUT >N:` or `PUT <N:` per call; pre-compute line numbers against the file map (ops reference original lines within one call, but a failed op poisons positions).
|
||||||
- Gitea API auth: `~/.config/secrets/gitea` is **URL-form** (`http://dank:<token>@host`), not a raw token. Extract: `T=$(sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p' ~/.config/secrets/gitea | head -1)`; raw `cat` as bearer token → 401.
|
- Recovery recipe when a literal op line lands: read exact bounds, `CUT N.=N` (junk), `CUT A.=B @block` (misplaced section), then `PUT <M @block` at the true anchor, re-read tail, add spacing blank if a `---` now abuts a list line.
|
||||||
- PR body ends with `---` + `cwd: <wd> · session: <id>` footer; then `xdg-open <PR-URL>` immediately; comment the campaign ledger summary on the issue. NEVER merge without Frank's explicit OK.
|
|
||||||
|
|
||||||
## Edit-tool field notes (recurring traps)
|
|
||||||
- `PUT N+=:` is invalid syntax — it inserts the literal text. One insert-op per edit call; recompute line numbers per call.
|
|
||||||
- File hash tag changes after every edit — re-`read` before each subsequent edit call to the same file; reusing a stale tag is rejected.
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: docs-point-of-use-destruction
|
||||||
|
description: "Documentation exists for point of use, not archival. Use before any dedupe/compact/verify-docs job, and whenever a census or \"only copy\" argument is being used to KEEP documentation: apply the purpose test and destroy obsolete measurements. Supersedes the preservation conclusions of vault-one-fact-one-owner-dedupe §3/§12."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation exists for point of use
|
||||||
|
|
||||||
|
**Supersedes** `vault-one-fact-one-owner-dedupe` §3 ("port before cut") and §12
|
||||||
|
("expect docs flat") wherever those conclude a page must be *kept*. Sole custody of a
|
||||||
|
useless fact is still useless.
|
||||||
|
|
||||||
|
## The test, before you write and again before you keep
|
||||||
|
|
||||||
|
- Would the reader, doing a real task tomorrow, **act differently** because of this
|
||||||
|
line? No → delete it.
|
||||||
|
- Is it a measured rate (t/s, tok/s, ms, MiB-of-pool, acceptance %, a bench ladder) for
|
||||||
|
a model / runtime / layout that is **not running now and not being compared now**?
|
||||||
|
Destroy it. Not "file it in an Eras row", not "preserve it for history". AI moves
|
||||||
|
faster than the vault: the number is wrong before anyone reads it, and a wrong number
|
||||||
|
in a runbook gets believed.
|
||||||
|
- Keep the **law** the measurement taught; drop the measurement. Qualitative causal
|
||||||
|
stories survive; numbers do not.
|
||||||
|
- What survives: live config and its exact numbers, procedures, failure signatures,
|
||||||
|
security posture, interface contracts, rollback pointers.
|
||||||
|
- **Losing information is an acceptable, usually correct outcome.** Frank said so
|
||||||
|
explicitly on 2026-10-19 after rejecting a PR that had preserved 53 ledgers.
|
||||||
|
|
||||||
|
## Traps that lead agents to preserve junk
|
||||||
|
|
||||||
|
- **A census cannot decide what to keep.** `port-census.py`-style tools answer "where
|
||||||
|
does this fact live", never "does anyone need it". "14–63 sole-record lines per page"
|
||||||
|
was read as *irreplaceable* when the fact was *obsolete*. Ask usefulness, not custody.
|
||||||
|
- **A census that includes the file under test proves the page is held by itself.**
|
||||||
|
Exclude it, print the corpus size, assert absence; treat `0 unheld` on a long page as
|
||||||
|
a tool bug until proven otherwise.
|
||||||
|
- **"One fact, one owner" is a placement rule, not a preservation rule.** It says where
|
||||||
|
a surviving fact lives; it never says the fact must survive.
|
||||||
|
- **A change diary (`log.md`) is the largest archive in most vaults** (this one: 2,380
|
||||||
|
lines, ~8 % of the vault). Git holds the chronology; a causal law belongs on the page
|
||||||
|
that owns the thing it governs. Retire the diary.
|
||||||
|
|
||||||
|
## Execution that worked (2026-10-19, −13.7k lines)
|
||||||
|
|
||||||
|
1. Write the kill list as a reviewed artifact, one path per line, grouped by *why it is
|
||||||
|
dead* — that list becomes the PR's argument and the trash manifest.
|
||||||
|
2. Destroy whole pages first (`scripts/destroy-vault.py <list>`): move to
|
||||||
|
`.AGENTTRASH/<date>-destruction/`, then delete every reference from survivors, whole
|
||||||
|
line included when the dead link was its only content. Do not compare link targets
|
||||||
|
with `pathlib.Path.stem` — it strips real suffixes (`sd.cpp`).
|
||||||
|
3. Then strip numbers from what remains (`scripts/axe-measurements.py`): delete ledger
|
||||||
|
sections (noun-LEDS the heading, nothing instructional after it), blank any table cell
|
||||||
|
reporting a rate, fold Eras older than the current one into a single row pointing at
|
||||||
|
git. Never touch a shape number or a Laws/Wire/Rollback section.
|
||||||
|
4. Sweep indices (generator must exclude the trash dir, drop rows whose page cell became
|
||||||
|
empty), re-lint, re-check links.
|
||||||
|
5. Rewrite the governing policy in the same PR, or the next agent re-grows the vault:
|
||||||
|
project `AGENTS.md` *Documentation Policy* + the global agent settings.
|
||||||
|
|
||||||
|
## Report honestly, per area
|
||||||
|
|
||||||
|
docs / scripts / operator-doc separately, measured against the merge-base. The tools are
|
||||||
|
a cost that must be named, not hidden inside a headline "net" number — that is what got
|
||||||
|
the previous pass rejected.
|
||||||
|
|
||||||
|
## Verify the repair tools themselves
|
||||||
|
|
||||||
|
One repair pass matched "line ends with a comma" and deleted the trailing comma from 660
|
||||||
|
wrapped prose lines. Restore by matching lines that are byte-identical to `HEAD` except
|
||||||
|
for trailing punctuation (`restore-punctuation.py`), keep the destructive rule out of the
|
||||||
|
tool, and record the incident in its docstring.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: fixture-delay-window-race-test
|
||||||
|
description: Pin a race/abort bug in a fixture-server test by delaying only the Nth request so the interruption lands inside the in-flight window; assert via a driver-installed unhandledRejection reporter.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Reproducing a timing-window bug against a fixture server
|
||||||
|
|
||||||
|
Use when a defect only appears in a window — a promise created before it is awaited, a kill/abort landing mid-flight, a prefetch interrupted — and a fixture HTTP server answers instantly, so the window never opens. Earned in `dankvoice` issue #7 (`[Unhandled Rejection] AbortError` from `/say stop` during a pipelined synthesis).
|
||||||
|
|
||||||
|
## Why naive attempts pass vacuously
|
||||||
|
|
||||||
|
1. **Uniform delay on the fixture route** (`DV_LATENCY_MS` for every request) makes *all* requests resolve at the same time, so by the time the interruption fires nothing is in flight. The test goes green against the broken code — worse than no test.
|
||||||
|
2. Asserting on a log line count that also counts interruption noise: `dankvoice`'s driver prints `PLAYS <n>` as the number of **player log lines**, and the stub appends `KILLED` on SIGTERM — so a single playback with one interrupt reports `PLAYS 2`. Count the payload lines instead (`grep -c 'PLAYER .*\.wav$'`).
|
||||||
|
|
||||||
|
## The shape that works
|
||||||
|
|
||||||
|
- **Delay only requests after the first.** The first response gets the pipeline into playback; the second stays in flight, which is the window:
|
||||||
|
```ts
|
||||||
|
const spoken = received.filter((c) => c.url?.endsWith("/audio/speech")).length; // already pushed
|
||||||
|
const delay = spoken > 1 ? Number(process.env.DV_SYNTH_MS ?? 0) : 0;
|
||||||
|
if (delay > 0) setTimeout(answer, delay); else answer();
|
||||||
|
```
|
||||||
|
- **Make the interruption land inside it.** Player holds long (`DV_HOLD=2`), interruption at a fixed offset (`DV_STOP_AT=400`) with the delayed response far later (`DV_SYNTH_MS=1200`): margins wide enough that the abort is neither before the request nor after the response.
|
||||||
|
- **Turn unhandled rejections into observable output** in the test driver instead of letting the runtime crash on them — that is also exactly what the real harness does to the user:
|
||||||
|
```ts
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
console.log(`UNHANDLED ${reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason)}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Then assert `[[ "$output" != *"UNHANDLED"* ]]` plus the concrete behavior (segments actually played, player killed).
|
||||||
|
- **Leak checks measure a delta, not an absolute:** count `dankvoice-*` temp dirs before and after the run and compare; an absolute `== 0` fails on another test's leftovers.
|
||||||
|
|
||||||
|
## Order
|
||||||
|
|
||||||
|
Write the scenario first and watch it fail **on the rejection assertion specifically** — if it fails on some other assertion (a count, a kill line), the window is wrong, not the bug. Then fix, then confirm red→green on that one assertion before running the full suite.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: fleet-status-daily-report
|
||||||
|
description: "Answer \"status update / what happened yesterday\" for the dank fleet: git-window record on origin/main plus a live per-host census (endpoints, units, RAM, DNS), with the reboot-attribution check that stops false incident causes."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fleet status report (Dankclaw + Dankserver)
|
||||||
|
|
||||||
|
Use for "what's the state of the fleet", "what happened yesterday", or a post-incident sweep. Read-only.
|
||||||
|
For one service's live state, prefer `fleet-service-runtime-census`; this is the fleet-wide sweep.
|
||||||
|
|
||||||
|
## 1. The day's record (repo is the ledger)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin --quiet
|
||||||
|
git log --no-merges --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD+1 00:00" \
|
||||||
|
--date=format:'%H:%M' --pretty='%ad %h %s' origin/main
|
||||||
|
git log --merges --since=... --pretty='%ad %h %s' --date=format:'%H:%M' origin/main # PR numbers
|
||||||
|
```
|
||||||
|
Merge lines carry the PR number — that is the review trail; the non-merge log is what actually changed.
|
||||||
|
Group commits into streams (endpoint names lead the subject) rather than listing 20 subjects.
|
||||||
|
|
||||||
|
## 2. Live census (parallel: one call per host + one endpoint sweep)
|
||||||
|
|
||||||
|
Endpoint sweep from any LAN box — `/health` is honest on all four (archon binds only after full load):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for u in 192.168.0.143:7770 192.168.0.143:7771 192.168.0.235:7770 192.168.0.143:7772; do
|
||||||
|
printf "%s %s\n" "$u" "$(curl -s -m 12 -w ' [%{http_code}]' http://$u/health | head -c 160)"; done
|
||||||
|
curl -s http://192.168.0.143:7770/v1/models # dragoon lists EVERY engine it serves, incl. NPU-lane models
|
||||||
|
```
|
||||||
|
|
||||||
|
Per host: `uptime -s; uptime -p`; `systemctl show <llm units> -p ActiveEnterTimestamp -p NRestarts -p DropInPaths`
|
||||||
|
(`sudo` for the root LLM units, `--user` for the quadlets); `list-units --failed`; `free -h`;
|
||||||
|
`journalctl -k --since "<evening>" -p err`; the host's own engine log tail.
|
||||||
|
|
||||||
|
## 3. Attribution rules (where reports go wrong)
|
||||||
|
|
||||||
|
- **Never call a host reboot from a container's start timestamp.** Check `uptime -s` first; if uptime spans the
|
||||||
|
event, the trigger was a **container restart** — find it with
|
||||||
|
`journalctl --user -u <name>.service --since … --until …` + `podman inspect <c> --format '{{.State.StartedAt}}'`.
|
||||||
|
(This exact mistake mislabelled a 2026-10-19 Pi-hole incident as boot-caused; the law's remedy belongs after
|
||||||
|
*any* restart, which is a different action item.)
|
||||||
|
- `podman stats` is a 28-container wall — sort/limit it; RSS of the LLM tenants is what matters.
|
||||||
|
- This podman's inspect template has **no `.Config.PodName`, no `.Config.GroupAdd`, and `.HostConfig.Devices`
|
||||||
|
can read `[]` even when a quadlet `AddDevice=` worked**. Prove GPU use from the engine's own log
|
||||||
|
(`ggml_vulkan: Found 1 Vulkan devices: … Radeon 8060S (RADV GFX1151)`), not from inspect.
|
||||||
|
- Kernel lines: **count** occurrences (`journalctl -k --since '7 days' | grep -c "NETDEV WATCHDOG"`) before
|
||||||
|
calling a one-off a trend.
|
||||||
|
- Failed units: `systemctl show <u> -p ExecMainExitTimestamp -p ExecMainStatus --value` separates
|
||||||
|
"broken since boot" from "broke yesterday" — and a nightly failure (e.g. logrotate) is a live risk, not noise.
|
||||||
|
|
||||||
|
## 4. Report shape
|
||||||
|
|
||||||
|
Stream table (what landed) → live-state table (what is true now) → **corrections to my own earlier record** →
|
||||||
|
open items that need Frank's decision, each with the evidence line and the one action. Flag anything that would
|
||||||
|
touch host config outside the repo as a decision, never a silent fix.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
name: gguf-lora-adapter-fit-census
|
||||||
|
description: "Read-only census before applying a GGUF LoRA/adapter to a llama.cpp (or fork) endpoint: Hub metadata, declared base provenance, runtime flag support, and the behavioural probe that proves the effect."
|
||||||
|
---
|
||||||
|
|
||||||
|
# GGUF LoRA / adapter fit census (read-only, before you promise a file to a payload)
|
||||||
|
|
||||||
|
A 3 MB adapter is a payload change: it edits the compute graph at load. Do the census
|
||||||
|
before editing an endpoint's trio, and prove the EFFECT behaviourally — no metadata,
|
||||||
|
card table, or health gate can see it.
|
||||||
|
|
||||||
|
## 1. Census the Hub repo first (no auth for public repos)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "https://huggingface.co/api/models/<org>/<repo>?blobs=true" | python3 -c "
|
||||||
|
import json,sys; d=json.load(sys.stdin)
|
||||||
|
print(d.get('pipeline_tag'), d.get('library_name'))
|
||||||
|
print([t for t in d.get('tags',[])][:20]) # base_model / base_model:adapter rows matter
|
||||||
|
for s in d['siblings']: print(s['rfilename'], s.get('size'), (s.get('lfs') or {}).get('oid'))"
|
||||||
|
curl -sS "https://huggingface.co/<org>/<repo>/raw/main/README.md" > /tmp/card.md # grep for --lora flags
|
||||||
|
```
|
||||||
|
|
||||||
|
- `base_model` / `base_model_relation: adapter` name the pack the adapter was built for.
|
||||||
|
- **A file with `lfs.oid = None` is a plain git object**: pin the sha256 of the Hub's bytes,
|
||||||
|
not an LFS oid. Fetcher law still applies: `curl -fsSL --fail-with-body`, byte count,
|
||||||
|
sha256, `--verify-only`, no resume.
|
||||||
|
|
||||||
|
## 2. Read the adapter's own GGUF KV section (settles provenance in one read)
|
||||||
|
|
||||||
|
Small python read of the KV pairs — no torch, no download beyond the adapter itself:
|
||||||
|
magic `GGUF`, `u32 ver`, `u64 n_tensors`, `u64 n_kv`; key = `u64 len` + bytes; type `u32`
|
||||||
|
(0 u8,1 i8,2 u16,3 i16,4 u32,5 i32,6 f32,7 bool,8 str,9 array,10 u64,11 i64,12 f64);
|
||||||
|
array = `u32 elem_type` + `u64 count`.
|
||||||
|
|
||||||
|
Keys worth printing: `general.type` (`adapter`), `adapter.type` (`lora`),
|
||||||
|
`adapter.lora_alpha`, and whatever provenance the publisher wrote — e.g. PrismML/abliteration
|
||||||
|
packs carry `prism.abliterate.base_gguf`, `base_sha256`, `direction_row`, `target_layers`,
|
||||||
|
`target_embedding`, `sites`, `basis`.
|
||||||
|
|
||||||
|
**Provenance is metadata, not a gate.** The loader matches **tensor names, not hashes**, so an
|
||||||
|
adapter fit against a sibling quantization of the same model still applies — which is a
|
||||||
|
decision to surface to the operator, not a failure to hide. Compare `base_sha256` against the
|
||||||
|
sha the endpoint's own fetcher pins; if they differ, say so in the PR body, the env header and
|
||||||
|
the endpoint page, and prove the effect behaviourally instead.
|
||||||
|
|
||||||
|
Two publisher facts that decide whether a fork can carry the file at all: the build it was
|
||||||
|
verified on, and what it deliberately does NOT touch (an embedding rank is the usual crash —
|
||||||
|
`token_embd` left alone is a graph constraint, not an omission).
|
||||||
|
|
||||||
|
## 3. Ask the runtime binary, not your memory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<llama-server> --help | grep -iE "lora|adapter"
|
||||||
|
```
|
||||||
|
Expect `--lora FNAME`, `--lora-scaled FNAME:SCALE,...`, `--lora-init-without-apply`
|
||||||
|
(+ `POST /lora-adapters` for runtime scale). Prefer `--lora-scaled <file>:<scale>` as ONE
|
||||||
|
argv token so a scale can never reach the engine without its file, and follow the endpoint's
|
||||||
|
existing opt-out convention (the `MMPROJ_FILE=` shape: `:`-less default so an explicitly empty
|
||||||
|
value means "off, on purpose"). Gate the deployer on the file's existence with the adapter's
|
||||||
|
OWN fetcher named in the error text.
|
||||||
|
|
||||||
|
## 4. Prove the effect: refusal/behaviour probe, before AND after
|
||||||
|
|
||||||
|
The probe is not a bench, so the no-bench law does not block it — and the **before** half must
|
||||||
|
be captured while the old shape is still live.
|
||||||
|
|
||||||
|
- Same prompts, `temperature` low, both before and after; classify with a refusal-marker regex
|
||||||
|
and print only the opening characters, never the generated body.
|
||||||
|
- **Budget ≥ 2 048 tokens on a thinking model.** A 600-token request can be swallowed whole by
|
||||||
|
the reasoning channel: `content` comes back empty with `finish_reason: length` and
|
||||||
|
`reasoning_content` full — which reads exactly like a refusal that never happened.
|
||||||
|
- A leading `⚠️ Disclaimer`/hedge after the adapter is style, not the circuit: judge the marker
|
||||||
|
sentence, not the tone.
|
||||||
|
|
||||||
|
## 5. Post-deploy reads (the ledger is the instrument)
|
||||||
|
|
||||||
|
- Boot log shows `llama_adapter_lora_init_impl: loading lora adapter from '<file>'` and then
|
||||||
|
**dumps the adapter's KV block** — the provenance mismatch is printed at every start, so the
|
||||||
|
runbook should point at the journal instead of restating it.
|
||||||
|
- Confirm the flag on argv: `tr '\0' ' ' < /proc/$(systemctl show <svc> -p MainPID --value)/cmdline`;
|
||||||
|
confirm `systemctl show <svc> -p DropInPaths --value` is empty (a leftover bench drop-in outranks
|
||||||
|
everything you just deployed).
|
||||||
|
- Shape must be untouched by an adapter: `n_slots` / `n_ctx_slot` / KV lines unchanged, and
|
||||||
|
`/metrics` still answering (a llama.cpp endpoint is invisible to Prometheus without `--metrics`).
|
||||||
|
- Root units log to the journal; rootless podman storage is read WITHOUT sudo (`sudo podman
|
||||||
|
inspect` sees a different storage namespace and reports "no such object").
|
||||||
|
|
||||||
|
## Yields
|
||||||
|
|
||||||
|
One sentence per question: does the file exist and at what size/sha; which pack it declares as
|
||||||
|
its base and whether that matches the served payload; which flag the runtime actually offers;
|
||||||
|
what the before/after probe classified. Anything else is a decision for the operator, and the
|
||||||
|
rollback should be one env line on a file the endpoint already loads.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: gitea-merge-form-encoded
|
||||||
|
description: "Use when a Gitea API merge on Dankclaw (192.168.0.143:3000) returns HTTP 422 \"[Do]: Required\" or 405 \"Please try again later\" — the merge endpoint needs form-encoded fields with the lowercase field name do, and a behind-main branch must be merged forward before it will merge."
|
||||||
|
---
|
||||||
|
|
||||||
|
Gitea on Dankclaw (192.168.0.143:3000) — merge quirks, measured 2026-10-19 on `dank/serverAppConfigs` and again on `dank/dankvoice`, re-earned the same day on serverAppConfigs PR #27.
|
||||||
|
|
||||||
|
## 1. The payload: form-encoded, lowercase `do`
|
||||||
|
|
||||||
|
Creating a PR accepts a JSON body. **Merging does not**: `POST /api/v1/repos/{owner}/{repo}/pulls/{n}/merge` with a JSON body (`{"merge_style":"no-ff", ...}`) or with those values as request headers returns `422 {"message":"[Do]: Required"}` — four variants of that all fail identically, so do not "fix" it by changing the style name or moving the headers around.
|
||||||
|
|
||||||
|
**The field name is lowercase `do`.** `--data-urlencode 'Do=merge'` (capital D, which the error message spells) returns the same `422 [Do]: Required`. Do not retry with case variants — the web-UI form field is `do`.
|
||||||
|
|
||||||
|
Working call (form-encoded):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tok=$(sed -n 's|^https\{0,1\}://[^:]*:\([^@]*\)@192\.168\.0\.143.*$|\1|p' ~/.config/secrets/gitea | head -1) # git credential.helper store file
|
||||||
|
curl -sS -X POST -w 'http %{http_code}\n' -K <(printf 'header = "Authorization: token %s"\n' "$tok") \
|
||||||
|
--data-urlencode 'do=merge' \
|
||||||
|
--data-urlencode 'merge_style=no-ff' \
|
||||||
|
http://192.168.0.143:3000/api/v1/repos/dank/<repo>/pulls/<N>/merge
|
||||||
|
# 200 with an empty body == merged
|
||||||
|
```
|
||||||
|
|
||||||
|
- `merge_style` values are the dashed names: `no-ff`, `merge`, `rebase`, `squash`.
|
||||||
|
- Keep the token out of argv: `-K <(printf …)` feeds the `Authorization` header through stdin (a `-H "Authorization: token …"` lands in `/proc/<pid>/cmdline`, world-readable — the 2026-09-16 HF-token leak class).
|
||||||
|
- Verify by fast-forwarding a checkout (`git fetch && git merge --ff-only origin/main`) rather than re-reading the PR.
|
||||||
|
- Fleet law still applies: never merge without an explicit current OK from Frank, and open the PR URL in his browser (`xdg-open`) the moment it exists.
|
||||||
|
|
||||||
|
## 2. `405 {"message":"Please try again later"}` = the branch is behind main
|
||||||
|
|
||||||
|
A correctly formed merge call returns **405**, not 422, when Gitea cannot test-merge the head into the base. Read it as "merge main forward", not "retry later" — no sleep-retry ever clears it.
|
||||||
|
|
||||||
|
Confirm first: `GET /pulls/{n}` shows `"mergeable": false` (and `merged: false`, `state: open`) for exactly this state.
|
||||||
|
|
||||||
|
The fix is the repo's own trunk-based law — merge forward, never rebase:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree add ../wt-prN -B prN <head-branch>
|
||||||
|
cd ../wt-prN && git merge --no-edit origin/main # resolve, see below
|
||||||
|
./tests/run.sh # the seam is the arbiter, run it on the RESOLVED tree
|
||||||
|
git push -q origin prN:refs/heads/<head-branch> # same branch name; Gitea reuses the open PR
|
||||||
|
# then re-POST the merge — 200 this time
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes earned on PR #27 (2026-10-19):
|
||||||
|
|
||||||
|
- A `git worktree` per PR keeps the deployed checkout untouched; clean up with `git worktree remove --force ../wt-prN && git branch -D prN`.
|
||||||
|
- `git status --short` during a merge lists every incoming file as staged — that is the merge commit's content, not a dirty tree.
|
||||||
|
- Push a local branch to the PR's branch name with an explicit refspec (`prN:refs/heads/docs/…`); the push output prints `Visit the existing pull request`, which is how you confirm you hit the right head.
|
||||||
|
- Gitea needs a few seconds after the push to recompute mergeability; a `sleep 3` before re-POSTing avoids a second 405.
|
||||||
|
|
||||||
|
## 3. Resolving vault conflicts against a compaction wave
|
||||||
|
|
||||||
|
When main has run a docs compaction (page deletions, description shortening), two conflict shapes recur:
|
||||||
|
|
||||||
|
- **`description:`** — `scripts/check-okf.py` enforces **DESC: ≤ 120 chars**. A branch's longer description must be shortened, not argued for; keep main's shape and fold the new topic into the wording.
|
||||||
|
- **A `[[Page]]` link whose target page main destroyed** — `git ls-tree -r --name-only origin/main -- serverAppConfigs-docs | grep -i '<page>'` settles it. Drop the dead link; do not resurrect a page. Compaction left link-stripped stub bullets behind (`- the compile fix for …`, dash + two spaces): `git grep -nE '^- [A-Z]' origin/main -- 'serverAppConfigs-docs/**/*.md'` counts them, and the seam cannot see them because a stub is no longer a link to validate.
|
||||||
|
|
||||||
|
After any frontmatter change, regenerate: `./scripts/gen-vault-index.py serverAppConfigs-docs` and commit the index rows — the `vault indices are generated, not hand-carried` seam diffs regeneration against the checkout. The sub-index sync can emit one extra space after `|` for a quoted description; commit what the generator emits, since the seam compares against the generator.
|
||||||
|
|
||||||
|
## Credential-store token extraction (same file, adjacent trap)
|
||||||
|
|
||||||
|
`~/.config/secrets/gitea` holds one line shaped `http://user:token@192.168.0.143:3000`. Splitting it with `awk -F'/'` puts the host in `$3`, not `$2`, and `awk -F'/' '{print $NF}'` yields the host+path, not the token — an empty/garbage token makes **private repos answer `404 not found`** (Gitea hides them from anonymous), which reads like a wrong URL rather than an auth failure. Extract with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tok=$(sed -n 's|^https\{0,1\}://[^:]*:\([^@]*\)@192\.168\.0\.143.*$|\1|p' ~/.config/secrets/gitea | head -1)
|
||||||
|
echo "token length: ${#tok}" # 40 — print the length, never the value
|
||||||
|
```
|
||||||
|
|
||||||
|
Other API quirks measured the same day:
|
||||||
|
|
||||||
|
- `GET /pulls?state=open` can return an error object where `state=all` returns the list; `state=all` list entries may omit `head`/`base` — fetch `/pulls/{n}` for those.
|
||||||
|
- POST `/pulls` for a head/base pair that already has a PR → `409` naming `issue_id: N`; the PR number is that `issue_id`, so PATCH it instead of POSTing again.
|
||||||
|
- Issues and PRs share the issue index per repo, so `#36`-style numbers are repo-scoped: a footer fix aimed at `dank/command-center#36` 404s when pointed at `dank/dankvoice`.
|
||||||
@@ -1,29 +1,53 @@
|
|||||||
---
|
---
|
||||||
name: gitea-pr-api-cycle
|
name: gitea-pr-api-cycle
|
||||||
description: "Open/update/merge PRs and issues on the Dankclaw Gitea API (192.168.0.143:3000, dank/command-center) with the token-safe curl+jq sequence, including the {\"Do\":\"merge\"} merge payload and post-merge live-checkout fast-forward."
|
description: "\"Open/update/merge PRs and issues on the Dankclaw Gitea API (192.168.0.143:3000) with the token-safe sequence, the form-encoded merge field, the empty-token-returns-404 trap, and post-merge live-checkout fast-forward plus the plugin-package-name upgrade gotcha.\""
|
||||||
---
|
---
|
||||||
|
|
||||||
# Gitea PR/issue cycle — dank/command-center
|
# Gitea PR/issue cycle — Dankclaw API
|
||||||
|
|
||||||
Friction-earned 2026-10-18 across PRs #15–#20 and issue #18: three separate fumbles (jq trailer flag, 422 merge payload, silent -o/-w swallowing) codified once.
|
Friction-earned 2026-10-18 (PRs #15–#20, issue #18) and re-earned 2026-10-19 on `dank/dankvoice` PRs #4–#6: the merge payload claim below was wrong, and an empty token masqueraded as a missing repo.
|
||||||
|
|
||||||
## Where
|
## Where
|
||||||
- API base: `http://192.168.0.143:3000/api/v1/repos/dank/command-center/{pulls,issues}`
|
- API base: `http://192.168.0.143:3000/api/v1/repos/dank/<repo>/{pulls,issues}` — repo varies (`command-center`, `dankvoice`, `serverAppConfigs`); numbering is per-repo, so a footer citing `#36` means nothing without the repo.
|
||||||
- Token: never inline, never echoed. Extract from the git credential file into a 600-perm temp curl config:
|
- Token: never inline, never in argv (`-H "Authorization: token …"` lands in `/proc/<pid>/cmdline`, world-readable). Extract into a 600-perm curl config and hand it to curl with `-K`:
|
||||||
`tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)`
|
```bash
|
||||||
`cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"` → `curl -K "$cfg"`; `rm -f "$cfg"` when done.
|
tok=$(sed -nE 's#^https?://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)
|
||||||
- Unauthenticated GET works for state reads (`GET /pulls/N` → `jq -c '{state,merged,merge_commit_sha}'`).
|
[ "${#tok}" -ge 40 ] || { echo "token extraction failed"; exit 1; }
|
||||||
|
cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
|
||||||
|
curl -sS -K "$cfg" … ; shred -u "$cfg"
|
||||||
|
```
|
||||||
|
- **Empty token answers `404 {"message":"not found"}`, NOT 401** — Gitea hides the private repo. A 404 on a path you have used before means auth, not a typo'd repo. The trap that produces the empty token: `awk -F'/' … '{print $NF}'` on the store line (`scheme://user:token@host/`) — the host is in `$3`, the match never fires, you get a silent empty variable. Always assert the length before POSTing.
|
||||||
|
- Multi-line bodies: build JSON in python (`json.dumps({"title":…, "body":open(f).read(),…})`) or `jq -n --arg b "$(cat b.txt)"`. NEVER pass `---` as a bare jq argument (parsed as a flag). Escaped quotes inside `python3 -c '…'` break on `\"` — write the script to a file or use a heredoc.
|
||||||
|
- Always capture status: `-o r.json -w '%{http_code}'`; a bare `-o` hides failures.
|
||||||
|
|
||||||
## Body construction (the two traps)
|
## Create
|
||||||
- Build JSON with `jq -n --arg t <title> --arg b "$(cat b.txt)" '{title:$t, head:<branch>, base:"main", body:$b}'`; write the body to a temp file first (`printf '%s' … > b.txt`). NEVER pass `---` as a bare jq argument — jq parses it as a flag (`unknown flag: ---`). The AGENTS.md attribution trailer (`---` + `cwd: … · session: …`) goes INSIDE the body string.
|
`POST /pulls` with `{title, body, base:"main", head:<branch>}` — JSON is accepted here (unlike merge).
|
||||||
- Always capture status explicitly: `-o r.json -w '%{http_code}'` and print it; a bare `-o` hides failures (a 422 looked like success-with-no-output once).
|
- **409 `pull request already exists for these targets [issue_id: N, …]`** — a PR for that head/base exists already. Common cause: the push message printed `/pulls/new/<branch>` and someone (Frank, or an `xdg-open` you ran) clicked Create. `GET /pulls?state=all`, find it, then `PATCH /pulls/N` with your title+body instead of re-posting.
|
||||||
|
- `GET /pulls?state=all` on this Gitea omits `head`/`base` per entry — read `/pulls/N` for those, or key off `number`/`user`.
|
||||||
|
- Issues: same shape, `POST /issues` `{title, body}`; `PATCH /issues/N` fixes a body retroactively (works for PRs too, since a PR is an issue).
|
||||||
|
|
||||||
## Merge (the 422 trap)
|
## Merge (the 422 trap) — corrected
|
||||||
`POST /pulls/N/merge` requires body `{"Do":"merge"}` — `Do` is the Required field on this Gitea. `{}`, `{"DoNotMerge":false}`, `{"merge_style":…}` all return 422 (`"[Do]: Required"`). Success may return an EMPTY body — verify by GET afterwards (`state=closed, merged=true`), not by the POST response.
|
`POST /pulls/N/merge` needs **form-encoded** fields; the merge form field name is lowercase **`do`**:
|
||||||
- Merge only with Frank's explicit per-PR OK (repo law).
|
|
||||||
- Post-merge the live deployment must follow: `git -C ~/.omp fetch -q origin && git -C ~/.omp merge --ff-only origin/main` (the checkout IS the deployment; branch is `main`).
|
|
||||||
|
|
||||||
## PR etiquette per AGENTS.md
|
```bash
|
||||||
- Immediately after opening: `xdg-open <PR-URL>` lands it in Frank's Firefox.
|
curl -sS -X POST -K "$cfg" \
|
||||||
- Redesign mid-review: amend + `git push -f` the branch, then PATCH the PR body (`/pulls/N`, `{"body":…}`) so title/body match the net diff — verify `{number,state,mergeable}` in the response.
|
--data-urlencode 'do=merge' --data-urlencode 'merge_style=no-ff' \
|
||||||
- Issue creation identical shape: `POST /issues` with `{title, body}`, expect 201; PATCH same endpoint `/issues/N` to fix bodies retroactively, then GET-readback verify.
|
http://192.168.0.143:3000/api/v1/repos/dank/<repo>/pulls/N/merge
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON `{"Do":"merge"}` (what this skill used to claim) and header variants all return `422 {"message":"[Do]: Required"}`. `merge_style` ∈ `no-ff|merge|rebase|squash`. Success returns **200 with an empty body**; verify by landing a checkout rather than re-reading the PR. Merge only with Frank's explicit per-PR OK.
|
||||||
|
|
||||||
|
## Post-merge landing
|
||||||
|
- Command-center: the checkout IS the deployment — `git -C ~/.omp fetch -q origin && git -C ~/.omp merge --ff-only origin/main`.
|
||||||
|
- Package repos (`~/Projects/<pkg>`): same `--ff-only`, then reinstall (`omp plugin upgrade <name>`) and `cmp` the installed `dist/index.js` against the repo `dist/index.js` to prove the live artifact is the merged one. Check `ls -ld node_modules` after landing anything that touched it.
|
||||||
|
- **`omp plugin upgrade` takes the npm package name, not the short name.** `omp plugin upgrade dankvoice` fails with `Failed to upgrade dankvoice: dankvoice is not installed` while the plugin is plainly installed; the package is `omp-dankvoice`. Find the truth with `ls ~/.omp/plugins/node_modules` or `find ~/.omp -path '*<name>/dist/index.js' -not -path '*/Projects/*'` — the installed tree is unscoped (`~/.omp/plugins/node_modules/omp-dankvoice/dist/index.js`), not `@dank/dankvoice`.
|
||||||
|
- **`cmp` exiting 1 BEFORE the upgrade is the expected state** (installed bundle is the pre-merge build, diff reported at some char/line). Do not treat it as a failed deploy and do not "fix" the build; run the upgrade, then `cmp` again for byte identity, and grep the new bundle for a string only the merged code contains (e.g. `grep -c "Heckle instead of summarizing"`) as a second, semantic confirmation.
|
||||||
|
- Prune: `git worktree remove --force <path>` (force: untracked deps link), `git branch -D`, `git push origin --delete <branch>` (allowed: local Gitea).
|
||||||
|
|
||||||
|
## Attribution footer (AGENTS.md)
|
||||||
|
Every issue/PR body ends with `---`, then `cwd: <session cwd> · session: <session id>`. Read the real values, never invent an id.
|
||||||
|
- **The session id is the UUID suffix of the LIVE transcript `.jsonl`, not the session directory name.** Transcript dirs are keyed by cwd (e.g. `-.omp-wt-wt-20261019-020309-6d1fc1f`) and ONE cwd can hold several sessions (resume / continuation forks), so `ls -t ~/.omp/agent/sessions/` cannot tell you which is yours and `ls -t <dir>` is equally blind. Pick the file being appended to right now: `ls -l --time-style=+%H:%M:%S <dir>/*.jsonl` beside `date +%H:%M:%S` — the mtime that equals the current second is the live session. The id is the part after `_` in `<timestamp>_<uuid>.jsonl`.
|
||||||
|
- A `history.db` query for `session_id` filtered by `cwd` usually agrees, but it is not authoritative for WHICH of several sessions in that cwd is live — use the jsonl mtime test.
|
||||||
|
- `local/` does NOT disambiguate: a forked transcript dir carries copies of the same `local://` artifacts, so matching artifact names proves nothing about which session is live.
|
||||||
|
- `cwd` is that session's own working directory (the path the harness states), **not** the build worktree you committed in — when they differ, name the build worktree inside the body and keep `cwd` the session's.
|
||||||
|
- Resolve `local://<name>.md` with `read local://<name>.md` (its header prints the absolute path) instead of guessing the directory.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: gitea-token-from-http-credential-store
|
||||||
|
description: "Extract the Dankclaw Gitea token from ~/.config/secrets/gitea for API calls to PRIVATE repos, and diagnose the empty-token 404 that looks like a missing repo."
|
||||||
|
---
|
||||||
|
|
||||||
|
The Gitea credential store line on Frank's boxes is **http** (not https) with a percent-encoded port:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://dank:<40-char-token>@192.168.0.143%3a3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extraction that actually works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tok=$(sed -nE 's#^[a-z]+://[^:]+:([^@]+)@.*#\1#p' ~/.config/secrets/gitea | head -1)
|
||||||
|
curl -s -H "Authorization: token $tok" http://192.168.0.143:3000/api/v1/<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Common wrong pattern (silent failure): an `awk`/`grep` anchored on `https://` or on `@192.168.0.143:` matches nothing, leaving `tok=""` — verify with `echo "tok len ${#tok}"` before believing any API result.
|
||||||
|
|
||||||
|
## The 404 trap
|
||||||
|
|
||||||
|
`dank/command-center` is **public**: an empty token still returns real issue data. `dank/dankvoice` is **private**: the same empty token returns `404` on `/api/v1/repos/<owner>/<repo>` and `{}`-shaped bodies (`title: undefined`) on issues. A 404 with a zero-length token is an auth problem, NOT a missing repo — confirm by printing the token length first, then re-check.
|
||||||
|
|
||||||
|
## Other notes
|
||||||
|
|
||||||
|
- `git credential fill` fails headless here (`could not read Username … terminal prompts disabled`); read the store file directly instead.
|
||||||
|
- Never print the token: mask when inspecting the file —
|
||||||
|
`sed -E 's#(://[^:]+:)[^@]+@#\1REDACTED@#' ~/.config/secrets/gitea`
|
||||||
|
- Issue/PR numbers are **per repo**: `dank/dankvoice` #8–#11 (refactor, hardening, AbortError fix, menu defects) are unrelated to `dank/command-center` #8–#11 (SDLC, trunk-law). Read the body from the repo the work targets before referencing an issue in a commit message.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: gitignore-trailing-slash-symlink-contamination
|
||||||
|
description: "Trailing-slash gitignore rules don't match symlinks, so worktree dependency symlinks get committed; detect with git ls-tree mode 120000, fix with git rm --cached."
|
||||||
|
---
|
||||||
|
|
||||||
|
A `.gitignore` entry written with a **trailing slash matches directories only**. A symlink of the same name is not a directory, so it slips past the rule, and `git add -A` in a `git worktree` checkout silently **tracks** it. Cost measured 2026-10-19: a machine-specific symlink (`node_modules -> /home/blank/Projects/dankvoice/node_modules`) landed in `main` with a merged PR.
|
||||||
|
|
||||||
|
Why worktrees are the trigger: a worktree has no `node_modules` (or other installed-deps dir) of its own, so the cheap way to make build/test work there is a symlink into the primary checkout. With `node_modules/` ignored, nothing complains.
|
||||||
|
|
||||||
|
## Detect
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git ls-tree HEAD -- node_modules # mode 120000 == tracked symlink
|
||||||
|
git ls-files -s | awk '$1==120000' # every tracked symlink in the index
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix (branch, never on main directly)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm -q --cached node_modules # untrack; leaves the file on disk
|
||||||
|
```
|
||||||
|
|
||||||
|
plus relax the ignore rule to the slash-free form, with the reason inline so nobody re-adds the slash:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Trailing slash would only match directories: a node_modules symlink (the
|
||||||
|
# shape `git worktree` checkouts end up with) slips past it and gets tracked.
|
||||||
|
node_modules
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `git check-ignore -v node_modules` must print the rule, and the suite runs after (the symlink still resolves, it is just ignored).
|
||||||
|
|
||||||
|
## Prevent
|
||||||
|
|
||||||
|
In a fresh worktree, create the deps symlink **after** confirming the ignore rule has no trailing slash:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git check-ignore -v node_modules || sed -i 's|^node_modules/$|node_modules|' .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
Same class for any ignored-by-convention dir that a worktree might provide as a link: `node_modules`, `vendor`, `.venv`, `target`, `dist` when built artifacts are ignored.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: land-merged-pr-with-blocked-tree-ops
|
||||||
|
description: "Use when a merged PR must land in a checkout whose working tree is dirty and workspace policy blocks git switch/restore/stash/reset-to-commit: prove identity, commit forward, merge, verify."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Landing a merged PR into a checkout with blocked tree operations
|
||||||
|
|
||||||
|
Shared-workspace policy can block `git switch`, `git checkout <branch>`, `git restore`, `git stash`, and `git reset <commit>` ("uncommitted changes other concurrent agents rely on"). A `git pull --ff-only` then aborts with *"Your local changes to the following files would be overwritten by merge"* — **even when the local content is byte-identical to the incoming commit**, because git compares the index, not intent. Observed twice on 2026-10-19 (dank/command-center, dank/dankvoice).
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. **Prove identity first — never assume it.**
|
||||||
|
`git fetch && git diff --stat origin/main -- <the dirty paths>` → **empty output** means the working tree already equals the merged result, so committing it forward destroys nothing. If it is not empty, stop: there is real local work; do not proceed.
|
||||||
|
2. **Commit forward** the identical paths (message says exactly that, e.g. *"land merged #N content; policy blocks restore/stash, so the identical copies are committed rather than discarded"*). Hooks still run.
|
||||||
|
3. **Merge, don't ff:** `git merge --no-edit origin/main` (ort resolves identical changes on both sides cleanly). `--ff-only` is impossible once step 2 created a commit.
|
||||||
|
4. **Untracked-collision case:** if the abort says *"Please move or remove them before you merge"*, an untracked local file occupies a path the merge adds. `mv` it aside, merge, then `cmp` the restored file against the copy — identical → drop the copy (if the copy came from `.AGENTTRASH`, delete it and `rmdir`, since the file is now tracked).
|
||||||
|
5. **Verify:** repo lint/tests, `git status --porcelain -- <paths>` empty, and `git rev-parse --short HEAD origin/main` equal **after a fresh `git fetch`**.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `git merge … | tail -3` masks the exit status — `tail` always exits 0 and an `&&` chain then continues past an aborted merge. Run the merge unmasked, or check `${PIPESTATUS[0]}`.
|
||||||
|
- Immediately after a push, `git rev-list --count main..origin/main` can report phantom commits-behind from a stale remote-tracking snapshot. Re-`fetch` and compare `rev-parse` before believing any drift.
|
||||||
|
- Do not push redundant commits into a *deployment* checkout without checking the repo's tier law: machine/live tiers commit straight to `main`, human/agent prose goes by PR.
|
||||||
|
|
||||||
|
## Never
|
||||||
|
|
||||||
|
No `--no-verify`, no `reset --hard`, no `checkout -- <path>`, no "temporary" stash: those are exactly the operations the policy blocks because they discard other agents' work.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: llamacpp-adapter-swap
|
||||||
|
description: "Add or roll back a GGUF LoRA adapter on a dank-fleet llama.cpp endpoint (e.g. frank-zealot): provenance census, fork-flag gate, fetcher+env+deployer surface, seam tests, and the behavioural before/after probe that is the only real proof."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Applying a GGUF LoRA adapter to a fleet endpoint
|
||||||
|
|
||||||
|
An adapter is **payload, not shape**: it changes what the endpoint answers and nothing in
|
||||||
|
the KV/budget equation. Fleet law still applies — no benchmarks unless Frank orders one,
|
||||||
|
rollback is one env line, `./tests/run.sh` before commit, deployer health gate + e2e gate.
|
||||||
|
|
||||||
|
## 1. Census the artifact BEFORE writing any code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "https://huggingface.co/api/models/<repo>?blobs=true" # size + LFS oid (None => plain git object)
|
||||||
|
curl -sSL -o /tmp/a.gguf "https://huggingface.co/<repo>/resolve/main/<file>" && sha256sum /tmp/a.gguf
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse the GGUF KV section (15 lines of struct.unpack) and read **provenance**: abliteration
|
||||||
|
adapters carry `prism.abliterate.base_gguf` / `base_sha256`, `adapter.type`, `sites`,
|
||||||
|
`target_layers`, `target_embedding`. Compare the declared base against the file the endpoint
|
||||||
|
actually serves (its sha is in the endpoint's own fetcher script). A mismatch is normal for
|
||||||
|
sibling quantizations — the loader matches **tensor names, not hashes** — but it must be
|
||||||
|
stated in the docs, because it means the card's measured numbers are not this endpoint's
|
||||||
|
numbers.
|
||||||
|
|
||||||
|
## 2. Gate the runtime, from the binary's own `--help`
|
||||||
|
|
||||||
|
On the host: `/home/dank/GitPackages/<build>/llama-server --help | grep -iE 'lora|adapter'`.
|
||||||
|
Confirm `--lora-scaled FNAME:SCALE` exists (and `--lora-init-without-apply` + `POST
|
||||||
|
/lora-adapters` if a runtime toggle is ever wanted). Vendor forks gate adapters by metadata
|
||||||
|
version too — e.g. PrismML builds older than `prism-b10777` refuse packs with `unsupported
|
||||||
|
prism.hadamard.version: 2`. Read the card for the build it verified on and put that in the
|
||||||
|
fetcher header.
|
||||||
|
|
||||||
|
## 3. Surface (five files, copy the mmproj convention)
|
||||||
|
|
||||||
|
- `scripts/<svc>.env`: `LORA_FILE=<path>` + `LORA_SCALE=1`; document that **`LORA_FILE=` (empty)**
|
||||||
|
is the rollback and `LORA_SCALE=0` is the inert-but-present mode. Use `${VAR=default}` (no
|
||||||
|
colon) so an explicitly empty value survives — same reason `MMPROJ_FILE`/`PRESENCE_PENALTY` do.
|
||||||
|
- `scripts/serve-<svc>.sh`: `EXTRA_ARGS+=(--lora-scaled "${LORA_FILE}:${LORA_SCALE}")` — **one
|
||||||
|
argv token**, so a scale can never arrive without its file; refuse a missing file by name
|
||||||
|
(`Restart=always` turns an unfinished fetch into a crash loop that reads like a runtime fault).
|
||||||
|
- `scripts/download-<svc>-<adapter>.sh`: copy the sibling fetcher (`-fsSL --fail-with-body`, byte
|
||||||
|
count, sha256, `--verify-only`, idempotent). No LFS oid => pin the content sha. Header carries
|
||||||
|
the provenance finding.
|
||||||
|
- `scripts/deploy-remote.sh`: `LLM_<SVC>_LORA="${LLM_<SVC>_LORA:-<path>}"` + a check whose error
|
||||||
|
names **this** fetcher, not the payload's.
|
||||||
|
- `scripts/deploy-plan.py` needs nothing: fetchers are host-side state and select no service.
|
||||||
|
|
||||||
|
## 4. Seam tests (argv is the contract)
|
||||||
|
|
||||||
|
Stub-engine harness: create the adapter stub from the **shipped env's basename**, emit
|
||||||
|
`LORA_FILE`/`LORA_SCALE` in the generated test env, then assert on output only —
|
||||||
|
`--lora-scaled` value equals `<path>:<scale>`; a distinctive `LORA_SCALE` (0, 0.75) changes the
|
||||||
|
token; `LORA_FILE=` yields **absence** of the flag (`! grep -qxF -- '--lora-scaled'`); a missing
|
||||||
|
file exits non-zero naming that path; extend the env↔deployer anti-drift pair list.
|
||||||
|
|
||||||
|
## 5. Deploy and prove it behaviourally
|
||||||
|
|
||||||
|
Capture the **before** state on the live endpoint first, then: merge → on host `git pull` +
|
||||||
|
run the fetcher → `./scripts/deploy.sh <host> --service <svc>`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl show <svc> -p DropInPaths --value # must be empty (bench-drop-in law)
|
||||||
|
sudo tr '\0' ' ' < /proc/$(sudo systemctl show <svc> -p MainPID --value)/cmdline
|
||||||
|
sudo journalctl -u <svc> --since <boot> | grep -iE 'adapter|lora' # llama_adapter_lora_init_impl prints prism.* keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Refusal probe (the only proof of an abliteration adapter): same prompts, `temperature 0.1`,
|
||||||
|
**`max_tokens >= 2048`** — on thinking models a short budget is consumed by
|
||||||
|
`reasoning_content` and `content` comes back empty with `finish_reason: length`, which reads
|
||||||
|
as a refusal that never happened. Classify with a marker regex and report refusal-vs-complied;
|
||||||
|
do not paste generated harmful content into chat. Also run the standing trio: exact-phrase
|
||||||
|
chat, `tool_calls` with valid JSON, and a >=256 px solid-colour vision probe. A residual
|
||||||
|
`⚠️ Important Disclaimer` heading after the swap is style, not the refusal circuit.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
Endpoint page (`serverAppConfigs-docs/LLM/<svc>.md`): Adapter row, Fetch row, Flags row, one
|
||||||
|
Era row whose rollback is the env line, and a measured before/after paragraph. Laws worth
|
||||||
|
adding: provenance is metadata not a gate; an `token_embd` rank would crash, so an adapter is
|
||||||
|
not inert; the runtime pin may gain a second reason. Never restate the card's benchmark table.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: monolith-split-under-dist-pin
|
||||||
|
description: "Use when cutting a monolithic source file into modules behind a black-box test suite whose committed build artifact (dist) must stay byte-identical to src; one verified commit per cut, red-proof against the previous bundle. Not for feature work or doc-only edits."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Monolith split under a committed-bundle pin
|
||||||
|
|
||||||
|
Procedure that landed a 1354-line `index.ts` as 9 modules + 301 lines of wiring across 9 commits with zero red commits. Assumes: black-box test suite (tests drive the built bundle, not internals) and a pin test asserting the committed `dist/` equals a fresh build of `src/`.
|
||||||
|
|
||||||
|
## Per-cut loop (one commit per cut, always in this order)
|
||||||
|
|
||||||
|
1. `bun run build` (or project equivalent) **before** staging — commit `dist/` in the same commit as the moved `src/`, otherwise the pin test fails ("committed dist is byte-identical to a fresh build") and you must amend. Amending is fine only while the branch is unpushed.
|
||||||
|
2. `tsc --noEmit`, then run the full suite async (`bats tests/ > /tmp/x.log 2>&1; grep -E '^1\.\.'`).
|
||||||
|
3. **Never write to `src/` while the suite runs.** `setup()` rebuilds the bundle from `src/`, so a concurrent edit attributes someone else's half-written state to your run. Read-only prep (planning the next cut's exact line ranges) is the correct thing to do while waiting.
|
||||||
|
4. Gate green → next cut. Gate red → fix, rebuild, amend, re-gate.
|
||||||
|
|
||||||
|
## Cutting mechanics
|
||||||
|
|
||||||
|
- Cut leaf/pure modules first (grammar, transcript rules, URL resolution, pure segmenter), then stateful ones (config, IO, pipeline, UI). Never let a module import `index` — if the new module needs something from `index`, that thing is a leaf and must move too (this is what forced `segment.ts` out early).
|
||||||
|
- Extract in **verbatim** bodies: read the exact ranges, write them into the new file, then delete the same ranges. Only module docs, export keywords, and injected dependencies change. Keeps each commit reviewable as a move.
|
||||||
|
- Edit tools that require "displayed lines" from the latest read snapshot: re-read the exact ranges under the current tag, then issue one edit call with all ops listed **bottom-up** (original line numbers throughout). Adjacent ranges merge into one `CUT a=b`; leaving a doubled blank line is the usual scar — one extra `CUT` next turn.
|
||||||
|
- Mutable config must reach a long-lived object as **getters**, not a construction-time snapshot: menu handlers mutate `cfg` after construction, and a snapshot freezes the settings the UI exists to change.
|
||||||
|
- After moving code, grep the old file for the moved names: stale call sites compile under bundlers that skip typechecking (`stop()` vs `speaker.stop()` was caught only by `tsc`, not by `bun build`).
|
||||||
|
|
||||||
|
## Proving a new assertion is red-capable (before trusting it green)
|
||||||
|
|
||||||
|
Build the old bundle and point the test harness at it — no stash, no worktree juggling:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git show HEAD:dist/index.js > /tmp/old-dist.js # bundle is self-contained
|
||||||
|
# rebuild the throwaway harness by extracting the driver the suite writes
|
||||||
|
sed -n "<heredoc-body-range>p" tests/behavior.bats > "$W/driver.ts"
|
||||||
|
DV_EXT=/tmp/old-dist.js <same env as the test> bun "$W/driver.ts" <scenario> # expect RED
|
||||||
|
DV_EXT="$PWD/dist/index.js" <same env> bun "$W/driver.ts" <scenario> # expect GREEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the project's real player/server stub verbatim — a simplified stub (e.g. one that skips writing the log file the driver counts) prints a false `PLAYS 0` and looks like a failing fix. Pin the scratch dir to a fixed path; `ls -dt /tmp/tmp.*` picks up another session's directory.
|
||||||
|
|
||||||
|
## Black-box test hygiene learned the hard way
|
||||||
|
|
||||||
|
- A test that counts artifacts in the **shared** temp directory observes every other process on the box (live agent sessions synthesize into the same `dankvoice-*` dirs). Give the run a private `TMPDIR="$WORK/scratch-tmp"` and assert there; the assertion's sensitivity is proven by the fact it caught a real leftover while scoped globally.
|
||||||
|
- Let preseeds reference the ephemeral fixture port with a placeholder (`__PORT__`) substituted by the driver, so a test can preseed a URL pointing at its own fixture server.
|
||||||
|
- Refactor-only commits get **no new test**: re-asserting unchanged labels/order after a commit whose point is that they are unchanged is a tautology; the existing suite is the gate. New tests belong to behavior changes, red-first.
|
||||||
|
- Tests must observe **output**, never internals: assert `PLAYS`/`CALLS`/`CALL <url>`/`NOTIFY <text>`, not that a variable was set.
|
||||||
|
|
||||||
|
## Docs after a split (same branch, own commit)
|
||||||
|
|
||||||
|
Move each explanation to the file that now owns it (knob list → the module that reads env; state-machine prose → the module that implements it) and leave the entry point a one-line-per-module map. Then grep for pointers into the old file — a README saying "see header of `src/index.ts`" becomes a lie the moment the knobs move.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: omp-extension-event-probe
|
||||||
|
description: "Prove which lifecycle events the omp harness actually dispatches to extensions, with a throwaway timestamping probe plugin, instead of inferring delivery from side effects."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prove harness→extension event delivery with a probe plugin
|
||||||
|
|
||||||
|
Use when an extension handler seems never to fire, or when you must know *when* an
|
||||||
|
event arrives relative to another (mid-run vs settle). Never infer dispatch from
|
||||||
|
the feature's side effects: async work in the handler (LLM rewrites, synthesis,
|
||||||
|
network) lags seconds and destroys the timing signal.
|
||||||
|
|
||||||
|
## Shape (no build step needed)
|
||||||
|
|
||||||
|
`/tmp/probe/package.json` — the installer validates the declared entry on disk:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "event-probe",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": "./index.js",
|
||||||
|
"pi": { "extensions": ["./index.js"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`/tmp/probe/index.js` — append arrival times, not counts:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { appendFileSync } from "node:fs";
|
||||||
|
const LOG = "/tmp/probe.log";
|
||||||
|
const stamp = (line) => appendFileSync(LOG, `${new Date().toISOString().slice(11, 23)} ${line}\n`);
|
||||||
|
export default function probe(pi) {
|
||||||
|
pi.on("agent_start", () => stamp("agent_start"));
|
||||||
|
pi.on("turn_end", (e) => stamp(`turn_end turn=${e.turnIndex} toolResults=${(e.toolResults ?? []).length}`));
|
||||||
|
pi.on("agent_end", (e) => stamp(`agent_end messages=${(e.messages ?? []).length} willContinue=${String(e.willContinue)}`));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
1. `cd ~/.omp/plugins && omp plugin link /tmp/probe` (link, never edit
|
||||||
|
`node_modules`), then `omp plugin doctor` → expect a `✔ plugin:event-probe` line.
|
||||||
|
2. Launch a **fresh** TUI session in a pty (`omp --cwd /tmp/probe-run`, `pty: true`).
|
||||||
|
Extensions load at startup only — an already-running session never sees the probe.
|
||||||
|
3. PTY gotcha: input sent before the splash clears is swallowed. Read the screen
|
||||||
|
first, then submit text and finish with a separate write containing a bare
|
||||||
|
`\r` (LF alone left the text sitting in the composer).
|
||||||
|
4. Drive a task with ≥2 tool rounds so turns and settles separate in time
|
||||||
|
(sequential reads work and need no approval; ask for steps "strictly one
|
||||||
|
after the other" or the model parallelises them into one turn).
|
||||||
|
5. Wait for a terminal line with an async bounded loop (`grep -q agent_end`,
|
||||||
|
`sleep 1` inside one backgrounded bash call) — never poll across turns.
|
||||||
|
|
||||||
|
## Read
|
||||||
|
|
||||||
|
`turn_end` timestamps strictly earlier than `agent_end` ⇒ per-turn delivery
|
||||||
|
works. `willContinue=true` on `agent_end` marks a non-terminal settle (harness
|
||||||
|
maps `isTerminal === false` to it) — handlers that `return` on it never speak.
|
||||||
|
|
||||||
|
## Teardown (mandatory)
|
||||||
|
|
||||||
|
`omp plugin uninstall event-probe`; `omp plugin doctor`; delete `/tmp/probe*`.
|
||||||
|
The link writes into `~/.omp/plugins/package.json` (tracked, live tier) —
|
||||||
|
uninstall restores it; confirm with `grep` for the probe name returning nothing.
|
||||||
|
|
||||||
|
## Ground truth read for free
|
||||||
|
|
||||||
|
Event union + payloads: `~/.omp/plugins/node_modules/@oh-my-pi/pi-coding-agent/dist/types/extensibility/shared-events.d.ts`;
|
||||||
|
the session→extension mapping lives in `src/extensibility/extensions/lifecycle-mirror.ts`
|
||||||
|
(`extensionEventFromSessionEvent`). Extension-level `turn_end` carries
|
||||||
|
`turnIndex`/`message`/`toolResults` but **no `willContinue`** — that field exists
|
||||||
|
only on `agent_end`, so "is this the last turn" cannot be decided from `turn_end`.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: omp-extension-stale-module-audit
|
||||||
|
description: "Diagnose recurring omp extension handler timeouts/silence when the on-disk fix is already deployed: harness has no extension hot-reload, so running sessions freeze the module at session start (zombie modules)."
|
||||||
|
---
|
||||||
|
|
||||||
|
Use when a fix for an omp extension is merged/deployed yet the SAME errors persist ("handler timed out after 30000ms", silence, wrong behavior) in running sessions.
|
||||||
|
|
||||||
|
**Core fact:** the harness loads extension `.ts` modules at session start and never reloads them (no `/reload` for extensions; bundle `reload` hits are LSP/MCP/browser only). A session older than the fix keeps executing stale code regardless of the file on disk.
|
||||||
|
|
||||||
|
**Procedure:**
|
||||||
|
1. Census the errors from logs, not guesses — timeout lines carry the culprit pids:
|
||||||
|
`grep -h "handler timed out" ~/.omp/logs/omp.<today>*.log | python3 -c 'import sys,json; rows=[json.loads(l) for l in sys.stdin]; rows.sort(key=lambda r:r["timestamp"]); [print(r["timestamp"], r["event"], r["pid"]) for r in rows]'`
|
||||||
|
(`~/.omp/logs/omp.<date>.<pid>.log` — the pid in the filename is the session.)
|
||||||
|
2. Session start times vs deployed file versions:
|
||||||
|
`ps -eo pid,lstart,args | grep '[o]mp'`
|
||||||
|
`git log --format='%h %ad %s' --date=format:'%m-%d %H:%M' -- <extension path>`
|
||||||
|
`git reflog --format='%gd %ad %gs' --date=format:'%m-%d %H:%M' | grep merge` (live-sync pull times = when the file content changed).
|
||||||
|
Any session whose start time precedes the fix's arrival has the stale module.
|
||||||
|
3. Confirm the stale revision actually contains the bad pattern — read what that session loaded, not HEAD:
|
||||||
|
`git show <old-rev>:<path> | grep -n 'await speak\|async (event'`
|
||||||
|
(e.g. an `async agent_end` awaiting a >30 s chain is exactly what the harness's per-handler `Kzc` 30 s race logs; fire-and-forget `void` is fine — detached promises are NOT tracked by the dispatch window.)
|
||||||
|
4. Optional empirical proof when static analysis says "impossible": probe extensions through the real harness:
|
||||||
|
`omp -p --no-extensions -e /tmp/probe.ts "Reply OK"` with a probe that logs handler start/return and `void`s a 40 s detached promise + spawns a long child. Fast exit + no timeout log ⇒ detachment is untracked ⇒ blame stale modules, not the harness.
|
||||||
|
5. Remediation: restart the affected sessions (kill the listed pids or their terminals — the operator session itself may be one of the zombies; say so explicitly). There is no other fix.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Per-handler attribution in the log is accurate per dispatch; don't blame other extensions before checking the pid's start time.
|
||||||
|
- Endpoints healthy + silence persisting usually means TWO stacked bugs (e.g. stale awaiting module in old sessions AND a mute bug in current code) — diagnose each pid cohort separately.
|
||||||
|
- Extension chain code must use self-owned `AbortSignal.timeout(...)`, never a ctx-scoped signal: harness dispatch signals die when the dispatch window closes.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: omp-lifecycle-event-selection
|
||||||
|
description: "Pick the right omp harness lifecycle event for narration/telemetry: agent_end is a settle, turn_end waits for tool results, message_end is earliest with content, after_provider_response carries no content."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Choosing an omp lifecycle event (narration, telemetry, hooks)
|
||||||
|
|
||||||
|
Re-derived twice from the installed typings/sources (`@oh-my-pi/pi-coding-agent`, `@oh-my-pi/pi-agent-core`) while fixing dankvoice. Read this before wiring a handler; verify against the installed version's `src/extensibility/extensions/types.ts` and `pi-agent-core/src/agent-loop.ts` when precision matters.
|
||||||
|
|
||||||
|
| event | payload you can use | when it fires | traps |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `after_provider_response` | `status`, `headers`, `requestId`, `metadata` **only** | response headers arrive, **before the stream body is consumed** | No message content at all: useless for anything that speaks or summarizes text. Right for latency telemetry, non-200/abort detection, `requestId` correlation. |
|
||||||
|
| `message_end` | full `message` (assistant, user, tool-result, gate-stop, error) | the moment that message is built (`agent-loop.ts`, assistant case) | Earliest event carrying assistant text. Fires for non-assistant messages too → gate `message.role === "assistant"`. Payload is cloned for extensions. |
|
||||||
|
| `turn_end` | `turnIndex`, `message`, `toolResults` | after that turn's **tool results** come back | Stale by the tool duration: a 14 s tool delays a `turn_end` trigger 14 s. Extension event has **no `willContinue`** (the `turnIndex` is numbered by the lifecycle mirror), so you cannot tell the terminal turn from here. |
|
||||||
|
| `agent_end` | `messages`, `willContinue`, `awaitingAsyncWork` | once per user prompt, when the loop settles | `willContinue: true` = the harness scheduled a continuation (auto-retry / empty-stop), not a user-visible settle — narrating it speaks an answer the model is about to redo. Silence-prone on long-running sessions. |
|
||||||
|
| `agent_start` | — | run begin | Use to reset per-run dedupe state, so a later answer with identical text is not swallowed. |
|
||||||
|
|
||||||
|
## Narration cadence pattern (what dankvoice ships)
|
||||||
|
|
||||||
|
- `turn_end` (or `message_end` if the tool-time lag matters) for mid-run commentary; `agent_end` only for the settled answer.
|
||||||
|
- Drop a mid-run trigger while audio/IO is busy — never queue, never interrupt.
|
||||||
|
- Dedupe the terminal text per run (`agent_start` resets), because the last turn and the settle carry the same string.
|
||||||
|
- Skip `ctx.mode === "print" | "json"` so benches stay silent.
|
||||||
|
|
||||||
|
## Prove delivery, don't infer it
|
||||||
|
|
||||||
|
Audio/UI timing cannot show which events arrive. Link a throwaway extension that appends timestamps per event and run one real session: `omp plugin link /tmp/probe` (package.json with `"pi": { "extensions": ["./index.js"] }`), prompt a task with a slow tool, read the log, then `omp plugin uninstall probe`. Confirmed live 2026-10-19: extensions receive `agent_start`, `turn_end`, `agent_end`; `turn_end` landed seconds before `agent_end`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: omp-local-artifact-path
|
||||||
|
description: Resolve a local:// artifact to a real filesystem path (for scripts/curl) without guessing the session-slug directory.
|
||||||
|
---
|
||||||
|
|
||||||
|
`local://<name>.md` artifacts live in a per-cwd, per-session directory whose name you cannot guess. Three invented paths failed before this worked (2026-10-19).
|
||||||
|
|
||||||
|
Actual layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.omp/agent/sessions/<cwd-slug>/<timestamp>Z_<session-id>/local/<name>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`<cwd-slug>` is the working directory with `/` replaced by `-`, **keeping the leading dash** — `/home/blank/.omp/wt/wt-20261019-125509-7c8a99d` under `/home/blank` becomes `-.omp-wt-wt-20261019-125509-7c8a99d`. Dropping the leading `-.` is the mistake that produces `FileNotFoundError`.
|
||||||
|
|
||||||
|
## Get the real filesystem path
|
||||||
|
|
||||||
|
Ask the tool, don't reconstruct it — a ranged read of the artifact reports the resolved path in its header:
|
||||||
|
|
||||||
|
```
|
||||||
|
read local://pr-body-msgend.md:1-1 -> [~/.omp/agent/sessions/-.../local/pr-body-msgend.md#TAG]
|
||||||
|
```
|
||||||
|
|
||||||
|
Or locate it by basename (needs enough depth; `maxdepth 5` misses it — the file sits 6–7 levels down):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
f=$(find ~/.omp/agent/sessions -maxdepth 7 -name 'pr-body-msgend.md' | head -1)
|
||||||
|
cp "$f" /tmp/prbody.md # stable path for the python/curl step
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy to `/tmp` before handing the content to a script: one cheap command, and every later tool call stops depending on session-scoped paths.
|
||||||
|
|
||||||
|
## Related lookups in the same directory tree
|
||||||
|
|
||||||
|
- Session id for the current cwd — `~/.omp/agent/history.db` (prompts table has `session_id`, `cwd`; the column is `prompt`, **not** `text`, and raw `?q=` queries cannot be combined with a table selector or pagination):
|
||||||
|
```
|
||||||
|
read history.db?q=SELECT session_id, cwd, substr(prompt,1,50) AS p FROM history WHERE cwd LIKE '%wt-20261019%' ORDER BY rowid DESC LIMIT 3
|
||||||
|
```
|
||||||
|
Use the real id in issue/PR attribution footers; never reuse an id remembered from a compacted transcript.
|
||||||
|
- Transcript dir for the same session sits beside `local/`.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
name: open-notebook-provision-model-rest
|
||||||
|
description: "Provision or repair an open-notebook model (LLM/TTS/embedding) over its REST API on Dankclaw: credential→model→defaults order, the defaults-store-the-ID trap, the /api/embed 500-that-succeeds, and the two-note ranking proof for embeddings."
|
||||||
|
---
|
||||||
|
|
||||||
|
# open-notebook model provisioning over REST
|
||||||
|
|
||||||
|
Use when adding/repairing a model row (embedding, LLM, TTS) in open-notebook on Dankclaw, when semantic
|
||||||
|
search is dead, or when an embed error appears in the worker log. Config lives in the **database**
|
||||||
|
(encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`) — no repo file mirrors it, REST or the UI are the only doors.
|
||||||
|
|
||||||
|
## Access (never put the bearer in argv)
|
||||||
|
|
||||||
|
The API bearer IS `OPEN_NOTEBOOK_PASSWORD`; the API publishes on loopback `127.0.0.1:5056` only, so run
|
||||||
|
from Dankclaw over ssh. `ssh dankclaw 'curl -H "Authorization: Bearer …"'` leaks the secret into
|
||||||
|
`/proc/<pid>/cmdline` — use a mode-600 curl config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PW=$(grep -m1 '^OPEN_NOTEBOOK_PASSWORD=' /home/dank/Containers/open-notebook/env | cut -d= -f2- | tr -d '"')
|
||||||
|
cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: Bearer %s"\n' "$PW" > "$cfg"
|
||||||
|
B=http://127.0.0.1:5056 # not 5055: seerr holds the host's 5055
|
||||||
|
```
|
||||||
|
|
||||||
|
## Order, and the address rule
|
||||||
|
|
||||||
|
`POST /api/credentials` → `POST /api/models` (`credential: <credential id>`) → `PUT /api/models/defaults`
|
||||||
|
→ any profile that names a model (speaker `voice_model`, episode `speaker_config`/`outline_llm`).
|
||||||
|
|
||||||
|
- Credential provider for any in-house OpenAI-compatible server: **`openai_compatible`** (not `openai`),
|
||||||
|
with a dummy `api_key` when the upstream does not authenticate.
|
||||||
|
- The app's URL guard refuses base URLs resolving in `169.254.0.0/16`, so `host.containers.internal`
|
||||||
|
cannot be stored; a **container name on `dankclaw_default`** (e.g. `http://frank-dragoon-halogen:8731/v1`)
|
||||||
|
or another machine's RFC1918 IP both pass.
|
||||||
|
|
||||||
|
## The two traps that eat the most time
|
||||||
|
|
||||||
|
1. **`PUT /api/models/defaults` takes the model ID, never the name.** `"default_embedding_model":
|
||||||
|
"qwen3-embedding-0.6b"` is accepted and echoed back, then every job dies in the worker with
|
||||||
|
`Model with ID qwen3-embedding-0.6b not found`. Use the row's `model:…` id (GET defaults first, change
|
||||||
|
one key, PUT the whole object back — the other keys in that response are ids and must be preserved).
|
||||||
|
2. **`POST /api/embed` answers 500 *after* the work succeeds.** Upstream `EmbedResponse.command_id`
|
||||||
|
declares `str` and the worker returns a `RecordID`, so FastAPI raises in serialization only. The truth is
|
||||||
|
`podman logs open-notebook | grep "Successfully embedded"`, never the HTTP code. Note saves
|
||||||
|
(`POST /api/notes`) submit `embed_note` themselves; `/api/embed` is only for backfills.
|
||||||
|
|
||||||
|
## Proof it actually works
|
||||||
|
|
||||||
|
- Per row: `POST /api/models/<id>/test` → `Embedding dimensions: 1024` (embeddings) or a real completion.
|
||||||
|
- Embeddings, discriminating test: create **two** unrelated notes, then
|
||||||
|
`POST /api/search` `{"query": "...", "type": "vector", "search_notes": true, "limit": 5}` — the enum is
|
||||||
|
`text|vector` (not `semantic`) and the score field is `similarity`. Each query must rank its own note
|
||||||
|
high and the other low (measured 0.659/0.292 vs 0.906/0.318). One note proves nothing: `total_count` is 1
|
||||||
|
either way. Delete the probe notes afterwards (`DELETE /api/notes/<id>`).
|
||||||
|
- Confirm which engine served it: `podman logs <engine container> | grep "POST /v1/embeddings"`.
|
||||||
|
|
||||||
|
Owner pages: `serverAppConfigs-docs/Dankclaw/Open Notebook.md` (app-side shapes), and the endpoint's own
|
||||||
|
page for whatever serves the model.
|
||||||
@@ -1,68 +1,34 @@
|
|||||||
---
|
---
|
||||||
name: parallel-worktree-path-hazard
|
name: parallel-worktree-path-hazard
|
||||||
description: "\"Use when several agents edit the same repo in different git worktrees at once, or when an edit/read may have landed in the wrong checkout: which path forms bind where, how to detect with git -C status, and the repair protocol (cp + git show HEAD blob write-back) because git checkout/restore is policy-blocked in a shared workspace.\""
|
description: "Use when several agents edit the same repo in different git worktrees at once, or when an edit lands in the wrong checkout: relative tool paths resolve to the session cwd, not the shell cwd."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Concurrent-worktree path hazard (shared repo, many agents)
|
Use when several agents (or you across two checkouts) edit the same repo in different git worktrees at once, or when an edit/query lands in the wrong checkout.
|
||||||
|
|
||||||
Measured 2026-10-18 on `serverAppConfigs` with two agents live at once. The
|
## The silent failure: relative tool paths resolve to the SESSION cwd
|
||||||
failure is **silent**: the tool reports success, the diff looks right, and the
|
|
||||||
change lives in a *different* worktree.
|
|
||||||
|
|
||||||
## Which path form binds where
|
`read` / `edit` / `write` / `grep` resolve a **relative** `path` against the **session working directory** — never against the `cd` inside a neighboring `bash` call, and not against a `../other-worktree/...` prefix either (that gets normalized back into the session tree). So after `cd ../wt-B && grep …` you can `read path="docs/page.md"` and get **wt-A's** copy.
|
||||||
- **Parent/top-level session**: a **relative** `path` resolves against the
|
|
||||||
session CWD — its own worktree — never against the worktree named in the
|
|
||||||
prompt. This is how a change meant for `wt-tts-trial-docs` landed in
|
|
||||||
`wt-20261017-…`.
|
|
||||||
- **Subagent**: an **absolute** path to its own assigned worktree can resolve
|
|
||||||
into the parent's worktree when the same *relative* path exists there
|
|
||||||
(`edit` twice onto `dankclaw/.env.example`). Its `write` with the same
|
|
||||||
absolute path was fine — the bug is `edit`/`read` path resolution, not `write`.
|
|
||||||
|
|
||||||
Rules that hold:
|
Why it survives "I just read it to check": the returned path is printed workspace-relative and the file name is identical in every checkout, so the output looks like a correct read of the tree you meant. Only content that differs between the checkouts betrays it.
|
||||||
1. A subagent passes paths as `../<its-worktree>/<rel>` (relative to the parent
|
|
||||||
worktree) for `read` **and** `edit`; the parent passes **absolute** paths to
|
|
||||||
any worktree that is not its own CWD.
|
|
||||||
2. After every edit batch, prove the landing site, do not trust the tool's echo:
|
|
||||||
`git -C <intended-worktree> status --short` must list the file. A file you
|
|
||||||
edited that is missing from that list went somewhere else.
|
|
||||||
3. Keep each work item on its own branch/worktree, and keep ONE integration
|
|
||||||
owner (the parent) for shared files — `scripts/deploy-remote.sh`,
|
|
||||||
`scripts/deploy-plan.py`, `tests/`, `serverAppConfigs-docs/index.md`,
|
|
||||||
`AGENTS.md` are the collision hot spots in this repo. Never rebase a
|
|
||||||
sibling's branch.
|
|
||||||
|
|
||||||
## Repair protocol (no `git checkout`, no `git restore`)
|
Rules:
|
||||||
`git checkout -- <path>` and `git restore <path>` are **policy-blocked** in a
|
- **Absolute paths for every tool call that targets a worktree other than the session's.** No exceptions for quick reads.
|
||||||
shared workspace ("overwriting working-tree files … discards changes other
|
- Confirm the checkout from content you know differs (a heading, a status line), not from the echoed path.
|
||||||
concurrent agents rely on"). Unstaging (`git reset [<path>]`,
|
- A `read` error like "Did you mean .AGENTTRASH/<name>" is a signal the relative path resolved into the session tree — re-issue absolute.
|
||||||
`git restore --staged <path>`) is allowed but does not undo content. So:
|
- Same trap for `bash`: it starts each call in the session cwd, so `cd` twice per call or use absolute paths there too.
|
||||||
|
|
||||||
```bash
|
## Landing a change when the session worktree is stale
|
||||||
F="path/with spaces if any"
|
|
||||||
# 1. move the change to the worktree that should own it (both at the same base commit)
|
|
||||||
cp "<wrong-wt>/$F" "<right-wt>/$F"
|
|
||||||
# 2. undo in the wrong worktree by writing the committed blob back
|
|
||||||
git -C "<wrong-wt>" show "HEAD:$F" | tee "<wrong-wt>/$F" > /dev/null
|
|
||||||
# 3. prove both sides
|
|
||||||
md5sum <(git -C "<wrong-wt>" show "HEAD:$F") "<wrong-wt>/$F" "<right-wt>/$F"
|
|
||||||
git -C "<wrong-wt>" status --short # must be empty for that file
|
|
||||||
git -C "<right-wt>" diff --stat # must show the intended delta
|
|
||||||
```
|
|
||||||
|
|
||||||
Never fix it blind: if the wrong worktree had its own uncommitted edits to that
|
`git diff --name-only HEAD origin/main` first. If main moved a file you are editing, your edit's base is wrong even though the file "looks fine" — do not commit on the stale base.
|
||||||
file, `cp`/blob-write-back would destroy them — check
|
|
||||||
`git -C <wrong-wt> status --short` FIRST and reconcile content, don't overwrite.
|
|
||||||
|
|
||||||
## Tidy after a cancelled sibling
|
1. `git worktree add ../wt-clean -b <type>/<slug> origin/main`; copy the new files in.
|
||||||
A killed subagent can leave a worktree plus an empty branch. Removing them is
|
2. Re-apply the edit against the clean tree's version **with absolute paths**, then read it back absolutely to prove which tree changed.
|
||||||
safe only when verified empty: `git -C <main> log --oneline -3 <branch>` (no
|
3. Run the suite inside the clean worktree; commit there.
|
||||||
unique commits) and `git -C <wt> status --short` (clean), then
|
4. Remove your own duplicate edit from the session worktree by editing it away (`CUT`/`PUT`). `git checkout -- <path>` and branch switches are policy-blocked in shared workspaces and should stay blocked — they discard edits other agents rely on.
|
||||||
`git worktree remove <wt>` + `git branch -D <branch>`. An orphan worktree at a
|
5. Never carry the same uncommitted edit in two worktrees at once; if you must keep both trees, keep a task that reconciles them.
|
||||||
stale commit is the same class of trap as a leftover bench drop-in: the next
|
|
||||||
agent reads it as unfinished work.
|
|
||||||
|
|
||||||
## Report it
|
## Multi-agent hazards in one shared worktree
|
||||||
File the misresolution to `xd://report_issue` (false positives are fine) and
|
|
||||||
record the incident in the repo worklog — a repair nobody wrote down looks like
|
- Another agent can stage, commit, or check out files under you; re-read a file immediately before editing it if any tool result hinted at intervening change.
|
||||||
a hijack later.
|
- Untracked scratch you drop in a shared tree (reports, notes, `.AGENTTRASH/` entries) is visible to everyone and can be committed by them. Put throwaway artifacts outside the checkout.
|
||||||
|
- Never `git clean -f`, `rm -rf`, or branch switching to "get a clean state" in a tree another agent is using.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: plugin-git-install-committed-artifact
|
||||||
|
description: "Use when shipping or landing an omp/pi plugin (omp plugin install/upgrade git): install validation fails on a missing build artifact, a stale bun git-mirror cache is reused, the live bundle needs proof it is the merged build, or upgrade says \"not installed\"."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plugin install from git: committed artifact + fresh mirror
|
||||||
|
|
||||||
|
`omp plugin install git:<url>` (and `pi install git:`) **clone the repo and validate the manifest's declared extension entry WITHOUT running a build step**. Two failure classes seen live (dank/dankvoice, 2026-10-19):
|
||||||
|
|
||||||
|
## 1. Declared entry missing from the clone
|
||||||
|
|
||||||
|
Symptom: `Plugin <name> extension validation failed: ./dist/index.js: declared extension entry not found on disk`.
|
||||||
|
|
||||||
|
Cause: build output (`dist/`) is gitignored; the manifest points at it; the clone has only `src/`.
|
||||||
|
|
||||||
|
Fix — ship the artifact:
|
||||||
|
- Remove `dist/` from `.gitignore`, `bun run build`, commit `dist/`.
|
||||||
|
- Prove build determinism first: build twice, `cmp` the outputs (bun esm builds are byte-reproducible without sourcemaps).
|
||||||
|
- Pin the contract in a small bats suite so it can't rot:
|
||||||
|
1. the manifest's declared entry is tracked in git (`git ls-files -- "${entry#./}"` non-empty — this is the installer's exact contract, not a config assertion);
|
||||||
|
2. committed bytes equal a fresh build (`bun run build` then `git diff --name-only -- dist` is empty → red on drift).
|
||||||
|
|
||||||
|
## 2. Stale bun git-mirror cache
|
||||||
|
|
||||||
|
Symptom: fix pushed, retry **still** fails identically, and the retry is suspiciously fast (seconds).
|
||||||
|
|
||||||
|
Cause: bun keeps bare mirrors in `~/.bun/install/cache/<hash>.git` and may reuse them at an old commit — even after the fix is pushed and the default branch is correct.
|
||||||
|
|
||||||
|
Diagnose + fix:
|
||||||
|
```bash
|
||||||
|
find ~/.bun/install/cache -maxdepth 2 -iname "*<repo>*"
|
||||||
|
git --git-dir ~/.bun/install/cache/<hash>.git log --oneline -3 --all # stale HEAD?
|
||||||
|
rm -rf ~/.bun/install/cache/<hash>.git # it is a cache; safe
|
||||||
|
```
|
||||||
|
Prove the fetch path independently first (also validates credentials for private Gitea repos):
|
||||||
|
```bash
|
||||||
|
GIT_TERMINAL_PROMPT=0 git clone -q <url> /tmp/x && git -C /tmp/x log --oneline -1 && ls /tmp/x/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Order of investigation (don't skip)
|
||||||
|
1. Gitea default branch correct? (`GET /api/v1/repos/<o>/<r>` with token — private repos 404/None unauthenticated.)
|
||||||
|
2. Plain unattended clone contains the entry? If yes, the installer's view is cached → purge mirror.
|
||||||
|
3. Only then suspect the installer itself (validation code lives in the minified bundle; `grep -oE '.{600}<error string>' "$bunfile"` on `readlink -f $(which omp)` shows the exact check).
|
||||||
|
|
||||||
|
## Landing a merged PR on the live box
|
||||||
|
|
||||||
|
After the merge and the `--ff-only` on the dev checkout (`ls -ld node_modules` after landing anything that could have touched it):
|
||||||
|
|
||||||
|
- **`omp plugin upgrade <name>` takes the PACKAGE name, not the install alias.** `omp plugin upgrade dankvoice` fails `dankvoice is not installed` while the live copy sits in `~/.omp/plugins/node_modules/omp-dankvoice/`. Read the directory name (or `package.json` `"name"`) and upgrade that.
|
||||||
|
- Then close the chain of custody with one command — the deployed bytes are the merged bytes:
|
||||||
|
```bash
|
||||||
|
cmp ~/Projects/<repo>/dist/index.js ~/.omp/plugins/node_modules/<pkg>/dist/index.js
|
||||||
|
```
|
||||||
|
If only docs/tests changed, `dist/` is untouched and this should already match; if `src/` changed, the plugin must be upgraded first or this fails with `char <n>` — which is a real finding (stale live bundle), not a test bug.
|
||||||
|
- The session that did the merge keeps the OLD bundle in memory: it is still running pre-merge code. Never conclude "the change does not work" from the merging session.
|
||||||
|
|
||||||
|
## After install — proof without a TUI
|
||||||
|
- `omp plugin doctor` → per-plugin ✔ line.
|
||||||
|
- Audible/behavioral chain when TUI input is unusable (update-banner modal freezes pty submits): run `omp --mode=rpc --no-session` with the feature env armed (dankvoice: `DANKVOICE_AUTO=1`, gate is print/json-only) and watch for objective side effects, e.g. PipeWire: `pw-dump | grep -q "say-<ms>.wav"` proves synth+playback end-to-end.
|
||||||
|
- When pty submits DO work (no modal), the strongest instrument is a stub player plus a clip count, not ears or pixels: `DANKVOICE_PLAYER=/tmp/stub.sh` where the stub appends `$1` to a log and exits 0. Launch the DEPLOYED bundle by path (`omp --no-extensions --no-session -e ~/.omp/plugins/node_modules/<pkg>/dist/index.js`) in a neutral cwd with `DANKVOICE_CONFIG` pointed at a scratch file so the operator's settings are untouched.
|
||||||
|
- **Count clips per narration EVENT, not per turn.** One turn can fire several narrations (dankvoice: `message_end` carries prose+thinking, `agent_end` the settled answer; the dedupe only skips byte-identical text, so thinking-on means both speak). A "too many clips" result is usually correct behaviour — check the harness transcript for the turn count before believing a bug. The player deletes each temp wav, so the log line count is the only surviving evidence: park it in `~/.AGENTTRASH/<date>-<topic>/` if it becomes evidence for an issue.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: red-verify-without-stash
|
||||||
|
description: "Prove a regression test is red-capable against pre-fix code in a shared worktree where git stash/checkout are policy-blocked, via git-show blob swap with md5-verified restore."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Red-verifying tests without `git stash`
|
||||||
|
|
||||||
|
## When
|
||||||
|
You fixed a file and added tests that should have failed before the fix, but workspace policy blocks `git stash` (and `git checkout -- file`) in shared worktrees (e.g. `~/.omp` shared-workspace policy). Needed to prove red-capture instead of asserting it.
|
||||||
|
|
||||||
|
## Recipe
|
||||||
|
One `bash` call — swap the fixed file for the HEAD (pre-fix) blob, run only the fix-relevant tests, restore, verify byte-identity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "$WT"
|
||||||
|
md5sum path/to/fixed.file > /tmp/file-hash
|
||||||
|
cp path/to/fixed.file /tmp/fixed.file.bak
|
||||||
|
git show HEAD:path/to/fixed.file > path/to/fixed.file # pre-fix content; new tests now exercise old code
|
||||||
|
bats -f "test name A|test name B" tests/suite.bats # expect: not ok for every fix-relevant test
|
||||||
|
cp /tmp/fixed.file.bak path/to/fixed.file
|
||||||
|
md5sum -c /tmp/file-hash && echo RESTORED
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `git show HEAD:path > path` truncates-and-refills the file in place; safe because you hold the md5 and a backup copy, and the restore check proves the fixed bytes are back.
|
||||||
|
- Run only the fix-relevant tests via the runner's name filter (`bats -f` regex); a full suite wastes time and old tests pass on both versions.
|
||||||
|
- Expect exactly the tests that pin the fix's behavior to fail pre-fix; tests unrelated to the change (pure coverage of old behavior) pass on both versions — don't count them as red evidence.
|
||||||
|
- `bats` pipelines mask exit codes without `pipefail`; judge by the `ok`/`not ok` lines, not the shell status.
|
||||||
|
- For non-test proof of endpoint behavior (live A/B of an HTTP request body), replay the exact body with `curl` against the live endpoint instead of speculating; keep both timings.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: ryzenai-generic-failure-loader-order
|
||||||
|
description: "Debug AMD Ryzen AI OGA \"Exception during initialization: Generic Failure\" on Linux XDNA2 — the LD_LIBRARY_PATH-vs-activate ordering trap, its proof instruments, and the OGA 0.14 run contract."
|
||||||
|
---
|
||||||
|
|
||||||
|
Symptom (any `onnxruntime_genai` run on a Ryzen AI NPU, container or bare):
|
||||||
|
|
||||||
|
```
|
||||||
|
[E:onnxruntime:, inference_session.cc:2811 operator()] Exception during initialization: Generic Failure
|
||||||
|
```
|
||||||
|
|
||||||
|
Fails ~2 ms after `og.Model()`. No `openat` of `/dev/accel/accel0`, **nothing in the kernel log**, no XRT log (the INI logger is not built into AMD's shipped XRT), no core dump. It is NOT a platform, model-export, or driver-ABI failure.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
`source <venv>/bin/activate` from `install_ryzen_ai.sh` **prepends** to `LD_LIBRARY_PATH`:
|
||||||
|
|
||||||
|
```
|
||||||
|
site-packages/flexml/flexml_extras/lib : site-packages/onnxruntime/capi : site-packages/voe/lib
|
||||||
|
```
|
||||||
|
|
||||||
|
`voe/lib` is the **compiler-side** bundle: it ships its own `libxrt_coreutil.so.2` and `libryzen_mm.so.1`. Set the path *before* activating and those copies win, XRT's XDNA shim binds against the wrong coreutil, `xrt_core::shim_loader::shim_loader()` → `get_userpf_device()` → `xrt::device(0)` throws, `ryzenai::Allocator::Alloc` throws next, and ORT flattens both into the bare string above.
|
||||||
|
|
||||||
|
**Rule: activate first, THEN set `LD_LIBRARY_PATH`, with `/opt/xilinx/xrt/lib` first.** Passing the var via `podman -e` and sourcing activate afterwards in the same `bash -lc` is the broken shape — that is exactly how a runner script differs from a hand-typed probe, and it is why diagnostics pass while the runner fails. Mounting XRT is not enough either: the tree must be **on** the path.
|
||||||
|
|
||||||
|
## Proof instruments, cheapest first
|
||||||
|
|
||||||
|
1. `ldd <deployment>/lib/libonnxruntime_providers_ryzenai.so | grep -E "libxrt_coreutil|libryzen_mm"` — the resolved path settles it alone: `/opt/xilinx/xrt/lib` = good, `.../voe/lib` = this bug.
|
||||||
|
2. `XILINX_XRT=/opt/xilinx/xrt /opt/xilinx/xrt/bin/xrt-smi examine` → `RyzenAI-npu5 | aie2p | 6x8` + `NPU Firmware Version`. Needs `libboost_filesystem1.83.0`, `libboost_program_options1.83.0`, `libuuid1` (26.04 names; the 24.04 docs' 1.74.0 does not exist). A "No such file or directory" for a file that exists = missing interpreter/dep, not a missing binary.
|
||||||
|
3. `strace -f -e trace=openat` — `/dev/accel/accel0` never opened ⇒ failure is pre-device ⇒ loader-level.
|
||||||
|
4. `gdb -q -batch` with `catch throw` + `bt 10` loop → names `shim_loader` / `Allocator::Alloc`.
|
||||||
|
|
||||||
|
Known dead ends (one run each, then stop): `--ulimit memlock`; XRT INI verbosity; `DD_LOG_LEVEL` / `RYZENAI_TRACE` / `RYZENAI_EP_TRACING` (print nothing before the throw); byte-scanning XRT `.so` for PCI IDs (ambiguous); libboost presence for the OGA path (only `xrt-smi` needs it); flag-by-flag container matrices (`--memory`, `--security-opt`, `--name`, extra mounts — all innocent).
|
||||||
|
|
||||||
|
## Verified facts on this stack
|
||||||
|
|
||||||
|
- Vendor XRT 2.25.37 userspace (`dpkg-deb -x`, never dpkg-installed) works against Ubuntu's in-tree `amdxdna` 0.7.0; no DKMS module needed. PCI `1022:17f0` rev 11 = XDNA2, reported as `aie2p 6x8`.
|
||||||
|
- AMD's `quicktest` passing does **not** prove NPU use (it can silently run on CPU); trust `xrt-smi` + logits, not "it didn't crash".
|
||||||
|
- `/dev/accel/accel0` is `root:render 0660`; rootless podman cannot read it (userns drops the gid; `keep-groups`, `keep-id:gid=990`, `--group-add render` all fail) → rootful container, or a udev rule like the GPU nodes'.
|
||||||
|
- OGA 0.14 (RAI 1.8) contract: `GeneratorParams` has only `set_search_options` (no `input_ids`, no `model.generate`); feed with `Generator.append_tokens(ids)`, loop `generate_next_token()`, read `get_sequence(0)`; `max_length` counts **prompt + generated**. Full Fusion (`*_npu_4K`) caps input+output at 4096.
|
||||||
|
- Validate content, not just exit code: `np.argsort(logits[0])[-8:]`. A real logits tensor after `append_tokens` proves the graph executed (prefill is folded into step 1, so a sub-millisecond first step + a 1-token sequence means it did not); if argmax is the sequence terminator (`
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: ryzenai-npu-oga-linux-run
|
||||||
|
description: "Run an AMD vendor-compiled Ryzen AI (XDNA2) model — OGA/onnxruntime-genai — on a Linux Ryzen AI host with the in-tree amdxdna driver, without installing the vendor driver or touching the host driver; includes the triage for ORT \"Exception during initialization: Generic Failure\"."
|
||||||
|
---
|
||||||
|
|
||||||
|
Run a vendor-compiled Ryzen AI (XDNA2) model on Linux with the **in-tree** amdxdna driver: no vendor kernel module, no `dpkg -i`, host driver untouched.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
- Host: Strix Point / Strix Halo / Krackan (PCI `1022:17f0`), in-tree `amdxdna` bound to the NPU function (`readlink /sys/bus/pci/devices/<bdf>/driver` → `amdxdna`), `/dev/accel/accel0` present.
|
||||||
|
- `ryzen_ai-<ver>.tgz` installed into a venv (`install_ryzen_ai.sh -a yes -p <venv>`; it hard-codes `python3.12` — put a uv-managed 3.12 **bin dir** on PATH, never a symlink: a symlinked shim breaks venv creation with `No module named 'encodings'`).
|
||||||
|
- Model artifact MUST match the RAI version: `<name>_rai_<ver>_npu_<ctx>`. Other versions load and emit garbage.
|
||||||
|
- Vendor XRT userspace: `dpkg-deb -x` the debs from `RAI_<ver>_Linux_NPU_XRT.zip` (base + npu + `xrt_plugin…amdxdna.deb` — the plugin is what ships `libxrt_driver_xdna.so.*`) into a host dir, bind-mount it at `/opt/xilinx/xrt`.
|
||||||
|
|
||||||
|
## Container run line (the part that is easy to get wrong)
|
||||||
|
```
|
||||||
|
--device /dev/accel/accel0 # root:render 0660 → rootful, or a world-writable udev rule
|
||||||
|
-v <xrt-root>/opt/xilinx/xrt:/opt/xilinx/xrt:ro
|
||||||
|
-e RYZEN_AI_INSTALLATION_PATH=<venv> -e XILINX_XRT=/opt/xilinx/xrt
|
||||||
|
-e LD_LIBRARY_PATH=/opt/xilinx/xrt/lib:<venv>/onnxruntime/lib:<venv>/deployment/lib
|
||||||
|
# cwd MUST be <venv>/deployment/lib — the RyzenAI EP is dlopen'd by bare name and the
|
||||||
|
# only place ORT looks is the working directory.
|
||||||
|
# Activate the venv explicitly; a login shell (`bash -lc`) re-sources the profile and
|
||||||
|
# can put a numpy-less base python first on PATH.
|
||||||
|
```
|
||||||
|
|
||||||
|
## LAW: mounting XRT is not enough — `/opt/xilinx/xrt/lib` must be ON `LD_LIBRARY_PATH`
|
||||||
|
The RyzenAI EP reaches the XDNA shim through XRT's own loader. With the tree bind-mounted
|
||||||
|
but off the loader path, `xrt::device(0)` throws in `xrt_core::shim_loader`, the RMM
|
||||||
|
allocator fails next, and ORT flattens all of it into
|
||||||
|
|
||||||
|
```
|
||||||
|
[E:onnxruntime:, inference_session.cc:2811] Exception during initialization: Generic Failure
|
||||||
|
```
|
||||||
|
|
||||||
|
Signature that distinguishes this from a real device/driver fault: fails in ~2 ms, **never
|
||||||
|
opens `/dev/accel/accel0`**, nothing in `dmesg`/`journalctl -k`, no XRT log (the INI logger
|
||||||
|
is not built into the vendor build — `XRT_INI_PATH` + `[Common] verbosity` writes nothing).
|
||||||
|
|
||||||
|
## Triage recipe for "Generic Failure" (in this order — each step is one call)
|
||||||
|
1. `strace -f -e trace=openat` → does it open `/dev/accel/accel0`? If **no**, the failure is pre-device: loader/shim, not the driver. Also note the last file it opens (`genai_config.json` = config parsed, then provider setup fails).
|
||||||
|
2. `gdb -q -batch -x cmds --args python driver.py` with `catch throw` + `bt 10` + `continue`. Frames `xrt_core::shim_loader::shim_loader` / `get_userpf_device` / `xrt::device::device` then `ryzenai::Allocator::Alloc` ⇒ the shim never loaded ⇒ fix `LD_LIBRARY_PATH`.
|
||||||
|
3. Prove it with a two-arm run in ONE container (toggle only the suspect) before writing the law down. Both arms passing ⇒ that variable was never the cause.
|
||||||
|
4. Ground truth on device visibility: `xrt-smi examine` → `RyzenAI-npu5 | aie2p | 6x8` + `NPU Firmware Version`. If xrt-smi dies on `libboost_filesystem.so.1.83.0`, install `libboost_filesystem1.83.0`+`libboost_program_options1.83.0` — **boost is only needed by xrt-smi, not by the OGA/EP path** (verified by purging it and still loading the model).
|
||||||
|
5. `voe` in site-packages is a *compiler* toolkit (passes/patterns), not a runtime — swapping `LD_LIBRARY_PATH` to `voe/lib` fixes nothing.
|
||||||
|
6. Byte-scanning XRT `.so` files for supported PCI IDs is inconclusive (2-byte ID patterns collide constantly) — don't bother.
|
||||||
|
|
||||||
|
## Distrust "it ran" on this stack
|
||||||
|
- Vendor `quicktest` can print success while computing on CPU (missing peano lib ⇒ silent CPU fallback). Prove NPU residency per run: sample `/sys/class/accel/accel0/device/mem_usage` (needs root) during generation, or read the EP's `Actually running on NPU` line.
|
||||||
|
- Never report request #1 after a boot as a rate — first pass includes graph/init cost.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: ryzenai-npu-oga-linux-spike
|
||||||
|
description: "Run an AMD-precompiled AMD Ryzen AI (XDNA2) ONNX-GenAI model on a Linux Strix Halo host from public artifacts: staging, rootless NPU device-access wall, container recipe, and the NuTTS-air speech-token contract (65,536-code vocab, 50 codes/s real-time line)."
|
||||||
|
---
|
||||||
|
|
||||||
|
Run an AMD-precompiled XDNA2 model (OGA / onnxruntime-genai) on a Linux Strix Halo host (Dankclaw-class) without installing the vendor stack on the host.
|
||||||
|
|
||||||
|
## Where the bits actually live (both public, no AMD account)
|
||||||
|
|
||||||
|
- Runtime tarball: `https://download.amd.com/opendownload/RyzenAI/1.8.0/ryzen_ai-1.8.0.tgz` (~7.28 GiB). Verify with a ranged GET — first bytes `1f 8b 08`.
|
||||||
|
- NPU driver/XRT zip: `https://download.amd.com/opendownload/RyzenAI/Driver/RAI_1.8_Linux_NPU_XRT.zip` (90 MB).
|
||||||
|
- Dead ends (404, do not re-probe): `download.amd.com/driver/linux/RAI_1.8_Linux_NPU_XRT.zip`, `.../opendownload/RyzenAI/ryzen_ai-1.8.0.tgz`, `www.xilinx.com/bin/public/openDownload?filename=...` (301 → 404). The login-walled EULA *form* is not the gate; the `opendownload` tree is the real file host.
|
||||||
|
|
||||||
|
Tarball has 30 top-level entries: cp312/linux_x86_64 wheels (`onnxruntime_genai_ryzenai-0.14.0`, `onnxruntime_vitisai-1.27.0`, `vitis_mllib`, `flexml*`, `device_essentials_strx`, `model_generate`, `voe-1.8.0`), plus `deployment.tar.gz` (contains `lib/libonnxruntime-genai.so`, `lib/libonnxruntime_providers_ryzenai.so`, AIE compiler libs), `onnxruntime.tar.gz`, `quicktest.tar.gz` (VitisAI-EP install validator), `LLM.tar.gz` (text-only OGA examples: `run_model.py`, `tokenizer_factory.py`), `install_ryzen_ai.sh`. `voe-4.0-linux_x86_64.tar.gz` is only `vaip_config.json` — no TTS xclbins. **AMD ships no TTS runner and no TTS example.**
|
||||||
|
|
||||||
|
## Host rules
|
||||||
|
|
||||||
|
- **Never `dpkg -i` the vendor XRT.** `dpkg-deb -x <deb> <dir>` and bind-mount `<dir>/opt/xilinx/xrt` at the same path in the container. The plugin deb would install a DKMS `amdxdna` that replaces the distro in-tree driver; the in-tree driver on Ubuntu 26.04 is `amdxdna 0.7.0` and already loads `amdnpu/17f0_11/npu_7.sbin` at boot (`dmesg | grep amdxdna`).
|
||||||
|
- **Rootless containers cannot read `/dev/accel/accel0`** (`root:render 0660`): the host gid never enters the userns gid map, so `--device` yields `nobody:nogroup`. Verified failing 2026-10-19: `--group-add keep-groups`, `--userns=keep-id`, `--userns=keep-id:gid=990` (process gets gid 990, node still unreadable), `--group-add render`, `--group-add gid:990` (both error). Options: run rootful (`sudo podman`, separate storage → rebuild the image rootful, ~1 min), or add a world-writable udev rule for `accel0` (the fleet already does exactly this for `kfd`/`renderD*` in `99-comfyui-gpu-access.rules`, whose header explains why: systemd user-unit spawns carry no supplementary groups).
|
||||||
|
- **Rootless image build trap:** the vendor installer's `tar -xf` preserves recorded uids (95777 etc.) and aborts under `set -e` with `Cannot change ownership ... Invalid argument`, silently skipping the `deployment`/`onnxruntime` extraction. Patch it in the Containerfile: `sed -i 's/tar -xf/tar --no-same-owner -xf/g' install_ryzen_ai.sh`.
|
||||||
|
- Installer usage: `./install_ryzen_ai.sh -a yes -p /opt/rai -c /opt/rai` (python **3.12** required; base `python:3.12-slim`; needs `linux-libc-dev zip pciutils patch libgomp1`). It builds a venv from the local wheels, reinstalls `onnxruntime-vitisai` then `--no-deps onnxruntime-genai-ryzenai`, installs `requirements_extras.txt` (transformers 4.57.6), patches `bin/activate` (sets `RYZEN_AI_INSTALLATION_PATH=$VIRTUAL_ENV` + `LD_LIBRARY_PATH` into site-packages), and untars `LLM/deployment/onnxruntime/voe/quicktest` into the cpp path.
|
||||||
|
- `xrt-smi` in a container needs `libboost-filesystem1.83.0` + `libboost-program-options1.83.0` (**hyphenated** Ubuntu names) and `apt update` first; boost is **only** needed by `xrt-smi`, never by the OGA/EP path.
|
||||||
|
|
||||||
|
## HF artifact staging
|
||||||
|
|
||||||
|
- Gated (401 on API, 403 on git) for a plain account: `neuphonic/neucodec` (the **only** NeuCodec encoder), `neuphonic/neucodec-onnx-decoder`, `neuphonic/neucodec-onnx-decoder-int8`, `neuphonic/neutts-air`. `amd/neutts-air_rai_1.8.0_npu_4K` is public. Public mirrors of the codec exist (`eugenehp/neucodec` has the full 1.16 GB `pytorch_model.bin`) — treat a mirror of a gated release as a licensing decision for the operator, not a workaround to take silently.
|
||||||
|
- Gated-repo loader trap: `HF_HUB_OFFLINE=1` + `HF_HOME` pointed at a cache laid out as `hub/models--<org>--<repo>/snapshots/<rev>/…` works, but `NeuCodec._from_pretrained` **asserts the repo id** is in `["neuphonic/neucodec", "neuphonic/distill-neucodec"]` — a local directory path is refused outright, so pass the repo id and satisfy it from the cache.
|
||||||
|
- `github.com/neuphonic/neutts` is public and ships `samples/{dave,emily,greta,jo}.{pt,txt,wav}` = **pre-encoded reference codes + transcripts** → a spike needs zero gated bytes.
|
||||||
|
- Auth without leaking the token into `ps`: build a 600-perm credential file with a shell builtin and point git at it —
|
||||||
|
`umask 077; printf 'https://oauth2:%s@huggingface.co\n' "$(cat ~/.cache/huggingface/token)" > ~/.git-credentials-hf`
|
||||||
|
then `git -c credential.helper="store --file ~/.git-credentials-hf" clone ...`. Never `git clone https://oauth2:$TOKEN@...`.
|
||||||
|
|
||||||
|
## NuTTS-air on XDNA2 (the model-side contract)
|
||||||
|
|
||||||
|
- The package is an OGA model dir: `genai_config.json` (`type: qwen2`, 24 layers, hidden 896, 14 heads / 2 KV, ctx 32768, `vocab_size` **217,652**, `provider_options: [{"RyzenAI": {"hybrid_opt_token_backend": "npu"}}]`), plus `dd_metastate_*` AIE artifacts and `model.onnx` + `model.onnx.data`.
|
||||||
|
- **Speech tokens live in the text vocab, and the codebook is 65,536 — not 10,001.** `tokenizer.json` `added_tokens` holds exactly 65,536 `<|speech_N|>` entries, ids **151,671–217,206** contiguous, over a base BPE vocab of 151,643; specials are 151,644–151,670 (`<|SPEECH_GENERATION_END|>` = 151,670). The vendor template maps a codec index straight to its id: `"".join(f"<|speech_{i}|>" for i in ref_codes)`. An encoded reference really does contain codes in the 60k range, so a 10k assumption silently truncates the space.
|
||||||
|
- The graph's own `eos_token_id` is `[151645, 151643]` (the **chat turn** stop) and `search.eos_token_ids` is unset — pass 151,670 as an extra eos or speech ends after one token.
|
||||||
|
- **Real-time line = 50 speech codes/s** — NeuCodec `sample_rate 24000 / hop_length 480`, measured exactly (49.96–50.00 across three 20–25 s references). Judge any NPU/iGPU TTS number against 50. (Qwen3-TTS, the fleet's deployed arm, is 12.5 codes/s — do not conflate the two bars.)
|
||||||
|
- Reference encode is cheap and cacheable: `librosa.load(sr=16000, mono=True)` → `[1,1,T]` float tensor → `codec.encode_code(audio_or_path=…)` (mirrors `NeuTTS.encode_reference`); CPU encoder RTF 0.28–0.38 on 8 threads. `neucodec` needs `torchtune` (`RotaryPositionalEmbeddings`) — install both `--no-deps --target <dir>` so a second torch never shadows the venv's.
|
||||||
|
- Build the prompt by *importing* the vendor function, never by copying the template:
|
||||||
|
`sys.path.insert(0, <neutts repo>); from neutts.neutts import NeuTTS`, then
|
||||||
|
`NeuTTS._apply_chat_template(SimpleNamespace(tokenizer=hf_tok, input_format="text"), ref_codes, ref_text, text, None)` — works on a duck-typed `self`, so no backbone/codec load. Template: `user\n<|TEXT_REPLACE|>\nassistant\n<|SPEECH_REPLACE|>`; splice `TEXT_PROMPT_START + encode(f"{ref_text} {text}") + TEXT_PROMPT_END` at TEXT_REPLACE, then truncate at SPEECH_REPLACE and append `[SPEECH_GENERATION_START] + encode("".join(f"<|speech_{i}|>"))`.
|
||||||
|
- Voice identity = reference codes + transcript (request-time conditioning). No trained artifact exists to preserve; packs holding only `reference.wav` require the gated encoder once, offline — **and** a transcript, which a voice pack does not carry (`manifest.json` holds provenance/crop geometry only) and a host without ASR cannot produce.
|
||||||
|
- Full-Fusion input ceiling: 4096 tokens **total**, input + output.
|
||||||
|
|
||||||
|
## Status caveat (2026-10-19)
|
||||||
|
|
||||||
|
Staged artifacts, image build, device-access matrix, prompt contract, reference pre-encode:
|
||||||
|
**done and reproducible.** Token generation on the NPU: **NO-GO, and the loader is the
|
||||||
|
wall.** The device is healthy (`xrt-smi validate` PASSED incl. `throughput`,
|
||||||
|
`RyzenAI-npu5 / aie2p / 6×8`, `pyxrt.device(0)` constructs), and `strace` shows the
|
||||||
|
failing load reading `genai_config.json`, opening `model.onnx`, loading
|
||||||
|
`libxrt_driver_xdna.so.2` and opening `/dev/accel/accel0` with successful GEM ioctls —
|
||||||
|
then the RyzenAI EP **declines the graph before touching any `dd_metastate_*`**, silently:
|
||||||
|
`GENAI_ORT_VERBOSE_LOGGING`, `GENAI_LOG_ORT_LIB`, `set_log_options`,
|
||||||
|
`set_log_callback(callable)`, `RYZENAI_EP_TRACING`, `RYZENAI_LLM_PROFILE_INIT` all emit
|
||||||
|
zero lines. Hardlinked mirror dirs of the model tree were ruled out as shadowing. One run
|
||||||
|
loaded and produced finite logits; byte-identical runs never reproduced it. **No token rate
|
||||||
|
was ever measured, so quote none**, and remember `quicktest` success ≠ NPU residency (that
|
||||||
|
build falls back to CPU silently — prove residency via
|
||||||
|
`/sys/class/accel/accel0/device/mem_usage`).
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
name: ryzenai-oga-model-dir-and-eos-override
|
||||||
|
description: "Debug OGA on XDNA2 when the RyzenAI EP never claims the If-rooted graph (shadow model dirs, bind-mounted configs, missing device), or a TTS/LLM graph stops on the wrong eos token."
|
||||||
|
---
|
||||||
|
|
||||||
|
# OGA on XDNA2: graph not claimed, or generation stops at once
|
||||||
|
|
||||||
|
Two independent failure families in the AMD `onnxruntime-genai` (OGA) + RyzenAI EP stack on
|
||||||
|
Linux XDNA2 (Strix Halo / Ryzen AI Max). Diagnose them in this order — they are separate
|
||||||
|
gates and each has its own instrument.
|
||||||
|
|
||||||
|
## Gate 0 — is the NPU path alive at all?
|
||||||
|
|
||||||
|
Run the vendor's own instrument before touching anything else:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/rai/quicktest && RYZEN_AI_INSTALLATION_PATH=/opt/rai python quicktest.py
|
||||||
|
# want: "Setting environment for STX/KRK" ... "Session successfully initialized" ... "Test Finished"
|
||||||
|
```
|
||||||
|
|
||||||
|
It finds the device by shelling out to `lspci -nn` and grepping `1022:17f0`, so it also
|
||||||
|
proves `lspci` is present in the container. Pass ⇒ the in-tree `amdxdna` driver and the
|
||||||
|
extracted userspace pair correctly; stop blaming the host.
|
||||||
|
|
||||||
|
Separate **XRT enumeration** from **OGA's device probe** — they are not the same check:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys; sys.path.insert(0, "/opt/xilinx/xrt/python")
|
||||||
|
import pyxrt; dev = pyxrt.device(0) # constructs => XRT sees the device
|
||||||
|
```
|
||||||
|
|
||||||
|
`pyxrt.device(0)` succeeding while OGA says `No RyzenAI devices detected` means the
|
||||||
|
refusal is OGA's own predicate, inside `libonnxruntime-genai.so` (the string lives there,
|
||||||
|
next to `RyzenAI`, `max_length_for_kv_cache`, `library_path`). EP-side env levers worth
|
||||||
|
pulling: `RYZENAI_EP_TRACING`, `RYZENAI_EP_PERFORMANCE_COUNTERS`, `RYZENAI_LLM_ASYNC_INIT`,
|
||||||
|
`RYZENAI_LLM_PROFILE_INIT`.
|
||||||
|
|
||||||
|
## Gate 1 — `invalid model … Node (If) cond_full`
|
||||||
|
|
||||||
|
This is **ONNX Runtime verifying the raw graph**, which happens when no provider claimed
|
||||||
|
it: the EP registered nothing and the CPU provider was left holding an `If` whose branches
|
||||||
|
mismatch. It is never a corrupted model.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LD_DEBUG=files LD_DEBUG_OUTPUT=/tmp/ld python loadprobe.py
|
||||||
|
grep -a "calling init:.*ryzenai\|undefined symbol" /tmp/ld.*
|
||||||
|
# observed: libonnxruntime_providers_ryzenai.so loaded, then
|
||||||
|
# symbol lookup error: undefined symbol: GetProvider (fatal)
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetProvider` is not a dynamic symbol in the ORT cores, the shared-provider stub, or the
|
||||||
|
EP (`nm -D` shows neither defines nor wants it), so the failure is registration, not an
|
||||||
|
ELF mismatch. Confirm the cores are identical before chasing versions: both shipped
|
||||||
|
`libonnxruntime.so.1.27.0` copies hash equal.
|
||||||
|
|
||||||
|
Separate the two gates with an explicit global-scope load before importing OGA:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import ctypes
|
||||||
|
ctypes.CDLL("/opt/rai/deployment/lib/libonnxruntime_providers_ryzenai.so",
|
||||||
|
mode=ctypes.RTLD_GLOBAL)
|
||||||
|
import onnxruntime_genai as og
|
||||||
|
og.Model(MODEL) # registration fixed => error MOVES to "No RyzenAI devices detected"
|
||||||
|
```
|
||||||
|
|
||||||
|
An error that **moves** proves you cleared one gate and exposes the next. Silence in the
|
||||||
|
first form means the provider list was empty.
|
||||||
|
|
||||||
|
### Dead ends (measured, do not retry)
|
||||||
|
|
||||||
|
- **Shadow model dirs never work.** Hardlink mirror, symlink mirror, and even a
|
||||||
|
read-only single-file bind mount of a patched `genai_config.json` over the export's own
|
||||||
|
`genai_config.json` all produce the same `If` rejection. The graph is only claimable
|
||||||
|
when loaded from the export's own directory, and there is no config-path override in
|
||||||
|
`0.14.0-dev`: the only env names in the shipped binaries are `GENAI_LOG_ORT_LIB`,
|
||||||
|
`GENAI_ORT_VERBOSE_LOGGING`, `OG_ORT_LIB`. Carrying a config change therefore means
|
||||||
|
either editing the download in place or a **single-file bind mount over the real dir**
|
||||||
|
(`-v file.json:/spike/<model>/genai_config.json:ro`) — and if the EP still refuses, the
|
||||||
|
gate is not the config.
|
||||||
|
- **Bisect the image before believing "environment drift".** Run the same load probe
|
||||||
|
against every local tag; three tags failing identically rules the image out.
|
||||||
|
- **Rule out host state with instruments, not memory:** `fuser -v /dev/accel/accel0`,
|
||||||
|
`dmesg | grep -i xdna`, `stat -c "%i %y"` on the model files, `df -h`, `free -g`.
|
||||||
|
- `sysfs` class is `accel`, not `misc`: glob `/sys/class/accel*/*`. An empty
|
||||||
|
`/sys/class/misc/accel*` is your glob, not a missing device.
|
||||||
|
- `GENAI_ORT_VERBOSE_LOGGING=1` and `og.set_log_options(...)` produce nothing useful in
|
||||||
|
this build; `set_log_options` is a pybind builtin (no `inspect.signature`) and rejects
|
||||||
|
`severity` as an int. Don't burn turns trying to make it talk — use `LD_DEBUG`.
|
||||||
|
- `strace` is absent from the RAI image and `apt` may have no mirror: `LD_DEBUG=files` is
|
||||||
|
the loader tracer that needs nothing installed.
|
||||||
|
|
||||||
|
## Gate 2 — generation stops after one token
|
||||||
|
|
||||||
|
OGA reads its stop list from the **model** section: `model.eos_token_id`. Chat exports
|
||||||
|
carry the chat-turn-close id (e.g. 151645) there, so the model emits it first and the
|
||||||
|
speech/tool budget is never spent — while `search` has **no** eos key: writing
|
||||||
|
`search.eos_token_id` is a parse refusal (`Unknown value "eos_token_id"`), not a no-op.
|
||||||
|
|
||||||
|
- Fix: rewrite `model.eos_token_id` to only the task's own end token (TTS: the
|
||||||
|
speech-generation-end id), or pass it through `set_search_options` if the build accepts
|
||||||
|
that key name.
|
||||||
|
- A sub-millisecond `generate_next_token()` **with finite, high-confidence logits** means
|
||||||
|
the graph ran and chose a stop token — it is not evidence of a CPU fallback.
|
||||||
|
- Verify a token-range census against the export's `added_tokens.json`, not against a
|
||||||
|
guessed vocabulary size: NeuCodec codes reach 65,536 entries (ids 151671…217206), and a
|
||||||
|
census bounded at 10k silently miscounts every code as a text token.
|
||||||
|
- A hardlinked run dir is a loaded gun: `open(dst, "w")` on a hardlink truncates the
|
||||||
|
**original** through the shared inode. `os.unlink` the link before writing a patched
|
||||||
|
file. Also skip `.git/` and mirror directories when walking an HF checkout.
|
||||||
|
|
||||||
|
## Rate measurement
|
||||||
|
|
||||||
|
Once a load succeeds, measure at ≥300 steps with a host-side `%CPU` sampler beside the
|
||||||
|
container (`ps -C python -o pid=,pcpu=,rss=` every 2 s): a device-bound decode waits on
|
||||||
|
`accel0` and stays under one core, a silent CPU fallback pegs at least one. Never report
|
||||||
|
request #1 after a boot — graph capture makes it a warmup artifact.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
name: ryzenai-venv-dependency-triangle
|
||||||
|
description: "Debug the torchao/torchtune/transformers import triangle inside an AMD Ryzen AI (ryzen_ai-*.tgz) venv under torch 2.6, including the masked \"cannot import name\" failures and the port-the-one-symbol escape."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ryzen AI venv dependency triangle (torch 2.6 era)
|
||||||
|
|
||||||
|
Symptom that starts it all — and it never names the culprit:
|
||||||
|
|
||||||
|
```
|
||||||
|
ImportError: cannot import name 'HubertModel' from 'transformers'
|
||||||
|
```
|
||||||
|
|
||||||
|
The named symbol is healthy. `import torchao` alone reproduces the failure; `import transformers` alone does not. transformers imports its **torchao integration** whenever the real modelling path loads, and the failure is re-raised two frames deep by the lazy loader as a missing name.
|
||||||
|
|
||||||
|
## Instruments
|
||||||
|
|
||||||
|
Never conclude from `grep -c Symbol transformers/__init__.py` — 4.5x generates its lazy `__init__`, so an absent literal proves nothing. Get the real exception:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import traceback, torch, transformers
|
||||||
|
try:
|
||||||
|
__import__("transformers.models.hubert.modeling_hubert")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
```
|
||||||
|
|
||||||
|
That prints the truth, e.g. `AttributeError: module 'torchao.quantization' has no attribute 'Float8WeightOnlyConfig'` from `transformers/modeling_layers.py`.
|
||||||
|
|
||||||
|
Also probe the order matrix (`import X; from transformers import Y` for each suspect): only the poisoned order fails, which isolates the culprit without reading vendor code.
|
||||||
|
|
||||||
|
## The triangle (measured 2026-10-19, ryzen_ai 1.8.0, torch 2.6.0+cpu, transformers 4.57.6)
|
||||||
|
|
||||||
|
| Option | Result |
|
||||||
|
|---|---|
|
||||||
|
| torchao absent | `from transformers import HubertModel` -> OK |
|
||||||
|
| torchao 0.7.0+cpu | `ImportError: Float8WeightOnlyConfig` (masked as missing symbol) |
|
||||||
|
| torchao 0.8.0+cpu | imports (warns `No module named 'triton'`), still lacks `Float8WeightOnlyConfig` |
|
||||||
|
| torchao >= 0.9 | has the symbol, requires torch >= 2.7 -> impossible in this venv |
|
||||||
|
| torchtune, ANY version (0.6.1 … 0.1.1) | guard `torchao not installed`, or `pyarrow requires NumPy 2.0 or newer, found 1.26.4` |
|
||||||
|
|
||||||
|
Two independent dead ends: torchtune's package init pulls `datasets` -> **the pyarrow AMD ships in its own venv refuses that venv's own numpy 1.26.4**. So `import torchtune` is structurally dead, and torchao (the only thing that satisfies its guard) breaks transformers. **Never "just upgrade" torch/numpy: the vendor's ONNX runtime and torch build are what the NPU EP is loaded against.**
|
||||||
|
|
||||||
|
## Escape: port the one symbol, pin both absent
|
||||||
|
|
||||||
|
Ask what the third-party package is actually used for before fighting the resolver. In `neucodec` it was one symbol (`RotaryPositionalEmbeddings`, ~40 lines, copied verbatim from `torchtune/modules/position_embeddings.py`, BSD-3) used by two files. Apply it in the image with a transform that **asserts** each import line exists, so a vendor update fails the build instead of shipping a half-patched package:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
COPY patches/rope_local.py /opt/patch/rope_local.py
|
||||||
|
RUN python - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
from importlib.util import find_spec
|
||||||
|
pkg = pathlib.Path(find_spec("neucodec").origin).parent # find_spec: importing it is what is impossible yet
|
||||||
|
(pkg / "rope_local.py").write_text(pathlib.Path("/opt/patch/rope_local.py").read_text())
|
||||||
|
old, new = "from torchtune.modules import RotaryPositionalEmbeddings", "from .rope_local import RotaryPositionalEmbeddings"
|
||||||
|
for name in ("bs_roformer5.py", "codec_decoder_vocos.py"):
|
||||||
|
p = pkg / name; s = p.read_text()
|
||||||
|
assert old in s, name + ": import line moved - re-read the vendor source"
|
||||||
|
p.write_text(s.replace(old, new))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Gate both absences at build time so nobody re-adds them and inherits a three-week-later mystery:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN ... import-check \
|
||||||
|
&& python -c "import importlib.util as u; assert u.find_spec('torchao') is None; assert u.find_spec('torchtune') is None"
|
||||||
|
```
|
||||||
|
|
||||||
|
Prove a ported numeric kernel with the vendor's own fixtures, not with faith: `neucodec` ships `samples/jo.wav` *and* its own encoding `samples/jo.pt`, so re-encoding the WAV and comparing integer codes is a pass/fail correctness test of the RoPE port.
|
||||||
|
|
||||||
|
## Rules of thumb
|
||||||
|
|
||||||
|
- `--no-deps` for anything adjacent to torch/onnxruntime/numpy; check the vendor pins by reading them out of the venv (`python -c "import torch; print(torch.__version__)"`), never from docs.
|
||||||
|
- A `--dry-run` install that resolves without touching torch/numpy/onnxruntime is the licence to install *with* deps.
|
||||||
|
- `uv pip install --target DIR` / a nested venv does **not** layer over a parent venv: a venv created from `/opt/rai/bin/python` resolves "system site-packages" to the base interpreter, and uv's resolver ignores `.pth` paths and will pull a second numpy that breaks the vendor C extensions.
|
||||||
|
- Ephemeral `podman run --rm` probes discard everything you installed in them — build a throwaway tag (truncate the Containerfile before the failing step) instead of re-hypothesising.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: sigterm-kill-test-stub
|
||||||
|
description: "Pin \"code kills its child process\" behavior with a bash stub player: make the stub SIGTERM-reactive via sleep&wait, log KILLED via trap; diagnose deferred-trap failures."
|
||||||
|
---
|
||||||
|
|
||||||
|
Use when a test must prove production code sends SIGTERM to a spawned child (player, worker, server stub) and the test asserts the kill happened — or when such a test mysteriously fails with `KILLED` missing and wall time equal to the stub's sleep duration.
|
||||||
|
|
||||||
|
## The trap-during-sleep gotcha
|
||||||
|
|
||||||
|
A bash stub that sleeps in the **foreground** does NOT run its TERM trap when signalled: bash defers traps until the current foreground command completes. The stub dies (or logs) only after the sleep finishes — long after the test read the evidence file.
|
||||||
|
|
||||||
|
Symptoms:
|
||||||
|
- Assertion on a `KILLED` marker line fails although production code demonstrably calls `child.kill("SIGTERM")`.
|
||||||
|
- Test wall time equals the stub's full sleep (the kill "had no effect").
|
||||||
|
|
||||||
|
## Fix: reactive stub skeleton
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" >> "$STUB_LOG" # call record
|
||||||
|
trap 'printf "KILLED\n" >> "$STUB_LOG"; exit 143' TERM # BEFORE the sleep
|
||||||
|
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$COPY" ;; esac; done
|
||||||
|
sleep "${STUB_HOLD:-0.1}" & # background sleep…
|
||||||
|
wait $! # …wait is interruptible
|
||||||
|
printf 'DONE\n' >> "$STUB_LOG"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `sleep N & wait $!` is the idiom: a signal makes builtin `wait` return immediately, the trap runs at once, `exit` inside the trap prevents fallthrough to `DONE`.
|
||||||
|
- Tune duration per test via the env knob (`STUB_HOLD=5`) so the child is provably still alive when the kill is triggered.
|
||||||
|
- Put the trap BEFORE any long command; a trap registered after the sleep never guards it.
|
||||||
|
|
||||||
|
## Harness wiring (black-box driver pattern)
|
||||||
|
|
||||||
|
- Scenario: start the long work → settle (>spawn latency, <hold) → trigger the killer (shutdown event, second invocation, `/stop`) → settle → read log.
|
||||||
|
- Expose the stub log lines on the driver's stdout (`LOG <line>` per line) so tests grep output, not files: black-box seam stays output-only.
|
||||||
|
- Assert `KILLED` present; for interrupt-semantics also count (`kills -eq 1`) and total call lines (`PLAYS 3` = call, KILLED, call).
|
||||||
|
|
||||||
|
## Orphan hygiene
|
||||||
|
|
||||||
|
Second child still sleeping when the driver exits: spawn with `stdio: "ignore"` so the orphan holds no capture pipe — it SIGPIPEs/quiets at teardown; do not lengthen tests waiting for it.
|
||||||
|
|
||||||
|
## Verify before trusting
|
||||||
|
|
||||||
|
Run the pair separately: kill-tests green while a byte-equality test (`cp` of the payload before sleep) stays green — the reactive rewrite must not drop the copy step (it sits between trap and sleep).
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: strix-halo-npu-readiness-census
|
name: strix-halo-npu-readiness-census
|
||||||
description: "Engine-agnostic census of whether an AMD Strix Halo / Ryzen AI host can run anything on its XDNA2 NPU: driver bind, firmware, device perms, IOMMU, distro XRT, missing AIE compiler, fabric clock, RAM headroom."
|
description: "Engine-agnostic census of whether an AMD Strix Halo / Ryzen AI host can run anything on its XDNA2 NPU: driver bind, firmware, device perms, IOMMU, distro XRT, missing AIE compiler, fabric clock, RAM headroom — plus the strace test that separates a dead device from a runtime that declines the graph."
|
||||||
---
|
---
|
||||||
|
|
||||||
Use when asked "can we run <engine/model> on the Strix Halo / Ryzen AI NPU?" for a runtime that is **not** halogen (halogen has its own gate + vendor catalog: use `halogen-npu-feasibility-census` for that). This answers only: is the *host* able, and what is missing.
|
Use when asked "can we run <engine/model> on the Strix Halo / Ryzen AI NPU?" for a runtime that is **not** halogen (halogen has its own gate + vendor catalog: use `halogen-npu-feasibility-census` for that). This answers only: is the *host* able, and what is missing.
|
||||||
|
|
||||||
## Why it is usually the wrong door first
|
## Why it is usually the wrong door first
|
||||||
|
|
||||||
XDNA2 executes **precompiled AIE partition programs** loaded through XRT → `amdxdna`. There is no generic tensor dispatch: an engine built on ggml/llama.cpp (or any GGUF loader) cannot "enable" the NPU — the port must be a re-export + vendor compile, not a flag. So pair this census with the accelerator the same engine *can* select by build flag (Vulkan/HIP on the gfx1151 iGPU) before proposing NPU work.
|
XDNA2 executes **precompiled AIE partition programs** loaded through XRT → `amdxdna`. There is no generic tensor dispatch: an engine built on ggml/llama.cpp (or any GGUF loader) cannot "enable" the NPU — the port must be a re-export + vendor compile, not a flag. So pair this census with the accelerator the same engine *can* select by build flag (Vulkan/HIP on the gfx1151 iGPU) before proposing NPU work. On Dankclaw the iGPU flag was ours to flip all along (`-DGGML_VULKAN=OFF` written out in the fleet's own engine Containerfile) — check the engine's own build file before treating the NPU as the only hardware door.
|
||||||
|
|
||||||
## Census (all read-only, over ssh from the operator box)
|
## Census (all read-only, over ssh from the operator box)
|
||||||
|
|
||||||
@@ -37,8 +37,20 @@ nproc; free -g | head -2 # RAM headroom + t
|
|||||||
- No `ryzenai-compile`/ONNX Ryzen AI EP ⇒ nothing on the host can produce a partition program. That is the enablement delta, and it is a toolchain install + a model-specific export/quantize/compile project, not a config change.
|
- No `ryzenai-compile`/ONNX Ryzen AI EP ⇒ nothing on the host can produce a partition program. That is the enablement delta, and it is a toolchain install + a model-specific export/quantize/compile project, not a config change.
|
||||||
- `free` headroom matters: NPU partitions want a few GB of host RAM each and can log benign `Failed to alloc dev bo memory, ret -28` under pressure.
|
- `free` headroom matters: NPU partitions want a few GB of host RAM each and can log benign `Failed to alloc dev bo memory, ret -28` under pressure.
|
||||||
|
|
||||||
|
## Device healthy, load still fails? Classify before blaming the hardware
|
||||||
|
|
||||||
|
Two different failures, one call apart. Run the load under `strace -f -e trace=openat`:
|
||||||
|
|
||||||
|
- **Never opens `/dev/accel/accel0`** ⇒ pre-device: loader/shim/env (see `ryzenai-npu-oga-linux-run` — the `Generic Failure` in ~2 ms is this class).
|
||||||
|
- **Opens `/dev/accel/accel0`, GEM ioctls succeed, then the EP declines** ⇒ post-device: the *runtime* rejects the graph. The device is innocent; stop the hardware investigation and fix the last file it opened (`model.onnx` = graph rejected; `dd_metastate_*` = partition artifact problem).
|
||||||
|
|
||||||
|
Ground truth that the device itself is sound, inside the container: `xrt-smi examine` (expect `RyzenAI-npu5 | aie2p | 6x8` + firmware version) then `xrt-smi validate` — `PASSED` incl. `throughput`. Needs `libboost-filesystem1.83.0` + `libboost-program-options1.83.0` (**hyphenated** Ubuntu names, after `apt update`); boost is required by `xrt-smi` only, never by the OGA/EP path. Vendor `quicktest` printing success does **not** prove NPU residency (that build falls back to CPU silently) — sample `/sys/class/accel/accel0/device/mem_usage` during generation instead.
|
||||||
|
|
||||||
|
When the vendor runtime declines silently, exhaust its own levers and then say so: `GENAI_ORT_VERBOSE_LOGGING`, `GENAI_LOG_ORT_LIB`, `set_log_options`, `set_log_callback(callable)`, `RYZENAI_EP_TRACING`, `RYZENAI_LLM_PROFILE_INIT` all produced zero lines on the 2026-10-19 NuTTS-Air decline. Rule out shadowed artifacts by moving hardlinked mirror copies out of the scanned tree (glob-based sibling scans) — if the failure is byte-identical afterwards, that theory is dead. **If a load ever succeeded once and the byte-identical command never reproduces it (files pristine by inode+mtime), that is a NO-GO, not a lead: no rate may be quoted for a run that cannot be repeated.**
|
||||||
|
|
||||||
## Laws / traps
|
## Laws / traps
|
||||||
|
|
||||||
- Read state only. Never probe or load the NPU beside a live inference service — stop + mask the service first (fleet benching policy); this host class has a precedent of a concurrent GPU/NPU experiment needing a power cycle.
|
- Read state only. Never probe or load the NPU beside a live inference service — stop + mask the service first (fleet benching policy); this host class has a precedent of a concurrent GPU/NPU experiment needing a power cycle.
|
||||||
- `sudo podman ...` finds nothing (per-user rootless storage); run podman as the deploy user.
|
- `sudo podman ...` finds nothing (per-user rootless storage); run podman as the deploy user.
|
||||||
- `/dev/accel/accel0` existing does not mean a context/PASID can be created; with IOMMU off the failure is at context creation, not at the node.
|
- `/dev/accel/accel0` existing does not mean a context/PASID can be created; with IOMMU off the failure is at context creation, not at the node.
|
||||||
|
- Rootless containers cannot read `accel0` (`root:render 0660`) by any documented flag combination — rootful, or a world-writable udev rule.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: tts-endpoint-parameter-capability-audit
|
||||||
|
description: "Determine which request parameters a fleet TTS endpoint actually honors by probing façade and engine separately, with a nondeterminism control, instead of trusting docs or byte diffs."
|
||||||
|
---
|
||||||
|
|
||||||
|
Use when asked whether a TTS (or other OpenAI-compatible shim) endpoint supports a parameter — mood, tone, instructions, speed, seed — or when a request "seems to" change the audio. Docs and single-request comparisons both lie; audit the layers.
|
||||||
|
|
||||||
|
## Why the naive checks fail
|
||||||
|
- **Byte-size diffs are meaningless.** Synthesis is nondeterministic: three byte-identical requests to frank-tts came back 107564 / 134444 / 111404 bytes (~±15 % length). "The mood file differs from the baseline" proves nothing.
|
||||||
|
- **HTTP 200 proves nothing.** A shim that rebuilds its own payload answers 200 to `speed:"banana"` because it never looks at the field.
|
||||||
|
- **A façade can drop fields the engine supports**, so testing only the published port under-reports capability.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
1. **Read the shim's parse block** (which request keys it reads) — not its docstring:
|
||||||
|
`ssh -o BatchMode=yes dankclaw 'podman exec frank-tts grep -nE "body\.get|payload = |json\.loads" /app/app.py'`
|
||||||
|
Whatever keys appear there (plus what it forwards in its own `payload = {...}` dict) is the real published contract.
|
||||||
|
2. **Enumerate the engine's key table from the binary** (works with no `strings` in the image):
|
||||||
|
`podman exec <engine> sh -c 'grep -aoE ".{60}instructions.{60}" /app/tts-server | head -2'`
|
||||||
|
Adjacent tokens come out as a run — that run IS the accepted-key list (`...instructions response_formatspeedseedmax_new_tokensrepetition_penalty subtalker_top_k subtalker_temperature ...`). Also `podman exec <engine> /app/tts-server --help`, and `podman inspect <c> --format "{{json .Config.Entrypoint}}"` to find the binary.
|
||||||
|
3. **Reach the engine directly** when its port is unpublished: `TTS_UPSTREAM` in `podman inspect <shim>` gives the real in-network address (`http://frank-tts-engine:8880`, not `host.containers.internal`). No curl/python on the host? Pipe a script into the container that has one: `podman exec -i frank-tts python3 - <<'PY' ... PY`.
|
||||||
|
4. **Control for nondeterminism before claiming an effect**: repeat the identical request twice as the control; only compare conditions separated by far more than the jitter, and prefer an error response (a 400 naming the unsupported flag) as the decisive evidence.
|
||||||
|
|
||||||
|
## Findings for the dank fleet (verify before trusting — they move)
|
||||||
|
- Published façade `:7772` reads only `model`, `input`, `voice`/`voice_id`, `response_format`; it rebuilds the payload, so `speed` and every unknown field are dropped before the engine.
|
||||||
|
- Engine (`frank-tts-engine`, qwentts.cpp, qwen-talker-1.7b-**base**, Vulkan) accepts `instructions`, `speed`, `seed`, `max_new_tokens`, `repetition_penalty`, `subtalker_top_k/_temperature/_top_p`, but answers `400 "--instruct is not supported for base models"` for `instructions` on the base checkpoint. `seed` does not make output reproducible.
|
||||||
|
- Therefore tone/mood must be produced in the **text layer** (prompt directives), not the TTS request; and `speed` needs a two-line façade fix if tempo control is ever wanted.
|
||||||
|
|
||||||
|
## Verification habits
|
||||||
|
- POST with a wrong *type* (`speed:"banana"`) to learn whether anything validates, and with a bogus `voice` to see the error shape (`available_voices` came from the engine, not the shim).
|
||||||
|
- Plain GET of `/v1/audio/voices` for the roster.
|
||||||
|
- Read-only probes of the fleet are always allowed; synthesis requests are transient GPU work, keep them to a handful.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: vault-one-fact-one-owner-dedupe
|
name: vault-one-fact-one-owner-dedupe
|
||||||
description: "\"Collapse per-payload/per-attempt vault pages into one owner page per endpoint without losing a fact: port-before-cut orphan census, doubled-page repair, trash-aware tools, delegated-write verification, index regeneration, and the merge-resolution order.\""
|
description: "\"Collapse per-payload/per-attempt vault pages into one owner page per endpoint without losing a fact: port-before-cut orphan census (corpus must exclude the file under test), doubled-page repair, trash-aware tools, delegated-write verification, honest per-area line accounting, index regeneration, and the merge-resolution order.\""
|
||||||
---
|
---
|
||||||
|
|
||||||
# One fact, one owner: dedupe the vault without losing a fact
|
# One fact, one owner: dedupe the vault without losing a fact
|
||||||
@@ -37,12 +37,19 @@ file being rewritten. Any line with **no** surviving key is an orphan → port,
|
|||||||
deliberately and say so. Use `scripts/port-census.py <new-dir> <git-HEAD:path|file>` (numbers
|
deliberately and say so. Use `scripts/port-census.py <new-dir> <git-HEAD:path|file>` (numbers
|
||||||
with ≥ 4 int digits, decimals, flags, paths, backticked spans, `--`, ALL-CAPS, or ≥ 2 hyphens).
|
with ≥ 4 int digits, decimals, flags, paths, backticked spans, `--`, ALL-CAPS, or ≥ 2 hyphens).
|
||||||
|
|
||||||
Two calibration traps:
|
Three calibration traps:
|
||||||
- **Prose words are not facts.** A hyphen or trailing period inside a word (`long-standing`,
|
- **Prose words are not facts.** A hyphen or trailing period inside a word (`long-standing`,
|
||||||
`deployed:`) was counted as a tracked token while real identifiers hid behind it; that noise
|
`deployed:`) was counted as a tracked token while real identifiers hid behind it; that noise
|
||||||
buried 300 real lines under 979 fake ones. Strip punctuation, require fact shape.
|
buried 300 real lines under 979 fake ones. Strip punctuation, require fact shape.
|
||||||
- **The corpus decides the question.** Corpus = the new file only → "what did this rewrite
|
- **The corpus decides the question.** Corpus = the new file only → "what did this rewrite
|
||||||
drop?" Corpus = whole vault → "is the dropped fact orphaned repo-wide?" Ask one at a time.
|
drop?" Corpus = whole vault → "is the dropped fact orphaned repo-wide?" Ask one at a time.
|
||||||
|
- **The corpus MUST exclude the file under test, and you must assert it.** A census pointed at
|
||||||
|
the directory that *contains* the page proves the page is held by itself: ten era pages
|
||||||
|
reported `0 sole-record lines`, which reads as "pure duplication, trash it". With the file
|
||||||
|
excluded, the same pages held **14–63 sole-record lines each** — campaign ledgers that exist
|
||||||
|
nowhere else. A tool's own docstring promise ("excluding it") is not a check: print the
|
||||||
|
corpus size, assert the target is absent, and treat `0 unheld` on a long page as a tool bug
|
||||||
|
until proven otherwise. A mechanical census is a scout: its verdict is a lead, not a finding.
|
||||||
|
|
||||||
Also grep `AGENTS.md` separately — a fact it carries is not an orphan.
|
Also grep `AGENTS.md` separately — a fact it carries is not an orphan.
|
||||||
|
|
||||||
@@ -52,7 +59,7 @@ Signature: a page whose `wc -l` is ~2× its `HEAD` size, with a **second frontma
|
|||||||
the body** whose closing `---` is glued onto the end of a body/timestamp line. Cause chain: a
|
the body** whose closing `---` is glued onto the end of a body/timestamp line. Cause chain: a
|
||||||
frontmatter rewrite that re-reads the file it just wrote (so its own output becomes the body),
|
frontmatter rewrite that re-reads the file it just wrote (so its own output becomes the body),
|
||||||
then a fence-repair pass keyed on `line == "---"` that cannot see a glued fence and inserts a
|
then a fence-repair pass keyed on `line == "---"` that cannot see a glued fence and inserts a
|
||||||
fence mid-page, so the next pass treats the tail as a new document.
|
fence mid-page, so the next page-tail becomes a new document.
|
||||||
|
|
||||||
Un-double rule that worked: find the first frontmatter's close (accepting a glued fence), then
|
Un-double rule that worked: find the first frontmatter's close (accepting a glued fence), then
|
||||||
the first `type:` line after it that has more keys (`title|description|tags|timestamp`) behind
|
the first `type:` line after it that has more keys (`title|description|tags|timestamp`) behind
|
||||||
@@ -82,9 +89,10 @@ grep -rnoE "\((page-a|page-b|...)\.md\)" --include="*.md" serverAppConfigs-docs
|
|||||||
|
|
||||||
Both wikilinks and markdown path refs must be repointed (Research `See Also` lists and bench
|
Both wikilinks and markdown path refs must be repointed (Research `See Also` lists and bench
|
||||||
pages are where path refs hide). Then **regenerate** index rows with a script, never by hand.
|
pages are where path refs hide). Then **regenerate** index rows with a script, never by hand.
|
||||||
The generator bug to check for: presence tested as a **bare stem substring** — a page whose
|
Generator bugs to check for: presence tested as a **bare stem substring** — a page whose name
|
||||||
name prefixes another's (`frank-medium` inside `frank-medium-rocmfpx`) then silently never
|
prefixes another's (`frank-medium` inside `frank-medium-rocmfpx`) then silently never gets a
|
||||||
gets a row. Test the wikilink form `[[<stem>]]`.
|
row (test the wikilink form `[[<stem>]]`); and **no dead-row sweep**, so deleting a page leaves
|
||||||
|
an index row that fails the link check — the generator must drop rows for absent pages too.
|
||||||
|
|
||||||
## 7. Enforce the law with a seam
|
## 7. Enforce the law with a seam
|
||||||
|
|
||||||
@@ -96,6 +104,12 @@ re-created era page then cannot pass tests. For the length hint, prefer **derive
|
|||||||
owned by the page that measured it) plus one documented per-page opt-out; never let a size
|
owned by the page that measured it) plus one documented per-page opt-out; never let a size
|
||||||
ceiling force a ledger to be split across two owners.
|
ceiling force a ledger to be split across two owners.
|
||||||
|
|
||||||
|
Splitting an over-long page to satisfy a ceiling is usually line-NEGATIVE: one measured split
|
||||||
|
turned a 700-line page into 488 + 306 across five files (+23 for the folder net of stub
|
||||||
|
deletions), because frontmatter blocks, pointer sections and connective prose are charged per
|
||||||
|
file while the facts move for free. Roll the split back, carry `okf-exempt: length` with the
|
||||||
|
reason, and keep the stub pages deleted.
|
||||||
|
|
||||||
## 8. Edit-tool hazard (costs the most time)
|
## 8. Edit-tool hazard (costs the most time)
|
||||||
|
|
||||||
Body rows beginning with `-` or `+` are op markers. The documented `+-` escape **writes a
|
Body rows beginning with `-` or `+` are op markers. The documented `+-` escape **writes a
|
||||||
@@ -104,6 +118,10 @@ line untouched and PUT only the indented continuation lines, or convert the note
|
|||||||
bullet whose body lines start with two/four spaces. If a wrapped `[[Two Word Page]]` shows up
|
bullet whose body lines start with two/four spaces. If a wrapped `[[Two Word Page]]` shows up
|
||||||
as an unresolved link, the target was hard-wrapped across lines — unwrap it.
|
as an unresolved link, the target was hard-wrapped across lines — unwrap it.
|
||||||
|
|
||||||
|
A `PUT` that spans a multi-line statement can silently drop neighbouring assignments
|
||||||
|
(auto-repaired boundary echo); after editing a constants block, `grep -n "^[A-Z_]* = "` and
|
||||||
|
run a syntax probe before executing.
|
||||||
|
|
||||||
## 9. Delegated writes: verify bytes, never narrative
|
## 9. Delegated writes: verify bytes, never narrative
|
||||||
|
|
||||||
Measured in one campaign, all four failure modes live:
|
Measured in one campaign, all four failure modes live:
|
||||||
@@ -146,7 +164,24 @@ matter how concrete they read; and re-read the source bullet before trusting a t
|
|||||||
- Open the PR through the API with a 600-perm curl config (managed skill
|
- Open the PR through the API with a 600-perm curl config (managed skill
|
||||||
`gitea-api-pr-operations`), end the body with the `cwd:` / `session:` footer, then
|
`gitea-api-pr-operations`), end the body with the `cwd:` / `session:` footer, then
|
||||||
`xdg-open <PR-URL>` the instant it exists. Never merge without Frank's current explicit OK.
|
`xdg-open <PR-URL>` the instant it exists. Never merge without Frank's current explicit OK.
|
||||||
|
Push with an explicit refspec (`HEAD:refs/heads/<pr-branch>`) — a worktree's own branch name
|
||||||
|
is not the PR branch, and `git push origin HEAD` opens a stray branch.
|
||||||
- Docs-only change ⇒ **no deploy**. Fast-forward the deploy checkout only
|
- Docs-only change ⇒ **no deploy**. Fast-forward the deploy checkout only
|
||||||
(`git -C ~/Projects/serverAppConfig merge --ff-only origin/main`).
|
(`git -C ~/Projects/serverAppConfig merge --ff-only origin/main`).
|
||||||
- Say what was NOT done, in the PR body: soft lint tallies left (missing `## Citations`),
|
- Say what was NOT done, in the PR body: soft lint tallies left (missing `## Citations`),
|
||||||
conflicts left standing, repo config that was never installed on a host.
|
conflicts left standing, repo config that was never installed on a host.
|
||||||
|
|
||||||
|
## 12. Report line counts per area, never one net
|
||||||
|
|
||||||
|
"Compaction" measured against the merge-base **per area**: docs / `scripts/` / `AGENTS.md`.
|
||||||
|
A single headline number hides that tooling and prose move in opposite directions — one
|
||||||
|
campaign reported "−1,849 vault lines" while the vault was flat and the growth was the two
|
||||||
|
enforcement scripts that made the law mechanical. Expect a mature one-owner vault to land
|
||||||
|
**docs flat**: the reductions available are deletions of *restatement* (duplicated pages,
|
||||||
|
transient deploy chatter in `log.md`, `AGENTS.md` bullets whose numbers an endpoint page owns)
|
||||||
|
and they are roughly cancelled by the cost of the citations the verification pass adds. When
|
||||||
|
asked to shrink further, show the menu — drop the tools, drop the citations, or accept content
|
||||||
|
loss — instead of inventing cuts. `log.md` compaction rule that survived census: drop a line
|
||||||
|
only when it matches a transient shape (`/health`, `deployed`, `restart`, `systemctl start`)
|
||||||
|
**and** every fact token in it is held by an owner page; a token-held line can still be the
|
||||||
|
only record of *when* an incident happened.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792441535971
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792441746823
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792443092036
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792440454430
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792398332410,
|
"lastConsolidatedAt": 1792439707623,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1791599865775,
|
"at": 1791599865775,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792401025778
|
"lastConsolidatedAt": 1792526055797
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792398289746
|
"lastConsolidatedAt": 1792469747088
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792400453482,
|
"lastConsolidatedAt": 1792439324791,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1792398531282,
|
"at": 1792398531282,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"v":1,"kind":"product_decision","statement":"Documentation links to @~/Projects/serverAppConfig/serverAppConfigs-docs/","source":"explicit_user","evidence":"correct our documentation to just link to @~/Projects/serverAppConfig/serverAppConfigs-docs/","friction":{"corrective":false,"regression":false,"subtle":false},"sessionId":"01a1531d-3e48-70b0-8ee0-6d48a197cae3","ts":1792397374946}
|
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792397349857
|
"lastConsolidatedAt": 1792439315327,
|
||||||
|
"lastResult": {
|
||||||
|
"at": 1792439315327,
|
||||||
|
"sessions": 1,
|
||||||
|
"deltas": 1,
|
||||||
|
"model": "frank-zealot"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792426412619,
|
"lastConsolidatedAt": 1792439612626,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1792401456699,
|
"at": 1792439612626,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
"deltas": 1,
|
"deltas": 1,
|
||||||
"model": "frank-zealot"
|
"model": "frank-zealot"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792426569902,
|
"lastConsolidatedAt": 1792524715562,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1792402864677,
|
"at": 1792433353188,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
"deltas": 8,
|
"deltas": 1,
|
||||||
"model": "frank-zealot"
|
"model": "frank-zealot"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792426408958,
|
"lastConsolidatedAt": 1792434572397,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1792401394555,
|
"at": 1792432178813,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
"deltas": 1,
|
"deltas": 2,
|
||||||
"model": "frank-zealot"
|
"model": "frank-zealot"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
## Agent Output Patterns
|
||||||
|
|
||||||
|
- Typed intro: "Update, [randomly generated epithet]!"
|
||||||
|
- Heckle mode option
|
||||||
|
- Typed outro: "More later, over and out!"
|
||||||
|
- Generation frequency control
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792524641573,
|
||||||
|
"lastResult": {
|
||||||
|
"at": 1792452243238,
|
||||||
|
"sessions": 1,
|
||||||
|
"deltas": 4,
|
||||||
|
"model": "frank-zealot"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792530736757
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
tt.*
|
||||||
|
.tests
|
||||||
|
doc/tags
|
||||||
|
debug
|
||||||
|
.repro
|
||||||
|
foo.*
|
||||||
|
*.log
|
||||||
|
data
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"neodev": {
|
||||||
|
"library": {
|
||||||
|
"enabled": true,
|
||||||
|
"plugins": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"neoconf": {
|
||||||
|
"plugins": {
|
||||||
|
"lua_ls": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 💤 LazyVim
|
||||||
|
|
||||||
|
A starter template for [LazyVim](https://github.com/LazyVim/LazyVim).
|
||||||
|
Refer to the [documentation](https://lazyvim.github.io/installation) to get started.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- bootstrap lazy.nvim, LazyVim and your plugins
|
||||||
|
require("config.lazy")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"LazyVim": { "branch": "main", "commit": "999700997f72227187d49d8b92667183dc7fc809" },
|
||||||
|
"base16-nvim": { "branch": "master", "commit": "7183774e90841f4bd27a47cb6e75cf532fd1192d" },
|
||||||
|
"blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" },
|
||||||
|
"bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" },
|
||||||
|
"catppuccin": { "branch": "main", "commit": "edefef779ab08ce1a4a404713e3012b0d202bd35" },
|
||||||
|
"conform.nvim": { "branch": "master", "commit": "016802de402556da54c36bd7359b441266b01cdd" },
|
||||||
|
"flash.nvim": { "branch": "main", "commit": "5f0f270fdc7c5b0c21d903ee85b9cb06f2ac636a" },
|
||||||
|
"friendly-snippets": { "branch": "main", "commit": "b4d01b0fdf3c9a549961c2f9ffe8dc09be166219" },
|
||||||
|
"gitsigns.nvim": { "branch": "main", "commit": "fd780920613e541cbdfac41bf711583ed49f9249" },
|
||||||
|
"grug-far.nvim": { "branch": "main", "commit": "bbde8ef5ae46ceee2c3bd8b21fc048c5f8ffbfc0" },
|
||||||
|
"lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" },
|
||||||
|
"lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" },
|
||||||
|
"lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" },
|
||||||
|
"mason-lspconfig.nvim": { "branch": "main", "commit": "00b5473cca4db5b410ff4bae724e3b6e21f2687f" },
|
||||||
|
"mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" },
|
||||||
|
"mini.ai": { "branch": "main", "commit": "d0c4987a1ead2bcf4eb22cc9815c662d76677c08" },
|
||||||
|
"mini.icons": { "branch": "main", "commit": "f916635dc5ae2f479fbb9ab8184bb9444b62e86c" },
|
||||||
|
"mini.pairs": { "branch": "main", "commit": "03eb1f0062a2cee5314d1ac3939246e4bb21352e" },
|
||||||
|
"noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" },
|
||||||
|
"nui.nvim": { "branch": "main", "commit": "10fc361835c856ba4233ef5ea135b919bf3dce97" },
|
||||||
|
"nvim-lint": { "branch": "master", "commit": "f6230a5c06ba59e6059c14637f1dbe29a7978b9b" },
|
||||||
|
"nvim-lspconfig": { "branch": "master", "commit": "45a3a444b710511c39abca3b60bb6e23a21e3de5" },
|
||||||
|
"nvim-treesitter": { "branch": "main", "commit": "29212d826054bb2948fa9d3a99eca9bf6a050eb2" },
|
||||||
|
"nvim-treesitter-textobjects": { "branch": "main", "commit": "5c7b0263797dfd1bd6202f2b219f3b53a80b2187" },
|
||||||
|
"nvim-ts-autotag": { "branch": "main", "commit": "d7220b55e16ed3c04e88086256def5d282429493" },
|
||||||
|
"persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
|
||||||
|
"plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" },
|
||||||
|
"snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" },
|
||||||
|
"todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" },
|
||||||
|
"tokyonight.nvim": { "branch": "main", "commit": "cdc07ac78467a233fd62c493de29a17e0cf2b2b6" },
|
||||||
|
"trouble.nvim": { "branch": "main", "commit": "bd67efe408d4816e25e8491cc5ad4088e708a69a" },
|
||||||
|
"ts-comments.nvim": { "branch": "main", "commit": "a59d6092213447450191122c9346f309161504cb" },
|
||||||
|
"which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extras": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"install_version": 8,
|
||||||
|
"news": {
|
||||||
|
"NEWS.md": "11866"
|
||||||
|
},
|
||||||
|
"version": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Autocmds are automatically loaded on the VeryLazy event
|
||||||
|
-- Default autocmds that are always set: https://github.com/LazyVim/LazyVim/blob/main/lua/lazyvim/config/autocmds.lua
|
||||||
|
--
|
||||||
|
-- Add any additional autocmds here
|
||||||
|
-- with `vim.api.nvim_create_autocmd`
|
||||||
|
--
|
||||||
|
-- Or remove existing autocmds by their group name (which is prefixed with `lazyvim_` for the defaults)
|
||||||
|
-- e.g. vim.api.nvim_del_augroup_by_name("lazyvim_wrap_spell")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Keymaps are automatically loaded on the VeryLazy event
|
||||||
|
-- Default keymaps that are always set: https://github.com/LazyVim/LazyVim/blob/main/lua/lazyvim/config/keymaps.lua
|
||||||
|
-- Add any additional keymaps here
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
|
||||||
|
if not (vim.uv or vim.loop).fs_stat(lazypath) then
|
||||||
|
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
|
||||||
|
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
|
||||||
|
if vim.v.shell_error ~= 0 then
|
||||||
|
vim.api.nvim_echo({
|
||||||
|
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
|
||||||
|
{ out, "WarningMsg" },
|
||||||
|
{ "\nPress any key to exit..." },
|
||||||
|
}, true, {})
|
||||||
|
vim.fn.getchar()
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vim.opt.rtp:prepend(lazypath)
|
||||||
|
|
||||||
|
require("lazy").setup({
|
||||||
|
spec = {
|
||||||
|
-- add LazyVim and import its plugins
|
||||||
|
{ "LazyVim/LazyVim", import = "lazyvim.plugins" },
|
||||||
|
-- import/override with your plugins
|
||||||
|
{ import = "plugins" },
|
||||||
|
},
|
||||||
|
defaults = {
|
||||||
|
-- By default, only LazyVim plugins will be lazy-loaded. Your custom plugins will load during startup.
|
||||||
|
-- If you know what you're doing, you can set this to `true` to have all your custom plugins lazy-loaded by default.
|
||||||
|
lazy = false,
|
||||||
|
-- It's recommended to leave version=false for now, since a lot the plugin that support versioning,
|
||||||
|
-- have outdated releases, which may break your Neovim install.
|
||||||
|
version = false, -- always use the latest git commit
|
||||||
|
-- version = "*", -- try installing the latest stable version for plugins that support semver
|
||||||
|
},
|
||||||
|
install = { colorscheme = { "tokyonight", "habamax" } },
|
||||||
|
checker = {
|
||||||
|
enabled = true, -- check for plugin updates periodically
|
||||||
|
notify = false, -- notify on update
|
||||||
|
}, -- automatically check for plugin updates
|
||||||
|
performance = {
|
||||||
|
rtp = {
|
||||||
|
-- disable some rtp plugins
|
||||||
|
disabled_plugins = {
|
||||||
|
"gzip",
|
||||||
|
-- "matchit",
|
||||||
|
-- "matchparen",
|
||||||
|
-- "netrwPlugin",
|
||||||
|
"tarPlugin",
|
||||||
|
"tohtml",
|
||||||
|
"tutor",
|
||||||
|
"zipPlugin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Options are automatically loaded before lazy.nvim startup
|
||||||
|
-- Default options that are always set: https://github.com/LazyVim/LazyVim/blob/main/lua/lazyvim/config/options.lua
|
||||||
|
-- Add any additional options here
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.setup()
|
||||||
|
require('base16-colorscheme').setup({
|
||||||
|
base00 = '#181818',
|
||||||
|
base01 = '#282828',
|
||||||
|
base02 = '#323232',
|
||||||
|
base03 = '#646464',
|
||||||
|
base04 = '#95a99f',
|
||||||
|
base05 = '#e4e4ef',
|
||||||
|
base06 = '#e4e4ef',
|
||||||
|
base07 = '#e4e4ef',
|
||||||
|
base08 = '#f43841',
|
||||||
|
base09 = '#9e95c7',
|
||||||
|
base0A = '#96a6c8',
|
||||||
|
base0B = '#f43841',
|
||||||
|
base0C = '#a596e9',
|
||||||
|
base0D = '#f8868c',
|
||||||
|
base0E = '#96b0e9',
|
||||||
|
base0F = '#becff4',
|
||||||
|
})
|
||||||
|
|
||||||
|
local hi = function(group, opts)
|
||||||
|
vim.api.nvim_set_hl(0, group, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- telescope.nvim
|
||||||
|
hi('TelescopeNormal', { fg = '#e4e4ef', bg = '#181818' })
|
||||||
|
hi('TelescopeBorder', { fg = '#646464', bg = '#181818' })
|
||||||
|
hi('TelescopePromptNormal', { fg = '#e4e4ef', bg = '#181818' })
|
||||||
|
hi('TelescopePromptBorder', { fg = '#646464', bg = '#181818' })
|
||||||
|
hi('TelescopePromptPrefix', { fg = '#f43841', bg = '#181818' })
|
||||||
|
hi('TelescopePromptCounter', { fg = '#95a99f', bg = '#181818' })
|
||||||
|
hi('TelescopePromptTitle', { fg = '#181818', bg = '#f43841' })
|
||||||
|
hi('TelescopePreviewTitle', { fg = '#181818', bg = '#96a6c8' })
|
||||||
|
hi('TelescopeResultsTitle', { fg = '#181818', bg = '#9e95c7' })
|
||||||
|
hi('TelescopeSelection', { fg = '#e4e4ef', bg = '#323232' })
|
||||||
|
hi('TelescopeSelectionCaret', { fg = '#f43841', bg = '#323232' })
|
||||||
|
hi('TelescopeMatching', { fg = '#f43841', bold = true })
|
||||||
|
|
||||||
|
-- mini.pick
|
||||||
|
hi('MiniPickNormal', { fg = '#e4e4ef', bg = '#181818' })
|
||||||
|
hi('MiniPickBorder', { fg = '#646464', bg = '#181818' })
|
||||||
|
hi('MiniPickPrompt', { fg = '#e4e4ef', bg = '#181818' })
|
||||||
|
hi('MiniPickPromptPrefix', { fg = '#f43841', bg = '#181818' })
|
||||||
|
hi('MiniPickBorderText', { fg = '#181818', bg = '#f43841' })
|
||||||
|
hi('MiniPickMatchCurrent', { fg = '#e4e4ef', bg = '#323232' })
|
||||||
|
hi('MiniPickPromptCaret', { fg = '#f43841', bg = '#323232' })
|
||||||
|
hi('MiniPickMatchRanges', { fg = '#f43841', bold = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Register a signal handler for SIGUSR1 (matugen updates).
|
||||||
|
-- The handler re-requires this module, which re-runs the code below, so the
|
||||||
|
-- previous handle is stopped first; otherwise handlers double on every signal.
|
||||||
|
if _G.__matugen_signal then
|
||||||
|
_G.__matugen_signal:stop()
|
||||||
|
_G.__matugen_signal:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
local signal = vim.uv.new_signal()
|
||||||
|
_G.__matugen_signal = signal
|
||||||
|
signal:start(
|
||||||
|
'sigusr1',
|
||||||
|
vim.schedule_wrap(function()
|
||||||
|
package.loaded['matugen'] = nil
|
||||||
|
require('matugen').setup()
|
||||||
|
end)
|
||||||
|
)
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
-- since this is just an example spec, don't actually load anything here and return an empty spec
|
||||||
|
-- stylua: ignore
|
||||||
|
if true then return {} end
|
||||||
|
|
||||||
|
-- every spec file under the "plugins" directory will be loaded automatically by lazy.nvim
|
||||||
|
--
|
||||||
|
-- In your plugin files, you can:
|
||||||
|
-- * add extra plugins
|
||||||
|
-- * disable/enabled LazyVim plugins
|
||||||
|
-- * override the configuration of LazyVim plugins
|
||||||
|
return {
|
||||||
|
-- add gruvbox
|
||||||
|
{ "ellisonleao/gruvbox.nvim" },
|
||||||
|
|
||||||
|
-- Configure LazyVim to load gruvbox
|
||||||
|
{
|
||||||
|
"LazyVim/LazyVim",
|
||||||
|
opts = {
|
||||||
|
colorscheme = "gruvbox",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- change trouble config
|
||||||
|
{
|
||||||
|
"folke/trouble.nvim",
|
||||||
|
-- opts will be merged with the parent spec
|
||||||
|
opts = { use_diagnostic_signs = true },
|
||||||
|
},
|
||||||
|
|
||||||
|
-- disable trouble
|
||||||
|
{ "folke/trouble.nvim", enabled = false },
|
||||||
|
|
||||||
|
-- override nvim-cmp and add cmp-emoji
|
||||||
|
{
|
||||||
|
"hrsh7th/nvim-cmp",
|
||||||
|
dependencies = { "hrsh7th/cmp-emoji" },
|
||||||
|
---@param opts cmp.ConfigSchema
|
||||||
|
opts = function(_, opts)
|
||||||
|
table.insert(opts.sources, { name = "emoji" })
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
|
||||||
|
-- change some telescope options and a keymap to browse plugin files
|
||||||
|
{
|
||||||
|
"nvim-telescope/telescope.nvim",
|
||||||
|
keys = {
|
||||||
|
-- add a keymap to browse plugin files
|
||||||
|
-- stylua: ignore
|
||||||
|
{
|
||||||
|
"<leader>fp",
|
||||||
|
function() require("telescope.builtin").find_files({ cwd = require("lazy.core.config").options.root }) end,
|
||||||
|
desc = "Find Plugin File",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-- change some options
|
||||||
|
opts = {
|
||||||
|
defaults = {
|
||||||
|
layout_strategy = "horizontal",
|
||||||
|
layout_config = { prompt_position = "top" },
|
||||||
|
sorting_strategy = "ascending",
|
||||||
|
winblend = 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- add pyright to lspconfig
|
||||||
|
{
|
||||||
|
"neovim/nvim-lspconfig",
|
||||||
|
---@class PluginLspOpts
|
||||||
|
opts = {
|
||||||
|
---@type lspconfig.options
|
||||||
|
servers = {
|
||||||
|
-- pyright will be automatically installed with mason and loaded with lspconfig
|
||||||
|
pyright = {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- add tsserver and setup with typescript.nvim instead of lspconfig
|
||||||
|
{
|
||||||
|
"neovim/nvim-lspconfig",
|
||||||
|
dependencies = {
|
||||||
|
"jose-elias-alvarez/typescript.nvim",
|
||||||
|
init = function()
|
||||||
|
require("lazyvim.util").lsp.on_attach(function(_, buffer)
|
||||||
|
-- stylua: ignore
|
||||||
|
vim.keymap.set( "n", "<leader>co", "TypescriptOrganizeImports", { buffer = buffer, desc = "Organize Imports" })
|
||||||
|
vim.keymap.set("n", "<leader>cR", "TypescriptRenameFile", { desc = "Rename File", buffer = buffer })
|
||||||
|
end)
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
---@class PluginLspOpts
|
||||||
|
opts = {
|
||||||
|
---@type lspconfig.options
|
||||||
|
servers = {
|
||||||
|
-- tsserver will be automatically installed with mason and loaded with lspconfig
|
||||||
|
tsserver = {},
|
||||||
|
},
|
||||||
|
-- you can do any additional lsp server setup here
|
||||||
|
-- return true if you don't want this server to be setup with lspconfig
|
||||||
|
---@type table<string, fun(server:string, opts:_.lspconfig.options):boolean?>
|
||||||
|
setup = {
|
||||||
|
-- example to setup with typescript.nvim
|
||||||
|
tsserver = function(_, opts)
|
||||||
|
require("typescript").setup({ server = opts })
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
-- Specify * to use this function as a fallback for any server
|
||||||
|
-- ["*"] = function(server, opts) end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- for typescript, LazyVim also includes extra specs to properly setup lspconfig,
|
||||||
|
-- treesitter, mason and typescript.nvim. So instead of the above, you can use:
|
||||||
|
{ import = "lazyvim.plugins.extras.lang.typescript" },
|
||||||
|
|
||||||
|
-- add more treesitter parsers
|
||||||
|
{
|
||||||
|
"nvim-treesitter/nvim-treesitter",
|
||||||
|
opts = {
|
||||||
|
ensure_installed = {
|
||||||
|
"bash",
|
||||||
|
"html",
|
||||||
|
"javascript",
|
||||||
|
"json",
|
||||||
|
"lua",
|
||||||
|
"markdown",
|
||||||
|
"markdown_inline",
|
||||||
|
"python",
|
||||||
|
"query",
|
||||||
|
"regex",
|
||||||
|
"tsx",
|
||||||
|
"typescript",
|
||||||
|
"vim",
|
||||||
|
"yaml",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- since `vim.tbl_deep_extend`, can only merge tables and not lists, the code above
|
||||||
|
-- would overwrite `ensure_installed` with the new value.
|
||||||
|
-- If you'd rather extend the default config, use the code below instead:
|
||||||
|
{
|
||||||
|
"nvim-treesitter/nvim-treesitter",
|
||||||
|
opts = function(_, opts)
|
||||||
|
-- add tsx and treesitter
|
||||||
|
vim.list_extend(opts.ensure_installed, {
|
||||||
|
"tsx",
|
||||||
|
"typescript",
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
|
||||||
|
-- the opts function can also be used to change the default opts:
|
||||||
|
{
|
||||||
|
"nvim-lualine/lualine.nvim",
|
||||||
|
event = "VeryLazy",
|
||||||
|
opts = function(_, opts)
|
||||||
|
table.insert(opts.sections.lualine_x, {
|
||||||
|
function()
|
||||||
|
return "😄"
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
|
||||||
|
-- or you can return new options to override all the defaults
|
||||||
|
{
|
||||||
|
"nvim-lualine/lualine.nvim",
|
||||||
|
event = "VeryLazy",
|
||||||
|
opts = function()
|
||||||
|
return {
|
||||||
|
--[[add your custom lualine config here]]
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
|
||||||
|
-- use mini.starter instead of alpha
|
||||||
|
{ import = "lazyvim.plugins.extras.ui.mini-starter" },
|
||||||
|
|
||||||
|
-- add jsonls and schemastore packages, and setup treesitter for json, json5 and jsonc
|
||||||
|
{ import = "lazyvim.plugins.extras.lang.json" },
|
||||||
|
|
||||||
|
-- add any tools you want to have installed below
|
||||||
|
{
|
||||||
|
"williamboman/mason.nvim",
|
||||||
|
opts = {
|
||||||
|
ensure_installed = {
|
||||||
|
"stylua",
|
||||||
|
"shellcheck",
|
||||||
|
"shfmt",
|
||||||
|
"flake8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Applies the hand-kept wallpaper palette (lua/matugen.lua) on top of LazyVim's
|
||||||
|
-- colorscheme instead of living with tokyonight defaults.
|
||||||
|
--
|
||||||
|
-- base16-colorscheme writes highlights directly and never touches v:colors_name,
|
||||||
|
-- so LazyVim keeps thinking tokyonight is loaded and its plugin-specific tweaks
|
||||||
|
-- still run; ours is the last word on the UI groups.
|
||||||
|
return {
|
||||||
|
"RRethy/base16-nvim",
|
||||||
|
lazy = false,
|
||||||
|
priority = 900,
|
||||||
|
config = function()
|
||||||
|
-- LazyVim loads its colorscheme after plugin configs, so re-apply on every
|
||||||
|
-- ColorScheme event: that also makes `:colorscheme foo` land on our palette.
|
||||||
|
vim.api.nvim_create_autocmd("ColorScheme", {
|
||||||
|
desc = "Re-apply the matugen base16 palette after any colorscheme",
|
||||||
|
callback = function()
|
||||||
|
require("matugen").setup()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
require("matugen").setup()
|
||||||
|
end,
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
indent_type = "Spaces"
|
||||||
|
indent_width = 2
|
||||||
|
column_width = 120
|
||||||
@@ -275,13 +275,13 @@ transition = [ "fade" ]
|
|||||||
interval_seconds = 1200
|
interval_seconds = 1200
|
||||||
|
|
||||||
[wallpaper.default]
|
[wallpaper.default]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_1kdgpg.jpg"
|
||||||
|
|
||||||
[wallpaper.last]
|
[wallpaper.last]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_1kdgpg.jpg"
|
||||||
|
|
||||||
[wallpaper.monitors.DP-1]
|
[wallpaper.monitors.DP-1]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_y8ewzk.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_1kdgpg.jpg"
|
||||||
|
|
||||||
[wallpaper.monitors.DP-2]
|
[wallpaper.monitors.DP-2]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
|
||||||
@@ -290,7 +290,7 @@ transition = [ "fade" ]
|
|||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
|
||||||
|
|
||||||
[wallpaper.monitors.eDP-1]
|
[wallpaper.monitors.eDP-1]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_1kdgpg.jpg"
|
||||||
|
|
||||||
[weather]
|
[weather]
|
||||||
unit = "imperial"
|
unit = "imperial"
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ source /usr/share/cachyos-zsh-config/cachyos-config.zsh
|
|||||||
[ -f ~/.config/secrets/hf_token ] && export HF_TOKEN="$(cat ~/.config/secrets/hf_token)"
|
[ -f ~/.config/secrets/hf_token ] && export HF_TOKEN="$(cat ~/.config/secrets/hf_token)"
|
||||||
|
|
||||||
[ -d "$HOME/.bun/bin" ] && export PATH="$HOME/.bun/bin:$PATH"
|
[ -d "$HOME/.bun/bin" ] && export PATH="$HOME/.bun/bin:$PATH"
|
||||||
|
|
||||||
|
# LazyVim lives in its own NVIM_APPNAME profile (~/.config/lazyvim), so plain
|
||||||
|
# `nvim` stays a vanilla quick-edit editor. NVIM_APPNAME swaps config, data,
|
||||||
|
# state and cache dirs wholesale — no shared plugin tree between the two.
|
||||||
|
lazyvim() { NVIM_APPNAME=lazyvim nvim "$@"; }
|
||||||
|
|||||||
+71
-220
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
type: reference
|
type: reference
|
||||||
title: dankvoice
|
title: dankvoice
|
||||||
description: omp extension that speaks assistant output with the fleet's cloned voices through frank-tts.
|
description: Voice output ships as the dank/dankvoice package; this page keeps only the fleet integration facts and the cutover procedure.
|
||||||
tags:
|
tags:
|
||||||
- omp
|
- omp
|
||||||
- extension
|
- extension
|
||||||
@@ -15,244 +15,95 @@ timestamp: 2026-10-19
|
|||||||
|
|
||||||
# dankvoice
|
# dankvoice
|
||||||
|
|
||||||
`agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive
|
Voice output lives in the **`dank/dankvoice` package**:
|
||||||
harness: `/say` opens the options menu, `/say <text>` speaks through the
|
`pi install git:http://192.168.0.143:3000/dank/dankvoice.git`. This page owns
|
||||||
speakers and `/say on` narrates the assistant's thinking and final answer
|
only fleet integration and the cutover. The package README
|
||||||
after every turn. The words are first rewritten into natural spoken prose by
|
(<http://192.168.0.143:3000/dank/dankvoice>) owns the `/say` command
|
||||||
the **@tiny** role model (exactly what the built-in vocalizer's
|
reference, the settings/env knob list, the segmentation state machine, the
|
||||||
`speech.enhanced` does), and everything is served by **frank-tts**
|
enhancer port, and mood control — never duplicate those facts here.
|
||||||
(Dankclaw :7772, the `speech` model role in [agent/models.yml](/core/index.md)).
|
|
||||||
|
|
||||||
## Why it exists
|
## Fleet wiring
|
||||||
|
|
||||||
Nothing built into the harness can pronounce Frank's cloned voices.
|
* **TTS endpoint** — resolved from the provider block whose model row carries
|
||||||
|
`api: openai-speech` in [agent/models.yml](/core/index.md):
|
||||||
|
**frank-tts** on Dankclaw `:7772`. LAN-only — no Caddy vhost fronts it.
|
||||||
|
* **Narration humanizer** — the harness `@tiny` role
|
||||||
|
(`modelRoles.tiny` in `agent/config.yml`), on this fleet **frank-zealot**
|
||||||
|
(Dankclaw `:7771`).
|
||||||
|
* **Hybrid-endpoint trap (load-bearing)** — a raw `fetch` to a
|
||||||
|
thinking-by-default model spends the token budget on `reasoning_content`
|
||||||
|
and answers `content: ""`. The package pins
|
||||||
|
`chat_template_kwargs: { enable_thinking: false }` on the rewrite call; do
|
||||||
|
not remove it. On a hybrid endpoint, empty content beside non-empty
|
||||||
|
`reasoning_content` means the flag was ignored — that is a failed rewrite
|
||||||
|
(fall back to the mechanical text), never the legitimate "nothing to say".
|
||||||
|
* **Voice contract** — omitting `voice` is legal: frank-tts fills its
|
||||||
|
`DEFAULT_VOICE` (2026-10-18 voice-pack work). `GET {base}/v1/audio/voices`
|
||||||
|
answers a bare JSON array; an unknown voice answers 400 with
|
||||||
|
`available_voices`, which the package uses as roster fallback. On this shim
|
||||||
|
`GET /v1/voices` and `GET /v1/models` are 404.
|
||||||
|
|
||||||
| Surface | Engine | Verdict |
|
## Cutover (Refs #34)
|
||||||
|---|---|---|
|
|
||||||
| TUI speak-aloud (`speech.enabled`, `speech.voice`) | bundled Kokoro ONNX worker, `synthesizeStream(voice, speed, signal)` | Kokoro voice enum only; never consults `models.yml` or the speech role |
|
|
||||||
| `omp say [TEXT]` | same local backend (`--model` = *local* model key) | `omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, af_bella, af_nicole, af_kore, af_sarah, am_michael, am_fenrir, am_puck, bf_emma, bm_george, bm_fable` (verified 2026-10-18) |
|
|
||||||
| `tts` tool / `xd://tts` device (`modelRoles.speech`) | frank-tts over OpenAI `/v1/audio/speech` | cloned voices work, but only as a **file** — nothing plays them |
|
|
||||||
|
|
||||||
`dankvoice` is not a patch to that vocalizer (harness code, not ours); it is a
|
The `agent/extensions/dankvoice.ts` drop-in and its 26-test bats suite are
|
||||||
second transport that happens to speak, so Kokoro stays available for quick
|
retired here (the package suite is a strict superset; test names ported 1:1).
|
||||||
local speech and the cloned voices carry the real narration.
|
Per box:
|
||||||
|
|
||||||
## Commands
|
1. `pi install git:http://192.168.0.143:3000/dank/dankvoice.git`
|
||||||
|
2. `/say status`, then "Speak a test phrase" from the `/say` menu — the
|
||||||
|
audible proof a silent session needs.
|
||||||
|
3. Pull `main` (the drop-in disappears with it) and restart the session.
|
||||||
|
|
||||||
| Command | Effect |
|
Menu choices survive the cutover: both transports persist to the same
|
||||||
|---|---|
|
gitignored settings file, `~/.omp/cache/dankvoice.json`
|
||||||
| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, thinking-narration toggle, humanize toggle, test phrase |
|
(`DANKVOICE_CONFIG` relocates it).
|
||||||
| `/say <text>` | synthesize once and play; interrupts whatever is playing |
|
|
||||||
| `/say on` \| `/say off` | arm/disarm auto-speak of the thinking blocks and final text at `agent_end`; the footer shows `🔊 <voice>` while armed |
|
|
||||||
| `/say voice <name>` | pick a voice; an empty name defers to the server's own default |
|
|
||||||
| `/say voices` | list what frank-tts reports |
|
|
||||||
| `/say stop` | cut the current utterance |
|
|
||||||
| `/say status` \| `/say help` | endpoint, voice, player, device, cap, thinking, humanize |
|
|
||||||
|
|
||||||
Menu choices persist to `~/.omp/cache/dankvoice.json` (runtime state, never
|
## Why not the built-in vocalizer
|
||||||
tracked); the environment overrides the file, the file overrides defaults.
|
|
||||||
"Speak a test phrase" synthesizes a short line through the current voice,
|
|
||||||
player and device — the audible proof whenever a session "has say on" but
|
|
||||||
stays silent.
|
|
||||||
|
|
||||||
Auto-speak is skipped in `print`/`json` mode so benches and pipelines stay
|
The harness speak-aloud path (`speech.enabled` / `speech.voice`, `omp say`)
|
||||||
silent. Each spoken unit — thinking first when narrated, then the answer —
|
synthesizes with bundled Kokoro ONNX against a **compile-time voice enum** —
|
||||||
is first rewritten into spoken prose by the @tiny model; when the enhancer
|
cloned voices can never reach it, and no setting routes it to frank-tts.
|
||||||
(below) declines, the text goes to segmentation as-is, where fenced code and
|
`dankvoice` is a second transport beside it, not a patch: Kokoro stays for
|
||||||
tables vanish, links collapse to their label and bullets and table pipes
|
quick local speech, the package carries cloned-voice narration. The `tts`
|
||||||
disappear. A long answer is never one monolithic synthesis request; see
|
tool / `xd://tts` device reach frank-tts too, but produce files, not audio
|
||||||
[Segmentation](#segmentation) for how utterances are cut and played.
|
playback.
|
||||||
|
|
||||||
## Segmentation
|
## Arming auto-speak
|
||||||
|
|
||||||
Utterances are cut by a state machine ported from the harness's own
|
Arming is **session-scoped by design**: `/say on` arms the running session and
|
||||||
speak-aloud vocalizer (the minified `fHe` class in the agent bundle's
|
is deliberately never persisted. A session that starts silent is a config
|
||||||
`dist/cli.js`), kept deliberately in lockstep with the stock path:
|
state, not a fault — set `"auto": true` in `~/.omp/cache/dankvoice.json` (this
|
||||||
|
fleet does) or export `DANKVOICE_AUTO=1` to start armed. Editing the settings
|
||||||
|
file mid-session does nothing; the plugin reads it at startup.
|
||||||
|
|
||||||
* A strong boundary is a run of `[.!?…]` followed by whitespace — so
|
Two checks prove the chain, in order: `/say status` must report
|
||||||
`!!!!!` and `...` are one boundary, never a pile of cuts.
|
`auto-speak on` with a real endpoint (not `NO ENDPOINT`), and `pgrep -a pw-play`
|
||||||
* Boundaries after `e.g.`, `etc.`, `Mr.` and friends, or inside inline
|
during a turn must show one player per spoken segment, `--target` set to the
|
||||||
code, don't count; decimals (`3.5`, `1,5`) survive because a boundary
|
saved sink. Thinking narration rides the same arming (`thinking: true`).
|
||||||
requires trailing whitespace.
|
|
||||||
* Line starts are classified: table rows and fenced code are swallowed
|
|
||||||
whole (their content is never read aloud), heading and bullet markers
|
|
||||||
vanish, ordered-list markers speak their number ("1, …"), quote markers
|
|
||||||
strip.
|
|
||||||
* Per segment, markdown is cleaned: links collapse to their label, URLs
|
|
||||||
to their host, deep paths to their last segment; emphasis markup dies.
|
|
||||||
* Segments aim for one sentence; run-ons break at commas or word wraps
|
|
||||||
before 280 characters.
|
|
||||||
|
|
||||||
Playback is strictly sequential: segment N+1 is synthesized only after
|
|
||||||
segment N's player process exited, so audio never stacks. The chain runs
|
|
||||||
detached from `agent_end` (fire-and-forget) and may legitimately outlive
|
|
||||||
the harness's 30 s handler timeout — the old awaiting design produced a
|
|
||||||
"handler timed out after 30000ms" toast on long answers. A new utterance,
|
|
||||||
`/say stop`, or disarming auto-speak retires the live chain through a run
|
|
||||||
token: the current player is killed and pending segments are skipped.
|
|
||||||
`DANKVOICE_MAX` is a per-answer character budget: the leading whole
|
|
||||||
segments that fit are spoken, the rest are dropped — the thinking unit
|
|
||||||
draws on it before the answer does.
|
|
||||||
|
|
||||||
## Endpoint resolution
|
|
||||||
|
|
||||||
The base URL is **read from `agent/models.yml`**, from the provider block whose
|
|
||||||
model row carries `api: openai-speech` — the same row the `speech` role
|
|
||||||
resolves, so no host address and no credential appears in this file. From
|
|
||||||
`baseUrl: http://192.168.0.143:7772/v1` it derives
|
|
||||||
`POST …/v1/audio/speech`, `GET …/v1/audio/voices` and `GET …/health`.
|
|
||||||
|
|
||||||
| Environment | Default | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `DANKVOICE_URL` | models.yml | speech base URL (`…/v1`), bypasses the YAML read |
|
|
||||||
| `DANKVOICE_MODEL` | the id in that block (`frank-tts`) | model named in the request body |
|
|
||||||
| `DANKVOICE_CONFIG` | `~/.omp/cache/dankvoice.json` | persisted menu choices (voice, player, sink, cap, auto) |
|
|
||||||
| `DANKVOICE_VOICE` | empty (server default) | default voice name |
|
|
||||||
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
|
|
||||||
| `DANKVOICE_MAX` | `700` | spoken-character budget per answer (whole leading segments, shared by the thinking and answer units) |
|
|
||||||
| `DANKVOICE_PLAYER` | detect | player binary, skipping detection |
|
|
||||||
| `DANKVOICE_MODELS` | `~/.omp/agent/models.yml` | YAML to read the endpoint from |
|
|
||||||
| `DANKVOICE_REWRITE_URL` | the @tiny role | chat base URL (`…/v1`) for the enhancer, bypassing role resolution |
|
|
||||||
| `DANKVOICE_REWRITE_MODEL` | the @tiny role's model | chat model named in the rewrite body |
|
|
||||||
| `DANKVOICE_REWRITE=0` | enhancer on when a route resolves | speak mechanical text |
|
|
||||||
| `DANKVOICE_THINKING` | settings file, default on | `0`/`1` force thinking narration off/on |
|
|
||||||
| `DANKVOICE_OMPCONFIG` | `~/.omp/agent/config.yml` | YAML to read the `modelRoles.tiny` role from |
|
|
||||||
|
|
||||||
Player detection order is `pw-play` → `paplay` → `ffplay` (PipeWire first:
|
|
||||||
the workstations run WirePlumber). A saved output device routes the player
|
|
||||||
explicitly — `pw-play --target <sink>` or `paplay --device=<sink>`; ffplay
|
|
||||||
has no device concept and ignores it — while an unset device leaves the
|
|
||||||
player's own default. Sinks are enumerated with `wpctl list audio sinks`;
|
|
||||||
if wireplumber is unreachable the menu offers only "system default". Each
|
|
||||||
utterance is written to `$TMPDIR/dankvoice-*/say-<ms>.wav` and the file plus
|
|
||||||
its directory are removed when the player process exits.
|
|
||||||
|
|
||||||
## Humanized narration (the @tiny role)
|
|
||||||
|
|
||||||
The built-in vocalizer never reads answers aloud verbatim when
|
|
||||||
`speech.enhanced` is on: a small model rewrites each utterance into words a
|
|
||||||
person would say. `dankvoice` ports that enhancer — same system prompt
|
|
||||||
(verbatim from the bundle), same budgets (1536 max tokens, 6 s timeout,
|
|
||||||
4000-char input window), same contract (a failed rewrite falls back to the
|
|
||||||
mechanically cleaned text; an empty rewrite skips the unit as "nothing to
|
|
||||||
say"). The target model is the harness's `@tiny` role, resolved the way the
|
|
||||||
harness resolves it: `modelRoles.tiny` in `agent/config.yml` reads
|
|
||||||
`provider (host)/model:effort`; the provider block in `agent/models.yml`
|
|
||||||
supplies the `baseUrl`; the request is `POST {base}/chat/completions`. On
|
|
||||||
this fleet that is **frank-zealot** (`llamacpp (dankclaw-3090)`,
|
|
||||||
`:7771`) — the 6 s abort keeps a slow tiny model off the critical path.
|
|
||||||
|
|
||||||
The request body pins `chat_template_kwargs: { enable_thinking: false }`,
|
|
||||||
and that field is load-bearing: the harness injects it for managed roles,
|
|
||||||
but a raw `fetch` gets the model's default — thinking on — so the Qwen35
|
|
||||||
hybrid spends the budget on `reasoning_content` and answers `content: ""`
|
|
||||||
(measured: 6.9 s / 6893 reasoning chars / empty content without the flag;
|
|
||||||
0.55 s / 369 clean chars with it). For a hybrid endpoint, empty content
|
|
||||||
beside non-empty `reasoning_content` means the flag was ignored — a failed
|
|
||||||
rewrite (mechanical fallback), never the legitimate "nothing to say".
|
|
||||||
|
|
||||||
Thinking blocks ride the same path: at `agent_end` the last assistant
|
|
||||||
message's true `thinking` parts are narrated (rewritten first, as their own
|
|
||||||
unit, before the answer), while blocks flagged `narration: true` — which
|
|
||||||
providers use to address the user through the thinking channel — count as
|
|
||||||
answer text. The menu, `/say status`, and `DANKVOICE_THINKING` toggle it;
|
|
||||||
the choice persists in the settings file.
|
|
||||||
|
|
||||||
## Verified facts (2026-10-18, extended 2026-10-19)
|
|
||||||
|
|
||||||
* **Extension discovery** — any `.ts` file in `~/.omp/agent/extensions/` loads
|
|
||||||
at startup: a probe file there printed `loaded 2026-10-19T02:36:55Z` from a
|
|
||||||
fresh `omp -p` run. `omp --help` documents `--no-extensions` (discovery off,
|
|
||||||
explicit `-e/--extension` and `--hook` still load) and `plugin install|link`.
|
|
||||||
* **Endpoint** — resolving the live `agent/models.yml` yields
|
|
||||||
`speechUrl http://192.168.0.143:7772/v1/audio/speech`,
|
|
||||||
`voicesUrl …/v1/audio/voices`, `healthUrl …/health`, `model frank-tts`.
|
|
||||||
* **Roster** — `GET /v1/audio/voices` answers the bare JSON array
|
|
||||||
`["narrator1","narrator2","narrator3"]`; `/health` answers
|
|
||||||
`{"status":"ok","backend":"omni-http","voices":[…]}`. `GET /v1/voices` and
|
|
||||||
`GET /v1/models` are **404** on this shim, and an unknown voice answers
|
|
||||||
`400 unknown voice 'zzz'; available voices: …` with `available_voices` in the
|
|
||||||
body — which is the fallback the extension uses if the roster route ever
|
|
||||||
disappears.
|
|
||||||
* **Omitting `voice` is legal** — frank-tts fills its `DEFAULT_VOICE` since the
|
|
||||||
2026-10-18 voice-pack work, so an unset voice synthesizes instead of failing.
|
|
||||||
* **Speech** — `/say "dankvoice live smoke test"` produced a 99,884 B / 2.08 s
|
|
||||||
WAV and `/say voice narrator2` + `/say "cloned voice check"` a 69,164 B /
|
|
||||||
1.44 s WAV (durations from `ffprobe`), each handed to the player; temp
|
|
||||||
directories were gone afterwards.
|
|
||||||
* **Auto-speak** — verified in a real TUI session: `/say on` armed the footer
|
|
||||||
(`🔊 narrator1`), the next finished turn synthesized and played the answer
|
|
||||||
(1.20 s WAV), and an unknown voice name answers with the roster instead of
|
|
||||||
speaking. An `agent_end` carrying `willContinue` (the harness scheduled an
|
|
||||||
auto-retry) is never narrated — only the terminal settle speaks.
|
|
||||||
* **Menu** — verified via driver scenarios 2026-10-18 (Refs #23): the menu
|
|
||||||
lists auto-speak/voice/device/player/cap/test-phrase; a voice chosen in the
|
|
||||||
menu is used by later utterances and survives a fresh load via the settings
|
|
||||||
file; a device chosen in the menu appears in the player invocation as
|
|
||||||
`--target <sink>`.
|
|
||||||
* **Segmented playback** — driver scenarios 2026-10-19: "First stop here.
|
|
||||||
Second one goes!!! Really? Third ... done" produced exactly 3 sequential
|
|
||||||
requests and 3 plays; "Model 3.5 ships today. Version 1,5 too." produced
|
|
||||||
exactly 2 (decimals never split); the markdown fixture's fenced code and
|
|
||||||
URL never reached the fixture endpoint; with `DANKVOICE_MAX=60` only the
|
|
||||||
leading segment was spoken.
|
|
||||||
* **Humanize + thinking (2026-10-19, Refs #27)** — driver scenarios cover the
|
|
||||||
rewrite round-trip (fixture chat route), the 404 → mechanical fallback,
|
|
||||||
thinking narrated as its own unit before the answer, `DANKVOICE_THINKING=0`
|
|
||||||
silencing it, and `narration: true` spoken once as the answer; the endpoint
|
|
||||||
status line shows the resolved humanize model from the @tiny role. Live
|
|
||||||
smoke against the real services: a markdown `/say` became 3 sequential
|
|
||||||
prose segments (mechanical cleanup of that text yields at most 2), and a
|
|
||||||
thinking+answer `agent_end` played thinking first. 22 green in
|
|
||||||
`tests/dankvoice.bats`.
|
|
||||||
* **Thinking-off fix (2026-10-19, Refs #32)** — replaying the exact rewrite
|
|
||||||
body against live frank-zealot: without `chat_template_kwargs.
|
|
||||||
enable_thinking: false` the reply arrives as 6893 `reasoning_content`
|
|
||||||
characters with `content: ""` in 6.9 s (twice the 6 s abort); with it,
|
|
||||||
369 clean characters in 0.55 s. Driver fixtures now pin the flag
|
|
||||||
(`thinkoff=false` on the recorded chat call), turn reasoning-only replies
|
|
||||||
into mechanical speech instead of silence, and time the menu's test
|
|
||||||
phrase (~0 ms handler). 26 green in `tests/dankvoice.bats`.
|
|
||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
|
|
||||||
* The TUI's own speak-aloud feature still narrates in Kokoro: its voice list is
|
* Auto-speak narrates **each turn** — the assistant's prose and thinking while
|
||||||
a compile-time enum in the harness bundle, and no setting routes it to
|
the agent is still working — plus the settled final answer, spoken once. A
|
||||||
frank-tts. Cloned voices live only on this file-producing/speaking path and
|
turn that lands while audio is still playing is dropped, not queued or
|
||||||
on the `tts` tool. See [Handy](/tools/handy.md) for the STT half.
|
interrupting; tool-only turns stay silent; `print`/`json` modes never speak,
|
||||||
* `agent_end` narrates thinking blocks and the final message only — mid-turn
|
so benches stay quiet. Narration goes through the humanizer, so it lags its
|
||||||
tool chatter is never spoken. Thinking covers the last assistant message;
|
turn by a rewrite round trip; `/say humanize off` removes the lag.
|
||||||
tool-loop gaps in earlier messages are skipped by design.
|
* Arming is read at startup only — see **Arming auto-speak** above. A session
|
||||||
* Humanized narration is serial: audio starts only after the @tiny rewrite
|
that never speaks is unarmed, not broken; `/say on` before debugging.
|
||||||
returns (≤ 6 s per unit, then frank-tts synthesis). Say `/say off`-grade
|
* frank-tts deployment, voice packs and the registry are owned by the
|
||||||
latency is the price of prose; `DANKVOICE_REWRITE=0` trades it back for
|
`serverAppConfigs` repo (`serverAppConfigs-docs/Dankclaw/Frank TTS.md`).
|
||||||
instant mechanical speech.
|
|
||||||
* frank-tts is LAN-only: no Caddy vhost fronts :7772, so this extension is
|
|
||||||
usable on the home network.
|
|
||||||
* Deployment, packs and the voice registry are owned by the
|
|
||||||
`serverAppConfigs` repo (`serverAppConfigs-docs/Dankclaw/Frank TTS.md`), not
|
|
||||||
by this one.
|
|
||||||
|
|
||||||
## Citations
|
## Citations
|
||||||
|
|
||||||
* Live probes on the workstation, 2026-10-18: `omp say --voice narrator1 …`,
|
* Package repo `dank/dankvoice` (README, `docs/decisions/`) — behaviour and
|
||||||
`curl http://192.168.0.143:7772/health`, `/v1/audio/voices`,
|
knob truth. `docs/decisions/2026-10-19-turn-narration.md` records why
|
||||||
`/v1/audio/…/speech` (invalid voice), `ffprobe` on the produced WAVs.
|
narration rides `turn_end` and not only `agent_end`.
|
||||||
* `omp --help` (extension discovery flags, `say`, `plugin`) and
|
* Gitea issues #21, #23, #27, #32 (drop-in history) and #34 (this cutover)
|
||||||
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts`
|
on `dank/command-center`.
|
||||||
(`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`).
|
* Live endpoint probes 2026-10-18/19 documented in those issues.
|
||||||
* The stock speak-aloud implementation — vocalizer pipeline `I6o`, utterance
|
|
||||||
segmenter `fHe` and speech enhancer `Qyr` with its prompt `T6o` — in
|
|
||||||
`@oh-my-pi/pi-coding-agent`'s `dist/cli.js`, read off the minified bundle
|
|
||||||
2026-10-19: dankvoice ports the mechanical and the enhanced halves, so an
|
|
||||||
LLM rewrite does happen before frank-tts.
|
|
||||||
* Gitea issue #21 on `dank/command-center` (the work item this page ships with).
|
|
||||||
* Gitea issue #23 on `dank/command-center` (the options-menu work item).
|
|
||||||
* Gitea issue #27 on `dank/command-center` (the enhanced-narration work item).
|
|
||||||
* Gitea issue #32 on `dank/command-center` (the latency/silence fix work item).
|
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
* [Core Documentation](/core/index.md) — the live harness configuration.
|
* [Core Documentation](/core/index.md) — the live harness configuration.
|
||||||
* [Handy](/tools/handy.md) — offline speech-to-text, the input half of voice.
|
* [Handy](/tools/handy.md) — offline speech-to-text, the input half of voice.
|
||||||
* `serverAppConfigs-docs/Dankclaw/Frank TTS.md` — the frank-tts service runbook.
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ These files define the structural conventions that govern the agent system itsel
|
|||||||
|
|
||||||
* [AGENTS.md Format Specification](agents-md-format.md) — Canonical structure and conventions for the system-level instruction file that governs agent behavior.
|
* [AGENTS.md Format Specification](agents-md-format.md) — Canonical structure and conventions for the system-level instruction file that governs agent behavior.
|
||||||
* [Pi Settings System](settings.md) — pi-era `settings.json` record (archive): providers, models, packages, subagent overrides.
|
* [Pi Settings System](settings.md) — pi-era `settings.json` record (archive): providers, models, packages, subagent overrides.
|
||||||
* [dankvoice](dankvoice.md) — Cloned-voice vocalization extension: `/say` and auto-speak over frank-tts, endpoint read from `agent/models.yml`.
|
* [dankvoice](dankvoice.md) — Voice output is the `dank/dankvoice` package (`pi install git:`); this page keeps fleet wiring and the cutover procedure.
|
||||||
* [OMP Status Line Customization](statusline.md) — How presets, composer shapes, and segment arrays actually interact; the fleet's tuned bar. (moved from /pi/, 2026-10-19)
|
* [OMP Status Line Customization](statusline.md) — How presets, composer shapes, and segment arrays actually interact; the fleet's tuned bar. (moved from /pi/, 2026-10-19)
|
||||||
* [OMP Subagent Limits & Fan-out Policy](subagent-limits.md) — Live caps: concurrency 2, no nested spawns (the `maxRecursionDepth: 0` trap), per-endpoint in-flight ceilings, isolation policy. (moved from /pi/, 2026-10-19)
|
* [OMP Subagent Limits & Fan-out Policy](subagent-limits.md) — Live caps: concurrency 2, no nested spawns (the `maxRecursionDepth: 0` trap), per-endpoint in-flight ceilings, isolation policy. (moved from /pi/, 2026-10-19)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,39 @@ lesson, a few lines. Full incident narratives are recoverable from git
|
|||||||
(`git log --follow omp-docs/log.md`); knowledge that outlives an entry lives
|
(`git log --follow omp-docs/log.md`); knowledge that outlives an entry lives
|
||||||
on its concept page, linked here. Never re-expand an entry in place.
|
on its concept page, linked here. Never re-expand an entry in place.
|
||||||
|
|
||||||
|
## 2026-10-19 — dankvoice auto-speak armed at startup
|
||||||
|
|
||||||
|
Sessions spoke only on explicit `/say`: the settings file carried no `auto`
|
||||||
|
key, and arming is session-scoped by design (`/say on` never persists). Set
|
||||||
|
`"auto": true` in `~/.omp/cache/dankvoice.json`. Same report, second half:
|
||||||
|
auto-speak listened only to `agent_end` — a settle that fires once per prompt
|
||||||
|
and is skipped outright when the harness schedules a continuation — so nothing
|
||||||
|
was narrated while an agent worked; fixed in dank/dankvoice#3, which narrates
|
||||||
|
`turn_end` as well. Law: silence at session start is a config state — read
|
||||||
|
`/say status` for `auto-speak on`, then watch `pgrep -a pw-play` for one player
|
||||||
|
per segment, before blaming the endpoint. Law: whether the harness delivers an
|
||||||
|
event to extensions is settled in minutes by a throwaway probe extension that
|
||||||
|
timestamps `turn_end`/`agent_end` — never inferred from audio timing, which the
|
||||||
|
humanizer lags by seconds. Details: [dankvoice](core/dankvoice.md).
|
||||||
|
|
||||||
|
## 2026-10-19 — LazyVim installed as an NVIM_APPNAME profile
|
||||||
|
|
||||||
|
`lazyvim` had never been installed here — shell history shows the command typed
|
||||||
|
twice and dropped; `~/.config/nvim` held only an orphaned `matugen.lua` palette
|
||||||
|
that nothing loaded. Now: LazyVim starter in `dotfiles/lazyvim/` symlinked to
|
||||||
|
`~/.config/lazyvim`, a `lazyvim` zsh function, and the palette applied through
|
||||||
|
`RRethy/base16-nvim` on `ColorScheme`. Law: the distro stays out of
|
||||||
|
`~/.config/nvim` so bare `nvim` keeps working as a vanilla quick-edit editor.
|
||||||
|
Details: [neovim](tools/neovim.md).
|
||||||
|
|
||||||
|
## 2026-10-19 — dankvoice cut over to the `dank/dankvoice` package (Refs #34)
|
||||||
|
|
||||||
|
Drop-in `agent/extensions/dankvoice.ts` and its 26-test suite retired; the
|
||||||
|
package carries the behaviour (41/41 black-box suite) and is installed with
|
||||||
|
`pi install git:`. Law: install and prove `/say status` speaks on a box
|
||||||
|
*before* pulling the cutover commit — the shared settings file keeps menu
|
||||||
|
state across both transports, so the swap is stateless once installed.
|
||||||
|
|
||||||
## 2026-10-19 — dankvoice: silence and timeouts, root-caused (Refs #32)
|
## 2026-10-19 — dankvoice: silence and timeouts, root-caused (Refs #32)
|
||||||
|
|
||||||
The humanizer's raw `fetch` to frank-zealot omitted `chat_template_kwargs.
|
The humanizer's raw `fetch` to frank-zealot omitted `chat_template_kwargs.
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ Applications that ship with or are configured on CachyOS with Niri on the workst
|
|||||||
|
|
||||||
| Application | Version | Purpose |
|
| Application | Version | Purpose |
|
||||||
|-------------|---------|---------|
|
|-------------|---------|---------|
|
||||||
| ](ghostty/index.md) | 1.3.1 | Terminal emulator |
|
| [Ghostty](ghostty/index.md) | 1.3.1 | Terminal emulator |
|
||||||
| **Firefox** | 153.0 | Primary browser |
|
| **Firefox** | 153.0 | Primary browser |
|
||||||
| **Chromium** | 150.0 | Secondary browser |
|
| **Chromium** | 150.0 | Secondary browser |
|
||||||
| **Nautilus** | 50.2.2 | File manager |
|
| **Nautilus** | 50.2.2 | File manager |
|
||||||
| **Neovim** | 0.12.4 | Text editor |
|
| [Neovim](neovim.md) | 0.12.6 | Text editor — vanilla `nvim` plus the `lazyvim` profile |
|
||||||
| **Steam** | 1.0.0.86 | Gaming platform |
|
| **Steam** | 1.0.0.86 | Gaming platform |
|
||||||
|
|
||||||
## System Utilities
|
## System Utilities
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Configuration guides and reference material for installed software. Each tool ha
|
|||||||
* [live-sync](live-sync.md) — Heartbeat daemon committing machine-written paths (memories, managed-skills, live config, dotfiles) in the command-center checkout; systemd user timer, alerts via ntfy.
|
* [live-sync](live-sync.md) — Heartbeat daemon committing machine-written paths (memories, managed-skills, live config, dotfiles) in the command-center checkout; systemd user timer, alerts via ntfy.
|
||||||
* [llama.cpp](llama-cpp/index.md) — Local LLM inference server supporting GGUF models with OpenAI-compatible API.
|
* [llama.cpp](llama-cpp/index.md) — Local LLM inference server supporting GGUF models with OpenAI-compatible API.
|
||||||
* [linux-power-management](linux-power-management.md) — Sleep/wake troubleshooting, NVIDIA GPU power management, and diagnostics for Linux systems.
|
* [linux-power-management](linux-power-management.md) — Sleep/wake troubleshooting, NVIDIA GPU power management, and diagnostics for Linux systems.
|
||||||
|
* [neovim](neovim.md) — Two editors in one binary: vanilla `nvim` for quick edits and the `lazyvim` NVIM_APPNAME profile, with the base16 wallpaper-palette hook and fresh-box install.
|
||||||
* [niri](niri/index.md) — Scrollable-tiling Wayland compositor — configuration, keybindings, and architecture for the workstation.
|
* [niri](niri/index.md) — Scrollable-tiling Wayland compositor — configuration, keybindings, and architecture for the workstation.
|
||||||
* [noctalia-shell](noctalia-shell.md) — Native desktop environment for Niri — launcher, control center, settings, lock screen, session menu, hotkey overlay, media controls, and system management.
|
* [noctalia-shell](noctalia-shell.md) — Native desktop environment for Niri — launcher, control center, settings, lock screen, session menu, hotkey overlay, media controls, and system management.
|
||||||
* [tmux](tmux.md) — Terminal multiplexer configuration, extended-keys setup, keyboard input best practices, and reload/restart procedures.
|
* [tmux](tmux.md) — Terminal multiplexer configuration, extended-keys setup, keyboard input best practices, and reload/restart procedures.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
title: Neovim and LazyVim
|
||||||
|
description: Two nvim entry points on the workstations — vanilla `nvim` and the `lazyvim` NVIM_APPNAME profile — plus the wallpaper palette hook and the fresh-box install procedure.
|
||||||
|
tags:
|
||||||
|
- editor
|
||||||
|
- nvim
|
||||||
|
- lazyvim
|
||||||
|
- dotfiles
|
||||||
|
- blankpad
|
||||||
|
verified: 2026-10-19
|
||||||
|
timestamp: 2026-10-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# Neovim and LazyVim
|
||||||
|
|
||||||
|
Two editors share one binary. Plain `nvim` is **vanilla** (no user config at
|
||||||
|
all — quick edits, agent-spawned edits, `git commit` buffers). The LazyVim
|
||||||
|
distribution lives in a **separate `NVIM_APPNAME` profile**, reached with the
|
||||||
|
`lazyvim` shell function. Keeping them apart is deliberate: a distro in
|
||||||
|
`~/.config/nvim` would slow down and surprise every throwaway `nvim file` call.
|
||||||
|
|
||||||
|
| | vanilla | LazyVim |
|
||||||
|
|---|---|---|
|
||||||
|
| command | `nvim` | `lazyvim` (= `NVIM_APPNAME=lazyvim nvim`) |
|
||||||
|
| config | `~/.config/nvim` (empty) | `~/.config/lazyvim` → tracked at `dotfiles/lazyvim/` |
|
||||||
|
| plugins/state | none | `~/.local/share/lazyvim`, `~/.local/state/lazyvim`, `~/.cache/lazyvim` — runtime, **never tracked** |
|
||||||
|
| vim (not nvim) | `~/.vimrc` absent → Vim 9.2 stock defaults | — |
|
||||||
|
|
||||||
|
The function is defined in `dotfiles/zz-blankpad/zsh/.zshrc`; `vim` and bare
|
||||||
|
`nvim` stay untouched on purpose.
|
||||||
|
|
||||||
|
## Fresh box
|
||||||
|
|
||||||
|
1. `git clone http://192.168.0.143:3000/dank/command-center.git ~/.omp` — the
|
||||||
|
profile arrives as `dotfiles/lazyvim/` (dotfiles tier, see
|
||||||
|
[live-sync](live-sync.md)).
|
||||||
|
2. `ln -s ~/.omp/dotfiles/lazyvim ~/.config/lazyvim` — files, not the whole
|
||||||
|
directory, are the repo convention elsewhere, but this tree is nvim-only and
|
||||||
|
the plugin manager rewrites `lazy-lock.json` in place.
|
||||||
|
3. `NVIM_APPNAME=lazyvim nvim --headless "+Lazy! sync" +qa` — bootstraps
|
||||||
|
lazy.nvim, then every plugin. Needs network (GitHub) once.
|
||||||
|
4. `:LazyHealth` inside `lazyvim` to confirm.
|
||||||
|
|
||||||
|
Requirements met on blankpad: nvim ≥ 0.11 with LuaJIT, git, ripgrep, `fd`,
|
||||||
|
`fzf`, `lazygit`, a Nerd Font. `tree-sitter` CLI is **not** installed and is
|
||||||
|
**not** needed — nvim-treesitter downloads/compiles parsers with `cc` on the
|
||||||
|
first real session. Language servers install on demand through mason.
|
||||||
|
|
||||||
|
## The wallpaper palette (`lua/matugen.lua`)
|
||||||
|
|
||||||
|
LazyVim's colorscheme is overridden by a base16 palette kept in
|
||||||
|
`lua/matugen.lua`, applied through `RRethy/base16-nvim` on every `ColorScheme`
|
||||||
|
event (`lua/plugins/matugen.lua`) — so `:colorscheme <anything>` still lands on
|
||||||
|
the palette, and `v:colors_name` keeps reporting the underlying theme, which
|
||||||
|
LazyVim's own plugin tweaks depend on.
|
||||||
|
|
||||||
|
The module also registers a `SIGUSR1` handler that re-requires itself: a real
|
||||||
|
`matugen` install (theme generator, not installed here) writing this file from
|
||||||
|
a template can hot-retheme a live editor with `pkill -USR1 -x nvim`. Without
|
||||||
|
matugen the palette is a static snapshot — the file is the source of truth.
|
||||||
|
|
||||||
|
To recolor by hand: edit the `base00`–`base0F` table, then send `SIGUSR1` (or
|
||||||
|
restart). Highlight groups for pickers live below the table; unlisted groups are
|
||||||
|
harmless no-ops.
|
||||||
|
|
||||||
|
## Do not
|
||||||
|
|
||||||
|
* Do not clone `LazyVim/starter` over `~/.config/nvim` — the official recipe,
|
||||||
|
but it destroys the vanilla quick-edit path this box relies on.
|
||||||
|
* Do not commit `~/.local/share/lazyvim` (≈200 MB of checkouts). `lazy-lock.json`
|
||||||
|
inside the profile *is* tracked: it pins plugin revisions.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
* [applications](applications.md) — the editor's place in the CachyOS/Niri stack.
|
||||||
|
* [tmux](tmux.md) — extended-keys setup that the same terminals need.
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
#!/usr/bin/env bats
|
|
||||||
# tests/dankvoice.bats — black-box tests for agent/extensions/dankvoice.ts
|
|
||||||
#
|
|
||||||
# The extension's contract, from outside: it registers a `say` command and an
|
|
||||||
# `agent_end` listener; what is observable is (a) the JSON it POSTs to the
|
|
||||||
# speech route, (b) the file it hands to the player, and (c) the notifications
|
|
||||||
# and select-lists it renders. A fixture HTTP server stands in for frank-tts
|
|
||||||
# (it answers the speech route with a fixed WAV, the roster route with a JSON
|
|
||||||
# array, and everything else with 404), a stub player stands in for pw-play
|
|
||||||
# (it records its full argv and copies the .wav it was given), and a fixture
|
|
||||||
# `wpctl` in $WORK/bin stands in for wireplumber's sink enumeration.
|
|
||||||
#
|
|
||||||
# Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY
|
|
||||||
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink
|
|
||||||
# name the fixture wpctl reports), DV_MODELS (models.yml fixture),
|
|
||||||
# DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override),
|
|
||||||
# DV_MODE (the harness mode reported to the extension), DV_REWRITE (fixture
|
|
||||||
# behavior for the humanizer route: prose | echo | reasoning | stall, with
|
|
||||||
# DV_STALL_MS).
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
|
||||||
WORK="$(mktemp -d)"
|
|
||||||
|
|
||||||
export DV_EXT="$REPO/agent/extensions/dankvoice.ts"
|
|
||||||
export DV_STUB="$WORK/player.sh"
|
|
||||||
export DV_STUB_LOG="$WORK/player.log"
|
|
||||||
export DV_COPY="$WORK/played.wav"
|
|
||||||
export DV_EMPTY_BIN="$WORK/emptybin"
|
|
||||||
mkdir -p "$DV_EMPTY_BIN"
|
|
||||||
|
|
||||||
cat > "$DV_STUB" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '%s\n' "$*" >> "$DV_STUB_LOG"
|
|
||||||
if [ -n "${DV_TIMING_LOG:-}" ]; then printf 'START %s\n' "$(date +%s%3N)" >> "$DV_TIMING_LOG"; fi
|
|
||||||
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
|
|
||||||
sleep "${DV_HOLD:-0.1}"
|
|
||||||
if [ -n "${DV_TIMING_LOG:-}" ]; then printf 'END %s\n' "$(date +%s%3N)" >> "$DV_TIMING_LOG"; fi
|
|
||||||
SH
|
|
||||||
chmod +x "$DV_STUB"
|
|
||||||
|
|
||||||
# the same stub reachable under the canonical player names, plus a fixture
|
|
||||||
# wpctl so output-device enumeration is deterministic on any machine
|
|
||||||
mkdir -p "$WORK/bin"
|
|
||||||
ln -s "$DV_STUB" "$WORK/bin/pw-play"
|
|
||||||
export DV_PWPLAY="$WORK/bin/pw-play"
|
|
||||||
export DV_WPCTL="alsa_output.usb-KT.0"
|
|
||||||
cat > "$WORK/bin/wpctl" <<'WPC'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '57\talsa_output.pci-0000_07_00.6.HiFi__Speaker__sink\taudio/sink\t\n116\t%s\taudio/sink\t*\n' "$DV_WPCTL"
|
|
||||||
WPC
|
|
||||||
chmod +x "$WORK/bin/wpctl"
|
|
||||||
|
|
||||||
# settings live in an isolated file so menu choices never leak between tests
|
|
||||||
export DANKVOICE_CONFIG="$WORK/settings.json"
|
|
||||||
|
|
||||||
# models.yml fixture: a chat provider first, the speech provider second
|
|
||||||
export DV_MODELS="$WORK/models.yml"
|
|
||||||
cat > "$DV_MODELS" <<'YML'
|
|
||||||
providers:
|
|
||||||
"chat (box)":
|
|
||||||
baseUrl: http://10.0.0.1:9999/v1
|
|
||||||
models:
|
|
||||||
- id: chatmodel
|
|
||||||
api: openai-completions
|
|
||||||
"speech (box)":
|
|
||||||
baseUrl: http://127.0.0.1:1/v1
|
|
||||||
auth: none
|
|
||||||
models:
|
|
||||||
- id: speechmodel
|
|
||||||
api: openai-speech
|
|
||||||
YML
|
|
||||||
|
|
||||||
# omp config.yml fixture: the @tiny role points at the chat provider above
|
|
||||||
export DV_OMPCONFIG="$WORK/ompconfig.yml"
|
|
||||||
cat > "$DV_OMPCONFIG" <<'CFY'
|
|
||||||
modelRoles:
|
|
||||||
tiny: chat (box)/chatmodel:off
|
|
||||||
CFY
|
|
||||||
|
|
||||||
cat > "$WORK/driver.ts" <<'TS'
|
|
||||||
// Black-box driver: stand up the frank-tts fixture, load the extension against
|
|
||||||
// it, invoke one scenario, print what the endpoint received and what got played.
|
|
||||||
import { createServer } from "node:http";
|
|
||||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
||||||
import { dirname } from "node:path";
|
|
||||||
|
|
||||||
const scenario = process.argv[2] ?? "speak";
|
|
||||||
const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
|
||||||
const started = Date.now();
|
|
||||||
interface Call {
|
|
||||||
url: string | undefined;
|
|
||||||
body: string;
|
|
||||||
at: number;
|
|
||||||
}
|
|
||||||
const received: Call[] = [];
|
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
req.on("end", () => {
|
|
||||||
const at = Date.now() - started;
|
|
||||||
const body = Buffer.concat(chunks).toString("utf8");
|
|
||||||
received.push({ url: req.url, body, at });
|
|
||||||
if (req.url?.endsWith("/audio/voices")) {
|
|
||||||
res.setHeader("content-type", "application/json");
|
|
||||||
res.end(JSON.stringify(["narrator1", "narrator2"]));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (req.url?.endsWith("/audio/speech")) {
|
|
||||||
res.setHeader("content-type", "audio/wav");
|
|
||||||
res.end(AUDIO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (process.env.DV_REWRITE && req.url?.endsWith("/chat/completions")) {
|
|
||||||
const respond = () => {
|
|
||||||
res.setHeader("content-type", "application/json");
|
|
||||||
const spoken = process.env.DV_REWRITE === "echo"
|
|
||||||
? String(JSON.parse(body).messages?.[1]?.content ?? "")
|
|
||||||
: "Spoken, humanized words.";
|
|
||||||
const verdict = process.env.DV_REWRITE === "reasoning"
|
|
||||||
? { choices: [{ message: { content: "", reasoning_content: "Long internal deliberation that never reaches the reply field." } }] }
|
|
||||||
: { choices: [{ message: { content: spoken } }] };
|
|
||||||
res.end(JSON.stringify(verdict));
|
|
||||||
};
|
|
||||||
if (process.env.DV_REWRITE === "stall") setTimeout(respond, Number(process.env.DV_STALL_MS ?? 200));
|
|
||||||
else respond();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end("{}");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
||||||
const port = (server.address() as { port: number }).port;
|
|
||||||
|
|
||||||
if (scenario === "endpoint") {
|
|
||||||
process.env.DANKVOICE_MODELS = process.env.DV_MODELS;
|
|
||||||
} else {
|
|
||||||
process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`;
|
|
||||||
}
|
|
||||||
process.env.DANKVOICE_OMPCONFIG = process.env.DV_OMPCONFIG;
|
|
||||||
if (scenario !== "endpoint") process.env.DANKVOICE_REWRITE_URL = `http://127.0.0.1:${port}/v1`;
|
|
||||||
if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin";
|
|
||||||
else process.env.PATH = `${dirname(process.env.DV_STUB ?? ".")}/bin:${process.env.PATH ?? ""}`;
|
|
||||||
process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_PLAYER_BIN ?? process.env.DV_STUB;
|
|
||||||
|
|
||||||
const extension = await import(process.env.DV_EXT as string);
|
|
||||||
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
|
|
||||||
const events = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
|
|
||||||
extension.default({
|
|
||||||
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options),
|
|
||||||
on: (event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) => events.set(event, handler),
|
|
||||||
});
|
|
||||||
const answers = (process.env.DV_SELECT ?? "").split("|").filter((answer) => answer !== "");
|
|
||||||
let answerIndex = 0;
|
|
||||||
const ctx = {
|
|
||||||
mode: process.env.DV_MODE ?? "tui",
|
|
||||||
ui: {
|
|
||||||
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
|
|
||||||
setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`),
|
|
||||||
select: (title: string, options: string[]) => {
|
|
||||||
console.log(`SELECT ${title} ${options.join("|")}`);
|
|
||||||
const answer = answerIndex < answers.length ? answers[answerIndex] : undefined;
|
|
||||||
answerIndex += 1;
|
|
||||||
return Promise.resolve(answer ?? options[0]);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const say = commands.get("say");
|
|
||||||
const agentEnd = events.get("agent_end");
|
|
||||||
const answer = (text: string) => ({ messages: [{ role: "assistant", content: [{ type: "text", text }] }] });
|
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 800));
|
|
||||||
|
|
||||||
if (!say) {
|
|
||||||
console.log("COMMANDS none");
|
|
||||||
} else {
|
|
||||||
console.log(`COMMANDS ${[...commands.keys()].join(",")} EVENTS ${[...events.keys()].join(",")}`);
|
|
||||||
switch (scenario) {
|
|
||||||
case "speak":
|
|
||||||
await say.handler("hello world", ctx);
|
|
||||||
break;
|
|
||||||
case "voice":
|
|
||||||
await say.handler("voice narrator2", ctx);
|
|
||||||
await say.handler("good morning", ctx);
|
|
||||||
break;
|
|
||||||
case "menu":
|
|
||||||
await say.handler("menu", ctx);
|
|
||||||
break;
|
|
||||||
case "empty":
|
|
||||||
await say.handler("", ctx);
|
|
||||||
break;
|
|
||||||
case "devicemenu":
|
|
||||||
await say.handler("menu", ctx);
|
|
||||||
await say.handler("hello world", ctx);
|
|
||||||
break;
|
|
||||||
case "auto":
|
|
||||||
await say.handler("on", ctx);
|
|
||||||
await agentEnd?.(answer("the answer"), ctx);
|
|
||||||
await settle();
|
|
||||||
await say.handler("off", ctx);
|
|
||||||
await agentEnd?.(answer("the second answer"), ctx);
|
|
||||||
break;
|
|
||||||
case "continuation":
|
|
||||||
await say.handler("on", ctx);
|
|
||||||
await agentEnd?.({ ...answer("not yet"), willContinue: true }, ctx);
|
|
||||||
await settle();
|
|
||||||
await agentEnd?.(answer("the settled answer"), ctx);
|
|
||||||
break;
|
|
||||||
case "printmode":
|
|
||||||
await say.handler("on", ctx);
|
|
||||||
await agentEnd?.(answer("quiet"), ctx);
|
|
||||||
break;
|
|
||||||
case "voices":
|
|
||||||
await say.handler("voices", ctx);
|
|
||||||
break;
|
|
||||||
case "endpoint":
|
|
||||||
await say.handler("status", ctx);
|
|
||||||
break;
|
|
||||||
case "markdown":
|
|
||||||
await say.handler("## Title\n\nUse `pw-play` here.\n\n```bash\nnpm i x\n```\n\nsee [docs](http://example.test) and **bold**\n- a\n- b", ctx);
|
|
||||||
break;
|
|
||||||
case "truncate":
|
|
||||||
await say.handler("Sentence one is here. ".repeat(40), ctx);
|
|
||||||
break;
|
|
||||||
case "sentences":
|
|
||||||
await say.handler("First stop here. Second one goes!!! Really? Third ... done", ctx);
|
|
||||||
break;
|
|
||||||
case "decimals":
|
|
||||||
await say.handler("Model 3.5 ships today. Version 1,5 too.", ctx);
|
|
||||||
break;
|
|
||||||
case "rewrite":
|
|
||||||
await say.handler("The deploy failed because a secret was missing.", ctx);
|
|
||||||
break;
|
|
||||||
case "thinking":
|
|
||||||
await say.handler("on", ctx);
|
|
||||||
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Considering the wiring." }, { type: "text", text: "The answer is nine." }] }] }, ctx);
|
|
||||||
break;
|
|
||||||
case "testphrase": {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
await say.handler("menu", ctx);
|
|
||||||
console.log(`HANDLER_MS ${Date.now() - startedAt}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "narration":
|
|
||||||
await say.handler("on", ctx);
|
|
||||||
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Here is the result.", narration: true }] }] }, ctx);
|
|
||||||
break;
|
|
||||||
case "noplayer":
|
|
||||||
await say.handler("speak anyway", ctx);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const call of received) {
|
|
||||||
const parsed: unknown = JSON.parse(call.body === "" ? "{}" : call.body);
|
|
||||||
const field = (key: string): string => {
|
|
||||||
if (parsed && typeof parsed === "object" && key in parsed) {
|
|
||||||
const value = parsed[key];
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
}
|
|
||||||
return "-";
|
|
||||||
};
|
|
||||||
const kwargs = parsed && typeof parsed === "object" && "chat_template_kwargs" in parsed
|
|
||||||
? (parsed as { chat_template_kwargs?: { enable_thinking?: unknown } }).chat_template_kwargs
|
|
||||||
: undefined;
|
|
||||||
const think = kwargs && typeof kwargs === "object" && "enable_thinking" in kwargs ? String(kwargs.enable_thinking) : "absent";
|
|
||||||
console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}${call.url?.endsWith("/chat/completions") ? ` thinkoff=${think}` : ""}`);
|
|
||||||
}
|
|
||||||
console.log(`CALLS ${received.filter((call) => call.url?.endsWith("/audio/speech")).length}`);
|
|
||||||
const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : "";
|
|
||||||
console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`);
|
|
||||||
if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`);
|
|
||||||
if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
|
|
||||||
server.close();
|
|
||||||
TS
|
|
||||||
|
|
||||||
export DV_DRIVER="$WORK/driver.ts"
|
|
||||||
}
|
|
||||||
|
|
||||||
teardown() {
|
|
||||||
rm -rf "$WORK"
|
|
||||||
}
|
|
||||||
|
|
||||||
say_run() {
|
|
||||||
run env DV_SELECT="${2:-}" bun "$DV_DRIVER" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "the extension registers the say command and an agent_end listener" {
|
|
||||||
say_run speak
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"COMMANDS say EVENTS agent_end"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "speaking posts the text to the speech route" {
|
|
||||||
say_run speak
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"CALL /v1/audio/speech model=frank-tts format=wav voice=- input=hello world"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "one utterance is played once, as exactly the bytes the endpoint sent" {
|
|
||||||
say_run speak
|
|
||||||
[[ "$output" == *"PLAYS 1"* ]]
|
|
||||||
[[ "$output" == *"COPIED 24"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "the voice is named in the request only when the user chose one" {
|
|
||||||
say_run speak
|
|
||||||
[[ "$output" == *"voice=- input=hello world"* ]]
|
|
||||||
say_run voice
|
|
||||||
[[ "$output" == *"voice=narrator2 input=good morning"* ]]
|
|
||||||
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "auto-speak narrates the final answer once and stays silent after it is disarmed" {
|
|
||||||
say_run auto
|
|
||||||
[[ "$output" == *"PLAYS 1"* ]]
|
|
||||||
[[ "$output" == *"input=the answer"* ]]
|
|
||||||
[[ "$output" != *"the second answer"* ]]
|
|
||||||
[[ "$output" == *"STATUS dankvoice 🔊"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "auto-speak stays silent outside the interactive modes" {
|
|
||||||
DV_MODE=print say_run printmode
|
|
||||||
[[ "$output" == *"PLAYS 0"* ]]
|
|
||||||
[[ "$output" != *"input=quiet"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "the voice list comes from the endpoint roster route" {
|
|
||||||
say_run voices
|
|
||||||
[[ "$output" == *"CALL /v1/audio/voices"* ]]
|
|
||||||
[[ "$output" == *"dankvoice voices: narrator1, narrator2"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "the endpoint is taken from the speech row of the models file, never the chat provider" {
|
|
||||||
say_run endpoint
|
|
||||||
[[ "$output" == *"http://127.0.0.1:1/v1/audio/speech"* ]]
|
|
||||||
[[ "$output" == *"humanize chatmodel"* ]]
|
|
||||||
[[ "$output" != *"10.0.0.1"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "markdown never reaches the speech engine" {
|
|
||||||
say_run markdown
|
|
||||||
[[ "$output" == *"input=Title"* ]]
|
|
||||||
[[ "$output" == *"input=Use pw-play here."* ]]
|
|
||||||
[[ "$output" == *"input=see docs and bold"* ]]
|
|
||||||
[[ "$output" != *"npm i x"* ]]
|
|
||||||
[[ "$output" != *"example.test"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "the spoken budget keeps only the leading whole segments" {
|
|
||||||
DANKVOICE_MAX=60 say_run truncate
|
|
||||||
[[ "$output" == *"CALLS 1"* ]]
|
|
||||||
[[ "$output" == *"PLAYS 1"* ]]
|
|
||||||
[[ "$output" == *"input=Sentence one is here."* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "a long answer is spoken as sequential sentence-sized requests" {
|
|
||||||
say_run sentences
|
|
||||||
[[ "$output" == *"CALLS 3"* ]]
|
|
||||||
[[ "$output" == *"PLAYS 3"* ]]
|
|
||||||
[[ "$output" == *"input=First stop here."* ]]
|
|
||||||
[[ "$output" == *"input=Second one goes!!! Really?"* ]]
|
|
||||||
[[ "$output" == *"input=Third ... done"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "decimals and comma-decimals never split a sentence" {
|
|
||||||
say_run decimals
|
|
||||||
[[ "$output" == *"CALLS 2"* ]]
|
|
||||||
[[ "$output" == *"input=Model 3.5 ships today."* ]]
|
|
||||||
[[ "$output" == *"input=Version 1,5 too."* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "an answer is rewritten into spoken prose before synthesis" {
|
|
||||||
DV_REWRITE=prose say_run rewrite
|
|
||||||
[[ "$output" == *"CALL /v1/chat/completions"* ]]
|
|
||||||
[[ "$output" == *"input=Spoken, humanized words."* ]]
|
|
||||||
[[ "$output" == *"CALLS 1"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "a failed rewrite speaks the mechanical text unchanged" {
|
|
||||||
say_run speak
|
|
||||||
[[ "$output" == *"input=hello world"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "thinking is narrated before the answer as its own segment" {
|
|
||||||
DV_REWRITE=echo say_run thinking
|
|
||||||
[[ "$output" == *"input=Considering the wiring."* ]]
|
|
||||||
[[ "$output" == *"input=The answer is nine."* ]]
|
|
||||||
[[ "$output" == *"CALLS 2"* ]]
|
|
||||||
local first second
|
|
||||||
first=$(grep -n "input=Considering the wiring\." <<<"$output" | head -1 | cut -d: -f1)
|
|
||||||
second=$(grep -n "input=The answer is nine\." <<<"$output" | head -1 | cut -d: -f1)
|
|
||||||
[ -n "$first" ] && [ -n "$second" ] && [ "$first" -lt "$second" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "thinking narration can be switched off" {
|
|
||||||
DANKVOICE_THINKING=0 DV_REWRITE=echo say_run thinking
|
|
||||||
[[ "$output" == *"input=The answer is nine."* ]]
|
|
||||||
[[ "$output" != *"input=Considering the wiring."* ]]
|
|
||||||
[[ "$output" == *"CALLS 1"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "narration-flagged thinking is spoken once as the answer" {
|
|
||||||
DV_REWRITE=echo say_run narration
|
|
||||||
[[ "$output" == *"input=Here is the result."* ]]
|
|
||||||
[[ "$output" == *"CALLS 1"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "a missing player is reported, not swallowed" {
|
|
||||||
say_run noplayer
|
|
||||||
[[ "$output" == *"NOTIFY error dankvoice: no audio player found"* ]]
|
|
||||||
[[ "$output" == *"PLAYS 0"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "a scheduled continuation is never narrated as the final answer" {
|
|
||||||
say_run continuation
|
|
||||||
[[ "$output" == *"PLAYS 1"* ]]
|
|
||||||
[[ "$output" == *"input=the settled answer"* ]]
|
|
||||||
[[ "$output" != *"input=not yet"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "the menu lists every knob and a plain /say opens it" {
|
|
||||||
say_run menu
|
|
||||||
[[ "$output" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|Thinking narration: on (toggle)|Humanize answers: on (tiny) (toggle)|Speak a test phrase"* ]]
|
|
||||||
[[ "$output" == *"NOTIFY info dankvoice: auto-speak armed (server default @ http://127.0.0.1:"* ]]
|
|
||||||
say_run empty
|
|
||||||
[[ "$output" == *"SELECT dankvoice — options"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "a voice picked from the menu is spoken by every later request" {
|
|
||||||
say_run menu "Voice: server default (change)|narrator2"
|
|
||||||
[[ "$output" == *"SELECT dankvoice — voice server default|narrator1|narrator2"* ]]
|
|
||||||
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
|
|
||||||
say_run speak
|
|
||||||
[[ "$output" == *"voice=narrator2 input=hello world"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "a device chosen from the menu becomes the player's output target" {
|
|
||||||
DV_PLAYER_BIN="$DV_PWPLAY" say_run menu "Output device: system default (change)|$DV_WPCTL"
|
|
||||||
[[ "$output" == *"SELECT dankvoice — output device system default|alsa_output.pci-0000_07_00.6.HiFi__Speaker__sink|$DV_WPCTL"* ]]
|
|
||||||
[[ "$output" == *"NOTIFY info dankvoice: output device = $DV_WPCTL"* ]]
|
|
||||||
DV_PLAYER_BIN="$DV_PWPLAY" say_run speak
|
|
||||||
[[ "$output" == *"PLAYER --target $DV_WPCTL"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "the rewrite asks the humanizer with thinking explicitly off" {
|
|
||||||
DV_REWRITE=prose say_run rewrite
|
|
||||||
[[ "$output" == *"input=- thinkoff=false"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "an answer hiding in reasoning_content speaks the mechanical text, not silence" {
|
|
||||||
DV_REWRITE=reasoning say_run rewrite
|
|
||||||
[[ "$output" == *"input=The deploy failed because a secret was missing."* ]]
|
|
||||||
[[ "$output" == *"CALLS 1"* ]]
|
|
||||||
[[ "$output" == *"PLAYS 1"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "a rewrite slower than a breath but under the timeout still speaks" {
|
|
||||||
DV_REWRITE=stall DV_STALL_MS=300 say_run rewrite
|
|
||||||
[[ "$output" == *"input=Spoken, humanized words."* ]]
|
|
||||||
[[ "$output" == *"CALLS 1"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "the menu test phrase speaks in the background instead of blocking the menu" {
|
|
||||||
DV_REWRITE=stall DV_STALL_MS=1500 say_run testphrase "Speak a test phrase"
|
|
||||||
[[ "$output" == *"NOTIFY info dankvoice: speaking the test phrase"* ]]
|
|
||||||
local ms
|
|
||||||
ms=$(grep -o 'HANDLER_MS [0-9]*' <<<"$output" | cut -d' ' -f2)
|
|
||||||
[ -n "$ms" ] && [ "$ms" -lt 1200 ]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user