kyp-mem 0.4.2 → 0.4.3

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
@@ -310,7 +310,7 @@ def _run_install_hooks(global_config: bool = False, remove: bool = False):
310
310
  stop_hooks = [h for h in stop_hooks if not _has_kyp_hook(h)]
311
311
 
312
312
  post_tool_hooks.append({
313
- "matcher": "Edit|Write|Bash",
313
+ "matcher": "Edit|Write|Read|Bash",
314
314
  "hooks": [{"type": "command", "command": f"{mcp_command} hook post-tool-use"}],
315
315
  })
316
316
  prompt_hooks.append({
package/kyp_mem/hooks.py CHANGED
@@ -60,6 +60,9 @@ def handle_post_tool_use():
60
60
  elif tool_name == "Write":
61
61
  entry["action"] = "create"
62
62
  entry["file"] = tool_input.get("file_path", "")
63
+ elif tool_name == "Read":
64
+ entry["action"] = "read"
65
+ entry["file"] = tool_input.get("file_path", "")
63
66
  elif tool_name == "Bash":
64
67
  entry["action"] = "command"
65
68
  entry["command"] = tool_input.get("command", "")
@@ -71,6 +74,197 @@ def handle_post_tool_use():
71
74
  f.write(json.dumps(entry) + "\n")
72
75
 
73
76
 
77
+ def _relative_path(filepath, project_dir):
78
+ try:
79
+ return str(Path(filepath).relative_to(project_dir))
80
+ except (ValueError, TypeError):
81
+ return Path(filepath).name if filepath else ""
82
+
83
+
84
+ def _group_files_by_dir(filepaths, project_dir):
85
+ groups = {}
86
+ for fp in sorted(filepaths):
87
+ rel = _relative_path(fp, project_dir)
88
+ parent = str(Path(rel).parent)
89
+ if parent == ".":
90
+ parent = "(root)"
91
+ groups.setdefault(parent, []).append(Path(rel).name)
92
+ return groups
93
+
94
+
95
+ def _classify_command(cmd):
96
+ if not cmd.strip():
97
+ return "other", ""
98
+ first = cmd.strip().split()[0]
99
+ search_cmds = {'grep', 'rg', 'ag', 'ack'}
100
+ explore_cmds = {'find', 'fd', 'ls', 'tree', 'du'}
101
+ read_cmds = {'cat', 'head', 'tail', 'less', 'more', 'wc', 'file'}
102
+ diff_cmds = {'diff', 'git diff', 'git log', 'git status', 'git show'}
103
+ test_cmds = {'pytest', 'python -m pytest', 'npm test', 'jest', 'mocha', 'cargo test', 'go test'}
104
+ build_cmds = {'npm run', 'npm install', 'pip install', 'make', 'cargo build', 'go build'}
105
+ server_cmds = {'python3 -m', 'uvicorn', 'node', 'npm start', 'flask run'}
106
+ git_cmds = {'git commit', 'git push', 'git add', 'git checkout', 'git branch', 'git merge', 'git stash'}
107
+
108
+ if first in search_cmds or cmd.strip().startswith('git grep'):
109
+ return "search", cmd
110
+ if first in explore_cmds:
111
+ return "explore", cmd
112
+ if first in read_cmds:
113
+ return "read_cmd", cmd
114
+ for prefix in diff_cmds:
115
+ if cmd.strip().startswith(prefix):
116
+ return "git_inspect", cmd
117
+ for prefix in test_cmds:
118
+ if cmd.strip().startswith(prefix):
119
+ return "test", cmd
120
+ for prefix in build_cmds:
121
+ if cmd.strip().startswith(prefix):
122
+ return "build", cmd
123
+ for prefix in git_cmds:
124
+ if cmd.strip().startswith(prefix):
125
+ return "git_write", cmd
126
+ for prefix in server_cmds:
127
+ if cmd.strip().startswith(prefix):
128
+ return "run", cmd
129
+ if first == 'curl':
130
+ return "api_test", cmd
131
+ return "other", cmd
132
+
133
+
134
+ def _build_investigated(files_read, commands_classified, project_dir):
135
+ items = []
136
+ seen_files = set()
137
+
138
+ search_cmds = [cmd for cls, cmd in commands_classified if cls == "search"]
139
+ for cmd in search_cmds[:8]:
140
+ parts = cmd.strip().split()
141
+ if len(parts) >= 2:
142
+ pattern = None
143
+ for i, p in enumerate(parts):
144
+ if not p.startswith('-') and i > 0:
145
+ pattern = p.strip("'\"")
146
+ break
147
+ if pattern:
148
+ items.append(f"- Searched for `{pattern[:60]}`")
149
+ else:
150
+ items.append(f"- `{cmd[:100]}`")
151
+
152
+ read_groups = _group_files_by_dir(files_read, project_dir)
153
+ for dir_name, filenames in read_groups.items():
154
+ unique = [f for f in filenames if f not in seen_files]
155
+ seen_files.update(unique)
156
+ if not unique:
157
+ continue
158
+ if len(unique) <= 3:
159
+ items.append(f"- Read {', '.join(f'`{f}`' for f in unique)}")
160
+ else:
161
+ items.append(f"- Read {len(unique)} files in `{dir_name}/`")
162
+
163
+ explore_cmds = [cmd for cls, cmd in commands_classified if cls == "explore"]
164
+ if explore_cmds:
165
+ items.append(f"- Explored project structure ({len(explore_cmds)} commands)")
166
+
167
+ git_cmds = [cmd for cls, cmd in commands_classified if cls == "git_inspect"]
168
+ if git_cmds:
169
+ items.append(f"- Inspected git history/diff ({len(git_cmds)} commands)")
170
+
171
+ api_cmds = [cmd for cls, cmd in commands_classified if cls == "api_test"]
172
+ if api_cmds:
173
+ items.append(f"- Tested API endpoints ({len(api_cmds)} requests)")
174
+
175
+ return items
176
+
177
+
178
+ def _build_learned(files_read, files_edited, files_created, commands_classified, project_dir):
179
+ items = []
180
+ read_set = {Path(f).name for f in files_read}
181
+ edit_set = {Path(f).name for f in files_edited}
182
+ create_set = {Path(f).name for f in files_created}
183
+
184
+ investigated_then_modified = read_set & edit_set
185
+ if investigated_then_modified:
186
+ names = sorted(investigated_then_modified)[:5]
187
+ items.append(f"- Investigated and modified: {', '.join(f'`{n}`' for n in names)}")
188
+
189
+ config_files = {f for f in (files_edited | files_created)
190
+ if any(f.endswith(ext) for ext in
191
+ ('.json', '.toml', '.yaml', '.yml', '.ini', '.cfg', '.env', '.conf'))}
192
+ if config_files:
193
+ items.append(f"- Configuration changes: {', '.join(f'`{Path(f).name}`' for f in sorted(config_files)[:4])}")
194
+
195
+ test_cmds = [cmd for cls, cmd in commands_classified if cls == "test"]
196
+ if test_cmds:
197
+ items.append(f"- Ran tests ({len(test_cmds)} run{'s' if len(test_cmds) != 1 else ''})")
198
+
199
+ new_only = create_set - read_set
200
+ if new_only:
201
+ items.append(f"- Created new files: {', '.join(f'`{n}`' for n in sorted(new_only)[:5])}")
202
+
203
+ return items
204
+
205
+
206
+ def _build_completed(files_edited, files_created, commands_classified, project_dir):
207
+ items = []
208
+
209
+ edit_groups = _group_files_by_dir(files_edited, project_dir)
210
+ for dir_name, filenames in edit_groups.items():
211
+ if len(filenames) == 1:
212
+ items.append(f"- Modified `{filenames[0]}`")
213
+ else:
214
+ items.append(f"- Modified {len(filenames)} files in `{dir_name}/`: {', '.join(f'`{f}`' for f in filenames[:5])}")
215
+
216
+ create_groups = _group_files_by_dir(files_created, project_dir)
217
+ for dir_name, filenames in create_groups.items():
218
+ if len(filenames) == 1:
219
+ items.append(f"- Created `{filenames[0]}`")
220
+ else:
221
+ items.append(f"- Created {len(filenames)} files in `{dir_name}/`")
222
+
223
+ test_cmds = [cmd for cls, cmd in commands_classified if cls == "test"]
224
+ if test_cmds:
225
+ items.append(f"- Ran test suite")
226
+
227
+ git_writes = [cmd for cls, cmd in commands_classified if cls == "git_write"]
228
+ for cmd in git_writes:
229
+ if 'commit' in cmd:
230
+ items.append("- Committed changes to git")
231
+ break
232
+ for cmd in git_writes:
233
+ if 'push' in cmd:
234
+ items.append("- Pushed to remote")
235
+ break
236
+
237
+ build_cmds = [cmd for cls, cmd in commands_classified if cls == "build"]
238
+ if build_cmds:
239
+ items.append("- Ran build/install")
240
+
241
+ run_cmds = [cmd for cls, cmd in commands_classified if cls == "run"]
242
+ if run_cmds:
243
+ items.append("- Started/tested server")
244
+
245
+ return items
246
+
247
+
248
+ def _build_next_steps(files_edited, files_created, commands_classified):
249
+ items = []
250
+
251
+ git_writes = {cmd for cls, cmd in commands_classified if cls == "git_write"}
252
+ has_commit = any('commit' in cmd for cmd in git_writes)
253
+ has_push = any('push' in cmd for cmd in git_writes)
254
+
255
+ all_changed = files_edited | files_created
256
+ if all_changed and not has_commit:
257
+ items.append("- Commit pending changes")
258
+ if has_commit and not has_push:
259
+ items.append("- Push committed changes to remote")
260
+
261
+ test_cmds = [cmd for cls, cmd in commands_classified if cls == "test"]
262
+ if not test_cmds and all_changed:
263
+ items.append("- Run tests to verify changes")
264
+
265
+ return items
266
+
267
+
74
268
  def handle_stop():
75
269
  if not CURRENT_SESSION.exists():
76
270
  return
@@ -100,6 +294,7 @@ def handle_stop():
100
294
  project_name = Path(project_dir).name
101
295
  session_id = datetime.now().strftime("%Y-%m-%d_%H%M%S")
102
296
 
297
+ files_read = set()
103
298
  files_edited = set()
104
299
  files_created = set()
105
300
  commands = []
@@ -114,6 +309,10 @@ def handle_stop():
114
309
  if action == "prompt":
115
310
  prompts.append({"ts": ts, "text": e.get("prompt", "")})
116
311
  timeline.append(f" {ts} — Prompt: {e.get('prompt', '')[:60]}...")
312
+ elif action == "read":
313
+ fp = e.get("file", "")
314
+ files_read.add(fp)
315
+ timeline.append(f" {ts} — Read `{Path(fp).name}`")
117
316
  elif action == "edit":
118
317
  fp = e.get("file", "")
119
318
  files_edited.add(fp)
@@ -128,6 +327,8 @@ def handle_stop():
128
327
  short = cmd[:80] + "..." if len(cmd) > 80 else cmd
129
328
  timeline.append(f" {ts} — `{short}`")
130
329
 
330
+ commands_classified = [_classify_command(cmd) for cmd in commands]
331
+
131
332
  summary_items = []
132
333
  if files_edited:
133
334
  summary_items.append(f"Modified {len(files_edited)} file{'s' if len(files_edited) != 1 else ''}")
@@ -136,12 +337,10 @@ def handle_stop():
136
337
  if commands:
137
338
  summary_items.append(f"Ran {len(commands)} command{'s' if len(commands) != 1 else ''}")
138
339
 
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)
340
+ investigated = _build_investigated(files_read, commands_classified, project_dir)
341
+ learned = _build_learned(files_read, files_edited, files_created, commands_classified, project_dir)
342
+ completed = _build_completed(files_edited, files_created, commands_classified, project_dir)
343
+ next_steps = _build_next_steps(files_edited, files_created, commands_classified)
145
344
 
146
345
  parts = [f"# Session {session_id}", ""]
147
346
  parts.append(f"**Project:** `{project_dir}`")
@@ -161,27 +360,23 @@ def handle_stop():
161
360
  parts.append("")
162
361
 
163
362
  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}`")
363
+ if investigated:
364
+ parts.extend(investigated)
168
365
  parts.append("")
169
366
 
170
367
  parts.append("## LEARNED")
171
- parts.append("")
368
+ if learned:
369
+ parts.extend(learned)
172
370
  parts.append("")
173
371
 
174
372
  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}`")
373
+ if completed:
374
+ parts.extend(completed)
181
375
  parts.append("")
182
376
 
183
377
  parts.append("## NEXT STEPS")
184
- parts.append("")
378
+ if next_steps:
379
+ parts.extend(next_steps)
185
380
  parts.append("")
186
381
 
187
382
  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:
@@ -1573,29 +1573,52 @@ body.resizing .resize-handle { pointer-events: auto !important; }
1573
1573
  color: var(--text-secondary);
1574
1574
  }
1575
1575
 
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 {
1576
+ /* ============ BACKLINK & UNLINKED ITEMS ============ */
1577
+ .rp-link-item {
1588
1578
  display: flex;
1589
- flex-direction: column;
1590
- padding: 5px 8px;
1579
+ align-items: center;
1580
+ padding: 4px 8px;
1591
1581
  border-radius: var(--radius-sm);
1592
1582
  cursor: pointer;
1593
1583
  margin-bottom: 1px;
1594
1584
  transition: background 0.1s;
1585
+ gap: 6px;
1586
+ }
1587
+
1588
+ .rp-link-item:hover { background: var(--bg-hover); }
1589
+
1590
+ .rp-link-item .rp-link-icon {
1591
+ font-size: 9px;
1592
+ flex-shrink: 0;
1593
+ opacity: 0.5;
1594
+ }
1595
+
1596
+ .rp-link-item .rp-link-icon.backlink { color: var(--neon-cyan); }
1597
+ .rp-link-item .rp-link-icon.unlinked { color: var(--text-muted); }
1598
+
1599
+ .rp-link-item .rp-link-title {
1600
+ font-size: 12px;
1601
+ color: var(--neon-cyan);
1602
+ font-weight: 500;
1595
1603
  overflow: hidden;
1604
+ text-overflow: ellipsis;
1605
+ white-space: nowrap;
1606
+ transition: all 0.15s;
1596
1607
  }
1597
1608
 
1598
- .rp-item-col:hover { background: var(--bg-hover); }
1609
+ .rp-link-item:hover .rp-link-title {
1610
+ text-decoration: underline;
1611
+ text-underline-offset: 2px;
1612
+ }
1613
+
1614
+ .rp-link-item.unlinked .rp-link-title {
1615
+ color: var(--text-secondary);
1616
+ }
1617
+
1618
+ .rp-link-item.unlinked:hover .rp-link-title {
1619
+ color: var(--neon-cyan);
1620
+ text-decoration: underline;
1621
+ }
1599
1622
 
1600
1623
  /* ============ TRANSITIONS ============ */
1601
1624
  .fade-in { animation: fadeIn 0.15s ease; }
@@ -2188,10 +2211,8 @@ function renderRightPanel(note) {
2188
2211
  document.getElementById('bl-count').textContent = backlinks.length;
2189
2212
  backlinks.forEach(bl => {
2190
2213
  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;
2214
+ item.className = 'rp-link-item';
2215
+ item.innerHTML = `<span class="rp-link-icon backlink">&#8592;</span><span class="rp-link-title">${bl.title || bl.path || bl}</span>`;
2195
2216
  item.addEventListener('click', () => loadNote(bl.path || bl));
2196
2217
  blList.appendChild(item);
2197
2218
  });
@@ -2244,10 +2265,8 @@ function renderRightPanel(note) {
2244
2265
  document.getElementById('unlinked-count').textContent = unlinked.length;
2245
2266
  unlinked.forEach(u => {
2246
2267
  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;
2268
+ item.className = 'rp-link-item unlinked';
2269
+ item.innerHTML = `<span class="rp-link-icon unlinked">&#8776;</span><span class="rp-link-title">${u.title}</span>`;
2251
2270
  item.addEventListener('click', () => loadNote(u.path));
2252
2271
  ulList.appendChild(item);
2253
2272
  });
@@ -2905,6 +2924,23 @@ async function init() {
2905
2924
  `;
2906
2925
  }
2907
2926
 
2927
+ // --- Auto-refresh: poll for vault changes ---
2928
+ let lastKnownStats = null;
2929
+
2930
+ async function pollForChanges() {
2931
+ try {
2932
+ const stats = await fetchJSON('/api/stats');
2933
+ const key = `${stats.notes}:${stats.links}:${stats.tags}`;
2934
+ if (lastKnownStats && lastKnownStats !== key) {
2935
+ await refreshTree();
2936
+ if (currentPath) loadNote(currentPath);
2937
+ }
2938
+ lastKnownStats = key;
2939
+ } catch {}
2940
+ }
2941
+
2942
+ setInterval(pollForChanges, 3000);
2943
+
2908
2944
  initResize();
2909
2945
  initGraphResize();
2910
2946
  init();
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.3",
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
  },