kyp-mem 0.7.3 → 0.8.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/bin/cli.mjs +15 -3
- package/kyp_mem/cli.py +39 -0
- package/kyp_mem/hooks.py +134 -34
- package/kyp_mem/server.py +26 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -26,13 +26,25 @@ function run(command, cmdArgs, stdio = "ignore") {
|
|
|
26
26
|
if (args[0] === "hook") {
|
|
27
27
|
const hookType = args[1];
|
|
28
28
|
const sessionDir = join(homedir(), ".kyp-mem", "sessions");
|
|
29
|
-
const sessionFile = join(sessionDir, "current.jsonl");
|
|
30
29
|
|
|
31
30
|
const chunks = [];
|
|
32
31
|
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
33
32
|
await new Promise((r) => process.stdin.on("end", r));
|
|
34
33
|
const raw = Buffer.concat(chunks).toString();
|
|
35
34
|
|
|
35
|
+
// Partition the activity log per Claude session id. Previously every session
|
|
36
|
+
// (across all projects) appended to one shared current.jsonl, so concurrent
|
|
37
|
+
// sessions interleaved and the Stop hook filed the whole batch under whichever
|
|
38
|
+
// project logged first — leaking foreign summaries into unrelated projects.
|
|
39
|
+
let sessionId = "";
|
|
40
|
+
try {
|
|
41
|
+
sessionId = ((JSON.parse(raw) || {}).session_id || "").toString();
|
|
42
|
+
} catch (_) {}
|
|
43
|
+
const safeId = sessionId.replace(/[^A-Za-z0-9_-]/g, "");
|
|
44
|
+
const sessionFile = safeId
|
|
45
|
+
? join(sessionDir, `current-${safeId}.jsonl`)
|
|
46
|
+
: join(sessionDir, "current.jsonl");
|
|
47
|
+
|
|
36
48
|
if (hookType === "user-prompt") {
|
|
37
49
|
try {
|
|
38
50
|
const data = JSON.parse(raw);
|
|
@@ -59,7 +71,7 @@ if (args[0] === "hook") {
|
|
|
59
71
|
const input = data.tool_input || {};
|
|
60
72
|
const rawResp = data.tool_response || "";
|
|
61
73
|
const resp = (typeof rawResp === "string" ? rawResp : JSON.stringify(rawResp)).slice(0, 2000);
|
|
62
|
-
const entry = { ts: new Date().toISOString(), tool, cwd: process.cwd() };
|
|
74
|
+
const entry = { ts: new Date().toISOString(), tool, cwd: process.env.CLAUDE_PROJECT_DIR || process.cwd() };
|
|
63
75
|
|
|
64
76
|
if (tool === "Edit" || tool === "Write") {
|
|
65
77
|
entry.file = input.file_path || "";
|
|
@@ -93,7 +105,7 @@ if (args[0] === "hook") {
|
|
|
93
105
|
const py = resolvePython();
|
|
94
106
|
if (py) {
|
|
95
107
|
const [cmd, pre] = py;
|
|
96
|
-
const r = run(cmd, [...pre, "-m", "kyp_mem.hooks", "stop"], "inherit");
|
|
108
|
+
const r = run(cmd, [...pre, "-m", "kyp_mem.hooks", "stop", safeId], "inherit");
|
|
97
109
|
process.exit(r.status ?? 0);
|
|
98
110
|
}
|
|
99
111
|
process.exit(0);
|
package/kyp_mem/cli.py
CHANGED
|
@@ -49,6 +49,10 @@ def main():
|
|
|
49
49
|
cfg_parser.add_argument("key", nargs="?", help="Config key (e.g. session_model)")
|
|
50
50
|
cfg_parser.add_argument("value", nargs="?", help="Value to set")
|
|
51
51
|
|
|
52
|
+
obj_parser = subparsers.add_parser("objective", help="Get or set a project's objective (injected at session start)")
|
|
53
|
+
obj_parser.add_argument("project", nargs="?", help="Project name (defaults to current directory name)")
|
|
54
|
+
obj_parser.add_argument("text", nargs="*", help="Objective text to set (omit to read the current objective)")
|
|
55
|
+
|
|
52
56
|
hook_parser = subparsers.add_parser("hook", help="Handle Claude Code hook events (internal)")
|
|
53
57
|
hook_sub = hook_parser.add_subparsers(dest="hook_command")
|
|
54
58
|
hook_sub.add_parser("session-start", help="Inject project context at session start")
|
|
@@ -79,6 +83,8 @@ def main():
|
|
|
79
83
|
_run_install_hooks(global_config=args.global_config, remove=args.remove)
|
|
80
84
|
elif args.command == "config":
|
|
81
85
|
_run_config(args.key, args.value)
|
|
86
|
+
elif args.command == "objective":
|
|
87
|
+
_run_objective(args.project, " ".join(args.text).strip())
|
|
82
88
|
elif args.command == "uninstall":
|
|
83
89
|
_run_uninstall(purge=args.purge)
|
|
84
90
|
elif args.command == "doctor":
|
|
@@ -460,6 +466,39 @@ def _run_config(key, value):
|
|
|
460
466
|
print(f" {G}✓{R} {key} = {value}")
|
|
461
467
|
|
|
462
468
|
|
|
469
|
+
def _run_objective(project, text):
|
|
470
|
+
from .config import get_vault_path
|
|
471
|
+
from .vault import Vault
|
|
472
|
+
|
|
473
|
+
project = project or Path.cwd().name
|
|
474
|
+
vault = Vault(get_vault_path())
|
|
475
|
+
path = f"{project}/Objective.md"
|
|
476
|
+
|
|
477
|
+
if not text:
|
|
478
|
+
note = vault.read(path)
|
|
479
|
+
print()
|
|
480
|
+
print(f" {C}KYP-MEM{R} — Objective for {G}{project}{R}")
|
|
481
|
+
print()
|
|
482
|
+
if not note:
|
|
483
|
+
print(f" {Y}(not set){R}")
|
|
484
|
+
print(f" {D} Set one: kyp-mem objective {project} \"<your goal>\"{R}")
|
|
485
|
+
else:
|
|
486
|
+
content = note.content.strip()
|
|
487
|
+
lines = content.split("\n")
|
|
488
|
+
if lines and lines[0].lstrip().startswith("# "):
|
|
489
|
+
content = "\n".join(lines[1:]).strip()
|
|
490
|
+
print(f" {content}")
|
|
491
|
+
print()
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
content = f"# Objective\n\n{text}\n"
|
|
495
|
+
vault.write_note(path, content, ["objective", project.lower().replace(" ", "-")], {})
|
|
496
|
+
print()
|
|
497
|
+
print(f" {G}✓{R} Objective saved for {G}{project}{R} ({path})")
|
|
498
|
+
print(f" {D} Injected at every session start.{R}")
|
|
499
|
+
print()
|
|
500
|
+
|
|
501
|
+
|
|
463
502
|
def _run_stats():
|
|
464
503
|
from .config import get_vault_path
|
|
465
504
|
from .vault import Vault
|
package/kyp_mem/hooks.py
CHANGED
|
@@ -10,6 +10,37 @@ from pathlib import Path
|
|
|
10
10
|
SESSION_DIR = Path.home() / ".kyp-mem" / "sessions"
|
|
11
11
|
CURRENT_SESSION = SESSION_DIR / "current.jsonl"
|
|
12
12
|
|
|
13
|
+
|
|
14
|
+
def _session_file(session_id):
|
|
15
|
+
"""Per-Claude-session activity log path.
|
|
16
|
+
|
|
17
|
+
Each Claude session gets its own ``current-<session_id>.jsonl`` so that
|
|
18
|
+
concurrent sessions in different projects never share one file. Falls back
|
|
19
|
+
to the legacy shared ``current.jsonl`` when no session id is available.
|
|
20
|
+
"""
|
|
21
|
+
if session_id:
|
|
22
|
+
safe = "".join(c for c in str(session_id) if c.isalnum() or c in "_-")
|
|
23
|
+
if safe:
|
|
24
|
+
return SESSION_DIR / f"current-{safe}.jsonl"
|
|
25
|
+
return CURRENT_SESSION
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _prune_stale_logs(max_age_days=3):
|
|
29
|
+
"""Remove orphaned activity logs left by sessions that never fired Stop
|
|
30
|
+
(crashes, kills) plus the legacy shared current.jsonl once it goes idle."""
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
cutoff = time.time() - max_age_days * 86400
|
|
34
|
+
try:
|
|
35
|
+
for f in SESSION_DIR.glob("current*.jsonl"):
|
|
36
|
+
try:
|
|
37
|
+
if f.stat().st_mtime < cutoff:
|
|
38
|
+
f.unlink(missing_ok=True)
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
13
44
|
MIN_ACTIONS = 5
|
|
14
45
|
CHARS_PER_TOKEN = 4
|
|
15
46
|
|
|
@@ -114,8 +145,42 @@ def _build_stats_line(project_name, injected_chars, session_ids):
|
|
|
114
145
|
return None
|
|
115
146
|
|
|
116
147
|
|
|
148
|
+
def _objective_note_path(project_name):
|
|
149
|
+
"""Canonical vault path for a project's objective note."""
|
|
150
|
+
return f"{project_name}/Objective.md"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _find_objective_path(vault, project_name):
|
|
154
|
+
"""Case-insensitive lookup of the objective note (vault casing may differ
|
|
155
|
+
from the cwd basename on case-insensitive filesystems)."""
|
|
156
|
+
target = f"{project_name}/objective.md".lower()
|
|
157
|
+
for p in vault.index.notes:
|
|
158
|
+
if p.lower() == target:
|
|
159
|
+
return p
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _read_objective(vault, project_name):
|
|
164
|
+
"""Return the objective text for a project, or None if not set.
|
|
165
|
+
|
|
166
|
+
Strips an optional leading ``# ...`` heading so only the objective body
|
|
167
|
+
is surfaced.
|
|
168
|
+
"""
|
|
169
|
+
path = _find_objective_path(vault, project_name)
|
|
170
|
+
if not path:
|
|
171
|
+
return None
|
|
172
|
+
note = vault.read(path)
|
|
173
|
+
if not note:
|
|
174
|
+
return None
|
|
175
|
+
lines = note.content.strip().split("\n")
|
|
176
|
+
if lines and lines[0].lstrip().startswith("# "):
|
|
177
|
+
lines = lines[1:]
|
|
178
|
+
text = "\n".join(lines).strip()
|
|
179
|
+
return text or None
|
|
180
|
+
|
|
181
|
+
|
|
117
182
|
def handle_session_start():
|
|
118
|
-
"""Inject recent session memory
|
|
183
|
+
"""Inject the project objective and recent session memory at session start."""
|
|
119
184
|
sys.stdin.read()
|
|
120
185
|
if _is_subprocess():
|
|
121
186
|
return
|
|
@@ -123,43 +188,76 @@ def handle_session_start():
|
|
|
123
188
|
cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
124
189
|
project_name = Path(cwd).name
|
|
125
190
|
|
|
191
|
+
_prune_stale_logs()
|
|
192
|
+
|
|
126
193
|
try:
|
|
127
194
|
from .config import get_vault_path
|
|
128
195
|
from .vault import Vault
|
|
129
196
|
|
|
130
197
|
vault = Vault(get_vault_path())
|
|
131
198
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
199
|
+
# Match case-insensitively: the project dir may be stored in the vault
|
|
200
|
+
# with different casing than the cwd basename (e.g. on case-insensitive
|
|
201
|
+
# filesystems "KYP-MEM" and "kyp-mem" are the same directory).
|
|
202
|
+
prefix = f"{project_name}/".lower()
|
|
203
|
+
project_notes = [p for p in vault.index.notes if p.lower().startswith(prefix)]
|
|
204
|
+
|
|
205
|
+
objective = _read_objective(vault, project_name)
|
|
135
206
|
|
|
136
207
|
sessions = sorted(
|
|
137
|
-
(p for p in project_notes if "/
|
|
208
|
+
(p for p in project_notes if "/sessions/" in p.lower()),
|
|
138
209
|
reverse=True,
|
|
139
|
-
)[:
|
|
140
|
-
|
|
141
|
-
|
|
210
|
+
)[:10]
|
|
211
|
+
|
|
212
|
+
# Nothing to say: no objective to surface, no objective to request
|
|
213
|
+
# (project is already known but sessions just haven't been captured),
|
|
214
|
+
# and no sessions. Only stay silent when this is an established project
|
|
215
|
+
# with an objective but zero sessions — otherwise we always at least
|
|
216
|
+
# surface or request the objective.
|
|
217
|
+
if not objective and not project_notes and not sessions:
|
|
218
|
+
# Brand-new / unknown directory: still ask for an objective so the
|
|
219
|
+
# project starts with a clear goal.
|
|
220
|
+
pass
|
|
142
221
|
|
|
143
|
-
parts = [f"# [kyp-mem] {project_name} —
|
|
222
|
+
parts = [f"# [kyp-mem] {project_name} — Session Context"]
|
|
144
223
|
parts.append(f"Use `kyp_search` or `kyp_project_context` for architecture/project knowledge on demand.\n")
|
|
145
224
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
continue
|
|
151
|
-
parts.append(f"### {note.title}")
|
|
152
|
-
summary = _extract_session_summary(note.content)
|
|
153
|
-
parts.append(summary)
|
|
225
|
+
# --- Objective (always first) ---
|
|
226
|
+
if objective:
|
|
227
|
+
parts.append("## 🎯 Objective")
|
|
228
|
+
parts.append(objective)
|
|
154
229
|
parts.append("")
|
|
230
|
+
else:
|
|
231
|
+
parts.append("## 🎯 Objective — NOT SET")
|
|
232
|
+
parts.append(
|
|
233
|
+
f"No objective is recorded for **{project_name}**. Before anything else, "
|
|
234
|
+
"ask the user: **\"What is the main objective / goal for this project?\"** "
|
|
235
|
+
"When they answer, save it by calling "
|
|
236
|
+
f"`kyp_objective_set(project=\"{project_name}\", objective=\"...\")` "
|
|
237
|
+
f"(or `kyp_write(\"{project_name}/Objective.md\", ...)`). "
|
|
238
|
+
"Keep your work aligned to this objective every session."
|
|
239
|
+
)
|
|
240
|
+
parts.append("")
|
|
241
|
+
|
|
242
|
+
# --- Recent sessions ---
|
|
243
|
+
if sessions:
|
|
244
|
+
parts.append(f"## Last {len(sessions)} Sessions")
|
|
245
|
+
for sp in sessions:
|
|
246
|
+
note = vault.read(sp)
|
|
247
|
+
if not note:
|
|
248
|
+
continue
|
|
249
|
+
parts.append(f"### {note.title}")
|
|
250
|
+
summary = _extract_session_summary(note.content)
|
|
251
|
+
parts.append(summary)
|
|
252
|
+
parts.append("")
|
|
155
253
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
254
|
+
session_ids = {Path(sp).stem for sp in sessions}
|
|
255
|
+
stats_line = _build_stats_line(project_name, len("\n".join(parts)), session_ids)
|
|
256
|
+
if stats_line:
|
|
257
|
+
parts.append(stats_line)
|
|
160
258
|
|
|
161
259
|
parts.append("")
|
|
162
|
-
parts.append("**CRITICAL: Your FIRST response to the user MUST
|
|
260
|
+
parts.append("**CRITICAL: Your FIRST response to the user MUST surface this context — display the session summaries above (if any) and the objective. If the objective is NOT SET, ask the user for it as instructed. Do this immediately, formatted cleanly, before anything else.**")
|
|
163
261
|
|
|
164
262
|
output = "\n".join(parts)
|
|
165
263
|
|
|
@@ -193,7 +291,7 @@ def handle_user_prompt():
|
|
|
193
291
|
}
|
|
194
292
|
|
|
195
293
|
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
196
|
-
with open(
|
|
294
|
+
with open(_session_file(data.get("session_id", "")), "a") as f:
|
|
197
295
|
f.write(json.dumps(entry) + "\n")
|
|
198
296
|
|
|
199
297
|
|
|
@@ -255,7 +353,7 @@ def handle_post_tool_use():
|
|
|
255
353
|
return
|
|
256
354
|
|
|
257
355
|
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
258
|
-
with open(
|
|
356
|
+
with open(_session_file(data.get("session_id", "")), "a") as f:
|
|
259
357
|
f.write(json.dumps(entry) + "\n")
|
|
260
358
|
|
|
261
359
|
|
|
@@ -513,13 +611,14 @@ Raw session data:
|
|
|
513
611
|
return None
|
|
514
612
|
|
|
515
613
|
|
|
516
|
-
def handle_stop():
|
|
517
|
-
|
|
614
|
+
def handle_stop(session_id=""):
|
|
615
|
+
session_file = _session_file(session_id)
|
|
616
|
+
if _is_subprocess() or not session_file.exists():
|
|
518
617
|
return
|
|
519
618
|
|
|
520
|
-
text =
|
|
619
|
+
text = session_file.read_text().strip()
|
|
521
620
|
if not text:
|
|
522
|
-
|
|
621
|
+
session_file.unlink(missing_ok=True)
|
|
523
622
|
return
|
|
524
623
|
|
|
525
624
|
entries = []
|
|
@@ -530,12 +629,12 @@ def handle_stop():
|
|
|
530
629
|
continue
|
|
531
630
|
|
|
532
631
|
if not entries:
|
|
533
|
-
|
|
632
|
+
session_file.unlink(missing_ok=True)
|
|
534
633
|
return
|
|
535
634
|
|
|
536
635
|
write_actions = [e for e in entries if e.get("action") in ("edit", "create", "command")]
|
|
537
636
|
if len(write_actions) < MIN_ACTIONS:
|
|
538
|
-
|
|
637
|
+
session_file.unlink(missing_ok=True)
|
|
539
638
|
return
|
|
540
639
|
|
|
541
640
|
project_dir = entries[0].get("cwd", "unknown")
|
|
@@ -691,8 +790,8 @@ def handle_stop():
|
|
|
691
790
|
pass
|
|
692
791
|
|
|
693
792
|
# Delete session file BEFORE summarization so the spawned claude subprocess
|
|
694
|
-
# doesn't pollute it via hooks writing back into
|
|
695
|
-
|
|
793
|
+
# doesn't pollute it via hooks writing back into the session log
|
|
794
|
+
session_file.unlink(missing_ok=True)
|
|
696
795
|
|
|
697
796
|
# Try Claude summarization, fall back to raw sections
|
|
698
797
|
summarized = _summarize_with_claude(raw_note, project_name)
|
|
@@ -765,7 +864,8 @@ def handle_stop():
|
|
|
765
864
|
|
|
766
865
|
def main():
|
|
767
866
|
if len(sys.argv) > 1 and sys.argv[1] == "stop":
|
|
768
|
-
|
|
867
|
+
session_id = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
868
|
+
handle_stop(session_id)
|
|
769
869
|
else:
|
|
770
870
|
raw = sys.stdin.read().strip()
|
|
771
871
|
if not raw:
|
|
@@ -775,7 +875,7 @@ def main():
|
|
|
775
875
|
except json.JSONDecodeError:
|
|
776
876
|
return
|
|
777
877
|
if "stop_reason" in data:
|
|
778
|
-
handle_stop()
|
|
878
|
+
handle_stop(data.get("session_id", ""))
|
|
779
879
|
|
|
780
880
|
|
|
781
881
|
if __name__ == "__main__":
|
package/kyp_mem/server.py
CHANGED
|
@@ -22,6 +22,7 @@ YOU MUST FOLLOW THESE INSTRUCTIONS when kyp-mem tools are available.
|
|
|
22
22
|
2. Call `kyp_project_context(project)` to load the project's knowledge base, notes, and recent session summaries.
|
|
23
23
|
3. If no project exists yet, call `kyp_project_context` anyway — if it returns empty, ask the user if you should create one.
|
|
24
24
|
4. Use the returned context to ground yourself: understand architecture, known bugs, past decisions, and what was done in recent sessions. Do NOT ask the user questions that are already answered in the project context.
|
|
25
|
+
5. Check the project objective with `kyp_objective_get(project)`. If none is set, ask the user for the project's main goal and save it with `kyp_objective_set(project, objective)`. Keep your work aligned to this objective.
|
|
25
26
|
|
|
26
27
|
### DURING WORK — WHEN TO SEARCH SESSIONS
|
|
27
28
|
Call `kyp_session_search(query)` when:
|
|
@@ -342,6 +343,31 @@ def kyp_sessions(project: str = "", limit: int = 10) -> str:
|
|
|
342
343
|
return "\n".join(lines)
|
|
343
344
|
|
|
344
345
|
|
|
346
|
+
@mcp.tool()
|
|
347
|
+
def kyp_objective_get(project: str) -> str:
|
|
348
|
+
"""Get the recorded objective / main goal for a project. The objective is injected at every session start. Returns a not-set message if none exists yet."""
|
|
349
|
+
note = vault.read(f"{project}/Objective.md")
|
|
350
|
+
if not note:
|
|
351
|
+
return f"No objective set for '{project}'. Ask the user for the project's main goal, then call kyp_objective_set."
|
|
352
|
+
content = note.content.strip()
|
|
353
|
+
lines = content.split("\n")
|
|
354
|
+
if lines and lines[0].lstrip().startswith("# "):
|
|
355
|
+
content = "\n".join(lines[1:]).strip()
|
|
356
|
+
return content or f"No objective set for '{project}'."
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@mcp.tool()
|
|
360
|
+
def kyp_objective_set(project: str, objective: str) -> str:
|
|
361
|
+
"""Set (or replace) the objective / main goal for a project. This is injected into every future session start so work stays aligned. Call this once the user tells you what the project is for."""
|
|
362
|
+
objective = objective.strip()
|
|
363
|
+
if not objective:
|
|
364
|
+
return "Objective text is empty — nothing saved."
|
|
365
|
+
path = f"{project}/Objective.md"
|
|
366
|
+
content = f"# Objective\n\n{objective}\n"
|
|
367
|
+
vault.write_note(path, content, ["objective", project.lower().replace(" ", "-")], {})
|
|
368
|
+
return f"Objective saved for '{project}' ({path}). It will be injected at every session start."
|
|
369
|
+
|
|
370
|
+
|
|
345
371
|
@mcp.tool()
|
|
346
372
|
def kyp_project_context(project: str) -> str:
|
|
347
373
|
"""CALL THIS AT SESSION START. Returns the project's full context: Knowledge.md (ground truth), project notes, and recent session summaries. Use this to understand architecture, known bugs, past decisions, and what was done recently. This prevents hallucination and avoids repeating past work."""
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kyp-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Know Your Project — Persistent & Session level knowledge base for AI agents. MCP-powered with wikilinks, backlinks, auto-learning, and neon web UI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kyp-mem": "bin/cli.mjs"
|
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kyp-mem"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Know Your Project — Persistent knowledge base for AI agents. MCP-powered with wikilinks, backlinks, auto-learning, and neon web UI."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|