Memory

glove-memory is the memory layer for Glove. Storage-agnostic adapter contracts, schema-first ontology, and auto-registered tool surfaces. Five complementary, independently usable subsystems with bring-your-own storage.

Entity, episodic, and resources use a reader / curator split — readers attach to the conversational agent, curators run as orchestrator-driven extractors. Context is different: it's user-configured rather than curator-extracted, so it uses a single registration that gives the agent both read and write tools plus system-prompt injection.

The five subsystems

Entity memory. Graph-shaped, schema-first, deterministic identity resolution. Nodes have a class (Person, Organization, …), a Zod-validated property bag, and identity keys that the curator uses to upsert without duplicates. Relationships connect nodes by typed edges. The query DSL supports traversal, predicates, and bounded fan-out.

Episodic memory. Timeline-bound, append-only, semantically searchable. An Episode has a registered kind (e.g. meeting), a list of participant entity ids, an occurrence time, free-form properties, and a content field that gets embedded out-of-band for semantic search.

Resources. A POSIX-style virtual filesystem the agent navigates with ls / read / grep / glob / edit. Roots are declared in the schema so the agent only ever sees configured trees. Files carry metadata including links that point at entity ids, episodes, or other paths — the same reverse-lookup primitive the reconciliation primitives consume.

Context. User-configured ambient context, auto-injected into the system prompt every turn. Small surface (4 tools), the agent both reads and writes, and a wrapper composes pinned entries after the developer's system prompt before each model call.

Forms. Structured collection over a conversation. Definitions are code — Zod schemas, gate closures and executors colocated in one type-threaded builder — and the agent never reads them. It reads a projection of evaluated state, pulled a tier at a time so a sixty-field form costs the same standing prompt line as a six-field one.

Subpath exports

The package ships a top-level barrel plus subpath exports that keep consumer dependencies tight.

ImportContents
glove-memoryBarrel
glove-memory/coreShared types — Provenance, Link, EmbeddingAdapter, MemorySchema, errors
glove-memory/entityEntityMemoryAdapter contract, query DSL, types
glove-memory/episodicEpisodicMemoryAdapter contract, Episode types, semantic-search opts
glove-memory/resourcesResourceFsAdapter contract, file types, POSIX path helpers
glove-memory/contextContextAdapter contract, ContextEntry type, default markdown rendering
glove-memory/formsdefineForm builder, FormAdapter contract, compiler, engine, projection
glove-memory/toolsAuto-registered read/write tool factories and useMemory* / useEpisodic* / useResources* / useContext / useFormRunner helpers
glove-memory/in-memoryReference in-process adapters for dev/test

Schema

Every memory deployment starts with a MemorySchema. It declares node classes (with identity keys for deterministic upsert), relationship types, episode kinds, and resource roots. Tool descriptions render only the slice of the schema each role needs, so the schema is also what bounds prompt surface.

schema definitionts
import { MemorySchema } from "glove-memory";
import { z } from "zod";

const schema = new MemorySchema()
  .defineNodeClass({
    name: "Person",
    schema: z.object({ name: z.string(), email: z.string().optional() }),
    identityKeys: [["email"], ["name"]],
    searchableProperties: ["name", "email"],
  })
  .defineNodeClass({
    name: "Organization",
    schema: z.object({ name: z.string(), domain: z.string().optional() }),
    identityKeys: [["domain"], ["name"]],
    searchableProperties: ["name"],
  })
  .defineRelationship({ type: "worksAt", from: "Person", to: "Organization" })
  .defineEpisodeKind({ name: "meeting", description: "A scheduled gathering." })
  .defineResourceRoot({ path: "/research", description: "External research artifacts." })
  .defineResourceRoot({ path: "/transcripts", description: "Meeting transcripts." });

Don't attach memory tools to your main Glove

If you're building an agent that needs memory access, we advise against attaching the entity / episodic / resources tools directly to your main Glove instance. Build subagents — one per retrieval task — and register them on the main agent. Each subagent attaches only the adapter slice it needs; the main agent stays small and routes to the right subagent based on what the user asked for.

Why:

  • Bounded prompt surface. The main agent's tool descriptions don't render every node class, every relationship, every episode kind, and every resource root on every turn. Each subagent renders only the schema slice for its role. Token cost scales with role, not with total ontology size.
  • Sharper routing. Subagent names and descriptions are themselves part of the model's reasoning surface. "When the user asks about a person, route to lookup" is a tighter signal than "you have these eight memory tools, decide which to call."
  • Mutation scope is explicit. A retrieval subagent attached with useMemoryReader cannot write — the affordance isn't there. The main agent never has to be told "don't accidentally create entities mid-conversation"; it structurally can't.
  • Adapters are still shared. All subagents read and write to the same underlying graph, timeline, and filesystem. Splitting memory across subagents would defeat the point; splitting tools does not.

The exception is useContext. Context is small (4 tools), user-driven ("remember that…"), and ships with the system-prompt-injection wrapper that has to live on the agent the user actually talks to. Keep useContext on the main agent.

reader subagents on a main Glovets
import { Glove } from "glove-core";
import {
  InMemoryEntityAdapter,
  InMemoryEpisodicAdapter,
  InMemoryResourcesAdapter,
  InMemoryContextAdapter,
  useMemoryReader,
  useEpisodicReader,
  useResourcesReader,
  useContext,
} from "glove-memory";

const entity = new InMemoryEntityAdapter({ schema });
const episodic = new InMemoryEpisodicAdapter({ schema, embedder });
const resources = new InMemoryResourcesAdapter({ schema, embedder });
const context = new InMemoryContextAdapter({ schema });

// `lookup` — answers "who is Don?", "what do you know about Acme?". Sees
// only the entity graph; doesn't render episode kinds or resource roots.
const lookupFactory = ({ parentStore, parentControls }) =>
  useMemoryReader(
    new Glove({
      store: parentStore,
      model,
      displayManager: parentControls.displayManager,
      systemPrompt:
        "You answer factual questions about people, organizations, and their " +
        "relationships. Use glove_memory_find for fuzzy lookups, glove_memory_get " +
        "for one-hop neighbourhoods, glove_memory_query for deeper traversal.",
      compaction_config: { compaction_instructions: "..." },
      serverMode: true,
    }),
    entity,
  );

// `recall` — answers "what did we discuss with Don last week?". Reads
// episodes; reads entity for resolving names to ids.
const recallFactory = ({ parentStore, parentControls }) => {
  let glove = new Glove({
    store: parentStore,
    model,
    displayManager: parentControls.displayManager,
    systemPrompt:
      "You answer questions about past events. Resolve participant names to " +
      "ids via glove_memory_find first, then use glove_episodic_timeline / " +
      "glove_episodic_find / glove_episodic_search depending on whether the " +
      "user asked about a specific person, a window, or a topic.",
    compaction_config: { compaction_instructions: "..." },
    serverMode: true,
  });
  glove = useMemoryReader(glove, entity);
  glove = useEpisodicReader(glove, episodic);
  return glove;
};

// `find-notes` — answers "what notes do we have on Aptos regulation?".
// Browses the filesystem; reads entity for "notes about <person>".
const findNotesFactory = ({ parentStore, parentControls }) => {
  let glove = new Glove({
    store: parentStore,
    model,
    displayManager: parentControls.displayManager,
    systemPrompt:
      "You find research notes, transcripts, and link collections in the " +
      "resource filesystem. Use glove_resources_grep / _glob / _search to " +
      "locate files; glove_resources_read to fetch their contents. When the " +
      "user asks for notes about a specific person or organization, look up " +
      "the entity id first and use glove_resources_links_for to find " +
      "everything that links to it.",
    compaction_config: { compaction_instructions: "..." },
    serverMode: true,
  });
  glove = useMemoryReader(glove, entity);
  glove = useResourcesReader(glove, resources);
  return glove;
};

// Main agent — keeps useContext for the system-prompt injection and the
// small "remember that..." tool surface, but offloads every other memory
// task to a subagent.
const main = useContext(new Glove({ /* ... */ }), context)
  .defineSubAgent({ name: "lookup", description: "Look up people, organizations, and their relationships.", factory: lookupFactory })
  .defineSubAgent({ name: "recall", description: "Recall past meetings, decisions, and events.", factory: recallFactory })
  .defineSubAgent({ name: "find-notes", description: "Find research notes, transcripts, and links.", factory: findNotesFactory })
  .build();

The shape generalises: any subagent the developer registers picks the smallest combination of use*Reader / use*Curator calls that makes its job possible. Reader-only when it's just resolving ids or summaries; curator when it actually needs to mutate; nothing at all when memory isn't relevant.

Curator composition — same pattern on the write side

The same advice applies to the curator. A parent curator that routes to specialised write-side subagents — entity-linker, episode-recorder, resource-writer — is preferable to a single curator with every write tool attached. Each subagent attaches only the adapters it needs, so its tool descriptions render only the schema slice for its role. The entity-linker never sees episode kinds; the episode-recorder gets a read-only view of entity classes (so it can resolve participant ids) plus the episode-kind list for writes; the resource-writer gets read access to entities and episodes so it can populate metadata.links correctly.

Subagents share the parent's adapters — there's no per-subagent memory namespace. What the linker writes, the recorder immediately reads.

curator routing to scoped write-side subagentsts
import { Glove } from "glove-core";
import {
  useMemoryCurator,
  useMemoryReader,
  useEpisodicCurator,
  useEpisodicReader,
  useResourcesCurator,
} from "glove-memory";

// Sees: node classes, relationships. NOT episode kinds, NOT resource roots.
const linkerFactory = ({ parentStore, parentControls }) =>
  useMemoryCurator(
    new Glove({ /* ... */ }),
    entity,
  );

// Sees: episode kinds (for writes) + read-only entity classes (to resolve
// participant ids). Does NOT see resource roots.
const recorderFactory = ({ parentStore, parentControls }) => {
  let glove = new Glove({ /* ... */ });
  glove = useMemoryReader(glove, entity);
  glove = useEpisodicCurator(glove, episodic);
  return glove;
};

// Sees: resource roots + read-only entities and episodes (so metadata.links
// points at real ids). Does NOT see write tools for entity / episodic.
const filerFactory = ({ parentStore, parentControls }) => {
  let glove = new Glove({ /* ... */ });
  glove = useMemoryReader(glove, entity);
  glove = useEpisodicReader(glove, episodic);
  glove = useResourcesCurator(glove, resources);
  return glove;
};

// The parent curator owns no memory tools itself — it just routes. Its job
// is reading the conversation slice and dispatching to the right subagent
// in sequence (linker -> recorder -> filer).
const curator = new Glove({ /* ... */ })
  .defineSubAgent({ name: "linker", description: "Extract entities and relationships.", factory: linkerFactory })
  .defineSubAgent({ name: "recorder", description: "Record episodes; resolves participant ids first.", factory: recorderFactory })
  .defineSubAgent({ name: "filer", description: "File research artifacts; resolves link targets first.", factory: filerFactory })
  .build();

Tool surfaces

Each subsystem auto-registers a focused set of tools. Read-on-demand tools attach via use*Reader; write tools attach via use*Curator. Context is the exception — it has a single registration that attaches read and write tools and the system-prompt-injection wrapper.

Entity reader / curator

ToolPurpose
glove_memory_findFind nodes by class + filter, optional fuzzy
glove_memory_getFetch a node by id + one-hop neighbourhood
glove_memory_queryFull structured query via the query DSL
glove_memory_add_nodeCreate or upsert a node by identity keys (curator)
glove_memory_update_nodePatch a node's properties (curator)
glove_memory_connectCreate or update an edge (curator)
glove_memory_disconnectRemove an edge (curator)
glove_memory_merge_nodesFold one node into another (curator)

Episodic reader / curator

ToolPurpose
glove_episodic_searchContent search over episodes — embedding-based semantic or in-process fuzzy/lexical, depending on the adapter (only registered when adapter advertises supportsSemanticSearch)
glove_episodic_findStructured filter — by kind, participant, time range, properties
glove_episodic_timelineChronological listing for an entity or time window
glove_episodic_recordAppend a new episode (curator)
glove_episodic_updatePatch an existing episode (curator)
glove_episodic_deleteRemove an episode (curator)

Resources reader / curator

ToolPurpose
glove_resources_lsList directory contents
glove_resources_readRead a file body, with optional line range
glove_resources_statGet metadata about a single path
glove_resources_grepText/regex search across the tree
glove_resources_globFind paths by name pattern
glove_resources_searchSemantic search (only registered when adapter advertises supportsSemanticSearch)
glove_resources_links_forReverse-lookup: find resources linking to a target
glove_resources_writeCreate or overwrite a file (curator)
glove_resources_editReplace a unique substring (curator)
glove_resources_mkdirCreate an empty directory (curator)
glove_resources_moveRename or relocate (curator)
glove_resources_removeDelete a file or directory (curator)
glove_resources_set_metadataPatch metadata without rewriting body (curator)

Context

ToolPurpose
glove_context_getRead entries by section or list all
glove_context_setAdd a new entry
glove_context_updatePatch an existing entry in place
glove_context_unsetRemove an entry or wipe an entire section

useContext wraps Glove.processRequest. On every turn it calls adapter.render() to materialise pinned entries as a markdown block, composes <base systemPrompt> + \n\n + <rendered context>, and calls setSystemPrompt. Pinned context goes after the developer's system prompt — developer prompt sets agent character and guardrails; user context modifies engagement for this specific user. Re-rendering happens every turn, so external updates the user made between turns are reflected immediately.

Layered memory — shared and private strata

Memory arrives in strata. Some of it is shared, authored elsewhere, and the agent must read it but never change it — an org handbook, a common ontology, published events, standing instructions. Some of it is the agent's own. The two live in different stores, because a shared corpus can't be copied into every private one, but the agent shouldn't have to know that.

Each layer* function takes a stack of adapters and returns one adapter of the ordinary contract, so the existing helpers fold the ordinary tool surface over it.

typescript
import { layerResources, useResourcesCurator } from "glove-memory";

const resources = layerResources([
  { name: "handbook", adapter: sharedFs,  access: "read", paths: ["/handbook"] },
  { name: "notes",    adapter: privateFs, access: "write" },
]);

useResourcesCurator(glove, resources);

The agent runs ls /, sees /handbook and /notes side by side, greps across both, and gets a refusal naming the stratum if it tries to edit the handbook. It never learns there are two stores. All four subsystems layer: layerEntity, layerEpisodic, layerResources, layerContext.

The rules that hold everywhere

  • Exactly one writable stratum per stack. Zero would make every write tool a trap; two would make write routing ambiguous. Both fail at construction, not at the first write.
  • Reads merge in layer order — earlier layers win an id or path collision, so order is the shadowing rule.
  • Writes route to whichever stratum owns the target, and are refused with MemoryLayerError (code: "layer_read_only") when it's read-only. The error names the layer.
  • limit / offset apply to the merged result. Each stratum is asked for limit + offset rows with no offset — the rows an earlier layer skipped aren't the rows the merged view skips.
  • Indexing is allowed against read-only strata. setEmbedding runs on the host's behalf, not the agent's.

Resources — mounted or union, one mechanism

Layer order is precedence and paths scopes a layer to a subtree, which covers both arrangements.

typescript
// Mounted: disjoint namespaces, nothing overlaps.
layerResources([
  { name: "handbook", adapter: shared,  access: "read", paths: ["/handbook"] },
  { name: "notes",    adapter: private, access: "write" },
]);

// Union: both span the whole tree, private shadows shared on a collision.
layerResources([
  { name: "notes",    adapter: private, access: "write" },
  { name: "handbook", adapter: shared,  access: "read" },
]);
  • Paths are not translated. A layer scoped to /handbook serves /handbook/pay.md by calling its adapter with that same absolute path, so the shared store must already be authored under that prefix. Translating would silently invalidate every metadata.links target stored in it.
  • A stratum can't leak outside its prefix. Results are filtered to what the layer claims; directories on the way down to a claimed prefix stay listable so the mount is reachable.
  • Writes route to whoever already holds the path, falling back to the prefix owner for a new path. That ordering makes refusals legible: remove("/handbook/pay.md") reports a read-only stratum rather than the "not found" you'd get from trying the private store first.
  • No copy-on-write. Editing a shared file is refused, not forked into a private shadow.
  • A move across strata is refused rather than half-completed, and a recursive remove that would reach a shared stratum is refused too.

Entity — the one lossy case

addNode checks the shared strata first. Before writing it looks for a node matching the class's identityKeys in each read-only stratum, and returns that node's id with created: false when it finds one. Without it every agent would grow a private copy of every shared entity on first mention, and the graph the layering exists to share would quietly fork. The consequence: the id you get back may belong to a read-only stratum, so a follow-up updateNode on it is refused — correctly, since the entity is shared and immutable.

Edges cannot cross strata. EntityMemoryAdapter.connect resolves and validates both endpoints inside one adapter, and the private store has no row for a shared node, so it cannot hold an edge pointing at one. A connect whose endpoints land in different strata is refused with code: "cross_layer_unsupported" rather than half-written.

The workaround is real: episodic participants and resource metadata.links are plain ids that nothing validates, so "my note about their company" crosses strata without special handling. This is the one place layering a graph is genuinely lossy, and why the other three subsystems layer more cleanly.

Episodic and context

Episodic interleaves strata into one true chronological timeline. Participants may reference entity ids from any stratum. supportsSemanticSearch is true when any stratum supports it and searchEpisodes queries only those that do, so a shared corpus with a built index composes with a private store that has none — though scores from two independently-built indexes aren't strictly comparable, making the merged ranking a best-effort interleave.

Context merges list / get and concatenates each stratum's render block shared first, so a private entry that refines a shared one reads as the later, more specific word. setSection replaces only the writable stratum's entries, so a shared section name can't poison the user's preferences pane.

Layering and path policies compose — layering answers which store does this live in, withResourceAccess answers what may be done inside one store. Wrap a layer's adapter to gate it further, or wrap the layered adapter to gate the merged view.

Narrowing what the agent may do

Two independent knobs, meant to be used together. One removes the affordance — the tool never reaches the model. The other removes the capability — the adapter refuses the call however it arrives.

Tool allowlists

Every use* helper takes an options bag selecting which tools of the surface get folded. Names resolve in full ("glove_resources_remove") or short ("remove"); allow narrows first, then deny subtracts.

typescript
// A curator that files notes but can never delete or relocate anything.
useResourcesCurator(glove, resources, { tools: { deny: ["remove", "move"] } });

// An entity curator that may create and connect, but never merge.
useMemoryCurator(glove, entity, { tools: { deny: ["merge_nodes"] } });

// Context the agent adds to and revises, but can't clear.
useContext(glove, context, { tools: { deny: ["unset"] } });

// Or start from nothing and name what's allowed.
useResourcesCurator(glove, resources, {
  tools: { allow: ["ls", "read", "grep", "glob", "write"] },
});

A selector that matches nothing throws MemoryToolSelectionError rather than doing nothing quietly — a typo in a deny entry would otherwise leave the tool registered, which is exactly what a denylist exists to prevent. selectTools is exported for surfaces you build yourself.

This is a prompt-surface control, not a data boundary: the adapter is still fully capable, and anything else holding it can still write. When the restriction has to hold structurally, reach for the next one.

Path-scoped access policies

withResourceAccess wraps a ResourceFsAdapter so every call is checked against a policy keyed on path — a read-only research corpus, an off-limits subtree, an allowlist of the few places the agent may write.

typescript
import { withResourceAccess, useResourcesCurator } from "glove-memory";

const resources = withResourceAccess(new InMemoryResourcesAdapter({ schema }), {
  default: "none",
  rules: [
    { path: "/research", access: "read", note: "curated by the research team" },
    { path: "/research/scratch", access: "write" },
    { path: "/notes", access: "write" },
    { path: "/**/*.locked.md", access: "read" },
  ],
});

useResourcesCurator(glove, resources);
ModeEffect
"write"Readable and mutable. The default when no policy says otherwise.
"read"Readable, but write / edit / mkdir / move / remove / set_metadata are refused with ResourceAccessError.
"none"Invisible. Reads are refused, and the path is filtered out of ls, grep, glob, search and links_for results.

path is an absolute directory prefix (/research — the directory and everything under it) or a glob using the same * / ** / ? vocabulary as glove_resources_glob. Rules are evaluated in order and the last match wins — the .gitignore cascade. default ("write" unless set) covers anything no rule matches.

Enforcement lives on the adapter, not the tool surface, for the same reason the reader / curator split does: it's structural. Whichever tools you fold, and whatever the model asks for, a write into a "read" path is refused.

  • Multi-path reads filter rather than fail. ls, grep, glob, searchSemantic and linksFor drop hidden paths from their results, so a policy narrows what the agent sees instead of breaking navigation. Naming a hidden path explicitly is still refused — exists returns false rather than throwing, so it can't be used to probe.
  • Directories on the way to a grant stay listable. Otherwise an allowlist policy would strand the subtree it just granted. Traversal is not read access; the files directly under it stay refused.
  • Blast radius is checked. A recursive remove (or a directory move) is refused when it would reach any path the policy protects, so rm -r / can't take a read-only subtree with it.
  • The policy is in the tool descriptions. The model is told about the walls instead of discovering them one refused call at a time. describe: false suppresses the text, never the enforcement.
  • replaceLinkTarget is refused under any restrictive policy — it rewrites the whole tree and can't be scoped path-by-path. Run reconciliation against the unwrapped adapter.
  • The embedding lifecycle passes through unfiltered. It runs out-of-band on the host's behalf, not the agent's, and a read-only directory still needs its index maintained.

Embedding lifecycle

Episodic and resources adapters generate embeddings out-of-band. Writes mark records embeddingStatus: "missing" (initial) or "stale" (content change) and return immediately. A separate process — typically a Station signal — picks them up via findEpisodesNeedingEmbedding / findFilesNeedingEmbedding, calls the configured EmbeddingAdapter, and writes vectors back via setEmbedding.

The EmbeddingAdapter contract is intentionally tiny — consumers plug in whatever provider they want without the package taking on a model dependency. The same embeddingStatus / findEpisodesNeedingEmbedding / setEmbedding lifecycle doubles as a generic background-indexing seam for any search backend — not just embeddings (see below).

Content search without embeddings (fuzzy mode)

Embeddings are opt-in, not required. glove_episodic_find (kind / participant / time / property filters) and glove_episodic_timeline need nothing. Only glove_episodic_search needs a ranking backend, and that backend doesn't have to be vectors. Pass fuzzySearch: true (and no embedder) to InMemoryEpisodicAdapter for in-process lexical search over episode content — exact-phrase and substring hits plus a bigram-Dice fuzzy fallback that tolerates typos. It sets supportsSemanticSearch: true with zero external services, no vectors, and no out-of-band embed loop. embedder wins when both are supplied.

content search, no embeddingsts
// No embeddings, no external service — content search still works.
const episodic = new InMemoryEpisodicAdapter({ schema, fuzzySearch: true });

Custom adapter with a background-built index (BYO search)

For production, implement your own EpisodicMemoryAdapter. The embeddingStatus + findEpisodesNeedingEmbedding + setEmbedding methods are a generic background-indexing lifecycle — the index can be a vector store, SQLite FTS5, Postgres tsvector, BM25, Meilisearch, Tantivy. To back glove_episodic_search with it, set supportsSemanticSearch: true and implement searchEpisodes.

  • Writes (recordEpisode / updateEpisode / deleteEpisode) persist to the primary store, mark the row missing / stale, and return immediately — no indexing on the hot path.
  • Structured reads (findEpisodes / episodesForEntity / episodesBetween) query the primary store directly and stay current — they don't depend on the index.
  • Index lifecycle: findEpisodesNeedingEmbedding returns the dirty rows; the background worker builds the index artifact and calls setEmbedding(id, vector) to commit it and mark the row fresh.
  • searchEpisodes(query, opts) queries the index, applies opts.filter, and returns { episode, score, distance } sorted by score descending (strip provenance; normalize relevance to [0, 1] before the recency blend).
out-of-band reindex workerts
// A Station signal, cron, or queue consumer. Index type is your choice.
async function reindexPass() {
  const pending = await adapter.findEpisodesNeedingEmbedding({ limit: 100 });
  if (!pending.length) return;
  const artifacts = await buildIndex(pending.map((p) => p.content)); // vectors | FTS docs | BM25 postings
  for (let i = 0; i < pending.length; i++) {
    await adapter.setEmbedding(pending[i].id, artifacts[i]); // commit + mark fresh
  }
}

A just-recorded episode is visible to find / timeline immediately but to search only after the worker catches up (eventual consistency). setEmbedding's vector param is only meaningful for a vector index — for FTS / BM25 / an external service, ignore it and treat setEmbedding as "write my doc + mark fresh".

Implementation choices in the in-memory adapters

  • Stale marking is content-only on episodes. updateEpisode flips embeddingStatus: "stale" and drops the cached vector only when the content field changes — kind / participant / property / occurredAt patches don't re-embed. The embedding represents content; the spec is silent on the others. Consumers wanting different behaviour can delete + re-record.
  • Recency blend uses a 30-day half-life. searchEpisodes ranks by (1 - recencyWeight) * semanticScore + recencyWeight * recencyScore where recencyScore = exp(-ln(2) * ageMs / halfLifeMs), halfLifeMs = 30 days. Default recencyWeight = 0.2. Companion adapters (sqlite/postgres) may pick different curves; only the shape of the blend is fixed by the spec.

Reconciliation primitives

The package's contract is deliberately narrow: store, query, write, search. It does not cascade across adapters. When an entity is merged or deleted, episodes that reference its old id don't update on their own. Orchestrators reach for the cross-adapter primitives instead — most importantly episodic.replaceParticipantId and resources.replaceLinkTarget for the merge case.

ActionPrimitive
Entity mergedepisodic.replaceParticipantId(oldId, newId, prov), resources.replaceLinkTarget("entity", oldId, newId, prov)
Entity deletedepisodic.findEpisodes({ where: { participantIds: [id] } }), resources.linksFor("entity", id) then orchestrator decides
Resource movedresources.replaceLinkTarget("resource", fromPath, toPath, prov)
Episode deletedresources.linksFor("episode", id) then orchestrator decides
Stale embeddingsfindEpisodesNeedingEmbedding / findFilesNeedingEmbedding → embed → setEmbedding

Forms

Structured collection over a conversation, and the one subsystem with a page of its own — it is large enough to warrant one. Definitions are code: Zod schemas, gate closures and executors colocated in a builder chain that the agent never reads. What the agent reads is a projection of evaluated state — the open step, what is still pending, and a one-line preview of what is coming.

typescript
import { z } from "zod";
import { defineForm } from "glove-memory/forms";

export const travelClaim = defineForm({ id: "travel-claim", version: 1, /* … */ })
  .step("claimant", { title: "Claimant", preview: "name, staff id, email" }, (s) =>
    s.field("fullName", { schema: z.string().min(2), label: "Full name" }),
  )
  .checkpoint("policy-cap", {
    when: (v) => typeof v.total === "number" && v.total > 750,
    blocking: true,
    run: () => ({ fail: "Over the limit — needs Finance pre-approval." }),
  })
  .onComplete(async (ctx) => {
    await ctx.memory.upsertNode("Person", { name: ctx.values.fullName });
  })
  .build();

The shape in one paragraph: optionality and the agent-readable type come from Zod rather than from flags; writes are never gated, so an answer given out of order still lands; entries are an append-only log, which makes retract / undo / redo pure cursor moves; a value that is not applicable yet is held rather than dropped, and goes live if the branch flips back; and executors colocated at four points can patch, fail, jump, complete or terminate — with ctx.memory bridging straight into the four subsystems above.

Attach it with useFormRunner (seven tools plus tier-0 prompt injection) or useFormReader for read-only history. The full reference — the tool surface, liveness rules, triggers and jumps, the lazy-loading tiers, operational notes and the FormAdapter contract — is on the Forms page.

Reference adapters

The package ships in-process reference adapters under glove-memory/in-memory: InMemoryEntityAdapter, InMemoryEpisodicAdapter, InMemoryResourcesAdapter, InMemoryContextAdapter, and InMemoryFormAdapter. They're intended for development and tests — every adapter contract is implemented end to end so you can wire up a full schema, exercise the tool surfaces, and write integration tests without standing up a database.

Companion storage backends ship as separate packages — glove-memory-sqlite and glove-memory-postgres — and are not part of the v0.1 release.

What this package doesn't own

  • Triggering, scheduling, or pipeline orchestration (Station's territory).
  • The curation logic itself (configured by the consumer).
  • Embedding generation — consumers plug in their own EmbeddingAdapter.
  • Schema persistence or migration — schema lives in code; consistency across deployments is the consumer's concern.
  • Cross-adapter cascade on entity merge, episode delete, or resource rename — that's reconciliation, an orchestrator responsibility.
  • The user-side write path for context — the adapter exposes set / update / unset; the UI / API / form / wherever users edit their preferences calls those directly.
  • Runtime-authored forms. Definitions are code — no JSON compile target, no authoring UI, no second front end.
  • Compensating a re-fired form executor. Hooks fire on every rising edge with a per-occurrence idempotency key; whether a repeat is real work is the executor's decision.
  • Binary resources. Resources is text-only.
  • . and .. path resolution. All paths are absolute.