On this page10 sections
Analysis Memories
A six-tool feature for running LLM-driven qualitative analysis over large, paginated datasets without dragging raw rows into the model’s context window — and for acting on a subset of that dataset without ever putting the IDs back in context.
The LLM downloads records once into offline storage, stores its own qualitative findings as semantic embeddings, then queries both layers — by meaning, by aggregate, by filter, by stratified sample — until it has enough material to synthesise a final answer.
Want a hands-on tour? The Analysis Quickstart walks each summary strategy through Inspector against a 5,000-book in-memory dataset.
Table of Contents
- Data flow
- When to use it
- Setup (integrators)
- The six tools
- Stratified sampling
- End-to-end workflow
- Lifecycle & retention
- Troubleshooting
- File reference
Data flow
Three tables back the feature: analysis_memories, ingested_records, and ingested_edges. Two of them store vectors — analysis_memories embeds findings and page summaries, and ingested_records carries an optional per-record embedding alongside the raw JSONB (embed_records defaults to true); ingested_edges is the plain relationship graph. The six tools are stitched together by the LLM, which drives the loop: ingest once, then read → reason → store (and optionally analysis_summarize to re-summarize without re-fetching), optionally act, then clear when done.
Where the embed boundary actually sits. Embedding happens at exactly five points, all crossing the ═══ EMBED ═══ line: ① the per-record embeddings written at ingest time (embed_records, default true), ① the page-summary side-effect of analysis_ingest, ③ every analysis_store finding, ② the query string in semantic mode (not the memories or records being searched — those were already embedded on write), and ② the ensureRecordEmbeddings back-fill that fills embedding gaps before a target: "records" ranking or cluster stratifier runs.
This is why aggregate/filter/sample/path queries are cheap and deterministic SQL, while semantic queries pay for a single query-side embed and rank rows by cosine distance.
Maintenance note: keep this diagram in sync with the code. If a future change adds a new write path (e.g. a fourth table, a new tool, or removing the page-summary side-effect), update the diagram in the same PR — the value of a high-level picture collapses the moment it stops matching the code. The authoritative sources are
analysis-ingest-tool.ts,analysis-store-tool.ts,analysis-query-tool.ts,analysis-act-tool.ts,analysis-clear-tool.ts, and the three pgvector backend files (analysis-memories.ts,ingested-records.ts,ingested-edges.ts) listed under File reference.
When to use it
Use it for:
- Qualitative analysis over result sets that span many pages (themes, anomalies, “what patterns do you see?”).
- Distribution / aggregation questions across an entire collection.
- Representative sampling from skewed datasets where a naive
ORDER BY RANDOM()would favour the majority class.
Don’t use it for:
- Single-record lookups or known-id reads — use
find_records. - Result sets that fit in one page and that you actually want returned to context — use
search_recordsorfind_model_app. - Transactional CRUD — use
create_model/update_model/delete_model.
The dividing line: if you need the raw data in context, use the data tools. If you need to reason about a dataset that’s too big for context, use the analysis tools.
Setup (integrators)
Prerequisites
- PostgreSQL with the
pgvectorextension installed. pgconnection pool you own and inject into mcp-rune.
1. Environment variables
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL | yes | Standard postgres://... connection string. |
ANALYSIS_ENABLED | no | A deployer-side convention, not read anywhere in the framework — the only place it appears is your own migration-filter snippet below (step 2). Tool registration is gated by initVectorStorage (step 3) plus ToolRegistry({ analysisStorageEnabled, toolClasses }) (step 4). |
2. Run migrations
mcp-rune ships migrations as data under the @mcp-rune/mcp-rune/db/migrations subpath import. The analysis tables (analysis_memories, ingested_records, ingested_edges) are tagged feature: 'analysis' — apply them conditionally:
import { migrations } from '@mcp-rune/mcp-rune/db/migrations'
const needed = migrations.filter(
(m) => m.feature === 'core' || process.env.ANALYSIS_ENABLED === 'true'
)
// ...apply each migration.up against your poolimport { migrations } from '@mcp-rune/mcp-rune/db/migrations'
const needed = migrations.filter(
(m) => m.feature === 'core' || process.env.ANALYSIS_ENABLED === 'true'
)
// ...apply each migration.up against your poolSee the Database section of the root README for the full migration runner snippet.
3. Initialise vector storage at startup
import pg from 'pg'
import { vectorStorage } from '@mcp-rune/mcp-rune/runtime'
import { createPgvectorAdapter } from '@mcp-rune/mcp-rune/runtime/integrations/pgvector'
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
vectorStorage.initVectorStorage({
adapter: createPgvectorAdapter({
pool, // required — pool lifecycle stays with you; mcp-rune never creates pools
toolMemoriesRetentionDays: 30, // default: 30 — sweep window for tool_memories
ingestedRecordsRetentionDays: 7, // default: 7 — TTL for ingested_records
ingestedEdgesRetentionDays: 7 // default: matches ingestedRecordsRetentionDays — TTL for ingested_edges
}),
serviceName: 'my-mcp-server',
version: '1.0.0',
backgroundCleanupIntervalMs: 6 * 60 * 60 * 1000 // optional — periodic cleanup; omit to disable
})import pg from 'pg'
import { vectorStorage } from '@mcp-rune/mcp-rune/runtime'
import { createPgvectorAdapter } from '@mcp-rune/mcp-rune/runtime/integrations/pgvector'
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
vectorStorage.initVectorStorage({
adapter: createPgvectorAdapter({
pool, // required — pool lifecycle stays with you; mcp-rune never creates pools
toolMemoriesRetentionDays: 30, // default: 30 — sweep window for tool_memories
ingestedRecordsRetentionDays: 7, // default: 7 — TTL for ingested_records
ingestedEdgesRetentionDays: 7 // default: matches ingestedRecordsRetentionDays — TTL for ingested_edges
}),
serviceName: 'my-mcp-server',
version: '1.0.0',
backgroundCleanupIntervalMs: 6 * 60 * 60 * 1000 // optional — periodic cleanup; omit to disable
})If options.adapter is omitted, vector storage stays disabled — initVectorStorage returns false and every storage call becomes a no-op. There’s no error path; the gate is silent by design. A live adapter is necessary but not sufficient for the tools to appear: the registry wiring in the next step is the other half of the gate.
backgroundCleanupIntervalMs is opt-in because short-lived processes (test runs, single-shot scripts) don’t need it; the boot-time sweep already evicts expired rows on startup. Set it for long-running servers where on-access eviction alone may leave orphaned rows behind.
4. Wire the registry
Registering the analysis tools takes both halves: spread ANALYSIS_TOOL_CLASSES into the toolClasses map, and pass analysisStorageEnabled so the requiresAnalysisStorage gate can pass. Miss either one and the tools silently stay out of tools/list.
import { ToolRegistry, DATA_TOOL_CLASSES, ANALYSIS_TOOL_CLASSES } from '@mcp-rune/mcp-rune/tools'
import { vectorStorage } from '@mcp-rune/mcp-rune/runtime'
const toolRegistry = new ToolRegistry({
// Half one: the analysis tool classes must be in the class map.
toolClasses: { ...DATA_TOOL_CLASSES, ...ANALYSIS_TOOL_CLASSES },
models: MODEL_CLASSES,
serverContext: { name: 'My Server', namespace: 'my-server' },
createApiClient: (token) => createApiClient(token, { apiUrl }),
// Half two: true only when step 3 initialised a live adapter —
// tools flagged requiresVectorStorage are filtered out otherwise.
analysisStorageEnabled: vectorStorage.isVectorStorageEnabled()
})import { ToolRegistry, DATA_TOOL_CLASSES, ANALYSIS_TOOL_CLASSES } from '@mcp-rune/mcp-rune/tools'
import { vectorStorage } from '@mcp-rune/mcp-rune/runtime'
const toolRegistry = new ToolRegistry({
// Half one: the analysis tool classes must be in the class map.
toolClasses: { ...DATA_TOOL_CLASSES, ...ANALYSIS_TOOL_CLASSES },
models: MODEL_CLASSES,
serverContext: { name: 'My Server', namespace: 'my-server' },
createApiClient: (token) => createApiClient(token, { apiUrl }),
// Half two: true only when step 3 initialised a live adapter —
// tools flagged requiresVectorStorage are filtered out otherwise.
analysisStorageEnabled: vectorStorage.isVectorStorageEnabled()
})5. Embeddings
Embeddings run locally via @huggingface/transformers using sentence-transformers/all-MiniLM-L6-v2 (384 dimensions, quantised, lazy-loaded on first use). No API keys, no outbound network calls. The first analysis_store or semantic analysis_query in a fresh process pays a one-time model warm-up cost.
The six tools
All six belong to the ANALYSIS tool category, gated by requiresVectorStorage. analysis_ingest and analysis_act call the upstream API; the others operate purely on the local pgvector tables.
analysis_ingest
Downloads records from the model’s API and stores them in ingested_records as JSONB. Only a status summary returns to context — the raw data never inflates the LLM window.
Two ingestion modes:
| Mode | Triggered by | Use for |
|---|---|---|
| Top-level | model | Listing/searching records of a single model. |
| Nested | parent_model + child_resource | Fetching child resources (e.g. metadata_errors) for every previously-ingested parent, with auto-resolved parent IDs. |
Key inputs:
| Field | Notes |
|---|---|
model | Required for top-level mode. |
analysis_id | Required. Session key — every later tool uses this to scope its work. |
filters | Optional. Routed through validateFilterParams; supports the same operators as search_records. |
page / per_page | per_page defaults to 50. |
fields | Optional projection (["id", "name", "status"]). {assoc}_id keys are auto-preserved when you ask for any flattened field from the association (e.g. title_name → title_id is kept). |
ingest_all | When true, auto-paginates up to 50 pages, reporting progress page-by-page. |
resume | With ingest_all, skips already-stored pages by counting existing rows and continuing from the next page. |
parent_model / child_resource / parent_ids | Nested mode. parent_ids is capped at 25; if omitted, auto-resolved from previously ingested records of parent_model in the same analysis_id. Nested fetches run with a concurrency cap of 5. |
hop_depth | Follow declared associations this many hops deep, ingesting the connected records (and their typed edges into ingested_edges) under the same analysis_id. Default 0 — extract edges from root records only, no follow-up fetches. Max 3. |
embed_records | Embed each ingested record (384-dim MiniLM) at write time so semantic target: "records", cluster stratification, and semantic-cluster summaries work without a back-fill round-trip. Default true. Set false for large datasets where ingest latency matters; ensureRecordEmbeddings back-fills lazily when needed. |
user_id | Service-account impersonation. |
Multi-model sessions. With hop_depth ≥ 1, one session holds records of several models — the session is a graph, not a table (ADR 0013). describeAnalysisSession reports the shape as { models: [{ model, count }], totalRecords }, and shape-sensitive queries (aggregate/filter/sample) must scope to one model at a time.
Side effects beyond storing records: every successful page also produces a page summary finding generated by one or more summary strategies (default: distribution — field distributions for low-cardinality fields, numeric min/max/avg/median, and date ranges). Each strategy writes one row with category page_summary:<strategy> (e.g. page_summary:distribution) and metadata.strategy: <strategy-name>, immediately searchable via analysis_query mode: "semantic".
Pick a different lens with summary_strategy: "coverage" | "anomaly" | "temporal" | "entity-extraction", or run several per page with summary_strategies: ["distribution", "anomaly"]. Hosts can ship custom strategies via ApiExtension. See Summary Strategies for the full catalog and authoring guide. If you want a different lens on data you’ve already ingested, call analysis_summarize instead of re-ingesting.
Dedupe: ingested_records has a partial unique index on (analysis_id, model, record_id) WHERE record_id IS NOT NULL, and inserts use ON CONFLICT ... DO UPDATE. Re-ingesting the same page (or running resume after a partial failure) replaces rather than duplicates.
Caps: 50 pages max per ingest_all call; 25 parent IDs max per nested call; 5 concurrent nested fetches.
analysis_store
Stores LLM-generated qualitative findings — patterns, anomalies, conclusions — as semantic embeddings. Not for raw record data (that’s analysis_ingest’s job, already done automatically).
Inputs:
| Field | Notes |
|---|---|
analysis_id | Required. |
findings | Array of { finding, category?, metadata? }. Max 25 per call. |
persistent | Default false (1-hour TTL). true keeps the finding around across conversations. |
category is free-form but acts as a grouping key for later recall (naming_inconsistency, missing_metadata, etc.). metadata is arbitrary JSON — typically record IDs or field values that justify the finding.
analysis_query
Single unified tool with six modes. Mode is the only required discriminator beyond analysis_id.
| Mode | Required params | Returns | Use for |
|---|---|---|---|
describe | — | Markdown table of fields, types, query syntax examples derived from the model’s attributes config | Discovering shape before querying. |
semantic (target: "findings", the default) | query | Findings + page summaries ranked by cosine similarity. Defaults: top_k=50, threshold 0.5. Filter by category to scope. | Recalling stored insights; searching page summaries by meaning. |
semantic (target: "records") | query | Ingested records ranked by cosine over their per-record embeddings — hybrid with where in one SQL statement; min_similarity sets an optional similarity floor (omit for rank-only). Back-fills missing embeddings before ranking. | ”Records like X”, optionally within a filtered subset. |
aggregate | group_by | { value, count } rows sorted by count desc, formatted as a percentage distribution. | Distribution of a field across the dataset. |
filter | where | Matching data rows. Default limit 20, hard cap 200. | Inspecting a specific subset. |
sample | — (all optional) | Sampled data rows. Default 5, hard cap 50. Composes stratify_by, where, and proximity. | Representative spot-checks. |
path | from_model, from_id, to_model, to_id | Shortest typed edge chains between the two records, via recursive CTE over ingested_edges. max_depth defaults to 4 (capped at 6); top_k caps the number of paths returned. | ”How is A connected to B?” over a hop-expanded session. |
Model scoping (ADR 0013). The record-scoped modes (aggregate, filter, sample, and semantic target: "records") take a model parameter. Single-model sessions resolve it automatically; on a multi-model (hop-expanded) session it’s required, and the error names the candidate models with their record counts.
where operator syntax (used by filter mode and as a pre-filter in sample mode):
{ "status": "active" } // exact match (JSONB containment)
{ "duration_minutes": { "$gte": 40, "$lte": 120 } } // numeric range
{ "started_at": { "$gte": "2026-01-01" } } // date range (auto-cast to timestamptz)
Operators: $gt, $gte, $lt, $lte. The cast (::numeric vs ::timestamptz) is inferred from the value type. Field names are validated against ^[a-zA-Z_][a-zA-Z0-9_]*$ before being interpolated into SQL.
analysis_act
Applies a bulk update or delete to records previously ingested in the session. Resolves matching record IDs server-side from ingested_records using the same where vocabulary as analysis_query mode: "filter", then runs the mutation in batches against the upstream API. Only an aggregate summary returns to context — per-record IDs and results are never echoed back to the LLM.
Annotated destructiveHint: true, requiresAuth: true. Same risk profile as bulk_action_models.
Inputs:
| Field | Notes |
|---|---|
analysis_id | Required. Must match a prior analysis_ingest call. |
model | Required. Must be a writable model present in the analysis session. |
where | Optional. Same operator vocabulary as analysis_query mode: "filter": exact match plus $gt/$gte/$lt/$lte. Omit to match every record of model in the session. |
action | Required. "update" or "delete". |
attributes | Required when action: "update", ignored when action: "delete". Applied uniformly to every matched record. |
dry_run | Optional. When true, returns { matched_count, sample_ids, sample_data, ingestedAtRange } without calling the API. Use it to confirm scope and snapshot age before mutating. |
user_id | Service-account impersonation. |
Batching: internal batches of 50, concurrency cap of 5. Higher than bulk_action_models (25) because batches are never surfaced to the LLM — only the aggregate summary is.
Response (live):
{
"summary": { "total": 312, "succeeded": 308, "failed": 4, "action": "update" },
"sample_errors": [
/* first 5 failed records, with status_code and message */
]
}
Response (dry-run):
{
"matched_count": 312,
"sample_ids": ["d-1", "d-2", ...], // first 10
"sample_data": [ /* first 3 rows, each with ingestedAt */ ],
"ingestedAtRange": {
"earliest": "2026-05-13T08:14:22Z",
"latest": "2026-05-13T08:15:01Z"
}
}
Snapshot staleness. ingested_records is a point-in-time copy. A long gap between ingest and act means the upstream state may have drifted. The ingestedAt timestamp on the dry-run sample and ingestedAtRange exist so the LLM (and the operator reviewing the call) can judge whether to re-ingest first. There is no automatic revalidation pass — that’s intentional, to keep the cost model predictable.
Failure model. Batches are not atomic across the whole set (same as bulk_action_models). A partial failure mid-run leaves earlier batches applied. sample_errors carries enough information to diagnose patterns; the server log carries the full per-record outcome.
Progress. When the MCP client supplies a progressToken, analysis_act emits one notifications/progress event per completed record.
analysis_summarize
Re-runs one or more summary strategies against an already-ingested session, without re-fetching from the API. Reads records from ingested_records (mode filter when where is provided, else mode sample with a max_records cap) and writes one analysis_memories row per applicable strategy, with category page_summary:<strategy> and metadata.source: "analysis_summarize".
| Field | Notes |
|---|---|
analysis_id | Required. |
model | Optional override. Single-model sessions resolve automatically (via describeAnalysisSession); multi-model (hop-expanded) sessions require it — the error names the candidate models with their record counts. |
strategy | One strategy name (enum populated from the registry). Mutually exclusive with strategies. |
strategies | Array of strategy names; each one writes a separate memory. Strategies whose appliesTo returns false are silently skipped. |
where | Optional filter using the analysis_query mode: filter operator vocabulary. |
max_records | Cap on records loaded per run. Default 1000; max 5000. |
Use it when you ingested with the default distribution strategy and now want an anomaly / temporal / entity-extraction / coverage view over the same records — no API round-trip required. See Summary Strategies for the built-in catalog.
analysis_clear
Cascade-deletes analysis_memories, ingested_records, and ingested_edges for the given analysis_id. Annotated destructiveHint: true. Call it once the synthesis is done.
Stratified sampling
The sample mode composes three dimensions of stratification freely. All three can be combined in one call.
1. Discrete: stratify_by
Distributes sample slots evenly across distinct values of a JSONB field, so minority groups always appear. Implementation: ROW_NUMBER() OVER (PARTITION BY data->>'<field>' ORDER BY RANDOM()) with a per-group budget of CEIL(sample_size / num_groups).
Without stratification, 85 active + 10 draft + 5 archived records with sample_size: 6 would almost always return six active rows. With stratify_by: "status" you get roughly two of each.
2. Temporal: proximity
Date-windowed sampling around an origin date, with optional bucket stratification.
{
"field": "created_at",
"origin": "2026-03-15",
"window": "7 days",
"bucket": "1 day"
}
windowandbucketare validated against^\d+\s+(day|days|week|weeks|month|months|hour|hours|minute|minutes)$.- Without
bucket: uniform random sampling within the window. - With
bucket: PostgreSQLdate_bin(bucket, value, origin)creates origin-anchored buckets, and the sameROW_NUMBER()budget allocation distributes slots across buckets.
For deeper detail (use cases, edge cases, performance notes), see proximity-sampling.md.
3. Pre-filter: where
Restricts the candidate set before sampling. Same operator vocabulary as filter mode. Useful for “sample from the population that matches X” rather than “sample then filter”.
Composing all three
{
"mode": "sample",
"analysis_id": "q1-deal-audit",
"sample_size": 12,
"stratify_by": "status",
"where": { "amount": { "$gte": 10000 } },
"proximity": {
"field": "closed_at",
"origin": "2026-03-15",
"window": "30 days",
"bucket": "1 week"
}
}
Reads as: “From deals over $10k that closed in the 60 days around March 15, give me 12 examples — spread across statuses, spread across weeks.” The filtered CTE applies where + the proximity date range, then the partition key (date_bin(week, closed_at, origin), data->>'status') allocates the budget.
End-to-end workflow
A realistic session: an LLM auditing the book model in an example bookshelf server. The user asks “what’s the state of our library — any quality issues across the collection?”
1. Ingest the dataset once, paginated up to the cap, with three lenses on every page:
analysis_ingest({
analysis_id: "library-audit-2026-05",
model: "book",
ingest_all: true,
per_page: 50,
fields: ["id", "title", "author", "status", "rating", "updated_at"],
summary_strategies: ["distribution", "anomaly", "temporal"]
})
// → "Stored 312 record(s) (6 fields per record) across 7 page(s). Analysis: library-audit-2026-05"
Up to twenty-one findings are stored automatically alongside the raw rows — three per page, one per strategy that passes its appliesTo check (temporal needs ≥1 ISO-date field; anomaly needs ≥4 records per page). Categories are page_summary:distribution, page_summary:anomaly, page_summary:temporal. Drop summary_strategies for the distribution-only default.
2. Discover the shape before querying:
analysis_query({ analysis_id: "library-audit-2026-05", mode: "describe" })
// → markdown table of book fields, enum values, and copy-pasteable query examples
3. Aggregate to ground the LLM in distributions:
analysis_query({ analysis_id: "library-audit-2026-05", mode: "aggregate", group_by: "status" })
// → "Distribution of \"status\" (312 total):
// completed: 180 (57.7%)
// reading: 80 (25.6%)
// unread: 52 (16.7%)"
4. Filter to investigate one segment:
analysis_query({
analysis_id: "library-audit-2026-05",
mode: "filter",
where: { "status": "completed", "rating": { "$lt": 2 } },
limit: 20
})
// → up to 20 raw rows the LLM can reason over
5. Sample representatively for spot-checks:
analysis_query({
analysis_id: "library-audit-2026-05",
mode: "sample",
sample_size: 9,
stratify_by: "status"
})
// → 3 of each status, so the LLM doesn't see only the majority class
5b. Re-summarize with new lenses — no refetch:
analysis_summarize({
analysis_id: "library-audit-2026-05",
strategies: ["coverage", "entity-extraction"],
max_records: 1000
})
// → adds page_summary:coverage and page_summary:entity-extraction
// memories drawn from already-ingested rows. metadata.source
// marks them as "analysis_summarize" so they're distinguishable
// from per-page ingest summaries.
6. Store findings as the LLM forms them:
analysis_store({
analysis_id: "library-audit-2026-05",
findings: [
{ finding: "12 completed books rated 1 — outliers worth a re-read or de-listing", category: "quality_issue", metadata: { count: 12 } },
{ finding: "Sci-fi authors over-represented in 'reading' status — possible stalled-progress bias", category: "pattern" }
]
})
7. Recall semantically near the end of the session, after several rounds of querying:
analysis_query({
analysis_id: "library-audit-2026-05",
mode: "semantic",
query: "issues with rating or quality",
top_k: 20
})
// → all "quality_issue" findings + any page summaries whose distributions hint at the same
8. Clear once the synthesis lands:
analysis_clear({ analysis_id: "library-audit-2026-05" })
// → "Cleared 312 ingested record(s) and 9 finding(s) for analysis \"library-audit-2026-05\"."
Lifecycle & retention
| Layer | Expiry | Eviction |
|---|---|---|
analysis_memories (ephemeral) | 1 hour from creation | On-access: every recallMemories call deletes expired rows first. |
analysis_memories (persistent) | Never (until explicit clear) | Set with persistent: true at store time. |
ingested_records | 7 days from store (configurable via ingestedRecordsRetentionDays) | On-access: every queryRecords call deletes expired rows first. Boot-time sweep on init. |
ingested_edges | Matches ingested_records by default (ingestedEdgesRetentionDays defaults to the records retention) | On-access eviction + boot-time sweep on init. |
tool_memories (separate feature) | retentionDays from initVectorStorage | Boot-time sweep + on-access. |
| Background sweep (opt-in) | Every backgroundCleanupIntervalMs ms | Periodic cleanup across all four tables. Off by default — set the option to enable for long-lived servers. |
Practical implications:
- The 7-day TTL on
ingested_recordsis the realistic working window for an analysis session — long enough to ingest in the morning andanalysis_actin the afternoon (or after a weekend), short enough that an abandoned session eventually frees its disk. analysis_memoriesis split deliberately: ephemeral findings are throw-away by design; thepersistent: trueflag is the explicit opt-in for findings that should outlive a session.- A session whose
ingested_recordshave expired will return an empty match set fromanalysis_actandanalysis_query. Re-runanalysis_ingestwithresume: trueto rebuild — page summaries will be regenerated. analysis_clearis the explicit teardown for a session that finished cleanly. Don’t rely on TTL for cleanup if you store findings persistently.
Troubleshooting
The six tools don’t appear in tools/list. They’re gated by requiresVectorStorage. Check:
DATABASE_URLis set.initVectorStorage({ adapter: createPgvectorAdapter({ pool }) })was called at server startup. The init returnsfalseand logs a warning when the adapter is missing.- The
ToolRegistryreceivedanalysisStorageEnabled: vectorStorage.isVectorStorageEnabled()and hasANALYSIS_TOOL_CLASSESspread intotoolClasses(see Setup step 4). - The
analysis_memories,ingested_records, andingested_edgestables exist (migrations applied).
analysis_ingest reports duplicate-looking counts after a retry. Resolved in commit c1cc813 — the table now has a partial unique index and inserts use ON CONFLICT DO UPDATE. If you’re seeing it, confirm your migrations are up to date (the unique index ships in the relevant migration).
analysis_ingest stops at 50 pages. That’s the MAX_INGEST_PAGES cap, surfaced in the response ((capped at 50 pages)). Tighten filters to reduce the result set, or run multiple sessions with disjoint filters.
analysis_query mode: "filter" returns nothing for what looks like a valid match. where uses JSONB containment for exact match — values must match the stored representation. Numeric fields stored as strings (e.g. via flattened HAL responses) need range operators with the right cast: { "amount": { "$gte": 100 } } infers ::numeric. If your value is a string "100", exact match needs the string form.
Field name rejected with “Invalid field name”. Stratification, range conditions, and proximity all validate the field against ^[a-zA-Z_][a-zA-Z0-9_]*$ to keep them safe to interpolate into SQL. Dotted or hyphenated paths aren’t supported — flatten the data at ingest time via fields or model associations.
First semantic query is slow. @huggingface/transformers lazy-loads and quantises the all-MiniLM-L6-v2 weights on first call (~1–2 s). Subsequent calls are fast.
File reference
| Path | Purpose |
|---|---|
src/mcp/tools/analysis/analysis-ingest-tool.ts | analysis_ingest tool |
src/mcp/tools/analysis/analysis-store-tool.ts | analysis_store tool |
src/mcp/tools/analysis/analysis-query-tool.ts | analysis_query tool (all six modes) |
src/mcp/tools/analysis/analysis-act-tool.ts | analysis_act tool (server-side ID resolution + batched mutation) |
src/mcp/tools/analysis/analysis-summarize-tool.ts | analysis_summarize tool (re-runs strategies against stored records, no refetch) |
src/mcp/tools/analysis/analysis-clear-tool.ts | analysis_clear tool |
src/mcp/tools/analysis/base-analysis-tool.ts | Category binding (ANALYSIS, requiresVectorStorage) |
src/mcp/tools/analysis/stratifier-validator.ts | Zod schemas + concept resolution for the sample-mode stratifiers parameter |
src/mcp/analysis-layer/analysis-layer.ts | Per-model-bound seam for analysis-domain operations (summary dispatch, edge projection) |
src/mcp/analysis-layer/edge-extraction.ts | Projects a record into typed edges + embedding text, driven by static associations/attributes |
src/mcp/analysis-layer/multi-hop-fetch.ts | BFS walk of declared belongsTo associations for hop-expanded ingestion |
src/mcp/analysis-layer/graph-stratifiers.ts | CTE builders for the concept / edge / cluster sample stratifiers |
src/mcp/analysis-layer/summary-strategies/ | Strategy interface + 9 built-ins (5 field-level + 4 GraphRAG-aware) + registry |
src/runtime/vector-storage.ts | Vendor-agnostic facade — initVectorStorage, isVectorStorageEnabled, all store/query/clear entry points |
src/runtime/integrations/pgvector/index.ts | Pool injection, cleanup-on-boot |
src/runtime/integrations/pgvector/analysis-memories.ts | Findings table SQL (store, recall, clear, eviction) |
src/runtime/integrations/pgvector/ingested-records.ts | Raw-data table SQL (store, aggregate/filter/sample, semantic record ranking, stratification) |
src/runtime/integrations/pgvector/ingested-edges.ts | Edge table SQL (store, path-mode recursive CTE, eviction) |
src/runtime/embeddings.ts | Local all-MiniLM-L6-v2 embeddings |
src/db/migrations.ts | Migration data (feature: 'analysis' for these tables) |
Related guides:
proximity-sampling.md— deeper treatment of date-windowed sampling.tool-creation.md— how theANALYSIScategory fits into the broader tool/category model.transient-context.md— howanalysis_storeconsumes transient context from upstream tools.
Out of scope for this iteration (tracked separately): a read-only analysis_export companion that returns filtered records to a downloadable artefact; an opt-in revalidation pass that re-fetches each candidate record before analysis_act mutates it to detect drift since ingest. See issue #80 for context.