Migrate entity graph from SQLite to Neo4j backend API (#9)

* Migrate entity graph from local SQLite to remote Neo4j backend API

Replace Tauri IPC invoke() calls with REST API calls to /api/entity-graph/
endpoints. EntityManager public API is unchanged so all consumers (AIProvider,
EntityQuery, SkillRegistry) need no modifications.

- Rewrite manager.ts to use apiClient instead of Tauri invoke()
- Add Neo4j response types and conversion helpers in types.ts
- Remove Rust entity_db.rs (607 lines) and rusqlite dependency
- Remove 12 ai_entity_* commands from Tauri generate_handler

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Git workflow guidelines to CLAUDE.md

- Introduced a new section outlining the requirement for all pull requests to target the `develop` branch instead of `main`. This update aims to standardize the contribution process and improve collaboration among developers.

* Enhance EntityManager with optimistic concurrency control for tag management

- Introduced MAX_TAG_RETRIES constant to handle HTTP 409 Conflict errors during tag addition and removal.
- Refactored addTag and removeTag methods to implement retry logic using If-Match headers for optimistic concurrency control.
- Updated search and getByTag methods to utilize URLSearchParams for improved query handling.
- Simplified entity fetching logic in getById and search methods to enhance performance and clarity.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-01-31 20:56:54 +05:30
committed by GitHub
co-authored by Claude Opus 4.5
parent 6e4eeecc0c
commit d668a3a66a
9 changed files with 301 additions and 833 deletions
+4
View File
@@ -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.
-62
View File
@@ -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",
-1
View File
@@ -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"
-606
View File
@@ -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<Mutex<rusqlite::Connection>> = 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<String>,
pub title: Option<String>,
pub summary: Option<String>,
/// JSON blob for type-specific fields.
pub metadata: Option<String>,
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<String>,
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<String>,
pub title: Option<String>,
pub summary: Option<String>,
pub metadata: Option<String>,
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<EntityRelation>,
}
/// Get the entity database path (~/.alphahuman/entities.db).
fn get_entity_db_path() -> Result<PathBuf, String> {
Ok(get_data_dir()?.join("entities.db"))
}
/// Initialize the entity database connection and create tables.
fn init_entity_db() -> Result<rusqlite::Connection, String> {
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<rusqlite::Connection>, String> {
ENTITY_DB.get_or_try_init(|| {
let conn = init_entity_db()?;
Ok::<Mutex<rusqlite::Connection>, 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<bool, String> {
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<bool, String> {
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<Option<Entity>, 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<Option<Entity>, 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<Vec<String>>,
limit: i64,
) -> Result<Vec<EntitySearchResult>, 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<String> = 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<Box<dyn rusqlite::types::ToSql>> = 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<Vec<Entity>, 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<bool, String> {
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<bool, String> {
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<String>,
relation_type: Option<String>,
) -> Result<Vec<EntityRelation>, 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<bool, String> {
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<bool, String> {
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<String>,
) -> Result<Vec<Entity>, 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)
}
-2
View File
@@ -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::*;
-13
View File
@@ -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,
+180 -55
View File
@@ -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<string, unknown> {
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<string, unknown>).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<void> {
await invoke("ai_entity_db_init");
this.initialized = true;
}
/** Ensure the database is initialized */
/** Ensure the manager is initialized */
private async ensureInit(): Promise<void> {
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<void> {
await this.ensureInit();
await invoke("ai_entity_upsert", { entity: toRustEntity(entity) });
const body = toNeo4jCreateBody(entity);
await apiClient.post<ApiResponse<Neo4jEntityNode>>(
`${ENTITY_API}/entities`,
body,
);
}
/** Get an entity by ID */
async get(id: string): Promise<Entity | null> {
await this.ensureInit();
const result = await invoke<EntityRust | null>("ai_entity_get", { id });
return result ? fromRustEntity(result) : null;
try {
const resp = await apiClient.get<ApiResponse<Neo4jEntityNode>>(
`${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<Entity | null> {
async getBySource(
source: EntitySource,
sourceId: string,
): Promise<Entity | null> {
await this.ensureInit();
const result = await invoke<EntityRust | null>("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<EntitySearchResult[]> {
await this.ensureInit();
const results = await invoke<EntitySearchResultRust[]>("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<Entity[]> {
await this.ensureInit();
const results = await invoke<EntityRust[]>("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<void> {
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<void> {
await this.ensureInit();
await invoke("ai_entity_add_relation", {
relation: toRustRelation(relation),
});
const body = toNeo4jRelationBody(relation);
await apiClient.post<ApiResponse<Neo4jRelationshipRecord>>(
`${ENTITY_API}/relationships`,
body,
);
}
/**
@@ -120,33 +178,100 @@ export class EntityManager {
relationType?: RelationType,
): Promise<EntityRelation[]> {
await this.ensureInit();
const results = await invoke<EntityRelationRust[]>("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<void> {
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<ApiResponse<Neo4jEntityNode>>(
`${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<void> {
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<ApiResponse<Neo4jEntityNode>>(
`${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<Entity[]> {
await this.ensureInit();
const results = await invoke<EntityRust[]>("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);
}
}
+116 -93
View File
@@ -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<T> {
success: boolean;
data: T;
}
// --- Conversion helpers ---
/** Parse a properties JSON string, returning an empty object on failure */
function parseProperties(props: string | null): Record<string, unknown> {
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<string, unknown> {
// Merge source, sourceId, tags, and any existing metadata into properties
const existingMeta = parseProperties(entity.metadata);
const properties: Record<string, unknown> = {
...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<string, unknown> {
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,
};
}
+1 -1
View File
@@ -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