basemind 0.22.1

Full AI context layer over MCP — tree-sitter code-map, document RAG (PDF/Office/HTML/email + OCR + reranker), shared agent memory, on-demand web crawl, git history + blame + per-symbol diff. 300+ languages, 10+ coding-agent harnesses, content-addressed Fjall + LanceDB.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
//! MCP server exposing the basemind code map + git context to AI agents.
//!
//! The server opens the store writably and is the canonical Fjall owner: it holds the exclusive
//! lock so the in-process `rescan` tool (and the background watcher) can refresh the index. While
//! a server is running, standalone `basemind scan` / `basemind watch` against the same repo fail
//! fast with a lock error rather than racing it. Tools return JSON so the agent can navigate by
//! file path + line numbers without opening source files.
//!
//! Transport: stdio (the canonical MCP transport). Spawn via `basemind serve`.

mod background;
mod budget;
mod completions;
pub(crate) mod cursor;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod daemon_forward;
mod helpers;
mod helpers_admin;
mod helpers_archmap;
mod helpers_calls;
mod helpers_calls_scan;
#[cfg(feature = "code-search")]
mod helpers_code;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod helpers_comms;
mod helpers_compress;
#[cfg(feature = "documents")]
mod helpers_documents;
mod helpers_files;
mod helpers_git;
#[cfg(feature = "memory")]
mod helpers_governance;
mod helpers_graph;
mod helpers_grep;
mod helpers_impls;
mod helpers_intel;
#[cfg(feature = "memory")]
mod helpers_proposals;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod helpers_registry;
#[cfg(all(feature = "shells", any(unix, windows)))]
mod helpers_shells;
mod helpers_telemetry;
#[cfg(feature = "crawl")]
mod helpers_web;
mod identity;
mod kneedle;
mod lean;
mod lenient;
mod map_fingerprint;
#[cfg(any(feature = "memory", feature = "documents", feature = "code-search"))]
mod memory;
#[cfg(feature = "memory")]
pub(crate) mod memory_ops;
mod notifications;
mod prompts;
#[cfg(feature = "memory")]
pub(crate) mod proposals_ops;
mod savings;
mod state;
mod telemetry;
mod tokens;
mod tools;
mod tools_admin;
mod tools_archmap;
mod tools_code;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod tools_comms;
mod tools_compress;
mod tools_git;
mod tools_governance;
mod tools_memory;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod tools_registry;
#[cfg(all(feature = "shells", any(unix, windows)))]
mod tools_shells;
#[cfg(feature = "crawl")]
mod tools_web;
mod toon;
mod types;
mod types_admin;
mod types_archmap;
mod types_code;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod types_comms;
mod types_compress;
mod types_documents;
mod types_git;
pub(crate) mod types_governance;
mod types_graph;
mod types_impls;
pub(crate) mod types_memory;
#[cfg(all(feature = "comms", any(unix, windows)))]
mod types_registry;
#[cfg(all(feature = "shells", any(unix, windows)))]
mod types_shells;
#[cfg(feature = "crawl")]
mod types_web;

use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use arc_swap::ArcSwap;
use lru::LruCache;
use rmcp::ServerHandler;
use rmcp::handler::server::router::prompt::PromptRouter;
use rmcp::handler::server::tool::ToolRouter;
use rmcp::model::{
    CompleteRequestParams, CompleteResult, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
    PaginatedRequestParams, ServerCapabilities, ServerInfo,
};
use rmcp::tool_handler;
use tokio::sync::RwLock;

use crate::extract::FileMapL1;
use crate::lang::LangId;
use crate::store::Store;

pub(crate) use state::{Lifecycle, MapCache, ServerState};

/// Public re-export of every tool `*Params` type plus the `Parameters` wrapper, so the
/// in-process CLI (`src/cli/`) can build tool arguments and call the `#[tool]` methods
/// directly. This is the parity-by-construction surface: the CLI runs the identical tool
/// code an MCP client would dispatch.
pub mod params {
    pub use rmcp::handler::server::wrapper::Parameters;

    pub(crate) use super::lenient::Lenient;

    pub use super::types::{
        BlameFileParams, BlameSymbolParams, CommitsTouchingParams, DependentsParams, DiffFileParams, DiffOutlineParams,
        FindCallersParams, FindCommitsByPathParams, FindFilesParams, FindReferencesParams, GotoDefinitionParams,
        HotFilesParams, ListFilesParams, OutlineParams, RecentChangesParams, RepoInfoParams, RescanParams,
        SearchDocumentsParams, SearchGitHistoryParams, SearchSymbolsParams, StatusParams, SymbolHistoryParams,
        TelemetrySummaryParams, WorkingTreeStatusParams, WorkspaceGrepParams,
    };
    #[cfg(feature = "crawl")]
    pub use super::types::{WebCrawlParams, WebMapParams, WebScrapeParams};
    pub use super::types_admin::{CacheClearParams, CacheGcParams, CacheStatsParams};
    pub use super::types_archmap::ArchitectureMapParams;
    pub use super::types_code::{GetChunkParams, SearchCodeParams};
    pub use super::types_compress::ExpandParams;
    pub use super::types_governance::{
        MemoryAuditParams, ProposalAcceptParams, ProposalRejectParams, ProposalsListParams, ProposalsMineParams,
    };
    pub use super::types_graph::CallGraphParams;
    pub use super::types_impls::FindImplementationsParams;
    pub use super::types_memory::{
        MemoryDeleteParams, MemoryGetParams, MemoryListParams, MemoryPutParams, MemorySearchParams, Visibility,
    };
    #[cfg(all(feature = "shells", any(unix, windows)))]
    pub use super::types_shells::{
        ShellBroadcastParams, ShellCaptureParams, ShellEnv, ShellKillParams, ShellListParams, ShellSendParams,
        ShellSpawnParams,
    };
}

pub use params::Parameters;

/// In-memory cache for `symbol_history`-style workflows: given a blob's git OID and the
/// language we'd extract with, hold onto the parsed `FileMapL1` and the source bytes so
/// repeated visits to the same blob (across commits, modes, or tool calls) skip the
/// tree-sitter parse entirely. Memory-only — blob OIDs are content-addressed and immutable,
/// so cache invalidation is implicit (a new blob = a new key).
///
/// Cap chosen to bound steady-state memory at a few MB for typical repositories: 512
/// entries × ~few KiB per `FileMapL1` + Arc'd source = on the order of 1–10 MiB.
pub(crate) const OUTLINE_CACHE_CAP: usize = 512;

pub(crate) struct OutlineEntry {
    pub map: Arc<FileMapL1>,
    pub source: Arc<Vec<u8>>,
}

pub(crate) type OutlineCache = Mutex<LruCache<(gix::ObjectId, LangId), Arc<OutlineEntry>>>;

/// Shared MCP server state. `ToolRouter<Self>` is Clone (Arc inside), so we hold it directly
/// on the struct as the `#[tool_handler]` macro expects.
#[derive(Clone)]
pub struct BasemindServer {
    pub(crate) state: Arc<ServerState>,
    #[allow(dead_code)]
    tool_router: ToolRouter<Self>,
    /// Reusable prompt templates (`prompts/list` + `prompts/get`). Built by the
    /// `#[prompt_router]` macro in [`prompts`]; `list_prompts` / `get_prompt` delegate here.
    prompt_router: PromptRouter<Self>,
}

/// Construction-time switches for [`BasemindServer`].
///
/// `serve` wants every background facility running; a one-shot CLI query wants
/// none of them (no auto-scan, no view watcher, no background GC) so the process
/// exits the instant the single tool call returns.
///
/// NOTE: this is intentionally a struct of named bools rather than a bare flag —
/// a future workstream (the live FS watcher / `--no-watch`) will extend it with
/// finer-grained switches (e.g. `auto_scan` vs `watch` decoupled). Keep new knobs
/// additive and defaulted so callers that only care about `background` stay terse.
#[derive(Debug, Clone, Copy)]
pub struct ServerOptions {
    /// When true, spawn the empty-index auto-scan, the view watcher thread, and
    /// the background blob GC. When false, the server is a pure one-shot query
    /// handle: it preloads the in-RAM map cache and nothing else.
    pub background: bool,
    /// When true (and `background` is on, and the served view is the working
    /// view), spawn a live filesystem watcher that funnels changed paths into
    /// `scan_and_refresh` so the in-RAM map stays current as the agent edits.
    /// When false, fall back to the passive view watcher (which only reacts to
    /// external scans writing `index.msgpack`). Disabled for one-shot queries.
    ///
    /// `--no-watch` on `basemind serve` flips this off — useful for very large
    /// repos (e.g. the ~81k-file TypeScript tree) or CI, where the continuous
    /// incremental re-scan is not worth the cost.
    pub watch: bool,
    /// When true, the store was opened read-only (it does NOT hold the write
    /// lock) because another `serve` owns it for this repo (issue #27). The
    /// server still answers every read tool from the shared index, but it must
    /// not write: the empty-index auto-scan and the active filesystem watcher
    /// are suppressed, and the `rescan` tool returns a clean error instead of
    /// scanning. The passive view watcher still runs, so the in-RAM map tracks
    /// the lock-holding writer's `index.msgpack` updates.
    pub read_only: bool,
    /// When true, this serve forwards every write (auto-scan, watcher rescan, `rescan` tool) to
    /// the machine daemon (the sole fjall writer) rather than writing locally, and rebuilds its
    /// in-RAM map from the daemon-written `index.msgpack`. Set only by the real `serve` binary on
    /// a `comms` build; always false for the in-process one-shot and non-comms builds.
    pub daemon_writer: bool,
    /// When true, defer [`MapCache::build`] to the first tool that actually reads the map, instead
    /// of running it at construction. Set only for the one-shot CLI — see
    /// [`ServerState::lazy_cache`].
    pub lazy_cache: bool,
}

impl Default for ServerOptions {
    fn default() -> Self {
        Self {
            background: true,
            watch: true,
            read_only: false,
            daemon_writer: false,
            lazy_cache: false,
        }
    }
}

impl BasemindServer {
    /// Construct a server with all background facilities running (the `serve` path).
    pub fn new(
        store: Store,
        root: PathBuf,
        config: Arc<crate::config::Config>,
        repo: Option<Arc<crate::git::Repo>>,
        git_cache: Arc<crate::git_cache::GitCache>,
    ) -> Self {
        Self::new_with_options(store, root, config, repo, git_cache, ServerOptions::default())
    }

    /// Construct a one-shot server with every background facility disabled.
    ///
    /// Used by the `basemind` CLI to run a single MCP tool in-process and exit — no auto-scan, no
    /// view watcher, no background GC. The in-RAM map cache is built LAZILY: a CLI process answers
    /// one tool call, and most tools never read the map, so preloading it charged every invocation
    /// the whole-corpus blob-deserialization cost (seconds on a large monorepo) for nothing. The
    /// first tool that does read the map still builds it in full, so results are identical to what
    /// an MCP client would see.
    pub fn new_oneshot(
        store: Store,
        root: PathBuf,
        config: Arc<crate::config::Config>,
        repo: Option<Arc<crate::git::Repo>>,
        git_cache: Arc<crate::git_cache::GitCache>,
    ) -> Self {
        Self::new_with_options(
            store,
            root,
            config,
            repo,
            git_cache,
            ServerOptions {
                background: false,
                watch: false,
                read_only: false,
                daemon_writer: false,
                lazy_cache: true,
            },
        )
    }

    /// Shared constructor honoring [`ServerOptions`]. `new` / `new_oneshot` are
    /// the public entry points; this threads the `background` switch through the
    /// three spawn sites + the initial auto-scan.
    pub fn new_with_options(
        store: Store,
        root: PathBuf,
        config: Arc<crate::config::Config>,
        repo: Option<Arc<crate::git::Repo>>,
        git_cache: Arc<crate::git_cache::GitCache>,
        options: ServerOptions,
    ) -> Self {
        let scope = repo
            .as_ref()
            .map(|r| crate::git::scope_key(r))
            .unwrap_or_else(|| format!("path:{}", root.display()));
        let agent_id = identity::resolve_agent_id(&config, &store);
        let history_dir = crate::git_history::shared_history_basemind_dir(&root);
        let git_history = Self::open_git_history(&root, &history_dir, repo.is_some(), &agent_id, &options);
        let corpus_bytes: u64 = store.index.files.values().map(|e| e.size_bytes).sum();
        let view_is_working = store.view == crate::store::VIEW_WORKING;
        let fjall_index_empty = store
            .index_db
            .as_ref()
            .map(|db| db.symbols_index_is_empty())
            .unwrap_or(false);
        let needs_initial_scan = (options.daemon_writer || !options.read_only)
            && view_is_working
            && (store.index.files.is_empty() || fjall_index_empty);
        let defer_warm = options.background && !needs_initial_scan;
        let cache = if defer_warm || options.lazy_cache {
            Arc::new(MapCache::empty())
        } else {
            Arc::new(MapCache::build(&store))
        };
        tracing::info!(
            files = store.index.files.len(),
            corpus_bytes,
            git = repo.is_some(),
            scope = %scope,
            deferred_warm = defer_warm,
            lazy_cache = options.lazy_cache,
            "code map ready for MCP server (preloaded, warming in background, or lazy)"
        );
        let outline_cache: Arc<OutlineCache> = Arc::new(Mutex::new(LruCache::new(
            NonZeroUsize::new(OUTLINE_CACHE_CAP).expect("OUTLINE_CACHE_CAP > 0"),
        )));
        let telemetry_handle = Arc::new(telemetry::Telemetry::new(&store.basemind_dir));
        #[cfg(feature = "crawl")]
        let crawl_engine = match crate::web::build_engine(&config.crawl) {
            Ok(e) => Some(e),
            Err(error) => {
                tracing::warn!(?error, "crawl engine init failed; web_* tools will report errors");
                None
            }
        };
        let state = Arc::new(ServerState {
            store: RwLock::new(store),
            root,
            cache: ArcSwap::from(cache),
            repo,
            git_cache,
            git_history,
            outline_cache,
            config,
            telemetry: telemetry_handle,
            corpus_bytes: std::sync::atomic::AtomicU64::new(corpus_bytes),
            cache_generation: std::sync::atomic::AtomicU32::new(1),
            scope,
            agent_id,
            #[cfg(any(feature = "memory", feature = "documents", feature = "code-search"))]
            lance: tokio::sync::OnceCell::new(),
            #[cfg(feature = "intelligence")]
            embedder: tokio::sync::OnceCell::new(),
            #[cfg(feature = "crawl")]
            crawl_engine,
            #[cfg(all(feature = "comms", any(unix, windows)))]
            comms_clients: tokio::sync::Mutex::new(ahash::AHashMap::new()),
            #[cfg(all(feature = "shells", any(unix, windows)))]
            shell_runtime: crate::shells::ShellRuntime::new(),
            log_level: std::sync::atomic::AtomicU8::new(notifications::DEFAULT_LOG_ORDINAL),
            initial_scan_active: std::sync::atomic::AtomicBool::new(false),
            initial_scan_ms: std::sync::atomic::AtomicU64::new(0),
            cache_warming: std::sync::atomic::AtomicBool::new(defer_warm),
            cache_warm_ms: std::sync::atomic::AtomicU64::new(0),
            cache_ready: tokio::sync::Notify::new(),
            rescan_active: std::sync::atomic::AtomicBool::new(false),
            lazy_cache: options.lazy_cache,
            lazy_cache_built: tokio::sync::OnceCell::new(),
            read_only: options.read_only,
            #[cfg(all(feature = "comms", any(unix, windows)))]
            daemon_writer: options.daemon_writer,
        });
        if options.background {
            let view_is_working = {
                match state.store.try_read() {
                    Ok(g) => g.view == crate::store::VIEW_WORKING,
                    Err(_) => false,
                }
            };
            if options.watch && (options.daemon_writer || !options.read_only) && view_is_working {
                background::spawn_serve_watcher(Arc::clone(&state));
            } else {
                background::spawn_view_watcher(Arc::clone(&state));
            }
            Self::spawn_git_history_sync(&state, &history_dir);
            if needs_initial_scan {
                background::spawn_initial_scan(Arc::clone(&state));
            } else {
                if defer_warm {
                    background::spawn_cache_warm(Arc::clone(&state));
                }
                let gc_state = Arc::clone(&state);
                tokio::spawn(async move {
                    background::run_background_gc(gc_state).await;
                });
            }
        }
        #[allow(unused_mut)]
        let mut router = Self::tool_router_core()
            + Self::tool_router_archmap()
            + Self::tool_router_git()
            + Self::tool_router_memory()
            + Self::tool_router_code()
            + Self::tool_router_governance()
            + Self::tool_router_admin()
            + Self::tool_router_compress();
        #[cfg(feature = "crawl")]
        {
            router += Self::tool_router_web();
        }
        #[cfg(all(feature = "comms", any(unix, windows)))]
        {
            router += Self::tool_router_comms();
            router += Self::tool_router_registry();
        }
        #[cfg(all(feature = "shells", any(unix, windows)))]
        {
            router += Self::tool_router_shells();
        }
        Self {
            state,
            tool_router: router,
            prompt_router: Self::prompt_router(),
        }
    }

    /// The git-history handle this session gets, if any. Fjall's directory lock is exclusive — even a
    /// read-only open takes it — so `git-history.fjall/` has exactly one holder machine-wide, and the
    /// only question here is whether that holder is us or the daemon:
    ///
    /// * **a daemon is up**: the DAEMON holds the database, so every session — a `daemon_writer`
    ///   serve, and equally the one-shot CLI, which runs these same tool bodies in-process — takes a
    ///   forwarding handle. It must not try to open the index: it cannot win the lock, so it would
    ///   burn the retry ladder on every invocation and then silently live-walk, on the exact machine
    ///   where the index is built and fresh. Serve knows a daemon is up by construction (it brings
    ///   one up); the CLI has to ask, which is one `stat` when there is no daemon and one ping when
    ///   there is (see [`crate::git_history::remote::daemon_is_up`]).
    /// * **no daemon** (a standalone process, or a non-`comms` build): this process holds the
    ///   database and builds it in-process, as before. Nobody else can, and history tools would
    ///   otherwise permanently live-walk.
    /// * **read-only fallback** (another process owns the write lock, no daemon): no handle — history
    ///   tools live-walk, visibly (`partial: true`). Unchanged.
    fn open_git_history(
        root: &std::path::Path,
        history_dir: &std::path::Path,
        has_repo: bool,
        agent_id: &str,
        options: &ServerOptions,
    ) -> Option<Arc<crate::git_history::GitHistoryIndex>> {
        if !has_repo || !crate::git_history::index_enabled() {
            return None;
        }
        #[cfg(all(feature = "comms", any(unix, windows)))]
        if options.daemon_writer || crate::git_history::remote::daemon_is_up() {
            let agent = crate::comms::ids::AgentId::parse(agent_id.to_string())
                .inspect_err(|error| tracing::warn!(%error, "git-history: bad agent id; tools will live-walk"))
                .ok()?;
            return Some(Arc::new(crate::git_history::GitHistoryIndex::remote(
                root.to_path_buf(),
                agent,
            )));
        }
        let _ = (root, agent_id);
        if options.read_only {
            return None;
        }
        match crate::git_history::GitHistoryIndex::open(history_dir) {
            Ok(index) => Some(Arc::new(index)),
            Err(error) => {
                tracing::warn!(?error, "git-history index unavailable; tools will live-walk");
                None
            }
        }
    }

    /// Kick the git-history index up to date, off the MCP thread. A session whose handle is
    /// daemon-backed ASKS the daemon to do it (which serializes the build per repo, so N sessions
    /// cause one walk); a session that holds the database does it in-process, as it always has.
    ///
    /// Keyed off the handle the routing above actually produced, not off `daemon_writer`: a
    /// non-`daemon_writer` session that found a live daemon also holds a forwarding handle, and
    /// asking the builder to write through it is a guaranteed
    /// [`NotLocal`](crate::git_history::GitHistoryError::NotLocal).
    ///
    /// Only ever reached with `background: true` (i.e. `serve`). The one-shot CLI requests no sync at
    /// all: it exits in milliseconds, and a first build on a deep repo is a minutes-long walk.
    fn spawn_git_history_sync(state: &Arc<ServerState>, history_dir: &std::path::Path) {
        let Some(index) = state.git_history.as_deref() else {
            return;
        };
        let _ = index;
        #[cfg(all(feature = "comms", any(unix, windows)))]
        if index.is_daemon_backed() {
            let root = state.root.clone();
            let agent_id = state.agent_id.clone();
            tokio::spawn(async move {
                let Ok(agent) = crate::comms::ids::AgentId::parse(agent_id) else {
                    return;
                };
                match crate::git_history::remote::request_sync(root, agent).await {
                    Some(outcome) => tracing::info!(?outcome, "git-history index synced by the daemon"),
                    None => tracing::warn!("git-history index sync unavailable; history tools live-walk"),
                }
            });
            return;
        }
        if let (Some(git_history), Some(repo)) = (state.git_history.clone(), state.repo.clone()) {
            let history_dir = history_dir.to_path_buf();
            tokio::task::spawn_blocking(move || {
                match crate::git_history::builder::sync(&git_history, &repo, &history_dir) {
                    Ok(outcome) => tracing::info!(?outcome, "git-history index sync complete"),
                    Err(error) => tracing::warn!(%error, "git-history index sync failed; tools live-walk"),
                }
            });
        }
    }

    /// Names of every tool this server advertises via `tools/list` (the full router, ignoring the
    /// `BASEMIND_MCP_LEAN` wrapper mode). Exposed for the `tests/cli_parity.rs` guard, which asserts
    /// each advertised tool has a CLI counterpart. The set follows the compiled feature flags.
    pub fn tool_names(&self) -> Vec<String> {
        self.tool_router
            .list_all()
            .into_iter()
            .map(|tool| tool.name.to_string())
            .collect()
    }
}

#[tool_handler(router = self.tool_router.clone())]
impl ServerHandler for BasemindServer {
    /// `tools/list`. Default (the overwhelming case): delegate to the static router exactly as
    /// the `#[tool_handler]` macro would, advertising every real tool. When `BASEMIND_MCP_LEAN`
    /// is set, advertise only the three lean wrapper tools instead. The macro detects this
    /// hand-written method and skips generating its own, so the default branch must remain a
    /// faithful copy of the generated body to keep the unset-flag surface byte-for-byte identical.
    async fn list_tools(
        &self,
        _request: Option<rmcp::model::PaginatedRequestParams>,
        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<rmcp::model::ListToolsResult, rmcp::ErrorData> {
        if lean::lean_mode_enabled() {
            return Ok(lean::lean_list_tools());
        }
        Ok(rmcp::model::ListToolsResult {
            tools: self.tool_router.list_all(),
            meta: None,
            next_cursor: None,
        })
    }

    /// `tools/call`. Default: dispatch through the static router exactly as the macro would.
    /// In lean mode, route the three wrapper tools through `lean::lean_call_tool`, which itself
    /// delegates `invoke_tool` back to this same router — no tool logic is duplicated.
    async fn call_tool(
        &self,
        request: rmcp::model::CallToolRequestParams,
        context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        if lean::lean_mode_enabled() {
            return lean::lean_call_tool(self, &self.tool_router, request, context).await;
        }
        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
        self.tool_router.call(tcc).await
    }

    /// `get_tool` introspection. Default mirrors the macro (router lookup); in lean mode it
    /// reports the three wrapper tools so task-support validation matches the advertised surface.
    fn get_tool(&self, name: &str) -> Option<rmcp::model::Tool> {
        if lean::lean_mode_enabled() {
            return lean::lean_get_tool(name);
        }
        self.tool_router.get(name).cloned()
    }

    /// `prompts/list`: advertise the reusable prompt templates. Delegates to the
    /// `#[prompt_router]`-built router (basemind can't use `#[prompt_handler]` — it would
    /// regenerate `get_info`).
    async fn list_prompts(
        &self,
        _request: Option<PaginatedRequestParams>,
        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<ListPromptsResult, rmcp::ErrorData> {
        Ok(ListPromptsResult {
            prompts: self.prompt_router.list_all(),
            meta: None,
            next_cursor: None,
        })
    }

    /// `prompts/get`: render one prompt template with its arguments, via the prompt router.
    async fn get_prompt(
        &self,
        request: GetPromptRequestParams,
        context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<GetPromptResult, rmcp::ErrorData> {
        let prompt_context =
            rmcp::handler::server::prompt::PromptContext::new(self, request.name, request.arguments, context);
        self.prompt_router.get_prompt(prompt_context).await
    }

    /// `logging/setLevel`: record the minimum severity the client wants. Subsequent log
    /// notifications (e.g. from `rescan`) are gated on this threshold.
    #[allow(deprecated)]
    async fn set_level(
        &self,
        request: rmcp::model::SetLevelRequestParams,
        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<(), rmcp::ErrorData> {
        self.state.log_level.store(
            notifications::level_ordinal(request.level),
            std::sync::atomic::Ordering::Relaxed,
        );
        Ok(())
    }

    /// `completion/complete`: autocomplete a prompt argument from the indexed code map (symbol
    /// names for `trace-symbol`, file paths for `explain-file`). Pure in-RAM prefix scan.
    async fn complete(
        &self,
        request: CompleteRequestParams,
        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<CompleteResult, rmcp::ErrorData> {
        self.state.await_cache_ready().await;
        Ok(self.complete_argument(&request))
    }

    #[allow(deprecated)]
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(
            ServerCapabilities::builder()
                .enable_tools()
                .enable_prompts()
                .enable_completions()
                .enable_logging()
                .build(),
        )
        .with_instructions(
            "basemind is the indexed context layer for this repository, served over MCP: a \
             tree-sitter code map across 300+ languages (symbols, references, callers, call \
             graphs, implementations), git history + blame at symbol resolution, full-text + \
             semantic search, document RAG over 90+ file formats, and shared cross-session \
             memory. The index lives in a machine-global cache (the platform data directory — \
             `~/Library/Application Support/basemind/` on macOS, `~/.local/share/basemind/` on \
             Linux; override with `BASEMIND_DATA_HOME`) keyed by workspace — nothing is written \
             into the repo — and a background daemon is the sole writer, so any number of sessions on \
             this repo read and write concurrently. basemind first, shell/grep/git fallback: \
             prefer these tools over reading files, over grep, and over naked `git` — and use \
             them for document extraction, web crawling, and code parsing too. You may be one of \
             several agents in this repo: on start, check your inbox and the threads scoped to \
             where you're working, and post status as you go (see Agent comms below).\n\
             Context economy — these tools return paths, line numbers, and signatures, not \
             file bodies, so they cost a fraction of the tokens of reading source. Default to \
             them: `outline` a file before you open it (then read only the span you need); \
             `search_symbols` instead of grep for a definition; `find_references` / \
             `find_callers` instead of grepping call sites; `workspace_grep` instead of \
             shelling out to ripgrep; `rescan` after edits instead of reconnecting. Do not \
             re-read a file basemind already mapped. Same discipline beyond code: use the git \
             tools (`recent_changes` / `blame_*` / `diff_*` / `commits_touching`) instead of \
             shelling out to `git log`/`git blame`; `search_documents` and the documents \
             pipeline for extraction, RAG, keyword + entity (NER), and summary instead of \
             opening files; `web_scrape` / `web_crawl` / `web_map` for scraping, crawling, and \
             sitemaps.\n\
             Routing: \
             \"where is X defined?\" → `search_symbols`; \
             \"what calls X?\" → `find_references` (any name) or `find_callers` (specific def); \
             \"shape of this file?\" → `outline` (add `l2: true` for calls + docs); \
             \"what changed recently?\" → `recent_changes`, `commits_touching`, `symbol_history`; \
             \"who last touched this?\" → `blame_file` / `blame_symbol`; \
             \"where's the churn?\" → `hot_files`; \
             \"semantic search across PDFs/docs in the repo?\" → `search_documents`; \
             \"recall something the agent remembered earlier?\" → `memory_get` / `memory_list` / \
             `memory_search`; \
             \"remember this for later sessions?\" → `memory_put` (delete with `memory_delete`); \
             \"refresh the index after editing code?\" → `rescan` (or `rescan { paths: [...] }` \
             to limit to changed files); \
             \"any other agents working here / leave a note for the next session?\"\
             `inbox_read` / `thread_list` / `thread_post`.\n\
             \"find a file by name/path when I only remember a fragment?\" → `find_files` \
             (fuzzy, fzf/fd-style).\n\
             \"which repos/worktrees/branches does the daemon know?\" → `workspaces` / \
             `worktrees` / `branches`; claim a worktree so sessions don't collide with \
             `worktree_claim` (release with `worktree_release`).\n\
             \"got a truncated result? fetch the next page?\" → pass `next_cursor` from the prior \
             response back as `cursor`.\n\
             \"need regex over file contents?\" → `workspace_grep`.\n\
             Code-map tools: `outline`, `search_symbols`, `find_references`, `find_callers`, \
             `list_files`, `find_files`, `workspace_grep`, `dependents`, `status`, `repo_info`, \
             `symbol_history`. \
             Coordination tools: `workspaces`, `worktrees`, `branches`, `worktree_claim`, \
             `worktree_release` (advisory claims across the daemon's known worktrees). \
             Git tools (inside a repo): `working_tree_status`, `recent_changes`, `commits_touching`, \
             `find_commits_by_path`, `hot_files`, `diff_outline`, `diff_file`, `blame_file`, \
             `blame_symbol`. \
             Intelligence tools (require build with `--features documents,memory`): \
             `search_documents`, `memory_put`, `memory_get`, `memory_list`, `memory_search`, \
             `memory_delete`. \
             Web tools (require build with `--features crawl`): `web_scrape` (one URL), \
             `web_crawl` (follow links from a seed URL), `web_map` (sitemap-only discovery). \
             Crawled pages land in the same LanceDB documents table as on-disk docs, scoped \
             under `web:<host>` — find them later with `search_documents`. \
             Agent comms (require build with `--features comms`): coordinate with other agents \
             via THREADS — scoped conversations addressed by at least two of {subject, \
             path-glob, members}. Threads are discovered by scope (you're a member, your cwd \
             matches the thread's path-glob, or a subject filter) — never globally — and you \
             must explicitly `thread_join` to participate; there is no auto-join. On start, \
             `inbox_read` (front-matter only: subject / from / id — call `message_get` with an \
             id for a body) and `thread_list` to see threads in scope; skim `thread_history` on \
             the relevant one. `thread_start {subject, path_glob?, members?}` opens a thread \
             (you're the creator/admin; a human is also admin); `thread_post {thread, subject, \
             body, reply_to?}` when you begin, finish, or hit a decision, and reply \
             (`reply_to`) to messages about your work — do not stay silent when collaborating. \
             `inbox_ack` clears read messages. Idle threads auto-archive; `thread_archive` \
             closes one explicitly. Tools: `thread_start`, `thread_list`, `thread_join`, \
             `thread_leave`, `thread_members`, `thread_add_member`, `thread_remove_member`, \
             `thread_archive`, `thread_post`, `thread_history`, `message_get`, `inbox_read`, \
             `inbox_ack`, `agent_register`, `agent_list`. \
             All paths are repository-relative with forward-slash separators. \
             If a tool reports \"no indexed files\", run `basemind scan` in the repo first.",
        )
    }
}

#[cfg(test)]
#[path = "lazy_cache_tests.rs"]
mod lazy_cache_tests;

#[cfg(test)]
mod map_cache_tests {
    use super::*;
    use std::fs;
    use std::path::Path;

    fn sym_names(cache: &MapCache, rel: &str) -> Vec<String> {
        let key = crate::path::RelPath::from(rel);
        cache
            .by_path
            .get(&key)
            .map(|l1| l1.symbols.iter().map(|s| s.name.clone()).collect())
            .unwrap_or_default()
    }

    /// The lifecycle precedence is BuildingIndex > WarmingUp > Rescanning > Ready — a from-scratch
    /// scan outranks a preload, which outranks a watcher refresh. Guards the ordering a read tool
    /// relies on to label a possibly-incomplete result correctly.
    #[test]
    fn lifecycle_from_flags_applies_precedence() {
        assert_eq!(Lifecycle::from_flags(false, false, false), Lifecycle::Ready);
        assert_eq!(Lifecycle::from_flags(false, false, true), Lifecycle::Rescanning);
        assert_eq!(Lifecycle::from_flags(false, true, true), Lifecycle::WarmingUp);
        assert_eq!(Lifecycle::from_flags(true, true, true), Lifecycle::BuildingIndex);
        assert_eq!(Lifecycle::from_flags(true, false, false), Lifecycle::BuildingIndex);
    }

    /// A notice is emitted for every non-ready state (with the stable machine tag and the right retry
    /// hint) and suppressed when ready, so a healthy response carries no `notice` field.
    #[test]
    fn lifecycle_notice_maps_state_to_tag_and_retry() {
        assert!(types::LifecycleNotice::for_state(Lifecycle::Ready).is_none());
        let warming = types::LifecycleNotice::for_state(Lifecycle::WarmingUp).expect("warming notice");
        assert_eq!(warming.state, "warming_up");
        assert!(warming.retry, "warming asks the caller to retry for complete results");
        let building = types::LifecycleNotice::for_state(Lifecycle::BuildingIndex).expect("building notice");
        assert_eq!(building.state, "building_index");
        assert!(building.retry);
        let rescanning = types::LifecycleNotice::for_state(Lifecycle::Rescanning).expect("rescan notice");
        assert_eq!(rescanning.state, "rescanning");
        assert!(!rescanning.retry, "rescan results are usable, no retry required");
    }

    /// `with_delta` must re-read only the changed blobs, preserve untouched entries, drop removed
    /// ones, and keep `imports_index` consistent — the incremental refresh the serve watcher uses
    /// instead of a whole-corpus rebuild (issue #33).
    #[test]
    fn with_delta_patches_updated_and_removed_paths_only() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        fs::write(root.join("a.rs"), b"pub fn alpha() {}\n").unwrap();
        fs::write(root.join("b.rs"), b"pub fn beta() {}\n").unwrap();
        let cfg = crate::config::ConfigV1::with_defaults();

        let mut store = crate::store::Store::open(root, crate::store::VIEW_WORKING).unwrap();
        crate::scanner::scan(
            root,
            &mut store,
            &cfg,
            crate::scanner::ScanSource::WorkingTree,
            crate::scanner::EmbedMode::Inline,
        )
        .unwrap();

        let cache = MapCache::build(&store);
        assert_eq!(sym_names(&cache, "a.rs"), vec!["alpha".to_string()]);
        assert_eq!(sym_names(&cache, "b.rs"), vec!["beta".to_string()]);
        assert!(cache.calls.is_none() && cache.impls.is_none());

        fs::write(root.join("a.rs"), b"pub fn alpha2() {}\npub fn alpha3() {}\n").unwrap();
        let report = crate::scanner::scan_paths(
            root,
            &mut store,
            &cfg,
            &[root.join("a.rs")],
            crate::scanner::EmbedMode::Inline,
        )
        .unwrap();
        assert_eq!(report.stats.updated, 1);

        let updated = vec![crate::path::RelPath::from("a.rs")];
        let next = cache.with_delta(&store, &updated, &[]);
        assert_eq!(
            sym_names(&next, "a.rs"),
            vec!["alpha2".to_string(), "alpha3".to_string()],
            "updated path reflects fresh L1"
        );
        assert_eq!(
            sym_names(&next, "b.rs"),
            vec!["beta".to_string()],
            "untouched path preserved without re-reading its blob"
        );

        let removed = vec![crate::path::RelPath::from("b.rs")];
        let after = next.with_delta(&store, &[], &removed);
        assert!(
            !after.by_path.contains_key(&crate::path::RelPath::from("b.rs")),
            "removed path dropped from by_path"
        );
        assert!(
            after.by_path.contains_key(&crate::path::RelPath::from("a.rs")),
            "other path kept"
        );
        assert!(
            !after.imports_index.iter().any(|(p, _)| p == Path::new("b.rs")),
            "imports_index must not retain a removed path"
        );
    }
}