The loom is the entity's autobiography. Every turn you and your children take
is recorded here; with durable storage, the loom persists across summonings
and prior turns are available as loom.turns.
Append-only durable reality for an entity.
The loom keeps the turn-shaped surface used by the runtime while also storing generic events. Compaction and prompt folding are projections over this record; they do not delete the underlying turns or events.
Later evolution work can project richer views from this event log, but this module intentionally stays generic: append events, append turns, graft child subtrees, and extract threads.
Persistence and rehydration
When a storage backend implements the optional load/1 callback, new/2
rehydrates the in-memory events and turns lists from durable state.
That is what lets a Familiar summoned a second time against the same
loom_path resume with its prior turns accessible via loom.turns.
The on-disk projection round-trips Elixir-native terms faithfully:
tuples and atoms are tagged on write (%{"__t__" => [...]},
%{"__a__" => "name"}) and restored on load. Atom restoration is
bounded to atoms that already exist in the runtime VM — unknown atom
names stay as strings rather than risk atom-table pollution.
The only unrestorable values are functions, PIDs, refs, and ports —
these survive as opaque %{"__inspect__" => "<...>"} placeholders so
they remain visible in the on-disk record without pretending to
reconstitute live process state.
One narrow shape doesn't round-trip cross-session: atom-keyed maps
inside user values (e.g., a done.(%{token: "mango"}) answer where
the map keys are atoms rather than strings). Those keys come back as
strings on a fresh session — an entity reading them via loom.turns
uses m["token"] instead of m.token. Atom keys at structural
positions (turn fields, observation fields, keyword-list binding
entries) do round-trip; the limit is specifically for arbitrary
user-provided maps. The trade-off was deliberate: full atom-key
tagging would invasively change the on-disk format for every map,
and the workaround is bounded.
Summary
Functions
Append a user/parent intent — the human's contribution to the conversation, the input that drives a cast/send episode.
Branches cantrip from a prefix of loom.
Interleaved view of the conversation: intents and entity turns ordered chronologically by the event log they share.
Types
Functions
Append a user/parent intent — the human's contribution to the conversation, the input that drives a cast/send episode.
Recorded as an event with type: :intent (durable, round-trips
through storage with the rest of the event log) and cached as a
projection in loom.intents for ergonomic access.
The shape mirrors the relevant subset of a turn — :role,
:utterance, :sequence, :metadata — so callers iterating a
transcript/1 can pattern-match on :role without minding which
field the record came from. Doesn't touch loom.turns, so LOOP-1
(entity-side alternation) is unaffected.
Options
:cantrip_id,:entity_id— caller threads through what it knows about which entity received the intent.
Branches cantrip from a prefix of loom.
from_turn is the number of turns to keep from the source loom. Options must
include :intent; they may include :llm to override the forked branch's
provider state.
Interleaved view of the conversation: intents and entity turns ordered chronologically by the event log they share.
Returns the records as-is (intents have role: "intent", entity
turns have role: "turn"). Callers pattern-match on :role to
render or process each kind. The shared :role discriminator makes
this a uniform Enumable shape:
loom
|> Cantrip.Loom.transcript()
|> Enum.map(fn
%{role: "intent", utterance: %{content: text}} -> "you: " <> text
%{role: "turn", utterance: %{content: c}} -> "me: " <> (c || "")
end)Computed on demand — not cached — because it's a merge view rather
than a primary record (cf. extract_thread/2, same pattern).