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 +44 -3
- package/kyp_mem/config.py +6 -0
- package/kyp_mem/hooks.py +320 -35
- package/kyp_mem/server.py +32 -9
- package/kyp_mem/static/index.html +1673 -2722
- package/kyp_mem/ui.py +90 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -0
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 == "
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
570
|
+
# Prompts go FIRST — they define the session's objective
|
|
355
571
|
if prompts:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
if
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
if learned:
|
|
369
|
-
parts.extend(learned)
|
|
370
|
-
parts.append("")
|
|
584
|
+
raw_note = "\n".join(raw_parts)
|
|
371
585
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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()
|