Skip to content

Commit 68b5a27

Browse files
authored
Merge pull request #114 from tinyhumansai/section-memory-api
Add the section API: conversations, learnings, documents, and recall
2 parents 47562d6 + 3e2d8fa commit 68b5a27

12 files changed

Lines changed: 2624 additions & 3 deletions

File tree

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,41 @@ Capabilities are asked **once, at bind time, and cached**: a host filters its RP
215215
surface and its agent-tool list from the answer, so a set that changed
216216
afterwards would not be noticed.
217217

218+
## The section surface
219+
220+
Namespaces follow a `<section>:<scope>` convention — `conversation:thread-8f21`,
221+
`learning:rust-async`, `document:handbook` — so "conversational memory",
222+
"document memory" and "learnings" mean the same thing to every host and every
223+
engine. `tinymemory::sections` makes that convention a typed surface instead of
224+
a string every caller concatenates by hand:
225+
226+
```rust
227+
use tinymemory::sections::Sections;
228+
229+
let sections = Sections::new(provider.as_ref());
230+
231+
sections.conversations().put("thread-8f21", "turn-1", text, category, None, taint).await?;
232+
let topics = sections.learnings().scopes().await?;
233+
let hits = sections.recall().across_section(&MemorySection::Learning, "async", 10, &opts, None).await?;
234+
```
235+
236+
`conversations()`, `learnings()` and `documents()` are the three sections a host
237+
writes to routinely; `section()` reaches the other four and `Custom`. Every call
238+
composes the **mandatory** families only, so the whole surface works on every
239+
driver — nothing to negotiate, and no capability-absent path. On a driver that
240+
retains nothing, every call succeeds and returns empty.
241+
242+
`across_section` is a fan-out: one namespace enumeration plus one recall per
243+
scope, capped, reporting what it searched and whether the cap bit. It is not an
244+
unfinished optimisation — `OwnedRecallOpts::namespace` is an exact match, and
245+
leaving it unset means the `global` namespace on the embedded engine but *every*
246+
namespace on the reference driver, so there is no cross-namespace recall to build
247+
a single call on. See [`docs/specs/memory-section-api.md`](docs/specs/memory-section-api.md).
248+
249+
Handing the layer a **file** is a different path: `DocumentIntake` sniffs the
250+
format, converts it, and picks the capability family. The section surface is for
251+
text you already hold.
252+
218253
## What lives here, and what deliberately does not
219254

220255
| Here | In the host |

crates/tinymemory/examples/tinycortex.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
//! The embedded engine, end to end: admit, construct, audit, store, recall.
1+
//! The embedded engine, end to end: admit, construct, audit, store, recall,
2+
//! and the same store read back through the section surface.
23
//!
34
//! Run with:
45
//!
@@ -19,7 +20,9 @@ use std::sync::Arc;
1920
use tinymemory::api::provider::{audit_provider, MemoryProvider};
2021
use tinymemory::api::recall::OwnedRecallOpts;
2122
use tinymemory::api::types::{MemoryCategory, MemoryTaint};
23+
use tinymemory::namespace::MemorySection;
2224
use tinymemory::registry::{ConfigLabels, DriverRegistry, TINYCORTEX_DRIVER_ID};
25+
use tinymemory::sections::Sections;
2326
use tinymemory::tinycortex::{provider, InMemoryMemoryStore};
2427

2528
#[tokio::main]
@@ -59,5 +62,44 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
5962
let hits = provider.recall("hello", 8, &opts, None).await?;
6063
println!("recall found {} entr(y/ies)", hits.len());
6164
assert!(!hits.is_empty(), "the stored entry must be recallable");
65+
66+
// 5. The same engine through the section surface: the caller names a
67+
// scope, never a namespace, and asks the whole section one question.
68+
let sections = Sections::new(provider.as_ref());
69+
for (scope, note) in [
70+
("rust-async", "pinning is not unpinning"),
71+
("rust-macros", "hygiene is per-expansion"),
72+
] {
73+
let namespace = sections
74+
.learnings()
75+
.put(
76+
scope,
77+
"note",
78+
note,
79+
MemoryCategory::Core,
80+
None,
81+
MemoryTaint::Internal,
82+
)
83+
.await?;
84+
println!("learning stored in '{namespace}'");
85+
}
86+
87+
let found = sections
88+
.recall()
89+
.across_section(
90+
&MemorySection::Learning,
91+
"is",
92+
8,
93+
&OwnedRecallOpts::default(),
94+
None,
95+
)
96+
.await?;
97+
println!(
98+
"section recall searched {} namespace(s) and found {} hit(s)",
99+
found.namespaces_searched,
100+
found.hits.len()
101+
);
102+
assert_eq!(found.namespaces_searched, 2, "both scopes must be searched");
103+
assert!(!found.hits.is_empty(), "the section recall must find them");
62104
Ok(())
63105
}

crates/tinymemory/src/lib.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
//! rather than re-deriving the same four subtleties.
2020
//! - **[`registry`]** — driver admission. Which driver ids exist, what class
2121
//! each binds as, and the fail-closed rule for out-of-process drivers.
22+
//! - **[`sections`]** — typed surfaces for the sections the namespace
23+
//! convention names: conversations, learnings, documents, and a
24+
//! section-aware recall. Composes the mandatory families only, so it works
25+
//! on every driver.
2226
//! - **Engine adapters** — one crate per engine under `crates/`, each
2327
//! implementing [`provider::MemoryProvider`] over a concrete engine, and
2428
//! each selected by the feature named after it.
@@ -143,12 +147,18 @@ pub use tinymemory_conformance as conformance;
143147

144148
pub mod registry;
145149

150+
// Typed surfaces for the sections the namespace convention names —
151+
// conversations, learnings, documents — plus a section-aware recall. Documented
152+
// by its own `//!` docs; an outer doc comment here as well would merge the two
153+
// and resolve the module's intra-doc links in this file's scope instead.
154+
pub mod sections;
155+
146156
// The contract, re-exported wholesale. Listed module by module rather than as a
147157
// glob so the crate's own surface is visible in one place and rustdoc links
148158
// resolve — and so adding a module to the contract is a deliberate act here too.
149159
pub use tinymemory_api::{
150-
capabilities, chunks, error, goals, health, null, provider, recall, tool_memory, traits, tree,
151-
types,
160+
capabilities, chunks, error, goals, health, namespace, null, provider, recall, tool_memory,
161+
traits, tree, types,
152162
};
153163
pub use tinymemory_api::{is_compatible, CONTRACT_VERSION};
154164

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# `sections`
2+
3+
Typed surfaces over the `<section>:<scope>` namespace convention
4+
(`crates/tinymemory-bus/src/namespace.rs`): `Sections`, `SectionView`, and
5+
`SectionRecall`. Nothing here is a new capability — every call composes
6+
`MemoryCore` and `MemoryRecall`, which every driver implements as supertraits —
7+
this module only stops a caller from hand-concatenating the `conversation:` /
8+
`learning:` / `document:` prefix, where a typo silently produces a different,
9+
valid namespace instead of an error.
10+
11+
## Design
12+
13+
```text
14+
Sections::new(provider)
15+
├── conversations() ─┐
16+
├── learnings() ├─ SectionView put / get / forget / list
17+
├── documents() │ scopes / list_section
18+
├── section(custom) ─┘
19+
└── recall() ── SectionRecall in_scope / across_section
20+
```
21+
22+
- `Sections` is the entry point: one named accessor per routine section
23+
(`conversations`, `learnings`, `documents`) plus `section(&MemorySection)` for
24+
the rest of the vocabulary (`entity:`, `profile:`, `tool:`, `source:`, and
25+
`Custom`) and `recall()` for the cross-cutting query surface.
26+
- `SectionView` addresses one section by scope — `put` / `get` / `forget` /
27+
`list` take the bare scope (`"thread-8f21"`), never the prefixed namespace —
28+
and enumerates it with `scopes()` / `list_section()`.
29+
- `SectionRecall` answers two different questions, deliberately kept apart
30+
because they cost different amounts: `in_scope` is one provider call;
31+
`across_section` fans out to one call per namespace in the section.
32+
33+
Every handle borrows `&dyn MemoryProvider` (see `view.rs`, `recall.rs`): cheap
34+
to construct, holds no state between calls, and cannot outlive the provider —
35+
so a caller builds one where it is needed instead of threading it through a
36+
struct.
37+
38+
`MemorySection` is normalised through `MemorySection::from_prefix` in
39+
`SectionView::new`, so `Custom("conversation")` and `MemorySection::Conversation`
40+
are the same view rather than two. Storing the caller's spelling verbatim would
41+
let a write land under `conversation:` while a `scopes()` call — which compares
42+
against this normalised field — reported the section as empty.
43+
44+
## Public surface
45+
46+
- `Sections::{new, conversations, learnings, documents, section, recall}`
47+
- `SectionView::{put, get, forget, list, scopes, list_section}`
48+
- `SectionRecall::{in_scope, across_section}`
49+
- `SectionScope`, `SectionHits` — the value types `scopes()` / recall return
50+
- `MAX_SECTION_NAMESPACES` — the fan-out cap `across_section` enforces
51+
- `NAMESPACE_FILTER_CONFLICT`, `CROSS_SESSION_SECTION_CONFLICT`,
52+
`CROSS_SESSION_FAN_OUT_CONFLICT` — the exact `MemoryError::Invalid` messages
53+
the recall refusals carry, exposed so a caller's test can assert against the
54+
same string it sees
55+
56+
## Operational constraints
57+
58+
**`across_section` is a fan-out, not a filtered call.** `OwnedRecallOpts::namespace`
59+
is exact-match, and `namespace: None` means the literal `global` namespace on
60+
the embedded engine but *every* namespace on the reference driver
61+
(`crates/tinymemory-conformance/src/reference/mod.rs`). A single unfiltered call
62+
plus post-filtering would return nothing in production, so `across_section`
63+
enumerates `scopes()` and issues one exact-namespace recall per scope instead,
64+
capped at `MAX_SECTION_NAMESPACES` and reported through `SectionHits::truncated`
65+
when the cap bites. Each namespace is asked for the full `limit`, never a
66+
share of it — a share would let one scope's best hit lose to another's worst.
67+
68+
**`cross_session` and `session_id` are refused outside the conversation
69+
section, and refused on `across_section` unconditionally.** The bundled
70+
`UnifiedMemory` driver's `cross_session` recall option surfaces episodic
71+
*conversational* rows from other sessions; its `session_id` option
72+
independently appends that session's episodic rows. Both relabel every such
73+
row with whichever namespace the call was pinned to, regardless of the
74+
option's own defaults. Honouring either on a `learning:` or `document:`
75+
section would therefore return conversational content mislabeled as that
76+
section's own hits, so `in_scope` rejects both with
77+
`CROSS_SESSION_SECTION_CONFLICT` — checked against the section's *normalised*
78+
form, so `Custom("conversation")` counts as `MemorySection::Conversation`
79+
unless `section == MemorySection::Conversation`.
80+
81+
`across_section` rejects both unconditionally, with
82+
`CROSS_SESSION_FAN_OUT_CONFLICT`, including on the conversation section. This
83+
is not merely the same hazard: the driver's episodic augmentation runs once,
84+
independent of the pinned namespace, so the fan-out would repeat the exact
85+
same rows once per scope in the merged result, crowding genuine hits out of
86+
`limit` — and it is also redundant even where it would not repeat, since
87+
`across_section` already visits every conversation scope on its own. A caller
88+
who wants cross-session or session-scoped recall uses `in_scope` instead,
89+
which issues exactly one call.
90+
91+
**Visit order is by entry count descending, not recency.** `SectionScope::last_updated`
92+
is optional and no bundled driver currently populates it, so `scopes()` cannot
93+
order by recency today. This is deliberate and raised as an open question in
94+
`docs/specs/memory-section-api.md`, not an oversight.
95+
96+
**This is not the document intake path.** `Sections::documents` writes through
97+
`MemoryCore`, for text a caller already holds. Handing the memory layer a
98+
*file* — sniffing its format, converting it to markdown, then choosing between
99+
`MemoryIngest`, `MemoryDocuments`, and `MemoryCore` — is `DocumentIntake`'s job
100+
in the `documents` module, which is the right entry point for an upload.
101+
102+
**The `namespace: None` divergence between drivers is out of scope here.** The
103+
embedded engine and the reference driver disagree on what an unfiltered recall
104+
means, as noted above; fixing that divergence needs its own spec and is
105+
deliberately not attempted by this module.

0 commit comments

Comments
 (0)