ziprin-context-optimizer 8.0.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 +48 -0
- package/bin/ziprin-context-mcp.js +34 -0
- package/bin/ziprin-context-setup.js +10 -0
- package/install-source.json +5 -0
- package/media/context-activity.svg +7 -0
- package/package.json +154 -0
- package/releases/ziprin-context-optimizer-8.0.0.vsix +0 -0
- package/scripts/install.mjs +204 -0
- package/scripts/package-vsix.mjs +38 -0
- package/scripts/paths.mjs +40 -0
- package/scripts/publish.mjs +37 -0
- package/scripts/update.mjs +101 -0
- package/src/adaptive-budget.js +46 -0
- package/src/analyzer.js +456 -0
- package/src/audit-history.js +137 -0
- package/src/bm25.js +132 -0
- package/src/collector.js +343 -0
- package/src/compression.js +67 -0
- package/src/config.js +28 -0
- package/src/context-memory.js +151 -0
- package/src/context-profiles.js +124 -0
- package/src/dependency-graph.js +90 -0
- package/src/estimate.js +109 -0
- package/src/eval-harness.js +76 -0
- package/src/extension.js +322 -0
- package/src/firewall.js +35 -0
- package/src/fts-index.js +319 -0
- package/src/gateway-api.js +404 -0
- package/src/git-recency.js +43 -0
- package/src/glob.js +42 -0
- package/src/identifier.js +37 -0
- package/src/inspector-panel.js +470 -0
- package/src/language.js +157 -0
- package/src/mcp-event-stream.js +179 -0
- package/src/mcp-health-cli.js +34 -0
- package/src/mcp-lifecycle-manager.js +427 -0
- package/src/mcp-server.js +372 -0
- package/src/mmr.js +57 -0
- package/src/pagerank.js +176 -0
- package/src/profiles.js +74 -0
- package/src/pruner.js +130 -0
- package/src/quality-guard.js +122 -0
- package/src/query-rewrite.js +32 -0
- package/src/relevance-scorer.js +197 -0
- package/src/repo-map.js +134 -0
- package/src/retrieve.js +350 -0
- package/src/serena.js +126 -0
- package/src/session-ledger.js +83 -0
- package/src/session-store.js +78 -0
- package/src/skeleton.js +129 -0
- package/src/slice-pack.js +70 -0
- package/src/supervisor.js +113 -0
- package/src/task-analyzer.js +189 -0
- package/src/tool-router.js +92 -0
- package/src/version.js +5 -0
- package/templates/ziprin-context-mcp.mjs +68 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP Event Stream — runtime publishes; dashboard only observes.
|
|
5
|
+
* File: .cursor/context-events.jsonl (append-only JSONL)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const EVENTS_REL = '.cursor/context-events.jsonl';
|
|
12
|
+
const MAX_EVENTS = 400;
|
|
13
|
+
|
|
14
|
+
const EVENT_TYPES = Object.freeze({
|
|
15
|
+
server_started: 'server_started',
|
|
16
|
+
server_connected: 'server_connected',
|
|
17
|
+
tool_registered: 'tool_registered',
|
|
18
|
+
context_analysis_started: 'context_analysis_started',
|
|
19
|
+
context_analysis_finished: 'context_analysis_finished',
|
|
20
|
+
files_selected: 'files_selected',
|
|
21
|
+
compression_finished: 'compression_finished',
|
|
22
|
+
health_changed: 'health_changed',
|
|
23
|
+
server_failed: 'server_failed',
|
|
24
|
+
server_recovered: 'server_recovered',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function eventsPath(root) {
|
|
28
|
+
return path.join(root, EVENTS_REL);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function publishEvent(root, partial) {
|
|
32
|
+
if (!root) return null;
|
|
33
|
+
const event = {
|
|
34
|
+
service: partial.service || 'ziprin-context',
|
|
35
|
+
type: partial.type || 'unknown',
|
|
36
|
+
status: partial.status || null,
|
|
37
|
+
health: partial.health || null,
|
|
38
|
+
tools: partial.tools ?? null,
|
|
39
|
+
toolsCount: partial.toolsCount ?? (Array.isArray(partial.tools) ? partial.tools.length : null),
|
|
40
|
+
timestamp: partial.timestamp || new Date().toISOString(),
|
|
41
|
+
...partial,
|
|
42
|
+
};
|
|
43
|
+
// Keep payload lean for observer
|
|
44
|
+
delete event.root;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const file = eventsPath(root);
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.appendFileSync(file, JSON.stringify(event) + '\n', 'utf8');
|
|
50
|
+
trimEvents(file);
|
|
51
|
+
return event;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function trimEvents(file) {
|
|
58
|
+
try {
|
|
59
|
+
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
60
|
+
if (lines.length <= MAX_EVENTS) return;
|
|
61
|
+
const kept = lines.slice(-MAX_EVENTS);
|
|
62
|
+
fs.writeFileSync(file, kept.join('\n') + '\n', 'utf8');
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readEvents(root, limit = 80) {
|
|
69
|
+
const file = eventsPath(root);
|
|
70
|
+
if (!fs.existsSync(file)) return [];
|
|
71
|
+
try {
|
|
72
|
+
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
73
|
+
const slice = lines.slice(-limit);
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const line of slice) {
|
|
76
|
+
try {
|
|
77
|
+
out.push(JSON.parse(line));
|
|
78
|
+
} catch {
|
|
79
|
+
/* skip bad line */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function latestByService(events) {
|
|
89
|
+
const map = {};
|
|
90
|
+
for (const e of events) {
|
|
91
|
+
const name = e.service || 'unknown';
|
|
92
|
+
map[name] = e;
|
|
93
|
+
}
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Derive observer MCP services model from event stream (+ optional health snapshot).
|
|
99
|
+
*/
|
|
100
|
+
function servicesFromEvents(events, healthReport) {
|
|
101
|
+
const by = latestByService(events);
|
|
102
|
+
const healthServers = new Map((healthReport?.servers || []).map((s) => [s.name, s]));
|
|
103
|
+
|
|
104
|
+
const names = ['ziprin-context', 'serena', 'TypeUI'];
|
|
105
|
+
const services = names.map((name) => {
|
|
106
|
+
const ev = by[name];
|
|
107
|
+
const hs = healthServers.get(name);
|
|
108
|
+
const raw =
|
|
109
|
+
ev?.status ||
|
|
110
|
+
ev?.health ||
|
|
111
|
+
hs?.state ||
|
|
112
|
+
'unknown';
|
|
113
|
+
const normalized =
|
|
114
|
+
raw === 'healthy' || raw === 'connected'
|
|
115
|
+
? 'connected'
|
|
116
|
+
: raw === 'failed'
|
|
117
|
+
? 'failed'
|
|
118
|
+
: raw === 'restarting' || raw === 'starting'
|
|
119
|
+
? 'restarting'
|
|
120
|
+
: raw === 'degraded'
|
|
121
|
+
? 'degraded'
|
|
122
|
+
: 'unknown';
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
priority: name === 'TypeUI' ? 'required' : 'critical',
|
|
127
|
+
state: normalized,
|
|
128
|
+
label:
|
|
129
|
+
normalized === 'connected'
|
|
130
|
+
? 'Connected'
|
|
131
|
+
: normalized === 'failed'
|
|
132
|
+
? 'Failed'
|
|
133
|
+
: normalized === 'unknown'
|
|
134
|
+
? 'Unknown'
|
|
135
|
+
: 'Recovering',
|
|
136
|
+
toolsCount: ev?.toolsCount ?? hs?.toolsCount ?? null,
|
|
137
|
+
lastHeartbeat: ev?.timestamp || hs?.lastHeartbeat || null,
|
|
138
|
+
lastRestart: hs?.retry?.lastAttempt || null,
|
|
139
|
+
lastAnalysis:
|
|
140
|
+
events
|
|
141
|
+
.filter((e) => e.service === name && e.type === EVENT_TYPES.context_analysis_finished)
|
|
142
|
+
.slice(-1)[0]?.timestamp || null,
|
|
143
|
+
lastError:
|
|
144
|
+
events
|
|
145
|
+
.filter((e) => e.service === name && (e.type === EVENT_TYPES.server_failed || e.error))
|
|
146
|
+
.slice(-1)[0]?.error ||
|
|
147
|
+
hs?.error ||
|
|
148
|
+
null,
|
|
149
|
+
error: hs?.error || (normalized === 'failed' ? ev?.error : null) || null,
|
|
150
|
+
details: { ...(hs?.details || {}), lastEventType: ev?.type || null },
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const critical = services.filter((s) => s.priority === 'critical');
|
|
155
|
+
const criticalFailed = critical.some((s) => s.state === 'failed');
|
|
156
|
+
const criticalDegraded = critical.some(
|
|
157
|
+
(s) => s.state === 'degraded' || s.state === 'restarting' || s.state === 'unknown'
|
|
158
|
+
);
|
|
159
|
+
const criticalOk = critical.every((s) => s.state === 'connected');
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
overall: criticalFailed ? 'failed' : criticalOk ? 'connected' : criticalDegraded ? 'degraded' : 'unknown',
|
|
163
|
+
checkedAt: new Date().toISOString(),
|
|
164
|
+
mcpConfigOk: healthReport?.mcpConfigOk !== false,
|
|
165
|
+
source: 'event-stream',
|
|
166
|
+
services,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
EVENTS_REL,
|
|
172
|
+
EVENT_TYPES,
|
|
173
|
+
MAX_EVENTS,
|
|
174
|
+
eventsPath,
|
|
175
|
+
publishEvent,
|
|
176
|
+
readEvents,
|
|
177
|
+
latestByService,
|
|
178
|
+
servicesFromEvents,
|
|
179
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plugin-local MCP health CLI (CJS).
|
|
6
|
+
* node src/mcp-health-cli.js [--json] [--workspace <path>]
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { runHealthCheck } = require('./mcp-lifecycle-manager');
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
let workspace = process.env.ZIPRIN_WORKSPACE || process.cwd();
|
|
14
|
+
let json = false;
|
|
15
|
+
for (let i = 2; i < argv.length; i++) {
|
|
16
|
+
if (argv[i] === '--json') json = true;
|
|
17
|
+
if (argv[i] === '--workspace' && argv[i + 1]) workspace = path.resolve(argv[++i]);
|
|
18
|
+
}
|
|
19
|
+
return { workspace, json };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { workspace, json } = parseArgs(process.argv);
|
|
23
|
+
const report = runHealthCheck(workspace, { persist: true });
|
|
24
|
+
|
|
25
|
+
if (json) {
|
|
26
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
27
|
+
} else {
|
|
28
|
+
console.error('[ziprin-mcp-health]', 'overall=', report.overall);
|
|
29
|
+
for (const s of report.servers) {
|
|
30
|
+
console.error(` ${s.name}: ${s.state}${s.error ? ' — ' + s.error : ''}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
process.exit(report.overall === 'failed' ? 1 : 0);
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP Lifecycle Manager — observability + config validation + recovery bookkeeping.
|
|
5
|
+
* Cursor owns actual MCP process spawn; this module makes failures diagnosable
|
|
6
|
+
* and drives the Inspector dashboard without replacing Cursor Chat.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const HEALTH_REL = '.cursor/ziprin-mcp-health.json';
|
|
15
|
+
const MCP_JSON_REL = '.cursor/mcp.json';
|
|
16
|
+
|
|
17
|
+
const STATES = Object.freeze({
|
|
18
|
+
starting: 'starting',
|
|
19
|
+
connected: 'connected',
|
|
20
|
+
degraded: 'degraded',
|
|
21
|
+
failed: 'failed',
|
|
22
|
+
restarting: 'restarting',
|
|
23
|
+
unknown: 'unknown',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const MANAGED = [
|
|
27
|
+
{ name: 'ziprin-context', priority: 'critical' },
|
|
28
|
+
{ name: 'serena', priority: 'critical' },
|
|
29
|
+
{ name: 'TypeUI', priority: 'required', optionalInProject: true },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const DEFAULT_RETRY = {
|
|
33
|
+
maxRetry: 5,
|
|
34
|
+
baseDelayMs: 500,
|
|
35
|
+
strategy: 'exponential_backoff',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function healthPath(root) {
|
|
39
|
+
return path.join(root, HEALTH_REL);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadHealth(root) {
|
|
43
|
+
const file = healthPath(root);
|
|
44
|
+
if (!fs.existsSync(file)) return null;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function saveHealth(root, report) {
|
|
53
|
+
fs.mkdirSync(path.dirname(healthPath(root)), { recursive: true });
|
|
54
|
+
fs.writeFileSync(healthPath(root), JSON.stringify(report, null, 2), 'utf8');
|
|
55
|
+
return report;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readMcpConfig(root) {
|
|
59
|
+
const file = path.join(root, MCP_JSON_REL);
|
|
60
|
+
if (!fs.existsSync(file)) {
|
|
61
|
+
return { ok: false, error: `Missing ${MCP_JSON_REL}`, servers: {} };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
65
|
+
if (/C:\/Users\/Ziprin\/Desktop/i.test(raw) || /C:\\\\Users\\\\Ziprin\\\\Desktop/i.test(raw)) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: 'mcp.json contains absolute Desktop paths — use ${workspaceFolder}',
|
|
69
|
+
servers: {},
|
|
70
|
+
absolutePathLeak: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const json = JSON.parse(raw);
|
|
74
|
+
return { ok: true, servers: json.mcpServers || {}, path: file };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { ok: false, error: err.message, servers: {} };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function expandWorkspaceArg(arg, root) {
|
|
81
|
+
return String(arg || '').replace(/\$\{workspaceFolder\}/g, root.replace(/\\/g, '/'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveZiprinPluginRoot(monorepoRoot) {
|
|
85
|
+
if (process.env.ZIPRIN_CONTEXT_PLUGIN) {
|
|
86
|
+
return path.resolve(process.env.ZIPRIN_CONTEXT_PLUGIN);
|
|
87
|
+
}
|
|
88
|
+
const manifestFile = path.join(os.homedir(), '.ziprin', 'ziprin-context-install.json');
|
|
89
|
+
if (fs.existsSync(manifestFile)) {
|
|
90
|
+
try {
|
|
91
|
+
const m = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
92
|
+
if (m.pluginRoot && fs.existsSync(path.join(m.pluginRoot, 'src', 'mcp-server.js'))) {
|
|
93
|
+
return m.pluginRoot;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const ziprinHome = path.join(os.homedir(), '.ziprin', 'ziprin-context-optimizer');
|
|
100
|
+
if (fs.existsSync(path.join(ziprinHome, 'src', 'mcp-server.js'))) return ziprinHome;
|
|
101
|
+
const sibling = path.resolve(monorepoRoot, '../plugin/ziprin-context-optimizer');
|
|
102
|
+
if (fs.existsSync(path.join(sibling, 'src', 'mcp-server.js'))) return sibling;
|
|
103
|
+
const vendored = path.resolve(monorepoRoot, 'tooling/ziprin-context-mcp/vendor');
|
|
104
|
+
if (fs.existsSync(path.join(vendored, 'src', 'mcp-server.js'))) return vendored;
|
|
105
|
+
return ziprinHome;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveZiprinLauncher(root, cfgEntry) {
|
|
109
|
+
const fromCfg = (cfgEntry?.args || []).map((a) => expandWorkspaceArg(a, root)).find((a) =>
|
|
110
|
+
/ziprin-context/i.test(a),
|
|
111
|
+
);
|
|
112
|
+
const candidates = [
|
|
113
|
+
fromCfg,
|
|
114
|
+
path.join(root, '.cursor/ziprin-context-mcp.mjs'),
|
|
115
|
+
path.join(root, 'tooling/ziprin-context-mcp/run.mjs'),
|
|
116
|
+
].filter(Boolean);
|
|
117
|
+
for (const c of candidates) {
|
|
118
|
+
if (fs.existsSync(c)) return c;
|
|
119
|
+
}
|
|
120
|
+
return candidates[0] || path.join(root, '.cursor/ziprin-context-mcp.mjs');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function probeZiprinContext(root, cfgEntry) {
|
|
124
|
+
const result = {
|
|
125
|
+
name: 'ziprin-context',
|
|
126
|
+
priority: 'critical',
|
|
127
|
+
state: STATES.starting,
|
|
128
|
+
toolsExpected: [
|
|
129
|
+
'ziprin_optimize_context',
|
|
130
|
+
'ziprin_health',
|
|
131
|
+
'ziprin_analyze_task',
|
|
132
|
+
'ziprin_rank_context',
|
|
133
|
+
],
|
|
134
|
+
toolsCount: null,
|
|
135
|
+
lastHeartbeat: null,
|
|
136
|
+
lastRestart: null,
|
|
137
|
+
error: null,
|
|
138
|
+
details: {},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const launcher = resolveZiprinLauncher(root, cfgEntry);
|
|
143
|
+
const pluginRoot = resolveZiprinPluginRoot(root);
|
|
144
|
+
const serverEntry = path.join(pluginRoot, 'src', 'mcp-server.js');
|
|
145
|
+
const sdk = path.join(pluginRoot, 'node_modules', '@modelcontextprotocol', 'sdk');
|
|
146
|
+
|
|
147
|
+
result.details = {
|
|
148
|
+
launcher,
|
|
149
|
+
pluginRoot,
|
|
150
|
+
serverEntry,
|
|
151
|
+
node: process.version,
|
|
152
|
+
configCommand: cfgEntry?.command || null,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (!fs.existsSync(serverEntry)) {
|
|
156
|
+
result.state = STATES.failed;
|
|
157
|
+
result.error = `MCP server missing: ${serverEntry}. Run: npx @ziprin/context-optimizer setup --workspace "${root}"`;
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
if (!fs.existsSync(sdk)) {
|
|
161
|
+
result.state = STATES.failed;
|
|
162
|
+
result.error = `MCP SDK not installed in plugin. Run npm install in ${pluginRoot}`;
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Syntax / load probe (does not keep MCP running)
|
|
167
|
+
const probe = spawnSync(
|
|
168
|
+
process.execPath,
|
|
169
|
+
[
|
|
170
|
+
'-e',
|
|
171
|
+
`const api=require(${JSON.stringify(path.join(pluginRoot, 'src/gateway-api.js').replace(/\\/g, '/'))});` +
|
|
172
|
+
`const h=api.healthInfo(${JSON.stringify(root.replace(/\\/g, '/'))});` +
|
|
173
|
+
`process.stdout.write(JSON.stringify({ok:true,version:h.version,status:h.status}));`,
|
|
174
|
+
],
|
|
175
|
+
{ encoding: 'utf8', timeout: 15000, windowsHide: true },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (probe.error || probe.status !== 0) {
|
|
179
|
+
result.state = STATES.failed;
|
|
180
|
+
result.error = probe.stderr || probe.error?.message || `probe exit ${probe.status}`;
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let health;
|
|
185
|
+
try {
|
|
186
|
+
health = JSON.parse(probe.stdout.trim());
|
|
187
|
+
} catch {
|
|
188
|
+
result.state = STATES.degraded;
|
|
189
|
+
result.error = 'health probe returned non-JSON';
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
result.details.engineVersion = health.version;
|
|
194
|
+
result.details.engineStatus = health.status;
|
|
195
|
+
result.toolsCount = result.toolsExpected.length;
|
|
196
|
+
result.lastHeartbeat = new Date().toISOString();
|
|
197
|
+
result.state = health.status === 'ready' ? STATES.connected : STATES.degraded;
|
|
198
|
+
|
|
199
|
+
// Session activity as soft heartbeat
|
|
200
|
+
const sessionsFile = path.join(root, '.cursor/context-inspector-sessions.jsonl');
|
|
201
|
+
if (fs.existsSync(sessionsFile)) {
|
|
202
|
+
result.details.lastSessionWrite = fs.statSync(sessionsFile).mtime.toISOString();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return result;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
result.state = STATES.failed;
|
|
208
|
+
result.error = err.message;
|
|
209
|
+
result.details.stack = err.stack;
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function probeSerena(root, cfgEntry) {
|
|
215
|
+
const result = {
|
|
216
|
+
name: 'serena',
|
|
217
|
+
priority: 'critical',
|
|
218
|
+
state: STATES.unknown,
|
|
219
|
+
toolsCount: null,
|
|
220
|
+
lastHeartbeat: null,
|
|
221
|
+
lastRestart: null,
|
|
222
|
+
error: null,
|
|
223
|
+
details: { symbolTools: 'configured_in_mcp_json' },
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (!cfgEntry) {
|
|
227
|
+
result.state = STATES.failed;
|
|
228
|
+
result.error = 'serena missing from .cursor/mcp.json';
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
result.details.command = cfgEntry.command;
|
|
233
|
+
result.details.argsPreview = (cfgEntry.args || []).slice(0, 4);
|
|
234
|
+
|
|
235
|
+
// Cursor owns the process. Config presence + portable args = connected/config-ready.
|
|
236
|
+
const argsJoined = JSON.stringify(cfgEntry.args || []);
|
|
237
|
+
if (/C:\/Users\/Ziprin\/Desktop/i.test(argsJoined)) {
|
|
238
|
+
result.state = STATES.degraded;
|
|
239
|
+
result.error = 'Serena args use absolute Desktop paths — prefer ${workspaceFolder}';
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
result.state = STATES.connected;
|
|
244
|
+
result.lastHeartbeat = new Date().toISOString();
|
|
245
|
+
result.details.symbolTools = 'available_when_cursor_connected';
|
|
246
|
+
result.details.note = 'Process lifecycle owned by Cursor MCP host';
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function probeTypeUI(root, cfgEntry) {
|
|
251
|
+
const result = {
|
|
252
|
+
name: 'TypeUI',
|
|
253
|
+
priority: 'required',
|
|
254
|
+
state: STATES.unknown,
|
|
255
|
+
toolsCount: null,
|
|
256
|
+
lastHeartbeat: null,
|
|
257
|
+
lastRestart: null,
|
|
258
|
+
error: null,
|
|
259
|
+
details: { note: 'Often registered as user-level MCP (user-TypeUI)' },
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (cfgEntry) {
|
|
263
|
+
result.state = STATES.connected;
|
|
264
|
+
result.details.inProjectMcp = true;
|
|
265
|
+
result.lastHeartbeat = new Date().toISOString();
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Not in project mcp.json — mark degraded (required but user-scoped)
|
|
270
|
+
result.state = STATES.degraded;
|
|
271
|
+
result.error = 'Not in project .cursor/mcp.json — enable in Cursor user MCP if needed';
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function applyRetryState(prev, next, policy = DEFAULT_RETRY) {
|
|
276
|
+
const retries = { ...(prev?.retries || {}) };
|
|
277
|
+
const name = next.name;
|
|
278
|
+
const entry = retries[name] || { count: 0, lastAttempt: null };
|
|
279
|
+
|
|
280
|
+
if (next.state === STATES.failed || next.state === STATES.degraded) {
|
|
281
|
+
entry.count = Math.min(policy.maxRetry, (entry.count || 0) + 1);
|
|
282
|
+
entry.lastAttempt = new Date().toISOString();
|
|
283
|
+
entry.nextDelayMs = Math.min(30_000, policy.baseDelayMs * 2 ** Math.max(0, entry.count - 1));
|
|
284
|
+
if (entry.count < policy.maxRetry && next.state === STATES.failed) {
|
|
285
|
+
next.state = STATES.restarting;
|
|
286
|
+
}
|
|
287
|
+
} else if (next.state === STATES.connected) {
|
|
288
|
+
entry.count = 0;
|
|
289
|
+
entry.nextDelayMs = 0;
|
|
290
|
+
}
|
|
291
|
+
retries[name] = entry;
|
|
292
|
+
next.retry = entry;
|
|
293
|
+
return { next, retries };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Full health scan for workspace.
|
|
298
|
+
*/
|
|
299
|
+
function runHealthCheck(root, opts = {}) {
|
|
300
|
+
const startedAt = new Date().toISOString();
|
|
301
|
+
const prev = loadHealth(root);
|
|
302
|
+
const cfg = readMcpConfig(root);
|
|
303
|
+
const servers = [];
|
|
304
|
+
|
|
305
|
+
const ziprinCfg = cfg.servers['ziprin-context'];
|
|
306
|
+
const serenaCfg = cfg.servers.serena;
|
|
307
|
+
const typeuiCfg = cfg.servers.TypeUI || cfg.servers.typeui || cfg.servers['user-TypeUI'];
|
|
308
|
+
|
|
309
|
+
let retries = prev?.retries || {};
|
|
310
|
+
|
|
311
|
+
let z = probeZiprinContext(root, ziprinCfg);
|
|
312
|
+
({ next: z, retries } = applyRetryState({ retries }, z, opts.retryPolicy));
|
|
313
|
+
servers.push(z);
|
|
314
|
+
|
|
315
|
+
let s = probeSerena(root, serenaCfg);
|
|
316
|
+
({ next: s, retries } = applyRetryState({ retries }, s, opts.retryPolicy));
|
|
317
|
+
servers.push(s);
|
|
318
|
+
|
|
319
|
+
let t = probeTypeUI(root, typeuiCfg);
|
|
320
|
+
({ next: t, retries } = applyRetryState({ retries }, t, opts.retryPolicy));
|
|
321
|
+
servers.push(t);
|
|
322
|
+
|
|
323
|
+
const criticalFailed = servers
|
|
324
|
+
.filter((x) => x.priority === 'critical')
|
|
325
|
+
.some((x) => x.state === STATES.failed);
|
|
326
|
+
const anyDegraded = servers.some((x) => x.state === STATES.degraded || x.state === STATES.restarting);
|
|
327
|
+
|
|
328
|
+
const report = {
|
|
329
|
+
version: 1,
|
|
330
|
+
checkedAt: startedAt,
|
|
331
|
+
workspace: root,
|
|
332
|
+
mcpConfigOk: cfg.ok,
|
|
333
|
+
mcpConfigError: cfg.error || null,
|
|
334
|
+
overall: !cfg.ok || criticalFailed ? STATES.failed : anyDegraded ? STATES.degraded : STATES.connected,
|
|
335
|
+
servers,
|
|
336
|
+
retries,
|
|
337
|
+
logs: [
|
|
338
|
+
`node=${process.version}`,
|
|
339
|
+
`platform=${process.platform}`,
|
|
340
|
+
`mcp.json ok=${cfg.ok}`,
|
|
341
|
+
...servers.map((x) => `${x.name}=${x.state}${x.error ? ' err=' + x.error : ''}`),
|
|
342
|
+
],
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
if (opts.persist !== false) saveHealth(root, report);
|
|
346
|
+
|
|
347
|
+
// Publish for Inspector observers (extension does not own lifecycle).
|
|
348
|
+
try {
|
|
349
|
+
const { publishEvent, EVENT_TYPES } = require('./mcp-event-stream');
|
|
350
|
+
for (const s of servers) {
|
|
351
|
+
publishEvent(root, {
|
|
352
|
+
service: s.name,
|
|
353
|
+
type: EVENT_TYPES.health_changed,
|
|
354
|
+
status: s.state,
|
|
355
|
+
health: s.state === 'connected' ? 'healthy' : s.state,
|
|
356
|
+
toolsCount: s.toolsCount,
|
|
357
|
+
error: s.error || null,
|
|
358
|
+
overall: report.overall,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
/* observer stream optional */
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return report;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function dashboardServicesModel(report) {
|
|
369
|
+
if (!report) {
|
|
370
|
+
return {
|
|
371
|
+
overall: STATES.unknown,
|
|
372
|
+
services: MANAGED.map((m) => ({
|
|
373
|
+
name: m.name,
|
|
374
|
+
priority: m.priority,
|
|
375
|
+
state: STATES.unknown,
|
|
376
|
+
label: 'Unknown',
|
|
377
|
+
toolsCount: null,
|
|
378
|
+
lastHeartbeat: null,
|
|
379
|
+
lastRestart: null,
|
|
380
|
+
error: null,
|
|
381
|
+
})),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const labelFor = (state) => {
|
|
386
|
+
if (state === STATES.connected) return 'Connected';
|
|
387
|
+
if (state === STATES.restarting || state === STATES.starting) return 'Recovering';
|
|
388
|
+
if (state === STATES.degraded) return 'Recovering';
|
|
389
|
+
if (state === STATES.failed) return 'Failed';
|
|
390
|
+
return 'Unknown';
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
overall: report.overall,
|
|
395
|
+
checkedAt: report.checkedAt,
|
|
396
|
+
mcpConfigOk: report.mcpConfigOk,
|
|
397
|
+
services: report.servers.map((s) => ({
|
|
398
|
+
name: s.name,
|
|
399
|
+
priority: s.priority,
|
|
400
|
+
state: s.state,
|
|
401
|
+
label: labelFor(s.state),
|
|
402
|
+
toolsCount: s.toolsCount,
|
|
403
|
+
lastHeartbeat: s.lastHeartbeat,
|
|
404
|
+
lastRestart: s.retry?.lastAttempt || null,
|
|
405
|
+
error: s.error,
|
|
406
|
+
details: s.details,
|
|
407
|
+
})),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
module.exports = {
|
|
412
|
+
HEALTH_REL,
|
|
413
|
+
MCP_JSON_REL,
|
|
414
|
+
STATES,
|
|
415
|
+
MANAGED,
|
|
416
|
+
DEFAULT_RETRY,
|
|
417
|
+
loadHealth,
|
|
418
|
+
saveHealth,
|
|
419
|
+
readMcpConfig,
|
|
420
|
+
resolveZiprinPluginRoot,
|
|
421
|
+
expandWorkspaceArg,
|
|
422
|
+
probeZiprinContext,
|
|
423
|
+
probeSerena,
|
|
424
|
+
probeTypeUI,
|
|
425
|
+
runHealthCheck,
|
|
426
|
+
dashboardServicesModel,
|
|
427
|
+
};
|