kyp-mem 0.4.4 → 0.5.1
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 +64 -230
- package/kyp_mem/cli.py +32 -0
- package/kyp_mem/config.py +6 -0
- package/kyp_mem/hooks.py +244 -34
- package/kyp_mem/server.py +32 -9
- package/kyp_mem/static/index.html +1673 -2740
- package/kyp_mem/ui.py +80 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -0
package/kyp_mem/hooks.py
CHANGED
|
@@ -10,6 +10,67 @@ SESSION_DIR = Path.home() / ".kyp-mem" / "sessions"
|
|
|
10
10
|
CURRENT_SESSION = SESSION_DIR / "current.jsonl"
|
|
11
11
|
|
|
12
12
|
MIN_ACTIONS = 3
|
|
13
|
+
CHARS_PER_TOKEN = 4
|
|
14
|
+
|
|
15
|
+
COMMAND_OUTPUT_ESTIMATES = {
|
|
16
|
+
"search": 2000,
|
|
17
|
+
"explore": 1000,
|
|
18
|
+
"read_cmd": 3000,
|
|
19
|
+
"git_inspect": 3000,
|
|
20
|
+
"test": 2000,
|
|
21
|
+
"build": 500,
|
|
22
|
+
"run": 200,
|
|
23
|
+
"git_write": 200,
|
|
24
|
+
"api_test": 1000,
|
|
25
|
+
"other": 300,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_token_stats():
|
|
30
|
+
from .config import STATS_FILE
|
|
31
|
+
if STATS_FILE.exists():
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(STATS_FILE.read_text())
|
|
34
|
+
except (json.JSONDecodeError, OSError):
|
|
35
|
+
pass
|
|
36
|
+
return {"sessions": [], "injections": []}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _save_token_stats(stats):
|
|
40
|
+
from .config import STATS_FILE
|
|
41
|
+
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
STATS_FILE.write_text(json.dumps(stats, indent=2) + "\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _record_session_tokens(session_id, project, exploration_tokens,
|
|
46
|
+
files_read_count, files_read_chars,
|
|
47
|
+
commands_run, commands_chars,
|
|
48
|
+
files_edited, files_created):
|
|
49
|
+
stats = _load_token_stats()
|
|
50
|
+
stats["sessions"].append({
|
|
51
|
+
"id": session_id,
|
|
52
|
+
"project": project,
|
|
53
|
+
"ts": datetime.now().isoformat(),
|
|
54
|
+
"exploration_tokens": exploration_tokens,
|
|
55
|
+
"files_read": files_read_count,
|
|
56
|
+
"files_read_chars": files_read_chars,
|
|
57
|
+
"commands_run": commands_run,
|
|
58
|
+
"commands_chars": commands_chars,
|
|
59
|
+
"files_edited": files_edited,
|
|
60
|
+
"files_created": files_created,
|
|
61
|
+
})
|
|
62
|
+
_save_token_stats(stats)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _record_injection(project, chars):
|
|
66
|
+
stats = _load_token_stats()
|
|
67
|
+
stats["injections"].append({
|
|
68
|
+
"ts": datetime.now().isoformat(),
|
|
69
|
+
"project": project,
|
|
70
|
+
"chars": chars,
|
|
71
|
+
"tokens": chars // CHARS_PER_TOKEN,
|
|
72
|
+
})
|
|
73
|
+
_save_token_stats(stats)
|
|
13
74
|
|
|
14
75
|
|
|
15
76
|
def handle_session_start():
|
|
@@ -82,7 +143,12 @@ def handle_session_start():
|
|
|
82
143
|
|
|
83
144
|
parts.append("Use `kyp_project_context` for full details. Use `kyp_session_search` to search past sessions.")
|
|
84
145
|
|
|
85
|
-
|
|
146
|
+
output = "\n".join(parts)
|
|
147
|
+
try:
|
|
148
|
+
_record_injection(project_name, len(output))
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
print(output)
|
|
86
152
|
except Exception:
|
|
87
153
|
pass
|
|
88
154
|
|
|
@@ -129,6 +195,10 @@ def handle_post_tool_use():
|
|
|
129
195
|
cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
130
196
|
entry["cwd"] = cwd
|
|
131
197
|
|
|
198
|
+
# Measure response size for token economics
|
|
199
|
+
tool_response = data.get("tool_response", "")
|
|
200
|
+
response_chars = len(str(tool_response)) if tool_response else 0
|
|
201
|
+
|
|
132
202
|
if tool_name == "Edit":
|
|
133
203
|
entry["action"] = "edit"
|
|
134
204
|
entry["file"] = tool_input.get("file_path", "")
|
|
@@ -138,9 +208,16 @@ def handle_post_tool_use():
|
|
|
138
208
|
elif tool_name == "Read":
|
|
139
209
|
entry["action"] = "read"
|
|
140
210
|
entry["file"] = tool_input.get("file_path", "")
|
|
211
|
+
if response_chars == 0:
|
|
212
|
+
try:
|
|
213
|
+
response_chars = Path(tool_input.get("file_path", "")).stat().st_size
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
entry["response_chars"] = response_chars
|
|
141
217
|
elif tool_name == "Bash":
|
|
142
218
|
entry["action"] = "command"
|
|
143
219
|
entry["command"] = tool_input.get("command", "")
|
|
220
|
+
entry["response_chars"] = response_chars
|
|
144
221
|
else:
|
|
145
222
|
return
|
|
146
223
|
|
|
@@ -340,6 +417,58 @@ def _build_next_steps(files_edited, files_created, commands_classified):
|
|
|
340
417
|
return items
|
|
341
418
|
|
|
342
419
|
|
|
420
|
+
def _summarize_with_claude(raw_note, project_name):
|
|
421
|
+
"""Use Claude to rewrite session sections in plain, human-readable language."""
|
|
422
|
+
try:
|
|
423
|
+
from .config import get_session_model
|
|
424
|
+
import anthropic
|
|
425
|
+
|
|
426
|
+
model = get_session_model()
|
|
427
|
+
client = anthropic.Anthropic()
|
|
428
|
+
|
|
429
|
+
prompt = f"""Summarize this coding session for "{project_name}" in plain English. A future AI agent will read this to understand what happened — write for that audience.
|
|
430
|
+
|
|
431
|
+
You have: user prompts (what was asked), a timeline of file edits/reads/commands (what happened), and raw section data. Synthesize these into a coherent narrative.
|
|
432
|
+
|
|
433
|
+
Rules:
|
|
434
|
+
- Summary: 2-3 sentences. State the objective (from prompts), what was done, and the outcome. Be specific: "Fixed navigation bug where clicking sessions broke the back button" not "Modified files and ran commands."
|
|
435
|
+
- INVESTIGATED: What was explored and WHY. "Examined the session hook pipeline to understand why summaries were empty" not "Searched for `session-view`". Max 4 bullets.
|
|
436
|
+
- LEARNED: Insights or discoveries. "The config CLI command was defined but never wired to the dispatcher" not "Investigated and modified: `cli.py`". Max 4 bullets.
|
|
437
|
+
- COMPLETED: Concrete deliverables. "Added AI-powered session summarization using Claude Haiku" not "Modified `hooks.py`". Max 5 bullets.
|
|
438
|
+
- NEXT STEPS: What should happen next session. Infer from context — unfinished work, unfixed bugs, natural follow-ups. Max 3 bullets.
|
|
439
|
+
|
|
440
|
+
NEVER include raw grep patterns, CSS class names, file paths, or command output. Write like you're telling a teammate what you did today.
|
|
441
|
+
|
|
442
|
+
Return ONLY this format (no preamble):
|
|
443
|
+
|
|
444
|
+
## Summary
|
|
445
|
+
<text>
|
|
446
|
+
|
|
447
|
+
## INVESTIGATED
|
|
448
|
+
- <item>
|
|
449
|
+
|
|
450
|
+
## LEARNED
|
|
451
|
+
- <item>
|
|
452
|
+
|
|
453
|
+
## COMPLETED
|
|
454
|
+
- <item>
|
|
455
|
+
|
|
456
|
+
## NEXT STEPS
|
|
457
|
+
- <item>
|
|
458
|
+
|
|
459
|
+
Raw session data:
|
|
460
|
+
{raw_note}"""
|
|
461
|
+
|
|
462
|
+
response = client.messages.create(
|
|
463
|
+
model=model,
|
|
464
|
+
max_tokens=1024,
|
|
465
|
+
messages=[{"role": "user", "content": prompt}],
|
|
466
|
+
)
|
|
467
|
+
return response.content[0].text.strip()
|
|
468
|
+
except Exception:
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
|
|
343
472
|
def handle_stop():
|
|
344
473
|
if not CURRENT_SESSION.exists():
|
|
345
474
|
return
|
|
@@ -417,49 +546,130 @@ def handle_stop():
|
|
|
417
546
|
completed = _build_completed(files_edited, files_created, commands_classified, project_dir)
|
|
418
547
|
next_steps = _build_next_steps(files_edited, files_created, commands_classified)
|
|
419
548
|
|
|
549
|
+
# Build raw note for Claude summarization
|
|
550
|
+
raw_parts = []
|
|
551
|
+
raw_parts.append("## Summary")
|
|
552
|
+
raw_parts.append(", ".join(summary_items) + f" in `{project_name}`." if summary_items else "")
|
|
553
|
+
raw_parts.append("")
|
|
554
|
+
raw_parts.append("## INVESTIGATED")
|
|
555
|
+
if investigated:
|
|
556
|
+
raw_parts.extend(investigated)
|
|
557
|
+
raw_parts.append("")
|
|
558
|
+
raw_parts.append("## LEARNED")
|
|
559
|
+
if learned:
|
|
560
|
+
raw_parts.extend(learned)
|
|
561
|
+
raw_parts.append("")
|
|
562
|
+
raw_parts.append("## COMPLETED")
|
|
563
|
+
if completed:
|
|
564
|
+
raw_parts.extend(completed)
|
|
565
|
+
raw_parts.append("")
|
|
566
|
+
raw_parts.append("## NEXT STEPS")
|
|
567
|
+
if next_steps:
|
|
568
|
+
raw_parts.extend(next_steps)
|
|
569
|
+
|
|
570
|
+
# Prompts go FIRST — they define the session's objective
|
|
571
|
+
if prompts:
|
|
572
|
+
raw_parts.insert(0, "## USER PROMPTS (what was asked)")
|
|
573
|
+
for i, p in enumerate(prompts):
|
|
574
|
+
raw_parts.insert(i + 1, f"- [{p['ts']}] {p['text'][:300]}")
|
|
575
|
+
raw_parts.insert(len(prompts) + 1, "")
|
|
576
|
+
|
|
577
|
+
# Full timeline gives Claude the narrative arc
|
|
578
|
+
if timeline:
|
|
579
|
+
raw_parts.append("")
|
|
580
|
+
raw_parts.append("## TIMELINE (what happened, chronological)")
|
|
581
|
+
for line in timeline[:50]:
|
|
582
|
+
raw_parts.append(line)
|
|
583
|
+
|
|
584
|
+
raw_note = "\n".join(raw_parts)
|
|
585
|
+
|
|
586
|
+
# Compute exploration tokens from captured response sizes
|
|
587
|
+
files_read_chars = 0
|
|
588
|
+
commands_chars = 0
|
|
589
|
+
files_read_count = len(files_read)
|
|
590
|
+
commands_run_count = len(commands)
|
|
591
|
+
|
|
592
|
+
for e in entries:
|
|
593
|
+
rc = e.get("response_chars", 0)
|
|
594
|
+
if e.get("action") == "read":
|
|
595
|
+
if rc > 0:
|
|
596
|
+
files_read_chars += rc
|
|
597
|
+
else:
|
|
598
|
+
try:
|
|
599
|
+
files_read_chars += Path(e.get("file", "")).stat().st_size
|
|
600
|
+
except OSError:
|
|
601
|
+
pass
|
|
602
|
+
elif e.get("action") == "command":
|
|
603
|
+
if rc > 0:
|
|
604
|
+
commands_chars += rc
|
|
605
|
+
else:
|
|
606
|
+
cls, _ = _classify_command(e.get("command", ""))
|
|
607
|
+
commands_chars += COMMAND_OUTPUT_ESTIMATES.get(cls, 300)
|
|
608
|
+
|
|
609
|
+
exploration_chars = files_read_chars + commands_chars
|
|
610
|
+
exploration_tokens = exploration_chars // CHARS_PER_TOKEN
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
_record_session_tokens(
|
|
614
|
+
session_id, project_name, exploration_tokens,
|
|
615
|
+
files_read_count, files_read_chars,
|
|
616
|
+
commands_run_count, commands_chars,
|
|
617
|
+
len(files_edited), len(files_created),
|
|
618
|
+
)
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
# Try Claude summarization, fall back to raw sections
|
|
623
|
+
summarized = _summarize_with_claude(raw_note, project_name)
|
|
624
|
+
|
|
420
625
|
parts = [f"# Session {session_id}", ""]
|
|
421
626
|
parts.append(f"**Project:** `{project_dir}`")
|
|
422
627
|
parts.append(f"**Actions:** {len(entries)} total, {len(write_actions)} substantive")
|
|
628
|
+
parts.append(f"**Exploration:** ~{exploration_tokens:,} tokens ({files_read_count} reads, {commands_run_count} commands)")
|
|
423
629
|
parts.append("")
|
|
424
630
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
631
|
+
if summarized:
|
|
632
|
+
# Insert prompts section before the Claude-rewritten sections
|
|
633
|
+
parts.append("## PROMPTS")
|
|
634
|
+
if prompts:
|
|
635
|
+
for i, p in enumerate(prompts, 1):
|
|
636
|
+
parts.append(f"### {i}. [{p['ts']}]")
|
|
637
|
+
parts.append(f"> {p['text']}")
|
|
638
|
+
parts.append("")
|
|
639
|
+
parts.append("")
|
|
640
|
+
parts.append(summarized)
|
|
641
|
+
else:
|
|
642
|
+
parts.append("## Summary")
|
|
643
|
+
parts.append(", ".join(summary_items) + f" in `{project_name}`." if summary_items else "")
|
|
644
|
+
parts.append("")
|
|
436
645
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
646
|
+
parts.append("## PROMPTS")
|
|
647
|
+
if prompts:
|
|
648
|
+
for i, p in enumerate(prompts, 1):
|
|
649
|
+
parts.append(f"### {i}. [{p['ts']}]")
|
|
650
|
+
parts.append(f"> {p['text']}")
|
|
651
|
+
parts.append("")
|
|
652
|
+
parts.append("")
|
|
441
653
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
654
|
+
parts.append("## INVESTIGATED")
|
|
655
|
+
if investigated:
|
|
656
|
+
parts.extend(investigated)
|
|
657
|
+
parts.append("")
|
|
446
658
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
659
|
+
parts.append("## LEARNED")
|
|
660
|
+
if learned:
|
|
661
|
+
parts.extend(learned)
|
|
662
|
+
parts.append("")
|
|
451
663
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
664
|
+
parts.append("## COMPLETED")
|
|
665
|
+
if completed:
|
|
666
|
+
parts.extend(completed)
|
|
667
|
+
parts.append("")
|
|
456
668
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if len(timeline) > 40:
|
|
462
|
-
parts.append(f" ... and {len(timeline) - 40} more actions")
|
|
669
|
+
parts.append("## NEXT STEPS")
|
|
670
|
+
if next_steps:
|
|
671
|
+
parts.extend(next_steps)
|
|
672
|
+
parts.append("")
|
|
463
673
|
|
|
464
674
|
content = "\n".join(parts)
|
|
465
675
|
tags = ["session", "auto-captured", project_name]
|
package/kyp_mem/server.py
CHANGED
|
@@ -237,16 +237,39 @@ def kyp_recent(limit: int = 10) -> str:
|
|
|
237
237
|
|
|
238
238
|
@mcp.tool()
|
|
239
239
|
def kyp_stats() -> str:
|
|
240
|
-
"""Get vault statistics — note count, folders, tags, links."""
|
|
240
|
+
"""Get vault statistics — note count, folders, tags, links, and token economics (exploration cost vs memory injection cost)."""
|
|
241
241
|
s = vault.get_stats()
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
f" Notes: {s['notes']}
|
|
245
|
-
f" Folders: {s['folders']}
|
|
246
|
-
f" Tags: {s['tags']}
|
|
247
|
-
f" Links: {s['links']}
|
|
248
|
-
f" Backlinks: {s['backlinks']}"
|
|
249
|
-
|
|
242
|
+
lines = [
|
|
243
|
+
"Vault stats:",
|
|
244
|
+
f" Notes: {s['notes']}",
|
|
245
|
+
f" Folders: {s['folders']}",
|
|
246
|
+
f" Tags: {s['tags']}",
|
|
247
|
+
f" Links: {s['links']}",
|
|
248
|
+
f" Backlinks: {s['backlinks']}",
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
from .config import STATS_FILE
|
|
253
|
+
raw = json.loads(STATS_FILE.read_text()) if STATS_FILE.exists() else {}
|
|
254
|
+
sessions = raw.get("sessions", [])
|
|
255
|
+
injections = raw.get("injections", [])
|
|
256
|
+
if sessions:
|
|
257
|
+
total_exploration = sum(s.get("exploration_tokens", 0) for s in sessions)
|
|
258
|
+
avg_exploration = total_exploration // len(sessions)
|
|
259
|
+
latest_inj = injections[-1].get("tokens", 0) if injections else 0
|
|
260
|
+
lines.append("")
|
|
261
|
+
lines.append("Token economics:")
|
|
262
|
+
lines.append(f" Total exploration: ~{total_exploration:,}t across {len(sessions)} sessions")
|
|
263
|
+
lines.append(f" Avg per session: ~{avg_exploration:,}t (cold-start cost)")
|
|
264
|
+
lines.append(f" Memory injection: ~{latest_inj:,}t (session start)")
|
|
265
|
+
if latest_inj > 0 and avg_exploration > 0:
|
|
266
|
+
ratio = round(avg_exploration / latest_inj, 1)
|
|
267
|
+
pct = round((1 - latest_inj / avg_exploration) * 100, 1)
|
|
268
|
+
lines.append(f" Compression: {ratio}x — {pct}% smaller than re-exploring")
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
return "\n".join(lines)
|
|
250
273
|
|
|
251
274
|
|
|
252
275
|
@mcp.tool()
|