thread-recall 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.
- thread_recall/__init__.py +10 -0
- thread_recall/cli.py +46 -0
- thread_recall/mcp_server.py +157 -0
- thread_recall/store.py +208 -0
- thread_recall-0.1.0.dist-info/METADATA +104 -0
- thread_recall-0.1.0.dist-info/RECORD +9 -0
- thread_recall-0.1.0.dist-info/WHEEL +4 -0
- thread_recall-0.1.0.dist-info/entry_points.txt +3 -0
- thread_recall-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""thread-recall: governed, on-prem conversation memory for AI agents.
|
|
2
|
+
|
|
3
|
+
Per-thread history (recency) and semantic recall (nearest-neighbour over
|
|
4
|
+
embeddings), with optional PII masking on write and tamper-evident audit.
|
|
5
|
+
Part of the Governed Agent Stack.
|
|
6
|
+
"""
|
|
7
|
+
from thread_recall.store import Memory, Turn
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["Memory", "Turn", "__version__"]
|
thread_recall/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Command line: a quick zero-config demo of governed agent memory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from thread_recall import __version__
|
|
7
|
+
from thread_recall.store import Memory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cmd_demo(_args) -> int:
|
|
11
|
+
mem = Memory(":memory:")
|
|
12
|
+
thread = "demo-conversation"
|
|
13
|
+
|
|
14
|
+
# toy 3-dim embeddings just to show semantic recall without a model
|
|
15
|
+
mem.remember(thread, "user", "How much MRR did the pro plan make?", embedding=[1, 0, 0])
|
|
16
|
+
mem.remember(thread, "assistant", "Pro plan MRR was 297.", embedding=[0.9, 0.1, 0])
|
|
17
|
+
mem.remember(thread, "user", "What's the weather like?", embedding=[0, 0, 1])
|
|
18
|
+
|
|
19
|
+
print("thread-recall demo (in-memory SQLite, no model)\n")
|
|
20
|
+
print("Recent turns (chronological):")
|
|
21
|
+
for t in mem.recent(thread):
|
|
22
|
+
print(f" [{t.role}] {t.content}")
|
|
23
|
+
|
|
24
|
+
print("\nSemantic recall for a revenue-ish query [1,0,0] (top 2):")
|
|
25
|
+
for t in mem.search(thread, [1, 0, 0], k=2):
|
|
26
|
+
print(f" {t.score:.2f} [{t.role}] {t.content}")
|
|
27
|
+
|
|
28
|
+
print("\nWith mask=True (pii-veil, if installed) content is scrubbed before")
|
|
29
|
+
print("storage; with audit=True every write lands in an agent-blackbox ledger.")
|
|
30
|
+
mem.close()
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> None:
|
|
35
|
+
parser = argparse.ArgumentParser(prog="thread-recall", description=__doc__)
|
|
36
|
+
parser.add_argument("--version", action="version", version=f"thread-recall {__version__}")
|
|
37
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
38
|
+
sub.add_parser("demo", help="run a zero-config in-memory demo")
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
if args.cmd == "demo":
|
|
41
|
+
raise SystemExit(cmd_demo(args))
|
|
42
|
+
parser.print_help()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""thread-recall-mcp: expose governed agent memory as MCP tools.
|
|
2
|
+
|
|
3
|
+
An agent calls ``remember`` to store a turn and ``recall`` to retrieve relevant
|
|
4
|
+
past turns, instead of holding unbounded raw history in its context. The
|
|
5
|
+
governance is on the write side: PII is masked before anything is stored (via
|
|
6
|
+
pii-veil when installed), so the long-term memory never retains raw PII, and
|
|
7
|
+
every write can be mirrored into a tamper-evident audit ledger.
|
|
8
|
+
|
|
9
|
+
Isolation: every thread is namespaced by an ``actor`` -- the authenticated
|
|
10
|
+
principal. A caller can only reach threads under its own actor, so a raw
|
|
11
|
+
``thread_id`` is no longer a key into anyone else's memory (no cross-thread
|
|
12
|
+
read, poison, or mass-delete). The governed gateway sets ``actor`` from the
|
|
13
|
+
caller's token and does not let the client override it; standalone, set
|
|
14
|
+
``THREAD_RECALL_ACTOR`` (or pass ``actor``), else all callers share one
|
|
15
|
+
namespace. The caller always sees its own plain ``thread_id`` back.
|
|
16
|
+
|
|
17
|
+
Configuration (environment):
|
|
18
|
+
THREAD_RECALL_DB SQLite path for the store (default: logs/recall-mem.db)
|
|
19
|
+
THREAD_RECALL_MASK mask PII on write (default on; set 0 to disable)
|
|
20
|
+
THREAD_RECALL_AUDIT mirror writes to agent-blackbox (default off; set 1 on)
|
|
21
|
+
THREAD_RECALL_ACTOR default principal when no actor is passed (default: shared)
|
|
22
|
+
THREAD_RECALL_EMBED hashing (default) | ollama
|
|
23
|
+
THREAD_RECALL_OLLAMA_* HOST / MODEL overrides for the Ollama embedder
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from fastmcp import FastMCP
|
|
35
|
+
|
|
36
|
+
from thread_recall.store import Memory
|
|
37
|
+
|
|
38
|
+
_TOKEN = re.compile(r"[a-z0-9]+")
|
|
39
|
+
_ON = {"1", "true", "yes", "on"}
|
|
40
|
+
_SEP = "\x1f" # unit separator: partitions the store by actor, unseen by callers
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _principal(actor: str | None) -> str:
|
|
44
|
+
"""The authenticated identity a thread belongs to. The gateway supplies it."""
|
|
45
|
+
return (actor or os.environ.get("THREAD_RECALL_ACTOR", "shared")).strip() or "shared"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _key(actor: str | None, thread_id: str) -> str:
|
|
49
|
+
"""Namespace a thread by its actor so ids cannot collide across principals."""
|
|
50
|
+
return f"{_principal(actor)}{_SEP}{thread_id}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _plain(namespaced: str) -> str:
|
|
54
|
+
"""Strip the actor namespace so the caller sees the thread_id it passed in."""
|
|
55
|
+
return namespaced.split(_SEP, 1)[1] if _SEP in namespaced else namespaced
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _view(turn: dict) -> dict:
|
|
59
|
+
turn["thread_id"] = _plain(turn.get("thread_id", ""))
|
|
60
|
+
return turn
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hash_embed(text: str, dim: int = 256) -> list[float]:
|
|
64
|
+
"""Deterministic, offline bag-of-words embedding (no model, no network)."""
|
|
65
|
+
vec = [0.0] * dim
|
|
66
|
+
for tok in _TOKEN.findall(text.lower()):
|
|
67
|
+
vec[int(hashlib.md5(tok.encode()).hexdigest(), 16) % dim] += 1.0
|
|
68
|
+
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
|
69
|
+
return [v / norm for v in vec]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _embed(text: str) -> list[float]:
|
|
73
|
+
if os.environ.get("THREAD_RECALL_EMBED", "hashing").strip().lower() == "ollama":
|
|
74
|
+
import json
|
|
75
|
+
import urllib.request
|
|
76
|
+
|
|
77
|
+
host = os.environ.get("THREAD_RECALL_OLLAMA_HOST", "http://localhost:11434").rstrip("/")
|
|
78
|
+
model = os.environ.get("THREAD_RECALL_OLLAMA_MODEL", "nomic-embed-text")
|
|
79
|
+
req = urllib.request.Request(
|
|
80
|
+
f"{host}/api/embeddings",
|
|
81
|
+
data=json.dumps({"model": model, "prompt": text}).encode(),
|
|
82
|
+
headers={"Content-Type": "application/json"},
|
|
83
|
+
)
|
|
84
|
+
with urllib.request.urlopen(req) as resp:
|
|
85
|
+
return list(json.loads(resp.read())["embedding"])
|
|
86
|
+
return _hash_embed(text)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_memory() -> Memory:
|
|
90
|
+
return Memory(
|
|
91
|
+
os.environ.get("THREAD_RECALL_DB", "logs/recall-mem.db"),
|
|
92
|
+
mask=os.environ.get("THREAD_RECALL_MASK", "1").strip().lower() in _ON,
|
|
93
|
+
audit=os.environ.get("THREAD_RECALL_AUDIT", "0").strip().lower() in _ON,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
mcp = FastMCP("thread-recall")
|
|
98
|
+
_mem = _build_memory()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@mcp.tool
|
|
102
|
+
def remember(thread_id: str, content: str, role: str = "user",
|
|
103
|
+
actor: str | None = None) -> dict[str, Any]:
|
|
104
|
+
"""Store one turn of a thread's memory. PII is masked before storage.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
thread_id: The conversation/thread this turn belongs to.
|
|
108
|
+
content: The text to remember. Masked on write if masking is enabled.
|
|
109
|
+
role: Who said it (user / assistant / system).
|
|
110
|
+
actor: Authenticated principal owning the thread. Set by the gateway;
|
|
111
|
+
do not rely on client-supplied values for isolation.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The stored turn's id. The content held in memory is the masked form.
|
|
115
|
+
"""
|
|
116
|
+
turn_id = _mem.remember(_key(actor, thread_id), role, content, embedding=_embed(content))
|
|
117
|
+
return {"id": turn_id, "thread_id": thread_id, "stored": True}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@mcp.tool
|
|
121
|
+
def recall(thread_id: str, query: str, k: int = 5,
|
|
122
|
+
actor: str | None = None) -> dict[str, Any]:
|
|
123
|
+
"""Recall the turns most relevant to a query, scoped to one thread.
|
|
124
|
+
|
|
125
|
+
Semantic nearest-neighbour over the actor's own thread. Only this actor's
|
|
126
|
+
copy of the thread is searched; another principal's memory is unreachable.
|
|
127
|
+
"""
|
|
128
|
+
key = _key(actor, thread_id)
|
|
129
|
+
hits = _mem.search(key, _embed(query), k)
|
|
130
|
+
if not hits: # nothing embedded yet -> fall back to recency
|
|
131
|
+
hits = _mem.recent(key, k)
|
|
132
|
+
return {"count": len(hits), "results": [_view(t.as_dict()) for t in hits]}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@mcp.tool
|
|
136
|
+
def recent(thread_id: str, k: int = 10, actor: str | None = None) -> dict[str, Any]:
|
|
137
|
+
"""The last k turns of the actor's thread, in chronological order."""
|
|
138
|
+
hits = _mem.recent(_key(actor, thread_id), k)
|
|
139
|
+
return {"count": len(hits), "results": [_view(t.as_dict()) for t in hits]}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@mcp.tool
|
|
143
|
+
def forget(thread_id: str, actor: str | None = None) -> dict[str, Any]:
|
|
144
|
+
"""Delete the actor's copy of a thread. Returns the number of turns removed.
|
|
145
|
+
|
|
146
|
+
Only the calling principal's namespace is touched; a caller cannot wipe
|
|
147
|
+
another actor's thread even by naming the same thread_id.
|
|
148
|
+
"""
|
|
149
|
+
return {"removed": _mem.forget(_key(actor, thread_id)), "thread_id": thread_id}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> None:
|
|
153
|
+
mcp.run()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
main()
|
thread_recall/store.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Governed, on-prem conversation memory for AI agents.
|
|
2
|
+
|
|
3
|
+
A small store for what an agent should remember across turns: per-thread
|
|
4
|
+
conversation history (recency) and optional semantic recall (nearest-neighbour
|
|
5
|
+
over stored embeddings). The "governed" part is the point — memory can be
|
|
6
|
+
PII-masked on write (so raw personal data never lands in the store) and every
|
|
7
|
+
write can be mirrored into a tamper-evident audit log.
|
|
8
|
+
|
|
9
|
+
The default backend is SQLite with embeddings kept as JSON and cosine computed
|
|
10
|
+
in Python: zero dependencies, works immediately, nothing leaves the building.
|
|
11
|
+
A Postgres + pgvector backend (for scale) is the next step; this module keeps
|
|
12
|
+
the embedding/scoring logic backend-agnostic so it drops in cleanly.
|
|
13
|
+
|
|
14
|
+
This is deliberately NOT a read path into your database — that is sql-steward's
|
|
15
|
+
job, and it stays read-only. This is the write-side state store that sits next
|
|
16
|
+
to it.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
import sqlite3
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Turn:
|
|
30
|
+
"""One remembered turn."""
|
|
31
|
+
|
|
32
|
+
id: int
|
|
33
|
+
thread_id: str
|
|
34
|
+
ts: float
|
|
35
|
+
role: str
|
|
36
|
+
content: str
|
|
37
|
+
metadata: dict | None = None
|
|
38
|
+
score: float | None = None # set by search()
|
|
39
|
+
|
|
40
|
+
def as_dict(self) -> dict:
|
|
41
|
+
d = {"id": self.id, "thread_id": self.thread_id, "ts": self.ts,
|
|
42
|
+
"role": self.role, "content": self.content, "metadata": self.metadata}
|
|
43
|
+
if self.score is not None:
|
|
44
|
+
d["score"] = self.score
|
|
45
|
+
return d
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
49
|
+
if not a or not b or len(a) != len(b):
|
|
50
|
+
return 0.0
|
|
51
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
52
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
53
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
54
|
+
if na == 0.0 or nb == 0.0:
|
|
55
|
+
return 0.0
|
|
56
|
+
return dot / (na * nb)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Memory:
|
|
60
|
+
"""Thread-scoped agent memory.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
path: SQLite path, or ":memory:" (default).
|
|
64
|
+
mask: scrub PII from content on write via pii-veil, if installed.
|
|
65
|
+
audit: mirror every write into an agent-blackbox ledger, if installed.
|
|
66
|
+
audit_db: path for the audit ledger (default "logs/recall.db").
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, path: str = ":memory:", *, mask: bool = False,
|
|
70
|
+
audit: bool = False, audit_db: str = "logs/recall.db") -> None:
|
|
71
|
+
# check_same_thread=False + a lock so the store is usable from a server
|
|
72
|
+
# (e.g. the MCP server) that dispatches calls across worker threads.
|
|
73
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
74
|
+
self._conn.row_factory = sqlite3.Row
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
self._init_db()
|
|
77
|
+
self._veil = _make_veil() if mask else None
|
|
78
|
+
self._ledger = _make_ledger(audit_db) if audit else None
|
|
79
|
+
|
|
80
|
+
def _init_db(self) -> None:
|
|
81
|
+
self._conn.execute(
|
|
82
|
+
"""
|
|
83
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
84
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
85
|
+
thread_id TEXT NOT NULL,
|
|
86
|
+
ts REAL NOT NULL,
|
|
87
|
+
role TEXT NOT NULL,
|
|
88
|
+
content TEXT NOT NULL,
|
|
89
|
+
metadata TEXT,
|
|
90
|
+
embedding TEXT
|
|
91
|
+
)
|
|
92
|
+
"""
|
|
93
|
+
)
|
|
94
|
+
self._conn.execute(
|
|
95
|
+
"CREATE INDEX IF NOT EXISTS idx_thread ON memories(thread_id, id)"
|
|
96
|
+
)
|
|
97
|
+
self._conn.commit()
|
|
98
|
+
|
|
99
|
+
# -- write --------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def remember(
|
|
102
|
+
self,
|
|
103
|
+
thread_id: str,
|
|
104
|
+
role: str,
|
|
105
|
+
content: str,
|
|
106
|
+
*,
|
|
107
|
+
embedding: list[float] | None = None,
|
|
108
|
+
metadata: dict | None = None,
|
|
109
|
+
) -> int:
|
|
110
|
+
"""Store one turn. Returns its id. Content is PII-masked first if enabled."""
|
|
111
|
+
if self._veil is not None:
|
|
112
|
+
try:
|
|
113
|
+
content = self._veil.scrub_text(content)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
emb = json.dumps([float(x) for x in embedding]) if embedding else None
|
|
117
|
+
ts = time.time()
|
|
118
|
+
with self._lock:
|
|
119
|
+
cur = self._conn.execute(
|
|
120
|
+
"INSERT INTO memories (thread_id, ts, role, content, metadata, embedding)"
|
|
121
|
+
" VALUES (?, ?, ?, ?, ?, ?)",
|
|
122
|
+
(thread_id, ts, role, content, json.dumps(metadata) if metadata else None, emb),
|
|
123
|
+
)
|
|
124
|
+
self._conn.commit()
|
|
125
|
+
if self._ledger is not None:
|
|
126
|
+
try:
|
|
127
|
+
self._ledger.record(
|
|
128
|
+
actor="thread-recall", action="remember", target=thread_id,
|
|
129
|
+
meta={"role": role, "id": cur.lastrowid},
|
|
130
|
+
)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
return cur.lastrowid
|
|
134
|
+
|
|
135
|
+
def forget(self, thread_id: str) -> int:
|
|
136
|
+
"""Delete a thread's memory. Returns rows removed."""
|
|
137
|
+
with self._lock:
|
|
138
|
+
cur = self._conn.execute("DELETE FROM memories WHERE thread_id = ?", (thread_id,))
|
|
139
|
+
self._conn.commit()
|
|
140
|
+
return cur.rowcount
|
|
141
|
+
|
|
142
|
+
# -- read ---------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def recent(self, thread_id: str, k: int = 10) -> list[Turn]:
|
|
145
|
+
"""The last k turns of a thread, in chronological order."""
|
|
146
|
+
with self._lock:
|
|
147
|
+
rows = self._conn.execute(
|
|
148
|
+
"SELECT * FROM memories WHERE thread_id = ? ORDER BY id DESC LIMIT ?",
|
|
149
|
+
(thread_id, k),
|
|
150
|
+
).fetchall()
|
|
151
|
+
return [_row_to_turn(r) for r in reversed(rows)]
|
|
152
|
+
|
|
153
|
+
def search(self, thread_id: str, query_embedding: list[float], k: int = 5) -> list[Turn]:
|
|
154
|
+
"""Semantic recall: the k turns whose embeddings are closest to the query."""
|
|
155
|
+
with self._lock:
|
|
156
|
+
rows = self._conn.execute(
|
|
157
|
+
"SELECT * FROM memories WHERE thread_id = ? AND embedding IS NOT NULL",
|
|
158
|
+
(thread_id,),
|
|
159
|
+
).fetchall()
|
|
160
|
+
scored: list[Turn] = []
|
|
161
|
+
for r in rows:
|
|
162
|
+
turn = _row_to_turn(r)
|
|
163
|
+
turn.score = _cosine(query_embedding, json.loads(r["embedding"]))
|
|
164
|
+
scored.append(turn)
|
|
165
|
+
scored.sort(key=lambda t: t.score or 0.0, reverse=True)
|
|
166
|
+
return scored[:k]
|
|
167
|
+
|
|
168
|
+
def count(self, thread_id: str | None = None) -> int:
|
|
169
|
+
with self._lock:
|
|
170
|
+
if thread_id is None:
|
|
171
|
+
return self._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
|
|
172
|
+
return self._conn.execute(
|
|
173
|
+
"SELECT COUNT(*) FROM memories WHERE thread_id = ?", (thread_id,)
|
|
174
|
+
).fetchone()[0]
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
self._conn.close()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _row_to_turn(r: sqlite3.Row) -> Turn:
|
|
181
|
+
return Turn(
|
|
182
|
+
id=r["id"], thread_id=r["thread_id"], ts=r["ts"], role=r["role"],
|
|
183
|
+
content=r["content"],
|
|
184
|
+
metadata=json.loads(r["metadata"]) if r["metadata"] else None,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _make_veil():
|
|
189
|
+
try:
|
|
190
|
+
from pii_veil import Veil
|
|
191
|
+
|
|
192
|
+
return Veil()
|
|
193
|
+
except Exception:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _make_ledger(path: str):
|
|
198
|
+
try:
|
|
199
|
+
import os
|
|
200
|
+
|
|
201
|
+
from agent_blackbox import Ledger
|
|
202
|
+
|
|
203
|
+
parent = os.path.dirname(path)
|
|
204
|
+
if parent:
|
|
205
|
+
os.makedirs(parent, exist_ok=True)
|
|
206
|
+
return Ledger(path)
|
|
207
|
+
except Exception:
|
|
208
|
+
return None
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thread-recall
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Governed, on-prem conversation memory for AI agents: per-thread history and semantic recall, with optional PII masking on write and tamper-evident audit. Zero-dependency SQLite by default.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Pawansingh3889/thread-recall
|
|
6
|
+
Project-URL: Repository, https://github.com/Pawansingh3889/thread-recall
|
|
7
|
+
Project-URL: Governed Agent Stack, https://github.com/Pawansingh3889/governed-agent-stack
|
|
8
|
+
Author: Pawan Singh Kapkoti
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,conversation,embeddings,governance,memory,on-prem,pgvector,pii,semantic-search,sqlite
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
26
|
+
Provides-Extra: mask
|
|
27
|
+
Requires-Dist: pii-veil>=0.1; extra == 'mask'
|
|
28
|
+
Provides-Extra: mcp
|
|
29
|
+
Requires-Dist: fastmcp>=2.0; extra == 'mcp'
|
|
30
|
+
Provides-Extra: postgres
|
|
31
|
+
Requires-Dist: pgvector>=0.2; extra == 'postgres'
|
|
32
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
33
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'postgres'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# thread-recall
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/thread-recall/) [](https://pepy.tech/projects/thread-recall)
|
|
39
|
+
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
[](https://www.python.org/)
|
|
42
|
+
|
|
43
|
+
> Part of the [Governed Agent Stack](https://github.com/Pawansingh3889/governed-agent-stack): free, on-prem building blocks for an AI agent you can point at a real database and audit.
|
|
44
|
+
|
|
45
|
+
Governed, on-prem conversation memory for AI agents. It stores what an agent should remember across turns — per-thread history and optional semantic recall — and makes that memory **governable**: PII can be masked before it is ever written, and every write can be mirrored into a tamper-evident audit log.
|
|
46
|
+
|
|
47
|
+
The default backend is **SQLite with embeddings as JSON and cosine in Python**: zero dependencies, works immediately, nothing leaves the building. A Postgres + pgvector backend (for scale) is the next step; the scoring logic is backend-agnostic so it drops in cleanly.
|
|
48
|
+
|
|
49
|
+
This is the **write-side** companion to [sql-steward](https://github.com/Pawansingh3889/sql-steward). sql-steward is the read-only query gateway and stays read-only; thread-recall is where agent state lives, kept separate on purpose.
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install thread-recall
|
|
55
|
+
thread-recall demo
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from thread_recall import Memory
|
|
60
|
+
|
|
61
|
+
mem = Memory("agent.db") # or ":memory:"; mask=True / audit=True optional
|
|
62
|
+
|
|
63
|
+
mem.remember("thread-42", "user", "How much MRR did the pro plan make?", embedding=embed(q))
|
|
64
|
+
mem.remember("thread-42", "assistant", "Pro plan MRR was 297.")
|
|
65
|
+
|
|
66
|
+
mem.recent("thread-42", k=10) # last 10 turns, chronological
|
|
67
|
+
mem.search("thread-42", embed("revenue"), k=5) # nearest turns by embedding
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Why it's "governed"
|
|
71
|
+
|
|
72
|
+
- **PII masked on write.** `Memory(..., mask=True)` runs content through [pii-veil](https://github.com/Pawansingh3889/pii-veil) before it is stored, so raw personal data never lands in the memory store. No-op if pii-veil isn't installed.
|
|
73
|
+
- **Tamper-evident audit.** `Memory(..., audit=True)` mirrors every write into an [agent-blackbox](https://github.com/Pawansingh3889/agent-blackbox) hash-chained ledger, so "what did the agent choose to remember" has a checkable answer. No-op if agent-blackbox isn't installed.
|
|
74
|
+
- **On-prem by default.** SQLite file on your disk; embeddings generated by whatever local model you choose. Nothing is sent out.
|
|
75
|
+
|
|
76
|
+
## API
|
|
77
|
+
|
|
78
|
+
| Method | Purpose |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `remember(thread_id, role, content, embedding=None, metadata=None)` | Store a turn (PII-masked first if enabled). Returns its id. |
|
|
81
|
+
| `recent(thread_id, k=10)` | Last k turns, chronological. |
|
|
82
|
+
| `search(thread_id, query_embedding, k=5)` | Semantic recall: nearest turns by cosine over stored embeddings. |
|
|
83
|
+
| `forget(thread_id)` | Delete a thread's memory. |
|
|
84
|
+
| `count(thread_id=None)` | Turn count for a thread, or overall. |
|
|
85
|
+
|
|
86
|
+
Embeddings are supplied by the caller (generate them with any local model). thread-recall stores and scores them; it does not call out to any embedding service itself.
|
|
87
|
+
|
|
88
|
+
## Roadmap
|
|
89
|
+
|
|
90
|
+
- **Postgres + pgvector backend** for scale and native nearest-neighbour (the SQLite backend computes cosine in Python, fine for thread-scoped recall, not for millions of rows).
|
|
91
|
+
- A thin LangGraph adapter, to use thread-recall alongside a LangGraph checkpointer.
|
|
92
|
+
|
|
93
|
+
## Develop
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git clone https://github.com/Pawansingh3889/thread-recall
|
|
97
|
+
cd thread-recall
|
|
98
|
+
pip install -e ".[dev]"
|
|
99
|
+
pytest -q
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
thread_recall/__init__.py,sha256=j2Hcr3PdTs_rivaz16uDJtFWEC3RqjPr60c7zRW_cdA,369
|
|
2
|
+
thread_recall/cli.py,sha256=2wbj4_SkS16chNqGM-T-9neODg0FMXeG6jaZBeraUuk,1662
|
|
3
|
+
thread_recall/mcp_server.py,sha256=MBt4SGHiRc7CZ_auNVi_PzlarVro9hnBzwwJyiXUhBg,6099
|
|
4
|
+
thread_recall/store.py,sha256=Y3OCipi3fGKMNLjuQr9Ux4E_nRz4AhsW55NrETCsIVI,7210
|
|
5
|
+
thread_recall-0.1.0.dist-info/METADATA,sha256=c9CvChUxBavY20EL4vk9lbP2nY7xaQ0gUYqSZlaQqc0,5249
|
|
6
|
+
thread_recall-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
thread_recall-0.1.0.dist-info/entry_points.txt,sha256=1i4QYU9zhi-BAqc82YBjQrJXhltHS_EixOmihppZWjM,107
|
|
8
|
+
thread_recall-0.1.0.dist-info/licenses/LICENSE,sha256=51_xoiIIUa98JHgVsADHCPu0ReTwVG738gd08erXscM,1076
|
|
9
|
+
thread_recall-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pawan Singh Kapkoti
|
|
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.
|