What each MemoryEngine operation does, independent of engine. For the
types see api.md and api-items.md; for how CortexDB
realises them see cortex.md.
Every operation starts the same way: validate the request (its validate
method) and return Error::InvalidRequest before touching storage. Filters
are applied identically everywhere through MetaFilter::matches, including
the reach that confines which namespaces are visible.
store(item) -> StoreReceipt { id, replayed }
item.validate(). A blank body, unresolvedUri, empty conversation, blank learning or out-of-range confidence isInvalidRequest.- Derive the item's identity from
item.fingerprint(). - If the engine already holds that item, write nothing and return the
existing id with
replayed: true. - Otherwise write it at
meta.namespace(the item lands at exactly one node) and returnreplayed: false.
Which fields are in the fingerprint, and why observed_at is not, is in
Idempotency.
store_many(items) -> Vec<StoreReceipt>, for imports, backfills and syncs.
validate_many:1..=100items (MAX_STORE_MANY), each valid. Empty or oversized isInvalidRequest; so is the first invalid item.- Store the items in order. The default implementation calls
storeper item; an engine may batch. - Receipts come back in item order. An item repeated within the batch is a replay of its first copy.
- On return every item is readable through
list,getandforget. Rankedfetchandrecallmay lag a moment behind for all but the last item; that is what lets an engine skip a per-item wait. - On an error, the items before the failing one are stored. Sending the batch again is safe: the stored ones come back as replays.
fetch(req) -> FetchPage { hits, next_cursor }: raw retrieval, no synthesis.
- The engine checks
req.modeagainst its descriptor (EngineDescriptor::ensure_mode): a mode it does not serve isError::Unsupported. Hosts readfetch_modesand never offer one the engine lacks. req.validate(): blank query or zero limit isInvalidRequest.- Rank items admitted by
req.filterinKeyword(lexical),Vector(embedding) orHybrid(the engine's blend) mode. Hits are best first, each with ascore. - Return up to
limithits and anext_cursorwhen more remain (cursors).
EngineDescriptor::fetch_modes is the engine's declaration of what it
serves. An engine need not serve all three (CortexDB declares only Hybrid).
Gating is by declaration: supports(mode) answers, ensure_mode(mode)
fails with Unsupported("engine does not offer <mode> fetch").
tinymemory-tools mirrors this: the memory_fetch tool's mode enum lists
exactly the engine's modes, and an engine serving none gets no such tool.
The conformance suite checks that every declared mode works and every
undeclared one is Unsupported.
recall(req) -> RecallAnswer { answer, citations, model? }
req.validate(): blank question or zero limit isInvalidRequest.- Gather at most
limitcitations from items admitted byreq.filter. - Synthesise an answer, optionally steered by
req.instructions. How the engine answers is its own business. - Return the answer text, its
Citations and, when the engine reports it, the model.
Every citation's id must resolve through list; the conformance
suite checks it.
list(req) -> ListPage { items, next_cursor }: a query-free listing.
req.validate(): zero limit isInvalidRequest.- Return up to
limititems admitted byreq.filter, each aHitwithscore == 0.0, plus anext_cursorwhen more remain.
The contract does not promise an order across engines, only that following
cursors visits every matching item and that paging terminates. list is the
primitive the default explore and get are built on.
forget(target) -> ForgetReport { forgotten }
ForgetTarget::validate runs first:
| Target | Rule |
|---|---|
Ids(ids) |
At least one id, else InvalidRequest("forget needs at least one id"). |
Filter(filter) |
Must not be empty (MetaFilter::is_empty), else InvalidRequest: an empty filter would mean everything, so the contract refuses it. |
- By ids: remove those items, wherever they live. Ids are not scoped
by namespace. Ids that name nothing are skipped and not counted. A caller
confined to a reach reads the ids first with
getunder that reach and forgets only what came back (this is whatmemory_forgetdoes). - By filter: remove every item the filter admits. The filter's
reachconfines it. A filter holding only areachis not empty, soFilter(MetaFilter { reach: Some(..), .. })forgets everything in that reach. Callers that take filters from untrusted input should require a second field, astinymemory-toolsdoes.
forgotten counts items actually removed.
explore(req) -> ExplorePage: counts of stored items per value of one facet,
for explorers (a UI tree, a CLI, an audit script).
The facet is a metadata dimension fixed by the contract (Kind,
Source, SourceId, Workspace, Folder, FilePath, Language, Repo,
Url, Thread, Agent, ToolCall, Tag, Namespace), so one explorer
works on every engine. Facet::values(kind, &meta) gives an item's values
for a facet: none when the field is unset, several only for Tag.
Semantics of the default (explore_by_listing), which every engine gets
unless it overrides explore:
req.validate():limitin1..=500,scan_limitin1..=50 000(default 5 000).- Page through
listwithreq.filter, 200 at a time, reading at mostscan_limititems. - For each item read:
total += 1; if the facet has no value for it,missing += 1; each value it has increments that value's count. A tagged item counts once per tag, so forTagbucket counts can sum to more thantotal. - Sort buckets by count descending, ties by value ascending; cut to
limit;more_bucketsis the number of distinct values cut. truncatedistruewhen the scan stopped atscan_limitwith more items remaining; counts are then a lower bound, andtotalis the number read.
An engine that aggregates server-side overrides explore and may ignore
scan_limit.
facet.narrow(&mut filter, value) turns a chosen bucket back into a filter
field, so drilling down is: explore → pick a bucket → narrow → explore
(another facet) or list.
| Facet | Sets on the filter |
|---|---|
Kind |
kinds = [value] (must name an item kind) |
Source |
sources = [value] (must name a source kind) |
SourceId |
source_id |
Workspace, Language, Repo, Url, Agent, ToolCall |
workspace, language, repo, url, agent_id, tool_call |
Folder, FilePath |
folder, file_path (prefix match, so a folder also admits its subfolders) |
Thread |
thread_id |
Tag |
tags_any = [value] |
Namespace |
reach = Reach::exact(value.parse()?): exactly that node |
narrow replaces the one field it targets (a list field is replaced by a
one-element list; Namespace replaces any existing reach) and leaves others
alone. It fails with InvalidRequest for a blank value, an unknown kind or
source value, or a namespace that does not parse.
Drill-down example:
explore(facet=source) → folder: 40, github: 7
Source.narrow(filter, "folder")
explore(facet=folder, filter) → /notes: 31, /docs: 9
Folder.narrow(filter, "/notes")
list(filter) → the 31 items under /notes (and subfolders)
Because Folder matches by prefix, a bucket count for /notes (items whose
folder is exactly that value) can be smaller than the number of items a
narrowed list returns, since subfolder items match the prefix too.
get(req) -> Vec<Hit>: read whole items by id.
req.validate():1..=200ids (MAX_GET_IDS), none blank.- Look each id up. The default pages through
list(200 at a time, confined toreq.reachwhen set) until every id is found or the listing ends; an engine that can look an id up directly overrides it. - Return hits in the order the ids were named, each at most once. An id
that names nothing is left out, with no error. So is an id whose item lies
outside
req.reach: it is indistinguishable from a missing one.
Storing an identical item twice must not create a second item. The contract
expresses "identical" as StoreItem::fingerprint:
- What is hashed: SHA-256 over the item's JSON, keeping the first 20 bytes
as 40 hex characters. That JSON holds the whole item: kind, title, body,
mime, turns (with tool calls), learning kind, confidence, evidence, and all
of
meta(includingnamespace,tagsandsource). - What is excluded:
meta.observed_at, set toNonebefore hashing. It says when the item was seen, which a host stamps on every store. Including it would make a retried learning, or an unchanged file re-synced, a new item every time. - Namespace is included: the same text at two nodes is two items. Root-namespace items serialise without a namespace, so their fingerprints match those from before namespaces existed.
- Replay: an engine that finds the fingerprint already stored writes
nothing and returns
StoreReceipt { id, replayed: true }, with the same id as the first store. A retry after a timeout or a partialstore_manyis therefore safe. - Changing anything else is a new item: editing one tag, one character of text, or the confidence produces a different fingerprint and a second item; the old one is not replaced.
Engines choose how the id relates to the fingerprint (ItemId is opaque); the
reference engine uses the fingerprint itself.
fetch and list page with an opaque cursor: Option<String>.
- A first request has no cursor. A page that is not the last carries
next_cursor: Some(token); the last page hasNone. - Pass
next_cursorunchanged as the next request'scursor, with the same filter, query and mode. The format is the engine's business; do not parse or construct one. An engine rejects a cursor it does not recognise withInvalidRequest. limitis the page size and must be positive. A page may hold fewer items thanlimit; only a missingnext_cursormeans the end.- Cursors must make progress: a repeated cursor means paging never ends, and the conformance suite fails an engine that does that.
exploreandgettake no cursor: they page internally throughlist.
health() -> EngineHealth (Ok, Degraded(reason), Down(reason)) is
infallible and cheap to call. Degraded still serves; Down does not
(is_serving()). The method returns no Result, so an engine reports
trouble through the variant and its reason (which must not carry a
credential), never as an error. The conformance suite's first check is
that the engine reports itself serving.