swafra 0.1.0__tar.gz

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.
swafra-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: swafra
3
+ Version: 0.1.0
4
+ Summary: MCP server engine for swafra — Leiden-chunked, graph-linked semantic memory
5
+ Author: kunal12203
6
+ License: MIT
7
+ Keywords: mcp,knowledge-graph,leiden,semantic-memory,claude
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: fastembed>=0.4.0
16
+ Requires-Dist: igraph>=0.11.0
17
+ Requires-Dist: leidenalg>=0.10.0
18
+ Requires-Dist: numpy>=1.26.0
19
+ Dynamic: author
20
+ Dynamic: classifier
21
+ Dynamic: description
22
+ Dynamic: description-content-type
23
+ Dynamic: keywords
24
+ Dynamic: license
25
+ Dynamic: requires-dist
26
+ Dynamic: requires-python
27
+ Dynamic: summary
28
+
29
+ # swafra
30
+
31
+ MCP server for knowledge graphs — Leiden-chunked, graph-linked semantic memory for Claude AI and ChatGPT.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install swafra
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ swafra
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT
swafra-0.1.0/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # swafra
2
+
3
+ MCP server for knowledge graphs — Leiden-chunked, graph-linked semantic memory for Claude AI and ChatGPT.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install swafra
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ swafra
15
+ ```
16
+
17
+ ## License
18
+
19
+ MIT
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
swafra-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
swafra-0.1.0/setup.py ADDED
@@ -0,0 +1,32 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="swafra",
5
+ version="0.1.0",
6
+ description="MCP server engine for swafra — Leiden-chunked, graph-linked semantic memory",
7
+ long_description=open("README.md").read(),
8
+ long_description_content_type="text/markdown",
9
+ author="kunal12203",
10
+ license="MIT",
11
+ packages=find_packages(),
12
+ python_requires=">=3.10",
13
+ install_requires=[
14
+ "fastembed>=0.4.0",
15
+ "igraph>=0.11.0",
16
+ "leidenalg>=0.10.0",
17
+ "numpy>=1.26.0",
18
+ ],
19
+ entry_points={
20
+ "console_scripts": [
21
+ "swafra=swafra.engine:main",
22
+ ],
23
+ },
24
+ keywords=["mcp", "knowledge-graph", "leiden", "semantic-memory", "claude"],
25
+ classifiers=[
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ ],
32
+ )
@@ -0,0 +1,3 @@
1
+ """swafra — Leiden-chunked, graph-linked semantic memory engine."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,882 @@
1
+ """scimap MCP engine — the Python subprocess that handles embeddings, chunking,
2
+ and knowledge graph operations.
3
+
4
+ v2: Improved retrieval with BM25 + vector hybrid, turn-level conversation chunking,
5
+ temporal indexing, and entity-aware search.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import math
13
+ import os
14
+ import re
15
+ import sys
16
+ from collections import Counter, defaultdict
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(levelname)s %(name)s: %(message)s")
21
+ log = logging.getLogger("scimap.engine")
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Storage
25
+ # ---------------------------------------------------------------------------
26
+ _DATA_DIR = Path(os.getenv("SCIMAP_DATA_DIR", os.path.expanduser("~/.scimap")))
27
+ _DATA_DIR.mkdir(parents=True, exist_ok=True)
28
+
29
+ _CHUNKS_FILE = _DATA_DIR / "chunks.json"
30
+ _EDGES_FILE = _DATA_DIR / "edges.json"
31
+ _SOURCES_FILE = _DATA_DIR / "sources.json"
32
+
33
+
34
+ def _load_json(path: Path) -> list | dict:
35
+ if path.exists():
36
+ with open(path) as f:
37
+ return json.load(f)
38
+ return []
39
+
40
+
41
+ def _save_json(path: Path, data):
42
+ with open(path, "w") as f:
43
+ json.dump(data, f, default=str)
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # BM25 (pure Python, no deps)
48
+ # ---------------------------------------------------------------------------
49
+ _STOP_WORDS = frozenset({
50
+ "i", "me", "my", "myself", "we", "our", "ours", "ourselves", "you", "your",
51
+ "yours", "yourself", "yourselves", "he", "him", "his", "himself", "she", "her",
52
+ "hers", "herself", "it", "its", "itself", "they", "them", "their", "theirs",
53
+ "themselves", "what", "which", "who", "whom", "this", "that", "these", "those",
54
+ "am", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
55
+ "having", "do", "does", "did", "doing", "a", "an", "the", "and", "but", "if",
56
+ "or", "because", "as", "until", "while", "of", "at", "by", "for", "with",
57
+ "about", "against", "between", "through", "during", "before", "after", "above",
58
+ "below", "to", "from", "up", "down", "in", "out", "on", "off", "over", "under",
59
+ "again", "further", "then", "once", "here", "there", "when", "where", "why",
60
+ "how", "all", "both", "each", "few", "more", "most", "other", "some", "such",
61
+ "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "s",
62
+ "t", "can", "will", "just", "don", "should", "now", "d", "ll", "m", "o", "re",
63
+ "ve", "y", "ain", "aren", "couldn", "didn", "doesn", "hadn", "hasn", "haven",
64
+ "isn", "ma", "mightn", "mustn", "needn", "shan", "shouldn", "wasn", "weren",
65
+ "won", "wouldn",
66
+ })
67
+
68
+ _WORD_RE = re.compile(r"[a-z0-9]+(?:'[a-z]+)?")
69
+
70
+
71
+ def _stem(word: str) -> str:
72
+ """Minimal suffix-stripping stemmer. Handles common English inflections."""
73
+ if len(word) <= 3:
74
+ return word
75
+ # Handle common suffixes (order matters — longest first)
76
+ suffixes = [
77
+ ("ational", "ate"), ("tional", "tion"), ("encies", "ence"),
78
+ ("ancies", "ance"), ("izers", "ize"), ("ously", "ous"),
79
+ ("ively", "ive"), ("ments", "ment"), ("ities", ""),
80
+ ("ness", ""), ("ings", ""), ("ment", ""), ("ence", ""),
81
+ ("ance", ""), ("ible", ""), ("able", ""), ("tion", ""),
82
+ ("ling", ""), ("ally", ""), ("ized", "ize"), ("ised", "ise"),
83
+ ("ful", ""), ("ing", ""), ("ers", ""), ("ies", "y"),
84
+ ("ess", ""), ("est", ""), ("ous", ""), ("ive", ""),
85
+ ("ize", ""), ("ise", ""), ("ion", ""), ("ed", ""),
86
+ ("er", ""), ("ly", ""), ("es", ""), ("'s", ""), ("s", ""),
87
+ ]
88
+ for suffix, replacement in suffixes:
89
+ if word.endswith(suffix) and len(word) - len(suffix) >= 2:
90
+ return word[:-len(suffix)] + replacement
91
+ return word
92
+
93
+
94
+ def _tokenize(text: str) -> list[str]:
95
+ """Tokenize, remove stop words, and stem."""
96
+ return [_stem(w) for w in _WORD_RE.findall(text.lower()) if w not in _STOP_WORDS and len(w) > 1]
97
+
98
+
99
+ def _tokenize_raw(text: str) -> list[str]:
100
+ """Tokenize without stemming (for entity matching)."""
101
+ return [w for w in _WORD_RE.findall(text.lower()) if w not in _STOP_WORDS and len(w) > 1]
102
+
103
+
104
+ class BM25Index:
105
+ """In-memory BM25 index. Rebuilt per-question (since we reset between questions in bench)."""
106
+
107
+ def __init__(self, k1: float = 1.5, b: float = 0.75):
108
+ self.k1 = k1
109
+ self.b = b
110
+ self.doc_freqs: Counter = Counter()
111
+ self.doc_lens: list[int] = []
112
+ self.doc_tokens: list[list[str]] = []
113
+ self.avg_dl: float = 0.0
114
+ self.n_docs: int = 0
115
+
116
+ def add(self, tokens: list[str]):
117
+ self.doc_tokens.append(tokens)
118
+ self.doc_lens.append(len(tokens))
119
+ self.n_docs += 1
120
+ seen = set(tokens)
121
+ for t in seen:
122
+ self.doc_freqs[t] += 1
123
+ self.avg_dl = sum(self.doc_lens) / max(1, self.n_docs)
124
+
125
+ def score(self, query_tokens: list[str], doc_idx: int) -> float:
126
+ doc_toks = self.doc_tokens[doc_idx]
127
+ dl = self.doc_lens[doc_idx]
128
+ tf_map = Counter(doc_toks)
129
+ score = 0.0
130
+ for qt in query_tokens:
131
+ if qt not in tf_map:
132
+ continue
133
+ tf = tf_map[qt]
134
+ df = self.doc_freqs.get(qt, 0)
135
+ idf = math.log((self.n_docs - df + 0.5) / (df + 0.5) + 1.0)
136
+ numerator = tf * (self.k1 + 1)
137
+ denominator = tf + self.k1 * (1 - self.b + self.b * dl / max(1, self.avg_dl))
138
+ score += idf * numerator / denominator
139
+ return score
140
+
141
+ def search(self, query: str, k: int = 10) -> list[tuple[int, float]]:
142
+ query_tokens = _tokenize(query)
143
+ scores = [(i, self.score(query_tokens, i)) for i in range(self.n_docs)]
144
+ scores.sort(key=lambda x: x[1], reverse=True)
145
+ return [(i, s) for i, s in scores[:k] if s > 0]
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Embedding (vector similarity — hash-based fallback or fastembed)
150
+ # ---------------------------------------------------------------------------
151
+ _EMBED_MODEL = None
152
+ _EMBED_DIM = 384
153
+
154
+
155
+ def _get_embedder():
156
+ global _EMBED_MODEL, _EMBED_DIM
157
+ if _EMBED_MODEL is not None:
158
+ return _EMBED_MODEL
159
+ try:
160
+ from fastembed import TextEmbedding
161
+ model_name = os.getenv("SCIMAP_EMBED_MODEL", "BAAI/bge-small-en-v1.5")
162
+ _EMBED_MODEL = TextEmbedding(model_name=model_name)
163
+ test = list(_EMBED_MODEL.embed(["test"]))[0]
164
+ _EMBED_DIM = len(test)
165
+ log.info("fastembed loaded: model=%s, dim=%d", model_name, _EMBED_DIM)
166
+ return _EMBED_MODEL
167
+ except Exception as e:
168
+ log.warning("fastembed unavailable (%s), using local fallback", e)
169
+ return None
170
+
171
+
172
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
173
+
174
+
175
+ def _local_vector(text: str) -> list[float]:
176
+ vec = [0.0] * _EMBED_DIM
177
+ toks = _TOKEN_RE.findall((text or "").lower())
178
+ if not toks:
179
+ vec[0] = 1.0
180
+ return vec
181
+ for tok in toks:
182
+ padded = f"#{tok}#"
183
+ ngrams = [padded[i:i+3] for i in range(max(1, len(padded) - 2))]
184
+ for feat in (tok, *ngrams):
185
+ h = hashlib.sha1(feat.encode()).digest()
186
+ bucket = int.from_bytes(h[:4], "big") % _EMBED_DIM
187
+ sign = 1.0 if (h[4] & 1) else -1.0
188
+ vec[bucket] += sign
189
+ norm = math.sqrt(sum(v * v for v in vec))
190
+ if norm == 0:
191
+ vec[0] = 1.0
192
+ return vec
193
+ return [v / norm for v in vec]
194
+
195
+
196
+ def embed(texts: list[str]) -> list[list[float]]:
197
+ model = _get_embedder()
198
+ if model is not None:
199
+ embeddings = list(model.embed(texts))
200
+ return [emb.tolist() for emb in embeddings]
201
+ return [_local_vector(t) for t in texts]
202
+
203
+
204
+ def cosine_sim(a: list[float], b: list[float]) -> float:
205
+ dot = sum(x * y for x, y in zip(a, b))
206
+ na = math.sqrt(sum(x * x for x in a))
207
+ nb = math.sqrt(sum(x * x for x in b))
208
+ if na == 0 or nb == 0:
209
+ return 0.0
210
+ return dot / (na * nb)
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Date/temporal extraction
215
+ # ---------------------------------------------------------------------------
216
+ _DATE_PATTERNS = [
217
+ # "March 15th", "April 3rd", "June 10"
218
+ re.compile(r'\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})(?:st|nd|rd|th)?\b', re.I),
219
+ # "03/15/2023", "2023-03-15"
220
+ re.compile(r'\b(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b'),
221
+ re.compile(r'\b(\d{1,2})[-/](\d{1,2})[-/](\d{4})\b'),
222
+ # Relative: "last week", "two weeks ago", "3 months"
223
+ re.compile(r'\b(\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+(days?|weeks?|months?|years?)\s*(ago|before|after|later)?\b', re.I),
224
+ # Days of week
225
+ re.compile(r'\b(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b', re.I),
226
+ # Times
227
+ re.compile(r'\b(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)?\b'),
228
+ re.compile(r'\b(\d{1,2})\s*(AM|PM|am|pm)\b'),
229
+ ]
230
+
231
+
232
+ def _extract_dates(text: str) -> list[str]:
233
+ """Extract date/time mentions from text."""
234
+ dates = []
235
+ for pat in _DATE_PATTERNS:
236
+ for m in pat.finditer(text):
237
+ dates.append(m.group(0).lower())
238
+ return dates
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Entity extraction (names, proper nouns, key terms)
243
+ # ---------------------------------------------------------------------------
244
+ _ENTITY_RE = re.compile(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b")
245
+ _NAME_STOPWORDS = frozenset({
246
+ "The", "This", "That", "These", "Those", "It", "Its", "They", "Their",
247
+ "We", "Our", "He", "She", "His", "Her", "You", "Your", "My", "In",
248
+ "On", "At", "By", "For", "With", "From", "To", "And", "But", "Or",
249
+ "If", "When", "Where", "What", "How", "Why", "Who", "Which", "Each",
250
+ "Every", "Some", "Any", "All", "Most", "Many", "Few", "No", "Not",
251
+ "Also", "However", "Therefore", "Furthermore", "Moreover", "Thus",
252
+ "Here", "There", "Now", "Then", "After", "Before", "While", "Since",
253
+ "Sure", "Yes", "No", "Well", "Great", "Good", "Thanks", "Thank",
254
+ "Hi", "Hello", "Hey", "Okay", "Ok", "Right", "Let", "Can", "Could",
255
+ "Would", "Should", "May", "Might", "Will", "Shall", "Do", "Does",
256
+ "Did", "Have", "Has", "Had", "Are", "Is", "Was", "Were", "Be",
257
+ "Been", "Being", "First", "Last", "Next", "New", "Old", "Just",
258
+ "Really", "Actually", "Definitely", "Absolutely", "Recently",
259
+ })
260
+
261
+
262
+ def _extract_entities(text: str) -> list[str]:
263
+ """Extract proper nouns and key entities."""
264
+ entities = []
265
+ for m in _ENTITY_RE.finditer(text):
266
+ ent = m.group(1)
267
+ if ent not in _NAME_STOPWORDS and len(ent) > 1:
268
+ entities.append(ent.lower())
269
+ # Also extract quoted terms
270
+ for m in re.finditer(r"['\"]([^'\"]{2,40})['\"]", text):
271
+ entities.append(m.group(1).lower())
272
+ return list(set(entities))
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Preference/opinion extraction
277
+ # ---------------------------------------------------------------------------
278
+ _PREF_PATTERNS = [
279
+ re.compile(r"\b(?:i|we)\s+(?:prefer|like|love|enjoy|hate|dislike|want|need|use|chose|picked|switched to|started using|recommend|always|usually|typically)\s+(.{3,80}?)(?:\.|,|!|\?|$)", re.I),
280
+ re.compile(r"\b(?:my|our)\s+(?:favorite|preferred|go-to|usual|regular|default)\s+(.{3,60}?)(?:\.|,|!|\?|$)", re.I),
281
+ re.compile(r"\b(?:i'm|i am)\s+(?:a fan of|into|interested in|passionate about|obsessed with|addicted to)\s+(.{3,50}?)(?:\.|,|!|\?|$)", re.I),
282
+ re.compile(r"\b(?:i|we)\s+(?:always|usually|typically|normally|generally)\s+(?:go with|choose|pick|get|order|buy|use|watch|listen to|read|play|eat|drink|wear)\s+(.{3,60}?)(?:\.|,|!|\?|$)", re.I),
283
+ re.compile(r"\b(?:i've been|i have been|i started)\s+(?:using|reading|watching|playing|eating|drinking|wearing|going to|listening to)\s+(.{3,60}?)(?:\.|,|!|\?|$)", re.I),
284
+ ]
285
+
286
+
287
+ def _extract_preferences(text: str) -> list[str]:
288
+ """Extract preference/opinion statements."""
289
+ prefs = []
290
+ for pat in _PREF_PATTERNS:
291
+ for m in pat.finditer(text):
292
+ prefs.append(m.group(0).lower().strip())
293
+ return prefs
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Conversation-aware chunking
298
+ # ---------------------------------------------------------------------------
299
+ def chunk_conversation(text: str, source_title: str = "") -> list[dict]:
300
+ """Chunk a conversation into meaningful units.
301
+
302
+ Strategy:
303
+ - If the text looks like a conversation (has "User:" / "Assistant:" markers),
304
+ chunk by exchange (user message + assistant response = 1 chunk).
305
+ - Additionally create a "facts" chunk that extracts key facts, dates, entities,
306
+ and preferences from the entire conversation.
307
+ - For non-conversation text, use paragraph-level chunking.
308
+ """
309
+ chunks = []
310
+ chunk_idx = 0
311
+
312
+ # Detect if this is a conversation
313
+ is_conversation = bool(re.search(r'^(User|Assistant|Human|AI):', text, re.M))
314
+
315
+ if is_conversation:
316
+ # Split into turns
317
+ turn_pattern = re.compile(r'^(User|Assistant|Human|AI):\s*', re.M)
318
+ parts = turn_pattern.split(text)
319
+ # parts = ['', 'User', content, 'Assistant', content, ...]
320
+
321
+ current_exchange = []
322
+ exchanges = []
323
+ i = 1 # skip empty first part
324
+ while i < len(parts) - 1:
325
+ role = parts[i].lower()
326
+ content = parts[i + 1].strip()
327
+ current_exchange.append(f"{role}: {content}")
328
+ # End exchange after assistant response
329
+ if role in ("assistant", "ai") and current_exchange:
330
+ exchanges.append("\n".join(current_exchange))
331
+ current_exchange = []
332
+ i += 2
333
+ if current_exchange:
334
+ exchanges.append("\n".join(current_exchange))
335
+
336
+ # Create chunks from exchanges (group 2-3 exchanges per chunk for context)
337
+ group_size = 2
338
+ for gi in range(0, len(exchanges), group_size):
339
+ group = exchanges[gi:gi + group_size]
340
+ content = "\n\n".join(group)
341
+ words = content.split()
342
+ entities = _extract_entities(content)
343
+ dates = _extract_dates(content)
344
+ prefs = _extract_preferences(content)
345
+
346
+ chunks.append({
347
+ "content": content,
348
+ "token_count": len(words),
349
+ "span": [0, len(words)],
350
+ "chunk_index": chunk_idx,
351
+ "community_id": chunk_idx,
352
+ "entities": entities,
353
+ "dates": dates,
354
+ "preferences": prefs,
355
+ "type": "exchange",
356
+ })
357
+ chunk_idx += 1
358
+
359
+ # Create a "facts summary" chunk with all extracted metadata
360
+ all_entities = []
361
+ all_dates = []
362
+ all_prefs = []
363
+ for c in chunks:
364
+ all_entities.extend(c["entities"])
365
+ all_dates.extend(c["dates"])
366
+ all_prefs.extend(c["preferences"])
367
+
368
+ if all_entities or all_dates or all_prefs:
369
+ facts_parts = []
370
+ if all_entities:
371
+ facts_parts.append("Entities mentioned: " + ", ".join(set(all_entities)))
372
+ if all_dates:
373
+ facts_parts.append("Dates/times: " + ", ".join(set(all_dates)))
374
+ if all_prefs:
375
+ facts_parts.append("Preferences: " + "; ".join(set(all_prefs)))
376
+ facts_content = f"[Session facts for {source_title}] " + ". ".join(facts_parts)
377
+ chunks.append({
378
+ "content": facts_content,
379
+ "token_count": len(facts_content.split()),
380
+ "span": [0, 0],
381
+ "chunk_index": chunk_idx,
382
+ "community_id": chunk_idx,
383
+ "entities": list(set(all_entities)),
384
+ "dates": list(set(all_dates)),
385
+ "preferences": list(set(all_prefs)),
386
+ "type": "facts",
387
+ })
388
+ chunk_idx += 1
389
+ else:
390
+ # Non-conversation: paragraph-level chunking
391
+ paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
392
+ # Group small paragraphs
393
+ current = []
394
+ current_words = 0
395
+ for para in paragraphs:
396
+ pw = len(para.split())
397
+ if current_words + pw > 256 and current:
398
+ content = "\n\n".join(current)
399
+ chunks.append({
400
+ "content": content,
401
+ "token_count": current_words,
402
+ "span": [0, current_words],
403
+ "chunk_index": chunk_idx,
404
+ "community_id": chunk_idx,
405
+ "entities": _extract_entities(content),
406
+ "dates": _extract_dates(content),
407
+ "preferences": _extract_preferences(content),
408
+ "type": "paragraph",
409
+ })
410
+ chunk_idx += 1
411
+ current = []
412
+ current_words = 0
413
+ current.append(para)
414
+ current_words += pw
415
+ if current:
416
+ content = "\n\n".join(current)
417
+ chunks.append({
418
+ "content": content,
419
+ "token_count": current_words,
420
+ "span": [0, current_words],
421
+ "chunk_index": chunk_idx,
422
+ "community_id": chunk_idx,
423
+ "entities": _extract_entities(content),
424
+ "dates": _extract_dates(content),
425
+ "preferences": _extract_preferences(content),
426
+ "type": "paragraph",
427
+ })
428
+ chunk_idx += 1
429
+
430
+ return chunks
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Leiden chunker (when deps available)
435
+ # ---------------------------------------------------------------------------
436
+ _SENT_RE = re.compile(r'(?<=[.!?])\s+(?=[A-Z])|(?<=\n)\s*(?=\S)|(?<=\.)\s*\n')
437
+ _MIN_SENTENCES = 8
438
+
439
+
440
+ def leiden_chunk(text: str) -> list[dict]:
441
+ """Leiden community-detection chunking. Falls back to conversation chunking."""
442
+ try:
443
+ import igraph as ig
444
+ import leidenalg
445
+ import numpy as np
446
+ except ImportError:
447
+ return [] # Signal that Leiden is unavailable
448
+
449
+ sentences = _SENT_RE.split(text.strip())
450
+ sentences = [s.strip() for s in sentences if s and s.strip()]
451
+ if len(sentences) < _MIN_SENTENCES:
452
+ return []
453
+
454
+ # Build sentence nodes with embeddings
455
+ embeddings = embed(sentences)
456
+ emb_array = np.array(embeddings, dtype=np.float32)
457
+ norms = np.linalg.norm(emb_array, axis=1, keepdims=True)
458
+ norms = np.where(norms == 0, 1, norms)
459
+ emb_array = emb_array / norms
460
+ sim_matrix = (emb_array @ emb_array.T).tolist()
461
+
462
+ n = len(sentences)
463
+ g = ig.Graph(n=n, directed=False)
464
+ edges, weights = [], []
465
+
466
+ for i in range(n):
467
+ for j in range(i + 1, n):
468
+ sim = sim_matrix[i][j]
469
+ dist = abs(i - j)
470
+ if dist <= 3:
471
+ sim += 0.2 * (1 - dist / 4)
472
+ if sim >= 0.3:
473
+ edges.append((i, j))
474
+ weights.append(sim)
475
+
476
+ if not edges:
477
+ return []
478
+
479
+ g.add_edges(edges)
480
+ g.es["weight"] = weights
481
+
482
+ total_words = sum(len(s.split()) for s in sentences)
483
+ expected = max(1, total_words // 256)
484
+ resolution = max(0.5, min(3.0, expected / max(1, n / 10)))
485
+
486
+ partition = leidenalg.find_partition(
487
+ g, leidenalg.RBConfigurationVertexPartition,
488
+ weights="weight", resolution_parameter=resolution,
489
+ n_iterations=-1, seed=42)
490
+
491
+ chunks = []
492
+ for ci, comm in enumerate(partition):
493
+ if not comm:
494
+ continue
495
+ comm_sorted = sorted(comm)
496
+ content = " ".join(sentences[i] for i in comm_sorted)
497
+ wc = sum(len(sentences[i].split()) for i in comm_sorted)
498
+ chunks.append({
499
+ "content": content,
500
+ "token_count": wc,
501
+ "span": [0, wc],
502
+ "chunk_index": ci,
503
+ "community_id": ci,
504
+ "entities": _extract_entities(content),
505
+ "dates": _extract_dates(content),
506
+ "preferences": _extract_preferences(content),
507
+ "type": "leiden",
508
+ })
509
+
510
+ return chunks
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # Knowledge graph operations
515
+ # ---------------------------------------------------------------------------
516
+ def _gen_id(content: str) -> str:
517
+ return hashlib.sha256(content.encode()).hexdigest()[:16]
518
+
519
+
520
+ def add_knowledge(text: str, source_title: str = "untitled") -> dict:
521
+ """Ingest text: chunk, embed, build graph edges, persist."""
522
+ chunks_store = _load_json(_CHUNKS_FILE) or []
523
+ edges_store = _load_json(_EDGES_FILE) or []
524
+ sources_store = _load_json(_SOURCES_FILE) or []
525
+
526
+ source_id = _gen_id(f"{source_title}:{text[:100]}")
527
+
528
+ # Remove existing data for this source (idempotent re-index)
529
+ chunks_store = [c for c in chunks_store if c.get("source_id") != source_id]
530
+ edges_store = [e for e in edges_store if e.get("source_id") != source_id]
531
+
532
+ # Try Leiden first, fall back to conversation-aware chunking
533
+ pieces = leiden_chunk(text)
534
+ if not pieces:
535
+ pieces = chunk_conversation(text, source_title)
536
+
537
+ if not pieces:
538
+ return {"source_id": source_id, "chunks": 0, "edges": 0}
539
+
540
+ # Embed
541
+ vectors = embed([p["content"] for p in pieces])
542
+ chunk_ids = []
543
+ for piece, vec in zip(pieces, vectors):
544
+ cid = _gen_id(f"{source_id}:{piece['chunk_index']}")
545
+ chunk_ids.append(cid)
546
+ chunks_store.append({
547
+ "id": cid,
548
+ "source_id": source_id,
549
+ "source_title": source_title,
550
+ "content": piece["content"],
551
+ "embedding": vec,
552
+ "token_count": piece["token_count"],
553
+ "chunk_index": piece["chunk_index"],
554
+ "community_id": piece["community_id"],
555
+ "entities": piece.get("entities", []),
556
+ "dates": piece.get("dates", []),
557
+ "preferences": piece.get("preferences", []),
558
+ "type": piece.get("type", "unknown"),
559
+ "span": piece["span"],
560
+ })
561
+
562
+ # Build edges
563
+ new_edges = []
564
+ for i in range(len(chunk_ids) - 1):
565
+ new_edges.append({"source_id": source_id, "from": chunk_ids[i], "to": chunk_ids[i+1], "type": "next", "weight": 1.0})
566
+ new_edges.append({"source_id": source_id, "from": chunk_ids[i+1], "to": chunk_ids[i], "type": "prev", "weight": 1.0})
567
+
568
+ # Similarity edges
569
+ for i in range(len(vectors)):
570
+ sims = []
571
+ for j in range(len(vectors)):
572
+ if i == j or abs(i - j) == 1:
573
+ continue
574
+ s = cosine_sim(vectors[i], vectors[j])
575
+ if s >= 0.7:
576
+ sims.append((j, s))
577
+ sims.sort(key=lambda x: x[1], reverse=True)
578
+ for j, score in sims[:5]:
579
+ new_edges.append({"source_id": source_id, "from": chunk_ids[i], "to": chunk_ids[j], "type": "similar", "weight": score})
580
+
581
+ # Entity co-occurrence edges (chunks sharing entities)
582
+ entity_to_chunks: dict[str, list[str]] = defaultdict(list)
583
+ for cid, piece in zip(chunk_ids, pieces):
584
+ for ent in piece.get("entities", []):
585
+ entity_to_chunks[ent].append(cid)
586
+ for ent, cids in entity_to_chunks.items():
587
+ if len(cids) > 1 and len(cids) <= 10:
588
+ for i in range(len(cids)):
589
+ for j in range(i + 1, len(cids)):
590
+ new_edges.append({"source_id": source_id, "from": cids[i], "to": cids[j], "type": "entity", "weight": 0.6})
591
+
592
+ edges_store.extend(new_edges)
593
+ sources_store = [s for s in sources_store if s["id"] != source_id]
594
+ sources_store.append({"id": source_id, "title": source_title, "chunks": len(pieces)})
595
+
596
+ _save_json(_CHUNKS_FILE, chunks_store)
597
+ _save_json(_EDGES_FILE, edges_store)
598
+ _save_json(_SOURCES_FILE, sources_store)
599
+
600
+ return {"source_id": source_id, "chunks": len(pieces), "edges": len(new_edges)}
601
+
602
+
603
+ def search_knowledge(query: str, k: int = 8) -> list[dict]:
604
+ """Hybrid search: BM25 + vector + entity/date matching."""
605
+ chunks_store = _load_json(_CHUNKS_FILE) or []
606
+ if not chunks_store:
607
+ return []
608
+
609
+ # Build BM25 index
610
+ bm25 = BM25Index()
611
+ for c in chunks_store:
612
+ bm25.add(_tokenize(c["content"]))
613
+
614
+ # BM25 scores
615
+ bm25_results = bm25.search(query, k=k * 3)
616
+ bm25_scores = {i: s for i, s in bm25_results}
617
+
618
+ # Vector scores
619
+ qvec = embed([query])[0]
620
+ vec_scores = {}
621
+ for i, c in enumerate(chunks_store):
622
+ vec_scores[i] = cosine_sim(qvec, c["embedding"])
623
+
624
+ # Entity/date bonus: if query mentions an entity/date, boost chunks containing it
625
+ query_entities = _extract_entities(query)
626
+ # Also extract quoted terms from query
627
+ for m in re.finditer(r"['\"]([^'\"]{2,40})['\"]", query):
628
+ query_entities.append(m.group(1).lower())
629
+ query_dates = _extract_dates(query)
630
+ query_lower = query.lower()
631
+
632
+ # Also extract key nouns from query that might be entity matches
633
+ query_keywords = [w for w in _tokenize(query) if len(w) > 3]
634
+
635
+ entity_scores = {}
636
+ for i, c in enumerate(chunks_store):
637
+ bonus = 0.0
638
+ chunk_entities = c.get("entities", [])
639
+ chunk_dates = c.get("dates", [])
640
+ chunk_prefs = c.get("preferences", [])
641
+ chunk_content_lower = c["content"].lower()
642
+
643
+ # Entity overlap
644
+ for qe in query_entities:
645
+ if qe in chunk_entities or qe in chunk_content_lower:
646
+ bonus += 0.3
647
+
648
+ # Date overlap
649
+ for qd in query_dates:
650
+ if qd in chunk_dates or qd in chunk_content_lower:
651
+ bonus += 0.2
652
+
653
+ # Preference match: if query asks about preferences and chunk has them
654
+ is_pref_query = any(w in query_lower for w in ("prefer", "favorite", "like", "go-to", "usually", "recommend", "what do i", "what's my", "what is my"))
655
+ if is_pref_query:
656
+ if chunk_prefs:
657
+ bonus += 0.35
658
+ # Extra: check if the preference content matches query keywords
659
+ for pref in chunk_prefs:
660
+ for kw in query_keywords:
661
+ if kw in pref:
662
+ bonus += 0.2
663
+ # Boost chunks containing user statements (preferences come from user, not assistant)
664
+ if "user:" in chunk_content_lower:
665
+ bonus += 0.1
666
+
667
+ # Keyword presence bonus (exact term matching beyond BM25)
668
+ for kw in query_keywords:
669
+ if kw in chunk_content_lower:
670
+ bonus += 0.05
671
+
672
+ entity_scores[i] = bonus
673
+
674
+ # Character n-gram overlap (fuzzy matching — catches morphological variants)
675
+ def _char_ngrams(text: str, n: int = 3) -> set[str]:
676
+ text = text.lower()
677
+ return {text[i:i+n] for i in range(len(text) - n + 1)}
678
+
679
+ query_ngrams = _char_ngrams(query)
680
+ ngram_scores = {}
681
+ for i, c in enumerate(chunks_store):
682
+ chunk_ngrams = _char_ngrams(c["content"])
683
+ if query_ngrams and chunk_ngrams:
684
+ overlap = len(query_ngrams & chunk_ngrams)
685
+ ngram_scores[i] = overlap / len(query_ngrams)
686
+ else:
687
+ ngram_scores[i] = 0.0
688
+
689
+ # Fuse scores: weighted combination of 4 signals
690
+ max_bm25 = max(bm25_scores.values()) if bm25_scores else 1.0
691
+ max_vec = max(vec_scores.values()) if vec_scores else 1.0
692
+ max_ngram = max(ngram_scores.values()) if ngram_scores else 1.0
693
+
694
+ fused = {}
695
+ for i in range(len(chunks_store)):
696
+ bm25_norm = bm25_scores.get(i, 0) / max(max_bm25, 0.001)
697
+ vec_norm = vec_scores.get(i, 0) / max(max_vec, 0.001)
698
+ ent_score = entity_scores.get(i, 0)
699
+ ngram_norm = ngram_scores.get(i, 0) / max(max_ngram, 0.001)
700
+ fused[i] = 0.4 * bm25_norm + 0.15 * vec_norm + 0.25 * ent_score + 0.2 * ngram_norm
701
+
702
+ # Sort and return top-k
703
+ ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)
704
+
705
+ results = []
706
+ seen_content = set()
707
+ for i, score in ranked:
708
+ if score <= 0:
709
+ continue
710
+ c = chunks_store[i]
711
+ # Dedup by content similarity
712
+ content_key = c["content"][:100].lower()
713
+ if content_key in seen_content:
714
+ continue
715
+ seen_content.add(content_key)
716
+ results.append({
717
+ "chunk_id": c["id"],
718
+ "content": c["content"],
719
+ "source_title": c["source_title"],
720
+ "score": round(score, 4),
721
+ "community_id": c["community_id"],
722
+ "entities": c.get("entities", []),
723
+ "type": c.get("type", "unknown"),
724
+ })
725
+ if len(results) >= k:
726
+ break
727
+
728
+ return results
729
+
730
+
731
+ def graph_walk(chunk_id: str, hops: int = 2, k: int = 10) -> list[dict]:
732
+ """BFS graph traversal from a starting chunk."""
733
+ chunks_store = _load_json(_CHUNKS_FILE) or []
734
+ edges_store = _load_json(_EDGES_FILE) or []
735
+
736
+ chunk_map = {c["id"]: c for c in chunks_store}
737
+ if chunk_id not in chunk_map:
738
+ return []
739
+
740
+ visited = {chunk_id}
741
+ frontier = [(chunk_id, 0, 1.0, "origin")]
742
+ results = []
743
+
744
+ while frontier:
745
+ current, depth, path_weight, edge_type = frontier.pop(0)
746
+ if current != chunk_id:
747
+ c = chunk_map.get(current)
748
+ if c:
749
+ results.append({
750
+ "chunk_id": current,
751
+ "content": c["content"],
752
+ "source_title": c["source_title"],
753
+ "distance": depth,
754
+ "path_type": edge_type,
755
+ "score": round(path_weight, 4),
756
+ "community_id": c["community_id"],
757
+ })
758
+
759
+ if depth < hops:
760
+ for e in edges_store:
761
+ if e["from"] == current and e["to"] not in visited:
762
+ visited.add(e["to"])
763
+ frontier.append((e["to"], depth + 1, path_weight * e["weight"], e["type"]))
764
+
765
+ results.sort(key=lambda x: x["score"], reverse=True)
766
+ return results[:k]
767
+
768
+
769
+ def get_context(query: str, k: int = 5, hops: int = 1, min_source_pct: float = 0.75) -> list[dict]:
770
+ """Combined search + graph walk. Returns best chunk per source until coverage target met.
771
+
772
+ Retrieves all scored chunks, then picks best chunk from each source (session).
773
+ Keeps adding sources until min_source_pct of total sources are covered.
774
+ """
775
+ sources_store = _load_json(_SOURCES_FILE) or []
776
+ total_sources = len(sources_store)
777
+ target_sources = max(k, int(total_sources * min_source_pct)) if total_sources > 0 else k
778
+
779
+ # Score ALL chunks
780
+ chunks_store = _load_json(_CHUNKS_FILE) or []
781
+ all_hits = search_knowledge(query, k=len(chunks_store))
782
+ if not all_hits:
783
+ return []
784
+
785
+ # Graph walk from top hits
786
+ seen_ids = {h["chunk_id"] for h in all_hits}
787
+ for top_hit in all_hits[:3]:
788
+ walked = graph_walk(top_hit["chunk_id"], hops=hops, k=k)
789
+ for w in walked:
790
+ if w["chunk_id"] not in seen_ids:
791
+ w["score"] = w["score"] * 0.5
792
+ all_hits.append(w)
793
+ seen_ids.add(w["chunk_id"])
794
+
795
+ # Source-diverse: best chunk per source
796
+ source_best: dict[str, dict] = {}
797
+ for h in all_hits:
798
+ src = h.get("source_title", "")
799
+ if src not in source_best or h["score"] > source_best[src]["score"]:
800
+ source_best[src] = h
801
+
802
+ sources_ranked = sorted(source_best.keys(), key=lambda s: source_best[s]["score"], reverse=True)
803
+
804
+ selected = []
805
+ for src in sources_ranked:
806
+ if len(selected) >= target_sources:
807
+ break
808
+ selected.append(source_best[src])
809
+
810
+ return selected
811
+
812
+
813
+ def list_sources() -> list[dict]:
814
+ return _load_json(_SOURCES_FILE) or []
815
+
816
+
817
+ def delete_source(source_id: str) -> dict:
818
+ chunks_store = _load_json(_CHUNKS_FILE) or []
819
+ edges_store = _load_json(_EDGES_FILE) or []
820
+ sources_store = _load_json(_SOURCES_FILE) or []
821
+
822
+ before = len(chunks_store)
823
+ chunks_store = [c for c in chunks_store if c.get("source_id") != source_id]
824
+ edges_store = [e for e in edges_store if e.get("source_id") != source_id]
825
+ sources_store = [s for s in sources_store if s.get("id") != source_id]
826
+
827
+ _save_json(_CHUNKS_FILE, chunks_store)
828
+ _save_json(_EDGES_FILE, edges_store)
829
+ _save_json(_SOURCES_FILE, sources_store)
830
+
831
+ return {"deleted_chunks": before - len(chunks_store)}
832
+
833
+
834
+ # ---------------------------------------------------------------------------
835
+ # JSON-line RPC protocol
836
+ # ---------------------------------------------------------------------------
837
+ METHODS = {
838
+ "add_knowledge": lambda p: add_knowledge(p["text"], p.get("title", "untitled")),
839
+ "search": lambda p: search_knowledge(p["query"], p.get("k", 8)),
840
+ "graph_walk": lambda p: graph_walk(p["chunk_id"], p.get("hops", 2), p.get("k", 10)),
841
+ "get_context": lambda p: get_context(p["query"], p.get("k", 5), p.get("hops", 1), p.get("min_source_pct", 0.15)),
842
+ "list_sources": lambda p: list_sources(),
843
+ "delete_source": lambda p: delete_source(p["source_id"]),
844
+ "ping": lambda p: {"status": "ok"},
845
+ }
846
+
847
+
848
+ def main():
849
+ log.info("scimap engine started (data_dir=%s)", _DATA_DIR)
850
+ _get_embedder()
851
+
852
+ for line in sys.stdin:
853
+ line = line.strip()
854
+ if not line:
855
+ continue
856
+ try:
857
+ req = json.loads(line)
858
+ except json.JSONDecodeError as e:
859
+ sys.stdout.write(json.dumps({"id": None, "error": f"invalid JSON: {e}"}) + "\n")
860
+ sys.stdout.flush()
861
+ continue
862
+
863
+ req_id = req.get("id")
864
+ method = req.get("method", "")
865
+ params = req.get("params", {})
866
+
867
+ if method not in METHODS:
868
+ resp = {"id": req_id, "error": f"unknown method: {method}"}
869
+ else:
870
+ try:
871
+ result = METHODS[method](params)
872
+ resp = {"id": req_id, "result": result}
873
+ except Exception as e:
874
+ log.exception("method %s failed", method)
875
+ resp = {"id": req_id, "error": str(e)}
876
+
877
+ sys.stdout.write(json.dumps(resp, default=str) + "\n")
878
+ sys.stdout.flush()
879
+
880
+
881
+ if __name__ == "__main__":
882
+ main()
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: swafra
3
+ Version: 0.1.0
4
+ Summary: MCP server engine for swafra — Leiden-chunked, graph-linked semantic memory
5
+ Author: kunal12203
6
+ License: MIT
7
+ Keywords: mcp,knowledge-graph,leiden,semantic-memory,claude
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: fastembed>=0.4.0
16
+ Requires-Dist: igraph>=0.11.0
17
+ Requires-Dist: leidenalg>=0.10.0
18
+ Requires-Dist: numpy>=1.26.0
19
+ Dynamic: author
20
+ Dynamic: classifier
21
+ Dynamic: description
22
+ Dynamic: description-content-type
23
+ Dynamic: keywords
24
+ Dynamic: license
25
+ Dynamic: requires-dist
26
+ Dynamic: requires-python
27
+ Dynamic: summary
28
+
29
+ # swafra
30
+
31
+ MCP server for knowledge graphs — Leiden-chunked, graph-linked semantic memory for Claude AI and ChatGPT.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install swafra
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ swafra
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ swafra/__init__.py
5
+ swafra/engine.py
6
+ swafra.egg-info/PKG-INFO
7
+ swafra.egg-info/SOURCES.txt
8
+ swafra.egg-info/dependency_links.txt
9
+ swafra.egg-info/entry_points.txt
10
+ swafra.egg-info/requires.txt
11
+ swafra.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ swafra = swafra.engine:main
@@ -0,0 +1,4 @@
1
+ fastembed>=0.4.0
2
+ igraph>=0.11.0
3
+ leidenalg>=0.10.0
4
+ numpy>=1.26.0
@@ -0,0 +1 @@
1
+ swafra