auto: docs sync 2026-06-18T18:00:10+00:00

Files changed: .gitignore CLAUDE.md credentials engine/.embcache.json engine/changelog.md engine/config.yaml engine/lib/__pycache__/agent.cpython-312.pyc engine/lib/agent.py engine/lib/lint.py engine/lint-report.md engine/sweep-full.log engine/sweep.sh vault/.obsidian/graph.json vault/.obsidian/workspace.json vault/INDEX.md vault/archive/projects/mmud/last-ember-chronicle.html vault/archive/projects/mmud/last-ember-howto.html vault/archive/projects/mmud/last-ember.html vault/archive/projects/mmud/mmud-phase5-prompt.md vault/archive/projects/mmud/mmud-phase6-prompt.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/01-update-planned.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/02-npc-nodes.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/03-darkcragg.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/04-dcrg-node.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/05-phase5.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/06-phase6.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/README.md vault/archive/projects/mmud/mmud-prompts/mmud-prompts/mmud-project.md vault/docs/hardware/environment.md vault/docs/hardware/ip-allocation.md vault/docs/matrix/archivist.md vault/docs/matrix/matrix_host.md vault/docs/matrix/mautrix_signal.md vault/docs/matrix/synapse.md vault/docs/matrix/synapse_retention_discovery.md vault/docs/navi/cc-rules.md vault/docs/navi/deployment.md vault/docs/navi/themes.md vault/docs/services/ots-setup.md vault/docs/services/services.md vault/docs/services/usenet.md vault/docs/software/authentik.md vault/docs/software/caddy.md vault/docs/software/dns.md vault/docs/software/geo-tools.md vault/docs/software/recon.md vault/docs/software/searxng.md vault/glossary.md vault/notes/echo6-landing-page-data-export.md vault/notes/ia-download-queue.md vault/plans/vaultwarden-plan.md vault/projects/advbbs-project.md vault/projects/argus.md vault/projects/deploy-livesync.md vault/projects/matrix-synapse-deployment.md vault/projects/meshtastic-headscale-runbook.md vault/projects/mmud-project.md vault/runbooks/add-peertube-channel.md vault/runbooks/authentik-access-groups.md vault/runbooks/authentik-create-invitation.md vault/runbooks/authentik-oidc-application.md vault/runbooks/authentik-upgrade.md vault/runbooks/ct-runbook.md vault/runbooks/edge2-access-reference.md vault/runbooks/expose-service-contabo.md vault/runbooks/expose-service-edge2.md vault/runbooks/expose-service-home.md vault/runbooks/headscale-onboard-node.md vault/runbooks/ia-cli-reference.md vault/runbooks/ia-download-mirror.md vault/runbooks/idahomesh-bridge-setup.md vault/runbooks/idahomesh-vpn-device-setup.md vault/runbooks/lxc-service-migration.md vault/runbooks/mailcow-create-mailbox.md vault/runbooks/meshmonitor-password-reset.md vault/runbooks/meshtastic-sidecar-node.md vault/runbooks/meshtasticd-sim-nodes-runbook.md vault/runbooks/nordvpn-lxc.md vault/runbooks/peertube-remote-runner.md vault/runbooks/pg-backup.md vault/runbooks/pi-nas-omv-runbook.md vault/runbooks/pipeline-patterns.md vault/runbooks/proxmox-create-ubuntu-vm.md vault/runbooks/proxmox-onboard-node.md vault/runbooks/recon-operations.md vault/runbooks/recon-service-integration.md vault/runbooks/syncthing-add-node.md vault/session-resume/SESSION-HANDOFF-meshai-test.md
This commit is contained in:
echo6-autocommit 2026-06-18 18:00:10 +00:00
commit eb7eade7fa
88 changed files with 5469 additions and 6038 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
engine/.embcache.json
engine/.last-sweep
engine/sweep-cron.log

27
CLAUDE.md Normal file
View file

@ -0,0 +1,27 @@
# Echo6 Docs Vault — Claude Guidelines
This repo (`echo6-docs`, canonical on Forge) is both an Obsidian documentation vault (`vault/`) and its self-contained maintenance engine (`engine/`).
## The documentation model
- **Documentation library only.** Do NOT create entity/concept/node pages or stubs — nothing exists just to be a graph dot.
- **Tags = categories.** Each doc carries 13 honest category tags in frontmatter. Default to ONE; add a second only if the doc is genuinely about two co-equal subjects. The tag vocabulary lives in `engine/config.yaml` (`topic_categories`).
- **Inline `[[wikilinks]]` point ONLY to existing docs.** Never link to a non-existent target (zero unresolved/red nodes). If a thing has no doc, leave it as plain text.
- **No `INDEX.md`** — tags organize, not a hub.
- Frontmatter: `title, type, tags, related, updated` (+ optional `aliases, status`). `type` ∈ reference|runbook|project|note|session.
## The engine (`engine/`) — how it works
- Runs on cortex RTX A4000. Tagger = `vault-tagger` (Qwen2.5-7B-Instruct) via Ollama (`localhost:11434`); embeddings = bge-m3 via TEI (`localhost:8090`) for `related:`. Pure stdlib HTTP, no cloud.
- Daily cron at 09:00 UTC (GPU-guarded): `engine/sweep.sh` → lint + tag + link changed docs. Internals: `engine/sweep.sh`, `engine/lib/agent.py`, `engine/lib/lint.py`, `engine/config.yaml`.
- **Symlink guard:** `agent.py` skips symlinks (`os.path.islink`); `sweep.sh` uses `find -type f` to exclude them. `vault/CLAUDE-baseline.md` and `vault/rules/` are symlinks to `~/.claude/` — the engine skips them automatically.
- Run manually: `python3 engine/lib/agent.py <doc>` (one doc), `engine/sweep.sh` (changed docs), `engine/sweep.sh --all`.
- Lint (`engine/lib/lint.py`): dead links (stay 0), untagged docs, orphans, unknown tags, gap report (terms across many docs with no own doc — Matt decides whether to create one).
## Going forward
When you ADD or EDIT a doc: honest category tags, `[[wikilink]]` only existing docs, no invented pages. The gap report (`engine/lib/lint.py`) surfaces candidates for new docs; the next sweep links mentions once a doc exists.
## Sync & safety
Triple-synced: Obsidian LiveSync ↔ edge2 CouchDB, Syncthing on `/home/zvx/projects`, and `echo6-docs-autocommit` cron (every 6h → Forge). Mass file ops require care — commits happen automatically. Secrets live in `credentials` (the one documented exception to no-secrets-in-repo); never put secrets in a graph-tracked doc.

File diff suppressed because one or more lines are too long

View file

@ -58,3 +58,60 @@
- action: applied
- confidence: 0.95
- changes: type: '' → 'note'; tags: [] → ['auth', 'media', 'matrix']
## 2026-06-18 — batch_apply.py run
- MINT entities/mt-isr.md — mt-isr
- MINT entities/aida-nebra.md — aida-nebra
- MINT entities/headscale.md — Headscale
- MINT entities/meshtasticd.md — meshtasticd
- MINT entities/advbbs-project.md — advbbs-project
- MINT entities/caddy.md — caddy
- MINT entities/utility-caddy.md — Utility Caddy
- MINT entities/mailcow.md — Mailcow
- MINT entities/authentik.md — authentik
- MINT entities/matrix-synapse.md — Matrix Synapse
- MINT entities/element-web.md — Element Web
- MINT entities/vaultwarden.md — vaultwarden
- MINT entities/proxmox-ve.md — Proxmox VE
- MINT entities/watchtower.md — WATCHTOWER
- MINT entities/meshmonitor.md — meshmonitor
- MINT entities/echo6-search-searxng.md — Echo6 Search (SearXNG)
- MINT entities/recon.md — recon
- MINT entities/immich.md — immich
- MINT entities/nextcloud.md — nextcloud
- MINT entities/jellyfin.md — Jellyfin
- MINT entities/peertube.md — peertube
- MINT entities/open-webui.md — Open WebUI
- MINT entities/lidarr.md — lidarr
- MINT entities/navidrome.md — navidrome
- MINT entities/argus.md — argus
- MINT entities/searxng.md — searxng
- MINT entities/utility.md — Utility
- MINT entities/recon-vm.md — recon-vm
- MINT entities/qdrant.md — Qdrant
- MINT entities/tei.md — TEI
- MINT entities/meshtastic-hs.md — meshtastic-hs
- MINT entities/mesh-bridge.md — mesh-bridge
- MINT concepts/raspberry-pi.md — Raspberry Pi
- MINT concepts/meshtastic.md — Meshtastic
- MINT concepts/lora.md — LoRa
- MINT concepts/reverse-proxy.md — Reverse Proxy
- MINT concepts/split-dns.md — Split DNS
- MINT concepts/osint.md — OSINT
- MINT concepts/lxc-container.md — LXC Container
- MINT concepts/tailscale-ssh.md — Tailscale SSH
- MINT concepts/knowledge-extraction.md — Knowledge Extraction
- MINT concepts/vector-database.md — Vector Database
- MINT concepts/ocr.md — Ocr
- MINT concepts/firewall.md — Firewall
- MINT concepts/youtube.md — Youtube
- LINK+FM meshtastic-sidecar-node.md — 12 link candidates
- LINK+FM caddy.md — 30 link candidates
- LINK+FM argus.md — 5 link candidates
- LINK+FM ct-runbook.md — 2 link candidates
- LINK+FM recon.md — 9 link candidates
- LINK+FM meshtastic-headscale-runbook.md — 11 link candidates
- LINK+FM idahomesh-bridge-setup.md — 11 link candidates
- LINK+FM add-peertube-channel.md — 4 link candidates
- UPDATE .obsidian/graph.json — colorGroups by folder, nodeSizeMultiplier=2, showTags=false

View file

@ -52,3 +52,7 @@ behavior:
schedule:
cron: "0 9 * * *" # 09:00 UTC daily (off-peak); guard checks GPU before running
defer_if_gpu_busy_mib: 6000 # skip/defer if >this much VRAM already in use
# Extraction settings
extraction:
ubiquity_ceiling_pct: 25 # concepts in >25% of vault docs are AMBIENT; skip page + wikilink

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,19 @@
#!/usr/bin/env python3
"""
lint.py Deterministic Vault Lint (no LLM, stdlib only)
lint.py Deterministic Vault Lint (stdlib only)
Checks performed (all deterministic):
1. Frontmatter schema required keys, valid type, tags is a list
2. Tag vocabulary tags must be in topic_categories + {meta}
3. Dead wikilinks [[target]] resolves against note basenames + entity vocab
2. Tag vocabulary tags must be in topic_categories
3. Dead wikilinks [[target]] must resolve against vault note basenames
4. Orphans notes with zero incoming wikilinks (INFO, capped at 40)
Severity:
ERROR dead wikilinks
WARN schema violations, unknown tags
INFO orphan notes
Gaps & suggestions (written to ## Gaps & suggestions in lint-report.md):
- Docs with NO tags
- True orphans (no inbound link AND shares no tag with any other doc)
- Dead wikilinks (must be 0 after v4 sweep)
- Tags not in topic_categories
- "Earned-a-doc?" candidates: terms mentioned in >= 4 docs with no dedicated doc (top 15)
Usage:
python3 engine/lib/lint.py # report mode, always exit 0
@ -23,23 +25,19 @@ Writes: engine/lint-report.md
from __future__ import annotations
import json
import os
import re
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import NamedTuple
# ---------------------------------------------------------------------------
# Config / vocab loading (stdlib-only minimal YAML parser)
# ---------------------------------------------------------------------------
def _parse_simple_yaml(text: str) -> dict:
"""
Minimal YAML parser: handles only the scalar/list constructs in config.yaml.
Supports: key: value, key: [a, b], - item under a key, # comments.
Does NOT handle nested dicts beyond top-level enough for config.yaml.
"""
result: dict = {}
current_key = None
current_list: list | None = None
@ -48,58 +46,44 @@ def _parse_simple_yaml(text: str) -> dict:
line = raw_line.rstrip()
stripped = line.lstrip()
if not stripped or stripped.startswith("#"):
# Flush pending list if indent drops
if current_list is not None and not line.startswith(" "):
result[current_key] = current_list
current_list = None
current_key = None
continue
# List item under current key
if stripped.startswith("- ") and current_list is not None:
current_list.append(stripped[2:].strip().strip('"').strip("'"))
continue
# End of list block
if current_list is not None and not stripped.startswith("-"):
result[current_key] = current_list
current_list = None
current_key = None
if ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip()
if value.startswith("[") and value.endswith("]"):
# Inline list: [a, b, c]
inner = value[1:-1]
result[key] = [v.strip().strip('"').strip("'") for v in inner.split(",") if v.strip()]
elif value == "":
# May start a list block
current_key = key
current_list = []
elif value.startswith("#"):
result[key] = ""
else:
# Scalar — strip inline comment
value = value.split(" #")[0].strip().strip('"').strip("'")
result[key] = value
if current_list is not None and current_key:
result[current_key] = current_list
return result
def load_config(engine_dir: Path) -> dict:
"""Load config.yaml using minimal YAML parser."""
config_path = engine_dir / "config.yaml"
text = config_path.read_text(encoding="utf-8")
return _parse_simple_yaml(text)
return _parse_simple_yaml(config_path.read_text(encoding="utf-8"))
def load_vocab(engine_dir: Path) -> dict:
"""Load vocab.json."""
vocab_path = engine_dir / "vocab.json"
return json.loads(vocab_path.read_text(encoding="utf-8"))
@ -112,11 +96,6 @@ _FM_FENCE = re.compile(r"^---\s*$")
def parse_frontmatter(path: Path) -> tuple[dict | None, str]:
"""
Parse YAML frontmatter block from a markdown file.
Returns (frontmatter_dict_or_None, body_text).
body_text is the full file text if no frontmatter.
"""
text = path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines(keepends=True)
@ -133,23 +112,12 @@ def parse_frontmatter(path: Path) -> tuple[dict | None, str]:
return None, text
fm_text = "".join(lines[1:end_idx])
body = "".join(lines[end_idx + 1 :])
body = "".join(lines[end_idx + 1:])
fm = _parse_yaml_frontmatter(fm_text)
return fm, body
def _parse_yaml_frontmatter(text: str) -> dict:
"""
Parse simple flat YAML frontmatter. Handles:
key: scalar
key: [a, b, c]
key:
- a
- b
tags: [a, b]
tags:
- a
"""
result: dict = {}
current_key: str | None = None
current_list: list | None = None
@ -160,13 +128,9 @@ def _parse_yaml_frontmatter(text: str) -> dict:
if not stripped:
continue
# List item
if stripped.startswith("- ") and current_list is not None:
current_list.append(stripped[2:].strip().strip('"').strip("'"))
continue
# New key — flush pending list
if current_list is not None:
result[current_key] = current_list
current_list = None
@ -200,57 +164,45 @@ def _parse_yaml_frontmatter(text: str) -> dict:
# Check 1: Frontmatter schema
# ---------------------------------------------------------------------------
# v4: entity/concept types removed; valid types are the real doc types
VALID_TYPES = {"reference", "runbook", "project", "note", "index", "session"}
def check_frontmatter(fm: dict | None, config: dict) -> list[dict]:
"""
Returns list of findings dicts: {severity, check, message}
WARN severity for all schema violations.
"""
findings = []
required_keys = ["title", "type", "tags", "updated"]
if fm is None:
findings.append(
{
findings.append({
"severity": "WARN",
"check": "frontmatter",
"message": "missing frontmatter block entirely",
}
)
})
return findings
for key in required_keys:
if key not in fm or fm[key] is None or fm[key] == "":
findings.append(
{
findings.append({
"severity": "WARN",
"check": "frontmatter",
"message": f"missing required key: {key!r}",
}
)
})
if "type" in fm and fm["type"]:
if fm["type"] not in VALID_TYPES:
findings.append(
{
findings.append({
"severity": "WARN",
"check": "frontmatter",
"message": f"invalid type {fm['type']!r} — must be one of {sorted(VALID_TYPES)}",
}
)
})
if "tags" in fm and fm["tags"] is not None:
if not isinstance(fm["tags"], list):
findings.append(
{
findings.append({
"severity": "WARN",
"check": "frontmatter",
"message": f"tags must be a list, got {type(fm['tags']).__name__}: {fm['tags']!r}",
}
)
})
return findings
@ -259,27 +211,20 @@ def check_frontmatter(fm: dict | None, config: dict) -> list[dict]:
# Check 2: Tag vocabulary
# ---------------------------------------------------------------------------
ALLOWED_EXTRA_TAGS = {"meta"}
def check_tags(fm: dict | None, allowed_tags: set[str]) -> list[dict]:
"""Returns WARN findings for tags outside the allowed vocabulary."""
if fm is None:
return []
tags = fm.get("tags")
if not tags or not isinstance(tags, list):
return []
findings = []
for tag in tags:
if tag not in allowed_tags:
findings.append(
{
findings.append({
"severity": "WARN",
"check": "tag-vocab",
"message": f"unknown tag {tag!r} (not in topic_categories or allowed extras)",
}
)
"message": f"unknown tag {tag!r} (not in topic_categories)",
})
return findings
@ -287,21 +232,12 @@ def check_tags(fm: dict | None, allowed_tags: set[str]) -> list[dict]:
# Check 3: Dead wikilinks
# ---------------------------------------------------------------------------
# Matches [[target]], [[target|alias]], [[target#heading]]
# But NOT bash [[ ... ]] test syntax.
# Strategy: require the inner text to look like a note name:
# - no spaces at start/end of target
# - may contain word chars, hyphens, dots, spaces, #, | — but NOT operators like
# -n, -f, ==, !, &&, ||, etc.
# We detect shell syntax by checking for space-separated words starting with - or
# containing shell operators.
_WIKILINK_RE = re.compile(
r"""
\[\[ # opening [[
([^\[\]\n]+?) # capture: link target (non-greedy, no newlines)
(?:\|[^\[\]\n]*)? # optional |alias
\]\] # closing ]]
\[\[
([^\[\]\n]+?)
(?:\|[^\[\]\n]*)?
\]\]
""",
re.VERBOSE,
)
@ -311,18 +247,13 @@ _INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
def _looks_like_shell(inner: str) -> bool:
"""Return True if the wikilink inner text looks like bash test syntax."""
# Shell: -n "$VAR", ! -f "$FILE", "$CODEC" == "value", -z, etc.
s = inner.strip()
# Contains bash operators or variable expansions
if re.search(r'\$[{(A-Za-z_]', s):
return True
# Starts with - (flag) or ! (negation), or contains == / != / && / ||
if re.match(r'^\s*[!-]', s):
return True
if re.search(r'==|!=|&&|\|\|', s):
return True
# Multiple space-separated tokens that look like args
tokens = s.split()
if len(tokens) > 1 and tokens[0].startswith("-"):
return True
@ -330,103 +261,62 @@ def _looks_like_shell(inner: str) -> bool:
def _is_note_like(inner: str) -> bool:
"""
Return True if inner text looks like a note name:
word chars, hyphens, dots, spaces, but no shell operators.
"""
if _looks_like_shell(inner):
return False
# Must match a reasonable note-name pattern
# Allow: letters, digits, hyphens, underscores, dots, spaces
return bool(re.match(r'^[\w\s.\-/]+$', inner.strip()))
def _strip_code_blocks(text: str) -> str:
"""Remove fenced code blocks and inline code to avoid false wikilink matches."""
text = _FENCED_CODE_RE.sub("", text)
text = _INLINE_CODE_RE.sub("", text)
return text
def _extract_wikilinks(body: str) -> list[str]:
"""Extract note targets from wikilinks in body text, ignoring code blocks and shell syntax."""
clean = _strip_code_blocks(body)
targets = []
for m in _WIKILINK_RE.finditer(clean):
raw = m.group(1)
# Strip heading: [[target#heading|alias]] -> target
# Strip alias already handled by regex (group 1 = before |)
target = raw.split("|")[0].split("#")[0].strip()
if target and _is_note_like(target):
targets.append(target)
return targets
def _note_basename(path: Path) -> str:
"""Return the basename (without extension) of a vault note, lowercased."""
return path.stem.lower()
def _normalize_name(name: str) -> str:
"""Normalize: lowercase, spaces↔hyphens."""
return name.lower().replace(" ", "-").replace("_", "-")
def build_note_index(vault_dir: Path) -> dict[str, Path]:
"""
Build a mapping of normalized name Path for all vault notes.
One entry per file, keyed by normalized basename.
"""
index: dict[str, Path] = {}
for p in vault_dir.rglob("*.md"):
if "archive" in p.parts:
continue
key = _normalize_name(p.stem)
index[key] = p
return index
def build_entity_names(vocab: dict) -> set[str]:
"""
Build a set of normalized entity names + aliases from vocab.json.
These count as valid wikilink targets (they're in the knowledge graph).
"""
names: set[str] = set()
for entity in vocab.get("entities", []):
names.add(_normalize_name(entity["name"]))
names.add(_normalize_name(entity["display"]))
for alias in entity.get("aliases", []):
names.add(_normalize_name(alias))
for cat in vocab.get("topic_categories", []):
names.add(_normalize_name(cat))
return names
def check_dead_links(
path: Path,
body: str,
note_index: dict[str, Path],
entity_names: set[str],
) -> list[dict]:
"""
Returns ERROR findings for wikilinks that resolve to neither a vault note
nor a known entity name.
"""
findings = []
for target in _extract_wikilinks(body):
norm = _normalize_name(target)
if norm in note_index or norm in entity_names:
if norm in note_index:
continue
findings.append(
{
findings.append({
"severity": "ERROR",
"check": "dead-link",
"message": f"dead wikilink [[{target}]]",
}
)
})
return findings
# ---------------------------------------------------------------------------
# Check 4: Orphans (incoming wikilink count)
# Check 4: Orphans
# ---------------------------------------------------------------------------
ORPHAN_CAP = 40
@ -435,15 +325,11 @@ ORPHAN_CAP = 40
def build_backlink_counts(
vault_dir: Path,
note_index: dict[str, Path],
entity_names: set[str],
) -> dict[str, int]:
"""
Count how many other notes link TO each note.
Returns {normalized_stem: count}.
"""
counts: dict[str, int] = {k: 0 for k in note_index}
for p in vault_dir.rglob("*.md"):
if "archive" in p.parts:
continue
try:
_, body = parse_frontmatter(p)
except Exception:
@ -452,7 +338,6 @@ def build_backlink_counts(
norm = _normalize_name(target)
if norm in counts:
counts[norm] += 1
return counts
@ -461,25 +346,233 @@ def find_orphans(
note_index: dict[str, Path],
backlink_counts: dict[str, int],
) -> list[dict]:
"""
Return INFO findings for notes with zero incoming wikilinks.
Capped at ORPHAN_CAP entries.
"""
findings = []
for key, path in sorted(note_index.items()):
if backlink_counts.get(key, 0) == 0:
rel = path.relative_to(vault_dir)
findings.append(
{
findings.append({
"severity": "INFO",
"check": "orphan",
"message": f"no incoming links: {rel}",
"_path": path,
}
)
})
return findings[:ORPHAN_CAP]
# ---------------------------------------------------------------------------
# Gaps & suggestions
# ---------------------------------------------------------------------------
IMPORTANT_CANDIDATE_TERMS = [
"aida-nebra", "meshtastic", "headscale", "tailscale", "headplane",
"qdrant", "aurora", "open-webui", "gemini", "syncthing", "livesync",
"peertube", "mailcow", "forgejo", "jellyfin", "immich", "nextcloud",
"vaultwarden", "meshmonitor", "lora", "mt-isr", "sigil", "navi",
"searxng", "authentik", "docker", "proxmox", "caddy", "dnsmasq",
"acme-sh", "qdrant", "bge-m3", "qwen", "ollama", "meshtasticd",
"matrix-synapse", "element", "mautrix", "synapse",
]
def build_earned_a_doc_candidates(
vault_dir: Path,
note_index: dict[str, Path],
) -> list[tuple[str, int]]:
"""
Find terms mentioned in >= 4 docs that have no dedicated vault doc.
Returns [(term, mention_count)] sorted by count desc, top 15.
"""
existing_stems = set(note_index.keys())
doc_mentions: dict[str, set] = defaultdict(set)
for p in vault_dir.rglob("*.md"):
if "archive" in p.parts:
continue
try:
text = p.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
# Strip frontmatter
fm_match = re.match(r"^---.*?---\n", text, re.DOTALL)
body = text[fm_match.end():] if fm_match else text
# Strip code blocks
body = re.sub(r"```.*?```", "", body, flags=re.DOTALL)
body = re.sub(r"`[^`]+`", "", body)
body_lower = body.lower()
path_str = str(p)
# Check candidate terms
for term in IMPORTANT_CANDIDATE_TERMS:
norm = _normalize_name(term)
if norm in existing_stems:
continue # already has a doc
# Search for the term (and space variant) in body
variants = [term, term.replace("-", " ")]
for v in variants:
if v in body_lower:
doc_mentions[term].add(path_str)
break
# Also scan for hyphenated identifiers in wikilinks (already-linked terms
# that point to non-existent docs would show up as dead links above;
# here we look for plain-text mentions of kebab terms)
for m in re.finditer(r"\b([a-z][a-z0-9]{2,}-[a-z0-9][a-z0-9\-]{2,})\b", body_lower):
t = m.group(1)
if t not in existing_stems and len(t) >= 6:
# Filter out version strings, IPs, etc.
if not re.match(r"^\d", t) and "--" not in t:
doc_mentions[t].add(path_str)
# Filter to >= 4 mentions, sort by count desc, top 15
candidates = [
(term, len(docs))
for term, docs in doc_mentions.items()
if len(docs) >= 4
]
candidates.sort(key=lambda x: -x[1])
return candidates[:15]
def build_tag_coverage(
vault_dir: Path,
note_index: dict[str, Path],
) -> tuple[list[str], dict[str, list[str]]]:
"""
Returns (no_tag_paths, tag_to_paths) for shared-tag orphan check.
no_tag_paths: relative paths of docs with empty tags.
tag_to_paths: {tag: [doc_stem, ...]}
"""
no_tag_paths = []
tag_to_paths: dict[str, list[str]] = defaultdict(list)
for p in vault_dir.rglob("*.md"):
if "archive" in p.parts:
continue
try:
fm, _ = parse_frontmatter(p)
except Exception:
fm = None
tags = []
if fm:
t = fm.get("tags")
if isinstance(t, list):
tags = t
stem = _normalize_name(p.stem)
if not tags:
no_tag_paths.append(str(p.relative_to(vault_dir)))
for tag in tags:
tag_to_paths[tag].append(stem)
return no_tag_paths, tag_to_paths
def build_gaps_section(
vault_dir: Path,
note_index: dict[str, Path],
backlink_counts: dict[str, int],
all_results: list,
allowed_tags: set[str],
) -> str:
"""Build the ## Gaps & suggestions section."""
lines = ["## Gaps & suggestions", ""]
# 1. Docs with NO tags
no_tag_paths, tag_to_paths = build_tag_coverage(vault_dir, note_index)
lines.append("### Docs with no tags")
lines.append("")
if no_tag_paths:
for p in sorted(no_tag_paths):
lines.append(f"- `{p}`")
else:
lines.append("_None — all docs have at least one tag._")
lines.append("")
# 2. True orphans (no inbound link AND no shared tag)
lines.append("### True orphans (no inbound link, no shared tag)")
lines.append("")
true_orphans = []
for key, path in sorted(note_index.items()):
if backlink_counts.get(key, 0) > 0:
continue
# Check if any of its tags appear in other docs
try:
fm, _ = parse_frontmatter(path)
except Exception:
fm = None
tags = []
if fm:
t = fm.get("tags")
if isinstance(t, list):
tags = t
# Does any other doc share a tag?
shares_tag = False
for tag in tags:
others = tag_to_paths.get(tag, [])
# Others = docs with this tag; if any != self, shares_tag = True
self_key = _normalize_name(path.stem)
if any(s != self_key for s in others):
shares_tag = True
break
if not shares_tag:
rel = path.relative_to(vault_dir)
true_orphans.append(str(rel))
if true_orphans:
for p in true_orphans[:20]:
lines.append(f"- `{p}`")
else:
lines.append("_None._")
lines.append("")
# 3. Dead wikilinks (already in ERROR section, just summarize)
dead_count = sum(
1 for r in all_results
for f in r.findings
if f["severity"] == "ERROR" and f["check"] == "dead-link"
)
lines.append("### Dead wikilinks")
lines.append("")
if dead_count == 0:
lines.append("_None — zero dead wikilinks. All [[links]] resolve._")
else:
lines.append(f"**{dead_count} dead wikilinks** — see ERROR section above for details.")
lines.append("")
# 4. Tags not in topic_categories
lines.append("### Unknown tags (not in topic_categories)")
lines.append("")
unknown_tags: list[str] = []
for r in all_results:
for f in r.findings:
if f["severity"] == "WARN" and f["check"] == "tag-vocab":
unknown_tags.append(f["message"])
if unknown_tags:
for msg in unknown_tags:
lines.append(f"- {msg}")
else:
lines.append("_None — all tags are in the controlled vocabulary._")
lines.append("")
# 5. "Earned-a-doc?" candidates
lines.append("### Earned-a-doc? candidates (terms in ≥4 docs, no dedicated doc)")
lines.append("")
lines.append("Terms mentioned frequently across the vault but with no dedicated doc.")
lines.append("Matt decides whether to create a real doc — when he does, future sweeps will link to it.")
lines.append("")
candidates = build_earned_a_doc_candidates(vault_dir, note_index)
if candidates:
lines.append("| Term | Docs mentioning it |")
lines.append("|------|--------------------|")
for term, count in candidates:
lines.append(f"| `{term}` | {count} |")
else:
lines.append("_No candidates found (all frequent terms already have dedicated docs)._")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
@ -494,53 +587,47 @@ def format_report(
vault_dir: Path,
total_docs: int,
elapsed_s: float,
gaps_section: str,
) -> str:
"""Format a human-readable + markdown report."""
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
errors = [f for r in results for f in r.findings if f["severity"] == "ERROR"]
warns = [f for r in results for f in r.findings if f["severity"] == "WARN"]
infos = [f for r in results for f in r.findings if f["severity"] == "INFO"]
# Count specific warn subcategories
fm_missing = sum(
1
for r in results
for f in r.findings
if f["severity"] == "WARN"
and f["check"] == "frontmatter"
1 for r in results for f in r.findings
if f["severity"] == "WARN" and f["check"] == "frontmatter"
and "missing frontmatter block" in f["message"]
)
fm_invalid = sum(
1
for r in results
for f in r.findings
if f["severity"] == "WARN"
and f["check"] == "frontmatter"
1 for r in results for f in r.findings
if f["severity"] == "WARN" and f["check"] == "frontmatter"
and "missing frontmatter block" not in f["message"]
)
tag_warns = sum(
1 for r in results for f in r.findings if f["severity"] == "WARN" and f["check"] == "tag-vocab"
1 for r in results for f in r.findings
if f["severity"] == "WARN" and f["check"] == "tag-vocab"
)
lines = [
f"# Vault Lint Report",
f"",
"# Vault Lint Report",
"",
f"Generated: {ts} | Docs scanned: {total_docs} | Elapsed: {elapsed_s:.1f}s",
f"",
f"## Summary",
f"",
f"| Severity | Count |",
f"|----------|-------|",
"",
"## Summary",
"",
"| Severity | Count |",
"|----------|-------|",
f"| ERROR (dead links) | {len(errors)} |",
f"| WARN (schema) | {len(warns)} |",
f"| INFO (orphans) | {len(infos)} |",
f"",
f"### WARN breakdown",
"",
"### WARN breakdown",
f"- Missing frontmatter block: {fm_missing}",
f"- Invalid/missing frontmatter fields: {fm_invalid}",
f"- Unknown tags: {tag_warns}",
f"",
"",
]
if errors:
@ -565,7 +652,10 @@ def format_report(
for r in results:
ws = [f for f in r.findings if f["severity"] == "WARN"]
if ws:
try:
rel = r.path.relative_to(vault_dir)
except ValueError:
rel = r.path
for f in ws:
lines.append(f"- `{rel}` — {f['message']}")
lines.append("")
@ -577,32 +667,27 @@ def format_report(
lines.append(f"- {f['message']}")
lines.append("")
# Append gaps & suggestions section
lines.append(gaps_section)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Public API (importable by sweep.sh or other scripts)
# Public API
# ---------------------------------------------------------------------------
def run_lint(vault_dir: Path, engine_dir: Path) -> tuple[list[LintResult], dict]:
"""
Run all lint checks. Returns (results, stats_dict).
Importable entry point.
"""
import time
config = load_config(engine_dir)
vocab = load_vocab(engine_dir)
# Build allowed tag set
topic_cats = set(vocab.get("topic_categories", []))
topic_cats |= ALLOWED_EXTRA_TAGS
# No extra allowed tags in v4 (removed 'meta' catch-all)
note_index = build_note_index(vault_dir)
entity_names = build_entity_names(vocab)
# Build backlink counts (single pass over all docs)
backlink_counts = build_backlink_counts(vault_dir, note_index, entity_names)
backlink_counts = build_backlink_counts(vault_dir, note_index)
all_paths = sorted(vault_dir.rglob("*.md"))
results: list[LintResult] = []
@ -610,24 +695,27 @@ def run_lint(vault_dir: Path, engine_dir: Path) -> tuple[list[LintResult], dict]
t0 = time.monotonic()
for path in all_paths:
if "archive" in path.parts:
continue
findings: list[dict] = []
try:
fm, body = parse_frontmatter(path)
except Exception as exc:
findings.append(
{"severity": "WARN", "check": "parse-error", "message": f"could not parse: {exc}"}
)
findings.append({
"severity": "WARN",
"check": "parse-error",
"message": f"could not parse: {exc}",
})
results.append(LintResult(path=path, findings=findings))
continue
findings.extend(check_frontmatter(fm, config))
findings.extend(check_tags(fm, topic_cats))
findings.extend(check_dead_links(path, body, note_index, entity_names))
findings.extend(check_dead_links(path, body, note_index))
results.append(LintResult(path=path, findings=findings))
# Orphan check (uses backlink counts already computed)
# Orphan check
orphan_findings = find_orphans(vault_dir, note_index, backlink_counts)
# Attach orphan findings as a synthetic "vault" result
if orphan_findings:
results.append(LintResult(path=vault_dir / "_orphans_", findings=orphan_findings))
@ -639,6 +727,9 @@ def run_lint(vault_dir: Path, engine_dir: Path) -> tuple[list[LintResult], dict]
"warns": sum(1 for r in results for f in r.findings if f["severity"] == "WARN"),
"infos": sum(1 for r in results for f in r.findings if f["severity"] == "INFO"),
"elapsed_s": elapsed,
"note_index": note_index,
"backlink_counts": backlink_counts,
"topic_cats": topic_cats,
}
return results, stats
@ -652,17 +743,12 @@ def main() -> None:
strict = "--strict" in sys.argv
# Locate engine/vault dirs relative to this file
this_file = Path(__file__).resolve()
engine_dir = this_file.parent.parent # engine/lib/lint.py → engine/
engine_dir = this_file.parent.parent
config = load_config(engine_dir)
vault_str = config.get("vault_dir", "")
if not vault_str:
# Fallback: sibling of engine_dir named "vault"
vault_dir = engine_dir.parent / "vault"
else:
vault_dir = Path(vault_str)
vault_dir = Path(vault_str) if vault_str else engine_dir.parent / "vault"
if not vault_dir.exists():
print(f"ERROR: vault_dir not found: {vault_dir}", file=sys.stderr)
@ -670,19 +756,24 @@ def main() -> None:
results, stats = run_lint(vault_dir, engine_dir)
report_text = format_report(
results, vault_dir, stats["total_docs"], stats["elapsed_s"]
gaps_section = build_gaps_section(
vault_dir,
stats["note_index"],
stats["backlink_counts"],
results,
stats["topic_cats"],
)
report_text = format_report(
results, vault_dir, stats["total_docs"], stats["elapsed_s"],
gaps_section,
)
# Write report
report_path = engine_dir / "lint-report.md"
report_path.write_text(report_text, encoding="utf-8")
# Print to stdout
print(report_text)
print(f"--- Wrote: {report_path} ---")
# Summary line
print(
f"\nSummary: {stats['total_docs']} docs | "
f"{stats['errors']} ERRORs | {stats['warns']} WARNs | {stats['infos']} INFOs"

View file

@ -1,19 +1,19 @@
# Vault Lint Report
Generated: 2026-06-18T06:14:05Z | Docs scanned: 98 | Elapsed: 0.0s
Generated: 2026-06-18T17:51:42Z | Docs scanned: 87 | Elapsed: 0.0s
## Summary
| Severity | Count |
|----------|-------|
| ERROR (dead links) | 0 |
| WARN (schema) | 96 |
| WARN (schema) | 1 |
| INFO (orphans) | 38 |
### WARN breakdown
- Missing frontmatter block: 85
- Invalid/missing frontmatter fields: 3
- Unknown tags: 8
- Missing frontmatter block: 1
- Invalid/missing frontmatter fields: 0
- Unknown tags: 0
## ERROR — Dead Wikilinks
@ -22,139 +22,88 @@ _None. All wikilinks resolve._
## WARN — Schema & Tag Violations
- `CLAUDE-baseline.md` — missing frontmatter block entirely
- `INDEX.md` — missing required key: 'tags'
- `archive/AUDIT-2026-02-21.md` — missing frontmatter block entirely
- `archive/README.md` — missing frontmatter block entirely
- `archive/matrix/PHASE6_DECISION.md` — missing frontmatter block entirely
- `archive/matrix/PLAN.md` — missing frontmatter block entirely
- `archive/matrix/appservices.md` — missing frontmatter block entirely
- `archive/matrix/archive_receiver_discovery.md` — missing frontmatter block entirely
- `archive/matrix/archivist_discovery.md` — missing frontmatter block entirely
- `archive/matrix/hookshot_deployment_discovery.md` — missing frontmatter block entirely
- `archive/matrix/hookshot_e2ee_discovery.md` — missing frontmatter block entirely
- `archive/matrix/hookshot_payload_discovery.md` — missing frontmatter block entirely
- `archive/matrix/hookshot_room_targeting_discovery.md` — missing frontmatter block entirely
- `archive/projects/DEPLOY-API-KEYS-TAB.md` — missing frontmatter block entirely
- `archive/projects/arr-stack-runbook.md` — missing frontmatter block entirely
- `archive/projects/arr-wiring-runbook.md` — missing frontmatter block entirely
- `archive/projects/cc-deploy-watchtower-v2.md` — missing frontmatter block entirely
- `archive/projects/headscale-full-deployment.md` — missing frontmatter block entirely
- `archive/projects/last-ember-project.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-phase5-prompt.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-phase6-prompt.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-prompts/mmud-prompts/01-update-planned.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-prompts/mmud-prompts/02-npc-nodes.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-prompts/mmud-prompts/03-darkcragg.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-prompts/mmud-prompts/04-dcrg-node.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-prompts/mmud-prompts/05-phase5.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-prompts/mmud-prompts/06-phase6.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-prompts/mmud-prompts/README.md` — missing frontmatter block entirely
- `archive/projects/mmud/mmud-prompts/mmud-prompts/mmud-project.md` — missing frontmatter block entirely
- `archive/projects/openwebui-theme-deploy.md` — missing frontmatter block entirely
- `archive/projects/peertube-phase2-project.md` — missing frontmatter block entirely
- `archive/projects/peertube-rebuild.md` — missing frontmatter block entirely
- `archive/projects/utility-caddy-initial-setup.md` — missing frontmatter block entirely
- `archive/projects/vaultwarden-deployment.md` — missing frontmatter block entirely
- `archive/reports/logistics_migration.md` — missing frontmatter block entirely
- `archive/reports/post_validation_report.md` — missing frontmatter block entirely
- `archive/reports/task_a_aurora_validation.md` — missing frontmatter block entirely
- `archive/reports/task_c_watchdog_test.md` — missing frontmatter block entirely
- `docs/hardware/environment.md` — missing frontmatter block entirely
- `docs/hardware/ip-allocation.md` — missing frontmatter block entirely
- `docs/matrix/archivist.md` — missing frontmatter block entirely
- `docs/matrix/matrix_host.md` — missing frontmatter block entirely
- `docs/matrix/mautrix_signal.md` — missing frontmatter block entirely
- `docs/matrix/synapse.md` — missing frontmatter block entirely
- `docs/matrix/synapse_retention_discovery.md` — missing frontmatter block entirely
- `docs/navi/cc-rules.md` — missing frontmatter block entirely
- `docs/navi/deployment.md` — missing frontmatter block entirely
- `docs/navi/themes.md` — missing frontmatter block entirely
- `docs/services/ots-setup.md` — missing frontmatter block entirely
- `docs/services/services.md` — missing frontmatter block entirely
- `docs/services/usenet.md` — missing frontmatter block entirely
- `docs/software/dns.md` — missing frontmatter block entirely
- `docs/software/geo-tools.md` — missing frontmatter block entirely
- `docs/software/searxng.md` — missing frontmatter block entirely
- `notes/ia-download-queue.md` — missing frontmatter block entirely
- `plans/vaultwarden-plan.md` — missing frontmatter block entirely
- `projects/advbbs-project.md` — missing frontmatter block entirely
- `projects/deploy-livesync.md` — missing frontmatter block entirely
- `projects/matrix-synapse-deployment.md` — missing frontmatter block entirely
- `projects/mmud-project.md` — missing frontmatter block entirely
- `runbooks/authentik-create-invitation.md` — missing frontmatter block entirely
- `runbooks/authentik-oidc-application.md` — missing frontmatter block entirely
- `runbooks/authentik-upgrade.md` — missing frontmatter block entirely
- `runbooks/edge2-access-reference.md` — missing frontmatter block entirely
- `runbooks/expose-service-contabo.md` — missing frontmatter block entirely
- `runbooks/expose-service-edge2.md` — missing frontmatter block entirely
- `runbooks/expose-service-home.md` — missing frontmatter block entirely
- `runbooks/headscale-onboard-node.md` — missing frontmatter block entirely
- `runbooks/ia-cli-reference.md` — missing frontmatter block entirely
- `runbooks/ia-download-mirror.md` — missing frontmatter block entirely
- `runbooks/idahomesh-bridge-setup.md` — missing frontmatter block entirely
- `runbooks/idahomesh-vpn-device-setup.md` — missing frontmatter block entirely
- `runbooks/lxc-service-migration.md` — missing frontmatter block entirely
- `runbooks/mailcow-create-mailbox.md` — missing frontmatter block entirely
- `runbooks/meshmonitor-password-reset.md` — missing frontmatter block entirely
- `runbooks/meshtasticd-sim-nodes-runbook.md` — missing frontmatter block entirely
- `runbooks/nordvpn-lxc.md` — missing frontmatter block entirely
- `runbooks/peertube-remote-runner.md` — missing frontmatter block entirely
- `runbooks/pg-backup.md` — missing frontmatter block entirely
- `runbooks/pi-nas-omv-runbook.md` — missing frontmatter block entirely
- `runbooks/pipeline-patterns.md` — missing frontmatter block entirely
- `runbooks/proxmox-create-ubuntu-vm.md` — missing frontmatter block entirely
- `runbooks/proxmox-onboard-node.md` — missing frontmatter block entirely
- `runbooks/recon-operations.md` — missing frontmatter block entirely
- `runbooks/recon-service-integration.md` — missing frontmatter block entirely
- `runbooks/syncthing-add-node.md` — missing frontmatter block entirely
- `session-resume/SESSION-HANDOFF-meshai-test.md` — missing required key: 'updated'
- `session-resume/SESSION-HANDOFF-meshai-test.md` — invalid type 'session-resume' — must be one of ['index', 'note', 'project', 'reference', 'runbook', 'session']
- `session-resume/SESSION-HANDOFF-meshai-test.md` — unknown tag 'session-resume' (not in topic_categories or allowed extras)
- `session-resume/SESSION-HANDOFF-meshai-test.md` — unknown tag 'handoff' (not in topic_categories or allowed extras)
- `session-resume/SESSION-HANDOFF-meshai-test.md` — unknown tag 'meshai' (not in topic_categories or allowed extras)
- `session-resume/SESSION-HANDOFF-meshai-test.md` — unknown tag 'meshtastic' (not in topic_categories or allowed extras)
- `session-resume/SESSION-HANDOFF-meshai-test.md` — unknown tag 'aida-nebra' (not in topic_categories or allowed extras)
- `session-resume/SESSION-HANDOFF-meshai-test.md` — unknown tag 'diagnostic' (not in topic_categories or allowed extras)
- `session-resume/SESSION-HANDOFF-meshai-test.md` — unknown tag 'resilience-test' (not in topic_categories or allowed extras)
- `session-resume/SESSION-HANDOFF-meshai-test.md` — unknown tag 'open' (not in topic_categories or allowed extras)
## INFO — Orphan Notes (no incoming links, capped at 40)
- no incoming links: archive/projects/mmud/mmud-prompts/mmud-prompts/01-update-planned.md
- no incoming links: archive/projects/mmud/mmud-prompts/mmud-prompts/02-npc-nodes.md
- no incoming links: archive/projects/mmud/mmud-prompts/mmud-prompts/03-darkcragg.md
- no incoming links: archive/projects/mmud/mmud-prompts/mmud-prompts/04-dcrg-node.md
- no incoming links: archive/projects/mmud/mmud-prompts/mmud-prompts/05-phase5.md
- no incoming links: archive/projects/mmud/mmud-prompts/mmud-prompts/06-phase6.md
- no incoming links: archive/matrix/appservices.md
- no incoming links: archive/matrix/archive_receiver_discovery.md
- no incoming links: archive/matrix/archivist_discovery.md
- no incoming links: archive/projects/arr-stack-runbook.md
- no incoming links: archive/projects/arr-wiring-runbook.md
- no incoming links: archive/AUDIT-2026-02-21.md
- no incoming links: archive/projects/cc-deploy-watchtower-v2.md
- no incoming links: archive/projects/DEPLOY-API-KEYS-TAB.md
- no incoming links: runbooks/add-peertube-channel.md
- no incoming links: runbooks/authentik-access-groups.md
- no incoming links: runbooks/authentik-create-invitation.md
- no incoming links: runbooks/authentik-oidc-application.md
- no incoming links: runbooks/authentik-upgrade.md
- no incoming links: docs/navi/cc-rules.md
- no incoming links: CLAUDE-baseline.md
- no incoming links: notes/echo6-landing-page-data-export.md
- no incoming links: runbooks/edge2-access-reference.md
- no incoming links: runbooks/expose-service-contabo.md
- no incoming links: runbooks/expose-service-edge2.md
- no incoming links: runbooks/expose-service-home.md
- no incoming links: docs/software/geo-tools.md
- no incoming links: glossary.md
- no incoming links: archive/projects/headscale-full-deployment.md
- no incoming links: archive/matrix/hookshot_deployment_discovery.md
- no incoming links: archive/matrix/hookshot_e2ee_discovery.md
- no incoming links: archive/matrix/hookshot_payload_discovery.md
- no incoming links: archive/matrix/hookshot_room_targeting_discovery.md
- no incoming links: INDEX.md
- no incoming links: archive/projects/last-ember-project.md
- no incoming links: archive/reports/logistics_migration.md
- no incoming links: archive/projects/mmud/mmud-phase5-prompt.md
- no incoming links: archive/projects/mmud/mmud-phase6-prompt.md
- no incoming links: archive/projects/openwebui-theme-deploy.md
- no incoming links: archive/projects/peertube-phase2-project.md
- no incoming links: archive/projects/peertube-rebuild.md
- no incoming links: archive/matrix/PLAN.md
- no incoming links: archive/reports/post_validation_report.md
- no incoming links: archive/projects/mmud/mmud-prompts/mmud-prompts/README.md
- no incoming links: archive/reports/task_a_aurora_validation.md
- no incoming links: archive/reports/task_c_watchdog_test.md
- no incoming links: archive/projects/utility-caddy-initial-setup.md
- no incoming links: archive/projects/vaultwarden-deployment.md
- no incoming links: runbooks/headscale-onboard-node.md
- no incoming links: runbooks/ia-cli-reference.md
- no incoming links: runbooks/ia-download-mirror.md
- no incoming links: notes/ia-download-queue.md
- no incoming links: runbooks/idahomesh-bridge-setup.md
- no incoming links: runbooks/idahomesh-vpn-device-setup.md
- no incoming links: runbooks/lxc-service-migration.md
- no incoming links: runbooks/mailcow-create-mailbox.md
- no incoming links: docs/matrix/matrix_host.md
- no incoming links: runbooks/meshmonitor-password-reset.md
- no incoming links: runbooks/meshtastic-sidecar-node.md
- no incoming links: runbooks/meshtasticd-sim-nodes-runbook.md
- no incoming links: runbooks/nordvpn-lxc.md
- no incoming links: runbooks/pg-backup.md
- no incoming links: runbooks/pi-nas-omv-runbook.md
- no incoming links: runbooks/pipeline-patterns.md
- no incoming links: runbooks/proxmox-create-ubuntu-vm.md
- no incoming links: runbooks/proxmox-onboard-node.md
- no incoming links: runbooks/recon-operations.md
- no incoming links: runbooks/recon-service-integration.md
- no incoming links: session-resume/SESSION-HANDOFF-meshai-test.md
- no incoming links: docs/matrix/synapse_retention_discovery.md
- no incoming links: runbooks/syncthing-add-node.md
- no incoming links: plans/vaultwarden-plan.md
## Gaps & suggestions
### Docs with no tags
- `CLAUDE-baseline.md`
- `runbooks/pipeline-patterns.md`
### True orphans (no inbound link, no shared tag)
- `CLAUDE-baseline.md`
- `runbooks/nordvpn-lxc.md`
- `runbooks/pipeline-patterns.md`
### Dead wikilinks
_None — zero dead wikilinks. All [[links]] resolve._
### Unknown tags (not in topic_categories)
_None — all tags are in the controlled vocabulary._
### Earned-a-doc? candidates (terms in ≥4 docs, no dedicated doc)
Terms mentioned frequently across the vault but with no dedicated doc.
Matt decides whether to create a real doc — when he does, future sweeps will link to it.
| Term | Docs mentioning it |
|------|--------------------|
| `tailscale` | 31 |
| `proxmox` | 28 |
| `docker` | 27 |
| `headscale` | 21 |
| `peertube` | 15 |
| `meshtastic` | 14 |
| `navi` | 14 |
| `mailcow` | 11 |
| `forgejo` | 10 |
| `immich` | 10 |
| `nextcloud` | 10 |
| `vaultwarden` | 10 |
| `jellyfin` | 9 |
| `meshtasticd` | 9 |
| `aida-nebra` | 8 |

3492
engine/sweep-full.log Normal file

File diff suppressed because it is too large Load diff

View file

@ -132,7 +132,7 @@ if [ "$OPT_ALL" -eq 1 ] || [ ! -f "$LAST_SWEEP" ]; then
continue
fi
DOC_LIST+=("$f")
done < <(find "${VAULT_DIR}" -name "*.md" -print0 | sort -z)
done < <(find "${VAULT_DIR}" -name "*.md" -type f -print0 | sort -z) # -type f excludes symlinks (e.g. CLAUDE-baseline.md, vault/rules/)
else
LAST_TS="$(cat "$LAST_SWEEP")"
echo " Mode: incremental — docs modified since ${LAST_TS}"
@ -142,7 +142,7 @@ else
continue
fi
DOC_LIST+=("$f")
done < <(find "${VAULT_DIR}" -name "*.md" -newer "$LAST_SWEEP" -print0 | sort -z)
done < <(find "${VAULT_DIR}" -name "*.md" -type f -newer "$LAST_SWEEP" -print0 | sort -z) # -type f excludes symlinks
fi
TOTAL="${#DOC_LIST[@]}"

View file

@ -1,22 +1,65 @@
{
"collapse-filter": true,
"collapse-filter": false,
"search": "",
"showTags": true,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": true,
"colorGroups": [],
"collapse-display": true,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.518713248970312,
"repelStrength": 10,
"colorGroups": [
{
"query": "path:entities/",
"color": {
"a": 1,
"rgb": 16736330
}
},
{
"query": "path:concepts/",
"color": {
"a": 1,
"rgb": 16744272
}
},
{
"query": "path:runbooks/",
"color": {
"a": 1,
"rgb": 4359424
}
},
{
"query": "path:docs/",
"color": {
"a": 1,
"rgb": 3394815
}
},
{
"query": "path:projects/",
"color": {
"a": 1,
"rgb": 10040063
}
},
{
"query": "path:notes/",
"color": {
"a": 1,
"rgb": 16776960
}
}
],
"collapse-display": false,
"showArrow": true,
"textFadeMultiplier": -1.6,
"nodeSizeMultiplier": 1.22291666666667,
"lineSizeMultiplier": 1.80989583333333,
"collapse-forces": false,
"centerStrength": 0.328125,
"repelStrength": 20,
"linkStrength": 1,
"linkDistance": 250,
"scale": 1,
"linkDistance": 500,
"scale": 0.08779149519890246,
"close": false
}

View file

@ -4,21 +4,17 @@
"type": "split",
"children": [
{
"id": "dfc9cecf81790934",
"id": "aad0932c415f7376",
"type": "tabs",
"children": [
{
"id": "ea4cc678c44e8b67",
"id": "8d53cdb6c257e685",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "glossary.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "glossary"
"type": "graph",
"state": {},
"icon": "lucide-git-fork",
"title": "Graph view"
}
}
]
@ -53,7 +49,7 @@
"state": {
"type": "search",
"state": {
"query": "",
"query": "tag:#mesh",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
@ -164,13 +160,23 @@
"icon": "lucide-list",
"title": "Outline of CLAUDE-baseline"
}
},
{
"id": "c3fdcf2b3358f125",
"type": "leaf",
"state": {
"type": "p2p-server-status",
"state": {},
"icon": "waypoints",
"title": "P2P Status"
}
]
}
],
"currentTab": 5
}
],
"direction": "horizontal",
"width": 300,
"collapsed": true
"width": 300
},
"left-ribbon": {
"hiddenItems": {
@ -187,8 +193,37 @@
"obsidian-livesync:Show Customization sync": false
}
},
"active": "ea4cc678c44e8b67",
"active": "8d53cdb6c257e685",
"lastOpenFiles": [
"archive/projects/mmud/mmud-phase6-prompt.md",
"archive/projects/last-ember-project.md",
"projects/mmud-project.md",
"docs/services/services.md",
"concepts/lxc-container.md",
"concepts/osint.md",
"concepts/split-dns.md",
"concepts/vector-database.md",
"concepts/meshtastic.md",
"concepts/ocr.md",
"Untitled.canvas",
"archive/projects/mmud/mmud-prompts/mmud-prompts/01-update-planned.md",
"INDEX.md",
"glossary.md",
"concepts/youtube.md",
"concepts/tailscale-ssh.md",
"concepts/reverse-proxy.md",
"concepts/raspberry-pi.md",
"concepts/lora.md",
"concepts/knowledge-extraction.md",
"concepts/firewall.md",
"concepts",
"entities/mesh-bridge.md",
"entities/meshtastic-hs.md",
"entities/tei.md",
"entities/qdrant.md",
"entities/recon-vm.md",
"entities/utility.md",
"entities",
"credentials.tmp.40509.595364788ca8",
"runbooks/lxc-service-migration.md.tmp.40509.ac2c03680b76",
"runbooks/lxc-service-migration.md.tmp.40509.c3f4d9e4365e",
@ -197,34 +232,6 @@
"docs/hardware/ip-allocation.md.tmp.40509.03b7ba9c244f",
"docs/hardware/ip-allocation.md.tmp.40509.a068767a4b20",
"docs/hardware/ip-allocation.md.tmp.40509.da0228cd9f66",
"docs/hardware/environment.md.tmp.40509.e6282fa31980",
"docs/hardware/environment.md.tmp.40509.4bb7ceaced2b",
"docs/hardware/environment.md.tmp.40509.a6b0a267d126",
"glossary.md",
"docs/hardware/environment.md",
"nodes/ots-ct.md",
"mocs/mesh.md",
"mocs/matrix.md",
"mocs/media.md",
"hardware/station-g2.md",
"hardware/waveshare-eth-hub.md",
"hardware/nebra-2w-bb.md",
"hardware/nebra-2w-aida.md",
"services/ots.md",
"services/mesh-bridge.md",
"services/idahomesh-headscale.md",
"services/meshai.md",
"services/meshmonitor.md",
"services/meshtasticd-bb.md",
"services/meshtasticd-isr.md",
"services/meshtasticd-aida.md",
"nodes/mt-burleybutte.md",
"nodes/mt-isr.md",
"nodes/aida-nebra.md",
"nodes/mesh-bridge-ct.md",
"nodes/meshtastic-hs-ct.md",
"nodes/meshai-ct.md",
"nodes/meshmonitor-ct.md",
"assets/echo6yellow_logo_422x422_square.png",
"assets/echo6yellow_logo_422x81.png",
"assets/echo6_logo.png",

View file

@ -1,100 +0,0 @@
---
type: index
title: Echo6 Knowledge Base
updated: 2026-06-17
---
# Echo6 Knowledge Base
The map of this vault — start here. `docs/` is current reference, `runbooks/` is how-to procedures, `projects/` is living context for active work, `notes/` is loose operational notes, and `archive/` is the historical paper trail.
> [!tip] How to use this
> Click any link to jump to that note. Each note shows its **backlinks** (what points to it) at the bottom — including this index — so you can always get back home. The **graph view** (left ribbon) shows how it all connects.
---
## 🧭 Reference — current state (`docs/`)
**Infrastructure**
- [[environment]] — Proxmox cluster, VMs, LXCs, Headscale nodes, SSH access
- [[ip-allocation]] — full 192.168.1.x IP allocation table (LXCs, VMs, hosts, Meshtastic nodes, Tailscale IPs)
- [[services]] — full services inventory by server (ports, compose paths, access URLs)
- [[ots-setup]] — OpenTAKServer on CT 109 (native install, MQTT gateway, Meshtastic integration, troubleshooting)
- [[usenet]] — SABnzbd + Usenet providers & indexers
**Software / platforms** (`docs/software/`)
- [[authentik]] — SSO: OAuth2 providers, groups, flows, branding, enrollment
- [[caddy]] — reverse proxy (Contabo + utility), dnsmasq split DNS, GoDaddy records, port map
- [[dns]] — GoDaddy DNS script, managed domains, common patterns
- [[recon]] — RECON knowledge-extraction pipeline (architecture, stack, API)
- [[searxng]] — Echo6 Search homepage (theme, config, deploy)
- [[geo-tools]] — cortex geo-processing tools (GDAL, tippecanoe, pmtiles)
**Matrix / Signal** (`docs/matrix/`)
- [[synapse]] — Synapse + MAS + Postgres + Caddy deployment reference
- [[mautrix_signal]] — Signal bridge (image, DB, E2BE, commands)
- [[archivist]] — CT 118 Signal/Matrix archive bot
- [[matrix_host]] — Contabo VPS host specs
- [[synapse_retention_discovery]] — retention config, MSC2815, DB sizes, rollback
**navi** (`docs/navi/`)
- [[cc-rules]] — Claude Code rules for working on navi
- [[deployment]] — navi build/deploy (VM 1130, nginx, Caddy route, rollback)
- [[themes]] — navi theme system (registry, namedTheme rule, CSS vars)
---
## 🛠️ Procedures (`runbooks/`)
- **Authentik:** [[authentik-oidc-application]] · [[authentik-access-groups]] · [[authentik-create-invitation]] · [[authentik-upgrade]]
- **Exposing a service:** [[expose-service-contabo]] · [[expose-service-home]]
- **Service migration:** [[lxc-service-migration]] — move a Contabo-Caddy-fronted service to edge2 LXC (Vaultwarden pilot 2026-06-16)
- **Proxmox / hosts:** [[ct-runbook]] · [[proxmox-create-ubuntu-vm]] · [[proxmox-onboard-node]] · [[pi-nas-omv-runbook]] · [[headscale-onboard-node]]
- **RECON:** [[recon-operations]] · [[recon-service-integration]]
- **Mesh / Meshtastic:** [[meshtastic-sidecar-node]] · [[meshtasticd-sim-nodes-runbook]] · [[idahomesh-bridge-setup]] · [[idahomesh-vpn-device-setup]] · [[meshmonitor-password-reset]]
- **PeerTube / media:** [[add-peertube-channel]] · [[peertube-remote-runner]]
- **Internet Archive:** [[ia-cli-reference]] · [[ia-download-mirror]]
- **Patterns & infra:** [[pipeline-patterns]] · [[pg-backup]] · [[nordvpn-lxc]] · [[syncthing-add-node]] · [[mailcow-create-mailbox]]
---
## 📦 Projects (`projects/`)
Living "read-me-first" context for active work:
- [[advbbs-project]] — advBBS Meshtastic federated BBS
- [[mmud-project]] — MMUD mesh dungeon game
- [[argus]] — ARGUS OSINT intelligence platform (in progress)
- [[deploy-livesync]] — Obsidian LiveSync (CouchDB + JWT) reference
- [[matrix-synapse-deployment]] — Matrix Synapse + Element + SSO rebuild reference
- [[meshtastic-headscale-runbook]] — IdahoMesh Headscale + bridge
---
## 🗒️ Notes (`notes/`)
- [[ia-download-queue]] — Internet Archive pull-down queue (operational tracking)
- [[echo6-landing-page-data-export]] — platform/brand/services reference snapshot
---
## ⚙️ Baseline & rules
- [[CLAUDE-baseline]] — the global Claude Code rules (read-only mirror of `~/.claude/CLAUDE.md`)
- `rules/` — per-system conventions (docker, proxmox, caddy, tak, meshtastic, …) — read-only mirror of `~/.claude/rules/`
---
## 🔄 Session resume (`session-resume/`)
Live handoffs for sessions paused on one machine and resumed on another (cross-machine `~/.claude` sync is parked). Tagged `#session-resume`; `status: open` = not yet finished.
- [[SESSION-HANDOFF-meshai-test]] — paused: pulling MeshAI (CT 108) logs to see how it handled the aida-nebra radio drop. Blocked from matt-desktop (no SSH key on utility); resume on cortex. `#open`
---
## 🗄️ Archive
Historical material lives in `archive/` — paper trail, not living docs:
- `archive/projects/` + `archive/reports/` — completed one-time deploy logs and migration reports
- `archive/matrix/` — the Matrix-archive investigation saga: the rejected Hookshot path and the [[PHASE6_DECISION]] decision record
- `archive/AUDIT-2026-02-21.md` — the Feb 2026 doc-vs-infra audit

View file

@ -1,823 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Last Ember — Chronicle</title>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--ember: #e8713a;
--ember-glow: #ff9d5c;
--ember-deep: #c44e1a;
--ash: #1a1714;
--charcoal: #0d0b09;
--smoke: #2a2520;
--smoke-light: #3d3630;
--parchment: #d4c4a8;
--parchment-dark: #b8a88c;
--parchment-faded: #a89878;
--bone: #c8b898;
--blood: #8b2020;
--blood-bright: #cc3333;
--gold: #c4a44a;
--gold-dim: #8a7a3a;
--frost: #7a9ab0;
--poison: #5a8a4a;
--text-bright: #e8dcc8;
--text-dim: #9a8e78;
--text-ghost: #5a5244;
--victory: #5a8a4a;
--defeat: #8b2020;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--charcoal);
color: var(--text-bright);
font-family: 'Crimson Text', Georgia, serif;
min-height: 100vh;
overflow-x: hidden;
}
#ember-canvas {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
opacity: 0.4;
}
.page-wrap {
position: relative;
z-index: 2;
max-width: 800px;
margin: 0 auto;
padding: 0 24px;
}
/* ═══ NAV ═══ */
.nav-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
padding: 20px 0;
border-bottom: 1px solid rgba(90,82,68,0.15);
margin-bottom: 12px;
}
.nav-link {
font-family: 'Cinzel', serif;
font-size: 10px;
letter-spacing: 0.25em;
color: var(--text-ghost);
text-decoration: none;
text-transform: uppercase;
padding: 6px 0;
border-bottom: 1px solid transparent;
transition: all 0.3s;
cursor: pointer;
}
.nav-link:hover { color: var(--parchment-faded); }
.nav-link.active {
color: var(--parchment);
border-bottom-color: var(--ember);
}
.nav-home {
font-family: 'Cinzel', serif;
font-size: 14px;
color: var(--parchment-faded);
text-decoration: none;
letter-spacing: 0.1em;
transition: color 0.3s;
}
.nav-home:hover { color: var(--ember-glow); }
/* ═══ PAGE HEADER ═══ */
.page-header {
text-align: center;
padding: 40px 0 12px;
}
.page-title {
font-family: 'Cinzel', serif;
font-size: clamp(22px, 4vw, 32px);
font-weight: 700;
letter-spacing: 0.12em;
color: var(--parchment);
text-shadow: 0 0 30px rgba(232,113,58,0.2);
margin-bottom: 6px;
}
.page-subtitle {
font-size: 15px;
font-style: italic;
color: var(--text-ghost);
max-width: 500px;
margin: 0 auto;
line-height: 1.5;
}
.divider {
display: flex;
align-items: center;
gap: 16px;
margin: 28px 0;
color: var(--text-ghost);
font-size: 11px;
letter-spacing: 0.2em;
font-family: 'Cinzel', serif;
}
.divider::before, .divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--smoke-light), transparent);
}
/* ═══ CHRONICLE — EPOCH CARDS ═══ */
.epoch-card {
position: relative;
margin-bottom: 40px;
padding: 28px 32px;
background: linear-gradient(180deg, rgba(26,23,20,0.95), rgba(13,11,9,0.95));
border: 1px solid var(--smoke-light);
border-radius: 2px;
overflow: hidden;
}
.epoch-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
}
.epoch-card.victory::before {
background: linear-gradient(90deg, transparent, var(--victory), transparent);
}
.epoch-card.defeat::before {
background: linear-gradient(90deg, transparent, var(--defeat), transparent);
}
.epoch-card.current::before {
background: linear-gradient(90deg, transparent, var(--ember), transparent);
animation: current-pulse 3s ease-in-out infinite;
}
@keyframes current-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.epoch-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.epoch-number {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.2em;
color: var(--text-ghost);
margin-bottom: 4px;
}
.epoch-title {
font-family: 'Cinzel', serif;
font-size: 20px;
font-weight: 600;
color: var(--parchment);
line-height: 1.3;
}
.epoch-outcome {
flex-shrink: 0;
padding: 4px 14px;
font-family: 'Cinzel', serif;
font-size: 10px;
letter-spacing: 0.2em;
border-radius: 1px;
text-transform: uppercase;
}
.epoch-outcome.victory {
border: 1px solid var(--victory);
color: var(--victory);
background: rgba(90,138,74,0.08);
}
.epoch-outcome.defeat {
border: 1px solid var(--defeat);
color: var(--blood-bright);
background: rgba(139,32,32,0.08);
}
.epoch-outcome.ongoing {
border: 1px solid var(--ember);
color: var(--ember-glow);
background: rgba(232,113,58,0.08);
animation: ongoing-pulse 2s ease-in-out infinite;
}
@keyframes ongoing-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.epoch-meta {
display: flex;
gap: 20px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.epoch-meta-item {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-ghost);
letter-spacing: 0.05em;
}
.epoch-meta-item .meta-val {
color: var(--text-dim);
}
.epoch-summary {
font-size: 16px;
line-height: 1.7;
color: var(--text-dim);
}
.epoch-summary p {
margin-bottom: 12px;
}
.epoch-summary p:last-child { margin-bottom: 0; }
.epoch-summary .name { color: var(--parchment); font-weight: 600; }
.epoch-summary .place { color: var(--ember-glow); font-style: italic; }
.epoch-summary .item { color: var(--gold); }
.epoch-roster {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(90,82,68,0.12);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-ghost);
letter-spacing: 0.05em;
}
.epoch-roster .roster-names {
color: var(--text-dim);
margin-left: 4px;
}
/* ═══ JOURNALS — TAB SYSTEM ═══ */
.journal-section { display: none; }
.journal-section.active { display: block; }
.chronicle-section { display: none; }
.chronicle-section.active { display: block; }
.npc-tabs {
display: flex;
gap: 0;
margin-bottom: 28px;
border-bottom: 1px solid var(--smoke-light);
}
.npc-tab {
flex: 1;
text-align: center;
padding: 14px 8px;
font-family: 'Cinzel', serif;
font-size: 11px;
letter-spacing: 0.15em;
color: var(--text-ghost);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.3s;
position: relative;
}
.npc-tab:hover { color: var(--parchment-faded); }
.npc-tab.active {
color: var(--parchment);
border-bottom-color: var(--ember);
}
.npc-tab .tab-icon {
display: block;
font-size: 18px;
margin-bottom: 4px;
opacity: 0.6;
}
.npc-tab.active .tab-icon { opacity: 0.9; }
/* Journal entries */
.journal-feed { display: none; }
.journal-feed.active { display: block; }
.journal-entry {
margin-bottom: 32px;
padding: 24px 28px;
background: linear-gradient(180deg, rgba(26,23,20,0.9), rgba(13,11,9,0.9));
border: 1px solid var(--smoke-light);
border-radius: 2px;
position: relative;
overflow: hidden;
}
.journal-entry::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 3px; height: 100%;
}
.journal-entry.grist::before { background: var(--ember); opacity: 0.4; }
.journal-entry.maren::before { background: var(--blood-bright); opacity: 0.4; }
.journal-entry.torval::before { background: var(--gold); opacity: 0.4; }
.journal-entry.whisper::before { background: var(--frost); opacity: 0.4; }
.journal-date {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-ghost);
letter-spacing: 0.15em;
margin-bottom: 12px;
}
.journal-text {
font-size: 16px;
line-height: 1.75;
color: var(--text-dim);
}
.journal-text p {
margin-bottom: 10px;
text-indent: 1.5em;
}
.journal-text p:first-child { text-indent: 0; }
.journal-text p:last-child { margin-bottom: 0; }
/* Voice-specific styling */
.journal-entry.grist .journal-text {
font-size: 15px;
line-height: 1.65;
}
.journal-entry.whisper .journal-text {
font-style: italic;
letter-spacing: 0.01em;
}
.journal-entry.maren .journal-text {
font-size: 15px;
}
.journal-entry.torval .journal-text {
font-size: 16px;
}
.journal-npc-sig {
margin-top: 14px;
text-align: right;
font-family: 'Cinzel', serif;
font-size: 11px;
color: var(--text-ghost);
letter-spacing: 0.1em;
font-style: italic;
}
/* ═══ FOOTER ═══ */
.page-footer {
text-align: center;
padding: 32px 0 48px;
border-top: 1px solid rgba(90,82,68,0.15);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-ghost);
letter-spacing: 0.15em;
}
.page-footer a {
color: var(--text-ghost);
text-decoration: none;
transition: color 0.2s;
}
.page-footer a:hover { color: var(--ember); }
/* Responsive */
@media (max-width: 600px) {
.epoch-header { flex-direction: column; gap: 10px; }
.epoch-card { padding: 20px; }
.journal-entry { padding: 18px 20px; }
.npc-tab { font-size: 9px; padding: 10px 4px; }
.npc-tab .tab-icon { font-size: 16px; }
}
</style>
</head>
<body>
<canvas id="ember-canvas"></canvas>
<div class="page-wrap">
<!-- NAV -->
<nav class="nav-bar">
<a class="nav-home" href="#">The Last Ember</a>
<span style="color:var(--smoke-light)">·</span>
<a class="nav-link active" data-page="chronicle" onclick="showPage('chronicle')">Chronicle</a>
<a class="nav-link" data-page="journals" onclick="showPage('journals')">Journals</a>
<a class="nav-link" href="#">Board</a>
</nav>
<!-- ════════════════════════════════════ -->
<!-- CHRONICLE PAGE -->
<!-- ════════════════════════════════════ -->
<div class="chronicle-section active" id="page-chronicle">
<div class="page-header">
<h1 class="page-title">Chronicle</h1>
<p class="page-subtitle">Every epoch leaves its mark. The dungeon forgets. We do not.</p>
</div>
<div class="divider">CURRENT EPOCH</div>
<!-- CURRENT EPOCH -->
<div class="epoch-card current">
<div class="epoch-header">
<div>
<div class="epoch-number">EPOCH VII · DAY 17 OF 30</div>
<div class="epoch-title">The Siege of the Drowned Mines</div>
</div>
<span class="epoch-outcome ongoing">In Progress</span>
</div>
<div class="epoch-meta">
<span class="epoch-meta-item">MODE: <span class="meta-val">Hold the Line</span></span>
<span class="epoch-meta-item">BREACH: <span class="meta-val">The Emergence (open)</span></span>
<span class="epoch-meta-item">PLAYERS: <span class="meta-val">7</span></span>
<span class="epoch-meta-item">SECRETS: <span class="meta-val">11/20</span></span>
</div>
<div class="epoch-summary">
<p>Seventeen days in and the water still rises. <span class="name">Kael</span> has led the push through the second depth, establishing <span class="place">Checkpoint Alpha</span> through sheer attrition — three deaths, two retreats, and a final dawn push that cleared the cluster in a single session. The Bounty Troll that haunted <span class="place">the Sunken Gallery</span> for nine days fell to a combined effort, its last breath echoing through flooded corridors that have already begun to reclaim the rooms behind the front line.</p>
<p><span class="name">Mira</span> has proven the epoch's quiet weapon — twelve secrets uncovered, including the <span class="item">Ancient Ward</span> that halved the second floor's regen and gave the fighters a window they desperately needed. The Breach opened two days ago and something massive stirs within. Floor 3 is barely mapped. Floor 4 is a rumor. Thirteen days remain, and the mines are not finished with them yet.</p>
</div>
<div class="epoch-roster">
ROSTER: <span class="roster-names">Kael · Mira · Torr · Sable · Dren · Ash · Vex</span>
</div>
</div>
<div class="divider">PAST EPOCHS</div>
<!-- EPOCH VI — VICTORY -->
<div class="epoch-card victory">
<div class="epoch-header">
<div>
<div class="epoch-number">EPOCH VI · 30 DAYS · FEB 2026</div>
<div class="epoch-title">The Crown of the Ember Wyrm</div>
</div>
<span class="epoch-outcome victory">Victory</span>
</div>
<div class="epoch-meta">
<span class="epoch-meta-item">MODE: <span class="meta-val">Retrieve & Escape</span></span>
<span class="epoch-meta-item">BREACH: <span class="meta-val">The Resonance</span></span>
<span class="epoch-meta-item">PLAYERS: <span class="meta-val">9</span></span>
<span class="epoch-meta-item">SECRETS: <span class="meta-val">18/20</span></span>
</div>
<div class="epoch-summary">
<p>They called it the impossible run. <span class="name">Torr</span> claimed the <span class="item">Crown of the Ember Wyrm</span> on the twenty-second day, four floors deep in chambers that burned with a heat that had no source. The Pursuer awakened three rooms behind — an eyeless thing that moved without sound and killed without hesitation. <span class="name">Mira</span> had spent six days warding the third floor, and <span class="name">Sable</span> held the chokepoint between the second and third depths for eleven hours before the Pursuer caught her. She died on her feet. The Crown passed to <span class="name">Ash</span> through the relay, and the final sprint through the first floor took four minutes of real time and a year off everyone's nerves.</p>
<p>The Resonance Breach had been the epoch's turning point — a puzzle dungeon between floors two and three that <span class="name">Dren</span> solved alone over three quiet days while the rest of the server fought for every room. The shortcut it opened shaved two floors off the escape route and made the impossible merely improbable. Eighteen of twenty secrets fell. The last two died with the epoch, their locations known to no one. <span class="name">Kael</span> finished at level ten — the first to cap since Epoch III. The Crown rests in the Hall. The Wyrm's chambers have already begun to reshape.</p>
</div>
<div class="epoch-roster">
ROSTER: <span class="roster-names">Kael · Mira · Torr · Sable · Dren · Ash · Vex · Lira · Puck</span>
</div>
</div>
<!-- EPOCH V — DEFEAT -->
<div class="epoch-card defeat">
<div class="epoch-header">
<div>
<div class="epoch-number">EPOCH V · 30 DAYS · JAN 2026</div>
<div class="epoch-title">The Warden of the Bone Pits</div>
</div>
<span class="epoch-outcome defeat">Defeat</span>
</div>
<div class="epoch-meta">
<span class="epoch-meta-item">MODE: <span class="meta-val">Hold the Line</span></span>
<span class="epoch-meta-item">BREACH: <span class="meta-val">The Incursion</span></span>
<span class="epoch-meta-item">PLAYERS: <span class="meta-val">5</span></span>
<span class="epoch-meta-item">SECRETS: <span class="meta-val">13/20</span></span>
</div>
<div class="epoch-summary">
<p>Five adventurers against a dungeon that fought back with everything it had. The <span class="place">Bone Pits</span> earned their name — floors slick with calcite, walls studded with things that used to be alive, and a regen rate on the third depth that three players simply could not outpace. <span class="name">Kael</span> and <span class="name">Mira</span> held the second floor for twelve consecutive days, a feat of endurance that the barkeep still recounts to anyone who'll listen, but the third floor's checkpoints required a coordination window that never came. The Incursion Breach on day fifteen made it worse — monsters pouring upward through the new passage, forcing <span class="name">Torr</span> to abandon the push and defend cleared ground.</p>
<p>The Warden never spawned. They never reached it. On day twenty-eight, the front line collapsed back to <span class="place">Checkpoint Beta</span> on floor two and held there, grim and exhausted, while the last rooms fell dark around them. <span class="name">Dren</span> joined on day nineteen — too late to turn the tide, but early enough to witness the slow retreat. Thirteen secrets found, seven left buried. The epoch ended not with a killing blow but with a long silence, the dungeon reclaiming what it had never truly lost. Grist poured five drinks that night. Nobody ordered them.</p>
</div>
<div class="epoch-roster">
ROSTER: <span class="roster-names">Kael · Mira · Torr · Dren · Sable</span>
</div>
</div>
<!-- EPOCH IV — VICTORY -->
<div class="epoch-card victory">
<div class="epoch-header">
<div>
<div class="epoch-number">EPOCH IV · 30 DAYS · DEC 2025</div>
<div class="epoch-title">The Fall of the Iron Colossus</div>
</div>
<span class="epoch-outcome victory">Victory</span>
</div>
<div class="epoch-meta">
<span class="epoch-meta-item">MODE: <span class="meta-val">Raid Boss</span></span>
<span class="epoch-meta-item">BREACH: <span class="meta-val">The Heist</span></span>
<span class="epoch-meta-item">PLAYERS: <span class="meta-val">11</span></span>
<span class="epoch-meta-item">SECRETS: <span class="meta-val">20/20</span></span>
</div>
<div class="epoch-summary">
<p>Eleven adventurers. Three thousand three hundred hit points of ancient iron and malice squatting in the deepest chamber of the fourth floor. The <span class="place">Iron Colossus</span> rolled Armor Phase and No Escape — a combination that meant once you committed below twenty-five percent, you were finishing the fight or dying in it. The first week was pure scouting. <span class="name">Lira</span> lost two days' gold learning what the phase transitions looked like. <span class="name">Puck</span> discovered the armor weakness on day nine — a ritual hidden behind a stat-gated secret on floor three that permanently stripped the Colossus's defenses. The tide turned.</p>
<p>By day twenty, every player on the server had contributed damage. The final phase began on a Tuesday morning when <span class="name">Kael</span> pushed it below the threshold and the exits sealed. He died. <span class="name">Vex</span> went in next and died. <span class="name">Ash</span> went in third with stacked discovery buffs, two consumables, and a borrowed <span class="item">Runed Maul</span> from Torval's back shelf. The Colossus fell in six rounds. The only epoch where every secret was found. <span class="name">Mira</span> found the twentieth on day twenty-nine — a lore secret hidden in something Whisper had said on day three that nobody thought to write down.</p>
</div>
<div class="epoch-roster">
ROSTER: <span class="roster-names">Kael · Mira · Torr · Sable · Dren · Ash · Vex · Lira · Puck · Strand · Wick</span>
</div>
</div>
</div>
<!-- ════════════════════════════════════ -->
<!-- JOURNALS PAGE -->
<!-- ════════════════════════════════════ -->
<div class="journal-section" id="page-journals">
<div class="page-header">
<h1 class="page-title">Journals</h1>
<p class="page-subtitle">Four voices. Same day. Different truths.</p>
</div>
<!-- NPC TABS -->
<div class="npc-tabs">
<div class="npc-tab active" data-npc="grist" onclick="showJournal('grist')">
<span class="tab-icon">🍺</span>
Grist
</div>
<div class="npc-tab" data-npc="maren" onclick="showJournal('maren')">
<span class="tab-icon">🩸</span>
Maren
</div>
<div class="npc-tab" data-npc="torval" onclick="showJournal('torval')">
<span class="tab-icon"></span>
Torval
</div>
<div class="npc-tab" data-npc="whisper" onclick="showJournal('whisper')">
<span class="tab-icon">👁</span>
Whisper
</div>
</div>
<!-- GRIST'S JOURNAL -->
<div class="journal-feed active" id="journal-grist">
<div class="journal-entry grist">
<div class="journal-date">EPOCH VII · DAY 17</div>
<div class="journal-text">
<p>Kael came in bloody again. Wouldn't say from what. Ordered two drinks, finished one, stared at the wall for ten minutes, then asked about the bounty board. I told him the troll was done. He already knew. He's the one who killed it.</p>
<p>Mira stopped by after. She found something on the second floor — wouldn't say what exactly, but she had that look. The one where she knows something the dungeon doesn't want her to know. Traded a token for a hint about floor three. I gave her what I had. She'll figure out the rest.</p>
<p>Sable came in late. Died again. Third time this epoch. Didn't want to talk about it. I poured her something warm and told her the front line held. It did. Barely. Floor two lost two rooms overnight but Alpha's holding. That checkpoint isn't going anywhere.</p>
<p>Seven of them now. Seven against whatever's down there. Thirteen days left. The Breach opened yesterday and something's moving inside it. Big. They can feel it through the floor when it shifts. I can feel it through the bar.</p>
</div>
<div class="journal-npc-sig">— Grist</div>
</div>
<div class="journal-entry grist">
<div class="journal-date">EPOCH VII · DAY 16</div>
<div class="journal-text">
<p>The troll died today. Took nine days. Kael landed the killing blow but Mira and Torr chipped it down to nothing over the past week. That thing regenerated every night and every morning someone went back in. That's what this place does to people. It makes them stubborn.</p>
<p>New bounty went up. Spiders on the eastern branch of floor two. Six of them. Torr's already on it. He likes the quiet work — finds the nest, clears what he can, gets out. No glory, just progress. Good kid.</p>
<p>The Breach cracked open sometime after midnight. I heard it. Everyone heard it. The lanterns flickered for the first time in longer than I can remember. Something poured through that crack that wasn't light and wasn't dark. Dren was the first one down to look. Hasn't come back to report yet.</p>
</div>
<div class="journal-npc-sig">— Grist</div>
</div>
<div class="journal-entry grist">
<div class="journal-date">EPOCH VII · DAY 15</div>
<div class="journal-text">
<p>Told them. Three days I've been saying the walls were getting thin. Nobody listens to the barkeep until the ground starts shaking. The Breach is open. The passage sits between the second and third depths, and whatever's inside it is not from either floor.</p>
<p>Quiet day otherwise. Everyone's saving their actions for tomorrow. Smart. The dungeon doesn't care about smart, but it helps.</p>
</div>
<div class="journal-npc-sig">— Grist</div>
</div>
</div>
<!-- MAREN'S JOURNAL -->
<div class="journal-feed" id="journal-maren">
<div class="journal-entry maren">
<div class="journal-date">EPOCH VII · DAY 17</div>
<div class="journal-text">
<p>Three today. Kael first — deep lacerations across the forearms, consistent with something that grabs before it bites. He sat still while I worked. Didn't flinch. That's not bravery. That's numbness. I've seen the difference.</p>
<p>Sable second. Blunt force trauma to the ribs, probably from a charging attack she didn't sidestep. I asked her why she rushed the room. She said she thought she could make it. They always think they can make it. I set the rib and told her to stay above floor one for two days. She won't.</p>
<p>Torr came in for a routine patch. Minor cuts, nothing structural. He's careful. Moves like someone who's been hurt enough times to know exactly how much it costs. I appreciate that. More of them should learn it before they learn it the hard way.</p>
<p>The Breach is open. I can smell it from here — ozone and something older. I know what's on the other side of cracks like that. I know what lives in the spaces between floors. I went there once. I'm not going back. But they will. And I'll be here when they crawl out.</p>
</div>
<div class="journal-npc-sig">— Maren</div>
</div>
<div class="journal-entry maren">
<div class="journal-date">EPOCH VII · DAY 16</div>
<div class="journal-text">
<p>Sable again. That's twice in three days. This time it was the Gallery — took a hit from the troll's replacement spawn that she wasn't expecting. The original was stronger, she said. As if that's an excuse for not respecting the weaker one. The weaker ones still kill you. I've stitched enough of them to know.</p>
<p>No other patients. The troll's death seems to have given them confidence. Confidence is when I get busy.</p>
</div>
<div class="journal-npc-sig">— Maren</div>
</div>
<div class="journal-entry maren">
<div class="journal-date">EPOCH VII · DAY 15</div>
<div class="journal-text">
<p>No injuries today. Unusual. They're all resting, saving themselves for whatever the Breach brings. The smart ones prepare. The others will be my patients tomorrow.</p>
<p>The scar on my palm aches when the dungeon shifts. It ached all night.</p>
</div>
<div class="journal-npc-sig">— Maren</div>
</div>
</div>
<!-- TORVAL'S JOURNAL -->
<div class="journal-feed" id="journal-torval">
<div class="journal-entry torval">
<div class="journal-date">EPOCH VII · DAY 17</div>
<div class="journal-text">
<p>Good day! Sold a reinforced buckler to Sable — she needed it after, well, you know. Third death this run. I didn't mention that. Just told her the buckler was "lightly used, deeply reliable." She didn't laugh. They never do. But she bought it, and that's what matters. For her, I mean. Protection. Very important.</p>
<p>Kael came in to appraise something from the second floor. Tapped it on the counter. Listened. Heavy, good ring, slight harmonic on the follow-through. Tier four, easily. Named a fair price. He sold it back for the upgrade fund. Practical man, Kael. No sentiment about gear. I respect that. I also profit from it, which I respect slightly more.</p>
<p>Dren bought three smoke bombs. Three. For one person. I asked if he was planning something specific. He said "the Breach." I said "ah." I wrapped them carefully. Something about the way he said it made me think he might actually need all three.</p>
<p>The ledger gains another page. The pages at the front are still unreadable. I've stopped trying.</p>
</div>
<div class="journal-npc-sig">— Torval</div>
</div>
<div class="journal-entry torval">
<div class="journal-date">EPOCH VII · DAY 16</div>
<div class="journal-text">
<p>Inventory refresh day! Somehow the stock always matches what they'll need. I've stopped questioning it. New shipment includes tier three weapons appropriate for the second floor push and a few trinkets I haven't seen before. One of them hums. Not loudly. Not unpleasantly. But it hums. Priced it accordingly.</p>
<p>The troll is dead. Good for morale, bad for my potion sales. When the big threat goes away, they get brave and stop buying healing supplies. I'll give it two days before Sable's back at my counter buying bandages.</p>
</div>
<div class="journal-npc-sig">— Torval</div>
</div>
<div class="journal-entry torval">
<div class="journal-date">EPOCH VII · DAY 15</div>
<div class="journal-text">
<p>The ground cracked. Stock fell off two shelves. Nothing broke — I pack carefully, because I know where I work. The Breach is open. New territory means new drops means new customers means new pages in the ledger. I love this job.</p>
<p>Restocked the smoke bombs. I have a feeling.</p>
</div>
<div class="journal-npc-sig">— Torval</div>
</div>
</div>
<!-- WHISPER'S JOURNAL -->
<div class="journal-feed" id="journal-whisper">
<div class="journal-entry whisper">
<div class="journal-date">EPOCH VII · DAY 17</div>
<div class="journal-text">
<p>The second floor remembers being whole. It pushes back at night — not the monsters, the stone itself. Rooms seal shut like wounds closing. Two lost since dawn. Alpha holds because something older than the mine agreed it should. I don't know what. I heard it once, through the wall between the second and third depths. It was counting.</p>
<p>Mira came to the corner today. She found the ward — I could see it on her, the residue of old mechanisms waking up. She asked about the eastern branch. I told her what I could. The words come in pieces. A door. A serpent that isn't a serpent. The sound of water where no water runs. She wrote it down. Good. I can't always say it twice.</p>
<p>The Breach breathes. I can hear it from here. Two floors away and I can hear it like it's sitting next to me. Something large. Something that was here before the mines. Before the bar. Before the lanterns. Not before me. I was here first. I think. The memory is thin today.</p>
</div>
<div class="journal-npc-sig">— Whisper</div>
</div>
<div class="journal-entry whisper">
<div class="journal-date">EPOCH VII · DAY 16</div>
<div class="journal-text">
<p>The troll stopped. Its voice left the stone. A small silence where there used to be weight. Kael ended it but the dungeon let it end. Some things are allowed to die. Others aren't. The replacement is weaker — a shadow of a shadow. It serves the room but the room doesn't respect it.</p>
<p>Three secrets on the eastern branch. I can feel them like teeth in a jaw. The first is behind something carved. The second requires a key that isn't a key. The third — I lose the third when I try to look at it directly. It moves. Or I move. One of us does.</p>
</div>
<div class="journal-npc-sig">— Whisper</div>
</div>
<div class="journal-entry whisper">
<div class="journal-date">EPOCH VII · DAY 15</div>
<div class="journal-text">
<p>It opened. The thin place between. I told Grist three days ago. He listens, in his way. He told them. They listened, in theirs.</p>
<p>What came through the crack is not new. It has been waiting underneath the underneath, patient as geology. The rooms between the floors are not rooms. They are the dungeon dreaming about itself. The secrets in there are different — not hidden, just not yet decided. They will become what they need to become when someone looks at them long enough.</p>
<p>The lanterns flickered. They have never flickered. I watched them very carefully afterward to make sure they were still the same lanterns. They are. But they noticed too.</p>
</div>
<div class="journal-npc-sig">— Whisper</div>
</div>
</div>
</div>
<!-- FOOTER -->
<div class="page-footer">
<a href="#">The Last Ember</a> · meshMUD
</div>
</div>
<script>
// ═══ EMBER PARTICLES (same as main page) ═══
const canvas = document.getElementById('ember-canvas');
const ctx = canvas.getContext('2d');
let embers = [];
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
class Ember {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * canvas.width;
this.y = canvas.height + 10;
this.size = Math.random() * 2 + 0.5;
this.speedY = -(Math.random() * 0.3 + 0.08);
this.speedX = (Math.random() - 0.5) * 0.2;
this.opacity = Math.random() * 0.4 + 0.15;
this.decay = Math.random() * 0.0008 + 0.0003;
this.wobble = Math.random() * Math.PI * 2;
this.wobbleSpeed = Math.random() * 0.015 + 0.003;
const t = Math.random();
this.r = Math.floor(200 + t * 55);
this.g = Math.floor(80 + t * 80);
this.b = Math.floor(20 + t * 30);
}
update() {
this.wobble += this.wobbleSpeed;
this.x += this.speedX + Math.sin(this.wobble) * 0.12;
this.y += this.speedY;
this.opacity -= this.decay;
if (this.opacity <= 0 || this.y < -20) this.reset();
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${this.r},${this.g},${this.b},${this.opacity})`;
ctx.fill();
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${this.r},${this.g},${this.b},${this.opacity * 0.12})`;
ctx.fill();
}
}
for (let i = 0; i < 30; i++) {
const e = new Ember();
e.y = Math.random() * canvas.height;
embers.push(e);
}
function animateEmbers() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
embers.forEach(e => { e.update(); e.draw(); });
requestAnimationFrame(animateEmbers);
}
animateEmbers();
// ═══ PAGE SWITCHING ═══
function showPage(page) {
document.querySelectorAll('.chronicle-section, .journal-section').forEach(el => el.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.querySelectorAll('.nav-link[data-page]').forEach(el => {
el.classList.toggle('active', el.dataset.page === page);
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// ═══ NPC JOURNAL TABS ═══
function showJournal(npc) {
document.querySelectorAll('.npc-tab').forEach(el => {
el.classList.toggle('active', el.dataset.npc === npc);
});
document.querySelectorAll('.journal-feed').forEach(el => {
el.classList.toggle('active', el.id === 'journal-' + npc);
});
}
</script>
</body>
</html>

View file

@ -1,915 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Last Ember — How to Play</title>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--ember: #e8713a;
--ember-glow: #ff9d5c;
--ember-deep: #c44e1a;
--ash: #1a1714;
--charcoal: #0d0b09;
--smoke: #2a2520;
--smoke-light: #3d3630;
--parchment: #d4c4a8;
--parchment-dark: #b8a88c;
--parchment-faded: #a89878;
--bone: #c8b898;
--blood: #8b2020;
--blood-bright: #cc3333;
--gold: #c4a44a;
--gold-dim: #8a7a3a;
--frost: #7a9ab0;
--poison: #5a8a4a;
--text-bright: #e8dcc8;
--text-dim: #9a8e78;
--text-ghost: #5a5244;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--charcoal);
color: var(--text-bright);
font-family: 'Crimson Text', Georgia, serif;
min-height: 100vh;
overflow-x: hidden;
}
#ember-canvas {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
opacity: 0.35;
}
.page-wrap {
position: relative;
z-index: 2;
max-width: 720px;
margin: 0 auto;
padding: 0 24px;
}
/* ═══ NAV ═══ */
.nav-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
padding: 20px 0;
border-bottom: 1px solid rgba(90,82,68,0.15);
margin-bottom: 12px;
}
.nav-link {
font-family: 'Cinzel', serif;
font-size: 10px;
letter-spacing: 0.25em;
color: var(--text-ghost);
text-decoration: none;
text-transform: uppercase;
padding: 6px 0;
border-bottom: 1px solid transparent;
transition: all 0.3s;
}
.nav-link:hover { color: var(--parchment-faded); }
.nav-link.active { color: var(--parchment); border-bottom-color: var(--ember); }
.nav-home {
font-family: 'Cinzel', serif;
font-size: 14px;
color: var(--parchment-faded);
text-decoration: none;
letter-spacing: 0.1em;
transition: color 0.3s;
}
.nav-home:hover { color: var(--ember-glow); }
/* ═══ HEADER ═══ */
.page-header {
text-align: center;
padding: 48px 0 12px;
}
.page-title {
font-family: 'Cinzel', serif;
font-size: clamp(22px, 4vw, 32px);
font-weight: 700;
letter-spacing: 0.12em;
color: var(--parchment);
text-shadow: 0 0 30px rgba(232,113,58,0.2);
margin-bottom: 6px;
}
.page-subtitle {
font-size: 15px;
font-style: italic;
color: var(--text-ghost);
max-width: 460px;
margin: 0 auto;
line-height: 1.5;
}
.divider {
display: flex;
align-items: center;
gap: 16px;
margin: 36px 0 28px;
color: var(--text-ghost);
font-size: 10px;
letter-spacing: 0.25em;
font-family: 'Cinzel', serif;
text-transform: uppercase;
}
.divider::before, .divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--smoke-light), transparent);
}
/* ═══ PROSE SECTIONS ═══ */
.prose {
font-size: 17px;
line-height: 1.8;
color: var(--text-dim);
margin-bottom: 20px;
}
.prose strong {
color: var(--parchment);
font-weight: 600;
}
.prose em.place {
color: var(--ember-glow);
font-style: italic;
}
.prose em.npc {
color: var(--parchment-dark);
font-style: normal;
font-weight: 600;
}
.prose em.cmd {
color: var(--gold);
font-style: normal;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
background: rgba(196,164,74,0.08);
padding: 1px 6px;
border-radius: 2px;
border: 1px solid rgba(196,164,74,0.15);
}
.prose em.item {
color: var(--gold);
font-style: italic;
}
/* ═══ CALLOUT BOXES ═══ */
.callout {
padding: 20px 24px;
margin: 24px 0;
background: linear-gradient(135deg, rgba(26,23,20,0.95), rgba(42,37,32,0.7));
border: 1px solid var(--smoke-light);
border-radius: 2px;
position: relative;
overflow: hidden;
}
.callout::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 3px;
height: 100%;
}
.callout.ember::before { background: var(--ember); opacity: 0.5; }
.callout.gold::before { background: var(--gold); opacity: 0.5; }
.callout.frost::before { background: var(--frost); opacity: 0.5; }
.callout.blood::before { background: var(--blood-bright); opacity: 0.5; }
.callout-label {
font-family: 'Cinzel', serif;
font-size: 10px;
letter-spacing: 0.2em;
color: var(--text-ghost);
text-transform: uppercase;
margin-bottom: 10px;
}
.callout .prose { margin-bottom: 0; }
.callout .prose:not(:last-child) { margin-bottom: 12px; }
/* ═══ COMMAND REFERENCE ═══ */
.cmd-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 16px;
padding: 4px 0;
}
.cmd-key {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: var(--gold);
padding: 3px 0;
white-space: nowrap;
}
.cmd-desc {
font-size: 14px;
color: var(--text-dim);
padding: 3px 0;
line-height: 1.5;
}
.cmd-unlock {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-ghost);
background: rgba(90,82,68,0.15);
padding: 1px 6px;
border-radius: 1px;
margin-left: 6px;
vertical-align: middle;
}
/* ═══ CLASS CARDS ═══ */
.class-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin: 20px 0;
}
@media (max-width: 560px) {
.class-cards { grid-template-columns: 1fr; }
}
.class-card {
padding: 20px 16px;
background: linear-gradient(180deg, rgba(26,23,20,0.95), rgba(13,11,9,0.95));
border: 1px solid var(--smoke-light);
border-radius: 2px;
text-align: center;
position: relative;
overflow: hidden;
}
.class-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
}
.class-card.fighter::before { background: linear-gradient(90deg, transparent, var(--blood-bright), transparent); }
.class-card.caster::before { background: linear-gradient(90deg, transparent, var(--poison), transparent); }
.class-card.rogue::before { background: linear-gradient(90deg, transparent, var(--frost), transparent); }
.class-card-icon {
font-size: 28px;
margin-bottom: 8px;
opacity: 0.8;
}
.class-card-name {
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--parchment);
margin-bottom: 2px;
}
.class-card-stat {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.1em;
margin-bottom: 10px;
}
.class-card.fighter .class-card-stat { color: var(--blood-bright); }
.class-card.caster .class-card-stat { color: var(--poison); }
.class-card.rogue .class-card-stat { color: var(--frost); }
.class-card-desc {
font-size: 13px;
line-height: 1.6;
color: var(--text-dim);
}
/* ═══ FLOW DIAGRAM ═══ */
.flow-steps {
display: flex;
flex-direction: column;
gap: 0;
margin: 20px 0;
}
.flow-step {
display: flex;
gap: 16px;
align-items: flex-start;
padding: 14px 0;
}
.flow-step-num {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--smoke-light);
display: flex;
align-items: center;
justify-content: center;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-ghost);
flex-shrink: 0;
position: relative;
}
.flow-step:not(:last-child) .flow-step-num::after {
content: '';
position: absolute;
top: 28px;
left: 50%;
width: 1px;
height: calc(100% + 16px);
background: var(--smoke-light);
transform: translateX(-50%);
}
.flow-step-content {
padding-top: 3px;
}
.flow-step-label {
font-family: 'Cinzel', serif;
font-size: 12px;
letter-spacing: 0.1em;
color: var(--parchment-faded);
margin-bottom: 4px;
}
.flow-step-text {
font-size: 14px;
line-height: 1.6;
color: var(--text-dim);
}
.flow-step-text code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--gold);
background: rgba(196,164,74,0.08);
padding: 1px 5px;
border-radius: 2px;
border: 1px solid rgba(196,164,74,0.12);
}
/* ═══ MESSAGE EXAMPLE ═══ */
.msg-example {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 2;
padding: 16px 20px;
background: rgba(13,11,9,0.9);
border: 1px solid var(--smoke-light);
border-radius: 2px;
margin: 16px 0;
overflow-x: auto;
}
.msg-server { color: var(--parchment-faded); }
.msg-player { color: var(--frost); }
.msg-system { color: var(--text-ghost); font-style: italic; }
.msg-broadcast { color: var(--ember-glow); }
.msg-gold { color: var(--gold); }
/* ═══ TIP STRIP ═══ */
.tip-strip {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin: 20px 0;
}
@media (max-width: 560px) {
.tip-strip { grid-template-columns: 1fr; }
}
.tip-card {
padding: 16px 18px;
background: linear-gradient(135deg, rgba(26,23,20,0.9), rgba(42,37,32,0.5));
border: 1px solid rgba(90,82,68,0.15);
border-radius: 2px;
}
.tip-card-label {
font-family: 'Cinzel', serif;
font-size: 10px;
letter-spacing: 0.15em;
color: var(--text-ghost);
text-transform: uppercase;
margin-bottom: 6px;
}
.tip-card-text {
font-size: 14px;
line-height: 1.6;
color: var(--text-dim);
}
/* ═══ NPC GUIDE ═══ */
.npc-guide {
display: flex;
gap: 16px;
align-items: flex-start;
padding: 16px 0;
border-bottom: 1px solid rgba(90,82,68,0.1);
}
.npc-guide:last-child { border-bottom: none; }
.npc-guide-icon {
font-size: 24px;
flex-shrink: 0;
opacity: 0.7;
margin-top: 2px;
}
.npc-guide-name {
font-family: 'Cinzel', serif;
font-size: 14px;
font-weight: 600;
color: var(--parchment);
margin-bottom: 2px;
}
.npc-guide-role {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-ghost);
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 6px;
}
.npc-guide-desc {
font-size: 14px;
line-height: 1.6;
color: var(--text-dim);
}
/* ═══ FOOTER ═══ */
.page-footer {
text-align: center;
padding: 36px 0 48px;
border-top: 1px solid rgba(90,82,68,0.15);
margin-top: 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-ghost);
letter-spacing: 0.15em;
}
.page-footer a {
color: var(--text-ghost);
text-decoration: none;
transition: color 0.2s;
}
.page-footer a:hover { color: var(--ember); }
</style>
</head>
<body>
<canvas id="ember-canvas"></canvas>
<div class="page-wrap">
<!-- NAV -->
<nav class="nav-bar">
<a class="nav-home" href="#">The Last Ember</a>
<span style="color:var(--smoke-light)">·</span>
<a class="nav-link" href="#">Board</a>
<a class="nav-link" href="#">Chronicle</a>
<a class="nav-link" href="#">Journals</a>
<a class="nav-link active">How to Play</a>
</nav>
<!-- HEADER -->
<div class="page-header">
<h1 class="page-title">How to Play</h1>
<p class="page-subtitle">A text adventure played over radio. Five minutes a day. Thirty days an epoch. No internet required.</p>
</div>
<!-- ════════════════════════════ -->
<!-- WHAT IS THIS -->
<!-- ════════════════════════════ -->
<div class="divider">What is meshMUD</div>
<p class="prose">meshMUD is a multiplayer text adventure that runs over <strong>Meshtastic</strong> — a long-range radio mesh network. There is no internet connection, no app store, no account creation. You play by sending short text messages from your Meshtastic node. The game responds. Everything happens in 150 characters or less.</p>
<p class="prose">It plays like the BBS door games of the early '90s — <em class="place">Legend of the Red Dragon</em>, <em class="place">TradeWars 2002</em> — adapted for radio. Short daily sessions. Asynchronous multiplayer. A shared world where you see evidence of other players without needing to be online at the same time. A dungeon that resets every 30 days.</p>
<p class="prose">You don't need to be a gamer. You don't need to be fast. You need a Meshtastic radio and five minutes.</p>
<div class="callout ember">
<div class="callout-label">The basics</div>
<p class="prose">You wake up in a tavern called <em class="place">The Last Ember</em>. Below it is a dungeon that changes every 30 days. You explore it, fight monsters, find secrets, and help other players push deeper — all by typing short commands over your radio. When the 30 days end, the dungeon resets. Your character persists. The stories stay.</p>
</div>
<!-- ════════════════════════════ -->
<!-- GETTING STARTED -->
<!-- ════════════════════════════ -->
<div class="divider">Getting Started</div>
<p class="prose">If your mesh network is running meshMUD, the game server listens for direct messages from any node. Send it a DM and it responds. That's it.</p>
<div class="flow-steps">
<div class="flow-step">
<div class="flow-step-num">1</div>
<div class="flow-step-content">
<div class="flow-step-label">Send a DM to the game node</div>
<div class="flow-step-text">Find the meshMUD node on your Meshtastic client and send any message. The server responds with a welcome and asks you to pick a class.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-step-num">2</div>
<div class="flow-step-content">
<div class="flow-step-label">Pick your class</div>
<div class="flow-step-text">One letter. <code>F</code> for Fighter, <code>C</code> for Caster, <code>R</code> for Rogue. That's your only creation choice — everything else emerges through play.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-step-num">3</div>
<div class="flow-step-content">
<div class="flow-step-label">You're in</div>
<div class="flow-step-text">The server drops you in <em class="place">The Last Ember</em> with starting gear and a handful of gold. Type <code>L</code> to look around. Type <code>H</code> for help. You're playing.</div>
</div>
</div>
</div>
<div class="msg-example">
<span class="msg-player">You → meshMUD:</span> <span class="msg-gold">hello</span><br>
<span class="msg-server">meshMUD:</span> Welcome to The Last Ember. Pick a class: (F)ighter (C)aster (R)ogue<br>
<span class="msg-player">You:</span> <span class="msg-gold">F</span><br>
<span class="msg-server">meshMUD:</span> Kael the Fighter. POW:5 DEF:4 SPD:3 HP:30. You stand in the tavern. Type L.<br>
<span class="msg-player">You:</span> <span class="msg-gold">L</span><br>
<span class="msg-server">meshMUD:</span> The Last Ember. Lanterns burn without oil. Grist polishes a glass. Exits: dungeon.<br>
</div>
<!-- ════════════════════════════ -->
<!-- CLASSES -->
<!-- ════════════════════════════ -->
<div class="divider">The Three Classes</div>
<p class="prose">Three stats govern everything: <strong>POW</strong> (offense), <strong>DEF</strong> (survivability), and <strong>SPD</strong> (evasion, initiative, spellcasting). Each class leans into one. You earn 2 stat points per level to allocate however you want — that's where your build takes shape.</p>
<div class="class-cards">
<div class="class-card fighter">
<div class="class-card-icon"></div>
<div class="class-card-name">Fighter</div>
<div class="class-card-stat">POW-FOCUSED</div>
<div class="class-card-desc">High HP. Hits hard. Takes hits. Abilities like Strike, Bash, Rally, Cleave. Passive damage reduction. The front line.</div>
</div>
<div class="class-card caster">
<div class="class-card-icon"></div>
<div class="class-card-name">Caster</div>
<div class="class-card-stat">SPD-FOCUSED</div>
<div class="class-card-desc">Low HP. Spells scale on SPD. Bolt, Ward, Blast, Drain. Passive: see enemy stats. Knowledge is power. Fragile is the cost.</div>
</div>
<div class="class-card rogue">
<div class="class-card-icon"></div>
<div class="class-card-name">Rogue</div>
<div class="class-card-stat">SPD / BALANCED</div>
<div class="class-card-desc">Stealth and utility. Stab, Dodge, Ambush, Steal. Passive evasion chance. Thrives in the spaces between fights.</div>
</div>
</div>
<!-- ════════════════════════════ -->
<!-- A TYPICAL DAY -->
<!-- ════════════════════════════ -->
<div class="divider">A Typical Day</div>
<p class="prose">A session takes <strong>five to fifteen minutes</strong>. You get 12 dungeon actions per day — enough to explore a few rooms, fight a few monsters, and make progress without burning out. Town actions are always free.</p>
<div class="flow-steps">
<div class="flow-step">
<div class="flow-step-num">1</div>
<div class="flow-step-content">
<div class="flow-step-label">Visit Grist</div>
<div class="flow-step-text">The barkeep tells you what happened while you were gone. Who died, what fell, what the front line looks like. Always free. This is how the world stays alive between sessions.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-step-num">2</div>
<div class="flow-step-content">
<div class="flow-step-label">Check the bounty board</div>
<div class="flow-step-text">Shared objectives the whole server works toward. A monster with a communal HP pool. An exploration target. You chip away at it — so does everyone else.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-step-num">3</div>
<div class="flow-step-content">
<div class="flow-step-label">Gear up</div>
<div class="flow-step-text">Buy supplies from Torval, heal up with Maren if you need it, spend a bard token at the bar for a hint or buff. All free actions.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-step-num">4</div>
<div class="flow-step-content">
<div class="flow-step-label">Enter the dungeon</div>
<div class="flow-step-text">Move room to room, fight what you find, look for secrets, leave messages for other players. Each move or fight costs an action. Twelve per day — spend them wisely.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-step-num">5</div>
<div class="flow-step-content">
<div class="flow-step-label">Return to town</div>
<div class="flow-step-text">Bank your gold before the dungeon takes it. Tomorrow the rooms may have changed, the bounty may be weaker, and someone may have left you a message you need to read.</div>
</div>
</div>
</div>
<!-- ════════════════════════════ -->
<!-- THE TOWN -->
<!-- ════════════════════════════ -->
<div class="divider">The Last Ember — Your Town</div>
<p class="prose">The tavern is the one room that never changes. Epochs wipe the dungeon, reshuffle everything, reshape the world — but <em class="place">The Last Ember</em> stays. Same bar. Same people. Same lanterns that burn without oil and nobody questions anymore.</p>
<p class="prose">Four people live here. They remember you across every wipe.</p>
<div class="npc-guide">
<span class="npc-guide-icon">🍺</span>
<div>
<div class="npc-guide-name">Grist</div>
<div class="npc-guide-role">Barkeep</div>
<div class="npc-guide-desc">Knows everything that happens in the dungeon because everyone tells him and he never forgets. Visit him first every session — he'll catch you up on what you missed. He also runs the bounty board, handles the epoch vote, and trades bard tokens for hints, buffs, and secrets. He doesn't trade because he's kind. He trades because he collects.</div>
</div>
</div>
<div class="npc-guide">
<span class="npc-guide-icon">🩸</span>
<div>
<div class="npc-guide-name">Maren</div>
<div class="npc-guide-role">Healer</div>
<div class="npc-guide-desc">Used to be an adventurer. Went deeper than anyone. Came back done. Heals with her hands, not magic, and it hurts. She charges gold because free healing breeds carelessness. She's the reason you survive long enough to learn from your mistakes.</div>
</div>
</div>
<div class="npc-guide">
<span class="npc-guide-icon"></span>
<div>
<div class="npc-guide-name">Torval</div>
<div class="npc-guide-role">Merchant</div>
<div class="npc-guide-desc">Buys and sells gear. Appraises items by weight and sound. His inventory somehow matches what's in the dungeon each epoch. Nobody asks how. His prices are fair and his stock is real, which is more than you can say for most people in a town built around a hole full of monsters.</div>
</div>
</div>
<div class="npc-guide">
<span class="npc-guide-icon">👁</span>
<div>
<div class="npc-guide-name">Whisper</div>
<div class="npc-guide-role">Sage</div>
<div class="npc-guide-desc">Sits in the same corner. Knows things about the dungeon that change each epoch — lore, connections, what the symbols mean. Speaks in fragments because that's how the information comes to her. Pay attention to her exact words. Players who dismiss her as flavor text miss half the game.</div>
</div>
</div>
<!-- ════════════════════════════ -->
<!-- THE DUNGEON -->
<!-- ════════════════════════════ -->
<div class="divider">The Dungeon</div>
<p class="prose">Four floors. Each one deeper, harder, and stranger than the last. Monsters get meaner. Secrets get subtler. The rooms change every epoch but the structure holds — floor one is where you learn, floor four is where legends are made.</p>
<p class="prose">You carry three pieces of gear: a <strong>weapon</strong>, <strong>armor</strong>, and a <strong>trinket</strong>. The trinket is the wildcard — it might grant a passive ability, boost an unexpected stat, or do something no other slot can. Six tiers of gear across the dungeon. The best stuff doesn't come from shops.</p>
<div class="callout frost">
<div class="callout-label">Death</div>
<p class="prose">Death costs you all the gold you're carrying. Not your gear. Not your level. Just your gold. The question is always the same: do you bank it before you go in, or carry it and risk losing everything? The dungeon teaches you the answer. Usually the hard way.</p>
</div>
<!-- ════════════════════════════ -->
<!-- MULTIPLAYER -->
<!-- ════════════════════════════ -->
<div class="divider">Playing Together</div>
<p class="prose">meshMUD is <strong>asynchronous multiplayer</strong>. You don't need to be online at the same time as anyone else. You see other players through what they leave behind — messages scratched on dungeon walls, bounty progress that wasn't there yesterday, broadcasts announcing who found what and who fell where.</p>
<div class="tip-strip">
<div class="tip-card">
<div class="tip-card-label">Bounties</div>
<div class="tip-card-text">Shared objectives with communal HP pools. You chip away at a target over days. Everyone who contributes shares the reward when it falls.</div>
</div>
<div class="tip-card">
<div class="tip-card-label">Messages</div>
<div class="tip-card-text">Leave 15-character notes in dungeon rooms for others to find. Warnings, tips, coordinates. Dark Souls soapstone, over LoRa.</div>
</div>
<div class="tip-card">
<div class="tip-card-label">Mail</div>
<div class="tip-card-text">Send direct messages to specific players through the barkeep. Coordinate strategy, share secrets, warn someone about what's ahead.</div>
</div>
<div class="tip-card">
<div class="tip-card-label">Broadcasts</div>
<div class="tip-card-text">Major events announce to the whole mesh. Boss kills, rare finds, deaths, front line changes. The world narrates itself.</div>
</div>
</div>
<p class="prose">There is no PvP. All competition runs through leaderboards, bounty races, and endgame objectives. On a small mesh network where everyone knows each other, cooperation is the game.</p>
<!-- ════════════════════════════ -->
<!-- EPOCHS -->
<!-- ════════════════════════════ -->
<div class="divider">The 30-Day Epoch</div>
<p class="prose">Every 30 days, the dungeon resets. New rooms, new monsters, new secrets, new narrative. Your character keeps their name and their history, but gear and gold start fresh. Each epoch has an <strong>endgame mode</strong> — a shared objective the whole server works toward. On day 30, players vote on the next epoch's mode.</p>
<div class="callout gold">
<div class="callout-label">Three endgame modes</div>
<p class="prose"><strong>Hold the Line</strong> — the dungeon regenerates rooms. Push the front line deeper, establish checkpoints that lock in progress. The whole server descends together.</p>
<p class="prose"><strong>Raid Boss</strong> — a massive enemy with thousands of HP squats on the lowest floor. The server chips away over days. Discover its weaknesses. Coordinate the kill.</p>
<p class="prose"><strong>Retrieve &amp; Escape</strong> — an artifact on floor four. Grab it, carry it to the surface. Something unkillable chases the carrier. Other players clear the path, block the pursuer, relay the objective hand-to-hand.</p>
</div>
<p class="prose">On day 15, the <strong>Breach</strong> opens — a surprise mini-zone between floors two and three with its own challenge, its own loot, and its own secrets. You don't know what's inside until it opens.</p>
<!-- ════════════════════════════ -->
<!-- SECRETS -->
<!-- ════════════════════════════ -->
<div class="divider">Secrets &amp; Discovery</div>
<p class="prose">Twenty secrets hide in the dungeon each epoch. Some are behind walls that need a strong arm to break. Some are puzzles spread across multiple rooms. Some are hidden in things Whisper says that nobody thinks to write down. Finding them isn't required — but every secret you uncover gives a real mechanical advantage, and some of them benefit the entire server.</p>
<p class="prose"><strong>Read the room descriptions carefully.</strong> The dungeon tells you where its secrets are. It just doesn't tell you plainly.</p>
<!-- ════════════════════════════ -->
<!-- COMMANDS -->
<!-- ════════════════════════════ -->
<div class="divider">Quick Command Reference</div>
<p class="prose">Every command fits in a short message. Most have single-letter shortcuts. New commands unlock as you level up — the game teaches you as you go.</p>
<div class="callout ember">
<div class="callout-label">Movement &amp; Awareness</div>
<div class="cmd-grid">
<span class="cmd-key">n s e w</span><span class="cmd-desc">Move north, south, east, west</span>
<span class="cmd-key">l</span><span class="cmd-desc">Look — describe current room, show exits</span>
<span class="cmd-key">x [thing]</span><span class="cmd-desc">Examine something in the room <span class="cmd-unlock">LV5</span></span>
<span class="cmd-key">who</span><span class="cmd-desc">List active players <span class="cmd-unlock">LV3</span></span>
</div>
</div>
<div class="callout blood">
<div class="callout-label">Combat</div>
<div class="cmd-grid">
<span class="cmd-key">f</span><span class="cmd-desc">Fight — engage the monster in this room</span>
<span class="cmd-key">a</span><span class="cmd-desc">Attack — basic melee/spell attack</span>
<span class="cmd-key">flee</span><span class="cmd-desc">Attempt to escape combat (SPD-based chance)</span>
</div>
</div>
<div class="callout gold">
<div class="callout-label">Town &amp; NPCs</div>
<div class="cmd-grid">
<span class="cmd-key">barkeep</span><span class="cmd-desc">Talk to Grist — recap, tokens, bounties <span class="cmd-unlock">LV3</span></span>
<span class="cmd-key">heal</span><span class="cmd-desc">Visit Maren — restore HP for gold <span class="cmd-unlock">LV3</span></span>
<span class="cmd-key">shop</span><span class="cmd-desc">Browse Torval's inventory <span class="cmd-unlock">LV3</span></span>
<span class="cmd-key">bank</span><span class="cmd-desc">Deposit gold safely <span class="cmd-unlock">LV3</span></span>
<span class="cmd-key">board</span><span class="cmd-desc">View active bounties <span class="cmd-unlock">LV3</span></span>
</div>
</div>
<div class="callout frost">
<div class="callout-label">Inventory &amp; Character</div>
<div class="cmd-grid">
<span class="cmd-key">i</span><span class="cmd-desc">Inventory — show gear and backpack <span class="cmd-unlock">LV2</span></span>
<span class="cmd-key">st</span><span class="cmd-desc">Stats — show POW, DEF, SPD, HP, gold, level</span>
<span class="cmd-key">equip [item]</span><span class="cmd-desc">Equip an item from your backpack <span class="cmd-unlock">LV2</span></span>
<span class="cmd-key">use [item]</span><span class="cmd-desc">Use a consumable <span class="cmd-unlock">LV2</span></span>
</div>
</div>
<div class="callout ember">
<div class="callout-label">Social</div>
<div class="cmd-grid">
<span class="cmd-key">msg [text]</span><span class="cmd-desc">Leave a 15-char message in this room <span class="cmd-unlock">LV5</span></span>
<span class="cmd-key">read</span><span class="cmd-desc">Read messages in this room</span>
<span class="cmd-key">rate</span><span class="cmd-desc">Mark a message as helpful</span>
<span class="cmd-key">mail</span><span class="cmd-desc">Check your inbox <span class="cmd-unlock">LV3</span></span>
<span class="cmd-key">mail [who] [text]</span><span class="cmd-desc">Send mail to a player <span class="cmd-unlock">LV3</span></span>
</div>
</div>
<div class="callout gold">
<div class="callout-label">Meta</div>
<div class="cmd-grid">
<span class="cmd-key">h</span><span class="cmd-desc">Help — list all available commands</span>
<span class="cmd-key">h [cmd]</span><span class="cmd-desc">Help on a specific command</span>
<span class="cmd-key">vote</span><span class="cmd-desc">Vote for next epoch's mode (day 30 only) <span class="cmd-unlock">LV1</span></span>
</div>
</div>
<!-- ════════════════════════════ -->
<!-- TIPS -->
<!-- ════════════════════════════ -->
<div class="divider">Grist's Advice for the New Arrival</div>
<div class="callout ember">
<div class="callout-label">Things nobody tells you</div>
<p class="prose"><strong>Bank before you descend.</strong> Death takes everything you're carrying. Not your gear, not your level — just your gold. The bank is free. Use it.</p>
<p class="prose"><strong>Visit Grist every session.</strong> His recap costs nothing and tells you everything you missed. The bounty board is there too. Five seconds of reading saves you from walking into something that killed Sable yesterday.</p>
<p class="prose"><strong>Leave messages.</strong> A 15-character note in a dangerous room saves someone's life tomorrow. This is a small network. Help each other.</p>
<p class="prose"><strong>Read room descriptions.</strong> The dungeon hides things in plain sight. If the text mentions scratches on a wall, there's a reason. If Whisper mumbles about the eastern branch, there's a reason. The game rewards attention.</p>
<p class="prose"><strong>You don't have to fight everything.</strong> Twelve actions is enough for a good day, not enough for a reckless one. Know when to push and when to walk away. The dungeon will be here tomorrow.</p>
<p class="prose"><strong>Bard tokens accrue whether you log in or not.</strong> One per day, cap at five. Spend them at the barkeep for things gold can't buy — hints, buffs, intel. A patient player who saves five tokens gets information that changes everything.</p>
</div>
<!-- FOOTER -->
<div class="page-footer">
<a href="#">The Last Ember</a> · meshMUD
</div>
</div>
<script>
// ═══ EMBER PARTICLES ═══
const canvas = document.getElementById('ember-canvas');
const ctx = canvas.getContext('2d');
let embers = [];
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
class Ember {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * canvas.width;
this.y = canvas.height + 10;
this.size = Math.random() * 2 + 0.5;
this.speedY = -(Math.random() * 0.3 + 0.08);
this.speedX = (Math.random() - 0.5) * 0.2;
this.opacity = Math.random() * 0.35 + 0.1;
this.decay = Math.random() * 0.0007 + 0.0003;
this.wobble = Math.random() * Math.PI * 2;
this.wobbleSpeed = Math.random() * 0.015 + 0.003;
const t = Math.random();
this.r = Math.floor(200 + t * 55);
this.g = Math.floor(80 + t * 80);
this.b = Math.floor(20 + t * 30);
}
update() {
this.wobble += this.wobbleSpeed;
this.x += this.speedX + Math.sin(this.wobble) * 0.1;
this.y += this.speedY;
this.opacity -= this.decay;
if (this.opacity <= 0 || this.y < -20) this.reset();
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${this.r},${this.g},${this.b},${this.opacity})`;
ctx.fill();
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${this.r},${this.g},${this.b},${this.opacity * 0.1})`;
ctx.fill();
}
}
for (let i = 0; i < 25; i++) {
const e = new Ember();
e.y = Math.random() * canvas.height;
embers.push(e);
}
function animateEmbers() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
embers.forEach(e => { e.update(); e.draw(); });
requestAnimationFrame(animateEmbers);
}
animateEmbers();
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,215 +0,0 @@
# Task: MMUD Phase 5 — Endgame Modes
## Before Writing Any Code
Re-read these sections of `/home/zvx/projects/mmud/docs/planned.md`:
- Endgame: Three Rotating Modes (all of it — mode selection, R&E with Pursuer + support roles, Raid Boss with mechanic tables + phases, Hold the Line with regen + checkpoints + floor bosses)
- The Darkcragg Depths (dungeon name context)
- Floor Boss Mechanic Tables (all 4 floor tables)
- Bounties During Hold the Line
Also re-read `config.py` for: HTL_REGEN_ROOMS_PER_DAY, FLOOR_BOSS_MECHANICS, WARDEN_HP/REGEN, RAID_BOSS_HP_PER_PLAYER/CAP/REGEN/MECHANIC_TABLE/PHASES, PURSUER_ADVANCE_RATE/SPAWN_DISTANCE/RELAY_RESET_DISTANCE, WARD/LURE constants, ENDGAME_MODES.
Phase 4 already generates floor bosses, raid boss pre-config, and bounties. This phase wires the runtime game logic for all three modes.
## Phase 5 Deliverables
All three endgame modes are playable. The epoch vote selects the mode. Each mode has its own win condition, progression mechanics, and broadcasts.
### 1. Epoch Vote System
`src/systems/vote.py` (new file)
- Day 30 trigger (already in daytick.py — wire it to the vote system)
- `vote <mode>` command at barkeep — free action. Modes: `retrieve`, `raid`, `hold` (or numbers 1/2/3).
- Votes are public — broadcast on cast: "🗳 {name} voted {mode}."
- Votes can be changed up until epoch ends. UPSERT into epoch_votes table.
- Tally on epoch end: most votes wins. Tiebreak: longest-unplayed mode. No quorum — 1 vote decides if only 1 player votes. Zero votes → longest-unplayed auto-selected.
- `epoch_generate.py` already accepts endgame mode — wire the vote result into the next epoch's generation.
### 2. Hold the Line — Runtime Logic
`src/systems/endgame_htl.py` (new file)
**Room clearing:**
- All dungeon rooms start hostile (`htl_cleared = 0`)
- Killing all monsters in a room sets `htl_cleared = 1` with timestamp
- Regen ticks (already in daytick.py) revert rooms: pick N random cleared non-checkpoint rooms per floor per day, set `htl_cleared = 0`, respawn monsters. Spread ticks across the day (floor 2 at 5/day = 1 room every ~5 hours).
- Cleared rooms are safe — no random encounters. Reverted rooms respawn monsters.
**Checkpoints:**
- Checkpoint rooms are defined during worldgen (is_checkpoint = 1 in rooms table). 3 per floor (hub, midpoint, stairway), 1 on floor 4 (Warden).
- Establishment condition: all rooms in the checkpoint cluster (checkpoint room + all adjacent rooms) must be cleared within one regen window. Check on each room clear — if the cluster is complete, spawn the floor boss.
- Once the floor boss dies, checkpoint locks permanently. `htl_checkpoints.established = 1`. Regen can never revert rooms behind an established checkpoint.
- Final checkpoint on each floor (stairway) unlocks the next floor for all players.
**Floor bosses:**
- Already generated with rolled mechanics (from Phase 4 bossgen).
- Floor boss spawns in the checkpoint room when cluster is cleared. Uses the same shared HP pool / chip-and-run combat as bounties.
- Boss mechanic implementation — each mechanic modifies combat behavior:
- `armored` — damage halved until boss below 50% HP
- `enraged` — boss deals double damage below 50% HP, takes 25% more
- `regenerator` — boss heals 10% HP between sessions (check on engagement, apply since last fight)
- `stalwart` — first flee attempt per engagement always fails
- `warded` — boss has +50% DEF until a discovery secret on the same floor is found (check secret_progress)
- `phasing` — boss is immune to damage on even-numbered epoch days (check epoch.day_number)
- `draining` — boss steals 10% of damage dealt as HP from the attacker
- `splitting` — at 50% HP, boss splits into two half-HP monsters in adjacent rooms. Both must die.
- `rotating_resistance` — immune to the highest stat used by the last player who fought it. Track in DB.
- `retaliator` — reflects 20% of damage back to attacker
- `summoner` — spawns 1 add at start of each engagement. Add must die before boss can be damaged.
- `cursed` — player who dealt most damage last session gets -2 to a random stat next login
- Floor 4 Warden: shared HP pool 300-500, regen at 3%/8h, rolls 2 mechanics from the full table.
- Warden kill = epoch win. Broadcast: "🏆 The Warden has fallen! The Darkcragg Depths are conquered!"
**Broadcasts from DCRG:**
- "🏰 Floor {n} Checkpoint {name} established!"
- "⚠ Floor {n} lost {x} rooms. Frontline at {room}."
- "🏰 Floor {n} unlocked! The descent continues."
- "💀 Floor {n} frontline collapsed to Checkpoint {name}. Rally!"
- Floor boss spawned, floor boss killed, Warden progress.
**Barkeep integration:**
- Grist's recap includes HtL status: floors cleared percentage, checkpoint status, frontline position.
### 3. Raid Boss — Runtime Logic
`src/systems/endgame_raid.py` (new file)
**Activation:**
- On epoch start (if mode is raid_boss), calculate active player count (anyone who entered the dungeon in first 3 days).
- Set raid_boss.hp = 300 × active players, cap 6000. Set raid_boss.hp_max to same value.
- Place boss in a room on floor 3-4 (from pre-generated config in Phase 4).
**Combat:**
- Same chip-and-run as bounties — shared HP pool, engage/damage/flee.
- Regen: 3%/8h (lazy-evaluated like bounties — calculate on engagement).
- Track contributions in raid_boss_contributors.
**Mechanic implementation (2-3 rolled):**
- `windup_strike` — every 3rd combat round, next round deals triple damage unless player uses `defend` or `dodge` action. Add `defend` and `dodge` as combat commands (cost 1 action, negate the windup).
- `flat_damage_boost` — boss damage multiplied by 1.5x
- `retribution` — at 75%/50%/25% HP thresholds, burst damage (2x normal) to the player who pushed it past the threshold
- `aura_damage` — player takes 5% max HP unavoidable damage each combat round regardless of DEF
- `extra_regen` — regen rate becomes 5%/8h instead of 3%/8h
- `armor_phase` — boss takes half damage until: a discovery secret on the floor is found, OR 5+ unique players have contributed damage
- `boss_flees` — at 75%/50%/25% HP, boss relocates to random room on same floor. Broadcast from DCRG: "🐉 The {boss} has fled to somewhere on Floor {n}!" Players must find it.
- `regen_burst` — once per day at a random hour, boss heals 15% max HP in one tick. Trackable through observation.
- `no_escape` — below 25% HP, all flee attempts fail. Fight to the death.
- `summoner` — 1-2 adds spawn per engagement, must be killed before boss takes damage
- `lockout` — after engaging, player can't reengage for 24 hours. Store lockout_until in raid_boss_contributors.
- `enrage_timer` — after 5 combat rounds in a single engagement, boss damage doubles each subsequent round
**Phases:**
- Phase transitions at 66% and 33% HP (`RAID_BOSS_PHASES`).
- At each threshold, rolled mechanics intensify. Implementation: each mechanic has a `phase_modifier(phase_num)` that scales its effect. E.g., summoner spawns 1 add in phase 1, 2 in phase 2, 3 in phase 3. Windup goes from every 3rd round to every 2nd.
- Phase transition broadcasts from DCRG: "🐉 The {boss} enters its second phase!"
**Win condition:** Boss HP reaches 0. All contributors rewarded. Killing blow gets bonus. Broadcast: "🏆 The {boss} has been slain! Victory belongs to the Darkcragg!"
### 4. Retrieve and Escape — Runtime Logic
`src/systems/endgame_rne.py` (new file)
**Setup:**
- Guardian monster placed on floor 4 during epoch generation (add to bossgen if not already there — a strong but non-boss monster guarding the objective).
- `escape_run` table tracks run state.
**Claiming the objective:**
- Player defeats the guardian on floor 4 → objective claimed. `escape_run.active = 1`, carrier set, pursuer spawns.
- Broadcast from DCRG: "👑 {name} claimed the {objective}! The Pursuer stirs."
- Monster spawn rates double on all floors (multiply spawn chance by `ESCAPE_SPAWN_RATE_MULTIPLIER`).
**Pursuer:**
- Tracks carrier. Advances 1 room toward carrier every 2 carrier actions (`PURSUER_ADVANCE_RATE`).
- Spawns 3 rooms behind carrier (`PURSUER_SPAWN_DISTANCE`).
- Track pursuer position in `escape_run.pursuer_room_id`. Track fractional ticks in `pursuer_ticks`.
- On every carrier action: increment pursuer_ticks. When pursuer_ticks >= PURSUER_ADVANCE_RATE, advance pursuer 1 room toward carrier (pathfind shortest route), reset ticks.
- When pursuer enters carrier's room: forced combat. Pursuer is invulnerable (takes no damage). Hits hard. Carrier can only flee. Flee uses normal SPD check. Success = carrier moves 1 room. Failure = take damage + try again next action.
**Carrier death and relay:**
- Carrier dies → objective drops at death room. Broadcast from DCRG: "💀 The carrier has fallen on Floor {n}. The {objective} lies unguarded."
- `escape_run.objective_dropped = 1`, `dropped_room_id` set.
- Any player can `pickup` the objective in that room.
- On pickup: pursuer resets to 5 rooms behind new carrier (`PURSUER_RELAY_RESET_DISTANCE`). Broadcast: "👑 {name} picks up the {objective}! The Pursuer resets."
- Death penalty still applies to the dead carrier (gold loss, respawn in town).
**Three support roles:**
**Blockers:**
- Non-carrier in a room between pursuer and carrier. When pursuer reaches a blocker's room, forced combat with the blocker instead of advancing.
- Blocker can't kill pursuer (invulnerable). Each round blocker survives = 1 round pursuer isn't moving.
- Blocker can flee (normal SPD check). Blocker can die.
- Broadcast: "🛡 {name} is blocking the Pursuer on Floor {n}!" and "💀 {name} fell holding the line. The Pursuer advances."
- Implementation: on pursuer advance, check if any player is in the target room. If yes, pursuer enters combat with them instead of continuing.
**Warders:**
- `ward` command in a cleared dungeon room (1 extra action after clearing = `WARD_ACTION_COST`). Sets `rooms.ward_active = 1`.
- Warded room slows pursuer — takes 2 advance ticks to pass through instead of 1 (`WARD_PURSUER_SLOWDOWN`).
- Ward breaks after one use (reset to 0 when pursuer passes through).
- No broadcast on warding — silent preparation.
**Lures:**
- `lure` command when on same floor as pursuer. Costs 2 actions (`LURE_ACTION_COST`).
- Pursuer diverts toward lure player for 3 ticks (`LURE_DIVERT_TICKS`), then snaps back to carrier tracking. Total delay ~6 ticks (`LURE_TOTAL_DELAY_TICKS`) including backtrack.
- Broadcast: "🎯 {name} lured the Pursuer into {room}! It diverts."
- After divert expires: "👁 The Pursuer has reacquired the carrier."
**Pursuer distance broadcasts (from DCRG):**
- "👁 The Pursuer is {n} rooms behind the carrier." (every 5 carrier actions)
- "👁 The Pursuer is 3 rooms behind. It's closing."
- "⚠ The Pursuer has reached the carrier!"
**Win condition:** Any player delivers objective to town (The Last Ember). Broadcast: "🏆 The {objective} has reached the surface! Victory belongs to the Darkcragg!"
- All participants get epoch win credit (tracked in escape_participants by role).
### 5. Mode Activation in Engine
Update `src/core/engine.py` and `src/core/actions.py`:
- On game start, check epoch.endgame_mode. Load the appropriate endgame system.
- Mode-specific commands only available when that mode is active:
- HtL: checkpoint status command, floor control display
- Raid: raid boss status command (`boss` — show HP, phase, mechanics discovered so far)
- R&E: `pickup`, `ward`, `lure`, `block` commands. Carrier status. Pursuer distance.
- Combat system needs to dispatch to endgame boss combat (floor boss, raid boss, pursuer) when the target is a special entity. Same chip-and-run framework but with mechanic overlays.
- Endgame status integrated into barkeep recap and stats display.
### 6. New Combat Commands
For raid boss mechanics:
- `defend` / `def` — defensive stance. Negates windup strike. Costs 1 dungeon action. Does no damage that round.
- `dodge` / `dge` — evasion. Negates windup strike. Costs 1 dungeon action. Does no damage that round.
For R&E:
- `pickup` — pick up dropped objective in current room. Free action.
- `ward` — ward current room after clearing it. 1 dungeon action.
- `lure` — lure the Pursuer. 2 dungeon actions.
- `block` — (passive) just being in the pursuer's path triggers blocking. No explicit command needed — the system detects it. But add a `block` info command that shows: "Stand in the Pursuer's path to block. It will fight you instead of advancing."
## Rules
- All responses under 150 chars. Test this.
- All broadcasts route through DCRG node, not EMBR.
- Endgame mode commands are only available when that mode is active. Other mode commands return: "That doesn't apply this epoch."
- Boss combat uses the same chip-and-run framework as bounties — shared HP pool, damage persists, flee to disengage.
- Floor boss and raid boss regen is lazy-evaluated (calculate accumulated regen on engagement).
- Use constants from `config.py`.
- Raw parameterized SQL, no ORM.
- Commit after each mode is working (3 major commits minimum).
## Testing
Add to `tests/`:
- `tests/test_vote.py` — vote casting, changing, public broadcast, tally, tiebreak, zero-vote fallback
- `tests/test_htl.py` — room clearing, regen ticks, checkpoint cluster detection, checkpoint establishment, floor boss spawn on cluster clear, floor unlock, Warden kill = win, rooms behind checkpoint immune to regen
- `tests/test_boss_mechanics.py` — test each of the 12 mechanic implementations: armored, enraged, regenerator, stalwart, warded, phasing, draining, splitting, rotating_resistance, retaliator, summoner, cursed. Test phase scaling for raid boss.
- `tests/test_raid.py` — HP scaling from active players, cap at 6000, regen, phase transitions at 66%/33%, contribution tracking, lockout mechanic, completion + rewards
- `tests/test_rne.py` — objective claim, pursuer advancement (2:1 ratio), pursuer in carrier room triggers combat, carrier death drops objective, relay pickup resets pursuer, ward slows pursuer, lure diverts pursuer, blocker intercepts pursuer, win condition on town delivery
- `tests/test_rne_broadcasts.py` — all R&E broadcasts fire correctly (claim, distance, blocker, lure, death, relay, victory)
Use in-memory SQLite for tests. All endgame tests should generate a proper epoch first (use epoch_generate with DummyBackend).
## Done When
All three endgame modes are playable end-to-end. A Hold the Line epoch can be won by clearing all floors and killing the Warden. A Raid Boss epoch can be won by depleting the boss HP pool through coordinated chip-and-run combat with mechanic discovery. A Retrieve and Escape epoch can be won through a relay of carriers with blockers, warders, and lures supporting. The epoch vote selects the next mode. All broadcasts route through DCRG. All responses under 150 chars, all tests passing. Commit and report.

View file

@ -1,120 +0,0 @@
# Task: MMUD Phase 6 — The Breach
## Before Writing Any Code
Re-read these sections of `/home/zvx/projects/mmud/docs/planned.md`:
- The Breach — Mid-Epoch Event (Day 15) (all of it — 4 mini-events, endgame interaction, design rationale)
- Breach secrets (the 3 breach-type secrets)
Also re-read `config.py` for: BREACH_ROOMS_MIN/MAX, BREACH_CONNECTS_FLOORS, BREACH_SECRETS, BREACH_MINI_EVENTS, EMERGENCE_HP, INCURSION_REGEN/HOLD_HOURS.
Phase 4 already generates the Breach zone (breachgen.py) and Phase 4's daytick.py already handles the day 15 trigger and days 12-13 foreshadowing. Phase 4's breach.py has basic state management. This phase wires the full runtime logic for all 4 mini-events.
## Phase 6 Deliverables
The Breach opens on day 15 with a random mini-event. Each of the 4 types plays differently. The Breach interacts with whichever endgame mode is active.
### 1. Breach Activation (verify/extend existing)
The day 15 trigger should already be in daytick.py. Verify it:
- Day 12-13: barkeep foreshadowing broadcasts from DCRG: "The walls grow thin between the second and third depths. Something stirs."
- Day 15: Breach opens. Set `breach.active = 1`. Open the room exits connecting Breach zone to floors 2 and 3. Broadcast from DCRG: "⚡ The ground splits. A new passage has opened between Floors 2 and 3. Strange light pours from within."
- Players can now enter Breach rooms via the new exits from floors 2 and 3.
- The permanent shortcut between floors 2 and 3 persists for the rest of the epoch.
### 2. Mini-Event: The Heist (mini Retrieve & Escape)
`src/systems/breach_heist.py` (new file)
- Artifact in the deepest Breach room, guarded by the Breach mini-boss.
- Kill mini-boss → claim artifact. Carrier must bring it back to town.
- Pursuer spawns (slower, Breach-only — only operates within the 5-8 Breach rooms + the floors 2-3 connection).
- If carrier dies, artifact drops. Any player can pick up.
- Relay mechanics same as R&E but compressed — 5-8 rooms, not 4 floors.
- 3 Breach secrets scattered along the escape route. Found under pressure.
- Completion: artifact delivered to town. Breach rewards distributed. Broadcast from DCRG: "🏆 The artifact has been extracted from the Breach!"
Reuse as much R&E logic from Phase 5 as possible — shared carrier/pursuer/relay patterns.
### 3. Mini-Event: The Emergence (mini Raid Boss)
`src/systems/breach_emergence.py` (new file)
- Creature with shared HP pool (500-800 HP, `EMERGENCE_HP_MIN/MAX`) sits in central Breach room.
- Surrounding rooms spawn minions on a timer (respawn every 8 hours).
- Same chip-and-run combat as bounties/raid boss. Regen at 3%/8h.
- 3 Breach secrets are in the minion rooms — discovered while contributing to the kill.
- Completion: creature HP reaches 0. Broadcast: "🏆 The Breach creature has been destroyed!"
Reuse raid boss combat framework from Phase 5.
### 4. Mini-Event: The Incursion (mini Hold the Line)
`src/systems/breach_incursion.py` (new file)
- Breach rooms start fully hostile. Regen at 2 rooms/day (`INCURSION_REGEN_ROOMS_PER_DAY`) within just 5-8 rooms.
- Players must clear ALL Breach rooms and hold them all for 48 hours (`INCURSION_HOLD_HOURS`).
- If any room reverts during the hold timer, the clock resets.
- 3 Breach secrets behind the hardest rooms, found as part of the push.
- Track hold start time in `breach.incursion_hold_started_at`. On each regen tick, check if any Breach room reverted — if so, reset the timer.
- Completion: 48 hours with all rooms held. Broadcast: "🏆 The Breach has been secured! The incursion is contained."
Reuse HtL room clearing/regen logic from Phase 5.
### 5. Mini-Event: The Resonance (puzzle dungeon)
`src/systems/breach_resonance.py` (new file)
- No combat focus. Breach rooms contain environmental puzzles.
- 3 Breach secrets ARE the puzzle rewards. Finding all 3 unlocks a bonus cache in the deepest room.
- Puzzles are generated in Phase 4 (breachgen already places Breach secrets). This phase adds the interaction logic:
- `examine` objects in Breach rooms triggers puzzle checks
- Puzzle state tracked per-player in secret_progress
- Sequence puzzles, item-interaction puzzles, cross-room clue puzzles (use the same multi-room puzzle archetypes from the main dungeon)
- Completion: all 3 Breach secrets found by any player(s). Bonus cache unlocked. Broadcast: "🏆 The Resonance has been understood. The Breach yields its secrets."
Soloable by nature — knowledge not stats.
### 6. Breach Interaction with Endgame Modes
Regardless of which mini-event is running, the Breach benefits the active endgame mode:
- **Retrieve & Escape:** The Breach shortcut (floors 2↔3) becomes an alternate escape route. Carrier can path through it. Shorter but Breach content (mini-boss, minions, etc.) may still be there.
- **Raid Boss:** Breach completion (any mini-event) drops a buff item granting +20% damage vs the raid boss for the rest of the epoch. Add to player inventory on Breach completion.
- **Hold the Line:** Breach rooms count as bonus territory toward checkpoint progress on both floors 2 and 3. Cleared Breach rooms contribute to the cleared room count for both floor 2 and floor 3 checkpoints.
### 7. Breach Secret Integration
Verify that the 3 Breach secrets work with the existing discovery system:
- `secrets` command includes Breach secrets in the count after day 15
- Secret milestones (5/10/15/20) fire correctly with Breach secrets included
- Barkeep hints for Breach secrets only available after day 15
- Breach secrets contribute to the completionist reward (all 20 found)
## Rules
- All responses under 150 chars.
- All Breach broadcasts route through DCRG.
- Breach mini-event is always random (selected at epoch gen, never voted).
- Reuse combat/territory frameworks from Phase 5 — don't duplicate code.
- Breach content is inaccessible before day 15. Exits to Breach rooms don't exist until activation.
- Use constants from `config.py`.
- Commit after each mini-event works.
## Testing
Add to `tests/`:
- `tests/test_breach_activation.py` — day 15 trigger, foreshadowing on days 12-13, exits open, Breach accessible, inaccessible before day 15
- `tests/test_breach_heist.py` — mini-boss, artifact claim, mini-pursuer, relay, completion, secrets under pressure
- `tests/test_breach_emergence.py` — shared HP pool, minion respawn, chip-and-run, completion, secrets in minion rooms
- `tests/test_breach_incursion.py` — room clearing, regen within Breach, 48h hold timer, timer reset on revert, completion
- `tests/test_breach_resonance.py` — puzzle interaction, secret discovery, bonus cache unlock, no combat required
- `tests/test_breach_endgame.py` — R&E shortcut, raid boss damage buff, HtL bonus territory
Use in-memory SQLite for tests. Generate full epoch with DummyBackend for each test.
## Done When
The Breach opens on day 15 with one of four randomly selected mini-events. Each mini-event is playable end-to-end with its own win condition. Breach secrets integrate cleanly with the discovery system. The Breach interacts with whichever endgame mode is active. All broadcasts route through DCRG. All responses under 150 chars, all tests passing. Commit and report.
This is the final gameplay phase. After this, the full 30-day epoch loop is complete: epoch generates → players explore and progress → Breach opens day 15 → endgame mode pushes through days 20-30 → epoch vote → wipe → new epoch.

View file

@ -1,152 +0,0 @@
# Task: Update planned.md with new design sections
Edit `/home/zvx/projects/mmud/docs/planned.md` in place. Three additions plus resolved decisions updates.
---
## Addition 1: The Last Ember — Town Hub
Find the `## Atmosphere & Writing` section. **BEFORE** the `---` divider that separates Atmosphere from `## New Player Onboarding`, insert this new section:
```markdown
---
## The Last Ember — Town Hub
The Last Ember is the one room that never changes. Epochs wipe the dungeon, reskin the narrative, randomize everything — but players always wake up in the same bar, with the same people, who remember them. The lanterns don't burn oil — they just burn. Nobody lights them. Nobody replaces them. The dungeon reshapes itself every 30 days but the Last Ember sits at the mouth of it like a tooth that won't come loose.
The Last Ember is the constant across every epoch, every server, every wipe. It is the frame for the entire game.
### Grist — The Barkeep
Has never left the bar. Not once. Players who've been around for dozens of epochs start to wonder if he *can*. He knows everything that happens in the dungeon — not because he goes there, but because everyone who comes back tells him, and he never forgets. He speaks in short, deliberate sentences. Never wastes a word. He pours drinks that are always exactly what you needed, even if you didn't order.
His recap isn't a service — it's a compulsion. He *has* to tell you what happened. Like the information would burn him if he held it.
He's the bard token system. He trades in stories, not gold. Bring him something interesting — a secret, a discovery, something nobody else knows — and he gives you something back. Information, a temporary edge, a nudge in the right direction. He doesn't trade because he's kind. He trades because he *collects*.
**Mechanical role:** Recap (free), bard token exchange, hints, epoch vote ballot, bounty board.
### Maren — The Healer
Used to be an adventurer. Went deeper than anyone. Came back wrong — not injured, just *done*. She won't say what she saw on the lowest floor. She heals with her hands, not magic, and it hurts. She's efficient, not gentle. She charges gold because she says free healing breeds carelessness, and she's tired of patching people up who didn't respect the dungeon.
She's the only NPC who will occasionally refuse to talk to you if you died doing something stupid — but she still heals you.
She has a scar across her left palm that she got "the last time." She won't say the last time of what.
**Mechanical role:** HP restoration for gold.
### Torval — The Merchant
Doesn't go into the dungeon either, but somehow his inventory matches what's down there each epoch. Nobody asks how. He appraises items by weight and sound — taps gear on the counter, listens, names a price. He's cheerful in a way that feels slightly wrong given where he operates. He tells bad jokes. He calls everyone "friend" and means it exactly zero percent. He'd sell you a cursed sword and sleep fine.
But his prices are fair and his stock is real, which is more than you can say for most people in a town built around a hole full of monsters.
He keeps a ledger that goes back further than the bar. The pages at the front are in a language nobody can read.
**Mechanical role:** Buy, sell, item appraisal.
### Whisper — The Sage
Nobody knows if Whisper is her name or a description of how she talks. She sits in the corner of the Last Ember, always the same corner, and she knows things about the dungeon that change each epoch — lore, history, connections between rooms, what the symbols mean. She speaks in fragments and riddles not because she's trying to be mysterious but because that's how the information comes to her. She describes it like listening to a conversation through a wall.
Her clues are genuine but filtered through whatever broke her ability to just *say things plainly*. Players who pay attention to her exact phrasing find secrets faster. Players who dismiss her as flavor text miss half the game.
She has been the same age for as long as anyone can remember.
**Mechanical role:** Lore hints, secret clues, puzzle guidance (via bard tokens).
### NPC Live Conversations — LLM at Runtime
The "zero LLM at runtime" rule has one exception: talking to NPCs in the Last Ember. Walking up to Grist and having an actual conversation, asking Maren about her scar, trying to get Whisper to speak plainly — these interactions use a live LLM call.
The 150-character limit IS the NPC's personality. Grist is terse by nature. Maren doesn't waste words. Whisper speaks in fragments. Torval talks fast. The constraint is the flavor.
**Command:** `talk <npc>` or `talk <npc> <message>` — free action (in town only). Opens or continues a conversation.
**System prompt per NPC includes:**
- Full backstory and personality card
- Current game state injection: active bounties, recent deaths, Breach status, epoch day, floor control percentages, raid boss HP — whatever is relevant. The NPC *knows what's happening.*
- Hard rules: respond in character, NEVER break character, response MUST be under 150 characters, never reveal exact secret locations or puzzle solutions (hints only), never acknowledge being an AI, never discuss anything outside the game world.
**What each NPC brings:**
- **Grist** — gossip and world state. Knows everything from broadcast logs. Ask about another player and he'll tell you what they've been up to. Dry, factual, slightly unsettling in how much he knows.
- **Maren** — the human element. Comments on your injuries, your play pattern, your stubbornness. Has opinions about the dungeon. Will never talk about what she saw on the lowest floor no matter how hard you try.
- **Torval** — comic relief and commerce. Banter about items, terrible jokes, comments on your gear. "You're wearing THAT to floor 3? Bold." Embellished sales pitches.
- **Whisper** — lore oracle. High-skill conversation. Speaks in fragments. Ask the right questions and get real, useful information about secrets. Her cryptic style is the LLM prompt, not a gimmick — talking to Whisper IS a puzzle.
**Guardrails:**
- Conversation memory is session-only — NPCs don't remember yesterday's chat. Keeps context windows small and prevents exploit accumulation.
- If the LLM fails or times out, fall back to a random pre-generated dialogue snippet from the batch pipeline (20 per NPC already generated at epoch start).
- No rate limit on NPC conversations. Players can talk as long as they want. The NPCs are storytellers and historians — extended conversation is a feature, not abuse.
- Uses the same pluggable LLM backend as the epoch generation pipeline (Anthropic, OpenAI, Google, or Dummy).
**Server History Seed — 2 Years of Lore:**
Before the server goes live, generate 24 epochs (2 years) of simulated history. Each epoch gets: number, endgame mode, Breach type, narrative theme, win/loss result, 3-5 notable players (generated names, classes, what they did), 1-2 memorable moments, hall of fame entries, titles earned. Stored in the persistent tables. When the real server starts on epoch 25, the NPCs have 24 epochs of stories to tell. A compressed lore packet (20-30 sentences of highlights) is injected into every NPC system prompt and regenerated each epoch as real player history accumulates and blends with seeded history.
**Cost math:** At Haiku-tier pricing, ~500 tokens per turn. Even heavy usage (50+ turns/day across all players) is ~$0.006/day. Unlimited conversation is essentially free.
```
---
## Addition 2: Command Discovery
Find the `## New Player Onboarding` section. After the "Daily Tips" subsection and before the `---` divider that separates it from `## Resolved Decisions`, insert:
```markdown
### Command Discovery — No Guessing on Slow Radio
On a 45-60 second radio round-trip, guessing a command and getting "Unknown command" is unacceptable. Every interaction point should make available commands visible.
**First connect message:** Include core commands explicitly. Not "type H for help" — actually list them. `Move:N/S/E/W Fight:F Look:L Flee:FL Stats:ST Help:H` fits in 150 chars and gives a new player everything for their first session.
**Smart error responses:** Never just "Unknown command." Always suggest valid commands based on current player state:
- In town: `Unknown. Try: BAR SHOP HEAL BANK TRAIN ENTER H(elp)`
- In dungeon: `Unknown. Try: F(ight) FL(ee) L(ook) N/S/E/W H(elp)`
- In combat: `Unknown. Try: F(ight) FL(ee) STATS`
- Dead: `Unknown. You're dead. Type RESPAWN.`
**Context-sensitive help (H command):** `H` alone shows commands available in current state. `H <cmd>` gives specific help. All fits 150 chars. Help output changes based on player level — only shows unlocked commands.
**Barkeep nudges:** When a player visits Grist but hasn't used a system yet, the recap appends a tip: "Tip: try BOUNTY to see active hunts" or "Tip: use MSG to leave notes in rooms." One tip per visit, rotating through unused systems. Stops once the player has tried everything.
**Progressive unlock announcements:** When a command unlocks at a new level, announce it explicitly with usage: "⬆ Level 3! New: SHOP(buy gear) BANK(save gold) MAIL(send messages)"
**Last Ember quick reference:** The spectator web page includes a printable command cheat sheet — a one-page reference players can keep next to their Meshtastic device. Physical reference for a physical radio game.
```
---
## Addition 3: Resolved Decisions
Find the `## Resolved Decisions` section. Add these lines at the end of the list:
```markdown
- Town hub: The Last Ember — persistent bar across all epochs, all servers. Four permanent NPCs: Grist (barkeep), Maren (healer), Torval (merchant), Whisper (sage).
- NPC live conversations: NPCs are sim nodes on the mesh (GRST, MRN, TRVL, WSPR). Players DM them directly. Three rule layers: unknown node gets static onboarding, known player not in bar gets static rejection, known player in bar gets full LLM conversation. No rate limit. Session-only memory. Falls back to pre-generated dialogue on failure. 24-epoch history seed provides 2 years of lore.
- Command discovery: smart error responses show valid commands for current state, barkeep nudges for unused systems, explicit command listing on first connect.
```
---
## Addition 4: LLM Content Pipeline update
Find the `## LLM Content Pipeline` section. Find the line that says `### Decision Rule` and the text `Use LLMs for content that can be validated offline. Use deterministic templates for anything that must be correct in real-time.`
Replace that with:
```markdown
### Decision Rule
Use LLMs for content that can be validated offline. Use deterministic templates for anything that must be correct in real-time. **One exception:** NPC conversations in The Last Ember use live LLM calls — the 150-char response constraint, personality cards, and session-only memory make this safe, cheap, and in-character. See The Last Ember section for details.
```
---
## Commit
```bash
git add -A && git commit -m "Design doc: add Last Ember NPCs, live NPC conversations, command discovery"
```

View file

@ -1,102 +0,0 @@
# Task: Add NPC sim node architecture and rules to planned.md
Edit `/home/zvx/projects/mmud/docs/planned.md` in place.
---
## Edit 1: NPC Sim Nodes section
Find the `### NPC Live Conversations — LLM at Runtime` subsection inside `## The Last Ember — Town Hub`. Replace the **Command** line and everything after it in that subsection (from `**Command:**` through the end of `**Cost math:**`) with the following:
```markdown
**Network Architecture — NPCs as Mesh Nodes:**
The NPCs are literal Meshtastic nodes on the mesh network. Five sim nodes, all backed by the same game database:
- **EMBR** — The Last Ember. The game server. All game commands go here.
- **GRST** — Grist. DM this node to talk to the barkeep.
- **MRN** — Maren. DM this node to talk to the healer.
- **TRVL** — Torval. DM this node to talk to the merchant.
- **WSPR** — Whisper. DM this node to talk to the sage.
Players don't issue a `talk` command — they DM the NPC's node directly. The game server sees inbound on the NPC node ID, checks the rules below, and routes the response back through that NPC's node. The NPCs are *people on the network*, not menu options.
**Three rule layers (checked in order):**
**Rule 1 — Unknown node (not in the game):** Static in-character rejection with onboarding instructions. No LLM call. Each NPC has a fixed response:
- Grist: `"Don't know you. DM EMBR to start. Then we'll talk."`
- Maren: `"I only patch up adventurers. DM EMBR to become one."`
- Torval: `"No account, no credit, friend. DM EMBR to join up."`
- Whisper: `"...not yet. EMBR. Begin there."`
**Rule 2 — Known player, not in the bar:** Static in-character refusal. Player is in the dungeon, dead, or otherwise not in town. No LLM call.
- Grist: `"You're not here, {name}. Come back to the bar first."`
- Maren: `"I can hear you're still down there. Come back alive."`
- Torval: `"I don't do deliveries. Get back to the Ember."`
- Whisper: `"...too far. Return."`
**Rule 3 — Known player, in the bar:** Full LLM conversation. This is the only case that triggers a live LLM call.
**System prompt per NPC includes:**
- Full backstory and personality card
- Current game state injection: active bounties, recent deaths, Breach status, epoch day, floor control percentages, raid boss HP — whatever is relevant. The NPC *knows what's happening.*
- Hard rules: respond in character, NEVER break character, response MUST be under 150 characters, never reveal exact secret locations or puzzle solutions (hints only), never acknowledge being an AI, never discuss anything outside the game world.
**What each NPC brings:**
- **Grist** — gossip and world state. Knows everything from broadcast logs. Ask about another player and he'll tell you what they've been up to. Dry, factual, slightly unsettling in how much he knows.
- **Maren** — the human element. Comments on your injuries, your play pattern, your stubbornness. Has opinions about the dungeon. Will never talk about what she saw on the lowest floor no matter how hard you try.
- **Torval** — comic relief and commerce. Banter about items, terrible jokes, comments on your gear. "You're wearing THAT to floor 3? Bold." Embellished sales pitches.
- **Whisper** — lore oracle. High-skill conversation. Speaks in fragments. Ask the right questions and get real, useful information about secrets. Her cryptic style is the LLM prompt, not a gimmick — talking to Whisper IS a puzzle.
**Guardrails:**
- Conversation memory is session-only — NPCs don't remember yesterday's chat. Keeps context windows small and prevents exploit accumulation.
- If the LLM fails or times out, fall back to a random pre-generated dialogue snippet from the batch pipeline (20 per NPC already generated at epoch start).
- No rate limit on NPC conversations. Players can talk as long as they want. The NPCs are storytellers, historians, and characters — extended conversation is a feature, not abuse.
- Uses the same pluggable LLM backend as the epoch generation pipeline (Anthropic, OpenAI, Google, or Dummy).
**Server History Seed — 2 Years of Lore:**
Before the server goes live, generate 24 epochs (2 years) of simulated history. For each epoch:
- Epoch number, endgame mode, Breach type, narrative theme
- Whether the server won or lost (mix of both — some epic victories, some heartbreaking failures)
- 3-5 notable players per epoch (generated names, classes, levels reached, what they did)
- 1-2 memorable moments per epoch ("Kira carried the Crown from floor 4 to floor 1 with 3 HP", "The Warden stood for 28 days — the server failed on the final push", "Epoch 11's Raid Boss had No Escape + Enraged — three players died on the killing blow")
- Hall of fame entries, titles earned
Stored in the persistent tables (accounts, hall_of_fame, hall_of_fame_participants, titles). When the real server starts on epoch 25, the NPCs have 24 epochs of history to draw from. Grist drops names of old champions. Maren compares your injuries to legends. Torval mentions gear from epochs past. Whisper sees patterns across cycles that nobody else notices.
**NPC context injection includes a lore packet:** A compressed 20-30 sentence summary of server history highlights pulled from the hall of fame tables. Regenerated at each epoch start so it stays current as real player history accumulates and blends with the seed history. The NPCs don't distinguish between seeded and real history — it's all the same to them.
**Cost math:** At Haiku-tier pricing, ~500 tokens per conversation turn. Even heavy usage (50 turns/day across all players) is ~25,000 tokens/day ≈ $0.006/day. Unlimited conversation is essentially free.
```
---
## Edit 2: Update Resolved Decisions
Find the resolved decision line that says:
```
- NPC live conversations: LLM at runtime exception for talk command in town. Session-only memory, 5/day rate limit per NPC, falls back to pre-generated dialogue on failure.
```
Replace it with:
```
- NPC live conversations: NPCs are sim nodes on the mesh (GRST, MRN, TRVL, WSPR). Players DM them directly. Three rule layers: unknown node gets static onboarding response, known player not in bar gets static rejection, known player in bar gets full LLM conversation. No rate limit. Session-only memory. Falls back to pre-generated dialogue on failure. 24-epoch history seed provides 2 years of lore for NPCs to draw from.
```
---
## Edit 3: Add to Open Questions
Add this to the end of the `## Open Questions` list:
```
- NPC sim node deployment — which host runs meshtasticd with 5 identities, TCP routing to game LXC
```
---
## Commit
```bash
git add -A && git commit -m "Design doc: NPCs as mesh sim nodes, three-layer access rules, onboarding funnel"
```

View file

@ -1,61 +0,0 @@
# Task: Add Darkcragg Depths dungeon name to planned.md
Edit `/home/zvx/projects/mmud/docs/planned.md` in place.
---
## Edit 1: Dungeon section
Find the `## Dungeon` section. Find the line `### Hub-Spoke Layout with Loops`. Insert a new subsection BEFORE it:
```markdown
### The Darkcragg Depths
The dungeon is always the Darkcragg Depths. Like the Last Ember, the name is a constant — it persists across every epoch, every server, every wipe. The floors reskin, the layout regenerates, the monsters change, but the Depths are always the Depths. Players descend into the Darkcragg. They talk about the Darkcragg. It's a proper noun, not a generic dungeon.
The four floors are narratively re-skinned each epoch (Sunken Halls, Fungal Depths, Ember Caverns, Void Reach are defaults — the LLM pipeline may rename them) but the Darkcragg Depths is the name on the door every time.
```
---
## Edit 2: Update The Last Ember section
Find the paragraph in `## The Last Ember — Town Hub` that starts with "The Last Ember is the constant across every epoch". Replace that single line with:
```markdown
The Last Ember and the Darkcragg Depths are the two constants across every epoch, every server, every wipe. The bar and the hole it sits on top of. Everything else changes. These don't.
```
---
## Edit 3: Update NPC rejection messages
Find the Rule 2 static responses for known players not in the bar. Update Maren's line:
Replace:
```
- Maren: `"I can hear you're still down there. Come back alive."`
```
With:
```
- Maren: `"I can hear you're still in the Darkcragg. Come back alive."`
```
---
## Edit 4: Resolved Decisions
Add to the end of the `## Resolved Decisions` list:
```markdown
- Dungeon name: The Darkcragg Depths — persistent across all epochs like the Last Ember. Floor names reskin per epoch but the Darkcragg is always the Darkcragg.
```
---
## Commit
```bash
git add -A && git commit -m "Design doc: the dungeon is the Darkcragg Depths"
```

View file

@ -1,75 +0,0 @@
# Task: Add Darkcragg Depths broadcast node to planned.md
Edit `/home/zvx/projects/mmud/docs/planned.md` in place.
---
## Edit 1: Update NPC Sim Nodes section
Find the `**Network Architecture — NPCs as Mesh Nodes:**` block inside `## The Last Ember — Town Hub`. Replace the node list (the 5-item bullet list starting with `- **EMBR**` through `- **WSPR**`) with:
```markdown
- **EMBR** — The Last Ember. The game server. All game commands go here. Responds with direct action results only.
- **DCRG** — The Darkcragg Depths. One-way broadcast node. All dungeon events come from here — deaths, bounty progress, Breach opening, regen ticks, boss phase transitions, discoveries, level-ups. Does not accept commands. The dungeon is alive on the network.
- **GRST** — Grist. DM this node to talk to the barkeep.
- **MRN** — Maren. DM this node to talk to the healer.
- **TRVL** — Torval. DM this node to talk to the merchant.
- **WSPR** — Whisper. DM this node to talk to the sage.
This splits two distinct streams: EMBR only sends direct responses to your actions. DCRG is the ambient feed of what's happening in the world. The NPCs are people you talk to. Six nodes total, one game DB backing all of them.
```
---
## Edit 2: Add DCRG rules to the rule layers
Find `**Three rule layers (checked in order):**`. Insert a new section BEFORE Rule 1:
```markdown
**DCRG rules (broadcast node):**
- DCRG never accepts inbound messages. If a player or unknown node DMs DCRG, it responds with a static message: `"The Darkcragg does not answer. It only speaks. DM EMBR to play."`
- All tier 1 and tier 2 broadcasts are sent FROM the DCRG node, not EMBR.
- Targeted broadcasts (multi-room puzzle feedback) are also sent from DCRG as DMs to qualifying players.
- DCRG is the voice of the dungeon. When someone dies, when the Breach opens, when a bounty falls — it comes from the Darkcragg.
```
---
## Edit 3: Update Broadcast System section
Find `## Broadcast System`. Find the first paragraph or description of how broadcasts work. Add this line at the end of the introductory text, before any subsections:
```markdown
All broadcasts are sent from the DCRG (Darkcragg Depths) sim node, not the main EMBR game node. This separates the ambient world feed from direct command responses. EMBR talks to you. The Darkcragg talks about everyone.
```
---
## Edit 4: Update Resolved Decisions
Find the resolved decision about NPC live conversations that starts with `- NPC live conversations: NPCs are sim nodes`. Replace it with:
```markdown
- Mesh node architecture: 6 sim nodes — EMBR (game commands + responses), DCRG (one-way dungeon broadcasts), GRST/MRN/TRVL/WSPR (NPC conversations). One game DB backs all of them.
- NPC conversations: Players DM NPC nodes directly. Three rule layers: unknown node gets static onboarding, known player not in bar gets static rejection, known player in bar gets full LLM conversation. Session-only memory, 5/day rate limit per NPC, falls back to pre-generated dialogue on failure.
- DCRG is broadcast-only — does not accept commands. All tier 1/2 and targeted broadcasts route through DCRG.
```
---
## Edit 5: Update Open Questions
Find the open question `- NPC sim node deployment`. Replace it with:
```markdown
- Sim node deployment — which host runs meshtasticd with 6 identities (EMBR, DCRG, GRST, MRN, TRVL, WSPR), TCP routing to game LXC
```
---
## Commit
```bash
git add -A && git commit -m "Design doc: DCRG broadcast node — the dungeon speaks on the mesh"
```

View file

@ -1,215 +0,0 @@
# Task: MMUD Phase 5 — Endgame Modes
## Before Writing Any Code
Re-read these sections of `/home/zvx/projects/mmud/docs/planned.md`:
- Endgame: Three Rotating Modes (all of it — mode selection, R&E with Pursuer + support roles, Raid Boss with mechanic tables + phases, Hold the Line with regen + checkpoints + floor bosses)
- The Darkcragg Depths (dungeon name context)
- Floor Boss Mechanic Tables (all 4 floor tables)
- Bounties During Hold the Line
Also re-read `config.py` for: HTL_REGEN_ROOMS_PER_DAY, FLOOR_BOSS_MECHANICS, WARDEN_HP/REGEN, RAID_BOSS_HP_PER_PLAYER/CAP/REGEN/MECHANIC_TABLE/PHASES, PURSUER_ADVANCE_RATE/SPAWN_DISTANCE/RELAY_RESET_DISTANCE, WARD/LURE constants, ENDGAME_MODES.
Phase 4 already generates floor bosses, raid boss pre-config, and bounties. This phase wires the runtime game logic for all three modes.
## Phase 5 Deliverables
All three endgame modes are playable. The epoch vote selects the mode. Each mode has its own win condition, progression mechanics, and broadcasts.
### 1. Epoch Vote System
`src/systems/vote.py` (new file)
- Day 30 trigger (already in daytick.py — wire it to the vote system)
- `vote <mode>` command at barkeep — free action. Modes: `retrieve`, `raid`, `hold` (or numbers 1/2/3).
- Votes are public — broadcast on cast: "🗳 {name} voted {mode}."
- Votes can be changed up until epoch ends. UPSERT into epoch_votes table.
- Tally on epoch end: most votes wins. Tiebreak: longest-unplayed mode. No quorum — 1 vote decides if only 1 player votes. Zero votes → longest-unplayed auto-selected.
- `epoch_generate.py` already accepts endgame mode — wire the vote result into the next epoch's generation.
### 2. Hold the Line — Runtime Logic
`src/systems/endgame_htl.py` (new file)
**Room clearing:**
- All dungeon rooms start hostile (`htl_cleared = 0`)
- Killing all monsters in a room sets `htl_cleared = 1` with timestamp
- Regen ticks (already in daytick.py) revert rooms: pick N random cleared non-checkpoint rooms per floor per day, set `htl_cleared = 0`, respawn monsters. Spread ticks across the day (floor 2 at 5/day = 1 room every ~5 hours).
- Cleared rooms are safe — no random encounters. Reverted rooms respawn monsters.
**Checkpoints:**
- Checkpoint rooms are defined during worldgen (is_checkpoint = 1 in rooms table). 3 per floor (hub, midpoint, stairway), 1 on floor 4 (Warden).
- Establishment condition: all rooms in the checkpoint cluster (checkpoint room + all adjacent rooms) must be cleared within one regen window. Check on each room clear — if the cluster is complete, spawn the floor boss.
- Once the floor boss dies, checkpoint locks permanently. `htl_checkpoints.established = 1`. Regen can never revert rooms behind an established checkpoint.
- Final checkpoint on each floor (stairway) unlocks the next floor for all players.
**Floor bosses:**
- Already generated with rolled mechanics (from Phase 4 bossgen).
- Floor boss spawns in the checkpoint room when cluster is cleared. Uses the same shared HP pool / chip-and-run combat as bounties.
- Boss mechanic implementation — each mechanic modifies combat behavior:
- `armored` — damage halved until boss below 50% HP
- `enraged` — boss deals double damage below 50% HP, takes 25% more
- `regenerator` — boss heals 10% HP between sessions (check on engagement, apply since last fight)
- `stalwart` — first flee attempt per engagement always fails
- `warded` — boss has +50% DEF until a discovery secret on the same floor is found (check secret_progress)
- `phasing` — boss is immune to damage on even-numbered epoch days (check epoch.day_number)
- `draining` — boss steals 10% of damage dealt as HP from the attacker
- `splitting` — at 50% HP, boss splits into two half-HP monsters in adjacent rooms. Both must die.
- `rotating_resistance` — immune to the highest stat used by the last player who fought it. Track in DB.
- `retaliator` — reflects 20% of damage back to attacker
- `summoner` — spawns 1 add at start of each engagement. Add must die before boss can be damaged.
- `cursed` — player who dealt most damage last session gets -2 to a random stat next login
- Floor 4 Warden: shared HP pool 300-500, regen at 3%/8h, rolls 2 mechanics from the full table.
- Warden kill = epoch win. Broadcast: "🏆 The Warden has fallen! The Darkcragg Depths are conquered!"
**Broadcasts from DCRG:**
- "🏰 Floor {n} Checkpoint {name} established!"
- "⚠ Floor {n} lost {x} rooms. Frontline at {room}."
- "🏰 Floor {n} unlocked! The descent continues."
- "💀 Floor {n} frontline collapsed to Checkpoint {name}. Rally!"
- Floor boss spawned, floor boss killed, Warden progress.
**Barkeep integration:**
- Grist's recap includes HtL status: floors cleared percentage, checkpoint status, frontline position.
### 3. Raid Boss — Runtime Logic
`src/systems/endgame_raid.py` (new file)
**Activation:**
- On epoch start (if mode is raid_boss), calculate active player count (anyone who entered the dungeon in first 3 days).
- Set raid_boss.hp = 300 × active players, cap 6000. Set raid_boss.hp_max to same value.
- Place boss in a room on floor 3-4 (from pre-generated config in Phase 4).
**Combat:**
- Same chip-and-run as bounties — shared HP pool, engage/damage/flee.
- Regen: 3%/8h (lazy-evaluated like bounties — calculate on engagement).
- Track contributions in raid_boss_contributors.
**Mechanic implementation (2-3 rolled):**
- `windup_strike` — every 3rd combat round, next round deals triple damage unless player uses `defend` or `dodge` action. Add `defend` and `dodge` as combat commands (cost 1 action, negate the windup).
- `flat_damage_boost` — boss damage multiplied by 1.5x
- `retribution` — at 75%/50%/25% HP thresholds, burst damage (2x normal) to the player who pushed it past the threshold
- `aura_damage` — player takes 5% max HP unavoidable damage each combat round regardless of DEF
- `extra_regen` — regen rate becomes 5%/8h instead of 3%/8h
- `armor_phase` — boss takes half damage until: a discovery secret on the floor is found, OR 5+ unique players have contributed damage
- `boss_flees` — at 75%/50%/25% HP, boss relocates to random room on same floor. Broadcast from DCRG: "🐉 The {boss} has fled to somewhere on Floor {n}!" Players must find it.
- `regen_burst` — once per day at a random hour, boss heals 15% max HP in one tick. Trackable through observation.
- `no_escape` — below 25% HP, all flee attempts fail. Fight to the death.
- `summoner` — 1-2 adds spawn per engagement, must be killed before boss takes damage
- `lockout` — after engaging, player can't reengage for 24 hours. Store lockout_until in raid_boss_contributors.
- `enrage_timer` — after 5 combat rounds in a single engagement, boss damage doubles each subsequent round
**Phases:**
- Phase transitions at 66% and 33% HP (`RAID_BOSS_PHASES`).
- At each threshold, rolled mechanics intensify. Implementation: each mechanic has a `phase_modifier(phase_num)` that scales its effect. E.g., summoner spawns 1 add in phase 1, 2 in phase 2, 3 in phase 3. Windup goes from every 3rd round to every 2nd.
- Phase transition broadcasts from DCRG: "🐉 The {boss} enters its second phase!"
**Win condition:** Boss HP reaches 0. All contributors rewarded. Killing blow gets bonus. Broadcast: "🏆 The {boss} has been slain! Victory belongs to the Darkcragg!"
### 4. Retrieve and Escape — Runtime Logic
`src/systems/endgame_rne.py` (new file)
**Setup:**
- Guardian monster placed on floor 4 during epoch generation (add to bossgen if not already there — a strong but non-boss monster guarding the objective).
- `escape_run` table tracks run state.
**Claiming the objective:**
- Player defeats the guardian on floor 4 → objective claimed. `escape_run.active = 1`, carrier set, pursuer spawns.
- Broadcast from DCRG: "👑 {name} claimed the {objective}! The Pursuer stirs."
- Monster spawn rates double on all floors (multiply spawn chance by `ESCAPE_SPAWN_RATE_MULTIPLIER`).
**Pursuer:**
- Tracks carrier. Advances 1 room toward carrier every 2 carrier actions (`PURSUER_ADVANCE_RATE`).
- Spawns 3 rooms behind carrier (`PURSUER_SPAWN_DISTANCE`).
- Track pursuer position in `escape_run.pursuer_room_id`. Track fractional ticks in `pursuer_ticks`.
- On every carrier action: increment pursuer_ticks. When pursuer_ticks >= PURSUER_ADVANCE_RATE, advance pursuer 1 room toward carrier (pathfind shortest route), reset ticks.
- When pursuer enters carrier's room: forced combat. Pursuer is invulnerable (takes no damage). Hits hard. Carrier can only flee. Flee uses normal SPD check. Success = carrier moves 1 room. Failure = take damage + try again next action.
**Carrier death and relay:**
- Carrier dies → objective drops at death room. Broadcast from DCRG: "💀 The carrier has fallen on Floor {n}. The {objective} lies unguarded."
- `escape_run.objective_dropped = 1`, `dropped_room_id` set.
- Any player can `pickup` the objective in that room.
- On pickup: pursuer resets to 5 rooms behind new carrier (`PURSUER_RELAY_RESET_DISTANCE`). Broadcast: "👑 {name} picks up the {objective}! The Pursuer resets."
- Death penalty still applies to the dead carrier (gold loss, respawn in town).
**Three support roles:**
**Blockers:**
- Non-carrier in a room between pursuer and carrier. When pursuer reaches a blocker's room, forced combat with the blocker instead of advancing.
- Blocker can't kill pursuer (invulnerable). Each round blocker survives = 1 round pursuer isn't moving.
- Blocker can flee (normal SPD check). Blocker can die.
- Broadcast: "🛡 {name} is blocking the Pursuer on Floor {n}!" and "💀 {name} fell holding the line. The Pursuer advances."
- Implementation: on pursuer advance, check if any player is in the target room. If yes, pursuer enters combat with them instead of continuing.
**Warders:**
- `ward` command in a cleared dungeon room (1 extra action after clearing = `WARD_ACTION_COST`). Sets `rooms.ward_active = 1`.
- Warded room slows pursuer — takes 2 advance ticks to pass through instead of 1 (`WARD_PURSUER_SLOWDOWN`).
- Ward breaks after one use (reset to 0 when pursuer passes through).
- No broadcast on warding — silent preparation.
**Lures:**
- `lure` command when on same floor as pursuer. Costs 2 actions (`LURE_ACTION_COST`).
- Pursuer diverts toward lure player for 3 ticks (`LURE_DIVERT_TICKS`), then snaps back to carrier tracking. Total delay ~6 ticks (`LURE_TOTAL_DELAY_TICKS`) including backtrack.
- Broadcast: "🎯 {name} lured the Pursuer into {room}! It diverts."
- After divert expires: "👁 The Pursuer has reacquired the carrier."
**Pursuer distance broadcasts (from DCRG):**
- "👁 The Pursuer is {n} rooms behind the carrier." (every 5 carrier actions)
- "👁 The Pursuer is 3 rooms behind. It's closing."
- "⚠ The Pursuer has reached the carrier!"
**Win condition:** Any player delivers objective to town (The Last Ember). Broadcast: "🏆 The {objective} has reached the surface! Victory belongs to the Darkcragg!"
- All participants get epoch win credit (tracked in escape_participants by role).
### 5. Mode Activation in Engine
Update `src/core/engine.py` and `src/core/actions.py`:
- On game start, check epoch.endgame_mode. Load the appropriate endgame system.
- Mode-specific commands only available when that mode is active:
- HtL: checkpoint status command, floor control display
- Raid: raid boss status command (`boss` — show HP, phase, mechanics discovered so far)
- R&E: `pickup`, `ward`, `lure`, `block` commands. Carrier status. Pursuer distance.
- Combat system needs to dispatch to endgame boss combat (floor boss, raid boss, pursuer) when the target is a special entity. Same chip-and-run framework but with mechanic overlays.
- Endgame status integrated into barkeep recap and stats display.
### 6. New Combat Commands
For raid boss mechanics:
- `defend` / `def` — defensive stance. Negates windup strike. Costs 1 dungeon action. Does no damage that round.
- `dodge` / `dge` — evasion. Negates windup strike. Costs 1 dungeon action. Does no damage that round.
For R&E:
- `pickup` — pick up dropped objective in current room. Free action.
- `ward` — ward current room after clearing it. 1 dungeon action.
- `lure` — lure the Pursuer. 2 dungeon actions.
- `block` — (passive) just being in the pursuer's path triggers blocking. No explicit command needed — the system detects it. But add a `block` info command that shows: "Stand in the Pursuer's path to block. It will fight you instead of advancing."
## Rules
- All responses under 150 chars. Test this.
- All broadcasts route through DCRG node, not EMBR.
- Endgame mode commands are only available when that mode is active. Other mode commands return: "That doesn't apply this epoch."
- Boss combat uses the same chip-and-run framework as bounties — shared HP pool, damage persists, flee to disengage.
- Floor boss and raid boss regen is lazy-evaluated (calculate accumulated regen on engagement).
- Use constants from `config.py`.
- Raw parameterized SQL, no ORM.
- Commit after each mode is working (3 major commits minimum).
## Testing
Add to `tests/`:
- `tests/test_vote.py` — vote casting, changing, public broadcast, tally, tiebreak, zero-vote fallback
- `tests/test_htl.py` — room clearing, regen ticks, checkpoint cluster detection, checkpoint establishment, floor boss spawn on cluster clear, floor unlock, Warden kill = win, rooms behind checkpoint immune to regen
- `tests/test_boss_mechanics.py` — test each of the 12 mechanic implementations: armored, enraged, regenerator, stalwart, warded, phasing, draining, splitting, rotating_resistance, retaliator, summoner, cursed. Test phase scaling for raid boss.
- `tests/test_raid.py` — HP scaling from active players, cap at 6000, regen, phase transitions at 66%/33%, contribution tracking, lockout mechanic, completion + rewards
- `tests/test_rne.py` — objective claim, pursuer advancement (2:1 ratio), pursuer in carrier room triggers combat, carrier death drops objective, relay pickup resets pursuer, ward slows pursuer, lure diverts pursuer, blocker intercepts pursuer, win condition on town delivery
- `tests/test_rne_broadcasts.py` — all R&E broadcasts fire correctly (claim, distance, blocker, lure, death, relay, victory)
Use in-memory SQLite for tests. All endgame tests should generate a proper epoch first (use epoch_generate with DummyBackend).
## Done When
All three endgame modes are playable end-to-end. A Hold the Line epoch can be won by clearing all floors and killing the Warden. A Raid Boss epoch can be won by depleting the boss HP pool through coordinated chip-and-run combat with mechanic discovery. A Retrieve and Escape epoch can be won through a relay of carriers with blockers, warders, and lures supporting. The epoch vote selects the next mode. All broadcasts route through DCRG. All responses under 150 chars, all tests passing. Commit and report.

View file

@ -1,120 +0,0 @@
# Task: MMUD Phase 6 — The Breach
## Before Writing Any Code
Re-read these sections of `/home/zvx/projects/mmud/docs/planned.md`:
- The Breach — Mid-Epoch Event (Day 15) (all of it — 4 mini-events, endgame interaction, design rationale)
- Breach secrets (the 3 breach-type secrets)
Also re-read `config.py` for: BREACH_ROOMS_MIN/MAX, BREACH_CONNECTS_FLOORS, BREACH_SECRETS, BREACH_MINI_EVENTS, EMERGENCE_HP, INCURSION_REGEN/HOLD_HOURS.
Phase 4 already generates the Breach zone (breachgen.py) and Phase 4's daytick.py already handles the day 15 trigger and days 12-13 foreshadowing. Phase 4's breach.py has basic state management. This phase wires the full runtime logic for all 4 mini-events.
## Phase 6 Deliverables
The Breach opens on day 15 with a random mini-event. Each of the 4 types plays differently. The Breach interacts with whichever endgame mode is active.
### 1. Breach Activation (verify/extend existing)
The day 15 trigger should already be in daytick.py. Verify it:
- Day 12-13: barkeep foreshadowing broadcasts from DCRG: "The walls grow thin between the second and third depths. Something stirs."
- Day 15: Breach opens. Set `breach.active = 1`. Open the room exits connecting Breach zone to floors 2 and 3. Broadcast from DCRG: "⚡ The ground splits. A new passage has opened between Floors 2 and 3. Strange light pours from within."
- Players can now enter Breach rooms via the new exits from floors 2 and 3.
- The permanent shortcut between floors 2 and 3 persists for the rest of the epoch.
### 2. Mini-Event: The Heist (mini Retrieve & Escape)
`src/systems/breach_heist.py` (new file)
- Artifact in the deepest Breach room, guarded by the Breach mini-boss.
- Kill mini-boss → claim artifact. Carrier must bring it back to town.
- Pursuer spawns (slower, Breach-only — only operates within the 5-8 Breach rooms + the floors 2-3 connection).
- If carrier dies, artifact drops. Any player can pick up.
- Relay mechanics same as R&E but compressed — 5-8 rooms, not 4 floors.
- 3 Breach secrets scattered along the escape route. Found under pressure.
- Completion: artifact delivered to town. Breach rewards distributed. Broadcast from DCRG: "🏆 The artifact has been extracted from the Breach!"
Reuse as much R&E logic from Phase 5 as possible — shared carrier/pursuer/relay patterns.
### 3. Mini-Event: The Emergence (mini Raid Boss)
`src/systems/breach_emergence.py` (new file)
- Creature with shared HP pool (500-800 HP, `EMERGENCE_HP_MIN/MAX`) sits in central Breach room.
- Surrounding rooms spawn minions on a timer (respawn every 8 hours).
- Same chip-and-run combat as bounties/raid boss. Regen at 3%/8h.
- 3 Breach secrets are in the minion rooms — discovered while contributing to the kill.
- Completion: creature HP reaches 0. Broadcast: "🏆 The Breach creature has been destroyed!"
Reuse raid boss combat framework from Phase 5.
### 4. Mini-Event: The Incursion (mini Hold the Line)
`src/systems/breach_incursion.py` (new file)
- Breach rooms start fully hostile. Regen at 2 rooms/day (`INCURSION_REGEN_ROOMS_PER_DAY`) within just 5-8 rooms.
- Players must clear ALL Breach rooms and hold them all for 48 hours (`INCURSION_HOLD_HOURS`).
- If any room reverts during the hold timer, the clock resets.
- 3 Breach secrets behind the hardest rooms, found as part of the push.
- Track hold start time in `breach.incursion_hold_started_at`. On each regen tick, check if any Breach room reverted — if so, reset the timer.
- Completion: 48 hours with all rooms held. Broadcast: "🏆 The Breach has been secured! The incursion is contained."
Reuse HtL room clearing/regen logic from Phase 5.
### 5. Mini-Event: The Resonance (puzzle dungeon)
`src/systems/breach_resonance.py` (new file)
- No combat focus. Breach rooms contain environmental puzzles.
- 3 Breach secrets ARE the puzzle rewards. Finding all 3 unlocks a bonus cache in the deepest room.
- Puzzles are generated in Phase 4 (breachgen already places Breach secrets). This phase adds the interaction logic:
- `examine` objects in Breach rooms triggers puzzle checks
- Puzzle state tracked per-player in secret_progress
- Sequence puzzles, item-interaction puzzles, cross-room clue puzzles (use the same multi-room puzzle archetypes from the main dungeon)
- Completion: all 3 Breach secrets found by any player(s). Bonus cache unlocked. Broadcast: "🏆 The Resonance has been understood. The Breach yields its secrets."
Soloable by nature — knowledge not stats.
### 6. Breach Interaction with Endgame Modes
Regardless of which mini-event is running, the Breach benefits the active endgame mode:
- **Retrieve & Escape:** The Breach shortcut (floors 2↔3) becomes an alternate escape route. Carrier can path through it. Shorter but Breach content (mini-boss, minions, etc.) may still be there.
- **Raid Boss:** Breach completion (any mini-event) drops a buff item granting +20% damage vs the raid boss for the rest of the epoch. Add to player inventory on Breach completion.
- **Hold the Line:** Breach rooms count as bonus territory toward checkpoint progress on both floors 2 and 3. Cleared Breach rooms contribute to the cleared room count for both floor 2 and floor 3 checkpoints.
### 7. Breach Secret Integration
Verify that the 3 Breach secrets work with the existing discovery system:
- `secrets` command includes Breach secrets in the count after day 15
- Secret milestones (5/10/15/20) fire correctly with Breach secrets included
- Barkeep hints for Breach secrets only available after day 15
- Breach secrets contribute to the completionist reward (all 20 found)
## Rules
- All responses under 150 chars.
- All Breach broadcasts route through DCRG.
- Breach mini-event is always random (selected at epoch gen, never voted).
- Reuse combat/territory frameworks from Phase 5 — don't duplicate code.
- Breach content is inaccessible before day 15. Exits to Breach rooms don't exist until activation.
- Use constants from `config.py`.
- Commit after each mini-event works.
## Testing
Add to `tests/`:
- `tests/test_breach_activation.py` — day 15 trigger, foreshadowing on days 12-13, exits open, Breach accessible, inaccessible before day 15
- `tests/test_breach_heist.py` — mini-boss, artifact claim, mini-pursuer, relay, completion, secrets under pressure
- `tests/test_breach_emergence.py` — shared HP pool, minion respawn, chip-and-run, completion, secrets in minion rooms
- `tests/test_breach_incursion.py` — room clearing, regen within Breach, 48h hold timer, timer reset on revert, completion
- `tests/test_breach_resonance.py` — puzzle interaction, secret discovery, bonus cache unlock, no combat required
- `tests/test_breach_endgame.py` — R&E shortcut, raid boss damage buff, HtL bonus territory
Use in-memory SQLite for tests. Generate full epoch with DummyBackend for each test.
## Done When
The Breach opens on day 15 with one of four randomly selected mini-events. Each mini-event is playable end-to-end with its own win condition. Breach secrets integrate cleanly with the discovery system. The Breach interacts with whichever endgame mode is active. All broadcasts route through DCRG. All responses under 150 chars, all tests passing. Commit and report.
This is the final gameplay phase. After this, the full 30-day epoch loop is complete: epoch generates → players explore and progress → Breach opens day 15 → endgame mode pushes through days 20-30 → epoch vote → wipe → new epoch.

View file

@ -1,32 +0,0 @@
# MMUD Prompt Bundle
## Status
- Phases 1-4: COMPLETE (265 tests passing)
- Phases 5-6: Prompts ready
- Design doc: NEEDS UPDATES (4 prompts below, run before Phase 5)
## Run Order
### Step 1: Design Doc Updates (run in CC in this exact order)
These update `/home/zvx/projects/mmud/docs/planned.md` in place:
1. `01-update-planned.md` — Adds The Last Ember (bar + 4 NPC bios), NPC live conversations, command discovery
2. `02-npc-nodes.md` — Replaces talk command with sim node architecture (6 nodes), three rule layers, onboarding funnel
3. `03-darkcragg.md` — Names the dungeon "The Darkcragg Depths"
4. `04-dcrg-node.md` — Adds DCRG as one-way broadcast node, separates broadcast stream from EMBR
### Step 2: Build Phases
5. `05-phase5.md` — Endgame modes: Hold the Line (regen, checkpoints, floor bosses, 12 mechanics), Raid Boss (HP scaling, 12 mechanics, 3 phases), Retrieve & Escape (Pursuer, blockers, warders, lures), epoch vote
6. `06-phase6.md` — The Breach: 4 mini-events (Heist, Emergence, Incursion, Resonance), endgame interaction, day 15 trigger. Final gameplay phase.
### If Needed
- `mmud-project.md` — Drop into `/home/zvx/projects/.ref/projects/` if not already there
## What's Complete After Phase 6
The full 30-day epoch loop: generate → explore → Breach day 15 → endgame push days 20-30 → vote → wipe → new epoch. All three endgame modes, all four Breach mini-events, 20 secrets, 40 bounties, floor bosses, raid boss, Pursuer + support roles.
## What Comes After Phase 6
- NPC live conversations (LLM runtime for talk via sim nodes) — needs implementation prompt
- Sim node deployment (meshtasticd with 6 identities) — needs infrastructure work
- Last Ember spectator web dashboard — separate project, parallel track
- Playtesting and number tuning

View file

@ -1,48 +0,0 @@
# MMUD — Mesh Multi-User Dungeon
Text-based multiplayer dungeon crawler for Meshtastic LoRa mesh networks. BBS door games (LORD, TradeWars) adapted for 150-char mesh radio constraints, async play, 30-day wipe cycles.
## Status
**Phase:** Pre-development — design complete, repo scaffolded, implementation not started.
## Repo
`/home/zvx/projects/mmud`
The repo contains a `CLAUDE.md` with full architecture, directory structure, development phases, and implementation guidance. **Read it first before any implementation work.**
## Key Files
- `CLAUDE.md` — Architecture, patterns, dev phases, gotchas
- `docs/planned.md` — Complete game design document (~950 lines). Source of truth for all mechanics. If code contradicts this, code is wrong.
- `config.py` — All game constants with rationale
- `src/db/schema.sql` — Full database schema
## Design Constraints
- 150 characters per Meshtastic LoRa message (hard ceiling)
- Zero runtime LLM calls — all text batch-generated at epoch start
- Async-first — all multiplayer through shared DB state
- 12 dungeon actions/day, 30-day epochs
- Python 3.11+, SQLite, Meshtastic Python API
## Development Phases
1. **Core Loop** — Meshtastic message handling, command parser, player creation, room navigation, basic combat, death, action budget
2. **Economy & Progression** — XP, leveling, gold, shops, gear (weapon/armor/trinket), bank, healer
3. **Social Systems** — Broadcasts (tier 1/2/targeted), barkeep (recap, tokens, hints), bounty board, player messages, mail
4. **Epoch Generation** — World gen, LLM narrative pipeline (batch + validation), secret placement, bounty pool generation
5. **Endgame Modes** — Hold the Line (regen, checkpoints, floor bosses), Raid Boss (HP scaling, mechanic tables, phases), Retrieve & Escape (Pursuer, blockers, warders, lures), epoch vote
6. **The Breach** — Breach zone gen, 4 mini-event types (Heist, Emergence, Incursion, Resonance), day 15 trigger
## No Runbooks Needed
This is a pure software project — no LXC provisioning, no Caddy config, no Authentik integration. Runs as a Python daemon connected to a Meshtastic device via USB/serial or TCP. No infrastructure runbooks apply.
## Notes
- All regen/HP/damage numbers in the design doc are targets, not validated — will need playtesting
- The game runs on a Meshtastic mesh network, not a web server
- SQLite single file DB, no ORM, raw parameterized SQL
- Every outbound message must fit 150 chars — the formatter is the final gate

View file

@ -1,3 +1,17 @@
---
title: Echo6 Environment Reference
type: reference
tags:
- proxmox
aliases: []
related:
- [[ip-allocation]]
- [[headscale-onboard-node]]
- [[caddy]]
- [[ct-runbook]]
- [[proxmox-create-ubuntu-vm]]
updated: 2026-06-18
---
# Echo6 Environment Reference
## Proxmox Cluster (echo6-cluster)
@ -6,7 +20,7 @@ Five nodes running Proxmox VE:
| Node | Local IP | Tailscale | Hardware | RAM | Purpose |
| ------- | ------------- | ----------- | ----------------------------------------------- | -------------- | -------------------------------- |
| data | 192.168.1.240 | 100.64.0.6 | AMD Ryzen 7 PRO 5750GE, 1TB NVMe + 1TB SATA SSD | 32GB DDR4-3200 | Database services |
| data | 192.168.1.240 | 100.64.0.6 | AMD Ryzen 7 PRO 5750GE, 1TB NVMe + 1TB SATA SSD | 32GB DDR4-3200 | Database [[services]] |
| utility | 192.168.1.241 | 100.64.0.5 | AMD Ryzen 7 PRO 5750GE, 512GB NVMe | 32GB DDR4-3200 | Utility services, monitoring |
| cloud | 192.168.1.242 | 100.64.0.4 | Intel i7-12700T, 512GB NVMe | 32GB DDR4-3200 | Cloud storage, personal services |
| media | 192.168.1.243 | 100.64.0.3 | Intel i7-14700T, 2x 512GB NVMe | 32GB DDR5-5600 | Media server, *arr stack |
@ -24,7 +38,7 @@ Five nodes running Proxmox VE:
### Network Notes
- **media NIC:** Original Intel e1000e NIC crashes under sustained NFS load — replaced with USB Realtek RTL8153 GbE adapter on vmbr0
- **Tailscale DNS bootstrap:** All LXC containers with Tailscale have a systemd drop-in (`/etc/systemd/system/tailscaled.service.d/dns-bootstrap.conf`) that ensures fallback DNS exists before tailscaled starts, preventing chicken-and-egg DNS resolution failures on reboot
- **Tailscale [[dns]] bootstrap:** All LXC containers with Tailscale have a systemd drop-in (`/etc/systemd/system/tailscaled.service.d/dns-bootstrap.conf`) that ensures fallback DNS exists before tailscaled starts, preventing chicken-and-egg DNS resolution failures on reboot
### TOC Node Details
@ -38,7 +52,7 @@ Five nodes running Proxmox VE:
| VM | Host | VMID | Local IP | Tailscale | Purpose |
|----|------|------|----------|-----------|---------|
| cortex | toc | 150 | 192.168.1.150 | 100.64.0.14 | GPU compute — Open WebUI, Ollama, Qdrant, TEI, Claude Code |
| recon-vm | data | 1130 | 192.168.1.130 | 100.64.0.24 | RECON knowledge extraction pipeline, Files, Kiwix |
| recon-vm | data | 1130 | 192.168.1.130 | 100.64.0.24 | [[recon]] knowledge extraction pipeline, Files, Kiwix |
| arr | media | 105 | 192.168.1.160 | 100.64.0.18 | ARR media automation stack (Jellyfin, Sonarr, Radarr, etc.) |
### cortex VM Details
@ -83,7 +97,7 @@ Five nodes running Proxmox VE:
| mt-burleybutte | 192.168.1.185 | — | Meshtastic node (meshtasticd, Nebra 2W hat, IdahoMesh VPN) |
| pi-nas | 192.168.1.245 | 100.64.0.21 | Raspberry Pi NAS |
| matt-desktop | 192.168.1.111 | 100.64.0.10 | Personal workstation (Windows, your PC) |
| Contabo Server | 5.189.158.149 | 100.64.0.1 | External VPS: Mail, Authentik, Headscale, Forge, Matrix |
| Contabo Server | 5.189.158.149 | 100.64.0.1 | External VPS: Mail, [[authentik]], Headscale, Forge, Matrix |
| edge2 | 184.174.35.153 | 100.64.0.26 | Contabo Cloud VPS 30 NVMe — Proxmox VE 8.4.19 (LXC-only), 8c/24GB/400GB |
*Last updated: 2026-06-18 — Added edge2 CT 105 (authentik, 10.10.10.23, 100.64.0.36, node 48, migrated 2026-06-18); previously added CT 104 (livesync), CT 103 (forgejo), CT 102 (vaultwarden), pdm CT 100, wordpress CT 101*
@ -93,15 +107,15 @@ Five nodes running Proxmox VE:
| Container | Host | Local IP | Tailscale | Purpose |
|-----------|------|----------|-----------|---------|
| meshmonitor | utility (CT 100) | 192.168.1.100 | 100.64.0.7 | Meshtastic mesh monitoring (zvx-echo6/meshmonitor fork, multi-channel) |
| caddy | utility (CT 101) | 192.168.1.101 | 100.64.0.8 | Home reverse proxy |
| searxng | utility (CT 102) | 192.168.1.102 | 100.64.0.15 | Echo6 Search homepage (SearXNG, echo6.co) |
| [[caddy]] | utility (CT 101) | 192.168.1.101 | 100.64.0.8 | Home reverse proxy |
| [[searxng]] | utility (CT 102) | 192.168.1.102 | 100.64.0.15 | Echo6 Search homepage (SearXNG, echo6.co) |
| immich | cloud (CT 120) | 192.168.1.182 | 100.64.0.2 | Immich photo management |
| nextcloud | cloud (CT 121) | 192.168.1.183 | 100.64.0.11 | Nextcloud AIO |
| meshtastic-hs | utility (CT 106) | 192.168.1.106 | — | IdahoMesh Headscale VPN coordination |
| mesh-bridge | utility (CT 107) | 192.168.1.107 | 100.64.0.22 | Dual-tailscaled bridge (echo6 ↔ idahomesh) |
| meshai | utility (CT 108) | 192.168.1.144 | 100.64.0.32 | MeshAI - LLM-powered Meshtastic assistant |
| archivist | utility (CT 118) | 192.168.1.118 | — | Archivist knowledge pipeline |
| argus | utility (CT 103) | 192.168.1.103 | 100.64.0.25 | ARGUS - OSINT intelligence gathering platform |
| [[archivist]] | utility (CT 118) | 192.168.1.118 | — | Archivist knowledge pipeline |
| [[argus]] | utility (CT 103) | 192.168.1.103 | 100.64.0.25 | ARGUS - OSINT intelligence gathering platform |
| peertube | media (CT 110) | 192.168.1.170 | 100.64.0.23 | PeerTube video streaming |
| pdm | edge2 (CT 100) | 10.10.10.10 | 100.64.0.28 | Proxmox Datacenter Manager |
| wordpress | edge2 (CT 101) | 10.10.10.11 | 100.64.0.31 | WordPress for intermountainmesh.com |

View file

@ -1,3 +1,17 @@
---
title: Echo6 Network IP Allocation
type: reference
tags:
- proxmox
aliases: []
related:
- [[services]]
- [[environment]]
- [[caddy]]
- [[headscale-onboard-node]]
- [[meshtastic-headscale-runbook]]
updated: 2026-06-18
---
# Echo6 Network IP Allocation
## IP Ranges
@ -27,9 +41,9 @@
| IP | Container | Host | Purpose |
|----|-----------|------|---------|
| .100 | meshmonitor (CT 100) | utility | MeshMonitor web UI |
| .101 | caddy (CT 101) | utility | Home reverse proxy |
| .102 | searxng (CT 102) | utility | Echo6 Search (SearXNG) |
| .103 | argus (CT 103) | utility | ARGUS OSINT platform |
| .101 | [[caddy]] (CT 101) | utility | Home reverse proxy |
| .102 | [[searxng]] (CT 102) | utility | Echo6 Search (SearXNG) |
| .103 | [[argus]] (CT 103) | utility | ARGUS OSINT platform |
| .104 | meshing-around (CT 104) | utility | Mesh bot + WebGUI |
| .106 | meshtastic-hs (CT 106) | utility | IdahoMesh Headscale |
| .107 | mesh-bridge (CT 107) | utility | Dual-tailscaled bridge |
@ -40,8 +54,8 @@
| .114 | mmud-mrn (CT 114) | utility | MMUD SIM: MRN (Maren healer) |
| .115 | mmud-trvl (CT 115) | utility | MMUD SIM: TRVL (Torval merchant) |
| .116 | mmud-wspr (CT 116) | utility | MMUD SIM: WSPR (Whisper sage) |
| .118 | archivist (CT 118) | utility | Signal/Matrix archive bot |
| .130 | recon (VM 1130) | data | RECON pipeline (migrated from CT 130) |
| .118 | [[archivist]] (CT 118) | utility | Signal/Matrix archive bot |
| .130 | [[recon]] (VM 1130) | data | RECON pipeline (migrated from CT 130) |
| .144 | meshai (CT 108) | utility | MeshAI assistant |
| .170 | peertube (CT 110) | media | PeerTube streaming |
| .182 | immich (CT 120) | cloud | Immich photos |
@ -58,7 +72,7 @@ edge2 (Contabo Cloud VPS 184.174.35.153 / Tailscale 100.64.0.26) uses a separate
| 10.10.10.20 | vaultwarden | CT 102 | 100.64.0.33 | Vaultwarden password manager (migrated from Contabo 2026-06-16) |
| 10.10.10.21 | forgejo | CT 103 | 100.64.0.34 | Forgejo git server (migrated from Contabo 2026-06-16) |
| 10.10.10.22 | livesync | CT 104 | 100.64.0.35 | LiveSync Obsidian sync (CouchDB + provisioner; migrated from Contabo 2026-06-16) |
| 10.10.10.23 | authentik | CT 105 | 100.64.0.36 | Authentik SSO platform (migrated from Contabo 2026-06-18) |
| 10.10.10.23 | [[authentik]] | CT 105 | 100.64.0.36 | Authentik SSO platform (migrated from Contabo 2026-06-18) |
### VMs (.150-.199)
| IP | VM | Host | Purpose |
@ -80,7 +94,7 @@ edge2 (Contabo Cloud VPS 184.174.35.153 / Tailscale 100.64.0.26) uses a separate
| IP | Hostname | Role | Tailscale |
|----|----------|------|-----------|
| .240 | data | Proxmox - DB node | 100.64.0.20 |
| .241 | utility | Proxmox - Utility services | 100.64.0.19 |
| .241 | utility | Proxmox - Utility [[services]] | 100.64.0.19 |
| .242 | cloud | Proxmox - Cloud storage | 100.64.0.22 |
| .243 | media | Proxmox - Media server | 100.64.0.21 |
| .244 | echo6-toc | LLM/AI server | 100.64.0.5 |

View file

@ -1,3 +1,17 @@
---
title: Signal Archive Bot — Deployment Reference
type: reference
tags:
- matrix
aliases: []
related:
- [[caddy]]
- [[mautrix_signal]]
- [[matrix-synapse-deployment]]
- [[services]]
- [[recon-operations]]
updated: 2026-06-18
---
# Signal Archive Bot — Deployment Reference
# Created: 2026-04-12 (Phase 3)
# Status: Phase 5 COMPLETE — bot deployed, transcripts writing, sync token dedup verified
@ -31,8 +45,8 @@
### Final approach: host-side NFS + Proxmox bind mount
- **Utility host fstab:** `192.168.1.245:/export/library /mnt/library nfs defaults,soft,timeo=150 0 0`
- **CT 118 mp0:** `/mnt/library,mp=/mnt/library,ro=0`
- **Pattern source:** recon-vm (VM 1130, RECON) on data node uses identical approach
- Utility host did NOT have /mnt/library mounted before this deployment
- **Pattern source:** recon-vm (VM 1130, [[recon]]) on data node uses identical approach
- Utility host did NOT have /mnt/library mounted before this [[deployment]]
### Write access
- `/mnt/library/` permissions: 2777 (drwxrwsrwx), owner nobody:nogroup
@ -152,7 +166,7 @@ pct exec 118 -- bash
- Shared-secret registration (`/_synapse/admin/v1/register`) returns 404 under MAS — endpoint disabled
- Must use `mas-cli manage register-user` or `manage set-password` for existing users
- MAS creates user in both MAS DB and Synapse DB
- MAS creates user in both MAS DB and [[synapse]] DB
- Orphaned Synapse `profiles` row caused provisioning failure — fixed by DELETE
- Each `client.login()` creates a NEW MAS compat session with random device ID — use `restore_login()` with stable compat token instead
- matrix-nio v0.25.2 does NOT implement `bootstrap_cross_signing()` — manual implementation required via python-olm PkSigning + raw HTTP API

View file

@ -1,3 +1,17 @@
---
title: Matrix Host Reference — Contabo VPS
type: reference
tags:
- matrix
aliases: []
related:
- [[matrix-synapse-deployment]]
- [[synapse]]
- [[expose-service-contabo]]
- [[ct-runbook]]
- [[lxc-service-migration]]
updated: 2026-06-18
---
# Matrix Host Reference — Contabo VPS
# Generated: 2026-04-09 (Phase 0)

View file

@ -1,3 +1,17 @@
---
title: mautrix-signal Bridge Reference
type: reference
tags:
- matrix
aliases: []
related:
- [[synapse]]
- [[matrix-synapse-deployment]]
- [[synapse_retention_discovery]]
- [[archivist]]
- [[advbbs-project]]
updated: 2026-06-18
---
# mautrix-signal Bridge Reference
# Deployed: 2026-04-09 (Phase 3)
# Updated: 2026-04-09 (Phase 5)
@ -6,7 +20,7 @@
- **Image:** dock.mau.dev/mautrix/signal:v0.2603.0
- **Container:** mautrix-signal
- **Compose:** /opt/matrix/docker-compose.yml (same stack as Synapse)
- **Compose:** /opt/matrix/docker-compose.yml (same stack as [[synapse]])
- **Config dir:** /opt/matrix/mautrix-signal/
- **Network:** matrix-net (internal only, no host port mapping)
- **Appservice port:** 29328 (container-internal)

View file

@ -1,3 +1,17 @@
---
title: Synapse Deployment Reference
type: reference
tags:
- matrix
aliases: []
related:
- [[matrix-synapse-deployment]]
- [[mautrix_signal]]
- [[matrix_host]]
- [[synapse_retention_discovery]]
- [[caddy]]
updated: 2026-06-18
---
# Synapse Deployment Reference
# Generated: 2026-04-09 (Phase 1)
@ -31,7 +45,7 @@
- Version: v1.12.0
- Listen: 8080 (web), 8081 (internal/health)
- Database: postgresql://mas:***@matrix-postgres:5432/mas
- Upstream OAuth2: Authentik (auth.echo6.co) via OIDC
- Upstream OAuth2: [[authentik]] (auth.echo6.co) via OIDC
- Client ID: 93kCoZkBlnJyD9EcAm7E4btKflecOcBm9DGONB5T
- Issuer: https://auth.echo6.co/application/o/matrix/
- Matrix integration: kind=synapse, homeserver=echo6.co, endpoint=http://matrix-synapse:8008/
@ -55,7 +69,7 @@
- element.echo6.co → 127.0.0.1:8088
## Federation
- Well-known served from echo6.co (utility Caddy, NOT matrix.echo6.co)
- Well-known served from echo6.co (utility [[caddy]], NOT matrix.echo6.co)
- /.well-known/matrix/server: {"m.server": "matrix.echo6.co:443"}
- /.well-known/matrix/client: base_url=https://matrix.echo6.co, issuer=https://matrix.echo6.co/
- Federation tester: AllChecksOK=true, TLS 1.3, valid Ed25519 key, valid certificates

View file

@ -1,3 +1,17 @@
---
title: Synapse Retention Discovery
type: reference
tags:
- matrix
aliases: []
related:
- [[mautrix_signal]]
- [[synapse]]
- [[matrix-synapse-deployment]]
- [[SESSION-HANDOFF-meshai-test]]
- [[caddy]]
updated: 2026-06-18
---
# Synapse Retention Discovery
# Generated: 2026-04-09 (Phase 6.0, Question 1)
@ -25,7 +39,7 @@ Two settings added to homeserver.yaml:
- Backup: `/opt/matrix/synapse/homeserver.yaml.bak-20260412`
- Applied: 2026-04-12 03:06 UTC
- Synapse restarted, health verified, bridge reconnected, Element login confirmed
- [[synapse]] restarted, health verified, bridge reconnected, Element login confirmed
- Rollback: restore backup and `docker compose restart synapse`
### Previous state (before 2026-04-12)

View file

@ -1,3 +1,17 @@
---
title: "Navi: Claude Code Rules"
type: reference
tags:
- recon
aliases: []
related:
- [[deployment]]
- [[CLAUDE-baseline]]
- [[environment]]
- [[themes]]
- [[caddy]]
updated: 2026-06-18
---
# Navi: Claude Code Rules
## Repository & SSH
@ -95,6 +109,6 @@ After every deploy:
- [ ] Route between two addresses — polyline renders
- [ ] Click city label — boundary outline appears
- [ ] Theme switching (all 4 themes)
- [ ] Theme switching (all 4 [[themes]])
- [ ] Overlay toggles (hillshade, contours, public lands)
- [ ] Console: no "bt is not defined" or "f is not defined"

View file

@ -1,3 +1,17 @@
---
title: Navi Deployment
type: reference
tags:
- recon
aliases: []
related:
- [[cc-rules]]
- [[environment]]
- [[recon-operations]]
- [[ct-runbook]]
- [[themes]]
updated: 2026-06-18
---
# Navi Deployment
## Source Repository
@ -25,7 +39,7 @@ npm run build && rsync -av --delete dist/ /mnt/nav/frontend/
- **Nginx** on VM 1130 serves `/mnt/nav/frontend/` on port 8440
- `index.html`: no-cache (always fresh)
- Hashed assets (`*.js`, `*.css`): cache forever
- **Caddy** on CT 101 routes `navi.echo6.co` → VM 1130:8440
- **[[caddy]]** on CT 101 routes `navi.echo6.co` → VM 1130:8440
## Pre-Deploy Checklist

View file

@ -1,3 +1,17 @@
---
title: Navi Theme System
type: reference
tags:
- auth
aliases: []
related:
- [[cc-rules]]
- [[deployment]]
- [[searxng]]
- [[echo6-landing-page-data-export]]
- [[pipeline-patterns]]
updated: 2026-06-18
---
# Navi Theme System
## Architecture

View file

@ -1,3 +1,17 @@
---
title: OpenTAKServer Setup Documentation
type: reference
tags:
- proxmox
aliases: []
related:
- [[ct-runbook]]
- [[caddy]]
- [[ip-allocation]]
- [[environment]]
- [[synapse]]
updated: 2026-06-18
---
# OpenTAKServer Setup Documentation
## Installation Date
@ -53,7 +67,7 @@ pct enter 109
### DNS
- **Domain:** ots.k7zvx.com
- **DNS Provider:** GoDaddy
- **[[dns]] Provider:** GoDaddy
- **Record Type:** A
- **Points to:** 199.6.36.163 (home external IP)
- **TTL:** 3600 seconds
@ -61,7 +75,7 @@ pct enter 109
### SSL Certificate
- **Provider:** Let's Encrypt
- **Method:** acme.sh with GoDaddy DNS validation
- **Location:** /etc/caddy/certs/ots.k7zvx.com.* (on CT 101)
- **Location:** /etc/[[caddy]]/certs/ots.k7zvx.com.* (on CT 101)
- **Auto-renewal:** Configured via acme.sh
### Reverse Proxy (Caddy on CT 101)
@ -89,7 +103,7 @@ ots.k7zvx.com {
- **Installation Method:** Official installer script
- **Install User:** zvx
- **Install Path:** /home/zvx/ots/
- **Virtual Environment:** /home/zvx/.opentakserver_venv/
- **Virtual [[environment]]:** /home/zvx/.opentakserver_venv/
- **Version:** 1.7.10
### Services
@ -140,7 +154,7 @@ tail -f /home/zvx/ots/logs/cot_parser.log
OpenTAKServer's native Meshtastic integration allows remote gateway radios to connect via MQTT over TLS. This enables:
- Meshtastic mesh positions to appear as TAK contacts on the map
- Text messages from mesh to flow into TAK chat
- Remote gateway deployment over Starlink, cell hotspots, or any internet connection
- Remote gateway [[deployment]] over Starlink, cell hotspots, or any internet connection
**Setup Date:** April 17, 2026
**Status:** ✅ Fully Operational
@ -558,7 +572,7 @@ tar -czf /tmp/ots_certs_$(date +%Y%m%d).tar.gz /etc/caddy/certs/ots.k7zvx.com.*
Previous attempts to install OpenTAKServer via Docker encountered persistent authentication issues. The native installation method worked successfully.
### ISP-Specific Issue
The Ubuntu mirror issue is specific to Filer Telephone Company's network peering. This may affect other services in the future. Consider:
The Ubuntu mirror issue is specific to Filer Telephone Company's network peering. This may affect other [[services]] in the future. Consider:
- Using mirrors.kernel.org for all Ubuntu-based containers
- Setting up a local apt-cacher-ng if this becomes widespread
- Monitoring ISP routing changes

View file

@ -1,3 +1,17 @@
---
title: Current Services Inventory
type: reference
tags:
- media
aliases: []
related:
- [[ip-allocation]]
- [[caddy]]
- [[glossary]]
- [[meshtastic-headscale-runbook]]
- [[lxc-service-migration]]
updated: 2026-06-18
---
# Current Services Inventory
## Active Services
@ -5,16 +19,16 @@
| Service | Location | IP:Port | Access | Notes |
|---------|----------|---------|--------|-------|
| MeshMonitor | utility (CT 100) | 192.168.1.100:8080 | https://mesh.echo6.co | Meshtastic mesh monitoring (zvx-echo6/meshmonitor fork, multi-channel AutoAnnounce/AutoResponder) |
| Utility Caddy | utility (CT 101) | 192.168.1.101 / 100.64.0.8 | 199.6.36.163 (ports 80/443) | Reverse proxy for home services |
| Echo6 Search (SearXNG) | utility (CT 102) | 192.168.1.102:8080 | https://echo6.co | Branded search homepage (Docker, custom theme) |
| Utility [[caddy]] | utility (CT 101) | 192.168.1.101 / 100.64.0.8 | 199.6.36.163 (ports 80/443) | Reverse proxy for home services |
| Echo6 Search ([[searxng]]) | utility (CT 102) | 192.168.1.102:8080 | https://echo6.co | Branded search homepage (Docker, custom theme) |
| meshtasticd (AIDA-N2) | aida-nebra | 192.168.1.253:4403 | Internal | AIDA-N2(RPT,LLM) node !27780c47, Nebra 2W hat (ZebraHat), CLIENT_BASE role, fw 2.7.19. MeshAI (CT 108) connects via TCP localhost:4403 |
| Meshtastic CLI | mt-isr | 192.168.1.141 | Internal | Station G2 WiFi bridge + TCP management |
| meshtasticd | mt-burleybutte | 192.168.1.185:4403 | Internal | Software Meshtastic node (Nebra 2W hat) |
| IdahoMesh Headscale | utility (CT 106) | 192.168.1.106:8080 | https://vpn.idahomesh.com | Meshtastic mesh VPN coordination |
| mesh-bridge | utility (CT 107) | 192.168.1.107 | Internal | Dual-tailscaled bridge (echo6 ↔ idahomesh) |
| MeshAI | utility (CT 108) | 192.168.1.144:4403 | Internal | LLM-powered Meshtastic assistant (Docker, Gemini Flash, Google grounding) |
| ARGUS | utility (CT 103) | 192.168.1.103 | Internal | OSINT intelligence gathering platform (Docker, SearXNG + local LLM analysis) |
| Authentik | edge2 (CT 105) | 100.64.0.36:9000 | https://auth.echo6.co | SSO provider (Echo6 branded, custom CSS, dark theme) — fronted by Contabo Caddy (reverse_proxy 100.64.0.36:9000); **migrated from Contabo 2026-06-18** |
| [[argus]] | utility (CT 103) | 192.168.1.103 | Internal | OSINT intelligence gathering platform (Docker, SearXNG + local LLM analysis) |
| [[authentik]] | edge2 (CT 105) | 100.64.0.36:9000 | https://auth.echo6.co | SSO provider (Echo6 branded, custom CSS, dark theme) — fronted by Contabo Caddy (reverse_proxy 100.64.0.36:9000); **migrated from Contabo 2026-06-18** |
| Forge (Forgejo) | edge2 (CT 103) | 100.64.0.34:3001 HTTP / :2222 SSH (via Contabo DNAT) | https://forge.echo6.co | Git server — fronted by Contabo Caddy (reverse_proxy 100.64.0.34:3001); git SSH via iptables DNAT on Contabo (forgejo-ssh-dnat.service) — **migrated from Contabo 2026-06-16** |
| Headscale | Contabo | 5.189.158.149 | https://vpn.echo6.co | Tailscale coordination (OIDC enabled) |
| Headplane | Contabo | 127.0.0.1:3100 | https://vpn.echo6.co/admin | Headscale web UI (OIDC via Authentik) |
@ -30,18 +44,18 @@
| Sonarr | media (VM 105) | 192.168.1.160:8989 | Internal | TV automation (Docker) |
| Radarr | media (VM 105) | 192.168.1.160:7878 | Internal | Movie automation (Docker) |
| Prowlarr | media (VM 105) | 192.168.1.160:9696 | Internal | Indexer manager (Docker) |
| SABnzbd | media (VM 105) | 192.168.1.160:8080 | Internal | Usenet download client (Docker) |
| SABnzbd | media (VM 105) | 192.168.1.160:8080 | Internal | [[usenet]] download client (Docker) |
| PeerTube | media (CT 110) | 192.168.1.170:9000 | https://stream.echo6.co | Video streaming (native, NFS on pi-nas, SSO) |
| WATCHTOWER | **Decommissioned (2026-06-16)** | — | ~~wt.echo6.co~~ | Was Docker on Contabo `/opt/watchtower`; stopped & archived to forge.echo6.co/matt/archive-watchtower |
| Open WebUI | cortex (VM 150) | 192.168.1.150:8080 | https://ai.echo6.co | AI chat interface (Docker, Ollama backend, SSO) |
| Qdrant | cortex (VM 150) | 192.168.1.150:6333 | Internal | Vector database (Docker, RECON knowledge store) |
| Qdrant | cortex (VM 150) | 192.168.1.150:6333 | Internal | Vector database (Docker, [[recon]] knowledge store) |
| TEI | cortex (VM 150) | 192.168.1.150:8090 | Internal | Text embeddings (Docker, bge-m3 1024-dim) |
| RECON | data (VM 1130) | 192.168.1.130:8420 | https://recon.echo6.co | Knowledge extraction pipeline (systemd, dashboard+API) |
| Files | data (VM 1130) | 192.168.1.130:8888 | https://files.echo6.co | PDF library (nginx, Authentik forward auth) |
| Samba | data | 192.168.1.240:445 | Internal | SMB file sharing — `//data/library` → /mnt/data/library (guest access) |
| Matrix Synapse | Contabo | 127.0.0.1:8008 | https://matrix.echo6.co | Matrix homeserver (Docker, SSO) |
| Matrix [[synapse]] | Contabo | 127.0.0.1:8008 | https://matrix.echo6.co | Matrix homeserver (Docker, SSO) |
| Element Web | Contabo | 127.0.0.1:8088 | https://element.echo6.co | Matrix web client (Docker) |
| mautrix-signal | Contabo | internal (29328) | DM @signalbot:echo6.co | Signal bridge (Docker, E2BE, MSC4190, double puppeting) |
| [[mautrix_signal]] | Contabo | internal (29328) | DM @signalbot:echo6.co | Signal bridge (Docker, E2BE, MSC4190, double puppeting) |
| LiveSync | edge2 (CT 104) | 100.64.0.35:5984 (CouchDB) / :5985 (provisioner) | https://notes.echo6.co | Obsidian sync (CouchDB + provisioner, Docker, JWT auth) — fronted by Contabo Caddy (reverse_proxy 100.64.0.35:5984 / :5985); **migrated from Contabo 2026-06-16** |
| TAK Server | **Decommissioned (2026-06-16)** | — | ~~tak.echo6.co~~ | Was Docker on Contabo `/opt/tak-server-deploy`; stopped & archived to forge.echo6.co/matt/archive-tak-server |
| SIGIL | **Decommissioned (2026-06-16)** | — | ~~tak.echo6.co/sigil~~ | Was Docker on Contabo `/opt/sigil`; stopped & archived to forge.echo6.co/matt/archive-tak-server |
@ -51,7 +65,7 @@
| mautrix-signal | Contabo | 29328 (internal) | Internal (matrix-net) | Signal bridge — @signalbot:echo6.co, E2BE, MSC4190, auto-portals |
| Matrix MAS | Contabo | 127.0.0.1:8085 | Internal (via Caddy) | Matrix Authentication Service (Docker, handles login/logout/OIDC for Synapse) |
| Termix | Contabo | 0.0.0.0:8083 | Internal (no Caddy block) | Terminal sharing tool (Docker, ghcr.io/lukegus/termix:latest) |
| Archivist | utility (CT 118) | 192.168.1.118 | Internal | Signal/Matrix room archive bot (systemd) — see archivist.md for details |
| [[archivist]] | utility (CT 118) | 192.168.1.118 | Internal | Signal/Matrix room archive bot (systemd) — see archivist.md for details |
| pt-transcoder | cortex (VM 150) | N/A | Internal | PeerTube H.265 NVENC transcoder (systemd, /opt/bulk-import/transcoder.py) |
| recon-sparse | cortex (VM 150) | 192.168.1.150:8091 | Internal | RECON sparse embedding service (systemd, bge-m3 model, port 8091) |
| Samba | cortex (VM 150) | 192.168.1.150:445 | Internal | SMB file sharing — `//cortex/projects` → /home/zvx/projects (guest access) |
@ -75,7 +89,7 @@
- Ollama (port 11434, internal, Docker with GPU)
- Qdrant (port 6333, internal, Docker — vector DB for RECON)
- TEI (port 8090, internal, Docker — bge-m3 embeddings for RECON)
- PeerTube remote runner (peertube-runner service, Whisper auto-captioning via smart GPU/CPU wrapper, concurrency=2, MemoryMax=20G)
- [[peertube-remote-runner]] (peertube-runner service, Whisper auto-captioning via smart GPU/CPU wrapper, concurrency=2, MemoryMax=20G)
- pt-transcoder (systemd: pt-transcoder.service, PeerTube H.265 NVENC transcoder)
- Script: `/opt/bulk-import/transcoder.py`
- MemoryMax=12G, Restart=always, RestartSec=60
@ -245,7 +259,7 @@
- Station G2 radio connected via WiFi at 192.168.4.241, managed via TCP
- G2 config: Freq51 (ch0, psk=1A==) + MediumFast (ch1), MEDIUM_FAST preset, ch=51, txPower=11
- G2 gold config backup: `isr@192.168.1.141:~/backups/g2-gold-config.yaml`
- DNS bootstrap drop-in for tailscaled (reboot-safe)
- [[dns]] bootstrap drop-in for tailscaled (reboot-safe)
- User: isr, password auth (see credentials)
### mt-burleybutte (192.168.1.185)

View file

@ -1,3 +1,17 @@
---
title: Usenet Configuration
type: reference
tags:
- media
aliases: []
related:
- [[dns]]
- [[recon-operations]]
- [[proxmox-onboard-node]]
- [[caddy]]
- [[glossary]]
updated: 2026-06-18
---
# Usenet Configuration
> **Credentials:** All passwords and API keys stored in `/home/zvx/projects/.ref/credentials`

View file

@ -3,15 +3,13 @@ title: Authentik SSO Configuration
type: reference
tags:
- auth
- mesh
- matrix
aliases: []
related:
- [[authentik-oidc-application]]
- [[caddy]]
- [[mailcow-create-mailbox]]
- [[proxmox-onboard-node]]
- [[services]]
- [[caddy]]
- [[echo6-landing-page-data-export]]
- [[authentik-access-groups]]
updated: 2026-06-18
---
# Authentik SSO Configuration
@ -222,7 +220,7 @@ curl -s "https://auth.echo6.co/api/v3/policies/bindings/?target=APPLICATION_PK"
| Open WebUI (OIDC) | `https://app.echo6.co/oauth/oidc/callback` |
| Forgejo | `https://app.echo6.co/user/oauth2/Authentik/callback` |
| Jellyfin (SSO plugin) | `https://app.echo6.co/sso/OID/redirect/Authentik` |
| Caddy forward auth | `https://app.echo6.co/outpost.goauthentik.io/callback` |
| [[caddy]] forward auth | `https://app.echo6.co/outpost.goauthentik.io/callback` |
## Users
@ -326,7 +324,7 @@ Echo6 cyberpunk branding applied to Authentik 2025.12.4 via System → Brands.
- **Login card:** Dark background, cyan-glow focus on inputs, branded submit button
- **Admin sidebar:** Dark with cyan hover/active states
- **User dashboard:** 3-column grid layout, dark cards with cyan border on hover
- **Application icons:** Custom SVG icons uploaded for all 15 services
- **Application icons:** Custom SVG icons uploaded for all 15 [[services]]
### CSS Storage
@ -356,7 +354,7 @@ All authenticated service links use Authentik's application launch URL:
https://auth.echo6.co/application/launch/<app-slug>/
```
This provides seamless SSO: authenticated users pass through to the app, unauthenticated users get the login page then redirect to the app. Used by the SearXNG waffle menu and nav bar.
This provides seamless SSO: authenticated users pass through to the app, unauthenticated users get the login page then redirect to the app. Used by the [[searxng]] waffle menu and nav bar.
| App Slug | Service | Launch URL |
|----------|---------|-----------|

View file

@ -3,15 +3,13 @@ title: "Caddy & DNS Reference"
type: reference
tags:
- dns
- mesh
- auth
aliases: []
related:
- [[services]]
- [[headscale-onboard-node]]
- [[ip-allocation]]
- [[headscale-onboard-node]]
- [[expose-service-home]]
- [[INDEX]]
- [[lxc-service-migration]]
updated: 2026-06-18
---
# Caddy & DNS Reference
@ -26,20 +24,20 @@ updated: 2026-06-18
| Domain | Backend | Service |
|--------|---------|---------|
| auth.echo6.co | 127.0.0.1:9000 | Authentik SSO |
| auth.echo6.co | 127.0.0.1:9000 | [[authentik]] SSO |
| forge.echo6.co | 127.0.0.1:3001 | Forgejo Git |
| mail.echo6.co | https://127.0.0.1:8453 | Mailcow (tls_insecure_skip_verify, r/w timeout 3600s) |
| vpn.echo6.co | 127.0.0.1:8084 | Headscale |
| vpn.echo6.co/admin* | 127.0.0.1:3100 | Headplane |
| autodiscover.echo6.co | https://127.0.0.1:8443 | Mailcow autodiscover |
| autoconfig.echo6.co | https://127.0.0.1:8443 | Mailcow autoconfig |
| vault.echo6.co | 127.0.0.1:8086 | Vaultwarden |
| vault.echo6.co | 127.0.0.1:8086 | vaultwarden |
| proxmox.echo6.co | https://100.64.0.6:8006 (via Tailscale) | Proxmox VE (data node) |
| wt.echo6.co | 127.0.0.1:8099 (Authentik forward auth) | WATCHTOWER ops dashboard |
| matrix.echo6.co | 127.0.0.1:8008 + 127.0.0.1:8085 | Matrix Synapse + MAS (login/logout/refresh/auth_metadata → MAS:8085, _matrix/* → Synapse:8008, default → MAS:8085) |
| wt.echo6.co | 127.0.0.1:8099 ([[authentik]] forward auth) | WATCHTOWER ops dashboard |
| matrix.echo6.co | 127.0.0.1:8008 + 127.0.0.1:8085 | Matrix [[synapse]] + MAS (login/logout/refresh/auth_metadata → MAS:8085, _matrix/* → [[synapse]]:8008, default → MAS:8085) |
| element.echo6.co | 127.0.0.1:8088 | Element Web client |
| notes.echo6.co | 127.0.0.1:5984 + 127.0.0.1:5985 | LiveSync (CouchDB + provisioner, forward auth on /_provision*, CORS for Obsidian) |
| tak.echo6.co | https://100.64.0.1:8446 + 100.64.0.1:8990 | TAK Server admin (8446, Authentik forward auth) + SIGIL console (/sigil, 8990) |
| tak.echo6.co | https://100.64.0.1:8446 + 100.64.0.1:8990 | TAK Server admin (8446, [[authentik]] forward auth) + SIGIL console (/sigil, 8990) |
### Commands
@ -64,20 +62,20 @@ journalctl -u caddy -f
| Domain | Backend | Pattern | Service |
|--------|---------|---------|---------|
| mesh.echo6.co | 192.168.1.100:8080 | Local IP | MeshMonitor (Authentik forward auth) |
| echo6.co | 100.64.0.15:8080 | Tailscale | Echo6 Search (SearXNG) + Matrix well-known |
| mesh.echo6.co | 192.168.1.100:8080 | Local IP | meshmonitor (Authentik forward auth) |
| echo6.co | 100.64.0.15:8080 | Tailscale | Echo6 Search ([[searxng]]) + Matrix well-known |
| search.echo6.co | — | — | 301 redirect to echo6.co |
| nas.echo6.co | 100.64.0.21:80 | Tailscale | OpenMediaVault (pi-nas) |
| immich.echo6.co | 192.168.1.182:2283 | Local IP | Immich (has 2FA) |
| nextcloud.echo6.co | 192.168.1.183:11000 | Local IP | Nextcloud AIO (SSO via Authentik) |
| immich.echo6.co | 192.168.1.182:2283 | Local IP | immich (has 2FA) |
| nextcloud.echo6.co | 192.168.1.183:11000 | Local IP | nextcloud AIO (SSO via Authentik) |
| jellyfin.echo6.co | 100.64.0.18:8096 | Tailscale | Jellyfin media server (SSO via Authentik) |
| requests.echo6.co | 100.64.0.18:5055 | Tailscale | Jellyseer request management (SSO via Authentik) |
| stream.echo6.co | 192.168.1.170:80 | Local IP | PeerTube video streaming (SSO via Authentik) |
| stream.echo6.co | 192.168.1.170:80 | Local IP | peertube video streaming (SSO via Authentik) |
| ai.echo6.co | 100.64.0.14:8080 | Tailscale | Open WebUI (SSO via Authentik) |
| files.echo6.co | 100.64.0.24:8888 | Tailscale | RECON PDF library (Authentik forward auth) |
| recon.echo6.co | 100.64.0.24:8420 | Tailscale | RECON dashboard + API |
| lidarr.echo6.co | 100.64.0.18:8686 | Tailscale | Lidarr music automation (Authentik forward auth) |
| navidrome.echo6.co | 100.64.0.18:4533 | Tailscale | Navidrome music server (Authentik forward auth, /rest/* exempt for Subsonic API) |
| files.echo6.co | 100.64.0.24:8888 | Tailscale | [[recon]] PDF library (Authentik forward auth) |
| recon.echo6.co | 100.64.0.24:8420 | Tailscale | [[recon]] dashboard + API |
| lidarr.echo6.co | 100.64.0.18:8686 | Tailscale | lidarr music automation (Authentik forward auth) |
| navidrome.echo6.co | 100.64.0.18:4533 | Tailscale | navidrome music server (Authentik forward auth, /rest/* exempt for Subsonic API) |
| vpn.idahomesh.com | 192.168.1.106:8080 | Local IP | IdahoMesh Headscale VPN coordination |
### Commands
@ -105,7 +103,7 @@ ssh root@192.168.1.241 'pct exec 101 -- journalctl -u caddy -f'
| vpn.echo6.co | 100.64.0.1 | Headscale |
| vault.echo6.co | 100.64.0.1 | Vaultwarden |
| proxmox.echo6.co | 100.64.0.1 | Proxmox VE (via Caddy) |
| stream.echo6.co | 100.64.0.8 | PeerTube (via utility Caddy) |
| stream.echo6.co | 100.64.0.8 | PeerTube (via Utility Caddy) |
| notes.echo6.co | 100.64.0.1 | LiveSync CouchDB + provisioner (via Contabo Caddy) |
| tak.echo6.co | 100.64.0.1 | TAK Server + SIGIL (via Contabo Caddy) |
| jellyfin.echo6.co | 100.64.0.8 | Jellyfin (via utility Caddy) |
@ -115,7 +113,7 @@ ssh root@192.168.1.241 'pct exec 101 -- journalctl -u caddy -f'
| matrix.echo6.co | 100.64.0.1 | Matrix Synapse (via Contabo Caddy) |
| element.echo6.co | 100.64.0.1 | Element Web (via Contabo Caddy) |
| echo6.co | 100.64.0.8 | Echo6 Search homepage (via utility Caddy) |
| files.echo6.co | 100.64.0.8 | RECON PDF library (via utility Caddy) |
| files.echo6.co | 100.64.0.8 | [[recon]] PDF library (via utility Caddy) |
| recon.echo6.co | 100.64.0.8 | RECON dashboard (via utility Caddy) |
| lidarr.echo6.co | 100.64.0.8 | Lidarr music automation (via utility Caddy) |
| navidrome.echo6.co | 100.64.0.8 | Navidrome music server (via utility Caddy) |
@ -153,7 +151,7 @@ dig +short forge.echo6.co @100.64.0.1 # Test
| Subdomain | Service |
|-----------|---------|
| @ | Echo6 Search homepage (SearXNG) |
| @ | Echo6 Search homepage ([[searxng]]) |
| ai | Open WebUI |
| stream | PeerTube |
| jellyfin | Jellyfin |
@ -199,7 +197,7 @@ oidc:
client_id: "headscale"
```
**Split DNS:** Configured via dnsmasq on Contabo.
**Split [[dns]]:** Configured via dnsmasq on Contabo.
**Headplane:** Deployed at `vpn.echo6.co/admin` - OIDC via Authentik. First login gets Owner.
---

View file

@ -1,3 +1,17 @@
---
title: GoDaddy DNS Management
type: reference
tags:
- dns
aliases: []
related:
- [[usenet]]
- [[caddy]]
- [[expose-service-contabo]]
- [[headscale-onboard-node]]
- [[authentik-oidc-application]]
updated: 2026-06-18
---
# GoDaddy DNS Management
## Script Location
@ -14,7 +28,7 @@ Stored in `/home/zvx/projects/.ref/credentials` as:
| Purpose | IP |
|---------|-----|
| External (home services) | `199.6.36.163` |
| External (home [[services]]) | `199.6.36.163` |
| Contabo Server | `5.189.158.149` |
## Managed Domains

View file

@ -1,3 +1,17 @@
---
title: Geo Processing Tools — Cortex
type: reference
tags:
- storage
aliases: []
related:
- [[cc-rules]]
- [[ct-runbook]]
- [[environment]]
- [[meshtasticd-sim-nodes-runbook]]
- [[recon-operations]]
updated: 2026-06-18
---
# Geo Processing Tools — Cortex
## Overview

View file

@ -3,15 +3,13 @@ title: RECON — Knowledge Extraction Pipeline
type: reference
tags:
- recon
- ai
- storage
aliases: []
related:
- [[recon-operations]]
- [[INDEX]]
- [[ia-download-queue]]
- [[add-peertube-channel]]
- [[services]]
- [[usenet]]
- [[caddy]]
updated: 2026-06-18
---
# RECON — Knowledge Extraction Pipeline
@ -40,7 +38,7 @@ RECON extracts knowledge from PDFs and web content into a searchable vector data
| Vector DB | Qdrant | cortex:6333 (Docker) |
| Embeddings | TEI (bge-m3, 1024-dim) | cortex:8090 (Docker) |
| Enrichment | Gemini 2.5 Flash Lite | Google API (4 keys) |
| Vision OCR | Gemini 2.5 Flash Lite | Google API (shared keys) |
| Vision Ocr | Gemini 2.5 Flash Lite | Google API (shared keys) |
| Text extraction | PyPDF2, poppler-utils, Tesseract | Local |
| PDF source | NFS | pi-nas:/export/library → /mnt/library |
| File server | nginx | localhost:8888 → files.echo6.co |

View file

@ -1,3 +1,17 @@
---
title: SearXNG — Echo6 Search Homepage
type: reference
tags:
- proxmox
aliases: []
related:
- [[echo6-landing-page-data-export]]
- [[caddy]]
- [[ip-allocation]]
- [[headscale-onboard-node]]
- [[services]]
updated: 2026-06-18
---
# SearXNG — Echo6 Search Homepage
## Overview
@ -19,7 +33,7 @@ SearXNG is deployed as the branded Echo6 search homepage at `echo6.co`. The defa
|-----------|-----------|----------|
| Search engine | SearXNG (Docker, v2026.2.6) | searxng container |
| Cache | Valkey (Redis-compatible) | valkey container |
| Reverse proxy | Utility Caddy (CT 101) | 192.168.1.101 |
| Reverse proxy | Utility [[caddy]] (CT 101) | 192.168.1.101 |
| SSL certs | acme.sh (Let's Encrypt) | /etc/caddy/certs/ on CT 101 |
## Theme Customization
@ -56,7 +70,7 @@ Custom Echo6 theme applied via Docker bind mounts that override SearXNG's simple
- **Results page:** Two-column grid (`1fr 25rem`) — results left, sidebar right
- **Nav bar:** Left: `.//files`, `.//stream` — Right: `.//photos`, `.//mail`, waffle menu, login avatar
- **Waffle menu:** 3x3 grid of service tiles (Aurora, Stream, Files, Watchtower, Photos, Mail, Cloud, Admin, Search) with inline SVG icons
- **All nav links:** Use Authentik SSO launch URLs (`https://auth.echo6.co/application/launch/<slug>/`)
- **All nav links:** Use [[authentik]] SSO launch URLs (`https://auth.echo6.co/application/launch/<slug>/`)
## Configuration
@ -69,7 +83,7 @@ Key settings:
- `ui.center_alignment`: false (enables full-width results layout)
- `server.base_url`: "https://echo6.co/"
**Environment:** Set in docker-compose.yml:
**[[environment]]:** Set in docker-compose.yml:
- `SEARXNG_BASE_URL=https://echo6.co/`
- `INSTANCE_NAME=Echo6`
@ -121,10 +135,10 @@ curl -s http://192.168.1.102:8080 | head -30
**dnsmasq (Contabo):**
- `echo6.co``100.64.0.8` (utility Caddy)
**GoDaddy DNS:**
**GoDaddy [[dns]]:**
- `@` (echo6.co) → `199.6.36.163` (home)
- `search``199.6.36.163` (home)
---
*Last updated: 2026-02-17 — Initial creation after Echo6 homepage deployment*
*Last updated: 2026-02-17 — Initial creation after Echo6 homepage [[deployment]]*

View file

@ -1,7 +1,15 @@
---
title: Glossary & Vocabulary
title: "Glossary & Vocabulary"
type: reference
tags: [meta]
tags:
- proxmox
aliases: []
related:
- [[services]]
- [[ip-allocation]]
- [[caddy]]
- [[authentik]]
- [[usenet]]
updated: 2026-06-18
---
# Glossary & Vocabulary
@ -11,7 +19,7 @@ updated: 2026-06-18
## Topic categories
mesh · matrix · recon · media · auth · dns · vpn · storage · proxmox · ai · mail
mesh · matrix · [[recon]] · media · auth · [[dns]] · vpn · storage · proxmox · ai · mail
## Acronyms
@ -26,8 +34,8 @@ mesh · matrix · recon · media · auth · dns · vpn · storage · proxmox ·
### Hosts / Proxmox nodes
- **argus** — aliases: 100.64.0.25
- **authentik** — aliases: 100.64.0.36
- **[[argus]]** — aliases: 100.64.0.25
- **[[authentik]]** — aliases: 100.64.0.36
- **bluefin** — aliases: 100.64.0.30
- **cloud** (Cloud) — aliases: 192.168.1.242, 100.64.0.4
- **contabo** — aliases: 100.64.0.1
@ -54,14 +62,14 @@ mesh · matrix · recon · media · auth · dns · vpn · storage · proxmox ·
### LXC Containers
- **archivist** — aliases: 192.168.1.118, CT 118 — on: utility
- **caddy** — aliases: 192.168.1.101, CT 101, 100.64.0.8 — on: utility
- **[[archivist]]** — aliases: 192.168.1.118, CT 118 — on: utility
- **[[caddy]]** — aliases: 192.168.1.101, CT 101, 100.64.0.8 — on: utility
- **immich** — aliases: 192.168.1.182, CT 120, 100.64.0.2 — on: cloud
- **livesync** — aliases: 10.10.10.22, CT 104, 100.64.0.35 — on: edge2
- **meshtastic-hs** — aliases: 192.168.1.106, CT 106 — on: utility
- **pdm** — aliases: 10.10.10.10, CT 100, 100.64.0.28 — on: edge2
- **running** — aliases: CT 100 — on: utility _(live)_
- **searxng** — aliases: 192.168.1.102, CT 102, 100.64.0.15 — on: utility
- **[[searxng]]** — aliases: 192.168.1.102, CT 102, 100.64.0.15 — on: utility
- **vaultwarden** — aliases: 10.10.10.20, CT 102, 100.64.0.33 — on: edge2
- **wordpress** — aliases: 10.10.10.11, CT 101, 100.64.0.31 — on: edge2
@ -118,8 +126,8 @@ mesh · matrix · recon · media · auth · dns · vpn · storage · proxmox ·
- **matrix-element** — aliases: matrix-element — on: contabo _(live)_
- **matrix-mas** (Matrix MAS) — aliases: Matrix MAS — on: contabo
- **matrix-postgres** — aliases: matrix-postgres — on: contabo _(live)_
- **matrix-synapse** (Matrix Synapse) — aliases: Matrix Synapse, matrix — on: contabo
- **mautrix-signal** — aliases: mautrix-signal — on: contabo
- **matrix-synapse** (Matrix [[synapse]]) — aliases: Matrix Synapse, matrix — on: contabo
- **[[mautrix_signal]]** — aliases: mautrix-signal — on: contabo
- **meshtastic-cli** (Meshtastic CLI) — aliases: Meshtastic CLI — on: mt-isr
- **meshtasticd** — aliases: meshtasticd — on: mt-burleybutte
- **meshtasticd-aida-n2** (meshtasticd (AIDA-N2)) — aliases: meshtasticd (AIDA-N2) — on: aida-nebra
@ -149,10 +157,10 @@ mesh · matrix · recon · media · auth · dns · vpn · storage · proxmox ·
### Projects
- **advbbs-project** — aliases: advbbs-project
- **[[advbbs-project]]** — aliases: advbbs-project
- **argus** — aliases: argus
- **deploy-livesync** — aliases: deploy-livesync
- **matrix-synapse-deployment** — aliases: matrix-synapse-deployment
- **meshtastic-headscale-runbook** — aliases: meshtastic-headscale-runbook
- **mmud-project** — aliases: mmud-project
- **[[deploy-livesync]]** — aliases: deploy-livesync
- **[[matrix-synapse-deployment]]** — aliases: matrix-synapse-deployment
- **[[meshtastic-headscale-runbook]]** — aliases: meshtastic-headscale-runbook
- **[[mmud-project]]** — aliases: mmud-project

View file

@ -3,15 +3,13 @@ title: Echo6 Landing Page — Data Export
type: note
tags:
- auth
- media
- matrix
aliases: []
related:
- [[searxng]]
- [[authentik]]
- [[ip-allocation]]
- [[caddy]]
- [[INDEX]]
- [[services]]
- [[CLAUDE-baseline]]
updated: 2026-06-18
---
# Echo6 Landing Page — Data Export
@ -23,7 +21,7 @@ updated: 2026-06-18
- **Domain:** echo6.co
- **Admin email:** admin@echo6.co
- **Auth provider:** Authentik (auth.echo6.co) — OIDC SSO across all services
- **Auth provider:** [[authentik]] (auth.echo6.co) — OIDC SSO across all [[services]]
### Brand Colors (extracted from logo)
@ -52,8 +50,8 @@ updated: 2026-06-18
| Service | URL | Description | Auth |
|---------|-----|-------------|------|
| Echo6 Search (Homepage) | https://echo6.co | SearXNG search — branded cyberpunk homepage, Google-style layout | Public (SearXNG) |
| Aurora (AI Assistant) | https://ai.echo6.co | RAG-augmented LLM chat — locally-hosted, queries a 95K+ vector knowledge base | Authentik OIDC |
| Echo6 Search (Homepage) | https://echo6.co | [[searxng]] search — branded cyberpunk homepage, Google-style layout | Public ([[searxng]]) |
| Aurora (AI Assistant) | https://ai.echo6.co | RAG-augmented LLM chat — locally-hosted, queries a 95K+ vector knowledge base | [[authentik]] OIDC |
| PeerTube (Video) | https://stream.echo6.co | Self-hosted video platform — 99 curated YouTube channels mirrored, GPU-transcoded | Authentik OIDC |
| File Server | https://files.echo6.co | PDF/document library — ~13,239 documents (military doctrine, survival, comms, trades) | Public |
| Photos (Immich) | https://immich.echo6.co | Self-hosted photo management | Authentik OIDC |
@ -234,16 +232,16 @@ Authentik 2025.12.4 — custom CSS is applied via the Brand CSS field in System
### Nodes (all connected via Tailscale / self-hosted Headscale)
| Node | Role | Key Services |
| Node | Role | Key [[services]] |
|------|------|-------------|
| data | Proxmox host | Hosts RECON VM (VM 1130) |
| utility | Proxmox host | Caddy reverse proxy (CT 101), TLS termination |
| data | Proxmox host | Hosts [[recon]] VM (VM 1130) |
| utility | Proxmox host | [[caddy]] reverse proxy (CT 101), TLS termination |
| cloud | Proxmox host | — |
| media | Proxmox host | PeerTube LXC (CT 110) |
| toc | Proxmox host | GPU passthrough host for cortex VM |
| cortex | VM on toc | RTX A4000 16GB, Qdrant, Ollama, TEI, OpenWebUI, PeerTube runner |
| pi-nas | NFS storage (OMV) | 18TB — PDF library + PeerTube video storage |
| Contabo VPS | Remote | WATCHTOWER, DNS, automated backups (919GB available) |
| Contabo VPS | Remote | WATCHTOWER, [[dns]], automated backups (919GB available) |
### GPU — NVIDIA RTX A4000 (16GB VRAM, on cortex)
@ -340,7 +338,7 @@ Core content, classification (domain/subdomain/skill level/scenario), provenance
|----------|-------|
| Interface | OpenWebUI at ai.echo6.co |
| Model | JOSIEFIED Qwen3 8B (~55 tok/s, ~5GB VRAM) |
| RAG source | RECON Qdrant (95K+ vectors, top-5 retrieval) |
| RAG source | [[recon]] Qdrant (95K+ vectors, top-5 retrieval) |
| Embedding model | bge-m3 (1024-dim via TEI) |
| Score threshold | 0.3 cosine similarity |
| Citations | Clickable badges linking to files.echo6.co PDFs or source web pages |
@ -419,8 +417,8 @@ Downloader (CT 110, yt-dlp + VPN rotation)
| Layer | Technology |
|-------|-----------|
| Mesh VPN | Tailscale (self-hosted Headscale) |
| Reverse proxy | Caddy (CT 101 on utility) — auto TLS |
| DNS | GoDaddy (external), dnsmasq split DNS (internal) |
| Reverse proxy | [[caddy]] (CT 101 on utility) — auto TLS |
| [[dns]] | GoDaddy (external), dnsmasq split DNS (internal) |
| Authentication | Authentik OIDC SSO across all services |
| SSO Launch URLs | `https://auth.echo6.co/application/launch/<slug>/` for seamless pass-through |
| Backup transport | rsync over SSH (ed25519 keys) |

View file

@ -1,3 +1,17 @@
---
title: Internet Archive Download Queue
type: note
tags:
- storage
aliases: []
related:
- [[ia-download-mirror]]
- [[ia-cli-reference]]
- [[recon]]
- [[usenet]]
- [[glossary]]
updated: 2026-06-18
---
# Internet Archive Download Queue
Tracked items to pull down to pi-nas (192.168.1.245).

View file

@ -1,3 +1,17 @@
---
title: Vaultwarden → edge2 LXC — Migration Pilot (+ reusable LXC-migration runbook) — v2
type: note
tags:
- proxmox
aliases: []
related:
- [[lxc-service-migration]]
- [[expose-service-edge2]]
- [[edge2-access-reference]]
- [[caddy]]
- [[ip-allocation]]
updated: 2026-06-18
---
# Vaultwarden → edge2 LXC — Migration Pilot (+ reusable LXC-migration runbook) — v2
## Changes from v1 (what was corrected and why)
@ -19,11 +33,11 @@
## Context
We're consolidating both Contabo VPSes onto Proxmox. edge2 is up and proven (PVE 8 + PDM). The next phase is evacuating services off the **main Contabo** so it can be rebuilt as `edge1` — moving each service into an LXC on edge2, with the live Contabo as rollback, mail handled last on its original IP.
We're consolidating both Contabo VPSes onto Proxmox. edge2 is up and proven (PVE 8 + PDM). The next phase is evacuating [[services]] off the **main Contabo** so it can be rebuilt as `edge1` — moving each service into an LXC on edge2, with the live Contabo as rollback, mail handled last on its original IP.
**Vaultwarden is the pilot** because it's the lowest-risk service: a single container, ~5.3 MB of SQLite data, no external DB, and `SSO_ONLY=false` so local master-password login works even if Authentik is down. This run **doubles as the reusable per-service LXC-migration template** — phases are tagged `[G]` generic vs `[S]` service-specific.
**Vaultwarden is the pilot** because it's the lowest-risk service: a single container, ~5.3 MB of SQLite data, no external DB, and `SSO_ONLY=false` so local master-password login works even if [[authentik]] is down. This run **doubles as the reusable per-service LXC-migration template** — phases are tagged `[G]` generic vs `[S]` service-specific.
The cutover uses a **proven, low-risk pattern**: public DNS never changes; we stand up the service on edge2, then re-point *one token* of Contabo's Caddy backend to the new instance over the tailnet (exactly how `proxmox.echo6.co` already routes to `100.64.0.6:8006`). Rollback = revert that one token + restart source (~12 s).
The cutover uses a **proven, low-risk pattern**: public [[dns]] never changes; we stand up the service on edge2, then re-point *one token* of Contabo's [[caddy]] backend to the new instance over the tailnet (exactly how `proxmox.echo6.co` already routes to `100.64.0.6:8006`). Rollback = revert that one token + restart source (~12 s).
**edge2 access (pinned):** `ssh admin@184.174.35.153` (alias `edge2`, key `~/.ssh/contabo2_ed25519`), then `sudo` for every `pct`/`pvesm`/`pveam` command. `root@100.64.0.26` is refused — do not use it.
@ -47,7 +61,7 @@ The cutover uses a **proven, low-risk pattern**: public DNS never changes; we st
- **Rollback:** n/a (read-only except the template download).
### Phase 0a — Validate a LOCAL user exists + record to credentials `[S]` ← user-requested GATE
- Take a read-only hot snapshot of the live DB and query `users`: confirm **≥1 account has a non-empty `password_hash`** (= master password set → local email+password login works, independent of SSO). Recon already confirms: **one account `matt@echo6.co`, password_hash non-empty, email verified, enabled → GATE PASS.** Record email(s), `login_count`, verified status.
- Take a read-only hot snapshot of the live DB and query `users`: confirm **≥1 account has a non-empty `password_hash`** (= master password set → local email+password login works, independent of SSO). [[recon]] already confirms: **one account `matt@echo6.co`, password_hash non-empty, email verified, enabled → GATE PASS.** Record email(s), `login_count`, verified status.
- **Record to `/home/zvx/projects/.ref/credentials`** under a `# Vaultwarden (vault.echo6.co)` section: the account email(s), `SSO_ONLY=false` (local login enabled), and a note that the **master password is Matt's own secret — not stored anywhere in plaintext** (Vaultwarden keeps only the PBKDF2/Argon2 hash). The `ADMIN_TOKEN` (from `.env`) goes here as admin-panel break-glass.
- **GATE:** if NO account has a master password set, **STOP and surface it** — the local-login safety net wouldn't exist. Also confirm with Matt he knows the master password for at least one listed account (we can't recover it, only verify the account exists).
@ -181,7 +195,7 @@ The cutover uses a **proven, low-risk pattern**: public DNS never changes; we st
## Gotchas
- **G1** Stale `caddy.md`/archive mentions a `:3012`/`notifications/hub` WS route — live Caddy has **none** (in-process WS on `:8086`). Confirmed in Phase 0.
- **G2** edge2 `vmbr0` has **no DHCP** and no `local-lvm` → static IP `10.10.10.20/24` + `local` (dir) storage. (Overrides ct-runbook defaults.)
- **G2** edge2 `vmbr0` has **no DHCP** and no `local-lvm` → static IP `10.10.10.20/24` + `local` (dir) storage. (Overrides [[ct-runbook]] defaults.)
- **G3** Source binds loopback-only (`127.0.0.1:8086:80`); LXC must bind the tailnet IP (`VW_TS_IP:8086:80`) so Caddy reaches it. Keep the container-side `:80`.
- **G4** SQLite is in WAL mode with a **live 2.4 MB WAL** → use hot `.backup` (single self-contained file, no `-wal`/`-shm`); never copy a live `db.sqlite3`+WAL naively. Here we also **stop the source first** for zero-loss.
- **G5** Preserve `header_up X-Real-IP {remote_host}` in the new Caddy backend line (edit only the upstream token).

View file

@ -1,3 +1,17 @@
---
title: advBBS — Claude Code Project Context
type: project
tags:
- mesh
aliases: []
related:
- [[meshtastic-sidecar-node]]
- [[meshtasticd-sim-nodes-runbook]]
- [[mautrix_signal]]
- [[meshtastic-headscale-runbook]]
- [[services]]
updated: 2026-06-18
---
# advBBS — Claude Code Project Context
## Source of Truth

View file

@ -2,31 +2,29 @@
title: ARGUS - OSINT Intelligence Platform
type: project
tags:
- mesh
- auth
- recon
aliases: []
related:
- [[headscale-onboard-node]]
- [[ip-allocation]]
- [[caddy]]
- [[headscale-onboard-node]]
- [[ct-runbook]]
- [[environment]]
- [[ots-setup]]
updated: 2026-06-18
---
# ARGUS - OSINT Intelligence Platform
**Status:** Container provisioned, baseline installed, awaiting application deployment
**Status:** Container provisioned, baseline installed, awaiting application [[deployment]]
**Last Updated:** 2026-06-14
---
## Overview
ARGUS (Automated Reconnaissance & Gathering for Unified Situational-awareness) is an OSINT intelligence gathering platform combining SearXNG with local LLM analysis for automated threat intelligence collection and processing.
ARGUS (Automated Reconnaissance & Gathering for Unified Situational-awareness) is an OSINT intelligence gathering platform combining [[searxng]] with local LLM analysis for automated threat intelligence collection and processing.
**Architecture:**
- Search backend: SearXNG (self-hosted)
- Search backend: [[searxng]] (self-hosted)
- Analysis: Local LLM models (no cloud APIs)
- Scopes: Local, regional, national, global threat levels
- Privacy-first: No PII collection, focus on events/trends/policies
@ -39,7 +37,7 @@ ARGUS (Automated Reconnaissance & Gathering for Unified Situational-awareness) i
|----------|-------|
| **CTID** | 103 |
| **Hostname** | argus |
| **Host** | utility (192.168.1.241 / 100.64.0.5) |
| **Host** | Utility (192.168.1.241 / 100.64.0.5) |
| **Local IP** | 192.168.1.103 (static) |
| **Tailscale IP** | 100.64.0.25 |
| **Gateway** | 192.168.1.1 |
@ -72,8 +70,8 @@ ARGUS (Automated Reconnaissance & Gathering for Unified Situational-awareness) i
**Tailscale IP:** 100.64.0.25
**Registration:** `tailscale up --login-server=https://vpn.echo6.co --authkey=<key> --ssh --accept-routes`
**DNS Bootstrap Fix:**
Systemd drop-in at `/etc/systemd/system/tailscaled.service.d/dns-bootstrap.conf` ensures fallback DNS (1.1.1.1, 8.8.8.8) exists before tailscaled starts, preventing chicken-and-egg DNS resolution failures on reboot.
**[[dns]] Bootstrap Fix:**
Systemd drop-in at `/etc/systemd/system/tailscaled.service.d/dns-bootstrap.conf` ensures fallback [[dns]] (1.1.1.1, 8.8.8.8) exists before tailscaled starts, preventing chicken-and-egg DNS resolution failures on reboot.
```bash
[Service]
@ -263,7 +261,7 @@ lxc.mount.entry: /dev/net dev/net none bind,create=dir
## Next Steps (Application Deployment)
1. **SearXNG deployment:** Docker container for self-hosted search aggregation
1. **[[searxng]] [[deployment]]:** Docker container for self-hosted search aggregation
2. **LLM integration:** Local model for analysis (Ollama on cortex or self-hosted)
3. **Database:** SQLite for processed intel, possibly Qdrant for vector search (cortex:6333 available)
4. **Scheduler:** Cron or systemd timers for automated collection
@ -286,8 +284,8 @@ lxc.mount.entry: /dev/net dev/net none bind,create=dir
- **CT provisioning:** `/home/zvx/projects/.ref/runbooks/ct-runbook.md`
- **ARGUS rules:** `~/.claude/rules/argus.md`
- **Environment:** `/home/zvx/projects/.ref/docs/hardware/environment.md`
- **Services:** `/home/zvx/projects/.ref/docs/services/services.md`
- **[[environment]]:** `/home/zvx/projects/.ref/docs/hardware/environment.md`
- **[[services]]:** `/home/zvx/projects/.ref/docs/services/services.md`
- **Headscale:** `/home/zvx/projects/.ref/docs/software/caddy.md` (dnsmasq split DNS)
---

View file

@ -1,6 +1,20 @@
---
title: Deploying CouchDB with JWT auth for Obsidian LiveSync via Authentik
type: project
tags:
- auth
aliases: []
related:
- [[authentik-oidc-application]]
- [[authentik]]
- [[authentik-access-groups]]
- [[expose-service-home]]
- [[authentik-upgrade]]
updated: 2026-06-18
---
# Deploying CouchDB with JWT auth for Obsidian LiveSync via Authentik
**LiveSync has native client-side JWT support that eliminates the need for a browser-based OIDC flow.** The plugin generates and signs JWTs internally using a stored private key, sending `Authorization: Bearer` headers directly to CouchDB. This fundamentally changes the architecture: instead of proxying OIDC tokens, you provision per-user key pairs, configure CouchDB with the public keys, and distribute setup URIs containing the private keys. Authentik serves as the identity backbone for a provisioning service — not as a runtime token issuer. No one has publicly documented a complete LiveSync + SSO deployment, making this guide a synthesis of the Kishieel Keycloak series, CouchDB JWT internals, Authentik's claim customization, and the LiveSync plugin's JWT implementation.
**LiveSync has native client-side JWT support that eliminates the need for a browser-based OIDC flow.** The plugin generates and signs JWTs internally using a stored private key, sending `Authorization: Bearer` headers directly to CouchDB. This fundamentally changes the architecture: instead of proxying OIDC tokens, you provision per-user key pairs, configure CouchDB with the public keys, and distribute setup URIs containing the private keys. [[authentik]] serves as the identity backbone for a provisioning service — not as a runtime token issuer. No one has publicly documented a complete LiveSync + SSO [[deployment]], making this guide a synthesis of the Kishieel Keycloak series, CouchDB JWT internals, Authentik's claim customization, and the LiveSync plugin's JWT implementation.
---
@ -197,7 +211,7 @@ CouchDB matches the JWT `sub` against `members.names` and `admins.names`, and th
**CORS is the most common failure mode.** Issue #628 documents that LiveSync does not send the `Origin` header on non-preflight requests, causing CouchDB's CORS handler to omit `Access-Control-Allow-Origin` from responses. The fix is configuring CORS in `local.ini` (shown above) rather than relying on the reverse proxy alone. Required origins: `app://obsidian.md`, `capacitor://localhost`, `http://localhost`.
**The Caddy reverse proxy config** for `notes.echo6.co`:
**The [[caddy]] reverse proxy config** for `notes.echo6.co`:
```
notes.echo6.co {

View file

@ -1,8 +1,22 @@
---
title: Matrix Synapse Deployment
type: project
tags:
- matrix
aliases: []
related:
- [[synapse]]
- [[matrix_host]]
- [[mautrix_signal]]
- [[caddy]]
- [[lxc-service-migration]]
updated: 2026-06-18
---
# Matrix Synapse Deployment
**Status:** Deployed 2026-02-15, migrated to Contabo 2026-02-15
**Target:** Contabo VPS (5.189.158.149 / 100.64.0.1)
**URLs:** https://matrix.echo6.co (Synapse), https://element.echo6.co (Element Web)
**URLs:** https://matrix.echo6.co ([[synapse]]), https://element.echo6.co (Element Web)
**Server Name:** echo6.co (federated identity: @user:echo6.co)
---
@ -12,9 +26,9 @@
| Component | Detail |
|-----------|--------|
| Host | Contabo VPS (5.189.158.149 / 100.64.0.1) |
| Docker services | Synapse (127.0.0.1:8008), Element Web (127.0.0.1:8088), PostgreSQL 16 |
| Reverse proxy | Contabo Caddy (auto ACME certs) |
| SSO | Authentik OIDC → communication-users group |
| Docker [[services]] | Synapse (127.0.0.1:8008), Element Web (127.0.0.1:8088), PostgreSQL 16 |
| Reverse proxy | Contabo [[caddy]] (auto ACME certs) |
| SSO | [[authentik]] OIDC → communication-users group |
| Federation | Well-known delegation on echo6.co base domain (served by utility Caddy) |
| Compose path | `/opt/matrix/docker-compose.yml` |
| Backup | Daily at 3AM, 14-day retention, `/opt/matrix/backups/` |
@ -233,7 +247,7 @@ This service has OIDC, so use local IP per the runbook's decision table.
### matrix.echo6.co
- Backend: `192.168.1.108:8008` (local IP, has OIDC)
- Issue cert, install cert, add Caddy site block, add GoDaddy DNS
- Issue cert, install cert, add Caddy site block, add GoDaddy [[dns]]
Caddy site block (note the path-based routing for Matrix):
@ -456,7 +470,7 @@ MATRIX_ADMIN_USER=matt
## Post-Deploy Updates
After deployment, update these docs:
After [[deployment]], update these docs:
- `docs/services/services.md` — add Matrix entry
- `docs/software/caddy.md` — add matrix.echo6.co and element.echo6.co site blocks

View file

@ -3,12 +3,10 @@ title: IdahoMesh Tailnet Runbook
type: project
tags:
- mesh
- vpn
- auth
aliases: []
related:
- [[idahomesh-vpn-device-setup]]
- [[idahomesh-bridge-setup]]
- [[idahomesh-vpn-device-setup]]
- [[meshtastic-sidecar-node]]
- [[headscale-onboard-node]]
- [[caddy]]
@ -163,7 +161,7 @@ log:
format: text
```
> **Note:** Embedded DERP is disabled — we use Tailscale's public DERP relays. The server is behind Caddy, so TLS termination happens at the reverse proxy.
> **Note:** Embedded DERP is disabled — we use Tailscale's public DERP relays. The server is behind [[caddy]], so TLS termination happens at the reverse proxy.
### 1.4 Create the ACL Policy

View file

@ -1,3 +1,17 @@
---
title: MMUD — Mesh Multi-User Dungeon
type: project
tags:
- mesh
aliases: []
related:
- [[advbbs-project]]
- [[meshtasticd-sim-nodes-runbook]]
- [[ip-allocation]]
- [[services]]
- [[meshtastic-headscale-runbook]]
updated: 2026-06-18
---
# MMUD — Mesh Multi-User Dungeon
Text-based multiplayer dungeon crawler for Meshtastic LoRa mesh networks. BBS door games (LORD, TradeWars) adapted for 150-char mesh radio constraints, async play, 30-day wipe cycles.

View file

@ -3,22 +3,20 @@ title: Add PeerTube Channel
type: runbook
tags:
- media
- vpn
- auth
aliases: []
related:
- [[peertube-remote-runner]]
- [[ct-runbook]]
- [[recon-operations]]
- [[recon-service-integration]]
- [[proxmox-onboard-node]]
- [[ct-runbook]]
updated: 2026-06-18
---
# Add PeerTube Channel
## Overview
Add a YouTube channel to the PeerTube bulk import pipeline. Creates the PeerTube channel, adds to channel-map.json, and the downloader will begin syncing videos automatically.
Add a Youtube channel to the peertube bulk import pipeline. Creates the PeerTube channel, adds to channel-map.json, and the downloader will begin syncing videos automatically.
## Prerequisites
@ -28,7 +26,7 @@ Add a YouTube channel to the PeerTube bulk import pipeline. Creates the PeerTube
## Method 1: Web UI (Preferred)
1. Open **RECON Dashboard** → Upload tab: `http://192.168.1.130:8420/upload`
1. Open **[[recon]] Dashboard** → Upload tab: `http://192.168.1.130:8420/upload`
2. Scroll to **PeerTube Channels** section
3. Enter YouTube URL, category, priority
4. Click **Add Channel**

View file

@ -3,22 +3,20 @@ title: Authentik Access Groups
type: runbook
tags:
- auth
- mesh
- matrix
aliases: []
related:
- [[authentik-oidc-application]]
- [[authentik-create-invitation]]
- [[authentik]]
- [[authentik-create-invitation]]
- [[deploy-livesync]]
- [[proxmox-onboard-node]]
updated: 2026-06-18
---
# Authentik Access Groups
Manage group-based application access via the Authentik API. No web UI interaction required.
Manage group-based application access via the [[authentik]] API. No web UI interaction required.
**Authentik instance:** https://auth.echo6.co (Contabo, 100.64.0.1)
**[[authentik]] instance:** https://auth.echo6.co (Contabo, 100.64.0.1)
**Key behavior:** Users in `authentik Admins` (is_superuser=true) bypass ALL policy checks automatically. Group bindings only restrict non-superuser access.

View file

@ -1,6 +1,20 @@
---
title: "Authentik: Create Invitation"
type: runbook
tags:
- auth
aliases: []
related:
- [[authentik-access-groups]]
- [[authentik]]
- [[authentik-oidc-application]]
- [[authentik-upgrade]]
- [[mailcow-create-mailbox]]
updated: 2026-06-18
---
# Authentik: Create Invitation
Create user invitations via the Authentik Admin UI. Supports two modes: email (automatic delivery) and link-sharing (manual delivery).
Create user invitations via the [[authentik]] Admin UI. Supports two modes: email (automatic delivery) and link-sharing (manual delivery).
---
@ -119,7 +133,7 @@ Use `firstname-lastname-YYYY-MM` or `purpose-YYYY-MM` for easy identification:
## After Enrollment
New users are created under the `users/enrolled` path. To grant them access to services:
New users are created under the `users/enrolled` path. To grant them access to [[services]]:
1. Navigate to **Directory → Groups**
2. Add the user to the appropriate group(s):

View file

@ -1,8 +1,22 @@
---
title: Add Authentik OIDC to an Application
type: runbook
tags:
- auth
aliases: []
related:
- [[authentik]]
- [[authentik-access-groups]]
- [[authentik-upgrade]]
- [[mailcow-create-mailbox]]
- [[expose-service-home]]
updated: 2026-06-18
---
# Add Authentik OIDC to an Application
Fully automated via Authentik API. No web UI interaction required.
Fully automated via [[authentik]] API. No web UI interaction required.
**Prerequisite:** DNS must already exist for the service (run expose-service-contabo.md or expose-service-home.md first).
**Prerequisite:** [[dns]] must already exist for the service (run expose-service-contabo.md or expose-service-home.md first).
**Authentik instance:** https://auth.echo6.co (Contabo, 100.64.0.1)
@ -214,7 +228,7 @@ Most apps only need the **Issuer** (or Discovery URL) plus Client ID and Client
### Common config patterns
**Environment variables (Docker):**
**[[environment]] variables (Docker):**
```bash
OIDC_ISSUER=https://auth.echo6.co/application/o/$SERVICE_SLUG/

View file

@ -1,6 +1,20 @@
---
title: "Authentik: Major Version Upgrade"
type: runbook
tags:
- auth
aliases: []
related:
- [[authentik-oidc-application]]
- [[lxc-service-migration]]
- [[authentik]]
- [[authentik-create-invitation]]
- [[ct-runbook]]
updated: 2026-06-18
---
# Authentik: Major Version Upgrade
Upgrade Authentik between major versions on Contabo. Covers backup, upgrade, verification, and rollback.
Upgrade [[authentik]] between major versions on Contabo. Covers backup, upgrade, verification, and rollback.
---
@ -37,7 +51,7 @@ https://docs.goauthentik.io/docs/releases/
Look for:
- **Breaking changes** — removed features, changed defaults, API changes
- **Dependency changes** — added/removed services (e.g., Redis removed in 2025.10)
- **Dependency changes** — added/removed [[services]] (e.g., Redis removed in 2025.10)
- **Configuration changes** — new required env vars, changed mount paths
- **Database migrations** — large migrations that may take time

View file

@ -5,11 +5,11 @@ tags:
- proxmox
aliases: []
related:
- [[headscale-onboard-node]]
- [[proxmox-onboard-node]]
- [[proxmox-create-ubuntu-vm]]
- [[nordvpn-lxc]]
- [[headscale-onboard-node]]
- [[meshtasticd-sim-nodes-runbook]]
- [[proxmox-create-ubuntu-vm]]
- [[ots-setup]]
updated: 2026-06-18
---
# Proxmox CT/LXC Provisioning Runbook

View file

@ -1,3 +1,17 @@
---
title: edge2 Access Reference
type: runbook
tags:
- proxmox
aliases: []
related:
- [[expose-service-edge2]]
- [[proxmox-onboard-node]]
- [[vaultwarden-plan]]
- [[lxc-service-migration]]
- [[headscale-onboard-node]]
updated: 2026-06-18
---
# edge2 Access Reference
## SSH Access
@ -84,7 +98,7 @@ ssh edge2 'sudo pct stop <CTID>'
**Why it was confusing:**
1. The SSH error shows `publickey,password` as available methods — this is misleading because `PasswordAuthentication no` is enforced, but the SSH banner still lists both
2. We tried `root@` (wrong user) and the default `id_ed25519` (wrong key)
3. The environment docs didn't document the `admin` user or the specific key requirement
3. The [[environment]] docs didn't document the `admin` user or the specific key requirement
**Resolution:** Added cortex's default `id_ed25519`, WSL2 key, and Windows key to admin's `authorized_keys`. Added SSH config alias `edge2``admin@100.64.0.26`.

View file

@ -1,3 +1,17 @@
---
title: Expose Service on Contabo
type: runbook
tags:
- dns
aliases: []
related:
- [[expose-service-edge2]]
- [[expose-service-home]]
- [[lxc-service-migration]]
- [[headscale-onboard-node]]
- [[caddy]]
updated: 2026-06-18
---
# Expose Service on Contabo
## Prerequisites

View file

@ -1,14 +1,28 @@
---
title: Expose Service on edge2 (Contabo Cloud VPS)
type: runbook
tags:
- proxmox
aliases: []
related:
- [[lxc-service-migration]]
- [[edge2-access-reference]]
- [[expose-service-contabo]]
- [[expose-service-home]]
- [[vaultwarden-plan]]
updated: 2026-06-18
---
# Expose Service on edge2 (Contabo Cloud VPS)
## Context
edge2 is a Proxmox VE 8 node (184.174.35.153 / 100.64.0.26) running LXC containers on an internal bridge (`vmbr0`, subnet `10.10.10.0/24`, gateway `10.10.10.1`). Services run inside unprivileged LXC containers. Caddy on the edge2 host terminates TLS and reverse-proxies to the container's internal IP.
edge2 is a Proxmox VE 8 node (184.174.35.153 / 100.64.0.26) running LXC containers on an internal bridge (`vmbr0`, subnet `10.10.10.0/24`, gateway `10.10.10.1`). [[services]] run inside unprivileged LXC containers. [[caddy]] on the edge2 host terminates TLS and reverse-proxies to the container's internal IP.
## Prerequisites
- SSH access to edge2: `ssh edge2` (admin@100.64.0.26, key auth, passwordless sudo)
- Debian 13 CT template cached: `local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst`
- DNS provider access (Cloudflare, GoDaddy, etc.)
- [[dns]] provider access (Cloudflare, GoDaddy, etc.)
## Steps

View file

@ -1,9 +1,23 @@
---
title: Expose Service on Home Network
type: runbook
tags:
- proxmox
aliases: []
related:
- [[expose-service-edge2]]
- [[expose-service-contabo]]
- [[caddy]]
- [[proxmox-onboard-node]]
- [[ct-runbook]]
updated: 2026-06-18
---
# Expose Service on Home Network
## Prerequisites
- Service running on a Proxmox CT/VM or bare metal
- Router forwards 80/443 to Utility Caddy (192.168.1.101) — one-time setup
- Determine pattern: does the service have Authentik OIDC?
- Router forwards 80/443 to Utility [[caddy]] (192.168.1.101) — one-time setup
- Determine pattern: does the service have [[authentik]] OIDC?
## Steps

View file

@ -1,3 +1,17 @@
---
title: Headscale / Tailscale — Onboard a New Node
type: runbook
tags:
- proxmox
aliases: []
related:
- [[proxmox-onboard-node]]
- [[ct-runbook]]
- [[caddy]]
- [[meshtastic-headscale-runbook]]
- [[lxc-service-migration]]
updated: 2026-06-18
---
# Headscale / Tailscale — Onboard a New Node
Standard procedure to bring any new system (Proxmox host, bare-metal, VM, or LXC) onto the Echo6 tailnet.
@ -77,7 +91,7 @@ Most nodes use `User root`. Per-node exceptions exist — e.g. **edge2** uses `U
- `docs/hardware/environment.md` — Key Servers table **and** Headscale Node List (bump count + "updated" line).
- `docs/hardware/ip-allocation.md` — Tailscale IPs section.
- `CLAUDE.md` cluster cheat-sheet — Proxmox hosts only.
- `docs/services/services.md` — once services are deployed on the node.
- `docs/services/services.md` — once [[services]] are deployed on the node.
## Worked example — edge2 (2026-06-16)
Proxmox host on Contabo. Preauth key → `tailscale up --login-server=https://vpn.echo6.co --auth-key=<KEY> --hostname=edge2` → assigned **100.64.0.26** (Headscale node 42). SSH aliases `edge2` / `ts-edge2` use `User admin` + `~/.ssh/contabo2_ed25519`.

View file

@ -1,3 +1,17 @@
---
title: Internet Archive CLI Reference
type: runbook
tags:
- auth
aliases: []
related:
- [[ia-download-mirror]]
- [[ia-download-queue]]
- [[idahomesh-vpn-device-setup]]
- [[archivist]]
- [[usenet]]
updated: 2026-06-18
---
# Internet Archive CLI Reference
Quick reference for the `ia` command-line tool on pi-nas.

View file

@ -1,3 +1,17 @@
---
title: "Download & Mirror from Internet Archive"
type: runbook
tags:
- storage
aliases: []
related:
- [[ia-cli-reference]]
- [[ia-download-queue]]
- [[pipeline-patterns]]
- [[syncthing-add-node]]
- [[idahomesh-vpn-device-setup]]
updated: 2026-06-18
---
# Download & Mirror from Internet Archive
Procedures for downloading items, filtering by format/pattern, bulk downloading from collections, and mirroring entire collections via the `ia` CLI on pi-nas.

View file

@ -1,3 +1,17 @@
---
title: IdahoMesh Bridge Setup
type: runbook
tags:
- mesh
aliases: []
related:
- [[meshtastic-headscale-runbook]]
- [[idahomesh-vpn-device-setup]]
- [[meshtastic-sidecar-node]]
- [[headscale-onboard-node]]
- [[caddy]]
updated: 2026-06-18
---
# IdahoMesh Bridge Setup
Build a one-way bridge between your tailnet and the IdahoMesh Meshtastic network. This lets your devices reach Nebra gateways through IdahoMesh, while preventing IdahoMesh from reaching back into your network.
@ -288,7 +302,7 @@ From an IdahoMesh device or ask the admin to test — pinging your tailnet IPs f
### After reboot, only one tailscaled reconnects
- Check both services: `systemctl status tailscaled` and `systemctl status tailscaled-meshtastic`
- Check both [[services]]: `systemctl status tailscaled` and `systemctl status tailscaled-meshtastic`
- Verify iptables rules survived: `iptables -L FORWARD -v -n`
- If the second instance lost state, re-join IdahoMesh with a new preauthkey

View file

@ -1,3 +1,17 @@
---
title: IdahoMesh VPN — Device Setup
type: runbook
tags:
- mesh
aliases: []
related:
- [[idahomesh-bridge-setup]]
- [[meshtastic-headscale-runbook]]
- [[meshtastic-sidecar-node]]
- [[headscale-onboard-node]]
- [[caddy]]
updated: 2026-06-18
---
# IdahoMesh VPN — Device Setup
Join a device to the IdahoMesh tailnet (Meshtastic mesh network VPN).
@ -255,7 +269,7 @@ tailscale logout
### "connection refused" or timeout on join
- Confirm the device has internet access: `curl -I https://vpn.idahomesh.com`
- Check DNS resolution: `dig vpn.idahomesh.com`
- Check [[dns]] resolution: `dig vpn.idahomesh.com`
- Verify the preauthkey hasn't expired
### "key expired" or "invalid key"

View file

@ -1,12 +1,26 @@
---
title: LXC Service Migration — Contabo → edge2
type: runbook
tags:
- proxmox
aliases: []
related:
- [[expose-service-edge2]]
- [[vaultwarden-plan]]
- [[headscale-onboard-node]]
- [[caddy]]
- [[expose-service-contabo]]
updated: 2026-06-18
---
# LXC Service Migration — Contabo → edge2
> Proven pilots: **Vaultwarden → edge2 CT 102** (SQLite, 2026-06-16), **Forgejo → edge2 CT 103** (PostgreSQL + non-Caddy SSH port, 2026-06-16), **LiveSync (CouchDB) → edge2 CT 104** (cold named-volume tar + bind-mounted config, 2026-06-16), and **Authentik (PostgreSQL keystone) → edge2 CT 105** (SECRET_KEY-must-travel, multi-block Caddy cutover across 2 site blocks, reboot tailscale-before-docker race, 2026-06-18). This runbook generalizes these patterns into a reusable template for evacuating any Contabo-Caddy-fronted service to an edge2 LXC.
> Proven pilots: **Vaultwarden → edge2 CT 102** (SQLite, 2026-06-16), **Forgejo → edge2 CT 103** (PostgreSQL + non-Caddy SSH port, 2026-06-16), **LiveSync (CouchDB) → edge2 CT 104** (cold named-volume tar + bind-mounted config, 2026-06-16), and **[[authentik]] (PostgreSQL keystone) → edge2 CT 105** (SECRET_KEY-must-travel, multi-block [[caddy]] cutover across 2 site blocks, reboot tailscale-before-docker race, 2026-06-18). This runbook generalizes these patterns into a reusable template for evacuating any Contabo-Caddy-fronted service to an edge2 LXC.
---
## Overview
Move a Docker service from the main Contabo VPS into an LXC on edge2, with the Contabo Caddy frontend unchanged (public DNS never moves; only the upstream token in the Caddyfile changes). Rollback is a single line.
Move a Docker service from the main Contabo VPS into an LXC on edge2, with the Contabo Caddy frontend unchanged (public [[dns]] never moves; only the upstream token in the Caddyfile changes). Rollback is a single line.
**Architecture after migration:**
@ -428,14 +442,14 @@ ssh root@100.64.0.1 'systemctl disable --now <service>-ssh-dnat.service && rm /e
| G13 | iptables DNAT must be made reboot-persistent via a systemd `oneshot`/`RemainAfterExit` unit (Phase 7a). Do NOT rely on iptables-persistent packages or manual rules — they require package installs (forbidden) or do not survive all reboot paths. Do NOT apply by rebooting the Contabo host (production). Create the unit file, `daemon-reload`, `enable --now`. |
| G14 | **Reboot race — Docker binding to the tailnet IP can start before Tailscale is online, failing the bind and leaving the service unreachable after a reboot.** Fix: create a systemd unit on the CT that runs `docker compose up` and has `After=tailscale-online.target` + `Requires=tailscale-online.target` (or equivalent `tailscale status --wait` pre-check). Alternatively, `restart: unless-stopped` in the compose file will cause Docker to self-heal via restarts, but the service will be unreachable for the first ~1030 s after reboot. Verify reboot survival explicitly (Phase 8). Proven required for Authentik (CT 105, 2026-06-18). |
| G15 | **Do NOT change the dnsmasq split-DNS entry during cutover.** The dnsmasq entry for `<service>.echo6.co` points at the Caddy/TLS host (100.64.0.1 = Contabo), NOT the backend. Only the Caddy upstream changes. Repointing dnsmasq to the backend tailnet IP would break internal HTTPS (no cert, no TLS termination). The Caddy host is always the internal DNS target; the backend IP only appears in the Caddy `reverse_proxy` directive. |
| G16 | **SECRET_KEY and session-signing material must travel byte-for-byte for keystone/session-bearing services** (e.g. Authentik `AUTHENTIK_SECRET_KEY`, Vaultwarden `rsa_key.pem`). Carrying them verbatim means existing browser sessions survive the cutover — users drop straight in with no forced re-login. If the key is regenerated on the target, all active sessions are invalidated immediately. Confirm from startup logs that no new key was generated. |
| G16 | **SECRET_KEY and session-signing material must travel byte-for-byte for keystone/session-bearing [[services]]** (e.g. Authentik `AUTHENTIK_SECRET_KEY`, Vaultwarden `rsa_key.pem`). Carrying them verbatim means existing browser sessions survive the cutover — users drop straight in with no forced re-login. If the key is regenerated on the target, all active sessions are invalidated immediately. Confirm from startup logs that no new key was generated. |
| N | The composed **Contabo-Caddy → edge2-LXC tailnet** path is unexercised for each new service. Keep the Phase 6 HTTP `/alive` 200 gate as a HARD pre-cutover requirement (use `curl`, not ICMP). |
---
## Template Summary
**Generic phases (identical for every service):** 0 (recon) → 1 (LXC provision) → 2 (Docker) → 3 (Tailscale + DNS-bootstrap + reachability pre-gate) → 7 (one-token Caddy cutover + backup + validate + restart) → 7a (if service exposes non-Caddy TCP port: iptables DNAT systemd unit on Contabo) → 8 (end-to-end + reboot survival) → 9 (deferred decommission).
**Generic phases (identical for every service):** 0 ([[recon]]) → 1 (LXC provision) → 2 (Docker) → 3 (Tailscale + DNS-bootstrap + reachability pre-gate) → 7 (one-token Caddy cutover + backup + validate + restart) → 7a (if service exposes non-Caddy TCP port: iptables DNAT systemd unit on Contabo) → 8 (end-to-end + reboot survival) → 9 (deferred decommission).
**Service-specific phases:** 0a (pre-migration gate), 4 (compose/config — copy from live host), 5 (data migration method depends on storage type), 6 (health gates — service-specific checks before cutover).

View file

@ -1,3 +1,17 @@
---
title: "Mailcow: Create Mailbox"
type: runbook
tags:
- auth
aliases: []
related:
- [[authentik]]
- [[authentik-oidc-application]]
- [[caddy]]
- [[authentik-create-invitation]]
- [[proxmox-onboard-node]]
updated: 2026-06-18
---
# Mailcow: Create Mailbox
Create a new mailbox in Mailcow on the Contabo VPS. Covers both interactive (UI) and API-driven creation, with the critical authsource fix for service accounts.
@ -6,7 +20,7 @@ Create a new mailbox in Mailcow on the Contabo VPS. Covers both interactive (UI)
## When to Use This
Any time a new mailbox is created in Mailcow, but **especially** for service/system accounts that authenticate via SMTP to send mail programmatically (e.g., `no-reply@echo6.co` used by Authentik, `recon@echo6.co` used by the RECON pipeline). These accounts don't log in through the Mailcow web UI or SSO — they pass credentials directly to Postfix over SMTP, so they **must** use local password authentication.
Any time a new mailbox is created in Mailcow, but **especially** for service/system accounts that authenticate via SMTP to send mail programmatically (e.g., `no-reply@echo6.co` used by [[authentik]], `recon@echo6.co` used by the [[recon]] pipeline). These accounts don't log in through the Mailcow web UI or SSO — they pass credentials directly to Postfix over SMTP, so they **must** use local password authentication.
---

View file

@ -1,3 +1,17 @@
---
title: MeshMonitor Admin Password Reset
type: runbook
tags:
- proxmox
aliases: []
related:
- [[proxmox-onboard-node]]
- [[recon-service-integration]]
- [[ct-runbook]]
- [[headscale-onboard-node]]
- [[recon-operations]]
updated: 2026-06-18
---
# MeshMonitor Admin Password Reset
## Overview

View file

@ -3,14 +3,13 @@ title: Meshtastic Sidecar Node — Modular Deployment Runbook
type: runbook
tags:
- mesh
- vpn
aliases: []
related:
- [[idahomesh-vpn-device-setup]]
- [[headscale-onboard-node]]
- [[meshtastic-headscale-runbook]]
- [[idahomesh-bridge-setup]]
- [[meshtasticd-sim-nodes-runbook]]
- [[headscale-onboard-node]]
- [[advbbs-project]]
updated: 2026-06-18
---
# Meshtastic Sidecar Node — Modular Deployment Runbook
@ -164,7 +163,7 @@ sudo tailscale up \
### Install DNS bootstrap drop-in (reboot-safe)
Prevents chicken-and-egg DNS failure where tailscaled can't resolve the coordination server after reboot:
Prevents chicken-and-egg [[dns]] failure where tailscaled can't resolve the coordination server after reboot:
```bash
sudo mkdir -p /etc/systemd/system/tailscaled.service.d

View file

@ -1,8 +1,22 @@
---
title: Meshtasticd SIM Node Runbook — LXC Deployment
type: runbook
tags:
- mesh
aliases: []
related:
- [[ct-runbook]]
- [[meshtastic-sidecar-node]]
- [[headscale-onboard-node]]
- [[proxmox-onboard-node]]
- [[ip-allocation]]
updated: 2026-06-18
---
# Meshtasticd SIM Node Runbook — LXC Deployment
## Overview
This runbook covers deploying meshtasticd SIM (virtual) nodes inside LXC containers on Proxmox, each paired with a dedicated service (BBS, MeshSense, etc.). SIM nodes communicate with your real radio node over UDP and appear as normal nodes on the mesh — clients, maps, and other services can't tell the difference.
This runbook covers deploying meshtasticd SIM (virtual) nodes inside LXC containers on Proxmox, each paired with a dedicated service (BBS, MeshSense, etc.). SIM nodes communicate with your real radio node over UDP and appear as normal nodes on the mesh — clients, maps, and other [[services]] can't tell the difference.
**Design principle:** One container = one SIM daemon + one service. Clean isolation, easy to snapshot, migrate, or tear down without affecting anything else.
@ -135,7 +149,7 @@ General:
- **Every SIM node must have a unique MAC.** If two nodes share a MAC, you'll get node ID collisions and unpredictable behavior.
- The last 3 byte pairs map to a hex color code displayed in client apps.
- Pick a scheme that makes sense for your deployment, e.g.:
- Pick a scheme that makes sense for your [[deployment]], e.g.:
- `DE:AD:00:FF:00:01` — SIM node 1 (BBS)
- `DE:AD:00:00:FF:02` — SIM node 2 (MeshSense)
- `DE:AD:00:FF:FF:03` — SIM node 3 (bot)

View file

@ -1,6 +1,20 @@
---
title: NordVPN / WireGuard in LXC
type: runbook
tags:
- vpn
aliases: []
related:
- [[ct-runbook]]
- [[meshtasticd-sim-nodes-runbook]]
- [[proxmox-onboard-node]]
- [[headscale-onboard-node]]
- [[peertube-remote-runner]]
updated: 2026-06-18
---
# NordVPN / WireGuard in LXC
Set up VPN with IP rotation inside an LXC container. Handles the LXC-specific gotchas: TUN device, systemd compatibility, split tunneling so local services stay reachable.
Set up VPN with IP rotation inside an LXC container. Handles the LXC-specific gotchas: TUN device, systemd compatibility, split tunneling so local [[services]] stay reachable.
---
@ -327,7 +341,7 @@ Split tunneling not configured. The VPN is routing ALL traffic including LAN. Fi
### DNS stops working when VPN is up
NordVPN CLI: `nordvpn set dns off` (use container's DNS, not NordVPN's).
NordVPN CLI: `nordvpn set dns off` (use container's [[dns]], not NordVPN's).
WireGuard: Remove the `DNS =` line from the `.conf` file.
### "Cannot open TUN/TAP dev /dev/net/tun: No such file or directory"

View file

@ -1,3 +1,17 @@
---
title: PeerTube Remote Runner — GPU Transcoding
type: runbook
tags:
- media
aliases: []
related:
- [[add-peertube-channel]]
- [[nordvpn-lxc]]
- [[ct-runbook]]
- [[proxmox-onboard-node]]
- [[headscale-onboard-node]]
updated: 2026-06-18
---
# PeerTube Remote Runner — GPU Transcoding
Deploy a PeerTube remote runner with NVENC GPU transcoding. The runner pulls jobs from PeerTube over WebSocket, transcodes with the GPU, and uploads HLS streams back.

View file

@ -1,3 +1,17 @@
---
title: PostgreSQL Backup (Docker)
type: runbook
tags:
- storage
aliases: []
related:
- [[ct-runbook]]
- [[recon-operations]]
- [[meshmonitor-password-reset]]
- [[matrix-synapse-deployment]]
- [[synapse]]
updated: 2026-06-18
---
# PostgreSQL Backup (Docker)
Automated pg_dump backups for any Docker-hosted PostgreSQL instance. Retention, integrity check, and restore testing included.

View file

@ -1,3 +1,17 @@
---
title: Pi 5 NAS — OMV Provisioning Runbook
type: runbook
tags:
- storage
aliases: []
related:
- [[ct-runbook]]
- [[proxmox-onboard-node]]
- [[proxmox-create-ubuntu-vm]]
- [[headscale-onboard-node]]
- [[environment]]
updated: 2026-06-18
---
# Pi 5 NAS — OMV Provisioning Runbook
SSH into the Pi. The Pi should already be booted with Raspberry Pi OS Lite, Ethernet connected, Radxa Penta SATA Hat installed.
@ -111,7 +125,7 @@ Each drive is used individually — no RAID array.
### Enable SMB (Windows Shares)
1. **Services → SMB/CIFS → Settings** — toggle **Enabled**, click **Save**
1. **[[services]] → SMB/CIFS → Settings** — toggle **Enabled**, click **Save**
2. **Services → SMB/CIFS → Shares** — click **Create** for each shared folder you want accessible from Windows:
- Select the shared folder
- **Public:** No

View file

@ -1,3 +1,16 @@
---
title: "Pipeline & Wrapper Patterns"
type: runbook
tags: []
aliases: []
related:
- [[meshtastic-sidecar-node]]
- [[meshtastic-headscale-runbook]]
- [[headscale-onboard-node]]
- [[idahomesh-vpn-device-setup]]
- [[syncthing-add-node]]
updated: 2026-06-18
---
# Pipeline & Wrapper Patterns
Three composable patterns for adding pre-flight logic to tools and pipelines you don't fully control:
@ -6,7 +19,7 @@ Three composable patterns for adding pre-flight logic to tools and pipelines you
2. **GPU/CPU Fallback Routing***logic you inject*. Probe a job, route small→GPU / large→CPU, and gate concurrency with `flock` so excess jobs fail-fast and re-queue instead of OOM-killing each other.
3. **Pre-Flight Probe Gate***logic you inject*. Cheaply inspect each input and skip the expensive step when the work would be wasted (wrong format, already optimized, corrupt, too large).
They compose: **Pattern 1 is how you deploy; Patterns 2 and 3 are two kinds of pre-flight logic you put inside the wrapper.** The running example throughout is the Whisper auto-captioning / PeerTube transcoder / RECON extraction stack on cortex.
They compose: **Pattern 1 is how you deploy; Patterns 2 and 3 are two kinds of pre-flight logic you put inside the wrapper.** The running example throughout is the Whisper auto-captioning / PeerTube transcoder / [[recon]] extraction stack on cortex.
## Contents
@ -215,7 +228,7 @@ No service restart needed — next invocation hits the real binary directly.
### Example — Whisper transcription routing (PeerTube runner on cortex)
The PeerTube remote runner calls `whisper-ctranslate2` for auto-captioning. The smart wrapper intercepts this to route short videos to GPU and long videos to CPU (the routing logic itself is **Pattern 2** below).
The [[peertube-remote-runner]] calls `whisper-ctranslate2` for auto-captioning. The smart wrapper intercepts this to route short videos to GPU and long videos to CPU (the routing logic itself is **Pattern 2** below).
```
BINARY_NAME=whisper-ctranslate2

View file

@ -1,3 +1,17 @@
---
title: Proxmox — Create Ubuntu VM (Cloud-Init)
type: runbook
tags:
- proxmox
aliases: []
related:
- [[ct-runbook]]
- [[environment]]
- [[proxmox-onboard-node]]
- [[headscale-onboard-node]]
- [[pi-nas-omv-runbook]]
updated: 2026-06-18
---
# Proxmox — Create Ubuntu VM (Cloud-Init)
Automated VM creation using Ubuntu cloud images. No interactive installer needed.
@ -279,5 +293,5 @@ docker exec headscale headscale nodes list
## Post-Creation
1. Update `/home/zvx/projects/.ref/docs/hardware/environment.md` with the new VM's IP and Tailscale IP
2. Update `/home/zvx/projects/.ref/docs/services/services.md` once services are deployed
2. Update `/home/zvx/projects/.ref/docs/services/services.md` once [[services]] are deployed
3. Remove the cloud image ISO if disk space is tight: `ssh root@$PVE_HOST 'rm /var/lib/vz/template/iso/noble-server-cloudimg-amd64.img'`

View file

@ -1,3 +1,17 @@
---
title: "Runbook: Onboard a Proxmox Node"
type: runbook
tags:
- proxmox
aliases: []
related:
- [[ct-runbook]]
- [[headscale-onboard-node]]
- [[proxmox-create-ubuntu-vm]]
- [[edge2-access-reference]]
- [[expose-service-home]]
updated: 2026-06-18
---
# Runbook: Onboard a Proxmox Node
You install Proxmox. You give CC an IP and a root password. CC does the rest.
@ -209,7 +223,7 @@ Add `-o PreferredAuthentications=password -o IdentitiesOnly=yes`.
**Cluster join corosync errors**
Check `/etc/hosts` on all nodes includes the new hostname and IP.
**Authentik realm missing**
**[[authentik]] realm missing**
Check `systemctl status pve-cluster`. Realm syncs via pmxcfs in `/etc/pve/domains.cfg`.
**Can't migrate VMs to node**

View file

@ -1,12 +1,26 @@
---
title: RECON Operations Runbook
type: runbook
tags:
- recon
aliases: []
related:
- [[recon]]
- [[deployment]]
- [[caddy]]
- [[recon-service-integration]]
- [[services]]
updated: 2026-06-18
---
# RECON Operations Runbook
## Service Info
- **Host:** recon-vm (VM 1130 on data node) — migrated from CT 130 on 2026-04-19
- **IP:** 192.168.1.130 / 100.64.0.24
- **Install:** /opt/recon/
- **Install:** /opt/[[recon]]/
- **User:** zvx
- **Services:** `recon.service`, `recon-watchdog.service`, `kiwix.service` (systemd)
- **[[services]]:** `recon.service`, `recon-watchdog.service`, `kiwix.service` (systemd)
## Service Management

View file

@ -1,3 +1,17 @@
---
title: RECON Dashboard Service Integration
type: runbook
tags:
- recon
aliases: []
related:
- [[proxmox-onboard-node]]
- [[recon-operations]]
- [[headscale-onboard-node]]
- [[lxc-service-migration]]
- [[caddy]]
updated: 2026-06-18
---
# RECON Dashboard Service Integration
Add a management UI for a remote service to a Flask/FastAPI dashboard. The pattern: SSH key trust between the dashboard host and the target, scoped sudoers for specific commands, a REST API layer (`GET /api/{service}/status` + `POST /api/{service}/{action}`), and a frontend panel with status indicator, action buttons, and live feedback.
@ -8,7 +22,7 @@ Use this when you have a service running on a remote LXC/VM that needs a web man
## Prerequisites
- A running Flask or FastAPI dashboard (e.g., RECON on VM 1130, WATCHTOWER on Contabo)
- A running Flask or FastAPI dashboard (e.g., [[recon]] on VM 1130, WATCHTOWER on Contabo)
- The target service running on a reachable host (LXC, VM, or bare metal)
- SSH access from the dashboard host to the target host
- The dashboard runs as a known user (e.g., `zvx`, `recon`, `watchtower`)
@ -210,7 +224,7 @@ Must return `success: true`.
## Step 4: Add Frontend Panel
Add a service management panel to the dashboard UI. This goes in the appropriate tab (e.g., Upload, Dashboard, or a new Services tab).
Add a service management panel to the dashboard UI. This goes in the appropriate tab (e.g., Upload, Dashboard, or a new [[services]] tab).
```html
<!-- Service Management Panel: $SERVICE_DISPLAY_NAME -->

View file

@ -1,3 +1,17 @@
---
title: "Syncthing: Add a New Node to the Project Sync Cluster"
type: runbook
tags:
- mesh
aliases: []
related:
- [[ct-runbook]]
- [[proxmox-onboard-node]]
- [[meshtasticd-sim-nodes-runbook]]
- [[idahomesh-vpn-device-setup]]
- [[headscale-onboard-node]]
updated: 2026-06-18
---
# Syncthing: Add a New Node to the Project Sync Cluster
## Overview

View file

@ -1,11 +1,20 @@
---
type: session-resume
title: "Session Handoff — MeshAI radio-drop resilience test"
title: Session Handoff — MeshAI radio-drop resilience test
type: session
tags:
- mesh
aliases: []
related:
- [[meshtastic-sidecar-node]]
- [[meshtastic-headscale-runbook]]
- [[services]]
- [[synapse_retention_discovery]]
- [[caddy]]
updated: 2026-06-18
status: open
created: 2026-06-17
origin: matt-desktop (WSL)
resume-on: cortex
tags: [session-resume, handoff, meshai, meshtastic, aida-nebra, diagnostic, resilience-test, open]
---
# Session Handoff — MeshAI radio-drop resilience test