What is Assay?
Heads up: this page describes an earlier version of Assay. For the current plugin, install steps, and code, read the README on GitHub.
Local-first decision memory for PMs. Every call you commit to with Claude gets captured as a typed, queryable decision with reasoning attached, stored on your laptop. No account, no cloud database, no API key required. Your PRDs, research, and decisions stay on your laptop — backed up with cp, moved with cp.
You (in Claude):
/assay-decision why did we kill the bulk-edit feature
Assay:
DECISION (2026-04-12, confirmed)
We killed bulk-edit before launch because the cross-tenant data risk
outweighed the cycle-time gain. 3 of 4 design reviewers (Sasha, Min,
R.) blocked on the audit-log gap. Superseded the "ship bulk-edit Q3"
plan from 2026-02-09.
EVIDENCE
• Security review notes — 2026-04-10
• Customer interviews #14, #17 — Jan 2026
• PRD v2.3 §6.2 — 2026-03-22
See more: /assay-decision <id> --expandReference, not pitch. That example is what you came to see. Everything below is the engineering reference. For high-level questions about why Assay exists and who it's for, see the landing-page FAQ.
Assay turns your PRDs, strategy documents, research notes, and recorded decisions into a structured set of cited evidence that your AI tools can query. Instead of relying on stale memory or keyword search, every question and proposal gets checked against what your organization actually knows — with source links, citation tokens, and explicit refusal when the evidence is insufficient.
This page describes the local versionof Assay. Everything — the indexed documents, the embeddings, the full-text index, the retrieval engine — lives on your machine in a single SQLite file at ~/.assay/assay.db. It plugs into Claude Code as an MCP server and exposes a set of slash commands (/assay-scan, /assay-decision, /assay-retrieve, /assay-stress-test, /assay-config, /assay-sync-status) plus a CLI. The full underlying MCP tool surface is documented in Under the hood.
Silo doctrine. Hook activation is project-scoped — Assay only fires when your cwd is the silo’d repo. Data at ~/.assay/assay.db is user-scoped — one memory across all your silos. This split is intentional: silos isolate when Assay listens, not what it remembers.
Prerequisites
- Node.js ≥20 (LTS recommended). Required — the MCP server and CLI are Node processes.
- git — to clone the repo.
- Claude Code (desktop app) — the MCP client that surfaces the slash commands. Any MCP-capable client works; Claude Code is the one we test against.
- ~600 MB free disk — the embedding model (one-time ~420 MB download) plus your corpus.
- macOS or Linux. Windows is untested.
Embedding: local embedding model. No API key required.
Under the hood
Technical detail below. PMs can skip to Installation.
Internally, Assay plugs into Claude Code as an MCP server and exposes ten MCP tools plus a CLI. The slash commands above are thin wrappers over those tools. Architecture diagram:
┌────────────────────────────────────────────────────────┐
│ Claude Code │
│ retrieve scan stress_test configure │
│ assay_decision_recall assay_decision_expand │
│ assay_recall brief_decision_render │
│ promote_claim demote_claim │
└──────────────────────────┬─────────────────────────────┘
│ MCP (stdio)
▼
┌────────────────────────────────────────────────────────┐
│ Assay MCP server │
│ │
│ Ingest ──► Chunk ──► Embed ──► Store ──► Retrieve │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ Markdown 512-tok local SQLite hybrid │
│ (Notion heading- embedding single keyword │
│ optional) aware model file + semantic │
│ search │
└────────────────────────────┬───────────────────────────┘
│
▼
~/.assay/assay.db (single file)MCP server tool names
The slash command is what a PM types in Claude. The MCP server tool name is the underlying tool the MCP client invokes. They map 1:1 for the user-facing surface; the rest are internal recall and capture primitives.
| Slash command | MCP server tool |
|---|---|
/assay-scan | scan |
/assay-decision | assay_decision_recall |
/assay-decision <id> --expand | assay_decision_expand |
/assay-retrieve | retrieve |
/assay-stress-test | stress_test |
/assay-config | configure |
/assay-sync-status | configure subcommand="status" |
| (internal — cascade recall) | assay_recall |
| (internal — brief synthesis deposit) | brief_decision_render |
| (internal — decision lifecycle) | promote_claim / demote_claim |
Installation
Heads up: this page describes an earlier version of Assay. For the current plugin, install steps, and code, read the README on GitHub.
Five minutes from clone to first cited answer.
Local install
Runs entirely on your machine as a single SQLite file at ~/.assay/assay.db. No Docker, no account. Uses a local embedding model. No API key required.
Install: bash scripts/install-tester.sh — project-scoped, never modifies ~/.claude/settings.json, never installs ECC, never clones everything-claude-code. ECC is a separate global tool you install independently; Assay does not bundle or wire it.
First command to try after install: /assay-scan— fast pre-flight signal check against whatever's already in your decision graph and corpus.
Prerequisites
- Node.js ≥20
- Claude Code installed (for the MCP integration)
- macOS or Linux (Windows untested)
1. Clone + install
git clone https://github.com/levievanshantz/assay.git
cd assaylabs
npm install2. Bootstrap the SQLite schema
node scripts/migrate.mjsIdempotent. Creates ~/.assay/assay.db, applies the base schema, and walks every migration in order. Postgres-era migrations (versions 000–022) are auto-skipped on SQLite. Re-run any time — already-applied migrations stay quiet.
3. Index a folder of markdown
./node_modules/.bin/tsx bin/assay.ts add ~/my-prds --name prds
./node_modules/.bin/tsx bin/assay.ts index
./node_modules/.bin/tsx bin/assay.ts statusFirst index downloads the local embedding model (~420MB, one-time). A 50-PRD folder finishes in under 60 seconds.
4. Wire up Claude Desktop via the launcher
The repo ships with a launcher script at ~/.local/bin/assay-mcp that auto-discovers the built MCP server and decouples your Claude Desktop config from the repo path. Recommended over hard-coding the dist path. Add this assay entry to mcpServers in claude_desktop_config.json:
"assay": {
"command": "/Users/YOU/.local/bin/assay-mcp",
"args": [],
"env": {
"ASSAY_DB": "sqlite",
"ASSAY_DB_PATH": "/Users/YOU/.assay/assay.db"
}
}Restart Claude Desktop. Test with /assay-retrieve. Verify with ~/.local/bin/assay-mcp.doctor— it checks the launcher, the config pointer, server boot, SQLite reachability, and recent log cleanliness in one pass. Run it any time Claude Desktop says “assay disconnected.”
Direct (no launcher) wiring still works too — point command at node with args: ["/abs/path/mcp-server/dist/index.js"].
5. Live-update (optional)
./node_modules/.bin/tsx bin/assay.ts watchKnown limitations: Single-machine only; no team sharing today. A shared institutional tier on Postgres + pgvector is speculative future work, not committed yet. If a query gives an obviously wrong top result, file it with the GitHub issue template; ranking tuning is an open work item.
Quick Start
See value in 5 minutes.
1. Seed the Demo Corpus
Assay ships with a small demo corpus — a handful of product strategy documents, research summaries, and decision records — so you can explore retrieval and stress-testing without connecting any real data sources first.
npm run seed-demoThis inserts roughly 40 evidence records and 120 extracted decisions into your local database. It takes about 30 seconds while embeddings are generated.
2. Configure Claude Code
Register Assay as an MCP server so Claude Code can call its tools. Add the following to your project’s .claude/settings.local.json:
{
"mcpServers": {
"assay": {
"command": "node",
"args": ["/absolute/path/to/assay/dist/index.js"],
"env": {
"ASSAY_DB": "sqlite",
"ASSAY_DB_PATH": "/Users/YOU/.assay/assay.db"
}
}
}
}Tip: Replace /absolute/path/to/assay with the actual directory where you cloned the repository. The path must be absolute — Claude Code does not resolve relative paths for MCP servers.
3. Test It
Open Claude Code in any project directory and try the following prompts:
Run a pre-flight check:
Run scanGet a briefing on a topic:
retrieve "customer feedback" mode="brief"What to Expect
The scantool returns a fast pre-flight signal check (3–5 signals, classed as clear / caution / blocker) over a fused candidate pool: decision-graph hits at cosine ≥0.65 plus hybrid corpus retrieval. If you want corpus health instead (sync freshness, record counts, drift), call configure subcommand="status".
The retrieve tool in brief mode returns a structured summary of what your organization already knows about the given topic. It includes:
- Context summary — a narrative overview of the topic drawn from your evidence
- Prior work — existing decisions, research, and documents that address the topic
- Active constraints — known blockers, limitations, and guardrails
- Unresolved debates — open questions and contradictions found across sources
- Open questions — gaps the team has not yet addressed
Next Steps
How It Works
The five-step pipeline from source document to retrievable intelligence.
Ingestion Pipeline
1. Ingest
Source pages are fetched from Notion or Confluence via their APIs. Each page's content is converted to a normalized Markdown representation, preserving heading structure.
2. Chunk
The Markdown is split at heading boundaries into sections of approximately 3,200 characters each. These become your evidence records — the atomic units of document-level storage.
3. Embed
Each evidence section is embedded into a dense vector using a local embedding model that runs in-process via @huggingface/transformers (no API key required). Vectors are stored alongside the rest of the corpus in the same SQLite file.
4. Index for full-text
Each section is also indexed for keyword search so retrieval can combine semantic similarity with exact lexical matching.
5. Retrieve
At query time, four parallel search lanes fire (keyword + semantic across both evidence sections and individual decisions) and their results merge into a single ranked list. Decisions resolve back to their parent evidence records via a source_id foreign key, so you always get full context.
4-Layer Hybrid Retrieval
Each query runs four search lanes in parallel:
- Evidence semantic search — cosine similarity against evidence record embeddings. Catches semantically related sections.
- Decision semantic search — cosine similarity against individual decision embeddings. Catches atomic assertions that point in a different semantic direction than their parent section.
- Evidence keyword search — built-in SQLite full-text search. Catches exact terminology and proper nouns that semantic search may miss.
- Decision keyword search — keyword search against decision text. Catches specific terms used in individual assertions.
Results from all four lanes are merged into a single ranked list using hybrid (keyword + semantic) fusion. Decisions resolve back to their parent evidence records via the source_id foreign key, so the final result set contains full-context sections rather than isolated sentences.
Index integrity — the decision keyword index stays in lockstep with the decision table via AFTER triggers (insert / update / delete) shipped in migration 032. Without those triggers, freshly drained decisions were invisible to keyword retrieval until a manual backfill. Now every write keeps both indexes synchronised.
Evidence & Decisions
The two-layer data model that powers granular retrieval. (The decision layer is internally still the claims table for code continuity.)
Evidence Records
An evidence record is a section produced by the two-pass break-point chunker (target ~512 tokens per chunk, headings used as natural breakpoints, oversized sections sub-split along sentence boundaries with heading-context preservation). Each record stores the raw text, a dense embedding vector (local embedding model; no API key required), source metadata (page URL, title, heading path), and a content hash for drift detection and delete/rename tombstone retirement.
Evidence records are the primary unit of retrieval. They provide the full context window that tools like retrieve (in brief mode) and stress_test synthesize from. Every decision links back to exactly one evidence record.
Decisions
A decision is an atomic assertion extracted from an evidence record or a Claude session — a single statement that can be independently verified, contradicted, or cited. Each decision gets its own embedding vector, which may point in a semantically different direction than its parent section. This is what enables retrieval of individual assertions that chunk-level averaging would lose.
On average, decision extraction adds 10.7 additional unique records per query, surfacing evidence that document-level search alone would miss.
Decision Types
Every decision is classified into one of seven types:
| Type | Description |
|---|---|
finding | An observed fact or data point from research |
recommendation | A suggested course of action |
assumption | A belief taken as given without direct evidence |
metric | A quantitative measurement or target |
constraint | A limitation, blocker, or guardrail |
commitment | A promise or agreed-upon deliverable |
deferral | A decision explicitly postponed to a later date |
Epistemic Layers
Each decision also carries an epistemic layer classification:
- Observation — a factual report of what was seen or measured
- Interpretation — an analysis or conclusion drawn from observations
- Intention — a stated plan, goal, or commitment to act
Decisions & Ambient Tagging
A first-class layer for material commitments. Capture, recall, and walk the chain of why something got decided.
What a decision is
A decision is an atomic, candidate-status row that names a real choice the team has committed to, with a reasoning trail and cited evidence. Decisions live in the same physical store as claims (column decision_kind IS NOT NULL) but are a distinct surface: they have their own status lifecycle (candidate → tentative → confirmed), their own provenance fields, and an append-only claim_evidence audit trail so an evidence-set change appends a new row instead of mutating the decision.
Recall returns lightweight summaries; expand pulls the full reasoning trail, current evidence list, and the predecessor chain on demand. This semantic-pointer pattern keeps Claude's context lean while preserving the full record one tool call away.
Three capture paths
- Structural — when
retrieve mode=briefruns, the caller's LLM produces strict JSON thatbrief_decision_rendervalidates, renders, and deposits as candidate decisions. Zero API key required server-side; the caller's model does the synthesis. - Ambient (session drain) — Claude emits structured
<decision>tags inline at commit moments. Theassay drainCLI extracts them from the session JSONL via regex, dedupes by content hash, embeds the statement, and writes the row. No LLM call in the forward path. - T0 historical backfill — a one-time Phi-4 14B pass over the existing corpus seeds the decision graph from prior PRDs and research notes. Idempotent via content hash, so re-running is safe.
Tag format
When Claude commits to a material decision in conversation, it emits a structured tag that the drain parser picks up:
Eng shape — schema / tooling / policy / rollout / eval / scope commitments:
<decision impact="schema|tooling|policy|rollout|eval|scope"
confidence="moderate|low"
layer="observation|interpretation|decision">
<statement>{<=200 chars; specific real subject}</statement>
<reasoning>{<=400 chars; why this was decided}</reasoning>
</decision>PM shape— product hypotheses with explicit problem / hypothesis / metric / outcome attributes:
<decision kind="pm"
problem="testers abandon install at step 3 (Claude Desktop wiring)"
hypothesis="auto-generating the mcpServers JSON block cuts step-3 drop-off in half"
metric="install-completion rate among new testers, week-over-week"
outcome="ship the auto-config writer behind --auto-wire before next tester cohort">
<statement>Auto-write the Claude Desktop MCP block during install-tester.sh.</statement>
<reasoning>Step-3 (manual JSON edit) is the highest-friction step in the install flow; a generated block removes a class of typo errors and makes the 5-minute promise real.</reasoning>
</decision>Both child tags (<statement> and <reasoning>) are required — missing or empty bodies are dropped at drain. For the eng shape, the impact attribute drives downstream filtering. For the PM shape, kind="pm" swaps the attribute set to problem / hypothesis / metric / outcome. The optional layer attribute (default decision) marks the epistemic level — observation for direct factual reports, interpretation for inference and synthesis, decision for committed action. Recall can filter by layer to surface only the committed decisions or trace the observations and interpretations that led there.
Decision graph over time
Every state change on a decision — initial deposit, promotion through the review lifecycle, supersession by a new decision, rejection — appends a row to the decision_transitions audit table. Each row records the actor agent (which Claude instance, GPT call, or Levi input), the model id when known, the transition reason, and any evidence record that triggered the change.
assay_decision_expand returns the full transition history alongside a depth-N predecessor walk (supersedes_chain) so callers can reconstruct exactly how a decision evolved across a quarter and which model touched it at every step. Cycle-guarded with a 10-hop ceiling to keep the response bounded.
Recall ranks with a hybrid score — 0.7 cosine + 0.2 recency + 0.1 frequency (90-day half-life on recency, log-normalized citation count for frequency). Cosine remains the floor and the tiebreaker, so semantically weak matches never get resurrected by recency alone. Pure cosine is one env flag away (ASSAY_RECALL_HYBRID=0) for kill-switch revert.
Pass includeSuperseded=trueto recall when you want the “what did we change our minds about” surface — the response then carries a sidecar of recently deprecated decisions tagged with their replacement pointers, capped so the active set still dominates.
Draining a session
Run assay drain at any time to ingest tags from the most recent session JSONL into the local decision ledger.
# Drain the latest session, last 24h
assay drain
# Specific session JSONL
assay drain --session ~/.claude/projects/<slug>/<id>.jsonl
# All sessions in your projects directory
assay drain --all
# Preview without writing
assay drain --dry-runThe drain is idempotent: re-running over the same session produces zero new rows. Each session also gets a sentinel evidence record (source_kind='session') that anchors its decisions for clean provenance, kept out of normal retrieval queries.
The two commands you’ll use first
Most of the value comes from two slash commands. Start here; the full tool surface is documented below for engineering reference.
/assay-scan— before you commit to a call, ask Assay what it already knows. You describe the proposal in plain English; Assay returns 3–5 signals classed clear, caution, or blocker, drawing from both prior decisions and the evidence corpus. Use it when you’re about to greenlight, kill, or reverse something and want a sanity check first./assay-decision— ask “what did we decide about X, and why?” Returns the matching decision summary first; expand any hit to see the full reasoning trail, the evidence cited at the time, and the predecessor chain (what this decision superseded, and what superseded it). This is the “why did we kill that?” answer that previously required an archeology dig across Slack and docs.
Full tool surface
The remaining MCP tools are named recall surfaces (each solves one job) plus a cascade tool for when the caller doesn’t know which layer holds the answer. Engineering reference:
/assay-retrieve(server tool:retrieve) — hybrid keyword + semantic search over evidence chunks, in modesraw/guided/evaluate/brief. The corpus layer of the memory hierarchy. Use when you want raw evidence rather than a committed decision./assay-stress-test(server tool:stress_test) — deliberate adversarial review of a proposal. Surfaces every dissenting decision and conflicting chunk. Use when you’ve already decided and want the strongest argument against./assay-config(server tool:configure) — view and update extraction mode, retrieval depth, feature toggles, presets./assay-sync-status(server tool:configure subcommand="status") — read-only corpus health: sync freshness, record counts, drift warnings.- Provenance (server tool:
assay_decision_expand) — given a decision id, returns the full reasoning trail, the latest cited evidence, the fulldecision_transitionsaudit log, and a depth-N predecessor walk (supersedes_chain). Invoked automatically by/assay-decisionwhen you ask it to expand a hit. - Cascade (server tool:
assay_recall) — tries the decision graph first, falls through to the evidence corpus if no semantic hit, returnsinsufficient_evidencewhen both layers come up empty. Every response carries asource_tierbreadcrumb so the caller knows whether it’s reading a structured decision, a raw corpus chunk, or a verdict that nothing in the ledger answers the question. Use when the question is abstract or you don’t know which layer holds the answer.
Supersession semantics — audit-only by default
When a tag includes supersedes="<id-or-prefix>", the drain resolves the predecessor with strict unique-match enforcement. Prefixes must be at least 12 characters; shorter prefixes or ambiguous matches fail the resolver and the new decision lands without the link (failure reason recorded for audit). On success the drain (a) sets superseded_by_claim_id on the new row and (b) appends a supersession-edge-recorded transition row on the predecessor. Default mode is audit-only — the predecessor’s status stays untouched so a wrong tag can’t accidentally hide live knowledge. Set ASSAY_SUPERSEDE_FLIP=1 in the MCP env to opt into the live-flip mode once you trust your tagging discipline.
Health check
assay doctor reports decision-layer state: total decisions, embedding coverage, status breakdown, and a per-source-agent breakdown so you can confirm the drain is landing rows under Claude Opus (or the appropriate model) and the corpus backfill is intact.
Retrieval Pipeline
How queries become ranked evidence.
Every retrieval query fires four parallel search lanes, each capturing a different axis of relevance:
- Evidence vector search — finds semantically similar document sections via cosine distance against evidence embeddings.
- Decision vector search— finds individual assertions whose embeddings are close to the query, even when the parent section's overall embedding diverges.
- Evidence keyword search — built-in SQLite full-text search for exact keyword matches in evidence records.
- Decision keyword search — keyword matches against the text of individual decisions.
Why hybrid search
Different search methods catch different things. Semantic search finds meaning-based similarity but can miss exact terminology. Keyword search catches precise terms but misses paraphrased content. Decision search catches individual assertions lost in chunk-level embedding averaging. Hybrid fusion merges ranked results from all four lanes into a single list, weighting results that appear across multiple lanes more heavily.
Configurable K Values
K values (the number of evidence records retrieved) are configurable via environment variables or the configure tool. Defaults: scan=40, stress_test=80, retrieve=20. Brief mode depth depends on setting: quick=5, standard=15, deep=30.
retrieve
Query the corpus with four retrieval modes.
The retrieve tool is the primary interface to the corpus. It runs 4-layer hybrid search and returns results in one of four modes, each suited to a different workflow. Default retrieval depth is K=20 evidence records.
Four Modes
- raw — returns top-K evidence records with hybrid ranking scores. No LLM call. Fastest and cheapest.
- guided — returns evidence plus an
eval_instructionsfield. The calling LLM processes the results itself — zero extra API cost. - evaluate — the server calls an LLM to synthesize findings and returns a plain-language summary.
- brief — generates a structured organizational briefing: context summary, prior work, active constraints, unresolved debates, open questions, and dependencies. Each brief deposits its synthesis back into the corpus, creating an accumulation loop.
Brief Output
When using mode="brief", the tool returns:
- Context summary — narrative overview of the topic from your corpus
- Prior work — existing decisions, research, and documents
- Active constraints — known blockers and guardrails
- Unresolved debates — contradictions found across sources
- Open questions — gaps the team has not addressed
- Dependencies — related work and cross-cutting concerns
Examples
Raw retrieval:
retrieve "competitive landscape" mode="raw" top_k=30Organizational briefing (default K=20):
retrieve "customer onboarding friction" mode="brief"Retrieval Depth
Default K=20 evidence records. Configurable per call via top_k parameter or globally via environment variables.
stress_test
Stress-test a proposal against organizational evidence.
The stress_test tool takes a proposal or plan and rigorously evaluates it against your corpus. It finds supporting evidence, surfaces contradictions, identifies assumption weaknesses, and delivers a verdict with confidence level.
What It Returns
- Verdict — one of:
ready,ready_with_conditions,blocked,needs_clarification - Overlap analysis — where the proposal aligns with existing evidence
- Conflict analysis — contradictions found in the corpus
- Assumption weaknesses — unstated or fragile assumptions
- Evidence gaps — areas where the corpus has no data
- Supporting evidence — records that back the proposal
- Alternatives — other approaches suggested by the evidence
- Falsification check — what evidence would need to be true for the proposal to fail
Example
stress_test "We should move the mobile onboarding flow to a native app"Retrieval Depth
Default K=80 evidence records. K values are configurable via the configure tool or environment variables.
scan
Fast pre-flight signal check over a fused candidate pool.
The scan tool runs both layers in parallel: pulls decision-graph hits at cosine ≥0.65 plus hybrid corpus retrieval, then returns 3–5 signals classed as clear, caution, or blocker. Use it before committing to a proposal to surface anything the corpus or decision graph already says against (or for) the idea.
For corpus health (sync status, record counts, drift warnings) call configure subcommand="status" instead. The two were once one tool and split when scan absorbed the two-layer fan-out.
What It Returns
- Header line — `[SCAN MODE] N corpus + M decisions in candidate pool` when fusion succeeds; falls back to corpus-only header when no decisions clear the floor.
- 3–5 signals — each classed clear / caution / blocker, with citations to source records by id.
- Decision-graph candidates block — the top decisions matching the proposal, when fusion finds any.
- Tier errors — surfaces inline if either tier fails (embedding outage, corpus query throw, etc).
Example
scan intent="ship the new pricing tier this quarter"One required arg: intent. Returns a fused signal report in one call.
Retrieval Depth
Default K=40 evidence records for the internal health check. Configurable via the configure tool or environment variables.
configure
View and update Assay settings at runtime.
The configure tool lets you view and update extraction mode, retrieval depth, feature toggles, and presets. Use it to adjust K values, switch extraction models, toggle dual embedding, or check current settings — all without editing environment files.
What You Can Configure
- K values — adjust retrieval depth per tool (scan=40, stress_test=80, retrieve=20)
- Max disclosure depth— cap retrieval at summary, full reasoning, or excerpts. (A future tier returns the original source snapshot at the time the decision was recorded; that snapshot tier isn't built today.)
- Feature toggles — enable/disable sync, reranker, hygiene passes
- Presets — apply a preset bundle (minimal, standard, full)
- Embedding — local embedding model. No API key required; no remote provider switch.
Examples
View current settings:
configureChange retrieval top-K:
configure top_k=30Apply a preset:
configure preset="full"Feature Toggles
Fine-tune which pipeline stages run automatically.
Assay's pipeline stages can be independently enabled or disabled. Configuration is stored in .assay.config.json at the project root.
Available Toggles
| Toggle | Default | Description |
|---|---|---|
sync | On | Automatic drift detection and re-sync of tracked pages |
extraction | On | Claim extraction from evidence sections during ingestion |
accumulation | On | Brief and stress_test results deposited back into the corpus |
hygiene | Off | Scheduled background sync at a configured time |
Enabling / Disabling
# Enable background hygiene
npx assay toggle hygiene on
# Disable claim extraction (evidence-only mode)
npx assay toggle extraction off
# Disable accumulation loop
npx assay toggle accumulation offPresets
Pre-configured bundles that set all toggles at once.
| Preset | Extraction | Sync | Accumulation | Hygiene |
|---|---|---|---|---|
| minimal | Off | Off | Off | Off |
| standard (recommended) | On | Daily | On | Off |
| full | On (Claude API) | Hourly | On | On |
minimal — evidence only. No extraction, no accumulation, no sync. Good for quick evaluation or testing retrieval with pre-existing embeddings.
standard (recommended) — evidence plus extraction with daily sync and accumulation enabled. This is the default configuration after installation. Covers the needs of most teams.
full — evidence plus high-tier Claude extraction (default Haiku 4.5; switch to Sonnet via configure subcommand="extraction" if a corpus warrants it) with hourly sync, accumulation, and background hygiene all enabled. Best for teams with large, actively changing corpora.
Notion Ingestion
Ingest your Notion workspace into the Assay corpus.
Setup
You need a Notion integration token. Create one at notion.so/my-integrations and share the pages you want to ingest with the integration.
# In .env.local:
NOTION_API_KEY=ntn_...Two-Step Pipeline
Ingestion uses two scripts that run sequentially:
- notion-crawl.mjs — calls the Notion API to fetch page content, converts blocks to Markdown, and writes the result to a JSON cache file.
- notion-ingest.mjs — reads the JSON cache, chunks at heading boundaries, generates embeddings, and stores evidence records in the local SQLite corpus.
Incremental Mode
By default, the crawl script only re-fetches pages that have changed since the last run (based on Notion's last_edited_time). This makes subsequent ingestion runs fast — typically under 30 seconds for workspaces with few recent edits.
Background Hygiene
Automated sync to keep your corpus fresh without manual intervention.
Background hygiene runs an automated sync at a configurable time. When enabled, it checks all tracked pages for drift and re-processes any that have changed.
Important Notes
- Default: OFF. Background hygiene is disabled by default. Enable it when you are ready with
npx assay toggle hygiene on. - Machine must be running. If your laptop is asleep when the scheduled time arrives, the sync fires on next wake.
- Recommended schedule: daily at a quiet hour (e.g. 3 AM local time) to minimize disruption.
Troubleshooting
Common issues and how to fix them.
| Issue | Cause | Fix |
|---|---|---|
/assay-*returns "MCP not connected" | Claude Desktop doesn't see the server, or the MCP binary hasn't been built | Settings → Developer → refresh the MCP entry, then restart Claude Desktop. Rebuild with cd mcp-server && npm run build. |
| Notion crawl returns 0 pages | Integration disconnected or pages not shared | Re-share pages with the integration at notion.so/my-integrations |
| First-index stalls on model download | Transformers.js cannot reach huggingface.co for the initial 420 MB model fetch | Check network. After the first successful download, indexing is fully offline. |
| Retrieve returns empty results | Cold start — no collection indexed yet | Run tsx bin/assay.ts add ~/your-docs and tsx bin/assay.ts index first |
Glossary
Key terms used throughout this documentation.
- Hybrid search
- Combines keyword and semantic search lanes into a single ranked list. Results that appear in multiple lanes are weighted more heavily.
- Local embedding model
- The embedding model that runs in-process on your machine to turn text into dense vectors. Embeddings live in the same SQLite file as the rest of the corpus. No API key, no network call at query time.
- Decisions (internally still the
claimstable for code continuity) - Atomic assertions and committed choices extracted from documents and conversations, each independently embedded for granular retrieval. A single evidence section may yield 5–15 decisions.
- Evidence records
- Document sections stored with embeddings in the local SQLite corpus. The primary unit of storage and retrieval context.
- Stance signal
- A 0.0-1.0 score indicating how strongly a decision carries organizational dissent or constraint.
- Tombstoning
- Soft-deletion of outdated records. Tombstoned records are marked inactive but not hard-deleted, preserving historical context.
- Cosine distance gate
- Minimum embedding distance threshold (0.10) used to filter redundant decisions during extraction. Ensures each decision is semantically distinct.
- Keyword search
- Built on SQLite's full-text search module. Assay indexes every evidence record's title, summary, and body content so exact keyword matches complement semantic similarity search.
- Disclosure depth
- How much of a decision a retrieval call returns: summary (id, statement, status), full reasoning (the trail the decision was recorded with), or excerpts (citations + supporting evidence). A future tier returns the original source snapshot taken at the time the decision was recorded; that snapshot tier isn't built today.