Skip to main content

microscope_memory/
bridge.rs

1use crate::config::Config;
2
3use crate::types::{BlockHeader, MemoryQueryOptions, ProjectId};
4use crate::{MicroscopeReader, LAYER_NAMES};
5use axum::{
6    extract::{Extension, Json, Query, State},
7    http::StatusCode,
8    middleware,
9    response::IntoResponse,
10    routing::{get, post},
11    Router,
12};
13use hmac::Mac;
14use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use sha2::Sha256;
18use std::fs;
19use std::net::SocketAddr;
20use std::path::{Path, PathBuf};
21use std::str::FromStr;
22use std::sync::{Arc, Mutex};
23use tower_http::cors::{Any, CorsLayer};
24use tower_http::trace::TraceLayer;
25
26const API_KEY_HEADER: &str = "x-api-key";
27
28/// Verified caller identity inserted by the auth middleware.
29///
30/// `Some(user_id)` when the request carried a valid per-user bearer token;
31/// `None` in the shared `X-API-Key` mode, where handlers fall back to the
32/// client-supplied `user_id` (single-user/trusted-team model).
33#[derive(Clone)]
34pub struct AuthenticatedUser(pub Option<String>);
35
36fn is_loopback(host: &str) -> bool {
37    matches!(host, "127.0.0.1" | "localhost" | "::1")
38}
39
40/// Build the CORS layer honoring the configured `[server] cors_origin`
41/// (`"*"`/unset keeps the fully-open default).
42fn cors_layer(configured: &Option<String>) -> CorsLayer {
43    let allow_origin = match configured.as_deref() {
44        None | Some("*") => tower_http::cors::AllowOrigin::any(),
45        Some(origin) => match origin.parse::<HeaderValue>() {
46            Ok(v) => tower_http::cors::AllowOrigin::exact(v),
47            Err(_) => tower_http::cors::AllowOrigin::any(),
48        },
49    };
50    CorsLayer::new()
51        .allow_origin(allow_origin)
52        .allow_methods(Any)
53        .allow_headers(Any)
54}
55
56/// Constant-time byte comparison for MAC verification.
57fn ct_eq(a: &[u8], b: &[u8]) -> bool {
58    if a.len() != b.len() {
59        return false;
60    }
61    let mut diff = 0u8;
62    for (x, y) in a.iter().zip(b.iter()) {
63        diff |= x ^ y;
64    }
65    diff == 0
66}
67
68/// Build a per-user bearer token: `user_id.<hex(hmac_sha256(master, user_id))>`.
69pub fn user_token(master: &str, user_id: &str) -> Result<String, String> {
70    if master.is_empty() {
71        return Err("set [server] api_key in config.toml first".to_string());
72    }
73    if user_id.is_empty()
74        || !user_id
75            .chars()
76            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
77    {
78        return Err(format!(
79            "invalid user_id '{user_id}': only alphanumeric, '-' and '_' allowed"
80        ));
81    }
82    let mut mac = hmac::Hmac::<Sha256>::new_from_slice(master.as_bytes())
83        .map_err(|e| format!("hmac init: {e}"))?;
84    mac.update(user_id.as_bytes());
85    Ok(format!(
86        "{user_id}.{}",
87        hex::encode(mac.finalize().into_bytes())
88    ))
89}
90
91/// Verify a per-user bearer token; returns the signed `user_id` on success.
92fn verify_user_token(master: &str, token: &str) -> Option<String> {
93    let (uid, mac_hex) = token.rsplit_once('.')?;
94    if uid.is_empty()
95        || !uid
96            .chars()
97            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
98    {
99        return None;
100    }
101    let mut mac = hmac::Hmac::<Sha256>::new_from_slice(master.as_bytes()).ok()?;
102    mac.update(uid.as_bytes());
103    let expected = mac.finalize().into_bytes();
104    let provided = hex::decode(mac_hex).ok()?;
105    if provided.len() != expected.len() || !ct_eq(&provided, &expected) {
106        return None;
107    }
108    Some(uid.to_string())
109}
110
111/// Optional shared API key check for inbound bridge requests.
112///
113/// Two modes, using the same `[server] api_key` as master secret:
114/// `Authorization: Bearer <user_id>.<hmac>` binds per-user identity (the
115/// verified `user_id` is attached as `AuthenticatedUser` for the handlers),
116/// and `X-API-Key: <key>` is the shared-secret fallback.
117///
118/// Without a configured key the bridge runs open (single-user localhost
119/// default); `run()` refuses to start on a non-loopback host without a key,
120/// so the open mode can never be accidentally exposed.
121// axum middleware must be able to return a Response from the Err arm -- that is
122// the shape `from_fn` expects, and it is how a middleware short-circuits before
123// calling `next`. Boxing it would break the trait, so the lint does not apply.
124#[allow(clippy::result_large_err)]
125async fn api_key_auth(
126    State(state): State<Arc<AppState>>,
127    mut req: axum::extract::Request,
128    next: middleware::Next,
129) -> Result<axum::response::Response, axum::response::Response> {
130    let master = state.config.server.api_key.as_deref().unwrap_or("");
131    let mut authed_user: Option<String> = None;
132
133    if let Some(authz) = req
134        .headers()
135        .get(axum::http::header::AUTHORIZATION)
136        .and_then(|v| v.to_str().ok())
137    {
138        let Some(token) = authz.strip_prefix("Bearer ") else {
139            return Err(
140                (StatusCode::UNAUTHORIZED, "unsupported Authorization scheme").into_response(),
141            );
142        };
143        if master.is_empty() {
144            return Err((
145                StatusCode::UNAUTHORIZED,
146                "api_key not configured; cannot verify user tokens",
147            )
148                .into_response());
149        }
150        match verify_user_token(master, token) {
151            Some(uid) => authed_user = Some(uid),
152            None => {
153                return Err(
154                    (StatusCode::UNAUTHORIZED, "invalid or forged user token").into_response()
155                )
156            }
157        }
158    } else if !master.is_empty() {
159        let provided = req
160            .headers()
161            .get(API_KEY_HEADER)
162            .and_then(|v| v.to_str().ok())
163            .unwrap_or("");
164        if provided != master {
165            return Err((
166                StatusCode::UNAUTHORIZED,
167                "missing or invalid X-API-Key header",
168            )
169                .into_response());
170        }
171    }
172
173    req.extensions_mut().insert(AuthenticatedUser(authed_user));
174    Ok(next.run(req).await)
175}
176
177#[derive(Clone)]
178pub struct AppState {
179    pub config: Config,
180    pub active_project: Arc<Mutex<Option<ProjectId>>>,
181}
182
183fn active_project_or_default(state: &AppState) -> ProjectId {
184    state
185        .active_project
186        .lock()
187        .ok()
188        .and_then(|g| *g)
189        .unwrap_or(state.config.project_id)
190}
191
192fn project_aware_config(state: &AppState) -> Config {
193    let mut cfg = state.config.clone();
194    cfg.project_id = active_project_or_default(state);
195    cfg
196}
197
198#[derive(Deserialize)]
199pub struct RecallQuery {
200    pub q: String,
201    pub k: Option<usize>,
202    pub user_id: Option<String>,
203    pub memory_backend: Option<String>,
204    pub memory_scope: Option<String>,
205}
206
207#[derive(Serialize, Clone)]
208pub struct MemoryResponse {
209    pub text: String,
210    pub depth: u8,
211    pub layer: String,
212    pub distance: f32,
213    pub memory_scope: String,
214    /// Every depth this same fact was found at, after content deduplication.
215    /// A single fact stored across D3/D4/D5 is reported once, with all depths
216    /// listed here instead of being repeated as separate hits.
217    #[serde(default)]
218    pub dedup_depths: Vec<u8>,
219}
220
221#[derive(Deserialize)]
222pub struct RememberRequest {
223    pub text: String,
224    pub layer: Option<String>,
225    pub importance: Option<u8>,
226    pub user_id: Option<String>,
227    pub memory_backend: Option<String>,
228    pub memory_scope: Option<String>,
229    /// Epistemic class: observation, evidence, inference, hypothesis
230    pub class: Option<String>,
231    /// Supporting observation/hashes (comma-separated hex or text)
232    pub supports: Option<String>,
233}
234
235#[derive(Deserialize)]
236pub struct SessionQuery {
237    pub user_id: Option<String>,
238    pub memory_backend: Option<String>,
239    pub memory_scope: Option<String>,
240}
241
242#[derive(Serialize)]
243pub struct StatusResponse {
244    pub version: String,
245    pub blocks: usize,
246    pub append_log: usize,
247    pub layers: Vec<String>,
248}
249
250#[derive(Serialize)]
251pub struct SessionResponse {
252    pub user_id: String,
253    pub memory_backend: String,
254    pub memory_scope: String,
255    pub namespace_dir: String,
256    pub personal_namespace_dir: String,
257    pub shared_namespace_dir: String,
258}
259
260#[derive(Clone, Copy)]
261enum MemoryScope {
262    Personal,
263    Shared,
264    Both,
265}
266
267impl MemoryScope {
268    fn as_str(self) -> &'static str {
269        match self {
270            Self::Personal => "personal",
271            Self::Shared => "shared",
272            Self::Both => "both",
273        }
274    }
275}
276
277fn sanitize_user_id(raw: Option<&str>) -> String {
278    let src = raw.unwrap_or("guest");
279    let cleaned: String = src
280        .chars()
281        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
282        .take(64)
283        .collect();
284    if cleaned.is_empty() {
285        "guest".to_string()
286    } else {
287        cleaned
288    }
289}
290
291fn sanitize_memory_backend(raw: Option<&str>) -> String {
292    match raw.unwrap_or("local").to_ascii_lowercase().as_str() {
293        "cloud" => "cloud".to_string(),
294        _ => "local".to_string(),
295    }
296}
297
298fn sanitize_memory_scope(raw: Option<&str>) -> MemoryScope {
299    match raw.unwrap_or("both").to_ascii_lowercase().as_str() {
300        "personal" => MemoryScope::Personal,
301        "shared" => MemoryScope::Shared,
302        _ => MemoryScope::Both,
303    }
304}
305
306fn namespace_dir(config: &Config, user_id: &str, memory_backend: &str) -> PathBuf {
307    Path::new(&config.paths.output_dir)
308        .join("namespaces")
309        .join(memory_backend)
310        .join(user_id)
311}
312
313fn shared_namespace_dir(config: &Config, memory_backend: &str) -> PathBuf {
314    Path::new(&config.paths.output_dir)
315        .join("namespaces")
316        .join(memory_backend)
317        .join("_shared")
318}
319
320fn scoped_config(
321    config: &Config,
322    user_id: Option<&str>,
323    memory_backend: Option<&str>,
324) -> Result<(Config, String, String, PathBuf), (StatusCode, String)> {
325    let user = sanitize_user_id(user_id);
326    let backend = sanitize_memory_backend(memory_backend);
327    let ns_dir = namespace_dir(config, &user, &backend);
328    fs::create_dir_all(&ns_dir).map_err(|e| {
329        (
330            StatusCode::INTERNAL_SERVER_ERROR,
331            format!("create namespace dir: {}", e),
332        )
333    })?;
334    let mut scoped = config.clone();
335    scoped.paths.output_dir = ns_dir.to_string_lossy().to_string();
336    Ok((scoped, user, backend, ns_dir))
337}
338
339fn shared_config(
340    config: &Config,
341    memory_backend: Option<&str>,
342) -> Result<(Config, String, PathBuf), (StatusCode, String)> {
343    let backend = sanitize_memory_backend(memory_backend);
344    let ns_dir = shared_namespace_dir(config, &backend);
345    fs::create_dir_all(&ns_dir).map_err(|e| {
346        (
347            StatusCode::INTERNAL_SERVER_ERROR,
348            format!("create shared namespace dir: {}", e),
349        )
350    })?;
351    let mut scoped = config.clone();
352    scoped.paths.output_dir = ns_dir.to_string_lossy().to_string();
353    Ok((scoped, backend, ns_dir))
354}
355
356#[derive(Deserialize)]
357pub struct MobileRecallRequest {
358    pub user_id: String,
359    pub query: String,
360    pub k: Option<usize>,
361}
362
363#[derive(Deserialize)]
364pub struct MobileRememberRequest {
365    pub user_id: String,
366    pub text: String,
367    pub layer: Option<String>,
368    pub importance: Option<u8>,
369}
370
371#[derive(Deserialize)]
372pub struct MobileChatRequest {
373    pub user_id: String,
374    pub message: String,
375    pub provider: String, // ollama | openai | gemini
376    pub model: String,
377    pub api_base: Option<String>,
378    pub recall_k: Option<usize>,
379    pub system_prompt: Option<String>,
380    pub layer: Option<String>,
381    pub importance: Option<u8>,
382    pub remember_user: Option<bool>,
383    pub remember_assistant: Option<bool>,
384    pub temperature: Option<f32>,
385    pub extra_headers: Option<std::collections::HashMap<String, String>>,
386}
387
388#[derive(Serialize)]
389pub struct MobileChatResponse {
390    pub provider: String,
391    pub model: String,
392    pub reply: String,
393    pub recalled: Vec<MemoryResponse>,
394    pub stored: Vec<String>,
395}
396
397fn user_prefix(user_id: &str) -> String {
398    format!("[user:{}] ", user_id.trim())
399}
400
401fn scope_user_text(user_id: &str, text: &str) -> String {
402    format!("{}{}", user_prefix(user_id), text)
403}
404
405fn strip_user_scope(text: &str, user_id: &str) -> Option<String> {
406    let prefix = user_prefix(user_id);
407    text.strip_prefix(&prefix).map(str::to_string)
408}
409
410/// The effective memory scope: the verified bearer identity wins over any
411/// client-claimed `user_id`; without a bearer token the claim is used
412/// (shared-key / single-user mode).
413fn effective_user_id<'a>(auth: &'a AuthenticatedUser, claimed: &'a str) -> &'a str {
414    auth.0.as_deref().unwrap_or(claimed)
415}
416
417#[derive(Deserialize)]
418pub struct SwitchProjectRequest {
419    pub project_path: String,
420    pub project_id: Option<String>,
421}
422
423#[derive(Serialize)]
424pub struct SwitchProjectResponse {
425    pub status: String,
426    pub active_project: String,
427    pub hot_blocks_count: usize,
428    pub delta_synced_entries: usize,
429}
430
431#[derive(Deserialize)]
432pub struct RecallRequest {
433    pub query: String,
434    pub project_id: Option<String>,
435    pub top_k: Option<usize>,
436}
437
438async fn switch_project(
439    State(state): State<Arc<AppState>>,
440    Json(payload): Json<SwitchProjectRequest>,
441) -> Result<Json<SwitchProjectResponse>, (StatusCode, String)> {
442    let pid = match payload.project_id {
443        Some(s) => ProjectId::from_str(&s).map_err(|e| (StatusCode::BAD_REQUEST, e))?,
444        None => ProjectId::from_path(&payload.project_path),
445    };
446
447    let (hot_blocks_count, delta_synced_entries) = {
448        let output_dir = Path::new(&state.config.paths.output_dir);
449        let meta_path = output_dir.join("meta.bin");
450        if meta_path.exists() {
451            let reader = MicroscopeReader::open(&state.config)
452                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
453            let hot = (0..reader.block_count)
454                .filter(|&i| {
455                    let h = reader.header(i);
456                    h.project_id == pid || h.project_id.is_global()
457                })
458                .count();
459            let append_path = output_dir.join("append.bin");
460            let appended = crate::read_append_log(&append_path);
461            let delta = appended
462                .iter()
463                .filter(|e| e.project_id == pid || e.project_id.is_global())
464                .count();
465            (hot, delta)
466        } else {
467            (0, 0)
468        }
469    };
470
471    *state.active_project.lock().map_err(|_| {
472        (
473            StatusCode::INTERNAL_SERVER_ERROR,
474            "active project lock poisoned".to_string(),
475        )
476    })? = Some(pid);
477
478    Ok(Json(SwitchProjectResponse {
479        status: "OK".to_string(),
480        active_project: pid.to_string(),
481        hot_blocks_count,
482        delta_synced_entries,
483    }))
484}
485
486async fn recall_request_handler(
487    State(state): State<Arc<AppState>>,
488    Json(payload): Json<RecallRequest>,
489) -> Result<Json<Vec<MemoryResponse>>, (StatusCode, String)> {
490    let k = payload.top_k.unwrap_or(10);
491    let active = match payload.project_id {
492        Some(s) => ProjectId::from_str(&s).map_err(|e| (StatusCode::BAD_REQUEST, e))?,
493        None => active_project_or_default(&state),
494    };
495    let opts = MemoryQueryOptions {
496        active_project: active,
497        include_global: true,
498        min_importance: 0,
499        max_results: k,
500    };
501
502    let reader = MicroscopeReader::open(&state.config)
503        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
504    let (qx, qy, qz) = crate::content_coords_blended(
505        &payload.query,
506        "long_term",
507        state.config.search.semantic_weight,
508    );
509    let depth = crate::auto_depth(&payload.query);
510    let results =
511        reader.radial_search_with_options(&state.config, qx, qy, qz, depth, 0.5, k, Some(&opts));
512
513    let append_path = Path::new(&state.config.paths.output_dir).join("append.bin");
514    let appended = crate::read_append_log(&append_path);
515
516    let mut response = Vec::new();
517    for res in results.all() {
518        if !res.is_main {
519            continue;
520        }
521        let raw_text = if res.block_idx >= 1_000_000 {
522            appended
523                .get(res.block_idx - 1_000_000)
524                .map(|e| e.text.clone())
525                .unwrap_or_default()
526        } else {
527            reader.text(res.block_idx).to_string()
528        };
529        if raw_text.is_empty() {
530            continue;
531        }
532        let h = if res.block_idx >= 1_000_000 {
533            appended
534                .get(res.block_idx - 1_000_000)
535                .map(|e| BlockHeader {
536                    x: e.x,
537                    y: e.y,
538                    z: e.z,
539                    zoom: e.depth as f32 / 8.0,
540                    depth: e.depth,
541                    layer_id: e.layer_id,
542                    data_offset: 0,
543                    data_len: 0,
544                    parent_idx: u32::MAX,
545                    child_count: 0,
546                    crc16: [0; 2],
547                    project_id: e.project_id,
548                    importance: e.importance,
549                    flags: 0,
550                })
551                .unwrap_or_else(|| reader.header(0))
552        } else {
553            reader.header(res.block_idx)
554        };
555        let layer = LAYER_NAMES
556            .get(h.layer_id as usize)
557            .copied()
558            .unwrap_or("long_term")
559            .to_string();
560        response.push(MemoryResponse {
561            text: raw_text,
562            depth: h.depth,
563            layer,
564            distance: res.dist_sq.sqrt(),
565            memory_scope: "active".to_string(),
566            dedup_depths: Vec::new(),
567        });
568        if response.len() >= k {
569            break;
570        }
571    }
572
573    // Apply the same content deduplication as recall: the same fact stored at
574    // several depths must not consume every slot of the top-k.
575    let response = dedup_by_text(response, k);
576
577    Ok(Json(response))
578}
579
580/// Collapse results carrying the same text into a single hit.
581///
582/// The nearest copy becomes the representative; every other depth it was found
583/// at is recorded in `dedup_depths` so the caller can still see that the fact
584/// lives on more than one level.
585fn dedup_by_text(items: Vec<MemoryResponse>, k: usize) -> Vec<MemoryResponse> {
586    let mut grouped: std::collections::HashMap<String, MemoryResponse> =
587        std::collections::HashMap::new();
588    let mut order: Vec<String> = Vec::new();
589
590    for mut item in items {
591        let key = normalise_for_dedup(&item.text);
592        if key.is_empty() {
593            continue;
594        }
595        match grouped.get_mut(&key) {
596            Some(existing) => {
597                // Merge the depths seen so far, then let the nearest copy take
598                // over as the representative while keeping the full set.
599                let mut depths = std::mem::take(&mut existing.dedup_depths);
600                depths.push(existing.depth);
601                depths.push(item.depth);
602                if item.distance < existing.distance {
603                    item.dedup_depths = depths;
604                    *existing = item;
605                } else {
606                    existing.dedup_depths = depths;
607                }
608            }
609            None => {
610                order.push(key.clone());
611                grouped.insert(key, item);
612            }
613        }
614    }
615
616    let mut out: Vec<MemoryResponse> = order
617        .iter()
618        .filter_map(|key| grouped.get(key).cloned())
619        .collect();
620    for item in &mut out {
621        item.dedup_depths.push(item.depth);
622        item.dedup_depths.sort_unstable();
623        item.dedup_depths.dedup();
624    }
625    out.sort_by(|a, b| a.distance.total_cmp(&b.distance));
626    out.truncate(k);
627    out
628}
629
630/// Normalise text so the same fact collapses to one key across depths.
631///
632/// Casing and whitespace runs are ignored; punctuation is kept, because
633/// "no nut allergy" and "nut allergy" are different facts.
634fn normalise_for_dedup(text: &str) -> String {
635    text.split_whitespace()
636        .collect::<Vec<_>>()
637        .join(" ")
638        .to_lowercase()
639}
640
641#[cfg(test)]
642mod dedup_tests {
643    use super::{dedup_by_text, normalise_for_dedup, MemoryResponse};
644
645    fn item(text: &str, depth: u8, distance: f32) -> MemoryResponse {
646        MemoryResponse {
647            text: text.to_string(),
648            depth,
649            layer: "long_term".to_string(),
650            distance,
651            memory_scope: "shared".to_string(),
652            dedup_depths: Vec::new(),
653        }
654    }
655
656    #[test]
657    fn same_fact_at_three_depths_returns_once() {
658        let items = vec![
659            item("The user has a pine nut allergy", 3, 0.10),
660            item("The user has a pine nut allergy", 4, 0.30),
661            item("The user has a pine nut allergy", 5, 0.50),
662        ];
663        let out = dedup_by_text(items, 5);
664        assert_eq!(out.len(), 1, "expected 1 hit, got {}", out.len());
665        assert_eq!(out[0].dedup_depths, vec![3, 4, 5]);
666    }
667
668    #[test]
669    fn dedup_frees_top_k_for_other_facts() {
670        // Three copies of one fact must not fill a top-3 with a single answer.
671        let items = vec![
672            item("pine nut allergy", 3, 0.10),
673            item("pine nut allergy", 4, 0.11),
674            item("pine nut allergy", 5, 0.12),
675            item("lives in Budapest", 3, 0.20),
676            item("prefers short check-ins", 4, 0.30),
677        ];
678        let out = dedup_by_text(items, 3);
679        assert_eq!(out.len(), 3, "top-3 must hold 3 distinct facts");
680    }
681
682    #[test]
683    fn nearest_copy_becomes_representative() {
684        let items = vec![
685            item("pine nut allergy", 5, 0.90),
686            item("pine nut allergy", 3, 0.10),
687        ];
688        let out = dedup_by_text(items, 5);
689        assert_eq!(out[0].depth, 3, "nearest copy should represent the fact");
690        assert_eq!(out[0].dedup_depths, vec![3, 5]);
691    }
692
693    #[test]
694    fn different_facts_are_not_merged() {
695        let items = vec![
696            item("has a nut allergy", 3, 0.1),
697            item("has no nut allergy", 4, 0.2),
698        ];
699        let out = dedup_by_text(items, 5);
700        assert_eq!(out.len(), 2, "negation must not be collapsed away");
701    }
702
703    #[test]
704    fn whitespace_and_case_variants_collapse() {
705        let items = vec![
706            item("Pine  Nut Allergy", 3, 0.1),
707            item("pine nut allergy", 4, 0.2),
708        ];
709        assert_eq!(dedup_by_text(items, 5).len(), 1);
710    }
711
712    #[test]
713    fn normalisation_is_stable() {
714        assert_eq!(normalise_for_dedup("  A  B  "), "a b");
715        assert_eq!(normalise_for_dedup("\n\tx\n\n"), "x");
716    }
717}
718
719fn recall_internal(
720    state: &AppState,
721    query: &str,
722    k: usize,
723    user_scope: Option<&str>,
724) -> Result<Vec<MemoryResponse>, (StatusCode, String)> {
725    let reader = MicroscopeReader::open(&state.config)
726        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
727
728    let query_lower = query.to_lowercase();
729    let (qx, qy, qz) =
730        crate::content_coords_blended(query, "long_term", state.config.search.semantic_weight);
731    let depth = crate::auto_depth(query);
732    let results = reader.radial_search(&state.config, qx, qy, qz, depth, 0.5, k);
733
734    let mut response = Vec::new();
735    for res in results.all() {
736        if !res.is_main {
737            continue;
738        }
739        let h = reader.header(res.block_idx);
740        let raw_text = reader.text(res.block_idx).to_string();
741        let text = if let Some(uid) = user_scope {
742            match strip_user_scope(&raw_text, uid) {
743                Some(t) => t,
744                None => continue,
745            }
746        } else {
747            raw_text
748        };
749        let layer = LAYER_NAMES
750            .get(h.layer_id as usize)
751            .copied()
752            .unwrap_or("unknown")
753            .to_string();
754        response.push(MemoryResponse {
755            text,
756            depth: h.depth,
757            layer,
758            distance: res.dist_sq.sqrt(),
759            memory_scope: if user_scope.is_some() {
760                "personal"
761            } else {
762                "shared"
763            }
764            .to_string(),
765            dedup_depths: Vec::new(),
766        });
767    }
768
769    let append_path = std::path::Path::new(&state.config.paths.output_dir).join("append.bin");
770    let appended = crate::read_append_log(&append_path);
771    for entry in &appended {
772        if response.len() >= k {
773            break;
774        }
775        if !entry.text.to_lowercase().contains(&query_lower) {
776            continue;
777        }
778        let text = if let Some(uid) = user_scope {
779            match strip_user_scope(&entry.text, uid) {
780                Some(t) => t,
781                None => continue,
782            }
783        } else {
784            entry.text.clone()
785        };
786        let layer = LAYER_NAMES
787            .get(entry.layer_id as usize)
788            .copied()
789            .unwrap_or("long_term")
790            .to_string();
791        response.push(MemoryResponse {
792            text,
793            depth: 4,
794            layer,
795            distance: 0.1,
796            memory_scope: if user_scope.is_some() {
797                "personal"
798            } else {
799                "shared"
800            }
801            .to_string(),
802            dedup_depths: Vec::new(),
803        });
804    }
805
806    // Deduplicate on text and apply the k limit to distinct facts, so one fact
807    // stored at several depths cannot occupy the whole top-k.
808    Ok(dedup_by_text(response, k))
809}
810
811async fn get_status(
812    State(state): State<Arc<AppState>>,
813) -> Result<Json<StatusResponse>, (StatusCode, String)> {
814    let reader = MicroscopeReader::open(&state.config)
815        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
816
817    let append_path = std::path::Path::new(&state.config.paths.output_dir).join("append.bin");
818    let appended = crate::read_append_log(&append_path);
819
820    Ok(Json(StatusResponse {
821        version: env!("CARGO_PKG_VERSION").to_string(),
822        blocks: reader.block_count,
823        append_log: appended.len(),
824        layers: LAYER_NAMES.iter().map(|&s| s.to_string()).collect(),
825    }))
826}
827
828async fn recall_memory(
829    State(state): State<Arc<AppState>>,
830    Query(params): Query<RecallQuery>,
831) -> Result<Json<Vec<MemoryResponse>>, (StatusCode, String)> {
832    let k = params.k.unwrap_or(10);
833    Ok(Json(recall_internal(&state, &params.q, k, None)?))
834}
835
836async fn remember_memory(
837    State(state): State<Arc<AppState>>,
838    Json(payload): Json<RememberRequest>,
839) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
840    let layer = payload.layer.unwrap_or_else(|| "long_term".to_string());
841    let importance = payload.importance.unwrap_or(5);
842    let scope = sanitize_memory_scope(payload.memory_scope.as_deref());
843    let (user_config, user_id, memory_backend, personal_ns) = scoped_config(
844        &state.config,
845        payload.user_id.as_deref(),
846        payload.memory_backend.as_deref(),
847    )?;
848    let (shared_cfg, _backend2, shared_ns) = shared_config(&state.config, Some(&memory_backend))?;
849    let mut written_scopes = Vec::new();
850
851    if matches!(scope, MemoryScope::Personal | MemoryScope::Both) {
852        crate::store_memory(&user_config, &payload.text, &layer, importance)
853            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
854        written_scopes.push("personal");
855    }
856    if matches!(scope, MemoryScope::Shared | MemoryScope::Both) {
857        crate::store_memory(&shared_cfg, &payload.text, &layer, importance)
858            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
859        written_scopes.push("shared");
860    }
861
862    // Epistemic layer: if class is provided, link supports.
863    let mut epistemic_info = serde_json::json!({});
864    if let Some(class_str) = &payload.class {
865        if let Ok(class) = class_str.parse::<crate::epistemic::EpistemicClass>() {
866            let output_dir = std::path::Path::new(&state.config.paths.output_dir);
867            let now = std::time::SystemTime::now()
868                .duration_since(std::time::UNIX_EPOCH)
869                .unwrap_or_default()
870                .as_millis() as u64;
871            let mut ledger = crate::epistemic::EvidenceLedger::load_or_init(output_dir);
872            let mut audit = crate::epistemic::AuditChain::load_or_init(output_dir);
873            let claim_hash = crate::epistemic::content_hash(&payload.text);
874            // Ensure the claim itself is in the ledger
875            ledger.get_or_create(claim_hash, class, 0, now);
876            // Link each support
877            let provider = crate::embeddings::provider_from_config(
878                &state.config.embedding,
879                state.config.embedding.dim,
880            );
881            if let Some(supports_str) = &payload.supports {
882                for sup in supports_str.split(',') {
883                    let sup = sup.trim();
884                    if sup.is_empty() {
885                        continue;
886                    }
887                    let sup_hash = crate::epistemic::content_hash(sup);
888                    let _ = crate::epistemic::link_evidence(
889                        &mut ledger,
890                        &mut audit,
891                        claim_hash,
892                        sup_hash,
893                        crate::epistemic::EpistemicClass::Observation,
894                        0,
895                        now,
896                        Some(sup),
897                        Some(&payload.text),
898                        Some(provider.as_ref()),
899                    );
900                }
901            }
902            let conf = ledger
903                .records
904                .get(&claim_hash)
905                .map(|r| r.confidence)
906                .unwrap_or(0);
907            let _ = ledger.save(output_dir);
908            let _ = audit.save(output_dir);
909            epistemic_info = serde_json::json!({
910                "class": class_str,
911                "confidence": conf,
912                "supports": payload.supports.as_deref().unwrap_or(""),
913            });
914        }
915    }
916
917    Ok(Json(serde_json::json!({
918        "status": "committed",
919        "message": "Memory stored in append log",
920        "user_id": user_id,
921        "memory_backend": memory_backend,
922        "memory_scope": scope.as_str(),
923        "written_scopes": written_scopes,
924        "namespace_dir": personal_ns.to_string_lossy(),
925        "personal_namespace_dir": personal_ns.to_string_lossy(),
926        "shared_namespace_dir": shared_ns.to_string_lossy(),
927        "epistemic": epistemic_info,
928    })))
929}
930
931async fn get_session(
932    State(state): State<Arc<AppState>>,
933    Query(params): Query<SessionQuery>,
934) -> Result<Json<SessionResponse>, (StatusCode, String)> {
935    let (_cfg, user_id, memory_backend, namespace) = scoped_config(
936        &state.config,
937        params.user_id.as_deref(),
938        params.memory_backend.as_deref(),
939    )?;
940    let (_shared_cfg, _backend2, shared_ns) = shared_config(&state.config, Some(&memory_backend))?;
941    let scope = sanitize_memory_scope(params.memory_scope.as_deref());
942    Ok(Json(SessionResponse {
943        user_id,
944        memory_backend,
945        memory_scope: scope.as_str().to_string(),
946        namespace_dir: namespace.to_string_lossy().to_string(),
947        personal_namespace_dir: namespace.to_string_lossy().to_string(),
948        shared_namespace_dir: shared_ns.to_string_lossy().to_string(),
949    }))
950}
951
952async fn mobile_recall(
953    State(state): State<Arc<AppState>>,
954    Extension(auth): Extension<AuthenticatedUser>,
955    Json(payload): Json<MobileRecallRequest>,
956) -> Result<Json<Vec<MemoryResponse>>, (StatusCode, String)> {
957    let k = payload.k.unwrap_or(10);
958    let uid = effective_user_id(&auth, &payload.user_id);
959    Ok(Json(recall_internal(&state, &payload.query, k, Some(uid))?))
960}
961
962async fn mobile_remember(
963    State(state): State<Arc<AppState>>,
964    Extension(auth): Extension<AuthenticatedUser>,
965    Json(payload): Json<MobileRememberRequest>,
966) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
967    let layer = payload.layer.unwrap_or_else(|| "long_term".to_string());
968    let importance = payload.importance.unwrap_or(7);
969    let uid = effective_user_id(&auth, &payload.user_id);
970    let scoped_text = scope_user_text(uid, &payload.text);
971
972    let cfg = project_aware_config(&state);
973    crate::store_memory(&cfg, &scoped_text, &layer, importance)
974        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
975
976    Ok(Json(serde_json::json!({
977        "status": "committed",
978        "message": "User-scoped memory stored"
979    })))
980}
981
982fn build_header_map(
983    extra_headers: Option<&std::collections::HashMap<String, String>>,
984    auth: Option<String>,
985) -> Result<HeaderMap, (StatusCode, String)> {
986    let mut headers = HeaderMap::new();
987
988    if let Some(token) = auth {
989        let value = HeaderValue::from_str(&token).map_err(|e| {
990            (
991                StatusCode::BAD_REQUEST,
992                format!("Invalid auth header: {}", e),
993            )
994        })?;
995        headers.insert(AUTHORIZATION, value);
996    }
997
998    // Whitelist of allowed extra headers for security
999    const ALLOWED_HEADERS: &[&str] = &[
1000        "accept",
1001        "content-type",
1002        "user-agent",
1003        "x-request-id",
1004        "x-api-key",
1005        "x-client-version",
1006    ];
1007
1008    if let Some(map) = extra_headers {
1009        for (k, v) in map {
1010            let lower_k = k.to_lowercase();
1011            // Allow headers that start with "x-" or are in the whitelist
1012            if !lower_k.starts_with("x-") && !ALLOWED_HEADERS.contains(&lower_k.as_str()) {
1013                return Err((
1014                    StatusCode::BAD_REQUEST,
1015                    format!("Header '{}' is not allowed. Only 'x-*' headers or whitelisted headers are permitted.", k),
1016                ));
1017            }
1018
1019            let name = HeaderName::from_bytes(k.as_bytes()).map_err(|e| {
1020                (
1021                    StatusCode::BAD_REQUEST,
1022                    format!("Invalid header name '{}': {}", k, e),
1023                )
1024            })?;
1025            let value = HeaderValue::from_str(v).map_err(|e| {
1026                (
1027                    StatusCode::BAD_REQUEST,
1028                    format!("Invalid header value for '{}': {}", k, e),
1029                )
1030            })?;
1031            headers.insert(name, value);
1032        }
1033    }
1034
1035    Ok(headers)
1036}
1037
1038async fn call_provider(
1039    req: &MobileChatRequest,
1040    memory_context: &str,
1041    config: &Config,
1042) -> Result<String, (StatusCode, String)> {
1043    let provider = req.provider.to_lowercase();
1044    let client = reqwest::Client::new();
1045    let temperature = req.temperature.unwrap_or(0.2);
1046    let system_prompt = req.system_prompt.clone().unwrap_or_else(|| {
1047        "You are a helpful assistant. Use recalled memories as trusted user context.".to_string()
1048    });
1049    let combined_system = format!(
1050        "{}\n\nRecalled Memories:\n{}",
1051        system_prompt, memory_context
1052    );
1053    let headers = build_header_map(req.extra_headers.as_ref(), None)?;
1054
1055    let response_json: Value = match provider.as_str() {
1056        "ollama" => {
1057            let api_base = req
1058                .api_base
1059                .clone()
1060                .unwrap_or_else(|| "http://127.0.0.1:11434".to_string());
1061            let url = format!("{}/api/chat", api_base.trim_end_matches('/'));
1062            let payload = serde_json::json!({
1063                "model": req.model,
1064                "stream": false,
1065                "messages": [
1066                    {"role": "system", "content": combined_system},
1067                    {"role": "user", "content": req.message}
1068                ],
1069                "options": {"temperature": temperature}
1070            });
1071            client
1072                .post(url)
1073                .headers(headers)
1074                .json(&payload)
1075                .send()
1076                .await
1077                .map_err(|e| {
1078                    (
1079                        StatusCode::BAD_GATEWAY,
1080                        format!("Ollama request failed: {}", e),
1081                    )
1082                })?
1083                .json()
1084                .await
1085                .map_err(|e| {
1086                    (
1087                        StatusCode::BAD_GATEWAY,
1088                        format!("Ollama invalid JSON: {}", e),
1089                    )
1090                })?
1091        }
1092        "openai" => {
1093            let api_base = req
1094                .api_base
1095                .clone()
1096                .unwrap_or_else(|| "https://api.openai.com".to_string());
1097            let api_key = config.server.openai_api_key.as_ref().ok_or((
1098                StatusCode::INTERNAL_SERVER_ERROR,
1099                "OpenAI API key not configured on server".to_string(),
1100            ))?;
1101            let url = format!("{}/v1/chat/completions", api_base.trim_end_matches('/'));
1102            let mut headers = headers;
1103            let bearer = format!("Bearer {}", api_key);
1104            headers.insert(
1105                AUTHORIZATION,
1106                HeaderValue::from_str(&bearer).map_err(|e| {
1107                    (
1108                        StatusCode::INTERNAL_SERVER_ERROR,
1109                        format!("Invalid OpenAI api_key: {}", e),
1110                    )
1111                })?,
1112            );
1113            let payload = serde_json::json!({
1114                "model": req.model,
1115                "temperature": temperature,
1116                "messages": [
1117                    {"role": "system", "content": combined_system},
1118                    {"role": "user", "content": req.message}
1119                ]
1120            });
1121            client
1122                .post(url)
1123                .headers(headers)
1124                .json(&payload)
1125                .send()
1126                .await
1127                .map_err(|e| {
1128                    (
1129                        StatusCode::BAD_GATEWAY,
1130                        format!("OpenAI request failed: {}", e),
1131                    )
1132                })?
1133                .json()
1134                .await
1135                .map_err(|e| {
1136                    (
1137                        StatusCode::BAD_GATEWAY,
1138                        format!("OpenAI invalid JSON: {}", e),
1139                    )
1140                })?
1141        }
1142        "gemini" => {
1143            let api_base = req
1144                .api_base
1145                .clone()
1146                .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
1147            let api_key = config.server.gemini_api_key.as_ref().ok_or((
1148                StatusCode::INTERNAL_SERVER_ERROR,
1149                "Gemini API key not configured on server".to_string(),
1150            ))?;
1151            let url = format!(
1152                "{}/v1beta/models/{}:generateContent?key={}",
1153                api_base.trim_end_matches('/'),
1154                req.model,
1155                api_key
1156            );
1157            let payload = serde_json::json!({
1158                "contents": [
1159                    {
1160                        "role": "user",
1161                        "parts": [
1162                            { "text": format!("{}\n\nUser: {}", combined_system, req.message) }
1163                        ]
1164                    }
1165                ],
1166                "generationConfig": {
1167                    "temperature": temperature
1168                }
1169            });
1170            client
1171                .post(url)
1172                .headers(headers)
1173                .json(&payload)
1174                .send()
1175                .await
1176                .map_err(|e| {
1177                    (
1178                        StatusCode::BAD_GATEWAY,
1179                        format!("Gemini request failed: {}", e),
1180                    )
1181                })?
1182                .json()
1183                .await
1184                .map_err(|e| {
1185                    (
1186                        StatusCode::BAD_GATEWAY,
1187                        format!("Gemini invalid JSON: {}", e),
1188                    )
1189                })?
1190        }
1191        _ => {
1192            return Err((
1193                StatusCode::BAD_REQUEST,
1194                "Unsupported provider. Use: ollama | openai | gemini".to_string(),
1195            ));
1196        }
1197    };
1198
1199    let reply = match provider.as_str() {
1200        "ollama" => response_json["message"]["content"]
1201            .as_str()
1202            .unwrap_or("")
1203            .to_string(),
1204        "openai" => response_json["choices"][0]["message"]["content"]
1205            .as_str()
1206            .unwrap_or("")
1207            .to_string(),
1208        "gemini" => response_json["candidates"][0]["content"]["parts"][0]["text"]
1209            .as_str()
1210            .unwrap_or("")
1211            .to_string(),
1212        _ => String::new(),
1213    };
1214
1215    if reply.is_empty() {
1216        return Err((
1217            StatusCode::BAD_GATEWAY,
1218            format!("{} returned an empty response", req.provider),
1219        ));
1220    }
1221
1222    Ok(reply)
1223}
1224
1225async fn mobile_chat(
1226    State(state): State<Arc<AppState>>,
1227    Extension(auth): Extension<AuthenticatedUser>,
1228    Json(payload): Json<MobileChatRequest>,
1229) -> Result<Json<MobileChatResponse>, (StatusCode, String)> {
1230    let k = payload.recall_k.unwrap_or(8);
1231    let uid = effective_user_id(&auth, &payload.user_id);
1232    let recalled = recall_internal(&state, &payload.message, k, Some(uid))?;
1233
1234    let memory_context = if recalled.is_empty() {
1235        "(no prior memory)".to_string()
1236    } else {
1237        recalled
1238            .iter()
1239            .enumerate()
1240            .map(|(i, m)| {
1241                format!(
1242                    "{}. [{} D{} dist={:.3}] {}",
1243                    i + 1,
1244                    m.layer,
1245                    m.depth,
1246                    m.distance,
1247                    m.text
1248                )
1249            })
1250            .collect::<Vec<String>>()
1251            .join("\n")
1252    };
1253
1254    let reply = call_provider(&payload, &memory_context, &state.config).await?;
1255
1256    let layer = payload
1257        .layer
1258        .clone()
1259        .unwrap_or_else(|| "long_term".to_string());
1260    let importance = payload.importance.unwrap_or(7);
1261    let mut stored = Vec::new();
1262
1263    if payload.remember_user.unwrap_or(true) {
1264        let text = scope_user_text(&payload.user_id, &payload.message);
1265        crate::store_memory(&state.config, &text, &layer, importance)
1266            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1267        stored.push("user_message".to_string());
1268    }
1269
1270    if payload.remember_assistant.unwrap_or(true) {
1271        let text = scope_user_text(&payload.user_id, &reply);
1272        crate::store_memory(&state.config, &text, &layer, importance)
1273            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1274        stored.push("assistant_reply".to_string());
1275    }
1276
1277    Ok(Json(MobileChatResponse {
1278        provider: payload.provider,
1279        model: payload.model,
1280        reply,
1281        recalled,
1282        stored,
1283    }))
1284}
1285
1286async fn get_openapi() -> Json<serde_json::Value> {
1287    static SPEC: &str = include_str!("../openapi.json");
1288    Json(
1289        serde_json::from_str(SPEC)
1290            .unwrap_or_else(|_| serde_json::json!({"error": "spec parse failed"})),
1291    )
1292}
1293
1294async fn get_root() -> axum::response::Html<&'static str> {
1295    axum::response::Html(
1296        r#"<!DOCTYPE html>
1297<html><head><title>Microscope Memory — Spine Bridge API</title>
1298<style>
1299    body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; max-width: 800px; margin: 40px auto; line-height: 1.6; background: #0f1115; color: #e0e0e0; }
1300    h1 { color: #00f2ff; border-bottom: 1px solid #333; padding-bottom: 10px; }
1301    code { background: #1a1d23; padding: 2px 5px; border-radius: 4px; color: #ff9d00; }
1302    table { width: 100%; border-collapse: collapse; margin: 20px 0; background: #16181d; }
1303    th, td { border: 1px solid #333; padding: 12px; text-align: left; }
1304    th { background: #1e2229; color: #00f2ff; }
1305    a { color: #00f2ff; text-decoration: none; }
1306    a:hover { text-decoration: underline; }
1307    .status-ok { color: #00ff88; font-weight: bold; }
1308    pre { background: #1a1d23; padding: 15px; border-radius: 8px; border: 1px solid #333; overflow-x: auto; }
1309</style>
1310</head>
1311<body>
1312<h1>🔬 Microscope Memory — Spine Bridge API <span style="font-size: 0.5em; vertical-align: middle; background: #00f2ff; color: #000; padding: 2px 8px; border-radius: 10px;">v1.0</span></h1>
1313<p>Sub-microsecond cognitive memory for AI models. <span class="status-ok">● Engine Active</span></p>
1314
1315<h3>API Endpoints (v1)</h3>
1316<table>
1317<tr><th>Method</th><th>Endpoint</th><th>Description</th></tr>
1318<tr><td>GET</td><td><code>/v1/status</code></td><td>Engine health &amp; stats</td></tr>
1319<tr><td>GET</td><td><code>/v1/session?user_id=u1&amp;memory_backend=cloud&amp;memory_scope=both</code></td><td>Resolve personal+shared namespaces</td></tr>
1320<tr><td>GET</td><td><code>/v1/recall?q=...&amp;k=10</code></td><td>Recall memories by query</td></tr>
1321<tr><td>POST</td><td><code>/v1/remember</code></td><td>Store a new memory</td></tr>
1322<tr><td>POST</td><td><code>/v1/mobile/chat</code></td><td>Provider-agnostic mobile chat (Ollama/OpenAI/Gemini)</td></tr>
1323<tr><td>GET</td><td><a href="/openapi.json">/openapi.json</a></td><td>OpenAPI spec</td></tr>
1324</table>
1325
1326<h3>Quick Start</h3>
1327<pre># Recall via v1 API
1328curl "http://localhost:6060/v1/recall?q=hello&amp;k=3&amp;user_id=alice&amp;memory_backend=cloud&amp;memory_scope=both"
1329
1330# Store via v1 API
1331curl -X POST http://localhost:6060/v1/remember \
1332  -H "Content-Type: application/json" \
1333  -d '{"text":"Hello world","layer":"long_term","user_id":"alice","memory_backend":"cloud","memory_scope":"both"}'
1334
1335# Mobile unified chat (user-scoped persistent memory)
1336curl -X POST http://localhost:6060/v1/mobile/chat \
1337  -H "Content-Type: application/json" \
1338  -d '{"user_id":"demo-user","message":"what did we discuss?","provider":"ollama","model":"llama3"}'</pre>
1339
1340<p style="font-size: 0.9em; color: #888;">Note: Legacy routes (<code>/status</code>, <code>/recall</code>, <code>/remember</code>) are supported but deprecated.</p>
1341</body></html>"#,
1342    )
1343}
1344
1345#[derive(Deserialize)]
1346pub struct FindQuery {
1347    pub q: String,
1348    pub k: Option<usize>,
1349}
1350
1351#[derive(Deserialize)]
1352pub struct LookQuery {
1353    pub x: f32,
1354    pub y: f32,
1355    pub z: f32,
1356    pub zoom: u8,
1357    pub k: Option<usize>,
1358}
1359
1360#[derive(Deserialize)]
1361pub struct MqlQuery {
1362    pub mql: String,
1363}
1364
1365#[derive(Deserialize)]
1366pub struct BuildRequest {
1367    pub force: Option<bool>,
1368}
1369
1370#[derive(Deserialize)]
1371pub struct SessionLogQuery {
1372    pub n: Option<usize>,
1373}
1374
1375#[derive(Serialize)]
1376pub struct FindResponse {
1377    pub text: String,
1378    pub depth: u8,
1379    pub layer: String,
1380}
1381
1382#[derive(Serialize)]
1383pub struct LookResponse {
1384    pub text: String,
1385    pub depth: u8,
1386    pub layer: String,
1387    pub distance: f32,
1388}
1389
1390#[derive(Serialize)]
1391pub struct MqlResponse {
1392    pub text: String,
1393    pub depth: u8,
1394    pub layer: String,
1395    pub score: f32,
1396}
1397
1398async fn find_memory(
1399    State(state): State<Arc<AppState>>,
1400    Query(params): Query<FindQuery>,
1401) -> Result<Json<Vec<FindResponse>>, (StatusCode, String)> {
1402    let reader = MicroscopeReader::open(&state.config)
1403        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1404    let k = params.k.unwrap_or(10);
1405    let append_path = std::path::Path::new(&state.config.paths.output_dir).join("append.bin");
1406    let appended = crate::read_append_log(&append_path);
1407    let results = reader.find_text_all(&state.config, &params.q, k);
1408    let mut response = Vec::new();
1409    for (depth, idx, is_main) in &results {
1410        let (text, layer) = if *is_main {
1411            let h = reader.header(*idx);
1412            (
1413                reader.text(*idx).to_string(),
1414                LAYER_NAMES
1415                    .get(h.layer_id as usize)
1416                    .copied()
1417                    .unwrap_or("unknown")
1418                    .to_string(),
1419            )
1420        } else if let Some(entry) = appended.get(idx.saturating_sub(1_000_000)) {
1421            (
1422                entry.text.clone(),
1423                LAYER_NAMES
1424                    .get(entry.layer_id as usize)
1425                    .copied()
1426                    .unwrap_or("unknown")
1427                    .to_string(),
1428            )
1429        } else {
1430            continue;
1431        };
1432        response.push(FindResponse {
1433            text: crate::safe_truncate(&text, 200),
1434            depth: *depth,
1435            layer,
1436        });
1437    }
1438    Ok(Json(response))
1439}
1440
1441async fn look_memory(
1442    State(state): State<Arc<AppState>>,
1443    Query(params): Query<LookQuery>,
1444) -> Result<Json<Vec<LookResponse>>, (StatusCode, String)> {
1445    let reader = MicroscopeReader::open(&state.config)
1446        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1447    let k = params.k.unwrap_or(10);
1448    let results = reader.look(&state.config, params.x, params.y, params.z, params.zoom, k);
1449    let mut response = Vec::new();
1450    for (dist, idx, is_main) in &results {
1451        if !is_main {
1452            continue;
1453        }
1454        let h = reader.header(*idx);
1455        let text = reader.text(*idx).to_string();
1456        let layer = LAYER_NAMES
1457            .get(h.layer_id as usize)
1458            .copied()
1459            .unwrap_or("unknown")
1460            .to_string();
1461        response.push(LookResponse {
1462            text: crate::safe_truncate(&text, 200),
1463            depth: h.depth,
1464            layer,
1465            distance: *dist,
1466        });
1467    }
1468    Ok(Json(response))
1469}
1470
1471async fn mql_query(
1472    State(state): State<Arc<AppState>>,
1473    Query(params): Query<MqlQuery>,
1474) -> Result<Json<Vec<MqlResponse>>, (StatusCode, String)> {
1475    let reader = MicroscopeReader::open(&state.config)
1476        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1477    let append_path = std::path::Path::new(&state.config.paths.output_dir).join("append.bin");
1478    let appended = crate::read_append_log(&append_path);
1479    let q = crate::query::parse(&params.mql);
1480    let results = crate::query::execute(&q, &reader, &appended);
1481    let mut response = Vec::new();
1482    for r in &results {
1483        let (text, depth, layer) = if r.is_main {
1484            let h = reader.header(r.block_idx);
1485            let t = reader.text(r.block_idx).to_string();
1486            let l = LAYER_NAMES
1487                .get(h.layer_id as usize)
1488                .copied()
1489                .unwrap_or("unknown")
1490                .to_string();
1491            (t, h.depth, l)
1492        } else if let Some(entry) = appended.get(r.block_idx) {
1493            let l = LAYER_NAMES
1494                .get(entry.layer_id as usize)
1495                .copied()
1496                .unwrap_or("unknown")
1497                .to_string();
1498            (entry.text.clone(), 4u8, l)
1499        } else {
1500            continue;
1501        };
1502        response.push(MqlResponse {
1503            text: crate::safe_truncate(&text, 200),
1504            depth,
1505            layer,
1506            score: r.score,
1507        });
1508    }
1509    Ok(Json(response))
1510}
1511
1512async fn build_index(
1513    State(state): State<Arc<AppState>>,
1514    Json(payload): Json<BuildRequest>,
1515) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
1516    let force = payload.force.unwrap_or(false);
1517    if force {
1518        crate::build::rebuild_pending(&state.config, true, true)
1519            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1520    } else {
1521        crate::build::build(&state.config, false, true)
1522            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1523    }
1524    let reader = MicroscopeReader::open(&state.config)
1525        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1526    let depths = reader.depth_ranges.iter().filter(|&&(_, c)| c > 0).count();
1527    Ok(Json(serde_json::json!({
1528        "status": "ok",
1529        "blocks": reader.block_count,
1530        "depths": depths
1531    })))
1532}
1533
1534async fn session_log(
1535    State(state): State<Arc<AppState>>,
1536    Query(params): Query<SessionLogQuery>,
1537) -> Result<Json<Vec<String>>, (StatusCode, String)> {
1538    let n = params.n.unwrap_or(50);
1539    let file_path = std::path::Path::new(&state.config.paths.layers_dir).join("session.txt");
1540    if !file_path.exists() {
1541        return Ok(Json(Vec::new()));
1542    }
1543    let content = std::fs::read_to_string(&file_path).unwrap_or_default();
1544    let entries: Vec<&str> = content
1545        .split("\n\n")
1546        .filter(|s| !s.trim().is_empty())
1547        .collect();
1548    let total = entries.len();
1549    let start = total.saturating_sub(n);
1550    let recent: Vec<String> = entries[start..]
1551        .iter()
1552        .map(|e| crate::safe_truncate(crate::reader::parse_imp_marker(e).0, 300))
1553        .collect();
1554    Ok(Json(recent))
1555}
1556
1557async fn consolidate_sessions(
1558    State(state): State<Arc<AppState>>,
1559) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
1560    let file_path = std::path::Path::new(&state.config.paths.layers_dir).join("session.txt");
1561    let content = if file_path.exists() {
1562        std::fs::read_to_string(&file_path).unwrap_or_default()
1563    } else {
1564        return Ok(Json(serde_json::json!({"consolidated": 0, "groups": []})));
1565    };
1566    let entries: Vec<&str> = content
1567        .split("\n\n")
1568        .filter(|s| !s.trim().is_empty())
1569        .collect();
1570    if entries.len() < 3 {
1571        return Ok(Json(serde_json::json!({"consolidated": 0, "groups": []})));
1572    }
1573    let mut groups: std::collections::HashMap<String, Vec<String>> =
1574        std::collections::HashMap::new();
1575    for raw_entry in &entries {
1576        let (entry, _imp) = crate::reader::parse_imp_marker(raw_entry);
1577        let sid = if entry.contains("[sid-") {
1578            entry
1579                .split("[sid-")
1580                .nth(1)
1581                .and_then(|s| s.split(']').next())
1582                .map(|s| format!("sid-{}", s))
1583                .unwrap_or_else(|| "nosid".to_string())
1584        } else {
1585            "nosid".to_string()
1586        };
1587        groups.entry(sid).or_default().push(entry.to_string());
1588    }
1589    let mut summaries = Vec::new();
1590    for (sid, group) in &groups {
1591        if group.len() < 2 {
1592            continue;
1593        }
1594        let topics: Vec<String> = group
1595            .iter()
1596            .take(5)
1597            .map(|e| {
1598                let parts: Vec<&str> = e.split("] ").collect();
1599                crate::safe_truncate(parts.last().unwrap_or(&""), 50)
1600            })
1601            .collect();
1602        let summary = format!(
1603            "[{}] CONSOLIDATED: {} interactions. Topics: {}",
1604            sid,
1605            group.len(),
1606            topics.join(" | ")
1607        );
1608        crate::store_memory(&state.config, &summary, "long_term", 8)
1609            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1610        summaries.push(summary);
1611    }
1612    Ok(Json(serde_json::json!({
1613        "consolidated": summaries.len(),
1614        "groups": summaries
1615    })))
1616}
1617
1618async fn dream_consolidation(
1619    State(state): State<Arc<AppState>>,
1620) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
1621    let output_dir = std::path::Path::new(&state.config.paths.output_dir);
1622    let reader = MicroscopeReader::open(&state.config)
1623        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
1624    let block_count = reader.block_count;
1625    drop(reader);
1626    match crate::dream::dream_consolidate(
1627        output_dir,
1628        block_count,
1629        state.config.index.max_blocks,
1630        state.config.index.protect_min_importance,
1631    ) {
1632        Ok(cycle) => Ok(Json(serde_json::json!({
1633            "status": "ok",
1634            "duration_ms": cycle.duration_ms,
1635            "replayed_fingerprints": cycle.replayed_fingerprints,
1636            "strengthened_pairs": cycle.strengthened_pairs,
1637            "pruned_pairs": cycle.pruned_pairs,
1638            "pruned_activations": cycle.pruned_activations,
1639            "consolidated_patterns": cycle.consolidated_patterns,
1640            "energy_before": cycle.energy_before,
1641            "energy_after": cycle.energy_after
1642        }))),
1643        Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
1644    }
1645}
1646
1647pub async fn run(
1648    config: Config,
1649    host: String,
1650    port: u16,
1651) -> Result<(), Box<dyn std::error::Error>> {
1652    if !is_loopback(&host) && config.server.api_key.as_deref().is_none_or(str::is_empty) {
1653        return Err(
1654            "bridge would be exposed on a non-loopback host without [server] api_key — \
1655             refusing to start"
1656                .into(),
1657        );
1658    }
1659    let state = Arc::new(AppState {
1660        config,
1661        active_project: Arc::new(Mutex::new(None)),
1662    });
1663
1664    let cors = cors_layer(&state.config.server.cors_origin);
1665
1666    let v1_routes = Router::new()
1667        .route("/status", get(get_status))
1668        .route("/session", get(get_session))
1669        .route("/recall", get(recall_memory))
1670        .route("/remember", post(remember_memory))
1671        .route("/find", get(find_memory))
1672        .route("/look", get(look_memory))
1673        .route("/mql", get(mql_query))
1674        .route("/build", post(build_index))
1675        .route("/session_log", get(session_log))
1676        .route("/consolidate", post(consolidate_sessions))
1677        .route("/dream", post(dream_consolidation))
1678        .route("/mobile/recall", post(mobile_recall))
1679        .route("/mobile/remember", post(mobile_remember))
1680        .route("/mobile/chat", post(mobile_chat))
1681        .route("/project/switch", post(switch_project))
1682        .route("/project/recall", post(recall_request_handler));
1683
1684    let app = Router::new()
1685        .route("/", get(get_root))
1686        // v1 API
1687        .nest("/v1", v1_routes)
1688        // Backward compatibility (Legacy)
1689        .route("/status", get(get_status))
1690        .route("/session", get(get_session))
1691        .route("/recall", get(recall_memory))
1692        .route("/remember", post(remember_memory))
1693        .route("/find", get(find_memory))
1694        .route("/look", get(look_memory))
1695        .route("/mql", get(mql_query))
1696        .route("/build", post(build_index))
1697        .route("/session_log", get(session_log))
1698        .route("/consolidate", post(consolidate_sessions))
1699        .route("/dream", post(dream_consolidation))
1700        .route("/project/switch", post(switch_project))
1701        .route("/project/recall", post(recall_request_handler))
1702        .route("/openapi.json", get(get_openapi))
1703        .layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
1704        .layer(TraceLayer::new_for_http())
1705        .layer(cors)
1706        .with_state(state);
1707
1708    let addr_str = format!("{}:{}", host, port);
1709    let addr: SocketAddr = addr_str.parse()?;
1710
1711    eprintln!(
1712        "Microscope Memory Spine Bridge API starting on http://{}",
1713        addr
1714    );
1715    eprintln!("  OpenAPI spec: http://{}/openapi.json", addr);
1716    eprintln!("  Import URL into ChatGPT/Claude for tool access.");
1717
1718    let listener = tokio::net::TcpListener::bind(addr).await?;
1719    axum::serve(listener, app).await?;
1720
1721    Ok(())
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726    use super::*;
1727    use axum::body::Body;
1728    use axum::http::Request;
1729    use tower::ServiceExt;
1730
1731    fn state_with_key(key: Option<&str>) -> Arc<AppState> {
1732        let mut config = Config::default();
1733        config.server.api_key = key.map(|s| s.to_string());
1734        Arc::new(AppState {
1735            config,
1736            active_project: Arc::new(Mutex::new(None)),
1737        })
1738    }
1739
1740    async fn whoami(Extension(auth): Extension<AuthenticatedUser>) -> String {
1741        auth.0.unwrap_or_else(|| "shared".to_string())
1742    }
1743
1744    #[test]
1745    fn user_token_roundtrip_and_forgery() {
1746        let master = "master";
1747        let token = user_token(master, "alice-1").unwrap();
1748        assert!(token.starts_with("alice-1."));
1749        assert_eq!(
1750            verify_user_token(master, &token).as_deref(),
1751            Some("alice-1")
1752        );
1753
1754        assert!(
1755            verify_user_token("other-master", &token).is_none(),
1756            "wrong master key must fail"
1757        );
1758
1759        let last = token.chars().last().unwrap();
1760        let replaced = if last == '0' { '1' } else { '0' };
1761        let tampered = format!("{}{}", &token[..token.len() - 1], replaced);
1762        assert!(
1763            verify_user_token(master, &tampered).is_none(),
1764            "tampered MAC must fail"
1765        );
1766
1767        assert!(user_token(master, "bad user!").is_err());
1768        assert!(user_token("", "alice").is_err());
1769    }
1770
1771    #[test]
1772    fn effective_user_id_prefers_verified_identity() {
1773        assert_eq!(
1774            effective_user_id(&AuthenticatedUser(Some("alice".to_string())), "bob"),
1775            "alice"
1776        );
1777        assert_eq!(effective_user_id(&AuthenticatedUser(None), "bob"), "bob");
1778    }
1779
1780    #[tokio::test]
1781    async fn bearer_token_binds_identity() {
1782        let master = "test-master-secret";
1783        let token = user_token(master, "alice").unwrap();
1784        let state = state_with_key(Some(master));
1785        let app = Router::new()
1786            .route("/whoami", get(whoami))
1787            .layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
1788            .with_state(state);
1789
1790        let resp = app
1791            .clone()
1792            .oneshot(
1793                Request::builder()
1794                    .uri("/whoami")
1795                    .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
1796                    .body(Body::empty())
1797                    .unwrap(),
1798            )
1799            .await
1800            .unwrap();
1801        assert_eq!(resp.status(), StatusCode::OK);
1802        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1803        assert_eq!(&body[..], b"alice");
1804
1805        let forged = user_token("different-master", "alice").unwrap();
1806        let resp = app
1807            .clone()
1808            .oneshot(
1809                Request::builder()
1810                    .uri("/whoami")
1811                    .header(
1812                        axum::http::header::AUTHORIZATION,
1813                        format!("Bearer {forged}"),
1814                    )
1815                    .body(Body::empty())
1816                    .unwrap(),
1817            )
1818            .await
1819            .unwrap();
1820        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1821
1822        let resp = app
1823            .oneshot(
1824                Request::builder()
1825                    .uri("/whoami")
1826                    .body(Body::empty())
1827                    .unwrap(),
1828            )
1829            .await
1830            .unwrap();
1831        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1832    }
1833
1834    #[tokio::test]
1835    async fn bearer_identity_overrides_body_user_id_end_to_end() {
1836        let tmp = tempfile::tempdir().unwrap();
1837        let mut config = Config::default();
1838        config.paths.layers_dir = tmp.path().join("layers").to_string_lossy().to_string();
1839        config.paths.output_dir = tmp.path().join("data").to_string_lossy().to_string();
1840        config.memory_layers.layers = vec!["long_term".to_string()];
1841        config.embedding.provider = "none".to_string();
1842        std::fs::create_dir_all(&config.paths.layers_dir).unwrap();
1843        crate::build::build(&config, true, true).unwrap();
1844
1845        let stored = "alice private note";
1846        crate::store_memory(&config, &scope_user_text("alice", stored), "long_term", 5).unwrap();
1847        crate::build::build(&config, true, true).unwrap();
1848
1849        let master = "master-key";
1850        let token = user_token(master, "alice").unwrap();
1851        let mut cfg = config.clone();
1852        cfg.server.api_key = Some(master.to_string());
1853        let state = Arc::new(AppState {
1854            config: cfg,
1855            active_project: Arc::new(Mutex::new(None)),
1856        });
1857        let app = Router::new()
1858            .route("/mobile/recall", post(mobile_recall))
1859            .layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
1860            .with_state(state);
1861
1862        // Body claims "bob"; the valid bearer token says "alice". Alice must win.
1863        let body = serde_json::json!({
1864            "user_id": "bob",
1865            "query": format!("[user:alice] {stored}"),
1866            "k": 10
1867        })
1868        .to_string();
1869        let resp = app
1870            .oneshot(
1871                Request::builder()
1872                    .method("POST")
1873                    .uri("/mobile/recall")
1874                    .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
1875                    .header("content-type", "application/json")
1876                    .body(Body::from(body))
1877                    .unwrap(),
1878            )
1879            .await
1880            .unwrap();
1881        assert_eq!(resp.status(), StatusCode::OK);
1882        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
1883            .await
1884            .unwrap();
1885        let text = String::from_utf8(bytes.to_vec()).unwrap();
1886        assert!(
1887            text.contains(stored),
1888            "verified bearer identity must override the body user_id: {text}"
1889        );
1890    }
1891
1892    #[tokio::test]
1893    async fn api_key_middleware_rejects_missing_and_accepts_valid_key() {
1894        let state = state_with_key(Some("secret"));
1895        let app = Router::new()
1896            .route("/ping", get(|| async { "pong" }))
1897            .layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
1898            .with_state(state);
1899
1900        let resp = app
1901            .clone()
1902            .oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
1903            .await
1904            .unwrap();
1905        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1906
1907        let resp = app
1908            .clone()
1909            .oneshot(
1910                Request::builder()
1911                    .uri("/ping")
1912                    .header(API_KEY_HEADER, "wrong")
1913                    .body(Body::empty())
1914                    .unwrap(),
1915            )
1916            .await
1917            .unwrap();
1918        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1919
1920        let resp = app
1921            .oneshot(
1922                Request::builder()
1923                    .uri("/ping")
1924                    .header(API_KEY_HEADER, "secret")
1925                    .body(Body::empty())
1926                    .unwrap(),
1927            )
1928            .await
1929            .unwrap();
1930        assert_eq!(resp.status(), StatusCode::OK);
1931    }
1932
1933    #[tokio::test]
1934    async fn api_key_middleware_passes_through_without_configured_key() {
1935        let state = state_with_key(None);
1936        let app = Router::new()
1937            .route("/ping", get(|| async { "pong" }))
1938            .layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
1939            .with_state(state);
1940
1941        let resp = app
1942            .oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
1943            .await
1944            .unwrap();
1945        assert_eq!(resp.status(), StatusCode::OK);
1946    }
1947
1948    #[test]
1949    fn loopback_detection() {
1950        assert!(is_loopback("127.0.0.1"));
1951        assert!(is_loopback("localhost"));
1952        assert!(is_loopback("::1"));
1953        assert!(!is_loopback("0.0.0.0"));
1954        assert!(!is_loopback("192.168.1.10"));
1955    }
1956
1957    #[tokio::test]
1958    async fn cors_honors_configured_origin() {
1959        let origin = "https://app.example".to_string();
1960        let app = Router::new()
1961            .route("/ping", get(|| async { "pong" }))
1962            .layer(cors_layer(&Some(origin)));
1963
1964        // Matching origin → allowed.
1965        let resp = app
1966            .clone()
1967            .oneshot(
1968                Request::builder()
1969                    .uri("/ping")
1970                    .header("origin", "https://app.example")
1971                    .body(Body::empty())
1972                    .unwrap(),
1973            )
1974            .await
1975            .unwrap();
1976        assert_eq!(
1977            resp.headers()
1978                .get("access-control-allow-origin")
1979                .and_then(|v| v.to_str().ok()),
1980            Some("https://app.example")
1981        );
1982
1983        // Non-matching origin → the response carries the configured origin,
1984        // never the requester's; a browser therefore blocks the read.
1985        let resp = app
1986            .oneshot(
1987                Request::builder()
1988                    .uri("/ping")
1989                    .header("origin", "https://evil.example")
1990                    .body(Body::empty())
1991                    .unwrap(),
1992            )
1993            .await
1994            .unwrap();
1995        assert!(
1996            resp.headers()
1997                .get("access-control-allow-origin")
1998                .and_then(|v| v.to_str().ok())
1999                != Some("https://evil.example"),
2000            "the requester's origin must never be reflected"
2001        );
2002    }
2003}