nomem 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nomem/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """nomem — No One's Memory Management.
2
+
3
+ Persistent, self-updating, per-user memory for LLM applications, structured as a
4
+ bi-temporal knowledge graph with an explicit LLM-powered CRUD interface.
5
+
6
+ from nomem import MemoryGraph
7
+
8
+ graph = MemoryGraph(user_id="u123", backend="sqlite", embedder="nomic")
9
+ graph.ingest(user="...", assistant="...")
10
+ result = graph.retrieve("Kira")
11
+
12
+ Storage, embedding, and extraction are adapter boundaries: SQLite (zero infra),
13
+ PostgreSQL/pgvector, and Neo4j all pass one identical behavioral contract suite,
14
+ the nomic (Ollama) and OpenAI embedders both talk plain HTTP with no extra
15
+ dependency, and any piece can be replaced with your own class or a bare callable.
16
+ Every method has an ``a``-prefixed async twin; the sync API is a thin wrapper over
17
+ the async core.
18
+
19
+ Capability is extended through the ``nomem.plugins`` entry point rather than by
20
+ subclassing or forking — see ``docs/stability.md`` for what ``>=0.1,<0.2``
21
+ guarantees.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from importlib.metadata import PackageNotFoundError
27
+ from importlib.metadata import version as _pkg_version
28
+
29
+ from .config import DecayConfig, IngestConfig, MemoryGraphConfig, RetrievalConfig
30
+ from .exceptions import (
31
+ BackendError,
32
+ ConfigError,
33
+ EdgeNotFoundError,
34
+ EmbedderError,
35
+ ExtractionError,
36
+ NodeNotFoundError,
37
+ NomemError,
38
+ NotSupportedError,
39
+ RetrievalBudgetExceededError,
40
+ )
41
+ from .graph import MemoryGraph
42
+ from .models import (
43
+ DecayResult,
44
+ Edge,
45
+ ExtractedEntity,
46
+ ExtractedRelation,
47
+ IngestReceipt,
48
+ Node,
49
+ PurgeResult,
50
+ ResolutionOutcome,
51
+ SubGraph,
52
+ )
53
+ from .plugins import Plugin
54
+
55
+ try:
56
+ __version__ = _pkg_version("nomem")
57
+ except PackageNotFoundError: # running from a source tree that was never installed
58
+ __version__ = "0.0.0+unknown"
59
+
60
+ __all__ = [
61
+ "BackendError",
62
+ "ConfigError",
63
+ "DecayConfig",
64
+ "DecayResult",
65
+ "Edge",
66
+ "EdgeNotFoundError",
67
+ "EmbedderError",
68
+ "ExtractedEntity",
69
+ "ExtractedRelation",
70
+ "ExtractionError",
71
+ "IngestConfig",
72
+ "IngestReceipt",
73
+ "MemoryGraph",
74
+ "MemoryGraphConfig",
75
+ "Node",
76
+ "NodeNotFoundError",
77
+ "NomemError",
78
+ "NotSupportedError",
79
+ "Plugin",
80
+ "PurgeResult",
81
+ "ResolutionOutcome",
82
+ "RetrievalBudgetExceededError",
83
+ "RetrievalConfig",
84
+ "SubGraph",
85
+ "__version__",
86
+ ]
nomem/_http.py ADDED
@@ -0,0 +1,63 @@
1
+ """Minimal async JSON-over-HTTP helper (stdlib only).
2
+
3
+ The default embedder and LLM adapters talk to a local Ollama. nomem's core has
4
+ no third-party runtime dependencies, so this wraps :mod:`urllib.request` and
5
+ offloads the blocking call to a worker thread.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import urllib.error
13
+ import urllib.request
14
+ from typing import Any
15
+
16
+
17
+ class HTTPError(RuntimeError):
18
+ """A non-2xx response or a transport-level failure."""
19
+
20
+
21
+ def _post_json_sync(
22
+ url: str,
23
+ payload: dict[str, Any],
24
+ timeout: float,
25
+ headers: dict[str, str] | None = None,
26
+ ) -> dict[str, Any]:
27
+ data = json.dumps(payload).encode("utf-8")
28
+ req = urllib.request.Request(
29
+ url,
30
+ data=data,
31
+ headers={"Content-Type": "application/json", **(headers or {})},
32
+ method="POST",
33
+ )
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
36
+ body = resp.read()
37
+ except urllib.error.HTTPError as exc: # pragma: no cover - network dependent
38
+ detail = exc.read().decode("utf-8", "replace")
39
+ raise HTTPError(f"POST {url} -> {exc.code}: {detail}") from exc
40
+ except urllib.error.URLError as exc: # pragma: no cover - network dependent
41
+ raise HTTPError(f"POST {url} failed: {exc.reason}") from exc
42
+ try:
43
+ parsed = json.loads(body)
44
+ except json.JSONDecodeError as exc: # pragma: no cover - defensive
45
+ raise HTTPError(f"POST {url} returned non-JSON body") from exc
46
+ if not isinstance(parsed, dict):
47
+ raise HTTPError(f"POST {url} returned a JSON {type(parsed).__name__}, expected an object")
48
+ return parsed
49
+
50
+
51
+ async def post_json(
52
+ url: str,
53
+ payload: dict[str, Any],
54
+ *,
55
+ timeout: float = 60.0,
56
+ headers: dict[str, str] | None = None,
57
+ ) -> dict[str, Any]:
58
+ """POST ``payload`` as JSON to ``url`` and return the decoded JSON response.
59
+
60
+ ``headers`` are merged over the default ``Content-Type`` — that is how the
61
+ OpenAI embedder passes its ``Authorization``.
62
+ """
63
+ return await asyncio.to_thread(_post_json_sync, url, payload, timeout, headers)
nomem/_vector.py ADDED
@@ -0,0 +1,58 @@
1
+ """Vector helpers (stdlib only).
2
+
3
+ Embeddings are stored as packed 32-bit floats; similarity is computed in Python.
4
+ Fine at SQLite/dev scale — the Postgres and Neo4j backends push this into the
5
+ database.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from array import array
12
+
13
+ from .models import Vector
14
+
15
+ _TYPECODE = "f"
16
+
17
+
18
+ def pack(vec: Vector) -> bytes:
19
+ """Pack a float vector into a compact ``bytes`` blob."""
20
+ return array(_TYPECODE, vec).tobytes()
21
+
22
+
23
+ def unpack(blob: bytes) -> Vector:
24
+ """Reverse :func:`pack`."""
25
+ out = array(_TYPECODE)
26
+ out.frombytes(blob)
27
+ return list(out)
28
+
29
+
30
+ def mean(vectors: list[Vector]) -> Vector:
31
+ """Component-wise mean of equal-length vectors; ``[]`` if the input is empty."""
32
+ usable = [v for v in vectors if v]
33
+ if not usable:
34
+ return []
35
+ dim = len(usable[0])
36
+ acc = [0.0] * dim
37
+ for vec in usable:
38
+ if len(vec) != dim:
39
+ continue
40
+ for i, x in enumerate(vec):
41
+ acc[i] += x
42
+ return [x / len(usable) for x in acc]
43
+
44
+
45
+ def cosine(a: Vector, b: Vector) -> float:
46
+ """Cosine similarity in ``[-1, 1]``; ``0.0`` if either vector is empty/zero."""
47
+ if not a or not b or len(a) != len(b):
48
+ return 0.0
49
+ dot = 0.0
50
+ na = 0.0
51
+ nb = 0.0
52
+ for x, y in zip(a, b, strict=True):
53
+ dot += x * y
54
+ na += x * x
55
+ nb += y * y
56
+ if na == 0.0 or nb == 0.0:
57
+ return 0.0
58
+ return dot / (math.sqrt(na) * math.sqrt(nb))
@@ -0,0 +1,49 @@
1
+ """Backend adapters and the name -> class registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..exceptions import ConfigError
8
+ from .base import BaseBackend
9
+ from .neo4j import Neo4jBackend
10
+ from .postgres import PostgresBackend
11
+ from .sqlite import SQLiteBackend
12
+
13
+ BACKEND_REGISTRY: dict[str, type[BaseBackend]] = {
14
+ "sqlite": SQLiteBackend,
15
+ "postgres": PostgresBackend,
16
+ "neo4j": Neo4jBackend,
17
+ }
18
+
19
+
20
+ def resolve_backend(
21
+ backend: str | BaseBackend,
22
+ options: dict[str, Any] | None = None,
23
+ ) -> BaseBackend:
24
+ """Turn a config value into a live backend.
25
+
26
+ ``backend`` may be a registered name (``"sqlite"``) or an already-built
27
+ :class:`BaseBackend` instance (which is returned as-is).
28
+ """
29
+ if isinstance(backend, BaseBackend):
30
+ return backend
31
+ if not isinstance(backend, str):
32
+ raise ConfigError(f"backend must be a name or a BaseBackend, got {type(backend)!r}")
33
+ try:
34
+ cls = BACKEND_REGISTRY[backend]
35
+ except KeyError:
36
+ raise ConfigError(
37
+ f"unknown backend {backend!r}; known: {sorted(BACKEND_REGISTRY)}"
38
+ ) from None
39
+ return cls(**(options or {}))
40
+
41
+
42
+ __all__ = [
43
+ "BACKEND_REGISTRY",
44
+ "BaseBackend",
45
+ "Neo4jBackend",
46
+ "PostgresBackend",
47
+ "SQLiteBackend",
48
+ "resolve_backend",
49
+ ]
@@ -0,0 +1,78 @@
1
+ """Helpers shared by the concrete backends.
2
+
3
+ Keeps bi-temporal filtering and graph traversal identical across adapters, so
4
+ switching backends never changes what a query returns.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+
11
+ from ..models import Edge, Node, SubGraph
12
+
13
+
14
+ def now_utc() -> datetime:
15
+ return datetime.now(tz=UTC)
16
+
17
+
18
+ def iso(dt: datetime) -> str:
19
+ """Serialize a datetime as a UTC ISO-8601 string (lexically comparable)."""
20
+ if dt.tzinfo is None:
21
+ dt = dt.replace(tzinfo=UTC)
22
+ return dt.astimezone(UTC).isoformat()
23
+
24
+
25
+ def parse_ts(value: str | datetime | None) -> datetime | None:
26
+ if value is None or isinstance(value, datetime):
27
+ return value
28
+ return datetime.fromisoformat(value)
29
+
30
+
31
+ def aware(dt: datetime) -> datetime:
32
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
33
+
34
+
35
+ def active_at(record: Node | Edge, as_of: datetime) -> bool:
36
+ """True if ``record`` was recorded by ``as_of`` and its window contains it."""
37
+ as_of = aware(as_of)
38
+ if aware(record.created_at) > as_of or aware(record.valid_from) > as_of:
39
+ return False
40
+ return record.valid_to is None or aware(record.valid_to) > as_of
41
+
42
+
43
+ def bfs_subgraph(
44
+ nodes_by_id: dict[str, Node],
45
+ edges: list[Edge],
46
+ seed_ids: list[str],
47
+ hops: int,
48
+ ) -> SubGraph:
49
+ """Expand ``hops`` undirected edge-steps out from ``seed_ids``.
50
+
51
+ ``nodes_by_id`` / ``edges`` must already be filtered to the visible set
52
+ (active-only, or ``as_of`` a timestamp).
53
+ """
54
+ frontier = {sid for sid in seed_ids if sid in nodes_by_id}
55
+ visited = set(frontier)
56
+ kept_edges: dict[str, Edge] = {}
57
+
58
+ for _ in range(max(hops, 0)):
59
+ next_frontier: set[str] = set()
60
+ for edge in edges:
61
+ if edge.source_id in frontier and edge.target_id in nodes_by_id:
62
+ kept_edges[edge.id] = edge
63
+ if edge.target_id not in visited:
64
+ next_frontier.add(edge.target_id)
65
+ if edge.target_id in frontier and edge.source_id in nodes_by_id:
66
+ kept_edges[edge.id] = edge
67
+ if edge.source_id not in visited:
68
+ next_frontier.add(edge.source_id)
69
+ if not next_frontier:
70
+ break
71
+ visited |= next_frontier
72
+ frontier = next_frontier
73
+
74
+ return SubGraph(
75
+ nodes=[nodes_by_id[nid] for nid in visited if nid in nodes_by_id],
76
+ edges=list(kept_edges.values()),
77
+ metadata={"hops": max(hops, 0), "seed_node_ids": list(seed_ids)},
78
+ )
nomem/backends/base.py ADDED
@@ -0,0 +1,135 @@
1
+ """Backend adapter interface.
2
+
3
+ Any object implementing :class:`BaseBackend` is a valid nomem storage backend —
4
+ devs can write their own. Storage is an adapter boundary: swapping a backend
5
+ touches nothing else (AGENT.md "Backend and embedder agnosticism").
6
+
7
+ Contract notes that apply to every implementation:
8
+
9
+ * **Bi-temporal, no hard delete.** ``retire_node`` / ``retire_edge`` set
10
+ ``valid_to = now`` and, when given, ``superseded_by``. Records are never
11
+ removed.
12
+ * **``as_of`` honesty.** ``get_node`` and ``traverse`` with ``as_of=`` must
13
+ return the graph as it was *known* at that instant.
14
+ * **User scoping.** Every record carries ``user_id``; a backend instance must
15
+ never leak rows across users.
16
+ * **Async.** Every method is a coroutine. Blocking drivers must be run in a
17
+ thread/executor by the adapter.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from abc import ABC, abstractmethod
23
+ from datetime import datetime
24
+ from typing import Any
25
+
26
+ from ..config import DecayConfig
27
+ from ..exceptions import NotSupportedError
28
+ from ..models import DecayResult, Edge, Node, PurgeResult, SubGraph, Vector
29
+
30
+
31
+ class BaseBackend(ABC):
32
+ """Abstract storage adapter. See module docstring for the shared contract."""
33
+
34
+ @abstractmethod
35
+ async def create_node(self, node: Node) -> Node:
36
+ """Persist a new node and return the stored record."""
37
+
38
+ @abstractmethod
39
+ async def update_node(self, node_id: str, updates: dict[str, Any]) -> Node:
40
+ """Apply a partial update to an active node and return the new record."""
41
+
42
+ @abstractmethod
43
+ async def retire_node(self, node_id: str, superseded_by: str | None = None) -> Node:
44
+ """Retire a node: set ``valid_to = now`` (+ ``superseded_by``). Never delete."""
45
+
46
+ @abstractmethod
47
+ async def get_node(self, node_id: str, as_of: datetime | None = None) -> Node | None:
48
+ """Return a node as known at ``as_of`` (or now), or ``None`` if absent."""
49
+
50
+ @abstractmethod
51
+ async def upsert_edge(self, edge: Edge) -> Edge:
52
+ """Create the edge, or bump ``weight`` / metadata if an active one exists."""
53
+
54
+ @abstractmethod
55
+ async def retire_edge(self, edge_id: str, superseded_by: str | None = None) -> Edge:
56
+ """Retire an edge: set ``valid_to = now`` (+ ``superseded_by``). Never delete."""
57
+
58
+ @abstractmethod
59
+ async def get_edge(self, edge_id: str, as_of: datetime | None = None) -> Edge | None:
60
+ """Return an edge as known at ``as_of`` (or now), or ``None`` if absent.
61
+
62
+ The node-side counterpart is :meth:`get_node`; the temporal rule is the
63
+ same one :func:`nomem.backends._common.active_at` implements.
64
+ """
65
+
66
+ @abstractmethod
67
+ async def vector_search(self, embedding: Vector, top_k: int) -> list[Node]:
68
+ """Return the ``top_k`` active nodes most similar to ``embedding``."""
69
+
70
+ @abstractmethod
71
+ async def list_nodes(
72
+ self,
73
+ *,
74
+ active_only: bool = True,
75
+ context: list[str] | None = None,
76
+ as_of: datetime | None = None,
77
+ ) -> list[Node]:
78
+ """Enumerate this user's nodes.
79
+
80
+ ``context`` keeps only nodes whose ``metadata["context"]`` intersects the
81
+ given tags. ``as_of`` applies the same temporal filter as ``get_node``.
82
+ Used by hierarchical retrieval and by callers that need the whole set.
83
+ """
84
+
85
+ @abstractmethod
86
+ async def list_edges(
87
+ self,
88
+ *,
89
+ active_only: bool = True,
90
+ as_of: datetime | None = None,
91
+ ) -> list[Edge]:
92
+ """Enumerate this user's edges.
93
+
94
+ ``as_of`` applies the same temporal filter as :meth:`get_edge` and takes
95
+ precedence over ``active_only``; ``active_only=False`` without ``as_of``
96
+ returns every edge on record, retired ones included. Enumeration — not
97
+ seed-based :meth:`traverse` — is what a full graph dump needs to
98
+ round-trip retired records.
99
+ """
100
+
101
+ @abstractmethod
102
+ async def traverse(
103
+ self, seed_ids: list[str], hops: int, as_of: datetime | None = None
104
+ ) -> SubGraph:
105
+ """Expand ``hops`` edges out from ``seed_ids``; honor ``as_of`` if given."""
106
+
107
+ @abstractmethod
108
+ async def cross_reference(self, node: Node, threshold: float) -> list[tuple[Node, float]]:
109
+ """Return active nodes whose similarity to ``node`` is >= ``threshold``."""
110
+
111
+ @abstractmethod
112
+ async def run_decay(self, config: DecayConfig) -> DecayResult:
113
+ """Run one decay/pruning pass over this user's nodes per ``config``."""
114
+
115
+ # --- the one hard-delete path ----------------------------------
116
+
117
+ async def purge_user(self, user_id: str) -> PurgeResult:
118
+ """Really delete every row for ``user_id``. **Not** part of retirement.
119
+
120
+ This is the single exception to "nomem never hard-deletes", and it
121
+ exists so GDPR right-to-forget can be built as a plugin instead of a
122
+ fork. It is deliberately fenced:
123
+
124
+ * it is **not abstract** — a custom backend may leave this default body,
125
+ which raises :class:`~nomem.exceptions.NotSupportedError`;
126
+ * implementations must raise :class:`~nomem.exceptions.BackendError`
127
+ unless ``user_id`` matches the backend's own user;
128
+ * it is **unreachable from** :class:`nomem.MemoryGraph` and
129
+ ``GraphCRUD``, and a guard test asserts it stays that way. A backend
130
+ handle is not the public API.
131
+ """
132
+ raise NotSupportedError(
133
+ f"{type(self).__name__} does not implement purge_user; "
134
+ "nomem retires records via valid_to and never hard-deletes"
135
+ )