Skip to content

Releases: silencespeakstruth/mindex

mindex 2.0.0

Choose a tag to compare

@silencespeakstruth silencespeakstruth released this 06 Aug 20:26

⚠️ REQUIRED — THIS IS A REBUILD, NOT AN UPGRADE

Every index must be rebuilt from scratch. The vectors are from a different model;
nothing migrates them.

# 1. the database lineage restarts — a pre-v2 database is REFUSED at startup, by name
rm ~/.local/share/mindex/mindex.db          # or wherever [database].path points
#    the Docker test stack needs `down -v` once

# 2. the embedder is no longer shipped. Install one that speaks /v1/embeddings —
#    deploy/embedder/ has a ~200-line reference server, llama.cpp and vLLM recipes,
#    and the measured throughput that tells them apart. Install it OUTSIDE the checkout.

# 3. config keys changed, and stale ones now fail at startup rather than being ignored
#    [model].name -> [model].id (a registry id: qwen3-embedding-0.6b | -4b | -8b)
#    removed: [qdrant].{dense_prefetch_limit,sparse_prefetch_limit,fusion_limit}
#             [indexing].sparse_min_weight

# 4. rebuild, then drop the _v1/_v2 Qdrant collections the startup log names
mindex-index --force --root <each project>

Runbook: docs/claude/qdrant.md. [slicer].max_chunk_tokens also moves 512 → 364,
which is part of what the rebuild produces.

FOR HUMANS

Search got a lot better, and the reason is that we finally measured it. mindex's
retrieval had never been evaluated: the numbers in its documentation came from a one-off
that no longer existed, over a question set that existed nowhere. This release ships
bench/ — a pre-registered harness whose ground truth is each project's own Sphinx
documentation resolved by AST against the source tree, so no model and no LLM ever touches
the answer key — and then acts on what it found.

The shipped v2.0.0 server against the shipped 1.2.0 server, same corpus, same queries:
nDCG@10 0.3549 → 0.4563, Δ +0.1014, 95% CI [+0.0832, +0.1190], p = 0.0001 at n = 1115.

One corpus, and the harness says so itself.

Three retrieval components were deleted, and the measurement is why. mindex used to run
BGE-M3's three heads: a dense vector, a sparse one fused in by reciprocal rank fusion, and
a late-interaction (ColBERT) rerank on top. Measured against each other:

  • RRF scored below the single dense leg it fused (0.4164 against 0.4448).
  • The sparse leg was worth +0.004 with both confidence intervals through zero, once the
    dense leg was a 2026 encoder. Against BGE-M3's own weak dense head it had been worth
    +0.015 — it was compensating, not contributing.
  • The rerank significantly harmed long queries (−0.016, p = 0.023) and was never
    established either way on short ones, a comparison this corpus is underpowered for by
    3×. It cost 99.6% of Qdrant's storage: 838 MB per segment against 2.6 MB dense.

So the pipeline is now one dense vector and one Qdrant query. The embedder was the lever;
the extra heads were not.

Switching models is cheap now, and switching sizes is nearly free. The model is a
registry entry — three Qwen3-Embedding sizes today (0.6B / 4B / 8B), one shared tokenizer
— and collections are named per (project, model). Changing [model].id writes a new
collection and holds the old one, so switching back is instant reuse rather than a
rebuild. Because all three sizes tokenize identically, changing size is mindex-index --vectors-only: re-embed the stored chunks, no re-slicing, no symbol work.

mindex stops shipping an embedder, and starts checking which one answered. The vendored
BGE-M3 server existed for one reason — nothing general returned three heads at once — and
one head makes it deletable. What replaces it is a contract: /v1/embeddings,
/v1/models, /health. But a general server brings a general problem, so
GET /v1/models is now a handshake: a server that answers and names a different model
is refused at startup, every response row is checked against the registry's width, and
GET /health re-checks it. None of that was checked before — a wrong embedder behind the
right URL indexed in silence.

Choose the serving stack by the indexing number, not the protocol. They differ by an
order of magnitude for identical vectors: this repository reindexes in 51 s through the
reference torch server and 410 s through llama.cpp, while query latency is 16 ms
against 30 ms. Numbers, method, and the three traps that cost real debugging (bf16 vs fp16
NaN, token-budget batching, empty_cache() corrupting output on ROCm) are in
deploy/embedder/README.md.

WHAT THIS RELEASE DOES NOT CLAIM

Stated here because a benchmark you cannot check is a press release.

  • Two corpora, one language, one query shape. django and scikit-learn, both Python,
    both query sets documentation prose. Every other corpus in the plan is declared and
    unrun.
  • The v2-vs-v3 number is one corpus and one comparison, and it is a system comparison
    — the embedder, the chunk window and the tokenizer move together, so none of the gain is
    attributable to one of them.
  • The model that ships is not the one the model comparison selected.
    granite-embedding-english-r2 was statistically indistinguishable and cheaper; Qwen3 was
    chosen for multilingual queries and its one-tokenizer size ladder, neither of which this
    harness measures.
  • The 364 chunk window is exploratory: a sweep rather than the pre-registered
    experiment, on one corpus, measured under the previous tokenizer.
  • "TOST" and "Holm–Bonferroni" are not what the code does — the non-inferiority check
    is a confidence interval clearing a margin, and the family-wise correction was applied by
    hand in one family and nowhere else. Both are relabelled and disclosed.
  • The noise floor was measured on the query path only, so cross-index comparisons —
    which includes the chunk window — have no measured floor.

Every one of these is in bench/FINDINGS.md's opening section, and every deviation from
the pre-registration is a dated row in bench/PROTOCOL.md §11.

FOR MACHINES

  • GET /config publishes embedding_dim, min_chunk_tokens, max_chunk_tokens;
    model_id is the canonical registry id, not an HF repo.
  • GET /version and mindex_build_info report 2.0.0; build_info now carries a
    model_id label.
  • Collections are {guid_simple}_{slug}_v3. DELETE /projects/{guid} drops every model's.
  • POST /index accepts vectors_only (re-embed stored chunks) beside symbols_only;
    the two are mutually exclusive, 400 validation.index_modes_exclusive.
  • Four error codes joined the published catalogue in the OpenAPI description:
    index.file_in_flight, auth.route_not_configured,
    validation.index_modes_exclusive, research.invented. A test now walks every variant
    against that document.
  • project_files gains chunker_id and embedded_model_id; both join the unchanged-file
    predicate, so flipping the model self-heals like a version bump.
  • Nothing changed in the /research SSE contract, /search's request or response
    shape, or the authorization model.
    A 1.2.0 client works against a 2.0.0 server.

ARTEFACTS

mindex-index and mindex-watch for Linux, Windows and macOS (Intel and Apple silicon);
the mindex server for Linux x86-64; the VS Code .vsix; a Docker image
(ghcr.io/silencespeakstruth/mindex:v2.0.0) and the reference embedder
(server.py + requirements.txt + its unit) — the last two are new, because a release
that says "use Docker" while publishing no image, and ships a server that cannot start
without an embedder it does not include, is not a complete release.

Each archive carries a .sha256 sidecar.

mindex 1.2.0

Choose a tag to compare

@silencespeakstruth silencespeakstruth released this 04 Aug 16:02

⚠️ REQUIRED IF THIS SERVER IS REACHABLE THROUGH A GATEWAY

[auth].enabled = true is mandatory behind deploy/gate/. The gateway admits on the
presence of a Bearer-shaped header — nginx cannot verify a signature and does not try —
so with authorization off, Authorization: Bearer x is admitted and served everything.

# 1. turn [auth] on in the server's config and restart, 2. then switch the gateway map
mindex mint-token --sub alice --project '*' --can search,research,index --days 30

That order is the only safe one: while mindex checked nothing, the API key was the only
thing guarding the remote path, and nothing breaks at the second step that the first did
not already break.

The shared X-Api-Key is gone, not deprecated. Remove it from every client; nothing
reads it.

A metrics scraper needs its own token/metrics is admin-scoped, so enabling
[auth] blanks every dashboard until the scrape carries one. Prometheus's
bearer_token_file must point outside $HOME (a unit with ProtectHome=true reads a
path there as absent, and the symptom is a scrape that stops rather than a permissions
message), and this is the one place --days 0 with its own --key-id is right.

Nothing here applies to an auth-off deployment, which stays byte-for-byte what it was.
enabled = false remains the default and now means exactly one thing: a server on a
trusted network that authorizes nothing.

FOR HUMANS

The server has a credential now, and it says what it may do and who holds it. Before
this, mindex authenticated nothing and a shared API key checked by a gateway stood in for
authorization. That key had no scope, no expiry, and no way to withdraw one holder without
withdrawing all of them — and it could not be fixed where it lived, because GET /projects
enumerates every project id in a response body, which no proxy filters without parsing
it. So the key is gone and a token replaces it: you mint one per person or per tool, name
the projects and the actions it may use, give it a lifetime, and that is the whole
decision. Nothing is stored server-side — no user table, no sessions — so there is nothing
to keep in sync and nothing to leak. Withdrawing a single holder is deleting one entry.

A token is finally worth pasting into an agent's context, which is the point of all of
it. mindex mint-token --project <this one> --can search,research --days 7 is a
credential that can read one project and cannot write, delete, or mint anything further —
so handing it to a coding agent is a decision you can actually reason about. VS Code will
issue one for you: a button in the Ask view mints a project-scoped, seven-day agent
token, offers read-only and read-and-write as the two obvious choices, puts delete behind
a deliberate tick, and never offers admin at all. The extension keeps its own token in
the OS keychain rather than in settings, because Settings Sync copies settings to every
machine you own. And a narrow token is a supported way to run the extension, not a broken
one — hand it a search-only credential and it disables Research and says which action is
missing, rather than failing at you.

A refused request finally says so. The gateway used to answer anything it did not like
by closing the connection, which is indistinguishable from a dead host — so a correctly
refused agent reported the whole deployment as broken. It now answers 401 with a real
error body naming what is served without a credential, while /.env and friends still get
silence. This was not theoretical: over three hours, one properly configured client
produced 840 closed connections on GET /health, and an agent that followed /llms.txt to
/config hit the same wall and was then banned for doing exactly what the document it had
just read told it to do. Of 1168 log lines the ban system called attacks, 1065 were this.

Point an agent at the URL and it can now work out the rest. /.well-known/mindex.json
is the service as data — identity, every endpoint, and the live config inlined — so
bootstrapping costs one request. It exists because the prose document was refused: /llms.txt
addressed the model in the imperative, which is the signature every modern agent harness has
to treat as prompt injection, and GitHub Copilot on a corporate machine declined to read it,
leaving that agent with nothing at all. The prose now argues instead of ordering, and JSON,
which has no register to object to, is the floor under it.

Research stopped punishing callers for not watching. POST /research streamed
events, always — and because disconnecting cancels a run, a caller that fired the request
and did not sit and read every frame spent the entire GPU budget, got nothing back, and saw
no error anywhere. It now answers one ordinary JSON body unless you ask for frames with
?stream=yes. Both shipped clients already ask, so nothing you use changes; what changes is
that the expensive mistake is no longer the default.

mindex-watch runs on Windows. It never had, since it was written — an unconditional
import of a Unix-only signal module — and nothing said so because nothing had ever tried to
build it there. The release workflow tried, and failed the job.

Plus a round of VS Code fixes for one repeated defect: a screen that disagreed with itself
after an action succeeded. Deleted reports that stayed on screen still ticked; a run that
finished and reached the panel through nothing at all, leaving a refuted report still
wearing its old trust badge; a cancel that then refused the reindex it was cancelled for.
None of them surfaced as an error.

The changelog has
the rest. Install the extension with
code --install-extension mindex-vscode-1.2.0.vsix --force.

FOR MACHINES

One removal — X-Api-Key — and it is not deprecated, it is gone. Two credentials where
one is strictly stronger is not defence in depth: the weaker sets the floor. Everything
else here is additive, and [auth].enabled defaults to false, so an unconfigured
deployment is unchanged.

[auth]. HS256, hand-written rather than taken from a crate so every copy of the secret
is owned (no Debug, zeroized, key file 0600 with O_EXCL) and algorithm confusion is
closed by construction — verify reads kid and nothing else before checking the MAC. Keys:
enabled, signing_key_file, max_token_days (90), leeway_seconds (60). The TLS key is
not reused. No denylist by design: revocation is expiry or deleting a kid.

The token is the mapping; there is no schema change. prj (dashless GUIDs, or exactly
["*"], which must be spelled — an empty list reaches nothing) and act
(search/research/index/delete/admin/mint) are signed in. The rejected
alternative was a tenant_id column; it cost a table rebuild, a trigger, a cache, a startup
warm and a rule for pre-existing rows, and it lost one bug class this keeps: only a caller
whose token already names a GUID can create that project, so POST /index is not an
existence oracle.

Refusals. An out-of-scope project answers 404 project.not_found, byte-identical to
one that never existed
— a distinguishable refusal confirms which GUIDs exist, and a GUID
is a bearer identifier, so auth.forbidden cannot exist on that path. The missing action
is named (403): the caller has already proved it holds the project. Two enforcement
layers: typed scope extractors check covers(guid) then permits(action), in that
order; enforce_route_policy fails closed, so a routed path with no policy row is refused
rather than served. ROUTE_POLICY names every route, and one of its guards drives the whole
refusal table so it stays exhaustive as routes are added.

Public routes are five and each says why: /health and /version (liveness — a probe
needing a credential reports the credential's health, not the server's), /config,
/llms.txt and /.well-known/mindex.json (discovery cannot be discovered from behind a
credential). admin covers /gc, /status, /metrics; there is no gc action, because
POST /gc walks every collection and no project list describes it.

POST /auth/tokens mints a narrower token from the one presented. Containment is
absolute — actions, projects and expiry may never exceed the minter, or a read-only mint
credential becomes admin one call later. --days 0 (non-expiring) is refused here and
allowed only from the local CLI. aud (--for cli,vscode,agent) is the one claim
nothing in the server reads: no part of an HTTP request identifies the process behind it, so
a check there would be theatre. The clients refuse instead, which catches a credential
pasted into the wrong place and catches nothing adversarial. Containment deliberately does
not bind it — audience is not authority, and binding it would refuse the VS Code button
minting an agent token from a vscode one.

Client credential resolution, first wins: --token > $MINDEX_TOKEN >
$MINDEX_TOKEN_FILE (a path to a 0600 file) > token in indexer.toml/watcher.toml >
the per-server entry in ~/.config/mindex/credentials.toml. MINDEX_TOKEN_FILE exists for
a caller configured by an environment block inside somebody else's config file — an MCP
server list lives in an editor's JSON, where a token sits in plaintext under no permission
check and a path does not. Its trap is the precedence: a shell exporting MINDEX_TOKEN
passes it to every child, so such a block must also set MINDEX_TOKEN="".

GET /.well-known/mindex.json — identity, version, transport, the endpoint inventory
and the live /config snapshot. endpoints[] is derived from the OpenAPI spec at first
use
, never written: the route table already had four copies and a hand-kept fifth is the
one nothing checks. Three things stay hand-kept and each has a guard...

Read more

mindex 1.1.0

Choose a tag to compare

@silencespeakstruth silencespeakstruth released this 03 Aug 11:55

⚠️ REQUIRED BEFORE THIS RELEASE WORKS

Reindex every project after upgrading, or its search stops working.

# 1. stop the server, 2. start it once (migrations 5 and 6 apply in place), then:
mindex-index --root <repo> --force        # for every project

Then drop the leftover *_v1 Qdrant collections by hand. The vector collection is
named {guid}_v2 now, and the rename is not self-healing: the new name names no
collection while SQLite still reports every file indexed. Search then fails one of two
ways — 503 qdrant.unavailable, which reads as an infrastructure fault rather than a
missing index, or, once anything has touched the project and created an empty
collection, empty results with no error anywhere. Dropping the old collections is
deliberately not automated: leaving them is what makes a rollback possible.

This release publishes mindex_stale_collections and mindex_orphaned_collections
precisely so that state is visible instead of silent.

FOR HUMANS

A research report can now be argued with. Point a challenge at any stored report and
a second run takes it as the subject under examination: it extracts the report's claims
and spends a whole research budget trying to break each one against the live index.
Nothing in the original counts as evidence — every location has to be re-derived through
the challenger's own tools, and that re-derivation is the check. The result is a
verdict per claim, and a trust badge that follows the original report everywhere it is
listed from then on. Two rules are enforced rather than suggested, because a weak local
model will otherwise oblige you: an inconclusive challenge is not an acquittal, and a
challenge that showed no code of its own can dispute but never refute. A challenge whose
own evidence later goes stale quietly stops counting.

And it can be checked without spending anything. Verify re-scores a report's
citations offline — no model, no GPU, seconds rather than minutes — and answers two
questions it deliberately keeps apart: whether the report's provenance still matches what
was recorded when it was written, and whether the files it rests on have moved since. The
first is a fact about the journal and never changes; the second is the number that
actually tells you whether to re-run.

A run that runs out of time now comes back with something. The report used to be a
single generation at the end, so a model that could not produce it produced nothing — a
fifteen-minute run returning zero. Reports are written section by section now, and the
run banks what it can already answer as it goes, so a section that fails costs that
section rather than the document, and a run stopped by its budget assembles the findings
it had instead of an apology. Alongside it, three guards for the thing that actually
breaks long runs on a shared GPU: a turn that is still producing but has eaten the whole
run's wall clock is abandoned rather than waited on. One measured plan turn took 912
seconds for 702 tokens while every other turn that week ran thirty times faster.

The server stopped making clients guess. GET /health says ok, degraded or
unhealthydegraded meaning only the optional local-model server is down, which is
exactly the state where you should keep searching and stop offering research. With only
two words, every client needed its own copy of which dependency was load-bearing, and the
VS Code extension's copy did not match the server's. Relatedly, a running server now
serves /llms.txt: the whole workflow as one document, with the live model list and the
measured cost of each effort level, so pointing an agent at one URL is enough.

Your index got about half as large. The ColBERT rerank vector was 99.6% of a
collection's bytes — 838 MB per segment against 2.6 MB for dense and 0.5 MB for sparse.
It is stored at half precision now, which is not a quality trade the way quantization
would be: that vector only orders results the other two already agreed on. This is what
the reindex above is for.

Downloads, at last. mindex-index and mindex-watch are built for Linux, Windows
and macOS (Intel and Apple silicon), the server for Linux x86-64, plus the VS Code
.vsix — all on native runners. Install the extension with
code --install-extension mindex-vscode-1.1.0.vsix --force.

The changelog
has the rest, including two dozen fixes for failures that previously produced no error at
all.

FOR MACHINES

Two removals. Everything else is additive.

  • callers is gone, with the reference half of project_file_symbols and the repo map
    that ranked by it.
    It was measured, not guessed: 23 810 reference rows against 3 397
    definitions — 87.5% of the table — serving one tool called twice across twenty-five
    recorded runs at a 50% miss rate. The edges are lexical, so the most-referenced names
    were assert_eq (1084), clone, Ok, unwrap, map, several with exactly one
    definition in the tree. Separating a core abstraction from a name shared with a language
    builtin is name resolution, which is the wall this project declines to climb. grep
    answers "who uses this name" lexically and says so. parent_name/parent_kind
    survive.
  • POST /v0/{guid}/symbols rejects role: with a 400 (request.malformed_body)
    rather than ignoring it. Accepting it would answer a role: "reference" query with the
    definitions — the one wrong answer that costs nothing to detect and looks exactly like a
    right one.

Challenge. POST /v0/{guid}/research/{run_id}/challenge — same loop, same semaphore,
same budgets, its own citation gate. The subject is injected as hearsay under examination
and never seeds the evidence. The stream is byte-for-byte an ordinary research stream plus
one event, verdict ({challenged_run_id, overall, grounded, claims}), after excerpts
and before done. overall is confirmed/disputed/refuted or null = challenged,
inconclusive, which no reader may render as an acquittal. The grounding cap is symmetric:
grounded = verified > 0 AND unverified <= verified; an ungrounded refuted caps at
disputed, an ungrounded confirmed resolves to null. 400 when the subject is invalid
(staleness must not be spendable as refutation) or is itself a challenge (trust
aggregation is single-level). trust is derived at read time over valid challenges
only
, severity wins; one challenge stands per report, and a newer one evicts the older
only if it has a parseable verdict.

Offline verification. GET /projects/{guid}/research/{run_id}/verification — pure
function over journal rows. provenance_matches: false is a journal bug, never news about
the code; staleness is recomputed against the index now. Nothing is stamped. Pre-migration-5
rows answer the staleness half only, with spans_available: false.

Live runs. GET /research/active, DELETE /research/active/{run_id} — global, not
per project, because the semaphore is. A run is now named at admission, streamed as the
first frame and registered while it runs; previously a run had no id until it ended, so an
occupied slot was an unattributable outage whose only remedy was a restart. The 429
names the endpoint.

GET /llms.txt — narrative plus a live section from the same snapshot /config
serves. Deliberately outside the OpenAPI spec; a test asserts the absence.

GET /health is tri-state. statusok/degraded/unhealthy; severity wins.
checks.* is exactly "ok" or "error"test == "ok", never a prefix, since an
older server spells it "error: <reason>". Failure reasons go to the log, not the
response: this endpoint is readable by anything that can reach the port and a driver's
error chain carries paths, URLs and versions. HTTP is always 200. New:
research.{slots_total, slots_busy, oldest_inflight_age_ms} — a busy slot is never a
degradation, a wedged one is unhealthy.

Migrations 5 and 6, both applied in place. 5 (v1.3.0_research_verification.sql)
rebuilds research_runs for the validation and challenge columns and adds
research_run_evidence / research_run_citations / research_run_steps — the structured
journal the offline check reads. 6 (v1.4.0_symbol_definitions.sql) drops
project_file_symbols.role; it does not delete the reference rows, because symbol rows
are wholly derived and SYMBOLS_DERIVATION_VERSION 1.0 → 1.1 removes them on the next
indexing run, keeping the rule in one place.

Request-shaped budgets. max_report_sections, max_report_words,
checkpoint_every_steps and evidence_width join the per-axis overrides, each capped by a
[research] ceiling that startup refuses to set below effort.high. Out-of-range gets its
own code, validation.research_shape_out_of_range, because these carry floors and two
accept 0 = off.

citations gained three fields, all because a number was ambiguous on the wire:
shown_paths (how many files the run saw the inside of — the denominator verified: 0
needed), path_resolved (a cited path may be the unambiguous tail of exactly one shown
path) and server_written (a forced-synthesis report cites nothing by construction and
otherwise scored byte-for-byte what a clean report scores). The admission rule
steps > 0 && verified > 0 is a machine check now rather than a reader's discipline.

PROMPT_VERSION is 2.7. Partition a stored corpus on it.

New config, all optional: [research].max_turn_seconds, slow_turn_tokens_per_second,
slow_turn_unaccounted_ms, first_token_timeout_ms, allowed_models,
max_request_report_{sections,words}, max_evidence_width, checkpoint_every_steps;
[qdrant].timeout_ms / connect_timeout_ms / search_hnsw_ef; [model].encode_timeout_ms;
[limits].max_research_delete_ids; [slicer].fill_gaps. `config.example.to...

Read more

mindex 1.0.0

Choose a tag to compare

@silencespeakstruth silencespeakstruth released this 30 Jul 12:33

FOR HUMANS

A coding agent should not read your codebase — it should ask. mindex indexes your repositories locally and answers three kinds of question cheaply: semantic search (hybrid BGE-M3 retrieval returns the handful of chunks that matter, not whole files), exact symbols (where is X defined, who calls it — a tree-sitter symbol table instead of a grep loop), and research (you ask a question in English, a local model runs the whole investigation and hands back a cited Markdown report, so the code it read never enters your expensive model's context).

Everything stays on your machine: vectors in a local Qdrant, metadata in a local SQLite file, embeddings from a local BGE-M3 server. 21 programming languages plus Markdown.

This first release contains the HTTPS API server, two CLI tools (mindex-index for bulk indexing, mindex-watch to keep an index live), a terminal search frontend, two MCP servers (mindex for search and symbols, scout for research) and a VS Code extension attached below — download mindex-vscode-1.0.0.vsix and run code --install-extension mindex-vscode-1.0.0.vsix. Start with the README.

FOR MACHINES

Wire contracts — stable, snapshot-tested, safe to build clients on.

  • Errors are RFC 7807 application/problem+json with a namespaced machine code (validation.top_k_out_of_range, selector.empty, research.busy, …). The code is the localization key and the thing to branch on; title/detail are English prose and are not. Pinned by codes_are_stable.
  • OpenAPI 3.1 at /api-docs/openapi.json, Swagger UI at /swagger-ui, both served by the same process. Every handler is in the spec — a test asserts the count, so an endpoint missing from it is a build failure, not a surprise.
  • Data-plane routes carry /v0; management routes (/projects, /gc, /status, /config, /health, /version) are unversioned. /v0 is the contract generation, not the release number — 1.0.0 ships /v0 deliberately.
  • POST /v0/{guid}/research is one-way SSE. Event names and their field sets are a contract (progress_wire_fields_are_stable, done_event_carries_the_reason_and_the_run_cost_on_the_wire, done_reason_wire_values_are_stable). Every frame is one data: line — payloads are JSON-escaped, so a line-oriented reader is safe. Cancellation is disconnect; there is no cancel endpoint.
  • GET /metrics is OpenMetrics on the same listener. Metric names and types are pinned by metric_names_are_stable; every label value comes from a server-defined set, and project_guid is the only open-ended one.

Reading the research output. The report's citations are provenance-checked server-side before you see them: each path:start-end is bucketed verified / path_only / unverified against what the run's own tools actually returned, and a draft that fails is sent back to the model with the offending locations named. So trust a report with a clean citations event; the one thing worth checking is citations.unverified_paths. Read done.reason too — finalized means the model judged the evidence sufficient, anything else means a budget stopped it and the report says so in its own first paragraph.

Keeping an index correct. Unchanged files are skipped by content hash and by derivation version, so a slicer or tags-query change rebuilds affected files on the next ordinary run with no --force and no bookkeeping. POST /drift compares a posted path → sha256 manifest against the index and classifies every file stale / missing / orphaned / indexing; it is read-only and backs mindex-index --check, the MCP drift tool and the watcher's sweep. Paths are repo-root-relative with forward slashes, exactly as first indexed — a different spelling creates a duplicate rather than updating.

Operational shape. One process per database (the indexing claim is an in-process lock). TLS is the whole of the transport security and there is no API auth — put a reverse proxy in front of it to reach it from anywhere but localhost; every client can carry an optional X-Api-Key for that proxy, which mindex itself ignores. Schema is one migration; there is no upgrade path across a schema change, and the database may be dropped and recreated freely.

Architecture, invariants, and the reasoning (with the measurements it rests on) are in .claude/CLAUDE.md.