kyp-mem 0.6.1 → 0.6.5

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/hooks.py CHANGED
@@ -3,6 +3,7 @@
3
3
  import os
4
4
  import sys
5
5
  import json
6
+ import subprocess
6
7
  from datetime import datetime
7
8
  from pathlib import Path
8
9
 
@@ -73,9 +74,51 @@ def _record_injection(project, chars):
73
74
  _save_token_stats(stats)
74
75
 
75
76
 
77
+ def _is_subprocess():
78
+ return os.environ.get("KYP_MEM_SUMMARIZING") == "1"
79
+
80
+
81
+ def _extract_session_summary(content, max_chars=800):
82
+ import re
83
+ parts = []
84
+ for heading in ("Summary", "LEARNED", "COMPLETED"):
85
+ m = re.search(rf"(?:^|\n)##\s+{re.escape(heading)}\s*\n(.*?)(?=\n##\s|\Z)", content, re.DOTALL)
86
+ if m:
87
+ text = m.group(1).strip()
88
+ if heading == "Summary":
89
+ parts.append(text)
90
+ else:
91
+ parts.append(f"**{heading.title()}:** {text[:250]}")
92
+ return "\n".join(parts)[:max_chars] if parts else content[:200]
93
+
94
+
95
+ def _build_stats_line(project_name, injected_chars, session_ids):
96
+ try:
97
+ stats = _load_token_stats()
98
+ project_sessions = [s for s in stats.get("sessions", []) if s.get("project") == project_name]
99
+ if not project_sessions:
100
+ return None
101
+ matched = [s for s in project_sessions if s.get("id") in session_ids]
102
+ exploration_tokens = sum(s.get("exploration_tokens", 0) for s in matched)
103
+ injected_tokens = injected_chars // CHARS_PER_TOKEN
104
+ if exploration_tokens == 0:
105
+ return None
106
+ saved = exploration_tokens - injected_tokens
107
+ if saved <= 0:
108
+ return None
109
+ return (
110
+ f"---\n"
111
+ f"*kyp-mem saved ~{saved:,} tokens this session (injected ~{injected_tokens:,} tokens instead of re-exploring ~{exploration_tokens:,})*"
112
+ )
113
+ except Exception:
114
+ return None
115
+
116
+
76
117
  def handle_session_start():
77
118
  """Inject recent session memory into the conversation at session start."""
78
119
  sys.stdin.read()
120
+ if _is_subprocess():
121
+ return
79
122
 
80
123
  cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
81
124
  project_name = Path(cwd).name
@@ -98,8 +141,7 @@ def handle_session_start():
98
141
  return
99
142
 
100
143
  parts = [f"# [kyp-mem] {project_name} — Recent Sessions"]
101
- parts.append(f"Use `kyp_search` or `kyp_project_context` for architecture/project knowledge on demand.")
102
- parts.append("")
144
+ parts.append(f"Use `kyp_search` or `kyp_project_context` for architecture/project knowledge on demand.\n")
103
145
 
104
146
  parts.append(f"## Last {len(sessions)} Sessions")
105
147
  for sp in sessions:
@@ -107,16 +149,20 @@ def handle_session_start():
107
149
  if not note:
108
150
  continue
109
151
  parts.append(f"### {note.title}")
110
- content = note.content
111
- timeline_idx = content.find("## TIMELINE")
112
- if timeline_idx < 0:
113
- timeline_idx = content.find("## Timeline")
114
- if timeline_idx > 0:
115
- content = content[:timeline_idx].strip()
116
- parts.append(content)
152
+ summary = _extract_session_summary(note.content)
153
+ parts.append(summary)
117
154
  parts.append("")
118
155
 
156
+ session_ids = {Path(sp).stem for sp in sessions}
157
+ stats_line = _build_stats_line(project_name, len("\n".join(parts)), session_ids)
158
+ if stats_line:
159
+ parts.append(stats_line)
160
+
161
+ parts.append("")
162
+ parts.append("**INSTRUCTION:** Display the session summaries and token savings above to the user as your first message at session start. Format it cleanly.")
163
+
119
164
  output = "\n".join(parts)
165
+
120
166
  try:
121
167
  _record_injection(project_name, len(output))
122
168
  except Exception:
@@ -128,7 +174,7 @@ def handle_session_start():
128
174
 
129
175
  def handle_user_prompt():
130
176
  raw = sys.stdin.read().strip()
131
- if not raw:
177
+ if not raw or _is_subprocess():
132
178
  return
133
179
  try:
134
180
  data = json.loads(raw)
@@ -153,7 +199,7 @@ def handle_user_prompt():
153
199
 
154
200
  def handle_post_tool_use():
155
201
  raw = sys.stdin.read().strip()
156
- if not raw:
202
+ if not raw or _is_subprocess():
157
203
  return
158
204
  try:
159
205
  data = json.loads(raw)
@@ -454,9 +500,11 @@ Return ONLY this format (no preamble):
454
500
  Raw session data:
455
501
  {raw_note}"""
456
502
 
503
+ env = os.environ.copy()
504
+ env["KYP_MEM_SUMMARIZING"] = "1"
457
505
  result = subprocess.run(
458
506
  [claude_bin, "-p", prompt, "--max-turns", "1", "--model", model],
459
- capture_output=True, text=True, timeout=120,
507
+ capture_output=True, text=True, timeout=120, env=env,
460
508
  )
461
509
  if result.returncode == 0 and result.stdout.strip():
462
510
  return result.stdout.strip()
@@ -466,7 +514,7 @@ Raw session data:
466
514
 
467
515
 
468
516
  def handle_stop():
469
- if not CURRENT_SESSION.exists():
517
+ if _is_subprocess() or not CURRENT_SESSION.exists():
470
518
  return
471
519
 
472
520
  text = CURRENT_SESSION.read_text().strip()
@@ -642,6 +690,10 @@ def handle_stop():
642
690
  except Exception:
643
691
  pass
644
692
 
693
+ # Delete session file BEFORE summarization so the spawned claude subprocess
694
+ # doesn't pollute it via hooks writing back into current.jsonl
695
+ CURRENT_SESSION.unlink(missing_ok=True)
696
+
645
697
  # Try Claude summarization, fall back to raw sections
646
698
  summarized = _summarize_with_claude(raw_note, project_name)
647
699
 
@@ -710,8 +762,6 @@ def handle_stop():
710
762
  vault = Vault(get_vault_path())
711
763
  vault.write_note(f"{project_name}/Sessions/{session_id}.md", content, tags, {})
712
764
 
713
- CURRENT_SESSION.unlink(missing_ok=True)
714
-
715
765
 
716
766
  def main():
717
767
  if len(sys.argv) > 1 and sys.argv[1] == "stop":
@@ -1042,6 +1042,18 @@ function openSession(path) {
1042
1042
  loadNote(path);
1043
1043
  }
1044
1044
 
1045
+ async function deleteSession(path) {
1046
+ if (!confirm('Delete this session?')) return;
1047
+ const res = await fetch(`/api/note/${path}`, { method: 'DELETE' });
1048
+ const data = await res.json();
1049
+ if (data.ok) {
1050
+ activeSession = null;
1051
+ currentPath = null;
1052
+ $('content-area').innerHTML = '<div class="dim" style="padding:40px;text-align:center">Session deleted</div>';
1053
+ refreshAll();
1054
+ }
1055
+ }
1056
+
1045
1057
  // ─── Load Note ───────────────────────────────────────────────────────────────
1046
1058
  async function loadNote(path) {
1047
1059
  currentPath = path;
@@ -1370,7 +1382,10 @@ function renderSessionView(note) {
1370
1382
  ${tagsHtml}
1371
1383
  <span class="dim" style="font-size:var(--fz-xs);margin-left:8px">${note.created || sessionFile}</span>
1372
1384
  </div>
1373
- <button class="ghost-btn" onclick="openEditor('${note.path}')">⌥ edit</button>
1385
+ <div style="display:flex;gap:6px">
1386
+ <button class="ghost-btn" onclick="openEditor('${note.path}')">⌥ edit</button>
1387
+ <button class="ghost-btn" style="color:var(--muted)" onclick="deleteSession('${note.path}')">✕ delete</button>
1388
+ </div>
1374
1389
  </div>
1375
1390
  <h1 style="margin:12px 0 4px;font-size:calc(var(--fz-xl) + 6px);font-weight:500;letter-spacing:-0.01em">
1376
1391
  Session ${sessionFile}
package/kyp_mem/ui.py CHANGED
@@ -27,6 +27,7 @@ def create_app(vault_path: str = None) -> FastAPI:
27
27
 
28
28
  @app.get("/api/stats")
29
29
  def stats():
30
+ vault.refresh_if_stale()
30
31
  return JSONResponse(vault.get_stats())
31
32
 
32
33
  @app.get("/api/graph")
@@ -199,6 +200,7 @@ def create_app(vault_path: str = None) -> FastAPI:
199
200
 
200
201
  @app.get("/api/sessions")
201
202
  def list_sessions(project: str = ""):
203
+ vault.refresh_if_stale()
202
204
  sessions = {}
203
205
  for path, note in vault.index.notes.items():
204
206
  if "/Sessions/" not in path and not path.startswith("Sessions/"):
package/kyp_mem/vault.py CHANGED
@@ -211,6 +211,14 @@ class Vault:
211
211
  self._load_all()
212
212
  self._sync_vector_db()
213
213
 
214
+ def _disk_note_paths(self) -> set[str]:
215
+ return {str(f.relative_to(self.root)) for f in self.root.rglob("*.md")}
216
+
217
+ def refresh_if_stale(self):
218
+ if self._disk_note_paths() != set(self.index.notes.keys()):
219
+ self._load_all()
220
+ self._sync_vector_db()
221
+
214
222
  def _sync_vector_db(self):
215
223
  mem = get_session_memory()
216
224
  for path, note in self.index.notes.items():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyp-mem",
3
- "version": "0.6.1",
3
+ "version": "0.6.5",
4
4
  "description": "Know Your Project — Persistent & Session level knowledge base for AI agents. MCP-powered with wikilinks, backlinks, auto-learning, and neon web UI.",
5
5
  "bin": {
6
6
  "kyp-mem": "bin/cli.mjs"
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "kyp-mem"
7
- version = "0.4.2"
7
+ version = "0.6.5"
8
8
  description = "Know Your Project — Persistent knowledge base for AI agents. MCP-powered with wikilinks, backlinks, auto-learning, and neon web UI."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}