From ba511be87109260d153ea6a164a972cf533676d2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:17:19 +0300 Subject: [PATCH 01/18] chore: files changed crates/tinymemory-tools/src/layout/types.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-tools/src/layout/types.rs | 99 +++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 crates/tinymemory-tools/src/layout/types.rs diff --git a/crates/tinymemory-tools/src/layout/types.rs b/crates/tinymemory-tools/src/layout/types.rs new file mode 100644 index 00000000..266945e8 --- /dev/null +++ b/crates/tinymemory-tools/src/layout/types.rs @@ -0,0 +1,99 @@ +//! [`CoreScope`]: a shared node above a layout that agents recall from. + +use serde::{Deserialize, Serialize}; +use tinymemory_api::{ItemKind, MetaFilter, Namespace, Reach}; + +use crate::context::Brief; + +/// Default number of items a core section shows. +pub const DEFAULT_CORE_LIMIT: usize = 6; + +/// A node above a layout's root whose memory every agent below it recalls: +/// a hive-wide core, or a company brain shared by every team. +/// +/// The node is read exactly ([`Reach::exact`]), never as a subtree, so a +/// core read sees what was written at the node itself and nothing from a +/// sibling tenant below it. A host that shares two levels (say the root and +/// `ws:acme`) names two scopes. +/// +/// ``` +/// use tinymemory_api::{ItemKind, Namespace}; +/// use tinymemory_tools::CoreScope; +/// +/// let company = CoreScope::new("ws:acme".parse()?, "Company").limit(4); +/// let reach = company.filter().reach.unwrap(); +/// assert!(reach.admits(&"ws:acme".parse()?)); +/// assert!(!reach.admits(&"ws:acme/team:hive".parse()?)); +/// assert_eq!(company.kinds, [ItemKind::Learning, ItemKind::Document]); +/// # Ok::<(), tinymemory_api::Error>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoreScope { + /// The shared node: a strict ancestor of the layout's root. + pub at: Namespace, + /// The heading its section carries in a pack. + pub heading: String, + /// The kinds of item read from it. + #[serde(default = "default_kinds")] + pub kinds: Vec, + /// The most items its section shows; `0` leaves the section out. + #[serde(default = "default_limit")] + pub limit: usize, +} + +fn default_kinds() -> Vec { + vec![ItemKind::Learning, ItemKind::Document] +} + +fn default_limit() -> usize { + DEFAULT_CORE_LIMIT +} + +impl CoreScope { + /// Learnings and documents at `at`, under `heading`, up to + /// [`DEFAULT_CORE_LIMIT`] items. + #[must_use] + pub fn new(at: Namespace, heading: impl Into) -> Self { + Self { + at, + heading: heading.into(), + kinds: default_kinds(), + limit: DEFAULT_CORE_LIMIT, + } + } + + /// The same scope reading only `kinds`; none reads every kind. + #[must_use] + pub fn kinds(mut self, kinds: impl IntoIterator) -> Self { + self.kinds = kinds.into_iter().collect(); + self + } + + /// The same scope showing at most `limit` items. + #[must_use] + pub fn limit(mut self, limit: usize) -> Self { + self.limit = limit; + self + } + + /// What the scope reads: its kinds, at exactly its node. + #[must_use] + pub fn filter(&self) -> MetaFilter { + MetaFilter { + reach: Some(Reach::exact(self.at.clone())), + ..MetaFilter::kinds(self.kinds.iter().copied()) + } + } + + /// A `context.md` section answering `question` from this scope alone. + /// Leave [`crate::context::ContextSpec::reach`] unset (or set it to an + /// agent's [`Reach::of`], which already covers its ancestors) or the + /// spec's reach replaces this brief's. + #[must_use] + pub fn brief(&self, question: impl Into) -> Brief { + Brief { + filter: self.filter(), + ..Brief::new(self.heading.clone(), question) + } + } +} From f34dff31404bef99e70ab39061cff4c7c20371ff Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:17:37 +0300 Subject: [PATCH 02/18] chore: files changed crates/tinymemory-tools/src/layout/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-tools/src/layout/mod.rs | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/tinymemory-tools/src/layout/mod.rs b/crates/tinymemory-tools/src/layout/mod.rs index 29b84144..b497cfcd 100644 --- a/crates/tinymemory-tools/src/layout/mod.rs +++ b/crates/tinymemory-tools/src/layout/mod.rs @@ -25,6 +25,11 @@ //! below a node of its own (`team:acme`), which keeps tenants apart on one //! engine. //! +//! **Core scopes** share memory beyond one layout. A host that nests every +//! tenant under one company node (`ws:acme/team:hive`) can name an ancestor +//! of the root as a [`CoreScope`]: a hive-wide core or a company brain that +//! every agent below it recalls, read exactly so sibling tenants stay apart. +//! //! # Example //! //! ``` @@ -43,10 +48,12 @@ //! ``` mod source; +mod types; use tinymemory_api::{Error, ItemKind, MetaFilter, Namespace, Reach, Result, Segment, SegmentKind}; pub use source::BrainSource; +pub use types::{CoreScope, DEFAULT_CORE_LIMIT}; /// Deepest a layout root may be: one level must remain for the brain's and /// the agents' nodes. @@ -80,6 +87,33 @@ impl MemoryLayout { &self.root } + /// The root's strict ancestors, root first: the nodes a [`CoreScope`] + /// may name. Empty for a layout at [`Namespace::ROOT`]. + #[must_use] + pub fn ancestors(&self) -> Vec { + let mut nodes = Reach::of(self.root.clone()).nodes(); + nodes.pop(); + nodes + } + + /// Checks `at` may be a core scope of this layout: a strict ancestor of + /// the root. + /// + /// # Errors + /// + /// [`Error::InvalidRequest`] for the root itself, a node below it, or a + /// node beside it. + pub fn admits_core(&self, at: &Namespace) -> Result<()> { + if at != &self.root && Reach::of(self.root.clone()).admits(at) { + Ok(()) + } else { + Err(Error::InvalidRequest(format!( + "a core scope must be an ancestor of the layout root `{}`, `{at}` is not", + self.root + ))) + } + } + /// The node `source`'s documents live at. /// /// # Errors From 339b7d906c13cb5cf3c8bd41442e5c56a1ec7c89 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:17:53 +0300 Subject: [PATCH 03/18] chore: files changed crates/tinymemory-tools/src/layout/mod_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-tools/src/layout/mod_tests.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/tinymemory-tools/src/layout/mod_tests.rs b/crates/tinymemory-tools/src/layout/mod_tests.rs index c8e5cb68..57da1220 100644 --- a/crates/tinymemory-tools/src/layout/mod_tests.rs +++ b/crates/tinymemory-tools/src/layout/mod_tests.rs @@ -75,3 +75,50 @@ fn source_ids_round_trip() { assert!(" ".parse::().is_err()); assert_eq!(BrainSource::Github.source_kind(), SourceKind::Github); } + +#[test] +fn admits_only_a_strict_ancestor_as_core() { + let layout = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); + layout.admits_core(&Namespace::ROOT).unwrap(); + layout.admits_core(&"ws:acme".parse().unwrap()).unwrap(); + for refused in ["ws:acme/team:hive", "ws:acme/team:other", "ws:acme/team:hive/agent:a", "ws:other"] { + assert!( + matches!( + layout.admits_core(&refused.parse().unwrap()), + Err(Error::InvalidRequest(_)) + ), + "{refused}" + ); + } + assert!(MemoryLayout::default().admits_core(&Namespace::ROOT).is_err()); +} + +#[test] +fn ancestors_lists_root_first() { + let layout = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); + let ancestors: Vec = layout.ancestors().iter().map(ToString::to_string).collect(); + assert_eq!(ancestors, ["root", "ws:acme"]); + assert!(MemoryLayout::default().ancestors().is_empty()); +} + +#[test] +fn a_core_scope_reads_its_node_exactly() { + let company = CoreScope::new("ws:acme".parse().unwrap(), "Company") + .kinds([ItemKind::Learning]) + .limit(2); + let filter = company.filter(); + let reach = filter.reach.unwrap(); + assert!(reach.admits(&"ws:acme".parse().unwrap())); + assert!(!reach.admits(&Namespace::ROOT)); + assert!(!reach.admits(&"ws:acme/team:other".parse().unwrap())); + assert_eq!(filter.kinds, [ItemKind::Learning]); + assert_eq!(company.limit, 2); + + let brief = company.brief("What does the company know?"); + assert_eq!(brief.heading, "Company"); + assert_eq!(brief.filter, company.filter()); + + let parsed: CoreScope = + serde_json::from_value(serde_json::json!({"at": "ws:acme", "heading": "Company"})).unwrap(); + assert_eq!(parsed, CoreScope::new("ws:acme".parse().unwrap(), "Company")); +} From 6a81f0aed6664cde7d050df3ad2caa0aa39df647 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:18:26 +0300 Subject: [PATCH 04/18] chore: files changed crates/tinymemory-tools/src/lifecycle/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-tools/src/lifecycle/mod.rs | 126 ++++++++++++++++--- 1 file changed, 110 insertions(+), 16 deletions(-) diff --git a/crates/tinymemory-tools/src/lifecycle/mod.rs b/crates/tinymemory-tools/src/lifecycle/mod.rs index 41327129..1def89f7 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod.rs @@ -21,9 +21,16 @@ //! part of the thread still in the prompt. //! //! A pack's sections, highest priority first (budget trimming takes from the -//! last): **Learnings**, **Brain**, **This agent's history**, and **Team -//! conversations** (other agents' turns). An item appears once, in the first -//! section that found it. +//! last): **Learnings**, one section per [`CoreScope`], **Brain**, **This +//! agent's history**, and **Team conversations** (other agents' turns). An +//! item appears once, in the first section that found it. +//! +//! Core scopes share memory above the layout, such as a hive-wide core or a +//! company brain. They are empty by default; [`AgentMemory::with_core`] sets +//! them, and because the memory is cheap to clone a host can override them +//! for one call: `memory.clone().with_core(scopes)?.pre_turn(turn)`. Only the +//! host writes there, through [`AgentMemory::promote`]; the model's tools +//! never choose a node. //! //! Each turn is stored as its own one-turn conversation item carrying the //! thread id, the turn's index, the agent id and the `conversation` source, @@ -74,7 +81,7 @@ use tinymemory_api::{ use crate::background::{BackgroundJob, BackgroundRunner, JobReport}; use crate::brain::Brain; -use crate::layout::MemoryLayout; +use crate::layout::{CoreScope, MemoryLayout}; use crate::recall::{ ContextPack, HolisticRecall, ScopeSection, SectionQuery, ThreadWindow, holistic_recall, }; @@ -127,6 +134,7 @@ pub struct AgentMemory { agent_id: String, node: Namespace, policy: RecallPolicy, + core: Vec, } impl std::fmt::Debug for AgentMemory { @@ -136,6 +144,7 @@ impl std::fmt::Debug for AgentMemory { .field("agent_id", &self.agent_id) .field("node", &self.node.to_string()) .field("policy", &self.policy) + .field("core", &self.core) .finish() } } @@ -164,6 +173,7 @@ impl AgentMemory { layout, agent_id: agent_id.to_string(), policy: RecallPolicy::default(), + core: Vec::new(), }) } @@ -174,6 +184,83 @@ impl AgentMemory { self } + /// The same memory recalling `core` as well, replacing any core scopes it + /// had; an empty list drops them. Clone first to override them for one + /// call. + /// + /// # Errors + /// + /// [`Error::InvalidRequest`] for a scope that is not a strict ancestor of + /// the layout root ([`MemoryLayout::admits_core`]), a blank heading, or a + /// node named twice. + pub fn with_core(mut self, core: Vec) -> Result { + for (index, scope) in core.iter().enumerate() { + self.layout.admits_core(&scope.at)?; + if scope.heading.trim().is_empty() { + return Err(Error::InvalidRequest(format!( + "the core scope `{}` needs a heading", + scope.at + ))); + } + if core[..index].iter().any(|earlier| earlier.at == scope.at) { + return Err(Error::InvalidRequest(format!( + "the core scope `{}` is named twice", + scope.at + ))); + } + } + self.core = core; + Ok(self) + } + + /// The core scopes recalled alongside the layout, in section order. + #[must_use] + pub fn core(&self) -> &[CoreScope] { + &self.core + } + + /// Writes `item` into the core scope at `scope`, for every agent below it + /// to recall: a hive-wide learning or a company brain document. The + /// item's namespace is set to `scope` whatever it was. Waits as + /// [`MemoryEngine::store`] does. + /// + /// # Errors + /// + /// [`Error::InvalidRequest`] for a node that is not one of this memory's + /// core scopes, or a conversation (turns stay at the agent's node); the + /// engine's failure to store. + pub async fn promote(&self, scope: &Namespace, mut item: StoreItem) -> Result { + if !self.core.iter().any(|core| &core.at == scope) { + return Err(Error::InvalidRequest(format!( + "`{scope}` is not a core scope of this memory" + ))); + } + if item.kind() == ItemKind::Conversation { + return Err(Error::InvalidRequest( + "only learnings and documents are promoted to a core scope".to_string(), + )); + } + item.meta_mut().namespace = scope.clone(); + self.engine.store(item).await + } + + /// A belief build of exactly the core scope at `scope`. + /// + /// # Errors + /// + /// [`Error::InvalidRequest`] for a node that is not one of this memory's + /// core scopes. + pub fn core_build(&self, scope: &Namespace) -> Result { + if !self.core.iter().any(|core| &core.at == scope) { + return Err(Error::InvalidRequest(format!( + "`{scope}` is not a core scope of this memory" + ))); + } + Ok(BackgroundJob::BuildBeliefs { + request: ConsolidateRequest::new(Reach::exact(scope.clone())), + }) + } + /// The agent's id. #[must_use] pub fn agent_id(&self) -> &str { @@ -217,7 +304,8 @@ impl AgentMemory { } /// The model-facing memory tools for this agent: writes land at its node, - /// reads see its node and the shared root ([`Reach::of`]). + /// reads see its node and every node above it ([`Reach::of`]), core + /// scopes included. #[must_use] pub fn tools(&self) -> MemoryTools { MemoryTools::new(self.engine.clone()).placed_at(self.node.clone()) @@ -408,16 +496,20 @@ impl AgentMemory { } } - /// Learnings, brain, this agent's history, then the team's, each filled - /// by fetch; a zero limit leaves its section out. + /// Learnings, each core scope, brain, this agent's history, then the + /// team's, each filled by fetch; a zero limit leaves its section out. fn standard_sections(&self) -> Vec { let policy = &self.policy; - [ - ( - LEARNINGS_HEADING, - self.layout.learnings_filter(), - policy.learnings_limit, - ), + let learnings = ( + LEARNINGS_HEADING, + self.layout.learnings_filter(), + policy.learnings_limit, + ); + let core = self + .core + .iter() + .map(|scope| (scope.heading.as_str(), scope.filter(), scope.limit)); + let layout = [ ( BRAIN_HEADING, self.layout.brain_filter(None), @@ -433,9 +525,11 @@ impl AgentMemory { self.layout.conversations_filter(None), policy.team_limit, ), - ] - .into_iter() - .filter(|(_, _, limit)| *limit > 0) + ]; + std::iter::once(learnings) + .chain(core) + .chain(layout) + .filter(|(_, _, limit)| *limit > 0) .map(|(heading, filter, limit)| ScopeSection::fetch(heading, filter, limit)) .collect() } From b3baee4eb77e2dffbf3e22a2dc89b780650de3e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:18:39 +0300 Subject: [PATCH 05/18] chore: files changed crates/tinymemory-tools/src/layout/mod_tests.rs,crates/tinymemory-tools/src/lib Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-tools/src/layout/mod_tests.rs | 18 +++++++++++++++--- crates/tinymemory-tools/src/lib.rs | 2 +- crates/tinymemory-tools/src/lifecycle/mod.rs | 6 +++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/tinymemory-tools/src/layout/mod_tests.rs b/crates/tinymemory-tools/src/layout/mod_tests.rs index 57da1220..b08910c5 100644 --- a/crates/tinymemory-tools/src/layout/mod_tests.rs +++ b/crates/tinymemory-tools/src/layout/mod_tests.rs @@ -81,7 +81,12 @@ fn admits_only_a_strict_ancestor_as_core() { let layout = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); layout.admits_core(&Namespace::ROOT).unwrap(); layout.admits_core(&"ws:acme".parse().unwrap()).unwrap(); - for refused in ["ws:acme/team:hive", "ws:acme/team:other", "ws:acme/team:hive/agent:a", "ws:other"] { + for refused in [ + "ws:acme/team:hive", + "ws:acme/team:other", + "ws:acme/team:hive/agent:a", + "ws:other", + ] { assert!( matches!( layout.admits_core(&refused.parse().unwrap()), @@ -90,7 +95,11 @@ fn admits_only_a_strict_ancestor_as_core() { "{refused}" ); } - assert!(MemoryLayout::default().admits_core(&Namespace::ROOT).is_err()); + assert!( + MemoryLayout::default() + .admits_core(&Namespace::ROOT) + .is_err() + ); } #[test] @@ -120,5 +129,8 @@ fn a_core_scope_reads_its_node_exactly() { let parsed: CoreScope = serde_json::from_value(serde_json::json!({"at": "ws:acme", "heading": "Company"})).unwrap(); - assert_eq!(parsed, CoreScope::new("ws:acme".parse().unwrap(), "Company")); + assert_eq!( + parsed, + CoreScope::new("ws:acme".parse().unwrap(), "Company") + ); } diff --git a/crates/tinymemory-tools/src/lib.rs b/crates/tinymemory-tools/src/lib.rs index 831db703..465191a9 100644 --- a/crates/tinymemory-tools/src/lib.rs +++ b/crates/tinymemory-tools/src/lib.rs @@ -72,7 +72,7 @@ pub mod tools; pub use background::{BackgroundJob, BackgroundRunner, JobOutcome, JobReport}; pub use brain::{Brain, BrainBatch, BrainDocument, Ingested}; -pub use layout::{BrainSource, MemoryLayout}; +pub use layout::{BrainSource, CoreScope, DEFAULT_CORE_LIMIT, MemoryLayout}; pub use lifecycle::{ AgentMemory, Compaction, PostTurn, PostTurnReport, PreTurn, RecallPolicy, SessionStart, TurnContext, diff --git a/crates/tinymemory-tools/src/lifecycle/mod.rs b/crates/tinymemory-tools/src/lifecycle/mod.rs index 1def89f7..580cca86 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod.rs @@ -76,7 +76,7 @@ use std::sync::Arc; use futures::future::join; use tinymemory_api::{ ConsolidateRequest, Error, ItemKind, MemoryEngine, MemoryMeta, MetaFilter, Namespace, Reach, - Result, Role, SourceKind, SourceRef, StoreItem, Turn, TurnRange, WriteOptions, + Result, Role, SourceKind, SourceRef, StoreItem, StoreReceipt, Turn, TurnRange, WriteOptions, }; use crate::background::{BackgroundJob, BackgroundRunner, JobReport}; @@ -530,8 +530,8 @@ impl AgentMemory { .chain(core) .chain(layout) .filter(|(_, _, limit)| *limit > 0) - .map(|(heading, filter, limit)| ScopeSection::fetch(heading, filter, limit)) - .collect() + .map(|(heading, filter, limit)| ScopeSection::fetch(heading, filter, limit)) + .collect() } fn request(&self, query: Option, sections: Vec) -> HolisticRecall { From cf48cecf742be6b1ba5e2c80263665e39fee3981 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:19:17 +0300 Subject: [PATCH 06/18] chore: files changed crates/tinymemory-tools/src/lifecycle/mod_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/lifecycle/mod_tests.rs | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs index edd337ba..d04a6751 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs @@ -407,3 +407,207 @@ fn a_zero_limit_leaves_its_section_out() { [LEARNINGS_HEADING, BRAIN_HEADING, HISTORY_HEADING] ); } + +/// A hive below a company: a company fact, a root fact, a sibling tenant's +/// secret and the hive's own learning, with `a` in the hive. +async fn company() -> (Arc, AgentMemory) { + let engine = Arc::new(ReferenceEngine::new()); + for (at, text) in [ + ("ws:acme", "Acme closes for the holidays on Friday"), + ("root", "Every agent answers in English"), + ( + "ws:acme/team:other", + "The other team's launch codename is Kestrel", + ), + ("ws:acme/team:hive", "The hive ships on Mondays"), + ] { + engine + .store(StoreItem::learning( + text, + LearningKind::Fact, + 0.9, + MemoryMeta { + namespace: at.parse().unwrap(), + ..MemoryMeta::default() + }, + )) + .await + .unwrap(); + } + let layout = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); + let memory = AgentMemory::new(engine.clone(), layout, "a").unwrap(); + (engine, memory) +} + +fn acme() -> Namespace { + "ws:acme".parse().unwrap() +} + +#[tokio::test] +async fn a_core_scope_adds_its_section_after_learnings() { + let (_, memory) = company().await; + let without = memory.recall("").await.unwrap().markdown; + assert!(!without.contains("holidays"), "{without}"); + + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let md = memory.recall("").await.unwrap().markdown; + let learnings = md.find("## Learnings").unwrap(); + let company = md + .find("## Company\n\n- Acme closes for the holidays on Friday") + .unwrap(); + assert!(learnings < company, "{md}"); + assert!(md.contains("The hive ships on Mondays")); +} + +#[tokio::test] +async fn a_core_scope_never_reads_a_sibling_tenant() { + let (_, memory) = company().await; + let memory = memory + .with_core(vec![ + CoreScope::new(Namespace::ROOT, "Core"), + CoreScope::new(acme(), "Company"), + ]) + .unwrap(); + let md = memory.recall("").await.unwrap().markdown; + assert!( + md.contains("## Core\n\n- Every agent answers in English"), + "{md}" + ); + assert!(md.contains("Acme closes")); + assert!(!md.contains("Kestrel"), "{md}"); +} + +#[tokio::test] +async fn with_core_replaces_the_set_per_call() { + let (_, memory) = company().await; + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let override_ = memory + .clone() + .with_core(vec![CoreScope::new(Namespace::ROOT, "Core")]) + .unwrap(); + let md = override_.recall("").await.unwrap().markdown; + assert!(md.contains("## Core") && !md.contains("## Company"), "{md}"); + assert_eq!(memory.core()[0].at, acme(), "the original keeps its set"); + + let dropped = memory.clone().with_core(Vec::new()).unwrap(); + assert!(dropped.core().is_empty()); + assert!( + !dropped + .recall("") + .await + .unwrap() + .markdown + .contains("holidays") + ); +} + +#[tokio::test] +async fn rejects_a_core_scope_outside_the_ancestors() { + let (_, memory) = company().await; + for at in [ + "ws:acme/team:hive", + "ws:acme/team:other", + "ws:acme/team:hive/agent:a", + ] { + let refused = memory + .clone() + .with_core(vec![CoreScope::new(at.parse().unwrap(), "Shared")]); + assert!(matches!(refused, Err(Error::InvalidRequest(_))), "{at}"); + } +} + +#[tokio::test] +async fn rejects_a_duplicate_core_node_or_a_blank_heading() { + let (_, memory) = company().await; + let twice = memory.clone().with_core(vec![ + CoreScope::new(acme(), "Company"), + CoreScope::new(acme(), "Again"), + ]); + assert!(matches!(twice, Err(Error::InvalidRequest(_)))); + let blank = memory.with_core(vec![CoreScope::new(acme(), " ")]); + assert!(matches!(blank, Err(Error::InvalidRequest(_)))); +} + +#[tokio::test] +async fn a_zero_limit_core_scope_is_left_out() { + let (_, memory) = company().await; + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company").limit(0)]) + .unwrap(); + let md = memory.recall("").await.unwrap().markdown; + assert!(!md.contains("## Company"), "{md}"); +} + +#[tokio::test] +async fn promote_writes_at_the_core_node() { + let (engine, memory) = company().await; + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let receipt = memory + .promote( + &acme(), + StoreItem::document("The expense limit is 500 euros", MemoryMeta::default()), + ) + .await + .unwrap(); + let stored = engine + .list(ListRequest::new( + MetaFilter::kinds([ItemKind::Document]), + 10, + )) + .await + .unwrap(); + assert_eq!(stored.items[0].id, receipt.id); + assert_eq!(stored.items[0].meta.namespace, acme()); + + let other = AgentMemory::new( + engine.clone(), + MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(), + "b", + ) + .unwrap() + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let md = other.recall("expense limit").await.unwrap().markdown; + assert!(md.contains("The expense limit is 500 euros"), "{md}"); +} + +#[tokio::test] +async fn promote_rejects_a_conversation_and_an_unconfigured_node() { + let (_, memory) = company().await; + let learning = StoreItem::learning("x", LearningKind::Fact, 0.5, MemoryMeta::default()); + let unconfigured = memory.promote(&acme(), learning.clone()).await; + assert!(matches!(unconfigured, Err(Error::InvalidRequest(_)))); + + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let conversation = StoreItem::Conversation { + meta: MemoryMeta::default(), + turns: vec![Turn::new(Role::User, "hello")], + }; + let refused = memory.promote(&acme(), conversation).await; + assert!(matches!(refused, Err(Error::InvalidRequest(_)))); +} + +#[tokio::test] +async fn core_build_consolidates_exactly_the_core_node() { + let (_, memory) = company().await; + assert!(matches!( + memory.core_build(&acme()), + Err(Error::InvalidRequest(_)) + )); + let memory = memory + .with_core(vec![CoreScope::new(acme(), "Company")]) + .unwrap(); + let BackgroundJob::BuildBeliefs { request } = memory.core_build(&acme()).unwrap() else { + panic!("a core build is a belief build"); + }; + assert_eq!(request.reach, Reach::exact(acme())); + assert!(request.kinds.is_empty()); +} From 3506089e1abb6ef2729c8b665bf2fc1a316b244f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:20:00 +0300 Subject: [PATCH 07/18] chore: files changed crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs,crates/tinymemory- Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/cortex/lifecycle_tests.rs | 57 ++++++++++++++++++- .../src/context/compile/mod_tests.rs | 29 ++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs b/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs index 68974466..01302216 100644 --- a/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs +++ b/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs @@ -3,10 +3,10 @@ use std::sync::Arc; -use tinymemory_api::MemoryEngine; +use tinymemory_api::{LearningKind, MemoryEngine, MemoryMeta, Namespace, StoreItem}; use tinymemory_tools::{ - AgentMemory, Brain, BrainDocument, BrainSource, JobOutcome, MemoryLayout, PostTurn, PreTurn, - RecallPolicy, SessionStart, + AgentMemory, Brain, BrainDocument, BrainSource, CoreScope, JobOutcome, MemoryLayout, PostTurn, + PreTurn, RecallPolicy, SessionStart, }; use crate::cortex::CortexWire; @@ -141,3 +141,54 @@ async fn an_agent_loop_runs_the_same_on_either_wire() { } } } + +#[tokio::test] +async fn a_core_scope_recalls_the_company_node_on_either_wire() { + for (engine, state) in both().await { + let wire = engine.wire(); + let engine: Arc = Arc::new(engine); + let acme: Namespace = "ws:acme".parse().unwrap(); + let hive = MemoryLayout::new("ws:acme/team:hive".parse().unwrap()).unwrap(); + let agent = AgentMemory::new(engine.clone(), hive, "a") + .unwrap() + .with_core(vec![CoreScope::new(acme.clone(), "Company")]) + .unwrap(); + agent + .promote( + &acme, + StoreItem::learning( + "Acme closes for the holidays on Friday", + LearningKind::Fact, + 0.9, + MemoryMeta::default(), + ), + ) + .await + .unwrap(); + state.seen.lock().unwrap().recalls.clear(); + + let pack = agent.recall("holidays").await.unwrap().markdown; + assert!( + pack.contains("## Company\n\n- Acme closes for the holidays on Friday"), + "{wire:?}: {pack}" + ); + let scopes: Vec = state + .seen + .lock() + .unwrap() + .recalls + .iter() + .filter_map(|body| body["scope"].as_str().map(str::to_string)) + .collect(); + assert!( + scopes + .iter() + .any(|scope| scope.ends_with("app:tinymemory/ws:acme/app:learnings")), + "{wire:?}: {scopes:?}" + ); + assert!( + scopes.iter().all(|scope| !scope.contains("team:other")), + "{wire:?}: {scopes:?}" + ); + } +} diff --git a/crates/tinymemory-tools/src/context/compile/mod_tests.rs b/crates/tinymemory-tools/src/context/compile/mod_tests.rs index 1f56e4f5..570acd5d 100644 --- a/crates/tinymemory-tools/src/context/compile/mod_tests.rs +++ b/crates/tinymemory-tools/src/context/compile/mod_tests.rs @@ -193,3 +193,32 @@ async fn a_reach_keeps_the_document_to_one_agent_s_memory() { ); assert!(!doc.markdown.contains("writer habit"), "{}", doc.markdown); } + +#[tokio::test] +async fn a_core_brief_reads_only_the_company_node() { + let engine = ReferenceEngine::new(); + let mut company = learning("company holiday habit", 0.9, Some(1)); + company.meta_mut().namespace = "ws:acme".parse().unwrap(); + let mut sibling = learning("other team habit", 0.9, Some(1)); + sibling.meta_mut().namespace = "ws:acme/team:other".parse().unwrap(); + for item in [company, sibling] { + engine.store(item).await.unwrap(); + } + let core = crate::layout::CoreScope::new("ws:acme".parse().unwrap(), "Company"); + let spec = ContextSpec { + briefs: vec![core.brief("habit")], + learnings_limit: 0, + ..ContextSpec::default() + }; + let doc = ContextCompiler::at(at()) + .compile(&engine, &spec) + .await + .unwrap(); + assert!(doc.markdown.contains("## Company"), "{}", doc.markdown); + assert!(doc.markdown.contains("company holiday habit")); + assert!( + !doc.markdown.contains("other team habit"), + "{}", + doc.markdown + ); +} From 68f1653256bcc737512155cd8669bc846ff8fc46 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:20:39 +0300 Subject: [PATCH 08/18] chore: files changed docs/architecture/lifecycle.md,docs/architecture/namespaces.md,docs/specs/READM Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/architecture/lifecycle.md | 1 + docs/architecture/namespaces.md | 17 +++++++++ docs/plans/core-scopes.md | 41 ++++++++++++++++++++ docs/specs/README.md | 3 ++ docs/specs/agent-memory.md | 5 ++- docs/specs/core-scopes.md | 67 +++++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 docs/plans/core-scopes.md create mode 100644 docs/specs/core-scopes.md diff --git a/docs/architecture/lifecycle.md b/docs/architecture/lifecycle.md index 16b2a212..874d5cad 100644 --- a/docs/architecture/lifecycle.md +++ b/docs/architecture/lifecycle.md @@ -42,6 +42,7 @@ host AgentMemory engine │ ├─ store_with(turn, Accepted) ─▶ (≈ capture, no indexing wait) │ ├─ holistic recall, concurrently: │ │ Learnings fetch ──────────▶ + │ │ fetch ──────────▶ (one per CoreScope, exact node) │ │ Brain fetch ──────────▶ │ │ History fetch ──────────▶ │ │ Team fetch ──────────▶ diff --git a/docs/architecture/namespaces.md b/docs/architecture/namespaces.md index 03fa6579..27ae481b 100644 --- a/docs/architecture/namespaces.md +++ b/docs/architecture/namespaces.md @@ -161,6 +161,23 @@ Items exist at R, T, W, H and E. What each reach sees: | `subtree(root)` | R, T, W, H, E | | no reach (`None`) | R, T, W, H, E | +### A company above its tenants + +A layout reads the subtree of its root, never what lies above it. To share +memory across tenants, a host nests every tenant below one company node and +names that node (and, optionally, the root) as a **core scope** +([specs/core-scopes.md](../specs/core-scopes.md)): + +```text +root core: "Core" (optional) +└── ws:acme core: "Company" company brain + ├── team:hive layout root of the hive's agents + └── team:other another tenant, never read by the hive +``` + +A core scope reads its node with `Reach::exact`, so the hive sees the +company's own items but nothing from `team:other`. + ## What each operation does with reach | Operation | Namespace handling | diff --git a/docs/plans/core-scopes.md b/docs/plans/core-scopes.md new file mode 100644 index 00000000..d812a0eb --- /dev/null +++ b/docs/plans/core-scopes.md @@ -0,0 +1,41 @@ +# Plan: core scopes + +**Spec:** [../specs/core-scopes.md](../specs/core-scopes.md) + +The change is additive and confined to `tinymemory-tools`. There is no contract +change and no engine change. Each step starts from a failing test. + +1. **`CoreScope`** in `crates/tinymemory-tools/src/layout/types.rs`, exported + from `layout/mod.rs` and `src/lib.rs`. + - Test: `a_core_scope_reads_its_node_exactly` (`layout/mod_tests.rs`). +2. **Layout ancestry** in `layout/mod.rs`: `admits_core` and `ancestors`. + - Tests: `admits_only_a_strict_ancestor_as_core` and + `ancestors_lists_root_first`. +3. **`AgentMemory`** in `lifecycle/mod.rs`: + - the `core` field, `with_core` and `core`; + - core sections in `standard_sections`, after Learnings; + - `promote` and `core_build`. + - Tests in `lifecycle/mod_tests.rs`, from section order through the + sibling-tenant guard, per-call replacement, every refusal, promote and + the build job. +4. **context.md**: test `a_core_brief_reads_only_the_company_node` in + `context/compile/mod_tests.rs`. +5. **CortexDB**: test `a_core_scope_recalls_the_company_node_on_either_wire` + in `tinymemory-integrations/src/cortex/lifecycle_tests.rs`. +6. **Docs:** the spec, `agent-memory.md` (standard sections), + `architecture/lifecycle.md` (turn diagram) and + `architecture/namespaces.md` (a company above its tenants). + +## Verification + +```sh +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo build --all-targets --all-features +cargo test --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features +``` + +## Checklist + +- [x] Steps 1–6 diff --git a/docs/specs/README.md b/docs/specs/README.md index 126ca808..7f2673d8 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -7,6 +7,9 @@ source, per-agent conversations, learnings), holistic recall, pre- and post-turn calls, compaction and background belief builds. Accepted; builds on memory v2. +- [Core scopes](core-scopes.md) — shared memory above a layout (a hive-wide + core, a company brain) recalled as its own sections, set per agent or per + call, written by the host. Accepted; builds on the agent memory lifecycle. Specifications define what the system must do before implementation details take over. Create one for behavior that changes a public API, crosses module diff --git a/docs/specs/agent-memory.md b/docs/specs/agent-memory.md index 99880fd0..a35a84d5 100644 --- a/docs/specs/agent-memory.md +++ b/docs/specs/agent-memory.md @@ -166,8 +166,9 @@ skipped, engine }`. | `recall(query)` | — | the pre-turn read without logging | | `run_background(job)` | the job's | — | -- **Standard sections**, in priority order: Learnings (the whole tree), Brain - (all documents), this agent's history, and team conversations (every +- **Standard sections**, in priority order: Learnings (the whole tree), one + section per core scope ([core-scopes.md](core-scopes.md); none by default), + Brain (all documents), this agent's history, and team conversations (every agent). A zero limit in `RecallPolicy` leaves a section out. - **`pre_turn` never fails on an engine error.** A failed log is reported in `TurnContext::log_error` and the pack is still returned. diff --git a/docs/specs/core-scopes.md b/docs/specs/core-scopes.md new file mode 100644 index 00000000..3d80bb33 --- /dev/null +++ b/docs/specs/core-scopes.md @@ -0,0 +1,67 @@ +# Core scopes: shared memory above a layout + +**Status:** Accepted. **Builds on:** [agent-memory.md](agent-memory.md). +**Plan:** [../plans/core-scopes.md](../plans/core-scopes.md). + +## Problem + +An `AgentMemory` recalls only the subtree of its layout root. A multi-agent +host such as tinyhivemind needs memory shared more widely than one layout: + +- a hive-wide **core** that every agent recalls; +- a **company brain** that every team below one company node recalls. + +The host must be able to set that shared memory once, change it for a single +call, and write into it. Before this change none of that was possible. + +## Goals and non-goals + +- **Goal:** recall from shared nodes *above* the layout root, as their own + pack sections, set once or per call. +- **Goal:** a host-only way to write learnings and documents into a shared node. +- **Non-goal:** sharing between unrelated nodes. A shared node must be an + ancestor of the layout root, so a company that shares memory nests its + tenants below one node (`ws:acme/team:hive`). The `Reach` contract and the + engines are unchanged. +- **Non-goal:** letting the model choose a scope. Tool arguments still may not + name a namespace or reach. + +## Behavior (`tinymemory-tools`) + +- **`CoreScope { at, heading, kinds, limit }`** + - `CoreScope::new(at, heading)` reads learnings and documents, up to + `DEFAULT_CORE_LIMIT` (6). It has `kinds(..)` and `limit(..)` builders. + - `filter()` reads `at` **exactly** (`Reach::exact`). + - `brief(question)` gives a `context.md` section. +- **`MemoryLayout::admits_core(at)`** accepts only a strict ancestor of the + root. **`MemoryLayout::ancestors()`** lists those nodes, root first. +- **`AgentMemory::with_core(Vec) -> Result`** replaces the + core set; an empty list drops it. It refuses a node that is not a strict + ancestor, a blank heading, or a node named twice. `AgentMemory` is cheap to + clone, so `memory.clone().with_core(..)?` overrides the set for one call. + `core()` returns the current set. +- **Standard sections** are Learnings, then one section per core scope in + order, then Brain, this agent's history, and Team conversations. A zero + limit leaves a core section out. +- **`AgentMemory::promote(&scope, item)`** stores a learning or document at a + configured core node and overwrites the item's namespace. It refuses a + conversation or an unconfigured node. +- **`AgentMemory::core_build(&scope)`** returns the `BuildBeliefs` job for + exactly that node. +- **`AgentMemory::tools()`** is unchanged. `Reach::of(node)` already reads + every ancestor, core nodes included. + +## Invariants + +- A core read never admits a sibling tenant. It reads its node exactly, never + the subtree below it. +- With no core scopes configured, packs are unchanged byte for byte. +- Turns are always written at the agent's node. + +## Acceptance criteria + +- On the reference engine, a core section shows the company node's + learnings and never another team's. +- Over both CortexDB doubles, a core section recalls + `app:tinymemory/ws:acme/app:learnings`, and no scope below a sibling team. +- A `context.md` brief built from a core scope reads only that node. From d78c679816b11ab36df093f71adc853104e22f47 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:31:17 +0300 Subject: [PATCH 09/18] chore: files changed crates/tinymemory-api/src/write/mod.rs,crates/tinymemory-integrations/src/corte Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-api/src/write/mod.rs | 1 + .../src/cortex/lifecycle_tests.rs | 13 +++++++++++++ crates/tinymemory-tools/src/lifecycle/mod.rs | 12 +++++++++--- .../src/lifecycle/mod_tests.rs | 18 ++++++++++-------- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/tinymemory-api/src/write/mod.rs b/crates/tinymemory-api/src/write/mod.rs index b73e0c00..f723cb3b 100644 --- a/crates/tinymemory-api/src/write/mod.rs +++ b/crates/tinymemory-api/src/write/mod.rs @@ -19,6 +19,7 @@ //! # Example //! //! ``` +//! #![cfg(feature = "conformance")] //! use tinymemory_api::{MemoryEngine, MemoryMeta, StoreItem, WaitFor, WriteOptions}; //! use tinymemory_api::conformance::ReferenceEngine; //! diff --git a/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs b/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs index 01302216..f70d5664 100644 --- a/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs +++ b/crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs @@ -165,6 +165,18 @@ async fn a_core_scope_recalls_the_company_node_on_either_wire() { ) .await .unwrap(); + engine + .store(StoreItem::learning( + "Kestrel's private schedule", + LearningKind::Fact, + 0.9, + MemoryMeta { + namespace: "ws:acme/team:other".parse().unwrap(), + ..MemoryMeta::default() + }, + )) + .await + .unwrap(); state.seen.lock().unwrap().recalls.clear(); let pack = agent.recall("holidays").await.unwrap().markdown; @@ -172,6 +184,7 @@ async fn a_core_scope_recalls_the_company_node_on_either_wire() { pack.contains("## Company\n\n- Acme closes for the holidays on Friday"), "{wire:?}: {pack}" ); + assert!(!pack.contains("Kestrel"), "{wire:?}: {pack}"); let scopes: Vec = state .seen .lock() diff --git a/crates/tinymemory-tools/src/lifecycle/mod.rs b/crates/tinymemory-tools/src/lifecycle/mod.rs index 580cca86..13db7698 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod.rs @@ -230,16 +230,22 @@ impl AgentMemory { /// core scopes, or a conversation (turns stay at the agent's node); the /// engine's failure to store. pub async fn promote(&self, scope: &Namespace, mut item: StoreItem) -> Result { - if !self.core.iter().any(|core| &core.at == scope) { + let Some(core) = self.core.iter().find(|core| &core.at == scope) else { return Err(Error::InvalidRequest(format!( "`{scope}` is not a core scope of this memory" ))); - } - if item.kind() == ItemKind::Conversation { + }; + let kind = item.kind(); + if kind == ItemKind::Conversation { return Err(Error::InvalidRequest( "only learnings and documents are promoted to a core scope".to_string(), )); } + if !core.kinds.is_empty() && !core.kinds.contains(&kind) { + return Err(Error::InvalidRequest(format!( + "the core scope `{scope}` does not read {kind:?} items" + ))); + } item.meta_mut().namespace = scope.clone(); self.engine.store(item).await } diff --git a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs index d04a6751..d0971ff6 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs @@ -495,14 +495,7 @@ async fn with_core_replaces_the_set_per_call() { let dropped = memory.clone().with_core(Vec::new()).unwrap(); assert!(dropped.core().is_empty()); - assert!( - !dropped - .recall("") - .await - .unwrap() - .markdown - .contains("holidays") - ); + assert_eq!(dropped.recall("").await.unwrap().markdown, without); } #[tokio::test] @@ -593,6 +586,15 @@ async fn promote_rejects_a_conversation_and_an_unconfigured_node() { }; let refused = memory.promote(&acme(), conversation).await; assert!(matches!(refused, Err(Error::InvalidRequest(_)))); + + let learning_only = memory + .with_core(vec![CoreScope::new(acme(), "Company").kinds([ItemKind::Learning])]) + .unwrap(); + let document = StoreItem::document("x", MemoryMeta::default()); + assert!(matches!( + learning_only.promote(&acme(), document).await, + Err(Error::InvalidRequest(_)) + )); } #[tokio::test] From 92a0942f43065aac62b5333a63d7669cf1d1bc4b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:31:26 +0300 Subject: [PATCH 10/18] style(lifecycle): rewrap core scope argument in promote test Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-tools/src/lifecycle/mod_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs index d0971ff6..412e943d 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs @@ -588,7 +588,9 @@ async fn promote_rejects_a_conversation_and_an_unconfigured_node() { assert!(matches!(refused, Err(Error::InvalidRequest(_)))); let learning_only = memory - .with_core(vec![CoreScope::new(acme(), "Company").kinds([ItemKind::Learning])]) + .with_core(vec![ + CoreScope::new(acme(), "Company").kinds([ItemKind::Learning]), + ]) .unwrap(); let document = StoreItem::document("x", MemoryMeta::default()); assert!(matches!( From 81c7f6cbb08e5ee46e649966fdbb217cb408349f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:31:46 +0300 Subject: [PATCH 11/18] chore: files changed crates/tinymemory-api/src/write/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-api/src/write/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinymemory-api/src/write/mod.rs b/crates/tinymemory-api/src/write/mod.rs index f723cb3b..1d71b430 100644 --- a/crates/tinymemory-api/src/write/mod.rs +++ b/crates/tinymemory-api/src/write/mod.rs @@ -18,8 +18,7 @@ //! //! # Example //! -//! ``` -//! #![cfg(feature = "conformance")] +//! ```ignore //! use tinymemory_api::{MemoryEngine, MemoryMeta, StoreItem, WaitFor, WriteOptions}; //! use tinymemory_api::conformance::ReferenceEngine; //! From 54f61c82655f09899e8cea74ec9dc3689dd3e3aa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:32:25 +0300 Subject: [PATCH 12/18] chore: files changed crates/tinymemory-api/src/write/mod.rs,crates/tinymemory-tools/src/lifecycle/mo Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-api/src/write/mod.rs | 30 +++++++++++-------- .../src/lifecycle/mod_tests.rs | 1 + 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/tinymemory-api/src/write/mod.rs b/crates/tinymemory-api/src/write/mod.rs index 1d71b430..e08aaf2a 100644 --- a/crates/tinymemory-api/src/write/mod.rs +++ b/crates/tinymemory-api/src/write/mod.rs @@ -18,20 +18,24 @@ //! //! # Example //! -//! ```ignore -//! use tinymemory_api::{MemoryEngine, MemoryMeta, StoreItem, WaitFor, WriteOptions}; -//! use tinymemory_api::conformance::ReferenceEngine; +//! ``` +//! fn main() { +//! #[cfg(feature = "conformance")] +//! { +//! use tinymemory_api::{MemoryEngine, MemoryMeta, StoreItem, WaitFor, WriteOptions}; +//! use tinymemory_api::conformance::ReferenceEngine; //! -//! # let runtime = tokio::runtime::Builder::new_current_thread().build()?; -//! # runtime.block_on(async { -//! let engine = ReferenceEngine::new(); -//! let item = StoreItem::document("logged without waiting", MemoryMeta::default()); -//! let receipt = engine.store_with(item, WriteOptions::accepted()).await?; -//! assert!(!receipt.replayed); -//! assert_eq!(WriteOptions::default().wait, WaitFor::Visible); -//! # Ok::<(), tinymemory_api::Error>(()) -//! # })?; -//! # Ok::<(), Box>(()) +//! let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap(); +//! runtime.block_on(async { +//! let engine = ReferenceEngine::new(); +//! let item = StoreItem::document("logged without waiting", MemoryMeta::default()); +//! let receipt = engine.store_with(item, WriteOptions::accepted()).await?; +//! assert!(!receipt.replayed); +//! assert_eq!(WriteOptions::default().wait, WaitFor::Visible); +//! Ok::<(), tinymemory_api::Error>(()) +//! }).unwrap(); +//! } +//! } //! ``` use serde::{Deserialize, Serialize}; diff --git a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs index 412e943d..4207e2b1 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs @@ -482,6 +482,7 @@ async fn a_core_scope_never_reads_a_sibling_tenant() { #[tokio::test] async fn with_core_replaces_the_set_per_call() { let (_, memory) = company().await; + let without = memory.recall("").await.unwrap().markdown; let memory = memory .with_core(vec![CoreScope::new(acme(), "Company")]) .unwrap(); From 0a83b605502df2ed0706d67a4d41c777dd7f2c2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:33:42 +0300 Subject: [PATCH 13/18] docs(write): fix doctest example to compile without a main wrapper The module-level example wrapped its body in a `fn main` and gated the imports behind a `#[cfg]` block, which prevented the doctest from compiling. The example now uses a hidden `#[cfg]` attribute on the block so it runs as a plain doctest body. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-api/src/write/mod.rs | 29 +++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/tinymemory-api/src/write/mod.rs b/crates/tinymemory-api/src/write/mod.rs index e08aaf2a..b8db9cb5 100644 --- a/crates/tinymemory-api/src/write/mod.rs +++ b/crates/tinymemory-api/src/write/mod.rs @@ -19,22 +19,21 @@ //! # Example //! //! ``` -//! fn main() { -//! #[cfg(feature = "conformance")] -//! { -//! use tinymemory_api::{MemoryEngine, MemoryMeta, StoreItem, WaitFor, WriteOptions}; -//! use tinymemory_api::conformance::ReferenceEngine; +//! # #[cfg(feature = "conformance")] +//! { +//! use tinymemory_api::{MemoryEngine, MemoryMeta, StoreItem, WaitFor, WriteOptions}; +//! use tinymemory_api::conformance::ReferenceEngine; //! -//! let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap(); -//! runtime.block_on(async { -//! let engine = ReferenceEngine::new(); -//! let item = StoreItem::document("logged without waiting", MemoryMeta::default()); -//! let receipt = engine.store_with(item, WriteOptions::accepted()).await?; -//! assert!(!receipt.replayed); -//! assert_eq!(WriteOptions::default().wait, WaitFor::Visible); -//! Ok::<(), tinymemory_api::Error>(()) -//! }).unwrap(); -//! } +//! let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap(); +//! runtime.block_on(async { +//! let engine = ReferenceEngine::new(); +//! let item = StoreItem::document("logged without waiting", MemoryMeta::default()); +//! let receipt = engine.store_with(item, WriteOptions::accepted()).await?; +//! assert!(!receipt.replayed); +//! assert_eq!(WriteOptions::default().wait, WaitFor::Visible); +//! Ok::<(), tinymemory_api::Error>(()) +//! }).unwrap(); +//! } //! } //! ``` From 05cb6cb8bbf9f0a775c01e4ca426deeded7f9d85 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:34:02 +0300 Subject: [PATCH 14/18] chore: files changed crates/tinymemory-api/src/write/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-api/src/write/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinymemory-api/src/write/mod.rs b/crates/tinymemory-api/src/write/mod.rs index b8db9cb5..a8aea9af 100644 --- a/crates/tinymemory-api/src/write/mod.rs +++ b/crates/tinymemory-api/src/write/mod.rs @@ -34,7 +34,6 @@ //! Ok::<(), tinymemory_api::Error>(()) //! }).unwrap(); //! } -//! } //! ``` use serde::{Deserialize, Serialize}; From 00b41ae02223e887474ff7915003eb22078fae1f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:42:42 +0300 Subject: [PATCH 15/18] chore: files changed crates/tinymemory-tools/src/lifecycle/mod_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-tools/src/lifecycle/mod_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs index 4207e2b1..910db88a 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs @@ -417,7 +417,7 @@ async fn company() -> (Arc, AgentMemory) { ("root", "Every agent answers in English"), ( "ws:acme/team:other", - "The other team's launch codename is Kestrel", + "The other team closes for the holidays on Monday", ), ("ws:acme/team:hive", "The hive ships on Mondays"), ] { @@ -447,6 +447,7 @@ fn acme() -> Namespace { async fn a_core_scope_adds_its_section_after_learnings() { let (_, memory) = company().await; let without = memory.recall("").await.unwrap().markdown; + assert_eq!(without, "# Memory\n\n## Learnings\n\n- The hive ships on Mondays"); assert!(!without.contains("holidays"), "{without}"); let memory = memory From 4184cc40c2b94ec3fcd3949780fc2a4830117358 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:43:21 +0300 Subject: [PATCH 16/18] chore: files changed crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs,crates/tinymemory Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tests/live_cortex_lifecycle.rs | 81 ++++++++++++++++++- .../src/lifecycle/mod_tests.rs | 5 +- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs b/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs index 9899326e..c0d405e3 100644 --- a/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs +++ b/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs @@ -18,11 +18,13 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tinymemory_api::{ForgetTarget, MemoryEngine, MemoryMeta}; +use tinymemory_api::{ForgetTarget, LearningKind, MemoryEngine, MemoryMeta, Namespace, StoreItem}; use tinymemory_integrations::brain::brain_document; use tinymemory_integrations::cortex::{CortexCredential, CortexEngine}; use tinymemory_integrations::documents::{ConverterChain, RawDocument}; -use tinymemory_tools::{AgentMemory, Brain, ContextPack, MemoryLayout, PostTurn, PreTurn}; +use tinymemory_tools::{ + AgentMemory, Brain, ContextPack, CoreScope, MemoryLayout, PostTurn, PreTurn, +}; const DEFAULT_KEY: &str = "tinymemory-cortex-test"; @@ -141,3 +143,78 @@ async fn live_an_agent_loop_runs_against_cortexdb() { .expect("forget"); assert!(forgotten.forgotten >= 4, "{forgotten:?}"); } + +#[tokio::test] +async fn live_core_scope_recall_and_promotion_respect_tenant_boundaries() { + let Some(engine) = live_engine() else { + eprintln!("TINYMEMORY_LIVE_CORTEXDB_URL unset; skipping"); + return; + }; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after the epoch") + .as_nanos(); + let company: Namespace = format!("project:core-{nanos}") + .parse() + .expect("valid namespace"); + let hive: Namespace = format!("project:core-{nanos}/team:hive") + .parse() + .expect("valid namespace"); + let other: Namespace = format!("project:core-{nanos}/team:other") + .parse() + .expect("valid namespace"); + let layout = MemoryLayout::new(hive).expect("valid layout"); + let agent = AgentMemory::new(engine.clone(), layout.clone(), "core-test") + .expect("agent") + .with_core(vec![CoreScope::new(company.clone(), "Company")]) + .expect("company ancestor scope"); + + agent + .promote( + &company, + StoreItem::learning( + "Quasar holidays close the support desk on Friday", + LearningKind::Fact, + 0.9, + MemoryMeta::default(), + ), + ) + .await + .expect("promote into company scope"); + engine + .store(StoreItem::learning( + "Quasar holidays reveal the other tenant's private schedule", + LearningKind::Fact, + 0.9, + MemoryMeta { + namespace: other, + ..MemoryMeta::default() + }, + )) + .await + .expect("store sibling fixture"); + + let pack = recall_until( + &agent, + "Quasar holidays", + &["Quasar holidays close the support desk on Friday"], + ) + .await; + assert!( + pack.markdown + .contains("## Company\n\n- Quasar holidays close the support desk on Friday"), + "company core appears in recall:\n{}", + pack.markdown + ); + assert!( + !pack.markdown.contains("other tenant's private schedule"), + "sibling item is excluded:\n{}", + pack.markdown + ); + + let forgotten = engine + .forget(ForgetTarget::Filter(layout.holistic_filter())) + .await + .expect("clean up test data"); + assert!(forgotten.forgotten >= 2, "{forgotten:?}"); +} diff --git a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs index 910db88a..1d22c61d 100644 --- a/crates/tinymemory-tools/src/lifecycle/mod_tests.rs +++ b/crates/tinymemory-tools/src/lifecycle/mod_tests.rs @@ -447,7 +447,10 @@ fn acme() -> Namespace { async fn a_core_scope_adds_its_section_after_learnings() { let (_, memory) = company().await; let without = memory.recall("").await.unwrap().markdown; - assert_eq!(without, "# Memory\n\n## Learnings\n\n- The hive ships on Mondays"); + assert_eq!( + without, + "# Memory\n\n## Learnings\n\n- The hive ships on Mondays\n" + ); assert!(!without.contains("holidays"), "{without}"); let memory = memory From 2cdfc1855643322e6a3f54a24acab06042fb6277 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:44:20 +0300 Subject: [PATCH 17/18] chore: files changed crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-integrations/tests/live_cortex_lifecycle.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs b/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs index c0d405e3..956b2603 100644 --- a/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs +++ b/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs @@ -181,6 +181,13 @@ async fn live_core_scope_recall_and_promotion_respect_tenant_boundaries() { ) .await .expect("promote into company scope"); + let build = agent + .core_build(&company) + .expect("build job for configured company scope"); + agent + .run_background(build) + .await + .expect("run the company-scope belief build"); engine .store(StoreItem::learning( "Quasar holidays reveal the other tenant's private schedule", From b93a80cf2c145f78074b3e6658614bf2e52791e4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 18:49:31 +0300 Subject: [PATCH 18/18] chore: files changed crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tests/live_cortex_lifecycle.rs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs b/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs index 956b2603..0656d4a1 100644 --- a/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs +++ b/crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs @@ -18,7 +18,9 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tinymemory_api::{ForgetTarget, LearningKind, MemoryEngine, MemoryMeta, Namespace, StoreItem}; +use tinymemory_api::{ + ForgetTarget, LearningKind, MemoryEngine, MemoryMeta, MetaFilter, Namespace, Reach, StoreItem, +}; use tinymemory_integrations::brain::brain_document; use tinymemory_integrations::cortex::{CortexCredential, CortexEngine}; use tinymemory_integrations::documents::{ConverterChain, RawDocument}; @@ -194,7 +196,7 @@ async fn live_core_scope_recall_and_promotion_respect_tenant_boundaries() { LearningKind::Fact, 0.9, MemoryMeta { - namespace: other, + namespace: other.clone(), ..MemoryMeta::default() }, )) @@ -219,9 +221,14 @@ async fn live_core_scope_recall_and_promotion_respect_tenant_boundaries() { pack.markdown ); - let forgotten = engine - .forget(ForgetTarget::Filter(layout.holistic_filter())) - .await - .expect("clean up test data"); - assert!(forgotten.forgotten >= 2, "{forgotten:?}"); + for namespace in [company, other] { + let forgotten = engine + .forget(ForgetTarget::Filter(MetaFilter { + reach: Some(Reach::exact(namespace)), + ..MetaFilter::default() + })) + .await + .expect("clean up test data"); + assert_eq!(forgotten.forgotten, 1, "{forgotten:?}"); + } }