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