This is the detailed implementation reference: concrete files, tables, functions,
and wire shapes. For the "why standalone" / boundaries story, read
ARCHITECTURE.md first — this doc complements it, it does not repeat it.
src/
index.ts # createMemory / registerMemoryRoutes + distiller re-exports
mount-config.ts # MemoryConfig + loadMemoryConfig() — the mount config
config.ts # EngineConfig — the core vector-plane config (db + embed + rerank)
memory.ts # createMemory — add/search/list against store or pgvector
grant-tags.ts # resolveAccessTags + canAccessDocument (host grants)
log.ts # getLogger(["memory"]) from @intx/log
migrations.ts # runMemoryMigrations(url)
ports/ # DocumentStore / SourceProvider + fakes
routes/ # the mounted routes
mount.ts # registerMemoryRoutes (HTTP)
deps.ts # RouteDeps, caller(c) (context identity), grantGuard
add.ts, search.ts, list.ts, feed.ts
tools/ # Interchange defineTool factories (HTTP clients)
add.ts, search.ts, list.ts, feed.ts, client.ts, install.ts
distiller/ # Resident distiller (CL-5869) — workflow + tick helpers
index.ts # createResidentDistiller, runDistillTick, buildDistilledClaim
workflow.ts # defineWorkflow + defineAgent with memory tools
tick.ts # imperative distill tick (host injects distill())
claim.ts # pure claim body / gate / cursor helpers
db/
schema.ts # Drizzle table defs (memory.* schema)
client.ts # createDb(config) -> { db (drizzle), sql (raw postgres-js) }
services/
capture.ts # captureDocument, deriveFromRawCapture — the write path
search.ts # hybridSearch and every retrieval-candidate query
timeline.ts # listTimelineEvents — durable recent docs + grant-tag filter
transform.ts # transform_config CRUD + runTransform (replay)
feed.ts # capture feed cursor pull (CL-5868)
retention.ts # deprecate / tombstone / sweep ephemeral (CL-5871)
share-grants.ts # peer grant materialization on share (CL-5873)
core/ # framework-agnostic (chunking, embed/rerank, merge, schemas)
# DocumentStore adapters / tools live as sibling packages (not in this tree):
# @corbits/mem0-memory-adapter → github.com/corbitsdev/corbits-mem0-memory-adapter
# @corbits/supermemory-memory-adapter → github.com/corbitsdev/corbits-supermemory-memory-adapter
# @corbits/linear-tools → github.com/corbitsdev/corbits-linear-tools
migrations/ # pgvector schema, applied in filename order by scripts/db-setup.ts
scripts/db-setup.ts # idempotent migration runner, tracked in `_migrations`
compose.yml # pgvector + Ollama + reranker for local dev
The SDK has no server and no process entrypoint. createMemory takes
the host's Hono<TenantEnv> app plus { config, grants? } and mounts the
routes; each reads identity from the context (caller(c)) and guards via
grantGuard. Services take { db, sql, config } explicitly (no module-level
singletons except the logger). Nothing has import-time side effects, so unit
tests exercise the routes and services directly without a listening server.
Mounting does not verify the FTS language against the database at boot —
see the memory_chunk section below. A host is expected to either run
runMemoryMigrations itself (which verifies) or wire its own readiness
probe to call verifyFtsLanguage; without one of those, a language mismatch
surfaces as a runtime failure on the plane's first query, not at mount time.
There are two config types, both in the SDK:
-
EngineConfig(src/config.ts) — the core vector-plane config the DB client and capture/search/transform services consume:databaseUrl,dbPoolMax,embed,rerank. -
MemoryConfig(src/mount-config.ts) — whatcreateMemorytakes: just
{ memory: EngineConfig }.loadMemoryConfig()builds one from the environment; hosts may also construct it programmatically. Auth, tenancy, and grants are the host's — none of that is config here.
loadMemoryConfig() uses the same fail-loud helpers: requireEnv(name)
throws if unset/empty, optionalEnv(name) returns undefined, intEnv(name, fallback) parses a positive integer or throws.
| Var | Required? | Default | Notes |
|---|---|---|---|
DATABASE_URL |
yes | — | the engine's own pgvector Postgres |
DB_POOL_MAX |
no | 8 |
postgres-js pool size |
FTS_LANGUAGE |
no | english |
text search config for the lexical channel; fixed into the generated column at migration time — changing it later requires rebuilding the column (recipe below), and runMemoryMigrations fails loudly if config and column disagree. Unqualified pg_catalog config names only — a schema-qualified config (myschema.mycfg) is rejected explicitly, both when configuring and when read back from an already-migrated column. |
EMBED_BASE_URL |
no | — | embed endpoint root, no path suffix; absent (with EMBED_MODEL also absent) => lexical-only, see below |
EMBED_MODEL |
no | — | model id/name passed to the embed endpoint; must be set together with EMBED_BASE_URL (both or neither — one without the other throws) |
EMBED_API_STYLE |
no | "openai" |
"openai" | "tei" | "ollama" |
EMBED_API_KEY |
no | undefined |
forwarded as Authorization: Bearer <key> |
RERANK_BASE_URL |
no | undefined |
absent => search degrades to fusion-only |
RERANK_MODEL |
no | undefined |
defaults to bge-reranker-v2-m3 in the client |
RERANK_API_KEY |
no | undefined |
forwarded as Bearer token to the rerank endpoint |
Lexical-only mode (CL-6287). EngineConfig.embed is optional — leave both
EMBED_BASE_URL/EMBED_MODEL unset and the engine still constructs and
serves add + lexical search against a pgvector Postgres with no
embed endpoint configured. Dense retrieval is skipped rather than
attempted (no doomed HTTP call on every query), add still captures
documents (chunks stored, no vectors), and both verbs report a degraded
reason array — never a bare boolean, so a host can write one "is this
response degraded" check across both: search reports
degraded: ["dense_unavailable", "lexical_only"]; add reports
degraded: ["embed_unavailable", "lexical_only"] (or ["embed_unavailable"]
alone when the endpoint IS configured but a specific embed pass failed — a
client error, timeout, or rejected chunk). The embed-model registry
(ensureEmbedModel/activateEmbedModel) is never reached in this mode.
Discoverability. A host does not have to run a search to learn recall is
limited: memory.capabilities.embeddingsConfigured (on the Memory handle
createMemory returns) is false for a lexical-only engine, true
otherwise — known at construction, no query needed. A custom documentStore
that doesn't report its own capabilities defaults to true (this SDK
cannot introspect a vendor store it doesn't own); see
DocumentStoreCapabilities (ports/types.ts) for how a vendor store opts in.
The replay/backfill pipeline (runTransform, promoteGeneration in
services/transform.ts) still requires an embed endpoint — re-deriving a
corpus is inherently a re-embedding operation — and fails loudly if run
against an engine with none configured; re-embedding documents captured while
lexical-only, once an endpoint is later added, is an open follow-up (not
implemented).
The engine's EngineConfig.rerank carries
no apiStyle field of its own; search.ts's toRerankClientConfig hardcodes
apiStyle: "tei" when building the client config, i.e. the engine currently
only wires a TEI-compatible reranker via env (Cohere/Voyage rerank backends
are reachable only through a per-transform_config rerank.apiStyle, not
through top-level env).
Model endpoints are trusted URLs — no SSRF guard, no self-host flag. Every
embed/rerank endpoint the engine calls — its own EngineConfig.embed/
EngineConfig.rerank (the capture embed pass, the dense-search query embed,
the embed-model-registry probe/activation, and toRerankClientConfig) and a
transform_config's replay embed override (buildEmbedClientConfig in
services/transform.ts) — is treated as a trusted URL, exactly like
DATABASE_URL. There is no private-IP / SSRF filtering and no allowSelfHost
knob anywhere: a self-hosted endpoint on localhost or a private IP is just a
URL, indistinguishable from a managed provider. Model/replay endpoints are
configured by the operator (env) or named by a trusted caller in a
transform_config, never by an unauthenticated request — so operators who need
egress control front the endpoints with an allowlisting proxy.
All tables are Drizzle-defined in db/schema.ts, DDL'd in migrations/
(applied by scripts/db-setup.ts, tracked in a _migrations ledger table so
re-running is a no-op). No memory table has a foreign key into any
control-plane table — tenant_id/principal_id/source refs are plain text.
The stable logical row for a captured source. Unique on
(tenant_id, adapter, external_ref) — this triple is the dedupe/identity key
every capture upserts against. Document access is grant tags:
access_tags text[] (resource strings in grant-pattern space; see
docs/AUTHZ-DOCUMENT-ACCESS.md). attributes is a flat jsonb bag of scalars.
last_seen_at bumps on every re-capture, even a content-hash NOOP.
The versioned body of a document. version is a monotonic integer scoped to
(document_id, generation) — not globally per-document — per the
memory_version_document_generation_version_uniq unique index (baseline
schema). status tracks 'active'|'superseded'|'deprecated'|'archived'|'tombstoned';
only one active row exists per (document_id, generation) at a time (the
capture path enforces this by flipping the prior active row to superseded
before inserting a new one). content_hash is the NOOP-check key. Attribution
columns: created_by_principal_id, created_by_kind
('human'|'agent'|'system'|'adapter'), generator_agent_id. Authority
columns (authority, actor_count, has_social_signal, source_class) are
a snapshot computed once at capture time (computeAuthority, never
recomputed retroactively). raw_capture_id points at the immutable source row
this version was derived from. generation (default 'live') is the
replay-generation tag: the normal add path always writes
'live'; a replay (runTransform) writes its own transform_run.id instead,
so a replayed corpus's versions never collide with, or even become visible
alongside, the live ones unless a caller explicitly searches that generation.
An ordered slice of a version's text, keyed by (version_id, ordinal)
(unique). Carries a generated-always text_fts tsvector column (GIN-indexed)
that powers the lexical search channel — this is the only place FTS is
computed; no separate FTS table exists. Its language comes from
FTS_LANGUAGE at migration time; the query side binds the same configured
language as a regconfig parameter. The invariant is verified twice, both
read-only against the catalog: runMemoryMigrations checks after
applying (the deploy step), and the memory plane runs the same check
once, memoized, before its first query (the serving path) — so a mismatch
or unmigrated schema fails loudly on first use regardless of who ran the
migrations. The serving-path check only runs when something actually
calls it — search()/capture() invoke it lazily and memoize the result,
but nothing forces that first call to happen at boot. A host that mounts the
engine without running runMemoryMigrations itself and without wiring a
readiness probe will not learn about a language mismatch until the first
real query or capture fails — not at startup. Hosts that want a boot-time
guarantee must call the exported verifyFtsLanguage from their own
readiness probe; it is not optional belt-and-suspenders, it is the only way
to get a boot-time check if this SDK instance isn't the one that migrated.
Chunks are never reused across versions — every new version gets a
fresh full insert of its own chunks.
Changing FTS_LANGUAGE on an already-migrated database (the mismatch
verifyFtsLanguage throws on) requires rebuilding the generated column —
runMemoryMigrations only applies new files and will not retroactively
alter an existing one. One-time recipe (verified against a live
postgres:16 instance):
BEGIN;
DROP INDEX IF EXISTS memory_chunk_text_fts_idx;
ALTER TABLE memory_chunk DROP COLUMN text_fts;
ALTER TABLE memory_chunk ADD COLUMN text_fts tsvector
GENERATED ALWAYS AS (to_tsvector('<new_language>', "text")) STORED;
COMMIT;
-- Separate statement/connection — CREATE INDEX CONCURRENTLY is rejected
-- inside any transaction block, unconditionally, since Postgres 8.2. It
-- cannot be combined with the BEGIN/COMMIT block above.
CREATE INDEX CONCURRENTLY memory_chunk_text_fts_idx ON memory_chunk USING gin (text_fts);Both ALTER TABLE statements take an ACCESS EXCLUSIVE lock and force a
full table rewrite (dropping then re-adding a STORED generated column
always rewrites) — plan for a stall on memory_chunk for the duration on
a populated database; run in a maintenance window. Only unqualified
pg_catalog config names are supported; a schema-qualified config on this
column is rejected explicitly by verifyFtsLanguage (with this same recipe
in the error) rather than silently mis-parsed.
Lightweight graph rows. memory_entity has no unique constraint; dedupe on
(tenant_id, kind, identifiers) is done in application code
(upsertEntity in capture.ts, an exact-match linear scan per kind). Same for
memory_edge (dedupe on the full (tenant_id, rel, from, to) tuple,
upsertEdge). rel is constrained (DB CHECK + arktype, single source of
truth in src/core/enums.ts) to
mentions|about|authored_by|involves|part_of|derived_from|supports|contradicts|supersedes;
from_type/to_type stored in the DB are
document|version|chunk|entity. Adapter-facing edge hints may also use
native as a planning-time principal ref; capture resolves it to an
entity row (kind=principal) before insert. A lockstep test asserts the
migration CHECK sets match the TS constants.
Two axes on memory.version, orthogonal to ranking priors:
| Column / field | Values | Meaning |
|---|---|---|
provenance |
stated | inferred | unknown |
How content was obtained. Capture defaults to stated; distilled claims write inferred. Existing rows default unknown. |
source_class (lineage) |
native | imported | derived |
Data lineage. Adapters write native (or imported for bulk import); distilled claims write derived via AdaptedDocument.lineageClass. |
Ranking priors (AdaptedDocument.sourceClass: native|thread|channel|call|record) feed computeAuthority only and are not written to the version source_class column — that was a latent CHECK violation fixed when the axes were split.
A derived claim is a normal version with provenance: inferred, lineageClass: derived, and a derived_from edge to the source version (or document). Core never runs inference; it only accepts the shape.
See docs/TEMPORAL.md. On memory.version:
| Column | Role |
|---|---|
occurred_at |
Effective time the content refers to |
ingested_at |
When the plane learned it (no separate asserted_at) |
temporal_class |
event | deadline | state | lesson — ranking prior |
valid_from / valid_until |
Optional validity window |
Search multiplies fused scores by temporalRecencyMultiplier (class-aware). Timeline and search both filter generation (default live) so replay rows never leak into live views.
Per-tenant registry of embed models and their discovered dimensionality
(discoverModelDims/probeEmbedDims — dims are never hard-coded, always
probed live against the endpoint). Unique on (tenant_id, model_key), where
model_key is sha256(baseUrl|modelId).slice(0,16) (computeModelKey).
Two write paths (CL-5872):
ensureEmbedModel— upserts the registry row withstatus = 'ready', creates the per-model table + indexes. Never flips the tenant's active dense table. Used by stagedrunTransformwhen the config's embed model differs from live.activateEmbedModel—ensurethenUPDATE … SET status = 'active'. Used only by the live capture path and bypromoteGenerationwhen cutover should make the generation's embed model serve live dense search.
"Active" for live dense search is the most-recently-updated_at row with
status = 'active' (resolveActiveEmbedTable). Replay dense search uses
resolveEmbedTableByModelKey against the owning transform config's embed
model_key — ready or active — so staged generations never steal live.
Optional archived_live_generation and archived_live_model_key on
transform_run record which generation and dense model held live rows before
promote (for demote/rollback of both corpus and dense search).
Not in db/schema.ts (no fixed shape — dimensionality varies by model) and
not in any migration file. Created at runtime by
activateEmbedModel (embed-model-registry.ts) the first time a given
(baseUrl, modelId) pair is used:
CREATE TABLE IF NOT EXISTS memory_embedding_<key> (
chunk_id text PRIMARY KEY,
tenant_id text NOT NULL,
embedding vector(<dims>),
CONSTRAINT memory_embedding_<key>_chunk_fk
FOREIGN KEY (chunk_id) REFERENCES memory_chunk (id) ON DELETE CASCADE
)plus a (tenant_id, chunk_id) index (created on every activation, so it
retrofits onto older tables). The FK completes the hard-delete cascade
chain document -> version -> chunk -> embedding. CREATE TABLE IF NOT EXISTS cannot add the FK to a table created before it existed; that gap is
accepted (no populated pre-FK installs exist). If hard deletes are ever
introduced against such a table, run once, per embedding table:
ALTER TABLE memory_embedding_<key>
ADD CONSTRAINT memory_embedding_<key>_chunk_fk
FOREIGN KEY (chunk_id) REFERENCES memory_chunk (id) ON DELETE CASCADE;plus an HNSW index: vector_cosine_ops up to 2000 dims (falling back to
ivfflat if the Postgres/pgvector build lacks the hnsw access method), or
a halfvec expression index (halfvec_cosine_ops, pgvector >= 0.7) for
2001–4000 dims — pgvector's vector-typed indexes cap at 2000 dims. Models
above 4000 dims are rejected at activation (MAX_EMBED_DIMS) since no index
type could serve them. MAX_EMBED_DIMS is now pinned to
HALFVEC_INDEX_MAX_DIMS (4000, pgvector's halfvec index cap), narrowed down
from the previous 4096 — a model in the 4001–4096 range that used to
activate and only fail later (no index type covering it) now fails loudly
at activation instead. The dense query's ORDER BY is generated by
cosineDistanceExpr from the same module so it always matches the indexed
expression. The table name is
validated against EMBED_TABLE_NAME_PATTERN
(/^"memory"\."embedding_[a-f0-9]{16}"$/) both when computed and again every
time it's read back from memory_embed_model, before ever being
string-interpolated into raw SQL — this is the only place in the codebase a
computed identifier is spliced into DDL/DML.
The immutable, append-only substrate (the raw-capture layer). Stores the exact add/ingest
request payload (adapter, occurred_at, document) as JSON in raw_text
(there's also a raw_bytes bytea column for non-textual payloads, currently
unused by any write path — everything captured today is JSON). Deduped on
(tenant_id, source_hash), where source_hash is
sha256(stableStringify({adapter, occurredAt, document})) — a byte-identical
recapture reuses the existing row (insertOrReuseRawCapture) rather than
inserting a duplicate; the table never has rows updated or deleted by
ingestion.
A named, versioned recipe of derivation + retrieval-tuning knobs
(TransformConfigParams: chunk — only token.recursive is a valid
strategy today — embed, optional rerank, authorityWeight,
recencyHalfLifeDays, mmrLambda, overfetch). Unique on
(tenant_id, name, version); re-POSTing the same name mints
version = max(existing) + 1 rather than colliding (createTransformConfig).
One execution of a transform_config against a (possibly scoped) slice of
raw_capture. generation is this run's own id, unique
(transform_run_generation_uniq) — so a generation always resolves back to
exactly one run and therefore one config at search time
(resolveGenerationSearchParams). status is 'running'|'completed'|'failed';
scope is jsonb ({ adapter?, since?, until? }, filtering on
raw_capture.fetched_at). runTransform never throws for a mid-run
derivation failure — it marks the run row 'failed' with error recorded and
returns the run summary either way.
adaptAndPlan(document)(core/adapt-and-plan.ts, pure, no I/O): validatestitle/kindare non-empty, re-chunks every incomingAdaptedDocumentChunkthrough the chunker port (defaultchunkTokenRecursive, capsDEFAULT_CHUNK_CAPS= 700 max / 40 min / 60 overlap tokens —adaptAndPlan's internalrechunkalways applies these defaults regardless of chunker, which is why the live path is unaffected by a replay's custom caps), and computescontentHashovertitle + kind + externalRef + stableStringify(attributes) + joined chunk text(core/hash.ts) — this hash is the NOOP-check key.insertOrReuseRawCapture: hashes the raw wire payload (computeSourceHash, independent ofcontentHash— this one covers the unadapted input includingadapter/occurredAt) and looks it up by(tenantId, sourceHash); reuses the existingraw_capturerow or inserts a new one, all inside the same transaction as the derived rows.deriveVersionInTransaction— the single derivation core shared by live capture and replay:- No existing
memory_documentfor(tenantId, adapter, externalRef)→ insert a new document row + a version atversion = 1,supersedesVersionId = null. - Existing document, and its current
activeversion (scoped to thisgeneration) has the samecontentHash→ NOOP: bumplast_seen_atonly, write nothing else, return{ status: "noop", chunks: 0 }. - Existing document, content changed → flip the prior active version to
status: "superseded", insert a new version atversion + 1withsupersedesVersionIdpointing at it, update the document's mutable fields (title,access_tags,attributes,last_seen_at).
- No existing
insertChunksAndGraph: inserts every plan chunk fresh (chunks are never reused across versions), then best-effort upserts entity hints (upsertEntity) and edge hints (upsertEdge) — these are independent of version and never rolled back if a later step fails within the same transaction (they're just additional statements inside it).- After the transaction commits —
embedInsertedChunksWithConfig: resolves/activates the tenant's embed model (activateEmbedModel, probing dims live and creating the per-model vector table if needed), thenembedChunksembeds and inserts vectors for the freshly-inserted chunks. This step is best-effort: an embed-client failure (timeout, HTTP error) or a per-chunk dims mismatch is logged vialog.warnand never thrown — the chunk rows are already durable in Postgres, and the module's own comments note a later re-embed pass could pick up anything left unembedded (no such background pass exists yet in this repo; chunks left unembedded simply never populate the dense channel for the query — they're still found by lexical/FTS). Any of these failure modes setsdegraded: trueon theCaptureResult, surfaced byPOST /api/tenants/:tenantId/memory/addas adegradedfield in its response — the add still succeeded (chunks are durable and lexically searchable), only the dense/vector channel for those chunks is incomplete.
Request-size guards independent of embedding. index.ts caps the whole
request body at MAX_REQUEST_BODY_BYTES = 10 MB (hono/body-limit,
rejecting oversized bodies with 413 before they're even parsed as JSON);
core/schemas/adapted-document.ts separately caps a single chunk's text at
MAX_CHUNK_TEXT_CHARS = 100,000 chars, the number of chunks per document at
MAX_CHUNKS_PER_DOCUMENT = 2,000, title at MAX_TITLE_CHARS = 500, and
kind at MAX_KIND_CHARS = 200 — a payload well under the body-size limit
could otherwise still carry pathologically many or large chunks.
The live path (captureInTransaction → deriveVersionInTransaction) always
writes generation = LIVE_GENERATION ("live", core/generation.ts) and
always resolves/reuses its own raw_capture row. deriveFromRawCapture is
the same core function called directly by a replay with an existing
raw_capture_id and the run's own generation — a replay never writes a new
raw_capture row (the raw-capture corpus is read-only from that path).
Entry point, one query in, one ranked/citable hit list out. k is clamped to
[1, MAX_K=100], default DEFAULT_HYBRID_TOP_K = 8. An empty query string
is only accepted if kinds or entityIds is provided (structured-filter-only
search); otherwise it throws MemorySearchInputError (400).
Living relevancy (CL-5867): after fusion, attachCorroborationCounts loads
supports/contradicts edge counts per version. Ranking multiplies by
corroborationFactor (bounded [0.7, 1.3]); evidence:strong also requires the
gate in core/corroboration.ts (stated human or support count ≥ floor).
Capture-time authority is never rewritten. See docs/RELEVANCY.md.
Wire attribution (CL-5870): attachDerivedFrom loads derived_from edges
onto candidates; toHit emits provenance, source/temporal class, occurred_at,
valid_until, corroboration counts, and derived_from. The plane maps these into
additive SearchItem.attribution (and DocumentStoreSearchItem.attribution).
List/timeline stays document-title oriented and does not attach the full
attribution block (version-level fields are search/feed concerns).
Retention (CL-5871): memory.version.retention_class + migration
0007_retention.sql. Plane helpers in services/retention.ts
(deprecateVersion, tombstoneDocument, hardDeleteDocument,
sweepEphemeral, setRetentionClass). Search accepts includeDeprecated
so lexical/dense can include status IN ('active','deprecated'). See
docs/RETENTION.md.
Capture feed (CL-5868): memory.feed({ after, limit, excludeGenerator? })
and GET .../memory/feed pull live versions ordered by feed_seq (migration
0006_capture_feed.sql). Grant-checked like search. See docs/FEED.md.
- Generation resolution —
generationdefaults toLIVE_GENERATION. For any non-live generation,resolveGenerationSearchParams(transform.ts) looks up the owningtransform_run→transform_configfor the search tenant and pulls its tuning knobs (authorityWeight,recencyHalfLifeDays,mmrLambda,overfetch,rerank); every field it doesn't supply falls back to the engine's own defaults. Live search never pays for this lookup. Cross-tenant generation ids resolve tonull(engine defaults) so embed overrides (includingapiKey) cannot leak. - Lexical channel —
fetchLexicalCandidates: Postgres full-text search (ts_rankagainstplainto_tsqueryin the configuredFTS_LANGUAGE, bound as aregconfigparameter, overmemory_chunk.text_fts), joined tomemory_version(filtered tostatus = 'active'and the resolvedgeneration) andmemory_document(tenant-scoped only — document access is grant-tag post-filter in the plane), optionally further filtered bykindsand/orentityIds(via a sub-select againstmemory_edge). Overfetches up tooverfetchLimitrows, non-deduped, per-chunk. - Dense channel —
fetchDenseCandidates: embeds the query (embedTexts), resolves the dense table:- live generation →
resolveActiveEmbedTable(tenant active model) - staged generation →
resolveEmbedTableByModelKeyfor the transform config's embed model (ready or active; never activates) runs a raw-SQL cosine-distance ANN query viacosineDistanceExpr(e.embedding <=> $vectorup to 2000 dims, or the matching(e.embedding::halfvec(N)) <=> $vector::halfvec(N)expression above that so the halfvec HNSW index is used) against that table joined back tomemory_chunk/memory_version/memory_documentwith the same tenant-only scope as the lexical channel (no mini-ACL in SQL). Returnsnull(not an error) when there's no active embed model yet or the query is empty; a thrown error from the embed call or the SQL itself is caught by the caller and also folds intonull/degraded — the dense channel never fails the whole search.
- live generation →
- RRF fusion —
fuseRrf(core/hybrid-search.ts): combines the lexical and dense per-channel rank orders (never raw scores — they're on incomparable scales) via Reciprocal Rank Fusion,score = Σ 1/(60 + rank). - Per-document dedupe (non-reranked path only) —
dedupeCandidatesPerDocument: collapses to the single highest-scoring chunk perdocumentId, usingauthorityWeightedScore(relevance * (1 + 0.5 * authority)) as the rank prior, tie-broken by recency. - Rerank (only if a rerank endpoint is configured — engine env
RERANK_BASE_URL, or the replay generation'stransform_config.params.rerank): dedupe (without the authority prior — authority is applied later on this path, never twice), take the topRERANK_CANDIDATE_LIMIT = 50, callrerankDocuments(cross-encoder), sort by rerank score. - Bounded authority/recency boosts —
applyBoosts: normalizes the active-stage score (rerank score, or fused RRF score on the degraded path) to[0,1]within the batch, then multiplies byauthorityBoostMultiplierandrecencyBoostMultiplier, both clamped to[0.7, 1.3]— a boost can never let a weak match outrank a strong one on its own. Take the topMMR_POOL_SIZE = 20. - MMR diversity pass —
mmrRerank(core/mmr.ts): greedy pick maximizingrelevance - λ * maxSimilarityToAlreadyPicked(λ = 0.7default, or the replay config'smmrLambda), using vectors pulled fresh from the active embedding table (fetchChunkVectors). Items without a vector are never dropped — appended by score after every vector-bearing item is placed. Produces the final top-korder. - Degrade path — if reranking fails (network error, non-2xx) or was
never configured, the pipeline falls back to
dedupeCandidatesPerDocument(mergedRows, true, authorityWeight).slice(0, k)(fused + authority-weighted order, no MMR) and reportsdegraded: ["rerank_unavailable"]. If dense retrieval failed/unconfigured,degradedincludes"dense_unavailable"and lexical alone answers. - Finishing —
attachEntityIdsjoins in each surviving document's entity edges;toHitbuilds the wireSearchHit(citation/open-target resolution viaopenTarget, mapping known adapters —artifact,task,workflow_run,mail— to a deep-linkable{type, id}, else a generic{type: "memory", id: documentId}). - Evidence —
deriveHybridEvidence:"none"if zero hits;"weak"if the lexical channel contributed zero rows (a dense-only result never reports"strong"); otherwisederiveEvidenceon the lexical rows —"strong"requires both the top-ranked hit's rawts_rank ≥ STRONG_RANK_FLOOR (0.05)and its authority≥ AUTHORITY_STRONG_FLOOR (0.3); else"weak".
Tenant isolation is unconditional and first in every query (tenant_id
filtered before grant-tag / creator document access); every table/channel is scoped that
way, with no exception.
raw_capture is the immutable substrate every replay reads from and never
writes to. A transform_config is a named/versioned recipe
(createTransformConfig, listTransformConfigs) capturing chunk/embed/rerank
- retrieval-tuning knobs.
runTransform(configId, scope?):
- Loads the config, mints a new
runId= the run's owngeneration. - Inserts a
transform_runrow (status: 'running'). - Selects every
raw_capturerow inscope(adapter/since/until filters onfetched_at; an empty scope = a full tenant backfill). - For each row: re-parses its stored JSON payload back into a
CaptureInput(parseRawCapturePayload, itself validated throughRawCapturePayloadSchema— re-hydrating from storage is treated as its own trust boundary, never a blindJSON.parse), then callsderiveFromRawCapturewith the config's own chunker (chunkTokenRecursivewith the config's caps) and embed client config, targeting the run'sgeneration— never the live one. Embed tables are ensured (ensureEmbedModel), never activated, so live dense search is untouched until an explicit promote. - On completion, updates the run row:
status: 'completed',rawCount,versionCount. On any exception mid-loop, catches it, logs it, and marks the run'failed'witherrorset —runTransformitself never throws to its caller; callers always get a run summary. resolveGenerationSearchParamsis howhybridSearchlater maps a generation back to its config's search-tuning knobs (authority weight, recency half-life, MMR λ, overfetch, rerank config) and densemodelKeyfor generation-scoped table resolution.
Promote / demote (staged cutover):
promoteGeneration— requiresstatus = 'completed'. Snapshots the pre-promote activemodel_key, activates the staged embed model (sole active for the tenant; peers demoted toready), then swaps generation tags (live→ archive, staged →live). Recordsarchived_live_generation+archived_live_model_keyfor demote. If the version swap fails after activate, re-activates the prior model_key (or clears active when there was no prior).demoteGeneration— re-activatesarchived_live_model_key(fail-closed if the registry row is gone), or clears all active models when no prior was recorded, then restores archive → live and staged corpus back onto the run generation.
Plane methods (engine DocumentStore only): createTransformConfig,
listTransformConfigs, runTransform, promoteGeneration,
demoteGeneration on Memory. Custom/fake stores omit these methods.
Host privilege: transform/promote/demote take tenantId (and config/run
ids) only — no principal, no HTTP routes. They rewrite live generation and the
active dense embed model for a tenant. The host must treat them as admin
operations (grant-gate before calling); never expose them unauthenticated to
end-user agents.
createMemory({ app }) registers these onto the host app. Identity is the request
principal read off the Interchange context (caller(c) →
{ scopeId: principal.tenantId, subjectId: principal.id }); clients never send
tenant_id/principal_id — the handlers only read title/text/query/limit/access_tags/share.
Each route is guarded with grantGuard(deps, action), which applies the host's
requireGrant("memory", action) when provided (else a pass-through).
Machine callers (CL-6286): RouteDeps.callerResolver /
createMemory({ callerResolver }) lets a host resolve identity for a caller
that never goes through its tenant-session middleware — e.g. a workflow-run
child authenticating with its own sidecar bearer token. Unset by default
(every existing host is unaffected). When set, resolveCaller (deps.ts)
runs ahead of requirePrincipal/grantGuard, calls the resolver, parses its
return with arktype (non-empty tenantId/principalId — a malformed
resolver return is a host bug and gets 500, not 401), and seats the
result as the context principal/tenant so the exact same
requireGrant/authorize path a browser caller gets applies to the machine
caller too. Migrating a host off a hand-rolled parallel surface (like a
createWorkflowMemoryRoutes-shaped workaround) onto callerResolver: that
kind of surface commonly also carries a per-run write-rate limiter and a
request payload cap that this package does not implement (see CL-6286's PR
body for why) — re-home both as host middleware before deleting the old
surface, or a migrating host silently loses them.
| Method + path | Grant action | Request body | Response |
|---|---|---|---|
POST /api/tenants/:tenantId/memory/add |
add |
{ title, text, access_tags?, share? } |
200 { documentId, versionId }; 400 on validation |
POST /api/tenants/:tenantId/memory/search |
search |
{ query, limit?, kinds?, entity_ids?, sources?, includeEvidence? } (limit 1–50; kinds/entity_ids/sources narrow retrieval before fusion; unset or [] = unfiltered; includeEvidence adds a short evidence string when true) |
200 { items[], evidence?, degraded? }; 400 on bad input |
GET /api/tenants/:tenantId/memory/list |
search |
query ?limit= (1–100, string on the wire) |
200 { events: [{ at, title, source, tenantId, principalId }] } — durable recent documents for the caller's scope, filtered with grant-tag access (canAccessDocument). One event per document (active live version). |
GET /api/tenants/:tenantId/memory/feed |
search |
query ?after=&limit=&exclude_generator= |
200 { entries[], nextCursor } — cursor pull of new live versions. See docs/FEED.md. |
POST /api/tenants/:tenantId/memory/documents/:documentId/forget |
forget |
{ reason? } |
200 { documentId, versions }; 403 unless caller is the document's creator; 404 unknown document. Tombstones — content is redacted, not archived; see docs/RETENTION.md. |
POST /api/tenants/:tenantId/memory/documents/:documentId/purge |
purge |
none | 200 { documentId, deleted, reason? }; 403 unless caller is the document's creator; 404 unknown document. Hard-deletes the row — irreversible; refused while a durable version is untombstoned. |
POST /api/tenants/:tenantId/memory/versions/:versionId/retention-class |
forget |
{ retention_class } |
200 { versionId, documentId, status }; 400 invalid class; 403 unless caller is the version's creator; 404 unknown version. |
registerMemoryRoutes and createMemory({ app }) register these seven HTTP
routes (add, search, list, feed, forget, purge, retention-class).
Agent tools ship in this package as Interchange defineTool factories
(@corbits/memory/tools / interchange.tools): thin HTTP clients that call the
mounted routes with install env (memoryBaseUrl, memoryTenantId,
memoryAuthToken). They do not import the plane. Host checklist: agent principal
needs memory:add and/or memory:search grants; Bearer token only (no session
cookie path); tool results are JSON strings; pass AbortSignal if you need hang
protection — the client has no default timeout. OpenAPI→MCP remains an optional
host bridge. The plane surface is add / search / list / close, plus
optional transform methods when backed by the engine DocumentStore
(createTransformConfig, listTransformConfigs, runTransform,
promoteGeneration, demoteGeneration) and optional retention methods
(tombstoneDocument, hardDeleteDocument, setRetentionClass,
sweepEphemeral, deprecateVersion) — see docs/RETENTION.md. Inference stays
on the host.
share.principals on add still mints owner tags, and when the host grant
store implements WritableGrantStore.putGrant:
- Appends
memory.doc:<documentId>to the document'saccess_tags. - Writes one allow/
searchgrant per peer on that resource, originsystem, withconditions.memoryShareaudit payload. resolveGrantConfigmergesMEMORY_SHARE_CONDITION_REGISTRYso those condition keys are not fail-closed-skipped by@intx/authz.
Without a writable store: tags only + warn log (peers need host grants).
Audience widening uses splitAudienceWiden (write-narrow-then-widen) and
shareWidenReceipt on version attributes after source-owner approval.
Ask-on-read remains design-only (fail-closed).
The process-local CaptureLog hardcoded source: "api" and recorded the
HTTP caller's subjectId as principalId at capture time. The durable
timeline maps different columns:
| Wire field | Source column / meaning |
|---|---|
at |
memory_document.last_seen_at (ISO) — re-captures rise in the feed |
title |
memory_document.title |
source |
memory_document.adapter (HTTP add defaults to "http", not "api") |
tenantId |
memory_document.tenant_id |
principalId |
memory_version.created_by_principal_id of the active live version (empty string when null) — the capturing actor stored on the version, not the request principal of a later timeline read |
Document access is Interchange authz — not a mini-ACL.
- Write path:
resolveAccessTagsalways writesmemory.owner:<caller>and merges optionalaccessTags/ share sugar (tenant, peerprincipals, explicittags). Stored onmemory.document.access_tags. - Read path (search + list):
canAccessDocument— creator always allowed; otherwiseauthorize(grantStore, principal, tenant, tag, "search")for any tag on the document. - SQL retrieval is tenant-scoped only. Document access is grant-tag
post-filter in the plane (
canAccessDocument); there is no SQL mini-ACL. - HTTP
POST /addacceptsaccess_tagsand/orshare— not productaclmodes or block lists.
See docs/AUTHZ-DOCUMENT-ACCESS.md.
The SDK does no logging or error-reporting setup of its own — that belongs to
the host app. Routes log a one-line console.error on a 5xx-class failure and
return a generic error body; the host's middleware owns request logging and any
Sentry/OTel wiring.
docker compose up -d # pgvector + Ollama + reranker
docker compose exec ollama ollama pull nomic-embed-text
cp .env.example .env
bun install
bun run db:setup # apply the memory schema, idempotent
bun run test # unit suite (no external services)compose.yml provisions the pgvector Postgres (memory db, host port
5434), an Ollama embeddings server (:11434), and a TEI reranker (:8085).
The engine never embeds internally — when EMBED_BASE_URL is set, it must
point at a real endpoint. A model endpoint is just a URL + capability options,
trusted the same as DATABASE_URL:
- Local default: Ollama at
http://localhost:11434(EMBED_API_STYLE=ollama,EMBED_MODEL=nomic-embed-text). - Paid provider: e.g.
EMBED_BASE_URL=https://api.openai.com,EMBED_MODEL=text-embedding-3-small,EMBED_API_STYLE=openai,EMBED_API_KEY=sk-....
RERANK_BASE_URL is optional — unset runs lexical+dense+MMR without the
cross-encoder (degraded: ["rerank_unavailable"], still ranked/citable hits).
EMBED_BASE_URL/EMBED_MODEL are optional too — unset both to run
lexical-only (degraded: ["dense_unavailable", "lexical_only"], no dense
channel, add still captures documents unvectorized). See the lexical-only
note above.
bun test ./src (bun run test), coverage via bun run test:coverage. Every
core/* module, most services, and the route/identity layer have colocated
*.test.ts files exercising pure logic and mocked-boundary behavior — no
external services required. The SDK ships with unit tests only.