Cantrip.Loom (Cantrip v1.3.3)

Copy Markdown View Source

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

t()

@type t() :: %Cantrip.Loom{
  events: [map()],
  identity: term(),
  intents: [map()],
  schema_version: pos_integer(),
  storage_module: module(),
  storage_state: term(),
  turns: [map()]
}

Functions

annotate_reward(loom, index, reward)

append_child_subtrees(loom, observations)

append_event(loom, attrs)

append_executed_turn(loom, turn_attrs, observations, opts \\ [])

append_intent(loom, text, opts \\ [])

@spec append_intent(t(), String.t(), keyword()) :: t()

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.

append_parent_continuation(loom, bool, context, parent_turn_id, sequence)

append_turn(loom, attrs)

extract_thread(loom, leaf_id \\ nil)

fork(cantrip, loom, from_turn, opts)

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.

new(identity, opts \\ [])

transcript(loom)

@spec transcript(t()) :: [map()]

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