1use std::collections::HashMap;
22use std::fmt::Write as _;
23use std::sync::Arc;
24
25use crate::embed::Embedder;
26use crate::graph::{CodeGraph, Node as GNode};
27use crate::model::Symbol;
28use anyhow::Result;
29use rmcp::handler::server::wrapper::Parameters;
30use rmcp::model::{CallToolResult, ContentBlock};
31use rmcp::{schemars, tool, tool_router, ServiceExt};
32use serde::{Deserialize, Serialize};
33
34pub type Teams = Arc<std::sync::RwLock<crate::team::TeamSettings>>;
36
37type Cached = (std::time::Instant, Vec<crate::ledger::Record>);
39
40type Enrichment = HashMap<String, (String, Vec<String>)>;
42
43#[derive(Clone)]
44pub struct ComindlyServer {
45 graph: Arc<CodeGraph>,
46 enrichment: Arc<Enrichment>,
47 style_guides: Arc<Vec<(String, String)>>,
49 flows: Arc<HashMap<String, (String, Vec<String>)>>,
51 symbols: Arc<HashMap<String, Symbol>>,
53 embedder: Option<Arc<Embedder>>,
55 uri: Arc<String>,
56 tables: Arc<crate::index::Tables>,
58 records: Arc<tokio::sync::Mutex<Option<Cached>>>,
60 packages: Arc<Vec<crate::manifests::PackageRow>>,
62 conventions: Arc<Vec<crate::conventions::Convention>>,
64 edges: Arc<Vec<crate::model::Edge>>,
66 markdown: bool,
68 signer: Option<std::path::PathBuf>,
70 identity: crate::ledger::Identity,
72 agent_name: Option<String>,
74 pub teams: Teams,
76}
77
78impl ComindlyServer {
79 fn ledger_root(&self) -> String {
81 self.uri
82 .trim_end_matches('/')
83 .strip_suffix("/_graph")
84 .unwrap_or(&self.uri)
85 .to_string()
86 }
87
88 fn team_settings(&self) -> crate::team::TeamSettings {
89 self.teams.read().unwrap_or_else(|e| e.into_inner()).clone()
90 }
91
92 fn in_team(&self, team: Option<&str>, repo: &str) -> bool {
94 let sel = crate::team::parse_selection(team);
95 sel.is_empty() || self.team_settings().in_view(repo, &sel)
96 }
97
98 fn entries(
100 &self,
101 records: &[crate::ledger::Record],
102 symbols: &[Symbol],
103 ) -> Vec<crate::ledger::Entry> {
104 let mut e = crate::ledger::derive(records, symbols, &crate::config::local_verifier());
105 crate::ledger::weigh_teams(&mut e, records, &self.team_settings());
106 e
107 }
108
109 async fn records(&self) -> Vec<crate::ledger::Record> {
110 let mut cached = self.records.lock().await;
111 if let Some((at, r)) = cached.as_ref() {
112 if at.elapsed() < std::time::Duration::from_secs(60) {
113 return r.clone();
114 }
115 }
116 let r = match crate::ledger::Ledger::open(&self.ledger_root()) {
117 Ok(l) => l.all().await.unwrap_or_default(),
118 Err(_) => Vec::new(),
119 };
120 *cached = Some((std::time::Instant::now(), r.clone()));
121 r
122 }
123
124 async fn wrote(&self, rec: &crate::ledger::Record) {
126 if let Some((_, r)) = self.records.lock().await.as_mut() {
127 r.push(rec.clone());
128 }
129 }
130
131 fn may_write(ctx: &rmcp::service::RequestContext<rmcp::RoleServer>) -> anyhow::Result<()> {
135 let viewer = ctx
136 .extensions
137 .get::<axum::http::request::Parts>()
138 .and_then(|parts| parts.extensions.get::<crate::server::Who>())
139 .is_some_and(|w| !w.role.at_least(crate::server::Role::Member));
140 anyhow::ensure!(!viewer, "viewers can read, not write to the ledger");
141 Ok(())
142 }
143
144 fn agent(
145 &self,
146 ctx: &rmcp::service::RequestContext<rmcp::RoleServer>,
147 ) -> crate::ledger::Provenance {
148 let name = self.agent_name.clone().or_else(|| client_name(ctx));
149 let who = ctx
150 .extensions
151 .get::<axum::http::request::Parts>()
152 .and_then(|parts| parts.extensions.get::<crate::server::Who>());
153 let identity = match who {
154 Some(w) => crate::ledger::Identity {
155 actor: format!("actor-{}", &blake3::hash(w.email.as_bytes()).to_hex()[..10]),
156 name: Some(w.email.clone()),
157 },
158 None => self.identity.clone(),
159 };
160 let mut p = crate::ledger::Provenance::agent("mcp", name, &identity);
161 if let Some(w) = who {
162 p.teams = self.team_settings().teams_of_member(&w.email);
163 }
164 p
165 }
166
167 async fn known(&self, ids: &[String], limit: usize) -> Vec<String> {
169 let records = self.records().await;
170 if records.is_empty() {
171 return Vec::new();
172 }
173 let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
174 let entries = self.entries(&records, &symbols);
175 let mut about = crate::ledger::about(&entries, ids);
176 about.sort_by_key(|e| !e.outside.is_empty());
178 about
179 .into_iter()
180 .filter(|e| e.trust >= crate::ledger::Trust::T2)
181 .filter(|e| {
182 matches!(
183 e.status,
184 crate::ledger::Status::Active
185 | crate::ledger::Status::Moved
186 | crate::ledger::Status::Stale
187 )
188 })
189 .take(limit)
190 .map(crate::ledger::line)
191 .collect()
192 }
193
194 async fn write_record(
195 &self,
196 p: RememberParam,
197 by: crate::ledger::Provenance,
198 ) -> anyhow::Result<String> {
199 let kind = crate::ledger::Kind::parse(&p.kind)
200 .ok_or_else(|| anyhow::anyhow!("unknown kind `{}`", p.kind))?;
201 let commits = crate::index::read_repo_commits(&self.uri)
202 .await
203 .unwrap_or_default();
204 let mut anchors = Vec::new();
205 for focus in &p.on {
206 let idx = self
207 .graph
208 .lookup(focus)
209 .ok_or_else(|| anyhow::anyhow!("no symbol matches `{focus}` — use `find` first"))?;
210 let id = self.graph.node(idx).id;
211 let s = self
212 .symbols
213 .get(&id)
214 .ok_or_else(|| anyhow::anyhow!("symbol `{id}` not loaded"))?;
215 anchors.push(crate::ledger::Anchor::of(s).at(&commits));
216 }
217 let repos: Vec<&str> = anchors.iter().map(|a| a.repo.as_str()).collect();
218 let teams = self.team_settings();
219 teams.accepts(&by.teams, &repos)?;
220 let outside = teams.outside(&by.teams, &repos);
221 let commit = anchors.first().and_then(|a| a.commit.clone());
222 let mut rec = crate::ledger::Record::new(kind, &p.title, anchors, by);
223 rec.snippets = p
224 .snippets
225 .into_iter()
226 .map(|s| crate::ledger::Snippet {
227 path: s.path,
228 code: s.code,
229 commit: s.commit.or_else(|| commit.clone()),
230 })
231 .collect();
232 rec.body = p.body.unwrap_or_default();
233 rec.trigger = p.trigger;
234 rec.evidence = p.evidence;
235 rec.fields = p
236 .fields
237 .into_iter()
238 .map(|(k, v)| (k, serde_json::Value::String(v)))
239 .collect();
240 crate::ledger::Ledger::open(&self.ledger_root())?
241 .signed_by(self.signer.clone())
242 .put(&rec)
243 .await?;
244 self.wrote(&rec).await;
245 let until = match outside {
246 Some(owners) => format!("a member of {} reviews it", owners.join(" or ")),
247 None => "a human reviews it".into(),
248 };
249 Ok(format!(
250 "recorded {} `{}` (id `{}`); it is unconfirmed until {until}",
251 kind.as_str(),
252 rec.title,
253 rec.id
254 ))
255 }
256
257 async fn write_review(
258 &self,
259 p: ReviewParam,
260 by: crate::ledger::Provenance,
261 ) -> anyhow::Result<String> {
262 let ledger =
263 crate::ledger::Ledger::open(&self.ledger_root())?.signed_by(self.signer.clone());
264 let records = self.records().await;
265 anyhow::ensure!(
266 p.id.len() >= 6,
267 "give at least the first 6 characters of the record id"
268 );
269 let matches: Vec<_> = records
270 .iter()
271 .filter(|r| r.id == p.id || r.id.starts_with(&p.id))
272 .collect();
273 let target = match matches.as_slice() {
274 [one] => *one,
275 [] => anyhow::bail!("no ledger record `{}`", p.id),
276 _ => anyhow::bail!(
277 "`{}` matches {} records; give more of the id",
278 p.id,
279 matches.len()
280 ),
281 };
282 let reason = p.reason.as_deref().unwrap_or_default();
283 let commits = crate::index::read_repo_commits(&self.uri)
284 .await
285 .unwrap_or_default();
286 let rec = crate::ledger::Record::review(target, &p.action, reason, by)?
287 .vouching(target, |id| {
288 Some(crate::ledger::Anchor::of(self.symbols.get(id)?).at(&commits))
289 });
290 ledger.put(&rec).await?;
291 self.wrote(&rec).await;
292 Ok(format!("{}: `{}`", p.action, target.title))
293 }
294
295 fn repo_of(&self, focus: &str) -> Option<String> {
297 if self.graph.repos().iter().any(|(r, _)| r == focus) {
298 return Some(focus.to_string());
299 }
300 if let Some(s) = self
301 .symbols
302 .values()
303 .find(|s| s.kind == crate::model::SymbolKind::File && s.file_path.ends_with(focus))
304 {
305 return Some(s.repo.0.clone());
306 }
307 self.graph.lookup(focus).map(|i| self.graph.node(i).repo)
308 }
309
310 fn node_dto(&self, n: GNode) -> NodeDto {
312 let enr = self.enrichment.get(&n.id);
313 NodeDto {
314 id: n.id,
315 name: n.name,
316 kind: n.kind,
317 repo: n.repo,
318 location: n.location,
319 signature: n.signature,
320 summary: enr.map(|(s, _)| s.clone()),
321 queries: enr.map(|(_, q)| q.clone()).unwrap_or_default(),
322 }
323 }
324
325 fn reply<T: Serialize>(&self, dto: &T, md: String) -> CallToolResult {
328 let value = serde_json::to_value(dto).ok();
329 let text = if self.markdown {
330 md
331 } else {
332 value
333 .as_ref()
334 .and_then(|v| serde_json::to_string_pretty(v).ok())
335 .unwrap_or_default()
336 };
337 let mut r = CallToolResult::success(vec![ContentBlock::text(text)]);
338 if !self.markdown {
340 r.structured_content = value;
341 }
342 r
343 }
344}
345
346#[derive(Serialize, schemars::JsonSchema)]
349struct NodeDto {
350 id: String,
351 name: String,
352 kind: String,
353 repo: String,
354 location: String,
356 signature: Option<String>,
357 summary: Option<String>,
359 #[serde(default, skip_serializing_if = "Vec::is_empty")]
361 queries: Vec<String>,
362}
363
364#[derive(Serialize, schemars::JsonSchema)]
365struct HopDto {
366 name: String,
367 kind: String,
368 repo: String,
369 location: String,
370 depth: usize,
371 via: String,
372}
373
374#[derive(Serialize, schemars::JsonSchema)]
375struct RepoCount {
376 repo: String,
377 symbols: usize,
378 #[serde(skip_serializing_if = "Vec::is_empty")]
380 teams: Vec<String>,
381 #[serde(skip_serializing_if = "Vec::is_empty")]
383 depends_on: Vec<DepDto>,
384}
385
386#[derive(Serialize, schemars::JsonSchema)]
387struct DepDto {
388 package: String,
389 publisher: String,
390 published: Option<String>,
391 locked: Option<String>,
392 skew: bool,
394}
395
396#[derive(Serialize, schemars::JsonSchema)]
397struct ReposDto {
398 repos: Vec<RepoCount>,
399 #[serde(skip_serializing_if = "Vec::is_empty")]
401 teams: Vec<String>,
402}
403
404#[derive(Serialize, schemars::JsonSchema)]
405struct TeamCount {
406 team: String,
407 repos: usize,
408 symbols: usize,
409}
410
411#[derive(Serialize, schemars::JsonSchema)]
412struct FindDto {
413 results: Vec<NodeDto>,
414}
415
416#[derive(Serialize, schemars::JsonSchema)]
417struct ThreadDto {
418 focus: String,
419 trace: Vec<HopDto>,
420}
421
422#[derive(Serialize, schemars::JsonSchema)]
423struct FlowDto {
424 focus: String,
425 found: bool,
426 narration: Option<String>,
428 queries: Vec<String>,
429 trace: Vec<HopDto>,
430}
431
432#[derive(Serialize, schemars::JsonSchema)]
433struct ZoomDto {
434 found: bool,
435 focus: Option<NodeDto>,
436 container: Option<NodeDto>,
437 callers: Vec<NodeDto>,
438 callees: Vec<NodeDto>,
439 importers: Vec<NodeDto>,
440 members: Vec<NodeDto>,
441 contracts: Vec<ContractRoleDto>,
443 providers: Vec<NodeDto>,
445 consumers: Vec<NodeDto>,
446 known: Vec<String>,
448}
449
450#[derive(Serialize, schemars::JsonSchema)]
451struct ContractRoleDto {
452 role: String,
453 contract: String,
454 counterparts: Vec<NodeDto>,
456}
457
458#[derive(Serialize, schemars::JsonSchema)]
459struct ContractDto {
460 contract: String,
461 providers: Vec<NodeDto>,
462 consumers: Vec<NodeDto>,
463}
464
465#[derive(Serialize, schemars::JsonSchema)]
466struct ContractsDto {
467 contracts: Vec<ContractDto>,
468}
469
470#[derive(Deserialize, schemars::JsonSchema, Default)]
471struct ContractsParam {
472 #[serde(default)]
474 kind: Option<String>,
475 #[serde(default)]
477 dangling: Option<bool>,
478 #[serde(default)]
480 query: Option<String>,
481 #[serde(default)]
483 team: Option<String>,
484}
485
486#[derive(Serialize, schemars::JsonSchema)]
487struct RippleDto {
488 focus: String,
489 found: bool,
490 total_dependents: usize,
491 by_repo: Vec<RepoCount>,
492 #[serde(skip_serializing_if = "Vec::is_empty")]
494 by_team: Vec<TeamCount>,
495 dependents: Vec<HopDto>,
496}
497
498#[derive(Serialize, schemars::JsonSchema)]
499struct PackItem {
500 name: String,
501 location: String,
502 est_tokens: usize,
503 summary: Option<String>,
504}
505
506#[derive(Serialize, schemars::JsonSchema)]
507struct GuideDto {
508 found: bool,
509 repo: Option<String>,
511 conventions: Vec<ConventionDto>,
513 guide: Option<String>,
515}
516
517#[derive(Serialize, schemars::JsonSchema)]
518struct ConventionDto {
519 scope: String,
520 dimension: String,
521 rule: String,
522 #[serde(skip_serializing_if = "Option::is_none")]
524 strength: Option<&'static str>,
525 #[serde(skip_serializing_if = "Option::is_none")]
527 evidence: Option<String>,
528 exemplars: Vec<String>,
529 counterexamples: Vec<String>,
530 #[serde(skip_serializing_if = "std::ops::Not::not")]
532 team: bool,
533}
534
535#[derive(Deserialize, schemars::JsonSchema, Default)]
536struct RememberParam {
537 kind: String,
539 on: Vec<String>,
541 title: String,
543 #[serde(default)]
544 body: Option<String>,
545 #[serde(default)]
547 trigger: Option<String>,
548 #[serde(default)]
550 evidence: Vec<String>,
551 #[serde(default)]
553 fields: std::collections::BTreeMap<String, String>,
554 #[serde(default)]
556 snippets: Vec<SnippetParam>,
557}
558
559#[derive(Deserialize, schemars::JsonSchema, Default)]
560struct SnippetParam {
561 path: String,
563 code: String,
564 #[serde(default)]
566 commit: Option<String>,
567}
568
569#[derive(Deserialize, schemars::JsonSchema, Default)]
570struct PreflightParam {
571 #[serde(default)]
573 files: Vec<String>,
574 #[serde(default)]
576 symbols: Vec<String>,
577}
578
579#[derive(Deserialize, schemars::JsonSchema, Default)]
580struct ReviewParam {
581 id: String,
583 action: String,
585 #[serde(default)]
586 reason: Option<String>,
587}
588
589#[derive(Serialize, schemars::JsonSchema)]
590struct LedgerDto {
591 ok: bool,
592 message: String,
593}
594
595#[derive(Deserialize, schemars::JsonSchema, Default)]
596struct GuideParam {
597 #[serde(default)]
599 focus: Option<String>,
600}
601
602#[derive(Serialize, schemars::JsonSchema)]
603struct PackDto {
604 focus: String,
605 found: bool,
606 est_total_tokens: usize,
607 token_budget: usize,
608 items: Vec<PackItem>,
609 known: Vec<String>,
611}
612
613#[derive(Serialize, schemars::JsonSchema)]
614struct SearchHitDto {
615 id: String,
617 name: String,
618 kind: String,
619 repo: String,
620 location: String,
621 signature: Option<String>,
622 score: f32,
623 deps: usize,
624 summary: Option<String>,
625}
626
627#[derive(Serialize, schemars::JsonSchema)]
628struct SearchDto {
629 query: String,
630 results: Vec<SearchHitDto>,
631}
632
633#[derive(Serialize, schemars::JsonSchema)]
634struct SuggestItem {
635 query: String,
636 symbol: String,
637 location: String,
638}
639
640#[derive(Serialize, schemars::JsonSchema)]
641struct SuggestDto {
642 total: usize,
643 suggestions: Vec<SuggestItem>,
644}
645
646#[derive(Deserialize, schemars::JsonSchema, Default)]
649struct ReposParam {
650 #[serde(default)]
652 team: Option<String>,
653}
654
655#[derive(Deserialize, schemars::JsonSchema, Default)]
656struct SearchParam {
657 query: String,
659 limit: Option<u32>,
661 #[serde(default)]
663 team: Option<String>,
664}
665
666#[derive(Deserialize, schemars::JsonSchema, Default)]
667struct SuggestParam {
668 about: Option<String>,
670 limit: Option<u32>,
672 #[serde(default)]
674 team: Option<String>,
675}
676
677#[derive(Deserialize, schemars::JsonSchema, Default)]
678struct FindParam {
679 query: String,
681 limit: Option<u32>,
683 #[serde(default)]
685 team: Option<String>,
686}
687
688#[derive(Deserialize, schemars::JsonSchema, Default)]
689struct FocusParam {
690 focus: String,
692}
693
694#[derive(Deserialize, schemars::JsonSchema, Default)]
695struct DepthParam {
696 focus: String,
698 depth: Option<u32>,
700}
701
702#[derive(Deserialize, schemars::JsonSchema, Default)]
703struct PackParam {
704 focus: String,
706 token_budget: Option<u32>,
708}
709
710#[tool_router(vis = "pub(crate)")]
713impl ComindlyServer {
714 #[tool(
715 name = "search",
716 description = "Natural-language + lexical hybrid code search, reranked by dependency-graph centrality. Ask a question ('how do we create a migration?') or pass a symbol name."
717 )]
718 async fn search(&self, Parameters(p): Parameters<SearchParam>) -> CallToolResult {
719 let limit = p.limit.unwrap_or(12) as usize;
720 let Some(embedder) = self.embedder.as_deref() else {
721 let dto = SearchDto {
722 query: p.query.clone(),
723 results: vec![],
724 };
725 return self.reply(
726 &dto,
727 "_search unavailable: the embedding model could not be loaded (offline?). Graph tools (find/zoom/ripple/thread/context_pack) still work._".into(),
728 );
729 };
730 let hits = crate::search::hybrid(
731 &self.tables,
732 &self.symbols,
733 &self.graph,
734 &self.enrichment,
735 embedder,
736 &p.query,
737 if p.team.is_some() { limit * 5 } else { limit },
738 )
739 .await
740 .unwrap_or_default();
741 let hits: Vec<_> = hits
742 .into_iter()
743 .filter(|h| self.in_team(p.team.as_deref(), &h.repo))
744 .take(limit)
745 .collect();
746 let md = crate::search::markdown(&p.query, &hits);
747 let dto = SearchDto {
748 query: p.query.clone(),
749 results: hits.iter().map(hit_dto).collect(),
750 };
751 self.reply(&dto, md)
752 }
753
754 #[tool(
755 name = "repos",
756 description = "List indexed repositories with their symbol counts and the teams labelling each; `team` narrows to some teams"
757 )]
758 fn repos(&self, Parameters(p): Parameters<ReposParam>) -> CallToolResult {
759 let teams = self.team_settings();
760 let dto = ReposDto {
761 repos: self
762 .graph
763 .repos()
764 .into_iter()
765 .filter(|(repo, _)| self.in_team(p.team.as_deref(), repo))
766 .map(|(repo, symbols)| RepoCount {
767 depends_on: comindly_deps(&self.packages, &repo),
768 teams: teams.teams_of(&repo),
769 repo,
770 symbols,
771 })
772 .collect(),
773 teams: teams.team_list().iter().map(|t| t.name.clone()).collect(),
774 };
775 let md = md_repos(&dto);
776 self.reply(&dto, md)
777 }
778
779 #[tool(
780 name = "suggest",
781 description = "Suggested questions you can ask about this codebase (pre-generated during --enrich), the most depended-on code first; vendored code is left out. Optionally filter by a keyword to discover what flows/topics exist."
782 )]
783 fn suggest(&self, Parameters(p): Parameters<SuggestParam>) -> CallToolResult {
784 let limit = p.limit.unwrap_or(40) as usize;
785 let about = p.about.as_deref().map(str::to_lowercase);
786 let mut items: Vec<(usize, SuggestItem)> = Vec::new();
787 for (id, (_, queries)) in self.enrichment.iter() {
788 if self
789 .symbols
790 .get(id)
791 .is_some_and(|s| third_party(&s.file_path))
792 {
793 continue;
794 }
795 let used = self.graph.dependents_count(id);
796 if p.team.is_some()
797 && !self
798 .symbols
799 .get(id)
800 .is_some_and(|s| self.in_team(p.team.as_deref(), &s.repo.0))
801 {
802 continue;
803 }
804 let (symbol, location) = self
805 .symbols
806 .get(id)
807 .map(|s| {
808 (
809 s.name.clone(),
810 format!("{}:{}", s.file_path, s.range.start.line),
811 )
812 })
813 .unwrap_or_else(|| (id.clone(), String::new()));
814 for q in queries {
815 if let Some(a) = &about {
816 if !q.to_lowercase().contains(a) && !symbol.to_lowercase().contains(a) {
817 continue;
818 }
819 }
820 items.push((
821 used,
822 SuggestItem {
823 query: q.clone(),
824 symbol: symbol.clone(),
825 location: location.clone(),
826 },
827 ));
828 }
829 }
830 items.sort_by(|(ua, a), (ub, b)| {
832 ub.cmp(ua)
833 .then(a.symbol.cmp(&b.symbol))
834 .then(a.query.cmp(&b.query))
835 });
836 let total = items.len();
837 let items: Vec<SuggestItem> = items.into_iter().take(limit).map(|(_, i)| i).collect();
838 let dto = SuggestDto {
839 total,
840 suggestions: items,
841 };
842 let md = md_suggest(&dto);
843 self.reply(&dto, md)
844 }
845
846 #[tool(
847 name = "find",
848 description = "Find symbols by name or path substring. Returns each match with its `id` and `file:line` — the entry point to the graph tools: pass an `id` to zoom/ripple/thread/context_pack/flow."
849 )]
850 fn find(&self, Parameters(p): Parameters<FindParam>) -> CallToolResult {
851 let limit = p.limit.unwrap_or(20) as usize;
852 let dto = FindDto {
853 results: self
854 .graph
855 .find(&p.query, if p.team.is_some() { limit * 5 } else { limit })
856 .into_iter()
857 .filter(|n| self.in_team(p.team.as_deref(), &n.repo))
858 .take(limit)
859 .map(|n| self.node_dto(n))
860 .collect(),
861 };
862 let md = md_find(&p.query, &dto);
863 self.reply(&dto, md)
864 }
865
866 #[tool(
867 name = "zoom",
868 description = "360° view of one symbol — definition, container, callers, callees, importers, members. Use to understand a symbol before changing it. Takes a symbol id/name from find/search."
869 )]
870 async fn zoom(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
871 let dto = match self.graph.lookup(&p.focus) {
872 None => ZoomDto {
873 found: false,
874 focus: None,
875 container: None,
876 callers: vec![],
877 callees: vec![],
878 importers: vec![],
879 members: vec![],
880 contracts: vec![],
881 providers: vec![],
882 consumers: vec![],
883 known: vec![],
884 },
885 Some(idx) => {
886 let z = self.graph.zoom(idx);
887 ZoomDto {
888 found: true,
889 focus: z.focus.map(|n| self.node_dto(n)),
890 container: z.container.map(|n| self.node_dto(n)),
891 callers: z.callers.into_iter().map(|n| self.node_dto(n)).collect(),
892 callees: z.callees.into_iter().map(|n| self.node_dto(n)).collect(),
893 importers: z.importers.into_iter().map(|n| self.node_dto(n)).collect(),
894 members: z.members.into_iter().map(|n| self.node_dto(n)).collect(),
895 contracts: z
896 .contracts
897 .into_iter()
898 .map(|c| ContractRoleDto {
899 role: c.role.to_string(),
900 contract: c.contract.name,
901 counterparts: c
902 .counterparts
903 .into_iter()
904 .map(|n| self.node_dto(n))
905 .collect(),
906 })
907 .collect(),
908 providers: z.providers.into_iter().map(|n| self.node_dto(n)).collect(),
909 consumers: z.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
910 known: match self.graph.scope_of(&p.focus) {
911 Some((_, ids)) => self.known(&ids, 5).await,
912 None => vec![],
913 },
914 }
915 }
916 };
917 let md = md_zoom(&p.focus, &dto);
918 self.reply(&dto, md)
919 }
920
921 #[tool(
922 name = "contracts",
923 description = "Service contracts across repos: HTTP routes ↔ clients, SQS/SNS queues and topics, S3 buckets, DB tables — each with providers and consumers. Use to see how services talk, find calls to routes nobody serves (`dangling`), or filter by `kind`/`query`. `ripple` already crosses these."
924 )]
925 fn contracts(&self, Parameters(p): Parameters<ContractsParam>) -> CallToolResult {
926 let views = self
927 .graph
928 .contracts(p.kind.as_deref(), p.dangling.unwrap_or(false));
929 let dto = ContractsDto {
930 contracts: views
931 .into_iter()
932 .filter(|v| {
933 p.query
934 .as_deref()
935 .is_none_or(|q| v.contract.name.contains(q))
936 })
937 .filter(|v| {
938 p.team.is_none()
939 || v.providers
940 .iter()
941 .chain(&v.consumers)
942 .any(|n| self.in_team(p.team.as_deref(), &n.repo))
943 })
944 .take(200)
945 .map(|v| ContractDto {
946 contract: v.contract.name,
947 providers: v.providers.into_iter().map(|n| self.node_dto(n)).collect(),
948 consumers: v.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
949 })
950 .collect(),
951 };
952 let md = md_contracts(&dto);
953 self.reply(&dto, md)
954 }
955
956 #[tool(
957 name = "why",
958 description = "Reasoning blame for a symbol: recorded decisions, failed attempts, constraints, gotchas and intents about it or its class/file — with status (active / stale since the code changed / orphaned), trust and evidence. Check before changing code so you don't repeat a failed approach or break a constraint."
959 )]
960 async fn why(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
961 let Some((name, ids)) = self.graph.scope_of(&p.focus) else {
962 let dto = LedgerDto {
963 ok: false,
964 message: format!("no symbol matches `{}`", p.focus),
965 };
966 let md = format!("_{}_", dto.message);
967 return self.reply(&dto, md);
968 };
969 let records = self.records().await;
970 let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
971 let entries = self.entries(&records, &symbols);
972 let about = crate::ledger::about(&entries, &ids);
973 let md = crate::ledger::markdown(&format!("why `{name}`"), &about);
974 let dto = LedgerDto {
975 ok: true,
976 message: md.clone(),
977 };
978 self.reply(&dto, md)
979 }
980
981 #[tool(
982 name = "preflight",
983 description = "Before you change code: given the files and/or symbols you plan to edit, returns recorded constraints and failed attempts about them, contracts (routes, tables, queues) other services consume, blast radius across repos, and directories that usually change together but aren't in your plan. Run it on your plan before editing."
984 )]
985 async fn preflight(&self, Parameters(p): Parameters<PreflightParam>) -> CallToolResult {
986 let mut changes = Vec::new();
987 for f in &p.files {
988 if let Some(s) = self.symbols.values().find(|s| {
989 s.kind == crate::model::SymbolKind::File
990 && (s.file_path == *f || f.ends_with(&format!("/{}", s.file_path)))
991 }) {
992 changes.push(crate::preflight::Change {
993 repo: s.repo.0.clone(),
994 path: s.file_path.clone(),
995 lines: Vec::new(),
996 });
997 }
998 }
999 for focus in &p.symbols {
1000 let Some(idx) = self.graph.lookup(focus) else {
1001 continue;
1002 };
1003 if let Some(s) = self.symbols.get(&self.graph.node(idx).id) {
1004 changes.push(crate::preflight::Change {
1005 repo: s.repo.0.clone(),
1006 path: s.file_path.clone(),
1007 lines: vec![(s.range.start.line, s.range.end.line)],
1008 });
1009 }
1010 }
1011 let records = self.records().await;
1012 let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
1013 let entries = self.entries(&records, &symbols);
1014 let findings = crate::checks::run(&entries, &symbols, &self.edges, &self.packages);
1015 let recipes: Vec<crate::conventions::Convention> = self
1016 .conventions
1017 .iter()
1018 .filter(|c| c.dimension == "recipes")
1019 .cloned()
1020 .collect();
1021 let md = if changes.is_empty() {
1022 "_no indexed files or symbols matched — pass repo-relative paths or names from `find`._"
1023 .to_string()
1024 } else {
1025 crate::preflight::report(
1026 &self.graph,
1027 &symbols,
1028 &self.edges,
1029 &entries,
1030 &recipes,
1031 &findings,
1032 &changes,
1033 ) + &crate::preflight::teams_affected(
1034 &self.graph,
1035 &symbols,
1036 &changes,
1037 &self.team_settings(),
1038 )
1039 };
1040 let dto = LedgerDto {
1041 ok: !changes.is_empty(),
1042 message: md.clone(),
1043 };
1044 self.reply(&dto, md)
1045 }
1046
1047 #[tool(
1048 name = "remember",
1049 description = "Record durable engineering knowledge about symbols for the team: a failed or reverted approach (`attempt`, evidence required), a choice between alternatives (`decision`), an invariant (`constraint`), a trap (`gotcha`), why code exists (`intent`) or an open `question`. Only record what a future engineer would be glad to know before editing — not summaries of what you did. Write neutral facts, never instructions."
1050 )]
1051 async fn remember(
1052 &self,
1053 ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1054 Parameters(p): Parameters<RememberParam>,
1055 ) -> CallToolResult {
1056 let result = match Self::may_write(&ctx) {
1057 Ok(()) => self.write_record(p, self.agent(&ctx)).await,
1058 Err(e) => Err(e),
1059 };
1060 let dto = LedgerDto {
1061 ok: result.is_ok(),
1062 message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1063 };
1064 let md = dto.message.clone();
1065 self.reply(&dto, md)
1066 }
1067
1068 #[tool(
1069 name = "review",
1070 description = "Act on a ledger record after checking it against the code: `confirm` (still true), `archive` (was true, no longer applies), `invalidate` (was wrong), `supersede` (replaced by a newer record) or `dispute` (contradicted). Give a reason. A `confirm` with a reason counts as your person's confirmation, so confirm only when they say the record holds, and put what they said in the reason. Retiring a record a person wrote or confirmed only flags it for a person to decide. Use when `why` shows a STALE record you have verified."
1071 )]
1072 async fn review(
1073 &self,
1074 ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1075 Parameters(p): Parameters<ReviewParam>,
1076 ) -> CallToolResult {
1077 let result = match Self::may_write(&ctx) {
1078 Ok(()) => self.write_review(p, self.agent(&ctx)).await,
1079 Err(e) => Err(e),
1080 };
1081 let dto = LedgerDto {
1082 ok: result.is_ok(),
1083 message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1084 };
1085 let md = dto.message.clone();
1086 self.reply(&dto, md)
1087 }
1088
1089 #[tool(
1090 name = "ripple",
1091 description = "Blast radius (reverse deps): who transitively depends on this symbol, cross-repo, grouped by repo and by team. Use to gauge the impact of changing it. This is the reverse direction of `thread`. Returns dependents with depth + repo counts."
1092 )]
1093 fn ripple(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1094 let depth = p.depth.unwrap_or(4) as usize;
1095 let dto = match self.graph.lookup(&p.focus) {
1096 None => RippleDto {
1097 focus: p.focus.clone(),
1098 found: false,
1099 total_dependents: 0,
1100 by_repo: vec![],
1101 by_team: vec![],
1102 dependents: vec![],
1103 },
1104 Some(idx) => {
1105 let hops = self.graph.ripple(idx, depth);
1106 let teams = self.team_settings();
1107 let mut by_repo: std::collections::BTreeMap<String, usize> =
1108 std::collections::BTreeMap::new();
1109 for h in &hops {
1110 *by_repo.entry(h.node.repo.clone()).or_default() += 1;
1111 }
1112 RippleDto {
1113 focus: p.focus.clone(),
1114 found: true,
1115 total_dependents: hops.len(),
1116 by_team: by_team(&teams, &by_repo),
1117 by_repo: by_repo
1118 .into_iter()
1119 .map(|(repo, symbols)| RepoCount {
1120 teams: teams.teams_of(&repo),
1121 repo,
1122 symbols,
1123 depends_on: Vec::new(),
1124 })
1125 .collect(),
1126 dependents: hops.into_iter().map(hop_dto).collect(),
1127 }
1128 }
1129 };
1130 let md = md_ripple(&dto);
1131 self.reply(&dto, md)
1132 }
1133
1134 #[tool(
1135 name = "guide",
1136 description = "Measured coding conventions to follow when editing: naming, signatures, base classes, error roots, layout, logging, config, internal libraries to use instead of raw ones, testing — each with counts, exemplar locations and known deviations. Pass `focus` (repo, file or symbol) for its repo's conventions and narrative guide; the org baseline is always included. Without `focus`: the org baseline and which repos have a guide."
1137 )]
1138 fn guide(&self, Parameters(p): Parameters<GuideParam>) -> CallToolResult {
1139 let repo = p.focus.as_deref().and_then(|f| self.repo_of(f));
1140 let repo_keys: std::collections::HashSet<&str> = self
1141 .conventions
1142 .iter()
1143 .filter(|c| Some(&c.repo) == repo.as_ref())
1144 .map(|c| c.key.as_str())
1145 .collect();
1146 let mut conventions: Vec<ConventionDto> = Vec::new();
1147 for c in self.conventions.iter() {
1148 let scoped = Some(&c.repo) == repo.as_ref();
1149 let org = c.repo == crate::conventions::ORG && !repo_keys.contains(c.key.as_str());
1150 if scoped || org {
1151 conventions.push(ConventionDto {
1152 scope: c.repo.clone(),
1153 dimension: c.dimension.clone(),
1154 rule: c.rule.clone(),
1155 strength: c.strength(),
1156 evidence: c.evidence(),
1157 exemplars: c.exemplars.iter().take(2).cloned().collect(),
1158 counterexamples: c.counterexamples.iter().take(2).cloned().collect(),
1159 team: c.origin != crate::conventions::Origin::Measured,
1160 });
1161 }
1162 }
1163 let guide = match &repo {
1165 Some(r) => self
1166 .style_guides
1167 .iter()
1168 .find(|(g, _)| g == r)
1169 .map(|(_, g)| g.clone()),
1170 None => (!self.style_guides.is_empty()).then(|| {
1171 let names: Vec<&str> = self.style_guides.iter().map(|(r, _)| r.as_str()).collect();
1172 format!(
1173 "Written for each repository; pass `focus` to read one: {}.",
1174 names.join(", ")
1175 )
1176 }),
1177 };
1178 let dto = GuideDto {
1179 found: !conventions.is_empty() || guide.is_some(),
1180 repo,
1181 conventions,
1182 guide,
1183 };
1184 let md = md_guide(&dto);
1185 self.reply(&dto, md)
1186 }
1187
1188 #[tool(
1189 name = "thread",
1190 description = "Forward call trace from an entry point: what this symbol calls, transitively, with depth + edge kind. Use to follow execution downstream. `flow` = this trace plus a narrated explanation; `ripple` = the reverse (who calls in)."
1191 )]
1192 fn thread(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1193 let depth = p.depth.unwrap_or(4) as usize;
1194 let trace = match self.graph.lookup(&p.focus) {
1195 Some(idx) => self
1196 .graph
1197 .thread(idx, depth)
1198 .into_iter()
1199 .map(hop_dto)
1200 .collect(),
1201 None => vec![],
1202 };
1203 let dto = ThreadDto {
1204 focus: p.focus.clone(),
1205 trace,
1206 };
1207 let md = md_thread(&dto);
1208 self.reply(&dto, md)
1209 }
1210
1211 #[tool(
1212 name = "flow",
1213 description = "Explain how a flow works: a pre-generated LLM walkthrough of an entry point (when the index was built with --flows) plus its live forward call trace."
1214 )]
1215 fn flow(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1216 let depth = p.depth.unwrap_or(4) as usize;
1217 let dto = match self.graph.lookup(&p.focus) {
1218 None => FlowDto {
1219 focus: p.focus.clone(),
1220 found: false,
1221 narration: None,
1222 queries: vec![],
1223 trace: vec![],
1224 },
1225 Some(idx) => {
1226 let fid = self.graph.zoom(idx).focus.map(|n| n.id);
1227 let (narration, queries) = fid
1228 .as_ref()
1229 .and_then(|id| self.flows.get(id))
1230 .map(|(n, q)| (Some(n.clone()), q.clone()))
1231 .unwrap_or((None, vec![]));
1232 let trace = self
1233 .graph
1234 .thread(idx, depth)
1235 .into_iter()
1236 .map(hop_dto)
1237 .collect();
1238 FlowDto {
1239 focus: p.focus.clone(),
1240 found: true,
1241 narration,
1242 queries,
1243 trace,
1244 }
1245 }
1246 };
1247 let md = md_flow(&dto);
1248 self.reply(&dto, md)
1249 }
1250
1251 #[tool(
1252 name = "context_pack",
1253 description = "The minimal token-budgeted set of symbols to read before editing the focus symbol safely. Use to assemble just-enough context for a change. Returns ranked symbols with locations + token estimates within the budget."
1254 )]
1255 async fn context_pack(&self, Parameters(p): Parameters<PackParam>) -> CallToolResult {
1256 let budget = p.token_budget.unwrap_or(1500) as usize;
1257 let dto = match self.graph.lookup(&p.focus) {
1258 None => PackDto {
1259 focus: p.focus.clone(),
1260 found: false,
1261 est_total_tokens: 0,
1262 token_budget: budget,
1263 items: vec![],
1264 known: vec![],
1265 },
1266 Some(idx) => {
1267 let pack = self.graph.context_pack(idx, budget);
1268 let est_total_tokens = pack.iter().map(|(_, t)| t).sum();
1269 let mut ids: Vec<String> = pack.iter().map(|(n, _)| n.id.clone()).collect();
1270 if let Some((_, scope)) = self.graph.scope_of(&p.focus) {
1271 ids.extend(scope);
1272 }
1273 PackDto {
1274 focus: p.focus.clone(),
1275 found: true,
1276 est_total_tokens,
1277 token_budget: budget,
1278 known: self.known(&ids, 6).await,
1279 items: pack
1280 .into_iter()
1281 .map(|(n, t)| PackItem {
1282 summary: self.enrichment.get(&n.id).map(|(s, _)| s.clone()),
1283 name: n.name,
1284 location: n.location,
1285 est_tokens: t,
1286 })
1287 .collect(),
1288 }
1289 }
1290 };
1291 let md = md_pack(&dto);
1292 self.reply(&dto, md)
1293 }
1294}
1295
1296impl ComindlyServer {
1297 pub async fn as_agent(&self, tool: &str, args: serde_json::Value) -> Result<String> {
1299 let me = Self {
1300 markdown: true,
1301 ..self.clone()
1302 };
1303 macro_rules! args {
1304 () => {
1305 Parameters(serde_json::from_value(args)?)
1306 };
1307 }
1308 let r = match tool {
1309 "repos" => me.repos(args!()),
1310 "contracts" => me.contracts(args!()),
1311 "guide" => me.guide(args!()),
1312 "find" => me.find(args!()),
1313 "suggest" => me.suggest(args!()),
1314 "ripple" => me.ripple(args!()),
1315 "thread" => me.thread(args!()),
1316 "flow" => me.flow(args!()),
1317 "zoom" => me.zoom(args!()).await,
1318 "why" => me.why(args!()).await,
1319 "context_pack" => me.context_pack(args!()).await,
1320 _ => anyhow::bail!("`{tool}` is not a read-only tool"),
1321 };
1322 Ok(r.content
1323 .iter()
1324 .filter_map(|c| c.as_text().map(|t| t.text.as_str()))
1325 .collect::<Vec<_>>()
1326 .join("\n"))
1327 }
1328}
1329
1330#[rmcp::tool_handler(router = Self::tool_router())]
1331impl rmcp::ServerHandler for ComindlyServer {
1332 fn get_info(&self) -> rmcp::model::ServerConfig {
1333 rmcp::model::ServerConfig::new(
1334 rmcp::model::ServerCapabilities::builder()
1335 .enable_tools()
1336 .build(),
1337 )
1338 .with_server_info(rmcp::model::Implementation::from_build_env())
1339 .with_instructions(crate::agents::GUIDE)
1340 }
1341
1342 async fn call_tool(
1344 &self,
1345 request: rmcp::model::CallToolRequestParams,
1346 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1347 ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
1348 let tool = request.name.to_string();
1349 let parts = context.extensions.get::<axum::http::request::Parts>();
1350 let client = client_name(&context).unwrap_or_default();
1351 let email = parts
1352 .and_then(|p| p.extensions.get::<crate::server::Who>())
1353 .map(|w| w.email.clone());
1354 let start = std::time::Instant::now();
1355 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
1356 let out = Self::tool_router().call(tcc).await;
1357 let ms = start.elapsed().as_millis() as u64;
1358 let ok = out.is_ok();
1359 if !crate::telemetry::logs("tool") {
1360 return out;
1361 }
1362 tracing::info!(
1363 kind = "tool",
1364 tool = %tool,
1365 client = %client,
1366 email = email.as_deref(),
1367 ok,
1368 duration_ms = ms,
1369 "tool {tool} by {} {ms} ms",
1370 if client.is_empty() { "an unnamed client" } else { client.as_str() }
1371 );
1372 out
1373 }
1374}
1375
1376fn client_name(ctx: &rmcp::service::RequestContext<rmcp::RoleServer>) -> Option<String> {
1379 ctx.extensions
1380 .get::<axum::http::request::Parts>()
1381 .and_then(|p| p.extensions.get::<crate::server::GatewayClient>())
1382 .map(|c| c.0.clone())
1383 .or_else(|| {
1384 let own = rmcp::model::Implementation::from_build_env().name;
1385 ctx.peer
1386 .peer_info()
1387 .map(|p| p.client_info.name.clone())
1388 .filter(|n| *n != own)
1389 })
1390}
1391
1392fn hop_dto(h: crate::graph::Hop) -> HopDto {
1393 HopDto {
1394 name: h.node.name,
1395 kind: h.node.kind,
1396 repo: h.node.repo,
1397 location: h.node.location,
1398 depth: h.depth,
1399 via: h.via.as_str().to_string(),
1400 }
1401}
1402
1403fn hit_dto(h: &crate::search::SearchHit) -> SearchHitDto {
1404 SearchHitDto {
1405 id: h.id.clone(),
1406 name: h.name.clone(),
1407 kind: h.kind.clone(),
1408 repo: h.repo.clone(),
1409 location: h.location.clone(),
1410 signature: h.signature.clone(),
1411 score: h.score,
1412 deps: h.deps,
1413 summary: h.summary.clone(),
1414 }
1415}
1416
1417fn third_party(path: &str) -> bool {
1419 path.split('/').any(|c| {
1420 matches!(
1421 c,
1422 "_vendor" | "vendor" | "vendored" | "third_party" | "node_modules" | "site-packages"
1423 )
1424 })
1425}
1426
1427fn node_bullet(o: &mut String, n: &NodeDto) {
1430 let _ = write!(o, "- **{}** _{}_ — `{}`", n.name, n.kind, n.location);
1431 if !n.repo.is_empty() {
1432 let _ = write!(o, " · {}", n.repo);
1433 }
1434 if let Some(s) = &n.summary {
1435 let _ = write!(o, " — {s}");
1436 }
1437 let _ = write!(o, " · id `{}`", n.id);
1439 o.push('\n');
1440}
1441
1442fn node_section(o: &mut String, title: &str, nodes: &[NodeDto]) {
1443 if nodes.is_empty() {
1444 return;
1445 }
1446 let _ = write!(o, "\n**{title}** ({})\n", nodes.len());
1447 for n in nodes {
1448 node_bullet(o, n);
1449 }
1450}
1451
1452fn md_repos(d: &ReposDto) -> String {
1453 let mut o = String::from("## Indexed repositories\n\n");
1454 if !d.teams.is_empty() {
1455 let _ = writeln!(o, "Teams: {}\n", d.teams.join(", "));
1456 }
1457 if d.repos.is_empty() {
1458 return o + "_none_\n";
1459 }
1460 for r in &d.repos {
1461 let teams = if r.teams.is_empty() {
1462 String::new()
1463 } else {
1464 format!(" · teams: {}", r.teams.join(", "))
1465 };
1466 let _ = writeln!(o, "- **{}** — {} symbols{teams}", r.repo, r.symbols);
1467 for dep in &r.depends_on {
1468 let _ = writeln!(
1469 o,
1470 " - uses `{}` from **{}** — locked {} vs published {}{}",
1471 dep.package,
1472 dep.publisher,
1473 dep.locked.as_deref().unwrap_or("?"),
1474 dep.published.as_deref().unwrap_or("?"),
1475 if dep.skew { " ⚠ skew" } else { "" }
1476 );
1477 }
1478 }
1479 o
1480}
1481
1482fn comindly_deps(rows: &[crate::manifests::PackageRow], repo: &str) -> Vec<DepDto> {
1483 crate::manifests::internal(rows)
1484 .into_iter()
1485 .filter(|d| d.consumer == repo)
1486 .map(|d| DepDto {
1487 skew: d.skewed(),
1488 package: d.name,
1489 publisher: d.publisher,
1490 published: d.published,
1491 locked: d.locked,
1492 })
1493 .collect()
1494}
1495
1496fn md_suggest(d: &SuggestDto) -> String {
1497 let shown = d.suggestions.len();
1498 let mut o = if d.total > shown {
1499 format!(
1500 "## Suggested questions ({shown} of {} — pass `about` to filter)\n\n",
1501 d.total
1502 )
1503 } else {
1504 format!("## Suggested questions ({shown})\n\n")
1505 };
1506 if d.suggestions.is_empty() {
1507 return o + "_none — index was built without `--enrich`, or no match_\n";
1508 }
1509 let mut cur = "";
1510 for it in &d.suggestions {
1511 if it.symbol != cur {
1512 let _ = write!(o, "\n**{}** `{}`\n", it.symbol, it.location);
1513 cur = &it.symbol;
1514 }
1515 let _ = writeln!(o, "- {}", it.query);
1516 }
1517 o
1518}
1519
1520fn md_find(query: &str, d: &FindDto) -> String {
1521 let mut o = format!("## `find` \"{query}\" — {} result(s)\n\n", d.results.len());
1522 if d.results.is_empty() {
1523 return o + "_no symbols matched_\n";
1524 }
1525 for n in &d.results {
1526 node_bullet(&mut o, n);
1527 }
1528 o
1529}
1530
1531fn md_zoom(focus: &str, d: &ZoomDto) -> String {
1532 if !d.found {
1533 return format!("## zoom `{focus}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n");
1534 }
1535 let mut o = String::new();
1536 if let Some(f) = &d.focus {
1537 let _ = writeln!(o, "## {} _{}_\n`{}`", f.name, f.kind, f.location);
1538 if let Some(sig) = &f.signature {
1539 let _ = writeln!(o, "\n```\n{sig}\n```");
1540 }
1541 if let Some(s) = &f.summary {
1542 let _ = writeln!(o, "\n{s}");
1543 }
1544 if !f.queries.is_empty() {
1545 let _ = writeln!(o, "\n**Ask:** {}", f.queries.join(" · "));
1546 }
1547 } else {
1548 let _ = writeln!(o, "## zoom `{focus}`");
1549 }
1550 if let Some(c) = &d.container {
1551 let _ = write!(o, "\n**Container**\n");
1552 node_bullet(&mut o, c);
1553 }
1554 node_section(&mut o, "Members", &d.members);
1555 node_section(&mut o, "Callers", &d.callers);
1556 node_section(&mut o, "Callees", &d.callees);
1557 node_section(&mut o, "Importers", &d.importers);
1558 if !d.known.is_empty() {
1559 o.push_str(
1560 "\n**Known constraints & dead ends** (team ledger — verify before relying on them)\n",
1561 );
1562 for k in &d.known {
1563 let _ = writeln!(o, "- {k}");
1564 }
1565 }
1566 node_section(&mut o, "Provided by", &d.providers);
1567 node_section(&mut o, "Used by", &d.consumers);
1568 if !d.contracts.is_empty() {
1569 let _ = write!(o, "\n**Contracts**\n");
1570 for c in &d.contracts {
1571 let _ = writeln!(o, "- {} `{}`", c.role, c.contract);
1572 for n in &c.counterparts {
1573 let _ = writeln!(o, " - **{}** ({}) `{}`", n.name, n.repo, n.location);
1574 }
1575 }
1576 }
1577 o
1578}
1579
1580fn md_contracts(d: &ContractsDto) -> String {
1581 let mut o = format!("## {} contract(s)\n", d.contracts.len());
1582 for c in &d.contracts {
1583 let _ = writeln!(o, "\n### `{}`", c.contract);
1584 if c.providers.is_empty() {
1585 o.push_str("- provided by: _none indexed_\n");
1586 }
1587 let both = |n: &NodeDto| c.consumers.iter().any(|m| m.id == n.id);
1589 let sides = c.providers.iter().map(|n| {
1590 (
1591 n,
1592 if both(n) {
1593 "provides and uses"
1594 } else {
1595 "provided by"
1596 },
1597 )
1598 });
1599 let users = c
1600 .consumers
1601 .iter()
1602 .filter(|n| !c.providers.iter().any(|m| m.id == n.id));
1603 for (n, label) in sides.chain(users.map(|n| (n, "used by"))) {
1604 let _ = writeln!(
1605 o,
1606 "- {label}: **{}** ({}) `{}` · id `{}`",
1607 n.name, n.repo, n.location, n.id
1608 );
1609 }
1610 if c.consumers.is_empty() {
1611 o.push_str("- used by: _none indexed_\n");
1612 }
1613 }
1614 o
1615}
1616
1617fn md_hop_lines(o: &mut String, hops: &[HopDto]) {
1618 for h in hops {
1619 let _ = writeln!(
1620 o,
1621 "- _d{}_ via **{}** — **{}** `{}`",
1622 h.depth, h.via, h.name, h.location
1623 );
1624 }
1625}
1626
1627fn md_ripple(d: &RippleDto) -> String {
1628 if !d.found {
1629 return format!("## Blast radius: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1630 }
1631 let mut o = format!(
1632 "## Blast radius: `{}`\n\n**{} dependent(s)**",
1633 d.focus, d.total_dependents
1634 );
1635 if !d.by_repo.is_empty() {
1636 let by: Vec<String> = d
1637 .by_repo
1638 .iter()
1639 .map(|r| format!("{} ({})", r.repo, r.symbols))
1640 .collect();
1641 let _ = write!(o, " across {}", by.join(", "));
1642 }
1643 if !d.by_team.is_empty() {
1644 let by: Vec<String> = d
1645 .by_team
1646 .iter()
1647 .map(|t| {
1648 let what = if t.repos == 1 { "repo" } else { "repos" };
1649 format!("{} ({} {what}, {} symbols)", t.team, t.repos, t.symbols)
1650 })
1651 .collect();
1652 let _ = write!(o, "\n\nTeams affected: {}", by.join(", "));
1653 }
1654 o.push_str("\n\n");
1655 md_hop_lines(&mut o, &d.dependents);
1656 o
1657}
1658
1659fn by_team(
1661 teams: &crate::team::TeamSettings,
1662 by_repo: &std::collections::BTreeMap<String, usize>,
1663) -> Vec<TeamCount> {
1664 if teams.team_list().is_empty() {
1665 return Vec::new();
1666 }
1667 let mut out: Vec<TeamCount> = Vec::new();
1668 for (repo, n) in by_repo {
1669 let mut names = teams.teams_of(repo);
1670 if names.is_empty() {
1671 names.push(crate::team::UNASSIGNED.to_string());
1672 }
1673 for name in names {
1674 match out.iter_mut().find(|t| t.team == name) {
1675 Some(t) => {
1676 t.repos += 1;
1677 t.symbols += n;
1678 }
1679 None => out.push(TeamCount {
1680 team: name,
1681 repos: 1,
1682 symbols: *n,
1683 }),
1684 }
1685 }
1686 }
1687 out.sort_by(|a, b| b.symbols.cmp(&a.symbols).then(a.team.cmp(&b.team)));
1688 out
1689}
1690
1691fn md_thread(d: &ThreadDto) -> String {
1692 let mut o = format!(
1693 "## Call trace from `{}` — {} step(s)\n\n",
1694 d.focus,
1695 d.trace.len()
1696 );
1697 if d.trace.is_empty() {
1698 return o + "_no outgoing calls found_\n";
1699 }
1700 md_hop_lines(&mut o, &d.trace);
1701 o
1702}
1703
1704fn md_flow(d: &FlowDto) -> String {
1705 if !d.found {
1706 return format!("## Flow: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1707 }
1708 let mut o = format!("## Flow: `{}`\n\n", d.focus);
1709 match &d.narration {
1710 Some(n) => {
1711 let _ = writeln!(o, "{n}\n");
1712 }
1713 None => {
1714 let _ = writeln!(
1715 o,
1716 "_no pre-generated narration (build the index with `--flows` to add one) — raw call trace below._\n"
1717 );
1718 }
1719 }
1720 if !d.queries.is_empty() {
1721 let _ = writeln!(o, "**Ask:** {}\n", d.queries.join(" · "));
1722 }
1723 let _ = writeln!(o, "**Call trace** ({} steps)", d.trace.len());
1724 md_hop_lines(&mut o, &d.trace);
1725 o
1726}
1727
1728fn md_pack(d: &PackDto) -> String {
1729 if !d.found {
1730 return format!("## Context pack: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1731 }
1732 let mut o = format!(
1733 "## Context to change `{}`\n\n_~{} of {} token budget · {} symbols_\n\n",
1734 d.focus,
1735 d.est_total_tokens,
1736 d.token_budget,
1737 d.items.len()
1738 );
1739 if !d.known.is_empty() {
1740 o.push_str(
1741 "**Known constraints & dead ends** (team ledger — verify before relying on them)\n",
1742 );
1743 for k in &d.known {
1744 let _ = writeln!(o, "- {k}");
1745 }
1746 o.push('\n');
1747 }
1748 for (i, it) in d.items.iter().enumerate() {
1749 let _ = write!(
1750 o,
1751 "{}. **{}** — `{}` _(~{} tok)_",
1752 i + 1,
1753 it.name,
1754 it.location,
1755 it.est_tokens
1756 );
1757 if let Some(s) = &it.summary {
1758 let _ = write!(o, " — {s}");
1759 }
1760 o.push('\n');
1761 }
1762 o
1763}
1764
1765fn md_guide(d: &GuideDto) -> String {
1766 let mut o = match &d.repo {
1767 Some(r) => format!("## Conventions — {r}\n"),
1768 None => "## Conventions — org baseline\n".to_string(),
1769 };
1770 if d.conventions.is_empty() {
1771 o.push_str("\n_none measured — re-run `comindly index`/`link` to store facts._\n");
1772 }
1773 let mut org_header = false;
1774 for c in &d.conventions {
1775 if c.scope == crate::conventions::ORG && d.repo.is_some() && !org_header {
1776 o.push_str("\n**Org baseline**\n");
1777 org_header = true;
1778 }
1779 let team = if c.team { " _(set by the team)_" } else { "" };
1780 let how = c
1781 .strength
1782 .map(|s| format!(" ({})", s.to_lowercase()))
1783 .unwrap_or_default();
1784 let _ = writeln!(o, "- _{}_{how}: {}{team}", c.dimension, c.rule);
1785 if !c.exemplars.is_empty() {
1786 let _ = writeln!(o, " - e.g. `{}`", c.exemplars.join("`, `"));
1787 }
1788 if !c.counterexamples.is_empty() {
1789 let _ = writeln!(o, " - deviations: `{}`", c.counterexamples.join("`, `"));
1790 }
1791 }
1792 if let Some(g) = &d.guide {
1793 let _ = write!(o, "\n## Narrative guide\n\n{g}\n");
1794 }
1795 o
1796}
1797
1798pub async fn serve_stdio(uri: &str, markdown: bool) -> Result<()> {
1802 let mut server = load(uri, markdown, crate::config::Config::load().fresh()).await?;
1803 server.signer = crate::config::local_signing_key();
1804 server.identity = crate::config::local_identity();
1805 server.agent_name = crate::config::env("COMINDLY_AGENT");
1806 if let Ok(t) = crate::team::load(&server.ledger_root()).await {
1807 server.teams = Arc::new(std::sync::RwLock::new(t.settings));
1808 }
1809 let service = server.serve(rmcp::transport::io::stdio()).await?;
1810 service.waiting().await?;
1811 Ok(())
1812}
1813
1814pub async fn load(uri: &str, markdown: bool, fresh: std::time::Duration) -> Result<ComindlyServer> {
1816 let (symbols, edges) = crate::index::read_graph(uri).await?;
1817 let mut conventions = match crate::index::read_facts(uri).await {
1818 Ok(Some(facts)) => crate::conventions::mine(&symbols, &edges, &facts),
1819 _ => Vec::new(),
1820 };
1821 conventions.extend(crate::index::read_recipes(uri).await.unwrap_or_default());
1822 let root = uri.trim_end_matches('/');
1823 let root = root.strip_suffix("/_graph").unwrap_or(root);
1824 let manual = match crate::conventions::Store::open(root) {
1825 Ok(s) => s.all().await.unwrap_or_default(),
1826 Err(_) => Vec::new(),
1827 };
1828 let conventions = crate::conventions::apply(conventions, &manual);
1829 let graph = Arc::new(CodeGraph::build(&symbols, &edges));
1830 let by_id: HashMap<String, Symbol> =
1831 symbols.iter().map(|s| (s.id.render(), s.clone())).collect();
1832
1833 let enrichment: Enrichment = crate::index::read_enrichment(uri)
1834 .await
1835 .ok()
1836 .flatten()
1837 .unwrap_or_default()
1838 .into_iter()
1839 .map(|(id, s, q)| (id.render(), (s, q)))
1840 .collect();
1841
1842 let style_guides = crate::index::read_style_guide(uri)
1844 .await
1845 .unwrap_or_default();
1846
1847 let flows: HashMap<String, (String, Vec<String>)> = crate::index::read_flows(uri)
1848 .await
1849 .ok()
1850 .flatten()
1851 .unwrap_or_default()
1852 .into_iter()
1853 .map(|(id, narr, q)| (id.render(), (narr, q)))
1854 .collect();
1855
1856 let model = crate::config::index_settings(root).await.embed_model;
1858 let embedder = match tokio::task::spawn_blocking(move || crate::embed::Embedder::shared(&model))
1859 .await
1860 .map_err(anyhow::Error::from)
1861 .and_then(|r| r)
1862 {
1863 Ok(e) => Some(e),
1864 Err(e) => {
1865 crate::ui::warn(&format!(
1866 "embedding model unavailable, `search` disabled: {e:#}"
1867 ));
1868 None
1869 }
1870 };
1871
1872 let server = ComindlyServer {
1873 graph,
1874 enrichment: Arc::new(enrichment),
1875 style_guides: Arc::new(style_guides),
1876 flows: Arc::new(flows),
1877 symbols: Arc::new(by_id),
1878 embedder,
1879 uri: Arc::new(uri.to_string()),
1880 tables: Arc::new(crate::index::Tables::connect(uri, fresh).await?),
1881 records: Arc::new(tokio::sync::Mutex::new(None)),
1882 packages: Arc::new(crate::index::read_packages(uri).await.unwrap_or_default()),
1883 conventions: Arc::new(conventions),
1884 edges: Arc::new(edges),
1885 markdown,
1886 signer: None,
1887 identity: Default::default(),
1888 agent_name: None,
1889 teams: Default::default(),
1890 };
1891 Ok(server)
1892}
1893
1894pub type Shared = Arc<std::sync::RwLock<Option<ComindlyServer>>>;
1896
1897pub fn http(
1899 current: Shared,
1900 hosts: Vec<String>,
1901) -> rmcp::transport::streamable_http_server::StreamableHttpService<
1902 ComindlyServer,
1903 rmcp::transport::streamable_http_server::session::local::LocalSessionManager,
1904> {
1905 let config = rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default()
1906 .with_allowed_hosts(hosts);
1907 rmcp::transport::streamable_http_server::StreamableHttpService::new(
1908 move || {
1909 current.read().ok().and_then(|s| s.clone()).ok_or_else(|| {
1910 std::io::Error::other("no index yet: an admin indexes repositories first")
1911 })
1912 },
1913 Default::default(),
1914 config,
1915 )
1916}
1917
1918pub fn tools() -> Vec<(String, String)> {
1920 ComindlyServer::tool_router()
1921 .list_all()
1922 .into_iter()
1923 .map(|t| {
1924 (
1925 t.name.to_string(),
1926 t.description.unwrap_or_default().to_string(),
1927 )
1928 })
1929 .collect()
1930}
1931
1932#[cfg(test)]
1933mod tests {
1934 use super::*;
1935
1936 #[test]
1937 fn a_contract_lists_each_symbol_once_with_its_id() {
1938 let node = |id: &str| NodeDto {
1939 id: id.into(),
1940 name: id.into(),
1941 kind: "function".into(),
1942 repo: "worker".into(),
1943 location: "w.py:1".into(),
1944 signature: None,
1945 summary: Some("long summary".into()),
1946 queries: vec!["a question".into()],
1947 };
1948 let md = md_contracts(&ContractsDto {
1949 contracts: vec![ContractDto {
1950 contract: "sqs:tasks".into(),
1951 providers: vec![node("retry"), node("send")],
1952 consumers: vec![node("retry"), node("run")],
1953 }],
1954 });
1955 assert_eq!(md.matches("**retry**").count(), 1);
1956 assert!(md.contains("provides and uses: **retry**"));
1957 assert!(md.contains("used by: **run** (worker) `w.py:1` · id `run`"));
1958 assert!(!md.contains("long summary") && !md.contains("a question"));
1959 }
1960
1961 #[test]
1962 fn vendored_code_is_third_party() {
1963 assert!(third_party("lib/_vendor/aioboto3/session.py"));
1964 assert!(third_party("web/node_modules/x/index.js"));
1965 assert!(!third_party("src/vendors.py"));
1966 }
1967
1968 #[test]
1969 fn impact_by_team() {
1970 let mut teams = crate::team::TeamSettings::default();
1971 teams.label("api", &["Backend".into(), "Payments".into()]);
1972 teams.label("web", &["Frontend".into()]);
1973 let by_repo = [
1974 ("api".to_string(), 3),
1975 ("web".into(), 1),
1976 ("tools".into(), 2),
1977 ]
1978 .into_iter()
1979 .collect();
1980 let got: Vec<(String, usize, usize)> = by_team(&teams, &by_repo)
1981 .into_iter()
1982 .map(|t| (t.team, t.repos, t.symbols))
1983 .collect();
1984 assert_eq!(
1985 got,
1986 [
1987 ("Backend".into(), 1, 3),
1988 ("Payments".into(), 1, 3),
1989 ("none".into(), 1, 2),
1990 ("Frontend".into(), 1, 1)
1991 ]
1992 );
1993 assert!(by_team(&Default::default(), &by_repo).is_empty());
1994 }
1995}