live: heartbeat sync 2026-10-19T15:24:41-07:00 (Refs #11)

This commit is contained in:
Frank
2026-10-19 15:24:41 -07:00
parent 6e3e5594eb
commit c14c64d6c0
12 changed files with 452 additions and 129 deletions
@@ -1,89 +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, the measured RADV heap gate, render-node resolution on hybrid hosts, build/runtime dependency surface, co-tenancy measurement."
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."
---
Add a ggml/Vulkan inference tenant to an AMD APU (gfx1151 / Strix Halo iGPU) that already serves another engine: APU memory census, the RADV heap gate, the build/runtime dependency surface, and 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.
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 (gfx115x / Strix Halo / Ryzen AI) that **already serves a production tenant**. Answers: what budget actually exists, what gate decides feasibility before a model loads, and exactly which files a Vulkan build needs.
## 1. The usual VRAM reasoning is wrong on an APU
## Why 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.
`/sys/class/drm/card*/device/mem_info_vram_total` reports a small **carve-out** (2 GiB class), while the real working set lives in **GTT** — and GTT pages are *system pages*. A second GPU tenant therefore consumes host RAM from the same pool the first tenant and the OS draw from; there is no second memory to size against. The aperture is almost never the constraint; available RAM and shader time are.
## Census (read-only; run before proposing anything)
```bash
for c in /sys/class/drm/card*; do
v=$(cat $c/device/mem_info_vram_used 2>/dev/null); g=$(cat $c/device/mem_info_gtt_used 2>/dev/null)
[ -n "$v$g" ] && echo "$(basename $c) vram_used=$((v/1048576))MiB gtt_used=$((g/1048576))MiB vram_total=$(($(cat $c/device/mem_info_vram_total)/1048576))MiB"
done
cat /sys/class/drm/card*/device/mem_info_gtt_total # the aperture, in bytes
free -g # available is the number that bites
```
**Resolve the render node by driver, never by number** — on a hybrid host (dGPU + APU)
`renderD128` is often the *other* vendor's node:
```bash
for r in /sys/class/drm/renderD*; do
echo "$(basename $r) $(basename $(readlink -f $r/device/driver))"
done # gfx1151 example on a box with an RTX 3090: renderD128 = nvidia, renderD129 = amdgpu
```
Also check what the *userspace* offers before promising a backend:
```bash
dpkg -l | grep -iE "mesa-vulkan|vulkan-tools" # ICD present? probe installed?
ls /usr/share/vulkan/icd.d/ # radeon_icd.json = RADV available
ls <image>/usr/share/vulkan/icd.d/ # the ICD must exist INSIDE the container
```
## Gate 1 — the heap, before any model loads
Ask what heap the Vulkan driver hands a tenant on this APU, **in the same container image
that will host the engine**, with `/dev/dri` passed through:
```bash
podman run --rm --device /dev/dri -v <dir>:/probe:rw <engine-build-base> bash -lc '
apt-get update -qq && apt-get install -y -qq --no-install-recommends mesa-vulkan-drivers vulkan-tools
vulkaninfo --text > /probe/full.txt' # NOT --batch: that flag does not exist and prints help, rc=1
awk "/memoryHeaps: count/,/memoryTypes: count/" /probe/full.txt # sizes + budgets + DEVICE_LOCAL flag
```
Measured on gfx1151 (Strix Halo, 116 GiB aperture) with a production ROCm tenant already
loaded: `Radeon 8060S Graphics (RADV GFX1151)`, driver `radv`, API 1.4.318, heap 0
host-visible **38.67 GiB** (budget 32.58) and heap 1 `DEVICE_LOCAL` **77.33 GiB** (budget
**65.16**). Mesa splits the aperture roughly ⅓/⅔ on APUs, so **`DEVICE_LOCAL` is the
aperture, not the 2 GiB carve-out** — the "if the heap is the carve-out, give up" branch
does not trigger on this silicon. ggml asks for `DEVICE_LOCAL`, so a few-GiB engine lands
inside a tens-of-GiB budget and memory stops being the question. Both heaps are GTT: the
same host RAM, so a budget is a ceiling on *mappings*, not free silicon memory — `free`
still decides whether it can be touched.
Two probe traps: `vulkaninfo --batch` is not a flag in Ubuntu 24.04's `vulkan-tools` (it
prints the help page and exits 1), and a slim container has **no `nvidia_icd.json`**, so
Vulkan device 0 happens to be the iGPU there — an image that grows an NVIDIA ICD silently
reorders the list. Select by device *name*, never by index.
This is enumeration: no allocation, no compute — but it opens the render node on a
production card, so it is still a decision for the operator, not a routine probe.
## Gate 2 — contention, measured on the co-tenant
If the target shape is **co-tenancy** (both resident), the honest measurement is the *existing* tenant's TTFT and decode with the new tenant's work in flight — one request each, no ladder. Stopping the co-tenant is for benchmarks, not for a deployment shape. Expect compute-unit sharing, not memory pressure.
## The dependency surface people miss
1. **Build stage needs more than the flag.** ggml compiles its Vulkan shaders at build time: `-DGGML_VULKAN=ON` alone fails at configure without `libvulkan-dev` **and** `glslang-dev` (or `glslang-tools`). A CPU-only image's build stage typically installs only `cmake g++ git make`.
2. **The runtime stage needs an ICD, not just a device node.** `mesa-vulkan-drivers` must be installed *in the image*; `/dev/dri` without `radeon_icd.json` in the container buys nothing.
3. **Rootless/quadlet device access**: `AddDevice=/dev/dri` (or the iGPU's own node — see the census: it is not necessarily `renderD128`) + `GroupAdd=keep-groups`, and the host needs the world-writable udev rule (`KERNEL=="renderD*", MODE="0666"`) — systemd user-unit spawns carry no supplementary groups, so the render group never arrives.
4. Runtime selection is a device flag on the engine (`--device Vulkan0` for ggml-based tools), not an env var — and confirm which physical device that index resolves to after the image gains its ICD.
## Laws
- 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 treat the `vram_total` carve-out as capacity, and never subtract a "dedicated" figure from it to find headroom.
- Don't reason from `nvidia-smi`-style intuition: on this platform the useful numbers are `mem_info_gtt_used`, `free`, and RADV's heap budgets.
- Unheld fabric clock (`pp_dpm_fclk` = `auto`) is a hazard only when the NPU joins too; a pure iGPU pair is unaffected — check it before adding a third tenant.
- 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,44 +1,44 @@
---
name: dankvoice-bats-serialization
description: "Run dankvoice bats suites without hangs: serialize builds on shared dist/, DV_HOLD is seconds, stub needs sleep&wait trap before hold, timing tests assert CALLS/at not tail PLAYS."
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."
---
# Dankvoice bats: serialization and hang-proofing
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.
## Serialize builds on the shared artifact
`tests/behavior.bats` setup runs `bun run build` into the repo's shared `dist/index.js`
before EVERY test (`DV_EXT="$REPO/dist/index.js"`). Any second concurrent bats run,
`bun run build`, or `bun run typecheck` racing the same repo contends on that file and
can stall single tests past 60 s. NEVER run two bats processes (or bats + build) against
`~/Projects/dankvoice` at the same time — including harness background jobs. If a suite
gets backgrounded, kill it before launching any other repo command; re-run serially.
## The two serialization hazards
## The stub player's units and traps
- `DV_HOLD` is in **seconds** (`sleep "${DV_HOLD:-0.1}"`), not ms. `DV_HOLD=1500`
means a 25-minute "hung" test. Use `DV_HOLD=2` for multi-second holds.
- The stub must sleep as `sleep "${DV_HOLD:-0.1}" &` + `wait $!`, with
`trap 'printf "KILLED\n" >> "$DV_STUB_LOG"; exit 143' TERM` installed **before** the
sleep: a foreground `sleep` defers the trap until the sleep finishes, so SIGTERM-kill
tests (shutdown/interrupt pins) stay red and every kill test eats the full hold.
- Keep the `cp *.wav "$DV_COPY"` loop when editing the stub — the byte-equality test
depends on it.
### 1. `setup()` rebuilds `dist/` from `src/`
Every test runs `cd "$REPO" && bun run build` in `setup()`. Consequences:
## Timing-sensitive assertions
The driver prints `PLAYS` from the stub log at scenario end (~800 ms settle), so with a
long `DV_HOLD` in-flight playback is UNDERCOUNTED — assert `CALLS`/arrival timestamps,
not tail `PLAYS`. The fixture stamps every request with `at=` (ms since driver start,
comparable between CALL lines); prefetch overlap = `t2 - t1 < 1500` with `DV_HOLD=2`
(sequential chain cannot answer inside 2000 ms).
- **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.
## Diagnosing a hung bats test (deterministic recipe)
1. `timeout 45 bats -f "<name>" tests/behavior.bats; echo rc=$?` — rc=124 confirms hang.
2. Launch the same test as a background job, then probe live:
`pgrep -af "driver.ts|bats"` and `ps afx | grep -E "sleep|stub"` — a `sleep <N>` child
with N seconds in the stub log names the culprit (usually DV_HOLD misuse).
3. Detached `(cmd &)` inside a temp shell is KILLED when the shell exits — use the
harness background jobs for anything you must probe.
4. Direct driver repro: extract the heredoc with
`sed -n '/cat > "$WORK\/driver.ts" <<.TS./,/^TS$/p' tests/behavior.bats | sed '1d;$d' > $W/driver.ts`,
build a fresh ext, then run `bun $W/driver.ts <scenario>` with the env the setup block
exports. Caveat: hand-made fixture env can yield different segment counts than bats —
trust in-bats numbers for assertions.
### 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,56 @@
---
name: dankvoice-bundle-swap-red-proof
description: "Prove a dankvoice behavior test is red-before/green-after by pointing DV_EXT at the previously committed dist bundle, with a pinned scratch fixture dir."
---
Use when a new black-box `tests/behavior.bats` assertion in dankvoice must be shown to be red-capable (fails on the pre-change code) without `git stash`/worktree juggling, or when the old bundle must be compared to the new one.
## Why
The bats suite drives `dist/index.js` only (nothing imports `src/`), and the bundle is self-contained. So the *previous commit's* behavior is one `git show` away: write it to a file, point `DV_EXT` at it, run the same driver, and read the same `PLAYS n` / `CALL` / `NOTIFY` lines the test asserts.
## Procedure
1. Build the new bundle and export the old one:
```bash
cd ~/Projects/<dankvoice-checkout>
bun run build >/dev/null 2>&1
git show HEAD:dist/index.js > /tmp/old-dist.js
```
2. Stand up the fixtures in a **pinned** scratch dir — never `$(ls -dt /tmp/tmp.* | head -1)`: other live sessions on the box create `tmp.*` dirs continuously, and a glob picks *theirs*, which is how a red-proof silently becomes "Module not found" and then a false `PLAYS 0`.
```bash
W=/tmp/dv-redproof; rm -rf "$W"; mkdir -p "$W/bin"
```
3. Copy the **real** player stub, not a simplification. The driver counts plays from the stub log; a stub that only `sleep`s records nothing and reports `PLAYS 0` for every build, red and green alike. Mirror `setup()` in `tests/behavior.bats`:
```bash
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" # PATH fallback player
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"
sed -n '86,408p' tests/behavior.bats > "$W/driver.ts" # the inline heredoc driver
```
(Re-check those line bounds against the current file; the driver lives inside `setup()`.)
4. Export the env the driver expects and run old vs new against the SAME scenario:
```bash
export 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
for ext in /tmp/old-dist.js "$PWD/dist/index.js"; do
rm -f "$W/player.log"
DV_EXT="$ext" DV_PLAYER_BIN="$W/no-such-player" bun "$W/driver.ts" speak | grep -E '^PLAYS|^NOTIFY'
done
```
The driver sets its own fixture server URL and prepends `dirname "$DV_STUB")/bin` to `PATH` (see its `DANKVOICE_*` block), so the discovered-player fallback has somewhere real to land.
5. Add a **control**: same scenario with a valid player path. Old-vs-new differences that the control also shows are harness bugs, not behavior changes.
## Reading the result
- old `PLAYS 0` + new `PLAYS 1` + control `PLAYS 1` → the assertion is red-capable and the fix is real.
- identical results old vs new → the harness is lying (stub too thin, wrong scratch dir, or the scenario never reaches the changed path). Fix the harness before believing any green.
- File-mode assertions: pre-seed the file with the *old* mode (`printf … > file; chmod 644 file`) — a fresh write with `mode:` in `writeFileSync` only applies at creation, so a fresh file can look green while the old code is equally green.
## Gotchas
- Never poll the bats suite while doing this; `setup()` runs `bun run build`, so a concurrent `src/` edit gets compiled into someone else's run (see `dankvoice-bats-serialization`).
- `bun run build` does not typecheck; a stale identifier reference survives bundling and only fails at runtime — the suite, not the build, is the gate.
@@ -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.
@@ -1,9 +1,11 @@
---
name: gitea-merge-form-encoded
description: "Use when a Gitea API merge on Dankclaw (192.168.0.143:3000) returns HTTP 422 \"[Do]: Required\" — the merge endpoint needs form-encoded fields with the lowercase field name do, not the JSON body that PR creation accepts."
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 quirk, measured 2026-10-19 on `dank/serverAppConfigs` and again on `dank/dankvoice`.
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.
@@ -12,7 +14,7 @@ Creating a PR accepts a JSON body. **Merging does not**: `POST /api/v1/repos/{ow
Working call (form-encoded):
```bash
tok=$(sed -n 's|http://[^:]*:\([^@]*\)@.*|\1|p' ~/.config/secrets/gitea | head -1) # git credential.helper store file
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' \
@@ -25,6 +27,38 @@ curl -sS -X POST -w 'http %{http_code}\n' -K <(printf 'header = "Authorization:
- 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:
@@ -1,6 +1,6 @@
---
name: gitea-pr-api-cycle
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."
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.\""
---
# Gitea PR/issue cycle — Dankclaw API
@@ -14,7 +14,7 @@ Friction-earned 2026-10-18 (PRs #15–#20, issue #18) and re-earned 2026-10-19 o
tok=$(sed -nE 's#^https?://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)
[ "${#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" … ; rm -f "$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.
@@ -44,4 +44,8 @@ JSON `{"Do":"merge"}` (what this skill used to claim) and header variants all re
- 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 suffix of the transcript dir name (`ls -t ~/.omp/agent/sessions/` → `…_01a155bb-…`), and `cwd` is that session's own working directory. `local://<name>.md` artifacts live under that same session dir in `local/` — resolve with `read local://<name>.md` (its header prints the absolute path) instead of guessing.
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`.
- `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,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.
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1792444614846,
"lastConsolidatedAt": 1792448574868,
"lastResult": {
"at": 1792433353188,
"sessions": 1,
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1792444862643,
"lastConsolidatedAt": 1792448620710,
"lastResult": {
"at": 1792444218045,
"sessions": 1,
@@ -275,13 +275,13 @@ transition = [ "fade" ]
interval_seconds = 1200
[wallpaper.default]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_e7963w.jpg"
[wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_e7963w.jpg"
[wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_e7963w.jpg"
[wallpaper.monitors.DP-2]
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"
[wallpaper.monitors.eDP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_e7963w.jpg"
[weather]
unit = "imperial"