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