kyp-mem 0.4.2 → 0.4.4

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/bin/cli.mjs CHANGED
@@ -60,6 +60,23 @@ if (args[0] === "hook") {
60
60
  await new Promise((r) => process.stdin.on("end", r));
61
61
  const raw = Buffer.concat(chunks).toString();
62
62
 
63
+ if (hookType === "user-prompt") {
64
+ try {
65
+ const data = JSON.parse(raw);
66
+ const prompt = (data.prompt || "").trim();
67
+ if (!prompt) process.exit(0);
68
+ const entry = {
69
+ ts: new Date().toISOString(),
70
+ cwd: process.env.CLAUDE_PROJECT_DIR || process.cwd(),
71
+ action: "prompt",
72
+ prompt,
73
+ };
74
+ mkdirSync(sessionDir, { recursive: true });
75
+ appendFileSync(sessionFile, JSON.stringify(entry) + "\n");
76
+ } catch (_) {}
77
+ process.exit(0);
78
+ }
79
+
63
80
  if (hookType === "post-tool-use") {
64
81
  try {
65
82
  const data = JSON.parse(raw);
@@ -72,6 +89,9 @@ if (args[0] === "hook") {
72
89
  if (tool === "Edit" || tool === "Write") {
73
90
  entry.file = input.file_path || "";
74
91
  entry.action = tool === "Edit" ? "edit" : "create";
92
+ } else if (tool === "Read") {
93
+ entry.file = input.file_path || "";
94
+ entry.action = "read";
75
95
  } else if (tool === "Bash") {
76
96
  entry.command = (input.command || "").slice(0, 300);
77
97
  entry.action = "command";
package/kyp_mem/cli.py CHANGED
@@ -45,6 +45,7 @@ def main():
45
45
 
46
46
  hook_parser = subparsers.add_parser("hook", help="Handle Claude Code hook events (internal)")
47
47
  hook_sub = hook_parser.add_subparsers(dest="hook_command")
48
+ hook_sub.add_parser("session-start", help="Inject project context at session start")
48
49
  hook_sub.add_parser("post-tool-use", help="Capture tool activity to session log")
49
50
  hook_sub.add_parser("user-prompt", help="Capture user prompt to session log")
50
51
  hook_sub.add_parser("stop", help="Compile session into vault note")
@@ -73,8 +74,10 @@ def main():
73
74
  elif args.command == "doctor":
74
75
  _run_doctor()
75
76
  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":
77
+ from .hooks import handle_session_start, handle_post_tool_use, handle_user_prompt, handle_stop
78
+ if args.hook_command == "session-start":
79
+ handle_session_start()
80
+ elif args.hook_command == "post-tool-use":
78
81
  handle_post_tool_use()
79
82
  elif args.hook_command == "user-prompt":
80
83
  handle_user_prompt()
@@ -286,7 +289,7 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
286
289
 
287
290
  if remove:
288
291
  changed = False
289
- for event in ("PostToolUse", "UserPromptSubmit", "Stop"):
292
+ for event in ("SessionStart", "PostToolUse", "UserPromptSubmit", "Stop"):
290
293
  if event in hooks:
291
294
  hooks[event] = [h for h in hooks[event] if not _has_kyp_hook(h)]
292
295
  if not hooks[event]:
@@ -301,16 +304,21 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
301
304
  print()
302
305
  return
303
306
 
307
+ session_start_hooks = hooks.setdefault("SessionStart", [])
304
308
  post_tool_hooks = hooks.setdefault("PostToolUse", [])
305
309
  prompt_hooks = hooks.setdefault("UserPromptSubmit", [])
306
310
  stop_hooks = hooks.setdefault("Stop", [])
307
311
 
312
+ session_start_hooks = [h for h in session_start_hooks if not _has_kyp_hook(h)]
308
313
  post_tool_hooks = [h for h in post_tool_hooks if not _has_kyp_hook(h)]
309
314
  prompt_hooks = [h for h in prompt_hooks if not _has_kyp_hook(h)]
310
315
  stop_hooks = [h for h in stop_hooks if not _has_kyp_hook(h)]
311
316
 
317
+ session_start_hooks.append({
318
+ "hooks": [{"type": "command", "command": f"{mcp_command} hook session-start"}],
319
+ })
312
320
  post_tool_hooks.append({
313
- "matcher": "Edit|Write|Bash",
321
+ "matcher": "Edit|Write|Read|Bash",
314
322
  "hooks": [{"type": "command", "command": f"{mcp_command} hook post-tool-use"}],
315
323
  })
316
324
  prompt_hooks.append({
@@ -320,6 +328,7 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
320
328
  "hooks": [{"type": "command", "command": f"{mcp_command} hook stop"}],
321
329
  })
322
330
 
331
+ hooks["SessionStart"] = session_start_hooks
323
332
  hooks["PostToolUse"] = post_tool_hooks
324
333
  hooks["UserPromptSubmit"] = prompt_hooks
325
334
  hooks["Stop"] = stop_hooks
package/kyp_mem/hooks.py CHANGED
@@ -12,6 +12,81 @@ CURRENT_SESSION = SESSION_DIR / "current.jsonl"
12
12
  MIN_ACTIONS = 3
13
13
 
14
14
 
15
+ def handle_session_start():
16
+ """Inject project context into the conversation at session start."""
17
+ sys.stdin.read()
18
+
19
+ cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
20
+ project_name = Path(cwd).name
21
+
22
+ try:
23
+ from .config import get_vault_path
24
+ from .vault import Vault
25
+
26
+ vault = Vault(get_vault_path())
27
+
28
+ project_notes = [p for p in vault.index.notes if p.startswith(f"{project_name}/")]
29
+ if not project_notes:
30
+ return
31
+
32
+ parts = [f"# [kyp-mem] {project_name} — Project Context"]
33
+ parts.append(f"Vault: {get_vault_path()}")
34
+ parts.append("")
35
+
36
+ knowledge_path = f"{project_name}/Knowledge.md"
37
+ knowledge = vault.read(knowledge_path)
38
+ if knowledge:
39
+ parts.append("## Knowledge")
40
+ content = knowledge.content
41
+ timeline_idx = content.find("## Timeline")
42
+ if timeline_idx > 0:
43
+ content = content[:timeline_idx].strip()
44
+ if len(content) > 2000:
45
+ parts.append(content[:2000] + "\n...")
46
+ else:
47
+ parts.append(content)
48
+ parts.append("")
49
+
50
+ other_notes = sorted(
51
+ p for p in project_notes
52
+ if "/Sessions/" not in p and p != knowledge_path
53
+ )
54
+ if other_notes:
55
+ parts.append("## Project Notes")
56
+ for p in other_notes:
57
+ note = vault.index.notes.get(p)
58
+ title = note.title if note else p
59
+ tags = f" [{', '.join(note.tags)}]" if note and note.tags else ""
60
+ parts.append(f"- {title} ({p}){tags}")
61
+ parts.append("")
62
+
63
+ sessions = sorted(
64
+ (p for p in project_notes if "/Sessions/" in p),
65
+ reverse=True,
66
+ )[:3]
67
+ if sessions:
68
+ parts.append(f"## Recent Sessions (last {len(sessions)})")
69
+ for sp in sessions:
70
+ note = vault.read(sp)
71
+ if not note:
72
+ continue
73
+ parts.append(f"### {note.title}")
74
+ content = note.content
75
+ timeline_idx = content.find("## Timeline")
76
+ if timeline_idx > 0:
77
+ content = content[:timeline_idx].strip()
78
+ if len(content) > 300:
79
+ content = content[:300] + "..."
80
+ parts.append(content)
81
+ parts.append("")
82
+
83
+ parts.append("Use `kyp_project_context` for full details. Use `kyp_session_search` to search past sessions.")
84
+
85
+ print("\n".join(parts))
86
+ except Exception:
87
+ pass
88
+
89
+
15
90
  def handle_user_prompt():
16
91
  raw = sys.stdin.read().strip()
17
92
  if not raw:
@@ -60,6 +135,9 @@ def handle_post_tool_use():
60
135
  elif tool_name == "Write":
61
136
  entry["action"] = "create"
62
137
  entry["file"] = tool_input.get("file_path", "")
138
+ elif tool_name == "Read":
139
+ entry["action"] = "read"
140
+ entry["file"] = tool_input.get("file_path", "")
63
141
  elif tool_name == "Bash":
64
142
  entry["action"] = "command"
65
143
  entry["command"] = tool_input.get("command", "")
@@ -71,6 +149,197 @@ def handle_post_tool_use():
71
149
  f.write(json.dumps(entry) + "\n")
72
150
 
73
151
 
152
+ def _relative_path(filepath, project_dir):
153
+ try:
154
+ return str(Path(filepath).relative_to(project_dir))
155
+ except (ValueError, TypeError):
156
+ return Path(filepath).name if filepath else ""
157
+
158
+
159
+ def _group_files_by_dir(filepaths, project_dir):
160
+ groups = {}
161
+ for fp in sorted(filepaths):
162
+ rel = _relative_path(fp, project_dir)
163
+ parent = str(Path(rel).parent)
164
+ if parent == ".":
165
+ parent = "(root)"
166
+ groups.setdefault(parent, []).append(Path(rel).name)
167
+ return groups
168
+
169
+
170
+ def _classify_command(cmd):
171
+ if not cmd.strip():
172
+ return "other", ""
173
+ first = cmd.strip().split()[0]
174
+ search_cmds = {'grep', 'rg', 'ag', 'ack'}
175
+ explore_cmds = {'find', 'fd', 'ls', 'tree', 'du'}
176
+ read_cmds = {'cat', 'head', 'tail', 'less', 'more', 'wc', 'file'}
177
+ diff_cmds = {'diff', 'git diff', 'git log', 'git status', 'git show'}
178
+ test_cmds = {'pytest', 'python -m pytest', 'npm test', 'jest', 'mocha', 'cargo test', 'go test'}
179
+ build_cmds = {'npm run', 'npm install', 'pip install', 'make', 'cargo build', 'go build'}
180
+ server_cmds = {'python3 -m', 'uvicorn', 'node', 'npm start', 'flask run'}
181
+ git_cmds = {'git commit', 'git push', 'git add', 'git checkout', 'git branch', 'git merge', 'git stash'}
182
+
183
+ if first in search_cmds or cmd.strip().startswith('git grep'):
184
+ return "search", cmd
185
+ if first in explore_cmds:
186
+ return "explore", cmd
187
+ if first in read_cmds:
188
+ return "read_cmd", cmd
189
+ for prefix in diff_cmds:
190
+ if cmd.strip().startswith(prefix):
191
+ return "git_inspect", cmd
192
+ for prefix in test_cmds:
193
+ if cmd.strip().startswith(prefix):
194
+ return "test", cmd
195
+ for prefix in build_cmds:
196
+ if cmd.strip().startswith(prefix):
197
+ return "build", cmd
198
+ for prefix in git_cmds:
199
+ if cmd.strip().startswith(prefix):
200
+ return "git_write", cmd
201
+ for prefix in server_cmds:
202
+ if cmd.strip().startswith(prefix):
203
+ return "run", cmd
204
+ if first == 'curl':
205
+ return "api_test", cmd
206
+ return "other", cmd
207
+
208
+
209
+ def _build_investigated(files_read, commands_classified, project_dir):
210
+ items = []
211
+ seen_files = set()
212
+
213
+ search_cmds = [cmd for cls, cmd in commands_classified if cls == "search"]
214
+ for cmd in search_cmds[:8]:
215
+ parts = cmd.strip().split()
216
+ if len(parts) >= 2:
217
+ pattern = None
218
+ for i, p in enumerate(parts):
219
+ if not p.startswith('-') and i > 0:
220
+ pattern = p.strip("'\"")
221
+ break
222
+ if pattern:
223
+ items.append(f"- Searched for `{pattern[:60]}`")
224
+ else:
225
+ items.append(f"- `{cmd[:100]}`")
226
+
227
+ read_groups = _group_files_by_dir(files_read, project_dir)
228
+ for dir_name, filenames in read_groups.items():
229
+ unique = [f for f in filenames if f not in seen_files]
230
+ seen_files.update(unique)
231
+ if not unique:
232
+ continue
233
+ if len(unique) <= 3:
234
+ items.append(f"- Read {', '.join(f'`{f}`' for f in unique)}")
235
+ else:
236
+ items.append(f"- Read {len(unique)} files in `{dir_name}/`")
237
+
238
+ explore_cmds = [cmd for cls, cmd in commands_classified if cls == "explore"]
239
+ if explore_cmds:
240
+ items.append(f"- Explored project structure ({len(explore_cmds)} commands)")
241
+
242
+ git_cmds = [cmd for cls, cmd in commands_classified if cls == "git_inspect"]
243
+ if git_cmds:
244
+ items.append(f"- Inspected git history/diff ({len(git_cmds)} commands)")
245
+
246
+ api_cmds = [cmd for cls, cmd in commands_classified if cls == "api_test"]
247
+ if api_cmds:
248
+ items.append(f"- Tested API endpoints ({len(api_cmds)} requests)")
249
+
250
+ return items
251
+
252
+
253
+ def _build_learned(files_read, files_edited, files_created, commands_classified, project_dir):
254
+ items = []
255
+ read_set = {Path(f).name for f in files_read}
256
+ edit_set = {Path(f).name for f in files_edited}
257
+ create_set = {Path(f).name for f in files_created}
258
+
259
+ investigated_then_modified = read_set & edit_set
260
+ if investigated_then_modified:
261
+ names = sorted(investigated_then_modified)[:5]
262
+ items.append(f"- Investigated and modified: {', '.join(f'`{n}`' for n in names)}")
263
+
264
+ config_files = {f for f in (files_edited | files_created)
265
+ if any(f.endswith(ext) for ext in
266
+ ('.json', '.toml', '.yaml', '.yml', '.ini', '.cfg', '.env', '.conf'))}
267
+ if config_files:
268
+ items.append(f"- Configuration changes: {', '.join(f'`{Path(f).name}`' for f in sorted(config_files)[:4])}")
269
+
270
+ test_cmds = [cmd for cls, cmd in commands_classified if cls == "test"]
271
+ if test_cmds:
272
+ items.append(f"- Ran tests ({len(test_cmds)} run{'s' if len(test_cmds) != 1 else ''})")
273
+
274
+ new_only = create_set - read_set
275
+ if new_only:
276
+ items.append(f"- Created new files: {', '.join(f'`{n}`' for n in sorted(new_only)[:5])}")
277
+
278
+ return items
279
+
280
+
281
+ def _build_completed(files_edited, files_created, commands_classified, project_dir):
282
+ items = []
283
+
284
+ edit_groups = _group_files_by_dir(files_edited, project_dir)
285
+ for dir_name, filenames in edit_groups.items():
286
+ if len(filenames) == 1:
287
+ items.append(f"- Modified `{filenames[0]}`")
288
+ else:
289
+ items.append(f"- Modified {len(filenames)} files in `{dir_name}/`: {', '.join(f'`{f}`' for f in filenames[:5])}")
290
+
291
+ create_groups = _group_files_by_dir(files_created, project_dir)
292
+ for dir_name, filenames in create_groups.items():
293
+ if len(filenames) == 1:
294
+ items.append(f"- Created `{filenames[0]}`")
295
+ else:
296
+ items.append(f"- Created {len(filenames)} files in `{dir_name}/`")
297
+
298
+ test_cmds = [cmd for cls, cmd in commands_classified if cls == "test"]
299
+ if test_cmds:
300
+ items.append(f"- Ran test suite")
301
+
302
+ git_writes = [cmd for cls, cmd in commands_classified if cls == "git_write"]
303
+ for cmd in git_writes:
304
+ if 'commit' in cmd:
305
+ items.append("- Committed changes to git")
306
+ break
307
+ for cmd in git_writes:
308
+ if 'push' in cmd:
309
+ items.append("- Pushed to remote")
310
+ break
311
+
312
+ build_cmds = [cmd for cls, cmd in commands_classified if cls == "build"]
313
+ if build_cmds:
314
+ items.append("- Ran build/install")
315
+
316
+ run_cmds = [cmd for cls, cmd in commands_classified if cls == "run"]
317
+ if run_cmds:
318
+ items.append("- Started/tested server")
319
+
320
+ return items
321
+
322
+
323
+ def _build_next_steps(files_edited, files_created, commands_classified):
324
+ items = []
325
+
326
+ git_writes = {cmd for cls, cmd in commands_classified if cls == "git_write"}
327
+ has_commit = any('commit' in cmd for cmd in git_writes)
328
+ has_push = any('push' in cmd for cmd in git_writes)
329
+
330
+ all_changed = files_edited | files_created
331
+ if all_changed and not has_commit:
332
+ items.append("- Commit pending changes")
333
+ if has_commit and not has_push:
334
+ items.append("- Push committed changes to remote")
335
+
336
+ test_cmds = [cmd for cls, cmd in commands_classified if cls == "test"]
337
+ if not test_cmds and all_changed:
338
+ items.append("- Run tests to verify changes")
339
+
340
+ return items
341
+
342
+
74
343
  def handle_stop():
75
344
  if not CURRENT_SESSION.exists():
76
345
  return
@@ -100,6 +369,7 @@ def handle_stop():
100
369
  project_name = Path(project_dir).name
101
370
  session_id = datetime.now().strftime("%Y-%m-%d_%H%M%S")
102
371
 
372
+ files_read = set()
103
373
  files_edited = set()
104
374
  files_created = set()
105
375
  commands = []
@@ -114,6 +384,10 @@ def handle_stop():
114
384
  if action == "prompt":
115
385
  prompts.append({"ts": ts, "text": e.get("prompt", "")})
116
386
  timeline.append(f" {ts} — Prompt: {e.get('prompt', '')[:60]}...")
387
+ elif action == "read":
388
+ fp = e.get("file", "")
389
+ files_read.add(fp)
390
+ timeline.append(f" {ts} — Read `{Path(fp).name}`")
117
391
  elif action == "edit":
118
392
  fp = e.get("file", "")
119
393
  files_edited.add(fp)
@@ -128,6 +402,8 @@ def handle_stop():
128
402
  short = cmd[:80] + "..." if len(cmd) > 80 else cmd
129
403
  timeline.append(f" {ts} — `{short}`")
130
404
 
405
+ commands_classified = [_classify_command(cmd) for cmd in commands]
406
+
131
407
  summary_items = []
132
408
  if files_edited:
133
409
  summary_items.append(f"Modified {len(files_edited)} file{'s' if len(files_edited) != 1 else ''}")
@@ -136,12 +412,10 @@ def handle_stop():
136
412
  if commands:
137
413
  summary_items.append(f"Ran {len(commands)} command{'s' if len(commands) != 1 else ''}")
138
414
 
139
- investigate_keywords = {'grep', 'find', 'cat', 'head', 'tail', 'less', 'ls', 'tree', 'rg', 'ag', 'fd', 'wc', 'diff'}
140
- investigated_cmds = []
141
- for cmd in commands:
142
- first_word = cmd.strip().split()[0] if cmd.strip() else ''
143
- if first_word in investigate_keywords:
144
- investigated_cmds.append(cmd)
415
+ investigated = _build_investigated(files_read, commands_classified, project_dir)
416
+ learned = _build_learned(files_read, files_edited, files_created, commands_classified, project_dir)
417
+ completed = _build_completed(files_edited, files_created, commands_classified, project_dir)
418
+ next_steps = _build_next_steps(files_edited, files_created, commands_classified)
145
419
 
146
420
  parts = [f"# Session {session_id}", ""]
147
421
  parts.append(f"**Project:** `{project_dir}`")
@@ -161,27 +435,23 @@ def handle_stop():
161
435
  parts.append("")
162
436
 
163
437
  parts.append("## INVESTIGATED")
164
- if investigated_cmds:
165
- for cmd in investigated_cmds[:15]:
166
- short = cmd[:120] + "..." if len(cmd) > 120 else cmd
167
- parts.append(f"- `{short}`")
438
+ if investigated:
439
+ parts.extend(investigated)
168
440
  parts.append("")
169
441
 
170
442
  parts.append("## LEARNED")
171
- parts.append("")
443
+ if learned:
444
+ parts.extend(learned)
172
445
  parts.append("")
173
446
 
174
447
  parts.append("## COMPLETED")
175
- if files_edited:
176
- for f in sorted(files_edited):
177
- parts.append(f"- Modified `{Path(f).name}`")
178
- if files_created:
179
- for f in sorted(files_created):
180
- parts.append(f"- Created `{Path(f).name}`")
448
+ if completed:
449
+ parts.extend(completed)
181
450
  parts.append("")
182
451
 
183
452
  parts.append("## NEXT STEPS")
184
- parts.append("")
453
+ if next_steps:
454
+ parts.extend(next_steps)
185
455
  parts.append("")
186
456
 
187
457
  if timeline:
package/kyp_mem/server.py CHANGED
@@ -324,11 +324,20 @@ def kyp_project_context(project: str) -> str:
324
324
  """CALL THIS AT SESSION START. Returns the project's full context: Knowledge.md (ground truth), project notes, and recent session summaries. Use this to understand architecture, known bugs, past decisions, and what was done recently. This prevents hallucination and avoids repeating past work."""
325
325
  parts = []
326
326
 
327
+ MAX_KNOWLEDGE_CHARS = 3000
328
+ MAX_NOTE_PREVIEW_LINES = 6
329
+ MAX_SESSION_CHARS = 400
330
+
327
331
  knowledge_path = f"{project}/Knowledge.md"
328
332
  knowledge = vault.read(knowledge_path)
329
333
  if knowledge:
330
334
  parts.append("=== PROJECT KNOWLEDGE ===")
331
- parts.append(knowledge.content)
335
+ content = knowledge.content
336
+ if len(content) > MAX_KNOWLEDGE_CHARS:
337
+ parts.append(content[:MAX_KNOWLEDGE_CHARS])
338
+ parts.append(f"\n... (truncated — use kyp_read(\"{knowledge_path}\", full=True) for complete content)")
339
+ else:
340
+ parts.append(content)
332
341
  parts.append("")
333
342
 
334
343
  project_notes = []
@@ -341,9 +350,9 @@ def kyp_project_context(project: str) -> str:
341
350
  for path, note in sorted(project_notes):
342
351
  parts.append(f"\n--- {note.title} ({path}) ---")
343
352
  preview = note.content.strip().split("\n")
344
- parts.append("\n".join(preview[:10]))
345
- if len(preview) > 10:
346
- parts.append("...")
353
+ parts.append("\n".join(preview[:MAX_NOTE_PREVIEW_LINES]))
354
+ if len(preview) > MAX_NOTE_PREVIEW_LINES:
355
+ parts.append(f"... (use kyp_read(\"{path}\", full=True) for complete content)")
347
356
  parts.append("")
348
357
 
349
358
  sessions = []
@@ -360,9 +369,11 @@ def kyp_project_context(project: str) -> str:
360
369
  content = note.content
361
370
  timeline_idx = content.find("## Timeline")
362
371
  if timeline_idx > 0:
363
- parts.append(content[:timeline_idx].strip())
372
+ content = content[:timeline_idx].strip()
373
+ if len(content) > MAX_SESSION_CHARS:
374
+ parts.append(content[:MAX_SESSION_CHARS] + "...")
364
375
  else:
365
- parts.append(content[:500])
376
+ parts.append(content)
366
377
  parts.append("")
367
378
 
368
379
  if not parts:
@@ -1294,8 +1294,8 @@ body.resizing .resize-handle { pointer-events: auto !important; }
1294
1294
 
1295
1295
  .session-item {
1296
1296
  display: flex;
1297
- align-items: center;
1298
- padding: 3px 10px;
1297
+ align-items: flex-start;
1298
+ padding: 5px 10px;
1299
1299
  border-radius: var(--radius-sm);
1300
1300
  cursor: pointer;
1301
1301
  font-size: 11px;
@@ -1328,6 +1328,14 @@ body.resizing .resize-handle { pointer-events: auto !important; }
1328
1328
  color: var(--neon-green);
1329
1329
  opacity: 0.5;
1330
1330
  flex-shrink: 0;
1331
+ margin-top: 3px;
1332
+ }
1333
+
1334
+ .session-item .si-content {
1335
+ display: flex;
1336
+ flex-direction: column;
1337
+ min-width: 0;
1338
+ flex: 1;
1331
1339
  }
1332
1340
 
1333
1341
  .session-item .si-time {
@@ -1336,7 +1344,17 @@ body.resizing .resize-handle { pointer-events: auto !important; }
1336
1344
  color: var(--text-secondary);
1337
1345
  }
1338
1346
 
1347
+ .session-item .si-summary {
1348
+ font-size: 10px;
1349
+ color: var(--text-muted);
1350
+ white-space: nowrap;
1351
+ overflow: hidden;
1352
+ text-overflow: ellipsis;
1353
+ line-height: 1.3;
1354
+ }
1355
+
1339
1356
  .session-item.active .si-time { color: var(--neon-green); }
1357
+ .session-item.active .si-summary { color: var(--text-secondary); }
1340
1358
 
1341
1359
  .session-badge {
1342
1360
  display: inline-flex;
@@ -1573,29 +1591,52 @@ body.resizing .resize-handle { pointer-events: auto !important; }
1573
1591
  color: var(--text-secondary);
1574
1592
  }
1575
1593
 
1576
- /* ============ BACKLINK CONTEXT ============ */
1577
- .rp-item .rp-context {
1578
- font-size: 10px;
1579
- color: var(--text-muted);
1580
- margin-top: 2px;
1581
- line-height: 1.4;
1582
- overflow: hidden;
1583
- text-overflow: ellipsis;
1584
- white-space: nowrap;
1585
- }
1586
-
1587
- .rp-item-col {
1594
+ /* ============ BACKLINK & UNLINKED ITEMS ============ */
1595
+ .rp-link-item {
1588
1596
  display: flex;
1589
- flex-direction: column;
1590
- padding: 5px 8px;
1597
+ align-items: center;
1598
+ padding: 4px 8px;
1591
1599
  border-radius: var(--radius-sm);
1592
1600
  cursor: pointer;
1593
1601
  margin-bottom: 1px;
1594
1602
  transition: background 0.1s;
1603
+ gap: 6px;
1604
+ }
1605
+
1606
+ .rp-link-item:hover { background: var(--bg-hover); }
1607
+
1608
+ .rp-link-item .rp-link-icon {
1609
+ font-size: 9px;
1610
+ flex-shrink: 0;
1611
+ opacity: 0.5;
1612
+ }
1613
+
1614
+ .rp-link-item .rp-link-icon.backlink { color: var(--neon-cyan); }
1615
+ .rp-link-item .rp-link-icon.unlinked { color: var(--text-muted); }
1616
+
1617
+ .rp-link-item .rp-link-title {
1618
+ font-size: 12px;
1619
+ color: var(--neon-cyan);
1620
+ font-weight: 500;
1595
1621
  overflow: hidden;
1622
+ text-overflow: ellipsis;
1623
+ white-space: nowrap;
1624
+ transition: all 0.15s;
1596
1625
  }
1597
1626
 
1598
- .rp-item-col:hover { background: var(--bg-hover); }
1627
+ .rp-link-item:hover .rp-link-title {
1628
+ text-decoration: underline;
1629
+ text-underline-offset: 2px;
1630
+ }
1631
+
1632
+ .rp-link-item.unlinked .rp-link-title {
1633
+ color: var(--text-secondary);
1634
+ }
1635
+
1636
+ .rp-link-item.unlinked:hover .rp-link-title {
1637
+ color: var(--neon-cyan);
1638
+ text-decoration: underline;
1639
+ }
1599
1640
 
1600
1641
  /* ============ TRANSITIONS ============ */
1601
1642
  .fade-in { animation: fadeIn 0.15s ease; }
@@ -2188,10 +2229,8 @@ function renderRightPanel(note) {
2188
2229
  document.getElementById('bl-count').textContent = backlinks.length;
2189
2230
  backlinks.forEach(bl => {
2190
2231
  const item = document.createElement('div');
2191
- item.className = 'rp-item-col';
2192
- let html = `<span class="rp-title rp-backlink-title" style="font-size:11px">${bl.title || bl.path || bl}</span>`;
2193
- if (bl.context) html += `<span class="rp-context">${bl.context}</span>`;
2194
- item.innerHTML = html;
2232
+ item.className = 'rp-link-item';
2233
+ item.innerHTML = `<span class="rp-link-icon backlink">&#8592;</span><span class="rp-link-title">${bl.title || bl.path || bl}</span>`;
2195
2234
  item.addEventListener('click', () => loadNote(bl.path || bl));
2196
2235
  blList.appendChild(item);
2197
2236
  });
@@ -2244,10 +2283,8 @@ function renderRightPanel(note) {
2244
2283
  document.getElementById('unlinked-count').textContent = unlinked.length;
2245
2284
  unlinked.forEach(u => {
2246
2285
  const item = document.createElement('div');
2247
- item.className = 'rp-item-col';
2248
- let html = `<span class="rp-title" style="font-size:11px;color:var(--text-secondary)">${u.title}</span>`;
2249
- if (u.context) html += `<span class="rp-context">${u.context}</span>`;
2250
- item.innerHTML = html;
2286
+ item.className = 'rp-link-item unlinked';
2287
+ item.innerHTML = `<span class="rp-link-icon unlinked">&#8776;</span><span class="rp-link-title">${u.title}</span>`;
2251
2288
  item.addEventListener('click', () => loadNote(u.path));
2252
2289
  ulList.appendChild(item);
2253
2290
  });
@@ -2905,6 +2942,23 @@ async function init() {
2905
2942
  `;
2906
2943
  }
2907
2944
 
2945
+ // --- Auto-refresh: poll for vault changes ---
2946
+ let lastKnownStats = null;
2947
+
2948
+ async function pollForChanges() {
2949
+ try {
2950
+ const stats = await fetchJSON('/api/stats');
2951
+ const key = `${stats.notes}:${stats.links}:${stats.tags}`;
2952
+ if (lastKnownStats && lastKnownStats !== key) {
2953
+ await refreshTree();
2954
+ if (currentPath) loadNote(currentPath);
2955
+ }
2956
+ lastKnownStats = key;
2957
+ } catch {}
2958
+ }
2959
+
2960
+ setInterval(pollForChanges, 3000);
2961
+
2908
2962
  initResize();
2909
2963
  initGraphResize();
2910
2964
  init();
package/kyp_mem/ui.py CHANGED
@@ -177,12 +177,22 @@ def create_app(vault_path: str = None) -> FastAPI:
177
177
  continue
178
178
  if proj not in sessions:
179
179
  sessions[proj] = []
180
+ summary = ""
181
+ lines = (note.content or "").split("\n")
182
+ for i, line in enumerate(lines):
183
+ if line.strip() == "## Summary":
184
+ for j in range(i + 1, len(lines)):
185
+ if lines[j].strip() and not lines[j].startswith("#"):
186
+ summary = lines[j].strip()
187
+ break
188
+ break
180
189
  sessions[proj].append({
181
190
  "path": path,
182
191
  "title": note.title,
183
192
  "tags": note.tags,
184
193
  "created": note.created,
185
194
  "updated": note.updated,
195
+ "summary": summary,
186
196
  })
187
197
  for proj in sessions:
188
198
  sessions[proj].sort(key=lambda s: s["path"], reverse=True)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kyp-mem",
3
- "version": "0.4.2",
4
- "description": "Know Your Project — Persistent knowledge base for AI agents. MCP-powered with wikilinks, backlinks, auto-learning, and neon web UI.",
3
+ "version": "0.4.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"
7
7
  },