Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ba511be
chore: files changed crates/tinymemory-tools/src/layout/types.rs
senamakel Oct 4, 2026
f34dff3
chore: files changed crates/tinymemory-tools/src/layout/mod.rs
senamakel Oct 4, 2026
339b7d9
chore: files changed crates/tinymemory-tools/src/layout/mod_tests.rs
senamakel Oct 4, 2026
6a81f0a
chore: files changed crates/tinymemory-tools/src/lifecycle/mod.rs
senamakel Oct 4, 2026
b3baee4
chore: files changed crates/tinymemory-tools/src/layout/mod_tests.rs,…
senamakel Oct 4, 2026
cf48cec
chore: files changed crates/tinymemory-tools/src/lifecycle/mod_tests.rs
senamakel Oct 4, 2026
3506089
chore: files changed crates/tinymemory-integrations/src/cortex/lifecy…
senamakel Oct 4, 2026
68f1653
chore: files changed docs/architecture/lifecycle.md,docs/architecture…
senamakel Oct 4, 2026
d78c679
chore: files changed crates/tinymemory-api/src/write/mod.rs,crates/ti…
senamakel Oct 4, 2026
92a0942
style(lifecycle): rewrap core scope argument in promote test
senamakel Oct 4, 2026
81c7f6c
chore: files changed crates/tinymemory-api/src/write/mod.rs
senamakel Oct 4, 2026
54f61c8
chore: files changed crates/tinymemory-api/src/write/mod.rs,crates/ti…
senamakel Oct 4, 2026
0a83b60
docs(write): fix doctest example to compile without a main wrapper
senamakel Oct 4, 2026
05cb6cb
chore: files changed crates/tinymemory-api/src/write/mod.rs
senamakel Oct 4, 2026
00b41ae
chore: files changed crates/tinymemory-tools/src/lifecycle/mod_tests.rs
senamakel Oct 4, 2026
4184cc4
chore: files changed crates/tinymemory-integrations/tests/live_cortex…
senamakel Oct 4, 2026
2cdfc18
chore: files changed crates/tinymemory-integrations/tests/live_cortex…
senamakel Oct 4, 2026
b93a80c
chore: files changed crates/tinymemory-integrations/tests/live_cortex…
senamakel Oct 4, 2026
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
26 changes: 14 additions & 12 deletions crates/tinymemory-api/src/write/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,21 @@
//! # Example
//!
//! ```
//! 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()?;
//! # 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<dyn std::error::Error>>(())
//! 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};
Expand Down
70 changes: 67 additions & 3 deletions crates/tinymemory-integrations/src/cortex/lifecycle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -141,3 +141,67 @@ 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() {
Comment thread
senamakel marked this conversation as resolved.
for (engine, state) in both().await {
Comment thread
senamakel marked this conversation as resolved.
let wire = engine.wire();
let engine: Arc<dyn MemoryEngine> = 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();
engine
.store(StoreItem::learning(
"Kestrel's private schedule",
Comment thread
senamakel marked this conversation as resolved.
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;
assert!(
Comment thread
senamakel marked this conversation as resolved.
pack.contains("## Company\n\n- Acme closes for the holidays on Friday"),
"{wire:?}: {pack}"
);
assert!(!pack.contains("Kestrel"), "{wire:?}: {pack}");
let scopes: Vec<String> = 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!(
Comment thread
senamakel marked this conversation as resolved.
scopes.iter().all(|scope| !scope.contains("team:other")),
"{wire:?}: {scopes:?}"
);
}
}
95 changes: 93 additions & 2 deletions crates/tinymemory-integrations/tests/live_cortex_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
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, MetaFilter, Namespace, Reach, 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";

Expand Down Expand Up @@ -141,3 +145,90 @@ 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");
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",
LearningKind::Fact,
0.9,
MemoryMeta {
namespace: other.clone(),
..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
);

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:?}");
}
}
29 changes: 29 additions & 0 deletions crates/tinymemory-tools/src/context/compile/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
34 changes: 34 additions & 0 deletions crates/tinymemory-tools/src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
//! ```
Expand All @@ -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.
Expand Down Expand Up @@ -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<Namespace> {
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
Expand Down
59 changes: 59 additions & 0 deletions crates/tinymemory-tools/src/layout/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,62 @@ fn source_ids_round_trip() {
assert!(" ".parse::<BrainSource>().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<String> = 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")
);
}
Loading
Loading