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.
- meshctx_ctxkit/__init__.py +17 -0
- meshctx_ctxkit/__main__.py +72 -0
- meshctx_ctxkit/consensus.py +314 -0
- meshctx_ctxkit/license.py +179 -0
- meshctx_ctxkit/memory.py +208 -0
- meshctx_ctxkit/py.typed +0 -0
- meshctx_ctxkit/server.py +241 -0
- meshctx_ctxkit/usage.py +70 -0
- meshctx_ctxkit-0.1.0.dist-info/METADATA +282 -0
- meshctx_ctxkit-0.1.0.dist-info/RECORD +14 -0
- meshctx_ctxkit-0.1.0.dist-info/WHEEL +5 -0
- meshctx_ctxkit-0.1.0.dist-info/entry_points.txt +3 -0
- meshctx_ctxkit-0.1.0.dist-info/licenses/LICENSE +661 -0
- meshctx_ctxkit-0.1.0.dist-info/top_level.txt +1 -0
meshctx_ctxkit/memory.py
ADDED
|
@@ -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)
|
meshctx_ctxkit/py.typed
ADDED
|
File without changes
|
meshctx_ctxkit/server.py
ADDED
|
@@ -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()
|
meshctx_ctxkit/usage.py
ADDED
|
@@ -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)}
|