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,17 @@
1
+ """ctxkit — local-first context kit for agents.
2
+
3
+ Two products in one MCP server:
4
+
5
+ 1. ``memory`` — cross-session memory on local disk. Survives restarts,
6
+ never leaves the machine. No account, no cloud.
7
+ 2. ``consensus`` — the same question asked across multiple models, then
8
+ scored for agreement. Catches hallucinations that a single model
9
+ would confidently hand you.
10
+
11
+ Everything degrades gracefully: with no API key and no browser it still
12
+ runs (memory works fully offline; consensus returns a clear, actionable
13
+ error instead of crashing).
14
+ """
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = ["__version__"]
@@ -0,0 +1,72 @@
1
+ """CLI: ``ctxkit`` command line interface.
2
+
3
+ ctxkit add "fact text" --tag project/alpha
4
+ ctxkit search "query" [--limit 5]
5
+ ctxkit stats
6
+ ctxkit verify "text to check"
7
+ ctxkit serve # run the MCP stdio server
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+
15
+ from .memory import MemoryStore
16
+ from .consensus import run_offline_checks
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ ap = argparse.ArgumentParser(prog="ctxkit",
21
+ description="local-first memory + verification for agents")
22
+ sub = ap.add_subparsers(dest="cmd", required=True)
23
+
24
+ p_add = sub.add_parser("add", help="store a memory")
25
+ p_add.add_argument("text")
26
+ p_add.add_argument("--tag", action="append", default=[])
27
+
28
+ p_search = sub.add_parser("search", help="search memories")
29
+ p_search.add_argument("query")
30
+ p_search.add_argument("--limit", type=int, default=5)
31
+
32
+ sub.add_parser("stats", help="store stats")
33
+
34
+ p_verify = sub.add_parser("verify", help="offline sanity checks on text")
35
+ p_verify.add_argument("text")
36
+
37
+ p_lic = sub.add_parser("license", help="show license tier, expiry and usage")
38
+ p_lic.add_argument("--key", help="verify a specific license token instead of the stored one")
39
+
40
+ sub.add_parser("serve", help="run MCP stdio server")
41
+
42
+ args = ap.parse_args(argv)
43
+ if args.cmd == "serve":
44
+ from .server import main as serve_main
45
+ serve_main()
46
+ return 0
47
+ store = MemoryStore()
48
+ if args.cmd == "license":
49
+ from .license import load_license_from_env, verify_license
50
+ from .usage import UsageMeter
51
+ lic = verify_license(args.key) if args.key else load_license_from_env()
52
+ out = lic.to_dict()
53
+ out["usage"] = UsageMeter(store.path.parent).snapshot()
54
+ out["free_tools"] = ["ctx_remember", "ctx_recall", "ctx_forget",
55
+ "ctx_stats", "ctx_verify_text"]
56
+ print(json.dumps(out, ensure_ascii=False, indent=2))
57
+ return 0
58
+ if args.cmd == "add":
59
+ mem = store.remember(args.text, tags=args.tag)
60
+ print(json.dumps({"ok": True, "id": mem.id}, ensure_ascii=False))
61
+ elif args.cmd == "search":
62
+ print(json.dumps({"hits": store.recall(args.query, args.limit)},
63
+ ensure_ascii=False, indent=2))
64
+ elif args.cmd == "stats":
65
+ print(json.dumps(store.stats(), ensure_ascii=False, indent=2))
66
+ elif args.cmd == "verify":
67
+ print(json.dumps(run_offline_checks(args.text), ensure_ascii=False, indent=2))
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ sys.exit(main())
@@ -0,0 +1,314 @@
1
+ """Verification & multi-model consensus for ctxkit.
2
+
3
+ Two layers:
4
+
5
+ ``run_offline_checks(text)``
6
+ Pure-stdlib sanity checks that catch the most common LLM failure
7
+ modes without any network: broken arithmetic, impossible dates,
8
+ unreachable-looking URLs, and unqualified absolute claims.
9
+
10
+ ``multi_model_consensus(question, ...)``
11
+ Ask the same question to N OpenAI-compatible endpoints, extract
12
+ each model's key claims, and score cross-model agreement. This is
13
+ the paid/hosted path (``ctxkit[api]`` + ``CTXKIT_API_KEY``); it is
14
+ optional and the server never requires it.
15
+
16
+ The offline layer is the free product core; the consensus layer is the
17
+ commercial add-on. Both are honest about what they cannot do: they flag
18
+ suspicion, they do not prove truth.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import statistics
26
+ from dataclasses import dataclass, field, asdict
27
+ from typing import Any, Callable
28
+
29
+ # ── offline checks ─────────────────────────────────────────────────────
30
+
31
+ _NUM = r"\d[\d,]*(?:\.\d+)?"
32
+ _ARITH_RE = re.compile(
33
+ rf"({_NUM})\s*([+\-*/×x÷])\s*({_NUM})\s*=\s*({_NUM})", re.IGNORECASE
34
+ )
35
+ _WORD_ARITH_RE = re.compile(
36
+ r"({num})\s*(plus|minus|times|multiplied by|divided by)\s*({num})\s*"
37
+ r"(?:is|equals|=)\s*({num})".format(num=_NUM),
38
+ re.IGNORECASE,
39
+ )
40
+ _PCT_RE = re.compile(
41
+ rf"({_NUM})\s*%\s*of\s*({_NUM})\s*(?:is|equals|=)\s*({_NUM})", re.IGNORECASE
42
+ )
43
+ _YEAR_RE = re.compile(r"\b((?:18|19|20|21)\d{2})\b")
44
+ _MD_DATE_RE = re.compile(
45
+ r"\b(January|February|March|April|May|June|July|August|September|October|"
46
+ r"November|December)\s+(\d{1,2})(?:st|nd|rd|th)?\b,?\s*((?:18|19|20|21)\d{2})?",
47
+ re.IGNORECASE,
48
+ )
49
+ _URL_RE = re.compile(r"https?://[^\s<>\"')\]]+", re.IGNORECASE)
50
+ _ABSOLUTE_RE = re.compile(
51
+ r"\b(always|never|100%|guaranteed|impossible|everyone|nobody|"
52
+ r"all users|zero risk|risk[- ]free|completely safe)\b",
53
+ re.IGNORECASE,
54
+ )
55
+ _MONTH_DAYS = {
56
+ "january": 31, "february": 29, "march": 31, "april": 30, "may": 31,
57
+ "june": 30, "july": 31, "august": 31, "september": 30, "october": 31,
58
+ "november": 30, "december": 31,
59
+ }
60
+ _OPS = {"+": 1, "-": 1, "*": 1, "/": 1, "×": 1, "x": 1, "÷": 1,
61
+ "plus": 1, "minus": 1, "times": 1, "multiplied by": 1, "divided by": 1}
62
+
63
+
64
+ def _num(s: str) -> float:
65
+ return float(s.replace(",", ""))
66
+
67
+
68
+ def _fmt(x: float) -> str:
69
+ return f"{x:.6f}".rstrip("0").rstrip(".")
70
+
71
+
72
+ @dataclass
73
+ class CheckResult:
74
+ name: str
75
+ passed: bool
76
+ detail: str
77
+
78
+
79
+ def arithmetic_check(text: str) -> list[CheckResult]:
80
+ """Recompute every ``a op b = c`` pattern stated in the text."""
81
+ out: list[CheckResult] = []
82
+ for a, op, b, c in _ARITH_RE.findall(text):
83
+ try:
84
+ x, y, z = _num(a), _num(b), _num(c)
85
+ got = {"+": x + y, "-": x - y, "*": x * y, "×": x * y, "x": x * y,
86
+ "/": x / y if y else float("inf"), "÷": x / y if y else float("inf")}[op.lower() if op.lower() in ("x",) else op]
87
+ except Exception:
88
+ continue
89
+ ok = abs(got - z) < 1e-6 * max(1.0, abs(z))
90
+ out.append(CheckResult(
91
+ "arithmetic", ok,
92
+ f'"{a} {op} {b} = {c}" — computed {_fmt(got)}'
93
+ + ("" if ok else f", text says {c}")))
94
+ for a, op, b, c in _WORD_ARITH_RE.findall(text):
95
+ try:
96
+ x, y, z = _num(a), _num(b), _num(c)
97
+ got = {"plus": x + y, "minus": x - y, "times": x * y,
98
+ "multiplied by": x * y,
99
+ "divided by": x / y if y else float("inf")}[op.lower()]
100
+ except Exception:
101
+ continue
102
+ ok = abs(got - z) < 1e-6 * max(1.0, abs(z))
103
+ out.append(CheckResult(
104
+ "arithmetic", ok,
105
+ f'"{a} {op} {b} = {c}" — computed {_fmt(got)}'
106
+ + ("" if ok else f", text says {c}")))
107
+ return out
108
+
109
+
110
+ def percent_check(text: str) -> list[CheckResult]:
111
+ out: list[CheckResult] = []
112
+ for p, base, claimed in _PCT_RE.findall(text):
113
+ try:
114
+ got = _num(base) * _num(p) / 100.0
115
+ except Exception:
116
+ continue
117
+ ok = abs(got - _num(claimed)) < 1e-6 * max(1.0, abs(got))
118
+ out.append(CheckResult(
119
+ "percent", ok,
120
+ f'"{p}% of {base} = {claimed}" — computed {_fmt(got)}'
121
+ + ("" if ok else f", text says {claimed}")))
122
+ return out
123
+
124
+
125
+ def date_check(text: str, *, now_year: int | None = None) -> list[CheckResult]:
126
+ import datetime
127
+ this_year = now_year or datetime.date.today().year
128
+ out: list[CheckResult] = []
129
+ for year in _YEAR_RE.findall(text):
130
+ y = int(year)
131
+ if y > this_year + 2:
132
+ out.append(CheckResult(
133
+ "date", False,
134
+ f"year {y} is more than 2 years in the future "
135
+ f"(current {this_year}) — likely a hallucinated date"))
136
+ for month, day, year in _MD_DATE_RE.findall(text):
137
+ d = int(day)
138
+ max_d = _MONTH_DAYS[month.lower()]
139
+ if d > max_d:
140
+ out.append(CheckResult(
141
+ "date", False,
142
+ f'"{month} {day}" does not exist ({month} has {max_d} days)'))
143
+ return out
144
+
145
+
146
+ def absolute_claim_check(text: str) -> list[CheckResult]:
147
+ hits = sorted({m.group(0).lower() for m in _ABSOLUTE_RE.finditer(text)})
148
+ if not hits:
149
+ return [CheckResult("absolute_claims", True, "no unqualified absolutes found")]
150
+ return [CheckResult(
151
+ "absolute_claims", False,
152
+ "unqualified absolutes (verify or hedge these): " + ", ".join(hits))]
153
+
154
+
155
+ def url_check(text: str) -> list[CheckResult]:
156
+ urls = _URL_RE.findall(text)
157
+ if not urls:
158
+ return []
159
+ suspicious = [u for u in urls
160
+ if u.endswith((".png", ".jpg", ".pdf")) and "arxiv" not in u]
161
+ ok = not suspicious
162
+ return [CheckResult(
163
+ "url_format", ok,
164
+ f"{len(urls)} URL(s) found" + (f"; suspicious: {suspicious}" if suspicious else ""))]
165
+
166
+
167
+ def run_offline_checks(text: str) -> dict[str, Any]:
168
+ checks = (arithmetic_check(text) + percent_check(text)
169
+ + date_check(text) + absolute_claim_check(text) + url_check(text))
170
+ failed = [c for c in checks if not c.passed]
171
+ return {
172
+ "verdict": "suspicious" if failed else "clean",
173
+ "n_checks": len(checks),
174
+ "failed": [asdict(c) for c in failed],
175
+ "passed": [asdict(c) for c in checks if c.passed],
176
+ }
177
+
178
+
179
+ # ── multi-model consensus (optional, hosted) ──────────────────────────
180
+
181
+ _STOP = {"the", "a", "an", "of", "is", "are", "was", "were", "to", "in",
182
+ "on", "for", "and", "or", "that", "this", "it", "with", "as", "by"}
183
+
184
+
185
+ def _norm_claim(s: str) -> frozenset[str]:
186
+ words = re.findall(r"[a-z0-9]+", s.lower())
187
+ return frozenset(w for w in words if w not in _STOP)
188
+
189
+
190
+ def claim_similarity(a: str, b: str) -> float:
191
+ sa, sb = _norm_claim(a), _norm_claim(b)
192
+ if not sa or not sb:
193
+ return 0.0
194
+ return len(sa & sb) / len(sa | sb)
195
+
196
+
197
+ def extract_claims(answer: str) -> list[str]:
198
+ """Ask-format helper: models are instructed to end with a JSON array of
199
+ claims; pull it out, falling back to sentence split."""
200
+ m = re.search(r"\[\s*\".*\"\s*\]", answer, re.DOTALL)
201
+ if m:
202
+ try:
203
+ arr = json.loads(m.group(0))
204
+ if isinstance(arr, list) and arr and all(isinstance(x, str) for x in arr):
205
+ return arr
206
+ except json.JSONDecodeError:
207
+ pass
208
+ sents = re.split(r"(?<=[.!?])\s+", answer.strip())
209
+ return [s for s in sents if len(s.split()) >= 4][:8]
210
+
211
+
212
+ @dataclass
213
+ class ConsensusReport:
214
+ question: str
215
+ models: list[str]
216
+ verdict: str # high | medium | low | error
217
+ agreement_score: float # 0..1
218
+ shared_claims: list[str] = field(default_factory=list)
219
+ outlier_claims: dict[str, list[str]] = field(default_factory=dict)
220
+ per_model: dict[str, str] = field(default_factory=dict)
221
+ notes: list[str] = field(default_factory=list)
222
+
223
+ def to_dict(self) -> dict[str, Any]:
224
+ return asdict(self)
225
+
226
+
227
+ def score_consensus(question: str, answers: dict[str, str],
228
+ threshold: float = 0.45) -> ConsensusReport:
229
+ """Pure scoring — injectable and testable without any network."""
230
+ models = list(answers)
231
+ if len(models) < 2:
232
+ return ConsensusReport(question, models, "error", 0.0, notes=[
233
+ "consensus needs >= 2 model answers"])
234
+ claim_sets = {m: extract_claims(a) for m, a in answers.items()}
235
+ shared: list[str] = []
236
+ outliers: dict[str, list[str]] = {m: [] for m in models}
237
+ pair_scores: list[float] = []
238
+ matched: set[tuple[int, int]] = set()
239
+ for m in models:
240
+ for claim in claim_sets[m]:
241
+ hit = False
242
+ for n in models:
243
+ if n == m:
244
+ continue
245
+ for i, c2 in enumerate(claim_sets[n]):
246
+ if claim_similarity(claim, c2) >= threshold:
247
+ pair_scores.append(claim_similarity(claim, c2))
248
+ matched.add((models.index(m), models.index(n)))
249
+ hit = True
250
+ break
251
+ if hit:
252
+ break
253
+ if hit and claim not in shared:
254
+ shared.append(claim)
255
+ elif not hit:
256
+ outliers[m].append(claim)
257
+ n_pairs = len(models) * (len(models) - 1)
258
+ coverage = len({(i, j) for i, j in matched}) / n_pairs if n_pairs else 0.0
259
+ avg_sim = statistics.mean(pair_scores) if pair_scores else 0.0
260
+ score = round(0.6 * coverage + 0.4 * avg_sim, 3)
261
+ verdict = "high" if score >= 0.7 else ("medium" if score >= 0.4 else "low")
262
+ return ConsensusReport(question, models, verdict, score,
263
+ shared_claims=shared[:10],
264
+ outlier_claims={m: v[:5] for m, v in outliers.items() if v},
265
+ per_model={m: a[:400] for m, a in answers.items()},
266
+ notes=["lexical claim matching; threshold "
267
+ f"{threshold} — flags suspicion, does not prove truth"])
268
+
269
+
270
+ CONSENSUS_PROMPT = (
271
+ "Answer the question below. After your answer, append a line "
272
+ "'CLAIMS:' followed by a JSON array of 3-7 short factual claims "
273
+ "your answer depends on.\n\nQuestion: {q}"
274
+ )
275
+
276
+
277
+ def default_http_transport(model: str, prompt: str, timeout: float) -> str:
278
+ """OpenAI-compatible chat completion. Requires httpx + env config:
279
+ CTXKIT_API_BASE, CTXKIT_API_KEY. The model name is passed through."""
280
+ import httpx # optional extra
281
+ base = os.environ.get("CTXKIT_API_BASE", "").rstrip("/")
282
+ key = os.environ.get("CTXKIT_API_KEY", "")
283
+ if not base or not key:
284
+ raise RuntimeError("CTXKIT_API_BASE / CTXKIT_API_KEY not configured")
285
+ r = httpx.post(
286
+ f"{base}/chat/completions",
287
+ headers={"Authorization": f"Bearer {key}"},
288
+ json={"model": model, "messages": [{"role": "user", "content": prompt}],
289
+ "temperature": 0.2},
290
+ timeout=timeout,
291
+ )
292
+ r.raise_for_status()
293
+ return r.json()["choices"][0]["message"]["content"]
294
+
295
+
296
+ def multi_model_consensus(question: str, models: list[str],
297
+ transport: Callable[[str, str, float], str] | None = None,
298
+ timeout: float = 120.0) -> dict[str, Any]:
299
+ transport = transport or default_http_transport
300
+ prompt = CONSENSUS_PROMPT.format(q=question)
301
+ answers: dict[str, str] = {}
302
+ errors: dict[str, str] = {}
303
+ for m in models:
304
+ try:
305
+ answers[m] = transport(m, prompt, timeout)
306
+ except Exception as e: # one model failing must not kill the report
307
+ errors[m] = str(e)[:200]
308
+ if len(answers) < 2:
309
+ return {"verdict": "error", "question": question,
310
+ "errors": errors,
311
+ "notes": ["need >= 2 successful model calls for consensus"]}
312
+ report = score_consensus(question, answers)
313
+ report.notes.append(f"model errors: {errors}" if errors else "all models answered")
314
+ return report.to_dict()
@@ -0,0 +1,179 @@
1
+ """License checking for ctxkit's paid tier.
2
+
3
+ Design constraint that drives everything here: **the license must verify
4
+ offline.** A user who paid should not be locked out because our licence
5
+ server is down, and we should not be running a phone-home endpoint that
6
+ sees every user's usage.
7
+
8
+ So licenses are Ed25519-signed tokens (JWT, ``EdDSA``):
9
+ - private key lives only on the server (never shipped)
10
+ - public key is baked into the package
11
+ - the client verifies the signature locally, against the expiry
12
+
13
+ This module is intentionally dependency-light: it uses ``PyJWT[crypto]``
14
+ when available, and falls back to a clear error when it is not. Memory and
15
+ verification (the free tier) never touch this module at all.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Literal
23
+
24
+ Tier = Literal["free", "pro", "team"]
25
+
26
+ # Public key shipped with the client. Replace with the real one when the
27
+ # Stripe setup is live (see billing/README.md). The placeholder is refused
28
+ # explicitly rather than silently trusting anything.
29
+ # The public key baked into the package at build time. This is the key the
30
+ # billing service signs licences with.
31
+ BUILTIN_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
32
+ MCowBQYDK2VwAyEAsnFCGUPdXj1osPhWOsdxNj8ngmaMciTksQP2h86hmw8=
33
+ -----END PUBLIC KEY-----"""
34
+
35
+
36
+ def _resolve_public_key() -> str:
37
+ """Return the public key to verify against.
38
+
39
+ Reads ``CTXKIT_LICENSE_PUBKEY`` at *call* time rather than import time.
40
+ Resolving it at import made the override silently useless for anyone who
41
+ exported the variable after the module was already imported (e.g. from a
42
+ test or a wrapper script) — the override looked supported but did nothing.
43
+ """
44
+ return os.environ.get("CTXKIT_LICENSE_PUBKEY") or BUILTIN_PUBLIC_KEY
45
+
46
+
47
+ # Kept for backwards compatibility with anything importing the old name.
48
+ PUBLIC_KEY_PEM = BUILTIN_PUBLIC_KEY
49
+
50
+ _PLACEHOLDER = "PLACEHOLDER_REPLACE_ME"
51
+
52
+ # Free tier never expires and needs no token.
53
+ FREE = "free"
54
+
55
+ QUOTAS: dict[Tier, dict[str, Any]] = {
56
+ "free": {"consensus_per_month": 0, "email_support": False, "managed_keys": False, "seats": 1},
57
+ "pro": {"consensus_per_month": 300, "email_support": True, "managed_keys": False, "seats": 1},
58
+ "team": {"consensus_per_month": 3000, "email_support": True, "managed_keys": True, "seats": 5},
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class License:
64
+ tier: Tier = "free"
65
+ email: str = ""
66
+ exp: int = 0 # unix ts; 0 = never (free)
67
+ seats: int = 1
68
+ managed_keys: bool = False
69
+ claims: dict[str, Any] = field(default_factory=dict)
70
+ reason: str = "" # why the license isn't active, if so
71
+
72
+ @property
73
+ def active(self) -> bool:
74
+ return self.tier != "free" and (self.exp == 0 or self.exp > time.time())
75
+
76
+ @property
77
+ def consensus_quota(self) -> int:
78
+ return QUOTAS[self.tier]["consensus_per_month"]
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ return {
82
+ "tier": self.tier,
83
+ "active": self.active,
84
+ "email": self.email,
85
+ "expires": self.exp,
86
+ "expires_human": (time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(self.exp))
87
+ if self.exp else "never"),
88
+ "seats": self.seats,
89
+ "managed_keys": self.managed_keys,
90
+ "consensus_quota": self.consensus_quota,
91
+ "reason": self.reason,
92
+ }
93
+
94
+
95
+ def _jwt():
96
+ try:
97
+ import jwt # type: ignore
98
+ return jwt
99
+ except ImportError: # pragma: no cover - optional dependency path
100
+ return None
101
+
102
+
103
+ def verify_license(token: str | None, public_key: str | None = None,
104
+ *, now: float | None = None) -> License:
105
+ """Verify a license token locally. Never raises; returns a License with
106
+ ``reason`` set if anything is off."""
107
+ if not token:
108
+ return License(tier="free", reason="no token (free tier)")
109
+ jwt = _jwt()
110
+ if jwt is None:
111
+ return License(tier="free", reason=(
112
+ "licence verification needs PyJWT: "
113
+ "pip install 'meshctx-ctxkit[license]'"))
114
+ key = public_key or _resolve_public_key()
115
+ if _PLACEHOLDER in key:
116
+ return License(tier="free",
117
+ reason="license public key not configured in this build")
118
+ try:
119
+ # EdDSA only. Rejecting HS256/RS256 prevents the classic
120
+ # "alg: none" and public-key-as-HMAC-secret downgrade attacks.
121
+ claims = jwt.decode(token, key, algorithms=["EdDSA"],
122
+ options={"require": ["tier", "exp"]})
123
+ except Exception as e:
124
+ return License(tier="free", reason=f"invalid license: {type(e).__name__}: {e}")
125
+
126
+ tier = claims.get("tier", "free")
127
+ if tier not in QUOTAS:
128
+ return License(tier="free", reason=f"unknown tier: {tier}")
129
+ exp = int(claims.get("exp") or 0)
130
+ lic = License(
131
+ tier=tier,
132
+ email=claims.get("email", ""),
133
+ exp=exp,
134
+ seats=int(claims.get("seats") or QUOTAS[tier]["seats"]),
135
+ managed_keys=bool(claims.get("managed_keys",
136
+ QUOTAS[tier]["managed_keys"])),
137
+ claims=claims,
138
+ )
139
+ if now is not None and exp and exp <= now:
140
+ lic.tier = "free"
141
+ lic.reason = f"license expired on {time.strftime('%Y-%m-%d', time.gmtime(exp))}"
142
+ return lic
143
+
144
+
145
+ def load_license_from_env(public_key: str | None = None) -> License:
146
+ """Read CTXKIT_LICENSE, or ~/.ctxkit/license.key if the env var is unset."""
147
+ token = os.environ.get("CTXKIT_LICENSE", "").strip()
148
+ if not token:
149
+ path = os.path.join(os.environ.get("CTXKIT_HOME") or
150
+ os.path.join(os.path.expanduser("~"), ".ctxkit"),
151
+ "license.key")
152
+ try:
153
+ with open(path, "r", encoding="utf-8") as fh:
154
+ token = fh.read().strip()
155
+ except OSError:
156
+ token = ""
157
+ return verify_license(token, public_key)
158
+
159
+
160
+ def sign_license(private_key_pem: str, *, tier: Tier, email: str,
161
+ days: int = 30, seats: int | None = None,
162
+ managed_keys: bool | None = None) -> str:
163
+ """Server-side only. Produces the token we email to a paying customer."""
164
+ jwt = _jwt()
165
+ if jwt is None:
166
+ raise RuntimeError("PyJWT required on the signing side: pip install pyjwt[crypto]")
167
+ now = int(time.time())
168
+ claims: dict[str, Any] = {
169
+ "tier": tier,
170
+ "email": email,
171
+ "iat": now,
172
+ "exp": now + days * 86400,
173
+ "iss": "ctxkit-billing",
174
+ }
175
+ if seats is not None:
176
+ claims["seats"] = seats
177
+ if managed_keys is not None:
178
+ claims["managed_keys"] = managed_keys
179
+ return jwt.encode(claims, private_key_pem, algorithm="EdDSA")