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 +20 -0
- package/kyp_mem/cli.py +13 -4
- package/kyp_mem/hooks.py +288 -18
- package/kyp_mem/server.py +17 -6
- package/kyp_mem/static/index.html +79 -25
- package/kyp_mem/ui.py +10 -0
- package/package.json +2 -2
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 == "
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
165
|
-
|
|
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
|
-
|
|
443
|
+
if learned:
|
|
444
|
+
parts.extend(learned)
|
|
172
445
|
parts.append("")
|
|
173
446
|
|
|
174
447
|
parts.append("## COMPLETED")
|
|
175
|
-
if
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[:
|
|
345
|
-
if len(preview) >
|
|
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
|
-
|
|
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
|
|
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:
|
|
1298
|
-
padding:
|
|
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
|
|
1577
|
-
.rp-item
|
|
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
|
-
|
|
1590
|
-
padding:
|
|
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
|
|
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
|
|
2192
|
-
|
|
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">←</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
|
|
2248
|
-
|
|
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">≈</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.
|
|
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
|
},
|