kyp-mem 0.6.0 → 0.6.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/kyp_mem/cli.py +18 -2
- package/kyp_mem/hooks.py +74 -25
- package/kyp_mem/static/index.html +107 -34
- package/kyp_mem/ui.py +12 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/kyp_mem/cli.py
CHANGED
|
@@ -133,8 +133,24 @@ def _run_init():
|
|
|
133
133
|
print(f" {G}✓{R} Vault: {vault_path}")
|
|
134
134
|
print(f" {G}✓{R} Config: {CONFIG_FILE}")
|
|
135
135
|
print()
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
|
|
137
|
+
claude_settings = Path.home() / ".claude" / "settings.json"
|
|
138
|
+
already_setup = False
|
|
139
|
+
if claude_settings.exists():
|
|
140
|
+
try:
|
|
141
|
+
cs = json.loads(claude_settings.read_text())
|
|
142
|
+
has_mcp = "kyp-mem" in cs.get("mcpServers", {})
|
|
143
|
+
has_hooks = any("kyp-mem" in str(h) for h in cs.get("hooks", {}).get("Stop", []))
|
|
144
|
+
already_setup = has_mcp and has_hooks
|
|
145
|
+
except (json.JSONDecodeError, KeyError):
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
if already_setup:
|
|
149
|
+
print(f" {G}✓{R} Claude Code already configured (MCP server + hooks)")
|
|
150
|
+
print(f" {D} Restart Claude Code if you changed the vault path.{R}")
|
|
151
|
+
else:
|
|
152
|
+
print(f" {Y}Next step:{R} Connect to Claude Code:")
|
|
153
|
+
print(f" {Y}kyp-mem setup-claude --global && kyp-mem install-hooks --global{R}")
|
|
138
154
|
print()
|
|
139
155
|
|
|
140
156
|
|
package/kyp_mem/hooks.py
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
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
|
|
|
9
10
|
SESSION_DIR = Path.home() / ".kyp-mem" / "sessions"
|
|
10
11
|
CURRENT_SESSION = SESSION_DIR / "current.jsonl"
|
|
11
12
|
|
|
12
|
-
MIN_ACTIONS =
|
|
13
|
+
MIN_ACTIONS = 5
|
|
13
14
|
CHARS_PER_TOKEN = 4
|
|
14
15
|
|
|
15
16
|
COMMAND_OUTPUT_ESTIMATES = {
|
|
@@ -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,16 @@ def handle_session_start():
|
|
|
107
149
|
if not note:
|
|
108
150
|
continue
|
|
109
151
|
parts.append(f"### {note.title}")
|
|
110
|
-
|
|
111
|
-
|
|
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)
|
|
119
160
|
output = "\n".join(parts)
|
|
161
|
+
|
|
120
162
|
try:
|
|
121
163
|
_record_injection(project_name, len(output))
|
|
122
164
|
except Exception:
|
|
@@ -128,7 +170,7 @@ def handle_session_start():
|
|
|
128
170
|
|
|
129
171
|
def handle_user_prompt():
|
|
130
172
|
raw = sys.stdin.read().strip()
|
|
131
|
-
if not raw:
|
|
173
|
+
if not raw or _is_subprocess():
|
|
132
174
|
return
|
|
133
175
|
try:
|
|
134
176
|
data = json.loads(raw)
|
|
@@ -153,7 +195,7 @@ def handle_user_prompt():
|
|
|
153
195
|
|
|
154
196
|
def handle_post_tool_use():
|
|
155
197
|
raw = sys.stdin.read().strip()
|
|
156
|
-
if not raw:
|
|
198
|
+
if not raw or _is_subprocess():
|
|
157
199
|
return
|
|
158
200
|
try:
|
|
159
201
|
data = json.loads(raw)
|
|
@@ -405,17 +447,19 @@ def _build_next_steps(files_edited, files_created, commands_classified):
|
|
|
405
447
|
|
|
406
448
|
|
|
407
449
|
def _summarize_with_claude(raw_note, project_name):
|
|
408
|
-
"""Use Claude to rewrite session sections
|
|
450
|
+
"""Use Claude CLI to rewrite session sections — uses existing Claude Code auth."""
|
|
409
451
|
try:
|
|
410
|
-
|
|
411
|
-
|
|
452
|
+
import shutil
|
|
453
|
+
claude_bin = shutil.which("claude")
|
|
454
|
+
if not claude_bin:
|
|
455
|
+
return None
|
|
412
456
|
|
|
457
|
+
from .config import get_session_model
|
|
413
458
|
model = get_session_model()
|
|
414
|
-
client = anthropic.Anthropic()
|
|
415
459
|
|
|
416
460
|
prompt = f"""Rewrite this raw coding session into a structured summary. A future AI agent reads this to pick up where you left off — be precise and technical.
|
|
417
461
|
|
|
418
|
-
You have: user prompts (the objectives), a timeline of file edits/reads/commands
|
|
462
|
+
You have: user prompts (the objectives), a timeline of file edits/reads/commands with their actual content and output. Synthesize into a dense, specific narrative.
|
|
419
463
|
|
|
420
464
|
## Format rules
|
|
421
465
|
|
|
@@ -452,18 +496,21 @@ Return ONLY this format (no preamble):
|
|
|
452
496
|
Raw session data:
|
|
453
497
|
{raw_note}"""
|
|
454
498
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
499
|
+
env = os.environ.copy()
|
|
500
|
+
env["KYP_MEM_SUMMARIZING"] = "1"
|
|
501
|
+
result = subprocess.run(
|
|
502
|
+
[claude_bin, "-p", prompt, "--max-turns", "1", "--model", model],
|
|
503
|
+
capture_output=True, text=True, timeout=120, env=env,
|
|
459
504
|
)
|
|
460
|
-
|
|
505
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
506
|
+
return result.stdout.strip()
|
|
507
|
+
return None
|
|
461
508
|
except Exception:
|
|
462
509
|
return None
|
|
463
510
|
|
|
464
511
|
|
|
465
512
|
def handle_stop():
|
|
466
|
-
if not CURRENT_SESSION.exists():
|
|
513
|
+
if _is_subprocess() or not CURRENT_SESSION.exists():
|
|
467
514
|
return
|
|
468
515
|
|
|
469
516
|
text = CURRENT_SESSION.read_text().strip()
|
|
@@ -639,6 +686,10 @@ def handle_stop():
|
|
|
639
686
|
except Exception:
|
|
640
687
|
pass
|
|
641
688
|
|
|
689
|
+
# Delete session file BEFORE summarization so the spawned claude subprocess
|
|
690
|
+
# doesn't pollute it via hooks writing back into current.jsonl
|
|
691
|
+
CURRENT_SESSION.unlink(missing_ok=True)
|
|
692
|
+
|
|
642
693
|
# Try Claude summarization, fall back to raw sections
|
|
643
694
|
summarized = _summarize_with_claude(raw_note, project_name)
|
|
644
695
|
|
|
@@ -707,8 +758,6 @@ def handle_stop():
|
|
|
707
758
|
vault = Vault(get_vault_path())
|
|
708
759
|
vault.write_note(f"{project_name}/Sessions/{session_id}.md", content, tags, {})
|
|
709
760
|
|
|
710
|
-
CURRENT_SESSION.unlink(missing_ok=True)
|
|
711
|
-
|
|
712
761
|
|
|
713
762
|
def main():
|
|
714
763
|
if len(sys.argv) > 1 and sys.argv[1] == "stop":
|
|
@@ -475,8 +475,9 @@ body.resizing #resize-handle { pointer-events: auto !important; }
|
|
|
475
475
|
.graph-header {
|
|
476
476
|
display: flex; align-items: center; justify-content: space-between;
|
|
477
477
|
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
|
478
|
-
background: var(--bg-2); gap: 12px;
|
|
478
|
+
background: var(--bg-2); gap: 12px; position: relative; z-index: 10;
|
|
479
479
|
}
|
|
480
|
+
.graph-header .ghost-btn { cursor: pointer; }
|
|
480
481
|
.graph-header-left { display: flex; align-items: center; gap: 10px; font-size: var(--fz-sm); white-space: nowrap; }
|
|
481
482
|
.graph-header-right { display: flex; gap: 6px; flex-shrink: 0; }
|
|
482
483
|
.tool-chip {
|
|
@@ -1041,6 +1042,18 @@ function openSession(path) {
|
|
|
1041
1042
|
loadNote(path);
|
|
1042
1043
|
}
|
|
1043
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
|
+
|
|
1044
1057
|
// ─── Load Note ───────────────────────────────────────────────────────────────
|
|
1045
1058
|
async function loadNote(path) {
|
|
1046
1059
|
currentPath = path;
|
|
@@ -1369,7 +1382,10 @@ function renderSessionView(note) {
|
|
|
1369
1382
|
${tagsHtml}
|
|
1370
1383
|
<span class="dim" style="font-size:var(--fz-xs);margin-left:8px">${note.created || sessionFile}</span>
|
|
1371
1384
|
</div>
|
|
1372
|
-
<
|
|
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>
|
|
1373
1389
|
</div>
|
|
1374
1390
|
<h1 style="margin:12px 0 4px;font-size:calc(var(--fz-xl) + 6px);font-weight:500;letter-spacing:-0.01em">
|
|
1375
1391
|
Session ${sessionFile}
|
|
@@ -1428,6 +1444,8 @@ function renderSessionView(note) {
|
|
|
1428
1444
|
}
|
|
1429
1445
|
|
|
1430
1446
|
// ─── Graph View ──────────────────────────────────────────────────────────────
|
|
1447
|
+
let _graphMode = 'projects';
|
|
1448
|
+
|
|
1431
1449
|
function renderGraphView() {
|
|
1432
1450
|
const area = $('content-area');
|
|
1433
1451
|
area.innerHTML = `
|
|
@@ -1436,24 +1454,19 @@ function renderGraphView() {
|
|
|
1436
1454
|
<div class="graph-header">
|
|
1437
1455
|
<div class="graph-header-left">
|
|
1438
1456
|
<span class="acc">▦</span>
|
|
1439
|
-
<
|
|
1457
|
+
<button class="tool-chip graph-mode active" data-mode="projects">projects</button>
|
|
1458
|
+
<button class="tool-chip graph-mode" data-mode="sessions">sessions</button>
|
|
1440
1459
|
<span class="dim tab-nums" id="graph-stats"></span>
|
|
1441
1460
|
</div>
|
|
1442
1461
|
<div class="graph-header-right">
|
|
1443
|
-
<button class="tool-chip active">force</button>
|
|
1444
|
-
<button class="tool-chip">radial</button>
|
|
1445
|
-
<button class="tool-chip">time</button>
|
|
1462
|
+
<button class="tool-chip graph-layout active" data-layout="force">force</button>
|
|
1463
|
+
<button class="tool-chip graph-layout" data-layout="radial">radial</button>
|
|
1464
|
+
<button class="tool-chip graph-layout" data-layout="time">time</button>
|
|
1446
1465
|
<button class="ghost-btn" id="graph-close">✕</button>
|
|
1447
1466
|
</div>
|
|
1448
1467
|
</div>
|
|
1449
1468
|
<div class="graph-svg-wrap" id="graph-svg-wrap"></div>
|
|
1450
|
-
<div class="graph-legend">
|
|
1451
|
-
<div class="graph-legend-title">legend</div>
|
|
1452
|
-
<div class="graph-legend-item"><i style="width:8px;height:8px;border-radius:999px;background:var(--accent)"></i>notes</div>
|
|
1453
|
-
<div class="graph-legend-item"><i style="width:8px;height:8px;border-radius:999px;background:var(--panel-2);border:1px solid var(--muted)"></i>sessions</div>
|
|
1454
|
-
<div class="graph-legend-item"><i style="width:12px;height:1px;background:var(--line-2)"></i>link</div>
|
|
1455
|
-
<div class="graph-legend-item"><i style="width:12px;height:0;border-top:1px dashed var(--line-2)"></i>session ref</div>
|
|
1456
|
-
</div>
|
|
1469
|
+
<div class="graph-legend" id="graph-legend"></div>
|
|
1457
1470
|
</section>
|
|
1458
1471
|
<aside class="graph-rail" id="graph-rail">
|
|
1459
1472
|
<div class="rail-card">
|
|
@@ -1470,36 +1483,88 @@ function renderGraphView() {
|
|
|
1470
1483
|
</div>
|
|
1471
1484
|
`;
|
|
1472
1485
|
|
|
1473
|
-
|
|
1486
|
+
updateGraphLegend();
|
|
1474
1487
|
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1488
|
+
const closeBtn = $('graph-close');
|
|
1489
|
+
if (closeBtn) {
|
|
1490
|
+
closeBtn.addEventListener('click', (e) => {
|
|
1491
|
+
e.stopPropagation();
|
|
1492
|
+
setView('note');
|
|
1493
|
+
if (currentPath) loadNote(currentPath);
|
|
1494
|
+
else $('content-area').innerHTML = '<div style="padding:40px;color:var(--muted)">Select a note from the sidebar</div>';
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Mode toggle (projects / sessions)
|
|
1499
|
+
document.querySelectorAll('.graph-mode').forEach(btn => {
|
|
1500
|
+
btn.addEventListener('click', () => {
|
|
1501
|
+
document.querySelectorAll('.graph-mode').forEach(b => b.classList.remove('active'));
|
|
1502
|
+
btn.classList.add('active');
|
|
1503
|
+
_graphMode = btn.dataset.mode;
|
|
1504
|
+
_graphData = null;
|
|
1505
|
+
updateGraphLegend();
|
|
1506
|
+
const activeLayout = document.querySelector('.graph-layout.active');
|
|
1507
|
+
buildFullGraph(activeLayout ? activeLayout.dataset.layout : 'force');
|
|
1508
|
+
});
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
// Layout toggle (force / radial / time)
|
|
1512
|
+
document.querySelectorAll('.graph-layout').forEach(chip => {
|
|
1478
1513
|
chip.addEventListener('click', () => {
|
|
1479
|
-
|
|
1514
|
+
document.querySelectorAll('.graph-layout').forEach(c => c.classList.remove('active'));
|
|
1480
1515
|
chip.classList.add('active');
|
|
1481
|
-
buildFullGraph(chip.
|
|
1516
|
+
buildFullGraph(chip.dataset.layout);
|
|
1482
1517
|
});
|
|
1483
1518
|
});
|
|
1484
1519
|
|
|
1485
1520
|
buildFullGraph('force');
|
|
1486
1521
|
}
|
|
1487
1522
|
|
|
1523
|
+
function updateGraphLegend() {
|
|
1524
|
+
const legend = $('graph-legend');
|
|
1525
|
+
if (!legend) return;
|
|
1526
|
+
if (_graphMode === 'sessions') {
|
|
1527
|
+
legend.innerHTML = `
|
|
1528
|
+
<div class="graph-legend-title">sessions</div>
|
|
1529
|
+
<div class="graph-legend-item"><i style="width:8px;height:8px;border-radius:999px;background:var(--panel-2);border:1px solid var(--muted)"></i>session</div>
|
|
1530
|
+
<div class="graph-legend-item"><i style="width:12px;height:0;border-top:1px dashed var(--line-2)"></i>temporal link</div>
|
|
1531
|
+
`;
|
|
1532
|
+
} else {
|
|
1533
|
+
legend.innerHTML = `
|
|
1534
|
+
<div class="graph-legend-title">projects</div>
|
|
1535
|
+
<div class="graph-legend-item"><i style="width:8px;height:8px;border-radius:999px;background:var(--accent)"></i>notes</div>
|
|
1536
|
+
<div class="graph-legend-item"><i style="width:12px;height:1px;background:var(--line-2)"></i>wikilink</div>
|
|
1537
|
+
`;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1488
1541
|
async function buildFullGraph(layout) {
|
|
1489
1542
|
const wrap = $('graph-svg-wrap');
|
|
1490
1543
|
if (!wrap) return;
|
|
1491
1544
|
wrap.innerHTML = '';
|
|
1492
1545
|
|
|
1493
1546
|
if (!_graphData) {
|
|
1494
|
-
_graphData = await fetchJSON(
|
|
1547
|
+
_graphData = await fetchJSON(`/api/graph?kind=${_graphMode}`);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
let nodes = _graphData.nodes.map(n => ({ ...n }));
|
|
1551
|
+
let links = _graphData.edges.map(e => ({ ...e }));
|
|
1552
|
+
|
|
1553
|
+
// For sessions mode, create temporal links between consecutive sessions
|
|
1554
|
+
if (_graphMode === 'sessions' && links.length === 0 && nodes.length > 1) {
|
|
1555
|
+
const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
|
|
1556
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
1557
|
+
links.push({ source: sorted[i - 1].id, target: sorted[i].id });
|
|
1558
|
+
}
|
|
1495
1559
|
}
|
|
1496
1560
|
|
|
1497
|
-
const nodes = _graphData.nodes.map(n => ({ ...n }));
|
|
1498
|
-
const links = _graphData.edges.map(e => ({ ...e }));
|
|
1499
1561
|
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
1500
1562
|
|
|
1501
1563
|
$('graph-stats').textContent = `${nodes.length}n · ${links.length}e`;
|
|
1502
|
-
if (nodes.length === 0)
|
|
1564
|
+
if (nodes.length === 0) {
|
|
1565
|
+
wrap.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:var(--fz-sm)">No nodes to display</div>';
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1503
1568
|
|
|
1504
1569
|
const rect = wrap.getBoundingClientRect();
|
|
1505
1570
|
const W = rect.width || 800;
|
|
@@ -1568,36 +1633,44 @@ async function buildFullGraph(layout) {
|
|
|
1568
1633
|
const s = typeof l.source === 'object' ? l.source : nodeMap.get(l.source);
|
|
1569
1634
|
const t = typeof l.target === 'object' ? l.target : nodeMap.get(l.target);
|
|
1570
1635
|
if (!s || !t) return;
|
|
1571
|
-
const
|
|
1636
|
+
const isTemporal = _graphMode === 'sessions';
|
|
1572
1637
|
edgeGroup.append('line')
|
|
1573
1638
|
.attr('x1', s.x).attr('y1', s.y).attr('x2', t.x).attr('y2', t.y)
|
|
1574
|
-
.attr('stroke', 'var(--line-2)').attr('stroke-width', 1.2)
|
|
1575
|
-
.attr('stroke-opacity',
|
|
1576
|
-
.attr('stroke-dasharray',
|
|
1639
|
+
.attr('stroke', 'var(--line-2)').attr('stroke-width', isTemporal ? 1 : 1.2)
|
|
1640
|
+
.attr('stroke-opacity', isTemporal ? 0.3 : 0.6)
|
|
1641
|
+
.attr('stroke-dasharray', isTemporal ? '4 3' : '')
|
|
1577
1642
|
.attr('stroke-linecap', 'round');
|
|
1578
1643
|
});
|
|
1579
1644
|
|
|
1580
1645
|
// Draw nodes
|
|
1581
1646
|
const nodeGroup = g.append('g');
|
|
1582
1647
|
nodes.forEach(n => {
|
|
1583
|
-
const
|
|
1648
|
+
const isPrimary = _graphMode === 'sessions' ? n.kind === 'session' : n.kind === 'note';
|
|
1649
|
+
const r = isPrimary ? 10 : 6;
|
|
1584
1650
|
const ng = nodeGroup.append('g').style('cursor', 'pointer');
|
|
1585
1651
|
|
|
1586
|
-
if (
|
|
1587
|
-
ng.append('circle').attr('cx', n.x).attr('cy', n.y).attr('r', r)
|
|
1588
|
-
.attr('fill', 'var(--bg-2)').attr('stroke', 'var(--muted)').attr('stroke-width', 1.2);
|
|
1589
|
-
} else {
|
|
1652
|
+
if (isPrimary) {
|
|
1590
1653
|
ng.append('circle').attr('cx', n.x).attr('cy', n.y).attr('r', r + 4)
|
|
1591
1654
|
.attr('fill', 'var(--accent)').attr('fill-opacity', 0.12);
|
|
1592
1655
|
ng.append('circle').attr('cx', n.x).attr('cy', n.y).attr('r', r)
|
|
1593
1656
|
.attr('fill', 'var(--panel-2)').attr('stroke', 'var(--accent)').attr('stroke-width', 1.5);
|
|
1657
|
+
} else {
|
|
1658
|
+
ng.append('circle').attr('cx', n.x).attr('cy', n.y).attr('r', r)
|
|
1659
|
+
.attr('fill', 'var(--bg-2)').attr('stroke', 'var(--muted)').attr('stroke-width', 1.2);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
let label = n.title;
|
|
1663
|
+
if (_graphMode === 'sessions' && n.kind === 'session') {
|
|
1664
|
+
const m = n.id.match(/(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/);
|
|
1665
|
+
label = m ? `${m[1]} ${m[2]}:${m[3]}` : n.title;
|
|
1594
1666
|
}
|
|
1667
|
+
if (label.length > 22) label = label.substring(0, 20) + '…';
|
|
1595
1668
|
|
|
1596
1669
|
ng.append('text').attr('x', n.x).attr('y', n.y + r + 14)
|
|
1597
1670
|
.attr('text-anchor', 'middle').attr('font-size', 11)
|
|
1598
|
-
.attr('fill',
|
|
1671
|
+
.attr('fill', isPrimary ? 'var(--muted)' : 'var(--dim)').attr('opacity', 0.9)
|
|
1599
1672
|
.style('font-family', 'JetBrains Mono, monospace')
|
|
1600
|
-
.text(
|
|
1673
|
+
.text(label);
|
|
1601
1674
|
|
|
1602
1675
|
ng.on('click', () => {
|
|
1603
1676
|
updateGraphRail(n, links, nodes);
|
package/kyp_mem/ui.py
CHANGED
|
@@ -30,13 +30,17 @@ def create_app(vault_path: str = None) -> FastAPI:
|
|
|
30
30
|
return JSONResponse(vault.get_stats())
|
|
31
31
|
|
|
32
32
|
@app.get("/api/graph")
|
|
33
|
-
def graph():
|
|
33
|
+
def graph(kind: str = "all"):
|
|
34
34
|
nodes = []
|
|
35
35
|
edges = []
|
|
36
36
|
seen_edges = set()
|
|
37
37
|
for path, note in vault.index.notes.items():
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
node_kind = "session" if "/Sessions/" in path else "note"
|
|
39
|
+
if kind == "projects" and node_kind == "session":
|
|
40
|
+
continue
|
|
41
|
+
if kind == "sessions" and node_kind == "note":
|
|
42
|
+
continue
|
|
43
|
+
nodes.append({"id": path, "title": note.title, "kind": node_kind, "tags": note.tags})
|
|
40
44
|
for link in (note.links or []):
|
|
41
45
|
target = None
|
|
42
46
|
link_lower = link.lower()
|
|
@@ -46,6 +50,11 @@ def create_app(vault_path: str = None) -> FastAPI:
|
|
|
46
50
|
target = p
|
|
47
51
|
break
|
|
48
52
|
if target and target != path:
|
|
53
|
+
target_kind = "session" if "/Sessions/" in target else "note"
|
|
54
|
+
if kind == "projects" and target_kind == "session":
|
|
55
|
+
continue
|
|
56
|
+
if kind == "sessions" and target_kind == "note":
|
|
57
|
+
continue
|
|
49
58
|
key = tuple(sorted([path, target]))
|
|
50
59
|
if key not in seen_edges:
|
|
51
60
|
seen_edges.add(key)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kyp-mem",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.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"
|
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
|
|
7
|
+
version = "0.6.4"
|
|
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"}
|