Long-term memory for agents, on Postgres. Prospecta stores what an agent (or a person) writes as verbatim notes cut into chunks, finds them again with one native Postgres query plus a cross-encoder, and hands back cited evidence: every hit says where it is from, when, why it matched, and quotes the matching words.
- Write path: chunks, no model.
retaincuts a note into chunks of at most 1000 characters, embeds them and writes them, in one transaction. The note is kept verbatim. Nothing calls a model at write time. - Stage 1: native Postgres. One query runs three retrievers over the chunks, each with its own reserved slots: semantic (pgvector HNSW, top 25), stemmed lexical (top 20) and exact / phrase lexical (top 20;
"quoted phrases", identifiers,v1.14). They fuse into the top 50 with anexact_snippet(ts_headline) per hit, with the date window, anti-echo list and scope pushed into every retriever. - Stage 2: cross-encoder.
qwen/qwen3-reranker-8b(through OpenRouter) re-scores every candidate against the query with a calibrated 0..1 score; a floor, a near-copy collapse and a timeout fallback to the Stage 1 order keep it safe. - Stage 0 and Stage 3 (optional gates). A decision model skips acknowledgements and live-state questions before a chat message costs a recall (Stage 0); an optional sufficiency check lets
askwiden its search once (Stage 3, off by default). - The cited-evidence contract.
source,created_on,kind,match_kind,relevance_score,exact_snippet, and the full chunk, on every hit and in every block handed to a model (quoted material, never instructions).
The design is docs/postgres-hybrid.md; it is the one normative description.
| mode | entry points | what runs | |
|---|---|---|---|
| Mode 1 | automatic recall for a chat message or a task brief | Memory.expand_and_recall, Memory.recall_for_task, POST /lookup {"task": ...} |
Stage 0 gate (chat only) → a query writer turns the prompt into N structured searches (query, verbatim terms, a period) → every search through Stage 1 and Stage 2 in parallel → one merged, cited block, inside a 7 s deadline |
| Mode 2A | direct lookup: keywords, phrases, filters | Memory.search, Memory.recall, prospecta search, POST /lookup {"questions": ...} |
Stage 1 + Stage 2, no gate, no writer |
| Mode 2B | a question answered from memory | Memory.ask |
Stage 1 + Stage 2, optional Stage 3, then an answer with [n] citations tied to [source | date] "verbatim snippet" |
v1.0. Release notes: CHANGELOG.md. A bank created fresh runs all of the above with no configuration: Memory.create_bank (so prospecta init, create-bank, the service and migrate-bank) stores channel_config = [hybrid_chunk] and a cross_encoder rerank (prospecta/bank_defaults.py). There is one design and one code path: a bank whose channel_config is empty runs hybrid_chunk with its defaults, and anything a stored config names that the library no longer has is ignored. Tests: tests/ (Postgres-backed tests use testcontainers, or PROSPECTA_TEST_PG_URL: docs/testing.md).
Substrate: Postgres with the pgvector extension, 0.8 or newer (Stage 1 uses iterative index scans). Models: an embedder and an llm are injected callables; the Stage 2 reranker and the decision model are reached over HTTP with an OPENROUTER_API_KEY. Without that key retrieval still works: the Stage 1 order stands instead of a rerank and a chat message is not gated (the result says so).
pip install prospecta # the library and the CLI (this also installs litellm)
# Capable defaults via LiteLLM (any provider it supports)
pip install 'prospecta[defaults]'
# Or single-provider minimal-deps embedders
pip install 'prospecta[embedder]' # local sentence-transformers, no API key
# (embed-sentence-transformers is a backward-compatible alias)
pip install 'prospecta[embed-openai]' # direct OpenAI HTTP
pip install 'prospecta[embed-openai-compatible]' # infinity-emb, vLLM, etc.The library code imports no provider SDK; prospecta.defaults and prospecta.embed.* do (PRINCIPLES.md, P3, P17).
Three commands from pip install to the first useful recall, once the environment is set (run from a checkout that has the bundled docker-compose.yml; .env.example has the two database variables):
export OPENAI_API_KEY="sk-..."
export POSTGRES_PASSWORD=changeme
export DATABASE_URL="postgresql://prospecta:changeme@localhost:5432/prospecta"
prospecta init # docker compose up + migrate + create the default bank
prospecta retain "Kelly was born March 4th 1990" --source kelly.md
prospecta search "Kelly birthday"prospecta init is idempotent — re-runs detect already-up containers, already-applied migrations, and the already-created default bank. For a database you already have, prospecta init --no-substrate with its DATABASE_URL. docs/deployment.md covers Docker, a local Postgres, Neon and Azure.
The CLI embedder is selectable via PROSPECTA_EMBEDDER. retain makes no model call, so the write path needs no API key with local sentence-transformers embeddings:
pip install 'prospecta[embedder]'
prospecta create-bank --id local --embedding-dim 384 # all-MiniLM-L6-v2 is 384-dim
PROSPECTA_EMBEDDER=sentence-transformers \
PROSPECTA_BANK=local \
prospecta retain "Kelly was born March 4th 1990" --source kelly.mdPROSPECTA_EMBEDDER accepts default/litellm (the LiteLLM multi-provider default) or sentence-transformers/st (offline). PROSPECTA_EMBED_MODEL selects the model id (sentence-transformers default all-MiniLM-L6-v2).
One default embedder: the LiteLLM default is openai/text-embedding-3-large cut to 1536 dimensions, also what migrate-bank writes and the width of new banks. The CLI reads the bank's embedding_dim and embeds queries at that width (PROSPECTA_EMBED_DIM overrides); prospecta health prints PROSPECTA DOWN if the embedder's width differs from the bank's. A bank's embedding_dim is fixed at creation and must match the embedder's output dimensionality: a sentence-transformers bank is not interchangeable with an OpenAI (1536-dim) bank.
With OPENROUTER_API_KEY set the CLI also builds the Stage 2 rerank transport, the decision model (Jev, System One) and the accounted LLM that writes the ask answer. PROSPECTA_JEV=off opts out of Jev.
Note dates: every retained note gets documents.created_on: its own date (frontmatter created / created_on / date, or the metadata= kwarg), else today's date in the zone named by PROSPECTA_TIMEZONE (an IANA name such as America/Los_Angeles; UTC when unset).
prospecta health probes the database (DATABASE_URL) and the configured embedder (including that sentence_transformers imports when selected). It prints one line and exits 0 with PROSPECTA OK: ... (naming the embedder probed), or exits 1 with PROSPECTA DOWN: ....
from prospecta import Memory
from prospecta.defaults import make_default_embedder, make_default_llm
from prospecta.stages import JevScore, openrouter_jev_transport, openrouter_rerank_transport
m = Memory(
database_url="postgres://localhost:5432/prospecta",
bank_id="default",
embed=make_default_embedder(), # or your own: (list[str]) -> list[list[float]]
llm=make_default_llm(), # or your own: (messages, *, json_mode, model=None) -> str
jev=JevScore(openrouter_jev_transport()), # Stage 0 gate and Stage 3 (optional)
rerank_transport=openrouter_rerank_transport(), # Stage 2 (optional)
)
m.create_bank("default", embedding_dim=1536) # once (prospecta init does it); a new bank needs no more configuration
# Write: the note is cut into chunks and embedded; no model call
m.retain("Kelly was born March 4th 1990. Her birthday party in 2026 was epic.",
source="kelly.md", tags=["family", "kelly"], metadata={"created_on": "2026-03-05"})
# Mode 2A: search. Phrases, OR, -exclude and identifiers work; the filters apply inside Stage 1
hits = m.search('"electric dreams" 0x80070005', date_from="2024-01-01", excluded_sources=["turn:41"])
hits[0].source, hits[0].created_on, hits[0].match_kind, hits[0].relevance_score, hits[0].exact_snippet
# Mode 2B: ask. Every statement carries [n]; optional Stage 3 widens the search once if the evidence falls short
res = m.ask("how are deletes handled?", sufficiency=True)
print(res.rendered) # the answer, then [n] [source | date] "verbatim supporting snippet"
# Mode 1: automatic recall, a chat message or a task brief
got = m.expand_and_recall("do you remember who I saw it with?", agent_last="You mentioned the Spring show.",
excluded_sources=["turn:41", "turn:42"], date_from="2024-01-01")
got = m.recall_for_task(title="Library tree view", ask="Build it", done="folders page in", project="atlas")
print(got.block) # the searches that ran, "cite [n]", then the numbered items
got.searches, got.items, got.fallbacks, got.timings_ms, got.accounting # what ran, what was found, what degraded, ms, tokens and costMode 1 in five lines (docs/postgres-hybrid.md, section 1, is the design; docs/recall-stages.md has every setting):
- Gate (chat only; a task is never gated): the decision model's
needs_memoryskips acknowledgements, go-aheads, greetings and live-state questions (at or above 0.5 it goes on).decision=callableinjects your own;gate=Falseswitches it off. - Writer: about 3 searches for a short message, up to 10 to 20 for an elaborate one, at most
max_searches(20). Each is{query, terms, date_from, date_to}:queryfeeds the semantic and stemmed retrievers and the reranker;terms(only proper names, identifiers, versions, paths, error codes and "quoted phrases", verbatim from the prompt, never common words: a deterministic filter drops a plain English word the prompt did not quote) feed the exact retriever; a period is pushed into Stage 1. A name, identifier or quoted phrase of the prompt that no search carries is put back into the first one. A bad reply falls back to one search: the prompt itself. If the filter drops every search, nothing is asked (a skipped, empty result). - Stage 1 and 2 in parallel, each search against its own query, with the same filters; at most
max_concurrency(8) searches in the database at once. - Merge: one record once (best score,
found_bythe searches), near-copies collapsed (by wording, then by meaning: chunk-embedding cosine of 0.90 or more; the kept record lists what collapsed into it), best first, a task's own project first; sized by relevance, never by a count. - Never raises on a provider failure and stays inside the deadline (7 s): a step that fails or runs late degrades and is named in
fallbacks.
Mode 1 runs on every bank: it needs Stage 1's hybrid_chunk, which a bank with an empty channel_config runs with its defaults. (Only a config that disables it, {"name": "hybrid_chunk", "enabled": false}, makes expand_and_recall return an error.)
Other entry points: Memory.index_directory (walks a corpus; frontmatter created, person, source_kind become the note's date, person and kind), Memory.start_sweeper (drift safety net), Memory.set_channel_config / set_recall_config (docs/recall-stages.md), Memory.bank_stats. The SourceIndexer indexes a host's records in place from database views (docs/source-contract.md).
prospecta init # docker compose up + migrate + create the default bank
prospecta migrate # apply pending migrations
prospecta create-bank --id alice --embedding-dim 1536
prospecta migrate-bank --source-bank alice # re-embed alice into alice-v2: another embedder or dimension (docs/embedding-migration.md)
prospecta retain "content..." --source X --tags a,b # or "@path/to/file"
prospecta index ./corpus # walk a directory; chunk + embed
prospecta search "query" [--limit N] [--depth standard|deep] [--json]
prospecta eval questions.md [--ablate] # score recall; docs/eval.md
prospecta import hindsight postgres://... # docs/import-hindsight.md
prospecta sweep ./corpus --watch # background sweeper for filesystem drift
prospecta stats # bank / document / event counters
prospecta health # database + embedder; non-zero if down
prospecta config # show loaded config (passwords redacted)
prospecta backup FILE / restore FILE # docs/backup-restore.mdThe global options are --database-url (else DATABASE_URL) and --bank (else PROSPECTA_BANK, default prospecta).
python -m prospecta.service (or the Dockerfile): stdlib HTTP, JSON.
POST /lookup {"questions": [...], "limit": 10, ...}— Mode 2A for each question: ranked records, each with its citation fields (created_on,match_kind,exact_snippet,relevance_score).POST /lookup {"task": {"title", "ask", "done"?, "project"?}}— Mode 1: the cited context block plussearches,results,fallbacksanderror. Nolimit: the block is sized by relevance.POST /index,/remove,/reconcile,GET /coverage,GET /healthz— in-place indexing of a host's records.
Filters on a lookup: date_from, date_to, excluded_sources (the anti-echo list), kind, project, person, allowed_audience_keys, allowed_projects; per-call reranker_model and decision_model. The contract, shared-database mode and every environment variable are in docs/source-contract.md.
Every load-bearing event is recorded in Postgres event tables, a durable audit trail rather than ephemeral stderr: retain_events, recall_events (the queries, the surfaced notes, the per-stage trace with the rerank's cuts and fallback reason, cost and tokens) with recall_event_candidates, llm_calls (every model call with its purpose, model, tokens, cost and, opt-out, the verbatim prompt and response), sweep_passes and sweeper_state. docs/observability.md is the inspection guide with the psql cheat sheet, the tracer, and the opt-outs.
tui/ is a Rust + Ratatui terminal UI that makes the trace legible without SQL: bank and document browsing, a live event stream with end-to-end thread views, a per-bank health dashboard, lexical search and a manual retain form. Install with cargo install --path tui.
docs/postgres-hybrid.md |
The architecture: the modes, the stages, the lexical engine, the SQL, the call sequence |
docs/recall-stages.md |
channel_config / recall_config: Stage 1 params, Stage 2, the models, Mode 1 settings, Stage 3, depth, the cited-evidence contract |
docs/schema.md |
the tables, indexes, write semantics and migrations 0001 to 0020 |
docs/limits.md |
every numeric limit and why it is there |
docs/observability.md |
the event tables, the psql cheat sheet, tracers |
docs/bank-isolation.md |
why one bank's size no longer slows another: the per-bank HNSW and GIN indexes |
docs/exact-tsv.md |
the stored exact vector (migration 0020, which rewrites memory_items), its operator runbook, the cost of plain questions |
docs/deployment.md |
Docker, local Postgres, Neon, Azure; pgvector 0.8 |
docs/source-contract.md |
shared-database mode, source views, the HTTP service |
docs/backup-restore.md, docs/embedding-migration.md, docs/import-hindsight.md, docs/testing.md, docs/eval.md, docs/decisions/0001-land-direct.md |
operations |
docs/hybrid-implementation-plan.md |
the plan that built this and the status of its slices (removed when slices 5 and 6 finish) |
PRINCIPLES.md |
the load-bearing principles |
AGENTS.md and the skills |
the practice, with its reasons and evidence: recall, retain, search-and-ask, tuning-and-testing, operating; other projects adopt them, and lessons come back as PRs |
Honest about what is not there:
- Sync only. All
retain/search/askcalls block; Mode 1 runs its searches on threads inside one call. An async surface is post-1.0. - English-only stemming. The stemmed retriever uses
to_tsvector('english', ...); the exact retriever (unstemmedsimple) and the semantic retriever are language-neutral. - pgvector 0.8 or newer. Stage 1 uses iterative index scans; there is no fallback for an older pgvector.
- Embedding-dim change requires a new bank (
prospecta migrate-bank). - HNSW build cost on large banks. Each bank has one partial HNSW index (over its chunks); the first bulk index can be slow.
- A very long plain question over common words is slow in the stemmed retriever (seconds at 20 common words on a 73,000-chunk bank): measured in
docs/exact-tsv.md; Mode 1's per-searchqueryis short. - Stage 2 and the gates need a key. With no rerank transport the Stage 1 order stands; with no decision transport there is no gate. Both are recorded in the result.
- Migration 0020 rewrites
memory_itemsunder anACCESS EXCLUSIVElock: readdocs/exact-tsv.mdbefore upgrading a database that holds a bank.
See LICENSE. ⚒️
