Skip to main content

lean_ctx/core/gateway/adapters/
mod.rs

1//! L4 typed adapters (deeper addon integration, #1096–#1101).
2//!
3//! Where the generic pipeline ([`super::postprocess`]) treats addon output as
4//! opaque text, a *typed* adapter understands a category's payload and folds it
5//! into the matching lean-ctx store or retrieval path:
6//!
7//! | category        | example addons            | what the adapter does                         |
8//! |-----------------|---------------------------|-----------------------------------------------|
9//! | `codebase-pack` | Repomix                   | pack → archive handle (`ctx_expand`)          |
10//! | `code-graph`    | Graphify                  | nodes/edges → property graph (`ctx_callgraph`) |
11//! | `code-symbols`  | Serena                    | references → property-graph call edges         |
12//! | `memory`        | Mem0/OpenMemory/Cognee    | memories → `ctx_knowledge` facts               |
13//! | `compression`   | Headroom/RTK              | downstream as a named `Compressor`             |
14//!
15//! Routing is config-driven: the owning `[[gateway.servers]]` entry carries an
16//! `integration` slug (set at install from the addon's category, or by hand),
17//! which the proxy already has in scope — no catalog lookup on the hot path.
18
19pub mod code_graph;
20pub mod code_symbols;
21pub mod codebase_pack;
22pub mod compression;
23pub mod memory;
24
25/// The category of deep integration applied to one downstream server's output.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum IntegrationKind {
28    /// No typed adapter — generic L1–L3 only.
29    None,
30    /// Repository packer (Repomix): pack → retrievable handle.
31    CodebasePack,
32    /// Code-graph tool (Graphify): nodes/edges → property graph.
33    CodeGraph,
34    /// Symbol/LSP tool (Serena): references → property-graph edges.
35    CodeSymbols,
36    /// Memory tool (Mem0/Cognee/Letta): memories → knowledge facts.
37    Memory,
38    /// Compressor (Headroom/RTK): registered as a named lean-ctx compressor.
39    Compression,
40}
41
42impl IntegrationKind {
43    /// Parse a canonical (or common-alias) integration slug.
44    #[must_use]
45    pub fn parse(s: &str) -> Self {
46        match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
47            "codebase-pack" | "pack" | "repomix" => Self::CodebasePack,
48            "code-graph" | "graph" | "callgraph" => Self::CodeGraph,
49            "code-symbols" | "symbols" | "lsp" => Self::CodeSymbols,
50            "memory" | "mem" => Self::Memory,
51            "compression" | "compress" | "compressor" => Self::Compression,
52            _ => Self::None,
53        }
54    }
55
56    /// First recognizable adapter among an addon's free-form categories.
57    #[must_use]
58    pub fn from_categories(categories: &[String]) -> Self {
59        categories
60            .iter()
61            .map(|c| Self::parse(c))
62            .find(|k| !k.is_none())
63            .unwrap_or(Self::None)
64    }
65
66    /// Canonical slug (round-trips through [`Self::parse`]).
67    #[must_use]
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Self::None => "none",
71            Self::CodebasePack => "codebase-pack",
72            Self::CodeGraph => "code-graph",
73            Self::CodeSymbols => "code-symbols",
74            Self::Memory => "memory",
75            Self::Compression => "compression",
76        }
77    }
78
79    #[must_use]
80    pub fn is_none(self) -> bool {
81        matches!(self, Self::None)
82    }
83}
84
85/// Side-channel ingestion: spawn a typed background job that folds the output
86/// into the matching store. Returns `true` when a typed adapter handled it (the
87/// caller then skips the generic L3 indexer); `false` to fall through to L3.
88#[must_use]
89pub fn ingest_spawn(
90    kind: IntegrationKind,
91    server: &str,
92    tool: &str,
93    text: &str,
94    project_root: &str,
95) -> bool {
96    let (server, tool, text, root) = (
97        server.to_string(),
98        tool.to_string(),
99        text.to_string(),
100        project_root.to_string(),
101    );
102    match kind {
103        IntegrationKind::CodeGraph => {
104            std::thread::spawn(move || code_graph::ingest(&server, &tool, &text, &root));
105            true
106        }
107        IntegrationKind::CodeSymbols => {
108            std::thread::spawn(move || code_symbols::ingest(&server, &tool, &text, &root));
109            true
110        }
111        IntegrationKind::Memory => {
112            std::thread::spawn(move || memory::ingest(&server, &tool, &text, &root));
113            true
114        }
115        // codebase-pack / compression keep the generic L3 indexer.
116        IntegrationKind::None | IntegrationKind::CodebasePack | IntegrationKind::Compression => {
117            false
118        }
119    }
120}
121
122/// Model-facing text transform applied before the generic L1/L2 path. Returns
123/// `Some(text)` when a typed adapter rewrote the output, else `None`.
124#[must_use]
125pub fn transform(
126    kind: IntegrationKind,
127    server: &str,
128    tool: &str,
129    text: &str,
130    budget_tokens: usize,
131) -> Option<String> {
132    match kind {
133        IntegrationKind::CodebasePack => {
134            codebase_pack::transform(server, tool, text, budget_tokens)
135        }
136        _ => None,
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn parses_canonical_and_aliases() {
146        assert_eq!(
147            IntegrationKind::parse("codebase-pack"),
148            IntegrationKind::CodebasePack
149        );
150        assert_eq!(
151            IntegrationKind::parse("repomix"),
152            IntegrationKind::CodebasePack
153        );
154        assert_eq!(IntegrationKind::parse("GRAPH"), IntegrationKind::CodeGraph);
155        assert_eq!(IntegrationKind::parse("mem"), IntegrationKind::Memory);
156        assert_eq!(
157            IntegrationKind::parse("compressor"),
158            IntegrationKind::Compression
159        );
160        assert_eq!(IntegrationKind::parse("whatever"), IntegrationKind::None);
161    }
162
163    #[test]
164    fn slug_round_trips() {
165        for k in [
166            IntegrationKind::CodebasePack,
167            IntegrationKind::CodeGraph,
168            IntegrationKind::CodeSymbols,
169            IntegrationKind::Memory,
170            IntegrationKind::Compression,
171        ] {
172            assert_eq!(IntegrationKind::parse(k.as_str()), k);
173        }
174    }
175
176    #[test]
177    fn from_categories_finds_first_match() {
178        let cats = vec!["workflow".into(), "graph".into(), "search".into()];
179        assert_eq!(
180            IntegrationKind::from_categories(&cats),
181            IntegrationKind::CodeGraph
182        );
183        let none = vec!["workflow".into(), "plans".into()];
184        assert_eq!(
185            IntegrationKind::from_categories(&none),
186            IntegrationKind::None
187        );
188    }
189
190    #[test]
191    fn untyped_kinds_do_not_claim_ingestion() {
192        assert!(!ingest_spawn(IntegrationKind::None, "s", "t", "x", "/tmp"));
193        assert!(!ingest_spawn(
194            IntegrationKind::CodebasePack,
195            "s",
196            "t",
197            "x",
198            "/tmp"
199        ));
200        assert!(!ingest_spawn(
201            IntegrationKind::Compression,
202            "s",
203            "t",
204            "x",
205            "/tmp"
206        ));
207    }
208}