klypix-mcp 1.52.0 → 1.53.0

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/README.md CHANGED
@@ -79,7 +79,7 @@ npx klypix-mcp conformance
79
79
 
80
80
  It runs in a temporary fixture and touches nothing else. It checks tool discovery, task memory,
81
81
  truthful peer reporting, overlap surfacing, proactive logging, and in-band delivery of a peer note.
82
- It verifies 12 required coordination behaviours — not the 18 tools, and not the retrieval engine.
82
+ It verifies 12 required coordination behaviours — not the 19 tools, and not the retrieval engine.
83
83
 
84
84
  ---
85
85
 
@@ -383,7 +383,7 @@ The MCP verbs below are what agents call. These are what **you** call:
383
383
 
384
384
  ---
385
385
 
386
- ## The 18 verbs
386
+ ## The 19 verbs
387
387
 
388
388
  | Tool | What it does |
389
389
  |---|---|
@@ -398,6 +398,7 @@ The MCP verbs below are what agents call. These are what **you** call:
398
398
  | `brain_message` | Session-to-session coordination notes (24h TTL, never written into the brain) |
399
399
  | `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing |
400
400
  | `brain_connect` | Find and draw related-but-unlinked cards |
401
+ | `project_map_context` | Read-only, bounded code-graph evidence beside correction-aware brain context; Graphify artifacts are supported but never installed or run |
401
402
  | `canvas_view` | Returns the board as a structured render spec plus a text summary, and declares an MCP Apps (SEP-1865) UI resource |
402
403
  | `read_canvas` | A canvas as markdown (cards, connection graph, `[[links]]`, `#tags`) |
403
404
  | `search_canvases` | Search across canvases by name and content |
@@ -406,7 +407,7 @@ The MCP verbs below are what agents call. These are what **you** call:
406
407
  | `add_to_canvas` | Append cards/connections (positions preserved) |
407
408
  | `list_canvases` | List every `.klypix` in the vault |
408
409
 
409
- Exactly 18, machine-verifiable with `npx klypix-mcp doctor`.
410
+ Exactly 19, machine-verifiable with `npx klypix-mcp doctor`.
410
411
 
411
412
  > **`canvas_view`:** no MCP Apps host has been observed rendering the UI resource yet — there is no
412
413
  > screenshot and no host-level test. Hosts without the extension get clean text, which is the path
@@ -598,7 +599,7 @@ Your `brain.klypix` is yours — it is a plain ZIP and stays readable with or wi
598
599
  Issues and pull requests: [github.com/dahshanlabs/klypix-mcp](https://github.com/dahshanlabs/klypix-mcp).
599
600
  Questions or feedback: [hello@klypix.com](mailto:hello@klypix.com).
600
601
 
601
- The repository carries 38 test files, 34 of them in the `npm test` chain, covering the presence
602
+ The repository carries 49 test files, 45 of them in the `npm test` chain, covering the presence
602
603
  lane and its cross-machine relay, the Context Gateway, supervisor hot-swap, auto-update, retrieval
603
604
  quality, decay, challenge, lenses, the format guard, the git tools (including a real `git merge`
604
605
  through the merge driver), uninstall, and conformance. Run them with `npm test` from a clone — they
@@ -226,7 +226,7 @@ const flatten = (code) => code
226
226
  .replace(/\.\.\/src\/klypix-(core|format)\.mjs/g, './klypix-$1.mjs')
227
227
  // brain-doctor + agent-rules (the server's lazy `import('../src/brain-doctor.mjs')`
228
228
  // for the brain_doctor tool) → flat sibling refs in the runtime layout.
229
- .replace(/\.\.\/src\/(brain-doctor|agent-rules|mcp-presence|mcp-supervisor|mcp-auto-update|semantic-memory|runtime-inspector)\.mjs/g, './$1.mjs')
229
+ .replace(/\.\.\/src\/(brain-doctor|agent-rules|mcp-presence|mcp-supervisor|mcp-auto-update|semantic-memory|runtime-inspector|project-graph)\.mjs/g, './$1.mjs')
230
230
  .replace(/klypix-worker\.mjs/g, 'klypix-mcp-worker.mjs')
231
231
  .replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
232
232
 
@@ -293,7 +293,7 @@ try {
293
293
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
294
294
  // file must never get a JS-comment banner) beside the flat server, which
295
295
  // resolves it via its ./canvas-view-app.html candidate path.
296
- for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
296
+ for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
297
297
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
298
298
  }
299
299
  for (const [src, dst] of [
@@ -30,6 +30,7 @@ import {
30
30
  opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
31
31
  opBrainTaskContext,
32
32
  } from '../src/klypix-core.mjs';
33
+ import { projectGraphContextMarkdown, queryProjectGraph } from '../src/project-graph.mjs';
33
34
  import { auditProject, compactAgentsBrief, linkProject, mcpServerEntry } from '../src/agent-rules.mjs';
34
35
  import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS } from '../src/mcp-presence.mjs';
35
36
  import {
@@ -201,6 +202,7 @@ const toContent = (r) => {
201
202
  ? { type: 'image', data: b.data, mimeType: b.mime }
202
203
  : { type: 'text', text: b.text });
203
204
  const result = r.isError ? { content, isError: true } : { content };
205
+ if (r.structured && typeof r.structured === 'object') result.structuredContent = r.structured;
204
206
  return mcpPresence.decorateToolResult(result);
205
207
  };
206
208
 
@@ -242,6 +244,67 @@ server.registerTool('brain_ask', {
242
244
  },
243
245
  }, async ({ question, canvas, as_of, k }) => toContent(await opBrainAsk({ vault: mcpPresence.vault, canvas, question, as_of, k, log })));
244
246
 
247
+ server.registerTool('project_map_context', {
248
+ title: 'Project Map context - current code structure plus brain decisions',
249
+ description: 'Read-only combined context for a coding question. It queries a provider-neutral, bounded view of the generated project graph (Graphify graphify-out/graph.json is supported first) and places that CURRENT CODE evidence beside fast, correction-aware KLYPIX brain cards (decisions and rationale). KLYPIX never installs or runs Graphify and never copies graph nodes into brain.klypix. Missing graph artifacts degrade cleanly to brain-only context. Source-file anchors are accepted only when they stay inside the declared project root. Set deep_history:true only when superseded history is genuinely needed; the default never loads the local embedding model.',
250
+ annotations: {
251
+ destructiveHint: false,
252
+ idempotentHint: true,
253
+ openWorldHint: false,
254
+ },
255
+ inputSchema: {
256
+ question: z.string().min(1).describe('Question or code concept to ground in both current structure and project memory.'),
257
+ project: z.string().optional().describe('Absolute project root. Defaults to this MCP connection\'s configured project/vault.'),
258
+ graph_path: z.string().optional().describe('Optional project-relative graph JSON path. Defaults to graphify-out/graph.json and may not escape the project root.'),
259
+ depth: z.number().optional().describe('Relationship hops around the best code matches (0-3, default 1).'),
260
+ max_nodes: z.number().optional().describe('Maximum code nodes returned (default 60, capped 200).'),
261
+ k: z.number().optional().describe('Maximum brain cards returned (default 8; fast mode caps 8, deep history caps 20).'),
262
+ deep_history: z.boolean().optional().describe('false (default) uses the sub-second lexical-fast correction-aware path; true opts into whole-brain semantic/history retrieval, which may cold-load the local model.'),
263
+ },
264
+ }, async ({ question, project, graph_path, depth, max_nodes, k, deep_history }) => {
265
+ let graphResult;
266
+ let graphMarkdown;
267
+ try {
268
+ graphResult = queryProjectGraph({
269
+ project: project || mcpPresence.vault,
270
+ graphPath: graph_path,
271
+ query: question,
272
+ depth,
273
+ maxNodes: max_nodes,
274
+ });
275
+ graphMarkdown = projectGraphContextMarkdown(graphResult);
276
+ } catch (error) {
277
+ graphResult = { schemaVersion: 1, status: 'invalid', error: error?.message || String(error) };
278
+ graphMarkdown = `# Project Map\n\nThe generated graph could not be read safely: ${graphResult.error}`;
279
+ }
280
+ const graphFiles = Array.isArray(graphResult?.nodes)
281
+ ? [...new Set(graphResult.nodes.map(node => node.sourceFile).filter(Boolean))].slice(0, 20)
282
+ : [];
283
+ const brainResult = deep_history
284
+ ? await opBrainAsk({
285
+ vault: mcpPresence.vault,
286
+ question,
287
+ k: Math.max(1, Math.min(20, Number(k) || 8)),
288
+ log,
289
+ })
290
+ : await opBrainTaskContext({
291
+ vault: mcpPresence.vault,
292
+ intent: question,
293
+ files: graphFiles,
294
+ k: Math.max(1, Math.min(8, Number(k) || 8)),
295
+ budgetChars: 4_500,
296
+ });
297
+ const brainMarkdown = brainResult.blocks
298
+ .filter(block => block.kind === 'text')
299
+ .map(block => block.text)
300
+ .join('\n\n');
301
+ return toContent({
302
+ blocks: [{ kind: 'text', text: `${graphMarkdown}\n\n---\n\n# Project Brain - ${deep_history ? 'decisions and history' : 'current decisions and corrections'}\n\n${brainMarkdown}` }],
303
+ isError: brainResult.isError,
304
+ structured: { projectGraph: graphResult, brainContext: brainResult.context || null, deepHistory: deep_history === true },
305
+ });
306
+ });
307
+
245
308
  server.registerTool('brain_challenge', {
246
309
  title: 'Challenge a decision against the brain (argue back with receipts)',
247
310
  description: 'BEFORE committing to a significant decision, ask the brain to ARGUE BACK: prior decisions that deterministically contradict the claim (correction-cue / opposite-polarity evidence — never mere topical similarity), 🛠 standing rules that dispute it, approaches tried before and REVERSED (with the correction/successor as the receipt), and open questions it collides with. Candidates, not verdicts — silence means "no deterministic contradiction signal", not verified consistency. Cards captured by a DIFFERENT agent are flagged so you coordinate instead of overriding. Dismiss a confirmed-false pair (after capturing the claim) with brain_connect pairs + relationship:"not_contradiction". Defaults to the project brain ("brain").',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.52.0",
3
+ "version": "1.53.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -56,6 +56,7 @@
56
56
  "./supervisor": "./src/mcp-supervisor.mjs",
57
57
  "./runtime-inspector": "./src/runtime-inspector.mjs",
58
58
  "./runtime": "./src/runtime-inspector.mjs",
59
+ "./project-graph": "./src/project-graph.mjs",
59
60
  "./auto-update": "./src/mcp-auto-update.mjs"
60
61
  },
61
62
  "files": [
@@ -73,19 +74,20 @@
73
74
  "node": ">=18"
74
75
  },
75
76
  "scripts": {
76
- "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
77
+ "test:project-graph": "node test/project-graph.mjs",
78
+ "test": "node test/project-graph.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
77
79
  "test:memory": "node test/memory-runtime.mjs",
78
80
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
79
81
  "runtime": "node bin/klypix-runtime.mjs"
80
82
  },
81
83
  "dependencies": {
82
- "@modelcontextprotocol/ext-apps": "^1.7.4",
83
- "@modelcontextprotocol/sdk": "^1.29.0",
84
+ "@modelcontextprotocol/ext-apps": "^1.7.5",
85
+ "@modelcontextprotocol/sdk": "^1.30.0",
84
86
  "fractional-indexing": "^3.2.0",
85
87
  "jszip": "^3.10.1",
86
88
  "zod": "^4.3.6"
87
89
  },
88
- "optionalDependencies": {
90
+ "devDependencies": {
89
91
  "@huggingface/transformers": "^4.2.0"
90
92
  }
91
93
  }
@@ -0,0 +1,340 @@
1
+ // Provider-neutral, read-only project-graph adapter.
2
+ //
3
+ // Graphify is the first supported producer, but callers only see the stable
4
+ // KLYPIX shape below. Generated graph artifacts remain disposable local cache:
5
+ // this module never runs Graphify, edits source, or writes into brain.klypix.
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+
10
+ export const PROJECT_GRAPH_SCHEMA_VERSION = 1;
11
+ export const DEFAULT_PROJECT_GRAPH = 'graphify-out/graph.json';
12
+ export const DEFAULT_PROJECT_GRAPH_HTML = 'graphify-out/graph.html';
13
+ export const DEFAULT_PROJECT_GRAPH_REPORT = 'graphify-out/GRAPH_REPORT.md';
14
+
15
+ const MAX_GRAPH_BYTES = 64 * 1024 * 1024;
16
+ const MAX_GRAPH_NODES = 100_000;
17
+ const MAX_GRAPH_EDGES = 500_000;
18
+ const MAX_QUERY_NODES = 200;
19
+ const MAX_QUERY_EDGES = 600;
20
+ const CACHE_TTL_MS = 60_000;
21
+
22
+ let cache = null;
23
+
24
+ const clamp = (value, min, max, fallback) => {
25
+ const n = Number(value);
26
+ return Number.isFinite(n) ? Math.max(min, Math.min(max, Math.floor(n))) : fallback;
27
+ };
28
+
29
+ const normalizedCase = (value) => process.platform === 'win32'
30
+ ? value.toLocaleLowerCase('en-US')
31
+ : value;
32
+
33
+ function isInside(root, candidate) {
34
+ const relative = path.relative(root, candidate);
35
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
36
+ }
37
+
38
+ function realRoot(project) {
39
+ const resolved = path.resolve(String(project || process.cwd()));
40
+ const real = fs.realpathSync(resolved);
41
+ const stat = fs.statSync(real);
42
+ if (!stat.isDirectory()) throw new Error(`Project root is not a directory: ${resolved}`);
43
+ return real;
44
+ }
45
+
46
+ function resolveInsideProject(projectRoot, requested, fallback) {
47
+ const candidate = path.resolve(projectRoot, String(requested || fallback));
48
+ if (!isInside(normalizedCase(projectRoot), normalizedCase(candidate))) {
49
+ throw new Error('Project graph path must stay inside the project root.');
50
+ }
51
+ if (!fs.existsSync(candidate)) return candidate;
52
+ const real = fs.realpathSync(candidate);
53
+ if (!isInside(normalizedCase(projectRoot), normalizedCase(real))) {
54
+ throw new Error('Project graph path resolves outside the project root.');
55
+ }
56
+ return real;
57
+ }
58
+
59
+ function relativePortable(root, file) {
60
+ return path.relative(root, file).split(path.sep).join('/');
61
+ }
62
+
63
+ function safeSourceFile(root, value) {
64
+ if (typeof value !== 'string' || !value.trim()) return null;
65
+ const original = value.trim().replace(/\\/g, '/');
66
+ const foreignAbsolute = /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
67
+ if (foreignAbsolute && !path.isAbsolute(value)) return null;
68
+ const candidate = path.isAbsolute(value)
69
+ ? path.resolve(value)
70
+ : path.resolve(root, ...original.split('/'));
71
+ if (!isInside(normalizedCase(root), normalizedCase(candidate))) return null;
72
+ const relative = relativePortable(root, candidate);
73
+ return relative && relative !== '.' ? relative : null;
74
+ }
75
+
76
+ function shortString(value, max = 1_000) {
77
+ if (value == null) return '';
78
+ return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '').slice(0, max);
79
+ }
80
+
81
+ function normalizeConfidence(edge) {
82
+ const raw = shortString(edge?.confidence, 32).toUpperCase();
83
+ if (raw === 'EXTRACTED' || raw === 'INFERRED' || raw === 'AMBIGUOUS') return raw;
84
+ return edge?.confidence_score == null ? 'EXTRACTED' : 'INFERRED';
85
+ }
86
+
87
+ function normalizeGraph(raw, projectRoot, artifact) {
88
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
89
+ throw new Error('graph.json must contain a JSON object.');
90
+ }
91
+ const rawNodes = Array.isArray(raw.nodes) ? raw.nodes : null;
92
+ const rawEdges = Array.isArray(raw.edges) ? raw.edges : Array.isArray(raw.links) ? raw.links : null;
93
+ if (!rawNodes || !rawEdges) {
94
+ throw new Error('Unsupported project graph: expected NetworkX node-link arrays named nodes and edges (or links).');
95
+ }
96
+ if (rawNodes.length > MAX_GRAPH_NODES || rawEdges.length > MAX_GRAPH_EDGES) {
97
+ throw new Error(`Project graph exceeds the safe in-process limit (${MAX_GRAPH_NODES.toLocaleString()} nodes / ${MAX_GRAPH_EDGES.toLocaleString()} edges).`);
98
+ }
99
+
100
+ const nodes = [];
101
+ const byId = new Map();
102
+ let unsafeSourcePaths = 0;
103
+ let duplicateNodes = 0;
104
+ for (const input of rawNodes) {
105
+ if (!input || typeof input !== 'object') continue;
106
+ const id = shortString(input.id, 512);
107
+ if (!id) continue;
108
+ if (byId.has(id)) { duplicateNodes++; continue; }
109
+ const originalSource = input.source_file ?? input.path;
110
+ const sourceFile = safeSourceFile(projectRoot, originalSource);
111
+ if (originalSource && !sourceFile) unsafeSourcePaths++;
112
+ const node = {
113
+ id,
114
+ label: shortString(input.label ?? input.name ?? id, 1_000),
115
+ kind: shortString(input.kind ?? input.type ?? input.file_type ?? 'symbol', 120),
116
+ sourceFile,
117
+ sourceLocation: shortString(input.source_location ?? input.location, 240) || null,
118
+ community: shortString(input.community, 160) || null,
119
+ origin: shortString(input._origin ?? input.origin, 160) || null,
120
+ };
121
+ byId.set(id, node);
122
+ nodes.push(node);
123
+ }
124
+
125
+ const edges = [];
126
+ const adjacency = new Map(nodes.map(node => [node.id, []]));
127
+ let danglingEdges = 0;
128
+ for (const input of rawEdges) {
129
+ if (!input || typeof input !== 'object') continue;
130
+ const source = shortString(input.source, 512);
131
+ const target = shortString(input.target, 512);
132
+ if (!source || !target || !byId.has(source) || !byId.has(target)) {
133
+ danglingEdges++;
134
+ continue;
135
+ }
136
+ const edge = {
137
+ source,
138
+ target,
139
+ relation: shortString(input.relation ?? input.type ?? 'related_to', 160),
140
+ confidence: normalizeConfidence(input),
141
+ };
142
+ const index = edges.length;
143
+ edges.push(edge);
144
+ adjacency.get(source).push({ nodeId: target, edgeIndex: index });
145
+ if (source !== target) adjacency.get(target).push({ nodeId: source, edgeIndex: index });
146
+ }
147
+
148
+ return {
149
+ schemaVersion: PROJECT_GRAPH_SCHEMA_VERSION,
150
+ provider: 'graphify',
151
+ artifact,
152
+ nodes,
153
+ edges,
154
+ byId,
155
+ adjacency,
156
+ diagnostics: {
157
+ unsafeSourcePaths,
158
+ duplicateNodes,
159
+ danglingEdges,
160
+ inputNodes: rawNodes.length,
161
+ inputEdges: rawEdges.length,
162
+ acceptedNodes: nodes.length,
163
+ acceptedEdges: edges.length,
164
+ },
165
+ };
166
+ }
167
+
168
+ function cacheLimitBytes() {
169
+ const configured = Number(process.env.KLYPIX_PROJECT_GRAPH_CACHE_MB);
170
+ const mb = Number.isFinite(configured) ? Math.max(0, Math.min(64, configured)) : 12;
171
+ return Math.floor(mb * 1024 * 1024);
172
+ }
173
+
174
+ export function discoverProjectGraph({ project, graphPath } = {}) {
175
+ const projectRoot = realRoot(project);
176
+ const filePath = resolveInsideProject(projectRoot, graphPath, DEFAULT_PROJECT_GRAPH);
177
+ const artifactDir = path.dirname(filePath);
178
+ const htmlPath = resolveInsideProject(projectRoot, path.join(artifactDir, 'graph.html'), DEFAULT_PROJECT_GRAPH_HTML);
179
+ const reportPath = resolveInsideProject(projectRoot, path.join(artifactDir, 'GRAPH_REPORT.md'), DEFAULT_PROJECT_GRAPH_REPORT);
180
+ const provider = path.basename(artifactDir).toLocaleLowerCase('en-US') === 'graphify-out' ? 'graphify' : 'project-graph';
181
+ if (!fs.existsSync(filePath)) {
182
+ return {
183
+ schemaVersion: PROJECT_GRAPH_SCHEMA_VERSION,
184
+ provider,
185
+ status: 'missing',
186
+ projectRoot,
187
+ filePath,
188
+ artifact: {
189
+ graphJson: relativePortable(projectRoot, filePath),
190
+ graphHtml: fs.existsSync(htmlPath) ? relativePortable(projectRoot, htmlPath) : null,
191
+ report: fs.existsSync(reportPath) ? relativePortable(projectRoot, reportPath) : null,
192
+ },
193
+ };
194
+ }
195
+ const stat = fs.statSync(filePath);
196
+ if (!stat.isFile()) throw new Error('Project graph path is not a file.');
197
+ if (stat.size > MAX_GRAPH_BYTES) {
198
+ throw new Error(`Project graph is ${(stat.size / 1024 / 1024).toFixed(1)} MB; the safe read limit is ${MAX_GRAPH_BYTES / 1024 / 1024} MB.`);
199
+ }
200
+ return {
201
+ schemaVersion: PROJECT_GRAPH_SCHEMA_VERSION,
202
+ provider,
203
+ status: 'ready',
204
+ projectRoot,
205
+ filePath,
206
+ artifact: {
207
+ graphJson: relativePortable(projectRoot, filePath),
208
+ graphHtml: fs.existsSync(htmlPath) ? relativePortable(projectRoot, htmlPath) : null,
209
+ report: fs.existsSync(reportPath) ? relativePortable(projectRoot, reportPath) : null,
210
+ sizeBytes: stat.size,
211
+ modifiedAt: stat.mtime.toISOString(),
212
+ },
213
+ stat,
214
+ };
215
+ }
216
+
217
+ export function loadProjectGraph(options = {}) {
218
+ const found = discoverProjectGraph(options);
219
+ if (found.status !== 'ready') return { ...found, graph: null };
220
+ const key = `${found.filePath}:${found.stat.size}:${found.stat.mtimeMs}`;
221
+ if (cache?.key === key && Date.now() - cache.loadedAt < CACHE_TTL_MS) {
222
+ return { ...found, graph: cache.graph, cache: 'hit' };
223
+ }
224
+ const json = fs.readFileSync(found.filePath, 'utf8');
225
+ let raw;
226
+ try { raw = JSON.parse(json); }
227
+ catch (error) { throw new Error(`Project graph is not valid JSON: ${error?.message || error}`); }
228
+ const graph = normalizeGraph(raw, found.projectRoot, found.artifact);
229
+ graph.provider = found.provider;
230
+ if (found.stat.size <= cacheLimitBytes()) cache = { key, graph, loadedAt: Date.now() };
231
+ else cache = null;
232
+ return { ...found, graph, cache: 'miss' };
233
+ }
234
+
235
+ function queryTokens(value) {
236
+ return [...new Set(String(value || '').toLocaleLowerCase('en-US').match(/[\p{L}\p{N}_./:@#$-]+/gu) || [])]
237
+ .filter(token => token.length > 1)
238
+ .slice(0, 32);
239
+ }
240
+
241
+ function scoreNode(node, query, tokens, degree) {
242
+ const label = node.label.toLocaleLowerCase('en-US');
243
+ const source = (node.sourceFile || '').toLocaleLowerCase('en-US');
244
+ const haystack = `${label} ${source} ${node.kind} ${node.community || ''}`.toLocaleLowerCase('en-US');
245
+ let score = Math.min(3, Math.log2(1 + degree));
246
+ if (query && label === query) score += 60;
247
+ else if (query && source === query) score += 55;
248
+ else if (query && label.includes(query)) score += 28;
249
+ else if (query && source.includes(query)) score += 24;
250
+ for (const token of tokens) {
251
+ if (label === token) score += 14;
252
+ else if (label.includes(token)) score += 8;
253
+ if (source.includes(token)) score += 7;
254
+ if (haystack.includes(token)) score += 2;
255
+ }
256
+ return score;
257
+ }
258
+
259
+ export function queryProjectGraph({ project, graphPath, query = '', depth = 1, maxNodes = 60 } = {}) {
260
+ const loaded = loadProjectGraph({ project, graphPath });
261
+ if (!loaded.graph) return { ...loaded, query: String(query || ''), nodes: [], edges: [] };
262
+ const graph = loaded.graph;
263
+ const normalizedQuery = String(query || '').trim().toLocaleLowerCase('en-US');
264
+ const tokens = queryTokens(query);
265
+ const cap = clamp(maxNodes, 1, MAX_QUERY_NODES, 60);
266
+ const hopLimit = clamp(depth, 0, 3, 1);
267
+ const ranked = graph.nodes
268
+ .map(node => ({ node, score: scoreNode(node, normalizedQuery, tokens, graph.adjacency.get(node.id)?.length || 0) }))
269
+ .filter(hit => !tokens.length || hit.score > 0)
270
+ .sort((a, b) => b.score - a.score || a.node.label.localeCompare(b.node.label));
271
+ const seeds = ranked.slice(0, Math.min(tokens.length ? 12 : cap, cap));
272
+ const selected = new Set();
273
+ const queue = seeds.map(hit => ({ id: hit.node.id, depth: 0 }));
274
+ while (queue.length && selected.size < cap) {
275
+ const current = queue.shift();
276
+ if (selected.has(current.id)) continue;
277
+ selected.add(current.id);
278
+ if (current.depth >= hopLimit) continue;
279
+ const neighbors = graph.adjacency.get(current.id) || [];
280
+ for (const neighbor of neighbors) {
281
+ if (!selected.has(neighbor.nodeId) && selected.size + queue.length < cap * 3) {
282
+ queue.push({ id: neighbor.nodeId, depth: current.depth + 1 });
283
+ }
284
+ }
285
+ }
286
+ const nodeScore = new Map(ranked.map(hit => [hit.node.id, hit.score]));
287
+ const nodes = [...selected]
288
+ .map(id => graph.byId.get(id))
289
+ .filter(Boolean)
290
+ .sort((a, b) => (nodeScore.get(b.id) || 0) - (nodeScore.get(a.id) || 0));
291
+ const edges = graph.edges
292
+ .filter(edge => selected.has(edge.source) && selected.has(edge.target))
293
+ .slice(0, MAX_QUERY_EDGES);
294
+ return {
295
+ schemaVersion: PROJECT_GRAPH_SCHEMA_VERSION,
296
+ provider: graph.provider,
297
+ status: 'ready',
298
+ query: String(query || ''),
299
+ depth: hopLimit,
300
+ artifact: graph.artifact,
301
+ counts: {
302
+ graphNodes: graph.nodes.length,
303
+ graphEdges: graph.edges.length,
304
+ returnedNodes: nodes.length,
305
+ returnedEdges: edges.length,
306
+ },
307
+ diagnostics: graph.diagnostics,
308
+ nodes,
309
+ edges,
310
+ cache: loaded.cache,
311
+ };
312
+ }
313
+
314
+ export function projectGraphContextMarkdown(result) {
315
+ if (result.status === 'missing') {
316
+ return `# Project Map\n\nNo supported project graph was found at \`${result.artifact.graphJson}\`. KLYPIX did not install or run a provider. Generate the artifact with Graphify, then retry.`;
317
+ }
318
+ const lines = result.nodes.map(node => {
319
+ const where = node.sourceFile ? ` — \`${node.sourceFile}${node.sourceLocation ? `:${node.sourceLocation}` : ''}\`` : '';
320
+ return `- **${node.label || node.id}** (${node.kind || 'symbol'})${where} · id \`${node.id}\``;
321
+ });
322
+ const edgeLines = result.edges.slice(0, 80).map(edge =>
323
+ `- \`${edge.source}\` —${edge.relation}→ \`${edge.target}\` [${edge.confidence}]`);
324
+ const warnings = [];
325
+ if (result.diagnostics?.unsafeSourcePaths) warnings.push(`${result.diagnostics.unsafeSourcePaths} unsafe/out-of-root source path(s) were withheld`);
326
+ if (result.diagnostics?.danglingEdges) warnings.push(`${result.diagnostics.danglingEdges} dangling edge(s) were ignored`);
327
+ return [
328
+ '# Project Map — code evidence',
329
+ `Provider artifact: \`${result.artifact.graphJson}\` · ${result.counts.graphNodes.toLocaleString()} nodes · ${result.counts.graphEdges.toLocaleString()} edges · query \`${result.query || '(overview)'}\`.`,
330
+ warnings.length ? `Safety note: ${warnings.join('; ')}.` : '',
331
+ '## Relevant code nodes',
332
+ lines.join('\n') || '_No matching code nodes._',
333
+ edgeLines.length ? '## Relationships\n' + edgeLines.join('\n') : '',
334
+ '_Graph evidence describes the current generated artifact; brain cards below remain the source for decisions, corrections, and project history._',
335
+ ].filter(Boolean).join('\n\n');
336
+ }
337
+
338
+ export function clearProjectGraphCache() {
339
+ cache = null;
340
+ }