ace-mcp-server 0.3.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.
ace_mcp/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ Ace MCP Server
3
+
4
+ Ace your AI memory. Free. Simple. Local.
5
+
6
+ Exposes PVM persistent memory as MCP tools for any AI tool
7
+ (Cursor, Claude Desktop, Windsurf, Codex, Copilot, etc.)
8
+
9
+ Author: Wenhui Tian (田文辉)
10
+ Email: rocky007cn@outlook.com
11
+ GitHub: https://github.com/rocky007cn/ace-memory
12
+ License: BSL 1.1 → MIT 2030
13
+ """
14
+
15
+ __version__ = "0.3.0"
16
+ __author__ = "Wenhui Tian"
17
+ __email__ = "rocky007cn@outlook.com"
18
+ __url__ = "https://github.com/rocky007cn/ace-memory"
ace_mcp/ace_fuzzy.py ADDED
@@ -0,0 +1,315 @@
1
+ """
2
+ Fuzzy Attribution — PVM Paper Chapter 6 Implementation
3
+
4
+ Author: Wenhui Tian (田文辉)
5
+ Email: rocky007cn@outlook.com
6
+ GitHub: https://github.com/rocky007cn/ace-memory
7
+
8
+ When multiple candidates are comparably scored, PVM does not guess.
9
+ Instead, it honestly reports the common features of all candidates,
10
+ maintaining user trust while accumulating calibration signals.
11
+
12
+ Trigger conditions (all three must be met):
13
+ 1. Candidate count >= 2
14
+ 2. Temporal span between candidates > threshold (default: 180 days)
15
+ 3. Similarity score variance < threshold (default: 0.2)
16
+
17
+ Reference: "Persistent Vector Memory" Section 6.2
18
+ """
19
+
20
+ import re
21
+ import time
22
+ from collections import Counter
23
+ from typing import Optional
24
+
25
+ from pvm import PVMRecord
26
+ from pvm.ranking import composite_score
27
+
28
+
29
+ class FuzzyAttributor:
30
+ """Implements the full fuzzy attribution mechanism from PVM Chapter 6."""
31
+
32
+ def __init__(
33
+ self,
34
+ temporal_span_threshold: float = 180.0,
35
+ score_variance_threshold: float = 0.2,
36
+ min_candidates: int = 2,
37
+ ):
38
+ """
39
+ Args:
40
+ temporal_span_threshold: Min days between oldest and newest
41
+ candidate to trigger fuzzy mode. Paper default: 180 days.
42
+ score_variance_threshold: Max score gap between top and bottom
43
+ candidate. Paper default: 0.2.
44
+ min_candidates: Minimum candidates to trigger. Paper default: 2.
45
+ """
46
+ self.temporal_span_threshold = temporal_span_threshold
47
+ self.score_variance_threshold = score_variance_threshold
48
+ self.min_candidates = min_candidates
49
+
50
+ def analyze(
51
+ self,
52
+ candidates: list[PVMRecord],
53
+ lambda_decay: float = 0.01,
54
+ ) -> Optional[dict]:
55
+ """Analyze candidates and return fuzzy attribution if triggered.
56
+
57
+ This implements the full three-condition check from Section 6.2:
58
+ 1. Enough candidates
59
+ 2. Temporal span exceeds threshold
60
+ 3. Score variance below threshold (no clear winner)
61
+
62
+ Args:
63
+ candidates: Ranked list of PVMRecord (already sorted by score).
64
+ lambda_decay: Temporal decay rate for score computation.
65
+
66
+ Returns:
67
+ None if attribution is clear-cut (single winner dominates).
68
+ Dict with fuzzy attribution info if triggered.
69
+ """
70
+ if len(candidates) < self.min_candidates:
71
+ return None
72
+
73
+ # --- Condition 1: Score variance ---
74
+ scores = [composite_score(c, lambda_decay) for c in candidates]
75
+ top_score = max(scores)
76
+ bottom_score = min(scores)
77
+ score_gap = top_score - bottom_score
78
+
79
+ if score_gap > self.score_variance_threshold:
80
+ return None # Clear winner, no fuzzy needed
81
+
82
+ # --- Condition 2: Temporal span ---
83
+ timestamps = [c.last_activated for c in candidates if c.last_activated]
84
+ if not timestamps:
85
+ return None
86
+
87
+ earliest = min(timestamps)
88
+ latest = max(timestamps)
89
+ temporal_span_days = (latest - earliest) / 86400.0
90
+
91
+ if temporal_span_days < self.temporal_span_threshold:
92
+ # Temporal span too small — candidates are from the same period.
93
+ # Still return fuzzy info but mark it as "low_confidence".
94
+ # The paper's default is 180 days, but we don't want to hide
95
+ # ambiguous results just because they're recent.
96
+ pass
97
+
98
+ # --- Extract common features ---
99
+ common_features = self._extract_common_features(candidates)
100
+ category = self._classify_candidates(candidates)
101
+
102
+ # --- Build honest summary ---
103
+ summary = self._build_summary(
104
+ candidates, common_features, category,
105
+ temporal_span_days, score_gap,
106
+ )
107
+
108
+ triggered = (
109
+ temporal_span_days >= self.temporal_span_threshold
110
+ and score_gap <= self.score_variance_threshold
111
+ )
112
+
113
+ return {
114
+ "mode": "fuzzy_attribution",
115
+ "triggered": triggered,
116
+ "trigger_conditions": {
117
+ "candidate_count": len(candidates),
118
+ "candidate_count_met": len(candidates) >= self.min_candidates,
119
+ "temporal_span_days": round(temporal_span_days, 1),
120
+ "temporal_span_met": temporal_span_days >= self.temporal_span_threshold,
121
+ "score_gap": round(score_gap, 4),
122
+ "score_variance_met": score_gap <= self.score_variance_threshold,
123
+ },
124
+ "category": category,
125
+ "common_features": common_features,
126
+ "honest_summary": summary,
127
+ "candidates": [
128
+ {
129
+ "fingerprint": c.fingerprint.hex(),
130
+ "content_preview": c.content[:150],
131
+ "weight": round(c.weight, 4),
132
+ "score": round(composite_score(c, lambda_decay), 4),
133
+ "created_at": c.created_at,
134
+ "last_activated": c.last_activated,
135
+ }
136
+ for c in candidates
137
+ ],
138
+ "advice": (
139
+ "Multiple memories match this query with similar scores. "
140
+ "Do NOT pick one and present it as certain. Instead, "
141
+ "summarize the common pattern across these memories and "
142
+ "let the user confirm which one they mean. "
143
+ "If the user confirms, call ace_calibrate with signal=1.0 "
144
+ "for the correct memory to sharpen future retrieval."
145
+ ),
146
+ }
147
+
148
+ def _extract_common_features(
149
+ self, candidates: list[PVMRecord]
150
+ ) -> dict:
151
+ """Extract common features across candidates.
152
+
153
+ Goes beyond simple token intersection:
154
+ - Shared keywords (content tokens)
155
+ - Shared memory types
156
+ - Shared sources
157
+ - Shared tags
158
+ """
159
+ # Tokenize each candidate's content
160
+ token_lists = [self._tokenize(c.content) for c in candidates]
161
+ token_sets = [set(tl) for tl in token_lists]
162
+
163
+ # Intersection of all token sets
164
+ common_tokens = token_sets[0]
165
+ for ts in token_sets[1:]:
166
+ common_tokens = common_tokens & ts
167
+
168
+ # Remove stop words and noise
169
+ common_tokens = self._filter_stop_words(common_tokens)
170
+
171
+ # Frequency across candidates (how many candidates have this token)
172
+ token_frequency = Counter()
173
+ for tl in token_lists:
174
+ for t in set(tl): # unique per candidate
175
+ token_frequency[t] += 1
176
+
177
+ # Sort by frequency (most common first)
178
+ common_sorted = sorted(
179
+ common_tokens,
180
+ key=lambda t: (-token_frequency[t], t),
181
+ )
182
+
183
+ # Extract memory types
184
+ types = set()
185
+ sources = set()
186
+ for c in candidates:
187
+ content = c.content
188
+ # Extract type: [source:X] [type] content
189
+ if "[source:" in content:
190
+ src_start = content.find("[source:") + 8
191
+ src_end = content.find("]", src_start)
192
+ if src_end > 0:
193
+ sources.add(content[src_start:src_end])
194
+ # Extract type tag
195
+ type_match = re.search(r"\[(fact|preference|decision|correction|context)\]", content)
196
+ if type_match:
197
+ types.add(type_match.group(1))
198
+
199
+ return {
200
+ "shared_keywords": common_sorted[:20],
201
+ "shared_types": sorted(types) if types else [],
202
+ "shared_sources": sorted(sources) if sources else [],
203
+ "total_unique_keywords": len(common_tokens),
204
+ }
205
+
206
+ def _classify_candidates(
207
+ self, candidates: list[PVMRecord]
208
+ ) -> str:
209
+ """Classify what kind of ambiguity this is.
210
+
211
+ Returns one of:
212
+ - "temporal_ambiguity": same topic, different time periods
213
+ - "topic_overlap": different events with shared elements
214
+ - "correction_chain": original + corrected versions
215
+ - "general": generic ambiguity
216
+ """
217
+ # Check if any candidate is a correction of another
218
+ for c in candidates:
219
+ for e in c.edges:
220
+ if e.relation_type == "corrects":
221
+ for other in candidates:
222
+ if other.fingerprint == e.target_id:
223
+ return "correction_chain"
224
+
225
+ # Check temporal spread
226
+ timestamps = [c.last_activated for c in candidates if c.last_activated]
227
+ if timestamps:
228
+ span = (max(timestamps) - min(timestamps)) / 86400.0
229
+ if span > 90:
230
+ return "temporal_ambiguity"
231
+
232
+ return "topic_overlap"
233
+
234
+ def _build_summary(
235
+ self,
236
+ candidates: list[PVMRecord],
237
+ features: dict,
238
+ category: str,
239
+ temporal_span: float,
240
+ score_gap: float,
241
+ ) -> str:
242
+ """Build an honest summary for the AI to use.
243
+
244
+ This is NOT the final user-facing text — the AI (LLM) should
245
+ use this as input to generate a natural response.
246
+ The summary provides the structured facts; the LLM provides
247
+ the language.
248
+ """
249
+ keywords = features.get("shared_keywords", [])
250
+ keyword_str = ", ".join(keywords[:10]) if keywords else "(none)"
251
+
252
+ parts = [
253
+ f"Fuzzy attribution triggered: {len(candidates)} candidates "
254
+ f"with score gap {score_gap:.4f} (threshold: {self.score_variance_threshold}).",
255
+ f"Temporal span: {temporal_span:.1f} days "
256
+ f"(threshold: {self.temporal_span_threshold} days).",
257
+ f"Category: {category}.",
258
+ f"Common keywords: {keyword_str}.",
259
+ ]
260
+
261
+ if category == "correction_chain":
262
+ parts.append(
263
+ "This includes an original memory and its correction. "
264
+ "Present both versions to the user."
265
+ )
266
+ elif category == "temporal_ambiguity":
267
+ parts.append(
268
+ "These memories span a long time period. The user may be "
269
+ "referring to any of them. Summarize the pattern rather "
270
+ "than picking one."
271
+ )
272
+ else:
273
+ parts.append(
274
+ "These memories share common features but may refer to "
275
+ "different events. Present the common theme and ask "
276
+ "the user to clarify."
277
+ )
278
+
279
+ return " ".join(parts)
280
+
281
+ @staticmethod
282
+ def _tokenize(text: str) -> list[str]:
283
+ """Simple tokenizer for feature extraction."""
284
+ tokens = re.findall(r"\w+", text.lower())
285
+ # Also extract CJK characters as individual tokens
286
+ cjk = re.findall(r"[\u4e00-\u9fff]+", text)
287
+ for segment in cjk:
288
+ # Simple bigram extraction for CJK
289
+ for i in range(len(segment) - 1):
290
+ tokens.append(segment[i:i+2])
291
+ tokens.append(segment)
292
+ return tokens
293
+
294
+ @staticmethod
295
+ def _filter_stop_words(tokens: set[str]) -> set[str]:
296
+ """Filter common stop words from token set."""
297
+ stop_words = {
298
+ "the", "a", "an", "is", "are", "was", "were", "be", "been",
299
+ "have", "has", "had", "do", "does", "did", "will", "would",
300
+ "could", "should", "may", "might", "must", "shall", "can",
301
+ "to", "of", "in", "for", "on", "with", "at", "by", "from",
302
+ "as", "into", "about", "like", "through", "after", "over",
303
+ "between", "out", "against", "during", "without", "before",
304
+ "under", "around", "among", "and", "but", "or", "not",
305
+ "no", "so", "if", "then", "that", "this", "these", "those",
306
+ "it", "i", "you", "he", "she", "we", "they", "me", "him",
307
+ "her", "us", "them", "my", "your", "his", "its", "our",
308
+ "their", "what", "which", "who", "when", "where", "why",
309
+ "how", "all", "each", "every", "both", "few", "more",
310
+ "most", "other", "some", "such", "only", "own", "same",
311
+ "than", "too", "very", "just", "also", "now", "here",
312
+ "there", "source", "fact", "preference", "decision",
313
+ "correction", "context", "reason",
314
+ }
315
+ return {t for t in tokens if t not in stop_words and len(t) > 1}
ace_mcp/server.py ADDED
@@ -0,0 +1,692 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Ace MCP Server — Free AI Memory That Learns
4
+
5
+ 12 MCP tools that turn PVM into a complete memory layer for any AI tool.
6
+
7
+ Author: Wenhui Tian (田文辉)
8
+ Email: rocky007cn@outlook.com
9
+ GitHub: https://github.com/rocky007cn/ace-memory
10
+ License: BSL 1.1 → MIT 2030
11
+
12
+ Core (5 tools):
13
+ ace_save — Store a memory
14
+ ace_search — Search memories (keyword + semantic, fuzzy attribution)
15
+ ace_get_context — Load recent memory context
16
+ ace_calibrate — Feedback to evolve weights
17
+ ace_stats — Engine statistics
18
+
19
+ Differentiators (6 tools):
20
+ ace_correct — Correct a memory WITHOUT losing the original (linked evolution)
21
+ ace_link — Manually create relational edges between memories
22
+ ace_forget — Soft-delete (weight → 0, preserved for audit)
23
+ ace_timeline — Chronological memory view
24
+ ace_compare — Compare original vs corrected versions (evolution chain)
25
+ ace_export — Export all memories (json / text / summary)
26
+
27
+ PVM Chapter 6 (1 tool):
28
+ ace_fuzzy_attribution — When uncertain, does NOT guess. Reports common features.
29
+
30
+ Usage:
31
+ ace-mcp
32
+ ace-mcp --path ~/my-memory.jsonl
33
+ ace-mcp --no-embedding
34
+
35
+ Config files in configs/ folder for Claude Desktop, Cursor, VS Code.
36
+ """
37
+
38
+ import argparse
39
+ import hashlib
40
+ import json
41
+ import os
42
+ import sys
43
+ import time
44
+ from datetime import datetime
45
+ from typing import Optional
46
+
47
+ from pvm import PVMEngine, Edge, PVMRecord
48
+
49
+ try:
50
+ from .ace_fuzzy import FuzzyAttributor
51
+ except ImportError:
52
+ from ace_mcp.ace_fuzzy import FuzzyAttributor
53
+
54
+
55
+ # ==============================================================================
56
+ # Embedding — auto-upgrade: sentence-transformers if available, else hash
57
+ # ==============================================================================
58
+
59
+ # Try to load sentence-transformers (optional, zero-cost fallback)
60
+ _ST_MODEL = None
61
+ _ST_AVAILABLE = False
62
+ try:
63
+ from sentence_transformers import SentenceTransformer
64
+ _ST_AVAILABLE = True
65
+ except ImportError:
66
+ pass
67
+
68
+
69
+ def _get_st_model():
70
+ """Lazily load the sentence-transformers model (only when first needed)."""
71
+ global _ST_MODEL
72
+ if _ST_MODEL is None and _ST_AVAILABLE:
73
+ _ST_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
74
+ return _ST_MODEL
75
+
76
+
77
+ def _embed(text: str) -> list[float]:
78
+ """Embed text. Uses sentence-transformers if available, else SHA-256 hash.
79
+
80
+ sentence-transformers gives true semantic search (database ≈ 数据库).
81
+ SHA-256 hash is deterministic, zero-dep, keyword-level matching.
82
+ """
83
+ if _ST_AVAILABLE:
84
+ model = _get_st_model()
85
+ vec = model.encode(text, normalize_embeddings=True)
86
+ return vec.tolist()
87
+ # Fallback: hash-based pseudo-embedding
88
+ dim = 64
89
+ h = hashlib.sha256(text.encode("utf-8")).digest()
90
+ vec = [(h[i % len(h)] + (i * 7 + 3) % 256) % 256 / 255.0 for i in range(dim)]
91
+ norm = sum(v * v for v in vec) ** 0.5
92
+ return [v / norm for v in vec] if norm > 0 else vec
93
+
94
+
95
+ def _embedding_mode() -> str:
96
+ """Return current embedding mode for display."""
97
+ if _ST_AVAILABLE:
98
+ return "semantic (sentence-transformers/all-MiniLM-L6-v2)"
99
+ return "hash (SHA-256 pseudo-embedding)"
100
+
101
+
102
+ # ==============================================================================
103
+ # MCP SDK
104
+ # ==============================================================================
105
+
106
+ try:
107
+ from mcp.server import Server
108
+ from mcp.server.stdio import stdio_server
109
+ from mcp.server.models import InitializationOptions, ServerCapabilities
110
+ import mcp.types as types
111
+ MCP_AVAILABLE = True
112
+ except ImportError:
113
+ MCP_AVAILABLE = False
114
+
115
+
116
+ # ==============================================================================
117
+ # Global State
118
+ # ==============================================================================
119
+
120
+ _engine: Optional[PVMEngine] = None
121
+ _embedding_dim: int = 64
122
+ _use_embedding: bool = True
123
+ _fuzzy: Optional[FuzzyAttributor] = None
124
+
125
+
126
+ def get_engine() -> PVMEngine:
127
+ if _engine is None:
128
+ raise RuntimeError("Engine not initialized.")
129
+ return _engine
130
+
131
+
132
+ def _fmt(rec: PVMRecord, full: bool = False) -> dict:
133
+ """Format a PVMRecord to JSON-safe dict."""
134
+ d = {
135
+ "fingerprint": rec.fingerprint.hex(),
136
+ "content": rec.content if full else rec.content[:500],
137
+ "weight": round(rec.weight, 4),
138
+ "created_at": rec.created_at,
139
+ "created_str": datetime.fromtimestamp(rec.created_at).isoformat()
140
+ if rec.created_at else None,
141
+ "last_activated": rec.last_activated,
142
+ "edge_count": len(rec.edges),
143
+ }
144
+ if rec.edges:
145
+ d["edges"] = [{
146
+ "target": e.target_id.hex(),
147
+ "relation": e.relation_type,
148
+ "decay": e.activation_decay,
149
+ } for e in rec.edges]
150
+ return d
151
+
152
+
153
+ # ==============================================================================
154
+ # Tool 1: ace_save
155
+ # ==============================================================================
156
+
157
+ def tool_save(content: str, tags=None, weight: float = 0.5,
158
+ memory_type: str = "fact", source: str = "") -> dict:
159
+ """Store a memory. memory_type: fact|preference|decision|correction|context.
160
+ source: which AI tool created this (e.g. Cursor, Trae, Copilot)."""
161
+ eng = get_engine()
162
+ src_prefix = f"[source:{source}] " if source else ""
163
+ typed = f"{src_prefix}[{memory_type}] {content}"
164
+ emb = _embed(typed) if _use_embedding else None
165
+ all_tags = (tags or []) + [memory_type]
166
+ if source:
167
+ all_tags.append(source.lower())
168
+ fp = eng.store(content=typed, embedding=emb, weight=weight, keywords=all_tags)
169
+ return {"fingerprint": fp.hex(), "content": content[:200],
170
+ "type": memory_type, "source": source or "unknown",
171
+ "weight": weight, "stored": True}
172
+
173
+
174
+ # ==============================================================================
175
+ # Tool 2: ace_search
176
+ # ==============================================================================
177
+
178
+ def tool_search(query: str, keywords=None, top_k: int = 10,
179
+ include_full: bool = False) -> dict:
180
+ """Search memories. Dual-channel + full fuzzy attribution (Chapter 6)."""
181
+ eng = get_engine()
182
+ emb = _embed(query) if _use_embedding else None
183
+ results = eng.query(query_text=query, query_embedding=emb,
184
+ keywords=keywords, top_k=top_k, propagate=True)
185
+ items = [_fmt(r, full=include_full) for r in results]
186
+
187
+ # Full fuzzy attribution per paper Chapter 6
188
+ fuzzy = None
189
+ if _fuzzy and len(results) >= 2:
190
+ fuzzy = _fuzzy.analyze(results, lambda_decay=eng.lambda_decay)
191
+
192
+ resp = {"query": query[:200], "total_found": len(items), "results": items}
193
+ if fuzzy:
194
+ resp["fuzzy_attribution"] = fuzzy
195
+ return resp
196
+
197
+
198
+ # ==============================================================================
199
+ # Tool 3: ace_get_context
200
+ # ==============================================================================
201
+
202
+ def tool_get_context(limit: int = 20, memory_type: str = None) -> dict:
203
+ """Recent memories, optionally filtered by type."""
204
+ eng = get_engine()
205
+ all_recs = sorted(eng.records.values(), key=lambda r: r.last_activated, reverse=True)
206
+ items = []
207
+ for r in all_recs:
208
+ if memory_type and not r.content.startswith(f"[{memory_type}]"):
209
+ continue
210
+ items.append(_fmt(r, full=True))
211
+ if len(items) >= limit:
212
+ break
213
+ return {"total_memories": len(eng), "returned": len(items), "memories": items}
214
+
215
+
216
+ # ==============================================================================
217
+ # Tool 4: ace_calibrate
218
+ # ==============================================================================
219
+
220
+ def tool_calibrate(fingerprint: str, signal: float, reason: str = "") -> dict:
221
+ """Evolve weight via Delta Rule. signal: 1=good, 0=bad, 0.5=neutral."""
222
+ eng = get_engine()
223
+ try:
224
+ fp = bytes.fromhex(fingerprint)
225
+ except ValueError:
226
+ return {"error": f"Invalid fingerprint: {fingerprint}"}
227
+ try:
228
+ eng.calibrate(fp, signal)
229
+ except KeyError:
230
+ return {"error": f"Memory not found: {fingerprint}"}
231
+ rec = eng.get(fp)
232
+ return {"fingerprint": fingerprint, "new_weight": round(rec.weight, 4),
233
+ "signal": signal, "reason": reason}
234
+
235
+
236
+ # ==============================================================================
237
+ # Tool 5: ace_correct ★ THE KILLER FEATURE ★
238
+ # ==============================================================================
239
+
240
+ def tool_correct(original_fingerprint: str, corrected_content: str,
241
+ reason: str = "", tags=None) -> dict:
242
+ """Correct a memory. Original kept (weight→0.2), new version stored
243
+ (weight→0.7) with 'corrects' edge. AI sees full evolution."""
244
+ eng = get_engine()
245
+ try:
246
+ fp_old = bytes.fromhex(original_fingerprint)
247
+ except ValueError:
248
+ return {"error": f"Invalid fingerprint: {original_fingerprint}"}
249
+
250
+ old_rec = eng.get(fp_old)
251
+ if old_rec is None:
252
+ return {"error": f"Original not found: {original_fingerprint}"}
253
+
254
+ # Preserve source from original if present
255
+ src = "unknown"
256
+ old_content = old_rec.content
257
+ if old_content.startswith("[source:"):
258
+ end = old_content.find("]", 8)
259
+ if end > 0:
260
+ src = old_content[8:end]
261
+
262
+ full = f"[source:{src}] [correction] {corrected_content}"
263
+ if reason:
264
+ full += f" | reason: {reason}"
265
+
266
+ emb = _embed(full) if _use_embedding else None
267
+ all_tags = (tags or []) + ["correction", "corrected"]
268
+ fp_new = eng.store(
269
+ content=full, embedding=emb, weight=0.7, keywords=all_tags,
270
+ edges=[Edge(target_id=fp_old, relation_type="corrects", activation_decay=0.5)],
271
+ )
272
+
273
+ eng.calibrate(fp_old, signal=0.2)
274
+ old_updated = eng.get(fp_old)
275
+
276
+ return {
277
+ "original": {"fingerprint": original_fingerprint,
278
+ "content": old_rec.content[:200],
279
+ "new_weight": round(old_updated.weight, 4)},
280
+ "corrected": {"fingerprint": fp_new.hex(),
281
+ "content": full[:200], "weight": 0.7},
282
+ "reason": reason,
283
+ "evolution": f"{old_rec.content[:80]}... → {corrected_content[:80]}...",
284
+ }
285
+
286
+
287
+ # ==============================================================================
288
+ # Tool 6: ace_link
289
+ # ==============================================================================
290
+
291
+ def tool_link(src_fp: str, tgt_fp: str, relation: str = "related_to",
292
+ decay: float = 0.7) -> dict:
293
+ """Create edge between two memories. Relations: related_to|depends_on|
294
+ follows|contradicts|extends."""
295
+ eng = get_engine()
296
+ try:
297
+ src, tgt = bytes.fromhex(src_fp), bytes.fromhex(tgt_fp)
298
+ except ValueError:
299
+ return {"error": "Invalid fingerprint"}
300
+
301
+ src_rec, tgt_rec = eng.get(src), eng.get(tgt)
302
+ if not src_rec: return {"error": f"Source not found: {src_fp}"}
303
+ if not tgt_rec: return {"error": f"Target not found: {tgt_fp}"}
304
+
305
+ src_rec.edges.append(Edge(target_id=tgt, relation_type=relation,
306
+ activation_decay=decay))
307
+ eng._sync_to_disk(src_rec)
308
+ return {"source": src_fp, "target": tgt_fp, "relation": relation, "linked": True}
309
+
310
+
311
+ # ==============================================================================
312
+ # Tool 7: ace_forget
313
+ # ==============================================================================
314
+
315
+ def tool_forget(fingerprint: str, reason: str = "") -> dict:
316
+ """Soft-delete. Weight → 0.001. Stays in storage, hidden from normal search."""
317
+ eng = get_engine()
318
+ try:
319
+ fp = bytes.fromhex(fingerprint)
320
+ except ValueError:
321
+ return {"error": f"Invalid fingerprint: {fingerprint}"}
322
+
323
+ rec = eng.get(fp)
324
+ if not rec: return {"error": f"Not found: {fingerprint}"}
325
+
326
+ old_w, content = rec.weight, rec.content[:200]
327
+ rec.weight = 0.001
328
+ rec.last_activated = time.time()
329
+ eng._sync_to_disk(rec)
330
+ return {"fingerprint": fingerprint, "content": content,
331
+ "old_weight": round(old_w, 4), "new_weight": 0.001,
332
+ "forgotten": True, "reason": reason}
333
+
334
+
335
+ # ==============================================================================
336
+ # Tool 8: ace_timeline
337
+ # ==============================================================================
338
+
339
+ def tool_timeline(start_time: float = None, end_time: float = None,
340
+ memory_type: str = None, limit: int = 50) -> dict:
341
+ """Chronological memory view."""
342
+ eng = get_engine()
343
+ now = time.time()
344
+ t0, t1 = start_time or 0.0, end_time or now
345
+
346
+ filtered = [r for r in eng.records.values()
347
+ if t0 <= r.created_at <= t1
348
+ and (not memory_type or r.content.startswith(f"[{memory_type}]"))]
349
+ filtered.sort(key=lambda r: r.created_at)
350
+
351
+ items = []
352
+ for r in filtered[:limit]:
353
+ d = _fmt(r, full=True)
354
+ d["created_str"] = datetime.fromtimestamp(r.created_at).strftime("%Y-%m-%d %H:%M:%S")
355
+ items.append(d)
356
+
357
+ return {"time_range": {"start": t0, "end": t1},
358
+ "total_in_range": len(filtered), "returned": len(items),
359
+ "timeline": items}
360
+
361
+
362
+ # ==============================================================================
363
+ # Tool 9: ace_compare
364
+ # ==============================================================================
365
+
366
+ def tool_compare(fingerprint: str) -> dict:
367
+ """Show evolution chain: original → corrections → current."""
368
+ eng = get_engine()
369
+ try:
370
+ fp = bytes.fromhex(fingerprint)
371
+ except ValueError:
372
+ return {"error": f"Invalid fingerprint: {fingerprint}"}
373
+
374
+ rec = eng.get(fp)
375
+ if not rec: return {"error": f"Not found: {fingerprint}"}
376
+
377
+ chain = {
378
+ "this": _fmt(rec, full=True),
379
+ "corrects": [],
380
+ "corrected_by": [],
381
+ }
382
+
383
+ # Outgoing "corrects" edges
384
+ for e in rec.edges:
385
+ if e.relation_type == "corrects":
386
+ t = eng.get(e.target_id)
387
+ if t: chain["corrects"].append(_fmt(t, full=True))
388
+
389
+ # Incoming "corrects" edges
390
+ for _, other in eng.records.items():
391
+ for e in other.edges:
392
+ if e.target_id == fp and e.relation_type == "corrects":
393
+ chain["corrected_by"].append(_fmt(other, full=True))
394
+
395
+ vcount = 1 + len(chain["corrects"]) + len(chain["corrected_by"])
396
+ chain["evolution_summary"] = {"versions": vcount}
397
+ return chain
398
+
399
+
400
+ # ==============================================================================
401
+ # Tool 10: ace_export
402
+ # ==============================================================================
403
+
404
+ def tool_export(fmt: str = "json") -> dict:
405
+ """Export memories. fmt: json|text|summary."""
406
+ eng = get_engine()
407
+
408
+ if fmt == "summary":
409
+ types_cnt = {}
410
+ top = []
411
+ for r in sorted(eng.records.values(), key=lambda r: r.weight, reverse=True):
412
+ mt = "unknown"
413
+ if r.content.startswith("["):
414
+ end = r.content.find("]")
415
+ if end > 0: mt = r.content[1:end]
416
+ types_cnt[mt] = types_cnt.get(mt, 0) + 1
417
+ if len(top) < 10:
418
+ top.append({"fingerprint": r.fingerprint.hex(), "type": mt,
419
+ "weight": round(r.weight, 4), "content": r.content[:120]})
420
+ return {"format": "summary", "total": len(eng), "by_type": types_cnt,
421
+ "top_weighted": top, "stats": eng.stats()}
422
+
423
+ if fmt == "text":
424
+ lines = [f"Ace Memory Export — {datetime.now():%Y-%m-%d %H:%M}",
425
+ f"Total: {len(eng)} records\n"]
426
+ for r in sorted(eng.records.values(), key=lambda r: r.created_at):
427
+ ts = datetime.fromtimestamp(r.created_at).strftime("%Y-%m-%d %H:%M")
428
+ lines.append(f"[{ts}] (w={r.weight:.3f}) {r.content}")
429
+ for e in r.edges:
430
+ t = eng.get(e.target_id)
431
+ lines.append(f" └─ {e.relation_type} → {t.content[:60] if t else '(missing)'}")
432
+ lines.append("")
433
+ return {"format": "text", "export": "\n".join(lines)}
434
+
435
+ return {"format": "json", "total": len(eng),
436
+ "memories": [_fmt(r, full=True) for r in eng.records.values()]}
437
+
438
+
439
+ # ==============================================================================
440
+ # Tool 11: ace_stats
441
+ # ==============================================================================
442
+
443
+ def tool_stats() -> dict:
444
+ s = get_engine().stats()
445
+ s["embedding_mode"] = _embedding_mode()
446
+ s["tool_count"] = 12
447
+ return s
448
+
449
+
450
+ # ==============================================================================
451
+ # Tool 12: ace_fuzzy_attribution ★ PVM Chapter 6 ★
452
+ # ==============================================================================
453
+
454
+ def tool_fuzzy_attribution(query: str, top_k: int = 10) -> dict:
455
+ """Analyze a query for fuzzy attribution (PVM Chapter 6).
456
+
457
+ When multiple memories match with similar scores, PVM does NOT guess.
458
+ Instead, it reports common features across all candidates and advises
459
+ the AI to summarize rather than pick one.
460
+
461
+ This implements the full three-condition trigger:
462
+ 1. Candidate count >= 2
463
+ 2. Temporal span > 180 days (configurable)
464
+ 3. Score variance < 0.2 (no clear winner)
465
+ """
466
+ eng = get_engine()
467
+ emb = _embed(query) if _use_embedding else None
468
+ results = eng.query(query_text=query, query_embedding=emb,
469
+ top_k=top_k, propagate=True)
470
+
471
+ if not results:
472
+ return {"mode": "no_results", "query": query[:200]}
473
+
474
+ if len(results) == 1:
475
+ return {"mode": "clear_winner", "query": query[:200],
476
+ "result": _fmt(results[0], full=True)}
477
+
478
+ if not _fuzzy:
479
+ return {"mode": "disabled", "query": query[:200],
480
+ "results": [_fmt(r) for r in results]}
481
+
482
+ analysis = _fuzzy.analyze(results, lambda_decay=eng.lambda_decay)
483
+ if analysis is None:
484
+ return {"mode": "clear_winner", "query": query[:200],
485
+ "result": _fmt(results[0], full=True)}
486
+
487
+ return analysis
488
+
489
+
490
+ # ==============================================================================
491
+ # MCP Server
492
+ # ==============================================================================
493
+
494
+ def build_mcp_server() -> Server:
495
+ server = Server("ace-mcp")
496
+
497
+ @server.list_tools()
498
+ async def list_tools() -> list[types.Tool]:
499
+ return [
500
+ types.Tool(name="ace_save", description=(
501
+ "Store a memory. Full text, never chunked, never compressed. "
502
+ "memory_type: fact|preference|decision|correction|context. "
503
+ "source: which AI tool created this (e.g. Cursor, Trae, Copilot)."),
504
+ inputSchema={"type": "object", "properties": {
505
+ "content": {"type": "string", "description": "Full text to remember."},
506
+ "tags": {"type": "array", "items": {"type": "string"}},
507
+ "weight": {"type": "number", "description": "Initial weight [0,1]."},
508
+ "memory_type": {"type": "string", "enum": ["fact","preference","decision","correction","context"]},
509
+ "source": {"type": "string", "description": "Which AI tool created this memory."},
510
+ }, "required": ["content"]}),
511
+
512
+ types.Tool(name="ace_search", description=(
513
+ "Search memories — deterministic keyword matching, no guessing. "
514
+ "When top results score similarly, returns fuzzy_attribution."),
515
+ inputSchema={"type": "object", "properties": {
516
+ "query": {"type": "string", "description": "What to search for."},
517
+ "keywords": {"type": "array", "items": {"type": "string"}},
518
+ "top_k": {"type": "integer", "description": "Max results. Default 10."},
519
+ "include_full": {"type": "boolean", "description": "Return full content."},
520
+ }, "required": ["query"]}),
521
+
522
+ types.Tool(name="ace_get_context", description=(
523
+ "Load recent memory context. Call at the START of every conversation."),
524
+ inputSchema={"type": "object", "properties": {
525
+ "limit": {"type": "integer", "description": "Max memories. Default 20."},
526
+ "memory_type": {"type": "string", "description": "Filter by type."},
527
+ }}),
528
+
529
+ types.Tool(name="ace_calibrate", description=(
530
+ "Evolve memory weight. signal 1.0=great, 0.0=bad, 0.5=neutral."),
531
+ inputSchema={"type": "object", "properties": {
532
+ "fingerprint": {"type": "string", "description": "Hex fingerprint."},
533
+ "signal": {"type": "number", "description": "Feedback [0,1]."},
534
+ "reason": {"type": "string", "description": "Why this feedback."},
535
+ }, "required": ["fingerprint","signal"]}),
536
+
537
+ types.Tool(name="ace_correct", description=(
538
+ "★ Correct a memory WITHOUT losing the original. "
539
+ "Original weight → 0.2, new version weight → 0.7 with 'corrects' edge. "
540
+ "AI sees full evolution: what was said → what was corrected → why."),
541
+ inputSchema={"type": "object", "properties": {
542
+ "original_fingerprint": {"type": "string", "description": "Fingerprint to correct."},
543
+ "corrected_content": {"type": "string", "description": "Corrected information."},
544
+ "reason": {"type": "string", "description": "Why the correction."},
545
+ "tags": {"type": "array", "items": {"type": "string"}},
546
+ }, "required": ["original_fingerprint","corrected_content"]}),
547
+
548
+ types.Tool(name="ace_link", description=(
549
+ "Create relationship edge: related_to|depends_on|follows|contradicts|extends."),
550
+ inputSchema={"type": "object", "properties": {
551
+ "source_fingerprint": {"type": "string"},
552
+ "target_fingerprint": {"type": "string"},
553
+ "relation": {"type": "string", "enum": ["related_to","depends_on","follows","contradicts","extends"]},
554
+ "decay": {"type": "number", "description": "Activation decay [0,1]."},
555
+ }, "required": ["source_fingerprint","target_fingerprint","relation"]}),
556
+
557
+ types.Tool(name="ace_forget", description=(
558
+ "Soft-delete. Weight → 0.001. Preserved for audit, hidden from search."),
559
+ inputSchema={"type": "object", "properties": {
560
+ "fingerprint": {"type": "string"},
561
+ "reason": {"type": "string"},
562
+ }, "required": ["fingerprint"]}),
563
+
564
+ types.Tool(name="ace_timeline", description=(
565
+ "Chronological memory view with time range filter."),
566
+ inputSchema={"type": "object", "properties": {
567
+ "start_time": {"type": "number", "description": "Unix timestamp."},
568
+ "end_time": {"type": "number", "description": "Unix timestamp."},
569
+ "memory_type": {"type": "string"},
570
+ "limit": {"type": "integer", "description": "Default 50."},
571
+ }}),
572
+
573
+ types.Tool(name="ace_compare", description=(
574
+ "Show evolution chain of a memory: original → all corrections → current."),
575
+ inputSchema={"type": "object", "properties": {
576
+ "fingerprint": {"type": "string"},
577
+ }, "required": ["fingerprint"]}),
578
+
579
+ types.Tool(name="ace_export", description=(
580
+ "Export all memories. Format: json|text|summary."),
581
+ inputSchema={"type": "object", "properties": {
582
+ "format": {"type": "string", "enum": ["json","text","summary"]},
583
+ }}),
584
+
585
+ types.Tool(name="ace_stats", description="Engine statistics.",
586
+ inputSchema={"type": "object", "properties": {}}),
587
+
588
+ types.Tool(name="ace_fuzzy_attribution", description=(
589
+ "★ PVM Chapter 6: Fuzzy Attribution. "
590
+ "When multiple memories match with similar scores, does NOT guess. "
591
+ "Instead, reports common features and advises honest summarization. "
592
+ "Call this when ace_search returns multiple similar-scoring results."),
593
+ inputSchema={"type": "object", "properties": {
594
+ "query": {"type": "string", "description": "What to search for."},
595
+ "top_k": {"type": "integer", "description": "Max candidates. Default 10."},
596
+ }, "required": ["query"]}),
597
+ ]
598
+
599
+ @server.call_tool()
600
+ async def call_tool(name: str, args: dict) -> list[types.TextContent]:
601
+ dispatch = {
602
+ "ace_save": lambda: tool_save(args["content"], args.get("tags"),
603
+ args.get("weight", 0.5), args.get("memory_type", "fact"),
604
+ args.get("source", "")),
605
+ "ace_search": lambda: tool_search(args["query"], args.get("keywords"),
606
+ args.get("top_k", 10), args.get("include_full", False)),
607
+ "ace_get_context": lambda: tool_get_context(args.get("limit", 20), args.get("memory_type")),
608
+ "ace_calibrate": lambda: tool_calibrate(args["fingerprint"], args["signal"], args.get("reason", "")),
609
+ "ace_correct": lambda: tool_correct(args["original_fingerprint"], args["corrected_content"],
610
+ args.get("reason", ""), args.get("tags")),
611
+ "ace_link": lambda: tool_link(args["source_fingerprint"], args["target_fingerprint"],
612
+ args.get("relation", "related_to"), args.get("decay", 0.7)),
613
+ "ace_forget": lambda: tool_forget(args["fingerprint"], args.get("reason", "")),
614
+ "ace_timeline": lambda: tool_timeline(args.get("start_time"), args.get("end_time"),
615
+ args.get("memory_type"), args.get("limit", 50)),
616
+ "ace_compare": lambda: tool_compare(args["fingerprint"]),
617
+ "ace_export": lambda: tool_export(args.get("format", "json")),
618
+ "ace_stats": lambda: tool_stats(),
619
+ "ace_fuzzy_attribution": lambda: tool_fuzzy_attribution(args["query"], args.get("top_k", 10)),
620
+ }
621
+ try:
622
+ fn = dispatch.get(name)
623
+ if fn is None:
624
+ result = {"error": f"Unknown tool: {name}"}
625
+ else:
626
+ result = fn()
627
+ except Exception as e:
628
+ result = {"error": str(e), "tool": name}
629
+
630
+ return [types.TextContent(type="text",
631
+ text=json.dumps(result, ensure_ascii=False, indent=2))]
632
+
633
+ return server
634
+
635
+
636
+ # ==============================================================================
637
+ # CLI
638
+ # ==============================================================================
639
+
640
+ def main():
641
+ global _engine, _embedding_dim, _use_embedding, _fuzzy
642
+
643
+ parser = argparse.ArgumentParser(description="Ace MCP Server — 12 tools")
644
+ parser.add_argument("--path", default=None,
645
+ help="Storage path (default: ~/.ace/memory.jsonl)")
646
+ parser.add_argument("--no-embedding", action="store_true",
647
+ help="Keyword-only mode")
648
+ parser.add_argument("--alpha", type=float, default=0.05,
649
+ help="Learning rate (default: 0.05)")
650
+ args = parser.parse_args()
651
+
652
+ if args.path:
653
+ storage_path = os.path.expanduser(args.path)
654
+ else:
655
+ ace_dir = os.path.join(os.path.expanduser("~"), ".ace")
656
+ os.makedirs(ace_dir, exist_ok=True)
657
+ storage_path = os.path.join(ace_dir, "memory.jsonl")
658
+
659
+ _engine = PVMEngine(storage_path=storage_path, alpha=args.alpha,
660
+ lambda_decay=0.01, similarity_threshold=0.3)
661
+ _use_embedding = not args.no_embedding
662
+ _embedding_dim = int(os.environ.get("ACE_EMBEDDING_DIM", "64"))
663
+ _fuzzy = FuzzyAttributor(
664
+ temporal_span_threshold=180.0,
665
+ score_variance_threshold=0.2,
666
+ min_candidates=2,
667
+ )
668
+
669
+ if not MCP_AVAILABLE:
670
+ print(f"♠ Ace MCP Server v{__import__('ace_mcp').__version__} — 12 tools ready")
671
+ print(f" GitHub: https://github.com/rocky007cn/ace-memory")
672
+ print(f" Author: Wenhui Tian — rocky007cn@outlook.com")
673
+ print(f" Install: pip install mcp")
674
+ print(f" Storage: {storage_path} | Records: {len(_engine)}")
675
+ print(f" Embedding: {_embedding_mode()}")
676
+ if not _ST_AVAILABLE:
677
+ print(f" Upgrade: pip install sentence-transformers (for semantic search)")
678
+ sys.exit(1)
679
+
680
+ import asyncio
681
+ server = build_mcp_server()
682
+
683
+ async def run():
684
+ async with stdio_server() as (read_stream, write_stream):
685
+ await server.run(read_stream, write_stream,
686
+ InitializationOptions(server_name="ace-mcp", server_version="0.1.0", capabilities=ServerCapabilities()))
687
+
688
+ asyncio.run(run())
689
+
690
+
691
+ if __name__ == "__main__":
692
+ main()
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: ace-mcp-server
3
+ Version: 0.3.0
4
+ Summary: Ace MCP Server — Free AI memory that learns. Zero cloud, zero API keys, zero cost.
5
+ Author-email: Wenhui Tian <rocky007cn@outlook.com>
6
+ License: BSL-1.1
7
+ Project-URL: Homepage, https://github.com/rocky007cn/ace-memory
8
+ Project-URL: Repository, https://github.com/rocky007cn/ace-memory
9
+ Keywords: mcp,mcp-server,ai-memory,memory,llm,agent-memory,rag,long-term-memory,local-first,free,zero-cost
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: Other/Proprietary License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: pvm-memory>=1.1.0
21
+ Requires-Dist: mcp>=1.0.0
22
+ Provides-Extra: semantic
23
+ Requires-Dist: sentence-transformers>=2.2.0; extra == "semantic"
24
+
25
+ # Ace MCP Server
26
+
27
+ ♠ **Ace your AI memory. Free. Simple. Local.**
28
+
29
+ Zero-cost MCP server that gives any AI tool persistent, self-improving memory.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install ace-mcp
35
+ ```
36
+
37
+ Requires `pvm-memory` (installed automatically as dependency).
38
+
39
+ ## Quick Start
40
+
41
+ ### VS Code / Cursor
42
+
43
+ Add to `.vscode/mcp.json`:
44
+
45
+ ```json
46
+ {
47
+ "servers": {
48
+ "ace": {
49
+ "command": "python",
50
+ "args": ["-m", "ace_mcp.server"]
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ### Claude Desktop
57
+
58
+ Add to `claude_desktop_config.json`:
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "ace": {
64
+ "command": "python",
65
+ "args": ["-m", "ace_mcp.server"]
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### Any MCP-compatible tool
72
+
73
+ ```bash
74
+ ace-mcp # default: ~/.ace/memory.jsonl
75
+ ace-mcp --path ./my-mem.jsonl
76
+ ace-mcp --no-embedding # keyword-only, faster
77
+ ```
78
+
79
+ ## Tools
80
+
81
+ | Tool | What |
82
+ |------|------|
83
+ | `ace_save` | Store a memory |
84
+ | `ace_search` | Keyword + semantic search |
85
+ | `ace_get_context` | Recent memory context |
86
+ | `ace_calibrate` | 👍/👎 feedback to train weights |
87
+ | `ace_stats` | Engine statistics |
88
+
89
+ ## vs Mem0
90
+
91
+ | | Mem0 | Ace |
92
+ |---|---|---|
93
+ | Price | 💰 Paid | 🆓 Free |
94
+ | API Key | Required | None |
95
+ | Cloud | Required | 100% Local |
96
+ | GPU | Recommended | Not needed |
97
+ | Dependencies | 50+ | 2 |
98
+ | Information | Chunked/lossy | Full text |
99
+
100
+ ## License
101
+
102
+ BSL 1.1 → MIT (2030)
@@ -0,0 +1,8 @@
1
+ ace_mcp/__init__.py,sha256=YVYdn0shm-ln_oRdZ2xz_1Zb2aB1aUA4JM8i4GB4znk,486
2
+ ace_mcp/ace_fuzzy.py,sha256=4MGllNk7KnJbpL0twflug49Lpzy_XzQO-77PM7R-FCc,12237
3
+ ace_mcp/server.py,sha256=m5D9a8WOS_If1WFEjX6w5g6GDr59U-fiLHnQrLbgs6M,29824
4
+ ace_mcp_server-0.3.0.dist-info/METADATA,sha256=-IMHMadZYIz-KhO0x2OM2IPqbMvZhSOoqJrNfZ5yH3Y,2532
5
+ ace_mcp_server-0.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
6
+ ace_mcp_server-0.3.0.dist-info/entry_points.txt,sha256=VrF2-CeoDGzdqhLHgoETGZw9uBC3bIxCV_3by495f7Q,48
7
+ ace_mcp_server-0.3.0.dist-info/top_level.txt,sha256=2p7I_QoPGw0wptjFbR6NoDSyrCVG9bDzueE6SK1CVDA,8
8
+ ace_mcp_server-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ace-mcp = ace_mcp.server:main
@@ -0,0 +1 @@
1
+ ace_mcp