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 client = context
1347 .peer
1348 .peer_info()
1349 .map(|p| p.client_info.name.clone())
1350 .unwrap_or_default();
1351 let email = context
1352 .extensions
1353 .get::<axum::http::request::Parts>()
1354 .and_then(|parts| parts.extensions.get::<crate::server::Who>())
1355 .map(|w| w.email.clone());
1356 let start = std::time::Instant::now();
1357 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
1358 let out = Self::tool_router().call(tcc).await;
1359 let ms = start.elapsed().as_millis() as u64;
1360 let ok = out.is_ok();
1361 if !crate::telemetry::logs("tool") {
1362 return out;
1363 }
1364 tracing::info!(
1365 kind = "tool",
1366 tool = %tool,
1367 client = %client,
1368 email = email.as_deref(),
1369 ok,
1370 duration_ms = ms,
1371 "tool {tool} by {} {ms} ms",
1372 if client.is_empty() { "an unnamed client" } else { client.as_str() }
1373 );
1374 out
1375 }
1376}
1377
1378fn hop_dto(h: crate::graph::Hop) -> HopDto {
1379 HopDto {
1380 name: h.node.name,
1381 kind: h.node.kind,
1382 repo: h.node.repo,
1383 location: h.node.location,
1384 depth: h.depth,
1385 via: h.via.as_str().to_string(),
1386 }
1387}
1388
1389fn hit_dto(h: &crate::search::SearchHit) -> SearchHitDto {
1390 SearchHitDto {
1391 id: h.id.clone(),
1392 name: h.name.clone(),
1393 kind: h.kind.clone(),
1394 repo: h.repo.clone(),
1395 location: h.location.clone(),
1396 signature: h.signature.clone(),
1397 score: h.score,
1398 deps: h.deps,
1399 summary: h.summary.clone(),
1400 }
1401}
1402
1403fn third_party(path: &str) -> bool {
1405 path.split('/').any(|c| {
1406 matches!(
1407 c,
1408 "_vendor" | "vendor" | "vendored" | "third_party" | "node_modules" | "site-packages"
1409 )
1410 })
1411}
1412
1413fn node_bullet(o: &mut String, n: &NodeDto) {
1416 let _ = write!(o, "- **{}** _{}_ — `{}`", n.name, n.kind, n.location);
1417 if !n.repo.is_empty() {
1418 let _ = write!(o, " · {}", n.repo);
1419 }
1420 if let Some(s) = &n.summary {
1421 let _ = write!(o, " — {s}");
1422 }
1423 let _ = write!(o, " · id `{}`", n.id);
1425 o.push('\n');
1426}
1427
1428fn node_section(o: &mut String, title: &str, nodes: &[NodeDto]) {
1429 if nodes.is_empty() {
1430 return;
1431 }
1432 let _ = write!(o, "\n**{title}** ({})\n", nodes.len());
1433 for n in nodes {
1434 node_bullet(o, n);
1435 }
1436}
1437
1438fn md_repos(d: &ReposDto) -> String {
1439 let mut o = String::from("## Indexed repositories\n\n");
1440 if !d.teams.is_empty() {
1441 let _ = writeln!(o, "Teams: {}\n", d.teams.join(", "));
1442 }
1443 if d.repos.is_empty() {
1444 return o + "_none_\n";
1445 }
1446 for r in &d.repos {
1447 let teams = if r.teams.is_empty() {
1448 String::new()
1449 } else {
1450 format!(" · teams: {}", r.teams.join(", "))
1451 };
1452 let _ = writeln!(o, "- **{}** — {} symbols{teams}", r.repo, r.symbols);
1453 for dep in &r.depends_on {
1454 let _ = writeln!(
1455 o,
1456 " - uses `{}` from **{}** — locked {} vs published {}{}",
1457 dep.package,
1458 dep.publisher,
1459 dep.locked.as_deref().unwrap_or("?"),
1460 dep.published.as_deref().unwrap_or("?"),
1461 if dep.skew { " ⚠ skew" } else { "" }
1462 );
1463 }
1464 }
1465 o
1466}
1467
1468fn comindly_deps(rows: &[crate::manifests::PackageRow], repo: &str) -> Vec<DepDto> {
1469 crate::manifests::internal(rows)
1470 .into_iter()
1471 .filter(|d| d.consumer == repo)
1472 .map(|d| DepDto {
1473 skew: d.skewed(),
1474 package: d.name,
1475 publisher: d.publisher,
1476 published: d.published,
1477 locked: d.locked,
1478 })
1479 .collect()
1480}
1481
1482fn md_suggest(d: &SuggestDto) -> String {
1483 let shown = d.suggestions.len();
1484 let mut o = if d.total > shown {
1485 format!(
1486 "## Suggested questions ({shown} of {} — pass `about` to filter)\n\n",
1487 d.total
1488 )
1489 } else {
1490 format!("## Suggested questions ({shown})\n\n")
1491 };
1492 if d.suggestions.is_empty() {
1493 return o + "_none — index was built without `--enrich`, or no match_\n";
1494 }
1495 let mut cur = "";
1496 for it in &d.suggestions {
1497 if it.symbol != cur {
1498 let _ = write!(o, "\n**{}** `{}`\n", it.symbol, it.location);
1499 cur = &it.symbol;
1500 }
1501 let _ = writeln!(o, "- {}", it.query);
1502 }
1503 o
1504}
1505
1506fn md_find(query: &str, d: &FindDto) -> String {
1507 let mut o = format!("## `find` \"{query}\" — {} result(s)\n\n", d.results.len());
1508 if d.results.is_empty() {
1509 return o + "_no symbols matched_\n";
1510 }
1511 for n in &d.results {
1512 node_bullet(&mut o, n);
1513 }
1514 o
1515}
1516
1517fn md_zoom(focus: &str, d: &ZoomDto) -> String {
1518 if !d.found {
1519 return format!("## zoom `{focus}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n");
1520 }
1521 let mut o = String::new();
1522 if let Some(f) = &d.focus {
1523 let _ = writeln!(o, "## {} _{}_\n`{}`", f.name, f.kind, f.location);
1524 if let Some(sig) = &f.signature {
1525 let _ = writeln!(o, "\n```\n{sig}\n```");
1526 }
1527 if let Some(s) = &f.summary {
1528 let _ = writeln!(o, "\n{s}");
1529 }
1530 if !f.queries.is_empty() {
1531 let _ = writeln!(o, "\n**Ask:** {}", f.queries.join(" · "));
1532 }
1533 } else {
1534 let _ = writeln!(o, "## zoom `{focus}`");
1535 }
1536 if let Some(c) = &d.container {
1537 let _ = write!(o, "\n**Container**\n");
1538 node_bullet(&mut o, c);
1539 }
1540 node_section(&mut o, "Members", &d.members);
1541 node_section(&mut o, "Callers", &d.callers);
1542 node_section(&mut o, "Callees", &d.callees);
1543 node_section(&mut o, "Importers", &d.importers);
1544 if !d.known.is_empty() {
1545 o.push_str(
1546 "\n**Known constraints & dead ends** (team ledger — verify before relying on them)\n",
1547 );
1548 for k in &d.known {
1549 let _ = writeln!(o, "- {k}");
1550 }
1551 }
1552 node_section(&mut o, "Provided by", &d.providers);
1553 node_section(&mut o, "Used by", &d.consumers);
1554 if !d.contracts.is_empty() {
1555 let _ = write!(o, "\n**Contracts**\n");
1556 for c in &d.contracts {
1557 let _ = writeln!(o, "- {} `{}`", c.role, c.contract);
1558 for n in &c.counterparts {
1559 let _ = writeln!(o, " - **{}** ({}) `{}`", n.name, n.repo, n.location);
1560 }
1561 }
1562 }
1563 o
1564}
1565
1566fn md_contracts(d: &ContractsDto) -> String {
1567 let mut o = format!("## {} contract(s)\n", d.contracts.len());
1568 for c in &d.contracts {
1569 let _ = writeln!(o, "\n### `{}`", c.contract);
1570 if c.providers.is_empty() {
1571 o.push_str("- provided by: _none indexed_\n");
1572 }
1573 let both = |n: &NodeDto| c.consumers.iter().any(|m| m.id == n.id);
1575 let sides = c.providers.iter().map(|n| {
1576 (
1577 n,
1578 if both(n) {
1579 "provides and uses"
1580 } else {
1581 "provided by"
1582 },
1583 )
1584 });
1585 let users = c
1586 .consumers
1587 .iter()
1588 .filter(|n| !c.providers.iter().any(|m| m.id == n.id));
1589 for (n, label) in sides.chain(users.map(|n| (n, "used by"))) {
1590 let _ = writeln!(
1591 o,
1592 "- {label}: **{}** ({}) `{}` · id `{}`",
1593 n.name, n.repo, n.location, n.id
1594 );
1595 }
1596 if c.consumers.is_empty() {
1597 o.push_str("- used by: _none indexed_\n");
1598 }
1599 }
1600 o
1601}
1602
1603fn md_hop_lines(o: &mut String, hops: &[HopDto]) {
1604 for h in hops {
1605 let _ = writeln!(
1606 o,
1607 "- _d{}_ via **{}** — **{}** `{}`",
1608 h.depth, h.via, h.name, h.location
1609 );
1610 }
1611}
1612
1613fn md_ripple(d: &RippleDto) -> String {
1614 if !d.found {
1615 return format!("## Blast radius: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1616 }
1617 let mut o = format!(
1618 "## Blast radius: `{}`\n\n**{} dependent(s)**",
1619 d.focus, d.total_dependents
1620 );
1621 if !d.by_repo.is_empty() {
1622 let by: Vec<String> = d
1623 .by_repo
1624 .iter()
1625 .map(|r| format!("{} ({})", r.repo, r.symbols))
1626 .collect();
1627 let _ = write!(o, " across {}", by.join(", "));
1628 }
1629 if !d.by_team.is_empty() {
1630 let by: Vec<String> = d
1631 .by_team
1632 .iter()
1633 .map(|t| {
1634 let what = if t.repos == 1 { "repo" } else { "repos" };
1635 format!("{} ({} {what}, {} symbols)", t.team, t.repos, t.symbols)
1636 })
1637 .collect();
1638 let _ = write!(o, "\n\nTeams affected: {}", by.join(", "));
1639 }
1640 o.push_str("\n\n");
1641 md_hop_lines(&mut o, &d.dependents);
1642 o
1643}
1644
1645fn by_team(
1647 teams: &crate::team::TeamSettings,
1648 by_repo: &std::collections::BTreeMap<String, usize>,
1649) -> Vec<TeamCount> {
1650 if teams.team_list().is_empty() {
1651 return Vec::new();
1652 }
1653 let mut out: Vec<TeamCount> = Vec::new();
1654 for (repo, n) in by_repo {
1655 let mut names = teams.teams_of(repo);
1656 if names.is_empty() {
1657 names.push(crate::team::UNASSIGNED.to_string());
1658 }
1659 for name in names {
1660 match out.iter_mut().find(|t| t.team == name) {
1661 Some(t) => {
1662 t.repos += 1;
1663 t.symbols += n;
1664 }
1665 None => out.push(TeamCount {
1666 team: name,
1667 repos: 1,
1668 symbols: *n,
1669 }),
1670 }
1671 }
1672 }
1673 out.sort_by(|a, b| b.symbols.cmp(&a.symbols).then(a.team.cmp(&b.team)));
1674 out
1675}
1676
1677fn md_thread(d: &ThreadDto) -> String {
1678 let mut o = format!(
1679 "## Call trace from `{}` — {} step(s)\n\n",
1680 d.focus,
1681 d.trace.len()
1682 );
1683 if d.trace.is_empty() {
1684 return o + "_no outgoing calls found_\n";
1685 }
1686 md_hop_lines(&mut o, &d.trace);
1687 o
1688}
1689
1690fn md_flow(d: &FlowDto) -> String {
1691 if !d.found {
1692 return format!("## Flow: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1693 }
1694 let mut o = format!("## Flow: `{}`\n\n", d.focus);
1695 match &d.narration {
1696 Some(n) => {
1697 let _ = writeln!(o, "{n}\n");
1698 }
1699 None => {
1700 let _ = writeln!(
1701 o,
1702 "_no pre-generated narration (build the index with `--flows` to add one) — raw call trace below._\n"
1703 );
1704 }
1705 }
1706 if !d.queries.is_empty() {
1707 let _ = writeln!(o, "**Ask:** {}\n", d.queries.join(" · "));
1708 }
1709 let _ = writeln!(o, "**Call trace** ({} steps)", d.trace.len());
1710 md_hop_lines(&mut o, &d.trace);
1711 o
1712}
1713
1714fn md_pack(d: &PackDto) -> String {
1715 if !d.found {
1716 return format!("## Context pack: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1717 }
1718 let mut o = format!(
1719 "## Context to change `{}`\n\n_~{} of {} token budget · {} symbols_\n\n",
1720 d.focus,
1721 d.est_total_tokens,
1722 d.token_budget,
1723 d.items.len()
1724 );
1725 if !d.known.is_empty() {
1726 o.push_str(
1727 "**Known constraints & dead ends** (team ledger — verify before relying on them)\n",
1728 );
1729 for k in &d.known {
1730 let _ = writeln!(o, "- {k}");
1731 }
1732 o.push('\n');
1733 }
1734 for (i, it) in d.items.iter().enumerate() {
1735 let _ = write!(
1736 o,
1737 "{}. **{}** — `{}` _(~{} tok)_",
1738 i + 1,
1739 it.name,
1740 it.location,
1741 it.est_tokens
1742 );
1743 if let Some(s) = &it.summary {
1744 let _ = write!(o, " — {s}");
1745 }
1746 o.push('\n');
1747 }
1748 o
1749}
1750
1751fn md_guide(d: &GuideDto) -> String {
1752 let mut o = match &d.repo {
1753 Some(r) => format!("## Conventions — {r}\n"),
1754 None => "## Conventions — org baseline\n".to_string(),
1755 };
1756 if d.conventions.is_empty() {
1757 o.push_str("\n_none measured — re-run `comindly index`/`link` to store facts._\n");
1758 }
1759 let mut org_header = false;
1760 for c in &d.conventions {
1761 if c.scope == crate::conventions::ORG && d.repo.is_some() && !org_header {
1762 o.push_str("\n**Org baseline**\n");
1763 org_header = true;
1764 }
1765 let team = if c.team { " _(set by the team)_" } else { "" };
1766 let how = c
1767 .strength
1768 .map(|s| format!(" ({})", s.to_lowercase()))
1769 .unwrap_or_default();
1770 let _ = writeln!(o, "- _{}_{how}: {}{team}", c.dimension, c.rule);
1771 if !c.exemplars.is_empty() {
1772 let _ = writeln!(o, " - e.g. `{}`", c.exemplars.join("`, `"));
1773 }
1774 if !c.counterexamples.is_empty() {
1775 let _ = writeln!(o, " - deviations: `{}`", c.counterexamples.join("`, `"));
1776 }
1777 }
1778 if let Some(g) = &d.guide {
1779 let _ = write!(o, "\n## Narrative guide\n\n{g}\n");
1780 }
1781 o
1782}
1783
1784pub async fn serve_stdio(uri: &str, markdown: bool) -> Result<()> {
1788 let mut server = load(uri, markdown, crate::config::Config::load().fresh()).await?;
1789 server.signer = crate::config::local_signing_key();
1790 server.identity = crate::config::local_identity();
1791 server.agent_name = crate::config::env("COMINDLY_AGENT");
1792 if let Ok(t) = crate::team::load(&server.ledger_root()).await {
1793 server.teams = Arc::new(std::sync::RwLock::new(t.settings));
1794 }
1795 let service = server.serve(rmcp::transport::io::stdio()).await?;
1796 service.waiting().await?;
1797 Ok(())
1798}
1799
1800pub async fn load(uri: &str, markdown: bool, fresh: std::time::Duration) -> Result<ComindlyServer> {
1802 let (symbols, edges) = crate::index::read_graph(uri).await?;
1803 let mut conventions = match crate::index::read_facts(uri).await {
1804 Ok(Some(facts)) => crate::conventions::mine(&symbols, &edges, &facts),
1805 _ => Vec::new(),
1806 };
1807 conventions.extend(crate::index::read_recipes(uri).await.unwrap_or_default());
1808 let root = uri.trim_end_matches('/');
1809 let root = root.strip_suffix("/_graph").unwrap_or(root);
1810 let manual = match crate::conventions::Store::open(root) {
1811 Ok(s) => s.all().await.unwrap_or_default(),
1812 Err(_) => Vec::new(),
1813 };
1814 let conventions = crate::conventions::apply(conventions, &manual);
1815 let graph = Arc::new(CodeGraph::build(&symbols, &edges));
1816 let by_id: HashMap<String, Symbol> =
1817 symbols.iter().map(|s| (s.id.render(), s.clone())).collect();
1818
1819 let enrichment: Enrichment = crate::index::read_enrichment(uri)
1820 .await
1821 .ok()
1822 .flatten()
1823 .unwrap_or_default()
1824 .into_iter()
1825 .map(|(id, s, q)| (id.render(), (s, q)))
1826 .collect();
1827
1828 let style_guides = crate::index::read_style_guide(uri)
1830 .await
1831 .unwrap_or_default();
1832
1833 let flows: HashMap<String, (String, Vec<String>)> = crate::index::read_flows(uri)
1834 .await
1835 .ok()
1836 .flatten()
1837 .unwrap_or_default()
1838 .into_iter()
1839 .map(|(id, narr, q)| (id.render(), (narr, q)))
1840 .collect();
1841
1842 let model = crate::config::index_settings(root).await.embed_model;
1844 let embedder = match tokio::task::spawn_blocking(move || crate::embed::Embedder::shared(&model))
1845 .await
1846 .map_err(anyhow::Error::from)
1847 .and_then(|r| r)
1848 {
1849 Ok(e) => Some(e),
1850 Err(e) => {
1851 crate::ui::warn(&format!(
1852 "embedding model unavailable, `search` disabled: {e:#}"
1853 ));
1854 None
1855 }
1856 };
1857
1858 let server = ComindlyServer {
1859 graph,
1860 enrichment: Arc::new(enrichment),
1861 style_guides: Arc::new(style_guides),
1862 flows: Arc::new(flows),
1863 symbols: Arc::new(by_id),
1864 embedder,
1865 uri: Arc::new(uri.to_string()),
1866 tables: Arc::new(crate::index::Tables::connect(uri, fresh).await?),
1867 records: Arc::new(tokio::sync::Mutex::new(None)),
1868 packages: Arc::new(crate::index::read_packages(uri).await.unwrap_or_default()),
1869 conventions: Arc::new(conventions),
1870 edges: Arc::new(edges),
1871 markdown,
1872 signer: None,
1873 identity: Default::default(),
1874 agent_name: None,
1875 teams: Default::default(),
1876 };
1877 Ok(server)
1878}
1879
1880pub type Shared = Arc<std::sync::RwLock<Option<ComindlyServer>>>;
1882
1883pub fn http(
1885 current: Shared,
1886 hosts: Vec<String>,
1887) -> rmcp::transport::streamable_http_server::StreamableHttpService<
1888 ComindlyServer,
1889 rmcp::transport::streamable_http_server::session::local::LocalSessionManager,
1890> {
1891 let config = rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default()
1892 .with_allowed_hosts(hosts);
1893 rmcp::transport::streamable_http_server::StreamableHttpService::new(
1894 move || {
1895 current.read().ok().and_then(|s| s.clone()).ok_or_else(|| {
1896 std::io::Error::other("no index yet: an admin indexes repositories first")
1897 })
1898 },
1899 Default::default(),
1900 config,
1901 )
1902}
1903
1904pub fn tools() -> Vec<(String, String)> {
1906 ComindlyServer::tool_router()
1907 .list_all()
1908 .into_iter()
1909 .map(|t| {
1910 (
1911 t.name.to_string(),
1912 t.description.unwrap_or_default().to_string(),
1913 )
1914 })
1915 .collect()
1916}
1917
1918#[cfg(test)]
1919mod tests {
1920 use super::*;
1921
1922 #[test]
1923 fn a_contract_lists_each_symbol_once_with_its_id() {
1924 let node = |id: &str| NodeDto {
1925 id: id.into(),
1926 name: id.into(),
1927 kind: "function".into(),
1928 repo: "worker".into(),
1929 location: "w.py:1".into(),
1930 signature: None,
1931 summary: Some("long summary".into()),
1932 queries: vec!["a question".into()],
1933 };
1934 let md = md_contracts(&ContractsDto {
1935 contracts: vec![ContractDto {
1936 contract: "sqs:tasks".into(),
1937 providers: vec![node("retry"), node("send")],
1938 consumers: vec![node("retry"), node("run")],
1939 }],
1940 });
1941 assert_eq!(md.matches("**retry**").count(), 1);
1942 assert!(md.contains("provides and uses: **retry**"));
1943 assert!(md.contains("used by: **run** (worker) `w.py:1` · id `run`"));
1944 assert!(!md.contains("long summary") && !md.contains("a question"));
1945 }
1946
1947 #[test]
1948 fn vendored_code_is_third_party() {
1949 assert!(third_party("lib/_vendor/aioboto3/session.py"));
1950 assert!(third_party("web/node_modules/x/index.js"));
1951 assert!(!third_party("src/vendors.py"));
1952 }
1953
1954 #[test]
1955 fn impact_by_team() {
1956 let mut teams = crate::team::TeamSettings::default();
1957 teams.label("api", &["Backend".into(), "Payments".into()]);
1958 teams.label("web", &["Frontend".into()]);
1959 let by_repo = [
1960 ("api".to_string(), 3),
1961 ("web".into(), 1),
1962 ("tools".into(), 2),
1963 ]
1964 .into_iter()
1965 .collect();
1966 let got: Vec<(String, usize, usize)> = by_team(&teams, &by_repo)
1967 .into_iter()
1968 .map(|t| (t.team, t.repos, t.symbols))
1969 .collect();
1970 assert_eq!(
1971 got,
1972 [
1973 ("Backend".into(), 1, 3),
1974 ("Payments".into(), 1, 3),
1975 ("none".into(), 1, 2),
1976 ("Frontend".into(), 1, 1)
1977 ]
1978 );
1979 assert!(by_team(&Default::default(), &by_repo).is_empty());
1980 }
1981}