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.
- api_server.py +433 -0
- cli_out.py +52 -0
- clients/__init__.py +46 -0
- clients/base.py +520 -0
- clients/jsonc.py +435 -0
- clients/local.py +403 -0
- clients/standard.py +551 -0
- clients/trust.py +162 -0
- consolidation.py +100 -0
- embed_local.py +293 -0
- extract.py +186 -0
- gateway.py +2657 -0
- governance.py +993 -0
- hubguard.py +984 -0
- integrations/__init__.py +0 -0
- integrations/auto_onboard.py +497 -0
- integrations/autogen_memory.py +41 -0
- integrations/langchain_memory.py +152 -0
- make_jury.py +79 -0
- mcp_server.py +1016 -0
- mem0_exchange.py +197 -0
- memory_court.py +211 -0
- memsearch.py +2268 -0
- memtether-0.1.0.dist-info/METADATA +817 -0
- memtether-0.1.0.dist-info/RECORD +69 -0
- memtether-0.1.0.dist-info/WHEEL +5 -0
- memtether-0.1.0.dist-info/entry_points.txt +3 -0
- memtether-0.1.0.dist-info/licenses/LICENSE +202 -0
- memtether-0.1.0.dist-info/licenses/NOTICE +96 -0
- memtether-0.1.0.dist-info/top_level.txt +28 -0
- memtether.py +1521 -0
- memtether_assets/__init__.py +2 -0
- memtether_assets/dashboard.html +303 -0
- memtether_assets/viewer.html +207 -0
- memtether_exchange.py +584 -0
- memtether_paths.py +103 -0
- memtether_pipeline.py +140 -0
- predicates.py +312 -0
- rerank.py +113 -0
- scripts/__init__.py +21 -0
- scripts/bench_memtether_adapter.py +137 -0
- scripts/check_packaging.py +299 -0
- scripts/cli_out.py +52 -0
- scripts/demo_exchange_5adapters.py +174 -0
- scripts/demo_exchange_conflict.py +155 -0
- scripts/demo_export_roundtrip.py +103 -0
- scripts/demo_federation.py +91 -0
- scripts/demo_gateway.py +52 -0
- scripts/download_models.py +165 -0
- scripts/make_demo_db.py +445 -0
- scripts/make_jury_report.py +104 -0
- scripts/memtether_export.py +172 -0
- scripts/memtether_guard.py +77 -0
- scripts/scan_docs.py +152 -0
- scripts/scan_history_leaks.py +211 -0
- scripts/scan_leaks.py +323 -0
- scripts/selfcheck.py +1088 -0
- scripts/test_pii_roundtrip.py +70 -0
- scripts/update_bench_page.py +76 -0
- scripts/watch.py +165 -0
- scripts/zh_calibration.py +113 -0
- skills/__init__.py +0 -0
- skills/agent-memory/SKILL.md +58 -0
- skills/agent-memory-court/SKILL.md +61 -0
- skills/agent-memory-recall/SKILL.md +55 -0
- tether_connect.py +740 -0
- tool_audit.py +186 -0
- trigger_detector.py +201 -0
- 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()]
|