A durable personal-knowledge brain with a disposable embedding index. Stdlib-only Python. No vector database. No network except the model calls you configure, to hosts you allowlist.
1. A model swap is a missing directory, not a plausible number.
Vectors from two different embedding models are not comparable, and most
retrieval tools will happily compare them anyway: the stored index loads,
ranks, and answers, silently wrong. EdgeDancer names every index directory
after a fingerprint computed from the vectors a frozen canary battery
actually produces on the live endpoint, re-probed on every query. Swap the
model and the query resolves to index/spaces/<new-fingerprint>/, which does
not exist: NoIndexForSpace, exit 3, and a printed rebuild command. The
fingerprint is content-derived because declared identity is falsifiable in
practice: one runtime was measured serving already-loaded f16 weights for a
quantized-model request while echoing back a model id it was not serving.
2. Refuse rather than guess. The chat lane (ask) probes a behavioural
identity canary (ranked logprobs at temperature 0, never completion text)
before every answer, keeps a TOFU fingerprint per (provider, model), and
backs it with a disk-persisted circuit breaker. A mismatch refuses the answer
and trips the breaker; a matching canary afterward does not clear a trip on
its own. The same posture runs through the whole tree: an unclassifiable
config value disables, an unparseable note refuses to write, a missing gate
dependency refuses to push.
3. Gate what the push transfers, not what the tree contains. The
pre-push leak gate scans the working tree and every blob the push would
actually transfer. The second scan exists because the first one had a
documented laundering path: commit a secret, watch the gate refuse, follow
the documented remediation (note quarantine, which moves the note to a
gitignored directory), and the tree reads clean while the secret ships in
history forever. That was a real hole, reproduced end to end before it was
fixed, and the fix is pinned by a test that asserts the tree really is clean
at the moment the object scan refuses.
4. UNCORROBORATED is a first-class epistemic state. Every note carries
author_kind and an epistemic grade. A model-authored note that claims
proven, verified or measured with an empty evidence list is refused
at write time, by name. Anything asserted without evidence renders as
UNCORROBORATED in find, ask and the console, so a confabulated claim can
never wear the clothes of a measurement.
5. Never a second, gentler write path. Every durable write in the tree,
CLI or console, goes through one gated function (notes.write_note, with a
required gate_findings parameter), so there is no syntactically valid
ungated write. The importer that converts an assistant-written document tree
into notes is a converter, not a copier, for exactly this reason.
The full argument, the measured calibration, and the honest proven/inferred split are in DESIGN.md.
docs/QUICKSTART.md is the full walkthrough. The short
version, which takes under ten minutes with any OpenAI-compatible embedding
server (LM Studio, llama-server, Ollama's OpenAI endpoint):
git clone <this-repo> edgedancer && cd edgedancer
# 1. Point [active] embed at your embeddings endpoint (edit providers.toml).
# The shipped file is a commented example; LM Studio's default
# http://127.0.0.1:1234/v1 works as-is.
# 2. Initialise a brain: git repo, pre-push hook (proven to fire by a
# negative control), seed leak baseline, starter sources.toml.
python3 edgedancer.py init --brain ~/edgedancer-brain
# 3. Give it something to find: the shipped starter notes, plus optionally
# your own directories as read-only roots (edit the brain's sources.toml).
cp brain-template/notes/*.md ~/edgedancer-brain/notes/
# 4. Build the index. The leak gate scans every file BEFORE any embedding
# call; the space id is derived from your endpoint's actual vectors.
python3 edgedancer.py index build --brain ~/edgedancer-brain
# 5. Search.
python3 edgedancer.py find "what lives in the durable layer" --brain ~/edgedancer-brainfind needs only an embeddings endpoint. ask (retrieval plus a cited,
identity-checked chat answer) additionally needs a chat endpoint that returns
logprobs. The console (serve) needs neither to start.
Note: doctor will report the missing brain remote until you point
layout.PINNED_REMOTE_URL at a private remote you control and run
link-remote. That is honest state, not breakage: the shipped default is a
placeholder on a reserved TLD (.invalid) so an unedited default can never
push anywhere real.
git push --no-verify bypasses the pre-push hook. Reproduced live
against a hook that unconditionally exits 1: the push succeeded and the ref
landed. The hook is a speed bump against accident, not a wall against
intent, and the repo says so in three places that cannot drift apart: the
hook file itself, this README, and a test named
test_no_verify_bypasses_the_hook_and_leaves_no_receipt.
The design is therefore prevention where prevention works, detection where it provably does not:
- The note-write gate is the earliest and strongest control.
write_notetakes a requiredgate_findingsparameter and a non-empty list raises before any bytes hit disk. A secret that never enters a commit cannot be shipped by any bypass. - The pre-push gate scans two things, because neither covers the other: the working tree (a secret in an earlier unpushed commit survives a delta scan) and every blob the push would transfer (a secret committed and then removed is gone from the tree while it stays in history forever).
- The receipt ledger is written by the hook itself, once per ref it
clears. An unreceipted commit on the pinned remote means the hook did not
run, and
doctorreports it as CRITICAL.
Carried openly, because unresolved items ship as named risk, not silence:
--no-verifyis not preventable client-side. The only real closure is a server-sidepre-receivehook on the git server that hosts your brain remote. Until you install one there, the receipt ledger is detection, not prevention.- A loopback socket carries no uid boundary. While
serveruns, any local process under any uid can reach the console and, through it, the brain. The CSRF layers are browser-shaped; they do not authenticate a non-browser local client. Run the console only on machines where every local user is you. - The hook version sentinel is stuck at
v1. The upgrade path compares hook versions to refuse silent downgrades, but the one real security fix in the hook's own history never bumped the sentinel, so version comparison cannot catch that class; content-hash comparison is the fallback and it cannot tell a fix from a tidy. Documented inbin/brain-init.py. - Chat identity uses an exact fingerprint hash, and some runtimes are not bit-stable across their own restarts. Measured on llama.cpp/Vulkan: the same GGUF, flags and seed produced different logprob values (and even rank order) after a server restart, while staying byte-identical within one process. So a routine model-server restart can trip the identity refusal. The refusal still fails closed and the message tells you to check for a restart first; a measured-threshold comparison (like the embedding side already uses) is the known better design and is future work.
- Gate coverage erosion is undetected. The anti-erosion fixtures catch a baseline edit that blinds a detector class they model; they say nothing about secret classes the detector set does not model at all.
If EdgeDancer disappears, the brain does not. Everything durable is plain UTF-8:
notes/<slug>.md markdown with a YAML-subset frontmatter block
journal/YYYY-MM.jsonl one JSON object per line
INDEX.md / ARCHIVE.md one markdown line per note, Q: ... -> A: ...
BRAIN-MANIFEST.json schema version, hook sha256, remote url
README-BRAIN.md written INTO the brain, explains the layout
leak-baseline.json the gate's exact-hash exemptions
sources.toml declared read-only external roots
cat, less and python3 -m json.tool are a complete toolchain for reading
it. A version-skewed harness may be refused a WRITE (BrainTooNew); it is
never refused a READ.
Stated as refusals, not as "not yet":
- No vector database. Brute-force cosine over a packed
array('f')withmath.sumprodmeasured 0.190s over a realistic 22,000 x 768 corpus. The decision to shard or quantize is gated on measured p95 latency, not taste. - No knowledge graph. Wiki-links resolve prefix-aware and that is all.
- No multi-agent fan-out. No tool-calling. There is no
tool_choicekey anywhere in the tree and a test greps for it. - No third-party dependency, at any version.
brain/schema.pyhand-rolls a restricted-YAML frontmatter parser and a test greps forimport yaml. - No unit runs on the machine holding the brain; nothing unattended.
The single checked-in unit,
relay/edgedancer-relay.service, belongs to the optional relay and targets a separate host; a test pins it as the only one. No.timerexists anywhere. - The UI never executes.
subprocessappears in exactly one module of theedgedancer/package (edgedancer/exec/run.py) and no UI module can reach it by any chain of first-party imports; two tests assert this by name. Outside the package onlybin/brain-init.py(the git ceremony) and the optional relay use it, and a test pins that exact three-file set.
python3 edgedancer.py # task-oriented menu
# write a note (SCQA-lite: q is the Question, description is the Answer)
python3 edgedancer.py note new why-disposable-index \
--q "Why is the embedding index disposable?" \
--description "Vectors are derived data; only notes are the record." \
--node-type lesson --type project
python3 edgedancer.py index-md # regenerate INDEX.md from the notes
python3 edgedancer.py index build # gate, probe, embed, publish
python3 edgedancer.py index rollback # a symlink move, not a re-embed
python3 edgedancer.py find "query" -k 8
python3 edgedancer.py ask "query" -k 8 # cited, identity-checked answer
python3 edgedancer.py show <chunk_id> # provenance: MATCH / CHANGED / GONE
python3 edgedancer.py gates scan # the fail-closed leak gate, by hand
python3 edgedancer.py gates triage # ATTENDED: prints raw candidates
python3 edgedancer.py doctor # D1-D9 health detectors
python3 edgedancer.py push # gate, then git push
python3 edgedancer.py serve # loopback console on 127.0.0.1:8722
python3 edgedancer.py breaker list
python3 edgedancer.py breaker reset --provider <name> --model <id>All commands take --brain <path> (default: ~/.edgedancer/brain).
Exit codes, pinned in edgedancer/errors.py and consumed by the hook:
| code | meaning |
|---|---|
| 0 | ok |
| 1 | failed |
| 2 | usage error |
| 3 | embedding space drift / chat identity refusal / open breaker |
| 4 | gate erosion: the baseline has blinded a detector class |
| 75 | EX_TEMPFAIL: the provider looks down or restarting, retry later |
Designed refusal, not designed success, is the core claim of this tool: every row below is a place EdgeDancer stops rather than guesses.
| exit | when | what is pinned |
|---|---|---|
| 1 | an endpoint is asked for a capability it does not declare | refused before a single HTTP request; a test asserts zero requests reach the stub |
| 3 | the current embedding model has no index directory | find/ask refuse rather than search a different space under the same name |
| 3 | the chat identity canary no longer matches the stored fingerprint | the answer is refused and the breaker trips rather than answering on unverified weights |
| 3 | the breaker is already open for a (provider, model) pair | the next ask short-circuits (measured 0.091s) with no provider contacted |
| 75 | the provider is unreachable or restarting | first-call failure is distinct from mid-run failure (exit 1), so "retry" is distinguishable from "investigate" |
A loopback web UI (127.0.0.1:8722, stdlib only, no build step) over the
same gated functions the CLI calls: Triage, New note, Find,
Ask, Doctor, Spaces, Journal. It renders corpus text and it
never executes anything. The bind address is a module constant, not a config
key; the brain is the most sensitive artifact this tool touches, and a
loopback console must never quietly become a network one.
relay/ is a single-file, stdlib-only HTTP service you can run on a small
public host so that external assistants (a Custom GPT Action, a remote chat)
can submit markdown items that your machine later pulls, without opening
any inbound surface on the machine that holds the brain:
- Submit/pull token split: the submit token cannot read anything, the pull token cannot submit. Constant-time compares; auth failures are logged in a stable fail2ban-friendly line format, and a pre-auth failure budget rate-limits floods before any token compare.
- Encrypt on receipt: each item is gpg-encrypted to a dedicated inbox public key the moment it arrives; the private key exists only on the pulling machine. The relay serves ciphertext and never logs bodies.
- Pull side fails closed:
edgedancer inbox pulldecrypts against a dedicated keyring, gate-scans the plaintext before staging it, and converts items into notes only through the gated importer path, where they arrive asauthor_kind=model, UNCORROBORATED, with relay provenance.
Deployment walkthrough: docs/relay-deploy.md. Custom GPT wiring: docs/chatgpt-action.md. Inbox lane: docs/inbox.md.
Two TOML files, and nothing else is meant to be edited by hand.
| key | meaning |
|---|---|
name |
the id [active] and --provider refer to |
base_url |
must be an IP literal or localhost, or it is a config error |
api_key_file |
path only, never read into a log or a repr. Auth is keyed per (host, port), never per model id |
caps |
subset of {"embed", "chat"}, checked against [active] at load |
queue_group |
endpoints behind one --parallel 1 worker; nothing fans out across a group |
enabled |
must be the literal boolean true, or absent. "true" and 1 resolve to DISABLED |
[active] embed = "<name>" selects the embedding endpoint; changing it is a
declared full-rebuild event. The transport wall is separate:
providers/http.py::LOCAL_HOSTS allowlists loopback only, and adding your
LAN model server's address there is deliberately a reviewed code edit, not a
config key.
Declared read-only external roots. Nothing here is ever written to; only chunk text plus provenance enters the index.
| key | meaning |
|---|---|
version |
must be 1 |
[defaults] max_file_bytes |
per-file cap, default 250000 |
[[root]] name |
namespaces the stored relpath |
[[root]] path |
absolute (~ is expanded) |
[[root]] include / exclude |
glob lists |
[[root]] enabled |
same literal-true rule |
A root that is declared, enabled and MISSING is a hard error. Silently shrinking the corpus is how retrieval quietly gets worse without anyone noticing.
python3 -m unittest discover -s tests -q ; echo $?
python3 -m pytest tests/ relay/tests -q # same suite, either runnerNever pipe the runner through tail or head. A pipeline exits with the
last command's status, so | tail masks the runner's exit code and a failing
suite reads as green. Check $? unmasked, or set -o pipefail first.
Every test is hermetic: provider I/O goes through a deterministic in-process fake, and the only real sockets are ephemeral loopback ports a test binds itself. Git tests use real temp repos and real temp bare remotes, never a network remote.
The tests that ARE the design, and which no change may weaken:
test_embedspace.py::test_query_after_model_swap_raises_no_index_for_spacetest_store.py::test_corrupt_vectors_after_write_blocks_promotiontest_gate_fixtures.py::test_baselining_a_fixture_hash_is_rejectedtest_hook.py::test_negative_control_asserts_on_sentinel_not_rctest_push.py::TestPushedObjectScan(the quarantine-laundering closure)
Every one of these cost real debugging time.
- A pipe masks the exit code. The single most expensive gotcha here.
- Not every userland is GNU. uutils coreutils rejects
tail -5 -- file;bfsrejects GNU relative timestamps, exits non-zero, and pipes intowc -las0, which reads exactly like "no matches". All staleness arithmetic in this repo is done in Python for that reason. - Proxy env vars poison loopback HTTP. The one HTTP client in the tree
builds
urllib.request.ProxyHandler({})(an empty dict) so a session that exportsALL_PROXYcannot make a healthy loopback endpoint read as down. A partialenv -u HTTPS_PROXYis not equivalent. core.hooksPathdoes not survive a clone. A freshly cloned brain has the hook in its tree and no active hook;brain-init.py --adoptre-arms it and doctor D1 checks instead of assuming.- A fresh repo's first push can fail before any hook runs, so the bootstrap negative control asserts on the hook's own sentinel line in stderr, never on the exit code alone.
- Keep pasted lines under ~100 characters. A wrapped paste can land a real newline inside a quoted string; the config parser passes it and the breakage surfaces layers away. The hook is under the limit and a test enforces it.
- When a gate fires on your own correct behaviour, change the behaviour,
not the gate. The generation id used to trip the leak gate's entropy
detector intermittently (128 of 200 realistic ids). The fix went into the
id format (a
.separator outside the entropy character class), not into widening the gate. Both numbers are recomputed by a test, never remembered.
MIT, see LICENSE. Issues and questions are welcome, see CONTRIBUTING.md. This is a personal tool published because its ideas earned their keep; it is not seeking feature velocity.