kyp-mem 0.6.7 → 0.6.9

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.
@@ -268,6 +268,9 @@ body.resizing #resize-handle { pointer-events: auto !important; }
268
268
  .tree-folder .tf-icon { color: var(--muted); font-size: 11px; }
269
269
  .tree-folder .tf-label { flex: 1; color: var(--fg); }
270
270
  .tree-folder .tf-count { font-size: var(--fz-xs); color: var(--dim); font-variant-numeric: tabular-nums; }
271
+ .tree-folder .tf-graph-btn { font-size: 11px; color: var(--dim); opacity: 0; transition: opacity 0.15s; padding: 0 2px; }
272
+ .tree-folder:hover .tf-graph-btn { opacity: 1; }
273
+ .tree-folder .tf-graph-btn:hover { color: var(--accent); }
271
274
 
272
275
  /* Sidebar rows */
273
276
  .sidebar-row {
@@ -720,7 +723,7 @@ let _graphData = null;
720
723
  let searchTimeout, sessionSearchTimeout;
721
724
 
722
725
  // ─── Helpers ─────────────────────────────────────────────────────────────────
723
- async function fetchJSON(url) { const r = await fetch(url); return r.json(); }
726
+ async function fetchJSON(url) { const r = await fetch(url, { cache: 'no-store' }); return r.json(); }
724
727
 
725
728
  function $(id) { return document.getElementById(id); }
726
729
 
@@ -790,7 +793,7 @@ function setView(v) {
790
793
  $('view-switch').addEventListener('click', e => {
791
794
  if (e.target.dataset.view) {
792
795
  setView(e.target.dataset.view);
793
- if (view === 'graph') { _graphData = null; renderGraphView(); }
796
+ if (view === 'graph') { _graphProject = null; _graphData = null; renderGraphView(); }
794
797
  else if (view === 'session' && activeSession) loadNote(activeSession);
795
798
  else if (view === 'note' && currentPath) loadNote(currentPath);
796
799
  }
@@ -886,7 +889,7 @@ function renderSidebar(sessionsData) {
886
889
  const group = document.createElement('div');
887
890
  const folder = document.createElement('div');
888
891
  folder.className = 'tree-folder';
889
- folder.innerHTML = `<span class="tf-arrow">▾</span><span class="tf-icon">≡</span><span class="tf-label">${project}</span><span class="tf-count">${sessions.length}</span>`;
892
+ folder.innerHTML = `<span class="tf-arrow">▾</span><span class="tf-icon">≡</span><span class="tf-label">${project}</span><span class="tf-count">${sessions.length}</span><button class="tf-graph-btn ghost-btn" title="Open graph for ${project}" data-project="${project}">▦</button>`;
890
893
 
891
894
  const list = document.createElement('div');
892
895
  list.style.cssText = 'display:flex;flex-direction:column;gap:1px;padding-left:16px;margin-top:2px;';
@@ -902,6 +905,14 @@ function renderSidebar(sessionsData) {
902
905
  list.appendChild(row);
903
906
  });
904
907
 
908
+ folder.querySelector('.tf-graph-btn').addEventListener('click', (e) => {
909
+ e.stopPropagation();
910
+ _graphProject = project;
911
+ _graphData = null;
912
+ setView('graph');
913
+ renderGraphView();
914
+ });
915
+
905
916
  folder.addEventListener('click', () => {
906
917
  const arrow = folder.querySelector('.tf-arrow');
907
918
  const isOpen = arrow.textContent === '▾';
@@ -955,13 +966,14 @@ function renderSidebar(sessionsData) {
955
966
 
956
967
  function renderProjectTree(container) {
957
968
  if (!treeData) return;
958
- function walk(node, parent) {
969
+ function walk(node, parent, depth) {
959
970
  if (node.type === 'folder' && node.name !== 'vault') {
960
971
  if (node.name === 'Sessions') return;
961
972
  const group = document.createElement('div');
962
973
  const folder = document.createElement('div');
963
974
  folder.className = 'tree-folder';
964
- folder.innerHTML = `<span class="tf-arrow">▾</span><span class="tf-icon">≡</span><span class="tf-label">${node.name}</span>`;
975
+ const isTopLevel = depth === 0;
976
+ folder.innerHTML = `<span class="tf-arrow">▾</span><span class="tf-icon">≡</span><span class="tf-label">${node.name}</span>${isTopLevel ? `<button class="tf-graph-btn ghost-btn" title="Open graph for ${node.name}" data-project="${node.name}">▦</button>` : ''}`;
965
977
  const children = document.createElement('div');
966
978
  children.style.cssText = 'display:flex;flex-direction:column;gap:1px;padding-left:16px;margin-top:2px;';
967
979
 
@@ -972,10 +984,20 @@ function renderProjectTree(container) {
972
984
  children.style.display = isOpen ? 'none' : 'flex';
973
985
  });
974
986
 
987
+ if (isTopLevel) {
988
+ folder.querySelector('.tf-graph-btn').addEventListener('click', (e) => {
989
+ e.stopPropagation();
990
+ _graphProject = node.name;
991
+ _graphData = null;
992
+ setView('graph');
993
+ renderGraphView();
994
+ });
995
+ }
996
+
975
997
  group.appendChild(folder);
976
998
  group.appendChild(children);
977
999
  parent.appendChild(group);
978
- (node.children || []).forEach(c => walk(c, children));
1000
+ (node.children || []).forEach(c => walk(c, children, depth + 1));
979
1001
  } else if (node.type === 'note') {
980
1002
  const row = document.createElement('button');
981
1003
  row.className = 'sidebar-row';
@@ -985,10 +1007,10 @@ function renderProjectTree(container) {
985
1007
  row.addEventListener('click', () => { setView('note'); loadNote(node.path); });
986
1008
  parent.appendChild(row);
987
1009
  } else if (node.children) {
988
- node.children.forEach(c => walk(c, parent));
1010
+ node.children.forEach(c => walk(c, parent, depth));
989
1011
  }
990
1012
  }
991
- walk(treeData, container);
1013
+ walk(treeData, container, 0);
992
1014
  }
993
1015
 
994
1016
  function renderTagCloud(container) {
@@ -1158,6 +1180,8 @@ function renderRail(note) {
1158
1180
  rail.appendChild(graphCard);
1159
1181
 
1160
1182
  graphCard.querySelector('#rail-graph-expand')?.addEventListener('click', () => {
1183
+ const parts = (note.path || '').split('/');
1184
+ _graphProject = parts.length > 1 ? parts[0] : null;
1161
1185
  _graphData = null;
1162
1186
  setView('graph');
1163
1187
  renderGraphView();
@@ -1445,6 +1469,7 @@ function renderSessionView(note) {
1445
1469
 
1446
1470
  // ─── Graph View ──────────────────────────────────────────────────────────────
1447
1471
  let _graphMode = 'projects';
1472
+ let _graphProject = null;
1448
1473
 
1449
1474
  function renderGraphView() {
1450
1475
  const area = $('content-area');
@@ -1457,6 +1482,10 @@ function renderGraphView() {
1457
1482
  <button class="tool-chip graph-mode active" data-mode="projects">projects</button>
1458
1483
  <button class="tool-chip graph-mode" data-mode="sessions">sessions</button>
1459
1484
  <span class="dim tab-nums" id="graph-stats"></span>
1485
+ <span id="graph-project-filter" style="display:none;font-size:var(--fz-sm);color:var(--accent);margin-left:4px">
1486
+ <span id="graph-project-name"></span>
1487
+ <button class="ghost-btn" id="graph-clear-project" style="color:var(--muted);margin-left:4px;font-size:var(--fz-xs)" title="Show all projects">✕</button>
1488
+ </span>
1460
1489
  </div>
1461
1490
  <div class="graph-header-right">
1462
1491
  <button class="tool-chip graph-layout active" data-layout="force">force</button>
@@ -1484,17 +1513,28 @@ function renderGraphView() {
1484
1513
  `;
1485
1514
 
1486
1515
  updateGraphLegend();
1516
+ updateGraphProjectFilter();
1487
1517
 
1488
1518
  const closeBtn = $('graph-close');
1489
1519
  if (closeBtn) {
1490
1520
  closeBtn.addEventListener('click', (e) => {
1491
1521
  e.stopPropagation();
1522
+ _graphProject = null;
1492
1523
  setView('note');
1493
1524
  if (currentPath) loadNote(currentPath);
1494
1525
  else $('content-area').innerHTML = '<div style="padding:40px;color:var(--muted)">Select a note from the sidebar</div>';
1495
1526
  });
1496
1527
  }
1497
1528
 
1529
+ // Clear project filter
1530
+ $('graph-clear-project')?.addEventListener('click', () => {
1531
+ _graphProject = null;
1532
+ _graphData = null;
1533
+ updateGraphProjectFilter();
1534
+ const activeLayout = document.querySelector('.graph-layout.active');
1535
+ buildFullGraph(activeLayout ? activeLayout.dataset.layout : 'force');
1536
+ });
1537
+
1498
1538
  // Mode toggle (projects / sessions)
1499
1539
  document.querySelectorAll('.graph-mode').forEach(btn => {
1500
1540
  btn.addEventListener('click', () => {
@@ -1520,6 +1560,17 @@ function renderGraphView() {
1520
1560
  buildFullGraph('force');
1521
1561
  }
1522
1562
 
1563
+ function updateGraphProjectFilter() {
1564
+ const el = $('graph-project-filter');
1565
+ if (!el) return;
1566
+ if (_graphProject) {
1567
+ $('graph-project-name').textContent = `› ${_graphProject}`;
1568
+ el.style.display = 'inline';
1569
+ } else {
1570
+ el.style.display = 'none';
1571
+ }
1572
+ }
1573
+
1523
1574
  function updateGraphLegend() {
1524
1575
  const legend = $('graph-legend');
1525
1576
  if (!legend) return;
@@ -1544,7 +1595,9 @@ async function buildFullGraph(layout) {
1544
1595
  wrap.innerHTML = '';
1545
1596
 
1546
1597
  if (!_graphData) {
1547
- _graphData = await fetchJSON(`/api/graph?kind=${_graphMode}`);
1598
+ let url = `/api/graph?kind=${_graphMode}`;
1599
+ if (_graphProject) url += `&project=${encodeURIComponent(_graphProject)}`;
1600
+ _graphData = await fetchJSON(url);
1548
1601
  }
1549
1602
 
1550
1603
  let nodes = _graphData.nodes.map(n => ({ ...n }));
@@ -1931,30 +1984,26 @@ async function init() {
1931
1984
  const density = localStorage.getItem('kyp-density') || 'regular';
1932
1985
  document.documentElement.setAttribute('data-density', density);
1933
1986
 
1934
- const [rawTree, sessionsData, stats] = await Promise.all([
1935
- fetchJSON('/api/tree'), fetchJSON('/api/sessions'), fetchJSON('/api/stats'),
1936
- ]);
1937
- treeData = rawTree;
1938
-
1939
- function walk(node) {
1940
- if (node.type === 'note') allNotes[node.path] = { title: node.name.replace('.md', ''), tags: node.tags || [] };
1941
- (node.children || []).forEach(walk);
1987
+ try {
1988
+ const [rawTree, sessionsData, stats] = await Promise.all([
1989
+ fetchJSON('/api/tree'), fetchJSON('/api/sessions'), fetchJSON('/api/stats'),
1990
+ ]);
1991
+ treeData = rawTree;
1992
+
1993
+ function walk(node) {
1994
+ if (node.type === 'note') allNotes[node.path] = { title: node.name.replace('.md', ''), tags: node.tags || [] };
1995
+ (node.children || []).forEach(walk);
1996
+ }
1997
+ walk(treeData);
1998
+
1999
+ renderSidebar(sessionsData);
2000
+ $('stat-notes').textContent = stats.notes;
2001
+ $('stat-folders').textContent = stats.folders;
2002
+ loadTokenEconomics();
2003
+ } catch (e) {
2004
+ console.error('KYP-MEM init failed:', e);
2005
+ $('sidebar-scroll').innerHTML = `<div style="padding:12px;color:#f66;font-size:12px">Init error: ${e.message}<br>Check console for details</div>`;
1942
2006
  }
1943
- walk(treeData);
1944
-
1945
- // Enrich notes with tags
1946
- const promises = Object.keys(allNotes).map(async path => {
1947
- try {
1948
- const note = await fetchJSON(`/api/note/${path}`);
1949
- if (!note.error) allNotes[path] = { title: note.title, tags: note.tags || [] };
1950
- } catch {}
1951
- });
1952
- await Promise.all(promises);
1953
-
1954
- renderSidebar(sessionsData);
1955
- $('stat-notes').textContent = stats.notes;
1956
- $('stat-folders').textContent = stats.folders;
1957
- loadTokenEconomics();
1958
2007
 
1959
2008
  // Show empty state
1960
2009
  $('content-area').innerHTML = `
package/kyp_mem/ui.py CHANGED
@@ -16,6 +16,18 @@ def create_app(vault_path: str = None) -> FastAPI:
16
16
  vault = Vault(vault_path)
17
17
  app = FastAPI(title="KYP-MEM")
18
18
 
19
+ from starlette.middleware.base import BaseHTTPMiddleware
20
+ from starlette.responses import Response
21
+
22
+ class NoCacheMiddleware(BaseHTTPMiddleware):
23
+ async def dispatch(self, request, call_next):
24
+ response: Response = await call_next(request)
25
+ if request.url.path.startswith("/api/"):
26
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
27
+ return response
28
+
29
+ app.add_middleware(NoCacheMiddleware)
30
+
19
31
  @app.get("/")
20
32
  def index():
21
33
  html_path = Path(__file__).parent / "static" / "index.html"
@@ -23,6 +35,7 @@ def create_app(vault_path: str = None) -> FastAPI:
23
35
 
24
36
  @app.get("/api/tree")
25
37
  def tree():
38
+ vault.refresh_if_stale()
26
39
  return JSONResponse(vault.get_full_tree())
27
40
 
28
41
  @app.get("/api/stats")
@@ -31,35 +44,38 @@ def create_app(vault_path: str = None) -> FastAPI:
31
44
  return JSONResponse(vault.get_stats())
32
45
 
33
46
  @app.get("/api/graph")
34
- def graph(kind: str = "all"):
35
- nodes = []
36
- edges = []
37
- seen_edges = set()
38
- for path, note in vault.index.notes.items():
47
+ def graph(kind: str = "all", project: str = None):
48
+ vault.refresh_if_stale()
49
+
50
+ def include(path: str) -> bool:
51
+ if project and not path.startswith(project + "/"):
52
+ return False
39
53
  node_kind = "session" if "/Sessions/" in path else "note"
40
54
  if kind == "projects" and node_kind == "session":
41
- continue
55
+ return False
42
56
  if kind == "sessions" and node_kind == "note":
57
+ return False
58
+ return True
59
+
60
+ nodes = []
61
+ for path, note in vault.index.notes.items():
62
+ if not include(path):
43
63
  continue
64
+ node_kind = "session" if "/Sessions/" in path else "note"
44
65
  nodes.append({"id": path, "title": note.title, "kind": node_kind, "tags": note.tags})
45
- for link in (note.links or []):
46
- target = None
47
- link_lower = link.lower()
48
- for p in vault.index.notes:
49
- stem = p.split("/")[-1].replace(".md", "").lower()
50
- if stem == link_lower:
51
- target = p
52
- break
53
- if target and target != path:
54
- target_kind = "session" if "/Sessions/" in target else "note"
55
- if kind == "projects" and target_kind == "session":
56
- continue
57
- if kind == "sessions" and target_kind == "note":
58
- continue
59
- key = tuple(sorted([path, target]))
60
- if key not in seen_edges:
61
- seen_edges.add(key)
62
- edges.append({"source": path, "target": target})
66
+
67
+ node_ids = {n["id"] for n in nodes}
68
+ edges = []
69
+ seen_edges = set()
70
+ for path in node_ids:
71
+ for target in vault.index.forward_links.get(path, set()):
72
+ if target not in node_ids:
73
+ continue
74
+ key = tuple(sorted([path, target]))
75
+ if key not in seen_edges:
76
+ seen_edges.add(key)
77
+ edges.append({"source": path, "target": target})
78
+
63
79
  return JSONResponse({"nodes": nodes, "edges": edges})
64
80
 
65
81
  @app.get("/api/note/{path:path}")
package/kyp_mem/vault.py CHANGED
@@ -98,6 +98,7 @@ class Index:
98
98
  def __init__(self):
99
99
  self.notes: dict[str, Note] = {}
100
100
  self.backlinks: dict[str, set] = defaultdict(set)
101
+ self.forward_links: dict[str, set] = defaultdict(set)
101
102
  self.tag_index: dict[str, set] = defaultdict(set)
102
103
  self._word_index: dict[str, set] = defaultdict(set)
103
104
  self._name_to_path: dict[str, str] = {}
@@ -105,6 +106,7 @@ class Index:
105
106
  def rebuild(self, notes: dict[str, Note]):
106
107
  self.notes = notes
107
108
  self.backlinks = defaultdict(set)
109
+ self.forward_links = defaultdict(set)
108
110
  self.tag_index = defaultdict(set)
109
111
  self._word_index = defaultdict(set)
110
112
  self._name_to_path = {}
@@ -116,8 +118,9 @@ class Index:
116
118
  for path, note in notes.items():
117
119
  for link in note.links:
118
120
  target = self._name_to_path.get(link.lower())
119
- if target:
121
+ if target and target != path:
120
122
  self.backlinks[target].add(path)
123
+ self.forward_links[path].add(target)
121
124
 
122
125
  for tag in note.tags:
123
126
  self.tag_index[tag.lower()].add(path)
@@ -210,14 +213,23 @@ class Vault:
210
213
  init_vector_db(str(self.root))
211
214
  self._load_all()
212
215
  self._sync_vector_db()
216
+ self._last_mtime = self._max_mtime()
213
217
 
214
218
  def _disk_note_paths(self) -> set[str]:
215
219
  return {str(f.relative_to(self.root)) for f in self.root.rglob("*.md")}
216
220
 
221
+ def _max_mtime(self) -> float:
222
+ mtimes = [f.stat().st_mtime for f in self.root.rglob("*.md")]
223
+ return max(mtimes) if mtimes else 0.0
224
+
217
225
  def refresh_if_stale(self):
218
- if self._disk_note_paths() != set(self.index.notes.keys()):
226
+ current_mtime = self._max_mtime()
227
+ paths_changed = self._disk_note_paths() != set(self.index.notes.keys())
228
+ content_changed = current_mtime != self._last_mtime
229
+ if paths_changed or content_changed:
219
230
  self._load_all()
220
231
  self._sync_vector_db()
232
+ self._last_mtime = current_mtime
221
233
 
222
234
  def _sync_vector_db(self):
223
235
  mem = get_session_memory()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyp-mem",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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.6.7"
7
+ version = "0.6.9"
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"}