semlith 0.47.1

Local search over your code and documents for AI agents. One binary that indexes your repositories, PDFs and Office files and serves them over MCP.
Documentation
{
 "about": "32 fixed chunks for semlith doctor --gpu. Vectors are CPU fp32 granite-embedding-small-english-r2 at the pinned revision, CLS-pooled, normalised, each chunk embedded alone; they are in known-answer.f32 as 32x384 little-endian f32.",
 "texts": [
  "## [0.17.2] - 2026-09-16\n",
  "            \"/home/someone/api/src/lock.rs\"\n        );\n        assert_eq!(plain(r\"C:\\work\\api\"), r\"C:\\work\\api\");\n        assert_eq!(\n            plain(r\"\\\\server\\share\\notes.md\"),\n            r\"\\\\server\\share\\notes.md\"\n        );\n        assert_eq!(plain(\"\"), \"\");\n    }\n}",
  "| CLI commands | The names `index`, `watch`, `search`, `stats`, `files`, `add`, `forget`, `drop`, `start`, `adopt`, `mcp`, `models`, `languages`, `ledger`, `symbol`, `neighbors`, `path`, `setup`, `upgrade`, and what each one does. |\n| CLI flags | Flag names, their short forms, and their meanings \u2014 including the repeatable `--store`/`-s` on the read commands and the single `--store` the write commands take. |",
  "### What the harness said no to\n\nA third rerank factor \u2014 a lift for a chunk sitting inside a named definition \u2014\nwas specified, built, measured and removed. In a code repository nearly every\ncode chunk sits inside a definition and nearly no prose chunk does, so it is a\nsecond and blunter `prefer: code` applied to every query, including the ones that\nasked for `prefer: docs`. It costs two hits at k=3, and `prefer-code-over-prose`\nranks first without it and third with it. It is recorded in the release's\niteration log rather than quietly dropped.\n\n",
  "| `semlith_search` | Where the answer is: path, line span, enclosing symbol, how it was found and whether the file has changed since it was indexed, with the same `path`/`ext`/`lang`/`store` narrowing as the CLI. `format: \"excerpt\"` returns the text instead. |\n| `semlith_brief` | One call instead of four: located spans, the text of the top ones, and the one-hop callers and callees of the symbols they sit inside, every part labelled with the list or edge that found it, all of it under a `budget` in tokens that defaults to 4000. |\n| `semlith_read` | One span or one symbol and nothing around it \u2014 the second stage after a search, so an agent locates first and reads only what it needs. |",
  "    match lang {\n        \"rust\" => Some((\n            tree_sitter_rust::LANGUAGE.into(),\n            tree_sitter_rust::TAGS_QUERY,\n        )),\n        \"typescript\" => Some((\n            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),\n            tree_sitter_typescript::TAGS_QUERY,\n        )),\n        \"python\" => Some((\n            tree_sitter_python::LANGUAGE.into(),\n            tree_sitter_python::TAGS_QUERY,\n        )),\n        \"go\" => Some((tree_sitter_go::LANGUAGE.into(), tree_sitter_go::TAGS_QUERY)),\n        \"java\" => Some((\n            tree_sitter_java::LANGUAGE.into(),\n            tree_sitter_java::TAGS_QUERY,\n        )),\n        \"c\" => Some((tree_sitter_c::LANGUAGE.into(), tree_sitter_c::TAGS_QUERY)),\n",
  "\n        let go = run(\"a.go\", \"package m\\nimport fp \\\"path/filepath\\\"\\n\");\n        assert!(has_edge(&go, \"fp\", \"filepath\", \"aliases\"), \"{:?}\", go.edges);\n    }\n\n    /// A re-export that does not rename has nothing to cross: the alias and\n    /// the definition are one name, and a self-edge would be noise.\n    #[test]\n    fn a_re_export_that_does_not_rename_adds_no_alias_edge() {\n        let e = run(\"a.rs\", \"pub use store::Semlith;\\n\");\n        assert!(\n            !e.edges.iter().any(|x| x.kind == \"aliases\"),\n            \"{:?}\",\n            e.edges\n        );\n    }\n\n    /// The defect the flat hop had: `near` is one hop from a single weak seed\n    /// and `hub` is two hops from three strong ones, and one hop ranked them",
  "/// The port `semlith start` listens on unless told otherwise.\n///\n/// 7365 spells SEML on a phone keypad, is registered to nothing in the IANA\n/// list, sits below every OS ephemeral range, and is nowhere near the ports\n/// local development already uses \u2014 3000, 4200, 5000, 5173, 8000, 8080, 8888,\n/// 9000, and the usual database and model-server ports. A stable port is what\n/// lets the URL be a bookmark.\npub const DEFAULT_PORT: u16 = 7365;\n\n/// Overrides [`DEFAULT_PORT`].\npub const PORT_ENV: &str = \"SEMLITH_PORT\";\n\n/// The header the session token travels in.\n///\n/// A custom header is the point rather than a detail: a browser will not attach\n/// one to a request a page on another origin made, and it cannot set one at all",
  "                // quietly ranking by codes alone.\n                match store.exact_bytes() {\n                    Some(exact) => println!(\n                        \"exact    {} (full-precision rescoring)\",\n                        semlith::human_bytes(exact as i64)\n                    ),\n                    None => println!(\"exact    none (re-index to rescore in full precision)\"),\n                }\n                // Zero here reads the same as \"nothing ever changed\", so the\n                // line says which by naming the table rather than the number\n                // alone.\n                println!(\"history  {} retired definitions\", store.retired_symbols()?);\n                // What the graph covers, per language, from the rows",
  "  bug, and we would like to know about it.\n- **The hand-written HTTP server.** `src/http.rs` parses requests itself rather\n  than through a crate. Request line and header parsing, the chunked response\n  writer, the header reader and the `Host` check are all semlith's code, and a\n  request that gets past the token, the same-origin rule or the `Host` check, or\n  that wedges a worker thread, is exactly the kind of thing worth reporting.\n  Header and body sizes are capped, a request has ten seconds to arrive in full,\n  connections are capped at 32 at once, and a panicking route answers 500\n  without taking its worker \u2014 but this is still hand-written code on a socket.\n- **The directory-listing route.** The portal's folder picker can list",
  "| Status | What it means |\n|---|---|\n| `queued` | Submitted and not yet under way. The pill carries its place in line when it is waiting on the daemon-wide queue below; without a place it is waiting on its own store's writer, which is the watcher being mid-file \u2014 one writer per store is the rule that stops two passes corrupting each other. |\n| `running` | A writer has it. |\n| `paused` | Held between files by this card's **Pause**. |\n| `stopping` | A stop was asked for and what the run embedded is being undone. Told apart from `stopped` because on a large corpus the undoing takes as long as the embedding did, and a card that jumped straight to `stopped` would be claiming the store was already back to what it was. |\n| `done` | Finished. |",
  "    /// The position belongs to whichever run is still waiting, so a finished\n    /// card is never labelled with a place in a queue it has already left.\n    pub fn run_snapshots(&self, position: Option<usize>) -> Vec<serde_json::Value> {\n        let runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());\n        runs.iter()\n            .map(|run| {\n                let position = (!run.status.finished()).then_some(position).flatten();\n                self.snapshot_of(run, position)\n            })\n            .collect()\n    }\n\n    fn snapshot_of(&self, run: &RunState, position: Option<usize>) -> serde_json::Value {\n        serde_json::json!({\n            \"id\": run.id,\n            \"store\": self.name,\n            \"paths\": run.paths.iter()",
  "### Changed\n\n- **Client stanzas lost their paths.** Every README stanza is now `semlith mcp`\n  with no arguments, because the server opens every registered store plus a\n  `.semlith` beside the working directory. Index a second repository and the\n  agent that is already configured can search it, with no file to edit.\n  `--store` still works and still wins when given.\n- `semlith mcp` with no flags opens every registered store rather than\n  `./.semlith` alone.\n- The out-of-scope note in `AGENTS.md` and `docs/architecture.md` now states the\n  loopback rule instead of forbidding a server, per\n  [#41](https://github.com/semlith/semlith/issues/41). A CLI command or MCP tool\n  is not done until its portal view exists, and `tests/portal.rs` is the gate.",
  "        &self,\n        only: Option<&[String]>,\n        filter: &Filter,\n        limit: usize,\n    ) -> Result<(Vec<String>, usize)> {\n        let chosen = self.chosen(only)?;\n        let label = self.members.len() > 1;\n        let mut out = Vec::new();\n        let mut total = 0;\n        for i in chosen {\n            for path in self.members[i].store.matching_paths(filter)? {\n                total += 1;\n                if out.len() < limit {\n                    out.push(if label {\n                        format!(\"[{}] {path}\", self.members[i].label)\n                    } else {\n                        path\n                    });\n                }\n            }\n        }\n        let left_out = total - out.len();\n        Ok((out, left_out))\n    }\n",
  "/// downloads would be the one part of it that goes stale. A store that is not\n/// in the registry \u2014 a `./.semlith` beside a corpus, or one named with\n/// `--store` \u2014 has no entry to add to, which is not an error: the file is\n/// indexed either way.\nfn register_root(store_dir: &Path) -> Result<()> {\n    let mut registry = home::Registry::load()?;\n    let Some(name) = registry.name_of(store_dir).map(str::to_string) else {\n        return Ok(());\n    };\n    let root = downloads_dir(store_dir);\n    let Some(entry) = registry.stores.get_mut(&name) else {\n        return Ok(());\n    };\n    if entry.roots.contains(&root) {\n        return Ok(());\n    }\n    entry.roots.push(root);\n    registry.save()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]",
  "#[derive(Debug, Clone, serde::Serialize)]\npub struct Proof {\n    /// The command a registration names \u2014 the thing that has to launch.\n    pub command: String,\n    /// Whether that command, run with the environment a service manager gives\n    /// a job rather than the one a login shell builds, answered `initialize`.\n    pub launched: bool,\n    /// How many tools it then listed. `None` when it never launched.\n    pub tools: Option<usize>,\n    /// What the client itself says, for the client that can be asked.\n    pub client: Option<ClientVerdict>,\n    /// The first step that failed, and the command that shows it. `None` when\n    /// every step passed.\n    pub failed: Option<String>,\n}\n\n/// The command Claude Code's own configuration names for semlith.\n///",
  "/// whole call expression, some an entire `impl` block. Descending to the name\n/// makes all of them agree, and a capture with no identifier in it at all is\n/// dropped rather than stored as a wall of source text.\nfn reference_name(node: tree_sitter::Node, text: &str) -> Option<String> {\n    if let Some(function) = node.child_by_field_name(\"function\") {\n        return reference_name(function, text);\n    }\n    if let Some(property) = node.child_by_field_name(\"property\") {\n        return reference_name(property, text);\n    }\n    let kind = node.kind();\n    if kind.ends_with(\"identifier\") || kind == \"type_identifier\" || kind == \"word\" {\n        return Some(text[node.byte_range()].to_string());\n    }\n    if let Some(name) = node.child_by_field_name(\"name\") {",
  "            ; `constant` rather than `identifier` \u2014 which is why this needs no\n            ; predicate and no naming convention of our own.\n            (assignment left: (constant) @name) @definition.constant\n        \"#\n        }\n        \"elixir\" => {\n            r#\"\n            ; `@max_holders 4`. A module attribute is the nearest thing\n            ; Elixir has to a constant, and the grammar has no node for one:\n            ; it is `@` applied to a call with no parentheses. The attributes\n            ; the language reserves for documentation and typespecs go\n            ; through the same syntax and are not constants.\n            ((unary_operator\n               \"@\"\n               operand: (call target: (identifier) @name (arguments))) @definition.constant",
  "    /// by name got \"no store called new-project is open\" until the daemon was\n    /// restarted.\n    pub fn store(self: &Arc<Self>, name: &str) -> Option<Arc<Store>> {\n        if let Some(open) = self.stores().into_iter().find(|s| s.name == name) {\n            return Some(open);\n        }\n        self.reconcile();\n        self.stores().into_iter().find(|s| s.name == name)\n    }\n\n    /// Open every registered store this daemon does not have open, and report\n    /// the ones it could not.\n    ///\n    /// Called on a by-name miss and on every `/api/stores` read, which is the\n    /// cheap place to notice: both are already the slow path, and neither runs\n    /// in the indexing loop.\n    ///\n    /// A directory another process holds the lock on is left alone and",
  "                        \"semlith: login service removed \u2014 a daemon already running is untouched\"\n                    } else {\n                        \"semlith: no login service was installed\"\n                    }\n                );\n                return Ok(());\n            }\n            if service {\n                let installed = semlith::service::install(None, port)?;\n                print_service(&installed);\n                return Ok(());\n            }\n            arm_airgap(airgap);\n            let dirs = semlith::daemon::stores_to_open(&cli.store, &paths, &cwd)?;\n            if dirs.is_empty() {\n                // Not an error: the portal's welcome screen exists for exactly\n                // this, and telling someone to go index something first is what",
  "        }\n        \"Read\" => match &event.tool_input.file_path {\n            Some(p) => (PathBuf::from(p), true),\n            None => return (Decision::Quiet, None),\n        },\n        // A grep with no path is the repository-wide one this is about; a grep\n        // scoped to a directory is still a scan of every file under it.\n        \"Grep\" => {\n            let at = event.tool_input.path.clone().unwrap_or_else(|| \".\".into());\n            (PathBuf::from(at), false)\n        }\n        _ => return (Decision::Quiet, None),\n    };\n\n    let Some((_store, absolute)) = covers(&target) else {\n        return (Decision::Quiet, None);\n    };\n\n    let said = line(&display(&absolute), whole_file);\n    // Strict refuses once per session and then gets out of the way. A hook that",
  "        // Look deeper than `k` in each half. Fusion can only rank what it is\n        // given, and a chunk that is second on one side and absent from the\n        // other still deserves to be considered.\n        let depth = (k * RANK_DEPTH).max(k);\n\n        // Which half of the fusion this query's own text says to trust.\n        let shape = shape_of(query);\n\n        let allowlist = self.allowlist(filter)?;\n        if matches!(allowlist, Allowlist::Empty) {\n            return Ok(Vec::new());\n        }\n\n        // The chunks that *define* the thing the query named, when the query\n        // named a thing.\n        //\n        // An agent that already knows a term is the one case where retrieval\n        // has no excuse, and it was the largest measured miss class on the",
  "}\n\n/// A Word document: its paragraphs, in order, one per line.\nfn docx(bytes: &[u8]) -> Option<String> {\n    let mut zip = archive(bytes)?;\n    let mut budget = MAX_ARCHIVE_TEXT;\n    let xml = entry(&mut zip, \"word/document.xml\", &mut budget)?;\n    Some(docx_body(&xml))\n}\n\n/// `w:p` is a paragraph, and a table is `w:tr` rows of `w:tc` cells, each cell\n/// holding paragraphs of its own. Kept as rows, a table reads as a table;\n/// flattened, every cell is a line and the row it belonged to is gone.\nfn docx_body(xml: &str) -> String {\n    let mut lines = Lines::default();\n    scan(xml, |event| match event {\n        Event::Text(text) => lines.text(text),\n        Event::Close(\"w:p\" | \"w:br\") => lines.end_line(),\n        Event::Open { name: \"w:tr\", .. } => lines.start_row(),",
  "/// wanted and could not have in 0.8.0: the call either happens or reports why,\n/// and never fails because somebody else holds the lock.\npub struct Writer(pub Arc<State>);\n\nimpl crate::mcp::Writer for Writer {\n    fn index(&self, store: Option<&str>, paths: &[PathBuf]) -> Result<String, String> {\n        let store = self.0.writable(store).map_err(|e| e.to_string())?.clone();\n        // The receiver, not the run id: an agent's `semlith_index` blocks until\n        // the writer has run it and answers with the summary, which is the\n        // guarantee it has always had. The run is in the store's snapshot too,\n        // so the portal shows an agent's index beside the page's own.\n        let (_run, progress) = self\n            .0\n            .index(&store, paths.to_vec())",
  "name = \"darling_macro\"\nversion = \"0.20.11\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead\"\ndependencies = [\n \"darling_core\",\n \"quote\",\n \"syn 2.0.119\",\n]\n\n[[package]]\nname = \"dary_heap\"\nversion = \"0.3.9\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe\"\ndependencies = [\n \"serde\",\n]\n\n[[package]]\nname = \"deranged\"\nversion = \"0.5.8\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c\"\n\n[[package]]\nname = \"derive_builder\"\nversion = \"0.20.2\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"",
  "**Reports schedules itself.** A schedule is a report the daemon writes on a\ncadence, to a directory you name: report type, window, scope, format, toggles,\ncadence, destination. It lives in `~/.semlith/schedules.json`, its own file beside\nthe registry rather than a section of it, so a schedules file somebody's editor\ntruncated costs them their schedules and not every store on the machine. The\ncadence is stored as an interval in seconds, so the three chips on the page are\nshortcuts over a number rather than the whole vocabulary. A daemon restart loses\nnothing. Two schedules never generate at once, a schedule due while its store is\nstill indexing waits rather than reporting on a half-written index, and a\ndestination that has gone away \u2014 deleted, renamed, on a volume that is not",
  "        match states\n            .iter()\n            .find(|(at, _)| at.parent() == Some(wanted.as_path()))\n        {\n            Some((_, State::Present)) => {}\n            Some((_, state)) => return *state,\n            None => return State::Absent,\n        }\n    }\n    State::Present\n}\n\n/// Whether the steering hook is in this client's settings.\n///\n/// Read for both strictnesses, because a machine set up with `--strict` has a\n/// different command in the file and neither is out of date.\nfn hook_state_for(client: &crate::clients::Client) -> crate::agentfiles::State {\n    use crate::agentfiles::State;\n    if client.hook_stanza().is_none() {\n        return State::Paste;\n    }\n    let found = |strict: bool| {\n        crate::agentfiles::hook_state(strict)\n            .into_iter()",
  "                    println!(\"{label}\");\n                }\n                for path in semlith::store::all_paths(store.db())? {\n                    println!(\"{}\", display(std::path::Path::new(&path)));\n                }\n                if many {\n                    println!();\n                }\n            }\n        }\n\n        Command::Add { url, name, airgap } => {\n            arm_airgap(airgap);\n\n            // The current directory is the anchor, the way it is for `index`\n            // with no path: \"add this to the store I am working in\".\n            let choice = home::resolve(&cli.store, &cwd, name.as_deref())?;\n            let choice = resolve_for_add(choice, &cli.store, name.as_deref())?;\n            if let Some(hint) = choice.hint() {\n                eprintln!(\"{hint}\");",
  "        match self {\n            Self::Indexing => \"indexing\",\n            Self::Writing => \"writing\",\n            Self::Unchanged => \"unchanged\",\n            Self::Skipped => \"skipped\",\n            Self::Removed => \"removed\",\n            Self::Refused => \"refused\",\n            Self::Failed => \"failed\",\n        }\n    }\n}\n\n/// Why a file was skipped, as a closed set.\n///\n/// Closed on purpose. \"Skipped\" with no reason is the line that sent this\n/// release's Windows logs in: a run that says nothing about two thousand files\n/// is indistinguishable from a run that lost them. Every branch that produces\n/// [`FileOutcome::Skipped`] names one of these, and `tests/index_failure.rs`\n/// fails if a `file` event ever carries `skipped` without a `why`.\n#[derive(Debug, Clone, PartialEq, Eq)]",
  "            model,\n            dim,\n            embedder: None,\n            tokenizer: None,\n            clip: image::Clip::default(),\n            generation,\n            quiet: false,\n            boundary: Boundary::default(),\n        })\n    }\n\n    /// Open a store that must already be one.\n    ///\n    /// [`Semlith::open`] creates what it is given, which is what `index` wants\n    /// and the opposite of what every read command wants: a mistyped store\n    /// directory becomes an empty store that answers every question with\n    /// nothing, and when several stores are searched at once the others hide it\n    /// completely.\n    pub fn open_existing(dir: impl AsRef<Path>) -> Result<Self> {\n        let dir = dir.as_ref();\n        if !dir.join(\"store.db\").exists() {\n            bail!(",
  "/// Strip the quoting a literal import path arrives wrapped in.\nfn trim_literal(raw: &str) -> &str {\n    raw.trim()\n        .trim_matches(|c| c == '\"' || c == '\\'' || c == '<' || c == '>')\n        .trim()\n}\n\n// --------------------------------------------------------------- traversal\n\n/// How many symbols a single traversal will visit before it stops and says so.\n///\n/// Reverse reachability over a monorepo is unbounded in principle \u2014 a utility\n/// everything calls reaches everything \u2014 and the answer stops being useful long\n/// before it stops growing. The budget is what keeps peak memory flat as the\n/// corpus grows, which is the property `tests/measure.rs` asserts, and a\n/// truncated answer says it was truncated rather than pretending to be whole.\npub const MAX_NODES: usize = 2000;",
  "        // Named as an unset rather than as a command with a path, because the\n        // variable is in the environment the daemon was given and the shell\n        // that has to unset it is the one the user will start the daemon from\n        // next.\n        manual: allow_private.then(|| {\n            format!(\n                \"unset {} and restart semlith start\",\n                crate::add::ALLOW_PRIVATE_ENV\n            )\n        }),\n        repair: None,\n        applicable: true,\n    });\n\n    let cache = crate::model_cache_dir().unwrap_or_default();\n    let cache_ok = crate::embed::check_cache_dir(&cache);\n    out.push(Finding {\n        id: \"model cache\",\n        ok: cache_ok.is_ok(),\n        check: match &cache_ok {\n            Ok(()) => format!(\"{} is yours alone\", cache.display()),"
 ]
}