Attractor — Adaptive Context and Personalization - Source
The parts that turn a proposed memory update into stored state, plus the engine and storage interfaces around them.
README · ARCHITECTURE · TECHNICAL · source
Find an excerpt
- 1. The safeguard layer
- 2. The engine boundary
- 3. Consolidation, and why it is its own call
- 4. Entropy, with a warning attached
- 5. Failing closed
- 6. The storage seam
1. The safeguard layer
applyUpdate is pure — state in, new state out, no I/O — which is what lets the
same guarantees hold whether the caller is the CLI, the Worker, an experiment, or a
test.
/**
* Apply a model-proposed update to state. Pure: returns a new state.
*
* Every proposed change passes through a safeguard here -- deltas are clamped,
* weights are bounded, and untouched basins decay on their own. The model
* proposes; this function decides.
*/
export function applyUpdate(state: AttractorState, update: AttractorUpdate): AttractorState {
const now = new Date().toISOString();
const basins: Basin[] = state.basins.map((b) => ({
...b,
keywords: [...b.keywords],
connections: [...b.connections],
trajectory: [...b.trajectory],
}));
// --- Touched basins: apply deltas and keyword changes ---
for (const bu of update.basin_updates) {
const basin = basins.find((b) => b.id === bu.id);
if (!basin) continue;
basin.weight = Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, basin.weight + bu.weight_delta));
if (bu.new_keywords) {
// Case-insensitive: a model call should not be spent noticing that
// "Claude CLI session" and "claude CLI session" are the same thing.
// Deterministic dedup first, semantic merging later.
const seen = new Set(basin.keywords.map((k) => k.toLowerCase().trim()));
for (const kw of bu.new_keywords) {
const norm = kw.toLowerCase().trim();
if (norm && !seen.has(norm)) {
seen.add(norm);
basin.keywords.push(kw.trim());
}
}
if (basin.keywords.length > MAX_KEYWORDS) {
// Record that the basin is out of room. The caller decides whether to
// consolidate; applyUpdate stays pure and synchronous, because it is
// the safeguard layer and a model call does not belong in it.
basin.capHits = (basin.capHits ?? 0) + 1;
basin.keywords = basin.keywords.slice(-MAX_KEYWORDS);
}
}
}
// ...decay of untouched basins, emergent basins, entropy and trajectory
}
Three separable decisions in one function:
- Bounds are applied unconditionally, not only when the model misbehaves. There is no path where a proposed delta reaches state unclamped.
- Deterministic deduplication before semantic merging. Lowercasing is free; deciding that two differently-worded keywords mean the same thing is a model call. Doing the free one first means the expensive one sees a smaller problem.
- The cap is recorded, not acted on.
capHitsincrements and the caller decides whether to consolidate, because a model call does not belong inside the safeguard layer. That single line is what keeps this function pure.
2. The engine boundary
Two transports, one primitive, and an explicit statement of what would go wrong without it.
/**
* What generates an attractor update.
*
* Two implementations: the Anthropic HTTP API (needs a pay-as-you-go key), and
* the local `claude -p` binary (uses whatever Claude Code is signed in as, so
* a Pro/Team/Max subscription works). Both send the identical prompt from
* `buildUpdatePrompt` and run the reply through the identical `parseUpdate`,
* so neither path can quietly drift from the other.
*/
export interface Engine {
/**
* The single primitive. Both engines must place `system` and `user` in the
* same roles, or any comparison between them measures the asymmetry rather
* than the transport.
*/
call(system: string, user: string, model: string, maxTokens: number): Promise<string>;
complete(prompt: string, model?: string, maxTokens?: number): Promise<string>;
generateUpdate(state: AttractorState, summary: string, vibes: string[]): Promise<AttractorUpdate>;
}
The interface is one method wide because that is the width at which two transports
can be held identical. Anything richer and the HTTP path and the subscription path
start diverging in ways that would show up in the compare command as a finding.
3. Consolidation, and why it is its own call
/**
* Ask for a basin's keywords to be abstracted rather than evicted.
*
* 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.
*
* Deliberately a separate call from the per-conversation update. That 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 asking one call to do both gets neither done well.
*/
export function buildConsolidatePrompt(basin: Basin): string {
115 additions and zero removals across 40 logged updates is the kind of number that changes a design. It says the per-conversation update will never prune on its own, so pruning has to be someone else's job — and that a keyword cap without an abstraction step would silently turn every basin into a description of last week.
4. Entropy, with a warning attached
/**
* Normalized Shannon entropy over basin weights.
*
* Weights are independent values in [0.05, 1], not a probability distribution,
* so they are normalized by their sum before the entropy is taken. The result
* measures how evenly attention is spread, not how uncertain it is.
*
* 0 = one basin dominates completely. 1 = perfectly even spread.
*/
export function computeEntropy(basins: Basin[]): number {
/**
* Classify the system's recent motion from per-basin weight deltas.
*
* Note this reads the *last step only* of each trajectory, so it describes
* the most recent update rather than a longer-run trend.
*/
export function computeTrajectory(basins: Basin[]): Trajectory {
Both docstrings exist to stop a reader over-reading the number. Because weights are normalised by their sum, entropy measures spread, not focus — observed in practice as entropy moving 1.000 → 0.986 across seven updates while the dominant basin went from 50% to 100% of the weight. A metric that barely moves while the thing you care about doubles is worth a warning in the source.
computeTrajectory reads only the last step, so stable means "did not move much
this update," not "has been steady." See the state rules.
5. Failing closed
/**
* Constant-time string comparison, so token checking doesn't leak length or
* prefix information through timing.
*/
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/**
* Every /api/attractor/* route requires `Authorization: Bearer <token>`
* matching the ATTRACTOR_TOKEN secret. Set it with:
*
* wrangler secret put ATTRACTOR_TOKEN
*
* If the secret is unset the Worker refuses all requests rather than serving
* them openly -- `seed` can wipe state and `ingest` spends your Anthropic key,
* so failing closed is the only safe default.
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const path = new URL(request.url).pathname;
if (!path.startsWith("/api/attractor")) return error("Not found", 404);
if (!env.ATTRACTOR_TOKEN) {
return error("Server misconfigured: ATTRACTOR_TOKEN is not set", 500);
}
const auth = request.headers.get("Authorization") ?? "";
const presented = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!presented || !timingSafeEqual(presented, env.ATTRACTOR_TOKEN)) {
return error("Unauthorized", 401);
}
// ...
},
} satisfies ExportedHandler<Env>;
The missing-secret branch returns 500 rather than falling through to an open Worker. The comment states the two specific consequences that make open access unacceptable — state loss and spend — rather than appealing to security in general.
The a.length !== b.length early return does leak length, which is the standard
tradeoff for a fixed-length token and worth knowing is there.
6. The storage seam
/**
* Where attractor state lives.
*
* Two implementations ship: a JSON file for local runs, and Cloudflare KV for
* the hosted Worker. The model doesn't know or care which -- `applyUpdate` is
* a pure function over plain state, so the safeguards travel with it.
*/
export interface Store {
load(): Promise<AttractorState | null>;
/** `by` records which engine and model produced this state, when known. */
save(state: AttractorState, by?: Provenance): Promise<void>;
history(): Promise<HistorySnapshot[]>;
}
/** Snapshots kept. This is a trend line, not an archive. */
const MAX_HISTORY = 10;
save takes a Provenance so a snapshot records which engine and model produced
it. That is what makes the two engines comparable after the fact instead of only
during a compare run.