tether-memory 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.
- tether/__init__.py +3 -0
- tether/config.py +32 -0
- tether/server.py +115 -0
- tether/store.py +171 -0
- tether/sync.py +100 -0
- tether_memory-0.1.0.dist-info/METADATA +138 -0
- tether_memory-0.1.0.dist-info/RECORD +10 -0
- tether_memory-0.1.0.dist-info/WHEEL +4 -0
- tether_memory-0.1.0.dist-info/entry_points.txt +2 -0
- tether_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
tether/__init__.py
ADDED
tether/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""config.py - resolve DB path, sync credentials, and device id from the env.
|
|
2
|
+
|
|
3
|
+
Pure environment reads, no side effects. The zero-config default (no env vars)
|
|
4
|
+
yields a local-only DB under XDG_DATA_HOME and no sync.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
from collections import namedtuple
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SyncConfig = namedtuple("SyncConfig", ["url", "token"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def db_path() -> Path:
|
|
16
|
+
override = os.environ.get("TETHER_DB")
|
|
17
|
+
if override:
|
|
18
|
+
return Path(override)
|
|
19
|
+
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
20
|
+
return Path(base) / "tether" / "memory.db"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sync_config():
|
|
24
|
+
url = os.environ.get("TETHER_SYNC_URL")
|
|
25
|
+
token = os.environ.get("TETHER_SYNC_TOKEN")
|
|
26
|
+
if url and token:
|
|
27
|
+
return SyncConfig(url, token)
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def device_id() -> str:
|
|
32
|
+
return os.environ.get("TETHER_DEVICE_ID") or socket.gethostname()
|
tether/server.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""server.py - the MCP server. The agent-facing edge.
|
|
3
|
+
|
|
4
|
+
Four verbs over a persistent SQLite-backed memory store, plus an auto-loaded
|
|
5
|
+
boot index exposed as an MCP resource. The store is built lazily on first use
|
|
6
|
+
so importing the module (and listing tools) never touches the filesystem.
|
|
7
|
+
|
|
8
|
+
Run it as an MCP stdio server:
|
|
9
|
+
|
|
10
|
+
tether # installed entry point
|
|
11
|
+
python -m tether.server # or as a module
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
|
|
18
|
+
from . import config
|
|
19
|
+
from .store import Store
|
|
20
|
+
from .sync import open_connection
|
|
21
|
+
|
|
22
|
+
mcp = FastMCP("tether")
|
|
23
|
+
|
|
24
|
+
_store = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_store() -> Store:
|
|
28
|
+
global _store
|
|
29
|
+
if _store is None:
|
|
30
|
+
path = config.db_path()
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
conn, sync_now = open_connection(path, config.sync_config())
|
|
33
|
+
store = Store(conn, device_id=config.device_id(), sync_now=sync_now)
|
|
34
|
+
store.migrate()
|
|
35
|
+
_store = store
|
|
36
|
+
return _store
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool()
|
|
40
|
+
def remember(type: str, title: str, body: str,
|
|
41
|
+
tags: str = "", links: list = None) -> dict:
|
|
42
|
+
"""Save a durable memory. UPSERTS: a memory of the same `type` with the same
|
|
43
|
+
(whitespace/case-normalized) `title` is updated in place instead of
|
|
44
|
+
duplicated, so re-remembering a fact refines it rather than cluttering.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
type: one of "user", "feedback", "project", "reference".
|
|
48
|
+
title: a short label; also the dedup key within a type.
|
|
49
|
+
body: the fact. For feedback/project, a "Why:" / "How to apply:" line helps.
|
|
50
|
+
tags: optional comma-separated tags.
|
|
51
|
+
links: optional list of related memory ids.
|
|
52
|
+
|
|
53
|
+
Returns {"id", "action"} where action is "created" or "updated".
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
return _get_store().remember(type, title, body, tags=tags, links=links)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return {"error": str(e)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool()
|
|
62
|
+
def recall(query: str, type: str = None, limit: int = 20) -> dict:
|
|
63
|
+
"""Search memories by keyword (title/body/tags), newest-relevant first.
|
|
64
|
+
|
|
65
|
+
Each hit carries {id, type, title, body, tags, updated_at} - use
|
|
66
|
+
`updated_at` to judge staleness (an old fact may no longer hold; verify
|
|
67
|
+
before relying on it) and `id` to cite what you update via remember/link.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
query: free text; punctuation is safe.
|
|
71
|
+
type: optional filter ("user"/"feedback"/"project"/"reference").
|
|
72
|
+
limit: max results (default 20).
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
return {"results": _get_store().recall(query, type=type, limit=limit)}
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return {"error": str(e)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def link(id_a: int, id_b: int) -> dict:
|
|
82
|
+
"""Create a bidirectional link between two memories by id."""
|
|
83
|
+
try:
|
|
84
|
+
return _get_store().link(id_a, id_b)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
return {"error": str(e)}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@mcp.tool()
|
|
90
|
+
def forget(id: int) -> dict:
|
|
91
|
+
"""Permanently delete a memory by id. Returns {"forgotten", "existed"}."""
|
|
92
|
+
try:
|
|
93
|
+
return _get_store().forget(id)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return {"error": str(e)}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.resource("tether://memory-index")
|
|
99
|
+
def memory_index() -> str:
|
|
100
|
+
"""A compact index of ALL memories - one line per memory as `[type] #id
|
|
101
|
+
title`, newest first. Auto-loaded each session so memory helps even without
|
|
102
|
+
an explicit recall; pull full bodies with recall() using the id.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
return _get_store().boot_index()
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return f"(memory index unavailable: {e})"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
mcp.run()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
tether/store.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""store.py - the memory store. Owns ALL SQL.
|
|
2
|
+
|
|
3
|
+
One table (`memories`) plus an external-content FTS5 index kept in sync by
|
|
4
|
+
triggers. The four verbs and the boot index are the only public surface;
|
|
5
|
+
nothing outside this module speaks SQL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
VALID_TYPES = ("user", "feedback", "project", "reference")
|
|
13
|
+
|
|
14
|
+
_SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
type TEXT NOT NULL CHECK (type IN ('user','feedback','project','reference')),
|
|
18
|
+
title TEXT NOT NULL,
|
|
19
|
+
title_norm TEXT NOT NULL,
|
|
20
|
+
body TEXT NOT NULL,
|
|
21
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
22
|
+
links TEXT NOT NULL DEFAULT '[]',
|
|
23
|
+
created_at TEXT NOT NULL,
|
|
24
|
+
updated_at TEXT NOT NULL,
|
|
25
|
+
device_id TEXT NOT NULL DEFAULT ''
|
|
26
|
+
);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_memories_dedup ON memories(type, title_norm);
|
|
28
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
|
|
29
|
+
USING fts5(title, body, tags, content='memories', content_rowid='id');
|
|
30
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
31
|
+
INSERT INTO memories_fts(rowid, title, body, tags)
|
|
32
|
+
VALUES (new.id, new.title, new.body, new.tags);
|
|
33
|
+
END;
|
|
34
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
35
|
+
INSERT INTO memories_fts(memories_fts, rowid, title, body, tags)
|
|
36
|
+
VALUES ('delete', old.id, old.title, old.body, old.tags);
|
|
37
|
+
END;
|
|
38
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
39
|
+
INSERT INTO memories_fts(memories_fts, rowid, title, body, tags)
|
|
40
|
+
VALUES ('delete', old.id, old.title, old.body, old.tags);
|
|
41
|
+
INSERT INTO memories_fts(rowid, title, body, tags)
|
|
42
|
+
VALUES (new.id, new.title, new.body, new.tags);
|
|
43
|
+
END;
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _now() -> str:
|
|
48
|
+
return datetime.now(timezone.utc).isoformat()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _norm(title: str) -> str:
|
|
52
|
+
"""Normalize a title for the dedup probe: lowercase, collapse whitespace."""
|
|
53
|
+
return re.sub(r"\s+", " ", title.strip().lower())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _tags_to_str(tags) -> str:
|
|
57
|
+
if not tags:
|
|
58
|
+
return ""
|
|
59
|
+
if isinstance(tags, str):
|
|
60
|
+
parts = tags.split(",")
|
|
61
|
+
else:
|
|
62
|
+
parts = list(tags)
|
|
63
|
+
return ",".join(t.strip() for t in parts if t and t.strip())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _fts_query(raw: str):
|
|
67
|
+
"""Turn a free-text query into a safe FTS5 MATCH string.
|
|
68
|
+
|
|
69
|
+
Each whitespace token is escaped and double-quoted so punctuation in the
|
|
70
|
+
query can never produce an FTS5 syntax error (degrade, never throw).
|
|
71
|
+
Returns None when the query has no usable tokens.
|
|
72
|
+
"""
|
|
73
|
+
toks = [t for t in re.split(r"\s+", raw.strip()) if t]
|
|
74
|
+
if not toks:
|
|
75
|
+
return None
|
|
76
|
+
return " ".join('"' + t.replace('"', '""') + '"' for t in toks)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Store:
|
|
80
|
+
def __init__(self, conn, device_id: str, sync_now):
|
|
81
|
+
self._conn = conn
|
|
82
|
+
self._device_id = device_id
|
|
83
|
+
self._sync_now = sync_now
|
|
84
|
+
|
|
85
|
+
def migrate(self) -> None:
|
|
86
|
+
self._conn.executescript(_SCHEMA)
|
|
87
|
+
self._conn.commit()
|
|
88
|
+
|
|
89
|
+
def remember(self, type, title, body, tags=None, links=None) -> dict:
|
|
90
|
+
if type not in VALID_TYPES:
|
|
91
|
+
raise ValueError(f"type must be one of {VALID_TYPES}, got {type!r}")
|
|
92
|
+
now = _now()
|
|
93
|
+
norm = _norm(title)
|
|
94
|
+
tags_s = _tags_to_str(tags)
|
|
95
|
+
links_s = json.dumps(links or [])
|
|
96
|
+
existing = self._conn.execute(
|
|
97
|
+
"SELECT id FROM memories WHERE type=? AND title_norm=?", (type, norm)
|
|
98
|
+
).fetchone()
|
|
99
|
+
if existing:
|
|
100
|
+
mid = existing[0]
|
|
101
|
+
self._conn.execute(
|
|
102
|
+
"UPDATE memories SET title=?, body=?, tags=?, links=?, "
|
|
103
|
+
"updated_at=?, device_id=? WHERE id=?",
|
|
104
|
+
(title, body, tags_s, links_s, now, self._device_id, mid))
|
|
105
|
+
action = "updated"
|
|
106
|
+
else:
|
|
107
|
+
cur = self._conn.execute(
|
|
108
|
+
"INSERT INTO memories(type, title, title_norm, body, tags, links, "
|
|
109
|
+
"created_at, updated_at, device_id) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
110
|
+
(type, title, norm, body, tags_s, links_s, now, now, self._device_id))
|
|
111
|
+
mid = cur.lastrowid
|
|
112
|
+
action = "created"
|
|
113
|
+
self._conn.commit()
|
|
114
|
+
self._sync_now()
|
|
115
|
+
return {"id": mid, "action": action}
|
|
116
|
+
|
|
117
|
+
def recall(self, query, type=None, limit=20) -> list:
|
|
118
|
+
match = _fts_query(query)
|
|
119
|
+
if match is None:
|
|
120
|
+
return []
|
|
121
|
+
sql = ("SELECT m.id, m.type, m.title, m.body, m.tags, m.updated_at "
|
|
122
|
+
"FROM memories_fts f JOIN memories m ON m.id = f.rowid "
|
|
123
|
+
"WHERE memories_fts MATCH ?")
|
|
124
|
+
params = [match]
|
|
125
|
+
if type is not None:
|
|
126
|
+
sql += " AND m.type = ?"
|
|
127
|
+
params.append(type)
|
|
128
|
+
sql += " ORDER BY rank LIMIT ?"
|
|
129
|
+
params.append(limit)
|
|
130
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
131
|
+
return [
|
|
132
|
+
{"id": r[0], "type": r[1], "title": r[2],
|
|
133
|
+
"body": r[3], "tags": r[4], "updated_at": r[5]}
|
|
134
|
+
for r in rows
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
def _links_of(self, mid) -> list:
|
|
138
|
+
row = self._conn.execute("SELECT links FROM memories WHERE id=?", (mid,)).fetchone()
|
|
139
|
+
if row is None:
|
|
140
|
+
raise ValueError(f"no memory with id {mid}")
|
|
141
|
+
return json.loads(row[0])
|
|
142
|
+
|
|
143
|
+
def link(self, id_a, id_b) -> dict:
|
|
144
|
+
a = self._links_of(id_a)
|
|
145
|
+
b = self._links_of(id_b)
|
|
146
|
+
if id_b not in a:
|
|
147
|
+
a.append(id_b)
|
|
148
|
+
if id_a not in b:
|
|
149
|
+
b.append(id_a)
|
|
150
|
+
now = _now()
|
|
151
|
+
self._conn.execute("UPDATE memories SET links=?, updated_at=? WHERE id=?",
|
|
152
|
+
(json.dumps(a), now, id_a))
|
|
153
|
+
self._conn.execute("UPDATE memories SET links=?, updated_at=? WHERE id=?",
|
|
154
|
+
(json.dumps(b), now, id_b))
|
|
155
|
+
self._conn.commit()
|
|
156
|
+
self._sync_now()
|
|
157
|
+
return {"linked": [id_a, id_b]}
|
|
158
|
+
|
|
159
|
+
def forget(self, id) -> dict:
|
|
160
|
+
cur = self._conn.execute("DELETE FROM memories WHERE id=?", (id,))
|
|
161
|
+
self._conn.commit()
|
|
162
|
+
self._sync_now()
|
|
163
|
+
return {"forgotten": id, "existed": cur.rowcount > 0}
|
|
164
|
+
|
|
165
|
+
def boot_index(self) -> str:
|
|
166
|
+
rows = self._conn.execute(
|
|
167
|
+
"SELECT id, type, title FROM memories ORDER BY updated_at DESC, id DESC"
|
|
168
|
+
).fetchall()
|
|
169
|
+
if not rows:
|
|
170
|
+
return "(no memories yet)"
|
|
171
|
+
return "\n".join(f"[{t}] #{i} {title}" for i, t, title in rows)
|
tether/sync.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""sync.py - the connection factory.
|
|
2
|
+
|
|
3
|
+
Zero config -> a stdlib sqlite3 connection (the local-only default). Sync
|
|
4
|
+
credentials present -> a libSQL embedded replica: local-speed reads, writes
|
|
5
|
+
that round-trip to the hosted primary. ANY failure on the replica path
|
|
6
|
+
degrades to the local file. Memory must never break the agent's work, so
|
|
7
|
+
open_connection never raises.
|
|
8
|
+
|
|
9
|
+
SPIKE FINDINGS (verified live against libsql-experimental, PyPI releases
|
|
10
|
+
0.0.41/0.0.55, on macOS arm64):
|
|
11
|
+
- Import name and connect signature match what's used below:
|
|
12
|
+
`libsql_experimental.connect(database, sync_url=None, auth_token="", ...)`.
|
|
13
|
+
- `.sync()` on a connection opened WITHOUT sync_url raises ValueError
|
|
14
|
+
("Sync is not supported in databases opened in File mode.") -- confirms
|
|
15
|
+
the local path must never call `.sync()`, which is why `_local()` below
|
|
16
|
+
uses a no-op.
|
|
17
|
+
- Cross-thread `.execute()`/`.sync()` calls did not hit any thread-safety
|
|
18
|
+
guard in the versions tested, so the background-thread + join(timeout)
|
|
19
|
+
pattern is safe to use.
|
|
20
|
+
- IMPORTANT DEVIATION FROM THE ORIGINAL PLAN: `.sync()` does NOT fail fast
|
|
21
|
+
against an unreachable/bogus sync_url. It retries the handshake
|
|
22
|
+
internally (observed every ~2-3s) and does not return control or raise
|
|
23
|
+
-- it was still retrying after 20+ seconds in testing. A bare, inline
|
|
24
|
+
`conn.sync()` used as an initial connectivity probe would therefore hang
|
|
25
|
+
server startup indefinitely instead of raising. So the initial probe
|
|
26
|
+
below is bounded by the same background-thread + timeout pattern used
|
|
27
|
+
for later syncs, and a timeout is treated as a failure.
|
|
28
|
+
- KNOWN LIMITATION (accepted for v0.1's experimental, opt-in sync layer):
|
|
29
|
+
if the initial probe times out, the abandoned libSQL connection's
|
|
30
|
+
background thread keeps retrying against the same db_path that the
|
|
31
|
+
local fallback then also opens. This is a daemon thread (never blocks
|
|
32
|
+
process exit) and, in the common failure case (persistently unreachable
|
|
33
|
+
network), it never actually writes -- so there is no realistic data
|
|
34
|
+
corruption path, but it is not a fully clean cancellation.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import sqlite3
|
|
38
|
+
import sys
|
|
39
|
+
import threading
|
|
40
|
+
|
|
41
|
+
_INITIAL_SYNC_TIMEOUT = 5.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _local(db_path):
|
|
45
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
46
|
+
return conn, (lambda timeout=2.0: None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _safe_sync(conn):
|
|
50
|
+
try:
|
|
51
|
+
conn.sync()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass # a failed background sync must never surface
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _open_replica(db_path, sync_cfg):
|
|
57
|
+
"""Open a libSQL embedded replica. Raises on any failure; caller degrades.
|
|
58
|
+
|
|
59
|
+
See the module docstring's SPIKE FINDINGS for why the initial sync is
|
|
60
|
+
bounded with a background thread rather than called inline.
|
|
61
|
+
"""
|
|
62
|
+
import libsql_experimental as libsql
|
|
63
|
+
|
|
64
|
+
conn = libsql.connect(
|
|
65
|
+
str(db_path), sync_url=sync_cfg.url, auth_token=sync_cfg.token,
|
|
66
|
+
check_same_thread=False)
|
|
67
|
+
|
|
68
|
+
errors = []
|
|
69
|
+
|
|
70
|
+
def probe():
|
|
71
|
+
try:
|
|
72
|
+
conn.sync() # initial pull; part of "did the backend work?"
|
|
73
|
+
except Exception as e:
|
|
74
|
+
errors.append(e)
|
|
75
|
+
|
|
76
|
+
t = threading.Thread(target=probe, daemon=True)
|
|
77
|
+
t.start()
|
|
78
|
+
t.join(_INITIAL_SYNC_TIMEOUT)
|
|
79
|
+
if t.is_alive():
|
|
80
|
+
raise TimeoutError(
|
|
81
|
+
f"sync backend unreachable after {_INITIAL_SYNC_TIMEOUT}s: {sync_cfg.url}")
|
|
82
|
+
if errors:
|
|
83
|
+
raise errors[0]
|
|
84
|
+
|
|
85
|
+
def sync_now(timeout=2.0):
|
|
86
|
+
t = threading.Thread(target=_safe_sync, args=(conn,), daemon=True)
|
|
87
|
+
t.start()
|
|
88
|
+
t.join(timeout) # bounded: a hung sync never blocks a read
|
|
89
|
+
|
|
90
|
+
return conn, sync_now
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def open_connection(db_path, sync_cfg):
|
|
94
|
+
if sync_cfg is None:
|
|
95
|
+
return _local(db_path)
|
|
96
|
+
try:
|
|
97
|
+
return _open_replica(db_path, sync_cfg)
|
|
98
|
+
except Exception as e: # import missing, connect failed, initial sync failed
|
|
99
|
+
sys.stderr.write(f"tether: sync offline ({e}); using local file\n")
|
|
100
|
+
return _local(db_path)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tether-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A shared memory layer for personal agents, across devices: an MCP server over local SQLite with opt-in libSQL/Turso sync.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sidyellur/tether
|
|
6
|
+
Project-URL: Issues, https://github.com/sidyellur/tether/issues
|
|
7
|
+
Author-email: sidyellur <20009719+sidyellur@users.noreply.github.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 sidyellur
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: agent,claude,claude-code,libsql,mcp,memory,sqlite,turso
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: POSIX
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Topic :: Database
|
|
37
|
+
Requires-Python: >=3.10
|
|
38
|
+
Requires-Dist: mcp>=1.0
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: build; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
42
|
+
Provides-Extra: sync
|
|
43
|
+
Requires-Dist: libsql-experimental>=0.0.30; extra == 'sync'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# tether
|
|
47
|
+
|
|
48
|
+
**A shared memory layer for personal agents, across devices.** `tether` is an
|
|
49
|
+
[MCP](https://modelcontextprotocol.io) server backed by a local SQLite file. Any
|
|
50
|
+
MCP-compatible agent can `remember`, `recall`, `link`, and `forget` durable notes
|
|
51
|
+
— facts about you, your projects, your preferences — so context follows you
|
|
52
|
+
instead of dying with each session.
|
|
53
|
+
|
|
54
|
+
It runs **local-only with zero configuration**. Point it at a hosted
|
|
55
|
+
[libSQL/Turso](https://turso.tech) primary and the same file becomes an embedded
|
|
56
|
+
replica that syncs your memory across every device in near-real-time.
|
|
57
|
+
|
|
58
|
+
## Why
|
|
59
|
+
|
|
60
|
+
The near future is personal agents living across many devices — laptop, desktop,
|
|
61
|
+
phone. For that to feel like *one* assistant rather than several amnesiac ones,
|
|
62
|
+
memory has to be a substrate that follows you: readable and writable from every
|
|
63
|
+
device and from any agent, not siloed inside a single tool.
|
|
64
|
+
|
|
65
|
+
`tether` is that substrate. It is deliberately a *convenience layer* — it makes an
|
|
66
|
+
agent more useful when present, and never breaks the agent's work when degraded.
|
|
67
|
+
|
|
68
|
+
## Status
|
|
69
|
+
|
|
70
|
+
v0.1 is implemented. Design and rationale: [`docs/superpowers/specs/2026-07-03-tether-design.md`](docs/superpowers/specs/2026-07-03-tether-design.md). Implementation plan: [`docs/superpowers/plans/2026-07-03-tether-v0.1-implementation.md`](docs/superpowers/plans/2026-07-03-tether-v0.1-implementation.md).
|
|
71
|
+
|
|
72
|
+
## Design at a glance
|
|
73
|
+
|
|
74
|
+
- **Four verbs**, nothing more: `remember` · `recall` · `link` · `forget`.
|
|
75
|
+
- **Upsert on write** so the store doesn't rot into near-duplicates.
|
|
76
|
+
- **Rich recall** (id, type, title, body, tags, `updated_at`) so an agent can
|
|
77
|
+
judge staleness and cite what it updates.
|
|
78
|
+
- **An auto-loaded boot index** — a compact one-line-per-memory list surfaced to
|
|
79
|
+
the agent each session, so memory helps even when the agent doesn't think to
|
|
80
|
+
search.
|
|
81
|
+
- **Local-first, sync optional** — the local path is untouched when no backend is
|
|
82
|
+
configured; degradation never throws.
|
|
83
|
+
- **Keyword search now, embeddings later** — the SQLite schema is built so
|
|
84
|
+
semantic search and a full entity/edge graph slot in without migrating data.
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
Requires Python ≥3.10 on a POSIX system (Linux/macOS).
|
|
89
|
+
|
|
90
|
+
Register it with Claude Code — with [uv](https://docs.astral.sh/uv/):
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
claude mcp add tether -- uvx --from tether-memory tether
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
…or install it first:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
pip install tether-memory
|
|
100
|
+
claude mcp add tether -- tether
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
(The PyPI package is named `tether-memory` — `tether` was already reserved on
|
|
104
|
+
PyPI as a common brand name — but the installed command is still `tether`.)
|
|
105
|
+
|
|
106
|
+
By default memory lives in a local SQLite file at
|
|
107
|
+
`~/.local/share/tether/memory.db` (override with `TETHER_DB`). No accounts, no
|
|
108
|
+
network — this is the whole tool for a single machine.
|
|
109
|
+
|
|
110
|
+
## Sync across devices (optional)
|
|
111
|
+
|
|
112
|
+
Point tether at a [Turso](https://turso.tech) / libSQL database and the local
|
|
113
|
+
file becomes an embedded replica — local-speed reads, writes that propagate to
|
|
114
|
+
your other devices. Install the extra and set two env vars:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
pip install 'tether-memory[sync]'
|
|
118
|
+
export TETHER_SYNC_URL='libsql://<your-db>.turso.io'
|
|
119
|
+
export TETHER_SYNC_TOKEN='<your-auth-token>'
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
If the backend is unreachable, tether logs `sync offline` and keeps working
|
|
123
|
+
against the local file; writes converge when it comes back.
|
|
124
|
+
|
|
125
|
+
## Tools
|
|
126
|
+
|
|
127
|
+
| Tool | What it does |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `remember(type, title, body, tags?, links?)` | Save a memory; upserts on `type`+`title` so facts refine rather than duplicate |
|
|
130
|
+
| `recall(query, type?, limit?)` | Keyword search; returns id/type/title/body/tags/updated_at |
|
|
131
|
+
| `link(id_a, id_b)` | Bidirectional link between two memories |
|
|
132
|
+
| `forget(id)` | Delete a memory |
|
|
133
|
+
|
|
134
|
+
Plus an auto-loaded resource `tether://memory-index` — a compact one-line-per-memory index surfaced each session.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tether/__init__.py,sha256=3lHrnX-vXMFVAVjfEpHCAAY3AAZVqWPiRbRo2E49DsI,97
|
|
2
|
+
tether/config.py,sha256=k_3SZ8FS3uorZjFCYQsF-7_bZZGus8SCSbWiCigZrls,891
|
|
3
|
+
tether/server.py,sha256=0OZV-Lbuxz9kVT0nEt9m4MqGNwBgHoRfPg__4QLoavI,3555
|
|
4
|
+
tether/store.py,sha256=cqbXC6zlcMQNNgHXimVUuruKR9-Sz3eUt2aqTqh3PFM,6341
|
|
5
|
+
tether/sync.py,sha256=AdW-V1jYEO3MjPUgATeWql393abTruT0gUIEkz4pzr0,3919
|
|
6
|
+
tether_memory-0.1.0.dist-info/METADATA,sha256=kv529bbPuo4TVZ2dHNWD3UBz1W6zGQFWvaZZugW7ZXM,6048
|
|
7
|
+
tether_memory-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
tether_memory-0.1.0.dist-info/entry_points.txt,sha256=prkyBTvX_yaMPGoq7GMsTq74BQDuSVohgLSTBHn064A,46
|
|
9
|
+
tether_memory-0.1.0.dist-info/licenses/LICENSE,sha256=Sg94Ihi25be7gJwtLYUArJURv2BS1xwdV5Yxepj9X7w,1066
|
|
10
|
+
tether_memory-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sidyellur
|
|
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.
|