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 +7 -0
- memstrand/api.py +182 -0
- memstrand/cli.py +38 -0
- memstrand/mcp_server.py +70 -0
- memstrand/pipeline/__init__.py +3 -0
- memstrand/pipeline/consolidate.py +47 -0
- memstrand/pipeline/distill.py +164 -0
- memstrand/pipeline/extract.py +124 -0
- memstrand/schema.py +107 -0
- memstrand/stores/__init__.py +3 -0
- memstrand/stores/base.py +48 -0
- memstrand/stores/graph_kuzu.py +175 -0
- memstrand/stores/sqlite.py +58 -0
- memstrand/stores/vector_qdrant.py +123 -0
- memstrand-0.1.0.dist-info/METADATA +127 -0
- memstrand-0.1.0.dist-info/RECORD +19 -0
- memstrand-0.1.0.dist-info/WHEEL +4 -0
- memstrand-0.1.0.dist-info/entry_points.txt +2 -0
- memstrand-0.1.0.dist-info/licenses/LICENSE +21 -0
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']}")
|
memstrand/mcp_server.py
ADDED
|
@@ -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,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
|
memstrand/stores/base.py
ADDED
|
@@ -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,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.
|