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 ADDED
@@ -0,0 +1,3 @@
1
+ """tether - a shared memory layer for personal agents, across devices."""
2
+
3
+ __version__ = "0.1.0"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tether = tether.server:main
@@ -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.