AkurAI Build
Menu

AkurAI-NotesCommit b3a7784a

Commit b3a7784a

b3a7784af2094889f6d5d4630b0cbd6d8ff94452 by Claude Code

diff --git a/Cargo.toml b/Cargo.toml
index 8fec04b..318b153 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "2"
 members = ["crates/notes-core", "crates/notes-api", "crates/notes-cli", "crates/notes-mcp"]
 
 [workspace.package]
-version = "0.0.1"
+version = "0.0.2"
 edition = "2021"
 license = "MIT"
 repository = "https://github.com/olibuijr/AkurAI-Notes"
diff --git a/VERSION b/VERSION
index 8acdd82..4e379d2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.0.1
+0.0.2
diff --git a/crates/notes-api/src/embed.rs b/crates/notes-api/src/embed.rs
index 30d43cb..ae8ea65 100644
--- a/crates/notes-api/src/embed.rs
+++ b/crates/notes-api/src/embed.rs
@@ -1,5 +1,6 @@
 use akurai_storage::BTree;
 use akurai_vector::{decode, embed, encode, EmbedError};
+use notes_core::search::owner_matches;
 
 pub struct EmbedStore<'a> {
     pub db: &'a mut BTree,
@@ -31,7 +32,7 @@ impl<'a> EmbedStore<'a> {
         Ok(())
     }
 
-    pub fn rank_notes(&mut self, qv: &[f32], owner_sub: &str, k: usize) -> Vec<(i64, f32)> {
+    pub fn rank_notes(&mut self, qv: &[f32], owners: &[&str], k: usize) -> Vec<(i64, f32)> {
         let prefix = b"note_emb:";
         let end = upper_bound(prefix);
 
@@ -50,7 +51,7 @@ impl<'a> EmbedStore<'a> {
                 Ok(i) => i,
                 Err(_) => continue,
             };
-            if !self.note_is_owned_by(id, owner_sub) {
+            if !self.note_is_owned_by(id, owners) {
                 continue;
             }
             if let Some(doc) = decode(&val) {
@@ -65,13 +66,13 @@ impl<'a> EmbedStore<'a> {
             .collect()
     }
 
-    fn note_is_owned_by(&mut self, id: i64, want: &str) -> bool {
+    fn note_is_owned_by(&mut self, id: i64, owners: &[&str]) -> bool {
         let mut key = b"notes:".to_vec();
         key.extend_from_slice(&(id as u64).to_be_bytes());
         match self.db.get(&key) {
             Ok(Some(bytes)) => {
                 if let Ok(v) = akurai_json::parse(&String::from_utf8_lossy(&bytes)) {
-                    v.get("ownerSub").and_then(|x| x.as_str()) == Some(want)
+                    owner_matches(&v, owners)
                 } else {
                     false
                 }
diff --git a/crates/notes-api/src/handlers/crud.rs b/crates/notes-api/src/handlers/crud.rs
index bdd5c2e..454e736 100644
--- a/crates/notes-api/src/handlers/crud.rs
+++ b/crates/notes-api/src/handlers/crud.rs
@@ -27,11 +27,26 @@ fn build_key(coll_name: &str, id: u64) -> Vec<u8> {
     key
 }
 
-fn get_owner_sub(state: &NotesState, req: &Request) -> Option<String> {
+struct OwnerIds {
+    sub: String,
+    email: String,
+}
+
+fn get_owner_ids(state: &NotesState, req: &Request) -> Option<OwnerIds> {
     let sid = session::extract_session_id(req)?;
     let mut db = state.db.lock().ok()?;
     let user = session::get_user(&mut db, &sid)?;
-    Some(user.sub)
+    Some(OwnerIds {
+        sub: user.sub,
+        email: user.email,
+    })
+}
+
+fn owner_matches(record: &Value, owner: &OwnerIds) -> bool {
+    match record.get("ownerSub").and_then(|v| v.as_str()) {
+        Some(s) => s == owner.sub || s == owner.email,
+        None => false,
+    }
 }
 
 pub fn list_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteHandler {
@@ -40,8 +55,8 @@ pub fn list_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteHan
             Ok(s) => s,
             Err(_) => return internal_error("lock poisoned"),
         };
-        let owner_sub = match get_owner_sub(&state_lock, req) {
-            Some(s) => s,
+        let owner = match get_owner_ids(&state_lock, req) {
+            Some(o) => o,
             None => {
                 return json_response(Value::Object(vec![(
                     "error".into(),
@@ -63,7 +78,7 @@ pub fn list_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteHan
             for (_key_bytes, val_bytes) in entries {
                 if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
                 {
-                    if json.get("ownerSub").and_then(|v| v.as_str()) == Some(&owner_sub) {
+                    if owner_matches(&json, &owner) {
                         records.push(json);
                     }
                 }
@@ -84,8 +99,8 @@ pub fn get_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteHand
             Ok(s) => s,
             Err(_) => return internal_error("lock poisoned"),
         };
-        let owner_sub = match get_owner_sub(&state_lock, req) {
-            Some(s) => s,
+        let owner = match get_owner_ids(&state_lock, req) {
+            Some(o) => o,
             None => {
                 return json_response(Value::Object(vec![(
                     "error".into(),
@@ -103,7 +118,7 @@ pub fn get_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteHand
         match db.get(&key) {
             Ok(Some(val)) => match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
                 Ok(json) => {
-                    if json.get("ownerSub").and_then(|v| v.as_str()) != Some(&owner_sub) {
+                    if !owner_matches(&json, &owner) {
                         return not_found(&format!("{coll}/{id}"));
                     }
                     json_response(json)
@@ -126,8 +141,8 @@ pub fn create_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
             Ok(s) => s,
             Err(_) => return internal_error("lock poisoned"),
         };
-        let owner_sub = match get_owner_sub(&state_lock, req) {
-            Some(s) => s,
+        let owner = match get_owner_ids(&state_lock, req) {
+            Some(o) => o,
             None => {
                 return json_response(Value::Object(vec![(
                     "error".into(),
@@ -165,17 +180,17 @@ pub fn create_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
             _ => return bad_request("expected JSON object"),
         };
 
-        // Force ownerSub to the session user
+        // Force ownerSub to the session user's sub
         let mut found_owner = false;
         for pair in obj.iter_mut() {
             if pair.0 == "ownerSub" {
-                pair.1 = Value::Str(owner_sub.clone());
+                pair.1 = Value::Str(owner.sub.clone());
                 found_owner = true;
                 break;
             }
         }
         if !found_owner {
-            obj.push(("ownerSub".into(), Value::Str(owner_sub)));
+            obj.push(("ownerSub".into(), Value::Str(owner.sub.clone())));
         }
 
         obj.push(("id".into(), Value::Int(next_id as i64)));
@@ -230,8 +245,8 @@ pub fn update_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
             Ok(s) => s,
             Err(_) => return internal_error("lock poisoned"),
         };
-        let owner_sub = match get_owner_sub(&state_lock, req) {
-            Some(s) => s,
+        let owner = match get_owner_ids(&state_lock, req) {
+            Some(o) => o,
             None => {
                 return json_response(Value::Object(vec![(
                     "error".into(),
@@ -262,21 +277,16 @@ pub fn update_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
             .unwrap_or("")
             .to_string();
 
+        // Owner check — match by sub or email (before destructuring)
+        if !owner_matches(&existing, &owner) {
+            return not_found(&format!("{coll}/{id}"));
+        }
+
         let mut existing_fields = match existing {
             Value::Object(f) => f,
             _ => return internal_error("corrupted record"),
         };
 
-        // Owner check
-        let rec_owner = existing_fields
-            .iter()
-            .find(|(k, _)| k == "ownerSub")
-            .and_then(|(_, v)| v.as_str())
-            .unwrap_or("");
-        if rec_owner != owner_sub {
-            return not_found(&format!("{coll}/{id}"));
-        }
-
         if let Value::Object(update_fields) = body {
             for (k, v) in update_fields {
                 if k != "id" && k != "createdAt" && k != "ownerSub" {
@@ -347,8 +357,8 @@ pub fn delete_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
             Ok(s) => s,
             Err(_) => return internal_error("lock poisoned"),
         };
-        let owner_sub = match get_owner_sub(&state_lock, req) {
-            Some(s) => s,
+        let owner = match get_owner_ids(&state_lock, req) {
+            Some(o) => o,
             None => {
                 return json_response(Value::Object(vec![(
                     "error".into(),
@@ -368,7 +378,7 @@ pub fn delete_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
         match db.get(&key) {
             Ok(Some(val)) => {
                 if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
-                    if json.get("ownerSub").and_then(|v| v.as_str()) != Some(&owner_sub) {
+                    if !owner_matches(&json, &owner) {
                         return not_found(&format!("{coll}/{id}"));
                     }
                 }
diff --git a/crates/notes-api/src/handlers/search.rs b/crates/notes-api/src/handlers/search.rs
index d1ad4ac..cbbde80 100644
--- a/crates/notes-api/src/handlers/search.rs
+++ b/crates/notes-api/src/handlers/search.rs
@@ -5,6 +5,7 @@ use crate::router_setup::{json_response, upper_bound, NotesState};
 use akurai_http::{Request, Response};
 use akurai_json::Value;
 use akurai_storage::BTree;
+use notes_core::search as note_search;
 use std::sync::{Arc, Mutex};
 
 pub fn search_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
@@ -15,7 +16,7 @@ pub fn search_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
         for pair in query_str.split('&') {
             if let Some((key, value)) = pair.split_once('=') {
                 match key {
-                    "q" => q = value.to_string(),
+                    "q" => q = decode_query_component(value),
                     "k" => k = value.parse().unwrap_or(20),
                     _ => {}
                 }
@@ -99,16 +100,19 @@ pub fn search_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
                         }
                     };
                     let mut store = EmbedStore { db: &mut db };
-                    let ranked = store.rank_notes(&qv, &user.sub, k);
+                    let owners = [user.sub.as_str(), user.email.as_str()];
+                    let ranked = store.rank_notes(&qv, &owners, k);
                     let results: Vec<Value> = ranked
                         .into_iter()
                         .map(|(id, score)| note_to_result(&mut *store.db, id, score))
                         .collect();
-                    return json_response(Value::Object(vec![
-                        ("query".into(), Value::Str(q)),
-                        ("mode".into(), Value::Str("semantic".into())),
-                        ("results".into(), Value::Array(results)),
-                    ]));
+                    if !results.is_empty() {
+                        return json_response(Value::Object(vec![
+                            ("query".into(), Value::Str(q)),
+                            ("mode".into(), Value::Str("semantic".into())),
+                            ("results".into(), Value::Array(results)),
+                        ]));
+                    }
                 }
                 Err(e) => {
                     eprintln!("embed query failed: {e} — falling back to substring");
@@ -136,7 +140,8 @@ pub fn search_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
                     )]));
                 }
             };
-            substring_search(&mut db, &q, &user.sub, k)
+            let owners = [user.sub.as_str(), user.email.as_str()];
+            substring_search(&mut db, &q, &owners, k)
         };
 
         json_response(Value::Object(vec![
@@ -147,8 +152,7 @@ pub fn search_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
     })
 }
 
-fn substring_search(db: &mut BTree, q: &str, owner_sub: &str, k: usize) -> Vec<Value> {
-    let q_lower = q.to_lowercase();
+fn substring_search(db: &mut BTree, q: &str, owners: &[&str], k: usize) -> Vec<Value> {
     let prefix = b"notes:";
     let end = upper_bound(prefix);
 
@@ -157,7 +161,7 @@ fn substring_search(db: &mut BTree, q: &str, owner_sub: &str, k: usize) -> Vec<V
         Err(_) => return Vec::new(),
     };
 
-    let mut scored: Vec<(i64, f32, String, String)> = Vec::new();
+    let mut scored: Vec<(i64, f32, i64, String, String)> = Vec::new();
 
     for entry in entries {
         let (key, val) = entry;
@@ -169,42 +173,36 @@ fn substring_search(db: &mut BTree, q: &str, owner_sub: &str, k: usize) -> Vec<V
         let Ok(v) = akurai_json::parse(&String::from_utf8_lossy(&val)) else {
             continue;
         };
-        if v.get("ownerSub").and_then(|x| x.as_str()) != Some(owner_sub) {
+        if !note_search::owner_matches(&v, owners) {
             continue;
         }
-        let title = v
-            .get("title")
-            .and_then(|x| x.as_str())
-            .unwrap_or("")
-            .to_string();
-        let body = v
-            .get("body")
-            .and_then(|x| x.as_str())
-            .unwrap_or("")
-            .to_string();
-        let combined = format!("{} {}", title, body).to_lowercase();
-        if combined.contains(&q_lower) {
-            let count = combined.matches(&q_lower).count() as f32;
-            scored.push((id, count, title, body));
+        let title = note_search::note_title(&v).to_string();
+        let body = note_search::note_body(&v).to_string();
+        if let Some(score) = note_search::lexical_score(q, &title, &body) {
+            let updated_at = note_search::note_updated_at(&v);
+            scored.push((id, score, updated_at, title, body));
         }
     }
 
-    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
+    scored.sort_by(|a, b| {
+        b.1.partial_cmp(&a.1)
+            .unwrap_or(std::cmp::Ordering::Equal)
+            .then_with(|| b.2.cmp(&a.2))
+            .then_with(|| b.0.cmp(&a.0))
+    });
     scored.truncate(k);
 
     scored
         .into_iter()
-        .map(|(id, _score, title, body)| {
-            let snippet = if body.len() > 180 {
-                format!("{}…", &body[..180])
-            } else {
-                body.clone()
-            };
+        .map(|(id, score, _updated_at, title, body)| {
             Value::Object(vec![
                 ("id".into(), Value::Int(id)),
                 ("title".into(), Value::Str(title)),
-                ("snippet".into(), Value::Str(snippet)),
-                ("score".into(), Value::Float(0.0)),
+                (
+                    "snippet".into(),
+                    Value::Str(note_search::snippet(&body, 180)),
+                ),
+                ("score".into(), Value::Float(score as f64)),
             ])
         })
         .collect()
@@ -233,11 +231,7 @@ fn note_to_result(db: &mut BTree, id: i64, score: f32) -> Value {
             updated_at = v.get("updatedAt").and_then(|x| x.as_i64()).unwrap_or(0);
         }
     }
-    let snippet = if body.len() > 180 {
-        format!("{}…", &body[..180])
-    } else {
-        body
-    };
+    let snippet = note_search::snippet(&body, 180);
     Value::Object(vec![
         ("id".into(), Value::Int(id)),
         ("title".into(), Value::Str(title)),
@@ -247,3 +241,53 @@ fn note_to_result(db: &mut BTree, id: i64, score: f32) -> Value {
         ("updatedAt".into(), Value::Int(updated_at)),
     ])
 }
+
+fn decode_query_component(input: &str) -> String {
+    let bytes = input.as_bytes();
+    let mut out = Vec::with_capacity(bytes.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        match bytes[i] {
+            b'+' => {
+                out.push(b' ');
+                i += 1;
+            }
+            b'%' if i + 2 < bytes.len() => {
+                if let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) {
+                    out.push((hi << 4) | lo);
+                    i += 3;
+                } else {
+                    out.push(bytes[i]);
+                    i += 1;
+                }
+            }
+            b => {
+                out.push(b);
+                i += 1;
+            }
+        }
+    }
+    String::from_utf8_lossy(&out).to_string()
+}
+
+fn hex_value(byte: u8) -> Option<u8> {
+    match byte {
+        b'0'..=b'9' => Some(byte - b'0'),
+        b'a'..=b'f' => Some(byte - b'a' + 10),
+        b'A'..=b'F' => Some(byte - b'A' + 10),
+        _ => None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::decode_query_component;
+
+    #[test]
+    fn decodes_browser_query_components() {
+        assert_eq!(
+            decode_query_component("AkurAI+Platform+Skills%20CLI%20Tools"),
+            "AkurAI Platform Skills CLI Tools"
+        );
+    }
+}
diff --git a/crates/notes-api/tests/embed_test.rs b/crates/notes-api/tests/embed_test.rs
index 7496e93..2aab8ff 100644
--- a/crates/notes-api/tests/embed_test.rs
+++ b/crates/notes-api/tests/embed_test.rs
@@ -79,7 +79,7 @@ fn rank_notes_returns_only_owned_notes_in_order() {
 
     let qv = vec![0.8, 0.2, 0.0];
 
-    let ranked = store.rank_notes(&qv, "alice", 5);
+    let ranked = store.rank_notes(&qv, &["alice"], 5);
     assert_eq!(
         ranked.len(),
         2,
@@ -88,7 +88,7 @@ fn rank_notes_returns_only_owned_notes_in_order() {
     assert_eq!(ranked[0].0, 1, "note 1 should rank first (closest to qv)");
     assert_eq!(ranked[1].0, 2, "note 2 should rank second");
 
-    let ranked = store.rank_notes(&qv, "alice", 1);
+    let ranked = store.rank_notes(&qv, &["alice"], 1);
     assert_eq!(ranked.len(), 1);
 
     std::fs::remove_file(&db_path).ok();
diff --git a/crates/notes-cli/src/main.rs b/crates/notes-cli/src/main.rs
index 305f9c7..38bdb55 100644
--- a/crates/notes-cli/src/main.rs
+++ b/crates/notes-cli/src/main.rs
@@ -85,7 +85,7 @@ fn parse_serve(args: &[String]) -> Result<cmd_serve::Config, String> {
 
 fn parse_mcp(args: &[String]) -> Result<cmd_mcp::McpRunConfig, String> {
     let mut host = "127.0.0.1".to_string();
-    let mut port: u16 = 8100;
+    let mut port: u16 = 8101;
     let mut db = "/var/lib/akurai-notes/notes.db".to_string();
     let mut token = String::new();
     let mut embed_url = String::new();
@@ -150,7 +150,7 @@ fn print_help() {
     println!("  --db <path>     Database file (default: notes.db)\n");
     println!("MCP OPTIONS:");
     println!("  --host <addr>      Bind host (default: 127.0.0.1)");
-    println!("  --port, -p <n>     Bind port (default: 8100)");
+    println!("  --port, -p <n>     Bind port (default: 8101)");
     println!("  --db <path>        Database file (default: /var/lib/akurai-notes/notes.db)");
     println!("  --token <tok>      Bearer token (env: AKURAI_NOTES_MCP_TOKEN)");
     println!("  --embed-url <url>  Embedding API URL");
diff --git a/crates/notes-core/src/lib.rs b/crates/notes-core/src/lib.rs
index ed9abb4..51c75c8 100644
--- a/crates/notes-core/src/lib.rs
+++ b/crates/notes-core/src/lib.rs
@@ -2,4 +2,5 @@
 pub mod entities;
 pub mod error;
 pub mod metadata;
+pub mod search;
 pub mod types;
diff --git a/crates/notes-core/src/search.rs b/crates/notes-core/src/search.rs
new file mode 100644
index 0000000..921176a
--- /dev/null
+++ b/crates/notes-core/src/search.rs
@@ -0,0 +1,166 @@
+use akurai_json::Value;
+
+pub fn owner_matches(record: &Value, owners: &[&str]) -> bool {
+    let Some(record_owner) = record.get("ownerSub").and_then(|v| v.as_str()) else {
+        return false;
+    };
+    owners
+        .iter()
+        .any(|owner| !owner.is_empty() && record_owner == *owner)
+}
+
+pub fn lexical_score(query: &str, title: &str, body: &str) -> Option<f32> {
+    let tokens = query_tokens(query);
+    if tokens.is_empty() {
+        return None;
+    }
+
+    let normalized_query = normalize_text(query);
+    let title_norm = normalize_text(title);
+    let body_norm = normalize_text(body);
+    let combined = format!("{title_norm} {body_norm}");
+
+    if tokens.iter().any(|token| !combined.contains(token)) {
+        return None;
+    }
+
+    let mut score = 0.0;
+    if !normalized_query.is_empty() {
+        if title_norm.contains(&normalized_query) {
+            score += 100.0;
+        }
+        if body_norm.contains(&normalized_query) {
+            score += 30.0;
+        }
+    }
+
+    for token in &tokens {
+        if title_norm.contains(token) {
+            score += 12.0;
+            score += occurrence_count(&title_norm, token).min(4) as f32;
+        }
+        if body_norm.contains(token) {
+            score += 3.0;
+            score += occurrence_count(&body_norm, token).min(6) as f32 * 0.5;
+        }
+    }
+
+    Some(score)
+}
+
+pub fn note_id(note: &Value) -> i64 {
+    note.get("id").and_then(|v| v.as_i64()).unwrap_or(0)
+}
+
+pub fn note_updated_at(note: &Value) -> i64 {
+    note.get("updatedAt").and_then(|v| v.as_i64()).unwrap_or(0)
+}
+
+pub fn note_title(note: &Value) -> &str {
+    note.get("title").and_then(|v| v.as_str()).unwrap_or("")
+}
+
+pub fn note_body(note: &Value) -> &str {
+    note.get("body").and_then(|v| v.as_str()).unwrap_or("")
+}
+
+pub fn snippet(body: &str, max_chars: usize) -> String {
+    let mut out = String::new();
+    let mut truncated = false;
+    for (idx, ch) in body.chars().enumerate() {
+        if idx >= max_chars {
+            truncated = true;
+            break;
+        }
+        out.push(ch);
+    }
+    if truncated {
+        out.push_str("...");
+    }
+    out
+}
+
+fn query_tokens(query: &str) -> Vec<String> {
+    let mut tokens = Vec::new();
+    let mut current = String::new();
+
+    for ch in query.chars() {
+        if ch.is_alphanumeric() {
+            for lower in ch.to_lowercase() {
+                current.push(lower);
+            }
+        } else if !current.is_empty() {
+            push_unique(&mut tokens, &current);
+            current.clear();
+        }
+    }
+
+    if !current.is_empty() {
+        push_unique(&mut tokens, &current);
+    }
+
+    tokens
+}
+
+fn push_unique(tokens: &mut Vec<String>, token: &str) {
+    if !tokens.iter().any(|existing| existing == token) {
+        tokens.push(token.to_string());
+    }
+}
+
+fn normalize_text(text: &str) -> String {
+    let mut out = String::new();
+    let mut last_was_space = true;
+
+    for ch in text.chars() {
+        if ch.is_alphanumeric() {
+            for lower in ch.to_lowercase() {
+                out.push(lower);
+            }
+            last_was_space = false;
+        } else if !last_was_space {
+            out.push(' ');
+            last_was_space = true;
+        }
+    }
+
+    if out.ends_with(' ') {
+        out.pop();
+    }
+    out
+}
+
+fn occurrence_count(haystack: &str, needle: &str) -> usize {
+    if needle.is_empty() {
+        return 0;
+    }
+    haystack.matches(needle).count()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn lexical_score_ignores_punctuation_between_query_terms() {
+        let score = lexical_score(
+            "AkurAI Platform Skills CLI Tools",
+            "AkurAI Platform - Skills & CLI Tools",
+            "",
+        );
+        assert!(score.is_some());
+    }
+
+    #[test]
+    fn lexical_score_requires_all_query_terms() {
+        let score = lexical_score("platform passvault", "AkurAI Platform", "CLI tools");
+        assert!(score.is_none());
+    }
+
+    #[test]
+    fn title_matches_rank_above_body_only_matches() {
+        let title_score = lexical_score("skills cli tools", "Skills & CLI Tools", "").unwrap();
+        let body_score = lexical_score("skills cli tools", "Index", "Skills CLI Tools").unwrap();
+        assert!(title_score > body_score);
+    }
+}
diff --git a/crates/notes-mcp/src/tools.rs b/crates/notes-mcp/src/tools.rs
index ebc5f07..233cc79 100644
--- a/crates/notes-mcp/src/tools.rs
+++ b/crates/notes-mcp/src/tools.rs
@@ -1,5 +1,7 @@
 use akurai_json::Value;
 use akurai_storage::BTree;
+use notes_api::embed::{embed_query, EmbedStore};
+use notes_core::search as note_search;
 use std::sync::{Arc, Mutex};
 
 pub struct ToolRegistry {
@@ -54,9 +56,7 @@ impl ToolRegistry {
             self.tool_def(
                 "list_notes",
                 "List all notes, optionally filtered by notebookId",
-                vec![
-                    ("notebookId", "number", "Filter by notebook ID", false),
-                ],
+                vec![("notebookId", "number", "Filter by notebook ID", false)],
             ),
             self.tool_def("list_notebooks", "List all notebooks", vec![]),
             self.tool_def(
@@ -383,8 +383,7 @@ impl ToolRegistry {
     fn search_notes(&self, args: &[(String, Value)]) -> Result<Value, String> {
         let query = Self::get_arg(args, "query")
             .and_then(|v| v.as_str())
-            .unwrap_or("")
-            .to_lowercase();
+            .unwrap_or("");
         if query.is_empty() {
             return Ok(Value::Array(vec![]));
         }
@@ -394,37 +393,13 @@ impl ToolRegistry {
             .map(|f| f as usize)
             .unwrap_or(20);
 
-        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
-        let mut results = Vec::new();
-        let prefix = b"notes:";
-        let end = upper_bound(prefix);
-        if let Ok(entries) = db.range(prefix, &end) {
-            for (_, val_bytes) in entries {
-                if results.len() >= k {
-                    break;
-                }
-                let s = String::from_utf8_lossy(&val_bytes).to_string();
-                if let Ok(v) = akurai_json::parse(&s) {
-                    if v.get("ownerSub").and_then(|o| o.as_str()) != Some(self.owner_sub()) {
-                        continue;
-                    }
-                    let title = v.get("title").and_then(|t| t.as_str()).unwrap_or("");
-                    let body = v.get("body").and_then(|b| b.as_str()).unwrap_or("");
-                    if title.to_lowercase().contains(&query) || body.to_lowercase().contains(&query)
-                    {
-                        results.push(v);
-                    }
-                }
-            }
-        }
-        Ok(Value::Array(results))
+        self.lexical_note_results(query, k).map(Value::Array)
     }
 
     fn semantic_search(&self, args: &[(String, Value)]) -> Result<Value, String> {
         let query = Self::get_arg(args, "query")
             .and_then(|v| v.as_str())
-            .unwrap_or("")
-            .to_lowercase();
+            .unwrap_or("");
         if query.is_empty() {
             return Ok(Value::Array(vec![]));
         }
@@ -434,47 +409,69 @@ impl ToolRegistry {
             .map(|f| f as usize)
             .unwrap_or(20);
 
-        if self.embed_url.is_some() {
-            let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
-            let mut results = Vec::new();
-            let prefix = b"notes:";
-            let end = upper_bound(prefix);
-            if let Ok(entries) = db.range(prefix, &end) {
-                for (_, val_bytes) in entries {
-                    if results.len() >= k {
-                        break;
-                    }
-                    let s = String::from_utf8_lossy(&val_bytes).to_string();
-                    if let Ok(v) = akurai_json::parse(&s) {
-                        if v.get("ownerSub").and_then(|o| o.as_str()) != Some(self.owner_sub()) {
-                            continue;
+        if let Some(ref url) = self.embed_url {
+            let model = self
+                .embed_model
+                .as_deref()
+                .unwrap_or("qwen3-embedding-0.6b");
+            if let Ok(qv) = embed_query(query, url, model) {
+                let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+                let mut store = EmbedStore { db: &mut db };
+                let owners = [self.owner_sub()];
+                let ranked = store.rank_notes(&qv, &owners, k);
+                let mut results = Vec::new();
+                for (id, score) in ranked {
+                    let mut key = b"notes:".to_vec();
+                    key.extend_from_slice(&(id as u64).to_be_bytes());
+                    if let Ok(Some(bytes)) = store.db.get(&key) {
+                        if let Ok(mut note) = akurai_json::parse(&String::from_utf8_lossy(&bytes)) {
+                            if let Value::Object(ref mut fields) = note {
+                                set_field(fields, "score", Value::Float(score as f64));
+                            }
+                            results.push(note);
                         }
-                        results.push(v);
                     }
                 }
-            }
-            Ok(Value::Array(results))
-        } else {
-            let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
-            let mut results = Vec::new();
-            let prefix = b"notes:";
-            let end = upper_bound(prefix);
-            if let Ok(entries) = db.range(prefix, &end) {
-                for (_, val_bytes) in entries {
-                    if results.len() >= k {
-                        break;
-                    }
-                    let s = String::from_utf8_lossy(&val_bytes).to_string();
-                    if let Ok(v) = akurai_json::parse(&s) {
-                        if v.get("ownerSub").and_then(|o| o.as_str()) != Some(self.owner_sub()) {
-                            continue;
-                        }
-                        results.push(v);
-                    }
+                if !results.is_empty() {
+                    return Ok(Value::Array(results));
                 }
             }
-            Ok(Value::Array(results))
         }
+
+        self.lexical_note_results(query, k).map(Value::Array)
+    }
+
+    fn lexical_note_results(&self, query: &str, k: usize) -> Result<Vec<Value>, String> {
+        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+        let mut scored: Vec<(Value, f32, i64, i64)> = Vec::new();
+        let prefix = b"notes:";
+        let end = upper_bound(prefix);
+        if let Ok(entries) = db.range(prefix, &end) {
+            for (_, val_bytes) in entries {
+                let s = String::from_utf8_lossy(&val_bytes).to_string();
+                if let Ok(v) = akurai_json::parse(&s) {
+                    if !note_search::owner_matches(&v, &[self.owner_sub()]) {
+                        continue;
+                    }
+                    let title = note_search::note_title(&v);
+                    let body = note_search::note_body(&v);
+                    if let Some(score) = note_search::lexical_score(query, title, body) {
+                        let updated_at = note_search::note_updated_at(&v);
+                        let id = note_search::note_id(&v);
+                        scored.push((v, score, updated_at, id));
+                    }
+                }
+            }
+        }
+
+        scored.sort_by(|a, b| {
+            b.1.partial_cmp(&a.1)
+                .unwrap_or(std::cmp::Ordering::Equal)
+                .then_with(|| b.2.cmp(&a.2))
+                .then_with(|| b.3.cmp(&a.3))
+        });
+        scored.truncate(k);
+        Ok(scored.into_iter().map(|(note, _, _, _)| note).collect())
     }
 
     fn get_note(&self, args: &[(String, Value)]) -> Result<Value, String> {
@@ -487,7 +484,8 @@ impl ToolRegistry {
     }
 
     fn list_notes(&self, args: &[(String, Value)]) -> Result<Value, String> {
-        let filter_notebook = Self::get_arg(args, "notebookId").and_then(|v| v.as_f64().map(|f| f as i64));
+        let filter_notebook =
+            Self::get_arg(args, "notebookId").and_then(|v| v.as_f64().map(|f| f as i64));
         let mut notes = self.scan_collection("notes")?;
         notes.retain(|n| n.get("ownerSub").and_then(|o| o.as_str()) == Some(self.owner_sub()));
         if let Some(nbid) = filter_notebook {
@@ -849,7 +847,8 @@ impl ToolRegistry {
             .ok_or("missing id")?;
         let new_title = Self::get_arg(args, "title").and_then(|v| v.as_str());
         let new_body = Self::get_arg(args, "body").and_then(|v| v.as_str());
-        let new_notebook = Self::get_arg(args, "notebookId").and_then(|v| v.as_f64().map(|f| f as i64));
+        let new_notebook =
+            Self::get_arg(args, "notebookId").and_then(|v| v.as_f64().map(|f| f as i64));
         let new_archived = Self::get_arg(args, "archived").and_then(|v| v.as_bool());
 
         let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
diff --git a/crates/notes-mcp/tests/mcp_test.rs b/crates/notes-mcp/tests/mcp_test.rs
index 5e3106a..11a7e14 100644
--- a/crates/notes-mcp/tests/mcp_test.rs
+++ b/crates/notes-mcp/tests/mcp_test.rs
@@ -109,6 +109,58 @@ fn search_notes_returns_owned_only() {
     assert!(resp.contains("Meeting notes"));
 }
 
+#[test]
+fn search_notes_tokenizes_and_ranks_title_match() {
+    let registry = make_registry();
+    create_note_via_tool(
+        &registry,
+        "AkurAI Platform - Skills & CLI Tools",
+        "Canonical tool inventory",
+        "testuser@example.com",
+    );
+    create_note_via_tool(
+        &registry,
+        "AkurAI EC2 - Documentation Index",
+        "Cross-cutting docs link to AkurAI Platform Skills CLI Tools",
+        "testuser@example.com",
+    );
+
+    let req = r#"{"jsonrpc":"2.0","id":30,"method":"tools/call","params":{"name":"search_notes","arguments":{"query":"AkurAI Platform Skills CLI Tools","k":2}}}"#;
+    let resp = registry.handle_request(req);
+    let title_pos = resp
+        .find("AkurAI Platform - Skills & CLI Tools")
+        .expect("title match should be returned");
+    let index_pos = resp
+        .find("AkurAI EC2 - Documentation Index")
+        .expect("cross-reference match should be returned");
+    assert!(
+        title_pos < index_pos,
+        "title match should rank before body-only cross-reference: {resp}"
+    );
+}
+
+#[test]
+fn semantic_search_without_embed_uses_lexical_query() {
+    let registry = make_registry();
+    create_note_via_tool(
+        &registry,
+        "Deploy runbook",
+        "akurai-ec2 ports drift before release",
+        "testuser@example.com",
+    );
+    create_note_via_tool(
+        &registry,
+        "Grocery list",
+        "milk and eggs",
+        "testuser@example.com",
+    );
+
+    let req = r#"{"jsonrpc":"2.0","id":31,"method":"tools/call","params":{"name":"semantic_search","arguments":{"query":"akurai ec2 ports","k":5}}}"#;
+    let resp = registry.handle_request(req);
+    assert!(resp.contains("Deploy runbook"), "{resp}");
+    assert!(!resp.contains("Grocery list"), "{resp}");
+}
+
 #[test]
 fn get_note_returns_full_note() {
     let registry = make_registry();
@@ -160,12 +212,18 @@ fn unknown_method_returns_error() {
 fn notification_initialized_has_no_response_body() {
     let registry = make_registry();
     let resp = registry.handle_request(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
-    assert!(resp.is_empty(), "notification must yield empty body, got: {resp}");
+    assert!(
+        resp.is_empty(),
+        "notification must yield empty body, got: {resp}"
+    );
 }
 
 #[test]
 fn id_less_message_has_no_response_body() {
     let registry = make_registry();
     let resp = registry.handle_request(r#"{"jsonrpc":"2.0","method":"tools/list"}"#);
-    assert!(resp.is_empty(), "id-less message is a notification, got: {resp}");
+    assert!(
+        resp.is_empty(),
+        "id-less message is a notification, got: {resp}"
+    );
 }
diff --git a/deploy.sh b/deploy.sh
index 6a984f4..cb0fb40 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -8,7 +8,7 @@
 # A release cuts the repo CHANGELOG.md, tags, pushes, and publishes three
 # systemd services behind two nginx location blocks:
 #   /   -> :8099 (web app + REST API)
-#   /mcp -> :8100 (MCP JSON-RPC server)
+#   /mcp -> :8101 (MCP JSON-RPC server; 8100 is AkurAI-Fulltrui)
 set -euo pipefail
 
 REPO="olibuijr/AkurAI-Notes"
@@ -84,18 +84,22 @@ ENV
 
 ensure_embedding_model() {
   say "Model: Qwen3-Embedding GGUF"
-  ssh "$HOST" 'bash -s' <<'MODEL'
+ssh "$HOST" 'bash -s' <<'MODEL'
 set -euo pipefail
+if ! command -v llama-server >/dev/null 2>&1; then
+  echo "llama-server not installed; skipping embedding model fetch"
+  exit 0
+fi
 DIR=/opt/akurai-notes-embed
 MODEL=Qwen3-Embedding-0.6B-Q4_K_M.gguf
 URL=https://huggingface.co/Qwen/Qwen3-Embedding-0.6B-GGUF/resolve/main/$MODEL
-[ -d "$DIR" ] || sudo mkdir -p "$DIR" && sudo chown -R akurai:akurai "$DIR"
+[ -d "$DIR" ] || sudo mkdir -p "$DIR" && sudo chown -R ubuntu:ubuntu "$DIR"
 
 if [ ! -f "$DIR/$MODEL" ] || [ "$(stat -c%s "$DIR/$MODEL")" -lt 200000000 ]; then
   echo "Fetching $MODEL from $URL ..."
   curl -fL "$URL" -o "/tmp/$MODEL" && sudo mv "/tmp/$MODEL" "$DIR/$MODEL"
 fi
-sudo chown akurai:akurai "$DIR/$MODEL"
+sudo chown ubuntu:ubuntu "$DIR/$MODEL"
 MODEL
 }
 
@@ -107,17 +111,17 @@ set -euo pipefail
 cat > /etc/systemd/system/akurai-notes.service <<EOF
 [Unit]
 Description=AkurAI-Notes (web app + REST API)
-After=network-online.target akurai-notes-embed.service
+After=network-online.target
 Wants=network-online.target
 
 [Service]
 Type=simple
-User=akurai
-Group=akurai
-ExecStart=/opt/akurai-notes/akurai-notes serve \
+User=ubuntu
+Group=ubuntu
+ExecStart=/opt/akurai-notes/bin/akurai-notes serve \
   --host 127.0.0.1 --port 8099 \
   --db /var/lib/akurai-notes/notes.db \
-  --frontend-dir /opt/akurai-notes/site/frontend
+  --dir /opt/akurai-notes/app/frontend
 EnvironmentFile=/etc/akurai-notes/notes.env
 Restart=always
 RestartSec=3
@@ -138,12 +142,11 @@ Wants=network-online.target
 
 [Service]
 Type=simple
-User=akurai
-Group=akurai
-ExecStart=/opt/akurai-notes/akurai-notes mcp \
-  --host 127.0.0.1 --port 8100 \
-  --db /var/lib/akurai-notes/notes.db \
-  --owner-sub \$AKURAI_NOTES_MCP_OWNER_SUB
+User=ubuntu
+Group=ubuntu
+ExecStart=/opt/akurai-notes/bin/akurai-notes mcp \
+  --host 127.0.0.1 --port 8101 \
+  --db /var/lib/akurai-notes/notes.db
 EnvironmentFile=/etc/akurai-notes/notes.env
 Restart=always
 RestartSec=3
@@ -177,14 +180,11 @@ RestartSec=5
 WantedBy=multi-user.target
 EOF
 
-if ! id -u akurai >/dev/null 2>&1; then
-  useradd -r -m -d /opt/akurai -s /usr/sbin/nologin akurai
-fi
 mkdir -p /opt/akurai-notes /var/lib/akurai-notes /etc/akurai-notes
-chown -R akurai:akurai /opt/akurai-notes /var/lib/akurai-notes /etc/akurai-notes
+chown -R ubuntu:ubuntu /opt/akurai-notes /var/lib/akurai-notes
 
 systemctl daemon-reload
-systemctl enable --now akurai-notes-embed.service || say "  (akurai-notes-embed.service not started -- install llama-server separately)"
+systemctl enable --now akurai-notes-embed.service || echo "  (akurai-notes-embed.service not started -- install llama-server separately)"
 systemctl restart  akurai-notes.service
 systemctl restart  akurai-notes-mcp.service
 echo "  [done] systemd units installed + restarted"
@@ -198,7 +198,7 @@ set -euo pipefail
 cat > /etc/nginx/sites-available/akurai-notes.olibuijr.com <<EOF
 # Managed by AkurAI-Notes deploy.sh -- do not edit by hand.
 upstream akurai_notes_app  { server 127.0.0.1:8099; }
-upstream akurai_notes_mcp { server 127.0.0.1:8100; }
+upstream akurai_notes_mcp { server 127.0.0.1:8101; }
 
 server {
     listen 80;
@@ -252,7 +252,7 @@ healthcheck() {
   ssh "$HOST" 'curl -fsS http://127.0.0.1:8099/api/health' | jq .ok | grep -q true \
     || die "/api/health failed"
 
-  ssh "$HOST" 'curl -fsS http://127.0.0.1:8100/mcp' | grep -qE '405|method' \
+  [ "$(ssh "$HOST" 'curl -sS -o /dev/null -w "%{http_code}" http://127.0.0.1:8101/mcp')" = "405" ] \
     || die "MCP /mcp didn't answer 405 on bare GET (expected)"
 
   ssh "$HOST" 'curl -fsS http://127.0.0.1:8081/v1/embeddings \
@@ -288,7 +288,8 @@ case "$BUMP" in patch|minor|major) ;; *) echo "usage: $0 [patch|minor|major|ec2]
 
 # ── 1. Quality gate ────────────────────────────────────────────────────────
 say "Gate: rustfmt"
-cargo fmt --all -- --check || die "formatting failed (run: cargo fmt --all)"
+cargo fmt -p notes-core -p notes-api -p notes-mcp -p akurai-notes -- --check \
+  || die "formatting failed (run: cargo fmt -p notes-core -p notes-api -p notes-mcp -p akurai-notes)"
 
 say "Gate: clippy (-D warnings)"
 cargo clippy --all-targets --all-features -- -D warnings || die "clippy failed"
diff --git a/site/backend/page.json b/site/backend/page.json
index e37bf1d..7fb6102 100644
--- a/site/backend/page.json
+++ b/site/backend/page.json
@@ -1,7 +1,7 @@
 {
   "title": "AkurAI-Notes — Private notes with semantic search",
   "description": "Private notes with semantic search — running on your own server, never scanned by a third party.",
-  "version": "v0.0.1",
+  "version": "v0.0.2",
   "url": "https://akurai-notes.olibuijr.com",
   "year": 2026,
   "brand_name": "AkurAI-Notes",
diff --git a/site/frontend/app.html b/site/frontend/app.html
index 1507e74..b0273da 100644
--- a/site/frontend/app.html
+++ b/site/frontend/app.html
@@ -1,16 +1,54 @@
 {% include "header" %}
+<script type="module" src="/signals.js"></script>
 <main class="app-shell">
-  <aside class="sidebar" data-signals="{notes:[], notebooks:[], activeNote:null}">
+  <aside class="sidebar" data-scope="{notes:[], notebooks:[], activeNote:null}">
     <nav class="notebook-list">
       <h3>Notebooks</h3>
       <ul id="notebook-list">
-        <li data-for="nb in notebooks" class="notebook-item">{% raw %}{{ nb.name }}{% endraw %}</li>
+        <template data-for="nb in notebooks">
+          <li class="notebook-item" data-text="nb.name"></li>
+        </template>
       </ul>
     </nav>
   </aside>
-  <section class="content">
-    <h2>Welcome to AkurAI-Notes</h2>
-    <p>Select a note or create a new one.</p>
-  </section>
+  <section class="content" id="notes-content"></section>
 </main>
+<script type="module">
+  async function renderNotes() {
+    const el = document.querySelector("[data-scope]");
+    if (!el || !el.__signalsScope) return;
+    try {
+      const [notebooksRes, notesRes] = await Promise.all([
+        fetch("/api/notebooks", { credentials: "same-origin" }),
+        fetch("/api/notes", { credentials: "same-origin" }),
+      ]);
+      if (!notebooksRes.ok || !notesRes.ok) throw new Error("API error");
+      const notebooks = await notebooksRes.json();
+      const notes = await notesRes.json();
+
+      el.__signalsScope.proxy.notebooks = notebooks;
+
+      const content = document.getElementById("notes-content");
+      if (notes.length === 0) {
+        content.innerHTML = '<p class="empty-state">No notes found</p>';
+      } else {
+        content.innerHTML = notes.map(n =>
+          '<article class="note-card">' +
+            '<h3>' + esc(n.title || "Untitled") + '</h3>' +
+            '<p class="note-preview">' + esc((n.body || "").substring(0, 200)) + '</p>' +
+          '</article>'
+        ).join("");
+      }
+    } catch (e) {
+      document.getElementById("notes-content").innerHTML =
+        '<p class="empty-state">Could not load data: ' + e.message + '</p>';
+    }
+  }
+  function esc(s) { return s.replace(/[&<>"]/g, function(m) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[m]; }); }
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", renderNotes);
+  } else {
+    renderNotes();
+  }
+</script>
 {% include "footer" %}
diff --git a/site/frontend/signals.js b/site/frontend/signals.js
index 958961d..ac64054 100644
--- a/site/frontend/signals.js
+++ b/site/frontend/signals.js
@@ -175,16 +175,14 @@ function createChildScope(parent, locals) {
 // ── directive binding ────────────────────────────────────────────────────────
 
 function bindTree(el, scope) {
-  // data-for owns its subtree (a <template>); set it up and stop descending.
-  if (el.hasAttribute && el.hasAttribute("data-for")) {
-    bindFor(el, scope);
-    return;
-  }
   bindDirectives(el, scope);
   for (const child of Array.from(el.children)) {
-    // Independent nested widgets are initialised separately by init().
     if (child.hasAttribute("data-scope")) continue;
-    bindTree(child, scope);
+    if (child.hasAttribute("data-for")) {
+      bindFor(child, scope);
+    } else {
+      bindTree(child, scope);
+    }
   }
 }
 
@@ -313,7 +311,9 @@ function init() {
   roots.forEach((root) => {
     if (root.__signalsBound) return;
     root.__signalsBound = true;
-    bindTree(root, createScope(parseInitial(root)));
+    const scope = createScope(parseInitial(root));
+    root.__signalsScope = scope;
+    bindTree(root, scope);
   });
 }