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 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 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 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 fn url(&self, path: &str) -> String {
147 format!("{}{}", self.connection.endpoint.trim_end_matches('/'), path)
148 }
149}
150
151#[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#[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#[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#[derive(Debug, Serialize, Deserialize)]
185pub struct IndexSyncEdge {
186 pub from_symbol: String,
187 pub to_symbol: String,
188 pub kind: String,
189}
190
191pub 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
220pub 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(¤t_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}