Skip to main content

lean_ctx/
server_client.rs

1use crate::config;
2use crate::models::{
3    ProjectContext, ProjectResolutionRequest, ProjectResolutionResponse, ServerConnection,
4    TelemetryIngestRequest, ToolCallRequest, ToolCallResponse, ToolListResponse,
5};
6use anyhow::{anyhow, Context, Result};
7use serde::de::DeserializeOwned;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11pub struct ServerClient {
12    connection: ServerConnection,
13}
14
15impl ServerClient {
16    pub fn load() -> Result<Self> {
17        let connection = config::load_connection()?.ok_or_else(|| {
18            anyhow!("No server connection saved. Run `nebu-ctx connect --endpoint <url> --token <token>`.")
19        })?;
20        Ok(Self { connection })
21    }
22
23    pub fn new(connection: ServerConnection) -> Self {
24        Self { connection }
25    }
26
27    pub fn endpoint(&self) -> &str {
28        &self.connection.endpoint
29    }
30
31    pub fn health(&self) -> Result<Value> {
32        self.get_json("/health")
33    }
34
35    pub fn manifest(&self) -> Result<Value> {
36        self.get_json("/v1/manifest")
37    }
38
39    pub fn list_tools(&self) -> Result<ToolListResponse> {
40        self.get_json("/v1/tools")
41    }
42
43    pub fn resolve_project(
44        &self,
45        project_context: &ProjectContext,
46    ) -> Result<ProjectResolutionResponse> {
47        if !project_context.fingerprint.has_safe_identity() {
48            return Err(anyhow!(
49                "Project resolution requires a repository fingerprint with a remote URL or host/owner/repo."
50            ));
51        }
52
53        self.post_json(
54            "/v1/projects/resolve",
55            &ProjectResolutionRequest {
56                fingerprint: project_context.fingerprint.clone(),
57                suggested_slug: Some(project_context.project_slug.clone()),
58                checkout_binding: Some(project_context.checkout_binding.clone()),
59                project_metadata: project_context.project_metadata.clone(),
60            },
61        )
62    }
63
64    pub fn call_tool(
65        &self,
66        tool_name: &str,
67        arguments: Map<String, Value>,
68        project_context: &ProjectContext,
69    ) -> Result<Value> {
70        let repository_fingerprint = project_context
71            .fingerprint
72            .has_safe_identity()
73            .then(|| project_context.fingerprint.clone());
74
75        let response: ToolCallResponse = self.post_json(
76            "/v1/tools/call",
77            &ToolCallRequest {
78                name: tool_name.to_string(),
79                arguments,
80                project_id: None,
81                project_slug: Some(project_context.project_slug.clone()),
82                repository_fingerprint,
83                checkout_binding: Some(project_context.checkout_binding.clone()),
84                project_metadata: project_context.project_metadata.clone(),
85            },
86        )?;
87
88        Ok(response.result)
89    }
90
91    /// Posts a single tool-call telemetry event to the server for dashboard aggregation.
92    /// Only token counts and metadata are sent; no raw content.
93    pub fn ingest_telemetry(&self, request: &TelemetryIngestRequest) -> Result<()> {
94        let _: serde_json::Value = self.post_json("/v1/telemetry/ingest", request)?;
95        Ok(())
96    }
97
98    /// Syncs the full project code index (files, symbols, call edges) to the server.
99    /// Returns the number of files, symbols, and edges successfully synced.
100    pub fn sync_index(&self, request: &IndexSyncPayload) -> Result<serde_json::Value> {
101        self.post_json("/v1/index/sync", request)
102    }
103
104    fn get_json<T>(&self, path: &str) -> Result<T>
105    where
106        T: DeserializeOwned,
107    {
108        let response = ureq::get(&self.url(path))
109            .header(
110                "Authorization",
111                &format!("Bearer {}", self.connection.token.trim()),
112            )
113            .call()
114            .map_err(|error| anyhow!("Request to {} failed: {}", self.url(path), error))?;
115        Self::read_json(response)
116    }
117
118    fn post_json<TResponse, TRequest>(&self, path: &str, request: &TRequest) -> Result<TResponse>
119    where
120        TResponse: DeserializeOwned,
121        TRequest: Serialize,
122    {
123        let body = serde_json::to_vec(request).context("failed to serialize request")?;
124        let response = ureq::post(&self.url(path))
125            .header(
126                "Authorization",
127                &format!("Bearer {}", self.connection.token.trim()),
128            )
129            .header("Content-Type", "application/json")
130            .send(body.as_slice())
131            .map_err(|error| anyhow!("Request to {} failed: {}", self.url(path), error))?;
132        Self::read_json(response)
133    }
134
135    /// Deserializes a JSON response body into a target type.
136    fn read_json<T>(response: ureq::http::Response<ureq::Body>) -> Result<T>
137    where
138        T: DeserializeOwned,
139    {
140        let mut body = response.into_body();
141        let payload = body.read_to_string().context("failed to read response body")?;
142        serde_json::from_str(&payload).context("failed to parse server response")
143    }
144
145    /// Combines the normalized endpoint and a relative API path.
146    fn url(&self, path: &str) -> String {
147        format!("{}{}", self.connection.endpoint.trim_end_matches('/'), path)
148    }
149}
150
151/// Payload for syncing a project's code index to the server.
152#[derive(Debug, Serialize, Deserialize)]
153pub struct IndexSyncPayload {
154    pub project_id: String,
155    pub files: Vec<IndexSyncFile>,
156    pub symbols: Vec<IndexSyncSymbol>,
157    pub edges: Vec<IndexSyncEdge>,
158}
159
160/// A single file entry in the index sync payload.
161#[derive(Debug, Serialize, Deserialize)]
162pub struct IndexSyncFile {
163    pub path: String,
164    pub hash: String,
165    pub language: String,
166    pub line_count: usize,
167    pub token_count: usize,
168    pub exports: Vec<String>,
169    pub summary: String,
170}
171
172/// A single symbol entry in the index sync payload.
173#[derive(Debug, Serialize, Deserialize)]
174pub struct IndexSyncSymbol {
175    pub file_path: String,
176    pub name: String,
177    pub kind: String,
178    pub start_line: usize,
179    pub end_line: usize,
180    pub is_exported: bool,
181}
182
183/// A single call edge in the index sync payload.
184#[derive(Debug, Serialize, Deserialize)]
185pub struct IndexSyncEdge {
186    pub from_symbol: String,
187    pub to_symbol: String,
188    pub kind: String,
189}
190
191/// Posts every current, high-confidence fact from local `knowledge.json` to the
192/// server-backed `ctx_knowledge` store. Called after auto-consolidation and from
193/// `handle_stop()` to keep PostgreSQL in sync with the local session outcome.
194/// Silently returns if the server is not configured or any call fails.
195pub fn post_knowledge_to_server(project_root: &str) {
196    let Ok(client) = ServerClient::load() else {
197        return;
198    };
199    let ctx = crate::git_context::discover_project_context(std::path::Path::new(project_root));
200    let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(project_root);
201
202    for fact in knowledge
203        .facts
204        .iter()
205        .filter(|f| f.is_current() && f.confidence >= 0.7)
206    {
207        let mut args = Map::new();
208        args.insert("action".to_string(), Value::String("remember".to_string()));
209        args.insert("category".to_string(), Value::String(fact.category.clone()));
210        args.insert("key".to_string(), Value::String(fact.key.clone()));
211        args.insert("value".to_string(), Value::String(fact.value.clone()));
212        args.insert(
213            "confidence".to_string(),
214            serde_json::json!(fact.confidence),
215        );
216        let _ = client.call_tool("ctx_knowledge", args, &ctx);
217    }
218}
219
220/// Posts a session summary to `ctx_brain` when a session is saved.
221/// Silently returns if the server is not configured.
222pub fn post_session_to_brain(session: &crate::core::session::SessionState) {
223    let Ok(client) = ServerClient::load() else {
224        return;
225    };
226    let current_dir = std::env::current_dir().unwrap_or_default();
227    let ctx = crate::git_context::discover_project_context(&current_dir);
228
229    let task = session
230        .task
231        .as_ref()
232        .map(|t| t.description.as_str())
233        .unwrap_or("(no task)");
234    let summary = format!(
235        "session={} task=\"{}\" calls={} tokens_saved={} decisions={} findings={}",
236        session.id,
237        task,
238        session.stats.total_tool_calls,
239        session.stats.total_tokens_saved,
240        session.decisions.len(),
241        session.findings.len(),
242    );
243    let key = format!("session-{}", session.id);
244
245    let mut args = Map::new();
246    args.insert("action".to_string(), Value::String("store".to_string()));
247    args.insert("key".to_string(), Value::String(key));
248    args.insert("value".to_string(), Value::String(summary));
249    let _ = client.call_tool("ctx_brain", args, &ctx);
250}