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/LICENSE +21 -0
- package/README.md +147 -0
- package/bin/cli.mjs +33 -0
- package/bin/install.mjs +36 -0
- package/kyp_mem/__init__.py +3 -0
- package/kyp_mem/__pycache__/__init__.cpython-314.pyc +0 -0
- package/kyp_mem/__pycache__/cli.cpython-314.pyc +0 -0
- package/kyp_mem/__pycache__/config.cpython-314.pyc +0 -0
- package/kyp_mem/__pycache__/ui.cpython-314.pyc +0 -0
- package/kyp_mem/__pycache__/vault.cpython-314.pyc +0 -0
- package/kyp_mem/cli.py +263 -0
- package/kyp_mem/config.py +31 -0
- package/kyp_mem/server.py +166 -0
- package/kyp_mem/static/index.html +1293 -0
- package/kyp_mem/ui.py +91 -0
- package/kyp_mem/vault.py +319 -0
- package/package.json +35 -0
- package/pyproject.toml +44 -0
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")
|
package/kyp_mem/vault.py
ADDED
|
@@ -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"]
|