memtether 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.
Files changed (69) hide show
  1. api_server.py +433 -0
  2. cli_out.py +52 -0
  3. clients/__init__.py +46 -0
  4. clients/base.py +520 -0
  5. clients/jsonc.py +435 -0
  6. clients/local.py +403 -0
  7. clients/standard.py +551 -0
  8. clients/trust.py +162 -0
  9. consolidation.py +100 -0
  10. embed_local.py +293 -0
  11. extract.py +186 -0
  12. gateway.py +2657 -0
  13. governance.py +993 -0
  14. hubguard.py +984 -0
  15. integrations/__init__.py +0 -0
  16. integrations/auto_onboard.py +497 -0
  17. integrations/autogen_memory.py +41 -0
  18. integrations/langchain_memory.py +152 -0
  19. make_jury.py +79 -0
  20. mcp_server.py +1016 -0
  21. mem0_exchange.py +197 -0
  22. memory_court.py +211 -0
  23. memsearch.py +2268 -0
  24. memtether-0.1.0.dist-info/METADATA +817 -0
  25. memtether-0.1.0.dist-info/RECORD +69 -0
  26. memtether-0.1.0.dist-info/WHEEL +5 -0
  27. memtether-0.1.0.dist-info/entry_points.txt +3 -0
  28. memtether-0.1.0.dist-info/licenses/LICENSE +202 -0
  29. memtether-0.1.0.dist-info/licenses/NOTICE +96 -0
  30. memtether-0.1.0.dist-info/top_level.txt +28 -0
  31. memtether.py +1521 -0
  32. memtether_assets/__init__.py +2 -0
  33. memtether_assets/dashboard.html +303 -0
  34. memtether_assets/viewer.html +207 -0
  35. memtether_exchange.py +584 -0
  36. memtether_paths.py +103 -0
  37. memtether_pipeline.py +140 -0
  38. predicates.py +312 -0
  39. rerank.py +113 -0
  40. scripts/__init__.py +21 -0
  41. scripts/bench_memtether_adapter.py +137 -0
  42. scripts/check_packaging.py +299 -0
  43. scripts/cli_out.py +52 -0
  44. scripts/demo_exchange_5adapters.py +174 -0
  45. scripts/demo_exchange_conflict.py +155 -0
  46. scripts/demo_export_roundtrip.py +103 -0
  47. scripts/demo_federation.py +91 -0
  48. scripts/demo_gateway.py +52 -0
  49. scripts/download_models.py +165 -0
  50. scripts/make_demo_db.py +445 -0
  51. scripts/make_jury_report.py +104 -0
  52. scripts/memtether_export.py +172 -0
  53. scripts/memtether_guard.py +77 -0
  54. scripts/scan_docs.py +152 -0
  55. scripts/scan_history_leaks.py +211 -0
  56. scripts/scan_leaks.py +323 -0
  57. scripts/selfcheck.py +1088 -0
  58. scripts/test_pii_roundtrip.py +70 -0
  59. scripts/update_bench_page.py +76 -0
  60. scripts/watch.py +165 -0
  61. scripts/zh_calibration.py +113 -0
  62. skills/__init__.py +0 -0
  63. skills/agent-memory/SKILL.md +58 -0
  64. skills/agent-memory-court/SKILL.md +61 -0
  65. skills/agent-memory-recall/SKILL.md +55 -0
  66. tether_connect.py +740 -0
  67. tool_audit.py +186 -0
  68. trigger_detector.py +201 -0
  69. zep_exchange.py +196 -0
api_server.py ADDED
@@ -0,0 +1,433 @@
1
+ # -*- coding: utf-8 -*-
2
+ """api_server.py — REST API Server for MemTether (S1)"""
3
+ import os, sys
4
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
5
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
6
+
7
+ try:
8
+ import memtether_paths as _mp
9
+ _DB = _mp.default_db()
10
+ except Exception:
11
+ _DB = os.environ.get("MEM_DB", os.path.join(os.path.dirname(os.path.abspath(__file__)), "memory.db"))
12
+ os.environ.setdefault("MEM_DB", _DB)
13
+
14
+ import gateway
15
+ from fastapi import Request, FastAPI, HTTPException
16
+ from pydantic import BaseModel, Field
17
+
18
+ app = FastAPI(title="MemTether API", description="Cross-client AI memory hub", version="0.1.0a19")
19
+
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+
22
+ # P2 (2026-10-05): rate limiting — 60 req/min default (via slowapi if available)
23
+ class _NoopLimiter:
24
+ def limit(self, *a, **k):
25
+ def deco(f):
26
+ return f
27
+ return deco
28
+
29
+ try:
30
+ from slowapi import Limiter, _rate_limit_exceeded_handler
31
+ from slowapi.util import get_remote_address
32
+ _limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
33
+ app.state.limiter = _limiter
34
+ app.add_exception_handler(429, _rate_limit_exceeded_handler)
35
+ except ImportError:
36
+ _limiter = _NoopLimiter()
37
+ _API_KEY_ENV = os.environ.get("MEMTETHER_API_KEY", "").strip()
38
+ # P7 (2026-10-05): wildcard CORS is fine for localhost-only use, but a keyed
39
+ # deployment should not accept authenticated requests from any origin.
40
+ _ALLOW_ORIGINS = ["*"] if not _API_KEY_ENV else []
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=_ALLOW_ORIGINS,
44
+ allow_credentials=bool(_ALLOW_ORIGINS),
45
+ allow_methods=["*"],
46
+ allow_headers=["*"],
47
+ )
48
+
49
+ # P0-5 (2026-10-05): optional API key auth - enforced only when MEMTETHER_API_KEY is set.
50
+ # Docs previously claimed this existed; it did not. Now it does.
51
+ from fastapi import Request, Request as _Req
52
+ _API_KEY = os.environ.get("MEMTETHER_API_KEY", "").strip()
53
+
54
+ if _API_KEY:
55
+ from starlette.middleware.base import BaseHTTPMiddleware
56
+ class _ApiKeyMiddleware(BaseHTTPMiddleware):
57
+ async def dispatch(self, request, call_next):
58
+ if request.url.path in ('/health', '/', '/dashboard'):
59
+ return await call_next(request)
60
+ if request.headers.get('authorization', '') != 'Bearer ' + _API_KEY:
61
+ from fastapi.responses import JSONResponse
62
+ return JSONResponse({'ok': False, 'error': 'unauthorized'}, status_code=401)
63
+ return await call_next(request)
64
+ app.add_middleware(_ApiKeyMiddleware)
65
+
66
+
67
+ class RememberRequest(BaseModel):
68
+ content: str = Field(..., min_length=1)
69
+ type: str = Field(default="fact")
70
+ source: str = Field(..., min_length=1)
71
+ tags: str = Field(default="")
72
+ confidence: float = Field(default=0.8, ge=0.0, le=1.0)
73
+
74
+ class SearchRequest(BaseModel):
75
+ query: str = Field(..., min_length=1)
76
+ limit: int = Field(default=10, ge=1, le=100)
77
+
78
+ class CorrectRequest(BaseModel):
79
+ old_uid: str
80
+ new_content: str = Field(..., min_length=1)
81
+ reason: str = Field(default="")
82
+ source: str
83
+
84
+ class RetireRequest(BaseModel):
85
+ uid: str
86
+ reason: str = Field(default="")
87
+ source: str
88
+
89
+ class AbsorbRequest(BaseModel):
90
+ content: str = Field(..., min_length=1)
91
+ source: str = Field(..., min_length=1)
92
+ type: str = Field(default="fact")
93
+ dry_run: bool = Field(default=True)
94
+
95
+ class QValueRequest(BaseModel):
96
+ uid: str
97
+ reward: float = Field(default=1.0, ge=0.0, le=1.0)
98
+ source: str
99
+
100
+
101
+
102
+ from fastapi.responses import HTMLResponse
103
+ import os
104
+
105
+
106
+
107
+ @app.get("/", response_class=HTMLResponse)
108
+ def root():
109
+ """Redirect to dashboard."""
110
+ from fastapi.responses import RedirectResponse
111
+ return RedirectResponse(url="/dashboard")
112
+
113
+ @app.get("/court/verify")
114
+ def court_verify():
115
+ """Full hash-chain recompute. Returns PASS/FAIL + details."""
116
+ import memory_court as mc
117
+ import sqlite3
118
+ db_path = os.environ.get("MEM_DB", "memory.db")
119
+ conn = sqlite3.connect(db_path)
120
+ try:
121
+ mc.maybe_anchor(conn, force=True)
122
+ ok, detail = mc.verify_chain(conn)
123
+ return {"verified": ok, **detail}
124
+ finally:
125
+ conn.close()
126
+
127
+
128
+ @app.get("/court/{uid}")
129
+ def court_case(uid: str):
130
+ """Memory Court: evidence dossier for one memory (machine-readable)."""
131
+ import sqlite3
132
+ db_path = os.environ.get("MEM_DB", "memory.db")
133
+ conn = sqlite3.connect(db_path)
134
+ conn.row_factory = sqlite3.Row
135
+ try:
136
+ fact = conn.execute("SELECT * FROM facts WHERE uid=?", (uid,)).fetchone()
137
+ if fact is None:
138
+ raise HTTPException(status_code=404, detail=f"no such memory: {uid}")
139
+ logs = conn.execute(
140
+ "SELECT id, op, target, agent, detail, ts FROM audit_log "
141
+ "WHERE target=? ORDER BY id", (uid,)).fetchall()
142
+ crs = conn.execute(
143
+ "SELECT id, uid_a, uid_b, verdict, by_agent, ts FROM conflict_reviews "
144
+ "WHERE uid_a=? OR uid_b=?", (uid, uid)).fetchall()
145
+ import memory_court as mc
146
+ mc.maybe_anchor(conn, force=True)
147
+ ok, detail = mc.verify_chain(conn)
148
+ return {
149
+ "uid": uid,
150
+ "content": fact["content"],
151
+ "status": fact["status"],
152
+ "confidence": fact["confidence"],
153
+ "q_value": fact["q_value"],
154
+ "use_count": fact["use_count"],
155
+ "valid_from": fact["valid_from"], "valid_to": fact["valid_to"],
156
+ "recorded_at": fact["recorded_at"], "invalidated_at": fact["invalidated_at"],
157
+ "source": fact["source"],
158
+ "provenance": [dict(zip(("id","op","target","agent","detail","ts"), r)) for r in logs],
159
+ "conflict_adjudications": [dict(zip(("id","uid_a","uid_b","verdict","by_agent","ts"), r)) for r in crs],
160
+ "chain_verified": ok,
161
+ "chain_detail": detail,
162
+ }
163
+ finally:
164
+ conn.close()
165
+
166
+
167
+ @app.get("/dashboard", response_class=HTMLResponse)
168
+ def dashboard():
169
+ """Serve the web dashboard."""
170
+ dashboard_path = os.path.join(os.path.dirname(__file__), "dashboard.html")
171
+ if os.path.exists(dashboard_path):
172
+ with open(dashboard_path, encoding="utf-8") as f:
173
+ return f.read()
174
+ # P0-fix (a41): pip install ships dashboard.html in the memtether_assets
175
+ # package (package-data) — the old inline package-data on "*" never applied
176
+ # to py-modules, so pip users always saw "Dashboard not found".
177
+ try:
178
+ from importlib.resources import files as _resfiles
179
+ return _resfiles("memtether_assets").joinpath("dashboard.html").read_text(encoding="utf-8")
180
+ except Exception:
181
+ pass
182
+ return "<h1>Dashboard not found</h1><p>Make sure dashboard.html is in the same directory as api_server.py</p>"
183
+
184
+ @app.get("/viewer", response_class=HTMLResponse)
185
+ def viewer():
186
+ """Governance viewer (Wave 2 B2): memories / conflicts / court / stats."""
187
+ vp = os.path.join(os.path.dirname(__file__), "viewer.html")
188
+ if os.path.exists(vp):
189
+ with open(vp, encoding="utf-8") as f:
190
+ return f.read()
191
+ try:
192
+ from importlib.resources import files as _resfiles
193
+ return _resfiles("memtether_assets").joinpath("viewer.html").read_text(encoding="utf-8")
194
+ except Exception:
195
+ pass
196
+ return "<h1>Viewer not found</h1><p>viewer.html missing next to api_server.py</p>"
197
+
198
+ @app.get("/health")
199
+ def health():
200
+ from importlib.metadata import version as _pkgver
201
+ try:
202
+ _v = _pkgver("memtether")
203
+ except Exception:
204
+ _v = "unknown"
205
+ return {"ok": True, "version": _v}
206
+
207
+ @app.post("/remember")
208
+ @_limiter.limit("60/minute")
209
+ async def remember(request: Request, req: RememberRequest):
210
+ # a46: asyncio.to_thread (Py3.9+) uses a dedicated pool instead of the
211
+ # default executor shared with Starlette internals — no event-loop blocking,
212
+ # no executor starvation under concurrent writes.
213
+ import asyncio
214
+ return await asyncio.to_thread(_remember_sync, req)
215
+
216
+
217
+ def _remember_sync(req):
218
+ try:
219
+ return gateway.remember(content=req.content, type=req.type, source=req.source, tags=req.tags, confidence=req.confidence)
220
+ except ValueError as e:
221
+ # a50: oversized/invalid content must be 4xx, not an unhandled 500
222
+ from fastapi import HTTPException
223
+ raise HTTPException(status_code=422, detail=str(e)[:200])
224
+
225
+ @app.post("/search")
226
+ @_limiter.limit("60/minute")
227
+ async def search(request: Request, req: SearchRequest):
228
+ """a46: runs gateway.search via asyncio.to_thread (dedicated thread pool)
229
+ so the event loop is not blocked by FTS5/vector computation. Compatible
230
+ with async agent frameworks (LangGraph, Flowise, n8n).
231
+ Note: this is I/O offload, not a native-async rewrite of the engine."""
232
+ import asyncio
233
+ result = await asyncio.to_thread(_search_sync, req.query, req.limit)
234
+ return result
235
+
236
+
237
+ def _search_sync(query, limit):
238
+ try:
239
+ result = gateway.search(query, limit)
240
+ # A1: Auto-increment use_count for returned facts (passive tracking)
241
+ if isinstance(result, dict) and "results" in result:
242
+ import sqlite3
243
+ db_path = os.environ.get("MEM_DB", "memory.db")
244
+ try:
245
+ conn = sqlite3.connect(db_path)
246
+ for r in result["results"]:
247
+ uid = r.get("uid", "")
248
+ if uid:
249
+ conn.execute("UPDATE facts SET use_count = use_count + 1 WHERE uid = ?", (uid,))
250
+ conn.commit()
251
+ conn.close()
252
+ except Exception:
253
+ pass # don't fail the search if bump fails
254
+ return result
255
+ except Exception as e:
256
+ raise HTTPException(status_code=500, detail=str(e)[:300])
257
+
258
+ @app.get("/stats")
259
+ def stats():
260
+ return gateway.stats()
261
+
262
+ @app.post("/correct")
263
+ @_limiter.limit("60/minute")
264
+ def correct(request: Request, req: CorrectRequest):
265
+ return gateway.correct(req.old_uid, req.new_content, req.reason, by_agent=req.source)
266
+
267
+ @app.post("/retire")
268
+ @_limiter.limit("60/minute")
269
+ def retire(request: Request, req: RetireRequest):
270
+ return gateway.retire(req.uid, req.reason, by_agent=req.source)
271
+
272
+ @app.get("/list")
273
+ def list_memories(limit: int = 20):
274
+ gateway.init_db()
275
+ conn = gateway.get_conn()
276
+ try:
277
+ rows = conn.execute("SELECT uid, type, content, source, updated_at FROM facts WHERE status='active' AND scope NOT IN ('private','restricted') ORDER BY updated_at DESC LIMIT ?", (limit,)).fetchall()
278
+ return {"ok": True, "count": len(rows), "items": [dict(r) for r in rows]}
279
+ finally:
280
+ conn.close()
281
+
282
+ @app.get("/timeline/{uid}")
283
+ def timeline_ep(uid: str):
284
+ """Supersession chain for a fact: forward evolution (who replaced it)."""
285
+ try:
286
+ return {"ok": True, "uid": uid, "chain": gateway.timeline(uid)}
287
+ except Exception as e:
288
+ raise HTTPException(status_code=500, detail=str(e)[:300])
289
+
290
+
291
+
292
+ # P2-5 (2026-10-05): reuse governance polarity lexicons for absorb.
293
+ try:
294
+ import governance as _gov
295
+ _NEG_LEX = _gov._NEG
296
+ _POS_LEX = _gov._POS
297
+ except Exception: # governance optional at API layer
298
+ _NEG_LEX, _POS_LEX = (), ()
299
+
300
+
301
+ def _polarity(text):
302
+ t = (text or '').lower()
303
+ n = sum(1 for w in _NEG_LEX if w.lower() in t)
304
+ p = sum(1 for w in _POS_LEX if w.lower() in t)
305
+ return p - n # <0 means negative-dominated
306
+
307
+ @app.post("/absorb")
308
+ def absorb(req: AbsorbRequest):
309
+ """Semantic absorb (P0, a46): classify incoming fact against existing memories
310
+ using local embeddings (embed_local, bge-m3-int8). Falls back to keyword
311
+ overlap when the model is unavailable (honest degradation, same as memsearch).
312
+
313
+ Returns classification per candidate (duplicate / contradiction / related / new)
314
+ and optionally writes if dry_run=False. Uses gateway.remember + conflict detection.
315
+ """
316
+ import sqlite3
317
+ db_path = os.environ.get("MEM_DB", "memory.db")
318
+ conn = sqlite3.connect(db_path)
319
+ conn.row_factory = sqlite3.Row
320
+ try:
321
+ rows = conn.execute(
322
+ "SELECT uid, content, type FROM facts WHERE status='active' AND scope NOT IN ('private','restricted') ORDER BY updated_at DESC LIMIT 200"
323
+ ).fetchall()
324
+
325
+ # --- semantic path (preferred) ---
326
+ method = "keyword"
327
+ incoming_vec = None
328
+ row_texts = [(r["uid"], r["content"] or "", r["type"]) for r in rows]
329
+ try:
330
+ import embed_local
331
+ if embed_local.available() and row_texts:
332
+ incoming_vec = embed_local.encode([req.content])[0]
333
+ row_vecs = embed_local.encode([t for _, t, _ in row_texts])
334
+ method = "semantic"
335
+ else:
336
+ row_vecs = None
337
+ except Exception:
338
+ row_vecs = None # fall through to keyword
339
+
340
+ candidates = []
341
+ if method == "semantic" and row_vecs is not None:
342
+ import math
343
+ def _cos(a, b):
344
+ dot = sum(x * y for x, y in zip(a, b))
345
+ na = math.sqrt(sum(x * x for x in a))
346
+ nb = math.sqrt(sum(x * x for x in b))
347
+ return dot / (na * nb) if na and nb else 0.0
348
+ for (uid, text, typ), vec in zip(row_texts, row_vecs):
349
+ sim = _cos(incoming_vec, vec)
350
+ if sim > 0.50:
351
+ candidates.append({"uid": uid, "content": text[:120],
352
+ "type": typ, "similarity": round(sim, 3)})
353
+ candidates.sort(key=lambda x: x["similarity"], reverse=True)
354
+ else:
355
+ # keyword fallback (L0, no LLM) — same logic as pre-a46
356
+ words = set(req.content.lower().split())
357
+ for uid, text, typ in row_texts:
358
+ rwords = set(text.lower().split())
359
+ overlap = len(words & rwords) / max(len(words | rwords), 1)
360
+ if overlap > 0.15:
361
+ candidates.append({"uid": uid, "content": text[:120],
362
+ "type": typ, "overlap": round(overlap, 3)})
363
+ candidates.sort(key=lambda x: x["overlap"], reverse=True)
364
+
365
+ # classify best candidate
366
+ score_key = "similarity" if method == "semantic" else "overlap"
367
+ classification = "new"
368
+ if candidates:
369
+ top = candidates[0][score_key]
370
+ if method == "semantic":
371
+ # thresholds calibrated on 12 EN + 68 ZH probe pairs (a50,
372
+ # LLM-generated via scripts/zh_calibration.py): same-topic
373
+ # 0.77-0.98, unrelated 0.53-0.83. dup=0.92 is conservative
374
+ # (a misjudged duplicate degrades to update = still written);
375
+ # related=0.84 excludes measured unrelated pairs (max 0.827).
376
+ # Digit guard above duplicate handles detail changes.
377
+ import re as _re
378
+ def _nums(t):
379
+ return _re.findall(r"\d+", t or "")
380
+ if top >= 0.92:
381
+ if _nums(req.content) != _nums(candidates[0]["content"]):
382
+ # different digits = conflicting fact (port 7890 vs 7897),
383
+ # never a duplicate -> update/contradiction
384
+ neg_in = _polarity(req.content)
385
+ neg_ex = _polarity(candidates[0]["content"])
386
+ classification = ("contradiction"
387
+ if ((neg_in < 0) != (neg_ex < 0)) else "update")
388
+ else:
389
+ classification = "duplicate"
390
+ elif top >= 0.75:
391
+ neg_in = _polarity(req.content)
392
+ neg_ex = _polarity(candidates[0]["content"])
393
+ classification = "contradiction" if ((neg_in < 0) != (neg_ex < 0)) else "update"
394
+ elif top >= 0.84:
395
+ classification = "related"
396
+ else:
397
+ if top > 0.7:
398
+ classification = "duplicate"
399
+ elif top > 0.4:
400
+ neg_in = _polarity(req.content)
401
+ neg_ex = _polarity(candidates[0]["content"])
402
+ classification = "contradiction" if ((neg_in < 0) != (neg_ex < 0)) else "update"
403
+ elif top > 0.15:
404
+ classification = "related"
405
+
406
+ result = {
407
+ "ok": True,
408
+ "classification": classification,
409
+ "method": method,
410
+ "candidates": candidates[:5],
411
+ "dry_run": req.dry_run,
412
+ }
413
+ if not req.dry_run and classification in ("new", "update"):
414
+ wr = gateway.remember(content=req.content, type=req.type, source=req.source)
415
+ result["write"] = wr
416
+ elif not req.dry_run and classification == "duplicate":
417
+ result["write"] = {"skipped": True, "reason": "duplicate of existing fact"}
418
+ return result
419
+ except Exception as e:
420
+ raise HTTPException(status_code=500, detail=str(e)[:300])
421
+ finally:
422
+ conn.close()
423
+ @app.post("/qvalue")
424
+ def qvalue(req: QValueRequest):
425
+ return gateway.bump_qvalue(req.uid, req.reward, req.source)
426
+
427
+ if __name__ == "__main__":
428
+ import argparse, uvicorn
429
+ p = argparse.ArgumentParser()
430
+ p.add_argument("--host", default="0.0.0.0")
431
+ p.add_argument("--port", type=int, default=8080)
432
+ a = p.parse_args()
433
+ uvicorn.run(app, host=a.host, port=a.port)
cli_out.py ADDED
@@ -0,0 +1,52 @@
1
+ # -*- coding: utf-8 -*-
2
+ """cli_out.py — Windows 下安全输出到 stdout 的共用助手
3
+
4
+ 为什么需要它(2026-10-21 实测,本机 4 个脚本真实崩溃):
5
+ Windows 默认 locale 是 cp936/gbk,print() 遇到 ✓ ✗ 🦞 ✅ ⇒ 等字符
6
+ 直接 UnicodeEncodeError 崩溃,stdout 全空。已中招的四个:
7
+ memory_hub/bootstrap.py (L102, 🦞)
8
+ memory_hub/smoke_bitemporal.py (L68, ✓/✗)
9
+ memtether/scripts/scan_leaks.py (L222, ✗)
10
+ memtether/scripts/scan_history_leaks.py (L171, ✗)
11
+ 后两个尤其危险:它们是**发布前安全扫描器**,崩在"无法判断"分支上,
12
+ 等于把本该报出的 "未参与扫描" 告警吞掉了 —— 典型的静默失败。
13
+
14
+ 用法:
15
+ from cli_out import emit
16
+ emit("...")
17
+ 或保留 print 调用形式:
18
+ from cli_out import print
19
+ """
20
+ import sys
21
+
22
+
23
+ def emit(text=''):
24
+ """把文本以 UTF-8 写到 stdout,绕开 Windows locale 编码坑。
25
+
26
+ - 走 sys.stdout.buffer 直接写字节,不经 text layer
27
+ - buffer 不可用(被替换/重定向)时退化为 replace 编码的文本写
28
+ - 全部异常吞掉:输出失败绝不该让业务逻辑崩掉
29
+ - 无参调用合法(等价于裸 print() 输出一个换行)
30
+ """
31
+ if not isinstance(text, str):
32
+ text = str(text)
33
+ data = text.encode('utf-8', 'replace')
34
+ try:
35
+ buf = getattr(sys.stdout, 'buffer', None)
36
+ if buf is not None:
37
+ buf.write(data)
38
+ buf.flush()
39
+ return
40
+ except Exception:
41
+ pass
42
+ try:
43
+ sys.stdout.write(data.decode('utf-8', 'replace'))
44
+ except Exception:
45
+ pass
46
+
47
+
48
+ def print(*args, **kwargs): # noqa: A001
49
+ """drop-in 替换内置 print,UTF-8 安全。"""
50
+ sep = kwargs.get('sep', ' ')
51
+ end = kwargs.get('end', '\n')
52
+ emit(sep.join(str(a) for a in args) + end)
clients/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ # -*- coding: utf-8 -*-
2
+ """clients — 各 AI 客户端的 MCP 接入适配器
3
+
4
+ 分层
5
+ ----
6
+ base.py 适配器基类 + 原子写 / 备份 / 幂等 / 失败关闭
7
+ jsonc.py JSONC 感知的「外科式」配置编辑器(保住注释与格式)
8
+ trust.py Electron 系客户端的 MCP 信任免 GUI 代写
9
+ standard.py 通用客户端(Claude / Cursor / VS Code / Zed / Cline … 共 17 家)
10
+ local.py 本机这一套(WorkBuddy 双版本 / CodeBuddy / ZCode / dsh / OpenClaw / 中立真源)
11
+
12
+ 用法
13
+ ----
14
+ from clients import all_adapters, find
15
+ for a in all_adapters():
16
+ for scope, path in a.candidates():
17
+ installed, reason = a.probe(scope, path)
18
+ ...
19
+ """
20
+ from .base import Adapter, ServerSpec, Target, Change, BACKUP_ROOT, atomic_write, backup # noqa
21
+ from . import jsonc, trust # noqa
22
+ from . import standard, local # noqa
23
+
24
+ __all__ = ["Adapter", "ServerSpec", "Target", "Change", "all_adapters", "find",
25
+ "jsonc", "trust", "BACKUP_ROOT"]
26
+
27
+
28
+ def all_adapters():
29
+ """全部适配器(通用在前,本机专属在后)。"""
30
+ return list(standard.STANDARD) + list(local.LOCAL)
31
+
32
+
33
+ def find(client_id: str):
34
+ """按 id 找适配器;支持模糊(唯一前缀/子串匹配)。"""
35
+ ads = all_adapters()
36
+ for a in ads:
37
+ if a.id == client_id:
38
+ return a
39
+ hit = [a for a in ads if client_id and client_id in a.id]
40
+ if len(hit) == 1:
41
+ return hit[0]
42
+ return None
43
+
44
+
45
+ def ids():
46
+ return [a.id for a in all_adapters()]