Skip to content

Repository files navigation

EdgeDancer: a durable personal-knowledge brain with a disposable embedding index

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.

MIT license Python 3.11+ zero third-party dependencies

The ideas this is built on

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.

Quickstart

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-brain

find 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.

The safety boundary, stated plainly

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:

  1. The note-write gate is the earliest and strongest control. write_note takes a required gate_findings parameter and a non-empty list raises before any bytes hit disk. A secret that never enters a commit cannot be shipped by any bypass.
  2. 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).
  3. 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 doctor reports it as CRITICAL.

Named residual risks

Carried openly, because unresolved items ship as named risk, not silence:

  • --no-verify is not preventable client-side. The only real closure is a server-side pre-receive hook 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 serve runs, 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 in bin/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.

The brain is readable without this software

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.

Non-goals

Stated as refusals, not as "not yet":

  • No vector database. Brute-force cosine over a packed array('f') with math.sumprod measured 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_choice key anywhere in the tree and a test greps for it.
  • No third-party dependency, at any version. brain/schema.py hand-rolls a restricted-YAML frontmatter parser and a test greps for import 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 .timer exists anywhere.
  • The UI never executes. subprocess appears in exactly one module of the edgedancer/ 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 only bin/brain-init.py (the git ceremony) and the optional relay use it, and a test pins that exact three-file set.

Commands

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

Refusals

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"

Console

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.

The ingest relay (optional)

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 pull decrypts 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 as author_kind=model, UNCORROBORATED, with relay provenance.

Deployment walkthrough: docs/relay-deploy.md. Custom GPT wiring: docs/chatgpt-action.md. Inbox lane: docs/inbox.md.

Config reference

Two TOML files, and nothing else is meant to be edited by hand.

providers.toml (in the code repo)

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.

sources.toml (in the brain)

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.

Tests

python3 -m unittest discover -s tests -q ; echo $?
python3 -m pytest tests/ relay/tests -q          # same suite, either runner

Never 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:

  1. test_embedspace.py::test_query_after_model_swap_raises_no_index_for_space
  2. test_store.py::test_corrupt_vectors_after_write_blocks_promotion
  3. test_gate_fixtures.py::test_baselining_a_fixture_hash_is_rejected
  4. test_hook.py::test_negative_control_asserts_on_sentinel_not_rc
  5. test_push.py::TestPushedObjectScan (the quarantine-laundering closure)

Gotchas worth stealing

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; bfs rejects GNU relative timestamps, exits non-zero, and pipes into wc -l as 0, 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 exports ALL_PROXY cannot make a healthy loopback endpoint read as down. A partial env -u HTTPS_PROXY is not equivalent.
  • core.hooksPath does not survive a clone. A freshly cloned brain has the hook in its tree and no active hook; brain-init.py --adopt re-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.

License and contributing

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.

About

A durable brain and a disposable index: local-first retrieval that refuses to guess when the model changes.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages