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