1use std::collections::HashMap;
22use std::fmt::Write as _;
23use std::sync::Arc;
24
25use crate::embed::Embedder;
26use crate::graph::{CodeGraph, Node as GNode};
27use crate::model::Symbol;
28use anyhow::Result;
29use rmcp::handler::server::wrapper::Parameters;
30use rmcp::model::{CallToolResult, ContentBlock};
31use rmcp::{schemars, tool, tool_router, ServiceExt};
32use serde::{Deserialize, Serialize};
33
34pub type Teams = Arc<std::sync::RwLock<crate::team::TeamSettings>>;
36
37type Cached = (std::time::Instant, Vec<crate::ledger::Record>);
39
40type Enrichment = HashMap<String, (String, Vec<String>)>;
42
43#[derive(Clone)]
44pub struct ComindlyServer {
45 graph: Arc<CodeGraph>,
46 enrichment: Arc<Enrichment>,
47 style_guides: Arc<Vec<(String, String)>>,
49 flows: Arc<HashMap<String, (String, Vec<String>)>>,
51 symbols: Arc<HashMap<String, Symbol>>,
53 embedder: Option<Arc<Embedder>>,
55 uri: Arc<String>,
56 tables: Arc<crate::index::Tables>,
58 records: Arc<tokio::sync::Mutex<Option<Cached>>>,
60 packages: Arc<Vec<crate::manifests::PackageRow>>,
62 conventions: Arc<Vec<crate::conventions::Convention>>,
64 edges: Arc<Vec<crate::model::Edge>>,
66 markdown: bool,
68 signer: Option<std::path::PathBuf>,
70 identity: crate::ledger::Identity,
72 agent_name: Option<String>,
74 pub teams: Teams,
76}
77
78impl ComindlyServer {
79 fn ledger_root(&self) -> String {
81 self.uri
82 .trim_end_matches('/')
83 .strip_suffix("/_graph")
84 .unwrap_or(&self.uri)
85 .to_string()
86 }
87
88 fn team_settings(&self) -> crate::team::TeamSettings {
89 self.teams.read().unwrap_or_else(|e| e.into_inner()).clone()
90 }
91
92 fn in_team(&self, team: Option<&str>, repo: &str) -> bool {
94 let sel = crate::team::parse_selection(team);
95 sel.is_empty() || self.team_settings().in_view(repo, &sel)
96 }
97
98 fn entries(
100 &self,
101 records: &[crate::ledger::Record],
102 symbols: &[Symbol],
103 ) -> Vec<crate::ledger::Entry> {
104 let mut e = crate::ledger::derive(records, symbols, &crate::config::local_verifier());
105 crate::ledger::weigh_teams(&mut e, records, &self.team_settings());
106 e
107 }
108
109 async fn records(&self) -> Vec<crate::ledger::Record> {
110 let mut cached = self.records.lock().await;
111 if let Some((at, r)) = cached.as_ref() {
112 if at.elapsed() < std::time::Duration::from_secs(60) {
113 return r.clone();
114 }
115 }
116 let r = match crate::ledger::Ledger::open(&self.ledger_root()) {
117 Ok(l) => l.all().await.unwrap_or_default(),
118 Err(_) => Vec::new(),
119 };
120 *cached = Some((std::time::Instant::now(), r.clone()));
121 r
122 }
123
124 async fn wrote(&self, rec: &crate::ledger::Record) {
126 if let Some((_, r)) = self.records.lock().await.as_mut() {
127 r.push(rec.clone());
128 }
129 }
130
131 fn may_write(ctx: &rmcp::service::RequestContext<rmcp::RoleServer>) -> anyhow::Result<()> {
135 let viewer = ctx
136 .extensions
137 .get::<axum::http::request::Parts>()
138 .and_then(|parts| parts.extensions.get::<crate::server::Who>())
139 .is_some_and(|w| !w.role.at_least(crate::server::Role::Member));
140 anyhow::ensure!(!viewer, "viewers can read, not write to the ledger");
141 Ok(())
142 }
143
144 fn agent(
145 &self,
146 ctx: &rmcp::service::RequestContext<rmcp::RoleServer>,
147 ) -> crate::ledger::Provenance {
148 let name = self.agent_name.clone().or_else(|| client_name(ctx));
149 let who = ctx
150 .extensions
151 .get::<axum::http::request::Parts>()
152 .and_then(|parts| parts.extensions.get::<crate::server::Who>());
153 let identity = match who {
154 Some(w) => crate::ledger::Identity {
155 actor: format!("actor-{}", &blake3::hash(w.email.as_bytes()).to_hex()[..10]),
156 name: Some(w.email.clone()),
157 },
158 None => self.identity.clone(),
159 };
160 let mut p = crate::ledger::Provenance::agent("mcp", name, &identity);
161 if let Some(w) = who {
162 p.teams = self.team_settings().teams_of_member(&w.email);
163 }
164 p
165 }
166
167 async fn known(&self, ids: &[String], limit: usize) -> Vec<String> {
169 let records = self.records().await;
170 if records.is_empty() {
171 return Vec::new();
172 }
173 let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
174 let entries = self.entries(&records, &symbols);
175 let mut about = crate::ledger::about(&entries, ids);
176 about.sort_by_key(|e| !e.outside.is_empty());
178 about
179 .into_iter()
180 .filter(|e| e.trust >= crate::ledger::Trust::T2)
181 .filter(|e| {
182 matches!(
183 e.status,
184 crate::ledger::Status::Active
185 | crate::ledger::Status::Moved
186 | crate::ledger::Status::Stale
187 )
188 })
189 .take(limit)
190 .map(crate::ledger::line)
191 .collect()
192 }
193
194 async fn write_record(
195 &self,
196 p: RememberParam,
197 by: crate::ledger::Provenance,
198 ) -> anyhow::Result<String> {
199 let kind = crate::ledger::Kind::parse(&p.kind)
200 .ok_or_else(|| anyhow::anyhow!("unknown kind `{}`", p.kind))?;
201 let commits = crate::index::read_repo_commits(&self.uri)
202 .await
203 .unwrap_or_default();
204 let mut anchors = Vec::new();
205 for focus in &p.on {
206 let idx = self
207 .graph
208 .lookup(focus)
209 .ok_or_else(|| anyhow::anyhow!("no symbol matches `{focus}` — use `find` first"))?;
210 let id = self.graph.node(idx).id;
211 let s = self
212 .symbols
213 .get(&id)
214 .ok_or_else(|| anyhow::anyhow!("symbol `{id}` not loaded"))?;
215 anchors.push(crate::ledger::Anchor::of(s).at(&commits));
216 }
217 let repos: Vec<&str> = anchors.iter().map(|a| a.repo.as_str()).collect();
218 let teams = self.team_settings();
219 teams.accepts(&by.teams, &repos)?;
220 let outside = teams.outside(&by.teams, &repos);
221 let commit = anchors.first().and_then(|a| a.commit.clone());
222 let mut rec = crate::ledger::Record::new(kind, &p.title, anchors, by);
223 rec.snippets = p
224 .snippets
225 .into_iter()
226 .map(|s| crate::ledger::Snippet {
227 path: s.path,
228 code: s.code,
229 commit: s.commit.or_else(|| commit.clone()),
230 })
231 .collect();
232 rec.body = p.body.unwrap_or_default();
233 rec.trigger = p.trigger;
234 rec.evidence = p.evidence;
235 rec.fields = p
236 .fields
237 .into_iter()
238 .map(|(k, v)| (k, serde_json::Value::String(v)))
239 .collect();
240 crate::ledger::Ledger::open(&self.ledger_root())?
241 .signed_by(self.signer.clone())
242 .put(&rec)
243 .await?;
244 self.wrote(&rec).await;
245 let until = match outside {
246 Some(owners) => format!("a member of {} reviews it", owners.join(" or ")),
247 None => "a human reviews it".into(),
248 };
249 Ok(format!(
250 "recorded {} `{}` (id `{}`); it is unconfirmed until {until}",
251 kind.as_str(),
252 rec.title,
253 rec.id
254 ))
255 }
256
257 async fn write_review(
258 &self,
259 p: ReviewParam,
260 by: crate::ledger::Provenance,
261 ) -> anyhow::Result<String> {
262 let ledger =
263 crate::ledger::Ledger::open(&self.ledger_root())?.signed_by(self.signer.clone());
264 let records = self.records().await;
265 anyhow::ensure!(
266 p.id.len() >= 6,
267 "give at least the first 6 characters of the record id"
268 );
269 let matches: Vec<_> = records
270 .iter()
271 .filter(|r| r.id == p.id || r.id.starts_with(&p.id))
272 .collect();
273 let target = match matches.as_slice() {
274 [one] => *one,
275 [] => anyhow::bail!("no ledger record `{}`", p.id),
276 _ => anyhow::bail!(
277 "`{}` matches {} records; give more of the id",
278 p.id,
279 matches.len()
280 ),
281 };
282 let reason = p.reason.as_deref().unwrap_or_default();
283 let rec = crate::ledger::Record::review(target, &p.action, reason, by)?;
284 ledger.put(&rec).await?;
285 self.wrote(&rec).await;
286 Ok(format!("{}: `{}`", p.action, target.title))
287 }
288
289 fn repo_of(&self, focus: &str) -> Option<String> {
291 if self.graph.repos().iter().any(|(r, _)| r == focus) {
292 return Some(focus.to_string());
293 }
294 if let Some(s) = self
295 .symbols
296 .values()
297 .find(|s| s.kind == crate::model::SymbolKind::File && s.file_path.ends_with(focus))
298 {
299 return Some(s.repo.0.clone());
300 }
301 self.graph.lookup(focus).map(|i| self.graph.node(i).repo)
302 }
303
304 fn node_dto(&self, n: GNode) -> NodeDto {
306 let enr = self.enrichment.get(&n.id);
307 NodeDto {
308 id: n.id,
309 name: n.name,
310 kind: n.kind,
311 repo: n.repo,
312 location: n.location,
313 signature: n.signature,
314 summary: enr.map(|(s, _)| s.clone()),
315 queries: enr.map(|(_, q)| q.clone()).unwrap_or_default(),
316 }
317 }
318
319 fn reply<T: Serialize>(&self, dto: &T, md: String) -> CallToolResult {
322 let value = serde_json::to_value(dto).ok();
323 let text = if self.markdown {
324 md
325 } else {
326 value
327 .as_ref()
328 .and_then(|v| serde_json::to_string_pretty(v).ok())
329 .unwrap_or_default()
330 };
331 let mut r = CallToolResult::success(vec![ContentBlock::text(text)]);
332 if !self.markdown {
334 r.structured_content = value;
335 }
336 r
337 }
338}
339
340#[derive(Serialize, schemars::JsonSchema)]
343struct NodeDto {
344 id: String,
345 name: String,
346 kind: String,
347 repo: String,
348 location: String,
350 signature: Option<String>,
351 summary: Option<String>,
353 #[serde(default, skip_serializing_if = "Vec::is_empty")]
355 queries: Vec<String>,
356}
357
358#[derive(Serialize, schemars::JsonSchema)]
359struct HopDto {
360 name: String,
361 kind: String,
362 repo: String,
363 location: String,
364 depth: usize,
365 via: String,
366}
367
368#[derive(Serialize, schemars::JsonSchema)]
369struct RepoCount {
370 repo: String,
371 symbols: usize,
372 #[serde(skip_serializing_if = "Vec::is_empty")]
374 teams: Vec<String>,
375 #[serde(skip_serializing_if = "Vec::is_empty")]
377 depends_on: Vec<DepDto>,
378}
379
380#[derive(Serialize, schemars::JsonSchema)]
381struct DepDto {
382 package: String,
383 publisher: String,
384 published: Option<String>,
385 locked: Option<String>,
386 skew: bool,
388}
389
390#[derive(Serialize, schemars::JsonSchema)]
391struct ReposDto {
392 repos: Vec<RepoCount>,
393 #[serde(skip_serializing_if = "Vec::is_empty")]
395 teams: Vec<String>,
396}
397
398#[derive(Serialize, schemars::JsonSchema)]
399struct TeamCount {
400 team: String,
401 repos: usize,
402 symbols: usize,
403}
404
405#[derive(Serialize, schemars::JsonSchema)]
406struct FindDto {
407 results: Vec<NodeDto>,
408}
409
410#[derive(Serialize, schemars::JsonSchema)]
411struct ThreadDto {
412 focus: String,
413 trace: Vec<HopDto>,
414}
415
416#[derive(Serialize, schemars::JsonSchema)]
417struct FlowDto {
418 focus: String,
419 found: bool,
420 narration: Option<String>,
422 queries: Vec<String>,
423 trace: Vec<HopDto>,
424}
425
426#[derive(Serialize, schemars::JsonSchema)]
427struct ZoomDto {
428 found: bool,
429 focus: Option<NodeDto>,
430 container: Option<NodeDto>,
431 callers: Vec<NodeDto>,
432 callees: Vec<NodeDto>,
433 importers: Vec<NodeDto>,
434 members: Vec<NodeDto>,
435 contracts: Vec<ContractRoleDto>,
437 providers: Vec<NodeDto>,
439 consumers: Vec<NodeDto>,
440 known: Vec<String>,
442}
443
444#[derive(Serialize, schemars::JsonSchema)]
445struct ContractRoleDto {
446 role: String,
447 contract: String,
448 counterparts: Vec<NodeDto>,
450}
451
452#[derive(Serialize, schemars::JsonSchema)]
453struct ContractDto {
454 contract: String,
455 providers: Vec<NodeDto>,
456 consumers: Vec<NodeDto>,
457}
458
459#[derive(Serialize, schemars::JsonSchema)]
460struct ContractsDto {
461 contracts: Vec<ContractDto>,
462}
463
464#[derive(Deserialize, schemars::JsonSchema, Default)]
465struct ContractsParam {
466 #[serde(default)]
468 kind: Option<String>,
469 #[serde(default)]
471 dangling: Option<bool>,
472 #[serde(default)]
474 query: Option<String>,
475 #[serde(default)]
477 team: Option<String>,
478}
479
480#[derive(Serialize, schemars::JsonSchema)]
481struct RippleDto {
482 focus: String,
483 found: bool,
484 total_dependents: usize,
485 by_repo: Vec<RepoCount>,
486 #[serde(skip_serializing_if = "Vec::is_empty")]
488 by_team: Vec<TeamCount>,
489 dependents: Vec<HopDto>,
490}
491
492#[derive(Serialize, schemars::JsonSchema)]
493struct PackItem {
494 name: String,
495 location: String,
496 est_tokens: usize,
497 summary: Option<String>,
498}
499
500#[derive(Serialize, schemars::JsonSchema)]
501struct GuideDto {
502 found: bool,
503 repo: Option<String>,
505 conventions: Vec<ConventionDto>,
507 guide: Option<String>,
509}
510
511#[derive(Serialize, schemars::JsonSchema)]
512struct ConventionDto {
513 scope: String,
514 dimension: String,
515 rule: String,
516 #[serde(skip_serializing_if = "Option::is_none")]
518 strength: Option<&'static str>,
519 #[serde(skip_serializing_if = "Option::is_none")]
521 evidence: Option<String>,
522 exemplars: Vec<String>,
523 counterexamples: Vec<String>,
524 #[serde(skip_serializing_if = "std::ops::Not::not")]
526 team: bool,
527}
528
529#[derive(Deserialize, schemars::JsonSchema, Default)]
530struct RememberParam {
531 kind: String,
533 on: Vec<String>,
535 title: String,
537 #[serde(default)]
538 body: Option<String>,
539 #[serde(default)]
541 trigger: Option<String>,
542 #[serde(default)]
544 evidence: Vec<String>,
545 #[serde(default)]
547 fields: std::collections::BTreeMap<String, String>,
548 #[serde(default)]
550 snippets: Vec<SnippetParam>,
551}
552
553#[derive(Deserialize, schemars::JsonSchema, Default)]
554struct SnippetParam {
555 path: String,
557 code: String,
558 #[serde(default)]
560 commit: Option<String>,
561}
562
563#[derive(Deserialize, schemars::JsonSchema, Default)]
564struct PreflightParam {
565 #[serde(default)]
567 files: Vec<String>,
568 #[serde(default)]
570 symbols: Vec<String>,
571}
572
573#[derive(Deserialize, schemars::JsonSchema, Default)]
574struct ReviewParam {
575 id: String,
577 action: String,
579 #[serde(default)]
580 reason: Option<String>,
581}
582
583#[derive(Serialize, schemars::JsonSchema)]
584struct LedgerDto {
585 ok: bool,
586 message: String,
587}
588
589#[derive(Deserialize, schemars::JsonSchema, Default)]
590struct GuideParam {
591 #[serde(default)]
593 focus: Option<String>,
594}
595
596#[derive(Serialize, schemars::JsonSchema)]
597struct PackDto {
598 focus: String,
599 found: bool,
600 est_total_tokens: usize,
601 token_budget: usize,
602 items: Vec<PackItem>,
603 known: Vec<String>,
605}
606
607#[derive(Serialize, schemars::JsonSchema)]
608struct SearchHitDto {
609 id: String,
611 name: String,
612 kind: String,
613 repo: String,
614 location: String,
615 signature: Option<String>,
616 score: f32,
617 deps: usize,
618 summary: Option<String>,
619}
620
621#[derive(Serialize, schemars::JsonSchema)]
622struct SearchDto {
623 query: String,
624 results: Vec<SearchHitDto>,
625}
626
627#[derive(Serialize, schemars::JsonSchema)]
628struct SuggestItem {
629 query: String,
630 symbol: String,
631 location: String,
632}
633
634#[derive(Serialize, schemars::JsonSchema)]
635struct SuggestDto {
636 total: usize,
637 suggestions: Vec<SuggestItem>,
638}
639
640#[derive(Deserialize, schemars::JsonSchema, Default)]
643struct ReposParam {
644 #[serde(default)]
646 team: Option<String>,
647}
648
649#[derive(Deserialize, schemars::JsonSchema, Default)]
650struct SearchParam {
651 query: String,
653 limit: Option<u32>,
655 #[serde(default)]
657 team: Option<String>,
658}
659
660#[derive(Deserialize, schemars::JsonSchema, Default)]
661struct SuggestParam {
662 about: Option<String>,
664 limit: Option<u32>,
666 #[serde(default)]
668 team: Option<String>,
669}
670
671#[derive(Deserialize, schemars::JsonSchema, Default)]
672struct FindParam {
673 query: String,
675 limit: Option<u32>,
677 #[serde(default)]
679 team: Option<String>,
680}
681
682#[derive(Deserialize, schemars::JsonSchema, Default)]
683struct FocusParam {
684 focus: String,
686}
687
688#[derive(Deserialize, schemars::JsonSchema, Default)]
689struct DepthParam {
690 focus: String,
692 depth: Option<u32>,
694}
695
696#[derive(Deserialize, schemars::JsonSchema, Default)]
697struct PackParam {
698 focus: String,
700 token_budget: Option<u32>,
702}
703
704#[tool_router(vis = "pub(crate)")]
707impl ComindlyServer {
708 #[tool(
709 name = "search",
710 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."
711 )]
712 async fn search(&self, Parameters(p): Parameters<SearchParam>) -> CallToolResult {
713 let limit = p.limit.unwrap_or(12) as usize;
714 let Some(embedder) = self.embedder.as_deref() else {
715 let dto = SearchDto {
716 query: p.query.clone(),
717 results: vec![],
718 };
719 return self.reply(
720 &dto,
721 "_search unavailable: the embedding model could not be loaded (offline?). Graph tools (find/zoom/ripple/thread/context_pack) still work._".into(),
722 );
723 };
724 let hits = crate::search::hybrid(
725 &self.tables,
726 &self.symbols,
727 &self.graph,
728 &self.enrichment,
729 embedder,
730 &p.query,
731 if p.team.is_some() { limit * 5 } else { limit },
732 )
733 .await
734 .unwrap_or_default();
735 let hits: Vec<_> = hits
736 .into_iter()
737 .filter(|h| self.in_team(p.team.as_deref(), &h.repo))
738 .take(limit)
739 .collect();
740 let md = crate::search::markdown(&p.query, &hits);
741 let dto = SearchDto {
742 query: p.query.clone(),
743 results: hits.iter().map(hit_dto).collect(),
744 };
745 self.reply(&dto, md)
746 }
747
748 #[tool(
749 name = "repos",
750 description = "List indexed repositories with their symbol counts and the teams labelling each; `team` narrows to some teams"
751 )]
752 fn repos(&self, Parameters(p): Parameters<ReposParam>) -> CallToolResult {
753 let teams = self.team_settings();
754 let dto = ReposDto {
755 repos: self
756 .graph
757 .repos()
758 .into_iter()
759 .filter(|(repo, _)| self.in_team(p.team.as_deref(), repo))
760 .map(|(repo, symbols)| RepoCount {
761 depends_on: comindly_deps(&self.packages, &repo),
762 teams: teams.teams_of(&repo),
763 repo,
764 symbols,
765 })
766 .collect(),
767 teams: teams.team_list().iter().map(|t| t.name.clone()).collect(),
768 };
769 let md = md_repos(&dto);
770 self.reply(&dto, md)
771 }
772
773 #[tool(
774 name = "suggest",
775 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."
776 )]
777 fn suggest(&self, Parameters(p): Parameters<SuggestParam>) -> CallToolResult {
778 let limit = p.limit.unwrap_or(40) as usize;
779 let about = p.about.as_deref().map(str::to_lowercase);
780 let mut items: Vec<(usize, SuggestItem)> = Vec::new();
781 for (id, (_, queries)) in self.enrichment.iter() {
782 if self
783 .symbols
784 .get(id)
785 .is_some_and(|s| third_party(&s.file_path))
786 {
787 continue;
788 }
789 let used = self.graph.dependents_count(id);
790 if p.team.is_some()
791 && !self
792 .symbols
793 .get(id)
794 .is_some_and(|s| self.in_team(p.team.as_deref(), &s.repo.0))
795 {
796 continue;
797 }
798 let (symbol, location) = self
799 .symbols
800 .get(id)
801 .map(|s| {
802 (
803 s.name.clone(),
804 format!("{}:{}", s.file_path, s.range.start.line),
805 )
806 })
807 .unwrap_or_else(|| (id.clone(), String::new()));
808 for q in queries {
809 if let Some(a) = &about {
810 if !q.to_lowercase().contains(a) && !symbol.to_lowercase().contains(a) {
811 continue;
812 }
813 }
814 items.push((
815 used,
816 SuggestItem {
817 query: q.clone(),
818 symbol: symbol.clone(),
819 location: location.clone(),
820 },
821 ));
822 }
823 }
824 items.sort_by(|(ua, a), (ub, b)| {
826 ub.cmp(ua)
827 .then(a.symbol.cmp(&b.symbol))
828 .then(a.query.cmp(&b.query))
829 });
830 let total = items.len();
831 let items: Vec<SuggestItem> = items.into_iter().take(limit).map(|(_, i)| i).collect();
832 let dto = SuggestDto {
833 total,
834 suggestions: items,
835 };
836 let md = md_suggest(&dto);
837 self.reply(&dto, md)
838 }
839
840 #[tool(
841 name = "find",
842 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."
843 )]
844 fn find(&self, Parameters(p): Parameters<FindParam>) -> CallToolResult {
845 let limit = p.limit.unwrap_or(20) as usize;
846 let dto = FindDto {
847 results: self
848 .graph
849 .find(&p.query, if p.team.is_some() { limit * 5 } else { limit })
850 .into_iter()
851 .filter(|n| self.in_team(p.team.as_deref(), &n.repo))
852 .take(limit)
853 .map(|n| self.node_dto(n))
854 .collect(),
855 };
856 let md = md_find(&p.query, &dto);
857 self.reply(&dto, md)
858 }
859
860 #[tool(
861 name = "zoom",
862 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."
863 )]
864 async fn zoom(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
865 let dto = match self.graph.lookup(&p.focus) {
866 None => ZoomDto {
867 found: false,
868 focus: None,
869 container: None,
870 callers: vec![],
871 callees: vec![],
872 importers: vec![],
873 members: vec![],
874 contracts: vec![],
875 providers: vec![],
876 consumers: vec![],
877 known: vec![],
878 },
879 Some(idx) => {
880 let z = self.graph.zoom(idx);
881 ZoomDto {
882 found: true,
883 focus: z.focus.map(|n| self.node_dto(n)),
884 container: z.container.map(|n| self.node_dto(n)),
885 callers: z.callers.into_iter().map(|n| self.node_dto(n)).collect(),
886 callees: z.callees.into_iter().map(|n| self.node_dto(n)).collect(),
887 importers: z.importers.into_iter().map(|n| self.node_dto(n)).collect(),
888 members: z.members.into_iter().map(|n| self.node_dto(n)).collect(),
889 contracts: z
890 .contracts
891 .into_iter()
892 .map(|c| ContractRoleDto {
893 role: c.role.to_string(),
894 contract: c.contract.name,
895 counterparts: c
896 .counterparts
897 .into_iter()
898 .map(|n| self.node_dto(n))
899 .collect(),
900 })
901 .collect(),
902 providers: z.providers.into_iter().map(|n| self.node_dto(n)).collect(),
903 consumers: z.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
904 known: match self.graph.scope_of(&p.focus) {
905 Some((_, ids)) => self.known(&ids, 5).await,
906 None => vec![],
907 },
908 }
909 }
910 };
911 let md = md_zoom(&p.focus, &dto);
912 self.reply(&dto, md)
913 }
914
915 #[tool(
916 name = "contracts",
917 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."
918 )]
919 fn contracts(&self, Parameters(p): Parameters<ContractsParam>) -> CallToolResult {
920 let views = self
921 .graph
922 .contracts(p.kind.as_deref(), p.dangling.unwrap_or(false));
923 let dto = ContractsDto {
924 contracts: views
925 .into_iter()
926 .filter(|v| {
927 p.query
928 .as_deref()
929 .is_none_or(|q| v.contract.name.contains(q))
930 })
931 .filter(|v| {
932 p.team.is_none()
933 || v.providers
934 .iter()
935 .chain(&v.consumers)
936 .any(|n| self.in_team(p.team.as_deref(), &n.repo))
937 })
938 .take(200)
939 .map(|v| ContractDto {
940 contract: v.contract.name,
941 providers: v.providers.into_iter().map(|n| self.node_dto(n)).collect(),
942 consumers: v.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
943 })
944 .collect(),
945 };
946 let md = md_contracts(&dto);
947 self.reply(&dto, md)
948 }
949
950 #[tool(
951 name = "why",
952 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."
953 )]
954 async fn why(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
955 let Some((name, ids)) = self.graph.scope_of(&p.focus) else {
956 let dto = LedgerDto {
957 ok: false,
958 message: format!("no symbol matches `{}`", p.focus),
959 };
960 let md = format!("_{}_", dto.message);
961 return self.reply(&dto, md);
962 };
963 let records = self.records().await;
964 let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
965 let entries = self.entries(&records, &symbols);
966 let about = crate::ledger::about(&entries, &ids);
967 let md = crate::ledger::markdown(&format!("why `{name}`"), &about);
968 let dto = LedgerDto {
969 ok: true,
970 message: md.clone(),
971 };
972 self.reply(&dto, md)
973 }
974
975 #[tool(
976 name = "preflight",
977 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."
978 )]
979 async fn preflight(&self, Parameters(p): Parameters<PreflightParam>) -> CallToolResult {
980 let mut changes = Vec::new();
981 for f in &p.files {
982 if let Some(s) = self.symbols.values().find(|s| {
983 s.kind == crate::model::SymbolKind::File
984 && (s.file_path == *f || f.ends_with(&format!("/{}", s.file_path)))
985 }) {
986 changes.push(crate::preflight::Change {
987 repo: s.repo.0.clone(),
988 path: s.file_path.clone(),
989 lines: Vec::new(),
990 });
991 }
992 }
993 for focus in &p.symbols {
994 let Some(idx) = self.graph.lookup(focus) else {
995 continue;
996 };
997 if let Some(s) = self.symbols.get(&self.graph.node(idx).id) {
998 changes.push(crate::preflight::Change {
999 repo: s.repo.0.clone(),
1000 path: s.file_path.clone(),
1001 lines: vec![(s.range.start.line, s.range.end.line)],
1002 });
1003 }
1004 }
1005 let records = self.records().await;
1006 let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
1007 let entries = self.entries(&records, &symbols);
1008 let findings = crate::checks::run(&entries, &symbols, &self.edges, &self.packages);
1009 let recipes: Vec<crate::conventions::Convention> = self
1010 .conventions
1011 .iter()
1012 .filter(|c| c.dimension == "recipes")
1013 .cloned()
1014 .collect();
1015 let md = if changes.is_empty() {
1016 "_no indexed files or symbols matched — pass repo-relative paths or names from `find`._"
1017 .to_string()
1018 } else {
1019 crate::preflight::report(
1020 &self.graph,
1021 &symbols,
1022 &self.edges,
1023 &entries,
1024 &recipes,
1025 &findings,
1026 &changes,
1027 ) + &crate::preflight::teams_affected(
1028 &self.graph,
1029 &symbols,
1030 &changes,
1031 &self.team_settings(),
1032 )
1033 };
1034 let dto = LedgerDto {
1035 ok: !changes.is_empty(),
1036 message: md.clone(),
1037 };
1038 self.reply(&dto, md)
1039 }
1040
1041 #[tool(
1042 name = "remember",
1043 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."
1044 )]
1045 async fn remember(
1046 &self,
1047 ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1048 Parameters(p): Parameters<RememberParam>,
1049 ) -> CallToolResult {
1050 let result = match Self::may_write(&ctx) {
1051 Ok(()) => self.write_record(p, self.agent(&ctx)).await,
1052 Err(e) => Err(e),
1053 };
1054 let dto = LedgerDto {
1055 ok: result.is_ok(),
1056 message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1057 };
1058 let md = dto.message.clone();
1059 self.reply(&dto, md)
1060 }
1061
1062 #[tool(
1063 name = "review",
1064 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."
1065 )]
1066 async fn review(
1067 &self,
1068 ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1069 Parameters(p): Parameters<ReviewParam>,
1070 ) -> CallToolResult {
1071 let result = match Self::may_write(&ctx) {
1072 Ok(()) => self.write_review(p, self.agent(&ctx)).await,
1073 Err(e) => Err(e),
1074 };
1075 let dto = LedgerDto {
1076 ok: result.is_ok(),
1077 message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1078 };
1079 let md = dto.message.clone();
1080 self.reply(&dto, md)
1081 }
1082
1083 #[tool(
1084 name = "ripple",
1085 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."
1086 )]
1087 fn ripple(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1088 let depth = p.depth.unwrap_or(4) as usize;
1089 let dto = match self.graph.lookup(&p.focus) {
1090 None => RippleDto {
1091 focus: p.focus.clone(),
1092 found: false,
1093 total_dependents: 0,
1094 by_repo: vec![],
1095 by_team: vec![],
1096 dependents: vec![],
1097 },
1098 Some(idx) => {
1099 let hops = self.graph.ripple(idx, depth);
1100 let teams = self.team_settings();
1101 let mut by_repo: std::collections::BTreeMap<String, usize> =
1102 std::collections::BTreeMap::new();
1103 for h in &hops {
1104 *by_repo.entry(h.node.repo.clone()).or_default() += 1;
1105 }
1106 RippleDto {
1107 focus: p.focus.clone(),
1108 found: true,
1109 total_dependents: hops.len(),
1110 by_team: by_team(&teams, &by_repo),
1111 by_repo: by_repo
1112 .into_iter()
1113 .map(|(repo, symbols)| RepoCount {
1114 teams: teams.teams_of(&repo),
1115 repo,
1116 symbols,
1117 depends_on: Vec::new(),
1118 })
1119 .collect(),
1120 dependents: hops.into_iter().map(hop_dto).collect(),
1121 }
1122 }
1123 };
1124 let md = md_ripple(&dto);
1125 self.reply(&dto, md)
1126 }
1127
1128 #[tool(
1129 name = "guide",
1130 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."
1131 )]
1132 fn guide(&self, Parameters(p): Parameters<GuideParam>) -> CallToolResult {
1133 let repo = p.focus.as_deref().and_then(|f| self.repo_of(f));
1134 let repo_keys: std::collections::HashSet<&str> = self
1135 .conventions
1136 .iter()
1137 .filter(|c| Some(&c.repo) == repo.as_ref())
1138 .map(|c| c.key.as_str())
1139 .collect();
1140 let mut conventions: Vec<ConventionDto> = Vec::new();
1141 for c in self.conventions.iter() {
1142 let scoped = Some(&c.repo) == repo.as_ref();
1143 let org = c.repo == crate::conventions::ORG && !repo_keys.contains(c.key.as_str());
1144 if scoped || org {
1145 conventions.push(ConventionDto {
1146 scope: c.repo.clone(),
1147 dimension: c.dimension.clone(),
1148 rule: c.rule.clone(),
1149 strength: c.strength(),
1150 evidence: c.evidence(),
1151 exemplars: c.exemplars.iter().take(2).cloned().collect(),
1152 counterexamples: c.counterexamples.iter().take(2).cloned().collect(),
1153 team: c.origin != crate::conventions::Origin::Measured,
1154 });
1155 }
1156 }
1157 let guide = match &repo {
1159 Some(r) => self
1160 .style_guides
1161 .iter()
1162 .find(|(g, _)| g == r)
1163 .map(|(_, g)| g.clone()),
1164 None => (!self.style_guides.is_empty()).then(|| {
1165 let names: Vec<&str> = self.style_guides.iter().map(|(r, _)| r.as_str()).collect();
1166 format!(
1167 "Written for each repository; pass `focus` to read one: {}.",
1168 names.join(", ")
1169 )
1170 }),
1171 };
1172 let dto = GuideDto {
1173 found: !conventions.is_empty() || guide.is_some(),
1174 repo,
1175 conventions,
1176 guide,
1177 };
1178 let md = md_guide(&dto);
1179 self.reply(&dto, md)
1180 }
1181
1182 #[tool(
1183 name = "thread",
1184 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)."
1185 )]
1186 fn thread(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1187 let depth = p.depth.unwrap_or(4) as usize;
1188 let trace = match self.graph.lookup(&p.focus) {
1189 Some(idx) => self
1190 .graph
1191 .thread(idx, depth)
1192 .into_iter()
1193 .map(hop_dto)
1194 .collect(),
1195 None => vec![],
1196 };
1197 let dto = ThreadDto {
1198 focus: p.focus.clone(),
1199 trace,
1200 };
1201 let md = md_thread(&dto);
1202 self.reply(&dto, md)
1203 }
1204
1205 #[tool(
1206 name = "flow",
1207 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."
1208 )]
1209 fn flow(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1210 let depth = p.depth.unwrap_or(4) as usize;
1211 let dto = match self.graph.lookup(&p.focus) {
1212 None => FlowDto {
1213 focus: p.focus.clone(),
1214 found: false,
1215 narration: None,
1216 queries: vec![],
1217 trace: vec![],
1218 },
1219 Some(idx) => {
1220 let fid = self.graph.zoom(idx).focus.map(|n| n.id);
1221 let (narration, queries) = fid
1222 .as_ref()
1223 .and_then(|id| self.flows.get(id))
1224 .map(|(n, q)| (Some(n.clone()), q.clone()))
1225 .unwrap_or((None, vec![]));
1226 let trace = self
1227 .graph
1228 .thread(idx, depth)
1229 .into_iter()
1230 .map(hop_dto)
1231 .collect();
1232 FlowDto {
1233 focus: p.focus.clone(),
1234 found: true,
1235 narration,
1236 queries,
1237 trace,
1238 }
1239 }
1240 };
1241 let md = md_flow(&dto);
1242 self.reply(&dto, md)
1243 }
1244
1245 #[tool(
1246 name = "context_pack",
1247 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."
1248 )]
1249 async fn context_pack(&self, Parameters(p): Parameters<PackParam>) -> CallToolResult {
1250 let budget = p.token_budget.unwrap_or(1500) as usize;
1251 let dto = match self.graph.lookup(&p.focus) {
1252 None => PackDto {
1253 focus: p.focus.clone(),
1254 found: false,
1255 est_total_tokens: 0,
1256 token_budget: budget,
1257 items: vec![],
1258 known: vec![],
1259 },
1260 Some(idx) => {
1261 let pack = self.graph.context_pack(idx, budget);
1262 let est_total_tokens = pack.iter().map(|(_, t)| t).sum();
1263 let mut ids: Vec<String> = pack.iter().map(|(n, _)| n.id.clone()).collect();
1264 if let Some((_, scope)) = self.graph.scope_of(&p.focus) {
1265 ids.extend(scope);
1266 }
1267 PackDto {
1268 focus: p.focus.clone(),
1269 found: true,
1270 est_total_tokens,
1271 token_budget: budget,
1272 known: self.known(&ids, 6).await,
1273 items: pack
1274 .into_iter()
1275 .map(|(n, t)| PackItem {
1276 summary: self.enrichment.get(&n.id).map(|(s, _)| s.clone()),
1277 name: n.name,
1278 location: n.location,
1279 est_tokens: t,
1280 })
1281 .collect(),
1282 }
1283 }
1284 };
1285 let md = md_pack(&dto);
1286 self.reply(&dto, md)
1287 }
1288}
1289
1290impl ComindlyServer {
1291 pub async fn as_agent(&self, tool: &str, args: serde_json::Value) -> Result<String> {
1293 let me = Self {
1294 markdown: true,
1295 ..self.clone()
1296 };
1297 macro_rules! args {
1298 () => {
1299 Parameters(serde_json::from_value(args)?)
1300 };
1301 }
1302 let r = match tool {
1303 "repos" => me.repos(args!()),
1304 "contracts" => me.contracts(args!()),
1305 "guide" => me.guide(args!()),
1306 "find" => me.find(args!()),
1307 "suggest" => me.suggest(args!()),
1308 "ripple" => me.ripple(args!()),
1309 "thread" => me.thread(args!()),
1310 "flow" => me.flow(args!()),
1311 "zoom" => me.zoom(args!()).await,
1312 "why" => me.why(args!()).await,
1313 "context_pack" => me.context_pack(args!()).await,
1314 _ => anyhow::bail!("`{tool}` is not a read-only tool"),
1315 };
1316 Ok(r.content
1317 .iter()
1318 .filter_map(|c| c.as_text().map(|t| t.text.as_str()))
1319 .collect::<Vec<_>>()
1320 .join("\n"))
1321 }
1322}
1323
1324#[rmcp::tool_handler(router = Self::tool_router())]
1325impl rmcp::ServerHandler for ComindlyServer {
1326 fn get_info(&self) -> rmcp::model::ServerConfig {
1327 rmcp::model::ServerConfig::new(
1328 rmcp::model::ServerCapabilities::builder()
1329 .enable_tools()
1330 .build(),
1331 )
1332 .with_server_info(rmcp::model::Implementation::from_build_env())
1333 .with_instructions(crate::agents::GUIDE)
1334 }
1335
1336 async fn call_tool(
1338 &self,
1339 request: rmcp::model::CallToolRequestParams,
1340 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1341 ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
1342 let tool = request.name.to_string();
1343 let parts = context.extensions.get::<axum::http::request::Parts>();
1344 let client = client_name(&context).unwrap_or_default();
1345 let email = parts
1346 .and_then(|p| p.extensions.get::<crate::server::Who>())
1347 .map(|w| w.email.clone());
1348 let start = std::time::Instant::now();
1349 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
1350 let out = Self::tool_router().call(tcc).await;
1351 let ms = start.elapsed().as_millis() as u64;
1352 let ok = out.is_ok();
1353 if !crate::telemetry::logs("tool") {
1354 return out;
1355 }
1356 tracing::info!(
1357 kind = "tool",
1358 tool = %tool,
1359 client = %client,
1360 email = email.as_deref(),
1361 ok,
1362 duration_ms = ms,
1363 "tool {tool} by {} {ms} ms",
1364 if client.is_empty() { "an unnamed client" } else { client.as_str() }
1365 );
1366 out
1367 }
1368}
1369
1370fn client_name(ctx: &rmcp::service::RequestContext<rmcp::RoleServer>) -> Option<String> {
1373 ctx.extensions
1374 .get::<axum::http::request::Parts>()
1375 .and_then(|p| p.extensions.get::<crate::server::GatewayClient>())
1376 .map(|c| c.0.clone())
1377 .or_else(|| {
1378 let own = rmcp::model::Implementation::from_build_env().name;
1379 ctx.peer
1380 .peer_info()
1381 .map(|p| p.client_info.name.clone())
1382 .filter(|n| *n != own)
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}