diff --git a/CLAUDE.md b/CLAUDE.md index 2a2723905..3096e336d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,6 +197,10 @@ Key updates from recent commits: - **Authentication**: Web-to-desktop handoff using `alphahuman://` scheme - **Connection Management**: Telegram MTProto and Socket.io integration +## Git Workflow + +- **PR target branch**: All pull requests should target the `develop` branch, not `main`. + ## Key Patterns - **No localStorage**: Avoid `localStorage` and `sessionStorage`; use Redux (and persist) for app state. Remove any direct usage when working on affected code. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index be8fec5b6..833514997 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -43,18 +43,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -1146,18 +1134,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "2.3.0" @@ -1691,9 +1667,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" @@ -1701,15 +1674,6 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "heck" version = "0.4.1" @@ -2285,17 +2249,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libsqlite3-sys" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3548,20 +3501,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rusqlite" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" -dependencies = [ - "bitflags 2.10.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "rust-ini" version = "0.21.3" @@ -4303,7 +4242,6 @@ dependencies = [ "parking_lot", "rand 0.8.5", "reqwest", - "rusqlite", "serde", "serde_json", "sha2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0fed8611e..464885672 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,7 +47,6 @@ log = "0.4" env_logger = "0.11" # AI module dependencies -rusqlite = { version = "0.31", features = ["bundled", "vtab"] } base64 = "0.22" aes-gcm = "0.10" argon2 = "0.5" diff --git a/src-tauri/src/ai/entity_db.rs b/src-tauri/src/ai/entity_db.rs deleted file mode 100644 index 41800f701..000000000 --- a/src-tauri/src/ai/entity_db.rs +++ /dev/null @@ -1,606 +0,0 @@ -//! SQLite entity database for the full platform graph. -//! -//! Stores metadata references (not full content) for all entities across -//! the platform: chats, messages, emails, contacts, wallets, tokens, -//! transactions. Enables cross-entity search and relationship traversal. -//! -//! Database location: `~/.alphahuman/entities.db` - -use once_cell::sync::OnceCell; -use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -use super::encryption::get_data_dir; - -/// Global database connection (initialized once). -static ENTITY_DB: OnceCell> = OnceCell::new(); - -/// Core entity record. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Entity { - pub id: String, - #[serde(rename = "type")] - pub entity_type: String, - pub source: String, - pub source_id: Option, - pub title: Option, - pub summary: Option, - /// JSON blob for type-specific fields. - pub metadata: Option, - pub created_at: i64, - pub updated_at: i64, -} - -/// Relationship between two entities. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct EntityRelation { - pub id: String, - pub from_entity_id: String, - pub to_entity_id: String, - pub relation_type: String, - pub metadata: Option, - pub created_at: i64, -} - -/// Tag attached to an entity. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct EntityTag { - pub entity_id: String, - pub tag: String, -} - -/// Search result from FTS5. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct EntitySearchResult { - pub id: String, - pub entity_type: String, - pub source: String, - pub source_id: Option, - pub title: Option, - pub summary: Option, - pub metadata: Option, - pub created_at: i64, - pub updated_at: i64, - pub score: f64, -} - -/// Entity with its relations for traversal queries. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct EntityWithRelations { - pub entity: Entity, - pub relations: Vec, -} - -/// Get the entity database path (~/.alphahuman/entities.db). -fn get_entity_db_path() -> Result { - Ok(get_data_dir()?.join("entities.db")) -} - -/// Initialize the entity database connection and create tables. -fn init_entity_db() -> Result { - let db_path = get_entity_db_path()?; - let conn = - rusqlite::Connection::open(&db_path).map_err(|e| format!("Open entity database: {e}"))?; - - conn.execute_batch("PRAGMA journal_mode=WAL;") - .map_err(|e| format!("WAL mode: {e}"))?; - - conn.execute_batch( - " - -- Core entity table (polymorphic) - CREATE TABLE IF NOT EXISTS entities ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL, - source TEXT NOT NULL, - source_id TEXT, - title TEXT, - summary TEXT, - metadata TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(source, source_id) - ); - - -- Relationships between entities - CREATE TABLE IF NOT EXISTS entity_relations ( - id TEXT PRIMARY KEY, - from_entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, - to_entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, - relation_type TEXT NOT NULL, - metadata TEXT, - created_at INTEGER NOT NULL, - UNIQUE(from_entity_id, to_entity_id, relation_type) - ); - - -- Tags for flexible categorization - CREATE TABLE IF NOT EXISTS entity_tags ( - entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, - tag TEXT NOT NULL, - PRIMARY KEY (entity_id, tag) - ); - - -- Indexes - CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type); - CREATE INDEX IF NOT EXISTS idx_entities_source ON entities(source, source_id); - CREATE INDEX IF NOT EXISTS idx_entities_updated ON entities(updated_at); - CREATE INDEX IF NOT EXISTS idx_relations_from ON entity_relations(from_entity_id); - CREATE INDEX IF NOT EXISTS idx_relations_to ON entity_relations(to_entity_id); - CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type); - CREATE INDEX IF NOT EXISTS idx_tags_tag ON entity_tags(tag); - - -- FTS for entity search - CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5( - title, summary, id UNINDEXED, type UNINDEXED - ); - - -- Enable foreign key constraints - PRAGMA foreign_keys = ON; - ", - ) - .map_err(|e| format!("Create entity tables: {e}"))?; - - Ok(conn) -} - -/// Get or initialize the global entity database connection. -fn get_entity_db() -> Result<&'static Mutex, String> { - ENTITY_DB.get_or_try_init(|| { - let conn = init_entity_db()?; - Ok::, String>(Mutex::new(conn)) - }) -} - -// --- Tauri Commands --- - -/// Initialize the entity database. Creates tables if they don't exist. -#[tauri::command] -pub async fn ai_entity_db_init() -> Result { - get_entity_db()?; - Ok(true) -} - -/// Upsert an entity (insert or update by id, or by source+source_id). -#[tauri::command] -pub async fn ai_entity_upsert(entity: Entity) -> Result { - let db = get_entity_db()?; - let conn = db.lock(); - - // Delete existing FTS entry if entity exists - conn.execute( - "DELETE FROM entities_fts WHERE id = ?1", - rusqlite::params![entity.id], - ) - .map_err(|e| format!("Delete entity FTS: {e}"))?; - - conn.execute( - "INSERT OR REPLACE INTO entities (id, type, source, source_id, title, summary, metadata, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - rusqlite::params![ - entity.id, - entity.entity_type, - entity.source, - entity.source_id, - entity.title, - entity.summary, - entity.metadata, - entity.created_at, - entity.updated_at, - ], - ) - .map_err(|e| format!("Upsert entity: {e}"))?; - - // Insert FTS entry - conn.execute( - "INSERT INTO entities_fts (title, summary, id, type) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![ - entity.title.as_deref().unwrap_or(""), - entity.summary.as_deref().unwrap_or(""), - entity.id, - entity.entity_type, - ], - ) - .map_err(|e| format!("Insert entity FTS: {e}"))?; - - Ok(true) -} - -/// Get an entity by ID. -#[tauri::command] -pub async fn ai_entity_get(id: String) -> Result, String> { - let db = get_entity_db()?; - let conn = db.lock(); - - let mut stmt = conn - .prepare( - "SELECT id, type, source, source_id, title, summary, metadata, created_at, updated_at - FROM entities WHERE id = ?1", - ) - .map_err(|e| format!("Prepare: {e}"))?; - - let result = stmt - .query_row(rusqlite::params![id], |row| { - Ok(Entity { - id: row.get(0)?, - entity_type: row.get(1)?, - source: row.get(2)?, - source_id: row.get(3)?, - title: row.get(4)?, - summary: row.get(5)?, - metadata: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - }) - }) - .ok(); - - Ok(result) -} - -/// Get an entity by source and source_id. -#[tauri::command] -pub async fn ai_entity_get_by_source( - source: String, - source_id: String, -) -> Result, String> { - let db = get_entity_db()?; - let conn = db.lock(); - - let mut stmt = conn - .prepare( - "SELECT id, type, source, source_id, title, summary, metadata, created_at, updated_at - FROM entities WHERE source = ?1 AND source_id = ?2", - ) - .map_err(|e| format!("Prepare: {e}"))?; - - let result = stmt - .query_row(rusqlite::params![source, source_id], |row| { - Ok(Entity { - id: row.get(0)?, - entity_type: row.get(1)?, - source: row.get(2)?, - source_id: row.get(3)?, - title: row.get(4)?, - summary: row.get(5)?, - metadata: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - }) - }) - .ok(); - - Ok(result) -} - -/// Full-text search on entities. -#[tauri::command] -pub async fn ai_entity_search( - query: String, - types: Option>, - limit: i64, -) -> Result, String> { - let db = get_entity_db()?; - let conn = db.lock(); - - // Build query with optional type filter - let base_sql = " - SELECT e.id, e.type, e.source, e.source_id, e.title, e.summary, e.metadata, - e.created_at, e.updated_at, rank AS score - FROM entities_fts - JOIN entities e ON e.id = entities_fts.id - WHERE entities_fts MATCH ?1"; - - let sql = if let Some(ref type_list) = types { - if type_list.is_empty() { - format!("{base_sql} ORDER BY rank LIMIT ?2") - } else { - let placeholders: Vec = type_list - .iter() - .enumerate() - .map(|(i, _)| format!("?{}", i + 3)) - .collect(); - format!( - "{base_sql} AND e.type IN ({}) ORDER BY rank LIMIT ?2", - placeholders.join(", ") - ) - } - } else { - format!("{base_sql} ORDER BY rank LIMIT ?2") - }; - - let mut stmt = conn - .prepare(&sql) - .map_err(|e| format!("Prepare FTS: {e}"))?; - - // Build params dynamically - let mut params: Vec> = vec![Box::new(query), Box::new(limit)]; - if let Some(ref type_list) = types { - for t in type_list { - params.push(Box::new(t.clone())); - } - } - - let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - - let results = stmt - .query_map(param_refs.as_slice(), |row| { - Ok(EntitySearchResult { - id: row.get(0)?, - entity_type: row.get(1)?, - source: row.get(2)?, - source_id: row.get(3)?, - title: row.get(4)?, - summary: row.get(5)?, - metadata: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - score: row.get::<_, f64>(9)?.abs(), - }) - }) - .map_err(|e| format!("Entity FTS search: {e}"))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(results) -} - -/// List entities by type with pagination. -#[tauri::command] -pub async fn ai_entity_list( - entity_type: String, - offset: i64, - limit: i64, -) -> Result, String> { - let db = get_entity_db()?; - let conn = db.lock(); - - let mut stmt = conn - .prepare( - "SELECT id, type, source, source_id, title, summary, metadata, created_at, updated_at - FROM entities WHERE type = ?1 ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3", - ) - .map_err(|e| format!("Prepare: {e}"))?; - - let results = stmt - .query_map(rusqlite::params![entity_type, limit, offset], |row| { - Ok(Entity { - id: row.get(0)?, - entity_type: row.get(1)?, - source: row.get(2)?, - source_id: row.get(3)?, - title: row.get(4)?, - summary: row.get(5)?, - metadata: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - }) - }) - .map_err(|e| format!("List entities: {e}"))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(results) -} - -/// Delete an entity and cascade to relations and tags. -#[tauri::command] -pub async fn ai_entity_delete(id: String) -> Result { - let db = get_entity_db()?; - let conn = db.lock(); - - // Delete FTS entry - conn.execute( - "DELETE FROM entities_fts WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| format!("Delete entity FTS: {e}"))?; - - // Delete relations (both directions) - conn.execute( - "DELETE FROM entity_relations WHERE from_entity_id = ?1 OR to_entity_id = ?1", - rusqlite::params![id], - ) - .map_err(|e| format!("Delete relations: {e}"))?; - - // Delete tags - conn.execute( - "DELETE FROM entity_tags WHERE entity_id = ?1", - rusqlite::params![id], - ) - .map_err(|e| format!("Delete tags: {e}"))?; - - // Delete entity - conn.execute("DELETE FROM entities WHERE id = ?1", rusqlite::params![id]) - .map_err(|e| format!("Delete entity: {e}"))?; - - Ok(true) -} - -/// Add a relationship between entities. -#[tauri::command] -pub async fn ai_entity_add_relation(relation: EntityRelation) -> Result { - let db = get_entity_db()?; - let conn = db.lock(); - - conn.execute( - "INSERT OR REPLACE INTO entity_relations (id, from_entity_id, to_entity_id, relation_type, metadata, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - rusqlite::params![ - relation.id, - relation.from_entity_id, - relation.to_entity_id, - relation.relation_type, - relation.metadata, - relation.created_at, - ], - ) - .map_err(|e| format!("Add relation: {e}"))?; - - Ok(true) -} - -/// Get related entities with optional direction and type filter. -#[tauri::command] -pub async fn ai_entity_get_relations( - entity_id: String, - direction: Option, - relation_type: Option, -) -> Result, String> { - let db = get_entity_db()?; - let conn = db.lock(); - - let dir = direction.as_deref().unwrap_or("both"); - - let sql = match (dir, relation_type.as_deref()) { - ("from", Some(rt)) => { - format!( - "SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at - FROM entity_relations - WHERE from_entity_id = ?1 AND relation_type = '{rt}'" - ) - } - ("to", Some(rt)) => { - format!( - "SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at - FROM entity_relations - WHERE to_entity_id = ?1 AND relation_type = '{rt}'" - ) - } - ("from", None) => { - "SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at - FROM entity_relations - WHERE from_entity_id = ?1" - .to_string() - } - ("to", None) => { - "SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at - FROM entity_relations - WHERE to_entity_id = ?1" - .to_string() - } - (_, Some(rt)) => { - format!( - "SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at - FROM entity_relations - WHERE (from_entity_id = ?1 OR to_entity_id = ?1) AND relation_type = '{rt}'" - ) - } - _ => "SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at - FROM entity_relations - WHERE from_entity_id = ?1 OR to_entity_id = ?1" - .to_string(), - }; - - let mut stmt = conn.prepare(&sql).map_err(|e| format!("Prepare: {e}"))?; - - let results = stmt - .query_map(rusqlite::params![entity_id], |row| { - Ok(EntityRelation { - id: row.get(0)?, - from_entity_id: row.get(1)?, - to_entity_id: row.get(2)?, - relation_type: row.get(3)?, - metadata: row.get(4)?, - created_at: row.get(5)?, - }) - }) - .map_err(|e| format!("Get relations: {e}"))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(results) -} - -/// Tag an entity. -#[tauri::command] -pub async fn ai_entity_add_tag(entity_id: String, tag: String) -> Result { - let db = get_entity_db()?; - let conn = db.lock(); - - conn.execute( - "INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?1, ?2)", - rusqlite::params![entity_id, tag], - ) - .map_err(|e| format!("Add tag: {e}"))?; - - Ok(true) -} - -/// Remove a tag from an entity. -#[tauri::command] -pub async fn ai_entity_remove_tag(entity_id: String, tag: String) -> Result { - let db = get_entity_db()?; - let conn = db.lock(); - - conn.execute( - "DELETE FROM entity_tags WHERE entity_id = ?1 AND tag = ?2", - rusqlite::params![entity_id, tag], - ) - .map_err(|e| format!("Remove tag: {e}"))?; - - Ok(true) -} - -/// Find entities by tag, with optional type filter. -#[tauri::command] -pub async fn ai_entity_get_by_tag( - tag: String, - entity_type: Option, -) -> Result, String> { - let db = get_entity_db()?; - let conn = db.lock(); - - let sql = if entity_type.is_some() { - "SELECT e.id, e.type, e.source, e.source_id, e.title, e.summary, e.metadata, e.created_at, e.updated_at - FROM entities e - JOIN entity_tags t ON t.entity_id = e.id - WHERE t.tag = ?1 AND e.type = ?2 - ORDER BY e.updated_at DESC" - } else { - "SELECT e.id, e.type, e.source, e.source_id, e.title, e.summary, e.metadata, e.created_at, e.updated_at - FROM entities e - JOIN entity_tags t ON t.entity_id = e.id - WHERE t.tag = ?1 - ORDER BY e.updated_at DESC" - }; - - let mut stmt = conn.prepare(sql).map_err(|e| format!("Prepare: {e}"))?; - - let results = if let Some(ref et) = entity_type { - stmt.query_map(rusqlite::params![tag, et], |row| { - Ok(Entity { - id: row.get(0)?, - entity_type: row.get(1)?, - source: row.get(2)?, - source_id: row.get(3)?, - title: row.get(4)?, - summary: row.get(5)?, - metadata: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - }) - }) - .map_err(|e| format!("Get by tag: {e}"))? - .filter_map(|r| r.ok()) - .collect() - } else { - stmt.query_map(rusqlite::params![tag], |row| { - Ok(Entity { - id: row.get(0)?, - entity_type: row.get(1)?, - source: row.get(2)?, - source_id: row.get(3)?, - title: row.get(4)?, - summary: row.get(5)?, - metadata: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - }) - }) - .map_err(|e| format!("Get by tag: {e}"))? - .filter_map(|r| r.ok()) - .collect() - }; - - Ok(results) -} diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs index 9a27b5b85..967818ea1 100644 --- a/src-tauri/src/ai/mod.rs +++ b/src-tauri/src/ai/mod.rs @@ -1,9 +1,7 @@ pub mod encryption; -pub mod entity_db; pub mod memory_fs; pub mod sessions; pub use encryption::*; -pub use entity_db::*; pub use memory_fs::*; pub use sessions::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a67df6cba..7ebc20f63 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -212,19 +212,6 @@ pub fn run() { ai_memory_get_cached_embedding, ai_memory_set_meta, ai_memory_get_meta, - // AI entity database commands - ai_entity_db_init, - ai_entity_upsert, - ai_entity_get, - ai_entity_get_by_source, - ai_entity_search, - ai_entity_list, - ai_entity_delete, - ai_entity_add_relation, - ai_entity_get_relations, - ai_entity_add_tag, - ai_entity_remove_tag, - ai_entity_get_by_tag, // AI session commands ai_sessions_init, ai_sessions_load_index, diff --git a/src/lib/ai/entities/manager.ts b/src/lib/ai/entities/manager.ts index 59e9407a4..0abdf02cd 100644 --- a/src/lib/ai/entities/manager.ts +++ b/src/lib/ai/entities/manager.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { apiClient } from "../../../services/apiClient"; import type { Entity, EntityRelation, @@ -6,77 +6,129 @@ import type { EntityType, EntitySource, RelationType, - EntityRust, - EntityRelationRust, - EntitySearchResultRust, + ApiResponse, + Neo4jEntityNode, + Neo4jRelationshipRecord, } from "./types"; import { - fromRustEntity, - fromRustRelation, - fromRustSearchResult, - toRustEntity, - toRustRelation, + fromNeo4jEntity, + fromNeo4jRelation, + toNeo4jCreateBody, + toNeo4jRelationBody, } from "./types"; +const ENTITY_API = "/api/entity-graph"; +const MAX_TAG_RETRIES = 3; + +/** Parse the properties JSON from a Neo4j entity node */ +function parseProps(props: string | null): Record { + if (!props) return {}; + try { + return JSON.parse(props); + } catch { + return {}; + } +} + +/** Check whether an error represents an HTTP 409 Conflict */ +function isConflictError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const error = (err as Record).error; + return typeof error === "string" && error.includes("409"); +} + /** - * EntityManager wraps Tauri commands for the entity relationship database. + * EntityManager wraps REST API calls to the Neo4j-backed entity graph. * * Provides a typed interface for CRUD operations on entities, relations, - * and tags in the platform graph. + * and tags in the platform graph via the backend API. */ export class EntityManager { private initialized = false; - /** Initialize the entity database */ + /** Initialize the entity manager (no-op for remote backend) */ async init(): Promise { - await invoke("ai_entity_db_init"); this.initialized = true; } - /** Ensure the database is initialized */ + /** Ensure the manager is initialized */ private async ensureInit(): Promise { if (!this.initialized) await this.init(); } /** - * Upsert an entity (insert or update). - * If an entity with the same id or same source+sourceId exists, it's updated. + * Upsert an entity (create via POST). + * The backend handles insert-or-update semantics. */ async upsert(entity: Entity): Promise { await this.ensureInit(); - await invoke("ai_entity_upsert", { entity: toRustEntity(entity) }); + const body = toNeo4jCreateBody(entity); + await apiClient.post>( + `${ENTITY_API}/entities`, + body, + ); } /** Get an entity by ID */ async get(id: string): Promise { await this.ensureInit(); - const result = await invoke("ai_entity_get", { id }); - return result ? fromRustEntity(result) : null; + try { + const resp = await apiClient.get>( + `${ENTITY_API}/entities/${id}`, + ); + return fromNeo4jEntity(resp.data); + } catch { + return null; + } } /** Get an entity by source system reference */ - async getBySource(source: EntitySource, sourceId: string): Promise { + async getBySource( + source: EntitySource, + sourceId: string, + ): Promise { await this.ensureInit(); - const result = await invoke("ai_entity_get_by_source", { - source, - sourceId, - }); - return result ? fromRustEntity(result) : null; + try { + const params = new URLSearchParams({ + source, + sourceId, + limit: "1", + }); + const resp = await apiClient.get< + ApiResponse<{ entities: Neo4jEntityNode[]; count: number }> + >(`${ENTITY_API}/entities?${params}`); + const node = resp.data.entities[0]; + return node ? fromNeo4jEntity(node) : null; + } catch { + return null; + } } - /** Full-text search on entities */ + /** Search entities via server-side query */ async search( query: string, types?: EntityType[], limit = 20, ): Promise { await this.ensureInit(); - const results = await invoke("ai_entity_search", { + const params = new URLSearchParams({ query, - types: types ?? null, - limit, + limit: String(limit), }); - return results.map(fromRustSearchResult); + if (types && types.length > 0) { + params.set("types", types.join(",")); + } + const resp = await apiClient.get< + ApiResponse<{ + entities: (Neo4jEntityNode & { score?: number })[]; + count: number; + }> + >(`${ENTITY_API}/entities?${params}`); + + return resp.data.entities.map((node) => ({ + ...fromNeo4jEntity(node), + score: node.score ?? 1.0, + })); } /** List entities by type with pagination */ @@ -86,26 +138,32 @@ export class EntityManager { limit = 50, ): Promise { await this.ensureInit(); - const results = await invoke("ai_entity_list", { - entityType, - offset, - limit, + const params = new URLSearchParams({ + type: entityType, + limit: String(limit), + offset: String(offset), }); - return results.map(fromRustEntity); + const resp = await apiClient.get< + ApiResponse<{ entities: Neo4jEntityNode[]; count: number }> + >(`${ENTITY_API}/entities?${params}`); + + return resp.data.entities.map(fromNeo4jEntity); } - /** Delete an entity and cascade relations/tags */ + /** Delete an entity (soft-delete on backend) */ async delete(id: string): Promise { await this.ensureInit(); - await invoke("ai_entity_delete", { id }); + await apiClient.delete(`${ENTITY_API}/entities/${id}`); } /** Add a relationship between entities */ async addRelation(relation: EntityRelation): Promise { await this.ensureInit(); - await invoke("ai_entity_add_relation", { - relation: toRustRelation(relation), - }); + const body = toNeo4jRelationBody(relation); + await apiClient.post>( + `${ENTITY_API}/relationships`, + body, + ); } /** @@ -120,33 +178,100 @@ export class EntityManager { relationType?: RelationType, ): Promise { await this.ensureInit(); - const results = await invoke("ai_entity_get_relations", { - entityId, - direction: direction ?? null, - relationType: relationType ?? null, - }); - return results.map(fromRustRelation); + const resp = await apiClient.get< + ApiResponse<{ relationships: Neo4jRelationshipRecord[] }> + >(`${ENTITY_API}/entities/${entityId}/relationships`); + + let relations = resp.data.relationships.map(fromNeo4jRelation); + + // Filter by direction + const dir = direction ?? "both"; + if (dir === "from") { + relations = relations.filter((r) => r.fromEntityId === entityId); + } else if (dir === "to") { + relations = relations.filter((r) => r.toEntityId === entityId); + } + + // Filter by relation type + if (relationType) { + relations = relations.filter((r) => r.relationType === relationType); + } + + return relations; } - /** Tag an entity */ + /** + * Tag an entity with optimistic concurrency control. + * Uses If-Match with the entity's updatedAt to detect concurrent writes, + * retrying up to MAX_TAG_RETRIES times on 409 Conflict. + */ async addTag(entityId: string, tag: string): Promise { await this.ensureInit(); - await invoke("ai_entity_add_tag", { entityId, tag }); + for (let attempt = 0; attempt < MAX_TAG_RETRIES; attempt++) { + const resp = await apiClient.get>( + `${ENTITY_API}/entities/${entityId}`, + ); + const node = resp.data; + const props = parseProps(node.properties); + const tags: string[] = Array.isArray(props.tags) ? props.tags : []; + if (!tags.includes(tag)) { + tags.push(tag); + } + props.tags = tags; + + try { + await apiClient.put( + `${ENTITY_API}/entities/${entityId}`, + { properties: JSON.stringify(props) }, + { headers: { "If-Match": node.updatedAt } }, + ); + return; + } catch (err: unknown) { + if (!isConflictError(err) || attempt === MAX_TAG_RETRIES - 1) throw err; + } + } } - /** Remove a tag from an entity */ + /** + * Remove a tag with optimistic concurrency control. + * Uses If-Match with the entity's updatedAt to detect concurrent writes, + * retrying up to MAX_TAG_RETRIES times on 409 Conflict. + */ async removeTag(entityId: string, tag: string): Promise { await this.ensureInit(); - await invoke("ai_entity_remove_tag", { entityId, tag }); + for (let attempt = 0; attempt < MAX_TAG_RETRIES; attempt++) { + const resp = await apiClient.get>( + `${ENTITY_API}/entities/${entityId}`, + ); + const node = resp.data; + const props = parseProps(node.properties); + const tags: string[] = Array.isArray(props.tags) ? props.tags : []; + props.tags = tags.filter((t) => t !== tag); + + try { + await apiClient.put( + `${ENTITY_API}/entities/${entityId}`, + { properties: JSON.stringify(props) }, + { headers: { "If-Match": node.updatedAt } }, + ); + return; + } catch (err: unknown) { + if (!isConflictError(err) || attempt === MAX_TAG_RETRIES - 1) throw err; + } + } } /** Find entities by tag, optionally filtered by type */ async getByTag(tag: string, entityType?: EntityType): Promise { await this.ensureInit(); - const results = await invoke("ai_entity_get_by_tag", { - tag, - entityType: entityType ?? null, - }); - return results.map(fromRustEntity); + const params = new URLSearchParams({ tag, limit: "500" }); + if (entityType) { + params.set("type", entityType); + } + const resp = await apiClient.get< + ApiResponse<{ entities: Neo4jEntityNode[]; count: number }> + >(`${ENTITY_API}/entities?${params}`); + + return resp.data.entities.map(fromNeo4jEntity); } } diff --git a/src/lib/ai/entities/types.ts b/src/lib/ai/entities/types.ts index ddcc8f352..6f2b337af 100644 --- a/src/lib/ai/entities/types.ts +++ b/src/lib/ai/entities/types.ts @@ -20,10 +20,9 @@ export type RelationType = | "traded" | "replied_to"; -/** Core entity record */ +/** Core entity record (frontend domain type) */ export interface Entity { id: string; - /** Mapped from Rust `type` field */ type: EntityType; source: EntitySource; sourceId: string | null; @@ -35,19 +34,6 @@ export interface Entity { updatedAt: number; } -/** Entity as returned from Rust IPC (snake_case) */ -export interface EntityRust { - id: string; - entity_type: string; - source: string; - source_id: string | null; - title: string | null; - summary: string | null; - metadata: string | null; - created_at: number; - updated_at: number; -} - /** Relationship between two entities */ export interface EntityRelation { id: string; @@ -58,32 +44,133 @@ export interface EntityRelation { createdAt: number; } -/** Relation as returned from Rust IPC (snake_case) */ -export interface EntityRelationRust { - id: string; - from_entity_id: string; - to_entity_id: string; - relation_type: string; - metadata: string | null; - created_at: number; -} - /** Tag attached to an entity */ export interface EntityTag { entityId: string; tag: string; } -/** Search result from entity FTS */ +/** Search result from entity search */ export interface EntitySearchResult extends Entity { score: number; } -/** Entity search result from Rust IPC */ -export interface EntitySearchResultRust extends EntityRust { - score: number; +// --- Neo4j backend response types --- + +/** Entity node as returned from the Neo4j backend API */ +export interface Neo4jEntityNode { + id: string; + name: string; + type: string; + description: string | null; + properties: string | null; + confidence: number; + isActive: boolean; + ownerId: string; + createdAt: string; + updatedAt: string; } +/** Relationship record as returned from the Neo4j backend API */ +export interface Neo4jRelationshipRecord { + id: string; + sourceId: string; + targetId: string; + type: string; + properties: string | null; + weight: number; + createdAt: string; +} + +/** Wrapper for all API responses */ +export interface ApiResponse { + success: boolean; + data: T; +} + +// --- Conversion helpers --- + +/** Parse a properties JSON string, returning an empty object on failure */ +function parseProperties(props: string | null): Record { + if (!props) return {}; + try { + return JSON.parse(props); + } catch { + return {}; + } +} + +/** Convert a Neo4j entity node to the frontend Entity type */ +export function fromNeo4jEntity(node: Neo4jEntityNode): Entity { + const props = parseProperties(node.properties); + + const source = (props.source as string) ?? "manual"; + const sourceId = (props.sourceId as string) ?? null; + + // Build metadata from remaining properties (excluding source, sourceId, tags) + const { source: _s, sourceId: _sid, tags: _t, ...rest } = props; + const metadata = Object.keys(rest).length > 0 ? JSON.stringify(rest) : null; + + return { + id: node.id, + type: node.type as EntityType, + source: source as EntitySource, + sourceId, + title: node.name, + summary: node.description, + metadata, + createdAt: new Date(node.createdAt).getTime(), + updatedAt: new Date(node.updatedAt).getTime(), + }; +} + +/** Build a request body for POST/PUT to the Neo4j entity API */ +export function toNeo4jCreateBody(entity: Entity): Record { + // Merge source, sourceId, tags, and any existing metadata into properties + const existingMeta = parseProperties(entity.metadata); + const properties: Record = { + ...existingMeta, + source: entity.source, + }; + if (entity.sourceId) { + properties.sourceId = entity.sourceId; + } + + return { + name: entity.title ?? "", + type: entity.type, + description: entity.summary ?? undefined, + properties: JSON.stringify(properties), + confidence: 1.0, + }; +} + +/** Convert a Neo4j relationship record to the frontend EntityRelation type */ +export function fromNeo4jRelation(r: Neo4jRelationshipRecord): EntityRelation { + return { + id: r.id, + fromEntityId: r.sourceId, + toEntityId: r.targetId, + relationType: r.type as RelationType, + metadata: r.properties, + createdAt: new Date(r.createdAt).getTime(), + }; +} + +/** Build a request body for POST to the Neo4j relationship API */ +export function toNeo4jRelationBody( + relation: EntityRelation, +): Record { + return { + sourceId: relation.fromEntityId, + targetId: relation.toEntityId, + type: relation.relationType, + properties: relation.metadata ?? undefined, + }; +} + +// --- Metadata interfaces --- + /** Contact metadata */ export interface ContactMetadata { username?: string; @@ -134,67 +221,3 @@ export interface TransactionMetadata { value?: string; method?: string; } - -// --- Conversion helpers --- - -/** Convert Rust entity to TypeScript entity */ -export function fromRustEntity(e: EntityRust): Entity { - return { - id: e.id, - type: e.entity_type as EntityType, - source: e.source as EntitySource, - sourceId: e.source_id, - title: e.title, - summary: e.summary, - metadata: e.metadata, - createdAt: e.created_at, - updatedAt: e.updated_at, - }; -} - -/** Convert TypeScript entity to Rust entity */ -export function toRustEntity(e: Entity): EntityRust { - return { - id: e.id, - entity_type: e.type, - source: e.source, - source_id: e.sourceId, - title: e.title, - summary: e.summary, - metadata: e.metadata, - created_at: e.createdAt, - updated_at: e.updatedAt, - }; -} - -/** Convert Rust relation to TypeScript relation */ -export function fromRustRelation(r: EntityRelationRust): EntityRelation { - return { - id: r.id, - fromEntityId: r.from_entity_id, - toEntityId: r.to_entity_id, - relationType: r.relation_type as RelationType, - metadata: r.metadata, - createdAt: r.created_at, - }; -} - -/** Convert TypeScript relation to Rust relation */ -export function toRustRelation(r: EntityRelation): EntityRelationRust { - return { - id: r.id, - from_entity_id: r.fromEntityId, - to_entity_id: r.toEntityId, - relation_type: r.relationType, - metadata: r.metadata, - created_at: r.createdAt, - }; -} - -/** Convert Rust search result to TypeScript search result */ -export function fromRustSearchResult(r: EntitySearchResultRust): EntitySearchResult { - return { - ...fromRustEntity(r), - score: r.score, - }; -} diff --git a/src/lib/ai/index.ts b/src/lib/ai/index.ts index fd112fb99..25ec3227e 100644 --- a/src/lib/ai/index.ts +++ b/src/lib/ai/index.ts @@ -6,7 +6,7 @@ * Modules: * - **constitution/** — Agent safety & compliance framework * - **memory/** — JSON file-based index + vector search memory storage - * - **entities/** — SQLite entity relationship database for platform graph + * - **entities/** — Remote Neo4j entity graph via backend API * - **prompts/** — Modular system prompt construction * - **sessions/** — JSONL session transcripts with compaction * - **skills/** — Skill loading, registry, lifecycle hooks, and installation