Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
b0fd602
feat(api): add graph module to tinymemory-api
senamakel Aug 20, 2026
91eeae6
fix(graph): handle empty adjacency list in topological sort
senamakel Aug 20, 2026
010455d
fix(graph): handle empty adjacency list in graph traversal
senamakel Aug 20, 2026
9369688
fix(provider): handle missing knowledge provider gracefully
senamakel Aug 20, 2026
cccf690
fix(api): remove unused provider module
senamakel Aug 20, 2026
d977bbf
fix(graph_tests): correct test assertion for memory graph traversal
senamakel Aug 20, 2026
3b1e063
fix(provider): simplify node presence check in push_view_edge
senamakel Aug 20, 2026
3d6db86
test(graph_view): add initial test file for graph view module
senamakel Aug 20, 2026
02ccab1
fix(provider/knowledge): track unexpanded nodes precisely instead of …
senamakel Aug 20, 2026
379323a
fix(graph): clarify frontier_remaining semantics and fix test expecta…
senamakel Aug 20, 2026
15ddaeb
docs(api): document the new namespace module in module-level docs
senamakel Aug 20, 2026
f851060
test(namespace): add initial test file for namespace module
senamakel Aug 20, 2026
5fb3787
chore(tinymemory-documents): add Cargo.toml for new crate
senamakel Aug 20, 2026
9a0a45c
fix(documents): handle untracked format module file
senamakel Aug 20, 2026
4bbbe43
fix(documents): handle empty document format gracefully
senamakel Aug 20, 2026
bfbb756
fix(html): handle numeric character references in entity decoding
senamakel Aug 20, 2026
7f60774
fix(html): correct entity test module path
senamakel Aug 20, 2026
898eeb3
feat(chunks): add Upload and WebPage data source variants
senamakel Aug 20, 2026
3e44e70
test(chunks): update expected variant count in data source test
senamakel Aug 20, 2026
774e3b4
fix(test): add Upload and WebPage to document source kind test
senamakel Aug 20, 2026
439ebd7
fix(convert): handle missing document type in conversion
senamakel Aug 20, 2026
3be6eb1
fix(documents): handle empty document in conversion
senamakel Aug 20, 2026
a4e1063
fix(ingest): handle empty document body during ingestion
senamakel Aug 20, 2026
27d596f
fix(ingest): handle empty document body in ingestion
senamakel Aug 20, 2026
83243c0
fix(ingest): handle empty document body in ingestion
senamakel Aug 20, 2026
c5bf75d
fix(fetch): handle missing document content gracefully
senamakel Aug 20, 2026
70504a3
fix(documents): remove unused import in lib.rs
senamakel Aug 20, 2026
948073c
feat(ssrf): make SSRF guard and fetch helpers public
senamakel Aug 20, 2026
6eaced7
fix(ingest): handle empty document in conversion test
senamakel Aug 20, 2026
09a48fa
fix(fetch): handle empty document list in test helper
senamakel Aug 20, 2026
c117679
chore(deps): add tinymemory-documents crate to workspace lockfile
senamakel Aug 20, 2026
8436e1d
fix(ingest): correct test assertion for document ingestion
senamakel Aug 20, 2026
59a81b7
fix(ingest): handle empty HTML documents during ingestion
senamakel Aug 20, 2026
c9920ca
fix(deps): enable network feature on tinymemory-sources when building…
senamakel Aug 20, 2026
d88f0ca
feat(tinymemory): add documents feature for document and URL intake
senamakel Aug 20, 2026
e4e94d8
feat(tinymemory-testing-ui): add document upload and URL ingestion su…
senamakel Aug 20, 2026
dd0b42f
feat(testing-ui): add document ingestion and graph view endpoints
senamakel Aug 20, 2026
fd74ed8
docs(tinymemory-testing-ui): document graph view, document upload, an…
senamakel Aug 20, 2026
637ea5f
docs(tinymemory-documents): add README with crate overview and usage
senamakel Aug 20, 2026
7209aa9
docs(specs): add specification for graph view and document intake
senamakel Aug 20, 2026
cab26ea
docs(readme): add tinymemory-documents crate to the project tree
senamakel Aug 20, 2026
6e22b79
chore: reformat long method chains and match arms for readability
senamakel Aug 20, 2026
c595554
fix(html): simplify table cell separator logic
senamakel Aug 20, 2026
01a91df
Merge remote-tracking branch 'upstream/main' into graph-view-and-inge…
senamakel Aug 20, 2026
114cbb2
chore: suppress clippy lint for test assertions
senamakel Aug 20, 2026
ed2f4d0
chore(graph): remove crate-qualified doc links for internal items
senamakel Aug 20, 2026
b966882
docs: clarify module-level doc comments in graph.rs
senamakel Aug 20, 2026
6fb27c0
fix(provider): remove unused knowledge provider trait
senamakel Aug 21, 2026
455327c
fix: handle empty namespace in memory bus
senamakel Aug 21, 2026
7581e5a
fix(fetch): handle missing document content gracefully
senamakel Aug 21, 2026
ecd740d
fix(fetch): handle missing document metadata gracefully
senamakel Aug 21, 2026
a15931e
fix(fetch): correct test assertion for document fetch error handling
senamakel Aug 21, 2026
1e2c4b9
fix(html): handle missing semicolons in HTML entity decoding
senamakel Aug 21, 2026
f35c9c6
fix(entity_test): correct HTML entity test assertions
senamakel Aug 21, 2026
4af62c4
fix(html): handle empty document body in HTML rendering
senamakel Aug 21, 2026
6bcb786
fix(html): handle empty document in test helper
senamakel Aug 21, 2026
1ce4afe
fix(ingest): remove unused `IngestDocument` struct
senamakel Aug 21, 2026
2a3fd56
fix(ingest): correct test assertion for document ingestion
senamakel Aug 21, 2026
dac5f3d
fix(documents): handle empty document body in format detection
senamakel Aug 21, 2026
4b87785
fix(documents): correct test assertion for empty document handling
senamakel Aug 21, 2026
0f26702
fix(ui): remove unused import in main.rs
senamakel Aug 21, 2026
720b1d5
fix(ui): remove unused import in main.rs
senamakel Aug 21, 2026
12aecc7
test: reformat assertion macros in fetch test file
senamakel Aug 21, 2026
cc508b3
Merge remote-tracking branch 'upstream/main' into graph-view-and-inge…
senamakel Aug 21, 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
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ crates/
│ them
├── tinymemory-sources/ memory-source contracts and readers — local folders
│ always, GitHub/RSS/web pages behind `network`
├── tinymemory-documents/ document and URL intake: sniff a format, convert it
│ to markdown, and write it into whichever engine is
│ bound. The URL half is behind `network` and reuses the
│ source readers' SSRF guard rather than growing a second
├── tinymemory-tinycortex/ the TinyCortex engine seen through the contract
├── tinymemory-remote/ native HTTP dialects for Supermemory, Mem0, and Cognee
├── tinymemory-conformance/ the behavioural suite every driver must pass
Expand Down Expand Up @@ -79,6 +83,8 @@ composition — no storage engine, no HTTP stack, no native library.
| `sync` | `tinymemory::sync` — the Composio normalisers |
| `sources` | `tinymemory::sources` — source contracts and local readers |
| `sources-network` | `sources`, plus the GitHub/RSS/web-page readers |
| `documents` | `tinymemory::documents` — document intake and markdown conversion |
| `documents-network` | `documents`, plus the URL fetch path |
| `conformance` | `tinymemory::conformance` — the driver contract suite |
| `memory-git` | git-backed diff snapshots (implies `tinycortex`; links libgit2) |
| `contacts` | the macOS address-book seeding path (implies `core`) |
Expand Down
10 changes: 9 additions & 1 deletion crates/tinymemory-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@
//! - [`traits`]: the [`traits::Memory`] storage-backend trait.
//! - [`chunks`]: the persisted chunk model ([`chunks::Chunk`], [`chunks::Metadata`],
//! [`chunks::SourceRef`], …) and the deterministic [`chunks::chunk_id`].
//! - [`graph`]: the bounded graph-view model ([`graph::GraphView`],
//! [`graph::GraphViewQuery`], [`graph::GraphNode`], [`graph::GraphEdge`]) —
//! the graph counterpart of [`tree`], and what
//! [`provider::MemoryGraph::graph_view`] returns.
//! - [`namespace`]: the `<section>:<scope>` namespace convention
//! ([`namespace::Namespace`], [`namespace::MemorySection`]) and its
//! validator.
//! - [`tree`]: the markdown summary-tree node model ([`tree::TreeNode`],
//! [`tree::NodeLevel`], [`tree::TreeStatus`], …).
//! - [`tool_memory`]: tool-scoped rule contracts ([`tool_memory::ToolMemoryRule`], …).
Expand Down Expand Up @@ -99,7 +106,8 @@ pub mod host;
// point: a second definition would need a conversion at the module seam that
// nothing type-checks.
pub use tinymemory_bus::{
capabilities, chunks, error, goals, health, recall, tool_memory, tree, types, version, wire,
capabilities, chunks, error, goals, graph, health, namespace, recall, tool_memory, tree, types,
version, wire,
};
/// The mandatory-family composition: wrap any [`traits::Memory`] backend as a
/// complete [`provider::MemoryProvider`].
Expand Down
246 changes: 246 additions & 0 deletions crates/tinymemory-api/src/provider/knowledge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,26 @@
//! extraction models, hotness decay curves, and snapshot retention are driver
//! concerns and appear in none of these signatures.

use std::collections::BTreeSet;

use async_trait::async_trait;

use crate::error::MemoryError;
use crate::graph::{GraphEdge, GraphNode, GraphView, GraphViewQuery};
use crate::provider::types::{DiffReport, EntityHit, SnapshotRef};
use crate::types::{GraphRelationRecord, MemoryKvRecord};

/// How many edges the default [`MemoryGraph::graph_view`] traversal scans per
/// predicate when it has to resolve *inbound* edges.
///
/// [`MemoryGraph::relations`] filters by subject and predicate but not by
/// object, so inbound expansion has no indexed form in this contract and the
/// default traversal falls back to a bounded scan. The bound exists so a graph
/// larger than memory cannot be pulled into a view; hitting it sets
/// [`GraphView::truncated`]. A driver whose store indexes the object column
/// should override `graph_view` and skip this path entirely.
pub const INBOUND_SCAN_LIMIT: usize = 4_096;

/// The entity index: who and what the stored memory is about.
#[async_trait]
pub trait MemoryEntities: Send + Sync {
Expand Down Expand Up @@ -140,6 +154,238 @@ pub trait MemoryGraph: Send + Sync {
/// [`MemoryError::Invalid`] for a malformed edge, otherwise backend
/// failures.
async fn put_relation(&self, relation: GraphRelationRecord) -> Result<(), MemoryError>;

/// Assemble a bounded, renderable slice of the graph.
///
/// This is to [`Self::relations`] what
/// [`crate::provider::MemoryTree::drill_down`] is to a raw node read: one
/// call returns a node *together with its surroundings*, already joined
/// into a node set and an edge set, so navigating a graph is a sequence of
/// view calls rather than a client-side reassembly that every caller would
/// write differently.
///
/// # The default implementation
///
/// Provided, not required: it breadth-first expands
/// [`GraphViewQuery::seeds`] using [`Self::relations`] alone, so every
/// existing driver gains a graph view without writing one, and a driver
/// that advertises no [`crate::capabilities::Capability::Graph`] family
/// still surfaces the same [`MemoryError::Unsupported`] its `relations`
/// returns.
///
/// It costs one `relations` call per node visited, including one final
/// round at the outermost hop that adds no nodes and exists only to close
/// edges *between* nodes already in the view — without it the outer ring
/// renders as a star rather than as the graph it is. A driver with a native
/// multi-hop traversal should override this and use it.
///
/// Inbound expansion has no indexed form here — `relations` cannot filter
/// by object — so [`crate::graph::GraphDirection::In`] and
/// [`crate::graph::GraphDirection::Both`] fall back to a scan capped at
/// [`INBOUND_SCAN_LIMIT`] per predicate.
///
/// # Errors
///
/// Whatever [`Self::relations`] returns. Bounds are never an error: a
/// traversal that hits one returns the partial view with
/// [`GraphView::truncated`] set.
async fn graph_view(&self, query: &GraphViewQuery) -> Result<GraphView, MemoryError> {
let namespace = query.namespace.as_deref();
let mut view = GraphView {
namespace: query.namespace.clone(),
seeds: query.seeds.clone(),
..GraphView::default()
};

// Each predicate needs its own call: `relations` takes one, not a set.
// An empty filter becomes the single unfiltered call rather than a
// special case further down.
let predicates: Vec<Option<&str>> = if query.predicates.is_empty() {
vec![None]
} else {
query.predicates.iter().map(|p| Some(p.as_str())).collect()
};

// Nodes reached but never expanded, either because a bound was hit or
// because they sit one hop past the requested depth. A set, not a
// counter: the same boundary node is commonly reached from several
// directions, and counting it twice would overstate what is left.
let mut unexpanded: BTreeSet<String> = BTreeSet::new();

// Unseeded: an overview, not a traversal. One bounded scan of the
// slice, and the node set is whatever the returned edges touch.
if query.seeds.is_empty() {
for predicate in &predicates {
// One past the ceiling: a call that asked for exactly
// `max_edges` and got them cannot tell a full slice from a
// truncated one.
let records = self
.relations(
namespace,
None,
*predicate,
query.max_edges.saturating_add(1),
)
.await?;
for record in records {
push_view_edge(&mut view, record, &mut unexpanded, query, 0);
}
}
view.stats.frontier_remaining = unexpanded.len();
view.recompute_stats();
return Ok(view);
}

// Inbound edges are resolved from one scan per predicate rather than
// one per node: the scan is the expensive part, and repeating it for
// every node visited would multiply it by `max_nodes`.
let mut inbound: Vec<GraphRelationRecord> = Vec::new();
if query.direction.follows_in() {
for predicate in &predicates {
let records = self
.relations(namespace, None, *predicate, INBOUND_SCAN_LIMIT)
.await?;
if records.len() >= INBOUND_SCAN_LIMIT {
view.truncated = true;
}
inbound.extend(records);
}
}

let mut frontier: Vec<String> = Vec::new();
for seed in &query.seeds {
if view.nodes.iter().any(|n| &n.id == seed) {
continue;
}
if view.nodes.len() >= query.max_nodes {
unexpanded.insert(seed.clone());
view.truncated = true;
continue;
}
view.nodes.push(GraphNode::bare(seed.clone(), 0));
frontier.push(seed.clone());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

for hop in 0..=query.depth {
if frontier.is_empty() {
break;
}
let mut next: Vec<String> = Vec::new();
for node_id in &frontier {
let mut incident: Vec<GraphRelationRecord> = Vec::new();
if query.direction.follows_out() {
for predicate in &predicates {
incident.extend(
self.relations(
namespace,
Some(node_id),
*predicate,
query.max_edges.saturating_add(1),
)
.await?,
);
}
}
if query.direction.follows_in() {
incident.extend(
inbound
.iter()
.filter(|record| &record.object == node_id)
.cloned(),
);
}

for record in incident {
if !query.accepts_predicate(&record.predicate) {
continue;
}
let other = if &record.subject == node_id {
record.object.clone()
} else {
record.subject.clone()
};
let known = view.nodes.iter().any(|n| n.id == other);
if !known {
// An edge to a node the view will not hold would
// dangle, so it is dropped either way — but *why* it
// was dropped matters. Reaching the requested depth is
// the caller getting what they asked for; hitting the
// node ceiling is not, and only the second makes the
// view truncated. Conflating them would set the flag on
// every finite traversal of a connected graph and leave
// it saying nothing.
if hop >= query.depth {
unexpanded.insert(other);
continue;
}
if view.nodes.len() >= query.max_nodes {
unexpanded.insert(other);
view.truncated = true;
continue;
}
view.nodes.push(GraphNode::bare(other.clone(), hop + 1));
next.push(other);
}
push_view_edge(&mut view, record, &mut unexpanded, query, hop);
}
}
frontier = next;
}

view.stats.frontier_remaining = unexpanded.len();
view.recompute_stats();
Ok(view)
}
}

/// Add one relation to a view, deduplicating by triple and honouring
/// [`GraphViewQuery::max_edges`].
///
/// A separate function rather than a closure so the borrow of `view` ends
/// between calls, which the traversal above needs while it is also pushing
/// nodes.
fn push_view_edge(
view: &mut GraphView,
record: GraphRelationRecord,
unexpanded: &mut BTreeSet<String>,
query: &GraphViewQuery,
depth: u32,
) {
if !query.accepts_predicate(&record.predicate) {
return;
}
let triple = (
record.subject.clone(),
record.predicate.clone(),
record.object.clone(),
);
if view
.edges
.iter()
.any(|e| e.key() == (&triple.0, &triple.1, &triple.2))
{
return;
}
if view.edges.len() >= query.max_edges {
unexpanded.insert(triple.0);
unexpanded.insert(triple.2);
view.truncated = true;
return;
}
// The unseeded overview derives its node set from the edges it found; the
// seeded traversal has already placed both endpoints.
for id in [triple.0.clone(), triple.2.clone()] {
if view.nodes.iter().any(|n| n.id == id) {
continue;
}
if view.nodes.len() >= query.max_nodes {
unexpanded.insert(id);
view.truncated = true;
return;
}
view.nodes.push(GraphNode::bare(id, depth));
}
view.edges.push(GraphEdge::from(record));
}

/// Snapshot capture and change computation over synced sources.
Expand Down
2 changes: 1 addition & 1 deletion crates/tinymemory-api/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ 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 knowledge::{MemoryDiff, MemoryEntities, MemoryGraph};
pub use knowledge::{MemoryDiff, MemoryEntities, MemoryGraph, INBOUND_SCAN_LIMIT};
pub use mandatory::{MemoryCore, MemoryPortability, MemoryRecall};
pub use people::{
AddressBookSeedOutcome, MemoryPeople, PersonHandle, PersonInteraction, PersonRecord, PersonRef,
Expand Down
Loading
Loading