{
  "markdown": "# loci\n\n<!-- mcp-name: io.github.3M1RY33T/loci -->\n\n**Scoped memory for coding agents.** A router in front of two stores.\n\n```\nquestion ──▶ router ──▶ ┌── structure store   what calls what\n             (no LLM)   └── episode store     what happened and why\n                        └─▶ merged, cited answer   │\n                            or ABSTAIN ────────────┘\n```\n\nKnowledge graphs hold structure but no prose, so they cannot answer *why did the\ncookie get dropped*. Verbatim-recall systems hold prose but no call graphs, so\nthey cannot answer *what calls `run_agent_turn`*. Both make you name the\nnamespace when you **write**.\n\nloci decides the scope when you **read**. One question path serves *\"how does\nauth work here\"* and *\"have I solved this in any project\"* — only the size of\nthe scope set changes.\n\n---\n\n## The shape, in one picture\n\nA street of independent shops. Each **shop** is one project — its own stock, its\nown books, nothing pooled in a shared warehouse. Each keeps **two ledgers**:\nwhat calls what, and what happened and why. They are read together or not at\nall.\n\nShops wear overlapping **badges** — yours, this client's, that vendor's, this\nmonorepo's. A badge can cover shops at opposite ends of the street, which is why\nthey are labels rather than a hierarchy.\n\nYou never walk the street yourself. You ask the **dispatcher**, who knows only\nwhich *uncommon* words are stocked where. They send you into at most three\nshops, or say nobody here carries that and name who might.\n\nEach shop also hangs a **signboard** naming what it *is* — repository, package,\ncommand — and keeps a list of what it **reaches for**. Reading one against the\nother is how the street answers *which of my shops buys from another*, which no\namount of asking the dispatcher ever could: that question is about the road\nbetween two shops, and the dispatcher only knows what is stocked inside them.\n\nOne thing the street still lacks: **aisles** inside a shop, so a word found in a\ntest fixture does not count like the same word in shipped code.\n\n---\n\n## The problem it solves\n\nPut every project in one index and the largest one wins regardless of the\nquestion. Measured against a merged graph of ten real repositories:\n\n| question | on-topic nodes returned |\n|---|---|\n| \"why was the admin session cookie dropped on localhost?\" | **18%** — 19 of 31 came from the biggest project |\n| \"what happens when a user clicks Save as ZIM?\" | **2%** — 61 of 62 came from the biggest project |\n| \"how does the reserved landing step work?\" | 98% — and the biggest project *was* the answer |\n\nThat third row is the trap. Merged retrieval looks excellent whenever the answer\nhappens to live in the largest corpus and collapses when it does not. It is the\nsame failure in both directions, visible in only one.\n\n---\n\n## Install\n\nThe distribution is **`loci-mem`**; the command, the import and the project are\nall `loci`. PyPI's `loci` is an unrelated outlier-detection package abandoned in\n2018 — the same split as `python-dateutil` installing as `dateutil`.\n\n```bash\npipx install loci-mem                 # routing + lexical search, ~3 small deps\npipx install 'loci-mem[all]'          # + graphify, local embeddings, MCP server\n```\n\nThe base install pulls `rank-bm25`, `scikit-learn`, `numpy` and `joblib`. No\ntorch, no vector database, no model download unless you ask for one.\n\n| extra | adds |\n|---|---|\n| `graphify` | code-symbol extraction across ~18 languages |\n| `embeddings` | local `bge-small` semantic ranking |\n| `rerank` | cross-encoder reranking (opt-in per query) |\n| `mcp` | the MCP server |\n\n---\n\n## Quickstart\n\n```bash\npipx install 'loci-mem[all]'\nloci setup             # scan, graph, index, embed, calibrate -- one pass\nloci ask \"why was the session cookie dropped on localhost?\"\n```\n\n`setup` asks only what it cannot decide for you: which directories hold your\nprojects, whether to register the repositories it found that are not yours, and\nwhether to spend a one-time model download on semantic search. It ends by\nrunning `doctor`, so whatever it could not cover is the last thing you read\nrather than something you discover from a bad answer a week later.\n\nEvery prompt takes its default when stdin is not a terminal, so it is safe to\nrun unattended in a container or under an agent. `-y` does the same from a\nterminal, and `--no-graphs` / `--no-embed` / `--no-calibrate` decide individual\nsteps up front.\n\nThe same thing by hand. The order is a dependency chain, not a preference:\ngraphs are what the index is built from, the index writes the chunks `embed`\nencodes, and `calibrate` fits its semantic floor from those vectors.\n\n```bash\nloci scan ~/code       # register every git repo it finds, one scope each\nloci graphs            # optional: add code symbols (free, no model calls)\nloci index             # build the routing index + episode store\nloci embed             # optional: local vectors for semantic recall\nloci calibrate         # optional: fit routing thresholds to your corpus\nloci doctor            # what is missing, and the command that fixes it\n\nloci ask \"which projects use wrangler and D1?\"\nloci eval              # measure routing accuracy on YOUR corpus\n```\n\nEach step writes what the next one reads, which is what makes the order fixed\nrather than preferred:\n\n```mermaid\nflowchart TD\n    scan[\"loci scan ~/code<br/>writes ~/.loci/scopes.json\"]\n    graphs[\"loci graphs<br/>writes graphify-out/graph.json, one per scope\"]\n    index[\"loci index<br/>writes ~/.loci/episodes.json, scope_index.json, rankers/\"]\n    embed[\"loci embed<br/>writes ~/.loci/embeddings.npz\"]\n    calibrate[\"loci calibrate<br/>writes ~/.loci/calibration.json\"]\n    doctor[\"loci doctor<br/>writes nothing at all\"]\n\n    scan -->|\"there is nothing to graph or index until a scope exists\"| graphs\n    graphs -->|\"the index is built FROM graph.json; index first<br/>and every project routes on prose alone\"| index\n    index -->|\"embed encodes episode chunks, and they do not<br/>exist until index has written the store\"| embed\n    embed -->|\"calibrate fits a per-scope semantic floor from<br/>those vectors; with none it keeps a default\"| calibrate\n    calibrate -->|\"and then reports whatever is still missing\"| doctor\n```\n\nRunning it out of order does not fail loudly. It produces an install that works\nand quietly retrieves worse, which is the failure `doctor` exists to name.\n\n`scan` registers one scope per git repository. It also reads who owns each\nrepository out of git, prints who owns what, and asks before registering the\nones that are not yours; that prompt takes its default like every other, which is to\nregister everything.\n\nA monorepo can become one scope per package instead. `--split` on `loci scan` or\n`loci setup` takes anything carrying `package.json`, `pyproject.toml`,\n`Cargo.toml` or `go.mod` one level down; a repo-local `.loci.json` names the rest\nand is honoured with or without the flag. **`--split` is off by default**, and\nthe reason is measured rather than cautious: a new scope's aliases include its\nbare directory name, and an alias outranks your working directory (6.0 to 4.0).\nOn the development corpus, splitting a repository holding `glasses/` sent eight\nhand-written questions about a *different* project to `Delroy/glasses` — seven of\nthem had routed correctly before the split, and six of the eight reverted when\nthe alias boost was zeroed. A Jekyll `_site/` build directory became a scope of\nits own on the same run. A `.loci.json` you wrote does not have that problem: you\nnamed those sub-projects deliberately, so you can see what they are called.\n\n`ask` uses your working directory by default, and should. Questions that name no\nproject — *\"how is this deployed?\"*, *\"how do I run the tests?\"* — route\ncorrectly **100% of the time with cwd**. Without it they are unanswerable, and\nloci abstains on all of them rather than guessing. cwd is not a tiebreaker\nsignal here, it is the primary one.\n\n---\n\n## Concepts\n\n**Scope.** One project, one namespace. A registered git repository, one package\nof a monorepo (under `--split`, or named in `.loci.json`), or anything you add\nexplicitly. Scopes are never merged.\n\n**Group.** An overlapping label on a scope: `me` and `vendor:<org>`, read from\ngit provenance; `client:acme` and anything else you assert by hand; and a\nmonorepo's own id, carried by every package inside it *and* by the monorepo\nitself, so `--group <monorepo>` reaches the code no package claimed. A scope can\nbe in several, and the scope set stays flat: grouping never merges scopes or\nnests one inside another.\n\nWhat a group does to a question is its **mode**, and the mode answers two\ndifferent questions. Reached through your working directory, `explicit` does\nnothing, `soft` (the default) multiplies every outside scope's evidence base by\n0.5, and `hard` confines routing to members and abstains when the best answer is\noutside. Named on the command line with `--group X`, **all three modes confine\nto X's members** — the mode decides only what happens when the best answer is\noutside them: `hard` abstains and says so, `soft` and `explicit` answer with the\nbest member anyway. Membership lives in the scope registry, mode in\n`groups.json`, so a re-scan — which rewrites the registry wholesale — cannot\ndiscard policy. Measured: `hard`\nanchored on cwd fires on questions that *name* an outside project (12 of 12) and\nnot on questions carrying only its vocabulary (0 of 24), so in practice `--group`\nand project names drive it rather than where you are standing.\n\n**Structure store.** What calls what — symbols, files, references, traversals\nwith `file:line` citations. Supplied by [graphify](https://github.com/safishamsi/graphify)\nthrough an adapter.\n\n**Episode store.** What happened and why — README and docs, git commit bodies,\ndocstrings and comment blocks, and any notes you point it at. Stored verbatim,\nchunked on heading boundaries, redacted before it is written.\n\n**Router.** Decides which scopes a question belongs to, deterministically, with\nno model call. Signals in order of weight: an explicit project name (6.0), the\nworking directory (4.0), vocabulary evidence (measured 0.1–1.5), recency (0.15).\nWeight is not usefulness — cwd is the signal that carries most real questions,\nbecause most real questions name no project at all.\n\n**Abstention.** A first-class outcome. `ABSTAIN` means *ask the user*, not *pick\nthe biggest*. Both layers can refuse.\n\nAn abstention that only says *no* leaves the asking to you, so it carries the\nshortlist it refused to choose between — each project, and the terms that put it\nthere:\n\n```\nloci ask \"how is caching handled?\"\n\nABSTAINED - not enough of the question exists in any project.\n  candidates: Delroy (handled, caching), odysseus (handled, caching), tensor-serve (caching)\n  re-run with --scope <name>, or from inside the project directory.\n```\n\nA scope earns its place by holding a term **at most half the corpus holds**.\nHolding one is not enough: `change`, `handled` and `work` sit in nearly every\nproject, so matching them is a coincidence rather than a claim. Measured on the\ndevelopment corpus, that is the difference between naming three projects and\nlisting all fourteen in score order, which is the registry rather than a\nshortlist.\n\nWhen nothing clears that bar there is no shortlist to print, and saying so is the\nanswer — the question's subject is not indexed anywhere, and choosing a scope\nwould only relocate the guess:\n\n```\nloci ask \"xyzzy plugh frotz quuz\"\n\nABSTAINED - not enough of the question exists in any project.\n  no project holds a distinctive term from this question -- `loci doctor` shows what is not indexed.\n```\n\n---\n\n## Architecture\n\n### The query path\n\nThe diagram at the top of this file is the elevator version. In full:\n\n```\nloci ask \"why was the session cookie dropped on localhost?\"\n  │\n  │   --scope NAME jumps straight past routing to the fan-out, and drops the\n  │   episode gate with it: that gate exists to stop an answer arriving from\n  │   the wrong scope, and you have just named the right one.\n  ▼\nconfinement                                                       groups.py\n  │   reads the registry and groups.json. Never the index, never a model.\n  │     --group X  ─▶  eligible = members of X, in ALL THREE modes\n  │     else cwd   ─▶  the strictest group of the scope you are standing in\n  ▼\nroute                                                             router.py\n  │   reads scope_index.json: token ─▶ {scope: node_df}. One dict lookup per\n  │   query token. Deterministic, sub-millisecond, no model call.\n  │\n  ├──▶ ABSTAIN — deictic, no_evidence or out_of_group. Names the cause, lists\n  │             the scopes with a claim on the question and what each one\n  │             holds, names the flag that fixes it, and queries nothing.\n  │             An outcome, not an error.\n  ▼\nselected scopes, at most 3        one thread each; neither store is ever\n  │                               queried across a scope boundary\n  ├─── scope ─── scope ─── scope\n  │      │\n  │      │  expand the question against THIS scope's postings — a dict\n  │      │  lookup, so a token the scope does not have cannot be invented —\n  │      │  then append the tokens of its nearest embedded symbol labels\n  │      │\n  │      ├─ structure store   graphify query --graph <this scope's graph>\n  │      │                    what calls what, with file:line citations\n  │      │\n  │      └─ episode store     BM25 + char 3–5 gram + embeddings, fused\n  │                           gated: lexically grounded OR semantically\n  │                           confident, so it can return nothing, and does\n  ▼\nmerged, cited answer — one block per scope, and `no evidence in this scope`\nwherever both of a scope's stores came back empty\n```\n\nBoth stages refuse independently: the router can decline to pick a scope at\nall, and a scope it did pick can still hand back nothing.\n\n### The routing decision\n\nEverything above the fan-out is one function. It scores every scope in the\ncorpus, then asks three refusal questions in a fixed order. An alias or cwd\nsignal skips the last two, but not the first.\n\n```mermaid\nflowchart TD\n    Q[\"question, plus cwd and --group when given\"]\n    CONF{\"which group confines this question?\"}\n    E1[\"eligible = members of X<br/>strict only when that group's mode is hard\"]\n    E2[\"eligible = members<br/>strict\"]\n    E3[\"demoted = every scope outside the group\"]\n    E4[\"nothing confined, nothing demoted\"]\n    BASE[\"evidence base, for EVERY scope in the corpus<br/>sum(scope-idf x prominence) / query tokens / size^0.15\"]\n    PEN[\"demoted? base x 0.5<br/>here, before the boosts, so it cannot invert one\"]\n    BOOST[\"+ ALIAS 6.0, the question names the project<br/>+ CWD 4.0, DEEPEST containing scope only<br/>+ RECENCY 0.15, a tiebreak and no more\"]\n    TOPS[\"top = best ELIGIBLE scope<br/>top_all = best scope in the whole corpus\"]\n    OOG{\"strict, and top_all is outside the group,<br/>and top_all would itself have been routable?\"}\n    A1([\"ABSTAIN out_of_group<br/>the best answer is outside the group\"])\n    FORCED{\"forced?<br/>top carries an alias hit or the cwd signal\"}\n    ENUM{\"enumerative?<br/>which projects / what repos / any of my / where else / have I ever\"}\n    DEIX{\"deictic?<br/>this / these / it / its / here / the project / the app / ...\"}\n    A2([\"ABSTAIN deictic<br/>the question points at a subject it never names\"])\n    EV{\"enough evidence?<br/>ANY ONE of the three is enough\"}\n    A3([\"ABSTAIN no_evidence<br/>too little of the question exists in any project\"])\n    SEL{\"did the question ask for a SET?\"}\n    SELECT[\"keep every eligible scope scoring at or above 0.85 x top,<br/>cap at 3, then force in every concentrated-token holder\"]\n    SETSEL[\"keep every eligible scope clearing 0.8 x the floor ON ITS OWN,<br/>in rank order, cap at 8 -- no ratio cutoff at all\"]\n    OUT([\"selected scopes\"])\n\n    Q --> CONF\n    CONF -->|\"--group X, any mode\"| E1\n    CONF -->|\"cwd scope's strictest group is hard\"| E2\n    CONF -->|\"cwd scope's strictest group is soft\"| E3\n    CONF -->|\"explicit, or no group at all\"| E4\n    E1 --> BASE\n    E2 --> BASE\n    E3 --> BASE\n    E4 --> BASE\n    BASE --> PEN\n    PEN --> BOOST\n    BOOST --> TOPS\n    TOPS --> OOG\n    OOG -->|yes| A1\n    OOG -->|no| FORCED\n    FORCED -->|yes| SEL\n    FORCED -->|no| ENUM\n    ENUM -->|\"yes, and it is not picking ONE scope\"| EV\n    ENUM -->|no| DEIX\n    DEIX -->|yes| A2\n    DEIX -->|no| EV\n    EV -->|\"summed token evidence at or above the floor<br/>(7.6 shipped, refitted by loci calibrate)\"| SEL\n    EV -->|\"at least 4 matched tokens\"| SEL\n    EV -->|\"an eligible scope holds a CONCENTRATED token:<br/>held by at most 2 scopes, prominent inside them\"| SEL\n    EV -->|\"none of the three\"| A3\n    SEL -->|no| SELECT\n    SEL -->|yes| SETSEL\n    SELECT --> OUT\n    SETSEL --> OUT\n```\n\nFour things in that shape are load-bearing and none of them are obvious.\n\n**`forced` is an escape, not a signal.** An alias hit or a cwd hit contributes\nnothing to either evidence count, so a scope winning purely on 6.0 or 4.0 reads\nas zero evidence to the gates below. It skips both of them instead: deixis is\nonly a problem when nothing else has identified the subject, so a question that\nsays \"it\" *and* names a project is unaffected.\n\n**`out_of_group` is judged against the whole corpus, not the group.** It fires\nonly when the corpus-wide winner is outside the group *and* would itself have\nrouted. Without that second test it swallowed the other two reasons entirely —\non a question matching no vocabulary, every scope scores near zero and the\nwinner is whoever took the 0.15 recency tiebreak.\n\n**The three evidence gates are OR'd because they fail on different question\nshapes.** A short question about a rare symbol has high evidence and a low\ncount; a long question about a familiar subsystem has the reverse. Requiring\nall three abstains on both.\n\n**Enumeration inverts every other gate, so it gets its own selection rule.**\nEvery gate above asks *is there enough evidence for ONE scope*, and a question\nabout something several projects share splits its evidence across them by\nconstruction — so the more projects genuinely share a term, the less likely all\nof them come back. Measured, `which of my projects use Cloudflare workers or\nD1?` returned one owner of two: 2.098 against 1.0867, where the 0.85 ratio\nneeded 1.783.\n\nThe ratio is the part that inverts. It measures distance from the *top* scope,\nand the top scope of an enumeration is merely whichever owner says the term most\noften. Set mode drops it and asks each scope the same routability question in\nturn, against a floor discounted to 0.8 — because a member of a set legitimately\ncarries less evidence than a lone answer.\n\nEnumeration is grammatical, like deixis, so it is detected the same way: a closed\nclass of markers, no threshold. It also **outranks** deixis. *\"where else does\nthis pattern appear?\"* points at its subject *and* asks across the corpus; the\ndeixis rule exists because a pointing question gives no way to pick one scope,\nand an enumerative question is not picking one.\n\nThe frame nouns are stripped from the query, which is the deixis insight one\nlevel down — `projects` is scaffolding, not vocabulary. It is not a cosmetic\nstep: in the development corpus `projects` is held by exactly two scopes, so it\nscored as discriminative evidence *for those two* and dragged both into every\n\"which of my projects\" answer. Stripping it moved negative-family abstention\nfrom 66.7% to 88.9%.\n\n```bash\nloci ask \"which of my projects use Cloudflare workers or D1?\"\n# ENUMERATED -> 3M1RY33T.github.io, urthreads\n```\n\n### Scopes and groups\n\nGroups are overlapping labels over a flat scope set. There is no tree anywhere,\nand nothing is ever merged.\n\n```\n      scope                        the groups it carries\n      ───────────────────────────  ──────────────────────────────────────\n      delroy           monorepo    me    delroy\n      delroy/glasses   sub-scope   me    delroy\n      delroy/client    sub-scope   me    delroy   client:acme\n      acme-api         repo        me             client:acme\n      vendorlib        repo                                     vendor:someorg\n\n      me, vendor:someorg   read from git provenance by `scan` or `groups infer`\n      delroy               computed by `scan` from the filesystem: a repository\n                           that splits gives its own id to every sub-project\n                           inside it, and to itself\n      client:acme          asserted by hand, with `loci group add`\n```\n\nFive scopes, four groups. `delroy/glasses` is not stored *inside* `delroy`; it\ncarries the label `delroy` exactly as it carries `me`. `client:acme` spans a\nmonorepo sub-project and an unrelated repository, which a hierarchy could not\nexpress at all. The two sub-scopes exist only because that repository declared\nthem in `.loci.json`, or was scanned with `--split`; without either, `delroy` is\none scope and the containment group does not exist. A parent excludes its\nsub-scopes' subtrees from its own collection, so no file is counted twice and\nneither vocabulary is inflated with the other's tokens.\n\nWhat a group does to a question is its **mode**, and the mode answers two\ndifferent questions depending on how the group was reached:\n\n```\n                    reached through your cwd        named with --group X\n  ────────────────  ─────────────────────────────   ────────────────────────\n  explicit          nothing                         confines to X's members\n  soft (default)    every outside scope's           confines to X's members\n                    evidence base × 0.5\n  hard              confines to members, and        confines to X's members,\n                    abstains when the best          and abstains when the\n                    answer is outside               best answer is outside\n```\n\nThe right-hand column is the counter-intuitive half, so it is measured rather\nthan asserted. On a three-scope fixture — `alpha` and `beta` in group `team`,\n`vend` outside it — one question, four policies:\n\n```\nloci route \"how does the gizmo parser emitter handle sprocket calibration\"\n\n  unconfined                  -> vend, alpha\n  --group team   explicit     -> alpha\n  --group team   soft         -> alpha\n  --group team   hard         -> ABSTAIN (out_of_group); candidates: alpha (sprocket), beta (calibration)\n```\n\nAll three modes dropped `vend`, the scope that won unconfined. `--group` is you\nasserting the answer is in here; the mode decides only what happens when it is\nnot. The shortlist under `hard` is wider than the selection above it on purpose:\nit is what the abstention refused to choose between, not what it would have\nanswered from — `beta` is on it because `calibration` is a term only `beta`\nholds, and off the answer because that is all it has.\n\nReached through cwd instead, `soft` demotes and does not confine. The same\nquestion asked from inside `alpha` with no `--group` at all returns\n`alpha, vend`: `vend` scores 3.39 against `alpha`'s 6.18 — far outside the\n0.85 widening band — and comes back regardless, because it is the sole holder\nof `gizmo`, `parser` and `emitter`, and the concentrated tier forces every\nholder of a shared-or-rare term into the answer. Demoted is not excluded.\n\n---\n\n## Commands\n\n| command | purpose |\n|---|---|\n| `loci setup [dirs…]` | scan, graph, index, embed and calibrate in one pass (`-y`, `--no-embed`, `--split`) |\n| `loci update [dirs…]` | refresh graphs, index, vectors and calibration for what is registered (`--no-scan`, `--force`) |\n| `loci scan <dirs>` | discover git repos and register them as scopes (`--split`) |\n| `loci add <path>` | register one scope explicitly (`--alias`, `--glob`) |\n| `loci scopes` | list what is registered (`--group`) |\n| `loci uses` | which of your projects reference another, with citations (`--refresh`) |\n| `loci groups` | list groups, their resolved mode, and how many are in each |\n| `loci groups infer` | label every scope `me` or `vendor:<org>` from git provenance |\n| `loci group set <group> --mode` | how much a group confines: `explicit`, `soft`, `hard` |\n| `loci group add\\|rm <scope> <group>` | edit one scope's membership |\n| `loci graphs [scope…]` | build missing structure graphs via graphify |\n| `loci index` | build the routing index and episode store (`--force`) |\n| `loci embed` | encode episode chunks locally (`--model`) |\n| `loci calibrate` | fit the evidence floor to your corpus (`--show`) |\n| `loci route \"…\"` | show where a question routes (`--explain`, `--group`) |\n| `loci ask \"…\"` | route, then query both stores (`--scope`, `--group`, `--fast`, `--rerank`) |\n| `loci doctor` | coverage gaps per scope, and the fix for each |\n| `loci eval` | measure routing on your corpus (`--misses`) |\n| `loci skill install` | install the `/loci` skill for an AI client (`--dir`, `--print`) |\n| `loci mcp` | run the MCP server on stdio |\n\n`loci index` reuses any scope whose files and git HEAD are unchanged, so\nreindexing is cheap enough to run from a commit hook.\n\n### Keeping it current\n\nNothing updates itself. There is no watcher, no daemon and no installed git\nhook: every refresh is a command you run.\n\n```bash\nloci update            # the whole chain, for everything registered\n```\n\nIt is `setup` for an install that already exists, so it prompts for nothing and\nopts you into nothing new: embeddings are re-encoded because you already had\nembeddings, and the thresholds are refitted because they were already fitted.\nIt also rescans wherever your last scan was pointed, so a repository created\nsince then is registered rather than missed — that is what the `roots` key in\n`scopes.json` is for, and `loci update ~/work` adds another.\n\nThe one thing it rebuilds unconditionally is the **structure graph**, because\nthat is the only staleness in loci that is otherwise silent. `loci graphs`\nbuilds a graph that is *missing* and skips every scope that already has one, so\na project whose code moved keeps routing on the symbols it had the day it was\nregistered. Everything else announces itself: `index` re-parses a scope whose\nfingerprint moved, and `doctor` names embeddings that no longer line up with\nthe store. Rebuilding all of them is affordable because graphify does not\nrewrite a graph whose code did not change — measured, a second consecutive run\nleft `graph.json` byte-identical with its mtime untouched, and loci's own\nfingerprint reads that mtime, so refreshing the graphs does not force a reindex\nof the scopes that stood still.\n\nBy hand, the same thing, and the order is the dependency chain again:\n\n```bash\nloci graphs --all      # `loci graphs` alone would skip every existing graph\nloci index             # unchanged scopes are reused, not re-parsed\nloci embed             # a full re-encode; skipping it silently disables\n                       # semantic search for every scope whose chunk count moved\nloci calibrate         # the floor measures shared vocabulary, which just changed\n```\n\nTwo things `update` cannot do for you. A project deleted from disk stays in the\nregistry — it is reported, not removed, because the entry carries groups and\naliases you set by hand and the directory may only be unmounted. And a\nlong-running `loci mcp` server holds its fitted rankers and vectors in memory\nfrom boot, so restart it after a rebuild.\n\n---\n\n## From inside an AI client\n\n```bash\nloci skill install          # -> ~/.claude/skills/loci/SKILL.md\n```\n\nThat installs one skill with subcommands, not eight commands cluttering the\nlisting:\n\n```\n/loci \"<question>\"          ask memory; routes on your working directory\n/loci update                refresh everything registered\n/loci doctor                coverage gaps per project, and the fix for each\n/loci scopes                what is registered, and the group each is in\n/loci route \"<question>\"    where a question routes, and why it abstained\n/loci add <path>            register one project\n/loci setup [dirs…]         first run\n/loci <anything else>       passed through to the `loci` CLI verbatim\n```\n\nThe passthrough rule is what keeps it short: `index`, `embed`, `graphs`,\n`calibrate`, `eval` and `groups` all work from the client without being\ndocumented twice, so the skill cannot drift from flags it never mentions. What\nit *does* mention is checked — a test parses the Usage block and fails if any\ncommand in it stops existing.\n\nThe skill ships inside the package rather than in a dotfiles repo, because it\ndocuments flags and abstention reasons that the CLI can change; `loci skill\ninstall` after an upgrade is the whole update path. `--print` writes it to\nstdout for a client that does not read `~/.claude/skills`.\n\nTwo things it teaches an agent that are not obvious from the CLI. **Run it from\nthe project root** — cwd is the primary routing signal, so a question asked from\nthe wrong directory routes worse than one asked from none. And **`ABSTAINED` is\nan answer**: the reason names what to do next, the shortlist under it names the\nprojects that could plausibly own the question and the terms that put them\nthere, and an agent that rephrases and retries instead is burning turns on a\ndecision that was about vocabulary rather than wording. An abstention carrying no\nshortlist at all is the one case where rephrasing cannot help either — nothing in\nthe corpus holds a distinctive term from the question.\n\n---\n\n## MCP\n\n`loci mcp` speaks stdio, so a client launches it — you do not run it yourself.\n\n**Claude Code:**\n\n```bash\nclaude mcp add loci --scope user -- loci mcp\nclaude mcp list                       # loci: ... - ✔ Connected\n```\n\n**Claude Desktop**, in `claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"loci\": { \"command\": \"loci\", \"args\": [\"mcp\"] }\n  }\n}\n```\n\nUse an absolute path for `command` if `loci` lives in a version-managed\nenvironment — pyenv shims and `pipx` venvs resolve against a shell your client\ndoes not have. `which loci` from a working shell, then paste the target.\n\nThree tools — `ask`, `scopes`, `doctor` — not several dozen. Agents are the\nprimary consumer, and a wide tool surface pushes orchestration onto the model\nand burns a turn per hop.\n\nWithout this the server is dead weight: it is installed by the `mcp` extra and\nreachable by nothing, and every question an agent asks goes through the CLI\ninstead — paying full process and model startup per question, and never touching\nthe boot-time warm-up the server exists to provide.\n\nPass `cwd` to `ask` whenever the client knows it, and `group` to narrow the same\nway `--group` does on the command line. The server loads the embedding model and\nthe largest scopes' rankers at boot, so the first tool call an agent makes is\nfast rather than paying ~4.4s of startup while the user waits.\n\n---\n\n## Measure it on your own corpus\n\nEvery accuracy figure below was fitted and measured against one person's\nrepositories — ten when the constants were fitted, fourteen now. That is a claim\nyou would otherwise have to take on trust, and the drift between those two\nnumbers cost the behavior family 57 points.\n\n```bash\nloci eval            # a few seconds, no labelling, no setup\nloci eval --misses   # and what it got wrong\n```\n\nIt only asks questions whose correct answer is known by construction:\n\n| family | how gold is known |\n|---|---|\n| deictic + cwd | asked from inside a scope, so the answer is that scope |\n| deictic, no cwd | nothing says which project — abstaining is the only correct answer |\n| unanswerable | nonsense no software corpus can answer |\n| signature | built from each scope's own most distinctive vocabulary |\n\n```\n10 scopes | random guessing would score 10.0%\n\nfamily                  n  correct  scopes  what it measures\ndeictic + cwd          80  100.0%       -   should route to the scope you are in\ndeictic, no cwd         8  100.0%       -   should abstain; nothing says which project\nunanswerable            6  100.0%       -   should abstain; no corpus can answer these\nsignature              20  100.0%     1.0   are your scopes distinguishable from each other?\n```\n\nThe signature family is an **upper bound** — it asks each scope about its own\nrarest words, so a real question phrased in shared vocabulary does worse. A low\nscore there is a fact about your projects, not necessarily a bug to tune away.\n\n### Calibration\n\nRouting gates on three independent signals, and **any one passing is enough**:\none exceptionally discriminative token, enough summed token evidence, or enough\nmatched tokens. They fail on different question shapes — a short question about\na rare symbol has high evidence and a low count, a long question about a\nfamiliar subsystem has the reverse — so requiring all three abstains on both.\n\nThe evidence floor is the one that depends on your corpus, because it depends on\nhow much vocabulary your projects share. `loci calibrate` fits it from the same\nauto-labelled questions, and reports whether the bands separate at all:\n\n```\nevidence floor      7.17\nfitted from         149 routable + 14 unroutable questions, none hand-labelled\nclassifies          84.0% of its own samples correctly\n\n! The bands OVERLAP -- some questions that should route score no higher than\n  questions that should not, so no threshold separates them cleanly.\n```\n\nAn overlap warning is information about your projects, not a defect: they share\nenough vocabulary that evidence alone cannot always tell them apart, and cwd or\n`--scope` should carry more of the weight.\n\n---\n\n## What it reads, and what it writes\n\nEverything stays on your machine. No LLM in the query path; routing is a dict\nlookup per token. Embeddings, when enabled, run locally.\n\nCollected per scope: `README*`, `docs/**/*.md`, source files (for docstrings and\ncomment blocks), and `git log`. Add anything else with `loci add --glob`,\nincluding absolute paths outside the repo.\n\nThe only configuration loci reads from inside a repository is `.loci.json`, and\nonly if you write it. It names sub-projects at any depth, and unlike\n`--split` it is honoured on every scan:\n\n```json\n{\"scopes\": [{\"path\": \"client\"}, {\"path\": \"services/api\"}]}\n```\n\nPaths are relative to the repository root and may not escape it. A malformed\nfile is ignored — a scan that aborts on a stray comma is worse than one that\nmisses a declaration. Nothing writes this file; you do.\n\nThe only thing loci ever puts inside a repository is `graphify-out/graph.json`.\nTwo commands write it: `loci graphs`, which does nothing else, and `loci setup`,\nwhich offers it at step 2 of 5 and takes yes as the default — including with no\nterminal, where every prompt takes its default. `loci setup --no-graphs` skips\nit, and every other command reads.\n\nSo inside a repository exactly two files matter, and they point in opposite\ndirections:\n\n```\n<repo>/graphify-out/graph.json    loci writes it, on request. You never do.\n<repo>/.loci.json                 you write it. loci never does.\n```\n\nEverything else goes to `~/.loci` (or `$LOCI_HOME`). One directory, not one per\nproject: routing is a cross-scope decision, so answering *which project is this\nabout* needs every scope's vocabulary in the same lookup, and N per-project\nindexes would turn the cheap question into the expensive one.\n\n```\n~/.loci/                    what it holds               written by\n\n├── scopes.json             the scope registry, and     scan, add, group,\n│                           the roots it was scanned    update\n│                           from, for `loci update`\n├── groups.json             group policy: default mode  group set\n│                           and any mode set per group\n├── scope_index.json        routing index, the token    index\n│                           -> {scope: node_df} map\n├── episodes.json           the episode store: chunks   index\n│                           verbatim, already redacted\n├── rankers/<scope>.joblib  fitted BM25 + char-gram     index\n├── embeddings.npz          chunk and symbol vectors    embed\n├── .symbols-<scope>.json   symbol labels, in the       embed\n│                           order embeddings.npz has\n├── calibration.json        the fitted evidence floor   calibrate\n│                           + a semantic floor / scope\n└── .index.lock             advisory build locks, so    index, embed\n    .embed.lock             two builds cannot inter-\n                            leave their writes\n```\n\n`scopes.json` is machine-managed and rewritten wholesale by every scan;\n`groups.json` is yours and must survive one. That is why membership lives in the\nregistry and mode lives in the policy, and why they are two files.\n\n### Redaction\n\nEverything collected passes through `loci.redact` **before** it is written, so a\ncredential committed by accident never reaches the episode store, the vectors or\nthe rankers.\n\nFiles that exist to hold secrets are never opened — `.env*`, `.dev.vars`,\n`*.pem`, `*.key`, `id_rsa`, `secrets.*`, `credentials.json`. Then every chunk is\nscanned for AWS keys, GitHub/Slack/Google/Stripe/OpenAI/Anthropic tokens,\nprivate-key blocks, JWTs, `user:password@host` connection strings, bearer\ntokens, and long opaque values assigned to a secret-shaped name.\n\nChunks are built through a single constructor, so a new collection path cannot\nforget to redact. A secret assignment keeps its key name and loses only its\nvalue, so the chunk still reads sensibly and still routes:\n\n```\nclient_secret = \"aVeryLong...\"   ->   client_secret = \"[REDACTED:secret-assignment]\"\n```\n\n`loci index` reports what it removed, per scope and by kind — silent redaction\nis indistinguishable from none. Deliberately biased toward over-redaction: a\nfalse positive costs one chunk a little retrieval quality, a false negative\ncopies a live credential into a plaintext file and a vector index.\n\n---\n\n## Design rules, and the measurements behind them\n\nEach of these is something a reasonable implementation gets wrong.\n\n**1. Never merge scopes into one index.** See the table at the top: 2% and 18%\non-topic against a merged graph.\n\n**2. Abstention is a feature at both layers.** Measured 10/10 on probes\nincluding deliberate nonsense. Every RAG tool confidently answers \"what is the\nairspeed velocity of an unladen swallow\"; this one says it does not know.\n\n**3. Margin and ratio tests do not measure confidence.** Both were tried and\nboth invert on real data. *\"How do I fix this bug?\"* produces routing margin\n0.94 while a correctly-routed question produces 0.18. Nonsense scored 0.608\nagainst a large scope with a **higher** top-to-p90 ratio than a genuine question\nagainst a small one. What separates them is whether the question's words exist\nin the corpus at all.\n\n**4. A matched-token count is not evidence.** Compared against auto-labelled\nquestions, count does not separate routable from unroutable at all — it inverts.\nSummed token evidence does. Count is kept only as one of three OR'd gates.\n\n**5. Deixis is grammatical, so detect it grammatically.** A question that points\nat its subject (\"this project\", \"the app\", \"it\") cannot be routed by vocabulary,\nbecause the words that would identify the subject are exactly the ones it\ndeclines to say. No lexical statistic rescues this: per-token evidence for\n`start` (2.51) and `services` (2.56) is indistinguishable from real evidence\nlike `session` (2.53). A closed class of markers took abstention from 37.5% to\n100%.\n\n**6. Normalize for scope size, twice.** With one scope 67× larger, ordinary\nEnglish words appear in exactly one scope and score as maximally discriminative\nfor it.\n\n**7. Docstrings are excellent retrieval material and poor routing material.**\nThey are high-volume generic English — \"returns\", \"the value\", \"default\",\n\"configuration\" — and folding them into routing halved accuracy on both a\nsymbol-indexed and a prose-only corpus.\n\n**8. Coverage is the binding constraint, not ranking.** This surfaced in every\nexperiment. Running a free AST-only index over three un-indexed repos moved\nrouting 69.6% → 82.6% *while adding two more scopes to compete against*. Hence\n`doctor` as a first-class command: an empty answer with a reason beats a\nconfident answer from the wrong project.\n\n---\n\n## Ranking\n\nEpisode search fuses three signals, renormalizing over whichever produced one:\n\n| ranker | catches |\n|---|---|\n| BM25 | exact terminology, rare identifiers |\n| char 3–5 grams | morphology and casing — `samesite` vs `SameSite=None` |\n| embeddings (bge-small, local, optional) | meaning without shared words |\n\nThe lexical pair recovers questions the structure graph cannot see at all. The\nembedding ranker covers what neither lexical ranker can: a question and its\nanswer that share no vocabulary.\n\nAdding embeddings destroyed abstention — every chunk gets a nonzero score, so an\nabsolute floor stops firing. The fix is a **two-tier gate**: a hit must be\nlexically grounded *or* semantically confident, because a lexical gate alone\nrejects exactly what embeddings were added for.\n\nCross-encoder reranking is available via `--rerank` and **off by default**: it\nmeasured 2/6 → 3/6 precision@1 at ~96ms/query with two small regressions, on six\ncases whose labels are themselves arguable.\n\n---\n\n## Performance\n\n```\nloci index          15s full · 3.9s when nothing changed\nloci ask            1.0s one-shot with --fast · ~5s with semantic ranking\nMCP server          6s at boot, then 0.2-0.3s per tool call\nrouting             sub-millisecond, and flat from 25 to 100 scopes\n```\n\nThree things make that work, each a measured bottleneck first:\n\n**Traversal prunes, it does not filter.** `Path.glob(\"**/*.py\")` descends into\n`node_modules`, `.venv` and `Pods` in full — 32.9s to enumerate one repo. It\nimproves the index as well as the clock: one project was 66% vendored\nthird-party source before pruning.\n\n**Lexical rankers are fitted at index time.** Char 3–5 gram TF-IDF over a large\nscope costs ~1.5s, and an in-process cache never survives a CLI or MCP\ninvocation.\n\n**The embedding model is loaded at MCP boot.** ~2.3s of imports plus model\nconstruction is process startup, not work. `--fast` skips it for one-shot CLI\nquestions.\n\n### Scale\n\n| scopes | vocabulary overlap | routing | top-1 |\n|---|---|---|---|\n| 25–100 | low | 0.1–0.5ms | **100%** |\n| 25–100 | moderate | 0.3–0.6ms | 90% |\n| 25 | high | 0.1ms | 36% — answers, and is usually wrong |\n| 50–100 | high | 0.5ms | abstains 90% of the time |\n\nScope count is not the limit; vocabulary overlap is. The failure gets *safer* as\nscope count rises — more scopes sharing a term means less evidence for any one,\nand the gate fires. A **small** corpus of highly similar projects is the\ndangerous configuration.\n\n---\n\n## Durability\n\nWrites go through a temp file and `os.replace`, so a reader never sees a\nhalf-written index. `Path.write_text` truncates before it fills: measured on an\n8MB store, **11 torn reads** in a few seconds of concurrent access, and 0 after\nthe change. An MCP server reading while `loci index` runs sat squarely in that\nwindow.\n\n`loci index` and `loci embed` hold an advisory lock, so two builds refuse to\ninterleave rather than producing an index and a store that disagree. Locks left\nby a dead process are detected and broken.\n\nThe store is written before the index, because the index is what readers gate\non: a crash between the two leaves a stale index pointing into a store that is a\nsuperset of it, never an index promising chunks that do not exist.\n\n---\n\n## Extending\n\nTwo contracts in `backends/base.py`:\n\n```python\nclass StructureBackend(Protocol):\n    def sources(self, scope) -> list[dict]: ...\n    def vocabulary(self, scope) -> Counter: ...      # feeds the router\n    def query(self, scope, query, *, budget, dfs) -> list[StructureHit]: ...\n\nclass EpisodeBackend(Protocol):\n    def collect(self, scope) -> list[Chunk]: ...      # verbatim, redacted\n    def search(self, question, chunks, scope_id, *, k, rerank) -> list[EpisodeHit]: ...\n```\n\nNothing above `backends/` knows which implementation is in use. Swapping\ngraphify for tree-sitter, SCIP or an LSP index means writing one adapter, not\nediting the router. `search` **must** be able to return an empty list — a store\nthat always finds something cannot be trusted when it does.\n\n### Why it is not a graphify fork\n\nloci depends on graphify through one adapter that reads its `graph.json` data\ncontract and shells out to `graphify query --graph`. Forking would mean\ninheriting a 767KB extractor and ~18 tree-sitter grammars to change code that\nnever needed changing. The scoping gap that motivated loci — graphify tags\nmerged-graph nodes with a `repo` attribute and exposes no way to filter on it —\nis solved *above* graphify by keeping one graph per scope. Both projects are\nMIT, so forking stays available; it just is not necessary.\n\n---\n\n## Status\n\nAlpha, and the numbers deserve their caveats.\n\nMeasured on a 173-item set over 14 scopes. 112 of those items are generated from\na fixed taxonomy applied to every scope, so that portion is unbiased by\nconstruction. The remaining 61 were hand-authored by the same person who built\nthe router; each carries a `contamination` field recording what the author had\nseen, and uncontaminated results are always reported separately.\n\n**The eval corpus is not in this repository.** The harness reads fourteen real\nprojects, six of them private or never pushed anywhere, and its records name\nthem at `file:line` — so `evals/` and the `evals/RESULTS.md` cited throughout\nthese sources are kept locally and are not published here or in the sdist. What\nships instead is `loci eval`, which generates its own questions from whatever\ncorpus *you* have registered; that is the number worth trusting about your own\nsetup, and it is the one this README asks you to run.\n\n**Known limits:**\n\n- **The behavior family fell from 85.7% to 28.6% when the corpus grew from 10\n  scopes to 14**, and the cause is not a threshold — sweeping `SIZE_PRIOR`\n  across 0.0–0.5 on the new corpus never beats the shipped 0.15. Two scopes now\n  hold 5,995 and 4,680 distinct tokens against `urthreads`' 427, and a scope\n  whose vocabulary approaches the corpus's own matches every question on\n  ordinary English. Five alternative scoring families were swept against it and\n  **none beat the plain sum**; the one strict improvement ships inert as\n  `CORROBORATION_WEIGHT`, because a single item on a single corpus is not\n  evidence. This is the largest open problem in the router.\n- The synthetic test bed cannot reproduce that failure: `CorpusSpec.size_skew`\n  varies file volume, not vocabulary breadth, so all thirteen shapes read 100%\n  at every value of the constant that would fix it. Closing that is a test-bed\n  change and it blocks the router change.\n- Two eval golds were found stale during this release, one of which was marking\n  a **correct** three-scope answer down as a precision failure. Corpus drift\n  turns right answers into recorded misses and nothing in the harness notices.\n- The structure store surfaces the answering symbol in 4 of 7 probes.\n- Large prose-only scopes over-attract; a scope with thousands of chunks and no\n  code graph absorbs questions belonging elsewhere.\n- CI now runs the suite on Linux, Windows and macOS across Python 3.10–3.13.\n  Standing it up found a third platform bug that reading had missed: Windows\n  refuses `os.replace` against a destination another handle holds open, so every\n  durable write failed there while both other platforms stayed green. Reading\n  finds bugs — it did not find that one. No user has run loci on Windows yet.\n- `loci eval` and `loci calibrate` share question families, so a perfect `eval`\n  score immediately after calibrating is partly circular.\n\nThe most useful thing anyone can do is run `loci eval` on a corpus that is not\nmine and say what it reports.\n\n---\n\n## License\n\nMIT.\n",
  "bytes": 47729,
  "sha": "fbdd1bb1c9debeb74b8056402a0d857004c96c428c48ead59f5f5ab4a3a88c58",
  "repo_slug": "3m1ry33t/loci",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_3m1ry33t_loci_adb55876/readme"
}