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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ from memmesh import MemMesh, subject
mm = MemMesh(api_key="sk-...", project_id="proj_...")

# 1 — Observe: feed it the raw turn; the engine's noise filter decides what to keep
res = mm.observe(text="Moved to the annual plan, prefers email over SMS.")
res = mm.observe(
text="Moved to the annual plan, prefers email over SMS.",
user_id="user_42", # provenance on whatever the engine keeps
session_id="thread_7", # keeps a conversation's turns linkable
)
print(res.saved, res.candidate_count) # filler comes back as saved == []

# 2 — Recall: hybrid semantic + keyword search
Expand Down Expand Up @@ -50,12 +54,35 @@ asyncio.run(main())
| Area | Methods |
|------|---------|
| **Memory** | `observe` · `create` · `search` · `list` · `update` · `delete` · `stats` · `feedback` |
| **Knowledge graph** (`mm.memory.graph`) | `stats` · `list_entities` · `get_entity` · `list_edges` · `traverse` |
| **Prediction** (`mm.lattice`) | `predict` · `mine` · `profile` · `predict_by_cohort` · `calibration` |

Every method accepts an optional `project_id=` to override the client default,
and raises a typed error (`AuthenticationError`, `RateLimitError`,
`ValidationError`, …) on failure. 429 and 5xx are retried with backoff.

## Knowledge graph

Observing doesn't only produce embeddable rows — extraction also resolves
entities and writes typed edges between them. That graph reaches facts no single
memory states outright.

```python
# How much of what you remember made it into the graph?
st = mm.memory.graph.stats()
print(st["entityCount"], st["edgeCount"], st["memoriesWithEdges"])

# Multi-hop: who does Sarah ultimately report to?
sarah, = mm.memory.graph.list_entities(search="Sarah", limit=1)
chain = mm.memory.graph.traverse(sarah["id"], hops=2, predicates=["member_of", "led_by"])
```

Use `stats()` — not `len(list_entities())` — for any "how big is it" question:
the list routes page, so their length is the page size, not the total.

Read-only. Entities and edges are written by extraction during `observe()`; a
hand-maintained graph is the work the engine exists to do for you.

## Configuration

```python
Expand Down
8 changes: 8 additions & 0 deletions src/memmesh/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
ValidationError,
)
from .types import (
EntityWithEdges,
GraphStats,
GraphTraversalEdge,
MemoryEntity,
Accumulator,
ActivityLevel,
AlertDeliveryResult,
Expand Down Expand Up @@ -166,6 +170,10 @@
)

__all__ = [
"GraphStats",
"MemoryEntity",
"GraphTraversalEdge",
"EntityWithEdges",
"__version__",
"MemMesh",
"AsyncMemMesh",
Expand Down
3 changes: 3 additions & 0 deletions src/memmesh/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .context import AsyncContextResource, ContextResource
from .events import AsyncEventsResource, EventsResource
from .financial import AsyncFinancialResource, FinancialResource
from .graph import AsyncGraphResource, GraphResource
from .health import AsyncHealthResource, HealthResource
from .lattice import AsyncLatticeResource, LatticeResource
from .learning import AsyncLearningResource, LearningResource
Expand All @@ -14,6 +15,8 @@

__all__ = [
"MemoryResource",
"GraphResource",
"AsyncGraphResource",
"AsyncMemoryResource",
"LatticeResource",
"AsyncLatticeResource",
Expand Down
220 changes: 220 additions & 0 deletions src/memmesh/resources/graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Knowledge-graph resource — the structural half of memory.

Observing text doesn't only produce embeddable rows; extraction also resolves
entities and writes typed edges between them. That graph is what reaches a fact
no single memory states outright ("who does Sarah report to?" answered from
``sarah -[member_of]-> team`` plus ``team -[led_by]-> priya``).

Both records are bi-temporal, and the two time axes mean different things:

* ``valid_from`` / ``valid_to`` — when the fact was TRUE in the world.
* ``expired_at`` (edges) — when the graph stopped BELIEVING it, because a
contradicting edge superseded it.

A fact that was true last year and a fact we were wrong about are not the same
thing, and collapsing them loses the audit trail.

Read-only by design. Entities and edges are written by extraction when you
:meth:`~memmesh.resources.memory.MemoryResource.observe`; the server's manual
create/retire routes exist for annotation tooling, and exposing them here would
invite hand-maintained graphs — which is the work the engine exists to do.

Mirrors ``@memmesh/sdk``'s ``resources/graph.ts``.
"""

from __future__ import annotations

from typing import Any, List, Optional

from ..types import EntityWithEdges, GraphStats, GraphTraversalEdge, MemoryEntity


def _entity_params(
type: Optional[str],
scope: Optional[str],
search: Optional[str],
limit: Optional[int],
offset: Optional[int],
) -> dict:
params: dict = {}
if type is not None:
params["type"] = type
if scope is not None:
params["scope"] = scope
if search is not None:
params["search"] = search
if limit is not None:
params["limit"] = limit
if offset is not None:
params["offset"] = offset
return params


def _traverse_body(
entity_id: str,
hops: Optional[int],
predicates: Optional[List[str]],
as_of: Optional[str],
) -> dict:
body: dict = {"entityId": entity_id}
if hops is not None:
body["hops"] = hops
if predicates is not None:
body["predicates"] = predicates
if as_of is not None:
body["asOf"] = as_of
return body


class GraphResource:
"""Synchronous knowledge-graph reads."""

def __init__(self, transport: Any) -> None:
self._t = transport

def stats(self, *, project_id: Optional[str] = None) -> GraphStats:
"""Aggregate counts for the whole graph.

Prefer this over ``len(list_entities())`` for any "how big is it"
question: these are SQL ``COUNT(*)``s over the full table, where the
list routes page and would report the page size as the total.
"""
return self._t.get("/admin/memory/graph/stats", None, project_id)

def list_entities(
self,
*,
type: Optional[str] = None,
scope: Optional[str] = None,
search: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
project_id: Optional[str] = None,
) -> List[MemoryEntity]:
"""Entities, filtered by type/scope or a substring of name or alias."""
return self._t.get(
"/admin/memory/entities",
_entity_params(type, scope, search, limit, offset),
project_id,
)

def get_entity(
self,
entity_id: str,
*,
as_of: Optional[str] = None,
project_id: Optional[str] = None,
) -> EntityWithEdges:
"""One entity plus its 1-hop neighbourhood."""
params = {"asOf": as_of} if as_of else None
return self._t.get(f"/admin/memory/entities/{entity_id}", params, project_id)

def list_edges(
self,
*,
as_of: Optional[str] = None,
limit: Optional[int] = None,
project_id: Optional[str] = None,
) -> List[GraphTraversalEdge]:
"""Every currently-valid edge.

Use for rendering a whole small graph; for a large one, seed from an
entity and :meth:`traverse` instead.
"""
params: dict = {}
if as_of is not None:
params["asOf"] = as_of
if limit is not None:
params["limit"] = limit
return self._t.get("/admin/memory/graph/edges", params, project_id)

def traverse(
self,
entity_id: str,
*,
hops: Optional[int] = None,
predicates: Optional[List[str]] = None,
as_of: Optional[str] = None,
project_id: Optional[str] = None,
) -> List[GraphTraversalEdge]:
"""Walk out from a seed entity (1-3 hops).

This is the multi-hop path: the edges returned here connect facts no
single memory states together, which is how a question gets answered
from a chain rather than from one lucky vector hit.
"""
return self._t.post(
"/admin/memory/graph/traverse",
_traverse_body(entity_id, hops, predicates, as_of),
project_id,
)


class AsyncGraphResource:
"""Async mirror of :class:`GraphResource`."""

def __init__(self, transport: Any) -> None:
self._t = transport

async def stats(self, *, project_id: Optional[str] = None) -> GraphStats:
"""Async mirror of :meth:`GraphResource.stats`."""
return await self._t.get("/admin/memory/graph/stats", None, project_id)

async def list_entities(
self,
*,
type: Optional[str] = None,
scope: Optional[str] = None,
search: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
project_id: Optional[str] = None,
) -> List[MemoryEntity]:
"""Async mirror of :meth:`GraphResource.list_entities`."""
return await self._t.get(
"/admin/memory/entities",
_entity_params(type, scope, search, limit, offset),
project_id,
)

async def get_entity(
self,
entity_id: str,
*,
as_of: Optional[str] = None,
project_id: Optional[str] = None,
) -> EntityWithEdges:
"""Async mirror of :meth:`GraphResource.get_entity`."""
params = {"asOf": as_of} if as_of else None
return await self._t.get(f"/admin/memory/entities/{entity_id}", params, project_id)

async def list_edges(
self,
*,
as_of: Optional[str] = None,
limit: Optional[int] = None,
project_id: Optional[str] = None,
) -> List[GraphTraversalEdge]:
"""Async mirror of :meth:`GraphResource.list_edges`."""
params: dict = {}
if as_of is not None:
params["asOf"] = as_of
if limit is not None:
params["limit"] = limit
return await self._t.get("/admin/memory/graph/edges", params, project_id)

async def traverse(
self,
entity_id: str,
*,
hops: Optional[int] = None,
predicates: Optional[List[str]] = None,
as_of: Optional[str] = None,
project_id: Optional[str] = None,
) -> List[GraphTraversalEdge]:
"""Async mirror of :meth:`GraphResource.traverse`."""
return await self._t.post(
"/admin/memory/graph/traverse",
_traverse_body(entity_id, hops, predicates, as_of),
project_id,
)
Loading