Files
openhuman/src-tauri/src/ai/memory_fs.rs
T
Steven EnamakelandGitHub bfaabd3b86 fix/rename (#20)
* chore: update AlphaHuman version to 0.49.3 and configure updater plugin in tauri.conf.json

- Bumped the AlphaHuman package version in Cargo.lock to 0.49.3.
- Added updater configuration in tauri.conf.json to enable automatic updates with specified endpoints.

* refactor: rename AlphaHuman to OpenHuman across the codebase

- Updated all instances of "AlphaHuman" to "OpenHuman" in comments, tooltips, and constants to reflect the new branding.
- Adjusted relevant documentation and prompts to ensure consistency with the new name.

* refactor: update documentation and configurations to reflect OpenHuman branding

- Replaced all instances of "AlphaHuman" with "OpenHuman" in documentation, comments, and configuration files to ensure consistency with the new branding.
- Updated deep link URLs and related authentication flows to use the new "openhuman://" scheme.
- Adjusted paths and references in the skills system and other related files to align with the new project name.te

* refactor: standardize OpenHuman references and update configurations

- Replaced all instances of "AlphaHuman" with "OpenHuman" across documentation, comments, and configuration files to maintain branding consistency.
- Updated URLs and paths to reflect the new "openhuman://" scheme.
- Adjusted environment variable names and related settings to align with the new project identity.
- Enhanced documentation for clarity and accuracy regarding the OpenHuman framework.r

* chore: update subproject commit reference in skills directory

* refactor: update backend URL to reflect new service domain

- Changed default backend URL from "https://api.openhuman.xyz" to "https://api.tinyhumans.ai" in both JavaScript and Rust configuration files.
- Ensured consistency across the codebase regarding the new backend service endpoint.

* feat: introduce identity and migration modules for OpenHuman

- Added a new identity module to support AIEOS v1.1 JSON format, including structures for identity, psychology, linguistics, motivations, capabilities, physicality, history, and interests.
- Implemented a migration module to facilitate data migration from OpenClaw memory, including SQLite and Markdown sources, with detailed reporting on migration statistics and warnings.
- Established utility functions for handling multimodal content and image processing within the OpenHuman framework.
- Enhanced the agent system with new dispatcher and classifier functionalities to improve tool management and message classification.

* chore: remove Android project files and configurations

- Deleted various Android project files including .editorconfig, .gitignore, build.gradle.kts, gradle.properties, and others to clean up the project structure.
- Removed all related resources, layouts, and source files from the Android app directory to streamline the codebase.
- This cleanup is part of a larger effort to refactor and simplify the project structure.

* refactor: update login flow and remove Telegram integration

- Removed the TelegramLoginButton component and its references from the OAuthLoginSection, streamlining the login options.
- Updated the AppRoutes to remove the login route, reflecting changes in the authentication flow.
- Enhanced the RotatingTetrahedronCanvas component with improved geometry and lighting effects for better visual presentation.
- Adjusted the TypewriterGreeting component's styling for consistency.
- Cleaned up the Welcome page to integrate the OAuthLoginSection directly, improving user experience.

* chore: update subproject commit reference in skills directory

* chore: update test configurations and improve test assertions

- Modified test scripts in package.json to use a specific Vitest configuration file for consistency.
- Updated assertions in loader tests to ensure loading durations are non-negative.
- Enhanced tool loading tests to clarify expected behavior regarding localStorage and cache management.
- Adjusted agent tool registry tests to improve error handling and ensure accurate statistics.
- Refined device detection tests to reflect updated fallback URLs.

* fix: enhance parameter formatting and remove unused components

- Updated the `formatParameters` function to handle cases where schema properties are empty, returning a more informative response.
- Deleted the `DownloadScreen` component and associated device detection utilities to streamline the codebase and remove unused functionality.
- Adjusted tests to reflect changes in the tool loading and agent tool registry, ensuring accuracy in assertions.

* chore: simplify Vitest configuration by removing unused include patterns

- Updated the Vitest configuration to remove unnecessary test file patterns, streamlining the test setup for better clarity and maintainability.

* refactor: update paths and comments for AI configuration and file watching

- Modified Vite configuration to ignore only the `src-tauri` directory.
- Updated logging messages to reflect the correct path for writing AI configuration files.
- Adjusted fetch calls in the file watcher to use the new path for `TOOLS.md`.
- Revised comments and logic in Rust code to clarify the handling of AI configuration file paths, including legacy fallback options.

* chore: remove unused updater secrets from GitHub Actions workflow

- Deleted UPDATER_GIST_URL and UPDATER_GIST_ID environment variables from the package-and-publish workflow, streamlining the configuration.

* chore: comment out Vitest thresholds for clarity

- Commented out the thresholds section in the Vitest configuration to improve clarity and maintainability, as it is currently not in use.

* ran formatter

* chore: update updater public key in tauri configuration

- Replaced the existing public key in the updater plugin configuration with a new value to ensure proper functionality and security.

* chore: update ESLint configuration and refactor components

- Added `localStorage` and `sessionStorage` as readonly globals in ESLint configuration for better linting support.
- Removed unused imports from `SkillsPanel.tsx` to clean up the code.
- Changed the type of `watcherInterval` in `file-watcher.ts` for improved type safety.
- Refactored toast management logic in `Intelligence.tsx` to enhance clarity and maintainability.
- Simplified import statements in `IntelligenceProvider.tsx` for consistency.
- Streamlined object property shorthand in `agentToolRegistry.ts` for cleaner code.

* refactor: improve error handling and type safety in Intelligence component

- Enhanced toast notification logic to defer state updates, preventing potential issues with setState in effects.
- Updated the source filter dispatch to use a more specific type for improved type safety.

* refactor: enhance type safety across various components and services

- Updated type definitions from `any` to `unknown` in multiple files to improve type safety and prevent potential runtime errors.
- Refactored state management in `TauriCommandsPanel` to use more specific types.
- Adjusted context and parameters in several interfaces to ensure consistent typing.
- Added ESLint directive to `polyfills.ts` for intentional global assignments.
- Streamlined type handling in utility functions and API responses for better clarity and maintainability.

* refactor: streamline import statements and improve code clarity

- Consolidated import statements in `agentToolRegistry.ts` and `intelligenceSlice.ts` for better readability.
- Simplified the `createTestStore` function in `test-utils.tsx` to enhance code conciseness.
- Cleaned up the `isExecutionStepProgressEvent` function in `intelligence-chat-api.ts` for improved clarity and maintainability.
2026-03-26 17:04:46 -07:00

461 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Filesystem-based memory index for AI memory storage.
//!
//! Replaces the SQLite memory_db with JSON files under ~/.openhuman/index/.
//! Chunk files, file metadata, embedding cache, and KV metadata are all
//! stored as readable JSON. All operations are exposed as Tauri commands
//! with the same signatures as the former SQLite implementation.
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use super::encryption::get_data_dir;
/// Lazy-initialized index directory state.
static INDEX_INIT: once_cell::sync::OnceCell<Mutex<()>> = once_cell::sync::OnceCell::new();
/// File metadata tracked in the index.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FileRecord {
pub path: String,
pub source: String,
pub hash: String,
pub mtime: i64,
pub size: i64,
}
/// A chunk of content with optional embedding.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ChunkRecord {
pub id: String,
pub path: String,
pub source: String,
pub start_line: i64,
pub end_line: i64,
pub hash: String,
pub model: String,
pub text: String,
/// Embedding stored as base64-encoded Float32Array bytes.
pub embedding: Option<Vec<u8>>,
pub updated_at: i64,
}
/// Chunk as stored in JSON (embedding is base64 string for readability).
#[derive(Serialize, Deserialize, Debug, Clone)]
struct ChunkJson {
id: String,
path: String,
source: String,
start_line: i64,
end_line: i64,
hash: String,
model: String,
text: String,
/// Base64-encoded Float32Array bytes, or null.
embedding_b64: Option<String>,
updated_at: i64,
}
impl From<ChunkRecord> for ChunkJson {
fn from(c: ChunkRecord) -> Self {
ChunkJson {
id: c.id,
path: c.path,
source: c.source,
start_line: c.start_line,
end_line: c.end_line,
hash: c.hash,
model: c.model,
text: c.text,
embedding_b64: c.embedding.map(|e| BASE64.encode(&e)),
updated_at: c.updated_at,
}
}
}
impl From<ChunkJson> for ChunkRecord {
fn from(c: ChunkJson) -> Self {
ChunkRecord {
id: c.id,
path: c.path,
source: c.source,
start_line: c.start_line,
end_line: c.end_line,
hash: c.hash,
model: c.model,
text: c.text,
embedding: c.embedding_b64.and_then(|b| BASE64.decode(&b).ok()),
updated_at: c.updated_at,
}
}
}
/// Search result with relevance score.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SearchResult {
pub chunk_id: String,
pub path: String,
pub source: String,
pub text: String,
pub score: f64,
pub start_line: i64,
pub end_line: i64,
}
/// Embedding cache entry.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EmbeddingCacheEntry {
pub provider: String,
pub model: String,
pub hash: String,
pub embedding: Vec<u8>,
pub dims: Option<i64>,
pub updated_at: i64,
}
/// Embedding cache entry as stored in JSON.
#[derive(Serialize, Deserialize, Debug, Clone)]
struct EmbeddingCacheJson {
provider: String,
model: String,
hash: String,
embedding_b64: String,
dims: Option<i64>,
updated_at: i64,
}
impl From<EmbeddingCacheEntry> for EmbeddingCacheJson {
fn from(e: EmbeddingCacheEntry) -> Self {
EmbeddingCacheJson {
provider: e.provider,
model: e.model,
hash: e.hash,
embedding_b64: BASE64.encode(&e.embedding),
dims: e.dims,
updated_at: e.updated_at,
}
}
}
impl From<EmbeddingCacheJson> for EmbeddingCacheEntry {
fn from(e: EmbeddingCacheJson) -> Self {
EmbeddingCacheEntry {
provider: e.provider,
model: e.model,
hash: e.hash,
embedding: BASE64.decode(&e.embedding_b64).unwrap_or_default(),
dims: e.dims,
updated_at: e.updated_at,
}
}
}
// --- Path helpers ---
/// Get the index directory (~/.openhuman/index/).
fn get_index_dir() -> Result<PathBuf, String> {
Ok(get_data_dir()?.join("index"))
}
/// Get the chunks subdirectory (~/.openhuman/index/chunks/).
fn get_chunks_dir() -> Result<PathBuf, String> {
Ok(get_index_dir()?.join("chunks"))
}
/// Encode a file path into a safe filename for chunk storage.
/// `/` → `--`, e.g. `memory/foo.md` → `memory--foo.md.json`.
fn encode_chunk_filename(path: &str) -> String {
format!("{}.json", path.replace('/', "--"))
}
/// Get path to files.json.
fn files_json_path() -> Result<PathBuf, String> {
Ok(get_index_dir()?.join("files.json"))
}
/// Get path to meta.json.
fn meta_json_path() -> Result<PathBuf, String> {
Ok(get_index_dir()?.join("meta.json"))
}
/// Get path to embedding-cache.json.
fn embedding_cache_path() -> Result<PathBuf, String> {
Ok(get_index_dir()?.join("embedding-cache.json"))
}
/// Get path to a chunk file for a given memory file path.
fn chunk_file_path(path: &str) -> Result<PathBuf, String> {
Ok(get_chunks_dir()?.join(encode_chunk_filename(path)))
}
// --- JSON file I/O helpers ---
/// Read and deserialize a JSON file, returning default if not found.
fn read_json<T: serde::de::DeserializeOwned + Default>(path: &PathBuf) -> Result<T, String> {
match std::fs::read_to_string(path) {
Ok(content) => {
if content.trim().is_empty() {
return Ok(T::default());
}
serde_json::from_str(&content).map_err(|e| format!("Parse {}: {e}", path.display()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(format!("Read {}: {e}", path.display())),
}
}
/// Serialize and write a JSON file atomically.
fn write_json<T: Serialize>(path: &PathBuf, data: &T) -> Result<(), String> {
let content = serde_json::to_string_pretty(data).map_err(|e| format!("Serialize: {e}"))?;
// Write to temp file then rename for atomicity
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &content).map_err(|e| format!("Write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path).map_err(|e| format!("Rename: {e}"))?;
Ok(())
}
// --- Tauri Commands ---
/// Initialize the memory index. Creates directories and empty JSON files.
#[tauri::command]
pub async fn ai_memory_init() -> Result<bool, String> {
INDEX_INIT.get_or_try_init(|| {
let index_dir = get_index_dir()?;
let chunks_dir = get_chunks_dir()?;
std::fs::create_dir_all(&index_dir).map_err(|e| format!("Create index dir: {e}"))?;
std::fs::create_dir_all(&chunks_dir).map_err(|e| format!("Create chunks dir: {e}"))?;
// Create empty JSON files if they don't exist
let files_path = files_json_path()?;
if !files_path.exists() {
let empty: HashMap<String, FileRecord> = HashMap::new();
write_json(&files_path, &empty)?;
}
let meta_path = meta_json_path()?;
if !meta_path.exists() {
let empty: HashMap<String, String> = HashMap::new();
write_json(&meta_path, &empty)?;
}
let cache_path = embedding_cache_path()?;
if !cache_path.exists() {
let empty: Vec<EmbeddingCacheJson> = Vec::new();
write_json(&cache_path, &empty)?;
}
Ok::<Mutex<()>, String>(Mutex::new(()))
})?;
Ok(true)
}
/// Upsert a file record in files.json.
#[tauri::command]
pub async fn ai_memory_upsert_file(file: FileRecord) -> Result<bool, String> {
let path = files_json_path()?;
let mut files: HashMap<String, FileRecord> = read_json(&path)?;
files.insert(file.path.clone(), file);
write_json(&path, &files)?;
Ok(true)
}
/// Get a file record by path.
#[tauri::command]
pub async fn ai_memory_get_file(path: String) -> Result<Option<FileRecord>, String> {
let files_path = files_json_path()?;
let files: HashMap<String, FileRecord> = read_json(&files_path)?;
Ok(files.get(&path).cloned())
}
/// Upsert a chunk record into the appropriate chunk file.
#[tauri::command]
pub async fn ai_memory_upsert_chunk(chunk: ChunkRecord) -> Result<bool, String> {
let chunk_path = chunk_file_path(&chunk.path)?;
let mut chunks: Vec<ChunkJson> = read_json(&chunk_path)?;
// Remove existing chunk with same ID
chunks.retain(|c| c.id != chunk.id);
// Add the new/updated chunk
chunks.push(ChunkJson::from(chunk));
write_json(&chunk_path, &chunks)?;
Ok(true)
}
/// Delete chunks by file path (removes the entire chunk file).
#[tauri::command]
pub async fn ai_memory_delete_chunks_by_path(path: String) -> Result<i64, String> {
let chunk_path = chunk_file_path(&path)?;
if chunk_path.exists() {
// Count chunks before deleting
let chunks: Vec<ChunkJson> = read_json(&chunk_path)?;
let count = chunks.len() as i64;
std::fs::remove_file(&chunk_path).map_err(|e| format!("Delete chunk file: {e}"))?;
Ok(count)
} else {
Ok(0)
}
}
/// Keyword search across all chunk files.
///
/// Algorithm (replaces FTS5 BM25):
/// 1. Lowercase query, split into whitespace-separated terms
/// 2. For each chunk across all files: lowercase text, count how many query terms
/// appear as substrings
/// 3. Score = matched_terms / total_terms (0.01.0), skip chunks with score 0
/// 4. Sort descending, return top `limit` results
#[tauri::command]
pub async fn ai_memory_fts_search(query: String, limit: i64) -> Result<Vec<SearchResult>, String> {
let chunks_dir = get_chunks_dir()?;
let query_lower = query.to_lowercase();
let terms: Vec<&str> = query_lower.split_whitespace().collect();
if terms.is_empty() {
return Ok(Vec::new());
}
let total_terms = terms.len() as f64;
let mut results: Vec<SearchResult> = Vec::new();
// Read all chunk files in the chunks directory
let entries = match std::fs::read_dir(&chunks_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("Read chunks dir: {e}")),
};
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let chunks: Vec<ChunkJson> = read_json(&file_path)?;
for chunk in chunks {
let text_lower = chunk.text.to_lowercase();
let matched = terms.iter().filter(|t| text_lower.contains(*t)).count();
if matched == 0 {
continue;
}
let score = matched as f64 / total_terms;
results.push(SearchResult {
chunk_id: chunk.id,
path: chunk.path,
source: chunk.source,
text: chunk.text,
score,
start_line: chunk.start_line,
end_line: chunk.end_line,
});
}
}
// Sort by score descending
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(limit as usize);
Ok(results)
}
/// Get all chunks for a file path.
#[tauri::command]
pub async fn ai_memory_get_chunks(path: String) -> Result<Vec<ChunkRecord>, String> {
let chunk_path = chunk_file_path(&path)?;
let chunks: Vec<ChunkJson> = read_json(&chunk_path)?;
let mut records: Vec<ChunkRecord> = chunks.into_iter().map(ChunkRecord::from).collect();
records.sort_by_key(|c| c.start_line);
Ok(records)
}
/// Get all embeddings for vector search (returns chunk IDs + embeddings).
#[tauri::command]
pub async fn ai_memory_get_all_embeddings() -> Result<Vec<(String, Vec<u8>)>, String> {
let chunks_dir = get_chunks_dir()?;
let mut results: Vec<(String, Vec<u8>)> = Vec::new();
let entries = match std::fs::read_dir(&chunks_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("Read chunks dir: {e}")),
};
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let chunks: Vec<ChunkJson> = read_json(&file_path)?;
for chunk in chunks {
if let Some(b64) = chunk.embedding_b64 {
if let Ok(bytes) = BASE64.decode(&b64) {
results.push((chunk.id, bytes));
}
}
}
}
Ok(results)
}
/// Cache an embedding result.
#[tauri::command]
pub async fn ai_memory_cache_embedding(entry: EmbeddingCacheEntry) -> Result<bool, String> {
let cache_path = embedding_cache_path()?;
let mut cache: Vec<EmbeddingCacheJson> = read_json(&cache_path)?;
// Remove existing entry with same key
cache.retain(|e| {
!(e.provider == entry.provider && e.model == entry.model && e.hash == entry.hash)
});
cache.push(EmbeddingCacheJson::from(entry));
write_json(&cache_path, &cache)?;
Ok(true)
}
/// Look up a cached embedding.
#[tauri::command]
pub async fn ai_memory_get_cached_embedding(
provider: String,
model: String,
hash: String,
) -> Result<Option<Vec<u8>>, String> {
let cache_path = embedding_cache_path()?;
let cache: Vec<EmbeddingCacheJson> = read_json(&cache_path)?;
let entry = cache
.into_iter()
.find(|e| e.provider == provider && e.model == model && e.hash == hash);
Ok(entry.and_then(|e| BASE64.decode(&e.embedding_b64).ok()))
}
/// Set a metadata value.
#[tauri::command]
pub async fn ai_memory_set_meta(key: String, value: String) -> Result<bool, String> {
let path = meta_json_path()?;
let mut meta: HashMap<String, String> = read_json(&path)?;
meta.insert(key, value);
write_json(&path, &meta)?;
Ok(true)
}
/// Get a metadata value.
#[tauri::command]
pub async fn ai_memory_get_meta(key: String) -> Result<Option<String>, String> {
let path = meta_json_path()?;
let meta: HashMap<String, String> = read_json(&path)?;
Ok(meta.get(&key).cloned())
}