mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory): ingestion pipeline + tree-architecture docs + ops/schemas split (#1142)
This commit is contained in:
@@ -5,7 +5,8 @@ use super::super::context::{
|
||||
use super::super::runtime::process_channel_message;
|
||||
use super::super::{traits, Channel};
|
||||
use super::common::{HistoryCaptureProvider, NoopMemory, RecordingChannel};
|
||||
use crate::openhuman::memory::{embeddings::NoopEmbedding, Memory, MemoryCategory, UnifiedMemory};
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, UnifiedMemory};
|
||||
use crate::openhuman::providers;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -44,8 +44,6 @@ pub struct MemoryConfig {
|
||||
#[serde(default = "default_min_relevance_score")]
|
||||
pub min_relevance_score: f64,
|
||||
#[serde(default)]
|
||||
pub response_cache_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub sqlite_open_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -71,7 +69,6 @@ impl Default for MemoryConfig {
|
||||
embedding_model: default_embedding_model(),
|
||||
embedding_dimensions: default_embedding_dims(),
|
||||
min_relevance_score: default_min_relevance_score(),
|
||||
response_cache_enabled: false,
|
||||
sqlite_open_timeout_secs: None,
|
||||
}
|
||||
}
|
||||
@@ -138,7 +135,7 @@ pub struct MemoryTreeConfig {
|
||||
pub llm_extractor_timeout_ms: Option<u64>,
|
||||
|
||||
/// Ollama endpoint for the summariser
|
||||
/// (`memory::tree::source_tree::summariser::llm::LlmSummariser`).
|
||||
/// (`memory::tree::tree_source::summariser::llm::LlmSummariser`).
|
||||
/// When unset, bucket-seal cascades use `InertSummariser` — sealed
|
||||
/// nodes contain concatenated+truncated child text instead of a
|
||||
/// real LLM summary. Soft failures fall back to inert per seal.
|
||||
|
||||
@@ -2,6 +2,50 @@
|
||||
|
||||
Persistent knowledge layer. Owns the unified store (SQLite + FTS5 + vector embeddings + graph relations), document ingestion pipelines, namespace + KV operations, conversation history, and retrieval scoring. Does NOT own raw provider embedding APIs (`local_ai/`), agent prompt assembly (`agent/memory_loader.rs`), or per-channel ingestion adapters beyond the bundled Slack importer.
|
||||
|
||||
## Architecture
|
||||
|
||||
The module is organised in concentric layers — the contract on the
|
||||
inside, the persistent backend around it, the ingestion + retrieval
|
||||
pipelines on top, and the per-domain glue at the edge:
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────┐
|
||||
│ conversations/ slack_ingestion/ │ per-domain plumbing
|
||||
├──────────────────────────────────────┤
|
||||
│ tree/ (bucket-seal LLD pipeline) │ new retrieval architecture
|
||||
├──────────────────────────────────────┤
|
||||
│ ingestion/ (extract chunks) │ document ingestion
|
||||
├──────────────────────────────────────┤
|
||||
│ store/ (UnifiedMemory backend) │ SQLite + FTS5 + vectors
|
||||
├──────────────────────────────────────┤
|
||||
│ traits.rs (Memory trait) │ contract
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **`traits.rs`** — `Memory`, `MemoryEntry`, `MemoryCategory`,
|
||||
`RecallOpts`. The backend-agnostic contract every store implements.
|
||||
- **`store/`** — `UnifiedMemory` is the production backend (SQLite
|
||||
with FTS5 for keyword search, vector tables for embeddings, and
|
||||
graph tables for entity/relation triples) plus the `MemoryClient`
|
||||
handle used by the rest of the process.
|
||||
- **`ingestion/`** — chunking + extraction pipeline (entities,
|
||||
relations, embeddings) and the background `IngestionQueue` worker.
|
||||
- **`tree/`** — the new bucket-seal retrieval architecture from
|
||||
`docs/MEMORY_ARCHITECTURE_LLD.md`: `canonicalize` (normalise
|
||||
inputs), `chunker` and `content_store` (durable chunks),
|
||||
`score`/`retrieval` (ranking surface),
|
||||
`tree_source`/`tree_topic`/`tree_global` (the three concentric
|
||||
trees the LLD calls for), and `jobs` (background seals/summaries).
|
||||
- **`conversations/`** — workspace-backed JSONL chat thread/message
|
||||
history. See `conversations/README.md`.
|
||||
- **`slack_ingestion/`** — Slack provider plumbing (bucketer +
|
||||
ingest wrapper + RPC). See `slack_ingestion/README.md`.
|
||||
|
||||
The legacy memory store (`store/` + `ingestion/`) and the new
|
||||
`tree/` pipeline coexist for now — `tree/` is replacing the older
|
||||
retrieval surface incrementally and both must remain wired into RPC
|
||||
until the migration completes.
|
||||
|
||||
## Public surface
|
||||
|
||||
- `pub trait Memory` / `pub struct MemoryEntry` / `pub enum MemoryCategory` / `pub struct RecallOpts` — `traits.rs:11-100` — backend contract for any memory store.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# conversations
|
||||
|
||||
Workspace-backed conversation thread/message storage. Lives at
|
||||
`<workspace>/memory/conversations/` as plain JSONL — easy to inspect,
|
||||
recover, and back up. Used by the desktop UI for chat threads and by
|
||||
non-web channel adapters (Slack, Telegram, …) so all surfaces share one
|
||||
persistence path.
|
||||
|
||||
## Files
|
||||
|
||||
- **`mod.rs`** — re-exports the public surface
|
||||
(`ConversationStore`, `ConversationThread`, `ConversationMessage`,
|
||||
`CreateConversationThread`, `ConversationMessagePatch`,
|
||||
`ConversationPurgeStats`, free-function shims, and
|
||||
`register_conversation_persistence_subscriber`).
|
||||
- **`types.rs`** — wire/storage structs: thread metadata, message
|
||||
records, create requests, partial-update patches.
|
||||
- **`store.rs`** — `ConversationStore` plus free-function shims.
|
||||
Thread metadata is appended to `threads.jsonl` (upsert/delete log);
|
||||
messages live in `threads/<thread_id>.jsonl`. A process-wide mutex
|
||||
serialises every on-disk mutation.
|
||||
- **`bus.rs`** — `EventHandler` that mirrors inbound `DomainEvent`
|
||||
channel messages into the store, so non-web providers persist
|
||||
alongside UI-driven threads.
|
||||
- **`store_tests.rs`** — unit tests covering upsert, append, label/
|
||||
title updates, deletion, and purge.
|
||||
|
||||
## Where it fits
|
||||
|
||||
Sits next to the unified memory store but is intentionally separate:
|
||||
the conversation log is append-only chat history with no embeddings or
|
||||
graph relations. Ingestion into the searchable memory tree happens via
|
||||
`tree/` and the per-provider ingestion modules (e.g. `slack_ingestion/`)
|
||||
— this folder only owns durable transcript storage.
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Event-bus subscriber that mirrors inbound channel messages into the
|
||||
//! workspace-backed conversation store, so non-web channels (Slack, Telegram,
|
||||
//! etc.) persist alongside UI-driven threads.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
//! JSONL-backed thread and message store. Thread metadata lives in
|
||||
//! `threads.jsonl` (append-only upsert/delete log); each thread's messages
|
||||
//! are appended to a per-thread JSONL file under `threads/<id>.jsonl`.
|
||||
//!
|
||||
//! All on-disk mutations serialise through a single process-wide mutex so
|
||||
//! concurrent RPC handlers don't interleave writes.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -28,12 +35,14 @@ fn redact_title_for_log(title: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Counts returned by [`purge_threads`] — how much was deleted.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ConversationPurgeStats {
|
||||
pub thread_count: usize,
|
||||
pub message_count: usize,
|
||||
}
|
||||
|
||||
/// Workspace-rooted handle that reads and writes the JSONL conversation log.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConversationStore {
|
||||
workspace_dir: PathBuf,
|
||||
@@ -57,10 +66,12 @@ enum ThreadLogEntry {
|
||||
}
|
||||
|
||||
impl ConversationStore {
|
||||
/// Construct a store rooted at the given workspace directory.
|
||||
pub fn new(workspace_dir: PathBuf) -> Self {
|
||||
Self { workspace_dir }
|
||||
}
|
||||
|
||||
/// Create or update a thread, appending an `Upsert` entry to `threads.jsonl`.
|
||||
pub fn ensure_thread(
|
||||
&self,
|
||||
request: CreateConversationThread,
|
||||
@@ -88,11 +99,13 @@ impl ConversationStore {
|
||||
.ok_or_else(|| format!("thread {} missing after ensure", request.id))
|
||||
}
|
||||
|
||||
/// List all live threads (folding the upsert/delete log).
|
||||
pub fn list_threads(&self) -> Result<Vec<ConversationThread>, String> {
|
||||
let _guard = CONVERSATION_STORE_LOCK.lock();
|
||||
self.list_threads_unlocked()
|
||||
}
|
||||
|
||||
/// Read every persisted message for a thread in append order.
|
||||
pub fn get_messages(&self, thread_id: &str) -> Result<Vec<ConversationMessage>, String> {
|
||||
let _guard = CONVERSATION_STORE_LOCK.lock();
|
||||
if !self.thread_exists_unlocked(thread_id)? {
|
||||
@@ -101,6 +114,7 @@ impl ConversationStore {
|
||||
read_jsonl::<ConversationMessage>(&self.thread_messages_path(thread_id))
|
||||
}
|
||||
|
||||
/// Append a message to the thread's JSONL file. Errors if the thread is missing.
|
||||
pub fn append_message(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
@@ -125,6 +139,7 @@ impl ConversationStore {
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Rewrite the thread title via a new `Upsert` log entry, preserving labels.
|
||||
pub fn update_thread_title(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
@@ -157,6 +172,7 @@ impl ConversationStore {
|
||||
.ok_or_else(|| format!("thread {} missing after title update", thread_id))
|
||||
}
|
||||
|
||||
/// Replace the label set on a thread via a new `Upsert` log entry.
|
||||
pub fn update_thread_labels(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
@@ -188,6 +204,7 @@ impl ConversationStore {
|
||||
.ok_or_else(|| format!("thread {} missing after labels update", thread_id))
|
||||
}
|
||||
|
||||
/// Apply a patch to one message and rewrite the thread's JSONL file in place.
|
||||
pub fn update_message(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
@@ -219,6 +236,8 @@ impl ConversationStore {
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Append a `Delete` entry and remove the thread's messages file. Returns
|
||||
/// `false` if the thread did not exist.
|
||||
pub fn delete_thread(&self, thread_id: &str, deleted_at: &str) -> Result<bool, String> {
|
||||
let _guard = CONVERSATION_STORE_LOCK.lock();
|
||||
if !self.thread_exists_unlocked(thread_id)? {
|
||||
@@ -252,6 +271,7 @@ impl ConversationStore {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Wipe the entire conversation directory and re-create an empty layout.
|
||||
pub fn purge_threads(&self) -> Result<ConversationPurgeStats, String> {
|
||||
let _guard = CONVERSATION_STORE_LOCK.lock();
|
||||
let stats = self.purge_stats_unlocked()?;
|
||||
@@ -486,6 +506,7 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::ensure_thread`].
|
||||
pub fn ensure_thread(
|
||||
workspace_dir: PathBuf,
|
||||
request: CreateConversationThread,
|
||||
@@ -493,10 +514,12 @@ pub fn ensure_thread(
|
||||
ConversationStore::new(workspace_dir).ensure_thread(request)
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::list_threads`].
|
||||
pub fn list_threads(workspace_dir: PathBuf) -> Result<Vec<ConversationThread>, String> {
|
||||
ConversationStore::new(workspace_dir).list_threads()
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::get_messages`].
|
||||
pub fn get_messages(
|
||||
workspace_dir: PathBuf,
|
||||
thread_id: &str,
|
||||
@@ -504,6 +527,7 @@ pub fn get_messages(
|
||||
ConversationStore::new(workspace_dir).get_messages(thread_id)
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::append_message`].
|
||||
pub fn append_message(
|
||||
workspace_dir: PathBuf,
|
||||
thread_id: &str,
|
||||
@@ -512,6 +536,7 @@ pub fn append_message(
|
||||
ConversationStore::new(workspace_dir).append_message(thread_id, message)
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::update_thread_title`].
|
||||
pub fn update_thread_title(
|
||||
workspace_dir: PathBuf,
|
||||
thread_id: &str,
|
||||
@@ -521,6 +546,7 @@ pub fn update_thread_title(
|
||||
ConversationStore::new(workspace_dir).update_thread_title(thread_id, title, updated_at)
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::update_thread_labels`].
|
||||
pub fn update_thread_labels(
|
||||
workspace_dir: PathBuf,
|
||||
thread_id: &str,
|
||||
@@ -530,6 +556,7 @@ pub fn update_thread_labels(
|
||||
ConversationStore::new(workspace_dir).update_thread_labels(thread_id, labels, updated_at)
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::update_message`].
|
||||
pub fn update_message(
|
||||
workspace_dir: PathBuf,
|
||||
thread_id: &str,
|
||||
@@ -539,10 +566,12 @@ pub fn update_message(
|
||||
ConversationStore::new(workspace_dir).update_message(thread_id, message_id, patch)
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::purge_threads`].
|
||||
pub fn purge_threads(workspace_dir: PathBuf) -> Result<ConversationPurgeStats, String> {
|
||||
ConversationStore::new(workspace_dir).purge_threads()
|
||||
}
|
||||
|
||||
/// Free-function shim around [`ConversationStore::delete_thread`].
|
||||
pub fn delete_thread(
|
||||
workspace_dir: PathBuf,
|
||||
thread_id: &str,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Unit tests for the JSONL-backed [`ConversationStore`], exercising thread
|
||||
//! upsert, message append, label/title updates, deletion and purge semantics.
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Wire/storage types for the workspace-backed conversation store: threads,
|
||||
//! messages, create requests, and partial-update patches.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// A persisted conversation thread, mirroring one entry in `threads.jsonl`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConversationThread {
|
||||
@@ -16,6 +20,7 @@ pub struct ConversationThread {
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single message appended to a thread's JSONL log.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConversationMessage {
|
||||
@@ -29,6 +34,7 @@ pub struct ConversationMessage {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Input payload to create-or-update a thread via [`super::ensure_thread`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateConversationThread {
|
||||
@@ -39,6 +45,7 @@ pub struct CreateConversationThread {
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Partial update to apply to a stored message (e.g. rewriting `extraMetadata`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConversationMessagePatch {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
//! Re-exports from the top-level `openhuman::embeddings` module.
|
||||
//!
|
||||
//! The canonical embedding logic now lives in `src/openhuman/embeddings/`.
|
||||
//! This file keeps the old `memory::embeddings::*` import paths working so
|
||||
//! that existing call sites do not need to change immediately.
|
||||
|
||||
pub use crate::openhuman::embeddings::*;
|
||||
@@ -47,7 +47,14 @@ pub fn init(workspace_dir: PathBuf) -> Result<MemoryClientRef, String> {
|
||||
Ok(GLOBAL_CLIENT.get().cloned().unwrap_or(client))
|
||||
}
|
||||
|
||||
/// Initialise using the default `.openhuman/workspace` directory.
|
||||
/// Initialise using the default `~/.openhuman/workspace` directory.
|
||||
///
|
||||
/// **TEST-ONLY.** Production code must call [`init`] with the real workspace
|
||||
/// directory at startup wiring. If this function ran first in production it
|
||||
/// would pin the singleton to `~/.openhuman/workspace`, causing every
|
||||
/// subsequent `init(custom_workspace)` to silently no-op and return the wrong
|
||||
/// handle (`OnceLock::set` is one-shot).
|
||||
#[cfg(test)]
|
||||
pub fn init_default() -> Result<MemoryClientRef, String> {
|
||||
let workspace_dir = crate::openhuman::config::default_root_openhuman_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -55,16 +62,27 @@ pub fn init_default() -> Result<MemoryClientRef, String> {
|
||||
init(workspace_dir)
|
||||
}
|
||||
|
||||
/// Returns the global memory client, lazily initialising with default paths
|
||||
/// if not yet set up.
|
||||
/// Returns the global memory client.
|
||||
///
|
||||
/// Prefer calling `init()` explicitly at startup so errors surface early.
|
||||
/// Returns `Err` if [`init`] has not yet been called. There is **no** lazy
|
||||
/// fallback: a fallback would pin the global to `~/.openhuman/workspace` on
|
||||
/// the first stray call (test, early RPC, etc.), and `OnceLock::set` is
|
||||
/// one-shot, so the real `init(custom_workspace)` would silently no-op
|
||||
/// afterwards and every caller would get the wrong workspace.
|
||||
///
|
||||
/// Callers that can tolerate "not yet ready" should use
|
||||
/// [`client_if_ready`] instead.
|
||||
pub fn client() -> Result<MemoryClientRef, String> {
|
||||
if let Some(c) = GLOBAL_CLIENT.get() {
|
||||
return Ok(Arc::clone(c));
|
||||
}
|
||||
// Lazy fallback — initialise with defaults.
|
||||
init_default()
|
||||
client_from(&GLOBAL_CLIENT)
|
||||
}
|
||||
|
||||
/// Implementation backing [`client`] — extracted so unit tests can pass a
|
||||
/// freshly-constructed local `OnceLock` and assert the uninitialised-error
|
||||
/// contract without racing the process-global singleton.
|
||||
fn client_from(slot: &OnceLock<MemoryClientRef>) -> Result<MemoryClientRef, String> {
|
||||
slot.get().cloned().ok_or_else(|| {
|
||||
"memory global accessed before init — call init(workspace) at startup".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the global client if already initialised, without lazy init.
|
||||
@@ -107,13 +125,30 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_returns_a_handle_either_via_lazy_init_or_existing() {
|
||||
// Bind TempDir at test scope so its directory outlives any lazy
|
||||
// init — the global client holds the path and can be used later in
|
||||
// this test (and potentially by other tests in the same binary).
|
||||
async fn client_returns_a_handle_after_explicit_init() {
|
||||
// Bind TempDir at test scope so its directory outlives the global
|
||||
// client — the singleton holds the path and may be used later in
|
||||
// this test binary.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Explicit init: client() no longer lazily initialises.
|
||||
let _ = client_if_ready().or_else(|| init(tmp.path().join("ws")).ok());
|
||||
let c = client().expect("global client should be available");
|
||||
let c = client().expect("global client should be available after init");
|
||||
let _arc: Arc<MemoryClient> = c;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_errs_clearly_when_not_initialised() {
|
||||
// Use a fresh local `OnceLock` rather than the process-global one:
|
||||
// other tests may have already called `init()` on the singleton, so
|
||||
// an `is_none`-gated check on `GLOBAL_CLIENT` would race / silently
|
||||
// skip. `client_from` lets us assert the contract deterministically.
|
||||
let local: OnceLock<MemoryClientRef> = OnceLock::new();
|
||||
match client_from(&local) {
|
||||
Ok(_) => panic!("client_from(empty) must error"),
|
||||
Err(err) => assert!(
|
||||
err.contains("init"),
|
||||
"error should mention init contract, got: {err}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Memory ingestion
|
||||
|
||||
Pipeline that turns raw document text into chunks plus extracted entities and relations, then upserts everything into `UnifiedMemory`. Runs synchronously when callers need the result (`MemoryClient::ingest_doc`) and as a background worker for fire-and-forget submissions (`MemoryClient::put_doc`).
|
||||
|
||||
## Files
|
||||
|
||||
- **`mod.rs`** — adds `ingest_document` and `extract_graph` to `UnifiedMemory`, plus the internal `upsert_graph_relations` helper. Re-exports the public types and the queue / state surface.
|
||||
- **`types.rs`** — public ingestion API: `MemoryIngestionRequest` / `MemoryIngestionResult` / `MemoryIngestionConfig`, `ExtractionMode` (sentence vs chunk), `ExtractedEntity` / `ExtractedRelation`, `DEFAULT_MEMORY_EXTRACTION_MODEL`. Crate-internal intermediates (`RawEntity`, `RawRelation`, `ExtractionUnit`, `ExtractionAccumulator`, `ParsedIngestion`) live here too.
|
||||
- **`parse.rs`** — `parse_document` pipeline: chunking, header / metadata enrichment, alias resolution, regex- and rule-driven extraction. Produces a `ParsedIngestion`.
|
||||
- **`regex.rs`** — lazily-initialised regexes (email headers, named emails, graph facts, ownership, preferences, action items, recipients, spatial relations, dates, person names) plus `sanitize_entity_name`, `sanitize_fact_text`, `classify_entity`.
|
||||
- **`rules.rs`** — semantic validation rules for graph predicates (allowed head/tail entity types) and the `ExtractionAccumulator` impl that gates `add_entity` / `add_relation` on those rules.
|
||||
- **`queue.rs`** — `IngestionQueue` (cloneable submit handle) plus `IngestionJob` and the background worker started via `start_worker_with_state`. The worker shares an `IngestionState` with synchronous callers so all ingestion serialises through the same singleton lock.
|
||||
- **`state.rs`** — `IngestionState` / `IngestionStatusSnapshot`: queue depth, in-flight metadata, last-completed status, and the `tokio::sync::Mutex` that enforces single-threaded extraction (the local LLM path can't be re-entered safely).
|
||||
- **`tests.rs`** — pipeline coverage exercising `parse_document`, regex extraction, and `UnifiedMemory::ingest_document` end-to-end.
|
||||
|
||||
## How it fits
|
||||
|
||||
`MemoryClient` owns the singleton `IngestionQueue` and forwards to it from `put_doc` (background) or `ingest_doc` (synchronous, behind the same lock). Every ingestion run publishes `MemoryIngestionStarted` / `MemoryIngestionCompleted` events on the global event bus so the UI status pill and `openhuman.memory_ingestion_status` RPC stay in sync. Output rows feed `UnifiedMemory`'s `memory_docs`, `vector_chunks`, and `graph_namespace` tables.
|
||||
@@ -11,28 +11,32 @@
|
||||
//! 5. **Persistence**: Upserting the document, text chunks, and graph relations into
|
||||
//! the memory store.
|
||||
|
||||
#[path = "ingestion_parse.rs"]
|
||||
mod ingestion_parse;
|
||||
#[path = "ingestion_regex.rs"]
|
||||
mod ingestion_regex;
|
||||
#[path = "ingestion_rules.rs"]
|
||||
mod ingestion_rules;
|
||||
#[path = "ingestion_types.rs"]
|
||||
mod ingestion_types;
|
||||
mod parse;
|
||||
mod regex;
|
||||
mod rules;
|
||||
mod types;
|
||||
|
||||
pub use ingestion_types::{
|
||||
pub mod queue;
|
||||
pub mod state;
|
||||
|
||||
pub use queue::{IngestionJob, IngestionQueue};
|
||||
pub use state::{IngestionState, IngestionStatusSnapshot};
|
||||
pub use types::{
|
||||
ExtractedEntity, ExtractedRelation, ExtractionMode, MemoryIngestionConfig,
|
||||
MemoryIngestionRequest, MemoryIngestionResult, DEFAULT_MEMORY_EXTRACTION_MODEL,
|
||||
};
|
||||
|
||||
use ingestion_parse::{enrich_document_metadata, parse_document};
|
||||
use ingestion_types::ParsedIngestion;
|
||||
use parse::{enrich_document_metadata, parse_document};
|
||||
use serde_json::json;
|
||||
use types::ParsedIngestion;
|
||||
|
||||
use crate::openhuman::memory::store::types::NamespaceDocumentInput;
|
||||
use crate::openhuman::memory::UnifiedMemory;
|
||||
|
||||
impl UnifiedMemory {
|
||||
/// Run the full ingestion pipeline for a document: parse + chunk + extract
|
||||
/// entities/relations, upsert the document row + vector chunks, and write
|
||||
/// the extracted relations into the namespace graph.
|
||||
pub async fn ingest_document(
|
||||
&self,
|
||||
request: MemoryIngestionRequest,
|
||||
@@ -155,5 +159,4 @@ impl UnifiedMemory {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ingestion_tests.rs"]
|
||||
mod tests;
|
||||
@@ -5,12 +5,12 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::ingestion_regex::{
|
||||
use super::regex::{
|
||||
action_item_regex, classify_entity, email_header_regex, explicit_owner_regex,
|
||||
explicit_preference_regex, graph_fact_regex, named_email_regex, recipient_regex,
|
||||
sanitize_entity_name, sanitize_fact_text, spatial_regex, will_review_regex,
|
||||
};
|
||||
use super::ingestion_types::{
|
||||
use super::types::{
|
||||
ExtractedEntity, ExtractedRelation, ExtractionAccumulator, ExtractionMode, ExtractionUnit,
|
||||
MemoryIngestionConfig, ParsedIngestion, RawEntity, RawRelation, DEFAULT_CHUNK_TOKENS,
|
||||
};
|
||||
@@ -12,9 +12,9 @@ use std::time::Instant;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::state::IngestionState;
|
||||
use super::MemoryIngestionConfig;
|
||||
use crate::core::event_bus::{publish_global, DomainEvent};
|
||||
use crate::openhuman::memory::ingestion::MemoryIngestionConfig;
|
||||
use crate::openhuman::memory::ingestion_state::IngestionState;
|
||||
use crate::openhuman::memory::store::{NamespaceDocumentInput, UnifiedMemory};
|
||||
|
||||
/// A job submitted to the ingestion worker.
|
||||
@@ -4,8 +4,8 @@ use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::ingestion_regex::sanitize_entity_name;
|
||||
use super::ingestion_types::{ExtractionAccumulator, RawEntity, RawRelation};
|
||||
use super::regex::sanitize_entity_name;
|
||||
use super::types::{ExtractionAccumulator, RawEntity, RawRelation};
|
||||
use crate::openhuman::memory::UnifiedMemory;
|
||||
|
||||
/// A validation rule for semantic relationships.
|
||||
@@ -1,11 +1,14 @@
|
||||
//! Tests for the ingestion pipeline — `parse_document`, regex extraction,
|
||||
//! and `UnifiedMemory::ingest_document` end-to-end.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::memory::{
|
||||
embeddings::NoopEmbedding, MemoryIngestionConfig, MemoryIngestionRequest,
|
||||
NamespaceDocumentInput, UnifiedMemory,
|
||||
MemoryIngestionConfig, MemoryIngestionRequest, NamespaceDocumentInput, UnifiedMemory,
|
||||
};
|
||||
|
||||
/// Test config for the heuristic-only ingestion pipeline.
|
||||
@@ -7,11 +7,8 @@
|
||||
|
||||
pub mod chunker;
|
||||
pub mod conversations;
|
||||
pub mod embeddings;
|
||||
pub mod global;
|
||||
pub mod ingestion;
|
||||
pub mod ingestion_queue;
|
||||
pub mod ingestion_state;
|
||||
pub mod ops;
|
||||
pub mod rpc_models;
|
||||
pub mod schemas;
|
||||
@@ -21,11 +18,10 @@ pub mod traits;
|
||||
pub mod tree;
|
||||
|
||||
pub use ingestion::{
|
||||
ExtractedEntity, ExtractedRelation, ExtractionMode, MemoryIngestionConfig,
|
||||
MemoryIngestionRequest, MemoryIngestionResult, DEFAULT_MEMORY_EXTRACTION_MODEL,
|
||||
ExtractedEntity, ExtractedRelation, ExtractionMode, IngestionJob, IngestionQueue,
|
||||
IngestionState, IngestionStatusSnapshot, MemoryIngestionConfig, MemoryIngestionRequest,
|
||||
MemoryIngestionResult, DEFAULT_MEMORY_EXTRACTION_MODEL,
|
||||
};
|
||||
pub use ingestion_queue::{IngestionJob, IngestionQueue};
|
||||
pub use ingestion_state::{IngestionState, IngestionStatusSnapshot};
|
||||
pub use ops as rpc;
|
||||
pub use ops::*;
|
||||
pub use rpc_models::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
//! Document, namespace, and recall RPC handlers — both the unified-memory
|
||||
//! direct API (`doc_*`, `namespace_*`, `context_*`) and the envelope-style
|
||||
//! façade (`memory_init`, `memory_list_documents`, `memory_query_namespace`,
|
||||
//! `memory_recall_*`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::openhuman::memory::{
|
||||
ApiEnvelope, DeleteDocumentRequest, DeleteDocumentResponse, EmptyRequest, ListDocumentsRequest,
|
||||
ListDocumentsResponse, ListNamespacesResponse, MemoryIngestionConfig, MemoryIngestionRequest,
|
||||
MemoryIngestionResult, MemoryInitRequest, MemoryInitResponse, MemoryRecallItem,
|
||||
NamespaceDocumentInput, NamespaceRetrievalContext, PaginationMeta, QueryNamespaceRequest,
|
||||
QueryNamespaceResponse, RecallContextRequest, RecallContextResponse, RecallMemoriesRequest,
|
||||
RecallMemoriesResponse,
|
||||
};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::envelope::{envelope, error_envelope, memory_counts};
|
||||
use super::helpers::{
|
||||
active_memory_client, build_retrieval_context, current_workspace_dir,
|
||||
filter_hits_by_document_ids, format_llm_context_message, maybe_retrieval_context,
|
||||
memory_kind_label, parse_memory_document_summaries, query_limit_for_request,
|
||||
RawDeleteDocumentResult,
|
||||
};
|
||||
use super::helpers::{default_category, default_priority, default_source_type};
|
||||
|
||||
/// Parameters for the `doc_put` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PutDocParams {
|
||||
/// Namespace to store the document in.
|
||||
pub namespace: String,
|
||||
/// Unique key for the document within the namespace.
|
||||
pub key: String,
|
||||
/// Human-readable title for the document.
|
||||
pub title: String,
|
||||
/// The raw text content of the document.
|
||||
pub content: String,
|
||||
/// The source type of the document (e.g., "doc", "web").
|
||||
#[serde(default = "default_source_type")]
|
||||
pub source_type: String,
|
||||
/// Priority level for retrieval (e.g., "high", "medium", "low").
|
||||
#[serde(default = "default_priority")]
|
||||
pub priority: String,
|
||||
/// Optional tags for categorization and filtering.
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Additional unstructured metadata.
|
||||
#[serde(default)]
|
||||
pub metadata: serde_json::Value,
|
||||
/// Core category for the document (e.g., "core", "user").
|
||||
#[serde(default = "default_category")]
|
||||
pub category: String,
|
||||
/// Optional session ID associated with the document.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
/// Optional explicit document ID.
|
||||
#[serde(default)]
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for the `doc_ingest` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IngestDocParams {
|
||||
/// Namespace to store the document in.
|
||||
pub namespace: String,
|
||||
/// Unique key for the document within the namespace.
|
||||
pub key: String,
|
||||
/// Human-readable title for the document.
|
||||
pub title: String,
|
||||
/// The raw text content of the document.
|
||||
pub content: String,
|
||||
/// The source type of the document.
|
||||
#[serde(default = "default_source_type")]
|
||||
pub source_type: String,
|
||||
/// Priority level for retrieval.
|
||||
#[serde(default = "default_priority")]
|
||||
pub priority: String,
|
||||
/// Optional tags for the document.
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Additional unstructured metadata.
|
||||
#[serde(default)]
|
||||
pub metadata: serde_json::Value,
|
||||
/// Core category for the document.
|
||||
#[serde(default = "default_category")]
|
||||
pub category: String,
|
||||
/// Optional session ID.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
/// Optional explicit document ID.
|
||||
#[serde(default)]
|
||||
pub document_id: Option<String>,
|
||||
/// Configuration for the ingestion process (chunking, etc.).
|
||||
#[serde(default)]
|
||||
pub config: Option<MemoryIngestionConfig>,
|
||||
}
|
||||
|
||||
/// Parameters for RPC methods that only require a namespace.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NamespaceOnlyParams {
|
||||
/// The target namespace.
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
/// Parameters for the `clear_namespace` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ClearNamespaceParams {
|
||||
/// The namespace to clear.
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
/// Result returned by the `clear_namespace` RPC method.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClearNamespaceResult {
|
||||
/// Whether the namespace was successfully cleared.
|
||||
pub cleared: bool,
|
||||
/// The namespace that was cleared.
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
/// Parameters for the `doc_delete` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteDocParams {
|
||||
/// The namespace containing the document.
|
||||
pub namespace: String,
|
||||
/// The unique ID of the document to delete.
|
||||
pub document_id: String,
|
||||
}
|
||||
|
||||
/// Parameters for the `context_query` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QueryNamespaceParams {
|
||||
/// The namespace to query.
|
||||
pub namespace: String,
|
||||
/// The natural language query string.
|
||||
pub query: String,
|
||||
/// Maximum number of results to return.
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parameters for the `context_recall` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecallNamespaceParams {
|
||||
/// The namespace to recall from.
|
||||
pub namespace: String,
|
||||
/// Maximum number of results to return.
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Result returned by the `doc_put` RPC method.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PutDocResult {
|
||||
/// The unique ID of the upserted document.
|
||||
pub document_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified-memory direct API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Lists all namespaces in the memory system.
|
||||
pub async fn namespace_list() -> Result<RpcOutcome<Vec<String>>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let namespaces = client.list_namespaces().await?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
namespaces,
|
||||
"memory namespaces listed",
|
||||
))
|
||||
}
|
||||
|
||||
/// Upserts a document into a namespace.
|
||||
pub async fn doc_put(params: PutDocParams) -> Result<RpcOutcome<PutDocResult>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let document_id = client
|
||||
.put_doc(NamespaceDocumentInput {
|
||||
namespace: params.namespace,
|
||||
key: params.key,
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
source_type: params.source_type,
|
||||
priority: params.priority,
|
||||
tags: params.tags,
|
||||
metadata: params.metadata,
|
||||
category: params.category,
|
||||
session_id: params.session_id,
|
||||
document_id: params.document_id,
|
||||
})
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
PutDocResult { document_id },
|
||||
"memory document upserted",
|
||||
))
|
||||
}
|
||||
|
||||
/// Ingests a document, performing chunking and embedding.
|
||||
pub async fn doc_ingest(
|
||||
params: IngestDocParams,
|
||||
) -> Result<RpcOutcome<MemoryIngestionResult>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let result = client
|
||||
.ingest_doc(MemoryIngestionRequest {
|
||||
document: NamespaceDocumentInput {
|
||||
namespace: params.namespace,
|
||||
key: params.key,
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
source_type: params.source_type,
|
||||
priority: params.priority,
|
||||
tags: params.tags,
|
||||
metadata: params.metadata,
|
||||
category: params.category,
|
||||
session_id: params.session_id,
|
||||
document_id: params.document_id,
|
||||
},
|
||||
config: params.config.unwrap_or_default(),
|
||||
})
|
||||
.await?;
|
||||
let msg = format!(
|
||||
"ingested document — {} entities, {} relations, {} chunks",
|
||||
result.entity_count, result.relation_count, result.chunk_count,
|
||||
);
|
||||
Ok(RpcOutcome::single_log(result, &msg))
|
||||
}
|
||||
|
||||
/// Lists documents, optionally filtered by namespace.
|
||||
pub async fn doc_list(
|
||||
params: Option<NamespaceOnlyParams>,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let docs = client
|
||||
.list_documents(params.as_ref().map(|v| v.namespace.as_str()))
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(docs, "memory documents listed"))
|
||||
}
|
||||
|
||||
/// Deletes a document from a namespace.
|
||||
pub async fn doc_delete(params: DeleteDocParams) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let result = client
|
||||
.delete_document(¶ms.namespace, ¶ms.document_id)
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(result, "memory document deleted"))
|
||||
}
|
||||
|
||||
/// Clears all data within a namespace.
|
||||
pub async fn clear_namespace(
|
||||
params: ClearNamespaceParams,
|
||||
) -> Result<RpcOutcome<ClearNamespaceResult>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
log::debug!("[memory] clear_namespace RPC invoked");
|
||||
client.clear_namespace(¶ms.namespace).await?;
|
||||
let msg = "memory namespace cleared".to_string();
|
||||
Ok(RpcOutcome::single_log(
|
||||
ClearNamespaceResult {
|
||||
cleared: true,
|
||||
namespace: params.namespace,
|
||||
},
|
||||
&msg,
|
||||
))
|
||||
}
|
||||
|
||||
/// Queries a namespace for contextual information based on a natural language string.
|
||||
pub async fn context_query(params: QueryNamespaceParams) -> Result<RpcOutcome<String>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let result = client
|
||||
.query_namespace(¶ms.namespace, ¶ms.query, params.limit.unwrap_or(10))
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(result, "memory context queried"))
|
||||
}
|
||||
|
||||
/// Recalls contextual information from a namespace without a specific query.
|
||||
pub async fn context_recall(
|
||||
params: RecallNamespaceParams,
|
||||
) -> Result<RpcOutcome<Option<String>>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let result = client
|
||||
.recall_namespace(¶ms.namespace, params.limit.unwrap_or(10))
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(result, "memory context recalled"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Envelope-style façade (`memory_*`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Initialise the local-only (SQLite) memory subsystem for the current workspace.
|
||||
///
|
||||
/// `request.jwt_token` is accepted for backward compatibility but ignored — all
|
||||
/// memory operations are local. Remote/cloud sync is a future consideration.
|
||||
pub async fn memory_init(
|
||||
request: MemoryInitRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<MemoryInitResponse>>, String> {
|
||||
let _ = request.jwt_token; // accepted but unused — memory is local-only
|
||||
let workspace_dir = current_workspace_dir().await?;
|
||||
// Initialise (or return existing) global singleton.
|
||||
let _ = super::super::global::init(workspace_dir.clone())?;
|
||||
let memory_dir = workspace_dir.join("memory");
|
||||
Ok(envelope(
|
||||
MemoryInitResponse {
|
||||
initialized: true,
|
||||
workspace_dir: workspace_dir.display().to_string(),
|
||||
memory_dir: memory_dir.display().to_string(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// Lists documents stored in memory, optionally filtered by namespace.
|
||||
pub async fn memory_list_documents(
|
||||
request: ListDocumentsRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<ListDocumentsResponse>>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let raw = client.list_documents(request.namespace.as_deref()).await?;
|
||||
let documents = parse_memory_document_summaries(raw)?;
|
||||
let count = documents.len();
|
||||
Ok(envelope(
|
||||
ListDocumentsResponse {
|
||||
namespace: request.namespace,
|
||||
documents,
|
||||
count,
|
||||
},
|
||||
Some(memory_counts([("num_documents", count)])),
|
||||
Some(PaginationMeta {
|
||||
limit: count,
|
||||
offset: 0,
|
||||
count,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Lists all namespaces that contain memory documents.
|
||||
pub async fn memory_list_namespaces(
|
||||
_request: EmptyRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<ListNamespacesResponse>>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let namespaces = client.list_namespaces().await?;
|
||||
let count = namespaces.len();
|
||||
Ok(envelope(
|
||||
ListNamespacesResponse { namespaces, count },
|
||||
Some(memory_counts([("num_namespaces", count)])),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// Deletes a specific document from a namespace.
|
||||
pub async fn memory_delete_document(
|
||||
request: DeleteDocumentRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<DeleteDocumentResponse>>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let raw = client
|
||||
.delete_document(&request.namespace, &request.document_id)
|
||||
.await?;
|
||||
let parsed: RawDeleteDocumentResult =
|
||||
serde_json::from_value(raw).map_err(|e| format!("decode delete document result: {e}"))?;
|
||||
Ok(envelope(
|
||||
DeleteDocumentResponse {
|
||||
status: if parsed.deleted {
|
||||
"completed".to_string()
|
||||
} else {
|
||||
"not_found".to_string()
|
||||
},
|
||||
namespace: parsed.namespace,
|
||||
document_id: parsed.document_id,
|
||||
deleted: parsed.deleted,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// Performs a semantic query against a namespace, returning a retrieval context.
|
||||
pub async fn memory_query_namespace(
|
||||
request: QueryNamespaceRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<QueryNamespaceResponse>>, String> {
|
||||
let include_references = request.include_references.unwrap_or(true);
|
||||
let requested_limit = request.resolved_limit() as usize;
|
||||
let result = async {
|
||||
let client = active_memory_client().await?;
|
||||
let retrieval_limit = query_limit_for_request(client.as_ref(), &request).await?;
|
||||
let mut context = client
|
||||
.query_namespace_context_data(&request.namespace, &request.query, retrieval_limit)
|
||||
.await?;
|
||||
context.hits = filter_hits_by_document_ids(context.hits, request.document_ids.as_deref());
|
||||
// `query_limit_for_request` may have over-fetched on purpose so that
|
||||
// the document_id filter has enough candidates; truncate back to what
|
||||
// the caller actually asked for.
|
||||
if context.hits.len() > requested_limit {
|
||||
context.hits.truncate(requested_limit);
|
||||
}
|
||||
Ok::<NamespaceRetrievalContext, String>(context)
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(context) => {
|
||||
let retrieval_context = build_retrieval_context(&context.hits);
|
||||
let counts = memory_counts([
|
||||
("num_entities", retrieval_context.entities.len()),
|
||||
("num_relations", retrieval_context.relations.len()),
|
||||
("num_chunks", retrieval_context.chunks.len()),
|
||||
]);
|
||||
let llm_context_message =
|
||||
format_llm_context_message(Some(&request.query), &context.hits);
|
||||
Ok(envelope(
|
||||
QueryNamespaceResponse {
|
||||
context: maybe_retrieval_context(include_references, retrieval_context),
|
||||
llm_context_message,
|
||||
},
|
||||
Some(counts),
|
||||
None,
|
||||
))
|
||||
}
|
||||
Err(message) => Ok(error_envelope("memory.query_namespace_failed", message)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recalls contextual data from a namespace without a specific query.
|
||||
pub async fn memory_recall_context(
|
||||
request: RecallContextRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<RecallContextResponse>>, String> {
|
||||
let include_references = request.include_references.unwrap_or(true);
|
||||
let result = async {
|
||||
let client = active_memory_client().await?;
|
||||
client
|
||||
.recall_namespace_context_data(&request.namespace, request.resolved_limit())
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(context) => {
|
||||
let retrieval_context = build_retrieval_context(&context.hits);
|
||||
let counts = memory_counts([
|
||||
("num_entities", retrieval_context.entities.len()),
|
||||
("num_relations", retrieval_context.relations.len()),
|
||||
("num_chunks", retrieval_context.chunks.len()),
|
||||
]);
|
||||
let llm_context_message = format_llm_context_message(None, &context.hits);
|
||||
Ok(envelope(
|
||||
RecallContextResponse {
|
||||
context: maybe_retrieval_context(include_references, retrieval_context),
|
||||
llm_context_message,
|
||||
},
|
||||
Some(counts),
|
||||
None,
|
||||
))
|
||||
}
|
||||
Err(message) => Ok(error_envelope("memory.recall_context_failed", message)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recalls memory items from a namespace with optional retention filtering.
|
||||
pub async fn memory_recall_memories(
|
||||
request: RecallMemoriesRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<RecallMemoriesResponse>>, String> {
|
||||
let result = async {
|
||||
let client = active_memory_client().await?;
|
||||
client
|
||||
.recall_namespace_memories(&request.namespace, request.resolved_limit())
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(hits) => {
|
||||
let memories = hits
|
||||
.into_iter()
|
||||
.map(|hit| MemoryRecallItem {
|
||||
kind: memory_kind_label(&hit.kind).to_string(),
|
||||
id: hit.id,
|
||||
content: hit.content,
|
||||
score: hit.score,
|
||||
retention: None,
|
||||
last_accessed_at: None,
|
||||
access_count: None,
|
||||
stability_days: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let count = memories.len();
|
||||
Ok(envelope(
|
||||
RecallMemoriesResponse { memories },
|
||||
Some(memory_counts([("num_memories", count)])),
|
||||
None,
|
||||
))
|
||||
}
|
||||
Err(message) => Ok(error_envelope("memory.recall_memories_failed", message)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Response envelope helpers shared across memory RPC handlers.
|
||||
//!
|
||||
//! These helpers standardise the `ApiEnvelope`/`ApiError` wrapping used by the
|
||||
//! envelope-style memory RPC methods (init, list_documents, query_namespace,
|
||||
//! recall_*, ai_*_memory_file).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::openhuman::memory::{ApiEnvelope, ApiError, ApiMeta, PaginationMeta};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
/// Generates a unique request ID for memory operations.
|
||||
///
|
||||
/// This ID is used for tracing and logging purposes in the API response metadata.
|
||||
pub(crate) fn memory_request_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Converts an iterator of memory counts into a BTreeMap.
|
||||
///
|
||||
/// This is a convenience helper for populating the `counts` field in the API metadata.
|
||||
pub(crate) fn memory_counts(
|
||||
entries: impl IntoIterator<Item = (&'static str, usize)>,
|
||||
) -> BTreeMap<String, usize> {
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_string(), value))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Wraps data in an RPC API envelope.
|
||||
///
|
||||
/// This standardises the response format for memory-related RPC methods.
|
||||
pub(crate) fn envelope<T: Serialize>(
|
||||
data: T,
|
||||
counts: Option<BTreeMap<String, usize>>,
|
||||
pagination: Option<PaginationMeta>,
|
||||
) -> RpcOutcome<ApiEnvelope<T>> {
|
||||
RpcOutcome::new(
|
||||
ApiEnvelope {
|
||||
data: Some(data),
|
||||
error: None,
|
||||
meta: ApiMeta {
|
||||
request_id: memory_request_id(),
|
||||
latency_seconds: None,
|
||||
cached: None,
|
||||
counts,
|
||||
pagination,
|
||||
},
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
/// Wraps an error in an RPC API envelope.
|
||||
///
|
||||
/// This provides a consistent error reporting format for the memory system.
|
||||
pub(crate) fn error_envelope<T: Serialize>(
|
||||
code: &str,
|
||||
message: String,
|
||||
) -> RpcOutcome<ApiEnvelope<T>> {
|
||||
RpcOutcome::new(
|
||||
ApiEnvelope {
|
||||
data: None,
|
||||
error: Some(ApiError {
|
||||
code: code.to_string(),
|
||||
message,
|
||||
details: None,
|
||||
}),
|
||||
meta: ApiMeta {
|
||||
request_id: memory_request_id(),
|
||||
latency_seconds: None,
|
||||
cached: None,
|
||||
counts: None,
|
||||
pagination: None,
|
||||
},
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! File-based memory RPC handlers (`ai_list_memory_files`,
|
||||
//! `ai_read_memory_file`, `ai_write_memory_file`).
|
||||
//!
|
||||
//! All filesystem I/O here is performed via `tokio::fs` so the handlers stay
|
||||
//! async-friendly and never block the executor.
|
||||
|
||||
use crate::openhuman::memory::{
|
||||
ApiEnvelope, ListMemoryFilesRequest, ListMemoryFilesResponse, ReadMemoryFileRequest,
|
||||
ReadMemoryFileResponse, WriteMemoryFileRequest, WriteMemoryFileResponse,
|
||||
};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::envelope::{envelope, memory_counts};
|
||||
use super::helpers::{
|
||||
resolve_existing_memory_path, resolve_writable_memory_path, validate_memory_relative_path,
|
||||
};
|
||||
|
||||
/// Lists files in a memory directory.
|
||||
pub async fn ai_list_memory_files(
|
||||
request: ListMemoryFilesRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<ListMemoryFilesResponse>>, String> {
|
||||
validate_memory_relative_path(&request.relative_dir)?;
|
||||
let directory = resolve_existing_memory_path(&request.relative_dir).await?;
|
||||
if !directory.is_dir() {
|
||||
return Err(format!(
|
||||
"memory directory not found: {}",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut read_dir = tokio::fs::read_dir(&directory)
|
||||
.await
|
||||
.map_err(|e| format!("read memory directory {}: {e}", directory.display()))?;
|
||||
while let Some(entry) = read_dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| format!("read memory directory entry: {e}"))?
|
||||
{
|
||||
// Skip subdirectories and symlinks — `ai_read_memory_file` only
|
||||
// consumes regular file entries, and surfacing other entry kinds
|
||||
// here would just produce confusing follow-up read errors.
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.await
|
||||
.map_err(|e| format!("read memory directory entry type: {e}"))?;
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let file_name = entry.file_name();
|
||||
let file_name = file_name.to_string_lossy();
|
||||
if !file_name.is_empty() {
|
||||
files.push(file_name.to_string());
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let count = files.len();
|
||||
Ok(envelope(
|
||||
ListMemoryFilesResponse {
|
||||
relative_dir: request.relative_dir,
|
||||
files,
|
||||
count,
|
||||
},
|
||||
Some(memory_counts([("num_files", count)])),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// Reads the contents of a memory file.
|
||||
pub async fn ai_read_memory_file(
|
||||
request: ReadMemoryFileRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<ReadMemoryFileResponse>>, String> {
|
||||
let path = resolve_existing_memory_path(&request.relative_path).await?;
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.map_err(|e| format!("read memory file {}: {e}", path.display()))?;
|
||||
Ok(envelope(
|
||||
ReadMemoryFileResponse {
|
||||
relative_path: request.relative_path,
|
||||
content,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// Writes content to a memory file.
|
||||
pub async fn ai_write_memory_file(
|
||||
request: WriteMemoryFileRequest,
|
||||
) -> Result<RpcOutcome<ApiEnvelope<WriteMemoryFileResponse>>, String> {
|
||||
let path = resolve_writable_memory_path(&request.relative_path).await?;
|
||||
tokio::fs::write(&path, request.content.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("write memory file {}: {e}", path.display()))?;
|
||||
let bytes_written = request.content.len();
|
||||
Ok(envelope(
|
||||
WriteMemoryFileResponse {
|
||||
relative_path: request.relative_path,
|
||||
written: true,
|
||||
bytes_written,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Formatting helpers, default constants, path validators, and the active
|
||||
//! memory-client lookup. Shared internals for the memory RPC handlers.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use chrono::TimeZone;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::memory::store::GraphRelationRecord;
|
||||
use crate::openhuman::memory::{
|
||||
MemoryClient, MemoryClientRef, MemoryDocumentSummary, MemoryItemKind, MemoryRetrievalChunk,
|
||||
MemoryRetrievalContext, MemoryRetrievalEntity, MemoryRetrievalRelation, NamespaceMemoryHit,
|
||||
QueryNamespaceRequest,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Formats a floating-point timestamp as an RFC3339 string.
|
||||
///
|
||||
/// Returns `None` if the timestamp is invalid (NaN, infinite, or negative).
|
||||
pub(crate) fn timestamp_to_rfc3339(timestamp: f64) -> Option<String> {
|
||||
if !timestamp.is_finite() || timestamp < 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let secs = timestamp.trunc() as i64;
|
||||
let nanos = ((timestamp.fract().abs()) * 1_000_000_000.0).round() as u32;
|
||||
chrono::Utc
|
||||
.timestamp_opt(secs, nanos.min(999_999_999))
|
||||
.single()
|
||||
.map(|value| value.to_rfc3339())
|
||||
}
|
||||
|
||||
/// Maps a memory item kind to a human-readable label.
|
||||
pub(crate) fn memory_kind_label(kind: &MemoryItemKind) -> &'static str {
|
||||
match kind {
|
||||
MemoryItemKind::Document => "document",
|
||||
MemoryItemKind::Kv => "kv",
|
||||
MemoryItemKind::Episodic => "episodic",
|
||||
MemoryItemKind::Event => "event",
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a unique string identity for a graph relation.
|
||||
///
|
||||
/// The identity is composed of the namespace, subject, predicate, and object.
|
||||
pub(crate) fn relation_identity(relation: &GraphRelationRecord) -> String {
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
relation.namespace.as_deref().unwrap_or("global"),
|
||||
relation.subject.as_str(),
|
||||
relation.predicate.as_str(),
|
||||
relation.object.as_str()
|
||||
)
|
||||
}
|
||||
|
||||
/// Formats relation metadata into a JSON Value.
|
||||
pub(crate) fn relation_metadata(relation: &GraphRelationRecord) -> Value {
|
||||
json!({
|
||||
"namespace": relation.namespace.clone(),
|
||||
"attrs": relation.attrs.clone(),
|
||||
"order_index": relation.order_index,
|
||||
"document_ids": relation.document_ids.clone(),
|
||||
"chunk_ids": relation.chunk_ids.clone(),
|
||||
"updated_at": timestamp_to_rfc3339(relation.updated_at),
|
||||
})
|
||||
}
|
||||
|
||||
/// Formats chunk metadata into a JSON Value.
|
||||
pub(crate) fn chunk_metadata(hit: &NamespaceMemoryHit) -> Value {
|
||||
json!({
|
||||
"kind": memory_kind_label(&hit.kind),
|
||||
"namespace": hit.namespace.clone(),
|
||||
"key": hit.key.clone(),
|
||||
"title": hit.title.clone(),
|
||||
"category": hit.category.clone(),
|
||||
"source_type": hit.source_type.clone(),
|
||||
"score_breakdown": {
|
||||
"keyword_relevance": hit.score_breakdown.keyword_relevance,
|
||||
"vector_similarity": hit.score_breakdown.vector_similarity,
|
||||
"graph_relevance": hit.score_breakdown.graph_relevance,
|
||||
"episodic_relevance": hit.score_breakdown.episodic_relevance,
|
||||
"freshness": hit.score_breakdown.freshness,
|
||||
"final_score": hit.score_breakdown.final_score,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Extracts an entity type for a specific role (subject/object) from relation attributes.
|
||||
pub(crate) fn extract_entity_type(attrs: &Value, role: &str) -> Option<String> {
|
||||
attrs
|
||||
.get("entity_types")
|
||||
.and_then(|et| et.get(role))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Transforms memory hits into a retrieval context with deduplicated entities and relations.
|
||||
pub(crate) fn build_retrieval_context(hits: &[NamespaceMemoryHit]) -> MemoryRetrievalContext {
|
||||
let mut entity_types: BTreeMap<String, Option<String>> = BTreeMap::new();
|
||||
let mut relations = BTreeMap::new();
|
||||
let chunks = hits
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
// Extract supporting relations from each hit to populate entities and relations
|
||||
for relation in &hit.supporting_relations {
|
||||
if !relation.subject.trim().is_empty() {
|
||||
let entry = entity_types.entry(relation.subject.clone()).or_insert(None);
|
||||
// Use the first non-empty entity type found for this subject
|
||||
if entry.is_none() {
|
||||
*entry = extract_entity_type(&relation.attrs, "subject");
|
||||
}
|
||||
}
|
||||
if !relation.object.trim().is_empty() {
|
||||
let entry = entity_types.entry(relation.object.clone()).or_insert(None);
|
||||
// Use the first non-empty entity type found for this object
|
||||
if entry.is_none() {
|
||||
*entry = extract_entity_type(&relation.attrs, "object");
|
||||
}
|
||||
}
|
||||
// Deduplicate relations based on their unique identity
|
||||
relations
|
||||
.entry(relation_identity(relation))
|
||||
.or_insert_with(|| MemoryRetrievalRelation {
|
||||
subject: relation.subject.clone(),
|
||||
predicate: relation.predicate.clone(),
|
||||
object: relation.object.clone(),
|
||||
score: None,
|
||||
evidence_count: Some(relation.evidence_count),
|
||||
metadata: relation_metadata(relation),
|
||||
});
|
||||
}
|
||||
|
||||
MemoryRetrievalChunk {
|
||||
chunk_id: hit.chunk_id.clone(),
|
||||
document_id: hit.document_id.clone(),
|
||||
content: hit.content.clone(),
|
||||
score: hit.score,
|
||||
metadata: chunk_metadata(hit),
|
||||
created_at: None,
|
||||
updated_at: timestamp_to_rfc3339(hit.updated_at),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
MemoryRetrievalContext {
|
||||
entities: entity_types
|
||||
.into_iter()
|
||||
.map(|(name, entity_type)| MemoryRetrievalEntity {
|
||||
id: None,
|
||||
name,
|
||||
entity_type,
|
||||
score: None,
|
||||
metadata: json!({}),
|
||||
})
|
||||
.collect(),
|
||||
relations: relations.into_values().collect(),
|
||||
chunks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats memory hits into a natural-language context message for LLM consumption.
|
||||
pub(crate) fn format_llm_context_message(
|
||||
query: Option<&str>,
|
||||
hits: &[NamespaceMemoryHit],
|
||||
) -> Option<String> {
|
||||
if hits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if let Some(query) = query {
|
||||
parts.push(format!("Query: {query}"));
|
||||
}
|
||||
|
||||
for hit in hits {
|
||||
let summary = match hit.kind {
|
||||
MemoryItemKind::Document => {
|
||||
let title = hit.title.clone().unwrap_or_else(|| hit.key.clone());
|
||||
format!("{title}: {}", hit.content.trim())
|
||||
}
|
||||
MemoryItemKind::Kv => format!("[kv:{}] {}", hit.key, hit.content.trim()),
|
||||
MemoryItemKind::Episodic => {
|
||||
format!("[episodic:{}] {}", hit.key, hit.content.trim())
|
||||
}
|
||||
MemoryItemKind::Event => {
|
||||
format!("[event:{}] {}", hit.key, hit.content.trim())
|
||||
}
|
||||
};
|
||||
parts.push(summary);
|
||||
|
||||
// Include typed relations if present for better LLM reasoning
|
||||
if !hit.supporting_relations.is_empty() {
|
||||
let relations = hit
|
||||
.supporting_relations
|
||||
.iter()
|
||||
.map(|relation| {
|
||||
let subject_type = extract_entity_type(&relation.attrs, "subject");
|
||||
let object_type = extract_entity_type(&relation.attrs, "object");
|
||||
let subject_label = match subject_type {
|
||||
Some(t) => format!("{} ({})", relation.subject, t),
|
||||
None => relation.subject.clone(),
|
||||
};
|
||||
let object_label = match object_type {
|
||||
Some(t) => format!("{} ({})", relation.object, t),
|
||||
None => relation.object.clone(),
|
||||
};
|
||||
format!(
|
||||
"{} -[{}]-> {}",
|
||||
subject_label, relation.predicate, object_label
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
parts.push(format!("Relations: {relations}"));
|
||||
}
|
||||
}
|
||||
|
||||
Some(parts.join("\n\n"))
|
||||
}
|
||||
|
||||
/// Filters memory hits to only include those matching specific document IDs.
|
||||
pub(crate) fn filter_hits_by_document_ids(
|
||||
hits: Vec<NamespaceMemoryHit>,
|
||||
document_ids: Option<&[String]>,
|
||||
) -> Vec<NamespaceMemoryHit> {
|
||||
let Some(document_ids) = document_ids else {
|
||||
return hits;
|
||||
};
|
||||
let allowed = document_ids.iter().cloned().collect::<BTreeSet<_>>();
|
||||
hits.into_iter()
|
||||
.filter(|hit| {
|
||||
hit.document_id
|
||||
.as_ref()
|
||||
.map(|document_id| allowed.contains(document_id))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the retrieval context if `include_references` is true and context is not empty.
|
||||
pub(crate) fn maybe_retrieval_context(
|
||||
include_references: bool,
|
||||
context: MemoryRetrievalContext,
|
||||
) -> Option<MemoryRetrievalContext> {
|
||||
if !include_references {
|
||||
return None;
|
||||
}
|
||||
if context.entities.is_empty() && context.relations.is_empty() && context.chunks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(context)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn default_source_type() -> String {
|
||||
"doc".to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn default_priority() -> String {
|
||||
"medium".to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn default_category() -> String {
|
||||
"core".to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace + memory-client lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Subdirectory under the workspace where the file-based memory RPCs operate.
|
||||
/// `ai_*_memory_file` handlers MUST resolve all caller-supplied relative paths
|
||||
/// against this directory — never the workspace root — to avoid leaking access
|
||||
/// to repo files such as `Cargo.toml`, `.env`, or source files.
|
||||
const MEMORY_SUBDIR: &str = "memory";
|
||||
|
||||
/// Returns the current workspace directory from configuration.
|
||||
pub(crate) async fn current_workspace_dir() -> Result<PathBuf, String> {
|
||||
Config::load_or_init()
|
||||
.await
|
||||
.map(|config| config.workspace_dir)
|
||||
.map_err(|e| format!("load config: {e}"))
|
||||
}
|
||||
|
||||
/// Returns the active memory client from the process-global singleton,
|
||||
/// auto-initialising from the configured workspace if startup wiring hasn't
|
||||
/// done so yet.
|
||||
///
|
||||
/// The auto-init resolves the workspace via [`current_workspace_dir`], which
|
||||
/// goes through `Config::load_or_init` — the same path startup wiring uses.
|
||||
/// It does **not** fall back to `~/.openhuman/workspace`; that hazard is the
|
||||
/// one [`crate::openhuman::memory::global::client`] guards against, and it
|
||||
/// remains guarded for any caller that bypasses this helper.
|
||||
pub(crate) async fn active_memory_client() -> Result<MemoryClientRef, String> {
|
||||
if let Some(client) = super::super::global::client_if_ready() {
|
||||
return Ok(client);
|
||||
}
|
||||
let workspace_dir = current_workspace_dir().await?;
|
||||
super::super::global::init(workspace_dir)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path validators (used by file-based memory handlers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validates that a relative path does not escape the memory directory.
|
||||
///
|
||||
/// An empty path is allowed and refers to the memory root itself
|
||||
/// (`<workspace>/memory`); read-style helpers can resolve it to that
|
||||
/// directory. Write helpers reject empty paths separately because they
|
||||
/// require a file name component.
|
||||
pub(crate) fn validate_memory_relative_path(path: &str) -> Result<(), String> {
|
||||
let candidate = Path::new(path);
|
||||
if candidate.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if candidate.is_absolute() {
|
||||
return Err("absolute paths are not allowed".to_string());
|
||||
}
|
||||
// Prevent traversal using .. components
|
||||
for component in candidate.components() {
|
||||
match component {
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
return Err("path traversal is not allowed".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the canonical path to the memory directory within the workspace.
|
||||
pub(crate) async fn resolve_memory_root() -> Result<PathBuf, String> {
|
||||
let workspace_dir = current_workspace_dir().await?;
|
||||
let memory_root = workspace_dir.join(MEMORY_SUBDIR);
|
||||
tokio::fs::create_dir_all(&memory_root)
|
||||
.await
|
||||
.map_err(|e| format!("create memory dir {}: {e}", memory_root.display()))?;
|
||||
memory_root
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("resolve memory dir {}: {e}", memory_root.display()))
|
||||
}
|
||||
|
||||
/// Resolves and canonicalizes an existing memory path, ensuring it stays within
|
||||
/// the `<workspace>/memory` directory (not the workspace root). An empty
|
||||
/// `relative_path` resolves to the memory root itself.
|
||||
pub(crate) async fn resolve_existing_memory_path(relative_path: &str) -> Result<PathBuf, String> {
|
||||
validate_memory_relative_path(relative_path)?;
|
||||
let memory_root = resolve_memory_root().await?;
|
||||
let full_path = if relative_path.is_empty() {
|
||||
memory_root.clone()
|
||||
} else {
|
||||
memory_root.join(relative_path)
|
||||
};
|
||||
let resolved = full_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("resolve memory path {}: {e}", full_path.display()))?;
|
||||
if !resolved.starts_with(&memory_root) {
|
||||
return Err("memory path escapes the memory directory".to_string());
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Resolves a path for writing, creating parent directories and ensuring it
|
||||
/// stays within the `<workspace>/memory` directory (not the workspace root).
|
||||
pub(crate) async fn resolve_writable_memory_path(relative_path: &str) -> Result<PathBuf, String> {
|
||||
validate_memory_relative_path(relative_path)?;
|
||||
let memory_root = resolve_memory_root().await?;
|
||||
let full_path = memory_root.join(relative_path);
|
||||
let parent = full_path
|
||||
.parent()
|
||||
.ok_or_else(|| "memory path must include a file name".to_string())?;
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("create memory path {}: {e}", parent.display()))?;
|
||||
let resolved_parent = parent
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("resolve memory parent {}: {e}", parent.display()))?;
|
||||
if !resolved_parent.starts_with(&memory_root) {
|
||||
return Err("memory path escapes the memory directory".to_string());
|
||||
}
|
||||
let file_name = full_path
|
||||
.file_name()
|
||||
.ok_or_else(|| "memory path must include a file name".to_string())?;
|
||||
let resolved = resolved_parent.join(file_name);
|
||||
// Security check: refuse to write through symlinks to prevent hijacking
|
||||
if let Ok(metadata) = tokio::fs::symlink_metadata(&resolved).await {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(format!(
|
||||
"refusing to write through symlink: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document summary parsing + query-limit resolution (shared by documents.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMemoryDocumentSummary {
|
||||
document_id: String,
|
||||
namespace: String,
|
||||
key: String,
|
||||
title: String,
|
||||
source_type: String,
|
||||
priority: String,
|
||||
created_at: f64,
|
||||
updated_at: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RawDeleteDocumentResult {
|
||||
pub deleted: bool,
|
||||
pub namespace: String,
|
||||
pub document_id: String,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_memory_document_summaries(
|
||||
raw: Value,
|
||||
) -> Result<Vec<MemoryDocumentSummary>, String> {
|
||||
let documents = raw
|
||||
.get("documents")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "memory document list missing 'documents' array".to_string())?;
|
||||
documents
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|value| {
|
||||
let raw: RawMemoryDocumentSummary = serde_json::from_value(value)
|
||||
.map_err(|e| format!("decode memory document: {e}"))?;
|
||||
Ok(MemoryDocumentSummary {
|
||||
document_id: raw.document_id,
|
||||
namespace: raw.namespace,
|
||||
key: raw.key,
|
||||
title: raw.title,
|
||||
source_type: raw.source_type,
|
||||
priority: raw.priority,
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn query_limit_for_request(
|
||||
client: &MemoryClient,
|
||||
request: &QueryNamespaceRequest,
|
||||
) -> Result<u32, String> {
|
||||
let requested = request.resolved_limit();
|
||||
if request.document_ids.is_none() {
|
||||
return Ok(requested);
|
||||
}
|
||||
|
||||
let raw = client.list_documents(Some(&request.namespace)).await?;
|
||||
let documents = parse_memory_document_summaries(raw)?;
|
||||
let total_documents = u32::try_from(documents.len()).unwrap_or(u32::MAX);
|
||||
Ok(requested.max(total_documents))
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Key-value and knowledge-graph RPC handlers for the unified memory store.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::helpers::active_memory_client;
|
||||
|
||||
/// Parameters for the `kv_set` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct KvSetParams {
|
||||
/// The namespace for the key-value pair.
|
||||
#[serde(default)]
|
||||
pub namespace: Option<String>,
|
||||
/// The unique key.
|
||||
pub key: String,
|
||||
/// The value to store.
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Parameters for `kv_get` and `kv_delete` RPC methods.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct KvGetDeleteParams {
|
||||
/// The namespace containing the key.
|
||||
#[serde(default)]
|
||||
pub namespace: Option<String>,
|
||||
/// The unique key.
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// Parameters for the `graph_upsert` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GraphUpsertParams {
|
||||
/// The namespace for the relation.
|
||||
#[serde(default)]
|
||||
pub namespace: Option<String>,
|
||||
/// The subject of the relation triple.
|
||||
pub subject: String,
|
||||
/// The predicate (relationship) of the triple.
|
||||
pub predicate: String,
|
||||
/// The object of the triple.
|
||||
pub object: String,
|
||||
/// Additional attributes for the relation.
|
||||
#[serde(default)]
|
||||
pub attrs: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Parameters for the `graph_query` RPC method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GraphQueryParams {
|
||||
/// The namespace to query.
|
||||
#[serde(default)]
|
||||
pub namespace: Option<String>,
|
||||
/// Optional subject filter.
|
||||
#[serde(default)]
|
||||
pub subject: Option<String>,
|
||||
/// Optional predicate filter.
|
||||
#[serde(default)]
|
||||
pub predicate: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KV handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sets a key-value pair in the memory store.
|
||||
pub async fn kv_set(params: KvSetParams) -> Result<RpcOutcome<bool>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
client
|
||||
.kv_set(params.namespace.as_deref(), ¶ms.key, ¶ms.value)
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(true, "memory kv set"))
|
||||
}
|
||||
|
||||
/// Retrieves a value by key from the memory store.
|
||||
pub async fn kv_get(
|
||||
params: KvGetDeleteParams,
|
||||
) -> Result<RpcOutcome<Option<serde_json::Value>>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let value = client
|
||||
.kv_get(params.namespace.as_deref(), ¶ms.key)
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(value, "memory kv get"))
|
||||
}
|
||||
|
||||
/// Deletes a key-value pair from the memory store.
|
||||
pub async fn kv_delete(params: KvGetDeleteParams) -> Result<RpcOutcome<bool>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let deleted = client
|
||||
.kv_delete(params.namespace.as_deref(), ¶ms.key)
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(deleted, "memory kv delete"))
|
||||
}
|
||||
|
||||
/// Lists all key-value entries in a namespace.
|
||||
pub async fn kv_list_namespace(
|
||||
params: super::documents::NamespaceOnlyParams,
|
||||
) -> Result<RpcOutcome<Vec<serde_json::Value>>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let rows = client.kv_list_namespace(¶ms.namespace).await?;
|
||||
Ok(RpcOutcome::single_log(rows, "memory namespace kv listed"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Upserts a relation triple in the knowledge graph.
|
||||
pub async fn graph_upsert(params: GraphUpsertParams) -> Result<RpcOutcome<bool>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
client
|
||||
.graph_upsert(
|
||||
params.namespace.as_deref(),
|
||||
¶ms.subject,
|
||||
¶ms.predicate,
|
||||
¶ms.object,
|
||||
¶ms.attrs,
|
||||
)
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(true, "memory graph upserted"))
|
||||
}
|
||||
|
||||
/// Queries relations from the knowledge graph.
|
||||
pub async fn graph_query(
|
||||
params: GraphQueryParams,
|
||||
) -> Result<RpcOutcome<Vec<serde_json::Value>>, String> {
|
||||
let client = active_memory_client().await?;
|
||||
let rows = client
|
||||
.graph_query(
|
||||
params.namespace.as_deref(),
|
||||
params.subject.as_deref(),
|
||||
params.predicate.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(RpcOutcome::single_log(rows, "memory graph queried"))
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! `memory_learn_all` — runs the tree summarizer over namespaces sequentially.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::helpers::active_memory_client;
|
||||
|
||||
/// Per-namespace outcome for `memory_learn_all`.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct NamespaceLearnResult {
|
||||
pub namespace: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Result returned by `memory_learn_all`.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct LearnAllResult {
|
||||
pub namespaces_processed: usize,
|
||||
pub results: Vec<NamespaceLearnResult>,
|
||||
}
|
||||
|
||||
/// Parameters for `memory_learn_all`.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct LearnAllParams {
|
||||
/// Optional list of namespaces to constrain. Defaults to all namespaces.
|
||||
#[serde(default)]
|
||||
pub namespaces: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Run the tree summarizer over all (or a constrained set of) namespaces.
|
||||
///
|
||||
/// Enumerates namespaces via `namespace_list`, then for each runs
|
||||
/// `tree_summarizer_run`. Results are collected per-namespace; a failing
|
||||
/// namespace does not abort the rest. Runs sequentially to avoid saturating
|
||||
/// the local AI provider.
|
||||
pub async fn memory_learn_all(
|
||||
params: LearnAllParams,
|
||||
) -> Result<RpcOutcome<LearnAllResult>, String> {
|
||||
tracing::info!(
|
||||
"[memory.learn] memory_learn_all: entry namespaces={:?}",
|
||||
params.namespaces
|
||||
);
|
||||
|
||||
// Resolve the target namespace list.
|
||||
let client = active_memory_client().await?;
|
||||
let all_ns = client.list_namespaces().await?;
|
||||
tracing::debug!("[memory.learn] available namespaces: {:?}", all_ns);
|
||||
|
||||
let target_ns: Vec<String> = match ¶ms.namespaces {
|
||||
Some(requested) if !requested.is_empty() => {
|
||||
let mut seen = BTreeSet::new();
|
||||
let filtered: Vec<_> = requested
|
||||
.iter()
|
||||
.filter(|ns| all_ns.contains(ns))
|
||||
.filter(|ns| seen.insert((*ns).clone()))
|
||||
.cloned()
|
||||
.collect();
|
||||
tracing::debug!("[memory.learn] constrained to namespaces: {:?}", filtered);
|
||||
filtered
|
||||
}
|
||||
Some(requested) => {
|
||||
// Explicit empty list → no-op (don't fall back to all namespaces).
|
||||
let mut seen = BTreeSet::new();
|
||||
let filtered: Vec<_> = requested
|
||||
.iter()
|
||||
.filter(|ns| all_ns.contains(ns))
|
||||
.filter(|ns| seen.insert((*ns).clone()))
|
||||
.cloned()
|
||||
.collect();
|
||||
tracing::debug!(
|
||||
"[memory.learn] Some([]) empty request → no-op or filtered to {:?}",
|
||||
filtered
|
||||
);
|
||||
filtered
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("[memory.learn] using all {} namespaces", all_ns.len());
|
||||
all_ns
|
||||
}
|
||||
};
|
||||
|
||||
// Short-circuit when there are no namespaces to process — avoids loading
|
||||
// config (and the local_ai.enabled guard) for an empty batch.
|
||||
if target_ns.is_empty() {
|
||||
tracing::info!(
|
||||
"[memory.learn] memory_learn_all: no namespaces to process, returning early"
|
||||
);
|
||||
return Ok(RpcOutcome::new(
|
||||
LearnAllResult {
|
||||
namespaces_processed: 0,
|
||||
results: vec![],
|
||||
},
|
||||
vec![],
|
||||
));
|
||||
}
|
||||
|
||||
let config = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|e| format!("load config: {e}"))?;
|
||||
|
||||
if !config.local_ai.enabled {
|
||||
return Err("memory_learn_all requires local_ai.enabled=true".to_string());
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(target_ns.len());
|
||||
for namespace in &target_ns {
|
||||
tracing::info!(
|
||||
"[memory.learn] running summarization for namespace='{}'",
|
||||
namespace
|
||||
);
|
||||
let outcome =
|
||||
crate::openhuman::tree_summarizer::ops::tree_summarizer_run(&config, namespace).await;
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
tracing::info!("[memory.learn] namespace='{}' ok", namespace);
|
||||
results.push(NamespaceLearnResult {
|
||||
namespace: namespace.clone(),
|
||||
status: "ok".to_string(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[memory.learn] namespace='{}' error: {}", namespace, e);
|
||||
results.push(NamespaceLearnResult {
|
||||
namespace: namespace.clone(),
|
||||
status: "error".to_string(),
|
||||
error: Some(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let namespaces_processed = results.len();
|
||||
tracing::info!(
|
||||
"[memory.learn] memory_learn_all: done processed={} results={:?}",
|
||||
namespaces_processed,
|
||||
results
|
||||
.iter()
|
||||
.map(|r| (&r.namespace, &r.status))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
Ok(RpcOutcome::new(
|
||||
LearnAllResult {
|
||||
namespaces_processed,
|
||||
results,
|
||||
},
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! RPC operations for the memory system.
|
||||
//!
|
||||
//! This module implements the handlers for memory-related RPC requests, including
|
||||
//! document management, semantic queries, key-value storage, and knowledge graph
|
||||
//! operations. It manages the active memory client and provides utility functions
|
||||
//! for formatting and filtering memory results.
|
||||
//!
|
||||
//! Internally the implementation is split across submodules by RPC family:
|
||||
//!
|
||||
//! - [`envelope`] — `ApiEnvelope`/`ApiError` wrapping helpers shared by every
|
||||
//! envelope-style handler.
|
||||
//! - [`helpers`] — formatting, default constants, path validators, and the
|
||||
//! active memory-client lookup.
|
||||
//! - [`documents`] — document/namespace direct API and the envelope-style
|
||||
//! façade (`memory_init`, `memory_list_documents`, `memory_query_namespace`,
|
||||
//! recall_*).
|
||||
//! - [`kv_graph`] — key-value and knowledge-graph handlers.
|
||||
//! - [`sync`] — `memory_sync_*` and `memory_ingestion_status`.
|
||||
//! - [`learn`] — `memory_learn_all`.
|
||||
//! - [`files`] — `ai_*_memory_file` handlers (use `tokio::fs`).
|
||||
|
||||
pub mod documents;
|
||||
pub mod envelope;
|
||||
pub mod files;
|
||||
pub mod helpers;
|
||||
pub mod kv_graph;
|
||||
pub mod learn;
|
||||
pub mod sync;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports preserving the previous flat `memory::ops::*` surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use documents::{
|
||||
clear_namespace, context_query, context_recall, doc_delete, doc_ingest, doc_list, doc_put,
|
||||
memory_delete_document, memory_init, memory_list_documents, memory_list_namespaces,
|
||||
memory_query_namespace, memory_recall_context, memory_recall_memories, namespace_list,
|
||||
ClearNamespaceParams, ClearNamespaceResult, DeleteDocParams, IngestDocParams,
|
||||
NamespaceOnlyParams, PutDocParams, PutDocResult, QueryNamespaceParams, RecallNamespaceParams,
|
||||
};
|
||||
pub use files::{ai_list_memory_files, ai_read_memory_file, ai_write_memory_file};
|
||||
pub use kv_graph::{
|
||||
graph_query, graph_upsert, kv_delete, kv_get, kv_list_namespace, kv_set, GraphQueryParams,
|
||||
GraphUpsertParams, KvGetDeleteParams, KvSetParams,
|
||||
};
|
||||
pub use learn::{memory_learn_all, LearnAllParams, LearnAllResult, NamespaceLearnResult};
|
||||
pub use sync::{
|
||||
memory_ingestion_status, memory_sync_all, memory_sync_channel, IngestionStatusResult,
|
||||
SyncAllResult, SyncChannelParams, SyncChannelResult,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test-only re-exports — keep the existing `ops_tests.rs` happy without
|
||||
// changing the test file. The tests reference private helpers via `super::*`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use envelope::{error_envelope, memory_counts, memory_request_id};
|
||||
#[cfg(test)]
|
||||
pub(crate) use helpers::{
|
||||
build_retrieval_context, chunk_metadata, default_category, default_priority,
|
||||
default_source_type, extract_entity_type, filter_hits_by_document_ids,
|
||||
format_llm_context_message, maybe_retrieval_context, memory_kind_label, relation_identity,
|
||||
relation_metadata, timestamp_to_rfc3339, validate_memory_relative_path,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../ops_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Memory-sync RPC handlers and ingestion-status reporting.
|
||||
//!
|
||||
//! Sync RPCs publish `DomainEvent::MemorySyncRequested` on the global event
|
||||
//! bus — they are fire-and-forget hooks for future ingestion subscribers.
|
||||
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
/// Parameters for `memory_sync_channel`.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SyncChannelParams {
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
/// Result returned by `memory_sync_channel`.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SyncChannelResult {
|
||||
pub requested: bool,
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
/// Result returned by `memory_sync_all`.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SyncAllResult {
|
||||
pub requested: bool,
|
||||
}
|
||||
|
||||
/// Result returned by `memory_ingestion_status`. Mirrors
|
||||
/// [`crate::openhuman::memory::IngestionStatusSnapshot`] but is the public RPC
|
||||
/// shape — the indirection keeps internal renames from breaking the wire
|
||||
/// contract.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct IngestionStatusResult {
|
||||
pub running: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_document_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_namespace: Option<String>,
|
||||
pub queue_depth: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_completed_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_document_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_success: Option<bool>,
|
||||
}
|
||||
|
||||
/// Request a memory sync for a specific channel.
|
||||
///
|
||||
/// Ingestion in OpenHuman is listener/webhook-driven — there is no per-provider
|
||||
/// pull mechanism yet. This RPC publishes `DomainEvent::MemorySyncRequested` so
|
||||
/// that future ingestion subscribers can react to an explicit pull request.
|
||||
/// The event is fire-and-forget; the caller receives confirmation that the
|
||||
/// request was published, not that ingestion ran.
|
||||
pub async fn memory_sync_channel(
|
||||
params: SyncChannelParams,
|
||||
) -> Result<RpcOutcome<SyncChannelResult>, String> {
|
||||
// `channel_id` is a user/context identifier — keep it out of normal logs.
|
||||
tracing::info!("[memory.sync] memory_sync_channel: entry");
|
||||
crate::core::event_bus::publish_global(
|
||||
crate::core::event_bus::DomainEvent::MemorySyncRequested {
|
||||
channel_id: Some(params.channel_id.clone()),
|
||||
},
|
||||
);
|
||||
tracing::debug!("[memory.sync] memory_sync_channel: MemorySyncRequested published");
|
||||
Ok(RpcOutcome::new(
|
||||
SyncChannelResult {
|
||||
requested: true,
|
||||
channel_id: params.channel_id,
|
||||
},
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
|
||||
/// Request a memory sync for all channels.
|
||||
///
|
||||
/// Publishes `DomainEvent::MemorySyncRequested { channel_id: None }` on the
|
||||
/// global event bus. No consumers exist yet — this is a hook for future
|
||||
/// ingestion subscribers.
|
||||
pub async fn memory_sync_all() -> Result<RpcOutcome<SyncAllResult>, String> {
|
||||
tracing::info!("[memory.sync] memory_sync_all: entry");
|
||||
crate::core::event_bus::publish_global(
|
||||
crate::core::event_bus::DomainEvent::MemorySyncRequested { channel_id: None },
|
||||
);
|
||||
tracing::debug!("[memory.sync] memory_sync_all: MemorySyncRequested(all) published");
|
||||
Ok(RpcOutcome::new(SyncAllResult { requested: true }, vec![]))
|
||||
}
|
||||
|
||||
/// Returns the current memory-ingestion status: whether a job is running, the
|
||||
/// in-flight document, queue depth, and the most recent completion. Read-only,
|
||||
/// safe to poll.
|
||||
pub async fn memory_ingestion_status() -> Result<RpcOutcome<IngestionStatusResult>, String> {
|
||||
let snapshot = match crate::openhuman::memory::global::client_if_ready() {
|
||||
Some(c) => c.ingestion_state().snapshot(),
|
||||
// Memory not yet initialised — report idle, no in-flight job.
|
||||
None => Default::default(),
|
||||
};
|
||||
Ok(RpcOutcome::new(
|
||||
IngestionStatusResult {
|
||||
running: snapshot.running,
|
||||
current_document_id: snapshot.current_document_id,
|
||||
current_title: snapshot.current_title,
|
||||
current_namespace: snapshot.current_namespace,
|
||||
queue_depth: snapshot.queue_depth,
|
||||
last_completed_at: snapshot.last_completed_at,
|
||||
last_document_id: snapshot.last_document_id,
|
||||
last_success: snapshot.last_success,
|
||||
},
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Unit tests for the memory `ops` helpers (retrieval context construction,
|
||||
//! hit filtering, and LLM context message formatting).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{build_retrieval_context, filter_hits_by_document_ids, format_llm_context_message};
|
||||
@@ -302,7 +305,10 @@ fn default_constants_are_stable() {
|
||||
|
||||
#[test]
|
||||
fn validate_memory_relative_path_rejects_empty_absolute_and_traversal() {
|
||||
assert!(validate_memory_relative_path("").is_err());
|
||||
// Empty string is now allowed: it refers to the memory root
|
||||
// (`<workspace>/memory`) since the file-based RPCs resolve everything
|
||||
// relative to that directory rather than the workspace root.
|
||||
assert!(validate_memory_relative_path("").is_ok());
|
||||
assert!(validate_memory_relative_path("/etc/passwd").is_err());
|
||||
assert!(validate_memory_relative_path("../secrets").is_err());
|
||||
assert!(validate_memory_relative_path("ok/subdir/file.md").is_ok());
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
//! # Response Cache
|
||||
//!
|
||||
//! Avoid burning tokens and reduce latency on repeated LLM prompts by caching
|
||||
//! responses.
|
||||
//!
|
||||
//! Stores LLM responses in a separate SQLite table keyed by a SHA-256 hash of
|
||||
//! `(model, system_prompt, user_prompt)`. Entries expire after a
|
||||
//! configurable TTL (Time To Live). The cache is optional and disabled by
|
||||
//! default — users opt in via `[memory] response_cache_enabled = true`.
|
||||
//!
|
||||
//! The cache is stored in `response_cache.db` within the workspace's `memory`
|
||||
//! directory. This separation from the main `brain.db` allows the cache to be
|
||||
//! cleared or deleted without affecting permanent memories.
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration, Local};
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::{params, Connection};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Response cache backed by a dedicated SQLite database.
|
||||
///
|
||||
/// Handles storage, retrieval, and eviction (TTL and LRU) of LLM responses.
|
||||
pub struct ResponseCache {
|
||||
/// Thread-safe connection to the SQLite database.
|
||||
conn: Mutex<Connection>,
|
||||
/// Path to the SQLite database file.
|
||||
#[allow(dead_code)]
|
||||
db_path: PathBuf,
|
||||
/// Time-to-live for cache entries in minutes.
|
||||
ttl_minutes: i64,
|
||||
/// Maximum number of entries to keep in the cache.
|
||||
max_entries: usize,
|
||||
}
|
||||
|
||||
impl ResponseCache {
|
||||
/// Open (or create) the response cache database.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `workspace_dir` - Path to the project workspace.
|
||||
/// * `ttl_minutes` - How long each entry should remain valid.
|
||||
/// * `max_entries` - The maximum number of entries to store before LRU eviction.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database directory cannot be created or if the
|
||||
/// SQLite connection fails to open.
|
||||
pub fn new(workspace_dir: &Path, ttl_minutes: u32, max_entries: usize) -> Result<Self> {
|
||||
let db_dir = workspace_dir.join("memory");
|
||||
std::fs::create_dir_all(&db_dir)?;
|
||||
let db_path = db_dir.join("response_cache.db");
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
|
||||
// Performance tuning for SQLite.
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA temp_store = MEMORY;",
|
||||
)?;
|
||||
|
||||
// Initialize the cache table and indices.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS response_cache (
|
||||
prompt_hash TEXT PRIMARY KEY,
|
||||
model TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
accessed_at TEXT NOT NULL,
|
||||
hit_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rc_accessed ON response_cache(accessed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rc_created ON response_cache(created_at);",
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
db_path,
|
||||
ttl_minutes: i64::from(ttl_minutes),
|
||||
max_entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a deterministic cache key from model + system prompt + user prompt.
|
||||
///
|
||||
/// Uses SHA-256 to create a unique identifier for the specific combination
|
||||
/// of model and inputs.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `model` - The name of the LLM model (e.g., "gpt-4o").
|
||||
/// * `system_prompt` - The optional system instruction prompt.
|
||||
/// * `user_prompt` - The main user query/prompt.
|
||||
pub fn cache_key(model: &str, system_prompt: Option<&str>, user_prompt: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(model.as_bytes());
|
||||
hasher.update(b"|");
|
||||
if let Some(sys) = system_prompt {
|
||||
hasher.update(sys.as_bytes());
|
||||
}
|
||||
hasher.update(b"|");
|
||||
hasher.update(user_prompt.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
format!("{:064x}", hash)
|
||||
}
|
||||
|
||||
/// Look up a cached response. Returns `None` on miss or expired entry.
|
||||
///
|
||||
/// If a match is found, it updates the `accessed_at` timestamp and
|
||||
/// increments the `hit_count`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - The SHA-256 cache key generated by [`Self::cache_key`].
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
let now = Local::now();
|
||||
let cutoff = (now - Duration::minutes(self.ttl_minutes)).to_rfc3339();
|
||||
|
||||
// Retrieve the response only if it hasn't expired according to the TTL.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT response FROM response_cache
|
||||
WHERE prompt_hash = ?1 AND created_at > ?2",
|
||||
)?;
|
||||
|
||||
let result: Option<String> = stmt.query_row(params![key, cutoff], |row| row.get(0)).ok();
|
||||
|
||||
if result.is_some() {
|
||||
// Bump hit count and accessed_at for LRU tracking and statistics.
|
||||
let now_str = now.to_rfc3339();
|
||||
conn.execute(
|
||||
"UPDATE response_cache
|
||||
SET accessed_at = ?1, hit_count = hit_count + 1
|
||||
WHERE prompt_hash = ?2",
|
||||
params![now_str, key],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Store a response in the cache.
|
||||
///
|
||||
/// This also triggers background maintenance:
|
||||
/// 1. Evicts entries that have exceeded the TTL.
|
||||
/// 2. Performs LRU (Least Recently Used) eviction if the cache size
|
||||
/// exceeds `max_entries`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - The unique SHA-256 key for this prompt.
|
||||
/// * `model` - The model that generated the response.
|
||||
/// * `response` - The generated text response.
|
||||
/// * `token_count` - The number of tokens used (for savings tracking).
|
||||
pub fn put(&self, key: &str, model: &str, response: &str, token_count: u32) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
let now = Local::now().to_rfc3339();
|
||||
|
||||
// Insert the new entry or replace an existing one.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO response_cache
|
||||
(prompt_hash, model, response, token_count, created_at, accessed_at, hit_count)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)",
|
||||
params![key, model, response, token_count, now, now],
|
||||
)?;
|
||||
|
||||
// 1. Evict expired entries (TTL-based)
|
||||
let cutoff = (Local::now() - Duration::minutes(self.ttl_minutes)).to_rfc3339();
|
||||
conn.execute(
|
||||
"DELETE FROM response_cache WHERE created_at <= ?1",
|
||||
params![cutoff],
|
||||
)?;
|
||||
|
||||
// 2. LRU eviction (Size-based)
|
||||
// If we have more than max_entries, delete the oldest entries based on accessed_at.
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
let max = self.max_entries as i64;
|
||||
conn.execute(
|
||||
"DELETE FROM response_cache WHERE prompt_hash IN (
|
||||
SELECT prompt_hash FROM response_cache
|
||||
ORDER BY accessed_at ASC
|
||||
LIMIT MAX(0, (SELECT COUNT(*) FROM response_cache) - ?1)
|
||||
)",
|
||||
params![max],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return cache statistics.
|
||||
///
|
||||
/// Returns a tuple of `(total_entries, total_hits, total_tokens_saved)`.
|
||||
pub fn stats(&self) -> Result<(usize, u64, u64)> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
// Count total active entries in the cache.
|
||||
let count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM response_cache", [], |row| row.get(0))?;
|
||||
|
||||
// Sum up total cache hits.
|
||||
let hits: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(hit_count), 0) FROM response_cache",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// Calculate total tokens saved (token_count * hit_count for each entry).
|
||||
let tokens_saved: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(token_count * hit_count), 0) FROM response_cache",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
Ok((count as usize, hits as u64, tokens_saved as u64))
|
||||
}
|
||||
|
||||
/// Wipe the entire cache.
|
||||
///
|
||||
/// Returns the number of entries deleted.
|
||||
pub fn clear(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
let affected = conn.execute("DELETE FROM response_cache", [])?;
|
||||
Ok(affected)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// ... (tests remain unchanged)
|
||||
@@ -565,9 +565,11 @@ pub struct WriteMemoryFileResponse {
|
||||
pub bytes_written: usize,
|
||||
}
|
||||
|
||||
/// Default directory for memory operations.
|
||||
/// Default directory for memory operations. Empty string means the memory
|
||||
/// root itself (`<workspace>/memory`); the file-based memory RPCs resolve all
|
||||
/// relative paths under that directory.
|
||||
fn default_memory_relative_dir() -> String {
|
||||
"memory".to_string()
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Unit tests for the memory RPC request/response models, covering
|
||||
//! deserialization compatibility and limit-resolution helpers.
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -231,5 +234,6 @@ fn api_envelope_round_trip_preserves_data_and_meta() {
|
||||
|
||||
#[test]
|
||||
fn default_memory_relative_dir_is_memory() {
|
||||
assert_eq!(default_memory_relative_dir(), "memory");
|
||||
// Empty string == the memory root itself (`<workspace>/memory`).
|
||||
assert_eq!(default_memory_relative_dir(), "");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,537 @@
|
||||
//! Schemas and handlers for document, namespace, recall, and clear-namespace
|
||||
//! RPC methods.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::memory::rpc::{
|
||||
self, ClearNamespaceParams, DeleteDocParams, IngestDocParams, NamespaceOnlyParams,
|
||||
PutDocParams, QueryNamespaceParams, RecallNamespaceParams,
|
||||
};
|
||||
use crate::openhuman::memory::{
|
||||
DeleteDocumentRequest, EmptyRequest, ListDocumentsRequest, MemoryInitRequest,
|
||||
QueryNamespaceRequest, RecallContextRequest, RecallMemoriesRequest,
|
||||
};
|
||||
|
||||
use super::{parse_params, to_json};
|
||||
|
||||
pub(super) const FUNCTIONS: &[&str] = &[
|
||||
"init",
|
||||
"list_documents",
|
||||
"list_namespaces",
|
||||
"delete_document",
|
||||
"query_namespace",
|
||||
"recall_context",
|
||||
"recall_memories",
|
||||
"namespace_list",
|
||||
"doc_put",
|
||||
"doc_ingest",
|
||||
"doc_list",
|
||||
"doc_delete",
|
||||
"context_query",
|
||||
"context_recall",
|
||||
"clear_namespace",
|
||||
];
|
||||
|
||||
pub(super) fn controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schema("init").unwrap(),
|
||||
handler: handle_init,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("list_documents").unwrap(),
|
||||
handler: handle_list_documents,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("list_namespaces").unwrap(),
|
||||
handler: handle_list_namespaces,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("delete_document").unwrap(),
|
||||
handler: handle_delete_document,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("query_namespace").unwrap(),
|
||||
handler: handle_query_namespace,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("recall_context").unwrap(),
|
||||
handler: handle_recall_context,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("recall_memories").unwrap(),
|
||||
handler: handle_recall_memories,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("namespace_list").unwrap(),
|
||||
handler: handle_namespace_list,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("doc_put").unwrap(),
|
||||
handler: handle_doc_put,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("doc_ingest").unwrap(),
|
||||
handler: handle_doc_ingest,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("doc_list").unwrap(),
|
||||
handler: handle_doc_list,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("doc_delete").unwrap(),
|
||||
handler: handle_doc_delete,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("context_query").unwrap(),
|
||||
handler: handle_context_query,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("context_recall").unwrap(),
|
||||
handler: handle_context_recall,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("clear_namespace").unwrap(),
|
||||
handler: handle_clear_namespace,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn schema(function: &str) -> Option<ControllerSchema> {
|
||||
Some(match function {
|
||||
"init" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "init",
|
||||
description: "Initialise the local-only (SQLite) memory subsystem for the current workspace. The jwt_token parameter is accepted for backward compatibility but ignored — memory is entirely local.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "jwt_token",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Accepted for backward compatibility but ignored — memory is local-only. Remote sync is a future consideration.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with initialisation status, workspace and memory paths.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"list_documents" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "list_documents",
|
||||
description: "List documents stored in memory, optionally filtered by namespace.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Namespace to filter documents by.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with documents array and count.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"list_namespaces" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "list_namespaces",
|
||||
description: "List all namespaces that contain memory documents.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with namespaces array and count.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"delete_document" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "delete_document",
|
||||
description: "Delete a specific document from a namespace.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Namespace containing the document.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "document_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Identifier of the document to delete.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with deletion status.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"query_namespace" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "query_namespace",
|
||||
description: "Semantic query against a namespace with optional reference data.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Namespace to query.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "query",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Natural-language query string.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "include_references",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
|
||||
comment: "Whether to include entity/relation context in the response.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "document_ids",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
|
||||
TypeSchema::String,
|
||||
)))),
|
||||
comment: "Restrict results to these document IDs.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum number of results to return.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "max_chunks",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum number of chunks to return (alias for limit).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with retrieval context and LLM context message.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"recall_context" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "recall_context",
|
||||
description: "Recall contextual data from a namespace without a specific query.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Namespace to recall from.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "include_references",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
|
||||
comment: "Whether to include entity/relation context.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum number of results.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "max_chunks",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum number of chunks (alias for limit).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with retrieval context and LLM context message.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"recall_memories" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "recall_memories",
|
||||
description: "Recall memory items from a namespace with optional retention filtering.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Namespace to recall memories from.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "min_retention",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::F64)),
|
||||
comment: "Minimum retention score (forward-compat, currently ignored).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "as_of",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::F64)),
|
||||
comment: "Temporal recall timestamp (forward-compat, currently ignored).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum number of results.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "max_chunks",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum number of chunks (alias for limit).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "top_k",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Top-k override (alias for limit).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with recalled memory items.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"namespace_list" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "namespace_list",
|
||||
description: "List all namespaces in the unified memory store.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "namespaces",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
|
||||
comment: "Namespace names.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"doc_put" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "doc_put",
|
||||
description: "Upsert a document into a namespace.",
|
||||
inputs: vec![
|
||||
FieldSchema { name: "namespace", ty: TypeSchema::String, comment: "Target namespace.", required: true },
|
||||
FieldSchema { name: "key", ty: TypeSchema::String, comment: "Document key for upsert deduplication.", required: true },
|
||||
FieldSchema { name: "title", ty: TypeSchema::String, comment: "Human-readable title.", required: true },
|
||||
FieldSchema { name: "content", ty: TypeSchema::String, comment: "Document body content.", required: true },
|
||||
FieldSchema { name: "source_type", ty: TypeSchema::String, comment: "Source type label (default: \"doc\").", required: false },
|
||||
FieldSchema { name: "priority", ty: TypeSchema::String, comment: "Priority level (default: \"medium\").", required: false },
|
||||
FieldSchema { name: "tags", ty: TypeSchema::Array(Box::new(TypeSchema::String)), comment: "Tags for categorisation (default: []).", required: false },
|
||||
FieldSchema { name: "metadata", ty: TypeSchema::Json, comment: "Arbitrary metadata (default: {}).", required: false },
|
||||
FieldSchema { name: "category", ty: TypeSchema::String, comment: "Memory category (default: \"core\").", required: false },
|
||||
FieldSchema { name: "session_id", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Optional session ID for provenance tracking.", required: false },
|
||||
FieldSchema { name: "document_id", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Optional explicit document ID; generated if omitted.", required: false },
|
||||
],
|
||||
outputs: vec![FieldSchema { name: "document_id", ty: TypeSchema::String, comment: "ID of the upserted document.", required: true }],
|
||||
},
|
||||
"doc_ingest" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "doc_ingest",
|
||||
description: "Ingest a document with entity/relation extraction and chunk embedding.",
|
||||
inputs: vec![
|
||||
FieldSchema { name: "namespace", ty: TypeSchema::String, comment: "Target namespace.", required: true },
|
||||
FieldSchema { name: "key", ty: TypeSchema::String, comment: "Document key.", required: true },
|
||||
FieldSchema { name: "title", ty: TypeSchema::String, comment: "Human-readable title.", required: true },
|
||||
FieldSchema { name: "content", ty: TypeSchema::String, comment: "Document body content.", required: true },
|
||||
FieldSchema { name: "source_type", ty: TypeSchema::String, comment: "Source type label (default: \"doc\").", required: false },
|
||||
FieldSchema { name: "priority", ty: TypeSchema::String, comment: "Priority level (default: \"medium\").", required: false },
|
||||
FieldSchema { name: "tags", ty: TypeSchema::Array(Box::new(TypeSchema::String)), comment: "Tags for categorisation (default: []).", required: false },
|
||||
FieldSchema { name: "metadata", ty: TypeSchema::Json, comment: "Arbitrary metadata (default: {}).", required: false },
|
||||
FieldSchema { name: "category", ty: TypeSchema::String, comment: "Memory category (default: \"core\").", required: false },
|
||||
FieldSchema { name: "session_id", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Optional session ID.", required: false },
|
||||
FieldSchema { name: "document_id", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Optional explicit document ID.", required: false },
|
||||
FieldSchema { name: "config", ty: TypeSchema::Option(Box::new(TypeSchema::Json)), comment: "Optional ingestion configuration overrides.", required: false },
|
||||
],
|
||||
outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Json, comment: "Ingestion result with entity, relation and chunk counts.", required: true }],
|
||||
},
|
||||
"doc_list" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "doc_list",
|
||||
description: "List documents in the unified memory store, optionally by namespace.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional namespace filter.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Json, comment: "Document listing.", required: true }],
|
||||
},
|
||||
"doc_delete" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "doc_delete",
|
||||
description: "Delete a document from the unified memory store.",
|
||||
inputs: vec![
|
||||
FieldSchema { name: "namespace", ty: TypeSchema::String, comment: "Namespace containing the document.", required: true },
|
||||
FieldSchema { name: "document_id", ty: TypeSchema::String, comment: "Document identifier to delete.", required: true },
|
||||
],
|
||||
outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Json, comment: "Deletion result.", required: true }],
|
||||
},
|
||||
"context_query" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "context_query",
|
||||
description: "Query a namespace for contextual information.",
|
||||
inputs: vec![
|
||||
FieldSchema { name: "namespace", ty: TypeSchema::String, comment: "Namespace to query.", required: true },
|
||||
FieldSchema { name: "query", ty: TypeSchema::String, comment: "Natural-language query string.", required: true },
|
||||
FieldSchema { name: "limit", ty: TypeSchema::Option(Box::new(TypeSchema::U64)), comment: "Maximum number of results.", required: false },
|
||||
],
|
||||
outputs: vec![FieldSchema { name: "result", ty: TypeSchema::String, comment: "Contextual query result string.", required: true }],
|
||||
},
|
||||
"context_recall" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "context_recall",
|
||||
description: "Recall context from a namespace.",
|
||||
inputs: vec![
|
||||
FieldSchema { name: "namespace", ty: TypeSchema::String, comment: "Namespace to recall from.", required: true },
|
||||
FieldSchema { name: "limit", ty: TypeSchema::Option(Box::new(TypeSchema::U64)), comment: "Maximum number of results.", required: false },
|
||||
],
|
||||
outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Json, comment: "Recalled context (may be null if empty).", required: true }],
|
||||
},
|
||||
"clear_namespace" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "clear_namespace",
|
||||
description: "Delete all documents, vector chunks, KV entries, and graph relations for a namespace.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Namespace to clear completely.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![
|
||||
FieldSchema { name: "cleared", ty: TypeSchema::Bool, comment: "True when the namespace was cleared.", required: true },
|
||||
FieldSchema { name: "namespace", ty: TypeSchema::String, comment: "The namespace that was cleared.", required: true },
|
||||
],
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_init(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<MemoryInitRequest>(params)?;
|
||||
to_json(rpc::memory_init(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_documents(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<ListDocumentsRequest>(params)?;
|
||||
to_json(rpc::memory_list_documents(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_namespaces(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(rpc::memory_list_namespaces(EmptyRequest {}).await?) })
|
||||
}
|
||||
|
||||
fn handle_delete_document(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<DeleteDocumentRequest>(params)?;
|
||||
to_json(rpc::memory_delete_document(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_query_namespace(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<QueryNamespaceRequest>(params)?;
|
||||
to_json(rpc::memory_query_namespace(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_recall_context(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<RecallContextRequest>(params)?;
|
||||
to_json(rpc::memory_recall_context(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_recall_memories(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<RecallMemoriesRequest>(params)?;
|
||||
to_json(rpc::memory_recall_memories(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_namespace_list(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(rpc::namespace_list().await?) })
|
||||
}
|
||||
|
||||
fn handle_doc_put(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<PutDocParams>(params)?;
|
||||
to_json(rpc::doc_put(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_doc_ingest(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<IngestDocParams>(params)?;
|
||||
to_json(rpc::doc_ingest(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DocListParams {
|
||||
#[serde(default)]
|
||||
namespace: Option<String>,
|
||||
}
|
||||
|
||||
fn handle_doc_list(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
// Reject invalid `namespace` types (e.g. `123`, `["x"]`) instead of
|
||||
// silently coercing to `None` and returning an unscoped document list.
|
||||
let parsed: DocListParams = parse_params(params)?;
|
||||
let namespace = parsed
|
||||
.namespace
|
||||
.map(|namespace| NamespaceOnlyParams { namespace });
|
||||
to_json(rpc::doc_list(namespace).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_doc_delete(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<DeleteDocParams>(params)?;
|
||||
to_json(rpc::doc_delete(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_context_query(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<QueryNamespaceParams>(params)?;
|
||||
to_json(rpc::context_query(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_context_recall(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<RecallNamespaceParams>(params)?;
|
||||
to_json(rpc::context_recall(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_clear_namespace(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<ClearNamespaceParams>(params)?;
|
||||
to_json(rpc::clear_namespace(payload).await?)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Schemas and handlers for file-based memory RPC methods.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::memory::rpc;
|
||||
use crate::openhuman::memory::{
|
||||
ListMemoryFilesRequest, ReadMemoryFileRequest, WriteMemoryFileRequest,
|
||||
};
|
||||
|
||||
use super::{parse_params, to_json};
|
||||
|
||||
pub(super) const FUNCTIONS: &[&str] = &["list_files", "read_file", "write_file"];
|
||||
|
||||
pub(super) fn controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schema("list_files").unwrap(),
|
||||
handler: handle_list_files,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("read_file").unwrap(),
|
||||
handler: handle_read_file,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("write_file").unwrap(),
|
||||
handler: handle_write_file,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn schema(function: &str) -> Option<ControllerSchema> {
|
||||
Some(match function {
|
||||
"list_files" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "list_files",
|
||||
description: "List files in a memory directory.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "relative_dir",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Relative directory path under the workspace (default: \"memory\").",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with file listing.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"read_file" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "read_file",
|
||||
description: "Read the contents of a memory file.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "relative_path",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Relative path to the file under the workspace memory directory.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with file content.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"write_file" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "write_file",
|
||||
description: "Write content to a memory file.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "relative_path",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Relative path to the file under the workspace memory directory.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "content",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Content to write to the file.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with write confirmation and bytes written.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListFilesParams {
|
||||
#[serde(default)]
|
||||
relative_dir: Option<String>,
|
||||
}
|
||||
|
||||
fn handle_list_files(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
// Reject invalid `relative_dir` types (e.g. `123`, `["x"]`) instead of
|
||||
// silently defaulting and masking client errors.
|
||||
let parsed: ListFilesParams = parse_params(params)?;
|
||||
// Empty string == the memory root itself (`<workspace>/memory`).
|
||||
let relative_dir = parsed.relative_dir.unwrap_or_default();
|
||||
let payload = ListMemoryFilesRequest { relative_dir };
|
||||
to_json(rpc::ai_list_memory_files(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_read_file(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<ReadMemoryFileRequest>(params)?;
|
||||
to_json(rpc::ai_read_memory_file(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_write_file(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<WriteMemoryFileRequest>(params)?;
|
||||
to_json(rpc::ai_write_memory_file(payload).await?)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Schemas and handlers for key-value and knowledge-graph RPC methods.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::memory::rpc::{
|
||||
self, GraphQueryParams, GraphUpsertParams, KvGetDeleteParams, KvSetParams, NamespaceOnlyParams,
|
||||
};
|
||||
|
||||
use super::{parse_params, to_json};
|
||||
|
||||
pub(super) const FUNCTIONS: &[&str] = &[
|
||||
"kv_set",
|
||||
"kv_get",
|
||||
"kv_delete",
|
||||
"kv_list_namespace",
|
||||
"graph_upsert",
|
||||
"graph_query",
|
||||
];
|
||||
|
||||
pub(super) fn controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schema("kv_set").unwrap(),
|
||||
handler: handle_kv_set,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("kv_get").unwrap(),
|
||||
handler: handle_kv_get,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("kv_delete").unwrap(),
|
||||
handler: handle_kv_delete,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("kv_list_namespace").unwrap(),
|
||||
handler: handle_kv_list_namespace,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("graph_upsert").unwrap(),
|
||||
handler: handle_graph_upsert,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("graph_query").unwrap(),
|
||||
handler: handle_graph_query,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn schema(function: &str) -> Option<ControllerSchema> {
|
||||
Some(match function {
|
||||
"kv_set" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "kv_set",
|
||||
description: "Set a key-value pair in the memory store.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional namespace scope.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "key",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Key to set.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "value",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "JSON value to store.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the value was stored.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"kv_get" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "kv_get",
|
||||
description: "Get a value by key from the memory store.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional namespace scope.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "key",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Key to retrieve.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Stored value or null if not found.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"kv_delete" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "kv_delete",
|
||||
description: "Delete a key-value pair from the memory store.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional namespace scope.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "key",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Key to delete.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the key was deleted.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"kv_list_namespace" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "kv_list_namespace",
|
||||
description: "List all key-value entries in a namespace.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Namespace to list.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of key-value entries.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"graph_upsert" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "graph_upsert",
|
||||
description: "Upsert a relation triple in the knowledge graph.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional namespace scope.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "subject",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Subject entity of the relation.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "predicate",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Relation predicate.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "object",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Object entity of the relation.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "attrs",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Extra attributes on the relation (default: {}).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the relation was upserted.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"graph_query" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "graph_query",
|
||||
description: "Query relations from the knowledge graph.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespace",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional namespace scope.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "subject",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Filter by subject entity.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "predicate",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Filter by relation predicate.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of matching relation records.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_kv_set(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<KvSetParams>(params)?;
|
||||
to_json(rpc::kv_set(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_kv_get(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<KvGetDeleteParams>(params)?;
|
||||
to_json(rpc::kv_get(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_kv_delete(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<KvGetDeleteParams>(params)?;
|
||||
to_json(rpc::kv_delete(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_kv_list_namespace(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<NamespaceOnlyParams>(params)?;
|
||||
to_json(rpc::kv_list_namespace(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_graph_upsert(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<GraphUpsertParams>(params)?;
|
||||
to_json(rpc::graph_upsert(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_graph_query(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<GraphQueryParams>(params)?;
|
||||
to_json(rpc::graph_query(payload).await?)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Schema and handler for the `memory.learn_all` RPC method.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::memory::rpc::{self, LearnAllParams};
|
||||
|
||||
use super::{parse_params, to_json};
|
||||
|
||||
pub(super) const FUNCTIONS: &[&str] = &["learn_all"];
|
||||
|
||||
pub(super) fn controllers() -> Vec<RegisteredController> {
|
||||
vec![RegisteredController {
|
||||
schema: schema("learn_all").unwrap(),
|
||||
handler: handle_learn_all,
|
||||
}]
|
||||
}
|
||||
|
||||
pub(super) fn schema(function: &str) -> Option<ControllerSchema> {
|
||||
Some(match function {
|
||||
"learn_all" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "learn_all",
|
||||
description: "Run the tree summarizer over all memory namespaces (or a constrained subset). Processes namespaces sequentially; a failing namespace is recorded but does not abort the rest.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "namespaces",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
|
||||
comment: "Optional list of namespaces to constrain. Defaults to all namespaces.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "namespaces_processed",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Total number of namespaces processed.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "results",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Per-namespace outcomes: [{ namespace, status: 'ok'|'skipped'|'error', error? }].",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_learn_all(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<LearnAllParams>(params)?;
|
||||
to_json(rpc::memory_learn_all(payload).await?)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! RPC schemas and controller registration for the memory system.
|
||||
//!
|
||||
//! This module defines the metadata (schemas) for all memory-related RPC
|
||||
//! functions and registers their corresponding handlers. It serves as the
|
||||
//! bridge between the RPC system and the underlying memory operations.
|
||||
//!
|
||||
//! Internally the schemas are organised into family submodules that mirror
|
||||
//! [`crate::openhuman::memory::ops`]:
|
||||
//!
|
||||
//! - [`documents`] — doc/namespace/recall/clear schemas + handlers.
|
||||
//! - [`kv_graph`] — key-value and knowledge-graph schemas + handlers.
|
||||
//! - [`sync`] — `sync_channel`, `sync_all`, `ingestion_status`.
|
||||
//! - [`learn`] — `learn_all`.
|
||||
//! - [`files`] — file-based memory schemas + handlers.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::RegisteredController;
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
mod documents;
|
||||
mod files;
|
||||
mod kv_graph;
|
||||
mod learn;
|
||||
mod sync;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns all controller schemas for the memory system.
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(documents::FUNCTIONS.iter().map(|f| schemas(f)));
|
||||
out.extend(files::FUNCTIONS.iter().map(|f| schemas(f)));
|
||||
out.extend(kv_graph::FUNCTIONS.iter().map(|f| schemas(f)));
|
||||
out.extend(sync::FUNCTIONS.iter().map(|f| schemas(f)));
|
||||
out.extend(learn::FUNCTIONS.iter().map(|f| schemas(f)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns all registered controllers for the memory system, mapping schemas to handlers.
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(documents::controllers());
|
||||
out.extend(files::controllers());
|
||||
out.extend(kv_graph::controllers());
|
||||
out.extend(sync::controllers());
|
||||
out.extend(learn::controllers());
|
||||
out
|
||||
}
|
||||
|
||||
/// Defines the schema for a specific memory controller function.
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
if let Some(schema) = documents::schema(function) {
|
||||
return schema;
|
||||
}
|
||||
if let Some(schema) = files::schema(function) {
|
||||
return schema;
|
||||
}
|
||||
if let Some(schema) = kv_graph::schema(function) {
|
||||
return schema;
|
||||
}
|
||||
if let Some(schema) = sync::schema(function) {
|
||||
return schema;
|
||||
}
|
||||
if let Some(schema) = learn::schema(function) {
|
||||
return schema;
|
||||
}
|
||||
unknown_schema()
|
||||
}
|
||||
|
||||
fn unknown_schema() -> ControllerSchema {
|
||||
ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "unknown",
|
||||
description: "Unknown memory controller function.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "function",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Unknown function requested for schema lookup.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Lookup error details.",
|
||||
required: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers shared by every handler submodule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(super) fn parse_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../schemas_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Schemas and handlers for memory-sync and ingestion-status RPC methods.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::memory::rpc::{self, SyncChannelParams};
|
||||
|
||||
use super::{parse_params, to_json};
|
||||
|
||||
pub(super) const FUNCTIONS: &[&str] = &["sync_channel", "sync_all", "ingestion_status"];
|
||||
|
||||
pub(super) fn controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schema("sync_channel").unwrap(),
|
||||
handler: handle_sync_channel,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("sync_all").unwrap(),
|
||||
handler: handle_sync_all,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schema("ingestion_status").unwrap(),
|
||||
handler: handle_ingestion_status,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn schema(function: &str) -> Option<ControllerSchema> {
|
||||
Some(match function {
|
||||
"sync_channel" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "sync_channel",
|
||||
description: "Request a memory sync for a specific channel. Publishes MemorySyncRequested on the event bus. No ingestion consumers exist yet; this is a hook for future pull-based subscribers.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "channel_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "ID of the channel to sync.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![
|
||||
FieldSchema { name: "requested", ty: TypeSchema::Bool, comment: "Always true when the event was published.", required: true },
|
||||
FieldSchema { name: "channel_id", ty: TypeSchema::String, comment: "Echo of the channel_id that was requested.", required: true },
|
||||
],
|
||||
},
|
||||
"sync_all" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "sync_all",
|
||||
description: "Request a memory sync for all channels. Publishes MemorySyncRequested { channel_id: None } on the event bus.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema { name: "requested", ty: TypeSchema::Bool, comment: "Always true when the event was published.", required: true }],
|
||||
},
|
||||
"ingestion_status" => ControllerSchema {
|
||||
namespace: "memory",
|
||||
function: "ingestion_status",
|
||||
description: "Returns the current memory-ingestion status (whether a job is running, the in-flight document, queue depth, and most recent completion). Safe to poll.",
|
||||
inputs: vec![],
|
||||
outputs: vec![
|
||||
FieldSchema { name: "running", ty: TypeSchema::Bool, comment: "True while an ingestion job is running on the local extraction LLM.", required: true },
|
||||
FieldSchema { name: "current_document_id", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Document id of the in-flight job.", required: false },
|
||||
FieldSchema { name: "current_title", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Title of the in-flight document.", required: false },
|
||||
FieldSchema { name: "current_namespace", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Namespace of the in-flight document.", required: false },
|
||||
FieldSchema { name: "queue_depth", ty: TypeSchema::U64, comment: "Number of jobs waiting behind the current one.", required: true },
|
||||
FieldSchema { name: "last_completed_at", ty: TypeSchema::Option(Box::new(TypeSchema::I64)), comment: "Unix-ms timestamp of the most recent completion.", required: false },
|
||||
FieldSchema { name: "last_document_id", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Document id of the most recent completed job.", required: false },
|
||||
FieldSchema { name: "last_success", ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), comment: "Whether the most recent job succeeded.", required: false },
|
||||
],
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_sync_channel(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = parse_params::<SyncChannelParams>(params)?;
|
||||
to_json(rpc::memory_sync_channel(payload).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_sync_all(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(rpc::memory_sync_all().await?) })
|
||||
}
|
||||
|
||||
fn handle_ingestion_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(rpc::memory_ingestion_status().await?) })
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Unit tests for memory RPC schema registration and parameter parsing,
|
||||
//! validating that every advertised function name has a registered controller.
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# slack_ingestion
|
||||
|
||||
Slack-specific plumbing that feeds the memory tree. Auth and scheduling
|
||||
live elsewhere — OAuth via Composio, periodic ticks via
|
||||
`composio::periodic` — this folder converts Slack messages into
|
||||
`tree::ingest::ingest_chat` calls.
|
||||
|
||||
## Files
|
||||
|
||||
- **`mod.rs`** — module re-exports
|
||||
(`all_slack_ingestion_controller_schemas`,
|
||||
`all_slack_ingestion_registered_controllers`).
|
||||
- **`types.rs`** — internal `SlackMessage`, `SlackChannel`, `Bucket`.
|
||||
Decoupled from raw Slack Web API payloads; the Composio provider
|
||||
parses into these.
|
||||
- **`bucketer.rs`** — 6-hour UTC-aligned windowing
|
||||
(`bucket_start_for`, `bucket_end_for`), grace-period extraction
|
||||
(`split_closed`), and stable `source_id_for` generation. Bucket
|
||||
width is a schema constant — changing it invalidates all existing
|
||||
source IDs.
|
||||
- **`ops.rs`** — bucket-to-`ChatBatch` canonicalisation
|
||||
(`bucket_to_chat_batch`) and the `ingest_bucket` wrapper around
|
||||
`tree::ingest::ingest_chat`. Free of HTTP and timers so it is
|
||||
easy to unit-test.
|
||||
- **`rpc.rs`** — handler bodies for `slack_memory_sync_trigger`
|
||||
(manual sync run) and `slack_memory_sync_status` (per-connection
|
||||
cursor + budget snapshot).
|
||||
- **`schemas.rs`** — controller schemas + dispatch wiring under the
|
||||
`slack_memory` JSON-RPC namespace.
|
||||
|
||||
## Where it fits
|
||||
|
||||
The Composio-backed `SlackProvider` (in `composio/providers/slack/`)
|
||||
calls into `bucketer` + `ops` to produce closed-bucket
|
||||
`ChatBatch`es; those flow into `memory::tree::ingest::ingest_chat` and
|
||||
become source-tree leaves. State (cursors, dedup set, daily budget)
|
||||
lives in the Composio sync-state KV — not here.
|
||||
@@ -9,7 +9,7 @@
|
||||
//! ## Why closed-bucket-only?
|
||||
//!
|
||||
//! Each ingested chunk becomes a *leaf* in the source tree via
|
||||
//! `append_leaf` (see `memory::tree::source_tree::bucket_seal`). Leaves
|
||||
//! `append_leaf` (see `memory::tree::tree_source::bucket_seal`). Leaves
|
||||
//! participate in a token-budget seal cascade — once appended, they
|
||||
//! cannot be cleanly retracted from already-sealed summary nodes
|
||||
//! upstream. So we only ingest a bucket once, *after* it has closed
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct SyncTriggerRequest {
|
||||
pub connection_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `slack_memory_sync_trigger` — per-connection [`SyncOutcome`]s
|
||||
/// plus aggregate counters.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SyncTriggerResponse {
|
||||
pub outcomes: Vec<SyncOutcome>,
|
||||
@@ -105,14 +107,18 @@ pub async fn sync_trigger_rpc(
|
||||
))
|
||||
}
|
||||
|
||||
/// Request body for `slack_memory_sync_status` — no parameters.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct SyncStatusRequest {}
|
||||
|
||||
/// Response body for `slack_memory_sync_status` — one row per active
|
||||
/// Slack Composio connection.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SyncStatusResponse {
|
||||
pub connections: Vec<ConnectionStatus>,
|
||||
}
|
||||
|
||||
/// Per-connection sync state snapshot pulled from the Composio sync-state KV.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ConnectionStatus {
|
||||
pub connection_id: String,
|
||||
|
||||
@@ -18,10 +18,12 @@ use crate::rpc::RpcOutcome;
|
||||
|
||||
const NAMESPACE: &str = "slack_memory";
|
||||
|
||||
/// Returns every schema published by the Slack-ingestion namespace.
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![schemas("sync_trigger"), schemas("sync_status")]
|
||||
}
|
||||
|
||||
/// Returns every controller (schema + handler pair) for the Slack-ingestion namespace.
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
@@ -35,6 +37,7 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Build the [`ControllerSchema`] for one named function in this namespace.
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"sync_trigger" => ControllerSchema {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Memory store
|
||||
|
||||
Storage backend for the memory subsystem. Houses the SQLite + FTS5 + vector + graph implementation (`UnifiedMemory`), the async client handle used by RPC controllers (`MemoryClient`), the `Memory` trait impl bridging both, and the factory functions used to bootstrap a memory instance.
|
||||
|
||||
## Files
|
||||
|
||||
- **`mod.rs`** — module root; re-exports `UnifiedMemory`, `MemoryClient`, factory functions, and the public types from `types.rs`.
|
||||
- **`types.rs`** — public input/output structs (`NamespaceDocumentInput`, `NamespaceMemoryHit`, `NamespaceRetrievalContext`, `RetrievalScoreBreakdown`, `MemoryItemKind`, `StoredMemoryDocument`, `MemoryKvRecord`, `GraphRelationRecord`) plus the `GLOBAL_NAMESPACE` sentinel.
|
||||
- **`client.rs`** — `MemoryClient` / `MemoryClientRef` / `MemoryState`. Async wrapper around `UnifiedMemory` that owns the singleton ingestion queue and exposes the surface called by RPC handlers (`put_doc`, `ingest_doc`, `query_namespace`, `recall_namespace_*`, `kv_*`, `graph_*`, skill-sync helpers). Always local — no remote sync.
|
||||
- **`client_tests.rs`** — coverage for the client-facing storage and graph round-trips against a fresh temp workspace.
|
||||
- **`factories.rs`** — `create_memory*` constructors that select the embedding provider from `MemoryConfig` and instantiate `UnifiedMemory`. `effective_memory_backend_name` always reports `"namespace"`.
|
||||
- **`memory_trait.rs`** — `impl Memory for UnifiedMemory`, mapping the generic trait surface (`store`, `recall`, `get`, `list`, `forget`, `namespace_summaries`) onto the unified store. Includes namespace normalisation and episodic-session augmentation.
|
||||
- **`unified/`** — the SQLite implementation, broken into per-table submodules. See `unified/README.md`.
|
||||
|
||||
## How it fits
|
||||
|
||||
Callers (RPC controllers, the agent harness, learning pipelines) interact with `MemoryClient`. The client delegates persistence to `UnifiedMemory` and offloads heavier work (chunk + embed + graph extraction) to the singleton `IngestionQueue` defined in `../ingestion/`. Generic consumers that just need `Memory` trait behaviour go through `memory_trait.rs`.
|
||||
@@ -11,12 +11,12 @@ use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::openhuman::memory::embeddings::{self, EmbeddingProvider};
|
||||
use crate::openhuman::embeddings::{self, EmbeddingProvider};
|
||||
use crate::openhuman::memory::ingestion::queue as ingestion_queue;
|
||||
use crate::openhuman::memory::ingestion::{
|
||||
MemoryIngestionConfig, MemoryIngestionRequest, MemoryIngestionResult,
|
||||
IngestionJob, IngestionQueue, IngestionState, MemoryIngestionConfig, MemoryIngestionRequest,
|
||||
MemoryIngestionResult,
|
||||
};
|
||||
use crate::openhuman::memory::ingestion_queue::{self, IngestionJob, IngestionQueue};
|
||||
use crate::openhuman::memory::ingestion_state::IngestionState;
|
||||
use crate::openhuman::memory::store::types::{
|
||||
NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Tests for `MemoryClient` — exercise the sync storage surface (upsert, list,
|
||||
//! kv, graph) against a fresh temp workspace.
|
||||
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::openhuman::config::{EmbeddingRouteConfig, MemoryConfig, StorageProviderConfig};
|
||||
use crate::openhuman::memory::embeddings::{self, EmbeddingProvider};
|
||||
use crate::openhuman::embeddings::{self, EmbeddingProvider};
|
||||
use crate::openhuman::memory::store::unified::UnifiedMemory;
|
||||
use crate::openhuman::memory::traits::Memory;
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ impl Memory for UnifiedMemory {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::memory::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(crate) const GLOBAL_NAMESPACE: &str = "global";
|
||||
|
||||
/// Input payload for upserting a namespace-scoped memory document.
|
||||
///
|
||||
/// Used by `MemoryClient::put_doc` and the ingestion pipeline. `document_id`
|
||||
/// is optional — when omitted, an existing row keyed by `(namespace, key)` is
|
||||
/// reused, otherwise a new id is generated.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NamespaceDocumentInput {
|
||||
pub namespace: String,
|
||||
@@ -23,6 +28,7 @@ pub struct NamespaceDocumentInput {
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
/// One ranked retrieval result for a namespace text query.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NamespaceQueryResult {
|
||||
pub key: String,
|
||||
@@ -32,6 +38,7 @@ pub struct NamespaceQueryResult {
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
/// Discriminator for the kind of stored memory item a hit refers to.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryItemKind {
|
||||
@@ -41,6 +48,8 @@ pub enum MemoryItemKind {
|
||||
Event,
|
||||
}
|
||||
|
||||
/// Persisted form of a memory document as stored in `memory_docs`,
|
||||
/// including timestamps and the markdown sidecar path.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredMemoryDocument {
|
||||
pub document_id: String,
|
||||
@@ -59,6 +68,7 @@ pub struct StoredMemoryDocument {
|
||||
pub markdown_rel_path: String,
|
||||
}
|
||||
|
||||
/// A single KV row, namespace-scoped or global (when `namespace` is `None`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryKvRecord {
|
||||
pub namespace: Option<String>,
|
||||
@@ -67,6 +77,10 @@ pub struct MemoryKvRecord {
|
||||
pub updated_at: f64,
|
||||
}
|
||||
|
||||
/// A graph edge (subject — predicate → object) plus accumulated evidence.
|
||||
///
|
||||
/// `document_ids` and `chunk_ids` track every source that contributed to this
|
||||
/// relation; `evidence_count` is the merged count after de-duplication.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphRelationRecord {
|
||||
pub namespace: Option<String>,
|
||||
@@ -81,6 +95,8 @@ pub struct GraphRelationRecord {
|
||||
pub chunk_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Per-signal contribution to a hit's final score, surfaced for debugging
|
||||
/// and UI ranking explainers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RetrievalScoreBreakdown {
|
||||
pub keyword_relevance: f64,
|
||||
@@ -91,6 +107,8 @@ pub struct RetrievalScoreBreakdown {
|
||||
pub final_score: f64,
|
||||
}
|
||||
|
||||
/// A single ranked retrieval hit returned from `query_namespace_hits` /
|
||||
/// `recall_namespace_memories`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NamespaceMemoryHit {
|
||||
pub id: String,
|
||||
@@ -112,6 +130,8 @@ pub struct NamespaceMemoryHit {
|
||||
pub supporting_relations: Vec<GraphRelationRecord>,
|
||||
}
|
||||
|
||||
/// Aggregated retrieval result for a namespace: rendered context text plus
|
||||
/// the underlying hits.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NamespaceRetrievalContext {
|
||||
pub namespace: String,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Unified memory store
|
||||
|
||||
SQLite-backed implementation of the memory store. One `UnifiedMemory` struct owns a WAL-mode connection plus the on-disk markdown sidecar tree and vector storage path; the rest of this directory adds capabilities to it via per-domain `impl` blocks.
|
||||
|
||||
## Files
|
||||
|
||||
- **`mod.rs`** — declares the `UnifiedMemory` struct (connection + paths + embedder) and wires the submodules.
|
||||
- **`init.rs`** — constructor, `CREATE TABLE` bootstrap (docs, kv, graph, vector chunks, episodic FTS5, segments, events, profile), idempotent legacy-namespace migrations, plus path / namespace helpers (`sanitize_namespace`, `now_ts`, `namespace_dir`).
|
||||
- **`documents.rs`** — `memory_docs` CRUD: `upsert_document` (chunks + embeds + writes markdown sidecar), `upsert_document_metadata_only` (light path), `list_documents`, `list_namespaces`, `delete_document`, `clear_namespace`.
|
||||
- **`kv.rs`** — global and namespace-scoped get/set/delete/list against `kv_global` / `kv_namespace`.
|
||||
- **`graph.rs`** — `graph_namespace` / `graph_global` upserts with attribute merging and evidence accumulation, plus namespace / global / cross-namespace queries and document-scoped relation removal.
|
||||
- **`query.rs`** — hybrid retrieval. Combines graph relevance, vector similarity, keyword overlap, episodic signal and freshness; exposes `query_namespace_*` (with query) and `recall_namespace_*` (query-less) entry points used by `MemoryClient`.
|
||||
- **`helpers.rs`** — shared utilities: f32-vector byte codecs, cosine similarity, markdown chunking, text/graph normalisation, JSON attribute merging, recency scoring.
|
||||
- **`fts5.rs`** — FTS5 episodic memory (`episodic_log` + `episodic_fts`). `EpisodicEntry` plus `episodic_insert` / `episodic_search` / `episodic_session_entries` for the Archivist and `search_memory` tool.
|
||||
- **`segments.rs`** — conversation segmentation (`conversation_segments`). Boundary detection (time gap, embedding drift, explicit markers, turn count), segment lifecycle (open → closed → summarised), and the `BoundaryConfig` knobs.
|
||||
- **`events.rs`** — event extraction (`event_log` + `event_fts`). Stores typed atomic events (Fact / Decision / Commitment / Preference / Question / Foresight) extracted from closed segments via heuristic pattern matching.
|
||||
- **`profile.rs`** — user profile facets (`user_profile`). Evidence-backed `FacetType` rows that accumulate across sessions; on conflict, evidence count is bumped and the value is overwritten only if confidence improves.
|
||||
- **`*_tests.rs`** — module-local tests for documents, events, profile, query, segments.
|
||||
|
||||
## How it fits
|
||||
|
||||
`MemoryClient` (in `../client.rs`) and the `impl Memory for UnifiedMemory` in `../memory_trait.rs` are the only things that should hold a `UnifiedMemory` directly. The ingestion pipeline (`../../ingestion/`) calls `upsert_document` and `graph_upsert_namespace` after parsing; the agent harness reads via `query_namespace_*` and `recall_namespace_*`; the Archivist writes episodic turns via `fts5::episodic_insert` and segments / events / profile facets via the dedicated submodules.
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Document CRUD against the `memory_docs` table.
|
||||
//!
|
||||
//! Owns the upsert pipeline (with chunking + embedding), metadata-only writes
|
||||
//! for high-frequency callers, list/delete/clear-namespace operations, and the
|
||||
//! markdown sidecar files in `memory/namespaces/<ns>/docs/`.
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
@@ -8,6 +14,9 @@ use crate::openhuman::memory::store::types::{NamespaceDocumentInput, StoredMemor
|
||||
use super::UnifiedMemory;
|
||||
|
||||
impl UnifiedMemory {
|
||||
/// Insert or update a document by `(namespace, key)`. Writes the markdown
|
||||
/// sidecar, replaces vector chunks, and embeds them with the configured
|
||||
/// provider.
|
||||
pub async fn upsert_document(&self, input: NamespaceDocumentInput) -> Result<String, String> {
|
||||
let namespace = Self::sanitize_namespace(&input.namespace);
|
||||
let key = input.key.trim().to_string();
|
||||
@@ -57,6 +66,7 @@ impl UnifiedMemory {
|
||||
updated_at,
|
||||
&input.content,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let tags_json = serde_json::to_string(&input.tags).map_err(|e| e.to_string())?;
|
||||
@@ -196,6 +206,7 @@ impl UnifiedMemory {
|
||||
updated_at,
|
||||
&input.content,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let tags_json = serde_json::to_string(&input.tags).map_err(|e| e.to_string())?;
|
||||
@@ -300,6 +311,8 @@ impl UnifiedMemory {
|
||||
Ok(docs)
|
||||
}
|
||||
|
||||
/// List documents in a namespace, or across all namespaces when `None`.
|
||||
/// Returns `{ "documents": [...], "count": N }` JSON.
|
||||
pub async fn list_documents(&self, namespace: Option<&str>) -> Result<Value, String> {
|
||||
let conn = self.conn.lock();
|
||||
let mut docs = Vec::new();
|
||||
@@ -357,6 +370,7 @@ impl UnifiedMemory {
|
||||
Ok(json!({ "documents": docs, "count": docs.len() }))
|
||||
}
|
||||
|
||||
/// Return every distinct namespace that has at least one document.
|
||||
pub async fn list_namespaces(&self) -> Result<Vec<String>, String> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn
|
||||
@@ -432,7 +446,7 @@ impl UnifiedMemory {
|
||||
// Remove on-disk markdown files for this namespace.
|
||||
let docs_dir = self.namespace_dir(&ns).join("docs");
|
||||
if docs_dir.exists() {
|
||||
std::fs::remove_dir_all(&docs_dir).map_err(|e| {
|
||||
tokio::fs::remove_dir_all(&docs_dir).await.map_err(|e| {
|
||||
format!(
|
||||
"clear_namespace remove docs dir {}: {e}",
|
||||
docs_dir.display()
|
||||
@@ -448,6 +462,8 @@ impl UnifiedMemory {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a single document plus its vector chunks, graph relations, and
|
||||
/// markdown sidecar. Returns `{ "deleted": bool, "namespace", "documentId" }`.
|
||||
pub async fn delete_document(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -487,7 +503,13 @@ impl UnifiedMemory {
|
||||
|
||||
if let Some(rel) = rel_path {
|
||||
let abs = self.workspace_dir.join(rel);
|
||||
let _ = std::fs::remove_file(abs);
|
||||
// Surface non-NotFound failures so storage drift between the DB
|
||||
// row and the markdown sidecar is diagnosable.
|
||||
if let Err(e) = tokio::fs::remove_file(&abs).await {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
log::warn!("[memory] failed to remove sidecar {}: {e}", abs.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"deleted": deleted, "namespace": ns, "documentId": document_id }))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Tests for the `documents` module — upsert / list / delete / clear-namespace.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::openhuman::memory::{embeddings::NoopEmbedding, NamespaceDocumentInput, UnifiedMemory};
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::memory::{NamespaceDocumentInput, UnifiedMemory};
|
||||
|
||||
fn make_doc_input(
|
||||
namespace: &str,
|
||||
|
||||
@@ -77,6 +77,7 @@ pub enum EventType {
|
||||
}
|
||||
|
||||
impl EventType {
|
||||
/// Stable lowercase identifier persisted in the `event_log` table.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Fact => "fact",
|
||||
@@ -88,6 +89,8 @@ impl EventType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored string back to an `EventType`; unknown values fall back
|
||||
/// to `Fact`.
|
||||
pub fn parse_or_default(s: &str) -> Self {
|
||||
match s {
|
||||
"decision" => Self::Decision,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Tests for the `events` module — heuristic extraction and FTS5 storage.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn setup_db() -> Arc<Mutex<Connection>> {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Knowledge-graph relations stored in `graph_namespace` and `graph_global`.
|
||||
//!
|
||||
//! Provides upsert (with attribute merging + evidence accumulation), namespace
|
||||
//! / global / cross-namespace queries, and the document-scoped removal used
|
||||
//! when a source document is deleted or re-ingested.
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
@@ -94,6 +100,7 @@ impl UnifiedMemory {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upsert a relation into the cross-namespace `graph_global` table.
|
||||
pub async fn graph_upsert_global(
|
||||
&self,
|
||||
subject: &str,
|
||||
@@ -105,6 +112,9 @@ impl UnifiedMemory {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Upsert a relation into the namespace-scoped `graph_namespace` table,
|
||||
/// merging attributes (evidence count, document/chunk ids) with any
|
||||
/// existing edge.
|
||||
pub async fn graph_upsert_namespace(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -117,6 +127,7 @@ impl UnifiedMemory {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Query relations in the global graph with optional subject/predicate filters.
|
||||
pub async fn graph_query_global(
|
||||
&self,
|
||||
subject: Option<&str>,
|
||||
@@ -153,6 +164,7 @@ impl UnifiedMemory {
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Query relations within a single namespace with optional subject/predicate filters.
|
||||
pub async fn graph_query_namespace(
|
||||
&self,
|
||||
namespace: &str,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! Shared helpers used across the unified store: byte/float vector codecs,
|
||||
//! cosine similarity, markdown chunking, text/predicate normalization, JSON
|
||||
//! attribute merging, and recency scoring.
|
||||
|
||||
use crate::openhuman::memory::chunker::chunk_markdown;
|
||||
|
||||
use super::UnifiedMemory;
|
||||
|
||||
impl UnifiedMemory {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn write_markdown_doc(
|
||||
pub(crate) async fn write_markdown_doc(
|
||||
&self,
|
||||
namespace: &str,
|
||||
doc_id: &str,
|
||||
@@ -17,7 +21,7 @@ impl UnifiedMemory {
|
||||
content: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let docs_dir = self.namespace_dir(namespace).join("docs");
|
||||
std::fs::create_dir_all(&docs_dir)?;
|
||||
tokio::fs::create_dir_all(&docs_dir).await?;
|
||||
let rel_path = format!(
|
||||
"memory/namespaces/{}/docs/{doc_id}.md",
|
||||
Self::sanitize_namespace(namespace)
|
||||
@@ -34,7 +38,7 @@ impl UnifiedMemory {
|
||||
created_at,
|
||||
updated_at
|
||||
);
|
||||
std::fs::write(abs_path, format!("{header}{content}\n"))?;
|
||||
tokio::fs::write(abs_path, format!("{header}{content}\n")).await?;
|
||||
Ok(rel_path)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
//! `UnifiedMemory` constructor + schema bootstrap.
|
||||
//!
|
||||
//! Creates the workspace directories, opens the SQLite connection in WAL mode,
|
||||
//! materialises every table the unified store owns (docs, kv, graph, vector
|
||||
//! chunks, episodic FTS5, segments, events, profile), and runs idempotent
|
||||
//! legacy-namespace migrations. Also exposes path / namespace helpers shared
|
||||
//! by the rest of the unified module.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::openhuman::memory::embeddings::EmbeddingProvider;
|
||||
use crate::openhuman::embeddings::EmbeddingProvider;
|
||||
use crate::openhuman::memory::store::types::GLOBAL_NAMESPACE;
|
||||
|
||||
use super::UnifiedMemory;
|
||||
|
||||
impl UnifiedMemory {
|
||||
/// Open (or create) the unified store rooted at `workspace_dir`.
|
||||
///
|
||||
/// Creates the on-disk layout, runs all `CREATE TABLE` statements, and
|
||||
/// applies idempotent legacy-namespace migrations. Safe to call on every
|
||||
/// boot.
|
||||
pub fn new(
|
||||
workspace_dir: &Path,
|
||||
embedder: Arc<dyn EmbeddingProvider>,
|
||||
@@ -179,14 +192,17 @@ impl UnifiedMemory {
|
||||
})
|
||||
}
|
||||
|
||||
/// Root workspace directory holding `memory/` and its subtrees.
|
||||
pub fn workspace_dir(&self) -> &Path {
|
||||
&self.workspace_dir
|
||||
}
|
||||
|
||||
/// Filesystem path of the SQLite database file.
|
||||
pub fn db_path(&self) -> &Path {
|
||||
&self.db_path
|
||||
}
|
||||
|
||||
/// Directory used for vector-related sidecar files.
|
||||
pub fn vectors_dir(&self) -> &Path {
|
||||
&self.vectors_dir
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
//! Key-value storage backed by the `kv_global` and `kv_namespace` tables.
|
||||
//!
|
||||
//! Provides global and namespace-scoped get/set/delete/list, plus internal
|
||||
//! record loaders used by the retrieval pipeline.
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -6,6 +11,7 @@ use crate::openhuman::memory::store::types::MemoryKvRecord;
|
||||
use super::UnifiedMemory;
|
||||
|
||||
impl UnifiedMemory {
|
||||
/// Insert or update a global key-value pair.
|
||||
pub async fn kv_set_global(&self, key: &str, value: &serde_json::Value) -> Result<(), String> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
@@ -18,6 +24,7 @@ impl UnifiedMemory {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a global key, returning `None` if absent.
|
||||
pub async fn kv_get_global(&self, key: &str) -> Result<Option<serde_json::Value>, String> {
|
||||
let conn = self.conn.lock();
|
||||
let value: Option<String> = conn
|
||||
@@ -31,6 +38,7 @@ impl UnifiedMemory {
|
||||
Ok(value.and_then(|v| serde_json::from_str(&v).ok()))
|
||||
}
|
||||
|
||||
/// Insert or update a namespace-scoped key-value pair.
|
||||
pub async fn kv_set_namespace(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -48,6 +56,7 @@ impl UnifiedMemory {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a namespace-scoped key, returning `None` if absent.
|
||||
pub async fn kv_get_namespace(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -65,6 +74,7 @@ impl UnifiedMemory {
|
||||
Ok(value.and_then(|v| serde_json::from_str(&v).ok()))
|
||||
}
|
||||
|
||||
/// Delete a global key. Returns `true` if a row was removed.
|
||||
pub async fn kv_delete_global(&self, key: &str) -> Result<bool, String> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn
|
||||
@@ -73,6 +83,7 @@ impl UnifiedMemory {
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Delete a namespace-scoped key. Returns `true` if a row was removed.
|
||||
pub async fn kv_delete_namespace(&self, namespace: &str, key: &str) -> Result<bool, String> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn
|
||||
@@ -84,6 +95,7 @@ impl UnifiedMemory {
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// List all keys in a namespace, most recently updated first.
|
||||
pub async fn kv_list_namespace(
|
||||
&self,
|
||||
namespace: &str,
|
||||
|
||||
@@ -5,8 +5,13 @@ use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::openhuman::memory::embeddings::EmbeddingProvider;
|
||||
use crate::openhuman::embeddings::EmbeddingProvider;
|
||||
|
||||
/// SQLite-backed unified memory store.
|
||||
///
|
||||
/// Owns a single connection (WAL-mode) plus the on-disk markdown sidecar
|
||||
/// directory and vector storage path. Methods are added across the sibling
|
||||
/// modules (`documents`, `kv`, `graph`, `query`, …) via `impl` blocks.
|
||||
pub struct UnifiedMemory {
|
||||
pub(crate) workspace_dir: PathBuf,
|
||||
pub(crate) db_path: PathBuf,
|
||||
|
||||
@@ -43,6 +43,7 @@ pub enum FacetType {
|
||||
}
|
||||
|
||||
impl FacetType {
|
||||
/// Stable lowercase identifier persisted in the `user_profile` table.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Preference => "preference",
|
||||
@@ -53,6 +54,8 @@ impl FacetType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored string back to a `FacetType`; unknown values fall back
|
||||
/// to `Preference`.
|
||||
pub fn parse_or_default(s: &str) -> Self {
|
||||
match s {
|
||||
"skill" => Self::Skill,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Tests for the `profile` module — facet upsert with confidence merging.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn setup_db() -> Arc<Mutex<Connection>> {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
//! Hybrid retrieval over the unified store.
|
||||
//!
|
||||
//! Combines graph relevance, vector similarity, keyword overlap, episodic
|
||||
//! signal, and freshness into a single score per hit. Owns the query planner
|
||||
//! (`build_retrieval_plan`), per-document score composition, and the
|
||||
//! `query_namespace_hits` / `query_namespace_ranked` / `recall_namespace_*`
|
||||
//! entry points used by `MemoryClient`.
|
||||
|
||||
use rusqlite::params;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -85,6 +93,9 @@ impl UnifiedMemory {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Hybrid retrieval: returns ranked hits across documents and KV records,
|
||||
/// scored by graph relevance + vector similarity + keyword overlap +
|
||||
/// freshness.
|
||||
pub async fn query_namespace_hits(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -322,6 +333,7 @@ impl UnifiedMemory {
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// Run a hybrid query and return only the rendered context text.
|
||||
pub async fn query_namespace_context(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -334,6 +346,8 @@ impl UnifiedMemory {
|
||||
Ok(context.context_text)
|
||||
}
|
||||
|
||||
/// Run a hybrid query and return both the rendered context text and the
|
||||
/// underlying ranked hits.
|
||||
pub async fn query_namespace_context_data(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -350,6 +364,8 @@ impl UnifiedMemory {
|
||||
})
|
||||
}
|
||||
|
||||
/// Query-less recall: rank documents and KV records by priority + graph
|
||||
/// relevance + freshness without a search query.
|
||||
pub async fn recall_namespace_memories(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -454,6 +470,8 @@ impl UnifiedMemory {
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// Query-less recall returning only rendered context text. `None` when
|
||||
/// the namespace is empty.
|
||||
pub async fn recall_namespace_context(
|
||||
&self,
|
||||
namespace: &str,
|
||||
@@ -468,6 +486,7 @@ impl UnifiedMemory {
|
||||
Ok(Some(Self::format_context_text(&hits, None)))
|
||||
}
|
||||
|
||||
/// Query-less recall returning both rendered text and ranked hits.
|
||||
pub async fn recall_namespace_context_data(
|
||||
&self,
|
||||
namespace: &str,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Tests for the `query` module — hybrid retrieval scoring.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::openhuman::memory::{embeddings::NoopEmbedding, NamespaceDocumentInput, UnifiedMemory};
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::memory::{NamespaceDocumentInput, UnifiedMemory};
|
||||
|
||||
#[tokio::test]
|
||||
async fn graph_duplicate_upsert_aggregates_evidence_count() {
|
||||
|
||||
@@ -49,6 +49,7 @@ pub enum SegmentStatus {
|
||||
}
|
||||
|
||||
impl SegmentStatus {
|
||||
/// Stable lowercase identifier persisted in the `conversation_segments` table.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Open => "open",
|
||||
@@ -57,6 +58,8 @@ impl SegmentStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored string back to a `SegmentStatus`; unknown values fall
|
||||
/// back to `Open`.
|
||||
pub fn parse_or_default(s: &str) -> Self {
|
||||
match s {
|
||||
"closed" => Self::Closed,
|
||||
@@ -116,6 +119,7 @@ pub enum BoundaryDecision {
|
||||
Boundary(BoundaryReason),
|
||||
}
|
||||
|
||||
/// Reason a new segment boundary was triggered.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BoundaryReason {
|
||||
TimeGap,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Tests for the `segments` module — boundary detection and segment lifecycle.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn setup_db() -> Arc<Mutex<Connection>> {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Memory tree
|
||||
|
||||
Bucket-seal-ready local memory architecture (Phase 1 of issue #707; the LLD design doc `docs/MEMORY_ARCHITECTURE_LLD.md` is referenced by the in-tree module headers but is not checked into this repo). Coexists with the legacy `store/` backend until full replacement.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```text
|
||||
source adapters (chat / email / document)
|
||||
│
|
||||
▼
|
||||
canonicalize/ ── normalised Markdown + provenance Metadata
|
||||
│
|
||||
▼
|
||||
chunker.rs ── deterministic IDs, ≤3k-token bounded segments
|
||||
│
|
||||
▼
|
||||
content_store/── atomic .md files on disk (body + tags)
|
||||
│
|
||||
▼
|
||||
store.rs ── SQLite persistence (chunks, scores, summaries, jobs, hotness)
|
||||
│
|
||||
▼
|
||||
score/ ── signals + embeddings + entity extraction
|
||||
│
|
||||
▼
|
||||
tree_source/ tree_topic/ tree_global/ ── per-scope summary trees
|
||||
│
|
||||
▼
|
||||
retrieval/ ── search / drill_down / topic / global / fetch
|
||||
│
|
||||
▼
|
||||
jobs/ ── background workers + scheduler (extract, admit, seal, digest)
|
||||
```
|
||||
|
||||
## Files at this level
|
||||
|
||||
- [`mod.rs`](mod.rs) — Phase 1 module banner; re-exports controller registries (`all_memory_tree_*`, `all_retrieval_*`).
|
||||
- [`chunker.rs`](chunker.rs) — slice canonical Markdown into ≤`DEFAULT_CHUNK_MAX_TOKENS` chunks; chat/email split at message boundaries, document at paragraphs.
|
||||
- [`ingest.rs`](ingest.rs) — orchestrator: `canonicalize -> chunk -> stage_chunks -> fast score -> persist -> enqueue extract jobs`. Hot path; heavy work runs out of `jobs/`.
|
||||
- [`rpc.rs`](rpc.rs) — JSON-RPC handlers for `memory_tree_ingest`, `list_chunks`, `get_chunk`, `trigger_digest`. Delegates to `ingest`/`store`/`jobs`.
|
||||
- [`schemas.rs`](schemas.rs) — `ControllerSchema` definitions + `RegisteredController` wiring for the four `memory_tree_*` RPC methods.
|
||||
- [`store.rs`](store.rs) — SQLite schema (chunks, score, entity index, trees, summaries, buffers, hotness, jobs) and accessors. Lazily initialised at `<workspace>/memory_tree/chunks.db`.
|
||||
- [`store_tests.rs`](store_tests.rs) — store-layer unit tests.
|
||||
- [`types.rs`](types.rs) — `Chunk`, `Metadata`, `SourceKind`, `DataSource`, `SourceRef`; deterministic `chunk_id` hash; `approx_token_count` heuristic.
|
||||
|
||||
## Subdirectories
|
||||
|
||||
- [`canonicalize/`](canonicalize/README.md) — chat / email / document → canonical Markdown + email body cleaner.
|
||||
- [`chunker.rs`](chunker.rs) — see above.
|
||||
- [`content_store/`](content_store/README.md) — on-disk `.md` files (atomic writes, paths, YAML compose, read+verify, tag rewrites).
|
||||
- [`jobs/`](jobs/) — async job queue (extract / admit / seal / topic / digest workers).
|
||||
- [`retrieval/`](retrieval/) — search and drill-down RPC surface.
|
||||
- [`score/`](score/) — fast scorer, embeddings, entity extraction, score persistence.
|
||||
- [`tree_source/`](tree_source/) — per-source summary trees (L0 buffer → L1 seal → cascade).
|
||||
- [`tree_topic/`](tree_topic/) — per-entity topic trees, materialised lazily by hotness.
|
||||
- [`tree_global/`](tree_global/) — daily global digest tree.
|
||||
- [`util/`](util/README.md) — shared helpers (`redact` for log PII).
|
||||
@@ -0,0 +1,17 @@
|
||||
# canonicalize/
|
||||
|
||||
Source-specific adapters that normalise upstream payloads (chat batches, email threads, documents) into a single shape — `CanonicalisedSource { markdown, metadata }` — that the chunker downstream slices into bounded chunks.
|
||||
|
||||
Adapters do not interpret content semantically; they only normalise shape and capture provenance. Scoring / extraction / summarisation happen later in the pipeline.
|
||||
|
||||
## Files
|
||||
|
||||
- [`mod.rs`](mod.rs) — `CanonicalisedSource` struct, generic `CanonicaliseRequest<P>` envelope, and `normalize_source_ref` helper shared by all adapters.
|
||||
- [`chat.rs`](chat.rs) — chat transcripts (Slack / Discord / Telegram / WhatsApp) → Markdown of `## <ts> — <author>\n<body>` blocks. Sorts messages and captures `time_range`. Produces empty-input `Ok(None)`.
|
||||
- [`document.rs`](document.rs) — single documents (Notion page, Drive doc, meeting note, uploaded file) → trimmed body Markdown. `time_range` collapses to a single point at `modified_at`.
|
||||
- [`email.rs`](email.rs) — email threads (Gmail + generic) → per-message `---\nFrom: …\nSubject: …\nDate: …\n\n<cleaned-body>` blocks. Bodies pass through `email_clean::clean_body` first.
|
||||
- [`email_clean.rs`](email_clean.rs) — pure-string helpers: `clean_body` (strip reply chains + footer/legal boilerplate), `truncate_body`, `md_escape`, `extract_email`, `parse_message_date`. Used by both the email canonicaliser and the `gmail-fetch-emails` bin.
|
||||
|
||||
## Output contract
|
||||
|
||||
The canonicalised Markdown carries no leading `# Header` line — provider/title metadata lives in YAML front-matter written by `content_store/compose.rs`. The chunker relies on the `##` prefix followed by a space (chat) and `---\nFrom:` (email) boundaries to split at message granularity.
|
||||
@@ -28,6 +28,9 @@ pub struct DocumentInput {
|
||||
pub source_ref: Option<String>,
|
||||
}
|
||||
|
||||
/// Canonicalise a single document into a [`CanonicalisedSource`]. Returns
|
||||
/// `Ok(None)` if both the title and body are empty — caller treats as nothing
|
||||
/// to ingest.
|
||||
pub fn canonicalise(
|
||||
source_id: &str,
|
||||
owner: &str,
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct EmailThread {
|
||||
pub messages: Vec<EmailMessage>,
|
||||
}
|
||||
|
||||
/// Canonicalise an email thread into a [`CanonicalisedSource`]. Bodies are
|
||||
/// passed through [`email_clean::clean_body`] to strip reply chains and footer
|
||||
/// boilerplate. Returns `Ok(None)` when the thread has no messages.
|
||||
pub fn canonicalise(
|
||||
source_id: &str,
|
||||
owner: &str,
|
||||
|
||||
@@ -22,7 +22,9 @@ use crate::openhuman::memory::tree::types::{Metadata, SourceRef};
|
||||
/// (a chat batch, an email, a document).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CanonicalisedSource {
|
||||
/// Canonical Markdown blob produced by the adapter.
|
||||
pub markdown: String,
|
||||
/// Provenance the chunker will clone onto each emitted [`Chunk`].
|
||||
pub metadata: Metadata,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::openhuman::memory::tree::util::redact::redact;
|
||||
|
||||
/// Default upper bound on per-chunk tokens.
|
||||
///
|
||||
/// Sized below the L0 seal budget (`source_tree::types::TOKEN_BUDGET = 4_500`)
|
||||
/// Sized below the L0 seal budget (`tree_source::types::TOKEN_BUDGET = 4_500`)
|
||||
/// so each seal accumulates roughly 1–3 chunks before firing — natural pacing
|
||||
/// for the local 1B summariser, which produces noticeably better summaries
|
||||
/// with smaller (≤4–5k) inputs than at the previous 10k cap.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# content_store/
|
||||
|
||||
On-disk `.md` storage for chunk and summary bodies (Phase MD-content). SQLite holds `content_path` (relative, forward-slash) and `content_sha256` (over body bytes only) as pointers + integrity tokens; the body itself lives at `<content_root>/<content_path>`.
|
||||
|
||||
The body is **immutable** once written — only the YAML front-matter `tags:` block may be rewritten post-extraction.
|
||||
|
||||
## Files
|
||||
|
||||
- [`mod.rs`](mod.rs) — public surface: `StagedChunk`, `stage_chunks` (write all chunks atomically before SQLite upsert), `update_summary_tags` re-export.
|
||||
- [`atomic.rs`](atomic.rs) — `write_if_new` (tempfile + fsync + rename, parent dir fsync on Unix), `stage_summary` (idempotent re-stage with on-disk SHA check + auto-rewrite on mismatch), `sha256_hex`, `StagedSummary`.
|
||||
- [`compose.rs`](compose.rs) — YAML front-matter + body composition. `compose_chunk_file` for chunks (with email-only `participants:` / `aliases:` fields parsed from `gmail:{addr1|addr2|…}` source ids), `compose_summary_md` for summary nodes. `rewrite_tags` / `rewrite_summary_tags` swap the `tags:` block in place. `split_front_matter` parses `---\n…\n---\n<body>`.
|
||||
- [`paths.rs`](paths.rs) — path generators. `chunk_rel_path` (`email/<participants_slug>/<id>.md`, `chat/<source_slug>/<id>.md`, `document/<source_slug>/<id>.md`); `summary_rel_path` (`summaries/{source,global,topic}/…`). `slugify_source_id` is the canonical filesystem-safe slug.
|
||||
- [`read.rs`](read.rs) — `read_chunk_file` / `read_summary_file` parse front-matter and return body+SHA. `verify_*` compares against an expected SHA. `read_chunk_body` / `read_summary_body` resolve the path via SQLite and verify the integrity hash; this is the authoritative entry-point for callers that need the **full** body (LLM extractor, summariser, embedder, retrieval API).
|
||||
- [`tags.rs`](tags.rs) — post-extraction tag rewrites. `update_chunk_tags` (atomic tempfile rewrite of the `tags:` block) and `update_summary_tags` (fetches entities from `mem_tree_entity_index`, builds Obsidian `kind/Value` tags, rewrites, verifies body SHA is unchanged). `slugify_tag_kind`, `slugify_tag_value`, `entity_tag` build the tag strings.
|
||||
|
||||
## Integrity contract
|
||||
|
||||
The body bytes never change after the first write. The SHA-256 stored in SQLite is computed over body bytes only — front-matter (including `tags:`) can be rewritten without invalidating the hash. Read paths verify SHA on every fetch and fail loudly on mismatch rather than serve corrupt data into the extractor or summariser.
|
||||
@@ -100,6 +100,7 @@ pub fn write_if_new(abs_path: &Path, bytes: &[u8]) -> anyhow::Result<bool> {
|
||||
/// A summary that has been written to disk and is ready for SQLite upsert.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StagedSummary {
|
||||
/// Identifier of the summary that was staged.
|
||||
pub summary_id: String,
|
||||
/// Relative content path (forward-slash, e.g. `"summaries/source/slug/L1/id.md"`).
|
||||
pub content_path: String,
|
||||
|
||||
@@ -238,24 +238,33 @@ fn replace_tags_in_front_matter(fm: &str, new_tags: &[String]) -> Result<String,
|
||||
|
||||
/// Input data required to compose a summary `.md` file.
|
||||
pub struct SummaryComposeInput<'a> {
|
||||
/// Stable id of the summary node (also used to derive the filename).
|
||||
pub summary_id: &'a str,
|
||||
/// Which tree (source / global / topic) this summary belongs to.
|
||||
pub tree_kind: SummaryTreeKind,
|
||||
/// Owning tree id (FK into `mem_tree_trees`).
|
||||
pub tree_id: &'a str,
|
||||
/// Raw tree scope string, e.g. `"gmail:alice@x.com|bob@y.com"` or `"global"`.
|
||||
pub tree_scope: &'a str,
|
||||
/// Level in the tree (L0 = leaves, L1+ = summaries).
|
||||
pub level: u32,
|
||||
/// Child ids (chunk_ids at L0 → L1, summary_ids for cascades).
|
||||
pub child_ids: &'a [String],
|
||||
/// Total child count (== child_ids.len() unless truncated).
|
||||
pub child_count: usize,
|
||||
/// Start of the time range covered by this summary's children.
|
||||
pub time_range_start: DateTime<Utc>,
|
||||
/// End of the time range covered by this summary's children.
|
||||
pub time_range_end: DateTime<Utc>,
|
||||
/// When the buffer was sealed into this summary node.
|
||||
pub sealed_at: DateTime<Utc>,
|
||||
/// Raw summariser output text — the body written to disk.
|
||||
pub body: &'a str,
|
||||
}
|
||||
|
||||
/// The composed front-matter, body, and full file content for a summary.
|
||||
///
|
||||
/// `body` is what the SHA-256 integrity hash is computed over.
|
||||
pub struct ComposedSummary {
|
||||
/// The YAML front-matter block (including `---` delimiters), UTF-8 string.
|
||||
pub front_matter: String,
|
||||
|
||||
@@ -46,6 +46,8 @@ impl IngestResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ingest a batch of chat messages: canonicalise → chunk → fast-score → persist
|
||||
/// → enqueue async extract jobs. Returns a noop [`IngestResult`] on an empty batch.
|
||||
pub async fn ingest_chat(
|
||||
config: &Config,
|
||||
source_id: &str,
|
||||
@@ -61,6 +63,8 @@ pub async fn ingest_chat(
|
||||
persist(config, source_id, canonical).await
|
||||
}
|
||||
|
||||
/// Ingest an email thread: canonicalise → chunk → fast-score → persist → enqueue
|
||||
/// async extract jobs. Returns a noop [`IngestResult`] on an empty thread.
|
||||
pub async fn ingest_email(
|
||||
config: &Config,
|
||||
source_id: &str,
|
||||
@@ -76,6 +80,8 @@ pub async fn ingest_email(
|
||||
persist(config, source_id, canonical).await
|
||||
}
|
||||
|
||||
/// Ingest a single document: canonicalise → chunk → fast-score → persist →
|
||||
/// enqueue async extract jobs. Returns a noop [`IngestResult`] on empty input.
|
||||
pub async fn ingest_document(
|
||||
config: &Config,
|
||||
source_id: &str,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Memory tree — jobs
|
||||
|
||||
Async job pipeline driving extraction, scoring, summarisation, and digesting off the ingest hot path. Replaces the previous synchronous `append_leaf → cascade_seal → LLM summarise` chain with a SQLite-backed queue (`mem_tree_jobs`) and a worker pool. Producers commit side-effect + follow-up job atomically inside one transaction via `enqueue_tx`.
|
||||
|
||||
## Pipeline shape
|
||||
|
||||
```text
|
||||
ingest::persist → enqueues `extract_chunk`
|
||||
worker pool (3 tasks):
|
||||
extract_chunk → LLM extraction → admission → enqueue `append_buffer` + `topic_route`
|
||||
append_buffer → push to L0 → enqueue `seal` if gate met
|
||||
seal → seal one level → enqueue parent seal if cascading
|
||||
topic_route → match topics → enqueue per-topic `append_buffer`
|
||||
digest_daily → call `tree_global::digest::end_of_day_digest`
|
||||
flush_stale → enqueue seals for time-stale buffers
|
||||
scheduler (1 task) → daily wall-clock tick → `digest_daily(yesterday)` + `flush_stale(today)`
|
||||
```
|
||||
|
||||
## Public surface
|
||||
|
||||
- `pub fn enqueue` / `enqueue_tx` / `claim_next` / `mark_done` / `mark_failed` / `recover_stale_locks` / `get_job` / `count_by_status` / `count_total` — `store.rs` — queue persistence.
|
||||
- `pub fn start` / `wake_workers` — `worker.rs` — spawn the worker pool (idempotent) and notify idle workers.
|
||||
- `pub fn trigger_digest` / `backfill_missing_digests` — `scheduler.rs` — manual digest enqueues.
|
||||
- `pub fn drain_until_idle` — `testing.rs` — deterministic test runner that processes all eligible jobs.
|
||||
- `pub enum JobKind` / `JobStatus` / `pub struct Job` / `NewJob` / payload structs (`ExtractChunkPayload`, `AppendBufferPayload`, `SealPayload`, `TopicRoutePayload`, `DigestDailyPayload`, `FlushStalePayload`) and `NodeRef` / `AppendTarget` — `types.rs`.
|
||||
- `pub const DEFAULT_LOCK_DURATION_MS` — `store.rs` — claim lease window (5 min).
|
||||
|
||||
## Files
|
||||
|
||||
- `mod.rs` — module surface and re-exports.
|
||||
- `types.rs` — `JobKind`, `JobStatus`, payload structs, `NewJob` builders. Each payload owns its `dedupe_key()` so duplicates in flight are silently suppressed.
|
||||
- `store.rs` — SQLite persistence: `INSERT OR IGNORE` + partial unique index on `dedupe_key WHERE status IN ('ready','running')` for at-most-one-active dedupe; `claim_next` is a single `UPDATE ... RETURNING`; `mark_done`/`mark_failed` are claim-token gated to make stale-worker settlements no-ops.
|
||||
- `worker.rs` — three worker tasks plus startup `recover_stale_locks` and a 3-permit semaphore around LLM-bound jobs. Calls into `crate::openhuman::scheduler_gate::wait_for_capacity()` before claiming so Throttled / Paused modes back off without holding DB leases.
|
||||
- `scheduler.rs` — daily tick at UTC 00:05 that enqueues `digest_daily(yesterday)` + `flush_stale(today)`; `trigger_digest` and `backfill_missing_digests` are manual catch-up helpers.
|
||||
- `handlers/` — per-`JobKind` handler implementations.
|
||||
- `testing.rs` — `drain_until_idle` for tests that need the pipeline to settle synchronously.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Memory tree — jobs handlers
|
||||
|
||||
Per-`JobKind` handler implementations dispatched by `worker::run_once_with_semaphore`. Each handler parses its payload, performs side effects, and enqueues any follow-up work (typically inside the same SQLite transaction as its primary write so a crash doesn't lose downstream jobs).
|
||||
|
||||
## Public surface
|
||||
|
||||
- `pub async fn handle_job(config, job)` — `mod.rs` — branches on `job.kind` and invokes the matching handler.
|
||||
|
||||
## Handlers (private to the module)
|
||||
|
||||
- `handle_extract` — runs the scorer + LLM extractor over one chunk, packs the embedding, writes `mem_tree_score` + entity-index rows + chunk lifecycle in one tx, and enqueues the follow-up `append_buffer` and `topic_route` jobs. Also rewrites Obsidian-style `tags:` in the on-disk chunk markdown (best-effort, post-tx).
|
||||
- `handle_append_buffer` — hydrates a `LeafRef` (chunk or summary), pushes into the target tree's L0 buffer, and enqueues a `seal` job if the buffer crosses its budget. Updates chunk lifecycle (`buffered`) for source-tree appends. All in one tx.
|
||||
- `handle_seal` — seals exactly one buffer level via `bucket_seal::seal_one_level` (which atomically inserts the parent-cascade seal and summary-side `topic_route` for source trees). Topic-tree seals are sinks and do not enqueue further routing. Rewrites tags on the sealed summary's `.md` post-commit.
|
||||
- `handle_topic_route` — for each canonical entity associated with the node, asks the topic curator whether to spawn a topic tree, and enqueues an `append_buffer` per matched topic tree.
|
||||
- `handle_digest_daily` — invokes `tree_global::digest::end_of_day_digest` for the requested UTC date; idempotent via the digest's own `find_existing_daily` check.
|
||||
- `handle_flush_stale` — walks `list_stale_buffers` and enqueues a forced `seal` per buffer over the configured `DEFAULT_FLUSH_AGE_SECS` cap.
|
||||
|
||||
## Files
|
||||
|
||||
- `mod.rs` — `handle_job` dispatch and all handler bodies.
|
||||
@@ -1,10 +1,16 @@
|
||||
//! Per-`JobKind` handler implementations dispatched by the worker pool.
|
||||
//!
|
||||
//! Each handler parses its payload from `Job::payload_json`, performs its
|
||||
//! side effects (DB writes, LLM calls, follow-up enqueues), and returns
|
||||
//! `Ok(())` on success or an `anyhow::Error` on retryable failure.
|
||||
//! [`handle_job`] fans out to the handler matching the row's `kind`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::memory::tree::content_store::{
|
||||
self as content_store, read as content_read, tags as content_tags,
|
||||
};
|
||||
use crate::openhuman::memory::tree::global_tree::digest::{self, DigestOutcome};
|
||||
use crate::openhuman::memory::tree::jobs::store;
|
||||
use crate::openhuman::memory::tree::jobs::types::{
|
||||
AppendBufferPayload, AppendTarget, DigestDailyPayload, ExtractChunkPayload, FlushStalePayload,
|
||||
@@ -14,12 +20,14 @@ use crate::openhuman::memory::tree::score;
|
||||
use crate::openhuman::memory::tree::score::embed::{build_embedder_from_config, pack_checked};
|
||||
use crate::openhuman::memory::tree::score::extract::build_summary_extractor;
|
||||
use crate::openhuman::memory::tree::score::store as score_store;
|
||||
use crate::openhuman::memory::tree::source_tree::{
|
||||
use crate::openhuman::memory::tree::store as chunk_store;
|
||||
use crate::openhuman::memory::tree::tree_global::digest::{self, DigestOutcome};
|
||||
use crate::openhuman::memory::tree::tree_source::{
|
||||
build_summariser, get_or_create_source_tree, LabelStrategy, LeafRef,
|
||||
};
|
||||
use crate::openhuman::memory::tree::store as chunk_store;
|
||||
use crate::openhuman::memory::tree::topic_tree::curator;
|
||||
use crate::openhuman::memory::tree::tree_topic::curator;
|
||||
|
||||
/// Dispatch a claimed job to the matching per-kind handler.
|
||||
pub async fn handle_job(config: &Config, job: &Job) -> Result<()> {
|
||||
match job.kind {
|
||||
JobKind::ExtractChunk => handle_extract(config, job).await,
|
||||
@@ -200,8 +208,8 @@ async fn handle_extract(config: &Config, job: &Job) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn handle_append_buffer(config: &Config, job: &Job) -> Result<()> {
|
||||
use crate::openhuman::memory::tree::source_tree::bucket_seal::should_seal;
|
||||
use crate::openhuman::memory::tree::source_tree::store as src_store;
|
||||
use crate::openhuman::memory::tree::tree_source::bucket_seal::should_seal;
|
||||
use crate::openhuman::memory::tree::tree_source::store as src_store;
|
||||
|
||||
let payload: AppendBufferPayload =
|
||||
serde_json::from_str(&job.payload_json).context("parse AppendBuffer payload")?;
|
||||
@@ -338,9 +346,9 @@ async fn handle_append_buffer(config: &Config, job: &Job) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn handle_seal(config: &Config, job: &Job) -> Result<()> {
|
||||
use crate::openhuman::memory::tree::source_tree::bucket_seal::{seal_one_level, should_seal};
|
||||
use crate::openhuman::memory::tree::source_tree::store as src_store;
|
||||
use crate::openhuman::memory::tree::source_tree::types::TreeKind;
|
||||
use crate::openhuman::memory::tree::tree_source::bucket_seal::{seal_one_level, should_seal};
|
||||
use crate::openhuman::memory::tree::tree_source::store as src_store;
|
||||
use crate::openhuman::memory::tree::tree_source::types::TreeKind;
|
||||
|
||||
let payload: SealPayload =
|
||||
serde_json::from_str(&job.payload_json).context("parse Seal payload")?;
|
||||
@@ -428,7 +436,7 @@ async fn handle_topic_route(config: &Config, job: &Job) -> Result<()> {
|
||||
chunk_id.clone()
|
||||
}
|
||||
NodeRef::Summary { summary_id } => {
|
||||
if crate::openhuman::memory::tree::source_tree::store::get_summary(config, summary_id)?
|
||||
if crate::openhuman::memory::tree::tree_source::store::get_summary(config, summary_id)?
|
||||
.is_none()
|
||||
{
|
||||
log::warn!(
|
||||
@@ -449,9 +457,9 @@ async fn handle_topic_route(config: &Config, job: &Job) -> Result<()> {
|
||||
let summariser = build_summariser(config);
|
||||
for entity_id in entity_ids {
|
||||
let _ = curator::maybe_spawn_topic_tree(config, &entity_id, summariser.as_ref()).await?;
|
||||
if let Some(tree) = crate::openhuman::memory::tree::source_tree::store::get_tree_by_scope(
|
||||
if let Some(tree) = crate::openhuman::memory::tree::tree_source::store::get_tree_by_scope(
|
||||
config,
|
||||
crate::openhuman::memory::tree::source_tree::types::TreeKind::Topic,
|
||||
crate::openhuman::memory::tree::tree_source::types::TreeKind::Topic,
|
||||
&entity_id,
|
||||
)? {
|
||||
let job = NewJob::append_buffer(&AppendBufferPayload {
|
||||
@@ -491,10 +499,10 @@ async fn handle_flush_stale(config: &Config, job: &Job) -> Result<()> {
|
||||
serde_json::from_str(&job.payload_json).context("parse FlushStale payload")?;
|
||||
let age_secs = payload
|
||||
.max_age_secs
|
||||
.unwrap_or(crate::openhuman::memory::tree::source_tree::types::DEFAULT_FLUSH_AGE_SECS);
|
||||
.unwrap_or(crate::openhuman::memory::tree::tree_source::types::DEFAULT_FLUSH_AGE_SECS);
|
||||
let cutoff = chrono::Utc::now() - chrono::Duration::seconds(age_secs);
|
||||
let buffers =
|
||||
crate::openhuman::memory::tree::source_tree::store::list_stale_buffers(config, cutoff)?;
|
||||
crate::openhuman::memory::tree::tree_source::store::list_stale_buffers(config, cutoff)?;
|
||||
for buf in buffers {
|
||||
let seal = SealPayload {
|
||||
tree_id: buf.tree_id.clone(),
|
||||
@@ -514,10 +522,10 @@ mod tests {
|
||||
use crate::openhuman::memory::tree::content_store;
|
||||
use crate::openhuman::memory::tree::jobs::store::{count_by_status, count_total};
|
||||
use crate::openhuman::memory::tree::jobs::types::JobStatus;
|
||||
use crate::openhuman::memory::tree::source_tree::bucket_seal::{append_leaf_deferred, LeafRef};
|
||||
use crate::openhuman::memory::tree::source_tree::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::source_tree::store as src_store;
|
||||
use crate::openhuman::memory::tree::store::with_connection;
|
||||
use crate::openhuman::memory::tree::tree_source::bucket_seal::{append_leaf_deferred, LeafRef};
|
||||
use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::tree_source::store as src_store;
|
||||
use chrono::TimeZone;
|
||||
use rusqlite::params;
|
||||
use tempfile::TempDir;
|
||||
@@ -571,7 +579,7 @@ mod tests {
|
||||
/// fire `handle_seal` and inspect the result.
|
||||
async fn seed_source_tree_ready_to_seal(
|
||||
cfg: &Config,
|
||||
) -> crate::openhuman::memory::tree::source_tree::types::Tree {
|
||||
) -> crate::openhuman::memory::tree::tree_source::types::Tree {
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::types::{
|
||||
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
|
||||
@@ -676,7 +684,7 @@ mod tests {
|
||||
// Spawn a topic tree directly via the registry (skipping curator's
|
||||
// hotness gate — we just need a TreeKind::Topic with leaves).
|
||||
let topic_tree =
|
||||
crate::openhuman::memory::tree::topic_tree::registry::get_or_create_topic_tree(
|
||||
crate::openhuman::memory::tree::tree_topic::registry::get_or_create_topic_tree(
|
||||
&cfg,
|
||||
"topic:phoenix-migration",
|
||||
)
|
||||
@@ -753,7 +761,7 @@ mod tests {
|
||||
|
||||
// 1. Create a target topic tree with a clean L0 buffer.
|
||||
let topic_tree =
|
||||
crate::openhuman::memory::tree::topic_tree::registry::get_or_create_topic_tree(
|
||||
crate::openhuman::memory::tree::tree_topic::registry::get_or_create_topic_tree(
|
||||
&cfg,
|
||||
"email:alice@example.com",
|
||||
)
|
||||
@@ -765,8 +773,8 @@ mod tests {
|
||||
// is to create a separate source tree, push two 6k leaves into
|
||||
// it, and let the seal produce a summary we can address.
|
||||
let source_tree = get_or_create_source_tree(&cfg, "slack:#eng").unwrap();
|
||||
use crate::openhuman::memory::tree::source_tree::bucket_seal::seal_one_level;
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_source::bucket_seal::seal_one_level;
|
||||
use crate::openhuman::memory::tree::types::{
|
||||
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
|
||||
};
|
||||
@@ -821,7 +829,7 @@ mod tests {
|
||||
&source_tree,
|
||||
&buf,
|
||||
summariser.as_ref(),
|
||||
&crate::openhuman::memory::tree::source_tree::bucket_seal::LabelStrategy::Empty,
|
||||
&crate::openhuman::memory::tree::tree_source::bucket_seal::LabelStrategy::Empty,
|
||||
// No follow-up enqueues — the test scopes assertions to the
|
||||
// append_buffer handler, not seal-side fan-out.
|
||||
false,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! append_buffer → push to L0 → enqueue seal if gate met → enqueue topic_route
|
||||
//! seal → seal one level → enqueue parent seal if cascading
|
||||
//! topic_route → match topics → enqueue per-topic append_buffer
|
||||
//! digest_daily → call global_tree::digest::end_of_day_digest
|
||||
//! digest_daily → call tree_global::digest::end_of_day_digest
|
||||
//! flush_stale → enqueue seals for time-stale buffers
|
||||
//!
|
||||
//! scheduler (1 task) ──► daily wall-clock tick:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
//! Wall-clock scheduler that wakes once a day shortly after UTC midnight to
|
||||
//! enqueue the global [`JobKind::DigestDaily`] for yesterday and a
|
||||
//! [`JobKind::FlushStale`] for today. Also exposes manual-trigger helpers
|
||||
//! for catch-up and testing.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Test helpers for the jobs runtime — not used in production code paths.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
@@ -27,6 +27,7 @@ pub enum JobKind {
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
/// Snake-case wire string written to `mem_tree_jobs.kind`.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
JobKind::ExtractChunk => "extract_chunk",
|
||||
@@ -38,6 +39,7 @@ impl JobKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::as_str`]; returns `Err` for unknown kinds.
|
||||
pub fn parse(s: &str) -> Result<Self> {
|
||||
Ok(match s {
|
||||
"extract_chunk" => JobKind::ExtractChunk,
|
||||
@@ -76,6 +78,7 @@ pub enum JobStatus {
|
||||
}
|
||||
|
||||
impl JobStatus {
|
||||
/// Snake-case wire string written to `mem_tree_jobs.status`.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
JobStatus::Ready => "ready",
|
||||
@@ -86,6 +89,7 @@ impl JobStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::as_str`]; returns `Err` for unknown values.
|
||||
pub fn parse(s: &str) -> Result<Self> {
|
||||
Ok(match s {
|
||||
"ready" => JobStatus::Ready,
|
||||
@@ -97,6 +101,8 @@ impl JobStatus {
|
||||
})
|
||||
}
|
||||
|
||||
/// True for `Done`, `Failed`, `Cancelled` — i.e. no further worker
|
||||
/// transitions are expected.
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -118,7 +124,8 @@ pub enum NodeRef {
|
||||
}
|
||||
|
||||
impl NodeRef {
|
||||
/// Stringified id with kind prefix, suitable for dedupe-key composition.
|
||||
/// Stringified id with kind prefix (`leaf:` or `summary:`), suitable
|
||||
/// for dedupe-key composition.
|
||||
pub fn dedupe_fragment(&self) -> String {
|
||||
match self {
|
||||
NodeRef::Leaf { chunk_id } => format!("leaf:{chunk_id}"),
|
||||
@@ -133,6 +140,8 @@ pub struct ExtractChunkPayload {
|
||||
}
|
||||
|
||||
impl ExtractChunkPayload {
|
||||
/// Stable dedupe key written to `mem_tree_jobs.dedupe_key` so a partial
|
||||
/// unique index can suppress in-flight duplicates.
|
||||
pub fn dedupe_key(&self) -> String {
|
||||
format!("extract:{}", self.chunk_id)
|
||||
}
|
||||
@@ -155,6 +164,8 @@ pub struct AppendBufferPayload {
|
||||
}
|
||||
|
||||
impl AppendBufferPayload {
|
||||
/// Stable dedupe key written to `mem_tree_jobs.dedupe_key` so a partial
|
||||
/// unique index can suppress in-flight duplicates.
|
||||
pub fn dedupe_key(&self) -> String {
|
||||
let node_part = self.node.dedupe_fragment();
|
||||
match &self.target {
|
||||
@@ -179,6 +190,8 @@ pub struct SealPayload {
|
||||
}
|
||||
|
||||
impl SealPayload {
|
||||
/// Stable dedupe key written to `mem_tree_jobs.dedupe_key` so a partial
|
||||
/// unique index can suppress in-flight duplicates.
|
||||
pub fn dedupe_key(&self) -> String {
|
||||
// Active seal-job uniqueness is enforced per (tree, level): a seal
|
||||
// already in flight suppresses duplicate enqueues. Once the job
|
||||
@@ -194,6 +207,8 @@ pub struct TopicRoutePayload {
|
||||
}
|
||||
|
||||
impl TopicRoutePayload {
|
||||
/// Stable dedupe key written to `mem_tree_jobs.dedupe_key` so a partial
|
||||
/// unique index can suppress in-flight duplicates.
|
||||
pub fn dedupe_key(&self) -> String {
|
||||
format!("topic_route:{}", self.node.dedupe_fragment())
|
||||
}
|
||||
@@ -207,6 +222,8 @@ pub struct DigestDailyPayload {
|
||||
}
|
||||
|
||||
impl DigestDailyPayload {
|
||||
/// Stable dedupe key written to `mem_tree_jobs.dedupe_key` so a partial
|
||||
/// unique index can suppress in-flight duplicates.
|
||||
pub fn dedupe_key(&self) -> String {
|
||||
format!("digest_daily:{}", self.date_iso)
|
||||
}
|
||||
@@ -221,6 +238,8 @@ pub struct FlushStalePayload {
|
||||
}
|
||||
|
||||
impl FlushStalePayload {
|
||||
/// Stable dedupe key. `date_iso` scopes one flush per UTC day so the
|
||||
/// scheduler can re-enqueue safely without duplicating work.
|
||||
pub fn dedupe_key(&self, date_iso: &str) -> String {
|
||||
format!("flush_stale:{date_iso}")
|
||||
}
|
||||
@@ -259,6 +278,7 @@ pub struct NewJob {
|
||||
}
|
||||
|
||||
impl NewJob {
|
||||
/// Build an [`JobKind::ExtractChunk`] enqueue request.
|
||||
pub fn extract_chunk(p: &ExtractChunkPayload) -> Result<Self> {
|
||||
Ok(Self {
|
||||
kind: JobKind::ExtractChunk,
|
||||
@@ -269,6 +289,7 @@ impl NewJob {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an [`JobKind::AppendBuffer`] enqueue request.
|
||||
pub fn append_buffer(p: &AppendBufferPayload) -> Result<Self> {
|
||||
Ok(Self {
|
||||
kind: JobKind::AppendBuffer,
|
||||
@@ -279,6 +300,7 @@ impl NewJob {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an [`JobKind::Seal`] enqueue request.
|
||||
pub fn seal(p: &SealPayload) -> Result<Self> {
|
||||
Ok(Self {
|
||||
kind: JobKind::Seal,
|
||||
@@ -289,6 +311,7 @@ impl NewJob {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an [`JobKind::TopicRoute`] enqueue request.
|
||||
pub fn topic_route(p: &TopicRoutePayload) -> Result<Self> {
|
||||
Ok(Self {
|
||||
kind: JobKind::TopicRoute,
|
||||
@@ -299,6 +322,7 @@ impl NewJob {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an [`JobKind::DigestDaily`] enqueue request.
|
||||
pub fn digest_daily(p: &DigestDailyPayload) -> Result<Self> {
|
||||
Ok(Self {
|
||||
kind: JobKind::DigestDaily,
|
||||
@@ -309,6 +333,7 @@ impl NewJob {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an [`JobKind::FlushStale`] enqueue request scoped to `date_iso`.
|
||||
pub fn flush_stale(p: &FlushStalePayload, date_iso: &str) -> Result<Self> {
|
||||
Ok(Self {
|
||||
kind: JobKind::FlushStale,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Worker pool: claims jobs from `mem_tree_jobs`, dispatches them through
|
||||
//! [`handlers::handle_job`], and settles the row. A small global semaphore
|
||||
//! caps concurrent LLM-bound work; non-LLM jobs run unrestricted.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -16,6 +20,8 @@ const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
static WORKER_NOTIFY: OnceLock<Arc<Notify>> = OnceLock::new();
|
||||
static STARTED: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
/// Notify any idle workers so they re-poll immediately instead of waiting
|
||||
/// out [`POLL_INTERVAL`]. Cheap no-op before [`start`] has run.
|
||||
pub fn wake_workers() {
|
||||
if let Some(notify) = WORKER_NOTIFY.get() {
|
||||
notify.notify_waiters();
|
||||
@@ -67,6 +73,9 @@ pub fn start(config: Config) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Claim and run a single job. Returns `true` when work was processed,
|
||||
/// `false` when no eligible row was available. Test entry point — the
|
||||
/// production worker loop calls [`run_once_with_semaphore`] directly.
|
||||
pub async fn run_once(config: &Config) -> Result<bool> {
|
||||
let llm_slots = Arc::new(Semaphore::new(1));
|
||||
run_once_with_semaphore(config, llm_slots).await
|
||||
|
||||
@@ -25,16 +25,16 @@
|
||||
pub mod canonicalize;
|
||||
pub mod chunker;
|
||||
pub mod content_store;
|
||||
pub mod global_tree;
|
||||
pub mod ingest;
|
||||
pub mod jobs;
|
||||
pub mod retrieval;
|
||||
pub mod rpc;
|
||||
pub mod schemas;
|
||||
pub mod score;
|
||||
pub mod source_tree;
|
||||
pub mod store;
|
||||
pub mod topic_tree;
|
||||
pub mod tree_global;
|
||||
pub mod tree_source;
|
||||
pub mod tree_topic;
|
||||
pub mod types;
|
||||
pub mod util;
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Retrieval
|
||||
|
||||
Phase 4 (#710) — search-time pipeline for the hierarchical memory tree. Exposes six LLM-callable primitives that read across the source / topic / global trees built by Phase 3 and surface results in a uniform [`RetrievalHit`] shape. There is no classifier, gate, or composer in this phase — orchestration (which tool to call, how to combine) is left to the calling LLM.
|
||||
|
||||
## Public surface
|
||||
|
||||
- `pub fn query_source` / `pub struct QuerySourceRequest` — `source.rs`, `rpc.rs` — per-source summary retrieval, optional semantic rerank.
|
||||
- `pub fn query_global` / `pub struct QueryGlobalRequest` — `global.rs`, `rpc.rs` — cross-source digest for a window in days.
|
||||
- `pub fn query_topic` / `pub struct QueryTopicRequest` — `topic.rs`, `rpc.rs` — entity-scoped retrieval across every tree.
|
||||
- `pub fn search_entities` / `pub struct SearchEntitiesRequest` — `search.rs`, `rpc.rs` — fuzzy LIKE lookup over the entity index.
|
||||
- `pub fn drill_down` / `pub struct DrillDownRequest` — `drill_down.rs`, `rpc.rs` — walk `child_ids` from a summary one (or more) levels down.
|
||||
- `pub fn fetch_leaves` / `pub struct FetchLeavesRequest` — `fetch.rs`, `rpc.rs` — batch-hydrate raw chunks by id (cap 20).
|
||||
- `pub struct RetrievalHit` / `pub enum NodeKind` / `pub struct QueryResponse` / `pub struct EntityMatch` — `types.rs` — wire shapes shared by every tool.
|
||||
- `pub fn all_retrieval_controller_schemas` / `pub fn all_retrieval_registered_controllers` — `schemas.rs` — registry exports wired into `core::all`.
|
||||
|
||||
## Files
|
||||
|
||||
- `mod.rs` — module surface; declares submodules and the `pub use` re-exports.
|
||||
- `types.rs` — shared wire types and the `hit_from_summary` / `hit_from_chunk` helpers.
|
||||
- `source.rs` / `global.rs` / `topic.rs` — query the corresponding tree level.
|
||||
- `search.rs` — free-text LIKE search over `mem_tree_entity_index`.
|
||||
- `drill_down.rs` — BFS walk of summary children with optional semantic rerank.
|
||||
- `fetch.rs` — batch hydration of leaf chunks.
|
||||
- `rpc.rs` — request / response structs and the JSON-RPC handler bodies.
|
||||
- `schemas.rs` — `ControllerSchema` definitions and dispatch table for the controller registry.
|
||||
- `integration_test.rs` — end-to-end test that drives the real ingest pipeline through every retrieval tool.
|
||||
|
||||
## Tests
|
||||
|
||||
Per-tool unit tests live in `mod tests` inside each file. The `integration_test.rs` module is private to this crate and exercises ingest → seal → retrieve in one workspace.
|
||||
@@ -28,8 +28,8 @@ use crate::openhuman::memory::tree::retrieval::types::{
|
||||
hit_from_chunk, hit_from_summary, RetrievalHit,
|
||||
};
|
||||
use crate::openhuman::memory::tree::score::embed::{build_embedder_from_config, cosine_similarity};
|
||||
use crate::openhuman::memory::tree::source_tree::store;
|
||||
use crate::openhuman::memory::tree::store::{get_chunk, get_chunk_embedding};
|
||||
use crate::openhuman::memory::tree::tree_source::store;
|
||||
|
||||
/// Walk the summary hierarchy down one step (or more if `max_depth > 1`)
|
||||
/// and return the hydrated child hits. Children at level 1 are raw chunks;
|
||||
@@ -239,13 +239,13 @@ fn walk_with_embeddings(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::memory::tree::content_store;
|
||||
use crate::openhuman::memory::tree::source_tree::bucket_seal::{
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_source::bucket_seal::{
|
||||
append_leaf, LabelStrategy, LeafRef,
|
||||
};
|
||||
use crate::openhuman::memory::tree::source_tree::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::source_tree::summariser::inert::InertSummariser;
|
||||
use crate::openhuman::memory::tree::source_tree::types::TreeKind;
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser;
|
||||
use crate::openhuman::memory::tree::tree_source::types::TreeKind;
|
||||
use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef};
|
||||
use chrono::Utc;
|
||||
use tempfile::TempDir;
|
||||
@@ -282,7 +282,7 @@ mod tests {
|
||||
tags: vec![],
|
||||
source_ref: Some(SourceRef::new("slack://x")),
|
||||
},
|
||||
token_count: crate::openhuman::memory::tree::source_tree::types::TOKEN_BUDGET * 6
|
||||
token_count: crate::openhuman::memory::tree::tree_source::types::TOKEN_BUDGET * 6
|
||||
/ 10,
|
||||
seq_in_source: seq,
|
||||
created_at: ts,
|
||||
@@ -305,7 +305,7 @@ mod tests {
|
||||
&tree,
|
||||
&LeafRef {
|
||||
chunk_id: c.id.clone(),
|
||||
token_count: crate::openhuman::memory::tree::source_tree::types::TOKEN_BUDGET
|
||||
token_count: crate::openhuman::memory::tree::tree_source::types::TOKEN_BUDGET
|
||||
* 6
|
||||
/ 10,
|
||||
timestamp: ts,
|
||||
@@ -410,9 +410,9 @@ mod tests {
|
||||
// (or similar — the key invariant is that BFS returns all siblings at
|
||||
// one depth before any descendant at a deeper depth).
|
||||
|
||||
use crate::openhuman::memory::tree::source_tree::store as tree_store;
|
||||
use crate::openhuman::memory::tree::source_tree::types::{SummaryNode, Tree, TreeStatus};
|
||||
use crate::openhuman::memory::tree::store::with_connection;
|
||||
use crate::openhuman::memory::tree::tree_source::store as tree_store;
|
||||
use crate::openhuman::memory::tree::tree_source::types::{SummaryNode, Tree, TreeStatus};
|
||||
|
||||
/// Build a tiny 2-level tree directly via store inserts so we can
|
||||
/// assert BFS ordering without needing ~100 leaves to cascade L1→L2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `memory_tree_query_global` — window-scoped recap from the global digest
|
||||
//! (Phase 4 / #710).
|
||||
//!
|
||||
//! Thin wrapper on [`global_tree::recap::recap`]. The recap function does
|
||||
//! Thin wrapper on [`tree_global::recap::recap`]. The recap function does
|
||||
//! the heavy lifting (level selection + time-range filter); we convert its
|
||||
//! output into the uniform [`RetrievalHit`] shape.
|
||||
//!
|
||||
@@ -13,10 +13,10 @@ use anyhow::Result;
|
||||
use chrono::Duration;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::memory::tree::global_tree::recap::{recap, RecapOutput};
|
||||
use crate::openhuman::memory::tree::global_tree::registry::get_or_create_global_tree;
|
||||
use crate::openhuman::memory::tree::retrieval::types::{NodeKind, QueryResponse, RetrievalHit};
|
||||
use crate::openhuman::memory::tree::source_tree::types::TreeKind;
|
||||
use crate::openhuman::memory::tree::tree_global::recap::{recap, RecapOutput};
|
||||
use crate::openhuman::memory::tree::tree_global::registry::get_or_create_global_tree;
|
||||
use crate::openhuman::memory::tree::tree_source::types::TreeKind;
|
||||
|
||||
/// Return the global digest for the given window in days. Always returns a
|
||||
/// [`QueryResponse`]; the response is empty if the global tree has no
|
||||
@@ -88,13 +88,13 @@ fn recap_to_hits(recap: RecapOutput, tree_id: &str, tree_scope: &str) -> Vec<Ret
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::memory::tree::content_store;
|
||||
use crate::openhuman::memory::tree::global_tree::digest::{end_of_day_digest, DigestOutcome};
|
||||
use crate::openhuman::memory::tree::source_tree::bucket_seal::{
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_global::digest::{end_of_day_digest, DigestOutcome};
|
||||
use crate::openhuman::memory::tree::tree_source::bucket_seal::{
|
||||
append_leaf, LabelStrategy, LeafRef,
|
||||
};
|
||||
use crate::openhuman::memory::tree::source_tree::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::source_tree::summariser::inert::InertSummariser;
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser;
|
||||
use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef};
|
||||
use chrono::{DateTime, Utc};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -194,13 +194,13 @@ async fn ingest_populates_chunk_embeddings() {
|
||||
async fn seal_populates_summary_embedding() {
|
||||
use crate::openhuman::memory::tree::content_store;
|
||||
use crate::openhuman::memory::tree::score::embed::EMBEDDING_DIM;
|
||||
use crate::openhuman::memory::tree::source_tree::bucket_seal::{
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_source::bucket_seal::{
|
||||
append_leaf, LabelStrategy, LeafRef,
|
||||
};
|
||||
use crate::openhuman::memory::tree::source_tree::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::source_tree::store as src_store;
|
||||
use crate::openhuman::memory::tree::source_tree::summariser::inert::InertSummariser;
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::tree_source::store as src_store;
|
||||
use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser;
|
||||
use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef};
|
||||
|
||||
let (_tmp, cfg) = test_config();
|
||||
|
||||
@@ -23,6 +23,8 @@ use crate::rpc::RpcOutcome;
|
||||
|
||||
// ── query_source ──────────────────────────────────────────────────────
|
||||
|
||||
/// Request body for `memory_tree_query_source`. All fields are optional;
|
||||
/// see [`super::source::query_source`] for selection semantics.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct QuerySourceRequest {
|
||||
#[serde(default)]
|
||||
@@ -41,6 +43,9 @@ pub struct QuerySourceRequest {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// JSON-RPC handler body for `memory_tree_query_source`. Parses the
|
||||
/// request, delegates to [`super::source::query_source`], and wraps the
|
||||
/// outcome with a PII-redacted log line.
|
||||
pub async fn query_source_rpc(
|
||||
config: &Config,
|
||||
req: QuerySourceRequest,
|
||||
@@ -76,11 +81,13 @@ pub async fn query_source_rpc(
|
||||
|
||||
// ── query_global ──────────────────────────────────────────────────────
|
||||
|
||||
/// Request body for `memory_tree_query_global`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct QueryGlobalRequest {
|
||||
pub window_days: u32,
|
||||
}
|
||||
|
||||
/// JSON-RPC handler body for `memory_tree_query_global`.
|
||||
pub async fn query_global_rpc(
|
||||
config: &Config,
|
||||
req: QueryGlobalRequest,
|
||||
@@ -97,6 +104,7 @@ pub async fn query_global_rpc(
|
||||
|
||||
// ── query_topic ───────────────────────────────────────────────────────
|
||||
|
||||
/// Request body for `memory_tree_query_topic`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct QueryTopicRequest {
|
||||
pub entity_id: String,
|
||||
@@ -110,6 +118,7 @@ pub struct QueryTopicRequest {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// JSON-RPC handler body for `memory_tree_query_topic`.
|
||||
pub async fn query_topic_rpc(
|
||||
config: &Config,
|
||||
req: QueryTopicRequest,
|
||||
@@ -145,6 +154,7 @@ pub async fn query_topic_rpc(
|
||||
|
||||
// ── search_entities ───────────────────────────────────────────────────
|
||||
|
||||
/// Request body for `memory_tree_search_entities`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SearchEntitiesRequest {
|
||||
pub query: String,
|
||||
@@ -154,11 +164,14 @@ pub struct SearchEntitiesRequest {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Response envelope for `memory_tree_search_entities`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SearchEntitiesResponse {
|
||||
pub matches: Vec<EntityMatch>,
|
||||
}
|
||||
|
||||
/// JSON-RPC handler body for `memory_tree_search_entities`. Validates the
|
||||
/// optional `kinds` filter against [`EntityKind`].
|
||||
pub async fn search_entities_rpc(
|
||||
config: &Config,
|
||||
req: SearchEntitiesRequest,
|
||||
@@ -191,6 +204,7 @@ pub async fn search_entities_rpc(
|
||||
|
||||
// ── drill_down ────────────────────────────────────────────────────────
|
||||
|
||||
/// Request body for `memory_tree_drill_down`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DrillDownRequest {
|
||||
pub node_id: String,
|
||||
@@ -207,11 +221,13 @@ pub struct DrillDownRequest {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Response envelope for `memory_tree_drill_down`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DrillDownResponse {
|
||||
pub hits: Vec<RetrievalHit>,
|
||||
}
|
||||
|
||||
/// JSON-RPC handler body for `memory_tree_drill_down`.
|
||||
pub async fn drill_down_rpc(
|
||||
config: &Config,
|
||||
req: DrillDownRequest,
|
||||
@@ -243,16 +259,19 @@ pub async fn drill_down_rpc(
|
||||
|
||||
// ── fetch_leaves ──────────────────────────────────────────────────────
|
||||
|
||||
/// Request body for `memory_tree_fetch_leaves`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FetchLeavesRequest {
|
||||
pub chunk_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Response envelope for `memory_tree_fetch_leaves`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FetchLeavesResponse {
|
||||
pub hits: Vec<RetrievalHit>,
|
||||
}
|
||||
|
||||
/// JSON-RPC handler body for `memory_tree_fetch_leaves`.
|
||||
pub async fn fetch_leaves_rpc(
|
||||
config: &Config,
|
||||
req: FetchLeavesRequest,
|
||||
|
||||
@@ -23,6 +23,8 @@ use crate::rpc::RpcOutcome;
|
||||
|
||||
const NAMESPACE: &str = "memory_tree";
|
||||
|
||||
/// Return one [`ControllerSchema`] per Phase 4 retrieval tool. Used by
|
||||
/// the controller registry to publish the `memory_tree.*` schemas.
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("query_source"),
|
||||
@@ -34,6 +36,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Return one [`RegisteredController`] per Phase 4 retrieval tool — schema
|
||||
/// paired with its dispatch handler. Wired into `core::all` at startup.
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
@@ -90,6 +94,8 @@ fn query_response_outputs() -> Vec<FieldSchema> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Look up the [`ControllerSchema`] for a single retrieval `function`
|
||||
/// name. Unknown names return a placeholder schema with an `error` field.
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"query_source" => ControllerSchema {
|
||||
@@ -143,7 +149,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "query_global",
|
||||
description: "Return the global digest for the last N days. Wraps \
|
||||
`global_tree::recap`; the returned hit carries `child_ids` pointing \
|
||||
`tree_global::recap`; the returned hit carries `child_ids` pointing \
|
||||
at the folded per-day summary ids for drill-down.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "window_days",
|
||||
|
||||
@@ -26,8 +26,8 @@ use crate::openhuman::memory::tree::retrieval::types::{
|
||||
hit_from_summary, QueryResponse, RetrievalHit,
|
||||
};
|
||||
use crate::openhuman::memory::tree::score::embed::{build_embedder_from_config, cosine_similarity};
|
||||
use crate::openhuman::memory::tree::source_tree::store;
|
||||
use crate::openhuman::memory::tree::source_tree::types::{SummaryNode, Tree, TreeKind};
|
||||
use crate::openhuman::memory::tree::tree_source::store;
|
||||
use crate::openhuman::memory::tree::tree_source::types::{SummaryNode, Tree, TreeKind};
|
||||
use crate::openhuman::memory::tree::types::SourceKind;
|
||||
|
||||
const DEFAULT_LIMIT: usize = 10;
|
||||
@@ -307,12 +307,12 @@ fn filter_by_window(hits: Vec<RetrievalHit>, window_days: u32) -> Vec<RetrievalH
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::memory::tree::content_store;
|
||||
use crate::openhuman::memory::tree::source_tree::bucket_seal::{
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_source::bucket_seal::{
|
||||
append_leaf, LabelStrategy, LeafRef,
|
||||
};
|
||||
use crate::openhuman::memory::tree::source_tree::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::source_tree::summariser::inert::InertSummariser;
|
||||
use crate::openhuman::memory::tree::store::upsert_chunks;
|
||||
use crate::openhuman::memory::tree::tree_source::registry::get_or_create_source_tree;
|
||||
use crate::openhuman::memory::tree::tree_source::summariser::inert::InertSummariser;
|
||||
use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef};
|
||||
use chrono::{DateTime, TimeZone};
|
||||
use tempfile::TempDir;
|
||||
@@ -346,7 +346,7 @@ mod tests {
|
||||
tags: vec!["eng".into()],
|
||||
source_ref: Some(SourceRef::new(format!("slack://{scope}/{seq}"))),
|
||||
},
|
||||
token_count: crate::openhuman::memory::tree::source_tree::types::TOKEN_BUDGET * 6
|
||||
token_count: crate::openhuman::memory::tree::tree_source::types::TOKEN_BUDGET * 6
|
||||
/ 10,
|
||||
seq_in_source: seq,
|
||||
created_at: ts,
|
||||
@@ -369,7 +369,7 @@ mod tests {
|
||||
&tree,
|
||||
&LeafRef {
|
||||
chunk_id: c.id.clone(),
|
||||
token_count: crate::openhuman::memory::tree::source_tree::types::TOKEN_BUDGET
|
||||
token_count: crate::openhuman::memory::tree::tree_source::types::TOKEN_BUDGET
|
||||
* 6
|
||||
/ 10,
|
||||
timestamp: ts,
|
||||
@@ -527,7 +527,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn query_reranks_by_cosine_similarity() {
|
||||
use crate::openhuman::memory::tree::score::embed::{pack_embedding, EMBEDDING_DIM};
|
||||
use crate::openhuman::memory::tree::source_tree::store as src_store;
|
||||
use crate::openhuman::memory::tree::tree_source::store as src_store;
|
||||
|
||||
let (_tmp, cfg) = test_config();
|
||||
let ts = Utc::now();
|
||||
@@ -550,14 +550,14 @@ mod tests {
|
||||
use crate::openhuman::memory::tree::store::with_connection;
|
||||
let phoenix_tree = src_store::get_tree_by_scope(
|
||||
&cfg,
|
||||
crate::openhuman::memory::tree::source_tree::types::TreeKind::Source,
|
||||
crate::openhuman::memory::tree::tree_source::types::TreeKind::Source,
|
||||
"slack:#phoenix",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let unrelated_tree = src_store::get_tree_by_scope(
|
||||
&cfg,
|
||||
crate::openhuman::memory::tree::source_tree::types::TreeKind::Source,
|
||||
crate::openhuman::memory::tree::tree_source::types::TreeKind::Source,
|
||||
"slack:#unrelated",
|
||||
)
|
||||
.unwrap()
|
||||
@@ -629,8 +629,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn legacy_null_embedding_rows_sort_last() {
|
||||
use crate::openhuman::memory::tree::score::embed::{pack_embedding, EMBEDDING_DIM};
|
||||
use crate::openhuman::memory::tree::source_tree::store as src_store;
|
||||
use crate::openhuman::memory::tree::source_tree::types::TreeKind;
|
||||
use crate::openhuman::memory::tree::tree_source::store as src_store;
|
||||
use crate::openhuman::memory::tree::tree_source::types::TreeKind;
|
||||
|
||||
let (_tmp, cfg) = test_config();
|
||||
let ts = Utc::now();
|
||||
|
||||
@@ -23,8 +23,8 @@ use crate::openhuman::memory::tree::retrieval::types::{
|
||||
};
|
||||
use crate::openhuman::memory::tree::score::embed::{build_embedder_from_config, cosine_similarity};
|
||||
use crate::openhuman::memory::tree::score::store::{lookup_entity, EntityHit};
|
||||
use crate::openhuman::memory::tree::source_tree::store;
|
||||
use crate::openhuman::memory::tree::source_tree::types::{Tree, TreeKind};
|
||||
use crate::openhuman::memory::tree::tree_source::store;
|
||||
use crate::openhuman::memory::tree::tree_source::types::{Tree, TreeKind};
|
||||
|
||||
const DEFAULT_LIMIT: usize = 10;
|
||||
/// How many rows we pull from the entity index before filtering. We give
|
||||
@@ -161,8 +161,8 @@ async fn rerank_by_semantic_similarity(
|
||||
hits: Vec<RetrievalHit>,
|
||||
) -> Result<Vec<RetrievalHit>> {
|
||||
use crate::openhuman::memory::tree::retrieval::types::NodeKind;
|
||||
use crate::openhuman::memory::tree::source_tree::store as src_store;
|
||||
use crate::openhuman::memory::tree::store::get_chunk_embedding;
|
||||
use crate::openhuman::memory::tree::tree_source::store as src_store;
|
||||
|
||||
let embedder = build_embedder_from_config(config)?;
|
||||
let query_vec = embedder.embed(query).await?;
|
||||
@@ -517,11 +517,11 @@ mod tests {
|
||||
use crate::openhuman::memory::tree::score::extract::EntityKind;
|
||||
use crate::openhuman::memory::tree::score::resolver::CanonicalEntity;
|
||||
use crate::openhuman::memory::tree::score::store as score_store;
|
||||
use crate::openhuman::memory::tree::source_tree::store as tree_store;
|
||||
use crate::openhuman::memory::tree::source_tree::types::{
|
||||
use crate::openhuman::memory::tree::store::with_connection;
|
||||
use crate::openhuman::memory::tree::tree_source::store as tree_store;
|
||||
use crate::openhuman::memory::tree::tree_source::types::{
|
||||
SummaryNode, Tree, TreeKind, TreeStatus,
|
||||
};
|
||||
use crate::openhuman::memory::tree::store::with_connection;
|
||||
|
||||
let (_tmp, cfg) = test_config();
|
||||
let ts = Utc::now();
|
||||
|
||||
@@ -19,7 +19,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::openhuman::memory::tree::score::extract::EntityKind;
|
||||
use crate::openhuman::memory::tree::source_tree::types::{SummaryNode, Tree, TreeKind};
|
||||
use crate::openhuman::memory::tree::tree_source::types::{SummaryNode, Tree, TreeKind};
|
||||
use crate::openhuman::memory::tree::types::{Chunk, SourceKind};
|
||||
|
||||
/// Whether a hit represents a leaf (raw chunk) or a summary node.
|
||||
@@ -34,6 +34,8 @@ pub enum NodeKind {
|
||||
}
|
||||
|
||||
impl NodeKind {
|
||||
/// Stable lowercase string form (`"leaf"` / `"summary"`) — matches the
|
||||
/// serde representation and is suitable for SQL discriminator columns.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Leaf => "leaf",
|
||||
|
||||
@@ -116,11 +116,14 @@ pub struct ListChunksRequest {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Response shape for the `list_chunks` RPC.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ListChunksResponse {
|
||||
pub chunks: Vec<Chunk>,
|
||||
}
|
||||
|
||||
/// `list_chunks` RPC handler. Filters and returns persisted chunks ordered by
|
||||
/// timestamp DESC.
|
||||
pub async fn list_chunks_rpc(
|
||||
config: &Config,
|
||||
req: ListChunksRequest,
|
||||
@@ -151,16 +154,19 @@ pub async fn list_chunks_rpc(
|
||||
))
|
||||
}
|
||||
|
||||
/// Request shape for the `get_chunk` RPC.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GetChunkRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Response shape for the `get_chunk` RPC.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GetChunkResponse {
|
||||
pub chunk: Option<Chunk>,
|
||||
}
|
||||
|
||||
/// `get_chunk` RPC handler. Returns the chunk identified by `id`, or `None`.
|
||||
pub async fn get_chunk_rpc(
|
||||
config: &Config,
|
||||
req: GetChunkRequest,
|
||||
@@ -192,6 +198,7 @@ pub struct TriggerDigestRequest {
|
||||
pub date_iso: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from the `trigger_digest` RPC.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TriggerDigestResponse {
|
||||
/// True when the job was newly enqueued; false when an active job for
|
||||
@@ -205,6 +212,9 @@ pub struct TriggerDigestResponse {
|
||||
pub date_iso: String,
|
||||
}
|
||||
|
||||
/// `trigger_digest` RPC handler. Manually enqueues the global tree's daily
|
||||
/// digest job for `date_iso` (defaults to yesterday in UTC); idempotent via the
|
||||
/// jobs-queue dedupe index.
|
||||
pub async fn trigger_digest_rpc(
|
||||
config: &Config,
|
||||
req: TriggerDigestRequest,
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::rpc::RpcOutcome;
|
||||
|
||||
const NAMESPACE: &str = "memory_tree";
|
||||
|
||||
/// All `memory_tree` controller schemas, used by the registry to advertise
|
||||
/// inputs/outputs to CLI + JSON-RPC consumers.
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("ingest"),
|
||||
@@ -27,6 +29,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Registered `memory_tree` controllers (schema + handler pairs) wired into
|
||||
/// `core::all`.
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
@@ -48,6 +52,7 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Lookup the [`ControllerSchema`] for a single `memory_tree` function name.
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"ingest" => ControllerSchema {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Memory tree — score (Phase 2 / #708)
|
||||
|
||||
Per-chunk admission, enrichment, and entity indexing for the bucket-seal-ready memory tree. Sits between leaf chunking and L0 buffer append: every chunk passes through `score_chunk` which decides whether to keep it, runs entity extraction, and persists score rationale + an inverted entity index used by retrieval.
|
||||
|
||||
## Public surface
|
||||
|
||||
- `pub fn score_chunk` / `pub fn score_chunks` / `pub fn score_chunks_fast` — `mod.rs` — scoring pipeline entry points (full / batch / cheap-only batch).
|
||||
- `pub struct ScoreResult` / `pub struct ScoringConfig` — `mod.rs` — outcome and configuration of one scoring pass.
|
||||
- `pub fn persist_score` / `persist_score_tx` — `mod.rs` — write the score row + entity-index rows for one kept chunk.
|
||||
- `pub const DEFAULT_DROP_THRESHOLD` / `DEFAULT_DEFINITE_KEEP` / `DEFAULT_DEFINITE_DROP` — `mod.rs` — admission band defaults.
|
||||
|
||||
## Subdirectories
|
||||
|
||||
- `signals/` — per-signal feature computation (token count, unique words, metadata weight, source weight, interaction tags, entity density, LLM importance) plus the weighted combine that produces the final `[0.0, 1.0]` total.
|
||||
- `extract/` — entity extraction: `EntityExtractor` trait, `RegexEntityExtractor` for mechanical identifiers (email, URL, handle, hashtag), `LlmEntityExtractor` for semantic NER + importance rating, `CompositeExtractor` for chaining them.
|
||||
- `embed/` — Phase 4 vector embedder: `Embedder` trait, `OllamaEmbedder` (default), `InertEmbedder` (tests), pack/unpack helpers for the SQLite BLOB storage layout.
|
||||
|
||||
## Files
|
||||
|
||||
- `mod.rs` — orchestration: `score_chunk` runs extraction → cheap signals → optional borderline LLM call → admission gate → canonicalisation.
|
||||
- `store.rs` — SQLite CRUD for `mem_tree_score` (per-chunk rationale) and `mem_tree_entity_index` (inverted index `entity_id → node_id`).
|
||||
- `resolver.rs` — entity canonicalisation: normalises surface forms (lowercase emails, strip leading `@`/`#`) and assigns stable `canonical_id` strings; promotes extracted topics into the canonical entity stream.
|
||||
- `mod_tests.rs` / `store_tests.rs` — unit tests.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Memory tree — score embed (Phase 4 / #710)
|
||||
|
||||
Vector embedder for chunks and summaries. Produces a fixed-dimension (`EMBEDDING_DIM = 768`) `Vec<f32>` per text so retrieval can rerank candidates by semantic similarity. Default backend is local Ollama running `nomic-embed-text`; tests use the deterministic `InertEmbedder` so no network is required.
|
||||
|
||||
## Public surface
|
||||
|
||||
- `pub trait Embedder` — `mod.rs` — `embed(text) -> Vec<f32>` contract; impls must return exactly `EMBEDDING_DIM` floats.
|
||||
- `pub fn build_embedder_from_config` — `factory.rs` — returns `OllamaEmbedder` when configured, otherwise `InertEmbedder` (or bails when `embedding_strict = true`).
|
||||
- `pub struct OllamaEmbedder` — `ollama.rs` — HTTP client posting to `{endpoint}/api/embeddings`.
|
||||
- `pub struct InertEmbedder` — `inert.rs` — zero-vector embedder for tests.
|
||||
- `pub fn cosine_similarity` / `pack_embedding` / `unpack_embedding` / `pack_checked` / `decode_optional_blob` — `mod.rs` — math + SQLite BLOB packing helpers.
|
||||
|
||||
## Files
|
||||
|
||||
- `mod.rs` — trait, `EMBEDDING_DIM`, math + pack/unpack helpers, write-time / read-time semantics.
|
||||
- `factory.rs` — `Config::memory_tree`-driven embedder selection with `embedding_strict` opt-in.
|
||||
- `ollama.rs` — Ollama `/api/embeddings` client; defaults at `http://localhost:11434` / `nomic-embed-text` / 10s timeout.
|
||||
- `inert.rs` — zero-vector embedder; cosine similarity between any two inert vectors is 0.0 (zero-magnitude short-circuit), so retrieval tests that need real reranking should hand-stitch embeddings instead of relying on this path.
|
||||
@@ -22,6 +22,7 @@ use super::{Embedder, EMBEDDING_DIM};
|
||||
pub struct InertEmbedder;
|
||||
|
||||
impl InertEmbedder {
|
||||
/// Construct an inert embedder. Free — `InertEmbedder` is a ZST.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user