kyp-mem 0.2.1 → 0.3.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/README.md +54 -65
- package/bin/cli.mjs +56 -1
- package/kyp_mem/__init__.py +1 -1
- package/kyp_mem/cli.py +223 -37
- package/kyp_mem/hooks.py +126 -0
- package/kyp_mem/server.py +37 -10
- package/kyp_mem/static/index.html +953 -310
- package/kyp_mem/ui.py +46 -2
- package/package.json +2 -2
- package/pyproject.toml +2 -2
package/kyp_mem/hooks.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""KYP-MEM session hooks — compile captured tool activity into vault notes."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SESSION_DIR = Path.home() / ".kyp-mem" / "sessions"
|
|
9
|
+
CURRENT_SESSION = SESSION_DIR / "current.jsonl"
|
|
10
|
+
|
|
11
|
+
MIN_ACTIONS = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def handle_stop():
|
|
15
|
+
if not CURRENT_SESSION.exists():
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
text = CURRENT_SESSION.read_text().strip()
|
|
19
|
+
if not text:
|
|
20
|
+
CURRENT_SESSION.unlink(missing_ok=True)
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
entries = []
|
|
24
|
+
for line in text.split("\n"):
|
|
25
|
+
try:
|
|
26
|
+
entries.append(json.loads(line))
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
if not entries:
|
|
31
|
+
CURRENT_SESSION.unlink(missing_ok=True)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
write_actions = [e for e in entries if e.get("action") in ("edit", "create", "command")]
|
|
35
|
+
if len(write_actions) < MIN_ACTIONS:
|
|
36
|
+
CURRENT_SESSION.unlink(missing_ok=True)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
project_dir = entries[0].get("cwd", "unknown")
|
|
40
|
+
project_name = Path(project_dir).name
|
|
41
|
+
session_id = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
42
|
+
|
|
43
|
+
files_edited = set()
|
|
44
|
+
files_created = set()
|
|
45
|
+
commands = []
|
|
46
|
+
timeline = []
|
|
47
|
+
|
|
48
|
+
for e in entries:
|
|
49
|
+
ts_raw = e.get("ts", "")
|
|
50
|
+
ts = ts_raw[11:19] if len(ts_raw) >= 19 else ""
|
|
51
|
+
action = e.get("action", "")
|
|
52
|
+
|
|
53
|
+
if action == "edit":
|
|
54
|
+
fp = e.get("file", "")
|
|
55
|
+
files_edited.add(fp)
|
|
56
|
+
timeline.append(f" {ts} — Edit `{Path(fp).name}`")
|
|
57
|
+
elif action == "create":
|
|
58
|
+
fp = e.get("file", "")
|
|
59
|
+
files_created.add(fp)
|
|
60
|
+
timeline.append(f" {ts} — Write `{Path(fp).name}`")
|
|
61
|
+
elif action == "command":
|
|
62
|
+
cmd = e.get("command", "")
|
|
63
|
+
commands.append(cmd)
|
|
64
|
+
short = cmd[:80] + "..." if len(cmd) > 80 else cmd
|
|
65
|
+
timeline.append(f" {ts} — `{short}`")
|
|
66
|
+
|
|
67
|
+
parts = [f"# Session {session_id}", ""]
|
|
68
|
+
parts.append(f"**Project:** `{project_dir}`")
|
|
69
|
+
parts.append(f"**Actions:** {len(entries)} total, {len(write_actions)} substantive")
|
|
70
|
+
parts.append("")
|
|
71
|
+
|
|
72
|
+
if files_edited:
|
|
73
|
+
parts.append("## Files Modified")
|
|
74
|
+
for f in sorted(files_edited):
|
|
75
|
+
parts.append(f"- `{f}`")
|
|
76
|
+
parts.append("")
|
|
77
|
+
|
|
78
|
+
if files_created:
|
|
79
|
+
parts.append("## Files Created")
|
|
80
|
+
for f in sorted(files_created):
|
|
81
|
+
parts.append(f"- `{f}`")
|
|
82
|
+
parts.append("")
|
|
83
|
+
|
|
84
|
+
if commands:
|
|
85
|
+
parts.append("## Commands Run")
|
|
86
|
+
for cmd in commands[:25]:
|
|
87
|
+
short = cmd[:120] + "..." if len(cmd) > 120 else cmd
|
|
88
|
+
parts.append(f"- `{short}`")
|
|
89
|
+
parts.append("")
|
|
90
|
+
|
|
91
|
+
if timeline:
|
|
92
|
+
parts.append("## Timeline")
|
|
93
|
+
for line in timeline[:40]:
|
|
94
|
+
parts.append(line)
|
|
95
|
+
if len(timeline) > 40:
|
|
96
|
+
parts.append(f" ... and {len(timeline) - 40} more actions")
|
|
97
|
+
|
|
98
|
+
content = "\n".join(parts)
|
|
99
|
+
tags = ["session", "auto-captured", project_name]
|
|
100
|
+
|
|
101
|
+
from .config import get_vault_path
|
|
102
|
+
from .vault import Vault
|
|
103
|
+
|
|
104
|
+
vault = Vault(get_vault_path())
|
|
105
|
+
vault.write_note(f"Sessions/{session_id}.md", content, tags, {})
|
|
106
|
+
|
|
107
|
+
CURRENT_SESSION.unlink(missing_ok=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main():
|
|
111
|
+
if len(sys.argv) > 1 and sys.argv[1] == "stop":
|
|
112
|
+
handle_stop()
|
|
113
|
+
else:
|
|
114
|
+
raw = sys.stdin.read().strip()
|
|
115
|
+
if not raw:
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
data = json.loads(raw)
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
return
|
|
121
|
+
if "stop_reason" in data:
|
|
122
|
+
handle_stop()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
package/kyp_mem/server.py
CHANGED
|
@@ -7,18 +7,23 @@ from .vault import Vault
|
|
|
7
7
|
|
|
8
8
|
vault = Vault(get_vault_path())
|
|
9
9
|
|
|
10
|
-
mcp = FastMCP("kyp-mem"
|
|
10
|
+
mcp = FastMCP("kyp-mem")
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@mcp.tool()
|
|
14
14
|
def kyp_list(path: str = "") -> str:
|
|
15
|
-
"""List notes and folders in the vault. Pass a folder path
|
|
15
|
+
"""List notes and folders in the vault. Shows inline tags for quick navigation. Pass a folder path or empty for root."""
|
|
16
16
|
tree = vault.list_tree(path)
|
|
17
17
|
lines = []
|
|
18
18
|
for f in tree["folders"]:
|
|
19
19
|
lines.append(f" {f}/")
|
|
20
20
|
for n in tree["notes"]:
|
|
21
|
-
|
|
21
|
+
rel = f"{path}/{n}" if path else n
|
|
22
|
+
note = vault.index.notes.get(rel)
|
|
23
|
+
if note and note.tags:
|
|
24
|
+
lines.append(f" {n} [{', '.join(note.tags)}]")
|
|
25
|
+
else:
|
|
26
|
+
lines.append(f" {n}")
|
|
22
27
|
if not lines:
|
|
23
28
|
lines.append("(empty vault)")
|
|
24
29
|
header = f"Vault: {path or '/'}"
|
|
@@ -26,24 +31,46 @@ def kyp_list(path: str = "") -> str:
|
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
@mcp.tool()
|
|
29
|
-
def kyp_read(path: str) -> str:
|
|
30
|
-
"""Read a note by
|
|
34
|
+
def kyp_read(path: str, full: bool = False) -> str:
|
|
35
|
+
"""Read a note. Returns brief summary by default (title, tags, preview, links). Set full=True for complete content."""
|
|
31
36
|
note = vault.read(path)
|
|
32
37
|
if not note:
|
|
33
38
|
return f"Not found: {path}"
|
|
34
39
|
|
|
40
|
+
if not full:
|
|
41
|
+
parts = [f"# {note.title}"]
|
|
42
|
+
if note.tags:
|
|
43
|
+
parts.append(f"tags: {', '.join(note.tags)}")
|
|
44
|
+
if note.created:
|
|
45
|
+
parts.append(f"created: {note.created}")
|
|
46
|
+
|
|
47
|
+
lines = [l for l in note.content.strip().split("\n") if l.strip() and not l.startswith("# ")]
|
|
48
|
+
preview = "\n".join(lines[:6])
|
|
49
|
+
if len(lines) > 6:
|
|
50
|
+
preview += "\n..."
|
|
51
|
+
parts.append("")
|
|
52
|
+
parts.append(preview)
|
|
53
|
+
|
|
54
|
+
backlinks = vault.get_backlinks(path)
|
|
55
|
+
outlinks = note.links
|
|
56
|
+
if outlinks:
|
|
57
|
+
parts.append(f"\nlinks: {', '.join(f'[[{l}]]' for l in outlinks)}")
|
|
58
|
+
if backlinks:
|
|
59
|
+
parts.append(f"backlinks: {', '.join(f'[[{b.replace('.md', '')}]]' for b in backlinks)}")
|
|
60
|
+
|
|
61
|
+
return "\n".join(parts)
|
|
62
|
+
|
|
35
63
|
parts = [f"# {note.title}", ""]
|
|
36
64
|
|
|
37
65
|
if note.tags or note.properties or note.created:
|
|
38
|
-
parts.append("**Properties:**")
|
|
39
66
|
if note.tags:
|
|
40
|
-
parts.append(f"
|
|
67
|
+
parts.append(f"tags: {', '.join(note.tags)}")
|
|
41
68
|
if note.created:
|
|
42
|
-
parts.append(f"
|
|
69
|
+
parts.append(f"created: {note.created}")
|
|
43
70
|
if note.updated:
|
|
44
|
-
parts.append(f"
|
|
71
|
+
parts.append(f"updated: {note.updated}")
|
|
45
72
|
for k, v in note.properties.items():
|
|
46
|
-
parts.append(f"
|
|
73
|
+
parts.append(f"{k}: {v}")
|
|
47
74
|
parts.append("")
|
|
48
75
|
|
|
49
76
|
parts.append(note.content)
|