kyp-mem 0.4.3 → 0.5.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/cli.py CHANGED
@@ -43,8 +43,13 @@ def main():
43
43
 
44
44
  subparsers.add_parser("doctor", help="Check installation and config health")
45
45
 
46
+ cfg_parser = subparsers.add_parser("config", help="Get or set configuration values")
47
+ cfg_parser.add_argument("key", nargs="?", help="Config key (e.g. session_model)")
48
+ cfg_parser.add_argument("value", nargs="?", help="Value to set")
49
+
46
50
  hook_parser = subparsers.add_parser("hook", help="Handle Claude Code hook events (internal)")
47
51
  hook_sub = hook_parser.add_subparsers(dest="hook_command")
52
+ hook_sub.add_parser("session-start", help="Inject project context at session start")
48
53
  hook_sub.add_parser("post-tool-use", help="Capture tool activity to session log")
49
54
  hook_sub.add_parser("user-prompt", help="Capture user prompt to session log")
50
55
  hook_sub.add_parser("stop", help="Compile session into vault note")
@@ -70,11 +75,15 @@ def main():
70
75
  _run_tree()
71
76
  elif args.command == "install-hooks":
72
77
  _run_install_hooks(global_config=args.global_config, remove=args.remove)
78
+ elif args.command == "config":
79
+ _run_config(args.key, args.value)
73
80
  elif args.command == "doctor":
74
81
  _run_doctor()
75
82
  elif args.command == "hook":
76
- from .hooks import handle_post_tool_use, handle_user_prompt, handle_stop
77
- if args.hook_command == "post-tool-use":
83
+ from .hooks import handle_session_start, handle_post_tool_use, handle_user_prompt, handle_stop
84
+ if args.hook_command == "session-start":
85
+ handle_session_start()
86
+ elif args.hook_command == "post-tool-use":
78
87
  handle_post_tool_use()
79
88
  elif args.hook_command == "user-prompt":
80
89
  handle_user_prompt()
@@ -286,7 +295,7 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
286
295
 
287
296
  if remove:
288
297
  changed = False
289
- for event in ("PostToolUse", "UserPromptSubmit", "Stop"):
298
+ for event in ("SessionStart", "PostToolUse", "UserPromptSubmit", "Stop"):
290
299
  if event in hooks:
291
300
  hooks[event] = [h for h in hooks[event] if not _has_kyp_hook(h)]
292
301
  if not hooks[event]:
@@ -301,14 +310,19 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
301
310
  print()
302
311
  return
303
312
 
313
+ session_start_hooks = hooks.setdefault("SessionStart", [])
304
314
  post_tool_hooks = hooks.setdefault("PostToolUse", [])
305
315
  prompt_hooks = hooks.setdefault("UserPromptSubmit", [])
306
316
  stop_hooks = hooks.setdefault("Stop", [])
307
317
 
318
+ session_start_hooks = [h for h in session_start_hooks if not _has_kyp_hook(h)]
308
319
  post_tool_hooks = [h for h in post_tool_hooks if not _has_kyp_hook(h)]
309
320
  prompt_hooks = [h for h in prompt_hooks if not _has_kyp_hook(h)]
310
321
  stop_hooks = [h for h in stop_hooks if not _has_kyp_hook(h)]
311
322
 
323
+ session_start_hooks.append({
324
+ "hooks": [{"type": "command", "command": f"{mcp_command} hook session-start"}],
325
+ })
312
326
  post_tool_hooks.append({
313
327
  "matcher": "Edit|Write|Read|Bash",
314
328
  "hooks": [{"type": "command", "command": f"{mcp_command} hook post-tool-use"}],
@@ -320,6 +334,7 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
320
334
  "hooks": [{"type": "command", "command": f"{mcp_command} hook stop"}],
321
335
  })
322
336
 
337
+ hooks["SessionStart"] = session_start_hooks
323
338
  hooks["PostToolUse"] = post_tool_hooks
324
339
  hooks["UserPromptSubmit"] = prompt_hooks
325
340
  hooks["Stop"] = stop_hooks
@@ -342,6 +357,32 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
342
357
  print()
343
358
 
344
359
 
360
+ def _run_config(key, value):
361
+ from .config import load_config, save_config
362
+
363
+ config = load_config()
364
+
365
+ if key is None:
366
+ print(f"\n {C}KYP-MEM{R} — Configuration\n")
367
+ for k, v in sorted(config.items()):
368
+ print(f" {k}: {G}{v}{R}")
369
+ print(f"\n {D}Configurable keys:{R}")
370
+ print(f" {D} vault_path — Path to vault directory{R}")
371
+ print(f" {D} session_model — Claude model for session summarization{R}")
372
+ print(f" {D} (default: claude-haiku-4-5-20251001){R}")
373
+ print()
374
+ return
375
+
376
+ if value is None:
377
+ current = config.get(key, f"{Y}(not set){R}")
378
+ print(f" {key}: {G}{current}{R}")
379
+ return
380
+
381
+ config[key] = value
382
+ save_config(config)
383
+ print(f" {G}✓{R} {key} = {value}")
384
+
385
+
345
386
  def _run_stats():
346
387
  from .config import get_vault_path
347
388
  from .vault import Vault
package/kyp_mem/config.py CHANGED
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
 
7
7
  CONFIG_DIR = Path.home() / ".kyp-mem"
8
8
  CONFIG_FILE = CONFIG_DIR / "config.json"
9
+ STATS_FILE = CONFIG_DIR / "token_stats.json"
9
10
  DEFAULT_VAULT = str(CONFIG_DIR / "vault")
10
11
 
11
12
 
@@ -29,3 +30,8 @@ def get_vault_path() -> str:
29
30
  return env
30
31
  config = load_config()
31
32
  return config.get("vault_path", DEFAULT_VAULT)
33
+
34
+
35
+ def get_session_model() -> str:
36
+ config = load_config()
37
+ return config.get("session_model", "claude-haiku-4-5-20251001")
package/kyp_mem/hooks.py CHANGED
@@ -10,6 +10,147 @@ 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)
74
+
75
+
76
+ def handle_session_start():
77
+ """Inject project context into the conversation at session start."""
78
+ sys.stdin.read()
79
+
80
+ cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
81
+ project_name = Path(cwd).name
82
+
83
+ try:
84
+ from .config import get_vault_path
85
+ from .vault import Vault
86
+
87
+ vault = Vault(get_vault_path())
88
+
89
+ project_notes = [p for p in vault.index.notes if p.startswith(f"{project_name}/")]
90
+ if not project_notes:
91
+ return
92
+
93
+ parts = [f"# [kyp-mem] {project_name} — Project Context"]
94
+ parts.append(f"Vault: {get_vault_path()}")
95
+ parts.append("")
96
+
97
+ knowledge_path = f"{project_name}/Knowledge.md"
98
+ knowledge = vault.read(knowledge_path)
99
+ if knowledge:
100
+ parts.append("## Knowledge")
101
+ content = knowledge.content
102
+ timeline_idx = content.find("## Timeline")
103
+ if timeline_idx > 0:
104
+ content = content[:timeline_idx].strip()
105
+ if len(content) > 2000:
106
+ parts.append(content[:2000] + "\n...")
107
+ else:
108
+ parts.append(content)
109
+ parts.append("")
110
+
111
+ other_notes = sorted(
112
+ p for p in project_notes
113
+ if "/Sessions/" not in p and p != knowledge_path
114
+ )
115
+ if other_notes:
116
+ parts.append("## Project Notes")
117
+ for p in other_notes:
118
+ note = vault.index.notes.get(p)
119
+ title = note.title if note else p
120
+ tags = f" [{', '.join(note.tags)}]" if note and note.tags else ""
121
+ parts.append(f"- {title} ({p}){tags}")
122
+ parts.append("")
123
+
124
+ sessions = sorted(
125
+ (p for p in project_notes if "/Sessions/" in p),
126
+ reverse=True,
127
+ )[:3]
128
+ if sessions:
129
+ parts.append(f"## Recent Sessions (last {len(sessions)})")
130
+ for sp in sessions:
131
+ note = vault.read(sp)
132
+ if not note:
133
+ continue
134
+ parts.append(f"### {note.title}")
135
+ content = note.content
136
+ timeline_idx = content.find("## Timeline")
137
+ if timeline_idx > 0:
138
+ content = content[:timeline_idx].strip()
139
+ if len(content) > 300:
140
+ content = content[:300] + "..."
141
+ parts.append(content)
142
+ parts.append("")
143
+
144
+ parts.append("Use `kyp_project_context` for full details. Use `kyp_session_search` to search past sessions.")
145
+
146
+ output = "\n".join(parts)
147
+ try:
148
+ _record_injection(project_name, len(output))
149
+ except Exception:
150
+ pass
151
+ print(output)
152
+ except Exception:
153
+ pass
13
154
 
14
155
 
15
156
  def handle_user_prompt():
@@ -54,6 +195,10 @@ def handle_post_tool_use():
54
195
  cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
55
196
  entry["cwd"] = cwd
56
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
+
57
202
  if tool_name == "Edit":
58
203
  entry["action"] = "edit"
59
204
  entry["file"] = tool_input.get("file_path", "")
@@ -63,9 +208,16 @@ def handle_post_tool_use():
63
208
  elif tool_name == "Read":
64
209
  entry["action"] = "read"
65
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
66
217
  elif tool_name == "Bash":
67
218
  entry["action"] = "command"
68
219
  entry["command"] = tool_input.get("command", "")
220
+ entry["response_chars"] = response_chars
69
221
  else:
70
222
  return
71
223
 
@@ -265,6 +417,58 @@ def _build_next_steps(files_edited, files_created, commands_classified):
265
417
  return items
266
418
 
267
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
+
268
472
  def handle_stop():
269
473
  if not CURRENT_SESSION.exists():
270
474
  return
@@ -342,49 +546,130 @@ def handle_stop():
342
546
  completed = _build_completed(files_edited, files_created, commands_classified, project_dir)
343
547
  next_steps = _build_next_steps(files_edited, files_created, commands_classified)
344
548
 
345
- parts = [f"# Session {session_id}", ""]
346
- parts.append(f"**Project:** `{project_dir}`")
347
- parts.append(f"**Actions:** {len(entries)} total, {len(write_actions)} substantive")
348
- parts.append("")
349
-
350
- parts.append("## Summary")
351
- parts.append(", ".join(summary_items) + f" in `{project_name}`." if summary_items else "")
352
- parts.append("")
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)
353
569
 
354
- parts.append("## PROMPTS")
570
+ # Prompts go FIRST — they define the session's objective
355
571
  if prompts:
356
- for i, p in enumerate(prompts, 1):
357
- parts.append(f"### {i}. [{p['ts']}]")
358
- parts.append(f"> {p['text']}")
359
- parts.append("")
360
- parts.append("")
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, "")
361
576
 
362
- parts.append("## INVESTIGATED")
363
- if investigated:
364
- parts.extend(investigated)
365
- parts.append("")
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)
366
583
 
367
- parts.append("## LEARNED")
368
- if learned:
369
- parts.extend(learned)
370
- parts.append("")
584
+ raw_note = "\n".join(raw_parts)
371
585
 
372
- parts.append("## COMPLETED")
373
- if completed:
374
- parts.extend(completed)
375
- parts.append("")
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)
376
591
 
377
- parts.append("## NEXT STEPS")
378
- if next_steps:
379
- parts.extend(next_steps)
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
+
625
+ parts = [f"# Session {session_id}", ""]
626
+ parts.append(f"**Project:** `{project_dir}`")
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)")
380
629
  parts.append("")
381
630
 
382
- if timeline:
383
- parts.append("## Timeline")
384
- for line in timeline[:40]:
385
- parts.append(line)
386
- if len(timeline) > 40:
387
- parts.append(f" ... and {len(timeline) - 40} more actions")
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("")
645
+
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("")
653
+
654
+ parts.append("## INVESTIGATED")
655
+ if investigated:
656
+ parts.extend(investigated)
657
+ parts.append("")
658
+
659
+ parts.append("## LEARNED")
660
+ if learned:
661
+ parts.extend(learned)
662
+ parts.append("")
663
+
664
+ parts.append("## COMPLETED")
665
+ if completed:
666
+ parts.extend(completed)
667
+ parts.append("")
668
+
669
+ parts.append("## NEXT STEPS")
670
+ if next_steps:
671
+ parts.extend(next_steps)
672
+ parts.append("")
388
673
 
389
674
  content = "\n".join(parts)
390
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
- return (
243
- f"Vault stats:\n"
244
- f" Notes: {s['notes']}\n"
245
- f" Folders: {s['folders']}\n"
246
- f" Tags: {s['tags']}\n"
247
- f" Links: {s['links']}\n"
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()