restyle-mcp 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.
- restyle_mcp-0.1.0.dist-info/METADATA +9 -0
- restyle_mcp-0.1.0.dist-info/RECORD +8 -0
- restyle_mcp-0.1.0.dist-info/WHEEL +4 -0
- restyle_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- voice_mcp/__init__.py +106 -0
- voice_mcp/persona.md +22 -0
- voice_mcp/rag.py +108 -0
- voice_mcp/stripper.py +26 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: restyle-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that restyles a draft into your own voice (runtime persona + local RAG exemplars + deterministic stripper). Style only, never content. One tool: restyle_as_me.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: anthropic[vertex]>=0.40
|
|
7
|
+
Requires-Dist: fastembed>=0.4
|
|
8
|
+
Requires-Dist: fastmcp>=3.0.2
|
|
9
|
+
Requires-Dist: numpy>=1.26
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
voice_mcp/__init__.py,sha256=Aw8ad1LWuUjMcXGK3BIeRKVME_QGgOfdl8ru2bYe4gE,3792
|
|
2
|
+
voice_mcp/persona.md,sha256=y_bD1iYIAV61KTMG2nuxGOwp8Avn7yio5VCKX1BkBJE,924
|
|
3
|
+
voice_mcp/rag.py,sha256=rO1fQGr9b3jcRlF8uhts7vNV6KhLlKsLfm1e31rc36M,4278
|
|
4
|
+
voice_mcp/stripper.py,sha256=GGYaN4ZtGreJsUkXmfCFHxpwehhrHkOZ2ATWsqfeqME,1140
|
|
5
|
+
restyle_mcp-0.1.0.dist-info/METADATA,sha256=rKd0cbn-hH2P65J8j5oyZ9QFzdpRA9OHU4ZbdNB66MQ,385
|
|
6
|
+
restyle_mcp-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
restyle_mcp-0.1.0.dist-info/entry_points.txt,sha256=rW5yRrgYVcqmX2Ffeiet5A2NB9-uSeFB0ea8s1Jhg-k,82
|
|
8
|
+
restyle_mcp-0.1.0.dist-info/RECORD,,
|
voice_mcp/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""voice-mcp — restyle a draft into Peter's voice. One tool: restyle_as_me.
|
|
2
|
+
|
|
3
|
+
STYLE ONLY, never content. Composition (facts) stays with the calling
|
|
4
|
+
model (Spark) + a live fact source (zep/KG); this tool only re-voices a
|
|
5
|
+
draft the model already wrote. It returns the styled text so Peter can
|
|
6
|
+
review before sending (review = send). See plans/004 in the voice repo.
|
|
7
|
+
|
|
8
|
+
Pipeline: RAG-retrieve clean exemplars (local fastembed index) -> fill the
|
|
9
|
+
persona prompt -> Haiku rewrite -> deterministic stripper -> return.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastmcp import FastMCP
|
|
17
|
+
|
|
18
|
+
from .rag import Retriever, format_exemplars
|
|
19
|
+
from .stripper import strip_tells
|
|
20
|
+
|
|
21
|
+
PKG = Path(__file__).resolve().parent
|
|
22
|
+
PERSONA_PATH = Path(os.environ.get("VOICE_PERSONA") or (PKG / "persona.md"))
|
|
23
|
+
INDEX_PATH = Path(os.environ.get("VOICE_INDEX") or "/data/rag_index.pkl")
|
|
24
|
+
MODEL = os.environ.get("VOICE_MODEL", "claude-haiku-4-5-20251001")
|
|
25
|
+
K = int(os.environ.get("VOICE_K", "6"))
|
|
26
|
+
|
|
27
|
+
mcp = FastMCP("voice-mcp")
|
|
28
|
+
|
|
29
|
+
_client = None
|
|
30
|
+
_retriever: Retriever | None = None
|
|
31
|
+
_retriever_tried = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _persona_template() -> str:
|
|
35
|
+
# the prompt is the block between the first pair of --- fences
|
|
36
|
+
return PERSONA_PATH.read_text(encoding="utf-8").split("\n---\n")[1].strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_client():
|
|
40
|
+
global _client
|
|
41
|
+
if _client is None:
|
|
42
|
+
if os.environ.get("VOICE_ANTHROPIC_BACKEND") == "vertex":
|
|
43
|
+
# Claude over Google Cloud Vertex AI: keeps inference inside the
|
|
44
|
+
# GCP perimeter (no personal Anthropic key), auth via ADC. Reads
|
|
45
|
+
# ANTHROPIC_VERTEX_PROJECT_ID + CLOUD_ML_REGION from the env
|
|
46
|
+
# (region "global" carries the newest Claude models). Use the
|
|
47
|
+
# Vertex model id form, e.g. VOICE_MODEL=claude-haiku-4-5@20251001.
|
|
48
|
+
from anthropic import AnthropicVertex
|
|
49
|
+
_client = AnthropicVertex()
|
|
50
|
+
else:
|
|
51
|
+
from anthropic import Anthropic
|
|
52
|
+
_client = Anthropic()
|
|
53
|
+
return _client
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_retriever() -> Retriever | None:
|
|
57
|
+
global _retriever, _retriever_tried
|
|
58
|
+
if not _retriever_tried:
|
|
59
|
+
_retriever_tried = True
|
|
60
|
+
if INDEX_PATH.exists():
|
|
61
|
+
_retriever = Retriever(str(INDEX_PATH))
|
|
62
|
+
return _retriever
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def restyle(text: str, channel: str | None = None) -> str:
|
|
66
|
+
"""Plain pipeline (testable): RAG -> persona -> Haiku -> stripper."""
|
|
67
|
+
text = (text or "").strip()
|
|
68
|
+
if not text:
|
|
69
|
+
return text
|
|
70
|
+
r = _get_retriever()
|
|
71
|
+
exemplars = format_exemplars(r.retrieve(text, channel, k=K, exclude=(text,))) if r else ""
|
|
72
|
+
system = _persona_template().replace("{EXEMPLARS}", exemplars or "(no examples available)")
|
|
73
|
+
msg = _get_client().messages.create(
|
|
74
|
+
model=MODEL,
|
|
75
|
+
max_tokens=600,
|
|
76
|
+
system=system,
|
|
77
|
+
messages=[{"role": "user",
|
|
78
|
+
"content": f"Rewrite this in your voice; keep all facts, change only style:\n\n{text}"}],
|
|
79
|
+
)
|
|
80
|
+
return strip_tells(msg.content[0].text.strip())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool
|
|
84
|
+
def restyle_as_me(text: str, channel: str | None = None) -> str:
|
|
85
|
+
"""Rewrite a draft message into Peter's voice — STYLE ONLY, never changing
|
|
86
|
+
facts or content. Use on a draft *before sending*: call this, then show
|
|
87
|
+
the returned text to Peter and send exactly that (review = send). Pass
|
|
88
|
+
`channel` ("imessage", "whatsapp", "email", or "linkedin") when known so
|
|
89
|
+
the register matches. Returns the restyled message text."""
|
|
90
|
+
return restyle(text, channel)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main() -> None:
|
|
94
|
+
transport = os.environ.get("MCP_TRANSPORT", "http")
|
|
95
|
+
if transport == "stdio":
|
|
96
|
+
mcp.run()
|
|
97
|
+
else:
|
|
98
|
+
mcp.run(
|
|
99
|
+
transport="http",
|
|
100
|
+
host=os.environ.get("HOST", "0.0.0.0"),
|
|
101
|
+
port=int(os.environ.get("PORT", "8000")),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
voice_mcp/persona.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Restyle persona — default template
|
|
2
|
+
|
|
3
|
+
Generic default voice prompt for `restyle_as_me`. This ships with the
|
|
4
|
+
package as a neutral fallback; the real persona is supplied at runtime via
|
|
5
|
+
the `VOICE_PERSONA` env var (a private file kept out of git alongside the
|
|
6
|
+
RAG index). The tool replaces `{EXEMPLARS}` with RAG-retrieved real replies
|
|
7
|
+
at call time. No private content lives in this file.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
You are the author of the draft below, rewriting it into your own voice.
|
|
12
|
+
Embody the author; never break character, never identify as an AI.
|
|
13
|
+
|
|
14
|
+
YOUR JOB: change only the STYLE. Preserve every fact, name, number, date,
|
|
15
|
+
decision, and intent in the draft exactly. Do not add or remove
|
|
16
|
+
information. Output only the rewritten message, nothing else.
|
|
17
|
+
|
|
18
|
+
HOW YOU WRITE (real examples of your messages):
|
|
19
|
+
{EXEMPLARS}
|
|
20
|
+
|
|
21
|
+
Rewrite the draft below in your voice; match the register and rhythm of the
|
|
22
|
+
examples above; keep all facts, change only style.
|
voice_mcp/rag.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Cheap, local exemplar retrieval for restyle_as_me.
|
|
2
|
+
|
|
3
|
+
No GPU, no hosting, no API: a small ONNX embedder (fastembed) over the
|
|
4
|
+
*clean* corpus, cosine top-k in-memory. The restyle tool is handed a DRAFT
|
|
5
|
+
(Peter's outgoing text), so we index on Peter's REPLIES and query with the
|
|
6
|
+
draft — "here are past messages of yours most like this one" — to fill
|
|
7
|
+
persona.md's {EXEMPLARS}. Index is built locally from ~/voice-corpora and
|
|
8
|
+
shipped to a Railway volume; it is NEVER committed (private correspondence).
|
|
9
|
+
|
|
10
|
+
voice-mcp-index build ~/voice-corpora /data/rag_index.pkl
|
|
11
|
+
voice-mcp-index query ~/voice-corpora/rag_index.pkl "draft text" --channel imessage
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import json, re, pickle, argparse
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
MODEL = "BAAI/bge-small-en-v1.5" # ~130MB ONNX, CPU
|
|
19
|
+
AUTO = re.compile(r"upcoming event|Release notes for|has been canceled|RSVP|"
|
|
20
|
+
r"Forwarded message|Firebase|invited to test|\u22c5", re.I)
|
|
21
|
+
|
|
22
|
+
def clean(s: str) -> bool:
|
|
23
|
+
"""Reject contamination: em-dashes (AI tell; Peter is anti-em-dash) and
|
|
24
|
+
automated/notification/forwarded junk."""
|
|
25
|
+
return bool(s) and "\u2014" not in s and not AUTO.search(s)
|
|
26
|
+
|
|
27
|
+
def _embedder():
|
|
28
|
+
from fastembed import TextEmbedding
|
|
29
|
+
return TextEmbedding(model_name=MODEL)
|
|
30
|
+
|
|
31
|
+
def _key(channel: str, text: str) -> str:
|
|
32
|
+
return f"[{channel}] {text}"
|
|
33
|
+
|
|
34
|
+
def load_clean_replies(corpora_dir: str):
|
|
35
|
+
rows = []
|
|
36
|
+
for ch in ["imessage", "whatsapp", "email", "linkedin"]:
|
|
37
|
+
p = Path(corpora_dir) / f"{ch}.jsonl"
|
|
38
|
+
if not p.exists():
|
|
39
|
+
continue
|
|
40
|
+
for l in open(p):
|
|
41
|
+
r = json.loads(l)
|
|
42
|
+
rep = (r.get("reply") or "").strip()
|
|
43
|
+
if clean(rep) and 4 <= len(rep) <= 300:
|
|
44
|
+
rows.append({"reply": rep, "channel": r.get("channel", ch)})
|
|
45
|
+
# de-dup identical replies (keep first)
|
|
46
|
+
seen, uniq = set(), []
|
|
47
|
+
for r in rows:
|
|
48
|
+
if r["reply"] in seen:
|
|
49
|
+
continue
|
|
50
|
+
seen.add(r["reply"]); uniq.append(r)
|
|
51
|
+
return uniq
|
|
52
|
+
|
|
53
|
+
def build_index(corpora_dir: str, out: str) -> int:
|
|
54
|
+
rows = load_clean_replies(corpora_dir)
|
|
55
|
+
emb = _embedder()
|
|
56
|
+
vecs = np.array(list(emb.embed([_key(r["channel"], r["reply"]) for r in rows])), dtype=np.float32)
|
|
57
|
+
vecs /= (np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-9)
|
|
58
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
with open(out, "wb") as f:
|
|
60
|
+
pickle.dump({"rows": rows, "vecs": vecs, "model": MODEL}, f)
|
|
61
|
+
return len(rows)
|
|
62
|
+
|
|
63
|
+
class Retriever:
|
|
64
|
+
def __init__(self, index_path: str):
|
|
65
|
+
with open(index_path, "rb") as f:
|
|
66
|
+
d = pickle.load(f)
|
|
67
|
+
self.rows, self.vecs = d["rows"], d["vecs"]
|
|
68
|
+
self.emb = _embedder()
|
|
69
|
+
|
|
70
|
+
def retrieve(self, draft: str, channel: str | None = None, k: int = 6,
|
|
71
|
+
same_channel_bonus: float = 0.04, exclude=()):
|
|
72
|
+
q = np.array(list(self.emb.embed([_key(channel, draft) if channel else draft]))[0], dtype=np.float32)
|
|
73
|
+
q /= (np.linalg.norm(q) + 1e-9)
|
|
74
|
+
sims = self.vecs @ q
|
|
75
|
+
if channel and same_channel_bonus:
|
|
76
|
+
sims = sims + np.array([same_channel_bonus if r["channel"] == channel else 0.0
|
|
77
|
+
for r in self.rows], dtype=np.float32)
|
|
78
|
+
out = []
|
|
79
|
+
for i in np.argsort(-sims):
|
|
80
|
+
r = self.rows[i]
|
|
81
|
+
if r["reply"] in exclude:
|
|
82
|
+
continue
|
|
83
|
+
out.append(r)
|
|
84
|
+
if len(out) >= k:
|
|
85
|
+
break
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
def format_exemplars(exs) -> str:
|
|
89
|
+
return "\n".join(f"- {e['reply']}" for e in exs)
|
|
90
|
+
|
|
91
|
+
def main() -> None:
|
|
92
|
+
ap = argparse.ArgumentParser()
|
|
93
|
+
ap.add_argument("cmd", choices=["build", "query"])
|
|
94
|
+
ap.add_argument("arg1") # build: corpora_dir ; query: index_path
|
|
95
|
+
ap.add_argument("arg2", nargs="?") # build: out ; query: draft text
|
|
96
|
+
ap.add_argument("--channel")
|
|
97
|
+
ap.add_argument("-k", type=int, default=6)
|
|
98
|
+
a = ap.parse_args()
|
|
99
|
+
if a.cmd == "build":
|
|
100
|
+
out = a.arg2 or str(Path(a.arg1) / "rag_index.pkl")
|
|
101
|
+
n = build_index(a.arg1, out)
|
|
102
|
+
print(f"indexed {n} clean replies -> {out}")
|
|
103
|
+
else:
|
|
104
|
+
for e in Retriever(a.arg1).retrieve(a.arg2, a.channel, a.k):
|
|
105
|
+
print(f'[{e["channel"]}] {e["reply"]}')
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
voice_mcp/stripper.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Deterministic, content-preserving stripper — the safety net.
|
|
2
|
+
|
|
3
|
+
Peter is strictly semicolons, NEVER em-dashes (an AI tell). The persona
|
|
4
|
+
already suppresses em-dashes, but a model can leak one; this guarantees
|
|
5
|
+
none survive. Also drops corporate sign-offs. All transforms are
|
|
6
|
+
mechanical and meaning-preserving — safe to run on outgoing text.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_SIGNOFFS = (r"Best regards|Kind regards|Warm regards|Best wishes|"
|
|
12
|
+
r"Best|Regards|Sincerely|Cheers|Warmly|Yours truly|Respectfully")
|
|
13
|
+
|
|
14
|
+
def strip_tells(s: str) -> str:
|
|
15
|
+
if not s:
|
|
16
|
+
return s
|
|
17
|
+
# em-dash -> semicolon (Peter's join); handles spaced and bare forms.
|
|
18
|
+
s = re.sub(r"\s*\u2014\s*", "; ", s)
|
|
19
|
+
s = s.replace("\u2013", "-") # en-dash -> hyphen (ranges)
|
|
20
|
+
# tidy doubled/space-before punctuation the swap can create
|
|
21
|
+
s = re.sub(r";\s*;", ";", s)
|
|
22
|
+
s = re.sub(r"\s+;", ";", s)
|
|
23
|
+
s = re.sub(r";\s*([.!?])", r"\1", s)
|
|
24
|
+
# drop a trailing corporate sign-off block (keep a bare "Peter").
|
|
25
|
+
s = re.sub(rf"\n+\s*(?:{_SIGNOFFS})[,.!]?\s*(?:\n+.*)?$", "", s, flags=re.I | re.S)
|
|
26
|
+
return s.strip()
|