reminisce

Agent memory on TypeDB: an Elixir library with an MCP face.

Status: 0.2.0, the first public release. While it is 0.x, a minor version may change the public API; docs/CHANGELOG.md lists every such change. The design is docs/spec.md, and its §9 is the readiness test this release passes in CI.

What it does

What it does not do

Requirements

Elixir 1.19 or newer on Erlang/OTP 28 or newer, and TypeDB 3.12 or newer. CI runs Elixir 1.19 on OTP 28 and 1.20 on OTP 29. On OTP 27 the VM's own terminal reader can take the MCP server's first input before the server reads it, and Elixir 1.18 cannot compile the library on OTP 28.

Every query passes its values through TypeQL's given stage, and nothing is built by string concatenation from caller input. It runs on the typedb driver over HTTP, using Finch.

Install

reminisce is not on Hex. Add it to the deps in your mix.exs from its git repository, at the release tag:

{:reminisce, git: "https://github.com/NoeticEcho/reminisce-elixir.git", tag: "v0.2.0"}

Then run mix deps.get. The library needs a TypeDB server to talk to; scripts/typedb-server start runs one on this machine for development and tests.

Quick start

test/readme_test.exs runs this block as it stands.

{:ok, _} =
  TypeDB.start_link(
    name: :memory_db,
    url: "http://localhost:8000",
    username: "admin",
    password: pw
  )

store = Reminisce.Store.new(:memory_db, "memory")
:ok = Reminisce.setup(store)

owner = {"agent", "alice"}

{:ok, m} =
  Reminisce.remember(store, owner, %{
    content: "The north gate opens at dawn.",
    aspects: [:episodic],
    basis: :observed,
    sources: ["doc:gates"],
    facets: [{"place", "north gate"}]
  })

{:ok, %{memories: [^m | _], scanned: 1}} =
  Reminisce.recall(store, owner, "when does the gate open")

{:ok, %{memory: ^m, sources: ["doc:gates"]}} = Reminisce.why(store, owner, m.key, depth: 5)

{:ok, _} = Reminisce.forget(store, owner, m.key, reason: "the gate was rebuilt")
{:ok, %{memories: []}} = Reminisce.recall(store, owner, "when does the gate open")

The URL above is a server on the same machine. A TypeDB server anywhere else should be reached at an https:// address: the driver signs in with the password and then sends a token with every request, and over plain http:// both cross the network as they are.

Every function returns {:ok, _} or {:error, reason} (setup/1 returns :ok), and none raises for a domain refusal. Error reasons name rules and fields, and keys only of memories that exist; never a memory's words, and never a caller's input echoed back (an unknown key is plain :not_found). A store the library could not reach is an error, never an empty answer.

The API

function what it does
setup/1 creates the database if needed and defines the schema. It refuses a database whose copy of the library's types differs
remember/3 writes one memory in one transaction. :key makes a retry idempotent
recall/4 ranks memories deterministically. Options: :k (default 8, at most 50), :aspects, :subject, :as_of, :similarity, :expand (0)
forget/4 status: :inactive | :archived | :forgotten, reason:. Keeps the words
why/4 explains one memory. depth: 1..10, default 3
supersede/4 writes a new memory, marks the old one superseded, closes its valid_to and links the two, all in one transaction
relate/5 :contradiction (claims may be derivations or causations), :causation, :association
withdraw/4 redacts every memory concerning a subject, in batches, and writes one withdrawal record
purge/3 deletes a fully withdrawn subject and its links. Needs confirm: true
as_of/4 an owner's memories as believed at a moment
mark_recalled/3 bumps the recall count and time
decay/2, consolidate/3 the pure decay, and the host-run pass
backup/2, restore/2 schema.tql plus data.jsonl. Restore goes into an empty database

remember/3 attributes

field required note
content yes the words. Refused if it carries a secret
aspects yes, at least one :episodic :semantic :procedural :autobiographical :prospective, configurable per store
basis yes :observed :told :inferred :generated. Model output is :generated, always
confidence no 0..1. A :generated memory may carry one only with reviewed_by
valid_from, valid_to, occurred_at, due_at no DateTime. valid_from defaults to now
sources no external references: a document id, a URL, a commit
derived_from no keys of existing memories
facets no [{name, value}], such as {"kind", "lesson"} or {"place", "harbour"}
salience no 0..1, default 0.5
subject + consent together or neither a pseudonymous reference, and the lawful basis and purpose
key no the idempotency key
reviewed_by no required by review_aspects and by a policy's {:hold, :review}

Hooks

Store options include :policy, :analyser, :aspects, :review_aspects, :page_size (default 2,000), :decay, :withdraw_batch (default 500), :withdraw_keep (default []) and :clock. See Reminisce.Store.

Extending the schema

A host may define its own attributes, its own subtypes of memory and its own relations, in its own define, after setup/1. setup/1 checks only the library's types. backup/2 exports every type in the database.

A withdrawal deletes every attribute a host defined on memory unless the store's :withdraw_keep names it. Name identifiers there, such as a tenant id, never words. An attribute the host makes required (@card(1..1)) must be named, because it cannot be deleted: without it in the list, a withdrawal of a memory that has it fails and leaves the words in place.

The library's types have plain names, with no prefix: memory, content, status, subject and so on (Reminisce.Schema lists them). In a database where a host type already has one of those names, setup/1 defines nothing. It answers {:error, {:schema_differs, facts}}, even when the definitions agree. A host in that position gives the library a database of its own. The design (docs/spec.md §5.6) says why there is no prefix, and what a rename would cost a host that has data.

The MCP server

mix compile   # first: Mix's own compile output would go to stdout
TYPEDB_PASSWORD=… mix reminisce.mcp --owner agent:alice --database memory

Logs go to stderr; stdout carries JSON-RPC and nothing else (a test starts the task as an OS process and parses every stdout line).

The server speaks JSON-RPC 2.0 over stdio, one message per line, and uses no MCP library. It reads standard input as bytes, from file descriptor 0 itself, and writes bytes, whatever the locale: a line must be UTF-8, and one that is not is answered as a parse error. \r\n ends a line as \n does, an empty line is skipped, and a last line with no newline is still read.

It never holds more than 1 MiB of one line. A longer line is answered as Request too large while it is read, and skipped.

The reader counts the lines it has handed to the server and not yet seen answered, and the bytes of those lines:

A client that writes faster than the server answers therefore leaves its backlog in the pipe. Two tests in test/mcp/stdio_test.exs measure this against the real OS process:

Input that cannot be read is answered with one error (-32000, Input read failed), and the task exits with status 1. The end of input exits 0. A message whose handling crashes is answered as an internal error that names nothing from it, and the server goes on. A host that calls Reminisce.MCP.Server.run/4 from its own code gets its devices' encoding and its logger filters back when the call returns.

Every argument is checked against its declared maxLength and maxItems before a tool runs. The caps are those of Reminisce.Limits, in bytes of UTF-8: text outside ASCII fits fewer characters than maxLength says.

tool inputs notes
memory_remember content, aspects, basis, confidence?, sources?, derived_from?, facets?, subject? + consent?, key? The secret guard and the store's policy run. A refusal is a tool error that names the rule and never echoes the words
memory_recall query, k?, aspects?, subject?, as_of? readOnlyHint
memory_forget key, status, reason destructiveHint: false, because nothing is deleted
memory_why key, depth? readOnlyHint

One server is one owner. No tool takes an owner, and a call that names one is refused with owner_is_fixed. Keys are per owner in the library itself (Reminisce.Keys), so the server's owner never meets another owner's key: a key another owner holds answers exactly as one nobody holds, to every tool. A message without an id is a notification and never runs a tool. withdraw is not a tool: withdrawal is a person's right, exercised through the host. A tool's structuredContent is exactly Reminisce.Output applied to the direct Elixir call's answer.

Erasure: what it does not reach

withdraw/4 and purge/3 remove a subject's words from the memories linked to that subject: every attribute a withdrawn memory owns except the library's bookkeeping (key, owner, aspects, basis, confidence, salience, recall counts, times, status and reviewer) is deleted, including attributes a host defined on memory, except those the host names in :withdraw_keep. They do not reach:

Rules for hosts

Measured on this release

The readiness tests

test/criteria/ has one file per criterion in docs/spec.md §9:

  1. Round trip: every type, a withdrawn memory, a contradiction whose claim is a derivation, and a host-defined attribute.
  2. Nothing deleted: 1,000 random operations.
  3. Withdrawal: the words are absent from backups, content and every public output, including withdrawals that are batched and interrupted.
  4. Secrets: 48 shapes (two in each of 24 rule families) refused through remember and memory_remember. No trace in logs, telemetry, errors or MCP responses, checked also on paths that reach the database with a secret in hand, with canaries proving each capture works. Near-misses refused: 0 of 40.
  5. Deterministic recall: 20 queries over 50 shuffled insertion orders, read in one page and in pages a third of the fixture.
  6. why: a 4-deep chain, a supersession, a contradiction, two sources, and an injected cycle, within 5 s.
  7. Import: directories of invented JSONL records, each with 20 held-out query and record pairs. The first set's pairs were committed before the importer; the second set's (test/fixtures/import_b) before any of its records was written. The same hand wrote queries and records in both, so this measures recall on realistic paraphrase, not on strangers' words. Currently 20 of 20 in the top 8 for each set.
  8. MCP: each tool equals the direct call, and the owner cannot be overridden.
  9. Publishable: LICENSE and NOTICE are present and the tree is clean of foreign ids. CI's gitleaks job checks the full history.

Tests tagged :typedb need a server (scripts/typedb-server start). Without one they are excluded, and the run says so.

Licence

Apache-2.0. See LICENSE and NOTICE. TypeDB™ and TypeQL™ are trademarks of TypeDB Ltd. This project is not affiliated with it.