Skip to main content

lean_ctx/
cloud_client.rs

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