@aabadin/project-memory-context 0.2.6 → 0.2.8

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.
@@ -6,9 +6,11 @@ import { fileURLToPath } from 'node:url';
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const PACKAGE_ROOT = resolve(__dirname, '..');
8
8
 
9
- const GRAPH_EXPLORER_PATH = resolve(PACKAGE_ROOT, '../pmc-graph-explorer/server.mjs');
9
+ // graph-explorer lives INSIDE the package: tools/pmc-graph-explorer/
10
+ const GRAPH_EXPLORER_PATH = resolve(PACKAGE_ROOT, 'tools/pmc-graph-explorer/server.mjs');
10
11
 
11
12
  const child = spawn(process.execPath, [GRAPH_EXPLORER_PATH], {
13
+ cwd: process.cwd(),
12
14
  stdio: 'inherit',
13
15
  detached: true,
14
16
  shell: true,
package/cli/bootstrap.mjs CHANGED
@@ -2,15 +2,15 @@
2
2
  import { resolve, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { access, constants, readFile, writeFile, mkdir } from 'node:fs/promises';
5
- import { existsSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs';
5
+ import { existsSync, readdirSync, copyFileSync } from 'node:fs';
6
6
  import { spawnSync } from 'node:child_process';
7
7
 
8
8
  import { bootstrapProjectInstall } from '../src/setup-bootstrap.mjs';
9
9
  import { resolveGraphify, spawnBackground, resolvePythonBin } from '../src/platform.mjs';
10
+ import { installPmcTools } from './install-pmc.mjs';
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const PMC_CLI_ROOT = resolve(__dirname);
13
- const PMC_ROOT = resolve(__dirname, '..', '..', '..');
14
14
  const PMC_PACKAGE_ROOT = resolve(__dirname, '..');
15
15
 
16
16
  const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
@@ -94,46 +94,8 @@ async function installGraphify() {
94
94
  }
95
95
 
96
96
  async function syncToolsToTarget(projectRoot) {
97
- log('Copying PMC tools to target repo...');
98
- const srcCli = resolve(PMC_ROOT, 'tools', 'project-memory-context', 'cli');
99
- const dstCli = resolve(projectRoot, 'tools', 'project-memory-context', 'cli');
100
-
101
- mkdirSync(resolve(projectRoot, 'tools', 'project-memory-context'), { recursive: true });
102
- mkdirSync(dstCli, { recursive: true });
103
-
104
- const files = ['new-project.mjs', 'bootstrap.mjs', 'enrich-queue.mjs', 'build-worklist.mjs', 'project-context.mjs', 'sanitize.mjs'];
105
- for (const f of files) {
106
- try {
107
- copyFileSync(resolve(srcCli, f), resolve(dstCli, f));
108
- log(` copied ${f}`);
109
- } catch {
110
- log(` skipped ${f} (not found in source)`);
111
- }
112
- }
113
-
114
- const srcSrc = resolve(PMC_ROOT, 'tools', 'project-memory-context', 'src');
115
- const dstSrc = resolve(projectRoot, 'tools', 'project-memory-context', 'src');
116
- mkdirSync(dstSrc, { recursive: true });
117
-
118
- function copyTree(srcDir, dstDir) {
119
- mkdirSync(dstDir, { recursive: true });
120
- const entries = readdirSync(srcDir, { withFileTypes: true });
121
- for (const entry of entries) {
122
- const srcPath = resolve(srcDir, entry.name);
123
- const dstPath = resolve(dstDir, entry.name);
124
- if (entry.isDirectory()) {
125
- copyTree(srcPath, dstPath);
126
- } else if (entry.isFile() && entry.name.endsWith('.mjs')) {
127
- copyFileSync(srcPath, dstPath);
128
- log(` copied ${dstPath.replace(`${projectRoot}\\`, '').replace(/\\/g, '/')}`);
129
- }
130
- }
131
- }
132
-
133
- try {
134
- copyTree(srcSrc, dstSrc);
135
- } catch { log(' src/ copy skipped (may not exist)'); }
136
-
97
+ const result = installPmcTools({ sourceRoot: PMC_PACKAGE_ROOT, targetRoot: projectRoot });
98
+ log(` copied ${result.cliFiles} CLI files, ${result.srcFiles} src files, ${result.templateFiles} templates`);
137
99
  log(' PMC tools synced to target repo.');
138
100
  }
139
101
 
@@ -199,7 +161,7 @@ function findFiles(dir, exts, ignore, projectRoot) {
199
161
  async function runStageB(projectRoot) {
200
162
  log('Running stage-b (build-worklist)...');
201
163
 
202
- const ignore = ['node_modules', 'dist', '.git', 'bin', 'obj', '.opencode', '.planning'];
164
+ const ignore = ['node_modules', 'dist', '.git', 'bin', 'obj', '.opencode', '.planning', '.next'];
203
165
  const files = [
204
166
  ...findFiles(projectRoot, ['.ts', '.mjs', '.js'], ignore, projectRoot),
205
167
  ...findFiles(projectRoot, ['.cs'], ignore, projectRoot),
@@ -281,7 +243,7 @@ export async function main(args = process.argv.slice(2)) {
281
243
  const runEnrichFlag = args.includes('--enrich');
282
244
 
283
245
  log(`Target repo: ${targetDir}`);
284
- log(`PMC package root: ${PMC_ROOT}`);
246
+ log(`PMC package root: ${PMC_PACKAGE_ROOT}`);
285
247
  log(`Ollama: ${OLLAMA_URL} | Model: ${OLLAMA_MODEL}`);
286
248
 
287
249
  if (!await ensureDir(targetDir)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aabadin/project-memory-context",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Portable project memory context CLI — bootstraps semantic enrichment workflows for any AI coding agent.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -58,6 +58,7 @@
58
58
  "@modelcontextprotocol/sdk": "^1.12.0",
59
59
  "acorn": "^8.16.0",
60
60
  "acorn-walk": "^8.3.0",
61
+ "express": "^5.2.1",
61
62
  "zod": "^3.24.0"
62
63
  }
63
64
  }
@@ -1,11 +1,24 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { join, relative } from 'node:path';
3
3
 
4
+ const IGNORED_DIRECTORIES = new Set([
5
+ 'node_modules',
6
+ '.git',
7
+ '.planning',
8
+ '.next',
9
+ 'graphify-out',
10
+ 'dist',
11
+ 'build',
12
+ 'coverage',
13
+ '.turbo',
14
+ '.vercel',
15
+ ]);
16
+
4
17
  async function listDirectories(dir, root, depth = 0, maxDepth = 2, acc = []) {
5
18
  const entries = await readdir(dir, { withFileTypes: true });
6
19
  for (const entry of entries) {
7
20
  if (!entry.isDirectory()) continue;
8
- if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.planning') continue;
21
+ if (IGNORED_DIRECTORIES.has(entry.name)) continue;
9
22
  const full = join(dir, entry.name);
10
23
  const rel = relative(root, full).replace(/\\/g, '/');
11
24
  acc.push({ rel, depth });
@@ -11,16 +11,10 @@ Open the PMC Graph Explorer to visualize the enrichment graph. The server runs o
11
11
  </objective>
12
12
 
13
13
  <execution>
14
- Start the graph explorer server:
14
+ Start the graph explorer server using the globally installed PMC CLI:
15
15
 
16
16
  ```bash
17
- npx @aabadin/project-memory-context view-context
18
- ```
19
-
20
- Or if PMC is installed globally:
21
-
22
- ```bash
23
- pmc-view-context
17
+ npx --yes --package @aabadin/project-memory-context pmc-view-context
24
18
  ```
25
19
 
26
20
  Then open http://localhost:3001 in your browser.
@@ -0,0 +1,94 @@
1
+ import { createGraph } from "./graph.js";
2
+ import { updateSidebar } from "./sidebar.js";
3
+ import { initFilters, updateStats } from "./filters.js";
4
+ import { loadContext } from "./context-tracker.js";
5
+
6
+ const state = {
7
+ graphData: null,
8
+ worklistData: null,
9
+ contextData: null,
10
+ selectedNode: null,
11
+ activeOnly: false,
12
+ searchQuery: "",
13
+ enabledCommunities: new Set(),
14
+ enabledTypes: new Set(["file", "class", "method", "function", "interface"]),
15
+ };
16
+
17
+ async function loadData() {
18
+ const [graphRes, worklistRes, contextRes] = await Promise.all([
19
+ fetch("/api/graph"),
20
+ fetch("/api/worklist"),
21
+ fetch("/api/context"),
22
+ ]);
23
+ state.graphData = await graphRes.json();
24
+ state.worklistData = await worklistRes.json();
25
+ state.contextData = await contextRes.json();
26
+ return state;
27
+ }
28
+
29
+ function getActiveNodeIds() {
30
+ if (!state.contextData || !Array.isArray(state.contextData.activeNodeIds)) {
31
+ return new Set();
32
+ }
33
+ return new Set(state.contextData.activeNodeIds);
34
+ }
35
+
36
+ function getEnrichedNodeIds() {
37
+ if (!state.worklistData) return new Set();
38
+ return new Set(state.worklistData.map((e) => e.graphNodeId));
39
+ }
40
+
41
+ function getWorklistStatusMap() {
42
+ if (!state.worklistData) return new Map();
43
+ const m = new Map();
44
+ state.worklistData.forEach((e) => m.set(e.graphNodeId, e.status));
45
+ return m;
46
+ }
47
+
48
+ async function init() {
49
+ await loadData();
50
+
51
+ const communities = [
52
+ ...new Set(state.graphData.nodes.map((n) => n.community)),
53
+ ].sort((a, b) => a - b);
54
+ communities.forEach((c) => state.enabledCommunities.add(c));
55
+
56
+ state.getEnrichedNodeIds = getEnrichedNodeIds;
57
+ state.getWorklistStatusMap = getWorklistStatusMap;
58
+
59
+ initFilters(state, communities);
60
+ updateStats(state);
61
+ createGraph(state, {
62
+ onNodeClick: (node) => {
63
+ state.selectedNode = node;
64
+ updateSidebar(node, state);
65
+ },
66
+ getActiveNodeIds,
67
+ getEnrichedNodeIds,
68
+ });
69
+ }
70
+
71
+ document.getElementById("search").addEventListener("input", (e) => {
72
+ state.searchQuery = e.target.value.toLowerCase();
73
+ document.dispatchEvent(new CustomEvent("graph-update", { detail: state }));
74
+ });
75
+
76
+ document.getElementById("toggle-active").addEventListener("click", (e) => {
77
+ state.activeOnly = !state.activeOnly;
78
+ e.target.classList.toggle("active", state.activeOnly);
79
+ document.dispatchEvent(new CustomEvent("graph-update", { detail: state }));
80
+ });
81
+
82
+ document.getElementById("toggle-panel").addEventListener("click", () => {
83
+ document.getElementById("sidebar").classList.toggle("collapsed");
84
+ document.dispatchEvent(new CustomEvent("graph-resize"));
85
+ });
86
+
87
+ document.addEventListener("context-changed", async () => {
88
+ const res = await fetch("/api/context");
89
+ state.contextData = await res.json();
90
+ document.dispatchEvent(new CustomEvent("graph-update", { detail: state }));
91
+ updateStats(state);
92
+ });
93
+
94
+ init();
@@ -0,0 +1,18 @@
1
+ export async function loadContext() {
2
+ try {
3
+ const res = await fetch("/api/context");
4
+ if (!res.ok) return { activeNodeIds: [] };
5
+ return await res.json();
6
+ } catch {
7
+ return { activeNodeIds: [] };
8
+ }
9
+ }
10
+
11
+ export async function markNodesActive(nodeIds) {
12
+ await fetch("/api/context", {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify({ add: nodeIds }),
16
+ });
17
+ document.dispatchEvent(new CustomEvent("context-changed"));
18
+ }
@@ -0,0 +1,48 @@
1
+ const COMMUNITY_COLORS_MAP = d3.scaleOrdinal(d3.schemeTableau10);
2
+
3
+ export function initFilters(state, communities) {
4
+ const container = document.getElementById("filters");
5
+
6
+ const communitySection = document.createElement("div");
7
+ communitySection.innerHTML = `<h3>Communities (${communities.length})</h3>`;
8
+
9
+ communities.forEach((c) => {
10
+ const row = document.createElement("label");
11
+ row.className = "filter-row";
12
+ row.innerHTML = `<input type="checkbox" checked data-community="${c}"><span class="filter-dot" style="background:${COMMUNITY_COLORS_MAP(c)}"></span>Community ${c}`;
13
+ row.querySelector("input").addEventListener("change", (e) => {
14
+ if (e.target.checked) {
15
+ state.enabledCommunities.add(c);
16
+ } else {
17
+ state.enabledCommunities.delete(c);
18
+ }
19
+ document.dispatchEvent(new CustomEvent("graph-update", { detail: state }));
20
+ updateStats(state);
21
+ });
22
+ communitySection.appendChild(row);
23
+ });
24
+
25
+ container.appendChild(communitySection);
26
+ }
27
+
28
+ export function updateStats(state) {
29
+ const statsEl = document.getElementById("stats");
30
+ const nodes = state.graphData?.nodes || [];
31
+ const activeIds = new Set(state.contextData?.activeNodeIds || []);
32
+ const enrichedIds = state.getEnrichedNodeIds ? state.getEnrichedNodeIds() : new Set();
33
+ const activeCount = nodes.filter((n) => activeIds.has(n.id)).length;
34
+ const enrichedCount = nodes.filter((n) => enrichedIds.has(n.id)).length;
35
+ const visibleCount = nodes.filter((n) => {
36
+ const matchCommunity = state.enabledCommunities.has(n.community);
37
+ const matchActive = !state.activeOnly || activeIds.has(n.id);
38
+ const matchSearch = !state.searchQuery || n.label.toLowerCase().includes(state.searchQuery);
39
+ return matchCommunity && matchActive && matchSearch;
40
+ }).length;
41
+
42
+ statsEl.innerHTML = `
43
+ <span class="stat-badge">Nodes: ${nodes.length}</span>
44
+ <span class="stat-badge">Visible: ${visibleCount}</span>
45
+ <span class="stat-badge">Enriched: ${enrichedCount}</span>
46
+ ${activeCount > 0 ? `<span class="stat-badge active">Active: ${activeCount}</span>` : ""}
47
+ `;
48
+ }
@@ -0,0 +1,187 @@
1
+ const COMMUNITY_COLORS = d3.scaleOrdinal(d3.schemeTableau10);
2
+ const EDGE_RELATION_THICKNESS = {
3
+ imports_from: 2,
4
+ imports: 1.5,
5
+ contains: 1,
6
+ method: 1,
7
+ calls: 1.5,
8
+ inherits: 2,
9
+ case_of: 1,
10
+ };
11
+
12
+ function nodeRadius(node) {
13
+ const label = (node.label || "").toLowerCase();
14
+ if (/\.(ts|js|mjs|json)$/.test(label)) return 12;
15
+ if (label.includes("(")) return 5;
16
+ return 8;
17
+ }
18
+
19
+ export function createGraph(state, callbacks) {
20
+ const container = document.getElementById("canvas");
21
+ const svg = d3.select("#graph-svg");
22
+ const tooltip = document.getElementById("tooltip");
23
+ const width = container.clientWidth;
24
+ const height = container.clientHeight;
25
+
26
+ svg.attr("viewBox", [0, 0, width, height]);
27
+
28
+ const g = svg.append("g");
29
+
30
+ const zoom = d3.zoom()
31
+ .scaleExtent([0.1, 8])
32
+ .on("zoom", (event) => {
33
+ g.attr("transform", event.transform);
34
+ });
35
+
36
+ svg.call(zoom);
37
+
38
+ const { nodes, links } = state.graphData;
39
+ const activeIds = callbacks.getActiveNodeIds();
40
+ const enrichedIds = callbacks.getEnrichedNodeIds();
41
+
42
+ const defs = svg.append("defs");
43
+ const filter = defs.append("filter").attr("id", "glow");
44
+ filter.append("feGaussianBlur").attr("stdDeviation", "6").attr("result", "blur");
45
+ filter.append("feMerge").selectAll("feMergeNode")
46
+ .data(["blur", "SourceGraphic"])
47
+ .join("feMergeNode")
48
+ .attr("in", (d) => d);
49
+
50
+ const linkElements = g.append("g")
51
+ .attr("class", "links")
52
+ .selectAll("line")
53
+ .data(links)
54
+ .join("line")
55
+ .attr("stroke", "#475569")
56
+ .attr("stroke-opacity", 0.15)
57
+ .attr("stroke-width", (d) => EDGE_RELATION_THICKNESS[d.relation] || 1);
58
+
59
+ const nodeGroups = g.append("g")
60
+ .attr("class", "nodes")
61
+ .selectAll("g")
62
+ .data(nodes)
63
+ .join("g")
64
+ .attr("class", "node-group")
65
+ .style("cursor", "pointer");
66
+
67
+ nodeGroups.filter((d) => activeIds.has(d.id))
68
+ .append("circle")
69
+ .attr("r", (d) => nodeRadius(d) + 6)
70
+ .attr("fill", "none")
71
+ .attr("stroke", "#06b6d4")
72
+ .attr("stroke-width", 2)
73
+ .attr("filter", "url(#glow)")
74
+ .attr("class", "active-halo");
75
+
76
+ nodeGroups.append("circle")
77
+ .attr("r", (d) => nodeRadius(d))
78
+ .attr("fill", (d) => COMMUNITY_COLORS(d.community))
79
+ .attr("fill-opacity", (d) => activeIds.has(d.id) ? 1 : 0.7)
80
+ .attr("stroke", (d) => activeIds.has(d.id) ? "#06b6d4" : "#1e293b")
81
+ .attr("stroke-width", (d) => activeIds.has(d.id) ? 2 : 1);
82
+
83
+ const simulation = d3.forceSimulation(nodes)
84
+ .force("link", d3.forceLink(links).id((d) => d.id).distance(40).strength(0.5))
85
+ .force("charge", d3.forceManyBody().strength(-80))
86
+ .force("center", d3.forceCenter(width / 2, height / 2))
87
+ .force("collide", d3.forceCollide().radius((d) => nodeRadius(d) + 4))
88
+ .alphaDecay(0.02)
89
+ .on("tick", ticked);
90
+
91
+ function ticked() {
92
+ linkElements
93
+ .attr("x1", (d) => d.source.x)
94
+ .attr("y1", (d) => d.source.y)
95
+ .attr("x2", (d) => d.target.x)
96
+ .attr("y2", (d) => d.target.y);
97
+
98
+ nodeGroups.attr("transform", (d) => `translate(${d.x},${d.y})`);
99
+ }
100
+
101
+ const drag = d3.drag()
102
+ .on("start", (event, d) => {
103
+ if (!event.active) simulation.alphaTarget(0.3).restart();
104
+ d.fx = d.x;
105
+ d.fy = d.y;
106
+ })
107
+ .on("drag", (event, d) => {
108
+ d.fx = event.x;
109
+ d.fy = event.y;
110
+ })
111
+ .on("end", (event, d) => {
112
+ if (!event.active) simulation.alphaTarget(0);
113
+ d.fx = null;
114
+ d.fy = null;
115
+ });
116
+
117
+ nodeGroups.call(drag);
118
+
119
+ nodeGroups
120
+ .on("mouseover", (event, d) => {
121
+ const status = enrichedIds.has(d.id) ? "enriched" : "not enriched";
122
+ const active = activeIds.has(d.id) ? " | IN CONTEXT" : "";
123
+ tooltip.innerHTML = `<strong>${d.label}</strong><br><code>${d.source_file}:${d.source_location}</code><br>Community ${d.community} | ${status}${active}`;
124
+ tooltip.classList.add("visible");
125
+ tooltip.style.left = `${event.offsetX + 12}px`;
126
+ tooltip.style.top = `${event.offsetY - 8}px`;
127
+ })
128
+ .on("mouseout", () => {
129
+ tooltip.classList.remove("visible");
130
+ })
131
+ .on("click", (event, d) => {
132
+ event.stopPropagation();
133
+ callbacks.onNodeClick(d);
134
+ })
135
+ .on("dblclick", (event, d) => {
136
+ event.stopPropagation();
137
+ const scale = 2;
138
+ const transform = d3.zoomIdentity
139
+ .translate(width / 2 - d.x * scale, height / 2 - d.y * scale)
140
+ .scale(scale);
141
+ svg.transition().duration(400).call(zoom.transform, transform);
142
+ });
143
+
144
+ document.addEventListener("graph-update", (e) => {
145
+ applyFilters(e.detail, nodeGroups, linkElements, simulation);
146
+ });
147
+
148
+ document.addEventListener("graph-resize", () => {
149
+ const w = container.clientWidth;
150
+ const h = container.clientHeight;
151
+ svg.attr("viewBox", [0, 0, w, h]);
152
+ simulation.force("center", d3.forceCenter(w / 2, h / 2));
153
+ simulation.alpha(0.3).restart();
154
+ });
155
+ }
156
+
157
+ function applyFilters(state, nodeGroups, linkElements, simulation) {
158
+ const activeIds = new Set(
159
+ state.contextData?.activeNodeIds || []
160
+ );
161
+ const q = state.searchQuery;
162
+
163
+ nodeGroups.style("display", (d) => {
164
+ const matchSearch = !q || d.label.toLowerCase().includes(q) || d.source_file.toLowerCase().includes(q);
165
+ const matchCommunity = state.enabledCommunities.has(d.community);
166
+ const kind = inferKind(d.label);
167
+ const matchType = state.enabledTypes.has(kind);
168
+ const matchActive = !state.activeOnly || activeIds.has(d.id);
169
+ return matchSearch && matchCommunity && matchType && matchActive ? null : "none";
170
+ });
171
+
172
+ if (q) {
173
+ nodeGroups.select("circle:not(.active-halo)")
174
+ .attr("fill-opacity", (d) => d.label.toLowerCase().includes(q) ? 1 : 0.15);
175
+ } else {
176
+ nodeGroups.select("circle:not(.active-halo)")
177
+ .attr("fill-opacity", (d) => activeIds.has(d.id) ? 1 : 0.7);
178
+ }
179
+
180
+ simulation.alpha(0.1).restart();
181
+ }
182
+
183
+ function inferKind(label) {
184
+ if (/\.(ts|js|mjs|json)$/.test(label)) return "file";
185
+ if (label.includes("(")) return "method";
186
+ return "class";
187
+ }
@@ -0,0 +1,39 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>PMC Graph Explorer</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+ <header class="header">
11
+ <div class="header-left">
12
+ <h1 class="header-title">PMC Graph Explorer</h1>
13
+ <div class="stats" id="stats"></div>
14
+ </div>
15
+ <div class="header-right">
16
+ <input type="text" id="search" class="search-input" placeholder="Search nodes..." autocomplete="off">
17
+ <button id="toggle-active" class="btn-toggle" title="Show only active context">Active only</button>
18
+ <button id="toggle-panel" class="btn-toggle" title="Toggle side panel">&#9776;</button>
19
+ </div>
20
+ </header>
21
+
22
+ <div class="main">
23
+ <aside class="sidebar" id="sidebar">
24
+ <div class="sidebar-content" id="sidebar-content">
25
+ <p class="sidebar-empty">Click a node to see details</p>
26
+ </div>
27
+ <div class="sidebar-filters" id="filters"></div>
28
+ </aside>
29
+
30
+ <div class="canvas" id="canvas">
31
+ <svg id="graph-svg"></svg>
32
+ <div class="tooltip" id="tooltip"></div>
33
+ </div>
34
+ </div>
35
+
36
+ <script src="https://d3js.org/d3.v7.min.js"></script>
37
+ <script type="module" src="app.js"></script>
38
+ </body>
39
+ </html>
@@ -0,0 +1,85 @@
1
+ const COMMUNITY_COLORS_MAP = d3.scaleOrdinal(d3.schemeTableau10);
2
+
3
+ export function updateSidebar(node, state) {
4
+ const container = document.getElementById("sidebar-content");
5
+ if (!node) {
6
+ container.innerHTML = '<p class="sidebar-empty">Click a node to see details</p>';
7
+ return;
8
+ }
9
+
10
+ const activeIds = new Set(state.contextData?.activeNodeIds || []);
11
+ const isActive = activeIds.has(node.id);
12
+ const worklistMap = state.getWorklistStatusMap ? state.getWorklistStatusMap() : new Map();
13
+ const enrichStatus = worklistMap.get(node.id) || "unknown";
14
+
15
+ const inLinks = state.graphData.links.filter((l) => l.target === node.id || (l.target && l.target.id === node.id));
16
+ const outLinks = state.graphData.links.filter((l) => l.source === node.id || (l.source && l.source.id === node.id));
17
+
18
+ const kind = inferKind(node.label);
19
+
20
+ container.innerHTML = `
21
+ <div class="node-detail">
22
+ <div class="node-detail-label">Name</div>
23
+ <div class="node-detail-value">${escapeHtml(node.label)}</div>
24
+
25
+ <div class="node-detail-label">Kind</div>
26
+ <div class="node-detail-value"><span class="badge" style="background:var(--bg-elevated)">${kind}</span></div>
27
+
28
+ <div class="node-detail-label">Source</div>
29
+ <div class="node-detail-value"><code>${escapeHtml(node.source_file || "")}:${escapeHtml(node.source_location || "")}</code></div>
30
+
31
+ <div class="node-detail-label">Community</div>
32
+ <div class="node-detail-value">
33
+ <span class="badge badge-community" style="background:${COMMUNITY_COLORS_MAP(node.community)}">Community ${node.community}</span>
34
+ </div>
35
+
36
+ <div class="node-detail-label">Enrichment</div>
37
+ <div class="node-detail-value">
38
+ <span class="badge" style="background:var(--bg-elevated)">${enrichStatus}</span>
39
+ </div>
40
+
41
+ ${isActive ? '<div class="node-detail-value"><span class="badge badge-context">EN CONTEXTO</span></div>' : ""}
42
+
43
+ <div class="node-detail-label">Incoming (${inLinks.length})</div>
44
+ <ul class="relations-list">
45
+ ${inLinks.map((l) => {
46
+ const srcId = typeof l.source === "object" ? l.source.id : l.source;
47
+ const srcNode = state.graphData.nodes.find((n) => n.id === srcId);
48
+ return `<li data-node-id="${srcId}"><span class="rel-type">${escapeHtml(l.relation)}</span>${srcNode ? escapeHtml(srcNode.label) : srcId}</li>`;
49
+ }).join("")}
50
+ </ul>
51
+
52
+ <div class="node-detail-label">Outgoing (${outLinks.length})</div>
53
+ <ul class="relations-list">
54
+ ${outLinks.map((l) => {
55
+ const tgtId = typeof l.target === "object" ? l.target.id : l.target;
56
+ const tgtNode = state.graphData.nodes.find((n) => n.id === tgtId);
57
+ return `<li data-node-id="${tgtId}"><span class="rel-type">${escapeHtml(l.relation)}</span>${tgtNode ? escapeHtml(tgtNode.label) : tgtId}</li>`;
58
+ }).join("")}
59
+ </ul>
60
+ </div>
61
+ `;
62
+
63
+ container.querySelectorAll("[data-node-id]").forEach((el) => {
64
+ el.addEventListener("click", () => {
65
+ const targetId = el.getAttribute("data-node-id");
66
+ const targetNode = state.graphData.nodes.find((n) => n.id === targetId);
67
+ if (targetNode) {
68
+ state.selectedNode = targetNode;
69
+ updateSidebar(targetNode, state);
70
+ }
71
+ });
72
+ });
73
+ }
74
+
75
+ function inferKind(label) {
76
+ if (/\.(ts|js|mjs|json)$/.test(label)) return "file";
77
+ if (label.includes("(")) return "method";
78
+ return "class";
79
+ }
80
+
81
+ function escapeHtml(str) {
82
+ const d = document.createElement("div");
83
+ d.textContent = str;
84
+ return d.innerHTML;
85
+ }
@@ -0,0 +1,264 @@
1
+ :root {
2
+ --bg-primary: #0f172a;
3
+ --bg-surface: #1e293b;
4
+ --bg-elevated: #334155;
5
+ --text-primary: #e2e8f0;
6
+ --text-secondary: #94a3b8;
7
+ --accent: #06b6d4;
8
+ --accent-dim: #0891b2;
9
+ --border: #475569;
10
+ --radius: 6px;
11
+ --header-h: 56px;
12
+ --sidebar-w: 280px;
13
+ --font-sans: "Inter", system-ui, -apple-system, sans-serif;
14
+ --font-mono: "JetBrains Mono", "Fira Code", monospace;
15
+ }
16
+
17
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
18
+
19
+ body {
20
+ font-family: var(--font-sans);
21
+ font-size: 14px;
22
+ color: var(--text-primary);
23
+ background: var(--bg-primary);
24
+ height: 100vh;
25
+ overflow: hidden;
26
+ }
27
+
28
+ .header {
29
+ height: var(--header-h);
30
+ background: var(--bg-surface);
31
+ border-bottom: 1px solid var(--border);
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: space-between;
35
+ padding: 0 16px;
36
+ z-index: 10;
37
+ }
38
+
39
+ .header-left { display: flex; align-items: center; gap: 16px; }
40
+
41
+ .header-title {
42
+ font-size: 18px;
43
+ font-weight: 600;
44
+ white-space: nowrap;
45
+ }
46
+
47
+ .stats {
48
+ display: flex;
49
+ gap: 12px;
50
+ font-size: 12px;
51
+ color: var(--text-secondary);
52
+ }
53
+
54
+ .stat-badge {
55
+ background: var(--bg-elevated);
56
+ padding: 2px 8px;
57
+ border-radius: var(--radius);
58
+ }
59
+
60
+ .stat-badge.active {
61
+ background: var(--accent);
62
+ color: var(--bg-primary);
63
+ font-weight: 600;
64
+ }
65
+
66
+ .header-right { display: flex; align-items: center; gap: 8px; }
67
+
68
+ .search-input {
69
+ background: var(--bg-elevated);
70
+ border: 1px solid var(--border);
71
+ border-radius: var(--radius);
72
+ color: var(--text-primary);
73
+ padding: 6px 12px;
74
+ font-size: 13px;
75
+ width: 200px;
76
+ outline: none;
77
+ transition: border-color 150ms;
78
+ }
79
+
80
+ .search-input:focus { border-color: var(--accent); }
81
+
82
+ .btn-toggle {
83
+ background: var(--bg-elevated);
84
+ border: 1px solid var(--border);
85
+ color: var(--text-secondary);
86
+ padding: 6px 12px;
87
+ border-radius: var(--radius);
88
+ cursor: pointer;
89
+ font-size: 13px;
90
+ transition: all 150ms;
91
+ }
92
+
93
+ .btn-toggle:hover { border-color: var(--accent); color: var(--text-primary); }
94
+ .btn-toggle.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
95
+
96
+ .main {
97
+ display: flex;
98
+ height: calc(100vh - var(--header-h));
99
+ }
100
+
101
+ .sidebar {
102
+ width: var(--sidebar-w);
103
+ background: var(--bg-surface);
104
+ border-right: 1px solid var(--border);
105
+ display: flex;
106
+ flex-direction: column;
107
+ transition: width 200ms ease, opacity 200ms ease;
108
+ overflow: hidden;
109
+ flex-shrink: 0;
110
+ }
111
+
112
+ .sidebar.collapsed { width: 0; opacity: 0; pointer-events: none; }
113
+
114
+ .sidebar-content {
115
+ flex: 1;
116
+ padding: 16px;
117
+ overflow-y: auto;
118
+ }
119
+
120
+ .sidebar-empty {
121
+ color: var(--text-secondary);
122
+ font-size: 13px;
123
+ text-align: center;
124
+ padding-top: 32px;
125
+ }
126
+
127
+ .sidebar-filters {
128
+ border-top: 1px solid var(--border);
129
+ padding: 12px 16px;
130
+ max-height: 240px;
131
+ overflow-y: auto;
132
+ }
133
+
134
+ .sidebar-filters h3 {
135
+ font-size: 11px;
136
+ text-transform: uppercase;
137
+ letter-spacing: 0.5px;
138
+ color: var(--text-secondary);
139
+ margin-bottom: 8px;
140
+ }
141
+
142
+ .filter-row {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 6px;
146
+ padding: 2px 0;
147
+ font-size: 12px;
148
+ cursor: pointer;
149
+ }
150
+
151
+ .filter-dot {
152
+ width: 10px;
153
+ height: 10px;
154
+ border-radius: 50%;
155
+ flex-shrink: 0;
156
+ }
157
+
158
+ .node-detail-label {
159
+ font-size: 11px;
160
+ text-transform: uppercase;
161
+ letter-spacing: 0.5px;
162
+ color: var(--text-secondary);
163
+ margin-bottom: 4px;
164
+ }
165
+
166
+ .node-detail-value {
167
+ font-size: 14px;
168
+ margin-bottom: 12px;
169
+ word-break: break-all;
170
+ }
171
+
172
+ .node-detail-value code {
173
+ font-family: var(--font-mono);
174
+ font-size: 12px;
175
+ background: var(--bg-elevated);
176
+ padding: 2px 6px;
177
+ border-radius: var(--radius);
178
+ }
179
+
180
+ .badge {
181
+ display: inline-block;
182
+ padding: 2px 8px;
183
+ border-radius: var(--radius);
184
+ font-size: 11px;
185
+ font-weight: 600;
186
+ }
187
+
188
+ .badge-context {
189
+ background: var(--accent);
190
+ color: var(--bg-primary);
191
+ animation: pulse 2s ease-in-out infinite;
192
+ }
193
+
194
+ .badge-community {
195
+ color: var(--text-primary);
196
+ }
197
+
198
+ .relations-list {
199
+ list-style: none;
200
+ margin-top: 8px;
201
+ }
202
+
203
+ .relations-list li {
204
+ padding: 4px 0;
205
+ font-size: 12px;
206
+ cursor: pointer;
207
+ color: var(--accent-dim);
208
+ transition: color 100ms;
209
+ }
210
+
211
+ .relations-list li:hover { color: var(--accent); }
212
+
213
+ .relations-list .rel-type {
214
+ color: var(--text-secondary);
215
+ margin-right: 4px;
216
+ }
217
+
218
+ .canvas {
219
+ flex: 1;
220
+ position: relative;
221
+ overflow: hidden;
222
+ }
223
+
224
+ #graph-svg {
225
+ width: 100%;
226
+ height: 100%;
227
+ }
228
+
229
+ .tooltip {
230
+ position: absolute;
231
+ background: var(--bg-surface);
232
+ border: 1px solid var(--border);
233
+ border-radius: var(--radius);
234
+ padding: 8px 12px;
235
+ font-size: 12px;
236
+ pointer-events: none;
237
+ opacity: 0;
238
+ transition: opacity 100ms;
239
+ z-index: 20;
240
+ max-width: 300px;
241
+ }
242
+
243
+ .tooltip.visible { opacity: 1; }
244
+
245
+ @keyframes pulse {
246
+ 0%, 100% { opacity: 1; }
247
+ 50% { opacity: 0.7; }
248
+ }
249
+
250
+ @media (prefers-reduced-motion: reduce) {
251
+ .badge-context { animation: none; }
252
+ .sidebar { transition: none; }
253
+ }
254
+
255
+ @media (max-width: 768px) {
256
+ .sidebar { width: 0; opacity: 0; pointer-events: none; }
257
+ .search-input { width: 120px; }
258
+ .stats { display: none; }
259
+ }
260
+
261
+ *:focus-visible {
262
+ outline: 2px solid var(--accent);
263
+ outline-offset: 2px;
264
+ }
@@ -0,0 +1,76 @@
1
+ import express from "express";
2
+ import { readFileSync, existsSync, writeFileSync } from "fs";
3
+ import { resolve, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ // Project root = where the user ran the command (their project directory)
9
+ const projectRoot = process.env.PMC_PROJECT_ROOT
10
+ ? resolve(process.env.PMC_PROJECT_ROOT)
11
+ : resolve(process.cwd());
12
+
13
+ const GRAPH_PATH = resolve(projectRoot, ".planning/project-memory-context/graph/graph.json");
14
+ const WORKLIST_PATH = resolve(projectRoot, ".planning/project-memory-context/enrichment/worklist.json");
15
+ const TRACKER_PATH = resolve(projectRoot, ".planning/project-memory-context/context-tracker.json");
16
+
17
+ if (!existsSync(resolve(projectRoot, ".planning/project-memory-context"))) {
18
+ console.error(`Error: No PMC data found at ${projectRoot}`);
19
+ console.error(`Run this command from a project with .planning/project-memory-context/`);
20
+ process.exit(1);
21
+ }
22
+
23
+ const app = express();
24
+ const PORT = 3001;
25
+
26
+ app.use(express.static(resolve(__dirname, "public")));
27
+ app.use(express.json());
28
+
29
+ app.get("/api/graph", (req, res) => {
30
+ try {
31
+ const data = JSON.parse(readFileSync(GRAPH_PATH, "utf-8"));
32
+ res.json(data);
33
+ } catch {
34
+ res.status(404).json({ error: "graph.json not found" });
35
+ }
36
+ });
37
+
38
+ app.get("/api/worklist", (req, res) => {
39
+ try {
40
+ const data = JSON.parse(readFileSync(WORKLIST_PATH, "utf-8"));
41
+ res.json(data);
42
+ } catch {
43
+ res.status(404).json({ error: "worklist.json not found" });
44
+ }
45
+ });
46
+
47
+ app.get("/api/context", (req, res) => {
48
+ if (!existsSync(TRACKER_PATH)) {
49
+ return res.json({ activeNodeIds: [] });
50
+ }
51
+ try {
52
+ const data = JSON.parse(readFileSync(TRACKER_PATH, "utf-8"));
53
+ res.json(data);
54
+ } catch {
55
+ res.json({ activeNodeIds: [] });
56
+ }
57
+ });
58
+
59
+ app.post("/api/context", (req, res) => {
60
+ const { add } = req.body;
61
+ if (!Array.isArray(add)) return res.status(400).json({ error: "add must be array" });
62
+ let tracker = { activeNodeIds: [] };
63
+ if (existsSync(TRACKER_PATH)) {
64
+ try { tracker = JSON.parse(readFileSync(TRACKER_PATH, "utf-8")); } catch {}
65
+ }
66
+ const existing = new Set(tracker.activeNodeIds || []);
67
+ add.forEach((id) => existing.add(id));
68
+ tracker.activeNodeIds = [...existing];
69
+ writeFileSync(TRACKER_PATH, JSON.stringify(tracker, null, 2));
70
+ res.json(tracker);
71
+ });
72
+
73
+ app.listen(PORT, () => {
74
+ console.log(`PMC Graph Explorer running at http://localhost:${PORT}`);
75
+ console.log(`Project root: ${projectRoot}`);
76
+ });