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 +18 -0
- ace_mcp/ace_fuzzy.py +315 -0
- ace_mcp/server.py +692 -0
- ace_mcp_server-0.3.0.dist-info/METADATA +102 -0
- ace_mcp_server-0.3.0.dist-info/RECORD +8 -0
- ace_mcp_server-0.3.0.dist-info/WHEEL +5 -0
- ace_mcp_server-0.3.0.dist-info/entry_points.txt +2 -0
- ace_mcp_server-0.3.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
ace_mcp
|