meshctx-ctxkit 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.
@@ -0,0 +1,208 @@
1
+ """Local-first memory store for ctxkit.
2
+
3
+ Design goals, in priority order:
4
+ 1. Never lose data. Every write is fsync'd into a JSONL append log.
5
+ 2. Never need a network. Zero dependencies; pure stdlib.
6
+ 3. Never be slow on read. An in-process index over the JSONL.
7
+
8
+ Storage layout (all under ``~/.ctxkit`` by default)::
9
+
10
+ memory.jsonl append-only log of {id, text, tags, ts, ...}
11
+ .lock advisory lock file
12
+
13
+ Retrieval is lexical (BM25-style) rather than vector-based on purpose:
14
+ it needs no embedding model, no downloads, and no GPU — which is what
15
+ "local-first, works on a plane" actually requires.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import os
22
+ import re
23
+ import threading
24
+ import time
25
+ import uuid
26
+ from dataclasses import dataclass, field, asdict
27
+ from pathlib import Path
28
+ from typing import Any, Iterable
29
+
30
+ _WORD_RE = re.compile(r"[a-z0-9_]+|[\u4e00-\u9fff]", re.IGNORECASE)
31
+
32
+
33
+ def _tokenize(text: str) -> list[str]:
34
+ """Split into lowercase word tokens; CJK is split per character so that
35
+ Chinese/Japanese notes still retrieve sensibly without a segmenter."""
36
+ return [t.lower() for t in _WORD_RE.findall((text or "").lower())]
37
+
38
+
39
+ @dataclass
40
+ class Memory:
41
+ id: str
42
+ text: str
43
+ tags: list[str] = field(default_factory=list)
44
+ ts: float = field(default_factory=time.time)
45
+ source: str = "manual"
46
+
47
+ def to_json(self) -> str:
48
+ return json.dumps(asdict(self), ensure_ascii=False)
49
+
50
+
51
+ class MemoryStore:
52
+ """Append-only JSONL memory with an in-memory lexical index."""
53
+
54
+ def __init__(self, root: str | os.PathLike | None = None) -> None:
55
+ base = Path(root or os.environ.get("CTXKIT_HOME") or Path.home() / ".ctxkit")
56
+ base.mkdir(parents=True, exist_ok=True)
57
+ self.path = base / "memory.jsonl"
58
+ self._lock = threading.RLock()
59
+ self._items: dict[str, Memory] = {}
60
+ self._tokens: dict[str, list[str]] = {}
61
+ self._df: dict[str, int] = {}
62
+ self._load()
63
+
64
+ # ── persistence ────────────────────────────────────────────────────
65
+ def _load(self) -> None:
66
+ if not self.path.exists():
67
+ return
68
+ with self.path.open("r", encoding="utf-8") as fh:
69
+ for line in fh:
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+ try:
74
+ obj = json.loads(line)
75
+ except json.JSONDecodeError:
76
+ continue # a torn trailing line must never break startup
77
+ if obj.get("_op") == "delete":
78
+ self._forget_index(obj.get("id", ""))
79
+ continue
80
+ mem = Memory(
81
+ id=obj.get("id") or uuid.uuid4().hex,
82
+ text=obj.get("text", ""),
83
+ tags=list(obj.get("tags") or []),
84
+ ts=float(obj.get("ts") or time.time()),
85
+ source=obj.get("source", "manual"),
86
+ )
87
+ self._index(mem)
88
+
89
+ def _index(self, mem: Memory) -> None:
90
+ self._items[mem.id] = mem
91
+ toks = _tokenize(mem.text + " " + " ".join(mem.tags))
92
+ self._tokens[mem.id] = toks
93
+ for tok in set(toks):
94
+ self._df[tok] = self._df.get(tok, 0) + 1
95
+
96
+ def _forget_index(self, mem_id: str) -> None:
97
+ mem = self._items.pop(mem_id, None)
98
+ if not mem:
99
+ return
100
+ for tok in set(self._tokens.pop(mem_id, [])):
101
+ n = self._df.get(tok, 0) - 1
102
+ if n <= 0:
103
+ self._df.pop(tok, None)
104
+ else:
105
+ self._df[tok] = n
106
+
107
+ def _append(self, obj: dict[str, Any]) -> None:
108
+ line = json.dumps(obj, ensure_ascii=False) + "\n"
109
+ with self.path.open("a", encoding="utf-8") as fh:
110
+ fh.write(line)
111
+ fh.flush()
112
+ os.fsync(fh.fileno())
113
+
114
+ # ── public API ─────────────────────────────────────────────────────
115
+ def remember(self, text: str, tags: Iterable[str] | None = None,
116
+ source: str = "manual") -> Memory:
117
+ text = (text or "").strip()
118
+ if not text:
119
+ raise ValueError("text must not be empty")
120
+ if len(text) > 100_000:
121
+ raise ValueError("text too long (100k chars max)")
122
+ mem = Memory(id=uuid.uuid4().hex, text=text,
123
+ tags=[t.strip() for t in (tags or []) if t.strip()],
124
+ ts=time.time(), source=source)
125
+ with self._lock:
126
+ self._append({"id": mem.id, "text": mem.text, "tags": mem.tags,
127
+ "ts": mem.ts, "source": mem.source})
128
+ self._index(mem)
129
+ return mem
130
+
131
+ def recall(self, query: str, limit: int = 5,
132
+ tags: Iterable[str] | None = None) -> list[dict[str, Any]]:
133
+ limit = max(1, min(int(limit), 100))
134
+ want = {t.strip() for t in (tags or []) if t.strip()}
135
+ q = _tokenize(query)
136
+ qset = set(q)
137
+ n = max(1, len(self._items))
138
+ avgdl = (sum(len(v) for v in self._tokens.values()) / n) if n else 1.0
139
+
140
+ k1, b = 1.5, 0.75
141
+ scored: list[tuple[float, Memory]] = []
142
+ with self._lock:
143
+ items = list(self._items.values())
144
+ for mem in items:
145
+ if want and not want.issubset(set(mem.tags)):
146
+ continue
147
+ toks = self._tokens.get(mem.id, [])
148
+ tf: list[str] = list(toks)
149
+ dl = len(toks) or 1
150
+ score = 0.0
151
+ unique_terms = list(dict.fromkeys(q))
152
+ for term in unique_terms:
153
+ f = tf.count(term)
154
+ if not f:
155
+ continue
156
+ idf = math.log(1 + (n - self._df.get(term, 0) + 0.5)
157
+ / (self._df.get(term, 0) + 0.5))
158
+ score += idf * (f * (k1 + 1)) / (f + k1 * (1 - b + b * dl / avgdl))
159
+ if qset:
160
+ # Exact whole-query substring hit is the strongest relevance
161
+ # signal; without it a 3-word doc can beat the longer doc that
162
+ # literally contains the phrase the user typed.
163
+ if query.strip() and query.strip().lower() in mem.text.lower():
164
+ score *= 1.5
165
+ if score <= 0:
166
+ continue
167
+ scored.append((score, mem))
168
+
169
+ scored.sort(key=lambda p: (p[0], p[1].ts), reverse=True)
170
+ out = []
171
+ for score, mem in scored[:limit]:
172
+ out.append({"id": mem.id, "text": mem.text, "tags": mem.tags,
173
+ "ts": mem.ts, "age_days": round((time.time() - mem.ts) / 86400, 1),
174
+ "score": round(score, 4)})
175
+ return out
176
+
177
+ def forget(self, mem_id: str) -> bool:
178
+ with self._lock:
179
+ if mem_id not in self._items:
180
+ return False
181
+ self._append({"_op": "delete", "id": mem_id})
182
+ self._forget_index(mem_id)
183
+ self._compact_if_needed()
184
+ return True
185
+
186
+ def stats(self) -> dict[str, Any]:
187
+ with self._lock:
188
+ return {
189
+ "count": len(self._items),
190
+ "path": str(self.path),
191
+ "tags": sorted({t for m in self._items.values() for t in m.tags}),
192
+ "vocab": len(self._df),
193
+ }
194
+
195
+ def _compact_if_needed(self, ratio: float = 0.4) -> None:
196
+ """Rewrite the log when deletes dominate, so it never grows forever."""
197
+ try:
198
+ raw_lines = sum(1 for _ in self.path.open("r", encoding="utf-8"))
199
+ except OSError:
200
+ return
201
+ live = len(self._items)
202
+ if raw_lines < 500 or live > raw_lines * (1 - ratio):
203
+ return
204
+ tmp = self.path.with_suffix(".jsonl.tmp")
205
+ with tmp.open("w", encoding="utf-8") as fh:
206
+ for mem in self._items.values():
207
+ fh.write(mem.to_json() + "\n")
208
+ os.replace(tmp, self.path)
File without changes
@@ -0,0 +1,241 @@
1
+ """ctxkit MCP server — stdio JSON-RPC, zero dependencies.
2
+
3
+ Speaks the Model Context Protocol over stdio so Claude Desktop, Cursor,
4
+ Cline, Windsurf, Zed and any MCP client can use it. Deliberately
5
+ hand-rolled (no `mcp` SDK import) so ``pip install ctxkit`` pulls
6
+ nothing: the whole point is a memory layer that works offline, in a
7
+ clean venv, on a plane.
8
+
9
+ Tools (free, fully offline):
10
+ ctx_remember(text, tags?) -> store a memory locally
11
+ ctx_recall(query, limit?, tags?) -> BM25-style retrieval
12
+ ctx_forget(id) -> delete a memory
13
+ ctx_stats() -> store stats
14
+ ctx_verify_text(text) -> offline sanity checks
15
+ (arithmetic / dates / absolutes / URLs)
16
+
17
+ Tools (optional, paid path, needs httpx + CTXKIT_API_KEY):
18
+ ctx_consensus(question, models?) -> N-model cross-check report
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ from typing import Any
26
+
27
+ from . import __version__
28
+ from .memory import MemoryStore
29
+ from .consensus import run_offline_checks, multi_model_consensus
30
+ from .license import License, load_license_from_env
31
+ from .usage import UsageMeter
32
+
33
+ PROTOCOL_VERSION = "2024-11-05"
34
+ DEFAULT_MODELS = ["gpt-4o-mini", "claude-3-5-haiku", "gemini-2.0-flash"]
35
+
36
+
37
+ class CtxKitServer:
38
+ def __init__(self, store: MemoryStore | None = None,
39
+ license_: License | None = None,
40
+ meter: UsageMeter | None = None) -> None:
41
+ self.store = store or MemoryStore()
42
+ self.license = license_ or load_license_from_env()
43
+ self.meter = meter or UsageMeter(self.store.path.parent)
44
+
45
+ # ── tool implementations ───────────────────────────────────────────
46
+ def tool_remember(self, args: dict[str, Any]) -> str:
47
+ mem = self.store.remember(
48
+ text=args.get("text", ""),
49
+ tags=args.get("tags") or [],
50
+ source=args.get("source", "mcp"),
51
+ )
52
+ return json.dumps({"ok": True, "id": mem.id,
53
+ "note": "stored locally; survives restarts"},
54
+ ensure_ascii=False)
55
+
56
+ def tool_recall(self, args: dict[str, Any]) -> str:
57
+ hits = self.store.recall(
58
+ query=args.get("query", ""),
59
+ limit=args.get("limit", 5),
60
+ tags=args.get("tags"),
61
+ )
62
+ return json.dumps({"ok": True, "hits": hits}, ensure_ascii=False)
63
+
64
+ def tool_forget(self, args: dict[str, Any]) -> str:
65
+ ok = self.store.forget(args.get("id", ""))
66
+ return json.dumps({"ok": ok, "id": args.get("id", "")})
67
+
68
+ def tool_stats(self, args: dict[str, Any]) -> str: # noqa: ARG002
69
+ return json.dumps({"ok": True, **self.store.stats()}, ensure_ascii=False)
70
+
71
+ def tool_verify(self, args: dict[str, Any]) -> str:
72
+ text = args.get("text", "")
73
+ if not text:
74
+ return json.dumps({"ok": False, "error": "text required"})
75
+ return json.dumps({"ok": True, **run_offline_checks(text)},
76
+ ensure_ascii=False)
77
+
78
+ def tool_consensus(self, args: dict[str, Any]) -> str:
79
+ question = args.get("question", "").strip()
80
+ if not question:
81
+ return json.dumps({"ok": False, "error": "question required"})
82
+ lic = self.license
83
+ if not lic.active:
84
+ return json.dumps({
85
+ "ok": False,
86
+ "error": "ctx_consensus requires a paid license",
87
+ "tier": lic.tier,
88
+ "detail": lic.reason,
89
+ "upgrade": "https://github.com/LucyAndLuna2023/meshctx#pricing",
90
+ "note": "ctx_remember / ctx_recall / ctx_verify_text stay free forever",
91
+ }, ensure_ascii=False)
92
+ allowed, used = self.meter.check_and_consume(lic.consensus_quota)
93
+ if not allowed:
94
+ return json.dumps({
95
+ "ok": False,
96
+ "error": f"monthly consensus quota reached "
97
+ f"({used}/{lic.consensus_quota} on {lic.tier})",
98
+ "resets": "1st of next month (UTC)",
99
+ }, ensure_ascii=False)
100
+ models = args.get("models") or _env_models() or DEFAULT_MODELS
101
+ try:
102
+ report = multi_model_consensus(question, models,
103
+ timeout=float(args.get("timeout", 120)))
104
+ except Exception as e:
105
+ return json.dumps({"ok": False, "error": str(e)[:300]})
106
+ report["quota"] = {"used": used, "limit": lic.consensus_quota, "tier": lic.tier}
107
+ return json.dumps({"ok": True, **report}, ensure_ascii=False)
108
+
109
+ def tool_license(self, args: dict[str, Any]) -> str: # noqa: ARG002
110
+ lic = self.license.to_dict()
111
+ lic["usage"] = self.meter.snapshot()
112
+ lic["free_tools"] = ["ctx_remember", "ctx_recall", "ctx_forget",
113
+ "ctx_stats", "ctx_verify_text"]
114
+ return json.dumps({"ok": True, **lic}, ensure_ascii=False)
115
+
116
+ # ── MCP protocol surface ───────────────────────────────────────────
117
+ def tools_schema(self) -> list[dict[str, Any]]:
118
+ return [
119
+ {"name": "ctx_remember",
120
+ "description": ("Save a durable local memory (facts, decisions, "
121
+ "preferences, project state). Survives restarts. "
122
+ "Stored on this machine only."),
123
+ "inputSchema": {"type": "object", "properties": {
124
+ "text": {"type": "string", "description": "the fact/memory to keep"},
125
+ "tags": {"type": "array", "items": {"type": "string"}},
126
+ "source": {"type": "string", "description": "who saved it"}},
127
+ "required": ["text"]}},
128
+ {"name": "ctx_recall",
129
+ "description": ("Retrieve relevant memories by keyword query "
130
+ "(BM25-style, offline, no embeddings needed)."),
131
+ "inputSchema": {"type": "object", "properties": {
132
+ "query": {"type": "string"},
133
+ "limit": {"type": "integer", "description": "default 5"},
134
+ "tags": {"type": "array", "items": {"type": "string"}}},
135
+ "required": ["query"]}},
136
+ {"name": "ctx_forget",
137
+ "description": "Delete a stored memory by id.",
138
+ "inputSchema": {"type": "object", "properties": {
139
+ "id": {"type": "string"}}, "required": ["id"]}},
140
+ {"name": "ctx_stats",
141
+ "description": "Memory store statistics (count, tags, disk path).",
142
+ "inputSchema": {"type": "object", "properties": {}}},
143
+ {"name": "ctx_verify_text",
144
+ "description": ("Offline sanity checks on generated text: recompute "
145
+ "stated arithmetic, validate dates, flag unqualified "
146
+ "absolutes and suspicious URLs. Free, no network."),
147
+ "inputSchema": {"type": "object", "properties": {
148
+ "text": {"type": "string"}}, "required": ["text"]}},
149
+ {"name": "ctx_consensus",
150
+ "description": ("Ask the same question to several models and score "
151
+ "agreement of their key claims. Catches single-model "
152
+ "hallucinations. Requires CTXKIT_API_KEY (paid tier)."),
153
+ "inputSchema": {"type": "object", "properties": {
154
+ "question": {"type": "string"},
155
+ "models": {"type": "array", "items": {"type": "string"},
156
+ "description": "default: 3 hosted models"},
157
+ "timeout": {"type": "number"}},
158
+ "required": ["question"]}},
159
+ {"name": "ctx_license",
160
+ "description": ("Show current license tier, expiry, this month's "
161
+ "consensus usage, and which tools are free."),
162
+ "inputSchema": {"type": "object", "properties": {}}},
163
+ ]
164
+
165
+ def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
166
+ handlers = {
167
+ "ctx_remember": self.tool_remember,
168
+ "ctx_recall": self.tool_recall,
169
+ "ctx_forget": self.tool_forget,
170
+ "ctx_stats": self.tool_stats,
171
+ "ctx_verify_text": self.tool_verify,
172
+ "ctx_consensus": self.tool_consensus,
173
+ "ctx_license": self.tool_license,
174
+ }
175
+ h = handlers.get(name)
176
+ if h is None:
177
+ return {"isError": True,
178
+ "content": [{"type": "text", "text": f"unknown tool: {name}"}]}
179
+ try:
180
+ text = h(args or {})
181
+ return {"content": [{"type": "text", "text": text}]}
182
+ except Exception as e:
183
+ return {"isError": True,
184
+ "content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}]}
185
+
186
+ def handle(self, msg: dict[str, Any]) -> dict[str, Any] | None:
187
+ method = msg.get("method", "")
188
+ mid = msg.get("id")
189
+ params = msg.get("params") or {}
190
+ if method == "initialize":
191
+ return {"jsonrpc": "2.0", "id": mid, "result": {
192
+ "protocolVersion": params.get("protocolVersion") or PROTOCOL_VERSION,
193
+ "capabilities": {"tools": {}},
194
+ "serverInfo": {"name": "ctxkit", "version": __version__}}}
195
+ if method == "notifications/initialized":
196
+ return None # notification: no response
197
+ if method == "ping":
198
+ return {"jsonrpc": "2.0", "id": mid, "result": {}}
199
+ if method == "tools/list":
200
+ return {"jsonrpc": "2.0", "id": mid, "result": {"tools": self.tools_schema()}}
201
+ if method == "tools/call":
202
+ name = params.get("name", "")
203
+ result = self.call_tool(name, params.get("arguments") or {})
204
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
205
+ if mid is not None:
206
+ return {"jsonrpc": "2.0", "id": mid,
207
+ "error": {"code": -32601, "message": f"method not found: {method}"}}
208
+ return None
209
+
210
+
211
+ def _env_models() -> list[str] | None:
212
+ raw = os.environ.get("CTXKIT_MODELS", "").strip()
213
+ return [m.strip() for m in raw.split(",") if m.strip()] or None
214
+
215
+
216
+ def main() -> None:
217
+ server = CtxKitServer()
218
+ out = sys.stdout.buffer
219
+ inp = sys.stdin.buffer
220
+ while True:
221
+ # readline() (not iteration): iterating stdin.buffer keeps an internal
222
+ # read-ahead buffer and can miss EOF, leaving the process hung after
223
+ # the MCP client closes the pipe.
224
+ line = inp.readline()
225
+ if not line:
226
+ break
227
+ line = line.strip()
228
+ if not line:
229
+ continue
230
+ try:
231
+ msg = json.loads(line)
232
+ except json.JSONDecodeError:
233
+ continue
234
+ resp = server.handle(msg)
235
+ if resp is not None:
236
+ out.write((json.dumps(resp, ensure_ascii=False) + "\n").encode())
237
+ out.flush()
238
+
239
+
240
+ if __name__ == "__main__":
241
+ main()
@@ -0,0 +1,70 @@
1
+ """Local usage metering for the paid tier.
2
+
3
+ Deliberately local-only: we count calls in a JSON file next to the memory
4
+ store. No telemetry, no phone-home, no user identification beyond what the
5
+ license token already contains.
6
+
7
+ The honest caveat, stated plainly: a local counter can be deleted by the
8
+ user. That is fine. The paid feature costs *us* money per call (model API
9
+ spend), so quotas protect against runaway bills and casual abuse, not
10
+ against a determined attacker. Licensing protects revenue; the counter
11
+ protects the credit card.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+
23
+ class UsageMeter:
24
+ def __init__(self, root: str | os.PathLike) -> None:
25
+ self.path = Path(root) / "usage.json"
26
+ self._lock = threading.RLock()
27
+
28
+ def _load(self) -> dict[str, int]:
29
+ try:
30
+ with self.path.open("r", encoding="utf-8") as fh:
31
+ data = json.load(fh)
32
+ return {k: int(v) for k, v in data.items() if isinstance(v, (int, float))}
33
+ except (OSError, json.JSONDecodeError, ValueError):
34
+ return {}
35
+
36
+ def _save(self, data: dict[str, int]) -> None:
37
+ tmp = self.path.with_suffix(".json.tmp")
38
+ with tmp.open("w", encoding="utf-8") as fh:
39
+ json.dump(data, fh)
40
+ fh.flush()
41
+ os.fsync(fh.fileno())
42
+ os.replace(tmp, self.path)
43
+
44
+ @staticmethod
45
+ def _month_key(now: float | None = None) -> str:
46
+ t = time.gmtime(now or time.time())
47
+ return f"consensus:{t.tm_year}-{t.tm_mon:02d}"
48
+
49
+ def used(self, now: float | None = None) -> int:
50
+ with self._lock:
51
+ return self._load().get(self._month_key(now), 0)
52
+
53
+ def check_and_consume(self, quota: int, now: float | None = None) -> tuple[bool, int]:
54
+ """Atomically reserve one call. Returns (allowed, used_after)."""
55
+ with self._lock:
56
+ data = self._load()
57
+ key = self._month_key(now)
58
+ used = data.get(key, 0)
59
+ if used >= quota:
60
+ return False, used
61
+ data[key] = used + 1
62
+ self._save(data)
63
+ return True, data[key]
64
+
65
+ def snapshot(self) -> dict[str, Any]:
66
+ with self._lock:
67
+ data = self._load()
68
+ key = self._month_key()
69
+ return {"month": key.split(":", 1)[1], "used": data.get(key, 0),
70
+ "file": str(self.path)}