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