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 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 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 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 fn url(&self, path: &str) -> String {
118 format!("{}{}", self.connection.endpoint.trim_end_matches('/'), path)
119 }
120}
121
122#[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#[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#[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#[derive(Debug, Serialize, Deserialize)]
156pub struct IndexSyncEdge {
157 pub from_symbol: String,
158 pub to_symbol: String,
159 pub kind: String,
160}
161
162pub 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
182pub 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(¤t_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}