adaptogen
v0.1.5
Published
Agent-owned, self-evolving DAG+FSM state: durable memory, policy and intuition in one event-sourced store, with soft/hard transition enforcement.
Maintainers
Readme
adaptogen
An agent-owned, self-evolving state system. One LLM agent interacts with it directly -- there is no second model in the loop. The agent builds a graph that is, at the same time, its memory, its policy, and its intuition, and keeps reshaping that graph while it works.
The graph is a hybrid:
- a DAG of dependency edges (what must happen before what), kept acyclic, and
- an FSM of transition edges (which state can follow which), with a cursor the agent moves along.
Every node is a memory cell (its payload). Every transition edge carries policy
(a guard plus soft/hard enforcement) and accrues intuition (visit/reward stats
that drive suggest()). Zones let the agent fence off "safe limited transition
zones" it may move within freely while crossing their boundary stays governed.
memory policy intuition
(node.payload) (guard + enforcement) (edge stats -> suggest)
\ | /
\ | /
+----------- one graph --------------+
DAG (deps) + FSM (transitions)Why event-sourced over SQLite
The spine is an append-only, hash-chained event log; the queryable graph is a projection of it (SQLite via libsql, WAL). This gets durability, crash recovery, time-travel, audit, and deterministic replay from one mechanism, which is exactly what "construct your own persistence and continuously evolve it reliably" needs.
It is buildless JavaScript (ES modules, no compile step) on Bun, persisting
through a synchronous libsql client behind a thin db facade. Dependencies were
surveyed and the FSM/graph libraries rejected in favor of a smaller maintained
surface:
- XState models statically-declared machines with code guards; adaptogen's
machine is constructed and mutated at runtime, carries intuition + persistence,
and runs agent-authored guards through a sandboxed DSL (never
eval). Poor fit. - graphology is in-memory only; topo sort and cycle detection are ~20 lines here and must integrate with the persistent projection anyway.
- libsql is the one runtime dependency: a synchronous SQLite that runs under Bun and plain Node, keeping the store portable off Bun while staying buildless.
Net: libsql + hand-rolled small graph algorithms + no FSM library.
Install / run
bun install
bun test # unit tests
bun test.js # end-to-end integration witness
bun run bench # large-graph hot loop + recovery timingRequires Bun and the libsql package. SQLite with FTS5 is used for recall, with
a LIKE fallback when FTS5 is absent.
Quick start
import { Adaptogen } from "adaptogen"; // `DState` is also exported as an alias
const ds = Adaptogen.open("./agent.db"); // recovers, locks, seeds a starter model
ds.remember({ id: "research", payload: { topic: "caches" } }); // memory
ds.remember({ id: "draft" });
ds.link("research", "draft"); // a transition edge
ds.depend("draft", "research"); // draft depends on research (DAG)
ds.setCursor(["research"]);
ds.transition("draft"); // move the cursor along the FSM
ds.reward(1); // reinforce the path just taken
console.log(ds.render()); // ASCII live view of the current state
ds.close();Driving it as an agent
The tight loop -- rank the legal moves, take the best, learn from the result --
is one call. step() composes suggest -> transition -> reward and tells you
what happened and whether anything is left:
const ds = Adaptogen.open("./agent.db");
ds.setCursor(["plan"]);
let s = ds.step({ reward: 1 }); // take the top-ranked move, reward it
while (s.ok && !s.value.done) {
// s.value: { to, suggestion{breakdown}, applied, soft_warned, denied, done }
s = ds.step({ reward: 1 });
}step({ to }) forces a target; omit reward to move without reinforcing. On a
dead end it returns a typed NoMoves fail whose error.details.hint names the
recovery. Every Result failure an agent can hit carries such a hint, and
describe().errorHints maps each code to its recovery. describe().patterns
holds runnable snippets for the common flows (step loop, checkpoint/rollback,
reward decay, zones), so a cold agent learns usage without reading source.
Guard DSL
A transition edge can carry a guard: a sandboxed boolean expression (never
eval) evaluated against a read-only context. It is loop-free and depth/length
bounded; an unknown path reads as undefined (comparisons against it are
false), so a guard never throws.
Context: from, to, fromTags, toTags, fromKind, toKind, edge.label,
edge.weight, stat.visits, stat.emaReward, stat.successes,
stat.failures, and vars.* (passed per transition(to, vars)). Operators:
&& || ! == != > >= < <= plus in (membership in a literal array) and has
(array/string contains).
ds.link("review", "ship", { guard: "stat.failures == 0 && vars.approved == true" });
ds.link("draft", "review", { guard: "toTags has 'ready'" });Full grammar, operators, and examples are in describe().guardDSL.
The verb surface
Memory
remember({id, kind?, label?, payload?, tags?, status?, expectVersion?})-- create/update a node;payloadis the memory. Optimistic concurrency viaexpectVersion.recall({id?|kind?|tag?|status?|text?|embedding?, limit?})-- query nodes by id/kind/tag/status, full-text (FTS5, LIKE fallback), or cosine similarity over a suppliedembedding.getNode(id),archive(id),deprecate(id).
Structure
link(from, to, {id?, kind?, label?, guard?, enforcement?, weight?})-- transition or dependency edge.weightmust be a finite number>= 0.depend(node, prereq)-- dependency edge; rejected with the cycle path if it would close a loop.unlink(edgeId),setEnforcement(edgeId, mode).ready(done?),topo(),reachable(from, kind?),ancestors(id),descendants(id).
FSM
setCursor(nodes),cursor().transition(to, vars?)-- legality + guard + zone + enforcement + record + stats, all at once. Returns a decision trace.legalMoves(vars?),explainTransition(to, vars?).
Zones (safe limited transition zones)
defineZone(name, members, {intra?, boundary?}),addToZone,removeFromZone,zonesOf(id),zones().deriveZone(seed, predicate?)-- the agent maps out a safe zone automatically from the reachable subset satisfying a guard predicate, then ratifies it.
Intuition
suggest(vars?)-- ranks legal moves by a learned value (UCB by default; epsilon/greedy configurable), each with aconfidenceand abreakdownof{reward, weight, explore}so you can see why a move ranked where it did.step({to?, vars?, reward?})-- one-callsuggest -> transition -> reward; returns{to, suggestion, applied, soft_warned, denied, done}.reward(value, {edgeId?|trace?, depth?, lambda?})-- single-step, or decayed multi-step credit:reward(1, {trace: true, depth: 3, lambda: 0.6})reinforces the last 3 transitions with exponential decay.getStat("node"|"edge", id),ftsEnabled().
Self-evolution
splitState,mergeStates,gc,migrate(kind, fn).optimize()-- mines graph + stats for dead nodes, duplicate/low-value edges, soft->hard promotion candidates, zone tightening.reweight(),selfIterate()-- one safe closed loop: reweight -> apply safe suggestions -> validate -> rollback on regression.
Durability & integrity
checkpoint(name),rollback(name),branch(file)/discard().snapshot(),compact(retain?).validate(),repair(),verifyIntegrity().
Observe & port
render(),metrics(),toMermaid(),toDot(),history(filter?).describe()-- machine-readable manifest of the whole verb surface, error codes, guard DSL grammar, and enforcement levels, so an agent can introspect the API without reading source.export()/importState(file, bundle).setTunable(key, value)/getTunables().
Soft vs hard enforcement
A transition is allowed unless a policy reason applies: a failing guard, a zone
boundary crossing, or an above-off intra-zone policy. Each reason is governed
by an enforcement mode and the strictest decision wins:
off-- allowed (a note in the trace).soft-- allowed, but flagged and counted; afterescalationThresholdsoft violations the edge auto-promotes tohard.hard-- blocked; aBlockedAttemptis recorded with the reason; the cursor does not move.
Edge enforcement overrides zone, which overrides the global default. An edge set
to off is the explicit gate that lets an otherwise-blocked crossing through.
explainTransition returns the full deciding trace.
Self-iteration loop
transition outcome -> stats -> optimize() suggestions
^ |
| v
validate() <---- apply safe edits (gc, promote, reweight)
|
+-- on broken invariant: rollback to the pre-iteration checkpointselfIterate() runs exactly one safe pass and reports the deltas, so the agent
iterates on its own abilities without ever leaving the graph in an invalid state.
Call optimize() to inspect candidate edits (dead nodes, duplicate/low-value
edges, soft->hard promotions) without applying them, and selfIterate() once per
episode to apply the safe subset under a checkpoint that rolls back on regression.
Zones fence off a region the agent moves within freely while crossing the
boundary stays governed: defineZone(name, members, {intra, boundary}) sets the
in-zone vs boundary enforcement, and deriveZone(seed, predicate?) auto-derives
the members from the reachable subset satisfying a guard predicate, then ratifies
them. Use a checkpoint around a risky exploration and rollback(name) if
validate() does not hold.
Durability model
- WAL + hash-chained, checksummed events.
- Boot
recover()verifies the chain, trims a torn/partial trailing write to the last good seq, loads the newest snapshot at/under head, and replays the tail. - Snapshots + compaction bound replay cost; recovery time is a function of the snapshot tail, not the whole log.
CLI
The CLI is a thin shell over the JS facade; an agent can drive a full session
(inspect and mutate) without writing JS. ASCII output, conventional exit codes,
--json for parseable status/history.
Inspect:
adaptogen status --db ./agent.db # cursor, ranked moves, ready frontier, violations
adaptogen status --json # same, as structured json for an agent to parse
adaptogen describe # machine-readable manifest (verbs, errors, guard DSL, tunables)
adaptogen graph / dot # mermaid / graphviz export
adaptogen suggest # ranked next moves (json, with score breakdown)
adaptogen explain <to> # decision trace
adaptogen legal-moves [--vars '<json>'] # all non-denied moves from the cursor
adaptogen validate # invariants + integrity (exit 1 if invalid)
adaptogen history [n] [--json]
adaptogen get <id> # node by id
adaptogen recall --text <q> [--kind --tag --status --embedding '<json>' --limit]Mutate:
adaptogen remember <id> [--kind --label --payload '<json>' --tags a,b --embedding '<json>']
adaptogen link <from> <to> [--kind --label --guard '<expr>' --enforcement --weight]
adaptogen depend <node> <prereq> # node depends on prereq (DAG edge)
adaptogen unlink <edgeId>
adaptogen enforce <edgeId> <off|soft|hard>
adaptogen archive <id> / deprecate <id>
adaptogen cursor [ids...] # print cursor, or set it
adaptogen transition <to> [--vars '<json>']
adaptogen step [to] [--reward <v>] [--vars '<json>'] # suggest -> transition -> reward
adaptogen reward <value> [--edgeId <id>]Zones:
adaptogen zone-define <name> <id,id,...> [--intra off|soft|hard] [--boundary ...]
adaptogen zone-add <name> <id> / zone-remove <name> <id> / zone-listDurability:
adaptogen checkpoint <name> / rollback <name> / checkpoints
adaptogen compact [retain]
adaptogen export <file> / import <file>License: MIT.
