kyp-mem 0.2.0

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.
package/kyp_mem/ui.py ADDED
@@ -0,0 +1,91 @@
1
+ """KYP-MEM web UI — Obsidian-like interface for browsing the vault."""
2
+
3
+ import webbrowser
4
+ from pathlib import Path
5
+ from fastapi import FastAPI
6
+ from fastapi.responses import HTMLResponse, JSONResponse
7
+ import uvicorn
8
+ from .config import get_vault_path
9
+ from .vault import Vault
10
+
11
+
12
+ def create_app(vault_path: str = None) -> FastAPI:
13
+ vault_path = vault_path or get_vault_path()
14
+ vault = Vault(vault_path)
15
+ app = FastAPI(title="KYP-MEM")
16
+
17
+ @app.get("/")
18
+ def index():
19
+ html_path = Path(__file__).parent / "static" / "index.html"
20
+ return HTMLResponse(html_path.read_text())
21
+
22
+ @app.get("/api/tree")
23
+ def tree():
24
+ return JSONResponse(vault.get_full_tree())
25
+
26
+ @app.get("/api/stats")
27
+ def stats():
28
+ return JSONResponse(vault.get_stats())
29
+
30
+ @app.get("/api/note/{path:path}")
31
+ def read_note(path: str):
32
+ note = vault.read(path)
33
+ if not note:
34
+ return JSONResponse({"error": "Not found"}, 404)
35
+ backlinks = vault.get_backlinks(path)
36
+ related = vault.get_related(path)
37
+ return JSONResponse({
38
+ "path": note.path,
39
+ "title": note.title,
40
+ "content": note.content,
41
+ "tags": note.tags,
42
+ "properties": note.properties,
43
+ "created": note.created,
44
+ "updated": note.updated,
45
+ "links": note.links,
46
+ "backlinks": backlinks,
47
+ "related": [{"path": p, "score": s, "title": vault.index.notes[p].title if p in vault.index.notes else p} for p, s in related],
48
+ })
49
+
50
+ @app.get("/api/search")
51
+ def search(q: str = "", tag: str = ""):
52
+ if not q and not tag:
53
+ return JSONResponse([])
54
+ if not q and tag:
55
+ paths = vault.get_notes_by_tag(tag)
56
+ return JSONResponse([
57
+ {"path": p, "score": 1.0, "snippet": "", "title": vault.index.notes[p].title if p in vault.index.notes else p}
58
+ for p in paths
59
+ ])
60
+ results = vault.search(q, tag or None)
61
+ return JSONResponse([
62
+ {"path": path, "score": score, "snippet": snippet, "title": vault.index.notes[path].title if path in vault.index.notes else path}
63
+ for path, score, snippet in results
64
+ ])
65
+
66
+ @app.get("/api/tags")
67
+ def tags():
68
+ return JSONResponse(vault.get_tags())
69
+
70
+ @app.get("/api/recent")
71
+ def recent(limit: int = 10):
72
+ notes = vault.get_recent(limit)
73
+ return JSONResponse([
74
+ {"path": n.path, "title": n.title, "updated": n.updated, "created": n.created, "tags": n.tags}
75
+ for n in notes
76
+ ])
77
+
78
+ @app.post("/api/reload")
79
+ def reload():
80
+ vault._load_all()
81
+ return JSONResponse({"ok": True, "stats": vault.get_stats()})
82
+
83
+ return app
84
+
85
+
86
+ def start_ui(port: int = 3333, vault_path: str = None, open_browser: bool = True):
87
+ app = create_app(vault_path)
88
+ print(f"\033[36mKYP-MEM\033[0m UI -> http://localhost:{port}")
89
+ if open_browser:
90
+ webbrowser.open(f"http://localhost:{port}")
91
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
@@ -0,0 +1,319 @@
1
+ """Vault — markdown file storage with frontmatter, wikilinks, and indexing."""
2
+
3
+ import re
4
+ import json
5
+ import yaml
6
+ from pathlib import Path
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from collections import defaultdict
10
+
11
+ FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
12
+ WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
13
+
14
+
15
+ @dataclass
16
+ class Note:
17
+ path: str
18
+ title: str
19
+ content: str
20
+ properties: dict = field(default_factory=dict)
21
+ tags: list = field(default_factory=list)
22
+ links: list = field(default_factory=list)
23
+ created: str = ""
24
+ updated: str = ""
25
+
26
+ @property
27
+ def folder(self) -> str:
28
+ parts = self.path.split("/")
29
+ return parts[0] if len(parts) > 1 else ""
30
+
31
+
32
+ def parse_note(path: str, raw: str) -> Note:
33
+ content = raw
34
+ properties = {}
35
+
36
+ fm_match = FRONTMATTER_RE.match(raw)
37
+ if fm_match:
38
+ try:
39
+ properties = yaml.safe_load(fm_match.group(1)) or {}
40
+ except yaml.YAMLError:
41
+ properties = {}
42
+ content = raw[fm_match.end():]
43
+
44
+ tags = properties.pop("tags", [])
45
+ if isinstance(tags, str):
46
+ tags = [t.strip() for t in tags.split(",")]
47
+
48
+ links = WIKILINK_RE.findall(content)
49
+ links = [l.split("#")[0].strip() for l in links]
50
+ links = list(set(links))
51
+
52
+ title = Path(path).stem
53
+ for line in content.strip().split("\n"):
54
+ if line.startswith("# "):
55
+ title = line[2:].strip()
56
+ break
57
+
58
+ created = properties.pop("created", "")
59
+ updated = properties.pop("updated", "")
60
+ if isinstance(created, datetime):
61
+ created = created.strftime("%Y-%m-%d")
62
+ if isinstance(updated, datetime):
63
+ updated = updated.strftime("%Y-%m-%d")
64
+
65
+ return Note(
66
+ path=path,
67
+ title=title,
68
+ content=content,
69
+ properties=properties,
70
+ tags=tags,
71
+ links=links,
72
+ created=str(created) if created else "",
73
+ updated=str(updated) if updated else "",
74
+ )
75
+
76
+
77
+ def serialize_note(note: Note) -> str:
78
+ fm = {}
79
+ if note.tags:
80
+ fm["tags"] = note.tags
81
+ if note.created:
82
+ fm["created"] = note.created
83
+ if note.updated:
84
+ fm["updated"] = note.updated
85
+ fm.update(note.properties)
86
+
87
+ parts = []
88
+ if fm:
89
+ parts.append("---")
90
+ parts.append(yaml.dump(fm, default_flow_style=None).strip())
91
+ parts.append("---")
92
+ parts.append("")
93
+ parts.append(note.content)
94
+ return "\n".join(parts)
95
+
96
+
97
+ class Index:
98
+ def __init__(self):
99
+ self.notes: dict[str, Note] = {}
100
+ self.backlinks: dict[str, set] = defaultdict(set)
101
+ self.tag_index: dict[str, set] = defaultdict(set)
102
+ self._word_index: dict[str, set] = defaultdict(set)
103
+ self._name_to_path: dict[str, str] = {}
104
+
105
+ def rebuild(self, notes: dict[str, Note]):
106
+ self.notes = notes
107
+ self.backlinks = defaultdict(set)
108
+ self.tag_index = defaultdict(set)
109
+ self._word_index = defaultdict(set)
110
+ self._name_to_path = {}
111
+
112
+ for path, note in notes.items():
113
+ self._name_to_path[Path(path).stem.lower()] = path
114
+ self._name_to_path[note.title.lower()] = path
115
+
116
+ for path, note in notes.items():
117
+ for link in note.links:
118
+ target = self._name_to_path.get(link.lower())
119
+ if target:
120
+ self.backlinks[target].add(path)
121
+
122
+ for tag in note.tags:
123
+ self.tag_index[tag.lower()].add(path)
124
+
125
+ text = f"{note.title} {note.content} {' '.join(note.tags)}".lower()
126
+ for word in set(re.findall(r"\w+", text)):
127
+ self._word_index[word].add(path)
128
+
129
+ def search(self, query: str, tag_filter: str = None) -> list[tuple[str, float, str]]:
130
+ query_words = re.findall(r"\w+", query.lower())
131
+ if not query_words:
132
+ return []
133
+
134
+ candidates = None
135
+ for word in query_words:
136
+ matching = set()
137
+ for idx_word, paths in self._word_index.items():
138
+ if word in idx_word:
139
+ matching |= paths
140
+ candidates = matching if candidates is None else candidates & matching
141
+
142
+ if not candidates:
143
+ return []
144
+
145
+ if tag_filter:
146
+ candidates &= self.tag_index.get(tag_filter.lower(), set())
147
+
148
+ results = []
149
+ for path in candidates:
150
+ note = self.notes[path]
151
+ text = f"{note.title}\n{note.content}"
152
+ score = sum(text.lower().count(w) for w in query_words) / max(len(text.split()), 1)
153
+
154
+ snippet = ""
155
+ lower_text = text.lower()
156
+ for w in query_words:
157
+ idx = lower_text.find(w)
158
+ if idx >= 0:
159
+ start = max(0, idx - 50)
160
+ end = min(len(text), idx + 80)
161
+ snippet = "..." + text[start:end].strip() + "..."
162
+ break
163
+
164
+ results.append((path, score, snippet))
165
+
166
+ results.sort(key=lambda x: -x[1])
167
+ return results[:20]
168
+
169
+ def get_related(self, path: str) -> list[tuple[str, float]]:
170
+ if path not in self.notes:
171
+ return []
172
+
173
+ note = self.notes[path]
174
+ scores: dict[str, float] = defaultdict(float)
175
+
176
+ for bl in self.backlinks.get(path, set()):
177
+ scores[bl] += 0.3
178
+
179
+ for link in note.links:
180
+ target = self._name_to_path.get(link.lower())
181
+ if target and target != path:
182
+ scores[target] += 0.25
183
+
184
+ for tag in note.tags:
185
+ for other_path in self.tag_index.get(tag.lower(), set()):
186
+ if other_path != path:
187
+ scores[other_path] += 0.2
188
+
189
+ folder = note.folder
190
+ if folder:
191
+ for other_path, other_note in self.notes.items():
192
+ if other_path != path and other_note.folder == folder:
193
+ scores[other_path] += 0.1
194
+
195
+ if not scores:
196
+ return []
197
+ max_score = max(scores.values())
198
+ results = [(p, round(s / max_score, 2)) for p, s in scores.items()]
199
+ results.sort(key=lambda x: -x[1])
200
+ return results[:15]
201
+
202
+
203
+ class Vault:
204
+ def __init__(self, vault_path: str):
205
+ self.root = Path(vault_path).expanduser().resolve()
206
+ self.root.mkdir(parents=True, exist_ok=True)
207
+ self.index = Index()
208
+ self._load_all()
209
+
210
+ def _load_all(self):
211
+ notes = {}
212
+ for md_file in self.root.rglob("*.md"):
213
+ rel = str(md_file.relative_to(self.root))
214
+ raw = md_file.read_text(encoding="utf-8")
215
+ notes[rel] = parse_note(rel, raw)
216
+ self.index.rebuild(notes)
217
+
218
+ def list_tree(self, path: str = "") -> dict:
219
+ base = self.root / path if path else self.root
220
+ if not base.exists():
221
+ return {"folders": [], "notes": []}
222
+ folders = sorted(d.name for d in base.iterdir() if d.is_dir() and not d.name.startswith("."))
223
+ notes = sorted(f.name for f in base.iterdir() if f.is_file() and f.suffix == ".md")
224
+ return {"folders": folders, "notes": notes}
225
+
226
+ def read(self, path: str) -> Note | None:
227
+ full = self.root / path
228
+ if not full.exists():
229
+ return None
230
+ raw = full.read_text(encoding="utf-8")
231
+ return parse_note(path, raw)
232
+
233
+ def write_note(self, path: str, content: str, tags: list = None, properties: dict = None):
234
+ full = self.root / path
235
+ full.parent.mkdir(parents=True, exist_ok=True)
236
+
237
+ now = datetime.now().strftime("%Y-%m-%d")
238
+ existing = self.read(path)
239
+ created = existing.created if existing else now
240
+
241
+ note = Note(
242
+ path=path,
243
+ title=Path(path).stem,
244
+ content=content,
245
+ tags=tags or [],
246
+ properties=properties or {},
247
+ created=created,
248
+ updated=now,
249
+ )
250
+
251
+ full.write_text(serialize_note(note), encoding="utf-8")
252
+ self._load_all()
253
+
254
+ def delete(self, path: str) -> bool:
255
+ full = self.root / path
256
+ if full.exists():
257
+ full.unlink()
258
+ # Clean up empty parent dirs
259
+ parent = full.parent
260
+ while parent != self.root and not any(parent.iterdir()):
261
+ parent.rmdir()
262
+ parent = parent.parent
263
+ self._load_all()
264
+ return True
265
+ return False
266
+
267
+ def search(self, query: str, tag: str = None) -> list:
268
+ return self.index.search(query, tag)
269
+
270
+ def get_tags(self) -> dict[str, int]:
271
+ return {tag: len(paths) for tag, paths in sorted(self.index.tag_index.items())}
272
+
273
+ def get_notes_by_tag(self, tag: str) -> list[str]:
274
+ return sorted(self.index.tag_index.get(tag.lower(), set()))
275
+
276
+ def get_related(self, path: str) -> list[tuple[str, float]]:
277
+ return self.index.get_related(path)
278
+
279
+ def get_backlinks(self, path: str) -> list[str]:
280
+ return sorted(self.index.backlinks.get(path, set()))
281
+
282
+ def get_recent(self, limit: int = 10) -> list[Note]:
283
+ notes = list(self.index.notes.values())
284
+ notes.sort(key=lambda n: n.updated or n.created or "", reverse=True)
285
+ return notes[:limit]
286
+
287
+ def get_full_tree(self) -> dict:
288
+ tree = {"name": "vault", "type": "folder", "children": []}
289
+ for path in sorted(self.index.notes):
290
+ parts = Path(path).parts
291
+ current = tree
292
+ for part in parts[:-1]:
293
+ existing = next((c for c in current["children"] if c["type"] == "folder" and c["name"] == part), None)
294
+ if not existing:
295
+ existing = {"name": part, "type": "folder", "children": []}
296
+ current["children"].append(existing)
297
+ current = existing
298
+ note = self.index.notes[path]
299
+ current["children"].append({
300
+ "name": parts[-1],
301
+ "type": "note",
302
+ "path": path,
303
+ "tags": note.tags,
304
+ })
305
+ return tree
306
+
307
+ def get_stats(self) -> dict:
308
+ all_tags = set()
309
+ all_links = 0
310
+ for note in self.index.notes.values():
311
+ all_tags.update(note.tags)
312
+ all_links += len(note.links)
313
+ return {
314
+ "notes": len(self.index.notes),
315
+ "folders": len(set(n.folder for n in self.index.notes.values() if n.folder)),
316
+ "tags": len(all_tags),
317
+ "links": all_links,
318
+ "backlinks": sum(len(v) for v in self.index.backlinks.values()),
319
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "kyp-mem",
3
+ "version": "0.2.0",
4
+ "description": "Know Your Project — Headless Obsidian for AI agents. MCP-powered knowledge base with neon web UI.",
5
+ "bin": {
6
+ "kyp-mem": "./bin/cli.mjs"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "kyp_mem/",
11
+ "pyproject.toml",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "postinstall": "node bin/install.mjs"
17
+ },
18
+ "keywords": [
19
+ "knowledge-base",
20
+ "obsidian",
21
+ "ai",
22
+ "mcp",
23
+ "claude",
24
+ "notes",
25
+ "wiki",
26
+ "second-brain"
27
+ ],
28
+ "author": "KDB AI Agency",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/Adhithya-Karthikeyan/KYP-MEM.git"
33
+ },
34
+ "homepage": "https://github.com/Adhithya-Karthikeyan/KYP-MEM"
35
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kyp-mem"
7
+ version = "0.2.0"
8
+ description = "Know Your Project — Headless Obsidian for AI agents. MCP-powered knowledge base with neon web UI."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ {name = "KDB AI Agency", email = "dfreakhitman@gmail.com"}
14
+ ]
15
+ keywords = ["knowledge-base", "obsidian", "ai", "mcp", "notes", "wiki", "second-brain"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Documentation",
26
+ "Topic :: Text Processing :: Markup :: Markdown",
27
+ ]
28
+ dependencies = [
29
+ "mcp>=1.0.0",
30
+ "pyyaml>=6.0",
31
+ "fastapi>=0.100.0",
32
+ "uvicorn>=0.20.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/kdbalagency/kyp-mem"
37
+ Repository = "https://github.com/kdbalagency/kyp-mem"
38
+ Issues = "https://github.com/kdbalagency/kyp-mem/issues"
39
+
40
+ [project.scripts]
41
+ kyp-mem = "kyp_mem.cli:main"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["kyp_mem"]