mnemonic-proxy 0.3.2__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.
- ctxproxy/__init__.py +8 -0
- ctxproxy/api_anthropic.py +426 -0
- ctxproxy/api_responses.py +436 -0
- ctxproxy/autosave.py +350 -0
- ctxproxy/check.py +356 -0
- ctxproxy/compat.py +91 -0
- ctxproxy/core.py +2218 -0
- ctxproxy/demo.py +193 -0
- ctxproxy/engines.py +314 -0
- ctxproxy/fake_engine.py +304 -0
- ctxproxy/kvarchive.py +855 -0
- ctxproxy/lang.py +57 -0
- ctxproxy/mcp_server.py +381 -0
- ctxproxy/packtok.py +29 -0
- ctxproxy/paging.py +508 -0
- ctxproxy/recall2.py +1003 -0
- ctxproxy/render.py +274 -0
- ctxproxy/server.py +797 -0
- ctxproxy/tooldefs.py +111 -0
- ctxproxy/upstream.py +106 -0
- mnemonic_proxy-0.3.2.dist-info/METADATA +841 -0
- mnemonic_proxy-0.3.2.dist-info/RECORD +26 -0
- mnemonic_proxy-0.3.2.dist-info/WHEEL +5 -0
- mnemonic_proxy-0.3.2.dist-info/entry_points.txt +4 -0
- mnemonic_proxy-0.3.2.dist-info/licenses/LICENSE +21 -0
- mnemonic_proxy-0.3.2.dist-info/top_level.txt +1 -0
ctxproxy/lang.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""Language of the texts the proxy writes into the model's prompt (0.3.1).
|
|
3
|
+
|
|
4
|
+
New conversations use `Config.prompt_language` (default "en"). The choice is stored per conversation on first use
|
|
5
|
+
(kv `prompt_language:<conv>`), so the physical prompt stays identical across requests and restarts. A conversation
|
|
6
|
+
that already has data in the archive but no stored language was born before 0.3.1 and keeps Italian: its stable
|
|
7
|
+
prefix (archive, session files, KV cache) stays valid byte for byte.
|
|
8
|
+
|
|
9
|
+
Recognition of the proxy's own markers (placeholders, receipts, guard, notes request...) accepts BOTH languages in
|
|
10
|
+
every conversation: an Italian marker inside an English conversation (or the reverse) is still recognized.
|
|
11
|
+
|
|
12
|
+
The Italian texts are kept verbatim from 0.3.0; do not edit them (old prefixes depend on them).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
LANGS = ("en", "it")
|
|
17
|
+
LANG_KEY = "prompt_language:" # kv: language chosen for a conversation ("en" | "it")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def norm(lang) -> str:
|
|
21
|
+
"""'it' -> 'it'; anything else -> 'en'."""
|
|
22
|
+
return "it" if str(lang or "").strip().lower() == "it" else "en"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pick(lang: str, it: str, en: str) -> str:
|
|
26
|
+
return it if lang == "it" else en
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Image note (api_anthropic / api_responses). The API layer writes the Italian one (0.3.0 text) because it runs
|
|
30
|
+
# BEFORE the conversation is known and the note is part of the hashed history (chain): changing it would make an
|
|
31
|
+
# existing conversation unrecognizable. Manager.build() swaps it for the English one in conversations with lang "en".
|
|
32
|
+
IMAGE_NOTE_IT = "[immagine omessa: il proxy non inoltra immagini al motore]"
|
|
33
|
+
IMAGE_NOTE_EN = "[image omitted: the proxy does not forward images to the engine]"
|
|
34
|
+
# Same reasoning for the description of the `input` parameter of Responses custom tools (it is in the hashed tool
|
|
35
|
+
# list): the API layer keeps the 0.3.0 text, Manager.prepare() swaps it in the physical tools of "en" conversations.
|
|
36
|
+
CUSTOM_INPUT_DESC_IT = "testo libero passato allo strumento"
|
|
37
|
+
CUSTOM_INPUT_DESC_EN = "free text passed to the tool"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# outcome() values are internal and stored in the archive (fileops.outcome): they stay Italian; only what the
|
|
41
|
+
# model reads is translated.
|
|
42
|
+
OUTCOME_EN = {"riuscito": "succeeded", "fallito": "failed", "non verificato": "not verified"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def outcome_text(o: str, lang: str) -> str:
|
|
46
|
+
return o if lang == "it" else OUTCOME_EN.get(o, o)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# role names used in recall headers
|
|
50
|
+
ROLE_IT = {"tool": "uscita di strumento", "assistant-reasoning": "ragionamento", "assistant-tool-args":
|
|
51
|
+
"argomenti di chiamata", "user": "utente", "assistant": "risposta", "system": "sistema"}
|
|
52
|
+
ROLE_EN = {"tool": "tool output", "assistant-reasoning": "reasoning", "assistant-tool-args": "call arguments",
|
|
53
|
+
"user": "user", "assistant": "answer", "system": "system"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def role_text(role: str, lang: str) -> str:
|
|
57
|
+
return (ROLE_IT if lang == "it" else ROLE_EN).get(role, role)
|
ctxproxy/mcp_server.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""Server MCP (Model Context Protocol) in sola lettura sull'archivio del proxy.
|
|
3
|
+
|
|
4
|
+
python3 -m ctxproxy.mcp_server --db data/archive.sqlite # stdio (Claude Code, Codex, Hermes)
|
|
5
|
+
python3 -m ctxproxy.mcp_server --db data/archive.sqlite --http 8133 # HTTP "streamable" su /mcp
|
|
6
|
+
|
|
7
|
+
Strumenti:
|
|
8
|
+
recall come lo strumento recall del proxy con recall_struct/recall_multi/recall_flex attivi (recall2.py): id,
|
|
9
|
+
query, queries, path, mode first/timeline/neighbors/output/reasoning/tree, filtri role/seg/from/to;
|
|
10
|
+
`conversation` facoltativo (di serie la conversazione più recente; con `id` quella del pezzo)
|
|
11
|
+
conversations elenco delle conversazioni archiviate (messaggi, token, segmenti, primo messaggio dell'utente)
|
|
12
|
+
journal eventi recenti del giornale del proxy (journal.jsonl accanto al database), filtrabili
|
|
13
|
+
|
|
14
|
+
Il database si apre in SOLA LETTURA (sqlite URI mode=ro): il proxy può continuare a scriverci. L'indice dei passaggi
|
|
15
|
+
di recall2 vive in tabelle TEMP di questa connessione (costruito alla prima ricerca su ogni conversazione), quindi
|
|
16
|
+
nessuna scrittura sul file. Protocollo: JSON-RPC 2.0, un messaggio per riga su stdio (initialize, tools/list,
|
|
17
|
+
tools/call, ping). Solo libreria standard.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sqlite3
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
28
|
+
|
|
29
|
+
from . import compat
|
|
30
|
+
from .core import Config, Journal, Manager, Store, TokenCounter
|
|
31
|
+
from .recall2 import Recall2
|
|
32
|
+
|
|
33
|
+
PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
34
|
+
SERVER_INFO = {"name": "mnemonic-archive", "version": "0.2.0"}
|
|
35
|
+
TEMP_SCHEMA = """
|
|
36
|
+
CREATE TEMP TABLE IF NOT EXISTS passages(pid INTEGER PRIMARY KEY, rid TEXT, conv TEXT, idx INT, role TEXT, off INT,
|
|
37
|
+
len INT);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS temp.passages_rid ON passages(rid);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS temp.passages_conv ON passages(conv, idx);
|
|
40
|
+
CREATE TEMP TABLE IF NOT EXISTS passages_done(rid TEXT, kind TEXT, PRIMARY KEY(rid, kind));
|
|
41
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS temp.p_raw USING fts5(t, content='', tokenize='unicode61 remove_diacritics 2');
|
|
42
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS temp.p_norm USING fts5(t, content='', tokenize='unicode61 remove_diacritics 2');
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
RECALL_DESC = (
|
|
46
|
+
"Search the EXACT archive of past conversations kept by the context proxy (tool outputs, messages, reasoning, "
|
|
47
|
+
"tool-call arguments). Use `query` (or `queries` for several variants at once) to search words, paths, function "
|
|
48
|
+
"names, errors; `id` for the full text of a piece (with `offset` to continue); `path` for the history of a file "
|
|
49
|
+
"(mode timeline, or first = first write); with `id`, mode neighbors/output/reasoning for nearby messages, the "
|
|
50
|
+
"output of a call or the reasoning of the same turn; mode=tree for a map of the session. 'Strong' results = all "
|
|
51
|
+
"words in the same passage. Read-only. Result headers and notes are in Italian.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def recall_schema() -> dict:
|
|
55
|
+
s = {"type": "string"}
|
|
56
|
+
i = {"type": "integer"}
|
|
57
|
+
return {"type": "object", "properties": {
|
|
58
|
+
"conversation": {**s, "description": "conversation id (see conversations); default: the most recent"},
|
|
59
|
+
"id": {**s, "description": "id of an archived piece, e.g. r1a2b3c4d5e6"},
|
|
60
|
+
"query": {**s, "description": "text to search"},
|
|
61
|
+
"queries": {"type": "array", "items": s, "description": "several search variants in one call"},
|
|
62
|
+
"path": {**s, "description": "file path or name: history of operations"},
|
|
63
|
+
"mode": {"type": "string", "enum": ["timeline", "first", "neighbors", "output", "reasoning", "tree"]},
|
|
64
|
+
"order": {"type": "string", "enum": ["pertinenza", "tempo"]},
|
|
65
|
+
"role": {**s, "description": "filter: user, assistant, reasoning, args, tool"},
|
|
66
|
+
"seg": {**i, "description": "filter: segment"},
|
|
67
|
+
"from": {**i, "description": "filter: from message number"},
|
|
68
|
+
"to": {**i, "description": "filter: up to message number"},
|
|
69
|
+
"n": {**i, "description": "with mode=neighbors: messages before and after"},
|
|
70
|
+
"offset": {**i, "description": "with id: character offset to continue from"},
|
|
71
|
+
}}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
TOOLS = [
|
|
75
|
+
{"name": "recall", "description": RECALL_DESC, "inputSchema": recall_schema(),
|
|
76
|
+
"annotations": {"readOnlyHint": True}},
|
|
77
|
+
{"name": "conversations", "description": "Conversations in the proxy's archive (most recent first): id, "
|
|
78
|
+
"messages, tokens, segments, date, start of the user's first "
|
|
79
|
+
"message. Read-only.",
|
|
80
|
+
"inputSchema": {"type": "object", "properties": {
|
|
81
|
+
"limit": {"type": "integer", "description": "how many (default 20)"},
|
|
82
|
+
"query": {"type": "string", "description": "filter: text contained in the user's first message or in "
|
|
83
|
+
"the id"}}},
|
|
84
|
+
"annotations": {"readOnlyHint": True}},
|
|
85
|
+
{"name": "journal", "description": "Recent events of the proxy journal (requests, recall, masking, segments, "
|
|
86
|
+
"saves...), most recent last. Read-only.",
|
|
87
|
+
"inputSchema": {"type": "object", "properties": {
|
|
88
|
+
"limit": {"type": "integer", "description": "how many events (default 30, max 500)"},
|
|
89
|
+
"event": {"type": "string", "description": "filter by type (e.g. recall, request, autosave); several "
|
|
90
|
+
"types separated by commas"},
|
|
91
|
+
"conversation": {"type": "string", "description": "filter by conversation"}}},
|
|
92
|
+
"annotations": {"readOnlyHint": True}},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def open_readonly(path: str) -> Store:
|
|
97
|
+
"""Store sopra un database aperto in sola lettura (niente schema/migrazioni: nessuna scrittura)."""
|
|
98
|
+
if not os.path.exists(path):
|
|
99
|
+
raise FileNotFoundError(path)
|
|
100
|
+
uri = compat.sqlite_ro_uri(path)
|
|
101
|
+
st = Store.__new__(Store)
|
|
102
|
+
st.db = sqlite3.connect(uri, uri=True, check_same_thread=False, isolation_level=None)
|
|
103
|
+
st.lock = threading.RLock()
|
|
104
|
+
names = {r[0] for r in st.db.execute("SELECT name FROM sqlite_master")}
|
|
105
|
+
if "archive" not in names:
|
|
106
|
+
raise ValueError("%s is not a proxy archive (no archive table)" % path)
|
|
107
|
+
st.fts = "archive_fts" in names
|
|
108
|
+
for tbl, ddl in (("fileops", "CREATE TEMP TABLE fileops(conv TEXT, idx INT, call_id TEXT, op TEXT, path TEXT, "
|
|
109
|
+
"outcome TEXT, rid_args TEXT, rid_out TEXT, head TEXT)"),
|
|
110
|
+
("segments", "CREATE TEMP TABLE segments(conv TEXT, seg INT, cut_idx INT, cut_h TEXT, "
|
|
111
|
+
"notes_msg TEXT, kind TEXT, created REAL, pins_msg TEXT)"),
|
|
112
|
+
("masks", "CREATE TEMP TABLE masks(h TEXT, conv TEXT, idx INT, rid TEXT, tokens INT, "
|
|
113
|
+
"created REAL, saved INT)")):
|
|
114
|
+
if tbl not in names: # archivi di versioni vecchie
|
|
115
|
+
st.db.execute(ddl)
|
|
116
|
+
st.db.executescript(TEMP_SCHEMA)
|
|
117
|
+
return st
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Archive:
|
|
121
|
+
def __init__(self, db: str, journal: str | None = None, max_tokens: int = 6000, tokenizer: str | None = None):
|
|
122
|
+
self.path = db
|
|
123
|
+
self.store = open_readonly(db)
|
|
124
|
+
self.journal_path = journal if journal is not None else os.path.join(os.path.dirname(os.path.abspath(db)),
|
|
125
|
+
"journal.jsonl")
|
|
126
|
+
self.cfg = Config(recall_struct=True, recall_multi=True, recall_flex=True,
|
|
127
|
+
recall_struct_max_tokens=max_tokens, recall_max_tokens=max_tokens)
|
|
128
|
+
self.mgr = Manager(self.cfg, self.store, TokenCounter(self.cfg.chars_per_token, tokenizer), Journal(None))
|
|
129
|
+
self.r2 = Recall2.__new__(Recall2)
|
|
130
|
+
# come Recall2.__init__, ma l'indice dei passaggi è già nelle tabelle TEMP (niente P_SCHEMA sul file)
|
|
131
|
+
from .recall2 import PassageIndex, Structure
|
|
132
|
+
self.r2.mgr, self.r2.cfg, self.r2.st, self.r2.tc = self.mgr, self.cfg, self.store, self.mgr.tc
|
|
133
|
+
self.r2.pi = PassageIndex.__new__(PassageIndex)
|
|
134
|
+
self.r2.pi.st = self.store
|
|
135
|
+
self.r2.sx = Structure(self.store)
|
|
136
|
+
self.lock = threading.Lock()
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------- strumenti
|
|
139
|
+
def latest_conv(self) -> str | None:
|
|
140
|
+
with self.store.lock:
|
|
141
|
+
r = self.store.db.execute("SELECT conv FROM archive GROUP BY conv ORDER BY max(created) DESC LIMIT 1"
|
|
142
|
+
).fetchone()
|
|
143
|
+
return r[0] if r else None
|
|
144
|
+
|
|
145
|
+
def recall(self, args: dict) -> str:
|
|
146
|
+
args = dict(args or {})
|
|
147
|
+
conv = str(args.pop("conversation", "") or "").strip()
|
|
148
|
+
rid = str(args.get("id") or "").strip().removeprefix("recall:").removeprefix("id=")
|
|
149
|
+
if not conv and rid:
|
|
150
|
+
r = self.store.get(rid)
|
|
151
|
+
conv = r[1] if r else ""
|
|
152
|
+
conv = conv or self.latest_conv()
|
|
153
|
+
if not conv:
|
|
154
|
+
return "empty archive"
|
|
155
|
+
with self.lock:
|
|
156
|
+
out = self.r2.recall(conv, args, None, [])
|
|
157
|
+
return "[conversation %s]\n%s" % (conv, out)
|
|
158
|
+
|
|
159
|
+
def conversations(self, args: dict) -> str:
|
|
160
|
+
limit = max(1, min(int((args or {}).get("limit") or 20), 500))
|
|
161
|
+
q = str((args or {}).get("query") or "").strip().lower()
|
|
162
|
+
with self.store.lock:
|
|
163
|
+
rows = self.store.db.execute(
|
|
164
|
+
"SELECT conv, count(*), coalesce(sum(tokens), 0), min(created), max(created), "
|
|
165
|
+
"(SELECT content FROM archive b WHERE b.conv=a.conv AND b.role='user' ORDER BY idx LIMIT 1) "
|
|
166
|
+
"FROM archive a GROUP BY conv ORDER BY max(created) DESC").fetchall()
|
|
167
|
+
segs = dict(self.store.db.execute("SELECT conv, count(*) FROM segments GROUP BY conv").fetchall())
|
|
168
|
+
out = []
|
|
169
|
+
for conv, n, tok, t0, t1, first in rows:
|
|
170
|
+
first = " ".join((first or "").split())
|
|
171
|
+
if q and q not in conv.lower() and q not in first.lower():
|
|
172
|
+
continue
|
|
173
|
+
out.append({"conversation": conv, "messages": n, "tokens": tok, "segments": segs.get(conv, 0),
|
|
174
|
+
"first": _ts(t0), "last": _ts(t1), "first_user": first[:200]})
|
|
175
|
+
if len(out) >= limit:
|
|
176
|
+
break
|
|
177
|
+
return json.dumps({"total": len(rows), "conversations": out}, ensure_ascii=False, indent=1)
|
|
178
|
+
|
|
179
|
+
def journal(self, args: dict) -> str:
|
|
180
|
+
args = args or {}
|
|
181
|
+
limit = max(1, min(int(args.get("limit") or 30), 500))
|
|
182
|
+
kinds = {k.strip() for k in str(args.get("event") or "").split(",") if k.strip()}
|
|
183
|
+
conv = str(args.get("conversation") or "").strip()
|
|
184
|
+
if not os.path.exists(self.journal_path):
|
|
185
|
+
return "journal not found: %s" % self.journal_path
|
|
186
|
+
out = []
|
|
187
|
+
for line in _tail_lines(self.journal_path, max(limit * 50, 2000) if (kinds or conv) else limit):
|
|
188
|
+
try:
|
|
189
|
+
e = json.loads(line)
|
|
190
|
+
except ValueError:
|
|
191
|
+
continue
|
|
192
|
+
if kinds and e.get("event") not in kinds:
|
|
193
|
+
continue
|
|
194
|
+
if conv and e.get("conv") != conv:
|
|
195
|
+
continue
|
|
196
|
+
for k in ("messages", "tools"):
|
|
197
|
+
e.pop(k, None)
|
|
198
|
+
out.append(e)
|
|
199
|
+
out = out[-limit:]
|
|
200
|
+
return "\n".join(json.dumps(e, ensure_ascii=False)[:2000] for e in out) or "no events"
|
|
201
|
+
|
|
202
|
+
def call(self, name: str, args: dict) -> str:
|
|
203
|
+
if name == "recall":
|
|
204
|
+
return self.recall(args)
|
|
205
|
+
if name == "conversations":
|
|
206
|
+
return self.conversations(args)
|
|
207
|
+
if name == "journal":
|
|
208
|
+
return self.journal(args)
|
|
209
|
+
raise KeyError(name)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _ts(t):
|
|
213
|
+
import time
|
|
214
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(t)) if t else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _tail_lines(path: str, n: int) -> list[str]:
|
|
218
|
+
"""Ultime n righe di un file anche grande (lettura a blocchi dalla fine)."""
|
|
219
|
+
with open(path, "rb") as f:
|
|
220
|
+
f.seek(0, os.SEEK_END)
|
|
221
|
+
end = f.tell()
|
|
222
|
+
buf, pos = b"", end
|
|
223
|
+
while pos > 0 and buf.count(b"\n") <= n:
|
|
224
|
+
step = min(1 << 20, pos)
|
|
225
|
+
pos -= step
|
|
226
|
+
f.seek(pos)
|
|
227
|
+
buf = f.read(step) + buf
|
|
228
|
+
lines = buf.decode("utf-8", "replace").splitlines()
|
|
229
|
+
if pos > 0:
|
|
230
|
+
lines = lines[1:] # la prima può essere a metà
|
|
231
|
+
return lines[-n:]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# -------------------------------------------------------------------- JSON-RPC
|
|
235
|
+
class McpServer:
|
|
236
|
+
def __init__(self, archive: Archive):
|
|
237
|
+
self.arc = archive
|
|
238
|
+
|
|
239
|
+
def handle(self, msg: dict) -> dict | None:
|
|
240
|
+
"""Un messaggio JSON-RPC -> risposta (None per le notifiche)."""
|
|
241
|
+
mid = msg.get("id")
|
|
242
|
+
method = msg.get("method")
|
|
243
|
+
if method is None: # risposta del client a una nostra richiesta: non ne facciamo
|
|
244
|
+
return None
|
|
245
|
+
if mid is None: # notifica (notifications/initialized, cancelled...)
|
|
246
|
+
return None
|
|
247
|
+
params = msg.get("params") or {}
|
|
248
|
+
try:
|
|
249
|
+
if method == "initialize":
|
|
250
|
+
v = params.get("protocolVersion")
|
|
251
|
+
return _ok(mid, {"protocolVersion": v if v in PROTOCOLS else PROTOCOLS[0],
|
|
252
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
253
|
+
"serverInfo": SERVER_INFO,
|
|
254
|
+
"instructions": "Exact archive of the context proxy's conversations: use recall "
|
|
255
|
+
"to search, conversations for the list, journal for events. "
|
|
256
|
+
"Read-only."})
|
|
257
|
+
if method == "ping":
|
|
258
|
+
return _ok(mid, {})
|
|
259
|
+
if method == "tools/list":
|
|
260
|
+
return _ok(mid, {"tools": TOOLS})
|
|
261
|
+
if method == "tools/call":
|
|
262
|
+
name = params.get("name")
|
|
263
|
+
if name not in {t["name"] for t in TOOLS}:
|
|
264
|
+
return _err(mid, -32602, "unknown tool: %s" % name)
|
|
265
|
+
try:
|
|
266
|
+
text = self.arc.call(str(name), params.get("arguments") or {})
|
|
267
|
+
return _ok(mid, {"content": [{"type": "text", "text": text}], "isError": False})
|
|
268
|
+
except Exception as e: # noqa: BLE001 errore dello strumento: lo vede il modello
|
|
269
|
+
return _ok(mid, {"content": [{"type": "text", "text": "errore: %r" % e}], "isError": True})
|
|
270
|
+
if method in ("resources/list", "prompts/list"):
|
|
271
|
+
return _ok(mid, {method.split("/")[0]: []})
|
|
272
|
+
if method == "resources/templates/list":
|
|
273
|
+
return _ok(mid, {"resourceTemplates": []})
|
|
274
|
+
return _err(mid, -32601, "method not supported: %s" % method)
|
|
275
|
+
except Exception as e: # noqa: BLE001
|
|
276
|
+
return _err(mid, -32603, "internal error: %r" % e)
|
|
277
|
+
|
|
278
|
+
def handle_any(self, data):
|
|
279
|
+
"""Messaggio singolo o batch (lista)."""
|
|
280
|
+
if isinstance(data, list):
|
|
281
|
+
out = [r for r in (self.handle(m) for m in data if isinstance(m, dict)) if r is not None]
|
|
282
|
+
return out or None
|
|
283
|
+
if not isinstance(data, dict):
|
|
284
|
+
return _err(None, -32600, "invalid request")
|
|
285
|
+
return self.handle(data)
|
|
286
|
+
|
|
287
|
+
# ---------- stdio ----------
|
|
288
|
+
def serve_stdio(self, inp=None, out=None):
|
|
289
|
+
inp = inp or sys.stdin.buffer
|
|
290
|
+
out = out or sys.stdout.buffer
|
|
291
|
+
for line in inp:
|
|
292
|
+
line = line.strip()
|
|
293
|
+
if not line:
|
|
294
|
+
continue
|
|
295
|
+
try:
|
|
296
|
+
data = json.loads(line)
|
|
297
|
+
except ValueError:
|
|
298
|
+
resp = _err(None, -32700, "invalid JSON")
|
|
299
|
+
else:
|
|
300
|
+
resp = self.handle_any(data)
|
|
301
|
+
if resp is not None:
|
|
302
|
+
out.write(json.dumps(resp, ensure_ascii=False).encode("utf-8") + b"\n")
|
|
303
|
+
out.flush()
|
|
304
|
+
|
|
305
|
+
# ---------- HTTP (streamable, risposte JSON semplici) ----------
|
|
306
|
+
def serve_http(self, host: str, port: int, path: str = "/mcp"):
|
|
307
|
+
srv = self
|
|
308
|
+
|
|
309
|
+
class H(BaseHTTPRequestHandler):
|
|
310
|
+
protocol_version = "HTTP/1.1"
|
|
311
|
+
|
|
312
|
+
def log_message(self, *a):
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
def _send(self, st, obj=None, extra=None):
|
|
316
|
+
data = b"" if obj is None else json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
317
|
+
self.send_response(st)
|
|
318
|
+
if obj is not None:
|
|
319
|
+
self.send_header("Content-Type", "application/json")
|
|
320
|
+
self.send_header("Content-Length", str(len(data)))
|
|
321
|
+
for k, v in (extra or {}).items():
|
|
322
|
+
self.send_header(k, v)
|
|
323
|
+
self.end_headers()
|
|
324
|
+
self.wfile.write(data)
|
|
325
|
+
|
|
326
|
+
def do_GET(self):
|
|
327
|
+
# nessun flusso SSE dal server (non mandiamo notifiche): consentito dalla specifica
|
|
328
|
+
self._send(405, extra={"Allow": "POST"})
|
|
329
|
+
|
|
330
|
+
def do_DELETE(self):
|
|
331
|
+
self._send(200 if self.path.split("?")[0] == path else 404)
|
|
332
|
+
|
|
333
|
+
def do_POST(self):
|
|
334
|
+
if self.path.split("?")[0] != path:
|
|
335
|
+
return self._send(404, {"error": "usa %s" % path})
|
|
336
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
337
|
+
try:
|
|
338
|
+
data = json.loads(self.rfile.read(n) or b"null")
|
|
339
|
+
except ValueError:
|
|
340
|
+
return self._send(400, _err(None, -32700, "invalid JSON"))
|
|
341
|
+
resp = srv.handle_any(data)
|
|
342
|
+
if resp is None:
|
|
343
|
+
return self._send(202)
|
|
344
|
+
self._send(200, resp)
|
|
345
|
+
|
|
346
|
+
httpd = ThreadingHTTPServer((host, port), H)
|
|
347
|
+
httpd.daemon_threads = True
|
|
348
|
+
print("[mnemonic-mcp] http://%s:%d%s (read-only: %s)" % (host, port, path, self.arc.path),
|
|
349
|
+
file=sys.stderr, flush=True)
|
|
350
|
+
httpd.serve_forever()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _ok(mid, result):
|
|
354
|
+
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _err(mid, code, message):
|
|
358
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def main(argv=None):
|
|
362
|
+
ap = argparse.ArgumentParser(prog="mnemonic-mcp",
|
|
363
|
+
description="Read-only MCP server over the Mnemonic Proxy archive "
|
|
364
|
+
"(tools: recall, conversations, journal).")
|
|
365
|
+
ap.add_argument("--db", required=True, help="the proxy's archive.sqlite (opened read-only)")
|
|
366
|
+
ap.add_argument("--journal", help="journal.jsonl (default: next to the database)")
|
|
367
|
+
ap.add_argument("--max-tokens", type=int, default=6000, help="token cap of one recall result (default: 6000)")
|
|
368
|
+
ap.add_argument("--tokenizer", help="HF tokenizer.json for token counts (optional)")
|
|
369
|
+
ap.add_argument("--http", type=int, metavar="PORT", help="serve streamable HTTP on /mcp instead of stdio")
|
|
370
|
+
ap.add_argument("--host", default="127.0.0.1", help="bind address for --http (default: 127.0.0.1)")
|
|
371
|
+
a = ap.parse_args(argv)
|
|
372
|
+
compat.console_safe(sys.stderr)
|
|
373
|
+
srv = McpServer(Archive(a.db, a.journal, a.max_tokens, a.tokenizer))
|
|
374
|
+
if a.http:
|
|
375
|
+
srv.serve_http(a.host, a.http)
|
|
376
|
+
else:
|
|
377
|
+
srv.serve_stdio()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
if __name__ == "__main__":
|
|
381
|
+
main()
|
ctxproxy/packtok.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""Tokenizer del pack di Strata -> tokenizer HF (`tokenizers`), identico a serve/strata_tokenizer.py.
|
|
3
|
+
|
|
4
|
+
Il tokenizer.json del pack è un descrittore (model gpt2, pre qwen35, pre_pattern), non un file HF: qui si costruisce
|
|
5
|
+
BPE byte-level con lo split regex del pack e i token speciali (CONTROL e USER_DEFINED, che Strata legge con
|
|
6
|
+
parse_special=True). Verificato: 109.490 token = prompt_tokens di Strata su una sessione reale.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build(d: str):
|
|
15
|
+
from tokenizers import AddedToken, Regex, Tokenizer, decoders, models, pre_tokenizers # type: ignore
|
|
16
|
+
desc = json.load(open(os.path.join(d, "tokenizer.json"), encoding="utf-8"))
|
|
17
|
+
vocab = json.load(open(os.path.join(d, "vocab.json"), encoding="utf-8"))
|
|
18
|
+
merges = [tuple(m.split(" ")) for m in open(os.path.join(d, "merges.txt"), encoding="utf-8").read().split("\n")
|
|
19
|
+
if m]
|
|
20
|
+
types = json.load(open(os.path.join(d, "token_type.json"), encoding="utf-8"))
|
|
21
|
+
inv = {i: t for t, i in vocab.items()}
|
|
22
|
+
tk = Tokenizer(models.BPE(vocab=vocab, merges=merges, fuse_unk=False, byte_fallback=False))
|
|
23
|
+
tk.pre_tokenizer = pre_tokenizers.Sequence([
|
|
24
|
+
pre_tokenizers.Split(Regex(desc["pre_pattern"]), behavior="isolated", invert=False),
|
|
25
|
+
pre_tokenizers.ByteLevel(add_prefix_space=False, trim_offsets=False, use_regex=False)])
|
|
26
|
+
tk.decoder = decoders.ByteLevel()
|
|
27
|
+
tk.add_special_tokens([AddedToken(inv[i], special=True, normalized=False)
|
|
28
|
+
for i, ty in enumerate(types) if ty in (3, 4)])
|
|
29
|
+
return tk
|