Menu
Commit b3a7784a
b3a7784af2094889f6d5d4630b0cbd6d8ff94452 by Claude Code
- Cargo.toml +1 -1
- VERSION +1 -1
- crates/notes-api/src/embed.rs +5 -4
- crates/notes-api/src/handlers/crud.rs +38 -28
- crates/notes-api/src/handlers/search.rs +84 -40
- crates/notes-api/tests/embed_test.rs +2 -2
- crates/notes-cli/src/main.rs +2 -2
- crates/notes-core/src/lib.rs +1 -0
- crates/notes-core/src/search.rs +166 -0
- crates/notes-mcp/src/tools.rs +67 -68
- crates/notes-mcp/tests/mcp_test.rs +60 -2
- deploy.sh +24 -23
- site/backend/page.json +1 -1
- site/frontend/app.html +44 -6
- site/frontend/signals.js +8 -8
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, ¤t);
+ current.clear();
+ }
+ }
+
+ if !current.is_empty() {
+ push_unique(&mut tokens, ¤t);
+ }
+
+ 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(
+ ®istry,
+ "AkurAI Platform - Skills & CLI Tools",
+ "Canonical tool inventory",
+ "testuser@example.com",
+ );
+ create_note_via_tool(
+ ®istry,
+ "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(
+ ®istry,
+ "Deploy runbook",
+ "akurai-ec2 ports drift before release",
+ "testuser@example.com",
+ );
+ create_note_via_tool(
+ ®istry,
+ "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 ({'&':'&','<':'<','>':'>','"':'"'})[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);
});
}