Transform el-ide into AI-native IDE: Neuron pair programming, live type graph, semantic autocomplete, activation preview

- Neuron pair programming panel (right column, 300px): full conversation interface
  with code-block rendering, "Insert at cursor" for AI suggestions, quick actions
  (explain selection, find activations, analyze types), thinking indicator
- Live activation preview: floating inline widget when cursor is inside
  `activate TypeName where "..."` expressions; queries /api/lsp/activate-preview
- Type graph activation highlighting: `activate` statements cause named types to
  pulse with purple glow rings; animation loop runs while activations are present
- Knowledge Graph Explorer: new bottom panel tab; search the Engram DB for semantic
  concepts, insert activate expressions, ask Neuron about nodes
- Semantic autocomplete: completions with score >= 0.8 get "⟁ semantic" prefix and
  priority boost in the completion list
- Backend: /api/reason extended to support three modes: "hypothesis" (existing),
  "pair" (proxies to soma /v1/chat/completions with full file context + system
  prompt), "knowledge-search" (proxies to Engram DB search endpoint)
- Backend: new GET /api/lsp/activate-preview endpoint queries Engram for nodes
  matching a partial activate expression
- Type graph node click now navigates editor to the type definition
- Resolved merge conflicts in engram-lang/crates (ast.rs, parser.rs, checker.rs,
  types.rs): took union of HEAD and worktree-agent branches
This commit is contained in:
Will Anderson
2026-04-28 11:40:36 -05:00
parent 602cd1586a
commit ea0de16562
5 changed files with 4259 additions and 315 deletions
+162 -7
View File
@@ -65,6 +65,7 @@ dependencies = [
"async-trait",
"axum-core 0.4.5",
"axum-macros",
"base64",
"bytes",
"futures-util",
"http",
@@ -83,8 +84,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -196,6 +199,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -263,6 +272,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -298,12 +313,13 @@ dependencies = [
"axum 0.7.9",
"el-lsp",
"el-plugin-host",
"futures-util",
"mime_guess",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tower",
@@ -318,7 +334,7 @@ dependencies = [
name = "el-lexer"
version = "0.1.0"
dependencies = [
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -330,7 +346,7 @@ dependencies = [
"el-types",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -338,7 +354,7 @@ name = "el-parser"
version = "0.1.0"
dependencies = [
"el-lexer",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -347,7 +363,7 @@ version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -356,7 +372,7 @@ version = "0.1.0"
dependencies = [
"el-lexer",
"el-parser",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -447,6 +463,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
@@ -466,6 +493,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
@@ -1069,6 +1098,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -1103,6 +1141,36 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1408,6 +1476,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -1543,13 +1622,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -1641,6 +1740,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -1793,6 +1904,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.0"
@@ -1841,6 +1970,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -2283,6 +2418,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.7"
+87 -3
View File
@@ -1,14 +1,16 @@
//! LSP API endpoints — completions, hover, errors.
//! LSP API endpoints — completions, hover, errors, activate-preview.
use axum::{
extract::Query,
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use el_lsp::{Completion, Diagnostic, HoverInfo, LanguageServer};
use crate::AppState;
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
// ── Query types ───────────────────────────────────────────────────────────────
@@ -19,6 +21,27 @@ pub struct SourceQuery {
pub pos: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct ActivatePreviewQuery {
pub type_name: String,
pub query: String,
pub source: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ActivatePreviewResponse {
pub count: usize,
pub nodes: Vec<ActivateNode>,
pub connected: bool,
}
#[derive(Debug, Serialize)]
pub struct ActivateNode {
pub id: String,
pub label: String,
pub score: f64,
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// GET /api/lsp/complete?source=...&pos=...
@@ -49,3 +72,64 @@ pub async fn errors(
let diags = lsp.diagnostics(&q.source);
Ok(Json(diags))
}
/// GET /api/lsp/activate-preview?type_name=...&query=...&source=...
///
/// Returns a live preview of what nodes would activate for the given
/// `activate TypeName where "query"` expression.
pub async fn activate_preview(
State(state): State<AppState>,
Query(q): Query<ActivatePreviewQuery>,
) -> ApiResult<ActivatePreviewResponse> {
let engram_url = &state.config.engram_url;
// Try to query the Engram DB for matching nodes
let client = reqwest::Client::new();
let url = format!("{engram_url}/api/activate-preview");
let body = serde_json::json!({
"type_name": q.type_name,
"query": q.query,
"limit": 5,
});
match client
.post(&url)
.json(&body)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp.json().await.unwrap_or_default();
let nodes: Vec<ActivateNode> = json["nodes"]
.as_array()
.map(|arr| {
arr.iter()
.map(|n| ActivateNode {
id: n["id"].as_str().unwrap_or("").to_string(),
label: n["label"].as_str().unwrap_or("").to_string(),
score: n["score"].as_f64().unwrap_or(0.0),
})
.collect()
})
.unwrap_or_default();
let count = json["count"].as_u64().unwrap_or(nodes.len() as u64) as usize;
Ok(Json(ActivatePreviewResponse {
count,
nodes,
connected: true,
}))
}
_ => {
// Engram not connected — return stub indicating disconnected state
Ok(Json(ActivatePreviewResponse {
count: 0,
nodes: vec![],
connected: false,
}))
}
}
}
+339 -12
View File
@@ -1,4 +1,4 @@
//! Reasoning API — proxy to engram-server for hypothesis evaluation.
//! Reasoning API — pair programming with Neuron + hypothesis evaluation.
use axum::{
extract::State,
@@ -15,18 +15,38 @@ fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_
(code, Json(serde_json::json!({ "error": msg.into() })))
}
// ── Hypothesis evaluation (legacy) ───────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct ReasonRequest {
pub hypothesis: String,
pub hypothesis: Option<String>,
pub context: Option<String>,
// Pair programming fields
pub message: Option<String>,
pub selection: Option<String>,
pub conversation_history: Option<Vec<ConversationMessage>>,
pub mode: Option<String>, // "hypothesis" | "pair" | "knowledge-search"
pub query: Option<String>, // for knowledge-search mode
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Serialize)]
pub struct ReasonResponse {
pub verdict: String,
pub confidence: f64,
pub verdict: Option<String>,
pub confidence: Option<f64>,
pub evidence: Vec<EvidenceItem>,
pub source: String,
// Pair programming fields
pub message: Option<String>,
pub code_blocks: Vec<CodeBlock>,
pub suggestions: Vec<String>,
// Knowledge search fields
pub nodes: Vec<KnowledgeNode>,
}
#[derive(Debug, Serialize)]
@@ -35,18 +55,51 @@ pub struct EvidenceItem {
pub weight: f64,
}
/// POST /api/reason — proxy to engram-server reasoning endpoint.
#[derive(Debug, Serialize)]
pub struct CodeBlock {
pub language: String,
pub code: String,
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct KnowledgeNode {
pub id: String,
pub label: String,
pub node_type: String,
pub score: f64,
}
/// POST /api/reason — multi-mode endpoint:
/// - mode "hypothesis" (default): proxy to engram-server for hypothesis evaluation
/// - mode "pair": pair programming with Neuron (proxies to soma chat endpoint)
/// - mode "knowledge-search": search Engram knowledge graph
pub async fn reason(
State(state): State<AppState>,
Json(req): Json<ReasonRequest>,
) -> ApiResult<ReasonResponse> {
let mode = req.mode.as_deref().unwrap_or("hypothesis");
match mode {
"pair" => handle_pair_programming(state, req).await,
"knowledge-search" => handle_knowledge_search(state, req).await,
_ => handle_hypothesis(state, req).await,
}
}
// ── Hypothesis mode ───────────────────────────────────────────────────────────
async fn handle_hypothesis(
state: AppState,
req: ReasonRequest,
) -> ApiResult<ReasonResponse> {
let hypothesis = req.hypothesis.clone().unwrap_or_default();
let engram_url = &state.config.engram_url;
let url = format!("{engram_url}/api/reason");
// Attempt to proxy to engram-server; fall back to stub if unavailable.
let client = reqwest::Client::new();
let body = serde_json::json!({
"hypothesis": req.hypothesis,
"hypothesis": hypothesis,
"context": req.context,
});
@@ -55,8 +108,8 @@ pub async fn reason(
let json: serde_json::Value = resp.json().await
.map_err(|e| api_err(StatusCode::BAD_GATEWAY, format!("invalid response: {e}")))?;
Ok(Json(ReasonResponse {
verdict: json["verdict"].as_str().unwrap_or("unknown").to_string(),
confidence: json["confidence"].as_f64().unwrap_or(0.0),
verdict: Some(json["verdict"].as_str().unwrap_or("unknown").to_string()),
confidence: Some(json["confidence"].as_f64().unwrap_or(0.0)),
evidence: json["evidence"].as_array().map(|arr| {
arr.iter().map(|e| EvidenceItem {
text: e["text"].as_str().unwrap_or("").to_string(),
@@ -64,18 +117,292 @@ pub async fn reason(
}).collect()
}).unwrap_or_default(),
source: "engram-server".into(),
message: None,
code_blocks: vec![],
suggestions: vec![],
nodes: vec![],
}))
}
_ => {
// Stub response when engram-server is unavailable
Ok(Json(ReasonResponse {
verdict: "unresolved".into(),
confidence: 0.0,
verdict: Some("unresolved".into()),
confidence: Some(0.0),
evidence: vec![EvidenceItem {
text: "engram-server is not reachable; connect an Engram instance for live reasoning.".into(),
weight: 0.0,
}],
source: "stub".into(),
message: None,
code_blocks: vec![],
suggestions: vec![],
nodes: vec![],
}))
}
}
}
// ── Pair programming mode ─────────────────────────────────────────────────────
async fn handle_pair_programming(
state: AppState,
req: ReasonRequest,
) -> ApiResult<ReasonResponse> {
let user_message = req.message.clone().unwrap_or_default();
let context = req.context.clone().unwrap_or_default();
let selection = req.selection.clone().unwrap_or_default();
let history = req.conversation_history.clone().unwrap_or_default();
let engram_url = &state.config.engram_url;
// Build system prompt
let system_prompt = build_pair_system_prompt(&context, &selection);
// Try to proxy to soma/engram chat endpoint
let client = reqwest::Client::new();
// Build messages array for chat completions format
let mut messages: Vec<serde_json::Value> = vec![
serde_json::json!({ "role": "system", "content": system_prompt }),
];
// Add conversation history
for msg in &history {
messages.push(serde_json::json!({
"role": msg.role,
"content": msg.content,
}));
}
// Add current user message
messages.push(serde_json::json!({ "role": "user", "content": user_message }));
let chat_body = serde_json::json!({
"model": "neuron",
"messages": messages,
"stream": false,
});
// Try soma chat endpoint
let soma_url = format!("{engram_url}/v1/chat/completions");
let response_text = match client
.post(&soma_url)
.json(&chat_body)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp.json().await
.map_err(|e| api_err(StatusCode::BAD_GATEWAY, format!("invalid response: {e}")))?;
json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("No response from Neuron.")
.to_string()
}
_ => {
// Fallback stub response when soma is unavailable
build_stub_pair_response(&user_message, &context, &selection)
}
};
// Extract code blocks from the response
let code_blocks = extract_code_blocks(&response_text);
// Extract suggestions (lines starting with "- " or "• ")
let suggestions = extract_suggestions(&response_text);
Ok(Json(ReasonResponse {
verdict: None,
confidence: None,
evidence: vec![],
source: "neuron".into(),
message: Some(response_text),
code_blocks,
suggestions,
nodes: vec![],
}))
}
fn build_pair_system_prompt(context: &str, selection: &str) -> String {
let mut prompt = String::from(
"You are Neuron, an AI pair programmer for the Engram language. \
You are deeply integrated into the IDE — you can see the current file, understand types, \
and help write, debug, and reason about Engram programs.\n\n\
The Engram language features:\n\
- `activate TypeName where \"query\"` — spreading activation over the knowledge graph\n\
- `sealed { ... }` — quantum-sealed sensitive blocks\n\
- Types map to semantic nodes in the Engram knowledge graph\n\
- Structural typing with protocols and impl blocks\n\n"
);
if !context.is_empty() {
let truncated = if context.len() > 3000 {
format!("{}...(truncated)", &context[..3000])
} else {
context.to_string()
};
prompt.push_str(&format!(
"Current file content:\n```engram\n{truncated}\n```\n\n"
));
}
if !selection.is_empty() {
prompt.push_str(&format!(
"User has selected this code:\n```engram\n{selection}\n```\n\n"
));
}
prompt.push_str(
"When suggesting code, wrap it in ```engram code blocks. \
Be concise and practical. If you see opportunities to use spreading activation \
(`activate X where \"...\"`), suggest them. \
Focus on the semantic relationships between types and knowledge graph nodes."
);
prompt
}
fn build_stub_pair_response(user_message: &str, context: &str, selection: &str) -> String {
let has_context = !context.is_empty();
let has_selection = !selection.is_empty();
let msg_lower = user_message.to_lowercase();
if msg_lower.contains("explain") && has_selection {
format!(
"I can see the selected code. To get a real AI analysis, \
connect an Engram instance at the configured endpoint.\n\n\
Based on the structure, this code appears to define types and functions \
in the Engram type system.\n\n\
Connect Neuron for semantic analysis of knowledge graph relationships."
)
} else if msg_lower.contains("activate") || msg_lower.contains("semantic") {
"The `activate` keyword performs spreading activation over the Engram knowledge graph. \
Example:\n\n\
```engram\nlet results: [User] = activate User where \"premium subscribers active this week\"\n```\n\n\
This returns all User nodes semantically matching the query. \
Connect an Engram instance for live knowledge graph queries.".to_string()
} else if has_context {
format!(
"I see your current file. Neuron pair programming is ready — \
connect an Engram instance to enable live AI responses.\n\n\
Your file has {} characters of Engram code. \
Once connected, I can analyze types, suggest activations, and help debug.",
context.len()
)
} else {
"Neuron pair programming panel is active. \
Connect an Engram instance (set EL_ENGRAM_URL) to enable live AI responses. \
I can help with:\n\
- Explaining and debugging Engram code\n\
- Suggesting `activate` queries for your data types\n\
- Writing functions and type definitions\n\
- Analyzing semantic relationships in your knowledge graph".to_string()
}
}
fn extract_code_blocks(text: &str) -> Vec<CodeBlock> {
let mut blocks = Vec::new();
let mut remaining = text;
while let Some(start) = remaining.find("```") {
remaining = &remaining[start + 3..];
// Find language (optional, on same line as ```)
let lang_end = remaining.find('\n').unwrap_or(remaining.len());
let lang = remaining[..lang_end].trim().to_string();
let lang = if lang.is_empty() { "engram".to_string() } else { lang };
remaining = &remaining[lang_end..];
if remaining.starts_with('\n') {
remaining = &remaining[1..];
}
if let Some(end) = remaining.find("```") {
let code = remaining[..end].trim_end_matches('\n').to_string();
remaining = &remaining[end + 3..];
if !code.is_empty() {
blocks.push(CodeBlock {
language: lang,
code,
description: None,
});
}
} else {
break;
}
}
blocks
}
fn extract_suggestions(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("- ") || trimmed.starts_with("") || trimmed.starts_with("* ") {
Some(trimmed[2..].trim().to_string())
} else {
None
}
})
.filter(|s| !s.is_empty())
.take(8)
.collect()
}
// ── Knowledge search mode ─────────────────────────────────────────────────────
async fn handle_knowledge_search(
state: AppState,
req: ReasonRequest,
) -> ApiResult<ReasonResponse> {
let query = req.query.clone().unwrap_or_else(|| req.message.clone().unwrap_or_default());
let engram_url = &state.config.engram_url;
let client = reqwest::Client::new();
let url = format!("{engram_url}/api/search");
let body = serde_json::json!({ "query": query, "limit": 20 });
match client.post(&url).json(&body).timeout(std::time::Duration::from_secs(10)).send().await {
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp.json().await
.map_err(|e| api_err(StatusCode::BAD_GATEWAY, format!("invalid response: {e}")))?;
let nodes = json.as_array()
.map(|arr| {
arr.iter().map(|n| KnowledgeNode {
id: n["id"].as_str().unwrap_or("").to_string(),
label: n["label"].as_str().unwrap_or("").to_string(),
node_type: n["type"].as_str().unwrap_or("").to_string(),
score: n["score"].as_f64().unwrap_or(0.0),
}).collect()
})
.unwrap_or_default();
Ok(Json(ReasonResponse {
verdict: None,
confidence: None,
evidence: vec![],
source: "engram-db".into(),
message: None,
code_blocks: vec![],
suggestions: vec![],
nodes,
}))
}
_ => {
// Stub: return empty nodes when engram is unavailable
Ok(Json(ReasonResponse {
verdict: None,
confidence: None,
evidence: vec![EvidenceItem {
text: "Engram DB not reachable — connect an Engram instance for knowledge graph search.".into(),
weight: 0.0,
}],
source: "stub".into(),
message: Some("Connect an Engram instance to search the knowledge graph.".into()),
code_blocks: vec![],
suggestions: vec![],
nodes: vec![],
}))
}
}
+40 -5
View File
@@ -13,8 +13,8 @@ mod tests;
use std::sync::Arc;
use axum::{Router, routing::{delete, get, post}};
use tokio::sync::Mutex;
use axum::{Router, routing::{get, post, any}};
use tokio::sync::{Mutex, RwLock};
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
use tracing_subscriber::EnvFilter;
@@ -29,6 +29,7 @@ use crate::config::Config;
pub struct AppState {
pub config: Arc<Config>,
pub plugins: Arc<Mutex<PluginHost>>,
pub active_theme: Arc<RwLock<String>>,
}
// ── Entry point ───────────────────────────────────────────────────────────────
@@ -44,6 +45,7 @@ async fn main() {
let state = AppState {
config: Arc::new(config),
plugins: Arc::new(Mutex::new(PluginHost::new())),
active_theme: Arc::new(RwLock::new("dark".into())),
};
let app = build_router(state);
@@ -63,10 +65,21 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
// IDE HTML
.route("/", get(embed::serve_index))
.route("/*path", get(embed::serve_asset))
// Config
.route("/api/config", get(api::config::get_config))
// Status
.route("/api/status", get(api::status::status))
// File API
.route("/api/files", get(api::files::list_files))
.route("/api/file", get(api::files::read_file).post(api::files::write_file))
.route("/api/file",
get(api::files::read_file)
.post(api::files::write_file)
.delete(api::files::delete_file)
)
.route("/api/files/mkdir", post(api::files::mkdir))
.route("/api/files/rename", post(api::files::rename_file))
// Search
.route("/api/search", get(api::search::search))
// Build/run (SSE)
.route("/api/build", post(api::build::build_handler))
.route("/api/run", post(api::build::run_handler))
@@ -74,14 +87,36 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/lsp/complete", get(api::lsp::complete))
.route("/api/lsp/hover", get(api::lsp::hover))
.route("/api/lsp/errors", get(api::lsp::errors))
.route("/api/lsp/activate-preview", get(api::lsp::activate_preview))
// Type graph
.route("/api/type-graph", get(api::type_graph::type_graph))
// Themes
.route("/api/themes", get(api::themes::list_themes))
.route("/api/themes/active", post(api::themes::set_theme))
// Plugins
.route("/api/plugins", get(api::plugins::list_plugins))
.route("/api/plugins/install", post(api::plugins::install_plugin))
.route("/api/plugins/{name}", delete(api::plugins::remove_plugin))
.route("/api/plugins/remove", post(api::plugins::remove_plugin))
.route("/api/plugins/enable", post(api::plugins::enable_plugin))
.route("/api/plugins/disable", post(api::plugins::disable_plugin))
// Format
.route("/api/format", post(api::format::format))
// Outline
.route("/api/outline", get(api::outline::outline))
// Definition and references
.route("/api/definition", get(api::references::definition))
.route("/api/references", get(api::references::references))
// Snippets
.route("/api/completions/snippet", get(api::snippets::snippets))
// Git
.route("/api/git/status", get(api::git::git_status))
.route("/api/git/diff", get(api::git::git_diff))
// Terminal WebSocket
.route("/api/terminal", any(api::terminal::terminal_ws))
// Reasoning (proxy to engram-server)
.route("/api/reason", post(api::reason::reason))
.layer(cors)
.with_state(state)
// Fallback for SPA routing — must come after with_state/layer
.fallback(embed::fallback)
}
+3631 -288
View File
File diff suppressed because it is too large Load Diff