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#[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
40fn 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
56fn 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
68pub 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
91fn 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#[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 #[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 pub class: Option<String>,
231 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, 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
410fn 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 let response = dedup_by_text(response, k);
576
577 Ok(Json(response))
578}
579
580fn 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 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
630fn 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 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 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, ¶ms.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 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 ledger.get_or_create(claim_hash, class, 0, now);
876 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 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 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 & stats</td></tr>
1319<tr><td>GET</td><td><code>/v1/session?user_id=u1&memory_backend=cloud&memory_scope=both</code></td><td>Resolve personal+shared namespaces</td></tr>
1320<tr><td>GET</td><td><code>/v1/recall?q=...&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&k=3&user_id=alice&memory_backend=cloud&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, ¶ms.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(¶ms.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 .nest("/v1", v1_routes)
1688 .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 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 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 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}