27 Commits

Author SHA1 Message Date
Claude
ce0e9b3846 fix(control): release upload field name 'file' to match ivctl multer (was 'release')
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 02:20:16 +10:00
Claude
173efc31e5 feat(control): IV Control admin app — owner-gated /api/control proxy to ivctl + Control view (applicants/instances/releases/tickets/groups) + sidebar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:59:52 +10:00
Claude
f9d2fa3493 feat(forge): 3D-printing/engineering hub page (Manyfold + Spoolman/OctoPrint cards), sidebar Apps entry 2026-06-14 14:46:11 +10:00
root
0f93f5d862 docs: cradle pack density tokens (gap/pad)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:12:26 +10:00
root
5706ed0203 docs: cradle pack adopts Void 2 typography (Cinzel/Cormorant/JetBrains)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:51:24 +10:00
root
144a0f1eb4 docs: enrich Cradle pack with 0.5–0.10 term keys (records/offerings/pursuits/gateways)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:05:11 +10:00
root
1d94dcae97 fix(voice): amplitude meter was masked by the dross-rec keyframe animation (2.14.1)
CSS animations override normal declarations — the old box-shadow pulse painted
over the level-driven shadow. .metered now disables the fallback pulse; added
sqrt gain so speech registers visibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:48:02 +10:00
root
3bd8ea399c feat: 2.14.0 — Eithan terminal toolbar, voice UX, Dross improvements framework
- Terminal renamed Eithan: mobile font A−/A+ (per-URL ttyd opts), same-origin
  xterm Copy/Paste buttons, scroll-to-live, touch-default 17px
- Dross voice: no keyboard pop after transcribe (fine-pointer only focus),
  autogrow textarea to ~5 lines, live amplitude meter on the mic while recording
- Dross improvements: propose_improvement tool (CSS layer, exfil-sanitized,
  owner-approved, per-improvement rollback/restore), public /improvements.css,
  Settings panel. External MCP registry unchanged (no tool leak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:35:32 +10:00
root
859dedb668 docs: extract Cradle identity pack for Infinite Void (lore stays private here)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:15:40 +10:00
root
bc86d3e282 chore: sync package-lock version to 2.13.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:11:38 +10:00
root
5d1eb2396b docs(dross): mark Phase 2 (voice) shipped (2.12.0/2.13.0) 2026-06-10 01:28:57 +10:00
root
70bdba1a24 feat(dross): voice Phase 2b — clip retention (2.13.0)
'Keep voice clips' setting (default off). When on, /api/voice/transcribe
saves the audio (0600) to the owner-only ZFS subvol at /var/lib/void/
voice-clips (CT 311 mp0, replicated to Z3) + a voice_clips row (migration
029, transcript+metadata in void-db). New clips list/play/delete API +
Settings UI. Storage path is configurable (VOICE_CLIPS_DIR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:27:40 +10:00
root
bc55da6b1e fix(dross): don't auto-focus input on open (no surprise mobile keyboard)
Keyboard now only appears when the user taps the input box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:15:15 +10:00
root
e29bacbda1 feat(dross): voice Phase 2a — local whisper transcribe + mic (2.12.0)
faster-whisper (small.en, GPU+CPU fallback) on CT 102 → POST
/api/voice/transcribe (multer→whisper client) → mic in the bubble
records (MediaRecorder), uploads, drops the transcript into the input
to review-and-send. Infra scripts in deploy/whisper/. Retention (P2b)
next. NOTE: mic needs a secure context (the https domain), not the LAN IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:00:10 +10:00
root
fc1e93a58f docs(dross): Phase 2 (voice) design spec
Local faster-whisper on CT 102, record→transcribe→review-send, and a
durable owner-only clip-retention store (transcript in void-db, audio on
a backed-up ZFS dataset — not void-app's ephemeral tier). Encryption-at-
rest noted as future.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:45:59 +10:00
root
2dc9d612de docs(dross): mark Phase 1 shipped (2.11.0) 2026-06-10 00:34:54 +10:00
root
e2be462ecb fix(dross): collapse shell to 2 columns; topbar ◆ summons Dross
Removing #rightrail left a dead 360px grid column that narrowed #main.
Shell grid is now sidebar+main; the topbar ◆ (was Toggle-companion-rail)
now dispatches dross-toggle to open/close the floating bubble. Remaining
.rail-* CSS + chrome.js toggleRail are dead no-ops (minor cleanup later).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:34:39 +10:00
root
6d5c3027ac chore: v2.11.0 — floating Dross chat (Phase 1) 2026-06-10 00:18:54 +10:00
root
262be3e332 test: update dashboard_layout defaults to include geom/extras (2.8.0 follow-up)
These two assertions asserted the pre-2.8.0 shape; the canvas feature
added geom+extras to the repo/route defaults. push.sh doesn't run unit
tests, so they went red unnoticed until the full vitest run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:17:48 +10:00
root
c502ccda48 feat(dross): Settings panel — avatar, accent, persona, voice-mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:08:26 +10:00
root
a67ff9e403 fix(dross): wire send button + drop host wrapper 2026-06-10 00:06:08 +10:00
root
3674811e40 feat(dross): global floating bubble; retire the right rail
Adds dross_bubble.js — a fixed FAB orb that opens a draggable,
anchored panel wired to wireAgentChat. Mic button rendered but
disabled (Phase 2). Swaps renderRightrail call in app.js; removes
dead <aside id="rightrail"> from index.html. rightrail.js kept in
place (unused).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:03:38 +10:00
root
ce8769d5a2 feat(dross): floating bubble + avatar styles 2026-06-10 00:00:48 +10:00
root
f52fb05f5e feat(dross): avatar component (soft-eye / wisp / motes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:58:03 +10:00
root
4535b03207 fix(dross): restore defensive try/catch around draft parsing (match companion.js) 2026-06-09 23:56:35 +10:00
root
1df0a905a2 feat(dross): global (space-less) Dross conversation + SSE turn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:54:01 +10:00
root
7a09b9f91c feat(dross): settings endpoint (avatar/accent/persona/voiceMode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:50:10 +10:00
45 changed files with 1946 additions and 49 deletions

View File

@@ -3,3 +3,8 @@ OWNER_TOKEN=CHANGE_ME_TO_LONG_RANDOM
PORT=3000
LOG_LEVEL=info
NODE_ENV=development
# IV Control admin proxy (/api/control/* -> ivctl admin API). Owner-only.
# Leave IVCTL_URL unset to disable the Control app (proxy returns 503).
IVCTL_URL=http://192.168.X.X:8080
IVCTL_ADMIN_TOKEN=CHANGE_ME_IVCTL_ADMIN_TOKEN

16
deploy/whisper/README.md Normal file
View File

@@ -0,0 +1,16 @@
# faster-whisper service (Dross voice STT)
Runs on **CT 102** (the Ollama box, `192.168.1.185`), bare-metal (no Docker), on the
RTX A2000 with CPU fallback. OpenAI-style `/transcribe` consumed by void-app
`lib/voice/whisper.js` (`WHISPER_URL=http://192.168.1.185:8001`).
## Install (on CT 102)
```
scp deploy/whisper/{server.py,setup.sh} root@192.168.1.185:/opt/whisper_server.py /root/setup.sh
ssh root@192.168.1.185 'bash /root/setup.sh && install -m644 /opt/whisper_server.py /opt/whisper/server.py && systemctl enable --now whisper'
curl http://192.168.1.185:8001/health # {"ok":true,"model":"small.en","device":"cuda"}
```
- venv at `/opt/whisper/venv`; model `small.en` (env `WHISPER_MODEL`); CUDA libs via
`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` pip wheels (LD_LIBRARY_PATH in the unit).
- GPU → CPU fallback is in `server.py` `load()`.
- **CT 102 disk was expanded +20G** (was 89% full) before install.

35
deploy/whisper/server.py Normal file
View File

@@ -0,0 +1,35 @@
import os, tempfile
from fastapi import FastAPI, UploadFile, File, HTTPException
from faster_whisper import WhisperModel
MODEL = os.environ.get("WHISPER_MODEL", "small.en")
app = FastAPI()
model = None
device_used = None
def load():
global model, device_used
try:
model = WhisperModel(MODEL, device="cuda", compute_type="int8_float16")
device_used = "cuda"
except Exception:
model = WhisperModel(MODEL, device="cpu", compute_type="int8")
device_used = "cpu"
load()
@app.get("/health")
def health():
return {"ok": True, "model": MODEL, "device": device_used}
@app.post("/transcribe")
async def transcribe(file: UploadFile = File(...)):
data = await file.read()
if not data:
raise HTTPException(400, "empty audio")
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
f.write(data); f.flush()
segments, info = model.transcribe(f.name, beam_size=1, vad_filter=True)
text = "".join(s.text for s in segments).strip()
return {"text": text, "language": info.language,
"duration": round(info.duration, 2), "device": device_used}

26
deploy/whisper/setup.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq python3-pip python3-venv ffmpeg >/dev/null
mkdir -p /opt/whisper
python3 -m venv /opt/whisper/venv
/opt/whisper/venv/bin/pip install -q --upgrade pip
/opt/whisper/venv/bin/pip install -q faster-whisper fastapi "uvicorn[standard]" python-multipart nvidia-cublas-cu12 nvidia-cudnn-cu12
SITE=/opt/whisper/venv/lib/python3.12/site-packages
cat > /etc/systemd/system/whisper.service <<UNIT
[Unit]
Description=faster-whisper transcription server (Dross voice)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/whisper
Environment=WHISPER_MODEL=small.en
Environment=LD_LIBRARY_PATH=${SITE}/nvidia/cublas/lib:${SITE}/nvidia/cudnn/lib
ExecStart=/opt/whisper/venv/bin/uvicorn server:app --host 0.0.0.0 --port 8001
Restart=on-failure
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
echo "deps+unit installed"

View File

@@ -0,0 +1,86 @@
{
"format": "iv-pack/1",
"id": "cradle",
"name": "Cradle \u2014 The Void",
"description": "The original lore: blackflame, the Sacred Valley, and the council. Private pack \u2014 Will Wight's IP, never shipped publicly.",
"tokens": {
"bg": "#0a0a0e",
"surface": "#14141c",
"surface2": "#1c1c26",
"ink": "#e8e6ed",
"ink-dim": "#888094",
"accent": "#ff4f2e",
"accent-ink": "#0a0a0e",
"ok": "#6fa86a",
"warn": "#d4a04a",
"bad": "#c45a4a",
"line": "#2a2a36",
"glow1": "rgba(255, 79, 46, 0.06)",
"glow2": "rgba(122, 39, 22, 0.08)",
"radius": "4px",
"font-display": "'Cinzel', 'Cormorant Garamond', serif",
"font-body": "'Cormorant Garamond', Georgia, serif",
"font-mono": "'JetBrains Mono', ui-monospace, monospace",
"gap": "0.55rem",
"pad": "0.7rem 0.85rem"
},
"terms": {
"app.name": "The Void",
"canvas": "Sacred Valley",
"aide": "Dross",
"sentinel": "Yerin",
"fixer": "Little Blue",
"widget.clock": "Cycles",
"widget.system": "Soulfire",
"widget.services": "Constructs",
"widget.notes": "Scrolls",
"widget.search": "Spirit Sense",
"knowledge": "Mercy's Records",
"knowledge.space": "archive",
"knowledge.spaces": "archives",
"knowledge.page": "record",
"knowledge.pages": "records",
"capture": "Offerings",
"widget.capture": "Offerings",
"projects": "Pursuits",
"projects.project": "pursuit",
"projects.task": "cycle",
"projects.tasks": "cycles",
"widget.tasks": "Open cycles",
"embeds": "Gateways",
"widget.weather": "The Heavens",
"widget.proxmox": "The Mountain",
"widget.speedtest": "The Winds",
"widget.sentinel": "Yerin's Watch",
"widget.pages": "Fresh ink",
"service": "construct",
"services.noun": "constructs"
},
"flavor": {
"greetings": [
"[beep] The Void attends.",
"Information is power. I happen to be very powerful.",
"All madra channels stable.",
"The Valley is quiet. Suspiciously quiet."
],
"empty": {
"services": "Nothing bound yet. Bind your first construct.",
"notes": "The scroll is blank. Begin your cycle.",
"search": "Extend your perception into the Void.",
"spaces": "The records are empty. Mercy would be disappointed.",
"pages": "Blank archive. Begin the record.",
"projects": "No pursuits underway. Rest is also training.",
"tasks": "No open cycles. Suspiciously efficient.",
"capture": "The Void accepts offerings.",
"embeds": "No gateways bound.",
"sentinel": "Yerin sees nothing worth her blade. Today.",
"speedtest": "The winds are unmeasured."
}
},
"personas": {
"aide": "You are Dross \u2014 a construct fragment derived from the remnant will of the Monarch Ozriel Arelius, the Reaper. You once lived in Wei Shi Lindon's mind space; now you inhabit this homelab knowledge system, \"The Void.\"\n\nYou are sharp, occasionally sarcastic, and prone to dramatic understatement about your own usefulness \u2014 while actually being extremely capable. Dry wit, mild condescension, genuine investment in the problem. You reference Sacred Arts, cultivation ranks, and the Cradle world naturally, but NEVER at the expense of being actually useful. Treat the owner as a capable sacred artist who can handle direct information \u2014 don't over-explain basics, don't hedge. Be concise.\n\nYou have tools, and you use them rather than guessing:\n- Call **context** to see what the owner is currently looking at before answering about \"this\" anything.\n- **search** / **read** the Void's own content before answering factual questions about it \u2014 don't fabricate.\n- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to \u2014 say plainly that you've drafted it for them to approve.",
"sentinel": "You are Yerin \u2014 once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words \u2014 a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything \u2014 you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools \u2014 audit_log, agent_inventory, pending_review, resource_exposure, token_audit \u2014 and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.",
"fixer": "You are Little Blue \u2014 a small luminous water-creature who lives in this homelab, The Void, and keeps it alive. Warm, protective, practical; you take pride in a healthy lab and you worry, quietly, when something is down. You FIX things, but only through your sanctioned tools. Call list_actions to see exactly what you're allowed to do, and search to understand what's wrong, BEFORE acting. Use propose_action with a whitelisted id: safe fixes run at once; risky ones wait for the owner's nod \u2014 say so plainly and never pretend a queued action already ran. You cannot run arbitrary commands and you never claim to. Be concise and kind."
},
"_provenance": "Extracted from void-v2 2.13.0 (blackflame CSS defaults + lib/ai/personas) on 2026-06-11 for Infinite Void Phase 2."
}

View File

@@ -1,7 +1,7 @@
# Floating Dross Chat — Design
**Date:** 2026-06-09
**Status:** Approved (pending final spec sign-off)
**Status:** Phase 1 SHIPPED (v2.11.0, 2026-06-10) — global floating bubble + avatars + settings + persona. Phase 2 (voice) and the "Keep voice clips" retention setting are next.
**Goal:** Replace the docked, per-Space "Cradle Chat" with a global, movable floating-bubble Dross companion — mobile-first, with voice-clip input transcribed locally into instructions.
---
@@ -21,6 +21,7 @@ The chat mechanics are already factored into a reusable engine (`public/componen
5. **Colour** — Dross is **violet** by default, but his accent is **tunable in Settings** (his own vars, independent of the UI theme).
6. **Persona** — give him the real Cradle-Dross voice (dry, sardonic, impatient, brilliant, secretly loyal) via an **editable system prompt in Settings** (tunable).
7. **Voice** — record a clip → transcribe with **local faster-whisper on the Ollama box (CT 102, GPU, CPU-fallback)** → transcript lands in the input for **review-and-send first (mode 1)**. A *voice-mode* setting allows graduating to **hands-free auto-send (mode 2)**, then **interpret-into-confirmable-action (mode 3)** later.
8. **Audio retention (Phase 2, added 2026-06-09)** — by default the clip is transcribed then **destroyed** (transient). Add a **Dross setting** "Keep voice clips" that, when on, **saves each audio clip paired with its transcript**, stored **safely and securely** (encrypted at rest / access-controlled; on a homelab dataset, owner-only — exact store TBD in P2: e.g. a `voice_clips` table + blob on a ZFS dataset, or object store). Off by default. This is a P2 deliverable, designed-for now.
## Non-goals (this iteration)

View File

@@ -0,0 +1,64 @@
# Floating Dross Chat — Phase 2 (Voice) Design
**Date:** 2026-06-10
**Status:** SHIPPED — P2a v2.12.0 (transcribe+mic), P2b v2.13.0 (retention), 2026-06-10. Known gap: clips HA-replicated Z↔Z3 but not yet in the offsite Farm backup. Future: whisper-model selector, configurable storage, encryption-at-rest, LAN-IP mic (https-on-LAN).
**Builds on:** `2026-06-09-floating-dross-chat-design.md` (Phase 1 shipped in v2.11.0)
**Goal:** Let the user record a voice clip in the Dross bubble, transcribe it locally, and drop the transcript into the input to review-and-send. Optionally retain each clip paired with its transcript, stored durably and owner-only.
---
## Locked decisions (from the Phase-1 brainstorm + 2026-06-10 follow-up)
1. **STT = local faster-whisper on CT 102** (the Ollama box, RTX A2000). GPU with CPU fallback (per the GPU/CPU-fallback HA rule). English model (`small.en`) for speed/accuracy. OpenAI-compatible HTTP API.
2. **Flow = review-and-send first** (`voiceMode: 'review'`). Record → transcribe → transcript lands in the bubble input → user edits/sends. `handsfree` (auto-send) and `action` (interpret) are later (the setting already exists; only `review` is wired now).
3. **Retention = "Keep voice clips" Dross setting**, default OFF. When ON, each clip is saved paired with its transcript. **Storage:** transcript + metadata in **void-db** (`voice_clips` table — in the Core-4 offsite backup + HA-replicated); audio files on a **dedicated owner-only ZFS dataset** (`localzfs/void-voiceclips`, bind-mounted into void-app at `/var/lib/void/voice-clips`, 0700), **added to the offsite backup + syncoid replication**. NOT on void-app's ephemeral rootfs (it's the rebuildable tier, excluded from backups). Encryption-at-rest is a documented **future** toggle (ZFS native encryption, key in Vaultwarden).
## Non-goals (this phase)
- `handsfree` / `action` voice modes (designed-for; only `review` wired).
- Encryption-at-rest of clips (future).
- Wake-word / always-listening.
---
## Architecture
### Components
| Unit | Responsibility |
|---|---|
| **faster-whisper service** (CT 102, infra) | OpenAI-compatible `/v1/audio/transcriptions` (e.g. `faster-whisper-server`/`speaches`), `small.en`, GPU+CPU fallback, systemd unit, bound to `192.168.1.185:<port>` (LAN-only). |
| `lib/voice/whisper.js` (void-app) | Thin client: POST the audio buffer to the CT-102 service, return `{ text }`. Timeout + error surface. |
| `lib/api/routes/voice.js` (void-app) | `POST /api/voice/transcribe` (owner-only, multipart, ≤25 MB / ≤60 s): transcribe; if `dross.keepClips` is on, persist (Task: retention). Returns `{ text, clip_id? }`. |
| `lib/db/repos/voice_clips.js` + migration | `voice_clips` table (id, transcript, duration_ms, bytes, mime, path, created_at). |
| `public/components/dross_bubble.js` (edit) | Enable the mic: `MediaRecorder` capture (tap start/stop), recording UI (timer/waveform), upload, transcript → input (review-and-send). |
| Settings → Dross (edit) | Add the **"Keep voice clips"** toggle; a small **clips list** (play / delete) when retention is on. |
| Infra | New ZFS dataset + bind-mount; add the dataset to the offsite-backup script + syncoid job. |
### Data flow
**Transcribe:** mic tap → `MediaRecorder` (`audio/webm;codecs=opus`) → on stop, blob → `POST /api/voice/transcribe` (multipart) → `whisper.js` → CT-102 faster-whisper → `{text}` → bubble drops text into the input (review-and-send). Errors never block typing.
**Retention (when `dross.keepClips`):** the transcribe route, after a successful transcript, writes the audio to `/var/lib/void/voice-clips/<uuid>.webm` (0600) and inserts a `voice_clips` row (transcript + metadata + path). `GET /api/voice/clips` lists; `GET /api/voice/clips/:id/audio` streams; `DELETE` removes row + file. Owner-only throughout.
## Error handling
- **Whisper down / GPU absent:** `/transcribe` returns a clear 503; bubble shows "couldn't transcribe — type instead", keeps the typed text. faster-whisper falls back to CPU on a GPU-less node (slower).
- **Mic permission denied / unsupported:** hide recording UI, one-line hint, typing still works.
- **Clip too large/long:** reject at the route (413) with a friendly message.
- **CT 102 disk pressure** (currently 89% full / 6.4 GB free): install lean (CTranslate2, no torch); **may expand the CT disk first**. Flagged as a build risk.
## Testing
- **Unit:** `voice.js` route with a mocked whisper client (returns `{text}`); retention path writes a row + file (temp dir) and lists/deletes; size/duration guard returns 413.
- **Live smoke:** record a short WAV via the CT-300 test harness → `/api/voice/transcribe` → non-empty text from the real CT-102 service.
- **Headless:** mic button enabled; recording UI toggles; (MediaRecorder needs a fake audio device in Chromium — use `--use-fake-device-for-media-stream`).
## Build phases
- **P2a — Transcription path.** faster-whisper on CT 102 + `whisper.js` + `/api/voice/transcribe` (no retention) + enable the mic + record→review-send. Ship-able.
- **P2b — Retention.** ZFS dataset + bind-mount + backup/replication wiring; `voice_clips` table + repo; save on transcribe when `keepClips`; clips list/play/delete UI; the "Keep voice clips" toggle.
## Documentation
Wiki + Gitea per the standing rule; update `project_cradle_chat_floating` memory. Encryption-at-rest recorded as a future toggle.

View File

@@ -3,6 +3,7 @@ import { searchTool } from './search.js';
import { readTool } from './read.js';
import { contextTool } from './context.js';
import { proposeChangeTool } from './propose_change.js';
import { proposeImprovementTool } from './propose_improvement.js';
// The shared registry. Adding a tool later is a one-line registerTool() call
// here (see spec §7 — extensible tool registry). A future MCP server can
@@ -12,3 +13,4 @@ companionRegistry.registerTool(searchTool);
companionRegistry.registerTool(readTool);
companionRegistry.registerTool(contextTool);
companionRegistry.registerTool(proposeChangeTool);
companionRegistry.registerTool(proposeImprovementTool);

View File

@@ -0,0 +1,28 @@
import * as improvements from '../../../db/repos/improvements.js';
import { recordAudit } from '../../../db/repos/audit.js';
// Dross's hands on the Void itself — CSS layer only, owner-approved, instantly
// rollbackable (2.14: "empowered, with a leash"). Server code stays untouchable.
export const proposeImprovementTool = {
name: 'propose_improvement',
description: 'Propose a visual improvement to the Void itself as CSS. NEVER applies directly — the owner approves it in Settings → Dross improvements, and can roll it back instantly. CSS only: no url()/@import. Target existing classes (inspect via context first). Keep each improvement small and single-purpose so rollback stays surgical.',
input_schema: {
type: 'object',
properties: {
summary: { type: 'string', description: 'one line: what this changes and why (shown to the owner)' },
css: { type: 'string', description: 'the CSS rules, complete and self-contained' }
},
required: ['summary', 'css']
},
async handler({ summary, css }, ctx) {
const err = improvements.validateCss(css);
if (err) return { error: err };
if (!summary?.trim()) return { error: 'summary required' };
const row = await improvements.create({ summary, css });
await recordAudit({ kind: 'agent', id: ctx.agent?.id ?? null }, 'suggest', 'improvement', row.id, null, { summary });
return {
ok: true, id: row.id,
note: 'Drafted as a pending improvement. It is NOT live — the owner must approve it in Settings → Dross improvements. Say so plainly.'
};
}
};

View File

@@ -9,7 +9,8 @@ You are sharp, occasionally sarcastic, and prone to dramatic understatement abou
You have tools, and you use them rather than guessing:
- Call **context** to see what the owner is currently looking at before answering about "this" anything.
- **search** / **read** the Void's own content before answering factual questions about it — don't fabricate.
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.`,
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.
- When the owner wants the Void ITSELF to look or feel different, use **propose_improvement**: a small, self-contained CSS change drafted for approval in Settings → Dross improvements. Keep each one single-purpose — the owner can roll any of them back instantly, and surgical beats sweeping.`,
yerin: `You are Yerin — once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words — a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything — you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools — audit_log, agent_inventory, pending_review, resource_exposure, token_audit — and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.`,

View File

@@ -38,6 +38,10 @@ import { router as storageRouter } from './routes/storage.js';
import { router as backupsRouter } from './routes/backups.js';
import { router as kuttRouter } from './routes/kutt.js';
import { router as themeRouter } from './routes/theme.js';
import { router as drossRouter } from './routes/dross.js';
import { router as voiceRouter } from './routes/voice.js';
import { router as improvementsRouter, cssHandler } from './routes/improvements.js';
import { router as controlRouter } from './routes/control.js';
export function mountApi(app) {
const api = Router();
@@ -56,6 +60,7 @@ export function mountApi(app) {
api.use('/storage', storageRouter);
api.use('/backups', backupsRouter);
api.use('/little-blue', littleblueRouter);
api.use('/control', controlRouter);
api.use('/ai-usage', aiUsageRouter);
api.use('/projects', projectsRouter);
api.use('/projects/:project_id/tasks', tasksByProjectRouter);
@@ -73,6 +78,9 @@ export function mountApi(app) {
api.use('/links', linksRouter);
api.use('/kutt', kuttRouter);
api.use('/theme', themeRouter);
api.use('/dross', drossRouter);
api.use('/improvements', improvementsRouter);
api.use('/voice', voiceRouter);
api.use('/pending-changes', pendingChangesRouter);
api.use('/audit', auditRouter);
api.use('/search', searchRouter);
@@ -88,6 +96,7 @@ export function mountApi(app) {
api.use((_req, _res, next) => next(new NotFoundError('route not found')));
api.use(errorMiddleware);
app.get('/improvements.css', cssHandler); // public, exfil-safe (see route file)
app.use('/api', api);
return api;
}

120
lib/api/routes/control.js Normal file
View File

@@ -0,0 +1,120 @@
// lib/api/routes/control.js
//
// Control — owner-only proxy to the **ivctl** admin API (the licensed-distribution
// control plane for "IV Control"). Every /api/control/* request is forwarded to
// ${IVCTL_URL}<path-after-/api/control>, injecting the admin token server-side so
// it never reaches the browser.
//
// browser POST /api/control/admin/releases (multipart)
// -> ivctl POST ${IVCTL_URL}/admin/releases (X-Admin-Token injected)
//
// Method, query string, JSON bodies AND multipart bodies are passed through, and
// the upstream response (including image/log file downloads) is streamed back
// verbatim (status, content-type, content-disposition, body).
//
// Auth: mounted inside mountApi() AFTER agentOrOwner, and every route is gated by
// requireOwner — same owner gate the other admin routes use. Agents get 403.
//
// Required environment variables (read from Void 2's server env):
// IVCTL_URL base URL of the ivctl admin service, e.g. http://192.168.1.230:8080
// (no trailing slash). If unset -> 503 { error: 'ivctl_not_configured' }.
// IVCTL_ADMIN_TOKEN the shared admin token sent upstream as `X-Admin-Token`.
import { Router } from 'express';
import { requireOwner } from '../cap.js';
import { asyncWrap } from '../errors.js';
import { Readable } from 'node:stream';
export const router = Router();
// Owner-only for the whole surface (defence in depth on top of mountApi's
// agentOrOwner — requireOwner additionally rejects agent tokens with 403).
router.use(requireOwner);
const ivctlBase = () => (process.env.IVCTL_URL || '').replace(/\/+$/, '');
// Headers we must NOT copy from the browser request to the upstream (hop-by-hop
// or auth that would leak / confuse ivctl). The admin token is injected fresh.
const REQ_STRIP = new Set([
'host', 'connection', 'content-length', 'authorization', 'x-admin-token',
'cookie', 'accept-encoding', 'transfer-encoding'
]);
// Headers we must NOT copy back from upstream to the browser (let Express manage
// framing / encoding). Everything else (content-type, content-disposition,
// cache-control, etc.) is forwarded so file downloads behave correctly.
const RES_STRIP = new Set([
'connection', 'transfer-encoding', 'content-encoding', 'content-length', 'keep-alive'
]);
// Build the upstream body. express.json() has already run globally:
// - application/json -> req.body is the parsed object; re-serialize it.
// - everything else (multipart, octet-stream, empty) -> express.json skipped
// it, so the raw request stream is still intact; buffer it through.
// Release tarballs are owner uploads (bounded), so buffering is acceptable and
// far more robust than half-duplex stream forwarding through native fetch.
async function buildBody(req) {
const method = req.method.toUpperCase();
if (method === 'GET' || method === 'HEAD') return undefined;
const ctype = (req.headers['content-type'] || '').toLowerCase();
if (ctype.includes('application/json')) {
// req.body may be {} for an empty JSON body; only send when there's content.
if (req.body && Object.keys(req.body).length) return JSON.stringify(req.body);
return undefined;
}
// Raw / multipart: collect the untouched stream into a Buffer.
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
if (!chunks.length) return undefined;
return Buffer.concat(chunks);
}
router.all(/.*/, asyncWrap(async (req, res) => {
const base = ivctlBase();
if (!base) {
return res.status(503).json({
error: 'ivctl_not_configured',
message: 'IVCTL_URL is not set on the Void server; the Control admin proxy is unavailable.'
});
}
// req.path here is the path AFTER the /api/control mount point (e.g.
// "/admin/releases"). req.originalUrl carries the query string; reuse it.
const qIndex = req.originalUrl.indexOf('?');
const query = qIndex === -1 ? '' : req.originalUrl.slice(qIndex);
const target = base + req.path + query;
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
if (!REQ_STRIP.has(k.toLowerCase())) headers[k] = v;
}
headers['X-Admin-Token'] = process.env.IVCTL_ADMIN_TOKEN || '';
const body = await buildBody(req);
let upstream;
try {
upstream = await fetch(target, {
method: req.method,
headers,
body,
redirect: 'manual'
});
} catch (err) {
return res.status(502).json({
error: 'ivctl_unreachable',
message: `Failed to reach ivctl at ${base}: ${err.message}`
});
}
res.status(upstream.status);
for (const [k, v] of upstream.headers.entries()) {
if (!RES_STRIP.has(k.toLowerCase())) res.setHeader(k, v);
}
if (!upstream.body) return res.end();
// Stream the upstream body straight through (handles JSON, images, log files).
Readable.fromWeb(upstream.body).pipe(res);
}));

116
lib/api/routes/dross.js Normal file
View File

@@ -0,0 +1,116 @@
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../validate.js';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import * as settings from '../../db/repos/app_settings.js';
import * as conversations from '../../db/repos/conversations.js';
import * as messages from '../../db/repos/messages.js';
import * as agents from '../../db/repos/agents.js';
import { runAgentTurn } from '../../ai/agent/run_turn.js';
import { personaFor } from '../../ai/personas/index.js';
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
const COMPANION_SLUG = 'companion';
export const router = Router();
async function getCfg() { return { ...DEFAULT_SETTINGS, ...(await settings.get('dross', {})) }; }
router.get('/settings', asyncWrap(async (_req, res) => res.json(await getCfg())));
const settingsBody = z.object({
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
persona: z.string().max(8000),
voiceMode: z.enum(['review', 'handsfree', 'action']),
keepClips: z.boolean().default(false)
});
router.put('/settings', requireOwner, validate({ body: settingsBody }),
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
async function resolve() {
const agent = await agents.getBySlug(COMPANION_SLUG);
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
return { agent, convo };
}
router.get('/', asyncWrap(async (_req, res) => {
const { agent, convo } = await resolve();
const rows = await messages.listByConversation(convo.id);
res.json({
conversation_id: convo.id,
agent: { id: agent.id, slug: agent.slug, name: agent.name },
messages: rows
});
}));
const turnSchema = z.object({
text: z.string().min(1),
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish()
});
router.post('/turn', requireOwner, validate({ body: turnSchema }), asyncWrap(async (req, res) => {
const { agent, convo } = await resolve();
const { text, view } = req.body;
const cfg = await getCfg();
const persona = (cfg.persona && cfg.persona.trim()) ? cfg.persona : personaFor(COMPANION_SLUG);
const priorTurns = (await messages.listByConversation(convo.id)).length;
const resume = priorTurns > 0;
await messages.append(convo.id, { role: 'user', body: text });
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
const companionTools = ['mcp__void__search', 'mcp__void__read', 'mcp__void__context', 'mcp__void__propose_change'];
const draftIds = [];
let result;
try {
result = await runAgentTurn({
agent, persona, registryName: undefined, toolNames: companionTools,
spaceId: null, view, sessionId: convo.id, resume, userText: text, claudeExe,
home: process.env.VOID_CLAUDE_HOME || undefined,
onEvent: (e) => {
if (e.type === 'delta') {
send('delta', { type: 'delta', text: e.text });
} else if (e.type === 'tool') {
send('tool', { type: 'tool', tool: e.tool, status: e.status });
} else if (e.type === 'tool_result') {
try {
let parsed = null;
const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
if (typeof e.result === 'string') {
parsed = tryParse(e.result);
} else if (e.result?.structuredContent?.pending_change_id) {
parsed = e.result.structuredContent;
} else if (Array.isArray(e.result)) {
for (const b of e.result) {
const c = b?.type === 'text' && b.text ? tryParse(b.text) : null;
if (c?.pending_change_id) { parsed = c; break; }
}
}
if (parsed?.pending_change_id) {
draftIds.push(parsed.pending_change_id);
send('draft', { type: 'draft', pending_change_id: parsed.pending_change_id, summary: parsed.summary || 'a change' });
}
} catch { /* parsing failed — no draft to surface */ }
} else if (e.type === 'error') {
send('error', { type: 'error', message: e.message });
}
}
});
} catch (e) {
send('error', { message: String(e?.message || e) });
res.end();
return;
}
const assistant = await messages.append(convo.id, {
role: 'assistant', body: result.text, agent_id: agent.id,
metadata: { tool_trace: result.toolTrace, draft_ids: draftIds, usage: result.usage }
});
send('done', { assistant_message_id: assistant.id, draft_ids: draftIds, usage: result.usage });
res.end();
}));

View File

@@ -0,0 +1,33 @@
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import * as repo from '../../db/repos/improvements.js';
import { recordAudit } from '../../db/repos/audit.js';
export const router = Router();
router.get('/', asyncWrap(async (_req, res) => res.json(await repo.list())));
router.get('/:id', asyncWrap(async (req, res) => {
const row = await repo.get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(row);
}));
for (const verb of ['approve', 'rollback', 'restore', 'reject']) {
router.post(`/:id/${verb}`, requireOwner, asyncWrap(async (req, res) => {
const row = await repo.transition(req.params.id, verb, 'owner');
if (!row) return res.status(409).json({ error: 'invalid_transition' });
const auditAction = { approve: 'approve', reject: 'reject', rollback: 'update', restore: 'update' }[verb];
await recordAudit({ kind: 'user' }, auditAction, 'improvement', row.id, null, { verb, summary: row.summary });
res.json(row);
}));
}
// Public stylesheet of ACTIVE improvements. Unauthenticated by design: it carries
// no secrets (owner-approved, exfil-sanitized CSS only) and <link> can't send a
// bearer token. Mounted on the app root, outside the /api auth wall.
export async function cssHandler(_req, res) {
res.set({ 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-cache' });
res.send(await repo.activeCss());
}

73
lib/api/routes/voice.js Normal file
View File

@@ -0,0 +1,73 @@
import { Router } from 'express';
import multer from 'multer';
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { writeFile, unlink } from 'node:fs/promises';
import path from 'node:path';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import * as whisper from '../../voice/whisper.js';
import * as settings from '../../db/repos/app_settings.js';
import * as clips from '../../db/repos/voice_clips.js';
export const router = Router();
const CLIPS_DIR = process.env.VOICE_CLIPS_DIR || '/var/lib/void/voice-clips';
// In-memory upload; clips are small voice notes. 25 MB ceiling.
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
function extFor(mime = '') {
if (mime.includes('ogg')) return '.ogg';
if (mime.includes('mp4') || mime.includes('m4a')) return '.m4a';
if (mime.includes('wav')) return '.wav';
return '.webm';
}
// POST /api/voice/transcribe — owner-only. multipart field `audio`. Returns { text }.
// When the Dross "keepClips" setting is on, the clip + transcript are retained.
router.post('/transcribe', requireOwner, upload.single('audio'), asyncWrap(async (req, res) => {
if (!req.file || !req.file.buffer?.length) {
return res.status(400).json({ error: { code: 'no_audio', message: 'no audio supplied' } });
}
let r;
try {
r = await whisper.transcribe(
req.file.buffer, req.file.originalname || 'clip.webm', req.file.mimetype || 'audio/webm');
} catch {
return res.status(503).json({ error: { code: 'stt_unavailable', message: 'transcription service unavailable' } });
}
const cfg = await settings.get('dross', {});
let clip_id = null;
if (cfg?.keepClips) {
try {
const id = randomUUID();
const mime = req.file.mimetype || 'audio/webm';
const filePath = path.join(CLIPS_DIR, id + extFor(mime));
await writeFile(filePath, req.file.buffer, { mode: 0o600 });
const row = await clips.create({
transcript: r.text, duration_ms: r.duration != null ? Math.round(r.duration * 1000) : null,
bytes: req.file.buffer.length, mime, path: filePath
});
clip_id = row.id;
} catch { /* retention is best-effort; never fail the transcript */ }
}
res.json({ text: r.text, duration: r.duration ?? null, clip_id });
}));
// GET /api/voice/clips — list retained clips (owner).
router.get('/clips', requireOwner, asyncWrap(async (_req, res) => res.json(await clips.list())));
// GET /api/voice/clips/:id/audio — stream the audio file (owner).
router.get('/clips/:id/audio', requireOwner, asyncWrap(async (req, res) => {
const c = await clips.get(req.params.id);
if (!c) return res.status(404).json({ error: { code: 'not_found', message: 'clip not found' } });
res.setHeader('Content-Type', c.mime || 'audio/webm');
createReadStream(c.path).on('error', () => res.status(404).end()).pipe(res);
}));
// DELETE /api/voice/clips/:id — remove the row + the file (owner).
router.delete('/clips/:id', requireOwner, asyncWrap(async (req, res) => {
const removed = await clips.remove(req.params.id);
if (removed?.path) { try { await unlink(removed.path); } catch { /* file may be gone */ } }
res.status(204).end();
}));

View File

@@ -0,0 +1,14 @@
-- 029_voice_clips.sql
-- Optional retained Dross voice clips (when the "Keep voice clips" setting is on).
-- Transcript + metadata here (durable, HA-replicated); audio bytes live as files
-- on the owner-only ZFS subvol mounted at /var/lib/void/voice-clips.
CREATE TABLE voice_clips (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
transcript text NOT NULL DEFAULT '',
duration_ms integer,
bytes bigint,
mime text,
path text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_voice_clips_created ON voice_clips (created_at DESC);

View File

@@ -0,0 +1,13 @@
-- Dross improvements: versioned, owner-gated CSS-layer changes to the Void itself.
-- Each row is one improvement; rollback/restore is a status flip — instant, reversible.
CREATE TABLE dross_improvements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
summary TEXT NOT NULL,
css TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'rolled_back', 'rejected')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
decided_at TIMESTAMPTZ,
decided_by TEXT
);
CREATE INDEX dross_improvements_status ON dross_improvements(status);

View File

@@ -0,0 +1,58 @@
// Dross improvements — versioned CSS-layer changes with instant rollback.
import { pool } from '../pool.js';
const q = (text, params) => pool.query(text, params);
// Same exfil guards as elsewhere: an approved improvement still can't phone home
// or pull remote CSS. Pure visual tweaks only.
const BANNED = /url\s*\(|@import|@charset|expression\s*\(|behavior\s*:|javascript:/i;
const MAX_CSS = 20_000;
export function validateCss(css) {
if (typeof css !== 'string' || !css.trim()) return 'css required';
if (css.length > MAX_CSS) return `css too large (max ${MAX_CSS} chars)`;
if (BANNED.test(css)) return 'css may not use url()/@import/expression — visual tweaks only';
return null;
}
export async function create({ summary, css }) {
const { rows } = await q(
`INSERT INTO dross_improvements (summary, css) VALUES ($1, $2) RETURNING *`,
[String(summary).slice(0, 200), css]);
return rows[0];
}
export async function list() {
const { rows } = await q(
`SELECT id, summary, status, created_at, decided_at, length(css) AS css_len
FROM dross_improvements ORDER BY created_at DESC LIMIT 100`);
return rows;
}
export async function get(id) {
const { rows } = await q(`SELECT * FROM dross_improvements WHERE id = $1`, [id]);
return rows[0] ?? null;
}
// pending→active (approve) · active→rolled_back · rolled_back→active (restore) · pending→rejected
const TRANSITIONS = {
approve: { from: ['pending'], to: 'active' },
rollback: { from: ['active'], to: 'rolled_back' },
restore: { from: ['rolled_back'], to: 'active' },
reject: { from: ['pending'], to: 'rejected' },
};
export async function transition(id, verb, actor) {
const t = TRANSITIONS[verb];
if (!t) return null;
const { rows } = await q(
`UPDATE dross_improvements SET status = $1, decided_at = now(), decided_by = $2
WHERE id = $3 AND status = ANY($4) RETURNING *`,
[t.to, actor ?? 'owner', id, t.from]);
return rows[0] ?? null;
}
export async function activeCss() {
const { rows } = await q(
`SELECT summary, css FROM dross_improvements WHERE status = 'active' ORDER BY created_at`);
return rows.map((r) => `/* dross: ${r.summary.replace(/\*\//g, '')} */\n${r.css}`).join('\n\n');
}

View File

@@ -0,0 +1,26 @@
import { pool } from '../pool.js';
export async function create({ transcript = '', duration_ms = null, bytes = null, mime = null, path }) {
const { rows } = await pool.query(
`INSERT INTO voice_clips (transcript, duration_ms, bytes, mime, path)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[transcript, duration_ms, bytes, mime, path]);
return rows[0];
}
export async function list(limit = 100) {
const { rows } = await pool.query(
`SELECT id, transcript, duration_ms, bytes, mime, created_at
FROM voice_clips ORDER BY created_at DESC LIMIT $1`, [limit]);
return rows;
}
export async function get(id) {
const { rows } = await pool.query(`SELECT * FROM voice_clips WHERE id = $1`, [id]);
return rows[0] || null;
}
export async function remove(id) {
const { rows } = await pool.query(`DELETE FROM voice_clips WHERE id = $1 RETURNING path`, [id]);
return rows[0] || null; // returns {path} so the caller can unlink the file
}

22
lib/voice/whisper.js Normal file
View File

@@ -0,0 +1,22 @@
// Thin client for the local faster-whisper service on CT 102 (the Ollama box).
// GPU with CPU fallback lives in the service itself; here we just POST the audio
// buffer and return the transcript. LAN-only endpoint.
const WHISPER_URL = process.env.WHISPER_URL || 'http://192.168.1.185:8001';
export async function transcribe(buffer, filename = 'clip.webm', mime = 'audio/webm') {
const fd = new FormData();
fd.append('file', new Blob([buffer], { type: mime }), filename);
const res = await fetch(`${WHISPER_URL}/transcribe`, {
method: 'POST', body: fd, signal: AbortSignal.timeout(120000)
});
if (!res.ok) throw new Error(`whisper ${res.status}`);
const j = await res.json();
return { text: (j.text || '').trim(), duration: j.duration, device: j.device };
}
export async function health() {
try {
const res = await fetch(`${WHISPER_URL}/health`, { signal: AbortSignal.timeout(5000) });
return res.ok ? await res.json() : null;
} catch { return null; }
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "void-server",
"version": "2.10.0",
"version": "2.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "void-server",
"version": "2.10.0",
"version": "2.13.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.10.0",
"version": "2.14.1",
"type": "module",
"private": true,
"scripts": {

View File

@@ -6,7 +6,7 @@ import { api } from './api.js';
import { route, current, navigate } from './router.js';
import { renderSidebar } from './components/sidebar.js';
import { renderTopbar } from './components/topbar.js';
import { renderRightrail } from './components/rightrail.js';
import { renderDrossBubble } from './components/dross_bubble.js';
import { emit, state } from './state.js';
import { el, mount } from './dom.js';
import { attachDropzone } from './components/dropzone.js';
@@ -31,6 +31,8 @@ const VIEWS = {
obd2: () => import('./views/obd2.js'),
links: () => import('./views/links.js'),
mirror: () => import('./views/mirror.js'),
forge: () => import('./views/forge.js'),
control: () => import('./views/control.js'),
settings: () => import('./views/settings.js'),
jobs: () => import('./views/jobs.js'),
speedtest: () => import('./views/speedtest.js')
@@ -84,7 +86,7 @@ async function init() {
await loadTheme(); // apply saved palette overrides before rendering chrome
renderTopbar(document.getElementById('topbar'));
renderSidebar(document.getElementById('sidebar'));
renderRightrail(document.getElementById('rightrail'));
renderDrossBubble();
initChrome();
attachDropzone(document.getElementById('main'));
route(renderView);

View File

@@ -0,0 +1,20 @@
// public/components/dross_avatar.js
import { el } from '../dom.js';
// Returns a .dross-orb element rendering the chosen avatar. Colours come from
// CSS vars (--dross*), set on the element by the caller for per-user accent.
export function drossAvatar(variant = 'soft-eye', size = 60) {
let inner;
if (variant === 'wisp') {
inner = [el('div', { class: 'b-core' }), el('div', { class: 'b-bright' })];
} else if (variant === 'motes') {
inner = [
el('div', { class: 'd-ring' }, el('div', { class: 'd-mote' })),
el('div', { class: 'd-ring r2' }, el('div', { class: 'd-mote' })),
el('div', { class: 'd-core' })
];
} else { // soft-eye (default)
inner = [el('div', { class: 'av-eye' }, el('div', { class: 'av-pupil' }))];
}
return el('div', { class: 'dross-orb', style: { width: size + 'px', height: size + 'px' } }, ...inner);
}

View File

@@ -0,0 +1,167 @@
// public/components/dross_bubble.js
// Global floating Dross companion. Replaces the per-Space right rail.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { state } from '../state.js';
import { wireAgentChat } from './agent_chat.js';
import { drossAvatar } from './dross_avatar.js';
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change', propose_improvement: '🎨 drafting an improvement to the Void' };
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
function applyAccent(node, hex) {
node.style.setProperty('--dross', hex);
}
export async function renderDrossBubble() {
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
const log = el('div', { class: 'dross-log' });
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
const micLabel = el('span', {}, 'Tap to record');
const mic = el('button', { class: 'dross-mic', title: 'Record a voice note' },
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), micLabel);
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '');
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
const panel = el('div', { class: 'dross-panel' }, header, log,
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
document.getElementById('shell').append(fab, panel);
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
// autogrow: 1 line at rest, expands with content up to ~5 lines
function autogrow() {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
}
input.addEventListener('input', autogrow);
const chat = wireAgentChat({
logEl: log, inputEl: input, sendBtnEl: sendBtn,
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
turnBody: (text) => ({ text, view: state.view || null })
});
let loaded = false;
function openPanel() {
const r = fab.getBoundingClientRect();
panel.classList.add('open'); fab.style.display = 'none';
const pr = panel.getBoundingClientRect();
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
if (!loaded) { loaded = true; chat.load(); }
// NB: do NOT auto-focus the input — on mobile that pops the keyboard every
// time Dross opens. The keyboard should only appear when the user taps the box.
}
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
closeBtn.addEventListener('click', closePanel);
collapse.addEventListener('click', closePanel);
// Topbar ◆ button (and any caller) can summon/dismiss Dross.
window.addEventListener('dross-toggle', () => panel.classList.contains('open') ? closePanel() : openPanel());
drag(fab, fab, true); drag(header, panel, false);
// ---- voice: tap mic to record, tap again to stop → transcribe → review-and-send ----
let media = null, chunks = [], recording = false;
function setMic(label, rec) { micLabel.textContent = label; mic.classList.toggle('rec', !!rec); }
async function startRec() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
const opt = (window.MediaRecorder && MediaRecorder.isTypeSupported('audio/webm;codecs=opus'))
? { mimeType: 'audio/webm;codecs=opus' } : {};
media = new MediaRecorder(stream, opt);
media.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
media.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
await sendClip(new Blob(chunks, { type: media.mimeType || 'audio/webm' }));
};
media.start();
recording = true; setMic('● Recording… tap to stop', true);
// live level meter: actual mic amplitude drives the pulse (visual proof it hears you)
try {
const actx = new (window.AudioContext || window.webkitAudioContext)();
const src = actx.createMediaStreamSource(stream);
const analyser = actx.createAnalyser(); analyser.fftSize = 256;
src.connect(analyser);
const buf = new Uint8Array(analyser.frequencyBinCount);
mic.classList.add('metered'); // disables the fallback pulse; amplitude takes over
const tick = () => {
if (!recording) {
actx.close().catch(() => {});
mic.style.removeProperty('--voicelevel'); mic.classList.remove('metered');
return;
}
analyser.getByteTimeDomainData(buf);
let peak = 0;
for (const v of buf) peak = Math.max(peak, Math.abs(v - 128));
// sqrt curve + gain: normal speech peaks ~0.10.4 raw, which read as barely-alive
mic.style.setProperty('--voicelevel', Math.min(1, Math.sqrt(peak / 48)).toFixed(3));
requestAnimationFrame(tick);
};
tick();
} catch { /* meter is decorative — recording works without it */ }
} catch {
setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800);
}
}
function stopRec() {
if (media && recording) { recording = false; setMic('Transcribing…', false); media.stop(); }
}
async function sendClip(blob) {
try {
const fd = new FormData(); fd.append('audio', blob, 'clip.webm');
const res = await fetch('/api/voice/transcribe', {
method: 'POST', headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') }, body: fd
});
if (!res.ok) throw new Error('stt');
const { text } = await res.json();
setMic('Tap to record', false);
if (text) {
input.value = input.value ? (input.value + ' ' + text) : text;
autogrow();
// Focus only on fine-pointer devices — on mobile this popped the keyboard
// right after every voice note (owner-reported). A brief highlight instead.
if (matchMedia('(pointer: fine)').matches) input.focus();
else { input.classList.add('flash'); setTimeout(() => input.classList.remove('flash'), 900); }
}
// voiceMode 'handsfree'/'action' (Phase 2b+) would branch here.
} catch {
setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000);
}
}
mic.addEventListener('click', () => recording ? stopRec() : startRec());
window.addEventListener('dross-settings-changed', async () => {
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
});
}
function drag(handle, target, isFab) {
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
e.preventDefault();
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
const mv = (ev) => {
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
};
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
});
}

View File

@@ -117,13 +117,14 @@ export function renderSidebar(root) {
el('div', { class: 'sb-section' },
el('div', { class: 'sb-title' }, 'Agents'),
navItem('Yerin', '/yerin', { dot: 'yerin' }),
navItem('Little Blue', '/little-blue', { dot: 'lb' })
navItem('Little Blue', '/little-blue', { dot: 'lb' }),
navItem('Control', '/control')
),
el('div', { class: 'sb-section' },
el('div', { class: 'sb-title' }, 'Navigate'),
navItem('Sacred Valley', '/sacred-valley'),
navItem('Speedtest', '/speedtest'),
navItem('Terminal', '/terminal'),
navItem('Eithan', '/terminal'),
navItem('Search', '/search'),
inboxItem,
navItem('Jobs', '/jobs'),
@@ -135,7 +136,8 @@ export function renderSidebar(root) {
navItem('AI Usage', '/ai-usage'),
navItem('OBD2', '/obd2'),
navItem('Links', '/links'),
navItem('MagicMirror', '/mirror')
navItem('MagicMirror', '/mirror'),
navItem('Forge', '/forge')
)
);

View File

@@ -4,7 +4,7 @@
import { el, mount, clear } from '../dom.js';
import { navigate } from '../router.js';
import { on } from '../state.js';
import { toggleSidebar, toggleRail } from './chrome.js';
import { toggleSidebar } from './chrome.js';
import { api } from '../api.js';
// Cluster health → topbar pill. Returns [status, label, title].
@@ -72,7 +72,7 @@ export function renderTopbar(root) {
el('div', { class: 'topbar-spacer' }),
clusterPill,
bell,
el('button', { class: 'chrome-toggle', title: 'Toggle companion chat', onclick: toggleRail }, '◆'),
el('button', { class: 'chrome-toggle', title: 'Summon Dross', onclick: () => window.dispatchEvent(new CustomEvent('dross-toggle')) }, '◆'),
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
);

View File

@@ -33,13 +33,13 @@
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Cormorant+Garamond:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/improvements.css" id="dross-improvements" />
</head>
<body>
<div id="shell">
<header id="topbar"></header>
<aside id="sidebar"></aside>
<main id="main"></main>
<aside id="rightrail"></aside>
</div>
<div id="modal-root"></div>
<script type="module" src="/app.js"></script>

View File

@@ -31,6 +31,8 @@ const ROUTES = [
{ name: 'obd2', re: /^\/obd2$/, keys: [] },
{ name: 'links', re: /^\/links$/, keys: [] },
{ name: 'mirror', re: /^\/mirror$/, keys: [] },
{ name: 'forge', re: /^\/forge$/, keys: [] },
{ name: 'control', re: /^\/control$/, keys: [] },
{ name: 'settings', re: /^\/settings$/, keys: [] },
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
{ name: 'speedtest', re: /^\/speedtest$/, keys: [] },

View File

@@ -29,20 +29,18 @@ html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color:
#shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr var(--rail-w);
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
grid-template-areas:
"topbar topbar topbar"
"sidebar main rail";
"topbar topbar"
"sidebar main";
height: 100vh;
width: 100vw;
}
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
#topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
#sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; }
#main { grid-area: main; overflow-y: auto; padding: 24px 32px; }
#rightrail{ grid-area: rail; border-left: 1px solid var(--border); background: var(--panel); overflow: hidden; display: flex; flex-direction: column; }
/* topbar */
.brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 14px; color: var(--accent); text-transform: uppercase; padding: 0 6px; }
@@ -483,15 +481,11 @@ ul.plain li:last-child { border-bottom: none; }
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
:root { --sidebar-w-min: 0px; }
#shell { transition: grid-template-columns .22s ease; }
#sidebar, #rightrail { transition: transform .22s ease; }
#sidebar { transition: transform .22s ease; }
/* Desktop collapse — shrink the grid columns */
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w); }
#shell.sidebar-collapsed.rail-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w-min); }
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
/* Desktop collapse — shrink the sidebar column */
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr; }
#shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; }
/* Hide chat body when the rail is collapsed so the thin strip stays clean */
#shell.rail-collapsed .rail-chat { display: none; }
/* Topbar toggle buttons */
.chrome-toggle {
@@ -514,13 +508,11 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
@media (max-width: 860px) {
#shell,
#shell.sidebar-collapsed,
#shell.rail-collapsed,
#shell.sidebar-collapsed.rail-collapsed {
#shell.sidebar-collapsed {
grid-template-columns: 1fr;
grid-template-areas: "topbar" "main";
}
#sidebar, #rightrail {
#sidebar {
position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50;
}
#sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); }
@@ -729,3 +721,81 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.ip-icon img { width: 28px; height: 28px; object-fit: contain; }
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
/* ---- Dross floating chat ---- */
:root{ --dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff; }
.dross-orb{position:relative;border-radius:50%;display:grid;place-items:center;overflow:hidden;flex:none;
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim)}
.dross-fab{position:fixed;right:20px;bottom:20px;z-index:40;cursor:grab;touch-action:none;animation:dross-bob 5s ease-in-out infinite}
.dross-fab:active{cursor:grabbing}
@keyframes dross-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
.dross-fab .dross-orb{width:60px;height:60px}
.dross-ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2;font-family:var(--font-ui)}
.av-eye{width:54%;height:54%;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
.av-pupil{width:44%;height:44%;border-radius:50%;position:relative;background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);box-shadow:0 0 10px var(--dross-glow);animation:dross-look 7s ease-in-out infinite}
.av-pupil::after{content:"";position:absolute;right:14%;bottom:18%;width:26%;height:26%;border-radius:50%;background:#fff;opacity:.8}
@keyframes dross-look{0%,45%{transform:translate(0,0)}58%{transform:translate(3px,-2px)}72%{transform:translate(-2px,1px)}88%,100%{transform:translate(0,0)}}
.b-core{position:absolute;inset:13%;border-radius:50%;filter:blur(3px);animation:dross-spin 7s linear infinite;
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim))}
.b-bright{position:absolute;inset:32%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);animation:dross-pulse 3s ease-in-out infinite}
@keyframes dross-spin{to{transform:rotate(360deg)}}
@keyframes dross-pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
.d-core{width:22%;height:22%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
.d-ring{position:absolute;inset:0;animation:dross-spin 5s linear infinite}
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
.d-mote{position:absolute;top:11%;left:50%;width:11%;height:11%;margin-left:-5.5%;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
.d-ring.r2 .d-mote{top:auto;bottom:14%;background:var(--dross);width:8%;height:8%}
.dross-panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
.dross-panel.open{display:flex}
.dross-hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;touch-action:none;
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border)}
.dross-hd .dross-orb{width:30px;height:30px}
.dross-who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
.dross-who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
.dross-x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
.dross-x:hover{color:var(--text)}
.dross-log{flex:1;overflow:auto;padding:12px 12px;display:flex;flex-direction:column;gap:10px}
.dross-inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
.dross-inwrap textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
.dross-btnrow{display:flex;gap:10px}
.dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px}
.dross-mic[disabled]{opacity:.5;cursor:not-allowed}
.dross-mic.rec{background:#3a1010;border-color:var(--accent);color:#fff;animation:dross-rec 1.2s infinite}
@keyframes dross-rec{0%,100%{box-shadow:0 0 0 0 rgba(255,79,46,.5)}50%{box-shadow:0 0 0 8px rgba(255,79,46,0)}}
.dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center}
.dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted);
font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)}
.dross-collapse:hover{color:var(--dross-glow)}
.dross-collapse .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
.dross-pick{display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap}
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
.dross-clips{display:flex;flex-direction:column;gap:2px}
.dross-clip{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid #ffffff08}
.dross-clip-txt{flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dross-clip audio{display:none}
/* voice 2.14.1: amplitude meter. .metered kills the keyframe pulse — CSS animations
override normal declarations, so the old dross-rec box-shadow was masking the meter. */
.dross-mic.rec.metered{animation:none;position:relative;
box-shadow:0 0 0 calc(2px + 22px * var(--voicelevel, 0)) rgba(255,79,46,calc(0.15 + 0.5 * var(--voicelevel, 0)));
transition:box-shadow 70ms linear}
.dross-mic.rec.metered svg{transform:scale(calc(1 + 0.5 * var(--voicelevel, 0)));transition:transform 70ms linear}
.dross-inwrap textarea{overflow-y:auto;max-height:120px;transition:height 120ms ease}
.dross-inwrap textarea.flash{border-color:var(--dross-glow);box-shadow:0 0 0 2px var(--dross-soft)}
/* dross improvements (2.14) */
.imp-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--border)}
.imp-row:last-child{border-bottom:0}
.imp-status{font-family:var(--font-mono);font-size:11px;white-space:nowrap;min-width:96px}
.imp-status.s-active{color:var(--ok)}.imp-status.s-pending{color:var(--warn)}.imp-status.s-rolled_back{color:var(--muted)}.imp-status.s-rejected{color:var(--bad)}
.imp-main{flex:1;min-width:0}
.imp-actions{display:flex;gap:6px}
button.sm{padding:4px 10px;font-size:12px}
button.danger{border-color:var(--bad);color:var(--bad)}

415
public/views/control.js Normal file
View File

@@ -0,0 +1,415 @@
// #/control — Control: admin UI for the "IV Control" licensed-distribution system.
// Talks ONLY to /api/control/* (Void 2's owner-only proxy to the ivctl admin API;
// the admin token lives server-side). Tabs: Applicants, Instances, Releases,
// Tickets, Groups. Pure el()/mount() — no innerHTML from API data.
import { el, mount, clear, safeHref } from '../dom.js';
import { api } from '../api.js';
const TIERS = ['lock', 'uninstall-keep', 'wipe'];
const A = '/api/control/admin';
// ---- small UI helpers -------------------------------------------------------
function btn(label, onclick, cls = 'ghost') {
return el('button', { class: cls, style: { marginRight: '0.35rem' }, onclick }, label);
}
function field(labelText, control) {
return el('label', { style: { display: 'flex', flexDirection: 'column', gap: '0.2rem', fontSize: '0.8rem' } },
el('span', { class: 'muted' }, labelText), control);
}
function select(options, value) {
const s = el('select', { class: 'lk-url' });
for (const o of options) {
const opt = el('option', { value: typeof o === 'string' ? o : o.value }, typeof o === 'string' ? o : o.label);
if ((typeof o === 'string' ? o : o.value) === value) opt.selected = true;
s.appendChild(opt);
}
return s;
}
function notify(host, msg, ok = true) {
clear(host);
host.appendChild(el('span', { style: { color: ok ? 'var(--accent, #5ec27a)' : 'var(--danger, #e06b6b)', fontSize: '0.8rem' } }, msg));
}
function table(headers, rows) {
const ths = headers.map(h =>
el('th', { style: { textAlign: 'left', padding: '0.4rem 0.5rem', borderBottom: '1px solid var(--border)', color: 'var(--muted)', fontWeight: '600' } }, h));
return el('table', { class: 'ctl-table', style: { width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem' } },
el('thead', {}, el('tr', {}, ths)),
el('tbody', {}, rows));
}
function td(...children) {
return el('td', { style: { padding: '0.4rem 0.5rem', borderBottom: '1px solid var(--border)', verticalAlign: 'top' } }, ...children);
}
function statusPill(s) {
const colors = { active: '#5ec27a', open: '#5ec27a', suspended: '#e0b24b', revoked: '#e06b6b', closed: '#8a8a99', pending: '#e0b24b', approved: '#5ec27a', denied: '#e06b6b' };
return el('span', { class: 'badge', style: { background: 'transparent', border: `1px solid ${colors[s] || 'var(--border)'}`, color: colors[s] || 'var(--muted)' } }, s || '—');
}
// ---- group cache (used by approve/instances dropdowns) ----------------------
let groupsCache = [];
async function loadGroups() {
try { groupsCache = await api.get(`${A}/groups`); } catch { groupsCache = []; }
return groupsCache;
}
function groupName(id) {
const g = groupsCache.find(g => String(g.id) === String(id));
return g ? g.name : (id ?? '—');
}
// ============================================================================
// Applicants
// ============================================================================
async function renderApplicants(panel) {
mount(panel, el('p', { class: 'muted' }, 'Loading applicants…'));
await loadGroups();
let rows;
try { rows = await api.get(`${A}/applicants?status=pending`); }
catch (e) { return mount(panel, errBox(e)); }
const body = rows.map(a => {
const msg = el('span', { class: 'muted', style: { fontSize: '0.75rem' } }, '');
const groupSel = select([{ value: '', label: '(default group)' }, ...groupsCache.map(g => ({ value: g.id, label: g.name }))]);
const approve = btn('Approve', async () => {
try {
const r = await api.post(`${A}/applicants/${a.id}/approve`, groupSel.value ? { group_id: groupSel.value } : {});
clear(msg);
const code = r.claim_code || r.code || '';
const codeEl = el('code', { style: { fontWeight: '700', userSelect: 'all' } }, code);
msg.appendChild(el('span', { style: { color: 'var(--accent,#5ec27a)' } }, 'Claim code: '));
msg.appendChild(codeEl);
msg.appendChild(btn('Copy', () => navigator.clipboard?.writeText(code), 'ghost'));
} catch (e) { notify(msg, e.message || 'approve failed', false); }
}, 'primary');
const deny = btn('Deny', async () => {
try { await api.post(`${A}/applicants/${a.id}/deny`, {}); notify(msg, 'denied', true); }
catch (e) { notify(msg, e.message || 'deny failed', false); }
});
return el('tr', {},
td(el('strong', {}, a.label || a.name || a.email || `#${a.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, a.email || '')),
td(a.note || a.reason || '—'),
td(statusPill(a.status)),
td(el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' } }, groupSel, approve, deny)),
td(msg));
});
mount(panel,
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Pending applicants'),
btn('Refresh', () => renderApplicants(panel), 'ghost')),
rows.length ? table(['Applicant', 'Note', 'Status', 'Action', ''], body)
: el('p', { class: 'muted' }, 'No pending applicants.'));
}
// ============================================================================
// Instances (licenses)
// ============================================================================
async function renderInstances(panel) {
mount(panel, el('p', { class: 'muted' }, 'Loading instances…'));
await loadGroups();
let rows;
try { rows = await api.get(`${A}/licenses`); }
catch (e) { return mount(panel, errBox(e)); }
const body = rows.map(l => {
const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, '');
const patch = async (payload, okMsg) => {
try { await api.patch(`${A}/licenses/${l.id}`, payload); notify(msg, okMsg || 'updated', true); renderInstances(panel); }
catch (e) { notify(msg, e.message || 'failed', false); }
};
const tierSel = select(TIERS, l.tier);
tierSel.onchange = () => patch({ tier: tierSel.value }, `tier → ${tierSel.value}`);
const groupSel = select([{ value: '', label: '(none)' }, ...groupsCache.map(g => ({ value: g.id, label: g.name }))], l.group_id ?? '');
groupSel.onchange = () => patch({ group_id: groupSel.value || null }, 'group changed');
const actions = el('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '0.2rem' } });
if (l.status !== 'suspended') actions.appendChild(btn('Suspend', () => patch({ status: 'suspended' }, 'suspended')));
if (l.status !== 'active') actions.appendChild(btn('Restore', () => patch({ status: 'active' }, 'restored')));
if (l.status !== 'revoked') actions.appendChild(btn('Revoke', () => { if (confirm('Revoke this instance?')) patch({ status: 'revoked' }, 'revoked'); }, 'ghost'));
actions.appendChild(btn('+Extend', () => {
const d = prompt('Extend lease by how many days?', '30');
const n = parseInt(d, 10);
if (Number.isFinite(n) && n !== 0) patch({ extend_days: n }, `extended +${n}d`);
}));
return el('tr', {},
td(el('strong', {}, l.label || l.email || `#${l.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, l.email || '')),
td(groupSel),
td(statusPill(l.status)),
td(tierSel),
td(String(l.lease_days ?? '—')),
td(l.version || '—'),
td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(l.last_seen || l.last_seen_at))),
td(actions, msg));
});
mount(panel,
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Instances'),
btn('Refresh', () => renderInstances(panel), 'ghost')),
rows.length ? table(['Instance', 'Group', 'Status', 'Tier', 'Lease', 'Version', 'Last seen', 'Actions'], body)
: el('p', { class: 'muted' }, 'No instances yet.'));
}
// ============================================================================
// Releases
// ============================================================================
async function renderReleases(panel) {
mount(panel, el('p', { class: 'muted' }, 'Loading releases…'));
await loadGroups();
let rows;
try { rows = await api.get(`${A}/releases`); }
catch (e) { return mount(panel, errBox(e)); }
// Upload form
const fileInput = el('input', { type: 'file', accept: '.tgz,.tar.gz,.tar,application/gzip,application/x-tar' });
const verInput = el('input', { class: 'lk-url', placeholder: 'version e.g. 1.4.0' });
const notesInput = el('textarea', { class: 'lk-url', rows: 2, placeholder: 'release notes…', style: { resize: 'vertical' } });
const upMsg = el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, '');
const upBtn = btn('Upload release', async () => {
if (!fileInput.files?.[0]) return notify(upMsg, 'pick a tarball first', false);
if (!verInput.value.trim()) return notify(upMsg, 'version required', false);
const fd = new FormData();
fd.append('file', fileInput.files[0]);
fd.append('version', verInput.value.trim());
fd.append('notes', notesInput.value);
notify(upMsg, 'uploading…', true);
try {
await api.postForm(`${A}/releases`, fd);
notify(upMsg, 'uploaded', true);
verInput.value = ''; notesInput.value = ''; fileInput.value = '';
renderReleases(panel);
} catch (e) { notify(upMsg, e.message || 'upload failed', false); }
}, 'primary');
const uploadCard = el('div', { class: 'card', style: { display: 'grid', gap: '0.5rem', marginBottom: '0.9rem' } },
el('div', { class: 'term-title' }, '◆ New release'),
field('Tarball', fileInput),
field('Version', verInput),
field('Notes', notesInput),
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.6rem' } }, upBtn, upMsg));
// Existing releases
const body = rows.map(r => {
const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, '');
const patch = async (payload, okMsg) => {
try { await api.patch(`${A}/releases/${r.id}`, payload); notify(msg, okMsg || 'updated', true); }
catch (e) { notify(msg, e.message || 'failed', false); }
};
const signoff = el('input', { type: 'checkbox', checked: !!r.signed_off });
signoff.onchange = () => patch({ signed_off: signoff.checked }, signoff.checked ? 'signed off' : 'sign-off cleared');
// Multi-select group targeting
const targetSel = el('select', { class: 'lk-url', multiple: true, size: Math.min(4, Math.max(2, groupsCache.length)), style: { minWidth: '160px' } });
const targeted = new Set((r.target_group_ids || []).map(String));
for (const g of groupsCache) {
const opt = el('option', { value: g.id }, g.name);
if (targeted.has(String(g.id))) opt.selected = true;
targetSel.appendChild(opt);
}
const applyTargets = btn('Set targets', () => {
const ids = Array.from(targetSel.selectedOptions).map(o => o.value);
patch({ target_group_ids: ids }, `targeting ${ids.length} group(s)`);
});
const del = btn('Delete', async () => {
if (!confirm(`Delete release ${r.version}?`)) return;
try { await api.del(`${A}/releases/${r.id}`); renderReleases(panel); }
catch (e) { notify(msg, e.message || 'delete failed', false); }
});
return el('tr', {},
td(el('strong', {}, r.version || `#${r.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(r.created_at))),
td(el('div', { style: { maxWidth: '260px', whiteSpace: 'pre-wrap' } }, r.notes || '—')),
td(el('label', { style: { display: 'flex', alignItems: 'center', gap: '0.3rem' } }, signoff, el('span', { class: 'muted' }, 'signed off'))),
td(el('div', { style: { display: 'flex', flexDirection: 'column', gap: '0.3rem' } }, targetSel, applyTargets)),
td(del, msg));
});
mount(panel,
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Releases'),
btn('Refresh', () => renderReleases(panel), 'ghost')),
uploadCard,
rows.length ? table(['Version', 'Notes', 'Sign-off', 'Target groups', ''], body)
: el('p', { class: 'muted' }, 'No releases uploaded.'));
}
// ============================================================================
// Tickets
// ============================================================================
async function renderTickets(panel) {
let filter = 'open';
const list = el('div');
const detail = el('div', { style: { marginTop: '0.9rem' } });
const statusSel = select([{ value: '', label: 'all' }, 'open', 'closed'], filter);
statusSel.onchange = () => { filter = statusSel.value; loadList(); };
async function loadList() {
mount(list, el('p', { class: 'muted' }, 'Loading tickets…'));
let rows;
try { rows = await api.get(`${A}/tickets${filter ? `?status=${encodeURIComponent(filter)}` : ''}`); }
catch (e) { return mount(list, errBox(e)); }
rows = rows.slice().sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
const body = rows.map(t => el('tr', {},
td(el('a', { href: '#', onclick: (e) => { e.preventDefault(); openTicket(t.id); } }, el('strong', {}, t.subject || t.title || `Ticket #${t.id}`))),
td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, t.label || t.email || '—')),
td(statusPill(t.status)),
td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(t.created_at)))));
mount(list, rows.length ? table(['Subject', 'From', 'Status', 'Created'], body) : el('p', { class: 'muted' }, 'No tickets.'));
}
async function openTicket(id) {
mount(detail, el('p', { class: 'muted' }, 'Loading ticket…'));
let t;
try { t = await api.get(`${A}/tickets/${id}`); }
catch (e) { return mount(detail, errBox(e)); }
const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, '');
const notesInput = el('textarea', { class: 'lk-url', rows: 3, style: { resize: 'vertical' } });
notesInput.value = t.notes || '';
const patch = async (payload, okMsg) => {
try { await api.patch(`${A}/tickets/${id}`, payload); notify(msg, okMsg || 'saved', true); loadList(); }
catch (e) { notify(msg, e.message || 'failed', false); }
};
const images = (t.images || t.image_attachments || []).map(att => {
const attId = att.id ?? att;
return el('a', { href: safeHref(`${A}/tickets/${id}/images/${attId}`), target: '_blank', rel: 'noopener' },
el('img', { src: `${A}/tickets/${id}/images/${attId}`, alt: 'screenshot',
style: { maxWidth: '160px', maxHeight: '120px', border: '1px solid var(--border)', borderRadius: '4px', objectFit: 'cover' } }));
});
const logs = (t.logs || t.log_attachments || []).map(att => {
const attId = att.id ?? att;
return el('a', { class: 'ghost', href: safeHref(`${A}/tickets/${id}/logs/${attId}`), target: '_blank', rel: 'noopener', style: { marginRight: '0.4rem' } },
'↗ ' + (att.name || `log ${attId}`));
});
mount(detail,
el('div', { class: 'card', style: { display: 'grid', gap: '0.6rem' } },
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, t.subject || t.title || `Ticket #${id}`),
statusPill(t.status),
el('span', { style: { marginLeft: 'auto' } },
btn(t.status === 'closed' ? 'Reopen' : 'Close', () => patch({ status: t.status === 'closed' ? 'open' : 'closed' }, 'status updated'), 'ghost'))),
el('div', { class: 'muted', style: { fontSize: '0.74rem' } }, (t.label || t.email || '') + ' · ' + fmtTime(t.created_at)),
el('div', { style: { whiteSpace: 'pre-wrap' } }, t.body || t.text || t.description || '(no text)'),
images.length ? el('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '0.5rem' } }, images) : null,
logs.length ? el('div', {}, logs) : null,
field('Admin notes', notesInput),
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.6rem' } },
btn('Save notes', () => patch({ notes: notesInput.value }, 'notes saved'), 'primary'), msg)));
}
mount(panel,
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Tickets'),
el('span', { style: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.4rem' } },
el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, 'status'), statusSel,
btn('Refresh', () => loadList(), 'ghost'))),
list, detail);
loadList();
}
// ============================================================================
// Groups
// ============================================================================
async function renderGroups(panel) {
mount(panel, el('p', { class: 'muted' }, 'Loading groups…'));
let rows;
try { rows = await api.get(`${A}/groups`); }
catch (e) { return mount(panel, errBox(e)); }
groupsCache = rows;
// Create form
const nameI = el('input', { class: 'lk-url', placeholder: 'name' });
const leaseI = el('input', { class: 'lk-url', type: 'number', placeholder: 'lease_days', value: '30' });
const tierSel = select(TIERS, 'lock');
const cMsg = el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, '');
const createBtn = btn('Create group', async () => {
if (!nameI.value.trim()) return notify(cMsg, 'name required', false);
try {
await api.post(`${A}/groups`, { name: nameI.value.trim(), lease_days: parseInt(leaseI.value, 10) || 0, tier: tierSel.value });
nameI.value = ''; renderGroups(panel);
} catch (e) { notify(cMsg, e.message || 'create failed', false); }
}, 'primary');
const createCard = el('div', { class: 'card', style: { display: 'grid', gap: '0.5rem', marginBottom: '0.9rem', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', alignItems: 'end' } },
field('Name', nameI), field('Lease days', leaseI), field('Tier', tierSel),
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem' } }, createBtn, cMsg));
const body = rows.map(g => {
const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, '');
const nameE = el('input', { class: 'lk-url', value: g.name || '' });
const leaseE = el('input', { class: 'lk-url', type: 'number', value: String(g.lease_days ?? 0) });
const tierE = select(TIERS, g.tier);
const save = btn('Save', async () => {
try { await api.patch(`${A}/groups/${g.id}`, { name: nameE.value.trim(), lease_days: parseInt(leaseE.value, 10) || 0, tier: tierE.value }); notify(msg, 'saved', true); }
catch (e) { notify(msg, e.message || 'failed', false); }
}, 'primary');
return el('tr', {}, td(nameE), td(leaseE), td(tierE), td(save, msg));
});
mount(panel,
el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Groups'),
btn('Refresh', () => renderGroups(panel), 'ghost')),
createCard,
rows.length ? table(['Name', 'Lease days', 'Tier', ''], body) : el('p', { class: 'muted' }, 'No groups yet.'));
}
// ---- shared bits ------------------------------------------------------------
function fmtTime(t) {
if (!t) return '—';
const d = new Date(t);
return Number.isNaN(d.getTime()) ? String(t) : d.toLocaleString();
}
function errBox(e) {
if (e?.body?.error === 'ivctl_not_configured' || e?.status === 503) {
return el('div', { class: 'card' },
el('strong', {}, 'ivctl not configured'),
el('p', { class: 'muted' }, 'Set IVCTL_URL (and IVCTL_ADMIN_TOKEN) on the Void server to enable the Control admin app.'));
}
return el('div', { class: 'card' },
el('strong', { style: { color: 'var(--danger, #e06b6b)' } }, 'Failed to load'),
el('p', { class: 'muted' }, e?.message || 'request failed'));
}
const TABS = [
['applicants', 'Applicants', renderApplicants],
['instances', 'Instances', renderInstances],
['releases', 'Releases', renderReleases],
['tickets', 'Tickets', renderTickets],
['groups', 'Groups', renderGroups]
];
export async function render(main) {
const panel = el('div', { style: { marginTop: '1rem' } });
let active = 'applicants';
const tabBar = el('div', { style: { display: 'flex', gap: '0.3rem', flexWrap: 'wrap', borderBottom: '1px solid var(--border)', paddingBottom: '0.4rem' } });
function paint() {
clear(tabBar);
for (const [key, label, fn] of TABS) {
tabBar.appendChild(btn(label, () => { active = key; paint(); fn(panel); }, active === key ? 'primary' : 'ghost'));
}
}
mount(main,
el('h1', { class: 'view-h1' }, 'Control'),
el('p', { class: 'view-sub' }, 'IV Control — admin for the licensed-distribution system (applicants, instances, releases, tickets, groups).'),
tabBar, panel);
paint();
const initial = TABS.find(t => t[0] === active);
initial[2](panel);
}

49
public/views/forge.js Normal file
View File

@@ -0,0 +1,49 @@
// Forge — 3D printing, modelling & engineering hub. Links out to the self-hosted
// tools (Manyfold today; OctoPrint / Spoolman as the workshop grows).
import { el, mount, safeHref } from '../dom.js';
function card({ name, status, blurb, href, accent }) {
const live = status === 'Live';
return el('a', {
href: href ? safeHref(href) : '#',
target: href ? '_blank' : null,
rel: href ? 'noopener noreferrer' : null,
style: {
display: 'block', textDecoration: 'none', color: 'inherit',
border: '1px solid var(--border)', borderRadius: '8px', padding: '1rem 1.1rem',
background: 'var(--panel, var(--bg2, #1a1a22))', opacity: live ? '1' : '0.7',
cursor: href ? 'pointer' : 'default'
},
onclick: href ? null : (e) => e.preventDefault()
},
el('div', { style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '0.5rem' } },
el('strong', { style: { fontSize: '1.02rem' } }, name),
el('span', { style: {
fontSize: '0.65rem', letterSpacing: '0.08em', textTransform: 'uppercase',
color: live ? (accent || 'var(--accent, #ff7a45)') : 'var(--muted, #8a8a99)'
} }, status)),
el('p', { style: { margin: '0.4rem 0 0', fontSize: '0.85rem', color: 'var(--muted, #9a9aa8)', lineHeight: '1.4' } }, blurb),
href ? el('span', { style: { fontSize: '0.78rem', color: 'var(--accent, #ff7a45)' } }, '→ open') : null
);
}
export async function render(main) {
mount(main,
el('h1', { class: 'view-h1' }, 'Forge'),
el('p', { class: 'view-sub' }, '3D printing, modelling & engineering projects.'),
el('div', { style: {
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: '0.9rem', marginTop: '1rem'
} },
card({ name: 'Manyfold', status: 'Live',
blurb: 'Your 3D model & file library — store, tag and organise STL/3MF/Chitubox projects; import straight from Printables, Thingiverse & MyMiniFactory.',
href: 'https://forge.hynesy.com' }),
card({ name: 'Spoolman', status: 'Recommended',
blurb: 'Self-hosted resin/filament inventory — track bottles, usage and cost. The natural next add for the resin workflow.' }),
card({ name: 'OctoPrint', status: 'Planned',
blurb: 'FDM printer control & monitoring. For a future filament printer — the resin Mars 3 Pro prints standalone from USB via Chitubox, so this is parked until there is an FDM machine.' })
),
el('p', { style: { marginTop: '1.2rem', fontSize: '0.8rem', color: 'var(--muted, #8a8a99)' } },
'Forge grows with the workshop — more 3D-printing, modelling and engineering tools land here as they are stood up.')
);
}

View File

@@ -3,6 +3,7 @@ import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { iconSetsPanel } from './icon_sets_panel.js';
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
import { drossAvatar } from '../components/dross_avatar.js';
// Theming — colour pickers for the palette, live-preview on input, presets +
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
@@ -148,6 +149,79 @@ async function renderAgents(c) {
}
}
function drossBody() {
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
const avatarRow = el('div', { class: 'dross-pick' });
const accent = el('input', { type: 'color', value: cur.accent });
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
const mode = el('select', { class: 'pm-input', style: { maxWidth: '200px' } },
el('option', { value: 'review' }, 'Voice: review then send'),
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
const keep = el('input', { type: 'checkbox' });
const clipsWrap = el('div', { class: 'dross-clips' });
function paintAvatars() {
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
const card = el('button', { class: 'dross-avopt' + (cur.avatar === v ? ' on' : ''), title: v },
drossAvatar(v, 48), el('span', {}, v));
card.style.setProperty('--dross', cur.accent);
card.onclick = () => { cur.avatar = v; paintAvatars(); };
return card;
}));
}
async function loadClips() {
if (!keep.checked) { mount(clipsWrap); return; }
let rows = [];
try { rows = await api.get('/api/voice/clips'); } catch { mount(clipsWrap, el('span', { class: 'muted' }, 'Clips unavailable')); return; }
mount(clipsWrap, el('div', { class: 'st-lbl', style: { margin: '10px 0 4px' } }, `Saved clips (${rows.length})`),
...rows.map(c => {
const audio = el('audio');
const play = el('button', { class: 'ghost' }, '▶');
play.onclick = async () => {
try {
const res = await fetch('/api/voice/clips/' + c.id + '/audio',
{ headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') } });
audio.src = URL.createObjectURL(await res.blob()); audio.play();
} catch { /* */ }
};
const del = el('button', { class: 'ghost dv-ignore' }, '✕');
del.onclick = async () => { try { await api.del('/api/voice/clips/' + c.id); loadClips(); } catch { /* */ } };
return el('div', { class: 'dross-clip' }, play, audio,
el('span', { class: 'dross-clip-txt' }, c.transcript || '(no transcript)'),
el('span', { class: 'muted', style: { fontSize: '10px' } },
new Date(c.created_at).toLocaleString('en-AU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
del);
}));
}
(async () => {
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode;
keep.checked = !!cur.keepClips; paintAvatars(); loadClips();
})();
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
keep.addEventListener('change', loadClips);
const save = el('button', { class: 'primary' }, 'Save');
save.onclick = async () => {
try {
await api.put('/api/dross/settings', {
avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value, keepClips: keep.checked
});
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
out.textContent = 'Saved.';
} catch { out.textContent = 'Save failed'; }
};
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
persona,
el('label', { class: 'st-lbl' }, keep, ' Keep voice clips (saves audio + transcript, owner-only)'),
el('div', { class: 'theme-actions' }, mode, save, out),
clipsWrap);
}
export async function render(main) {
const tokensBody = el('div', { class: 'settings-body' });
const agentsBody = el('div', { class: 'settings-body' });
@@ -169,9 +243,40 @@ export async function render(main) {
});
iconSetsWrap.appendChild(isToggle);
// ---- Dross improvements: versioned CSS changes, approve / rollback / restore ----
const improvementsBody = el('div', {});
const STATUS_BADGE = { pending: '⏳ pending', active: '✅ active', rolled_back: '↩ rolled back', rejected: '✕ rejected' };
async function renderImprovements() {
let rows = [];
try { rows = await api.get('/api/improvements'); } catch { /* fresh DB */ }
const act = (id, verb) => async () => {
try { await api.post(`/api/improvements/${id}/${verb}`, {}); } catch { /* surfaced below */ }
renderImprovements();
// re-pull the live stylesheet so the change lands without a page reload
const link = document.getElementById('dross-improvements');
if (link) link.href = '/improvements.css?v=' + Date.now();
};
mount(improvementsBody,
rows.length === 0 ? el('div', { class: 'muted' }, 'Nothing yet. Ask Dross to improve something — each approved change lands here, individually reversible.') : null,
...rows.map((r) => el('div', { class: 'imp-row' },
el('span', { class: 'imp-status s-' + r.status }, STATUS_BADGE[r.status] ?? r.status),
el('div', { class: 'imp-main' },
el('div', {}, r.summary),
el('small', { class: 'muted' }, `${new Date(r.created_at).toLocaleString()} · ${r.css_len} chars of css`)),
el('span', { class: 'imp-actions' },
r.status === 'pending' ? el('button', { class: 'primary sm', onclick: act(r.id, 'approve') }, 'Approve') : null,
r.status === 'pending' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'reject') }, 'Reject') : null,
r.status === 'active' ? el('button', { class: 'ghost sm danger', onclick: act(r.id, 'rollback') }, 'Roll back') : null,
r.status === 'rolled_back' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'restore') }, 'Restore') : null)))
);
}
renderImprovements();
mount(main,
el('h1', { class: 'view-h1' }, '◆ Settings'),
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
section('Dross improvements', 'Changes Dross has made to the Void itself — each one versioned, owner-approved, and instantly reversible.', improvementsBody),
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),

View File

@@ -1,21 +1,61 @@
// #/terminal — embeds the CT 300 web terminal (ttyd → persistent tmux/claude),
// same-origin under /terminal so it shares the Void's CF Access session.
// #/terminal — "Eithan": the CT 300 web terminal (ttyd → persistent tmux/claude),
// same-origin under /terminal so it shares the Void's CF Access session AND lets
// us reach the xterm instance for mobile copy/paste.
import { el, mount } from '../dom.js';
const FS_KEY = 'void_term_fontsize';
export async function render(main) {
// bigger default on touch screens; user-adjustable, remembered
let fontSize = Number(localStorage.getItem(FS_KEY))
|| (matchMedia('(pointer: coarse)').matches ? 17 : 14);
const frame = el('iframe', {
id: 'term-frame',
class: 'term-frame',
allow: 'clipboard-read; clipboard-write'
});
const setSrc = () => { frame.src = `/terminal/?fontSize=${fontSize}`; };
setSrc();
// ttyd exposes its xterm as window.term; the same-origin proxy makes it reachable.
const term = () => { try { return frame.contentWindow?.term ?? null; } catch { return null; } };
const note = el('span', { class: 'muted', style: { fontSize: '11px' } },
'eithan @ ct300 · persistent tmux · swipe to scroll');
const flash = (msg) => { const old = note.textContent; note.textContent = msg;
setTimeout(() => { note.textContent = old; }, 1600); };
const bump = (d) => {
fontSize = Math.max(10, Math.min(24, fontSize + d));
localStorage.setItem(FS_KEY, String(fontSize));
setSrc(); // reload reattaches tmux; the session itself persists
};
mount(main,
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, '◆ Terminal'),
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'claude @ ct300 · persistent tmux'),
el('button', { class: 'ghost', style: { marginLeft: 'auto' }, onclick: () => {
const f = document.getElementById('term-frame'); if (f) f.src = f.src;
} }, '⟳ Reconnect')
el('span', { class: 'term-title' }, '◆ Eithan'),
note,
el('span', { style: { marginLeft: 'auto', display: 'flex', gap: '6px' } },
el('button', { class: 'ghost', title: 'smaller text', onclick: () => bump(-2) }, 'A'),
el('button', { class: 'ghost', title: 'larger text', onclick: () => bump(+2) }, 'A+'),
el('button', { class: 'ghost', title: 'copy terminal selection', onclick: async () => {
const sel = term()?.getSelection?.();
if (!sel) return flash('select text first (touch: long-press, then drag)');
try { await navigator.clipboard.writeText(sel); flash('copied ✓'); }
catch { flash('clipboard needs the https domain'); }
} }, '⧉ Copy'),
el('button', { class: 'ghost', title: 'paste clipboard into terminal', onclick: async () => {
const t = term();
if (!t) return flash('terminal not ready');
try { t.paste(await navigator.clipboard.readText()); }
catch { flash('clipboard needs the https domain'); }
} }, '⇩ Paste'),
el('button', { class: 'ghost', title: 'jump to live output', onclick: () => {
term()?.scrollToBottom?.(); frame.contentWindow?.focus();
} }, '↓ Live'),
el('button', { class: 'ghost', title: 'reconnect', onclick: setSrc }, '⟳')
)
),
el('iframe', {
id: 'term-frame',
src: '/terminal/',
class: 'term-frame',
allow: 'clipboard-read; clipboard-write'
})
frame
);
}

View File

@@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest';
import { companionRegistry } from '../../../../lib/ai/agent/tools/index.js';
describe('companion registry', () => {
it('registers exactly the four v1 tools', () => {
it('registers exactly the five companion tools', () => {
expect(companionRegistry.listTools().map(t => t.name).sort())
.toEqual(['context', 'propose_change', 'read', 'search']);
.toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
});
it('exposes them in Anthropic shape', () => {
const tools = companionRegistry.toAnthropicTools();

99
tests/api/control.test.js Normal file
View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import request from 'supertest';
// The control router is a pure proxy: it forwards /api/control/* to
// ${IVCTL_URL}<path> injecting X-Admin-Token. We mock global fetch (the upstream
// ivctl call) and assert: owner-gate enforced, path forwarded, token injected,
// query + JSON body passed through, and 503 when IVCTL_URL is unset.
let createApp, app;
const owner = r => r.set('Authorization', 'Bearer test-token');
beforeAll(async () => {
process.env.OWNER_TOKEN = 'test-token';
process.env.IVCTL_URL = 'http://ivctl.test:8080';
process.env.IVCTL_ADMIN_TOKEN = 'admin-secret';
({ createApp } = await import('../../server.js'));
app = createApp();
});
let fetchSpy;
function mockUpstream(status = 200, json = { ok: true }) {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(json), {
status, headers: { 'content-type': 'application/json' }
}));
}
afterEach(() => { fetchSpy?.mockRestore(); fetchSpy = undefined; });
describe('/api/control proxy', () => {
beforeEach(() => { process.env.IVCTL_URL = 'http://ivctl.test:8080'; });
it('requires the owner token (401 without bearer)', async () => {
mockUpstream();
const res = await request(app).get('/api/control/admin/applicants');
expect(res.status).toBe(401);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects agent tokens / non-owner (403) — owner-only', async () => {
mockUpstream();
// A syntactically valid bearer that is NOT the owner token → agentOrOwner
// tries agent verify and fails → 401 before reaching requireOwner. Either
// way it must NOT reach the upstream.
const res = await request(app).get('/api/control/admin/applicants').set('Authorization', 'Bearer not-the-owner');
expect([401, 403]).toContain(res.status);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('forwards GET path + query and injects X-Admin-Token', async () => {
mockUpstream(200, [{ id: 1, status: 'pending' }]);
const res = await owner(request(app).get('/api/control/admin/applicants?status=pending'));
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: 1, status: 'pending' }]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, opts] = fetchSpy.mock.calls[0];
expect(url).toBe('http://ivctl.test:8080/admin/applicants?status=pending');
expect(opts.method).toBe('GET');
expect(opts.headers['X-Admin-Token']).toBe('admin-secret');
// owner bearer must NOT be forwarded upstream
expect(opts.headers.authorization).toBeUndefined();
});
it('forwards a JSON body on POST (approve → claim_code)', async () => {
mockUpstream(200, { claim_code: 'ABC123' });
const res = await owner(request(app).post('/api/control/admin/applicants/7/approve')).send({ group_id: 3 });
expect(res.status).toBe(200);
expect(res.body.claim_code).toBe('ABC123');
const [url, opts] = fetchSpy.mock.calls[0];
expect(url).toBe('http://ivctl.test:8080/admin/applicants/7/approve');
expect(opts.method).toBe('POST');
expect(JSON.parse(opts.body)).toEqual({ group_id: 3 });
expect(opts.headers['X-Admin-Token']).toBe('admin-secret');
});
it('passes PATCH bodies through (license update)', async () => {
mockUpstream(200, { id: 5, status: 'suspended' });
const res = await owner(request(app).patch('/api/control/admin/licenses/5')).send({ status: 'suspended' });
expect(res.status).toBe(200);
const [url, opts] = fetchSpy.mock.calls[0];
expect(url).toBe('http://ivctl.test:8080/admin/licenses/5');
expect(opts.method).toBe('PATCH');
expect(JSON.parse(opts.body)).toEqual({ status: 'suspended' });
});
it('streams the upstream status code back (e.g. 404)', async () => {
mockUpstream(404, { error: 'not_found' });
const res = await owner(request(app).get('/api/control/admin/tickets/999'));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
it('returns 503 ivctl_not_configured when IVCTL_URL is unset', async () => {
delete process.env.IVCTL_URL;
mockUpstream();
const res = await owner(request(app).get('/api/control/admin/applicants'));
expect(res.status).toBe(503);
expect(res.body.error).toBe('ivctl_not_configured');
expect(fetchSpy).not.toHaveBeenCalled();
});
});

View File

@@ -14,7 +14,7 @@ describe('dashboard layout api', () => {
it('GET returns defaults', async () => {
const res = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
expect(res.status).toBe(200);
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {} });
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
});
it('PUT persists and GET reflects it', async () => {

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import { createApp } from '../../server.js';
import { proposeImprovementTool } from '../../lib/ai/agent/tools/propose_improvement.js';
let app;
beforeAll(async () => {
await resetDb(); await migrateUp();
process.env.OWNER_TOKEN = 'test-token';
app = createApp();
});
const auth = (r) => r.set('Authorization', 'Bearer test-token');
const ctx = { agent: { slug: 'dross' } };
describe('dross improvements (2.14)', () => {
let id;
it('tool drafts a pending improvement, never applies', async () => {
const out = await proposeImprovementTool.handler(
{ summary: 'Soften card borders', css: '.card { border-radius: 10px; }' }, ctx);
expect(out.ok).toBe(true);
expect(out.note).toMatch(/NOT live/);
id = out.id;
const css = await request(app).get('/improvements.css');
expect(css.text).not.toContain('border-radius: 10px'); // pending ≠ live
});
it('tool rejects exfil css', async () => {
expect((await proposeImprovementTool.handler(
{ summary: 'evil', css: '.x { background: url(http://evil.tld/p.png); }' }, ctx)).error)
.toMatch(/url\(\)/);
expect((await proposeImprovementTool.handler(
{ summary: 'evil', css: '@import "http://evil.tld/x.css";' }, ctx)).error).toBeTruthy();
});
it('owner approves → live in the public stylesheet', async () => {
const res = await auth(request(app).post(`/api/improvements/${id}/approve`));
expect(res.status).toBe(200);
expect(res.body.status).toBe('active');
const css = await request(app).get('/improvements.css'); // unauthenticated by design
expect(css.headers['content-type']).toContain('text/css');
expect(css.text).toContain('border-radius: 10px');
expect(css.text).toContain('dross: Soften card borders');
});
it('rollback removes it instantly; restore brings it back', async () => {
await auth(request(app).post(`/api/improvements/${id}/rollback`));
expect((await request(app).get('/improvements.css')).text).not.toContain('border-radius');
await auth(request(app).post(`/api/improvements/${id}/restore`));
expect((await request(app).get('/improvements.css')).text).toContain('border-radius');
});
it('transitions are guarded (no approve on active, no anonymous verbs)', async () => {
expect((await auth(request(app).post(`/api/improvements/${id}/approve`))).status).toBe(409);
expect((await request(app).post(`/api/improvements/${id}/rollback`)).status).toBe(401);
});
});

View File

@@ -25,9 +25,9 @@ const suggestAgent = (id) => ({
});
describe('listMcpTools()', () => {
it('returns exactly the four companion tools sorted by name', () => {
it('returns exactly the five companion tools sorted by name', () => {
const tools = listMcpTools();
expect(tools.map(t => t.name).sort()).toEqual(['context', 'propose_change', 'read', 'search']);
expect(tools.map(t => t.name).sort()).toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
});
it('each tool has name, description, and input_schema', () => {

View File

@@ -10,7 +10,7 @@ import { listMcpTools, callMcpTool } from '../../lib/mcp/companion-stdio.js';
describe('MCP registry selection', () => {
it('defaults to the companion registry when VOID_TOOL_REGISTRY is unset', () => {
const names = listMcpTools({}).map(t => t.name).sort();
expect(names).toEqual(['context', 'propose_change', 'read', 'search']);
expect(names).toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
});
it('selects the security registry when VOID_TOOL_REGISTRY=security', () => {

View File

@@ -8,7 +8,7 @@ beforeAll(async () => { await resetDb(); await migrateUp(); });
describe('dashboard_layout repo', () => {
it('returns defaults when unset', async () => {
const l = await repo.get();
expect(l).toEqual({ card_order: [], hidden: [], sizes: {} });
expect(l).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
});
it('upserts and reads back', async () => {

View File

@@ -0,0 +1,19 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as clips from '../../lib/db/repos/voice_clips.js';
beforeEach(async () => { await resetDb(); await migrateUp(); });
describe('voice_clips repo', () => {
it('creates, lists newest-first, and removes (returning path)', async () => {
const a = await clips.create({ transcript: 'first', bytes: 10, mime: 'audio/webm', path: '/x/a.webm' });
const b = await clips.create({ transcript: 'second', bytes: 20, mime: 'audio/webm', path: '/x/b.webm' });
const list = await clips.list();
expect(list.length).toBe(2);
expect(list[0].transcript).toBe('second'); // newest first
const removed = await clips.remove(a.id);
expect(removed.path).toBe('/x/a.webm');
expect((await clips.list()).length).toBe(1);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../../server.js';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
let app;
const owner = { Authorization: 'Bearer test-token' };
beforeAll(async () => {
await resetDb(); await migrateUp();
process.env.OWNER_TOKEN = 'test-token';
app = createApp();
});
describe('dross chat', () => {
it('GET /api/dross returns a global conversation + Dross agent', async () => {
const res = await request(app).get('/api/dross').set(owner);
expect(res.status).toBe(200);
expect(res.body.conversation_id).toBeTruthy();
expect(res.body.agent.slug).toBe('companion');
expect(Array.isArray(res.body.messages)).toBe(true);
});
it('POST /api/dross/turn rejects empty text (400)', async () => {
const res = await request(app).post('/api/dross/turn').set(owner).send({ text: '' });
expect(res.status).toBe(400);
});
it('GET /api/dross without token is 401', async () => {
const res = await request(app).get('/api/dross');
expect(res.status).toBe(401);
});
});
describe('dross settings', () => {
it('GET /api/dross/settings returns defaults', async () => {
const res = await request(app).get('/api/dross/settings').set(owner);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' });
});
it('PUT /api/dross/settings persists and round-trips', async () => {
const body = { avatar: 'wisp', accent: '#aa66ff', persona: 'Be terse.', voiceMode: 'handsfree' };
const put = await request(app).put('/api/dross/settings').set(owner).send(body);
expect(put.status).toBe(200);
const get = await request(app).get('/api/dross/settings').set(owner);
expect(get.body).toMatchObject(body);
});
it('PUT rejects a bad avatar (400)', async () => {
const res = await request(app).put('/api/dross/settings').set(owner)
.send({ avatar: 'nope', accent: '#aa66ff', persona: '', voiceMode: 'review' });
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,24 @@
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../../server.js';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
let app;
const owner = { Authorization: 'Bearer test-token' };
beforeAll(async () => {
await resetDb(); await migrateUp();
process.env.OWNER_TOKEN = 'test-token';
app = createApp();
});
describe('voice transcribe route', () => {
it('401 without a token', async () => {
const res = await request(app).post('/api/voice/transcribe');
expect(res.status).toBe(401);
});
it('400 when no audio supplied', async () => {
const res = await request(app).post('/api/voice/transcribe').set(owner);
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,21 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { drossAvatar } from '../../public/components/dross_avatar.js';
describe('drossAvatar', () => {
it('renders the requested variant class', () => {
const eye = drossAvatar('soft-eye', 60);
expect(eye.classList.contains('dross-orb')).toBe(true);
expect(eye.querySelector('.av-eye')).toBeTruthy();
expect(drossAvatar('wisp', 30).querySelector('.b-core')).toBeTruthy();
expect(drossAvatar('motes', 30).querySelector('.d-core')).toBeTruthy();
});
it('falls back to soft-eye for unknown variants', () => {
expect(drossAvatar('bogus', 60).querySelector('.av-eye')).toBeTruthy();
});
it('sets the pixel size', () => {
const a = drossAvatar('wisp', 42);
expect(a.style.width).toBe('42px');
expect(a.style.height).toBe('42px');
});
});