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