memstrand 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.
memstrand/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """memstrand: local-first typed memory for AI agents."""
2
+
3
+ from .api import Memory
4
+ from .schema import Category, MemoryAtom, MemoryEdge, Provenance, RelationType
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["Category", "Memory", "MemoryAtom", "MemoryEdge", "Provenance", "RelationType"]
memstrand/api.py ADDED
@@ -0,0 +1,182 @@
1
+ """memstrand public API: remember / recall / improve / forget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ from datetime import UTC
8
+
9
+ import httpx
10
+
11
+ from .schema import Category, MemoryAtom, Provenance, RelationType, ScopedQuery
12
+ from .stores.base import GraphStore, SessionStore, VectorStore
13
+ from .stores.graph_kuzu import KuzuGraphStore
14
+ from .stores.sqlite import SqliteSessionStore
15
+ from .stores.vector_qdrant import QdrantVectorStore
16
+
17
+
18
+ class Memory:
19
+ """Local-first typed memory. All LLM calls go to a local endpoint."""
20
+
21
+ def __init__(
22
+ self,
23
+ qdrant_url: str = "http://localhost:6333",
24
+ kuzu_path: str = "./kuzu_data",
25
+ sqlite_path: str = "./memstrand.db",
26
+ llm_base_url: str | None = None,
27
+ llm_model: str | None = None,
28
+ # dependency injection: real backends by default, swappable in tests
29
+ embedder=None,
30
+ vector_store: VectorStore | None = None,
31
+ graph_store: GraphStore | None = None,
32
+ session_store: SessionStore | None = None,
33
+ ):
34
+ if embedder is None:
35
+ from fastembed import TextEmbedding
36
+
37
+ embedder = TextEmbedding() # local ONNX, no API key
38
+ self.embedder = embedder
39
+ self.vectors = vector_store or QdrantVectorStore(url=qdrant_url)
40
+ self.graph = graph_store or KuzuGraphStore(path=kuzu_path)
41
+ self.sessions = session_store or SqliteSessionStore(path=sqlite_path)
42
+ self.llm_base_url = (
43
+ llm_base_url or os.environ.get("MEMSTRAND_LLM_BASE_URL", "http://localhost:8081/v1")
44
+ )
45
+ self.llm_model = llm_model or os.environ.get("MEMSTRAND_LLM_MODEL", "local")
46
+
47
+ def _embed(self, text: str) -> list[float]:
48
+ return next(iter(self.embedder.embed([text])))
49
+
50
+ @staticmethod
51
+ def _atom_id(content: str) -> str:
52
+ """Content-hash id: identical re-adds are no-ops by construction."""
53
+ return f"mem:{hashlib.sha256(content.encode()).hexdigest()[:12]}"
54
+
55
+ # -- fast write path (L0/L3): no extraction, ~ms -------------------------
56
+ def remember(
57
+ self,
58
+ content: str,
59
+ category: Category = Category.fact,
60
+ provenance: Provenance = Provenance.explicit_statement,
61
+ confidence: float = 0.8,
62
+ session_id: str | None = None,
63
+ task_id: str | None = None,
64
+ host: str | None = None,
65
+ ttl_days: float | None = None,
66
+ ) -> MemoryAtom:
67
+ from datetime import datetime, timedelta
68
+
69
+ atom = MemoryAtom(
70
+ id=self._atom_id(content),
71
+ category=category,
72
+ content=content,
73
+ provenance=provenance,
74
+ confidence=confidence,
75
+ session_id=session_id,
76
+ task_id=task_id,
77
+ host=host,
78
+ expires_at=(
79
+ datetime.now(UTC) + timedelta(days=ttl_days)
80
+ if ttl_days
81
+ else None
82
+ ),
83
+ )
84
+ self.vectors.upsert(atom, self._embed(content))
85
+ self.graph.add_atom(atom)
86
+ self.sessions.log_provenance(atom.id, "remember", provenance.value)
87
+ return atom
88
+
89
+ # -- scoped read path ----------------------------------------------------
90
+ def recall(
91
+ self,
92
+ query: str,
93
+ session_id: str | None = None,
94
+ task_id: str | None = None,
95
+ host: str | None = None,
96
+ categories: list[Category] | None = None,
97
+ limit: int = 10,
98
+ ) -> list[dict]:
99
+ """Scope filter is a hard filter. Unscoped queries never see
100
+ task-scoped atoms — the Playwright-bug fix. Results are hydrated:
101
+ content, type, provenance, and graph neighbors ride along, and
102
+ contradictions are surfaced, never hidden."""
103
+ q = ScopedQuery(
104
+ text=query, session_id=session_id, task_id=task_id,
105
+ host=host, categories=categories, limit=limit,
106
+ )
107
+ hits = self.vectors.search(self._embed(query), q)
108
+ results = []
109
+ for atom_id, score in hits:
110
+ atom = self.graph.get_atom(atom_id)
111
+ neighbor_rows = []
112
+ for n_atom, edge in self.graph.neighbors(atom_id, depth=1):
113
+ neighbor_rows.append(
114
+ {
115
+ "id": n_atom.id,
116
+ "relation": edge.relation.value,
117
+ "content": n_atom.content,
118
+ "weight": edge.weight,
119
+ }
120
+ )
121
+ self.graph.strengthen_edge(atom_id, n_atom.id) # Hebbian
122
+ results.append(
123
+ {
124
+ "id": atom_id,
125
+ "score": score,
126
+ "content": atom.content if atom else None,
127
+ "category": atom.category.value if atom else None,
128
+ "provenance": atom.provenance.value if atom else None,
129
+ "confidence": atom.confidence if atom else None,
130
+ "task_id": atom.task_id if atom else None,
131
+ "neighbors": neighbor_rows,
132
+ "contradictions": [
133
+ n for n in neighbor_rows if n["relation"] == RelationType.contradicts.value
134
+ ],
135
+ }
136
+ )
137
+ return results
138
+
139
+ # -- session lifecycle ---------------------------------------------------
140
+ def improve(self, session_ids: list[str]) -> dict:
141
+ """Distill raw sessions into typed permanent memory."""
142
+ from .pipeline import distill
143
+
144
+ return distill.distill_sessions(self, session_ids)
145
+
146
+ def forget(self, atom_id: str) -> None:
147
+ """Delete with provenance-chain cleanup. Never silent."""
148
+ self.vectors.delete(atom_id)
149
+ self.graph.delete_atom(atom_id)
150
+ self.sessions.log_provenance(atom_id, "forget", "explicit user delete")
151
+ # TODO: propagate to session turns that cited this atom
152
+
153
+ # -- grounded answering ---------------------------------------------------
154
+ def grounded_answer(self, question: str, **recall_kwargs) -> str:
155
+ """Answer ONLY from stored memories. No retrieval = 'I don't know'."""
156
+ hits = self.recall(question, **recall_kwargs)
157
+ if not hits:
158
+ return "I don't know — nothing in memory covers this."
159
+ context = "\n".join(
160
+ f"- [{h['category']}] {h['content']} (score {h['score']:.2f})"
161
+ for h in hits
162
+ if h["content"]
163
+ )
164
+ if not context:
165
+ return "I don't know — nothing in memory covers this."
166
+ resp = httpx.post(
167
+ f"{self.llm_base_url}/chat/completions",
168
+ headers={"Authorization": "Bearer <redacted>"},
169
+ json={
170
+ "model": self.llm_model,
171
+ "messages": [
172
+ {
173
+ "role": "system",
174
+ "content": "Answer ONLY from the memories below. "
175
+ "If they don't cover the question, say so.",
176
+ },
177
+ {"role": "user", "content": f"Memories:\n{context}\n\nQ: {question}"},
178
+ ],
179
+ },
180
+ timeout=120,
181
+ )
182
+ return resp.json()["choices"][0]["message"]["content"]
memstrand/cli.py ADDED
@@ -0,0 +1,38 @@
1
+ """memstrand CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from .api import Memory
8
+ from .schema import Category, Provenance
9
+
10
+
11
+ def main() -> None:
12
+ p = argparse.ArgumentParser(prog="memstrand", description="local-first agent memory")
13
+ sub = p.add_subparsers(dest="cmd", required=True)
14
+
15
+ r = sub.add_parser("remember", help="store one memory")
16
+ r.add_argument("content")
17
+ r.add_argument("--category", default="fact", choices=[c.value for c in Category])
18
+ r.add_argument("--task", default=None)
19
+
20
+ q = sub.add_parser("recall", help="scoped recall")
21
+ q.add_argument("query")
22
+ q.add_argument("--task", default=None)
23
+ q.add_argument("--limit", type=int, default=10)
24
+
25
+ args = p.parse_args()
26
+ mem = Memory()
27
+
28
+ if args.cmd == "remember":
29
+ atom = mem.remember(
30
+ args.content,
31
+ category=Category(args.category),
32
+ provenance=Provenance.explicit_statement,
33
+ task_id=args.task,
34
+ )
35
+ print(atom.id)
36
+ elif args.cmd == "recall":
37
+ for hit in mem.recall(args.query, task_id=args.task, limit=args.limit):
38
+ print(f"{hit['score']:.3f} {hit['id']}")
@@ -0,0 +1,70 @@
1
+ """MCP server: memstrand tools for any MCP-compatible agent.
2
+
3
+ python -m memstrand.mcp_server
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from .api import Memory
11
+ from .schema import Category, Provenance
12
+
13
+ mcp = FastMCP("memstrand")
14
+ _mem: Memory | None = None
15
+
16
+
17
+ def _memory() -> Memory:
18
+ global _mem
19
+ if _mem is None:
20
+ _mem = Memory()
21
+ return _mem
22
+
23
+
24
+ @mcp.tool()
25
+ def remember(
26
+ content: str,
27
+ category: str = "fact",
28
+ provenance: str = "explicit_statement",
29
+ confidence: float = 0.8,
30
+ session_id: str | None = None,
31
+ task_id: str | None = None,
32
+ ) -> str:
33
+ """Store one atomic memory."""
34
+ atom = _memory().remember(
35
+ content,
36
+ category=Category(category),
37
+ provenance=Provenance(provenance),
38
+ confidence=confidence,
39
+ session_id=session_id,
40
+ task_id=task_id,
41
+ )
42
+ return atom.id
43
+
44
+
45
+ @mcp.tool()
46
+ def recall(
47
+ query: str,
48
+ session_id: str | None = None,
49
+ task_id: str | None = None,
50
+ limit: int = 10,
51
+ ) -> list[dict]:
52
+ """Scoped semantic recall."""
53
+ return _memory().recall(
54
+ query, session_id=session_id, task_id=task_id, limit=limit
55
+ )
56
+
57
+
58
+ @mcp.tool()
59
+ def forget(atom_id: str) -> str:
60
+ """Delete a memory with provenance logging."""
61
+ _memory().forget(atom_id)
62
+ return f"forgot {atom_id}"
63
+
64
+
65
+ def main() -> None:
66
+ mcp.run()
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,3 @@
1
+ from . import consolidate, distill, extract
2
+
3
+ __all__ = ["consolidate", "distill", "extract"]
@@ -0,0 +1,47 @@
1
+ """Sleep consolidation: FSRS + replay + Hebbian decay.
2
+
3
+ ZenBrain's stealable mechanics, adapted:
4
+ - per-fact FSRS state {difficulty, stability_days, next_review};
5
+ retrieval success counts as a review (stability grows)
6
+ - replay: nightly, pick top-k by
7
+ 0.3*access_freq + 0.3*emotion + 0.2*recency + 0.2*instability,
8
+ restabilize (stability x1.5, cap 365d)
9
+ - Hebbian edge decay on a schedule; prune weak EDGES, never facts
10
+
11
+ Runs as a cron job, not in the request path.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from datetime import UTC, datetime, timedelta
18
+
19
+
20
+ def fsrs_recall_probability(stability_days: float, days_since_review: float) -> float:
21
+ """R = e^(-t/S): probability the fact is still retrievable."""
22
+ if stability_days <= 0:
23
+ return 0.0
24
+ return math.exp(-days_since_review / stability_days)
25
+
26
+
27
+ def record_review(atom, success: bool) -> None:
28
+ """A successful recall strengthens; a miss weakens. Desirable difficulty."""
29
+ if success:
30
+ atom.fsrs_stability_days = min(atom.fsrs_stability_days * 1.5, 365.0)
31
+ atom.fsrs_difficulty = max(atom.fsrs_difficulty - 0.05, 0.1)
32
+ else:
33
+ atom.fsrs_stability_days = max(atom.fsrs_stability_days * 0.7, 1.0)
34
+ atom.fsrs_difficulty = min(atom.fsrs_difficulty + 0.05, 0.9)
35
+ atom.fsrs_next_review = datetime.now(UTC) + timedelta(
36
+ days=atom.fsrs_stability_days
37
+ )
38
+
39
+
40
+ def replay_priority(atom) -> float:
41
+ """Which memories get replayed tonight."""
42
+ return (
43
+ 0.3 * min(atom.access_count / 10.0, 1.0)
44
+ + 0.3 * atom.confidence
45
+ + 0.2 * 1.0 # recency term: compute from updated_at in real impl
46
+ + 0.2 * atom.fsrs_difficulty
47
+ )
@@ -0,0 +1,164 @@
1
+ """Session-end distillation (the 'improve' step).
2
+
3
+ Cognee's improve() pattern, local-first:
4
+ 1. extract candidate atoms from raw session turns
5
+ 2. dedup by content hash (identical re-add = no-op)
6
+ 3. conflict check: genuinely contradictory atoms become
7
+ `contradicts` EDGES — additive, both sides stay readable
8
+ 4. promote survivors into L2 (episodic, task-scoped) and
9
+ L3/L5 (semantic + graph)
10
+
11
+ Runs async at session end / pre-compact hook. Never blocks the chat loop.
12
+ Idempotent: content-hash ids make a crashed run safe to re-run.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import TYPE_CHECKING
19
+
20
+ from ..schema import MemoryEdge, Provenance, RelationType
21
+ from .extract import LLMUnavailable, _extract_json_array, chat_completion, extract_atoms
22
+
23
+ if TYPE_CHECKING:
24
+ from ..api import Memory
25
+
26
+ JUDGE_SYSTEM_PROMPT = """\
27
+ You resolve one candidate memory against existing memories.
28
+ Return a single JSON object, nothing else:
29
+ {"action": "add" | "update" | "delete" | "none",
30
+ "target_id": "<id of the existing memory, or null>",
31
+ "relation": "supports" | "extends" | "contradicts" | "supersedes" | "related_to",
32
+ "reason": "<one line>"}
33
+ Rules:
34
+ - "none": the candidate is already covered by an existing memory, or is noise.
35
+ - "update": the candidate replaces or refines the target. The old memory
36
+ stays queryable, linked by a supersedes edge. Nothing is deleted.
37
+ - "delete": the candidate shows the target memory is WRONG. Store the
38
+ correction as a new memory linked by a supersedes edge. Nothing is deleted.
39
+ - "contradicts" relation: genuinely contradictory memories both stay
40
+ readable, joined by a contradicts edge.
41
+ - Default "add" for new information; link it "related_to" the most similar
42
+ existing memory.\
43
+ """
44
+
45
+
46
+ def _judge(mem: Memory, candidate: dict, existing: list[dict]) -> dict:
47
+ """ADD/UPDATE/DELETE/NONE resolution (Mem0's pattern)."""
48
+ if not existing:
49
+ return {"action": "add", "target_id": None, "relation": "related_to", "reason": "no similar memories"}
50
+ lines = "\n".join(
51
+ f"- [{h['id']}] ({h['category']}) {h['content']}" for h in existing if h["content"]
52
+ )
53
+ raw = chat_completion(
54
+ mem.llm_base_url,
55
+ mem.llm_model,
56
+ [
57
+ {"role": "system", "content": JUDGE_SYSTEM_PROMPT},
58
+ {
59
+ "role": "user",
60
+ "content": (
61
+ f"Candidate ({candidate['category'].value}): {candidate['content']}\n"
62
+ f"Existing memories:\n{lines}"
63
+ ),
64
+ },
65
+ ],
66
+ timeout=120.0,
67
+ )
68
+ try:
69
+ verdict = _extract_json_array(f"[{raw}]")[0]
70
+ except (ValueError, json.JSONDecodeError, IndexError, TypeError, AttributeError):
71
+ return {"action": "add", "target_id": None, "relation": "related_to", "reason": "judge unparseable; defaulting to add"}
72
+ action = verdict.get("action", "add")
73
+ if action not in ("add", "update", "delete", "none"):
74
+ action = "add"
75
+ return {
76
+ "action": action,
77
+ "target_id": verdict.get("target_id"),
78
+ "relation": verdict.get("relation", "related_to"),
79
+ "reason": str(verdict.get("reason", ""))[:200],
80
+ }
81
+
82
+
83
+ def distill_sessions(mem: Memory, session_ids: list[str]) -> dict:
84
+ """Full implementation. Returns a per-stage report."""
85
+ report: dict = {
86
+ "sessions": session_ids,
87
+ "atoms": 0,
88
+ "edges": 0,
89
+ "contradictions": 0,
90
+ "skipped_dedup": 0,
91
+ "skipped_none": 0,
92
+ "errors": [],
93
+ }
94
+ for sid in session_ids:
95
+ turns = mem.sessions.get_session(sid)
96
+ if not turns:
97
+ continue
98
+ task_id = turns[0].get("task_id") or None
99
+ try:
100
+ candidates = extract_atoms(turns, mem.llm_base_url, mem.llm_model)
101
+ except LLMUnavailable as e:
102
+ report["errors"].append({"session": sid, "error": str(e)})
103
+ continue # content-hash ids make a later re-run safe
104
+ for cand in candidates:
105
+ atom_id = mem._atom_id(cand["content"])
106
+ if mem.vectors.exists(atom_id):
107
+ report["skipped_dedup"] += 1
108
+ continue
109
+ similar = [
110
+ h
111
+ for h in mem.recall(cand["content"], task_id=task_id, limit=5)
112
+ if h["score"] is not None and h["score"] >= 0.6 and h["id"] != atom_id
113
+ ]
114
+ verdict = _judge(mem, cand, similar)
115
+ if verdict["action"] == "none":
116
+ report["skipped_none"] += 1
117
+ continue
118
+ provenance = cand["provenance"]
119
+ if verdict["action"] == "delete":
120
+ # A retraction is a first-class memory, not a deletion.
121
+ provenance = Provenance.corrected
122
+ atom = mem.remember(
123
+ cand["content"],
124
+ category=cand["category"],
125
+ provenance=provenance,
126
+ confidence=cand["confidence"],
127
+ session_id=sid,
128
+ task_id=task_id,
129
+ )
130
+ report["atoms"] += 1
131
+ target = verdict["target_id"]
132
+ if verdict["action"] in ("update", "delete") and target:
133
+ mem.graph.add_edge(
134
+ MemoryEdge(
135
+ source_id=atom.id,
136
+ target_id=target,
137
+ relation=RelationType.supersedes,
138
+ reason=verdict["reason"] or "session distillation",
139
+ )
140
+ )
141
+ report["edges"] += 1
142
+ elif verdict["relation"] == "contradicts" and target:
143
+ mem.graph.add_edge(
144
+ MemoryEdge(
145
+ source_id=atom.id,
146
+ target_id=target,
147
+ relation=RelationType.contradicts,
148
+ reason=verdict["reason"] or "session distillation",
149
+ )
150
+ )
151
+ report["edges"] += 1
152
+ report["contradictions"] += 1
153
+ elif target and similar:
154
+ # new information, linked to its neighborhood
155
+ mem.graph.add_edge(
156
+ MemoryEdge(
157
+ source_id=atom.id,
158
+ target_id=target,
159
+ relation=RelationType.related_to,
160
+ reason=verdict["reason"] or "same topic",
161
+ )
162
+ )
163
+ report["edges"] += 1
164
+ return report
@@ -0,0 +1,124 @@
1
+ """ECL-lite EXTRACT: session turns -> typed MemoryAtoms.
2
+
3
+ The 13-category schema IS the extraction contract: the local LLM is
4
+ instructed to emit only valid Category/Provenance values, with a
5
+ confidence per atom. Resolution follows Mem0's pattern:
6
+ ADD / UPDATE / DELETE / NONE per candidate atom (resolved in distill.py).
7
+
8
+ Every LLM call goes to a local OpenAI-compatible endpoint. Zero keys.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+
16
+ import httpx
17
+
18
+ from ..schema import Category, Provenance
19
+
20
+ EXTRACTION_SYSTEM_PROMPT = """\
21
+ You extract durable memories from a conversation session.
22
+ Emit a JSON list. Each item: {"category": <one of the 13 categories>,
23
+ "content": "<atomic fact>", "provenance": "<how learned>",
24
+ "confidence": <0.0-1.0>}.
25
+ Valid categories: instruction, fact, decision, goal, commitment,
26
+ preference, relationship, context, event, learning, observation,
27
+ artifact, error.
28
+ Valid provenance: explicit_statement, inferred, corrected, validated,
29
+ observed, imported.
30
+ One atomic fact per item. Skip chit-chat and throwaway context.
31
+ Prefer high-signal items. Prefer explicit_statement when the user said
32
+ it directly, observed when it came from tool output, inferred otherwise.
33
+ Keep confidence honest: 0.9+ only for directly stated facts.\
34
+ """
35
+
36
+
37
+ class LLMUnavailable(Exception):
38
+ """The local LLM endpoint didn't answer. Nothing was written."""
39
+
40
+
41
+ def chat_completion(
42
+ base_url: str, model: str, messages: list[dict], timeout: float = 120.0
43
+ ) -> str:
44
+ """One blocking chat call against a local OpenAI-compatible endpoint."""
45
+ try:
46
+ resp = httpx.post(
47
+ f"{base_url.rstrip('/')}/chat/completions",
48
+ headers={"Authorization": "Bearer <redacted>"},
49
+ json={"model": model, "messages": messages, "temperature": 0.1},
50
+ timeout=timeout,
51
+ )
52
+ resp.raise_for_status()
53
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
54
+ raise LLMUnavailable(f"local LLM at {base_url} unreachable: {e}") from e
55
+ try:
56
+ return resp.json()["choices"][0]["message"]["content"]
57
+ except (KeyError, IndexError, ValueError) as e:
58
+ raise LLMUnavailable(f"malformed LLM response from {base_url}: {e}") from e
59
+
60
+
61
+ def _extract_json_array(text: str) -> list:
62
+ """Pull the first JSON array out of free-form model output."""
63
+ cleaned = text.strip()
64
+ cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
65
+ cleaned = re.sub(r"\s*```$", "", cleaned)
66
+ start = cleaned.find("[")
67
+ end = cleaned.rfind("]")
68
+ if start == -1 or end == -1 or end <= start:
69
+ raise ValueError(f"no JSON array in model output: {text[:200]!r}")
70
+ return json.loads(cleaned[start : end + 1])
71
+
72
+
73
+ def _valid_atom(item: dict) -> dict | None:
74
+ """Schema-contract check. Drops (never invents) malformed candidates."""
75
+ try:
76
+ category = Category(item["category"])
77
+ provenance = Provenance(item.get("provenance", "inferred"))
78
+ content = str(item["content"]).strip()
79
+ if not content:
80
+ return None
81
+ confidence = float(item.get("confidence", 0.7))
82
+ confidence = max(0.0, min(1.0, confidence))
83
+ except (KeyError, ValueError, TypeError):
84
+ return None
85
+ return {
86
+ "category": category,
87
+ "content": content,
88
+ "provenance": provenance,
89
+ "confidence": confidence,
90
+ }
91
+
92
+
93
+ def extract_atoms(
94
+ session_turns: list[dict],
95
+ llm_base_url: str,
96
+ llm_model: str,
97
+ timeout: float = 180.0,
98
+ ) -> list[dict]:
99
+ """Turns -> validated candidate atom dicts. LLM call goes here."""
100
+ transcript = "\n".join(
101
+ f"{t.get('role', 'user').upper()}: {t.get('content', '')}" for t in session_turns
102
+ )
103
+ if not transcript.strip():
104
+ return []
105
+ raw = chat_completion(
106
+ llm_base_url,
107
+ llm_model,
108
+ [
109
+ {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
110
+ {"role": "user", "content": f"Session transcript:\n{transcript}"},
111
+ ],
112
+ timeout=timeout,
113
+ )
114
+ try:
115
+ items = _extract_json_array(raw)
116
+ except (ValueError, json.JSONDecodeError):
117
+ return [] # unparseable extraction = no atoms, not a crash
118
+ atoms = []
119
+ for item in items:
120
+ if isinstance(item, dict):
121
+ valid = _valid_atom(item)
122
+ if valid:
123
+ atoms.append(valid)
124
+ return atoms
memstrand/schema.py ADDED
@@ -0,0 +1,107 @@
1
+ """memstrand schema: the typed memory contract.
2
+
3
+ Adapted from Memanto's 13-category typed schema (moorcheh-ai/memanto),
4
+ reimplemented local-first. Every memory atom carries its type, its
5
+ provenance, and its confidence — the three things flat vector memory
6
+ throws away.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import UTC, datetime
12
+ from enum import Enum
13
+ from uuid import uuid4
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ class Category(str, Enum):
19
+ """The 13 typed memory categories."""
20
+
21
+ instruction = "instruction" # durable directives ("always do X")
22
+ fact = "fact" # stable world knowledge
23
+ decision = "decision" # a choice that was made + rationale
24
+ goal = "goal" # an outcome being worked toward
25
+ commitment = "commitment" # a promise (often with a deadline)
26
+ preference = "preference" # likes, dislikes, defaults
27
+ relationship = "relationship" # people, roles, how they connect
28
+ context = "context" # situational background
29
+ event = "event" # something that happened (timestamped)
30
+ learning = "learning" # distilled lesson from experience
31
+ observation = "observation" # noticed, not yet verified
32
+ artifact = "artifact" # a produced thing (file, doc, link)
33
+ error = "error" # a mistake + what it taught
34
+
35
+
36
+ class Provenance(str, Enum):
37
+ """How this memory was learned. Never trust a memory without one."""
38
+
39
+ explicit_statement = "explicit_statement" # the user said it directly
40
+ inferred = "inferred" # derived by the agent
41
+ corrected = "corrected" # supersedes an earlier atom
42
+ validated = "validated" # confirmed against a source
43
+ observed = "observed" # seen in tool output / behavior
44
+ imported = "imported" # bulk-loaded from elsewhere
45
+
46
+
47
+ class RelationType(str, Enum):
48
+ """Typed edges between memories. Contradictions are edges, never deletes."""
49
+
50
+ informs = "informs"
51
+ supports = "supports"
52
+ contradicts = "contradicts" # additive only; both sides stay readable
53
+ extends = "extends"
54
+ supersedes = "supersedes" # new atom replaces old; old stays queryable
55
+ related_to = "related_to"
56
+ precedes = "precedes" # temporal ordering
57
+ caused_by = "caused_by"
58
+
59
+
60
+ class MemoryAtom(BaseModel):
61
+ """One typed unit of memory. The fundamental unit of the system."""
62
+
63
+ id: str = Field(default_factory=lambda: f"mem:{uuid4().hex[:12]}")
64
+ category: Category
65
+ content: str
66
+ provenance: Provenance = Provenance.inferred
67
+ confidence: float = Field(default=0.7, ge=0.0, le=1.0)
68
+
69
+ # scope: the Playwright-bug fix. Retrieval filters on these FIRST.
70
+ session_id: str | None = None
71
+ task_id: str | None = None
72
+ host: str | None = None
73
+ agent: str | None = None
74
+
75
+ # lifecycle
76
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
77
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
78
+ expires_at: datetime | None = None # None = permanent
79
+ access_count: int = 0
80
+
81
+ # FSRS memory-strength state (per-fact forgetting curve)
82
+ fsrs_difficulty: float = 0.5
83
+ fsrs_stability_days: float = 30.0
84
+ fsrs_next_review: datetime | None = None
85
+
86
+
87
+ class MemoryEdge(BaseModel):
88
+ """One typed relationship between two atoms."""
89
+
90
+ source_id: str
91
+ target_id: str
92
+ relation: RelationType
93
+ confidence: float = Field(default=0.7, ge=0.0, le=1.0)
94
+ reason: str = "" # why this edge exists (esp. contradicts)
95
+ weight: float = Field(default=1.0, ge=0.0) # Hebbian: use strengthens
96
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
97
+
98
+
99
+ class ScopedQuery(BaseModel):
100
+ """A recall query. Scope is a hard filter, not a ranking signal."""
101
+
102
+ text: str
103
+ session_id: str | None = None
104
+ task_id: str | None = None
105
+ host: str | None = None
106
+ categories: list[Category] | None = None
107
+ limit: int = 10
@@ -0,0 +1,3 @@
1
+ from .base import GraphStore, SessionStore, VectorStore
2
+
3
+ __all__ = ["GraphStore", "SessionStore", "VectorStore"]
@@ -0,0 +1,48 @@
1
+ """Store interfaces. All backends are swappable.
2
+
3
+ The GraphStore protocol exists because Kuzu upstream was archived after
4
+ the Oct 2025 acquisition: pin kuzu==0.11.3 today, swap tomorrow without
5
+ touching anything above this interface.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Protocol
11
+
12
+ from ..schema import MemoryAtom, MemoryEdge, ScopedQuery
13
+
14
+
15
+ class GraphStore(Protocol):
16
+ """Typed relationship graph. Kuzu today, something else tomorrow."""
17
+
18
+ def add_atom(self, atom: MemoryAtom) -> None: ...
19
+ def add_edge(self, edge: MemoryEdge) -> None: ...
20
+ def get_atom(self, atom_id: str) -> MemoryAtom | None: ...
21
+ def neighbors(
22
+ self, atom_id: str, relation: str | None = None, depth: int = 1
23
+ ) -> list[tuple[MemoryAtom, MemoryEdge]]: ...
24
+ def contradicting(self, atom_id: str) -> list[tuple[MemoryAtom, MemoryEdge]]: ...
25
+ def strengthen_edge(self, source_id: str, target_id: str, amount: float = 0.1) -> None: ...
26
+ def decay_edges(self, factor: float = 0.98, prune_below: float = 0.1) -> int: ...
27
+ def delete_atom(self, atom_id: str) -> None: ...
28
+
29
+
30
+ class VectorStore(Protocol):
31
+ """Semantic similarity over atoms. Qdrant today."""
32
+
33
+ def upsert(self, atom: MemoryAtom, embedding: list[float]) -> None: ...
34
+ def search(
35
+ self, embedding: list[float], query: ScopedQuery
36
+ ) -> list[tuple[str, float]]: ...
37
+ def exists(self, atom_id: str) -> bool: ...
38
+ def delete(self, atom_id: str) -> None: ...
39
+
40
+
41
+ class SessionStore(Protocol):
42
+ """Fast raw session cache (L1). SQLite today."""
43
+
44
+ def append_turn(self, session_id: str, role: str, content: str) -> None: ...
45
+ def get_session(self, session_id: str) -> list[dict]: ...
46
+ def list_sessions(self, task_id: str | None = None) -> list[str]: ...
47
+ def clear_session(self, session_id: str) -> None: ...
48
+ def log_provenance(self, atom_id: str, action: str, detail: str = "") -> None: ...
@@ -0,0 +1,175 @@
1
+ """Kuzu-backed GraphStore. Embedded, zero infrastructure.
2
+
3
+ Pinned to kuzu==0.11.3 (upstream archived Oct 2025). All access goes
4
+ through the GraphStore protocol so a future fork can be swapped in.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+
11
+ import kuzu
12
+
13
+ from ..schema import Category, MemoryAtom, MemoryEdge, Provenance, RelationType
14
+
15
+
16
+ def _row_to_atom(row: tuple) -> MemoryAtom:
17
+ (aid, cat, content, prov, conf, sess, task, host, created, expires) = row
18
+ return MemoryAtom(
19
+ id=aid,
20
+ category=Category(cat),
21
+ content=content,
22
+ provenance=Provenance(prov),
23
+ confidence=conf,
24
+ session_id=sess or None,
25
+ task_id=task or None,
26
+ host=host or None,
27
+ created_at=datetime.fromisoformat(created) if created else datetime.now(UTC),
28
+ expires_at=datetime.fromisoformat(expires) if expires else None,
29
+ )
30
+
31
+
32
+ def _row_to_edge(row: tuple) -> MemoryEdge:
33
+ (src, dst, rel, conf, reason, weight) = row
34
+ return MemoryEdge(
35
+ source_id=src,
36
+ target_id=dst,
37
+ relation=RelationType(rel),
38
+ confidence=conf,
39
+ reason=reason or "",
40
+ weight=weight,
41
+ )
42
+
43
+
44
+ class KuzuGraphStore:
45
+ def __init__(self, path: str = "./kuzu_data"):
46
+ self.db = kuzu.Database(path)
47
+ self.conn = kuzu.Connection(self.db)
48
+ self._init_schema()
49
+
50
+ def _init_schema(self) -> None:
51
+ self.conn.execute(
52
+ """CREATE NODE TABLE IF NOT EXISTS Atom(
53
+ id STRING, category STRING, content STRING,
54
+ provenance STRING, confidence DOUBLE,
55
+ session_id STRING, task_id STRING, host STRING,
56
+ created_at STRING, expires_at STRING,
57
+ PRIMARY KEY (id))"""
58
+ )
59
+ self.conn.execute(
60
+ """CREATE REL TABLE IF NOT EXISTS LINK(
61
+ FROM Atom TO Atom,
62
+ relation STRING, confidence DOUBLE,
63
+ reason STRING, weight DOUBLE)"""
64
+ )
65
+
66
+ def add_atom(self, atom: MemoryAtom) -> None:
67
+ self.conn.execute(
68
+ """MERGE (a:Atom {id: $id})
69
+ SET a.category=$cat, a.content=$content,
70
+ a.provenance=$prov, a.confidence=$conf,
71
+ a.session_id=$sess, a.task_id=$task, a.host=$host,
72
+ a.created_at=$created,
73
+ a.expires_at=$expires""",
74
+ {
75
+ "id": atom.id, "cat": atom.category.value,
76
+ "content": atom.content, "prov": atom.provenance.value,
77
+ "conf": atom.confidence,
78
+ "sess": atom.session_id or "", "task": atom.task_id or "",
79
+ "host": atom.host or "",
80
+ "created": atom.created_at.isoformat(),
81
+ "expires": atom.expires_at.isoformat() if atom.expires_at else "",
82
+ },
83
+ )
84
+
85
+ def add_edge(self, edge: MemoryEdge) -> None:
86
+ self.conn.execute(
87
+ """MATCH (a:Atom {id: $src}), (b:Atom {id: $dst})
88
+ CREATE (a)-[l:LINK {relation: $rel, confidence: $conf,
89
+ reason: $reason, weight: $w}]->(b)""",
90
+ {
91
+ "src": edge.source_id, "dst": edge.target_id,
92
+ "rel": edge.relation.value, "conf": edge.confidence,
93
+ "reason": edge.reason, "w": edge.weight,
94
+ },
95
+ )
96
+
97
+ def get_atom(self, atom_id: str) -> MemoryAtom | None:
98
+ """Row -> MemoryAtom hydration. Vector store is still the
99
+ primary read path; this is the fallback for graph-only reads."""
100
+ rows = self.conn.execute(
101
+ """MATCH (a:Atom {id: $id})
102
+ RETURN a.id, a.category, a.content, a.provenance,
103
+ a.confidence, a.session_id, a.task_id, a.host,
104
+ a.created_at, a.expires_at""",
105
+ {"id": atom_id},
106
+ ).get_all()
107
+ return _row_to_atom(rows[0]) if rows else None
108
+
109
+ def neighbors(
110
+ self, atom_id: str, relation: str | None = None, depth: int = 1
111
+ ) -> list[tuple[MemoryAtom, MemoryEdge]]:
112
+ """Depth-1 neighbor hydration, both directions. relation filters
113
+ to one edge type (e.g. 'contradicts'). Deeper traversal is a BFS
114
+ over this primitive — depth>1 expands one hop at a time."""
115
+ rel_filter = "AND l.relation = $rel" if relation else ""
116
+ params: dict = {"id": atom_id}
117
+ if relation:
118
+ params["rel"] = relation
119
+ rows = self.conn.execute(
120
+ f"""MATCH (a:Atom {{id: $id}})-[l:LINK]-(b:Atom)
121
+ WHERE b.id <> $id {rel_filter}
122
+ RETURN b.id AS bid, b.category, b.content, b.provenance,
123
+ b.confidence, b.session_id, b.task_id, b.host,
124
+ b.created_at, b.expires_at,
125
+ a.id AS src, b.id AS dst, l.relation, l.confidence, l.reason, l.weight""",
126
+ params,
127
+ ).get_all()
128
+ out = []
129
+ for r in rows:
130
+ out.append((_row_to_atom(r[:10]), _row_to_edge(r[10:])))
131
+ if depth > 1:
132
+ seen = {atom_id} | {a.id for a, _ in out}
133
+ frontier = [a.id for a, _ in out]
134
+ for _ in range(depth - 1):
135
+ nxt = []
136
+ for fid in frontier:
137
+ for na, ne in self.neighbors(fid, relation=relation, depth=1):
138
+ if na.id not in seen:
139
+ seen.add(na.id)
140
+ out.append((na, ne))
141
+ nxt.append(na.id)
142
+ frontier = nxt
143
+ return out
144
+
145
+ def delete_atom(self, atom_id: str) -> None:
146
+ """DETACH DELETE: removes the node and all its edges. Facts are
147
+ never pruned by consolidation, but an explicit forget() removes."""
148
+ self.conn.execute(
149
+ "MATCH (a:Atom {id: $id}) DETACH DELETE a", {"id": atom_id}
150
+ )
151
+
152
+ def contradicting(self, atom_id: str) -> list[tuple[MemoryAtom, MemoryEdge]]:
153
+ return self.neighbors(atom_id, relation="contradicts")
154
+
155
+ def strengthen_edge(self, source_id: str, target_id: str, amount: float = 0.1) -> None:
156
+ """Hebbian: use strengthens the edge, capped."""
157
+ self.conn.execute(
158
+ """MATCH (a:Atom {id: $src})-[l:LINK]->(b:Atom {id: $dst})
159
+ SET l.weight = l.weight + $amt * (1 - l.weight / 10)""",
160
+ {"src": source_id, "dst": target_id, "amt": amount},
161
+ )
162
+
163
+ def decay_edges(self, factor: float = 0.98, prune_below: float = 0.1) -> int:
164
+ """Hebbian decay. Prunes weak EDGES — never facts."""
165
+ self.conn.execute(
166
+ "MATCH ()-[l:LINK]->() SET l.weight = l.weight * $f",
167
+ {"f": factor},
168
+ )
169
+ result = self.conn.execute(
170
+ "MATCH ()-[l:LINK]->() WHERE l.weight < $floor "
171
+ "DELETE l RETURN count(*)",
172
+ {"floor": prune_below},
173
+ )
174
+ row = result.get_next()
175
+ return int(row[0]) if row else 0
@@ -0,0 +1,58 @@
1
+ """SQLite: session cache (L1) + provenance ledger. Embedded, zero setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ import time
7
+
8
+
9
+ class SqliteSessionStore:
10
+ def __init__(self, path: str = "./memstrand.db"):
11
+ self.conn = sqlite3.connect(path, check_same_thread=False)
12
+ self.conn.execute(
13
+ """CREATE TABLE IF NOT EXISTS turns(
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ session_id TEXT, task_id TEXT, role TEXT,
16
+ content TEXT, ts REAL)"""
17
+ )
18
+ self.conn.execute(
19
+ """CREATE TABLE IF NOT EXISTS provenance(
20
+ atom_id TEXT PRIMARY KEY, action TEXT,
21
+ detail TEXT, ts REAL)"""
22
+ )
23
+ self.conn.commit()
24
+
25
+ def append_turn(
26
+ self, session_id: str, role: str, content: str, task_id: str | None = None
27
+ ) -> None:
28
+ self.conn.execute(
29
+ "INSERT INTO turns(session_id, task_id, role, content, ts) VALUES (?,?,?,?,?)",
30
+ (session_id, task_id or "", role, content, time.time()),
31
+ )
32
+ self.conn.commit()
33
+
34
+ def get_session(self, session_id: str) -> list[dict]:
35
+ rows = self.conn.execute(
36
+ "SELECT role, content, ts, task_id FROM turns WHERE session_id=? ORDER BY ts",
37
+ (session_id,),
38
+ ).fetchall()
39
+ return [{"role": r, "content": c, "ts": t, "task_id": tid} for r, c, t, tid in rows]
40
+
41
+ def list_sessions(self, task_id: str | None = None) -> list[str]:
42
+ q = "SELECT DISTINCT session_id FROM turns"
43
+ args: tuple = ()
44
+ if task_id:
45
+ q += " WHERE task_id=?"
46
+ args = (task_id,)
47
+ return [r[0] for r in self.conn.execute(q, args).fetchall()]
48
+
49
+ def clear_session(self, session_id: str) -> None:
50
+ self.conn.execute("DELETE FROM turns WHERE session_id=?", (session_id,))
51
+ self.conn.commit()
52
+
53
+ def log_provenance(self, atom_id: str, action: str, detail: str = "") -> None:
54
+ self.conn.execute(
55
+ "INSERT OR REPLACE INTO provenance(atom_id, action, detail, ts) VALUES (?,?,?,?)",
56
+ (atom_id, action, detail, time.time()),
57
+ )
58
+ self.conn.commit()
@@ -0,0 +1,123 @@
1
+ """Qdrant-backed VectorStore. Scope lives in the payload filter.
2
+
3
+ Scoping rule (the Playwright-bug fix, enforced here):
4
+ - task_id / host are HARD filters. When the query carries them, only
5
+ atoms with the exact same value match. When the query does NOT carry
6
+ them, only GLOBAL atoms (stored with no task/host) match — an
7
+ unscoped query can never see task-scoped memories.
8
+ - session_id is provenance, not a recall filter. Sessions scope the L1
9
+ fast cache and the distillation provenance; semantic recall is
10
+ deliberately cross-session within a task (that's rubric test 2).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import uuid
16
+
17
+ from qdrant_client import QdrantClient
18
+ from qdrant_client.models import (
19
+ Distance,
20
+ FieldCondition,
21
+ Filter,
22
+ MatchValue,
23
+ PointStruct,
24
+ VectorParams,
25
+ )
26
+
27
+ from ..schema import MemoryAtom, ScopedQuery
28
+
29
+ COLLECTION = "memstrand"
30
+
31
+
32
+ def _point_id(atom_id: str) -> str:
33
+ """Deterministic UUID for a human-readable atom id (mem:abc123...).
34
+
35
+ Qdrant's embedded/local mode only accepts UUID or integer point ids,
36
+ while the server accepts arbitrary strings. UUIDv5 keeps one code
37
+ path that works on both — the real atom id rides in the payload.
38
+ """
39
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, f"memstrand:{atom_id}"))
40
+
41
+
42
+ class QdrantVectorStore:
43
+ def __init__(
44
+ self,
45
+ url: str = "http://localhost:6333",
46
+ dim: int = 384,
47
+ client: QdrantClient | None = None,
48
+ ):
49
+ self.client = client or QdrantClient(url=url)
50
+ if not self.client.collection_exists(COLLECTION):
51
+ self.client.create_collection(
52
+ COLLECTION,
53
+ vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
54
+ )
55
+
56
+ def _scope_filter(self, query: ScopedQuery) -> Filter | None:
57
+ must = []
58
+ # task_id: exact match, or global-only when the query is unscoped.
59
+ # This is the hard filter that stops cross-task leaks.
60
+ must.append(
61
+ FieldCondition(
62
+ key="task_id",
63
+ match=MatchValue(value=query.task_id if query.task_id else ""),
64
+ )
65
+ )
66
+ # host: same rule.
67
+ must.append(
68
+ FieldCondition(
69
+ key="host",
70
+ match=MatchValue(value=query.host if query.host else ""),
71
+ )
72
+ )
73
+ # session_id: deliberately NOT a filter (see module docstring).
74
+ if query.categories:
75
+ must.append(
76
+ FieldCondition(
77
+ key="category",
78
+ match=MatchValue(value=[c.value for c in query.categories]),
79
+ )
80
+ )
81
+ return Filter(must=must) if must else None
82
+
83
+ def upsert(self, atom: MemoryAtom, embedding: list[float]) -> None:
84
+ self.client.upsert(
85
+ collection_name=COLLECTION,
86
+ points=[
87
+ PointStruct(
88
+ id=_point_id(atom.id),
89
+ vector=embedding,
90
+ payload={
91
+ "atom_id": atom.id,
92
+ "category": atom.category.value,
93
+ "content": atom.content,
94
+ "session_id": atom.session_id or "",
95
+ "task_id": atom.task_id or "",
96
+ "host": atom.host or "",
97
+ "confidence": atom.confidence,
98
+ "provenance": atom.provenance.value,
99
+ },
100
+ )
101
+ ],
102
+ )
103
+
104
+ def exists(self, atom_id: str) -> bool:
105
+ """Content-hash dedup check: is this atom already stored?"""
106
+ records = self.client.retrieve(
107
+ collection_name=COLLECTION, ids=[_point_id(atom_id)], with_vectors=False
108
+ )
109
+ return len(records) > 0
110
+
111
+ def search(self, embedding: list[float], query: ScopedQuery) -> list[tuple[str, float]]:
112
+ hits = self.client.query_points(
113
+ collection_name=COLLECTION,
114
+ query=embedding,
115
+ query_filter=self._scope_filter(query),
116
+ limit=query.limit,
117
+ ).points
118
+ return [(h.payload["atom_id"], h.score) for h in hits]
119
+
120
+ def delete(self, atom_id: str) -> None:
121
+ self.client.delete(
122
+ collection_name=COLLECTION, points_selector=[_point_id(atom_id)]
123
+ )
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: memstrand
3
+ Version: 0.1.0
4
+ Summary: Local-first typed memory for AI agents. Zero API keys.
5
+ Author: Chiran
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: agents,ai,knowledge-graph,local-first,memory,rag
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: fastembed>=0.3
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: kuzu==0.11.3
13
+ Requires-Dist: mcp>=1.0
14
+ Requires-Dist: pydantic>=2.0
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: qdrant-client>=1.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.4; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # memstrand
23
+
24
+ **Local-first typed memory for AI agents. Zero API keys.**
25
+
26
+ Two strands, like DNA: one strand holds **facts** (vectors), the other holds **relationships** (graph). Every write joins them base-pair by base-pair — a memory is never stored without its connections, and never retrieved without its scope.
27
+
28
+ `pip install memstrand` → `remember()` → `recall()` → done. Everything runs on your machine: no cloud, no keys, no telemetry.
29
+
30
+ ## Why
31
+
32
+ Flat vector memory has three failure modes, and every agent hits all three:
33
+
34
+ 1. **No types** — a preference, a decision, and a guess all look the same to cosine similarity.
35
+ 2. **No scope** — a fact learned while testing Playwright leaks into an unrelated Mac disk-cleanup session. (This actually happened. It's why this exists.)
36
+ 3. **No provenance** — nobody knows whether a memory was *said*, *inferred*, or *corrected*, or how confident the agent was.
37
+
38
+ memstrand fixes all three: every memory is **typed** (13 categories), **scoped** (session/task/host), and **carries provenance** (how it was learned, confidence, expiry).
39
+
40
+ ## Quickstart
41
+
42
+ ```bash
43
+ docker compose up -d # Qdrant (vectors); Kuzu + SQLite are embedded, zero setup
44
+ pip install memstrand
45
+
46
+ export MEMSTRAND_LLM_BASE_URL=http://localhost:8081/v1 # any OpenAI-compatible local endpoint
47
+ ```
48
+
49
+ ```python
50
+ from memstrand import Memory, Category
51
+
52
+ mem = Memory()
53
+
54
+ mem.remember(
55
+ "Chiran prefers a natural, unstylized look in AI portraits",
56
+ category=Category.preference,
57
+ provenance="explicit_statement",
58
+ confidence=0.95,
59
+ )
60
+
61
+ for hit in mem.recall("what do I know about portrait preferences?"):
62
+ print(hit.content, hit.score)
63
+ ```
64
+
65
+ ## Architecture
66
+
67
+ Seven layers, deterministic write routing, scoped retrieval:
68
+
69
+ | Layer | What lives there | Scope |
70
+ |---|---|---|
71
+ | L0 working | live transcript + task/host scope; outranks everything | task |
72
+ | L1 session cache | fast raw Q&A, no extraction, TTL'd | session |
73
+ | L2 episodic | distilled session learnings | task + host |
74
+ | L3 semantic | typed facts, vector-embedded | global |
75
+ | L4 consolidated | curated cross-session synthesis | global |
76
+ | L5 graph | typed edges over everything above | global |
77
+ | L6 procedural | action sequences, workflow patterns, lessons | global |
78
+
79
+ **Write path** (the session-commit loop): during a session, writes land in L1 with zero extraction cost. At session end (or pre-compact hook), an async distillation pass extracts Memanto-typed atoms via your local LLM, dedups by content hash, checks for conflicts — contradictions become `contradicts` **edges**, never deletions — and promotes into L2/L3/L5.
80
+
81
+ **Read path:** scope filter *first* (session/task/host is a hard filter, not a ranking signal), then tiered abstracts before full content, graph traversal for multi-hop, and a `grounded_answer()` primitive that answers only from stored memory.
82
+
83
+ **Memory dynamics** (stolen fair and square, see below): per-fact FSRS strength with a review queue, Hebbian edge weights (use strengthens, disuse decays), and replay consolidation that prunes weak *edges* but never *facts*.
84
+
85
+ ## API
86
+
87
+ ```python
88
+ mem.remember(content, category=..., provenance=..., confidence=..., ttl_days=...)
89
+ mem.recall(query, session_id=..., task_id=..., limit=...) # scope-aware
90
+ mem.improve(session_ids=[...]) # distill sessions → permanent memory
91
+ mem.forget(memory_id) # provenance-chain cleanup, never silent
92
+ mem.grounded_answer(question) # answer ONLY from stored memories
93
+ ```
94
+
95
+ MCP server included: `python -m memstrand.mcp_server` exposes the same surface to Claude Code, Cursor, and friends over stdio.
96
+
97
+ ## Acceptance test
98
+
99
+ Any memory system should pass these three before you trust it (rubric adapted from a Cognee ad, because it was right):
100
+
101
+ 1. **Remember me in a brand-new chat** without re-pasting anything.
102
+ 2. **Search by meaning**, not keywords.
103
+ 3. **Connect facts across sessions** — and *don't* leak facts across unrelated tasks (the Playwright test).
104
+
105
+ ## Acknowledgments
106
+
107
+ Standing on shoulders, with credit:
108
+
109
+ - **Memanto** (moorcheh-ai/memanto) — the 13-category typed schema, provenance, and confidence model. Their product is cloud-only; this is the schema reimplemented local-first.
110
+ - **Cognee** (topoteretes/cognee) — the pipeline patterns: fast session cache → distillation, joint graph+vector writes, contradictions as additive edges.
111
+ - **ZenBrain** (zensation-ai/zenbrain) — per-fact FSRS, deterministic write routing, replay consolidation, Hebbian edges.
112
+ - **Mem0** — the ADD/UPDATE/DELETE/NONE extraction resolution pattern.
113
+
114
+ ## Roadmap
115
+
116
+ - [x] Schema + store interfaces + scaffold
117
+ - [x] Session-commit distillation loop (extract → dedup → judge → promote)
118
+ - [x] Scoped retrieval (task/host hard filters; unscoped queries see globals only)
119
+ - [x] Graph neighbor hydration + Hebbian strengthen/decay
120
+ - [ ] FSRS + replay consolidation pass (math is in `pipeline/consolidate.py`, not yet scheduled)
121
+ - [x] MCP server tools (remember/recall/forget)
122
+ - [ ] `memstrand import` — adopt an existing Markdown memory file
123
+ - [x] Benchmarks: the 3-test rubric, automated (`tests/test_memory_flow.py`)
124
+
125
+ ## License
126
+
127
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ memstrand/__init__.py,sha256=J-cleaWApe0UvFDd-Tlce1AmM54YtCgoW_Zh_IdKrys,275
2
+ memstrand/api.py,sha256=ZjIHBXaNqBgFo5P-9td051FOH0beG1iBTlMaohb331A,7173
3
+ memstrand/cli.py,sha256=1uXzsewUPLExWXXIcV8UIGQijyX4dwqCf1nDodGqSNE,1167
4
+ memstrand/mcp_server.py,sha256=8fQXKGenIegybnwTQ0Bl2Fs3hAJZSrR2fLN8BGdLUlU,1419
5
+ memstrand/schema.py,sha256=XO-W_SBfNu5hq3cMCpuVQJJ4e1J-SEv13VULrc4z0tY,4096
6
+ memstrand/pipeline/__init__.py,sha256=fljVr-aZV76vdtkF0RiPCjrzTJWlAKHsOz4c_YF4_BY,93
7
+ memstrand/pipeline/consolidate.py,sha256=O3o-S-wiEBxdar7ZnCaI99N5zIjTQMeYeRXLCGWjpwM,1691
8
+ memstrand/pipeline/distill.py,sha256=Dv07RcwPA9UudZ5yZR-pTPUy97WfC-WU_7rixyzl47M,6483
9
+ memstrand/pipeline/extract.py,sha256=apjtB-VTUbVgvb-gQwS7KG7Q6F9sfEwW89DNHCnIAio,4345
10
+ memstrand/stores/__init__.py,sha256=0RX3YN1vHOyKdmt78-m7qoPtltWjGCK8Ev93y4z4XTQ,113
11
+ memstrand/stores/base.py,sha256=WPZIBmLoROb6xouDUHDfSIBk4KcxzJuXBhaVbJY1uPY,1917
12
+ memstrand/stores/graph_kuzu.py,sha256=ERXm_OZyC3uN61df6xiHI7e-y2Tl_Riz0nypMMuG7Pw,6928
13
+ memstrand/stores/sqlite.py,sha256=eJHGFu1SX8kls-uoM8X6C-W5XVMxdsUipuYRbk3V96Y,2174
14
+ memstrand/stores/vector_qdrant.py,sha256=xnyvyyr3Sno19_iHtxOL2wmYnzFvGXUQUyt3DK_2kZM,4329
15
+ memstrand-0.1.0.dist-info/METADATA,sha256=_GEtFnUPnkqPq5Wk04nYlMyCW7OBh1AEtiJ4fKSC6BE,5783
16
+ memstrand-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
17
+ memstrand-0.1.0.dist-info/entry_points.txt,sha256=gc-yoOyQXucR10c1iWk2diHkZNY-oAJMeaurXJE5Apw,49
18
+ memstrand-0.1.0.dist-info/licenses/LICENSE,sha256=Z_DX2HrQCIFVJQN05yay85ejmKlQ4Y8gXpzE2rdc5Os,1063
19
+ memstrand-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ memstrand = memstrand.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chiran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.