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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ voice-mcp = voice_mcp:main
3
+ voice-mcp-index = voice_mcp.rag:main
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()