nexus-prime 7.3.0 → 7.4.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/dist/cli/install-wizard.js +4 -30
- package/dist/cli.js +10 -53
- package/dist/dashboard/app/views/knowledge.js +8 -4
- package/dist/dashboard/app/views/repo.js +113 -24
- package/dist/dashboard/routes/graph.js +218 -88
- package/dist/dashboard/server.js +1 -1
- package/dist/engines/instruction-gateway.js +9 -5
- package/dist/install/claude-code-hooks.d.ts +33 -0
- package/dist/install/claude-code-hooks.js +96 -0
- package/dist/install/state-locator.js +2 -0
- package/dist/phantom/runtime.js +2 -2
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ import { runPostinstallCleanup } from '../postinstall/cleanup.js';
|
|
|
17
17
|
import { openBrowser, printHandoffBanner, withSpinner } from './interactive-setup.js';
|
|
18
18
|
import { appendToManifest, detectArchitectureUpgrade, loadManifest, recordArchitectureGeneration, recordPath, recordRegistration, recordSetupMarker, INSTALL_ARCH_GENERATION, } from '../install/manifest.js';
|
|
19
19
|
import { enumerateNgramArchives } from '../install/state-locator.js';
|
|
20
|
+
import { NEXUS_HOOK_COMMAND_MARKER, writeNexusClaudeCodeHooks } from '../install/claude-code-hooks.js';
|
|
20
21
|
/** Compute sha256 of a file's content. Returns null if the file cannot be read. */
|
|
21
22
|
export function computeFileHash(filePath) {
|
|
22
23
|
try {
|
|
@@ -148,35 +149,8 @@ function _nexusMcpEntry(workspaceRoot) {
|
|
|
148
149
|
*/
|
|
149
150
|
function _writeClaudeCodeHooks(workspaceRoot) {
|
|
150
151
|
const settingsPath = join(workspaceRoot, '.claude', 'settings.json');
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
existing = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
155
|
-
}
|
|
156
|
-
catch { /* start fresh if unreadable */ }
|
|
157
|
-
}
|
|
158
|
-
const NEXUS_CMD_MARKER = 'nexus-prime hook';
|
|
159
|
-
const nexusHooks = {
|
|
160
|
-
UserPromptSubmit: [{ matcher: '', hooks: [{ type: 'command', command: 'nexus-prime hook bootstrap' }] }],
|
|
161
|
-
PreToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [{ type: 'command', command: 'nexus-prime hook mindkit' }] }],
|
|
162
|
-
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit|Bash', hooks: [{ type: 'command', command: 'nexus-prime hook memory' }] }],
|
|
163
|
-
Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'nexus-prime hook session-dna' }] }],
|
|
164
|
-
};
|
|
165
|
-
const existingHooks = (existing.hooks && typeof existing.hooks === 'object' && !Array.isArray(existing.hooks))
|
|
166
|
-
? existing.hooks
|
|
167
|
-
: {};
|
|
168
|
-
for (const [event, entries] of Object.entries(nexusHooks)) {
|
|
169
|
-
const current = Array.isArray(existingHooks[event]) ? existingHooks[event] : [];
|
|
170
|
-
// Strip stale nexus-prime hook entries then append fresh ones
|
|
171
|
-
const filtered = current.filter((e) => typeof e === 'object' && e !== null &&
|
|
172
|
-
!(Array.isArray(e.hooks) &&
|
|
173
|
-
e.hooks.some((h) => typeof h === 'object' && h !== null && typeof h.command === 'string' &&
|
|
174
|
-
h.command.includes(NEXUS_CMD_MARKER))));
|
|
175
|
-
existingHooks[event] = [...filtered, ...entries];
|
|
176
|
-
}
|
|
177
|
-
existing.hooks = existingHooks;
|
|
178
|
-
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
179
|
-
writeFileSync(settingsPath, JSON.stringify(existing, null, 2), 'utf8');
|
|
152
|
+
// Single source of truth: hook spec + dedup logic live in install/claude-code-hooks.ts.
|
|
153
|
+
writeNexusClaudeCodeHooks(settingsPath);
|
|
180
154
|
const isHome = workspaceRoot === homedir();
|
|
181
155
|
appendToManifest((m) => {
|
|
182
156
|
const withPath = recordPath(m, { path: settingsPath, kind: 'file', scope: 'state' });
|
|
@@ -184,7 +158,7 @@ function _writeClaudeCodeHooks(workspaceRoot) {
|
|
|
184
158
|
target: isHome ? 'claude-home' : 'claude-workspace',
|
|
185
159
|
filePath: settingsPath,
|
|
186
160
|
jsonPath: ['hooks'],
|
|
187
|
-
entryMarker:
|
|
161
|
+
entryMarker: NEXUS_HOOK_COMMAND_MARKER,
|
|
188
162
|
});
|
|
189
163
|
});
|
|
190
164
|
}
|
package/dist/cli.js
CHANGED
|
@@ -38,67 +38,24 @@ import { runUninstall } from './cli/uninstall.js';
|
|
|
38
38
|
import { runCleanup } from './cli/cleanup.js';
|
|
39
39
|
import { runDoctorStorage } from './cli/doctor-storage.js';
|
|
40
40
|
const tokenEngine = new TokenSupremacyEngine();
|
|
41
|
+
import { getNexusHookSpec, writeNexusClaudeCodeHooks, } from './install/claude-code-hooks.js';
|
|
41
42
|
/**
|
|
42
43
|
* Write (or merge) nexus-prime Claude Code hook entries into ~/.claude/settings.json.
|
|
43
|
-
* Idempotent
|
|
44
|
+
* Idempotent — delegates to the shared writer in install/claude-code-hooks.ts
|
|
45
|
+
* so cli.ts and install-wizard.ts cannot drift on the hook spec or dedup logic.
|
|
44
46
|
*/
|
|
45
47
|
function writeClaudeCodeHooks(settingsPath, dryRun) {
|
|
46
|
-
|
|
47
|
-
if (existsSync(settingsPath)) {
|
|
48
|
-
try {
|
|
49
|
-
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
settings = {};
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
const hooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
|
|
56
|
-
? { ...settings.hooks }
|
|
57
|
-
: {};
|
|
58
|
-
const nexusHooks = {
|
|
59
|
-
UserPromptSubmit: [
|
|
60
|
-
{ hooks: [{ type: 'command', command: 'nexus-prime hook bootstrap', timeout: 30 }] },
|
|
61
|
-
],
|
|
62
|
-
PreToolUse: [
|
|
63
|
-
{
|
|
64
|
-
matcher: 'Edit|Write|MultiEdit',
|
|
65
|
-
hooks: [{ type: 'command', command: 'nexus-prime hook mindkit', timeout: 10 }],
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
PostToolUse: [
|
|
69
|
-
{
|
|
70
|
-
matcher: 'Edit|Write|MultiEdit|Bash',
|
|
71
|
-
hooks: [{ type: 'command', command: 'nexus-prime hook memory', timeout: 10 }],
|
|
72
|
-
},
|
|
73
|
-
],
|
|
74
|
-
Stop: [
|
|
75
|
-
{ hooks: [{ type: 'command', command: 'nexus-prime hook session-dna', timeout: 60 }] },
|
|
76
|
-
],
|
|
77
|
-
};
|
|
78
|
-
for (const [event, entries] of Object.entries(nexusHooks)) {
|
|
79
|
-
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
80
|
-
// Remove stale nexus-prime hook entries before re-adding
|
|
81
|
-
const filtered = existing.filter((entry) => {
|
|
82
|
-
if (!entry || typeof entry !== 'object')
|
|
83
|
-
return true;
|
|
84
|
-
const hooksList = entry.hooks;
|
|
85
|
-
if (!Array.isArray(hooksList))
|
|
86
|
-
return true;
|
|
87
|
-
return !hooksList.some((h) => h && typeof h.command === 'string' &&
|
|
88
|
-
h.command.startsWith('nexus-prime hook'));
|
|
89
|
-
});
|
|
90
|
-
hooks[event] = [...filtered, ...entries];
|
|
91
|
-
}
|
|
92
|
-
settings.hooks = hooks;
|
|
48
|
+
const result = writeNexusClaudeCodeHooks(settingsPath, { dryRun });
|
|
93
49
|
if (dryRun) {
|
|
94
50
|
console.log('Hooks preview (would write to ~/.claude/settings.json):');
|
|
95
|
-
console.log(JSON.stringify(
|
|
51
|
+
console.log(JSON.stringify(getNexusHookSpec(), null, 2));
|
|
96
52
|
return;
|
|
97
53
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
54
|
+
console.log(` Hooks: ${settingsPath}${result.changed ? '' : ' (no change)'}`);
|
|
55
|
+
result.events.forEach((ev) => {
|
|
56
|
+
const tail = ev.toLowerCase().replace('tooluse', '').trim() || ev;
|
|
57
|
+
console.log(` ${ev} → nexus-prime hook ${tail}`);
|
|
58
|
+
});
|
|
102
59
|
}
|
|
103
60
|
const program = new Command();
|
|
104
61
|
let nexus = null;
|
|
@@ -228,11 +228,15 @@ function renderTopologyStats() {
|
|
|
228
228
|
$('topo-build-btn')?.addEventListener('click', _buildTopology);
|
|
229
229
|
return;
|
|
230
230
|
}
|
|
231
|
+
const m = t.meta || {};
|
|
231
232
|
const rows=[
|
|
232
|
-
['Nodes',
|
|
233
|
-
['Edges',
|
|
234
|
-
['Files',
|
|
235
|
-
['
|
|
233
|
+
['Nodes', fmtNum(t.nodes?.length)],
|
|
234
|
+
['Edges', fmtNum(t.edges?.length)],
|
|
235
|
+
['Files', fmtNum(m.fileCount ?? t.nodes?.filter(n=>n.type==='file').length)],
|
|
236
|
+
['Modules', fmtNum(m.moduleCount ?? t.nodes?.filter(n=>n.type==='module').length)],
|
|
237
|
+
['Memories', fmtNum(m.memoryCount ?? t.nodes?.filter(n=>n.type==='memory').length)],
|
|
238
|
+
['Concepts', fmtNum(m.conceptCount ?? t.nodes?.filter(n=>n.type==='concept').length)],
|
|
239
|
+
['Source', t.provenance?.graphSource || m.graphSource || '—'],
|
|
236
240
|
];
|
|
237
241
|
el.innerHTML=rows.map(([k,v])=>`<div class="row"><span class="row-k">${esc(k)}</span><span class="row-v">${esc(v)}</span></div>`).join('');
|
|
238
242
|
}
|
|
@@ -81,12 +81,14 @@ function _renderMeta() {
|
|
|
81
81
|
el.innerHTML = [
|
|
82
82
|
`<span>${esc(_topo.repoName || 'workspace')}</span>`,
|
|
83
83
|
`<span>${fmtNum(meta.fileCount ?? 0)} files</span>`,
|
|
84
|
+
meta.moduleCount ? `<span>${fmtNum(meta.moduleCount)} modules</span>` : null,
|
|
85
|
+
meta.memoryCount ? `<span>${fmtNum(meta.memoryCount)} memories</span>` : null,
|
|
86
|
+
meta.conceptCount ? `<span>${fmtNum(meta.conceptCount)} concepts</span>` : null,
|
|
84
87
|
`<span>${fmtNum(meta.edgeCount ?? 0)} edges</span>`,
|
|
85
88
|
`<span>${esc(provenance.graphSource || meta.graphSource || 'unknown-source')}</span>`,
|
|
86
|
-
`<span>${esc(provenance.freshness || meta.freshness || 'n/a')}</span>`,
|
|
87
89
|
`<span>${esc(buildLabel)}</span>`,
|
|
88
90
|
`<span>${esc(generatedAt)}</span>`,
|
|
89
|
-
].join(' · ');
|
|
91
|
+
].filter(Boolean).join(' · ');
|
|
90
92
|
_syncBuildControls();
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -129,18 +131,18 @@ function _renderGraph() {
|
|
|
129
131
|
if (_sim) _sim.stop();
|
|
130
132
|
|
|
131
133
|
_sim = d3.forceSimulation(nodes)
|
|
132
|
-
.force('link', d3.forceLink(links).id(d => d.id).distance(d => _edgeDistance(d.type)).strength(
|
|
133
|
-
.force('charge', d3.forceManyBody().strength(d => d
|
|
134
|
+
.force('link', d3.forceLink(links).id(d => d.id).distance(d => _edgeDistance(d.type)).strength(d => _edgeLinkStrength(d)))
|
|
135
|
+
.force('charge', d3.forceManyBody().strength(d => _nodeCharge(d)))
|
|
134
136
|
.force('center', d3.forceCenter(W / 2, H / 2))
|
|
135
|
-
.force('collide', d3.forceCollide(d => _nodeR(d) +
|
|
137
|
+
.force('collide', d3.forceCollide(d => _nodeR(d) + 4));
|
|
136
138
|
|
|
137
139
|
const linkLayer = d3s.append('g').attr('class', 'links');
|
|
138
140
|
const linkEl = linkLayer.selectAll('line')
|
|
139
141
|
.data(links).enter().append('line')
|
|
140
142
|
.attr('stroke', d => _edgeColor(d.type))
|
|
141
|
-
.attr('stroke-width', d => d
|
|
142
|
-
.attr('stroke-opacity', d => d
|
|
143
|
-
.attr('stroke-dasharray', d =>
|
|
143
|
+
.attr('stroke-width', d => _edgeWidth(d))
|
|
144
|
+
.attr('stroke-opacity', d => _edgeOpacity(d))
|
|
145
|
+
.attr('stroke-dasharray', d => _edgeDash(d.type));
|
|
144
146
|
|
|
145
147
|
const nodeLayer = d3s.append('g').attr('class', 'nodes');
|
|
146
148
|
const nodeEl = nodeLayer.selectAll('.repo-node')
|
|
@@ -170,6 +172,7 @@ function _renderGraph() {
|
|
|
170
172
|
function _makeNodeEl(d) {
|
|
171
173
|
const doc = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
172
174
|
const r = _nodeR(d);
|
|
175
|
+
|
|
173
176
|
if (d.type === 'memory') {
|
|
174
177
|
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
175
178
|
c.setAttribute('r', String(r));
|
|
@@ -182,12 +185,58 @@ function _makeNodeEl(d) {
|
|
|
182
185
|
return doc;
|
|
183
186
|
}
|
|
184
187
|
|
|
185
|
-
|
|
188
|
+
if (d.type === 'module') {
|
|
189
|
+
// Diamond shape — rotated square
|
|
190
|
+
const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
|
191
|
+
diamond.setAttribute('points', `0,${-r} ${r},0 0,${r} ${-r},0`);
|
|
192
|
+
diamond.setAttribute('fill', '#f59e0b');
|
|
193
|
+
diamond.setAttribute('fill-opacity', '0.9');
|
|
194
|
+
diamond.setAttribute('stroke', '#000');
|
|
195
|
+
diamond.setAttribute('stroke-width', '1.2');
|
|
196
|
+
doc.appendChild(diamond);
|
|
197
|
+
// Short label below diamond
|
|
198
|
+
const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
199
|
+
txt.setAttribute('text-anchor', 'middle');
|
|
200
|
+
txt.setAttribute('y', String(r + 9));
|
|
201
|
+
txt.setAttribute('font-size', '7');
|
|
202
|
+
txt.setAttribute('fill', '#a3a3a3');
|
|
203
|
+
txt.setAttribute('pointer-events', 'none');
|
|
204
|
+
txt.textContent = (d.label || '').slice(0, 14);
|
|
205
|
+
doc.appendChild(txt);
|
|
206
|
+
return doc;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (d.type === 'concept') {
|
|
210
|
+
// Hexagon shape
|
|
211
|
+
const pts = [];
|
|
212
|
+
for (let i = 0; i < 6; i++) {
|
|
213
|
+
const angle = (i * Math.PI) / 3 - Math.PI / 6;
|
|
214
|
+
pts.push(`${(r * Math.cos(angle)).toFixed(1)},${(r * Math.sin(angle)).toFixed(1)}`);
|
|
215
|
+
}
|
|
216
|
+
const hex = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
|
217
|
+
hex.setAttribute('points', pts.join(' '));
|
|
218
|
+
hex.setAttribute('fill', '#a855f7');
|
|
219
|
+
hex.setAttribute('fill-opacity', '0.85');
|
|
220
|
+
hex.setAttribute('stroke', '#000');
|
|
221
|
+
hex.setAttribute('stroke-width', '1');
|
|
222
|
+
doc.appendChild(hex);
|
|
223
|
+
const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
224
|
+
txt.setAttribute('text-anchor', 'middle');
|
|
225
|
+
txt.setAttribute('y', String(r + 9));
|
|
226
|
+
txt.setAttribute('font-size', '7');
|
|
227
|
+
txt.setAttribute('fill', '#c084fc');
|
|
228
|
+
txt.setAttribute('pointer-events', 'none');
|
|
229
|
+
txt.textContent = (d.label || '').slice(0, 12);
|
|
230
|
+
doc.appendChild(txt);
|
|
231
|
+
return doc;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// File node — rectangle
|
|
186
235
|
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
187
|
-
rect.setAttribute('x', String(-
|
|
188
|
-
rect.setAttribute('y', String(-
|
|
189
|
-
rect.setAttribute('width', String(
|
|
190
|
-
rect.setAttribute('height', String(
|
|
236
|
+
rect.setAttribute('x', String(-r));
|
|
237
|
+
rect.setAttribute('y', String(-r));
|
|
238
|
+
rect.setAttribute('width', String(r * 2));
|
|
239
|
+
rect.setAttribute('height', String(r * 2));
|
|
191
240
|
rect.setAttribute('rx', '2');
|
|
192
241
|
rect.setAttribute('fill', _fileColor(d));
|
|
193
242
|
rect.setAttribute('fill-opacity', '0.8');
|
|
@@ -198,31 +247,66 @@ function _makeNodeEl(d) {
|
|
|
198
247
|
}
|
|
199
248
|
|
|
200
249
|
function _nodeR(d) {
|
|
201
|
-
if (d.type === 'memory')
|
|
250
|
+
if (d.type === 'memory') return 4 + (d.priority || 0.3) * 8;
|
|
251
|
+
if (d.type === 'module') return 9;
|
|
252
|
+
if (d.type === 'concept') return 7;
|
|
202
253
|
return 5;
|
|
203
254
|
}
|
|
204
255
|
|
|
256
|
+
function _nodeCharge(d) {
|
|
257
|
+
if (d.type === 'module') return -120;
|
|
258
|
+
if (d.type === 'concept') return -90;
|
|
259
|
+
if (d.type === 'memory') return -40;
|
|
260
|
+
return -70;
|
|
261
|
+
}
|
|
262
|
+
|
|
205
263
|
function _edgeDistance(type) {
|
|
264
|
+
if (type === 'belongs-to') return 42;
|
|
265
|
+
if (type === 'concept-of') return 95;
|
|
206
266
|
if (type === 'memory-association') return 85;
|
|
207
|
-
if (type === 'memory-overlay')
|
|
208
|
-
if (type === 'configures')
|
|
209
|
-
if (type === 'tests')
|
|
267
|
+
if (type === 'memory-overlay') return 70;
|
|
268
|
+
if (type === 'configures') return 80;
|
|
269
|
+
if (type === 'tests') return 60;
|
|
210
270
|
return 55;
|
|
211
271
|
}
|
|
212
272
|
|
|
273
|
+
function _edgeLinkStrength(d) {
|
|
274
|
+
if (d.type === 'belongs-to') return 0.5;
|
|
275
|
+
if (d.type === 'imports') return 0.3;
|
|
276
|
+
return 0.2;
|
|
277
|
+
}
|
|
278
|
+
|
|
213
279
|
function _edgeColor(type) {
|
|
214
|
-
if (type === 'imports')
|
|
215
|
-
if (type === 'tests')
|
|
216
|
-
if (type === 'configures')
|
|
217
|
-
if (type === 'memory-overlay')
|
|
280
|
+
if (type === 'imports') return '#27272a';
|
|
281
|
+
if (type === 'tests') return '#22c55e';
|
|
282
|
+
if (type === 'configures') return '#78716c';
|
|
283
|
+
if (type === 'memory-overlay') return '#00d4ff';
|
|
218
284
|
if (type === 'memory-association') return '#3f3f46';
|
|
285
|
+
if (type === 'belongs-to') return '#52525b';
|
|
286
|
+
if (type === 'concept-of') return '#a855f7';
|
|
219
287
|
return '#27272a';
|
|
220
288
|
}
|
|
221
289
|
|
|
290
|
+
function _edgeWidth(d) {
|
|
291
|
+
const base = d.type === 'imports' ? 1.3 : d.type === 'belongs-to' ? 0.8 : 1;
|
|
292
|
+
return d.strength != null ? base * (0.5 + d.strength * 0.6) : base;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function _edgeOpacity(d) {
|
|
296
|
+
const base = ['memory-overlay', 'concept-of', 'belongs-to'].includes(d.type) ? 0.35 : 0.5;
|
|
297
|
+
return d.strength != null ? Math.min(0.85, base * (0.5 + d.strength * 0.7)) : base;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _edgeDash(type) {
|
|
301
|
+
if (['memory-overlay', 'memory-association', 'concept-of'].includes(type)) return '3,3';
|
|
302
|
+
if (type === 'belongs-to') return '2,4';
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
222
306
|
function _memColor(d) {
|
|
223
307
|
const tier = d.tier || 'cortex';
|
|
224
|
-
if (tier === 'prefrontal' || tier === 'working')
|
|
225
|
-
if (tier === 'hippocampus' || tier === 'episodic')
|
|
308
|
+
if (tier === 'prefrontal' || tier === 'working') return '#00d4ff';
|
|
309
|
+
if (tier === 'hippocampus' || tier === 'episodic') return '#00ff88';
|
|
226
310
|
return '#ffd14d';
|
|
227
311
|
}
|
|
228
312
|
|
|
@@ -254,7 +338,7 @@ function _onHover(d, nodeEl, linkEl) {
|
|
|
254
338
|
|
|
255
339
|
function _onHoverOut(nodeEl, linkEl) {
|
|
256
340
|
nodeEl.attr('opacity', 1);
|
|
257
|
-
linkEl.attr('stroke-opacity', l => l
|
|
341
|
+
linkEl.attr('stroke-opacity', l => _edgeOpacity(l));
|
|
258
342
|
_hideTooltip();
|
|
259
343
|
}
|
|
260
344
|
|
|
@@ -264,6 +348,11 @@ function _onClick(d) {
|
|
|
264
348
|
if (d.type === 'file') {
|
|
265
349
|
rows.push(['Path', d.path]);
|
|
266
350
|
rows.push(['Kind', d.kind || 'other']);
|
|
351
|
+
if (d.module) rows.push(['Module', d.module]);
|
|
352
|
+
} else if (d.type === 'module') {
|
|
353
|
+
rows.push(['Module path', d.path]);
|
|
354
|
+
} else if (d.type === 'concept') {
|
|
355
|
+
rows.push(['Terms', d.terms?.join(', ') || d.label]);
|
|
267
356
|
} else {
|
|
268
357
|
rows.push(['Tier', d.tier || 'cortex']);
|
|
269
358
|
rows.push(['Priority', (d.priority || 0).toFixed(2)]);
|
|
@@ -5,24 +5,34 @@ const FILE_LIMIT = 120;
|
|
|
5
5
|
const MEMORY_LIMIT = 60;
|
|
6
6
|
const FILE_READ_LIMIT = 64 * 1024;
|
|
7
7
|
const MAX_STRUCTURAL_EDGES = 360;
|
|
8
|
+
const MAX_MODULE_EDGES = 300;
|
|
9
|
+
const MAX_MEMORY_OVERLAY_EDGES = FILE_LIMIT * 2;
|
|
8
10
|
const MAX_MEMORY_ASSOC_EDGES = 120;
|
|
11
|
+
const MAX_CONCEPT_EDGES = 240;
|
|
12
|
+
const MEMORY_FILE_SIM_THRESHOLD = 0.08;
|
|
13
|
+
const MEMORY_ASSOC_SIM_THRESHOLD = 0.12;
|
|
14
|
+
const CONCEPT_MIN_OCCURRENCES = 2;
|
|
15
|
+
const CONCEPT_MAX = 20;
|
|
9
16
|
const IMPORT_CANDIDATE_SUFFIXES = [
|
|
10
17
|
'',
|
|
11
|
-
'.ts',
|
|
12
|
-
'.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'.mts',
|
|
16
|
-
'.cts',
|
|
17
|
-
'.json',
|
|
18
|
-
'.yaml',
|
|
19
|
-
'.yml',
|
|
20
|
-
'.toml',
|
|
21
|
-
`${path.sep}index.ts`,
|
|
22
|
-
`${path.sep}index.tsx`,
|
|
23
|
-
`${path.sep}index.js`,
|
|
24
|
-
`${path.sep}index.jsx`,
|
|
18
|
+
'.ts', '.tsx', '.js', '.jsx', '.mts', '.cts',
|
|
19
|
+
'.json', '.yaml', '.yml', '.toml',
|
|
20
|
+
`${path.sep}index.ts`, `${path.sep}index.tsx`,
|
|
21
|
+
`${path.sep}index.js`, `${path.sep}index.jsx`,
|
|
25
22
|
];
|
|
23
|
+
const GENERIC_TERMS = new Set([
|
|
24
|
+
'the', 'and', 'for', 'with', 'this', 'that', 'are', 'was',
|
|
25
|
+
'has', 'from', 'not', 'but', 'all', 'use', 'its', 'via',
|
|
26
|
+
'can', 'will', 'when', 'into', 'also', 'only', 'each',
|
|
27
|
+
'been', 'have', 'more', 'than', 'some', 'any', 'how',
|
|
28
|
+
]);
|
|
29
|
+
const GENERIC_TAGS = new Set([
|
|
30
|
+
'task', 'finding', 'note', 'important', 'todo', 'fix', 'done',
|
|
31
|
+
'bug', 'feature', 'info', 'update', 'change', 'work', 'new',
|
|
32
|
+
'code', 'file', 'data', 'type', 'value', 'item', 'list',
|
|
33
|
+
'run', 'log', 'error', 'result', 'output', 'input',
|
|
34
|
+
]);
|
|
35
|
+
// ── File helpers ─────────────────────────────────────────────────────────────
|
|
26
36
|
function classifyFileKind(relPath) {
|
|
27
37
|
const normalized = relPath.replace(/\\/g, '/');
|
|
28
38
|
const ext = path.extname(normalized).toLowerCase();
|
|
@@ -37,6 +47,17 @@ function classifyFileKind(relPath) {
|
|
|
37
47
|
}
|
|
38
48
|
return 'other';
|
|
39
49
|
}
|
|
50
|
+
function extractModulePath(relPath) {
|
|
51
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
52
|
+
const parts = normalized.split('/');
|
|
53
|
+
if (parts.length < 2)
|
|
54
|
+
return null;
|
|
55
|
+
if (parts[0] === 'src' && parts.length >= 3)
|
|
56
|
+
return `${parts[0]}/${parts[1]}`;
|
|
57
|
+
if (['test', 'tests', 'scripts'].includes(parts[0]))
|
|
58
|
+
return parts[0];
|
|
59
|
+
return parts[0];
|
|
60
|
+
}
|
|
40
61
|
async function readFileSnippet(absPath) {
|
|
41
62
|
try {
|
|
42
63
|
const stat = await fs.stat(absPath);
|
|
@@ -54,9 +75,8 @@ function extractRelativeImports(source) {
|
|
|
54
75
|
let match = null;
|
|
55
76
|
while ((match = pattern.exec(source))) {
|
|
56
77
|
const spec = String(match[1] || match[2] || match[3] || '').trim();
|
|
57
|
-
if (spec.startsWith('.'))
|
|
78
|
+
if (spec.startsWith('.'))
|
|
58
79
|
specs.add(spec);
|
|
59
|
-
}
|
|
60
80
|
}
|
|
61
81
|
return [...specs];
|
|
62
82
|
}
|
|
@@ -73,15 +93,6 @@ function resolveRelativeTarget(importerAbsPath, spec, absToRel) {
|
|
|
73
93
|
function normalizedBaseName(relPath) {
|
|
74
94
|
return path.basename(relPath).replace(/\.(test|spec)\.[^.]+$/i, '').replace(/\.[^.]+$/i, '').toLowerCase();
|
|
75
95
|
}
|
|
76
|
-
function pushEdge(edges, seen, source, target, type, limit) {
|
|
77
|
-
if (!source || !target || source === target || edges.length >= limit)
|
|
78
|
-
return;
|
|
79
|
-
const key = `${source}:${target}:${type}`;
|
|
80
|
-
if (seen.has(key))
|
|
81
|
-
return;
|
|
82
|
-
seen.add(key);
|
|
83
|
-
edges.push({ source, target, type });
|
|
84
|
-
}
|
|
85
96
|
function scoreConfigReference(configText, relPath) {
|
|
86
97
|
if (!configText)
|
|
87
98
|
return 0;
|
|
@@ -94,6 +105,42 @@ function scoreConfigReference(configText, relPath) {
|
|
|
94
105
|
return 1;
|
|
95
106
|
return 0;
|
|
96
107
|
}
|
|
108
|
+
// ── Semantic similarity ───────────────────────────────────────────────────────
|
|
109
|
+
function tokenizeText(text) {
|
|
110
|
+
return text
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.replace(/[/\\._\-:,;()[\]{}'"`<>@#!?=+*&%$^~]/g, ' ')
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.filter(w => w.length > 2 && !GENERIC_TERMS.has(w));
|
|
115
|
+
}
|
|
116
|
+
function tokenOverlapScore(aTokens, bTokens) {
|
|
117
|
+
if (!aTokens.length || !bTokens.length)
|
|
118
|
+
return 0;
|
|
119
|
+
const bSet = new Set(bTokens);
|
|
120
|
+
let overlap = 0;
|
|
121
|
+
for (const t of aTokens) {
|
|
122
|
+
if (bSet.has(t))
|
|
123
|
+
overlap++;
|
|
124
|
+
}
|
|
125
|
+
const union = aTokens.length + bTokens.length - overlap;
|
|
126
|
+
return union === 0 ? 0 : overlap / union;
|
|
127
|
+
}
|
|
128
|
+
function extractConceptTerms(tags) {
|
|
129
|
+
return tags
|
|
130
|
+
.map(t => t.replace(/^[#@]/, '').toLowerCase().trim())
|
|
131
|
+
.filter(t => t.length > 3 && !GENERIC_TAGS.has(t) && !GENERIC_TERMS.has(t));
|
|
132
|
+
}
|
|
133
|
+
// ── Edge helpers ──────────────────────────────────────────────────────────────
|
|
134
|
+
function pushEdge(edges, seen, source, target, type, limit, strength) {
|
|
135
|
+
if (!source || !target || source === target || edges.length >= limit)
|
|
136
|
+
return;
|
|
137
|
+
const key = `${source}:${target}:${type}`;
|
|
138
|
+
if (seen.has(key))
|
|
139
|
+
return;
|
|
140
|
+
seen.add(key);
|
|
141
|
+
edges.push({ source, target, type, ...(strength !== undefined ? { strength } : {}) });
|
|
142
|
+
}
|
|
143
|
+
// ── Route handler ─────────────────────────────────────────────────────────────
|
|
97
144
|
export const handleGraphRoutes = async (ctx, req, res, url) => {
|
|
98
145
|
if (req.method === 'POST' && url.pathname === '/api/knowledge-topology/build') {
|
|
99
146
|
const repoIdentity = ctx.getSelectedRepoIdentity(url);
|
|
@@ -104,13 +151,7 @@ export const handleGraphRoutes = async (ctx, req, res, url) => {
|
|
|
104
151
|
? repoIdentity.repoName.trim()
|
|
105
152
|
: path.basename(repoRoot) || 'workspace';
|
|
106
153
|
void ensureCrGraphBuilt(repoRoot);
|
|
107
|
-
ctx.respondJson(res, {
|
|
108
|
-
ok: true,
|
|
109
|
-
status: 'queued',
|
|
110
|
-
repoRoot,
|
|
111
|
-
repoName,
|
|
112
|
-
queuedAt: Date.now(),
|
|
113
|
-
}, 202);
|
|
154
|
+
ctx.respondJson(res, { ok: true, status: 'queued', repoRoot, repoName, queuedAt: Date.now() }, 202);
|
|
114
155
|
return true;
|
|
115
156
|
}
|
|
116
157
|
if (req.method === 'GET' && url.pathname === '/api/knowledge-topology') {
|
|
@@ -127,6 +168,7 @@ export const handleGraphRoutes = async (ctx, req, res, url) => {
|
|
|
127
168
|
const repoName = typeof repoIdentity?.repoName === 'string' && repoIdentity.repoName.trim()
|
|
128
169
|
? repoIdentity.repoName.trim()
|
|
129
170
|
: path.basename(repoRoot) || 'workspace';
|
|
171
|
+
// ── File nodes ─────────────────────────────────────────────────────────
|
|
130
172
|
let filePaths = [];
|
|
131
173
|
try {
|
|
132
174
|
filePaths = await orchestrator?.listTopologyFiles?.(FILE_LIMIT) ?? [];
|
|
@@ -145,10 +187,36 @@ export const handleGraphRoutes = async (ctx, req, res, url) => {
|
|
|
145
187
|
label: path.basename(relPath),
|
|
146
188
|
path: relPath,
|
|
147
189
|
absPath: path.normalize(absPath),
|
|
190
|
+
module: extractModulePath(relPath),
|
|
148
191
|
};
|
|
149
192
|
});
|
|
150
|
-
const absToRel = new Map(fileNodes.map((
|
|
151
|
-
const fileRelSet = new Set(fileNodes.map((
|
|
193
|
+
const absToRel = new Map(fileNodes.map((n) => [n.absPath, n.path]));
|
|
194
|
+
const fileRelSet = new Set(fileNodes.map((n) => n.path));
|
|
195
|
+
// Pre-tokenize file paths+labels for similarity scoring
|
|
196
|
+
const fileTokens = new Map();
|
|
197
|
+
for (const node of fileNodes) {
|
|
198
|
+
fileTokens.set(node.id, [...tokenizeText(node.path), ...tokenizeText(node.label)]);
|
|
199
|
+
}
|
|
200
|
+
// ── Module nodes + belongs-to edges ────────────────────────────────────
|
|
201
|
+
const modulePathSet = new Set();
|
|
202
|
+
for (const node of fileNodes) {
|
|
203
|
+
if (node.module)
|
|
204
|
+
modulePathSet.add(node.module);
|
|
205
|
+
}
|
|
206
|
+
const moduleNodes = [...modulePathSet].sort().map((mod) => ({
|
|
207
|
+
id: `module:${mod}`,
|
|
208
|
+
type: 'module',
|
|
209
|
+
label: path.basename(mod),
|
|
210
|
+
path: mod,
|
|
211
|
+
}));
|
|
212
|
+
const belongsToEdges = [];
|
|
213
|
+
const belongsToSeen = new Set();
|
|
214
|
+
for (const node of fileNodes) {
|
|
215
|
+
if (node.module) {
|
|
216
|
+
pushEdge(belongsToEdges, belongsToSeen, node.id, `module:${node.module}`, 'belongs-to', MAX_MODULE_EDGES, 1.0);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// ── Structural edges (imports / tests / configures) ────────────────────
|
|
152
220
|
const structuralEdges = [];
|
|
153
221
|
const structuralSeen = new Set();
|
|
154
222
|
for (const node of fileNodes) {
|
|
@@ -160,66 +228,130 @@ export const handleGraphRoutes = async (ctx, req, res, url) => {
|
|
|
160
228
|
for (const spec of extractRelativeImports(fileText)) {
|
|
161
229
|
const targetRel = resolveRelativeTarget(node.absPath, spec, absToRel);
|
|
162
230
|
if (targetRel) {
|
|
163
|
-
pushEdge(structuralEdges, structuralSeen, node.id, `file:${targetRel}`, 'imports', MAX_STRUCTURAL_EDGES);
|
|
231
|
+
pushEdge(structuralEdges, structuralSeen, node.id, `file:${targetRel}`, 'imports', MAX_STRUCTURAL_EDGES, 1.0);
|
|
164
232
|
}
|
|
165
233
|
}
|
|
166
234
|
if (node.kind === 'test') {
|
|
167
235
|
const affinityMatches = fileNodes
|
|
168
|
-
.filter((
|
|
169
|
-
.
|
|
170
|
-
.sort((left, right) => left.path.length - right.path.length)
|
|
236
|
+
.filter((c) => c.kind === 'source' && normalizedBaseName(c.path) === normalizedBaseName(node.path))
|
|
237
|
+
.sort((a, b) => a.path.length - b.path.length)
|
|
171
238
|
.slice(0, 2);
|
|
172
|
-
for (const
|
|
173
|
-
pushEdge(structuralEdges, structuralSeen, node.id,
|
|
239
|
+
for (const m of affinityMatches) {
|
|
240
|
+
pushEdge(structuralEdges, structuralSeen, node.id, m.id, 'tests', MAX_STRUCTURAL_EDGES, 0.9);
|
|
174
241
|
}
|
|
175
242
|
}
|
|
176
243
|
if (node.kind === 'config') {
|
|
177
244
|
const configMatches = fileNodes
|
|
178
|
-
.filter((
|
|
179
|
-
.map((
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}))
|
|
183
|
-
.filter((entry) => entry.score > 0)
|
|
184
|
-
.sort((left, right) => right.score - left.score || left.candidate.path.length - right.candidate.path.length)
|
|
245
|
+
.filter((c) => c.kind === 'source')
|
|
246
|
+
.map((c) => ({ c, score: scoreConfigReference(fileText, c.path) }))
|
|
247
|
+
.filter((e) => e.score > 0)
|
|
248
|
+
.sort((a, b) => b.score - a.score || a.c.path.length - b.c.path.length)
|
|
185
249
|
.slice(0, 4);
|
|
186
|
-
for (const
|
|
187
|
-
pushEdge(structuralEdges, structuralSeen, node.id,
|
|
250
|
+
for (const { c, score } of configMatches) {
|
|
251
|
+
pushEdge(structuralEdges, structuralSeen, node.id, c.id, 'configures', MAX_STRUCTURAL_EDGES, score / 3);
|
|
188
252
|
}
|
|
189
253
|
}
|
|
190
254
|
}
|
|
255
|
+
// ── Memory nodes ───────────────────────────────────────────────────────
|
|
191
256
|
const snapshots = memory?.listSnapshots?.(MEMORY_LIMIT, { state: 'active' }) ?? [];
|
|
192
|
-
const memoryNodes = snapshots.map((
|
|
193
|
-
id: `memory:${
|
|
257
|
+
const memoryNodes = snapshots.map((s) => ({
|
|
258
|
+
id: `memory:${s.id}`,
|
|
194
259
|
type: 'memory',
|
|
195
|
-
label: (
|
|
196
|
-
tier:
|
|
197
|
-
priority:
|
|
198
|
-
tags: Array.isArray(
|
|
199
|
-
promoted:
|
|
260
|
+
label: (s.excerpt ?? '').slice(0, 60) || s.id.slice(0, 16),
|
|
261
|
+
tier: s.tier ?? 'cortex',
|
|
262
|
+
priority: s.priority ?? 0.3,
|
|
263
|
+
tags: Array.isArray(s.tags) ? s.tags : [],
|
|
264
|
+
promoted: s.tier === 'hippocampus' || s.tier === 'cortex',
|
|
200
265
|
}));
|
|
266
|
+
// ── Memory → file overlay (token similarity, not fragile string match) ─
|
|
201
267
|
const memoryOverlayEdges = [];
|
|
202
268
|
const memoryOverlaySeen = new Set();
|
|
203
269
|
for (const snapshot of snapshots) {
|
|
204
270
|
const excerpt = String(snapshot.excerpt ?? '');
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
271
|
+
const excerptTokens = tokenizeText(excerpt);
|
|
272
|
+
const scored = [];
|
|
273
|
+
for (const node of fileNodes) {
|
|
274
|
+
// Exact path match first (high confidence)
|
|
275
|
+
if (excerpt.includes(node.path) || excerpt.includes(path.basename(node.path))) {
|
|
276
|
+
scored.push({ nodeId: node.id, score: 0.95 });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// Token overlap (semantic match)
|
|
280
|
+
const fTokens = fileTokens.get(node.id) ?? [];
|
|
281
|
+
const sim = tokenOverlapScore(excerptTokens, fTokens);
|
|
282
|
+
if (sim >= MEMORY_FILE_SIM_THRESHOLD) {
|
|
283
|
+
scored.push({ nodeId: node.id, score: sim });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Cap at top 3 links per memory — keeps graph readable
|
|
287
|
+
scored.sort((a, b) => b.score - a.score);
|
|
288
|
+
for (const { nodeId, score } of scored.slice(0, 3)) {
|
|
289
|
+
pushEdge(memoryOverlayEdges, memoryOverlaySeen, `memory:${snapshot.id}`, nodeId, 'memory-overlay', MAX_MEMORY_OVERLAY_EDGES, score);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// ── Memory ↔ memory association (specific tags + excerpt similarity) ───
|
|
293
|
+
const memoryAssocEdges = [];
|
|
294
|
+
const memoryAssocSeen = new Set();
|
|
295
|
+
const memCache = snapshots.map((s, i) => ({
|
|
296
|
+
id: memoryNodes[i]?.id ?? `memory:${s.id}`,
|
|
297
|
+
tokens: tokenizeText(String(s.excerpt ?? '')),
|
|
298
|
+
specificTags: (Array.isArray(s.tags) ? s.tags : [])
|
|
299
|
+
.map((t) => t.replace(/^[#@]/, '').toLowerCase())
|
|
300
|
+
.filter((t) => t.length > 3 && !GENERIC_TAGS.has(t)),
|
|
301
|
+
}));
|
|
302
|
+
for (let i = 0; i < memCache.length && memoryAssocEdges.length < MAX_MEMORY_ASSOC_EDGES; i++) {
|
|
303
|
+
for (let j = i + 1; j < memCache.length && memoryAssocEdges.length < MAX_MEMORY_ASSOC_EDGES; j++) {
|
|
304
|
+
const a = memCache[i];
|
|
305
|
+
const b = memCache[j];
|
|
306
|
+
const sharedTags = a.specificTags.filter((t) => b.specificTags.includes(t)).length;
|
|
307
|
+
const textSim = tokenOverlapScore(a.tokens, b.tokens);
|
|
308
|
+
if (sharedTags >= 1 || textSim >= MEMORY_ASSOC_SIM_THRESHOLD) {
|
|
309
|
+
const strength = Math.min(1, sharedTags * 0.35 + textSim);
|
|
310
|
+
pushEdge(memoryAssocEdges, memoryAssocSeen, a.id, b.id, 'memory-association', MAX_MEMORY_ASSOC_EDGES, strength);
|
|
209
311
|
}
|
|
210
312
|
}
|
|
211
313
|
}
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
for (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
314
|
+
// ── Concept nodes (prominent memory tags, ≥2 occurrences) ──────────────
|
|
315
|
+
const conceptCounts = new Map();
|
|
316
|
+
for (const snapshot of snapshots) {
|
|
317
|
+
const tags = Array.isArray(snapshot.tags) ? snapshot.tags : [];
|
|
318
|
+
for (const term of extractConceptTerms(tags)) {
|
|
319
|
+
conceptCounts.set(term, (conceptCounts.get(term) ?? 0) + 1);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const conceptTerms = [...conceptCounts.entries()]
|
|
323
|
+
.filter(([, count]) => count >= CONCEPT_MIN_OCCURRENCES)
|
|
324
|
+
.sort(([, a], [, b]) => b - a)
|
|
325
|
+
.slice(0, CONCEPT_MAX)
|
|
326
|
+
.map(([term]) => term);
|
|
327
|
+
const conceptNodes = conceptTerms.map((term) => ({
|
|
328
|
+
id: `concept:${term}`,
|
|
329
|
+
type: 'concept',
|
|
330
|
+
label: term,
|
|
331
|
+
terms: [term],
|
|
332
|
+
}));
|
|
333
|
+
// concept-of edges: memory → concept (via tags)
|
|
334
|
+
const conceptEdges = [];
|
|
335
|
+
const conceptEdgesSeen = new Set();
|
|
336
|
+
for (const snapshot of snapshots) {
|
|
337
|
+
const tags = Array.isArray(snapshot.tags) ? snapshot.tags : [];
|
|
338
|
+
for (const term of extractConceptTerms(tags)) {
|
|
339
|
+
if ((conceptCounts.get(term) ?? 0) >= CONCEPT_MIN_OCCURRENCES) {
|
|
340
|
+
pushEdge(conceptEdges, conceptEdgesSeen, `memory:${snapshot.id}`, `concept:${term}`, 'concept-of', MAX_CONCEPT_EDGES, 0.8);
|
|
219
341
|
}
|
|
220
342
|
}
|
|
221
343
|
}
|
|
222
|
-
|
|
344
|
+
// file → concept (via file name token match)
|
|
345
|
+
for (const node of fileNodes) {
|
|
346
|
+
const fTokens = fileTokens.get(node.id) ?? [];
|
|
347
|
+
for (const term of conceptTerms) {
|
|
348
|
+
if (fTokens.includes(term)) {
|
|
349
|
+
pushEdge(conceptEdges, conceptEdgesSeen, node.id, `concept:${term}`, 'concept-of', MAX_CONCEPT_EDGES, 0.7);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// ── Assemble ───────────────────────────────────────────────────────────
|
|
354
|
+
const edges = [...belongsToEdges, ...structuralEdges, ...memoryOverlayEdges, ...memoryAssocEdges, ...conceptEdges];
|
|
223
355
|
const graphSource = structuralEdges.length > 0
|
|
224
356
|
? 'heuristic-local-relationships'
|
|
225
357
|
: fileNodes.length > 0
|
|
@@ -227,37 +359,31 @@ export const handleGraphRoutes = async (ctx, req, res, url) => {
|
|
|
227
359
|
: memoryOverlayEdges.length > 0 || memoryNodes.length > 0
|
|
228
360
|
? 'memory-overlay-fallback'
|
|
229
361
|
: 'empty';
|
|
230
|
-
// fallbackMode tells the dashboard whether this payload represents a real
|
|
231
|
-
// topology, a degraded approximation, or no graph at all. The memory view
|
|
232
|
-
// uses this to pick between rendering the graph, a synthetic-data banner,
|
|
233
|
-
// or a "no graph yet" empty state — so we stop drawing edges that lie.
|
|
234
362
|
const fallbackMode = graphSource === 'heuristic-local-relationships' ? 'none'
|
|
235
363
|
: graphSource === 'runtime-topology-shell' ? 'synthetic'
|
|
236
364
|
: graphSource === 'memory-overlay-fallback' ? 'topology-missing'
|
|
237
365
|
: 'empty';
|
|
238
366
|
const sources = [
|
|
239
367
|
fileNodes.length > 0 ? 'runtime-topology-files' : null,
|
|
240
|
-
|
|
241
|
-
structuralEdges.some((
|
|
242
|
-
structuralEdges.some((
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
//
|
|
368
|
+
moduleNodes.length > 0 ? 'module-grouping' : null,
|
|
369
|
+
structuralEdges.some((e) => e.type === 'imports') ? 'heuristic-imports' : null,
|
|
370
|
+
structuralEdges.some((e) => e.type === 'tests') ? 'heuristic-test-affinity' : null,
|
|
371
|
+
structuralEdges.some((e) => e.type === 'configures') ? 'heuristic-config-affinity' : null,
|
|
372
|
+
memoryOverlayEdges.length > 0 ? 'memory-overlay-similarity' : null,
|
|
373
|
+
memoryAssocEdges.length > 0 ? 'memory-association' : null,
|
|
374
|
+
conceptNodes.length > 0 ? 'concept-extraction' : null,
|
|
375
|
+
].filter((v) => Boolean(v));
|
|
376
|
+
// Deterministic ordering — stable across refreshes
|
|
249
377
|
const sortedFileNodes = [...fileNodes].sort((a, b) => a.id.localeCompare(b.id));
|
|
378
|
+
const sortedModuleNodes = [...moduleNodes].sort((a, b) => a.id.localeCompare(b.id));
|
|
250
379
|
const sortedMemoryNodes = [...memoryNodes].sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
const rightKey = `${b.source}|${b.target}|${b.type}`;
|
|
254
|
-
return leftKey.localeCompare(rightKey);
|
|
255
|
-
});
|
|
380
|
+
const sortedConceptNodes = [...conceptNodes].sort((a, b) => a.id.localeCompare(b.id));
|
|
381
|
+
const sortedEdges = [...edges].sort((a, b) => `${a.source}|${a.target}|${a.type}`.localeCompare(`${b.source}|${b.target}|${b.type}`));
|
|
256
382
|
return {
|
|
257
383
|
generatedAt: Date.now(),
|
|
258
384
|
repoName,
|
|
259
385
|
repoRoot,
|
|
260
|
-
nodes: [...sortedFileNodes, ...sortedMemoryNodes],
|
|
386
|
+
nodes: [...sortedFileNodes, ...sortedModuleNodes, ...sortedMemoryNodes, ...sortedConceptNodes],
|
|
261
387
|
edges: sortedEdges,
|
|
262
388
|
fallbackMode,
|
|
263
389
|
provenance: {
|
|
@@ -276,10 +402,14 @@ export const handleGraphRoutes = async (ctx, req, res, url) => {
|
|
|
276
402
|
},
|
|
277
403
|
meta: {
|
|
278
404
|
fileCount: fileNodes.length,
|
|
405
|
+
moduleCount: moduleNodes.length,
|
|
279
406
|
memoryCount: memoryNodes.length,
|
|
407
|
+
conceptCount: conceptNodes.length,
|
|
280
408
|
structuralEdgeCount: structuralEdges.length,
|
|
409
|
+
belongsToEdgeCount: belongsToEdges.length,
|
|
281
410
|
memoryOverlayEdgeCount: memoryOverlayEdges.length,
|
|
282
|
-
memoryAssociationEdgeCount:
|
|
411
|
+
memoryAssociationEdgeCount: memoryAssocEdges.length,
|
|
412
|
+
conceptEdgeCount: conceptEdges.length,
|
|
283
413
|
edgeCount: edges.length,
|
|
284
414
|
graphSource,
|
|
285
415
|
fallbackMode,
|
package/dist/dashboard/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { handleAuthoringRoutes } from './routes/authoring.js';
|
|
|
21
21
|
import { handleArchitectsRoutes } from './routes/architects.js';
|
|
22
22
|
import { handleWorkforceRoutes } from './routes/workforce.js';
|
|
23
23
|
import { handleGraphRoutes } from './routes/graph.js';
|
|
24
|
+
import { DASHBOARD_API_VERSION } from './contract.js';
|
|
24
25
|
import { SseBroker } from './stream/sse-broker.js';
|
|
25
26
|
import { nexusEventBus } from '../engines/event-bus.js';
|
|
26
27
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -30,7 +31,6 @@ const DEFAULT_PORT = parseInt(process.env.NEXUS_DASHBOARD_PORT || '3377', 10);
|
|
|
30
31
|
const MAX_PORT_SCAN = 24;
|
|
31
32
|
const DASHBOARD_PROBE_TIMEOUT_MS = 3000;
|
|
32
33
|
const DASHBOARD_PROBE_ATTEMPTS = 2;
|
|
33
|
-
const DASHBOARD_API_VERSION = '4';
|
|
34
34
|
const DASHBOARD_SCHEMA_VERSION = 1;
|
|
35
35
|
const DASHBOARD_PRETTY_JSON = process.env.NEXUS_DASHBOARD_PRETTY_JSON === '1';
|
|
36
36
|
const CORE_CAPABILITIES = {
|
|
@@ -329,12 +329,16 @@ export class InstructionGateway {
|
|
|
329
329
|
const workspaceRuntimeDir = path.join(repoRoot, '.agent', 'runtime');
|
|
330
330
|
workspaceJsonPath = path.join(workspaceRuntimeDir, 'packet.json');
|
|
331
331
|
workspaceMarkdownPath = path.join(workspaceRuntimeDir, 'packet.md');
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
332
|
+
// Sync write: callers read this file immediately after execute() returns.
|
|
333
|
+
// Fire-and-forget async races the reader and causes truncated JSON.parse failures.
|
|
334
|
+
try {
|
|
335
|
+
fs.mkdirSync(workspaceRuntimeDir, { recursive: true });
|
|
336
|
+
fs.writeFileSync(workspaceJsonPath, JSON.stringify(packet, null, 2), 'utf8');
|
|
337
|
+
fs.writeFileSync(workspaceMarkdownPath, renderInstructionPacketMarkdown(packet), 'utf8');
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
336
340
|
console.error(`[nexus-prime] Failed to persist instruction packet to workspace: ${err}`);
|
|
337
|
-
}
|
|
341
|
+
}
|
|
338
342
|
}
|
|
339
343
|
else if (isRoot) {
|
|
340
344
|
console.error('[nexus-prime] Persistence to system root is disabled for stability.');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Marker that identifies a hook entry as Nexus-owned. */
|
|
2
|
+
export declare const NEXUS_HOOK_COMMAND_MARKER = "nexus-prime hook";
|
|
3
|
+
interface HookCommand {
|
|
4
|
+
type: 'command';
|
|
5
|
+
command: string;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
}
|
|
8
|
+
interface HookEntry {
|
|
9
|
+
matcher?: string;
|
|
10
|
+
hooks: HookCommand[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Canonical Nexus hook spec. Edit here, not in callers.
|
|
14
|
+
* Timeouts protect Claude Code from a wedged Nexus process — leave them set.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getNexusHookSpec(): Record<string, HookEntry[]>;
|
|
17
|
+
export interface NexusHookWriteResult {
|
|
18
|
+
/** True when the file would be written (or was written). */
|
|
19
|
+
changed: boolean;
|
|
20
|
+
/** Hook event names that ended up with Nexus entries. */
|
|
21
|
+
events: string[];
|
|
22
|
+
/** Number of stale Nexus entries removed before re-adding. */
|
|
23
|
+
staleRemoved: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Idempotent writer for the Nexus hook block. Returns metadata so callers can
|
|
27
|
+
* report status (`installed | unchanged | dry-run`) without re-implementing
|
|
28
|
+
* the merge logic.
|
|
29
|
+
*/
|
|
30
|
+
export declare function writeNexusClaudeCodeHooks(settingsPath: string, options?: {
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
}): NexusHookWriteResult;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the Claude Code hook entries Nexus Prime owns.
|
|
3
|
+
* Both `nexus-prime install` (cli.ts) and the install-wizard call into here
|
|
4
|
+
* so we cannot drift on hook spec, dedup logic, or marker detection.
|
|
5
|
+
*
|
|
6
|
+
* Foreign user hooks are preserved — only entries whose command starts with
|
|
7
|
+
* `nexus-prime hook` are stripped before re-adding.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
/** Marker that identifies a hook entry as Nexus-owned. */
|
|
12
|
+
export const NEXUS_HOOK_COMMAND_MARKER = 'nexus-prime hook';
|
|
13
|
+
/**
|
|
14
|
+
* Canonical Nexus hook spec. Edit here, not in callers.
|
|
15
|
+
* Timeouts protect Claude Code from a wedged Nexus process — leave them set.
|
|
16
|
+
*/
|
|
17
|
+
export function getNexusHookSpec() {
|
|
18
|
+
return {
|
|
19
|
+
UserPromptSubmit: [
|
|
20
|
+
{ hooks: [{ type: 'command', command: 'nexus-prime hook bootstrap', timeout: 30 }] },
|
|
21
|
+
],
|
|
22
|
+
PreToolUse: [
|
|
23
|
+
{
|
|
24
|
+
matcher: 'Edit|Write|MultiEdit',
|
|
25
|
+
hooks: [{ type: 'command', command: 'nexus-prime hook mindkit', timeout: 10 }],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
PostToolUse: [
|
|
29
|
+
{
|
|
30
|
+
matcher: 'Edit|Write|MultiEdit|Bash',
|
|
31
|
+
hooks: [{ type: 'command', command: 'nexus-prime hook memory', timeout: 10 }],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
Stop: [
|
|
35
|
+
{ hooks: [{ type: 'command', command: 'nexus-prime hook session-dna', timeout: 60 }] },
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function isNexusHookEntry(entry) {
|
|
40
|
+
if (!entry || typeof entry !== 'object')
|
|
41
|
+
return false;
|
|
42
|
+
const hooksList = entry.hooks;
|
|
43
|
+
if (!Array.isArray(hooksList))
|
|
44
|
+
return false;
|
|
45
|
+
return hooksList.some((h) => h && typeof h === 'object'
|
|
46
|
+
&& typeof h.command === 'string'
|
|
47
|
+
&& h.command.trimStart().startsWith(NEXUS_HOOK_COMMAND_MARKER));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Idempotent writer for the Nexus hook block. Returns metadata so callers can
|
|
51
|
+
* report status (`installed | unchanged | dry-run`) without re-implementing
|
|
52
|
+
* the merge logic.
|
|
53
|
+
*/
|
|
54
|
+
export function writeNexusClaudeCodeHooks(settingsPath, options = {}) {
|
|
55
|
+
const dryRun = options.dryRun ?? false;
|
|
56
|
+
let settings = {};
|
|
57
|
+
if (fs.existsSync(settingsPath)) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = fs.readFileSync(settingsPath, 'utf8');
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
62
|
+
settings = parsed;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
settings = {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const incomingHooks = getNexusHookSpec();
|
|
70
|
+
const events = Object.keys(incomingHooks);
|
|
71
|
+
const existing = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
|
|
72
|
+
? { ...settings.hooks }
|
|
73
|
+
: {};
|
|
74
|
+
let staleRemoved = 0;
|
|
75
|
+
let changed = !fs.existsSync(settingsPath);
|
|
76
|
+
for (const event of events) {
|
|
77
|
+
const before = Array.isArray(existing[event]) ? existing[event] : [];
|
|
78
|
+
const filtered = before.filter((entry) => {
|
|
79
|
+
const isNexus = isNexusHookEntry(entry);
|
|
80
|
+
if (isNexus)
|
|
81
|
+
staleRemoved += 1;
|
|
82
|
+
return !isNexus;
|
|
83
|
+
});
|
|
84
|
+
const next = [...filtered, ...incomingHooks[event]];
|
|
85
|
+
if (filtered.length !== before.length || next.length !== before.length)
|
|
86
|
+
changed = true;
|
|
87
|
+
existing[event] = next;
|
|
88
|
+
}
|
|
89
|
+
settings.hooks = existing;
|
|
90
|
+
if (dryRun || !changed) {
|
|
91
|
+
return { changed, events, staleRemoved };
|
|
92
|
+
}
|
|
93
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
94
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
95
|
+
return { changed, events, staleRemoved };
|
|
96
|
+
}
|
|
@@ -88,6 +88,8 @@ export function enumerateStatePaths(stateDir = getNexusStateDir()) {
|
|
|
88
88
|
{ path: path.join(stateDir, 'bootstrap-receipts'), scope: 'state', optional: true },
|
|
89
89
|
{ path: path.join(stateDir, 'setup-marker-v6.json'), scope: 'state', optional: true },
|
|
90
90
|
{ path: path.join(stateDir, 'install-manifest.json'), scope: 'state', optional: true },
|
|
91
|
+
{ path: path.join(stateDir, 'license.key'), scope: 'state', optional: true },
|
|
92
|
+
{ path: path.join(stateDir, 'trial.json'), scope: 'state', optional: true },
|
|
91
93
|
{ path: path.join(stateDir, 'nexus-daemon.lock.json'), scope: 'runtime', optional: true },
|
|
92
94
|
];
|
|
93
95
|
}
|
package/dist/phantom/runtime.js
CHANGED
|
@@ -2782,8 +2782,8 @@ export class SubAgentRuntime {
|
|
|
2782
2782
|
return;
|
|
2783
2783
|
const runtimeDir = path.join(worktreeDir, '.agent', 'runtime');
|
|
2784
2784
|
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
2785
|
-
|
|
2786
|
-
|
|
2785
|
+
fs.writeFileSync(path.join(runtimeDir, 'packet.json'), JSON.stringify(packet, null, 2), 'utf8');
|
|
2786
|
+
fs.writeFileSync(path.join(runtimeDir, 'packet.md'), renderInstructionPacketMarkdown(packet), 'utf8');
|
|
2787
2787
|
}
|
|
2788
2788
|
gatherSkillBindings(skills, allowMutateSkills) {
|
|
2789
2789
|
return skills.flatMap((skill) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexus-prime",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.4.0",
|
|
4
4
|
"description": "Local-first MCP control plane for coding agents with bootstrap-orchestrate execution, memory fabric, token budgeting, and worktree-backed swarms",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|