kyp-mem 0.4.4 → 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,6 +43,10 @@ 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")
48
52
  hook_sub.add_parser("session-start", help="Inject project context at session start")
@@ -71,6 +75,8 @@ def main():
71
75
  _run_tree()
72
76
  elif args.command == "install-hooks":
73
77
  _run_install_hooks(global_config=args.global_config, remove=args.remove)
78
+ elif args.command == "config":
79
+ _run_config(args.key, args.value)
74
80
  elif args.command == "doctor":
75
81
  _run_doctor()
76
82
  elif args.command == "hook":
@@ -351,6 +357,32 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
351
357
  print()
352
358
 
353
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
+
354
386
  def _run_stats():
355
387
  from .config import get_vault_path
356
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,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
- print("\n".join(parts))
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
- parts.append("## Summary")
426
- parts.append(", ".join(summary_items) + f" in `{project_name}`." if summary_items else "")
427
- parts.append("")
428
-
429
- parts.append("## PROMPTS")
430
- if prompts:
431
- for i, p in enumerate(prompts, 1):
432
- parts.append(f"### {i}. [{p['ts']}]")
433
- parts.append(f"> {p['text']}")
434
- parts.append("")
435
- parts.append("")
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
- parts.append("## INVESTIGATED")
438
- if investigated:
439
- parts.extend(investigated)
440
- parts.append("")
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
- parts.append("## LEARNED")
443
- if learned:
444
- parts.extend(learned)
445
- parts.append("")
654
+ parts.append("## INVESTIGATED")
655
+ if investigated:
656
+ parts.extend(investigated)
657
+ parts.append("")
446
658
 
447
- parts.append("## COMPLETED")
448
- if completed:
449
- parts.extend(completed)
450
- parts.append("")
659
+ parts.append("## LEARNED")
660
+ if learned:
661
+ parts.extend(learned)
662
+ parts.append("")
451
663
 
452
- parts.append("## NEXT STEPS")
453
- if next_steps:
454
- parts.extend(next_steps)
455
- parts.append("")
664
+ parts.append("## COMPLETED")
665
+ if completed:
666
+ parts.extend(completed)
667
+ parts.append("")
456
668
 
457
- if timeline:
458
- parts.append("## Timeline")
459
- for line in timeline[:40]:
460
- parts.append(line)
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
- 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()