Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions crates/tinymemory-api/src/null.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,8 +56,8 @@ use crate::error::MemoryError;
use crate::goals::GoalsDoc;
use crate::health::MemoryHealth;
use crate::provider::types::{
DiffReport, EntityHit, ExportPage, ExportRecord, ImportOutcome, IngestItem, IngestOutcome,
MaintenanceReport, SnapshotRef, SourceItem, SourceScope,
DiffReport, EntityHit, ExportPage, ExportRecord, FlushOutcome, ImportOutcome, IngestItem,
IngestOutcome, MaintenanceReport, ResetOutcome, SnapshotRef, SourceItem, SourceScope,
};
use crate::provider::{
AddressBookSeedOutcome, ChunkDetail, ChunkEmbedding, ChunkQuery, CoverWindowQuery, EntityMatch,
Expand DownExpand Up@@ -478,6 +478,19 @@ impl MemoryMaintenance for NullMemoryProvider {
async fn doctor(&self) -> Result<MaintenanceReport, MemoryError> {
unsupported(Capability::Maintenance)
}

// The trait defaults these to an empty outcome, which is the right answer
// for a real driver that simply has nothing buffered or nothing derived.
// It is the wrong answer here: both *mutate*, and this provider stores
// nothing, so "flushed nothing" and "reset nothing" would read as work
// done rather than as a driver that cannot do it.
async fn flush_pending(&self) -> Result<FlushOutcome, MemoryError> {
unsupported(Capability::Maintenance)
}

async fn reset_derived_index(&self) -> Result<ResetOutcome, MemoryError> {
unsupported(Capability::Maintenance)
}
}

#[async_trait]
Expand DownExpand Up@@ -612,6 +625,14 @@ impl MemoryRetrieval for NullMemoryProvider {
unsupported(Capability::Retrieval)
}

async fn recall_namespace_recent(
&self,
_namespace: &str,
_limit: usize,
) -> Result<Vec<NamespaceMemoryHit>, MemoryError> {
unsupported(Capability::Retrieval)
}

async fn search_entities(
&self,
_query: &str,
Expand Down
3 changes: 3 additions & 0 deletions crates/tinymemory-api/src/null_tests.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,9 @@ fn every_optional_method_fails_with_its_advertised_family_name() {
tags: Vec::new(),
taint: MemoryTaint::Internal,
path_scope: None,
author: None,
channel_label: None,
platform: None,
};
assert_unsupported(block_on(driver.ingest_document(ingest)), Capability::Ingest);

Expand Down
20 changes: 19 additions & 1 deletion crates/tinymemory-api/src/provider/episodic.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,7 +48,9 @@ use crate::error::MemoryError;
// — they cross the module boundary, and a host that only makes calls must be
// able to name them without compiling this trait — and re-exported here so
// every historical path keeps resolving and the types stay the same types.
pub use tinymemory_bus::provider::episodic::{ConversationSegment, EpisodicTurn};
pub use tinymemory_bus::provider::episodic::{
ConversationSegment, EpisodicEvent, EpisodicTurn, EventKind,
};

/// The turn-by-turn conversation record.
///
Expand DownExpand Up@@ -89,12 +91,17 @@ pub trait MemoryEpisodic: Send + Sync {
/// # Errors
///
/// Backend failures only.
#[allow(
clippy::too_many_arguments,
reason = "mirrors the engine row it creates; a params struct would be its only caller's"
)]
async fn create_segment(
&self,
segment_id: &str,
session_id: &str,
namespace: &str,
start_episodic_id: i64,
start_seq: Option<u32>,
start_timestamp: f64,
now: f64,
) -> Result<(), MemoryError>;
Expand All@@ -108,6 +115,7 @@ pub trait MemoryEpisodic: Send + Sync {
&self,
segment_id: &str,
episodic_id: i64,
seq: Option<u32>,
timestamp: f64,
now: f64,
) -> Result<(), MemoryError>;
Expand DownExpand Up@@ -148,6 +156,16 @@ pub trait MemoryEpisodic: Send + Sync {
/// # Errors
///
/// Backend failures only.
/// Record one extracted event against its segment.
///
/// Keyed on `event_id`, so re-running extraction over the same segment
/// replaces its own rows rather than duplicating them.
///
/// # Errors
///
/// Backend failures only.
async fn insert_event(&self, event: &EpisodicEvent) -> Result<(), MemoryError>;

async fn upsert_segment_embedding(
&self,
segment_id: &str,
Expand Down
8 changes: 4 additions & 4 deletions crates/tinymemory-api/src/provider/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,7 +77,7 @@ pub use audit::{audit_provider, CapabilityAudit};
pub use chunks::{ChunkDetail, ChunkEmbedding, ChunkQuery, MemoryChunks};
pub use content::{MemoryDocuments, MemoryIngest, MemoryTree};
pub use driver::MemoryProvider;
pub use episodic::{ConversationSegment, EpisodicTurn, MemoryEpisodic};
pub use episodic::{ConversationSegment, EpisodicEvent, EpisodicTurn, EventKind, MemoryEpisodic};
pub use knowledge::{MemoryDiff, MemoryEntities, MemoryGraph, INBOUND_SCAN_LIMIT};
pub use mandatory::{MemoryCore, MemoryPortability, MemoryRecall};
pub use people::{
Expand All@@ -91,7 +91,7 @@ pub use retrieval::{
RetrievalNodeKind, RetrievalResponse, SourceRetrievalQuery,
};
pub use types::{
ChangeKind, DiffReport, EntityHit, EntityRef, ExportPage, ExportRecord, ImportOutcome,
IngestItem, IngestOutcome, MaintenanceReport, SnapshotRef, SourceChange, SourceItem,
SourceScope,
ChangeKind, DiffReport, EntityHit, EntityRef, ExportPage, ExportRecord, FlushOutcome,
ImportOutcome, IngestItem, IngestOutcome, MaintenanceReport, ResetOutcome, SnapshotRef,
SourceChange, SourceItem, SourceScope,
};
51 changes: 50 additions & 1 deletion crates/tinymemory-api/src/provider/records.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,8 @@ use async_trait::async_trait;
use crate::error::MemoryError;
use crate::goals::GoalsDoc;
use crate::provider::types::{
IngestOutcome, MaintenanceReport, QueueFailure, QueueStats, SourceItem, StoreStats,
FlushOutcome, IngestOutcome, MaintenanceReport, QueueFailure, QueueStats, ResetOutcome,
SourceItem, StoreStats,
};
use crate::tool_memory::ToolMemoryRule;
use crate::types::MemoryTaint;
Expand DownExpand Up@@ -263,4 +264,52 @@ pub trait MemoryMaintenance: Send + Sync {
async fn backfill_in_progress(&self) -> Result<bool, MemoryError> {
Ok(false)
}

/// Flush buffered work that is old enough to be written out.
///
/// The caller is a "flush now" control: a user who does not want to wait
/// for the scheduled window. Whether a flush is *scheduled* is the
/// driver's business — this asks it to consider the buffers now, and
/// reports what it found and whether it acted.
///
/// Deduplication is the driver's, not the caller's. Two flushes inside one
/// window must not schedule the work twice, and the second reports
/// `enqueued: false` with a truthful `stale_buffers` — which is why both
/// numbers are on [`FlushOutcome`] rather than a bare bool.
///
/// Defaulted to an empty outcome: a driver with nothing buffered has
/// nothing to flush, which is true of it rather than a refusal.
///
/// # Errors
///
/// Backend failures only.
async fn flush_pending(&self) -> Result<FlushOutcome, MemoryError> {
Ok(FlushOutcome::default())
}

/// Drop everything derived from stored content and schedule its
/// re-derivation.
///
/// Summaries, buffers, entity indexes and the trees over them are all
/// *derived* — recomputable from the chunks they were built from. This
/// discards them and queues the work to build them again. **Nothing a
/// caller wrote is deleted**, which is the invariant that makes it safe to
/// offer as an operator control at all; a driver that cannot promise that
/// must refuse rather than implement this.
///
/// Necessarily one operation. Deleting the derived rows without scheduling
/// re-derivation leaves a store that answers structural queries with
/// nothing and looks healthy doing it, and the two halves are not
/// separately useful.
///
/// Defaulted to an empty outcome, for a driver with nothing derived.
///
/// # Errors
///
/// Backend failures only. A driver that keeps derived state it cannot
/// rebuild should answer [`MemoryError::Unsupported`] rather than delete
/// it.
async fn reset_derived_index(&self) -> Result<ResetOutcome, MemoryError> {
Ok(ResetOutcome::default())
}
}
33 changes: 33 additions & 0 deletions crates/tinymemory-api/src/provider/retrieval.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,39 @@ pub trait MemoryRetrieval: Send + Sync {
exclude_session_id: Option<&str>,
) -> Result<Vec<NamespaceMemoryHit>, MemoryError>;

/// Namespace recall ordered by **recency**, with no query to rank against.
///
/// # Why this is not [`Self::recall_namespace_scored`] with an empty query
///
/// It looks like the same call with one argument left blank, and it is not.
/// The two share a prefix — loading the namespace's documents and key-value
/// records — and diverge after it. The scored path ranks candidates against
/// the query text; handed an empty string it still runs the ranking, with
/// nothing to rank against, and returns hits ordered by a similarity signal
/// computed from nothing.
///
/// This path never ranks. It orders by freshness and priority, which is
/// what a caller asking "what is in this namespace" means, and what a
/// context-assembly step needs when there is no user query yet.
///
/// The substitution is dangerous precisely because it compiles, returns
/// plausible hits, and quietly changes what the user gets back. A caller
/// that *has* a real query wants the scored path; one that does not wants
/// this.
///
/// Hits carry the same [`NamespaceMemoryHit`] shape as the scored path, so
/// a host re-ranking on engine signals treats the two uniformly.
///
/// # Errors
///
/// Backend failures; an unknown namespace yields an empty vector, which is
/// a true statement about it rather than a fault.
async fn recall_namespace_recent(
&self,
namespace: &str,
limit: usize,
) -> Result<Vec<NamespaceMemoryHit>, MemoryError>;

/// Free-text search over the entity index.
///
/// `kinds` filters by classification; `None` matches every kind. This is
Expand Down
2 changes: 1 addition & 1 deletion crates/tinymemory-bus/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
//! the members that carry them.
//!
//! TinyMemory ships as a loadable `TinyBus` module: `crates/tinymemory-module`
//! exports one object with 94 members on it, built as a `cdylib`. A host that
//! exports one object with 98 members on it, built as a `cdylib`. A host that
//! loads it — OpenHuman — can call into it but cannot `use` anything out of it,
//! so the payload vocabulary has to be published as an ordinary library. This
//! is that library.
Expand Down
14 changes: 13 additions & 1 deletion crates/tinymemory-bus/src/names.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,6 +161,12 @@ pub mod methods {
/// `BackfillInProgress` — whether a re-embedding backfill is still running
/// anywhere in the driver's process.
pub const BACKFILL_IN_PROGRESS: &str = "BackfillInProgress";
/// `RecallNamespaceRecent` — namespace recall ordered by recency, no query.
pub const RECALL_NAMESPACE_RECENT: &str = "RecallNamespaceRecent";
/// `FlushPending` — flush buffered work old enough to be written out.
pub const FLUSH_PENDING: &str = "FlushPending";
/// `ResetDerivedIndex` — drop derived state and schedule its rebuild.
pub const RESET_DERIVED_INDEX: &str = "ResetDerivedIndex";

// The people store: ranking, handles, scores and interactions.
/// `ListPeople` — list people.
Expand DownExpand Up@@ -243,14 +249,16 @@ pub mod methods {
pub const SET_SEGMENT_SUMMARY: &str = "SetSegmentSummary";
/// `UpsertSegmentEmbedding` — upsert segment embedding.
pub const UPSERT_SEGMENT_EMBEDDING: &str = "UpsertSegmentEmbedding";
/// `InsertEvent` — record one extracted event against its segment.
pub const INSERT_EVENT: &str = "InsertEvent";
}

/// Every member name, in the order the module declares them.
///
/// The order matters: `tinybus`'s `Interface::members()` returns declaration
/// order, and the module compares the two sequences directly rather than as
/// sets, so a reordering is caught alongside an addition or a removal.
pub const METHODS: [&str; 94] = [
pub const METHODS: [&str; 98] = [
methods::DRIVER_ID,
methods::CAPABILITIES,
methods::HEALTH,
Expand DownExpand Up@@ -307,6 +315,9 @@ pub const METHODS: [&str; 94] = [
methods::QUEUE_STATS,
methods::LATEST_QUEUE_FAILURE,
methods::BACKFILL_IN_PROGRESS,
methods::FLUSH_PENDING,
methods::RESET_DERIVED_INDEX,
methods::RECALL_NAMESPACE_RECENT,
methods::LIST_PEOPLE,
methods::GET_PERSON,
methods::RESOLVE_HANDLE,
Expand All@@ -333,6 +344,7 @@ pub const METHODS: [&str; 94] = [
methods::CLOSE_SEGMENT,
methods::SET_SEGMENT_SUMMARY,
methods::UPSERT_SEGMENT_EMBEDDING,
methods::INSERT_EVENT,
methods::UPSERT_FACET,
methods::UPSERT_PROVIDER_FACET,
methods::SET_FACET_USER_STATE,
Expand Down
69 changes: 69 additions & 0 deletions crates/tinymemory-bus/src/provider/episodic.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,4 +110,73 @@ pub struct ConversationSegment {
pub embedding: Option<Vec<f32>>,
/// Whether the segment is still open.
pub open: bool,
/// Stable per-session sequence of the first user turn, when the backing
/// store assigns one. The md-backed archivist store rounds timestamps to
/// milliseconds, so a fast turn can sort before its segment's
/// higher-precision start time — the sequence is the identity that
/// survives that, and segment selection prefers it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_seq: Option<u32>,
/// Sequence of the last appended user turn, likewise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_seq: Option<u32>,
}

/// What kind of durable fact an extracted event records.
///
/// Mirrors the engine's `event_log.event_type` vocabulary; the wire carries
/// the same lowercase identifiers.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventKind {
/// A stated fact about the world or the user.
Fact,
/// A decision that was made.
Decision,
/// A commitment somebody took on.
Commitment,
/// A preference the user expressed.
Preference,
/// A question left open.
Question,
/// Something anticipated to happen.
Foresight,
}

/// One extracted event, ready to be recorded against its segment.
///
/// Events are segment-derived episodic artifacts — a summariser or heuristic
/// reads a closed segment and records the durable facts it found. The driver
/// owns the table; the caller owns the extraction policy, which is why the
/// record arrives fully formed rather than as text to extract from.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EpisodicEvent {
/// Caller-assigned id; an upsert key, so a re-run replaces its own rows.
pub event_id: String,
/// Segment the event was extracted from.
pub segment_id: String,
/// Session that segment belongs to.
pub session_id: String,
/// Namespace the event is scoped to.
pub namespace: String,
/// What kind of fact this is.
pub kind: EventKind,
/// The event, in prose.
pub content: String,
/// Who or what the event is about, when extraction identified one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
/// A time the prose refers to, verbatim, when extraction found one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp_ref: Option<String>,
/// Extraction confidence in `[0, 1]`.
pub confidence: f64,
/// Embedding for the content, when the caller computed one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding: Option<Vec<f32>>,
/// Turn ids the event was derived from, encoded by the caller.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_turn_ids: Option<String>,
/// When the event was recorded, seconds since the epoch.
pub created_at: f64,
}
Loading
Loading