Attractor — Adaptive Context and Personalization - Architecture
README · ARCHITECTURE · TECHNICAL · source
The shape of it
Two engines, two stores, and one shared core for applying state changes.
applyUpdate keeps the state-transition rules in one place, without storage or
model I/O. The CLI, Worker, and comparison flow all use it, so their normal write
paths share the same bounds and decay behavior.
Why two engines
The Engine interface has one primitive, call(system, user, model, maxTokens),
and both implementations must place system and user in the same roles.
The reason is stated in the source: any comparison between them measures the
asymmetry rather than the transport. Both send the identical prompt from
buildUpdatePrompt and run the reply through the identical parseUpdate, so
neither path can quietly drift from the other.
The practical motivation is access. The HTTP engine needs a pay-as-you-go API key.
The claude -p engine uses whatever Claude Code is signed in as, so a
subscription works. That makes the tool runnable by someone who has Claude but no
API billing — and it makes the two paths comparable, which is what the compare
command exists to exploit.
The update cycle
The prompt asks for changes of about ±0.1. applyUpdate clamps to ±0.3 regardless
of what comes back. That gap is deliberate: the prompt sets the intent and the code
sets the limit, so a model that ignores the instruction cannot swing the system.
The safeguard layer
parseUpdate does not trust the model to return well-formed shapes or honest
numbers. It clamps model-proposed deltas, and applyUpdate bounds the resulting
weights. Direct callers still need to validate finite numeric deltas before they
apply an update.
| Rule | Value | Why |
|---|---|---|
| Delta clamp | ±0.3 | The prompt asks for ±0.1; this is the hard limit if it does not comply |
| Weight floor | 0.05 | A basin goes dormant, never away, and a dormant basin can reactivate |
| Weight ceiling | 1.0 | — |
| Decay of untouched basins | 5% of the gap to 0.3 per update | Half-life ≈ 13.5 updates: drifts, does not swing |
| Seeded basin start | 0.5 | — |
| Emergent basin start | 0.4 | A new basin has to earn its place |
| Keywords per basin | 10, deduplicated case-insensitively | — |
The full derivation is in the state rules.
Keyword consolidation is a separate call
When a basin fills its keyword slots for the second time, its keywords are rewritten as five more general ones rather than evicted by recency.
The source gives the reason: eviction by recency means a basin's keyword list describes its last few conversations instead of its identity — the concepts that founded it get pushed out by whatever arrived most recently. Abstraction keeps the shape and drops the specifics, which is what a mode of engagement is, as opposed to a topic.
It is a separate model call on purpose. The per-conversation update answers a local question ("what did this conversation do?") and is purely additive in practice: across 40 logged updates it proposed 115 keyword additions and zero removals. Abstraction is a global question about the whole basin, and one call asked to do both does neither well.
Storage
History is capped at 10 snapshots, each holding only the timestamp, per-basin weights, and which engine and model produced it. The source is explicit about what that is: a trend line, not an archive. It is enough to compute a trajectory and not enough to study one — which is a real limit on the study plan, and why that study freezes state and records versions explicitly rather than reading live.
Surfaces
| Surface | Commands or routes |
|---|---|
| CLI | seed, ingest, sessions, compare, runs, history, context, show |
| Worker | GET /api/attractor, POST /seed, POST /basins, GET /history, POST /trigger, POST /ingest-transcript, POST /ingest |
| Web | AttractorView.jsx — a force-directed view of basins and connections |
attractor context prints the text block a calling application includes in its
next model request. Attractor does not inject anything itself; the application
using it is responsible for that, which keeps the tool honest about where its
output goes.
Failing closed
Every /api/attractor/* route requires a bearer token matching the
ATTRACTOR_TOKEN secret. Equal-length token contents are compared without an
early exit; unequal lengths are rejected first.
If the secret is unset the Worker refuses all requests rather than serving them
openly. The reason is written into the source: seed can wipe state and ingest
spends your Anthropic key, so failing closed is the only safe default.
Snapshot
This architecture describes source snapshot 1ad1783. Recorded development
examples help explain state changes; they are separate from a controlled
evaluation.
Read next
- The mechanics — every rule and constant, derived
- Running it — local and hosted setup
- source · Technical
- Project overview