@qe-mcp/server-ena 0.1.1

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/server.js ADDED
@@ -0,0 +1,1234 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ena-mcp-wasm — MCP server for Epistemic Network Analysis
4
+ *
5
+ * Zero-Python WASM backend using @qe-libs/rena-wasm.
6
+ * Tools:
7
+ * ena_inspect — triage tabular data for ENA suitability
8
+ * ena_fit — full pipeline; returns reusable model_id
9
+ * ena_accumulate — accumulation step only
10
+ * ena_compare_groups — mean-rotation comparison of two groups; returns model_id
11
+ * ena_plot — render any view from a cached model_id
12
+ */
13
+
14
+ import { readFileSync, mkdirSync, writeFileSync } from 'fs';
15
+ import { tmpdir } from 'os';
16
+ import { join } from 'path';
17
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import {
20
+ CallToolRequestSchema,
21
+ ListToolsRequestSchema,
22
+ } from '@modelcontextprotocol/sdk/types.js';
23
+ import loadENA from '@qe-libs/rena-wasm';
24
+
25
+ // Path to the qeviz UMD bundle (self-contained, no external deps)
26
+ import { createRequire } from 'module';
27
+ const _require = createRequire(import.meta.url);
28
+ const QEVIZ_BUNDLE = _require.resolve('@qe-libs/qeviz');
29
+ let _qevizJS = null;
30
+ function qevizJS() {
31
+ if (!_qevizJS) _qevizJS = readFileSync(QEVIZ_BUNDLE, 'utf8');
32
+ return _qevizJS;
33
+ }
34
+
35
+ // ── CSV parser ────────────────────────────────────────────────────────────────
36
+
37
+ function parseLine(line) {
38
+ const fields = [];
39
+ let cur = '', inQ = false;
40
+ for (let i = 0; i < line.length; i++) {
41
+ const ch = line[i];
42
+ if (ch === '"') {
43
+ if (inQ && line[i + 1] === '"') { cur += '"'; i++; }
44
+ else { inQ = !inQ; }
45
+ } else if (ch === ',' && !inQ) { fields.push(cur); cur = ''; }
46
+ else { cur += ch; }
47
+ }
48
+ fields.push(cur);
49
+ return fields;
50
+ }
51
+
52
+ function parseCSV(text) {
53
+ const lines = text.trim().split(/\r?\n/);
54
+ const headers = parseLine(lines[0]);
55
+ return lines.slice(1).filter(l => l.trim()).map(line => {
56
+ const vals = parseLine(line);
57
+ const row = {};
58
+ headers.forEach((h, i) => { row[h.trim()] = vals[i] ?? ''; });
59
+ return row;
60
+ });
61
+ }
62
+
63
+ function loadData(dataPath, dataCsv) {
64
+ if (dataPath) return parseCSV(readFileSync(dataPath, 'utf8'));
65
+ if (dataCsv) return parseCSV(dataCsv);
66
+ throw new Error('Provide either data_path (path to CSV) or data_csv (raw CSV string).');
67
+ }
68
+
69
+ // ── Result helpers ────────────────────────────────────────────────────────────
70
+
71
+ const R = (v, places = 6) => Math.round(v * 10 ** places) / 10 ** places;
72
+
73
+ function toRecords(fa, nRows, nCols, labels, prefix = 'dim') {
74
+ return Array.from({ length: nRows }, (_, i) => {
75
+ const rec = { label: labels[i] };
76
+ for (let j = 0; j < nCols; j++)
77
+ rec[`${prefix}${j + 1}`] = R(fa[i * nCols + j]);
78
+ return rec;
79
+ });
80
+ }
81
+
82
+ // Matches rena-wasm's rowKey() — separator must stay '__'
83
+ function rowKey(row, cols) {
84
+ return cols.map(c => row[c]).join('__');
85
+ }
86
+
87
+ function buildUnitGroupMap(rows, unitCols, groupCol) {
88
+ const map = new Map();
89
+ for (const row of rows) {
90
+ const key = rowKey(row, unitCols);
91
+ if (!map.has(key)) map.set(key, String(row[groupCol]));
92
+ }
93
+ return map;
94
+ }
95
+
96
+ // ── Column-role heuristics ────────────────────────────────────────────────────
97
+
98
+ const UNIT_HINTS = new Set(['user','student','participant','speaker','person',
99
+ 'author','subject','respondent','id','name','who']);
100
+ const CONVO_HINTS = new Set(['group','condition','session','conversation','convo',
101
+ 'episode','class','team','day','week','period',
102
+ 'context','case','trial']);
103
+ const EXCLUDE = new Set(['timestamp','time','date','text','utterance',
104
+ 'message','content','line','row','index','notes']);
105
+
106
+ function isBinary(rows, col) {
107
+ const vals = new Set(rows.map(r => r[col]).filter(v => v !== '' && v != null));
108
+ return [...vals].every(v => v === '0' || v === '1' || v === 0 || v === 1);
109
+ }
110
+
111
+ function scoreUnit(col, nUnique, nRows) {
112
+ const name = col.toLowerCase();
113
+ if (nUnique < 2 || nUnique > Math.min(nRows * 0.8, 500)) return 0;
114
+ let s = 0.3;
115
+ if ([...UNIT_HINTS].some(h => name.includes(h))) s += 0.5;
116
+ if (nUnique >= 3 && nUnique <= 200) s += 0.2;
117
+ return Math.min(s, 1.0);
118
+ }
119
+
120
+ function scoreConvo(col, nUnique, nRows) {
121
+ const name = col.toLowerCase();
122
+ if (nUnique < 2 || nUnique > Math.min(nRows * 0.5, 300)) return 0;
123
+ let s = 0.2;
124
+ if ([...CONVO_HINTS].some(h => name.includes(h))) s += 0.6;
125
+ if (nUnique >= 2 && nUnique <= 100) s += 0.2;
126
+ return Math.min(s, 1.0);
127
+ }
128
+
129
+ // ── qeviz viz helpers ─────────────────────────────────────────────────────────
130
+
131
+ const edgeColName = (n) => n.replace(' & ', '.');
132
+
133
+ function buildQEVizData(model, unitLabels, codes, groupMap = null, groupCol = null) {
134
+ const { nUnits, nConnections, connectionNames } = model;
135
+
136
+ const nodes = {
137
+ data: codes.map((code, i) => ({
138
+ code,
139
+ x: model.positions[i * 2],
140
+ y: model.positions[i * 2 + 1],
141
+ })),
142
+ types: { code: 'character', x: 'numeric', y: 'numeric' },
143
+ };
144
+
145
+ const edgeTypes = { ENA_UNIT: 'character' };
146
+ if (groupCol) edgeTypes[groupCol] = 'character';
147
+ connectionNames.forEach(n => { edgeTypes[edgeColName(n)] = 'numeric'; });
148
+
149
+ const edges = {
150
+ data: unitLabels.map((lbl, i) => {
151
+ const row = { ENA_UNIT: lbl };
152
+ if (groupCol && groupMap) row[groupCol] = groupMap.get(lbl) ?? '';
153
+ connectionNames.forEach((n, j) => {
154
+ row[edgeColName(n)] = model.networks[i * nConnections + j];
155
+ });
156
+ return row;
157
+ }),
158
+ types: edgeTypes,
159
+ };
160
+
161
+ const pointTypes = { ENA_UNIT: 'character', x: 'numeric', y: 'numeric' };
162
+ if (groupCol) pointTypes[groupCol] = 'character';
163
+
164
+ const pointsFrame = {
165
+ data: unitLabels.map((lbl, i) => {
166
+ const row = { ENA_UNIT: lbl, x: model.centroids[i * 2], y: model.centroids[i * 2 + 1] };
167
+ if (groupCol && groupMap) row[groupCol] = groupMap.get(lbl) ?? '';
168
+ return row;
169
+ }),
170
+ types: pointTypes,
171
+ };
172
+
173
+ const modelData = {
174
+ directed: false,
175
+ updated: Date.now(),
176
+ id_col: 'ENA_UNIT',
177
+ node_id_col: 'code',
178
+ x_col: 'x',
179
+ y_col: 'y',
180
+ nodes,
181
+ edges,
182
+ points: pointsFrame,
183
+ };
184
+ if (groupCol) modelData.group_col = groupCol;
185
+ return modelData;
186
+ }
187
+
188
+ function generateHTML(modelData, title = 'ENA Visualization', opts = {}) {
189
+ const { group1, group2 } = opts;
190
+ let edgesEl = '';
191
+ if (group1 && group2) {
192
+ edgesEl = `<qe-edges group="${group1}" also="${group2}"></qe-edges>`;
193
+ } else if (group1) {
194
+ edgesEl = `<qe-edges group="${group1}"></qe-edges>`;
195
+ }
196
+ const pointsEl = `<qe-points label="auto"></qe-points>`;
197
+ const meansEl = group1 ? `<qe-means confidence></qe-means>` : '';
198
+
199
+ return `<!DOCTYPE html>
200
+ <html lang="en">
201
+ <head>
202
+ <meta charset="UTF-8">
203
+ <title>${title}</title>
204
+ <style>
205
+ * { box-sizing: border-box; margin: 0; padding: 0; }
206
+ body { font-family: system-ui, sans-serif; background: #f0f0f0; padding: 1rem; }
207
+ h1 { font-size: 1rem; color: #333; margin-bottom: .75rem; font-weight: 500; }
208
+ .viz-wrap { background: #fff; border: 1px solid #ddd; border-radius: 6px; overflow: hidden; }
209
+ qe-visual { display: block; width: 100%; height: 620px; }
210
+ qe-graph { display: block; width: 100%; height: 100%; }
211
+ </style>
212
+ </head>
213
+ <body>
214
+ <h1>${title}</h1>
215
+ <div class="viz-wrap">
216
+ <qe-visual id="vis">
217
+ <qe-graph width="100%" height="620" label-nodes="on">
218
+ ${edgesEl}
219
+ ${pointsEl}
220
+ ${meansEl}
221
+ </qe-graph>
222
+ </qe-visual>
223
+ </div>
224
+ <script>
225
+ ${qevizJS()}
226
+ </script>
227
+ <script>
228
+ const modelData = ${JSON.stringify(modelData)};
229
+ document.getElementById('vis').setModelData(modelData);
230
+ </script>
231
+ </body>
232
+ </html>`;
233
+ }
234
+
235
+ function writeVizFile(html, name) {
236
+ const dir = join(tmpdir(), 'ena-viz');
237
+ mkdirSync(dir, { recursive: true });
238
+ const path = join(dir, `${name}.html`);
239
+ writeFileSync(path, html, 'utf8');
240
+ return path;
241
+ }
242
+
243
+ // ── jsdom SVG renderer ────────────────────────────────────────────────────────
244
+
245
+ import { JSDOM } from 'jsdom';
246
+ import { Resvg } from '@resvg/resvg-js';
247
+
248
+ const SVG_SIZE = 600;
249
+
250
+ async function renderSVG(modelData, opts = {}) {
251
+ const { group1, group2 } = opts;
252
+
253
+ const edgesEl = group1 && group2
254
+ ? `<qe-edges group="${group1}" also="${group2}"></qe-edges>`
255
+ : `<qe-edges group="${group1}"></qe-edges>`;
256
+ const childEls = `${edgesEl}<qe-points label="auto"></qe-points>${(!opts._noMeans && group1) ? '<qe-means confidence></qe-means>' : ''}`;
257
+
258
+ const dom = new JSDOM(
259
+ `<!DOCTYPE html><body><qe-visual id="vis"><qe-graph id="g" width="${SVG_SIZE}" height="${SVG_SIZE}" label-nodes="on">${childEls}</qe-graph></qe-visual></body>`,
260
+ { runScripts: 'dangerously', pretendToBeVisual: true }
261
+ );
262
+ const { window } = dom;
263
+
264
+ window.Element.prototype.getBoundingClientRect = () =>
265
+ ({ x:0, y:0, width:SVG_SIZE, height:SVG_SIZE, top:0, left:0, right:SVG_SIZE, bottom:SVG_SIZE });
266
+ window.requestAnimationFrame = cb => setTimeout(cb, 0);
267
+
268
+ const toStderr = (...a) => process.stderr.write(a.map(String).join(' ') + '\n');
269
+ window.console = { log: toStderr, info: toStderr, warn: toStderr, error: toStderr, debug: () => {} };
270
+
271
+ const s = window.document.createElement('script');
272
+ s.textContent = qevizJS();
273
+ window.document.head.appendChild(s);
274
+
275
+ window.document.getElementById('vis').setModelData(modelData);
276
+ await new Promise(r => setTimeout(r, 200));
277
+
278
+ const svgEl = window.document.getElementById('g')?.svg_element;
279
+ if (!svgEl) return null;
280
+
281
+ let svgHtml = svgEl.outerHTML;
282
+ // jsdom omits the SVG namespace declaration; resvg requires it to parse the document
283
+ if (!svgHtml.includes('xmlns=')) {
284
+ svgHtml = svgHtml.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
285
+ }
286
+ return svgHtml;
287
+ }
288
+
289
+ // ── Model / accumulation cache ────────────────────────────────────────────────
290
+
291
+ const accumCache = new Map(); // accumKey → { acc, rows }
292
+ const modelCache = new Map(); // model_id → cache entry
293
+ const fitCache = new Map(); // fitKey → model_id
294
+ let modelSeq = 0;
295
+
296
+ function accumCacheKey(p) {
297
+ return JSON.stringify({
298
+ data: p.data_path ?? p.data_csv,
299
+ units: p.units,
300
+ conversations: p.conversations,
301
+ codes: [...p.codes].sort(),
302
+ window_size: p.window_size ?? 4,
303
+ binary: p.binary ?? true,
304
+ });
305
+ }
306
+
307
+ function fitCacheKey(p) {
308
+ return JSON.stringify({
309
+ data: p.data_path ?? p.data_csv,
310
+ units: p.units,
311
+ conversations: p.conversations,
312
+ codes: [...p.codes].sort(),
313
+ window_size: p.window_size ?? 4,
314
+ binary: p.binary ?? true,
315
+ rotation: p.rotation ?? 'svd',
316
+ group_col: p.group_col ?? null,
317
+ group1_value: p.group1_value ?? null,
318
+ group2_value: p.group2_value ?? null,
319
+ });
320
+ }
321
+
322
+ function nextModelId() { return `m${++modelSeq}`; }
323
+
324
+ /**
325
+ * When no group filter is needed, qeviz still requires a group= attribute on
326
+ * <qe-edges> to know which rows to average. Inject a synthetic column that
327
+ * assigns every row to the same value so <qe-edges group="__g__"> draws the
328
+ * mean of all units in the frame.
329
+ */
330
+ function injectSyntheticGroup(vizData, groupName = '__g__') {
331
+ vizData.edges.types[groupName] = 'character';
332
+ vizData.points.types[groupName] = 'character';
333
+ vizData.edges.data.forEach(r => { r[groupName] = groupName; });
334
+ vizData.points.data.forEach(r => { r[groupName] = groupName; });
335
+ vizData.group_col = groupName;
336
+ }
337
+
338
+ function computeGroupMean(networks, nConnections, idx) {
339
+ const mean = new Float64Array(nConnections);
340
+ for (const i of idx)
341
+ for (let j = 0; j < nConnections; j++)
342
+ mean[j] += networks[i * nConnections + j];
343
+ if (idx.length > 0)
344
+ for (let j = 0; j < nConnections; j++) mean[j] /= idx.length;
345
+ return mean;
346
+ }
347
+
348
+ // ── Tool implementations ──────────────────────────────────────────────────────
349
+
350
+ /**
351
+ * Returns lightweight column metadata only — no raw data reaches Claude.
352
+ * Used for privacy-sensitive datasets where the user wants to keep data local.
353
+ */
354
+ function toolProfile({ data_path }) {
355
+ if (!data_path)
356
+ throw new Error('ena_profile requires data_path. Raw CSV is never needed — the file stays on your machine.');
357
+
358
+ const rows = loadData(data_path, null);
359
+ const nRows = rows.length;
360
+ const headers = nRows > 0 ? Object.keys(rows[0]) : [];
361
+
362
+ const columns = headers.map(col => {
363
+ const vals = rows.map(r => r[col]).filter(v => v !== '' && v != null);
364
+ const nUnique = new Set(vals).size;
365
+ const sample = [...new Set(vals)].slice(0, 6);
366
+ const numericCount = vals.filter(v => !isNaN(Number(v))).length;
367
+ const isBin = nUnique <= 2 && numericCount === vals.length &&
368
+ sample.every(v => v === '0' || v === '1');
369
+ return {
370
+ name: col,
371
+ n_unique: nUnique,
372
+ n_rows: vals.length,
373
+ is_binary: isBin,
374
+ sample_values: sample,
375
+ };
376
+ });
377
+
378
+ const binaryCols = columns.filter(c => c.is_binary).map(c => c.name);
379
+ const summary = [
380
+ `Dataset profile (no raw data transmitted): ${nRows} rows × ${headers.length} columns`,
381
+ `Binary code columns detected (${binaryCols.length}): ${binaryCols.join(', ') || 'none'}`,
382
+ `All columns: ${columns.map(c => `${c.name} (${c.n_unique} unique${c.is_binary ? ', binary' : ''})`).join('; ')}`,
383
+ ].join('\n');
384
+
385
+ return {
386
+ privacy_note: 'PRIVACY SAFE — only column metadata was computed locally. No raw row data was sent to Claude.',
387
+ n_rows: nRows,
388
+ n_cols: headers.length,
389
+ columns,
390
+ binary_columns: binaryCols,
391
+ summary,
392
+ };
393
+ }
394
+
395
+ function toolInspect({ data_path, data_csv }) {
396
+ const isPrivacySafe = !!data_path && !data_csv;
397
+ const rows = loadData(data_path, data_csv);
398
+ const nRows = rows.length;
399
+ const headers = nRows > 0 ? Object.keys(rows[0]) : [];
400
+
401
+ const codeCols = [];
402
+ const unitCands = [];
403
+ const convoCands = [];
404
+ const colSummary = [];
405
+
406
+ for (const col of headers) {
407
+ const nameLower = col.toLowerCase();
408
+ const vals = rows.map(r => r[col]).filter(v => v !== '' && v != null);
409
+ const nUnique = new Set(vals).size;
410
+
411
+ if ([...EXCLUDE].some(h => nameLower.includes(h))) {
412
+ colSummary.push({ name: col, n_unique: nUnique, likely_role: 'metadata' });
413
+ continue;
414
+ }
415
+
416
+ if (isBinary(rows, col)) {
417
+ codeCols.push(col);
418
+ colSummary.push({ name: col, n_unique: nUnique, likely_role: 'code' });
419
+ continue;
420
+ }
421
+
422
+ const uScore = scoreUnit(col, nUnique, nRows);
423
+ const cScore = scoreConvo(col, nUnique, nRows);
424
+ if (uScore > 0 || cScore > 0) {
425
+ const role = uScore >= cScore ? 'unit_candidate' : 'conversation_candidate';
426
+ if (uScore > 0) unitCands.push([uScore, col, nUnique]);
427
+ if (cScore > 0) convoCands.push([cScore, col, nUnique]);
428
+ colSummary.push({ name: col, n_unique: nUnique, likely_role: role });
429
+ continue;
430
+ }
431
+
432
+ colSummary.push({ name: col, n_unique: nUnique, likely_role: 'other' });
433
+ }
434
+
435
+ unitCands.sort((a, b) => b[0] - a[0]);
436
+ convoCands.sort((a, b) => b[0] - a[0]);
437
+
438
+ const suggestedUnits = unitCands.slice(0,5).map(([s,c,u]) =>
439
+ ({ column: c, n_unique: u, score: R(s, 2) }));
440
+ const suggestedConvos = convoCands.slice(0,5).map(([s,c,u]) =>
441
+ ({ column: c, n_unique: u, score: R(s, 2) }));
442
+
443
+ const suggestedGroups = {};
444
+ for (const [, col, nUnique] of convoCands.slice(0, 3)) {
445
+ if (nUnique >= 2 && nUnique <= 8) {
446
+ const vals = [...new Set(rows.map(r => String(r[col])).filter(Boolean))].sort();
447
+ suggestedGroups[col] = vals;
448
+ break;
449
+ }
450
+ }
451
+
452
+ const nCodes = codeCols.length;
453
+ const hasUnits = unitCands.length > 0 && unitCands[0][0] >= 0.3;
454
+ const hasConvo = convoCands.length > 0 && convoCands[0][0] >= 0.2;
455
+
456
+ let recommended, confidence, reason;
457
+ if (nCodes >= 3 && hasUnits && hasConvo) {
458
+ recommended = true;
459
+ confidence = nCodes >= 5 && unitCands[0][0] >= 0.7 ? 'high' : 'medium';
460
+ reason = `Found ${nCodes} binary code columns, a likely unit column ` +
461
+ `('${unitCands[0][1]}', ${unitCands[0][2]} unique values), and a ` +
462
+ `likely conversation column ('${convoCands[0][1]}', ${convoCands[0][2]} unique values). ` +
463
+ `This matches the ENA data signature: row-per-utterance with binary ` +
464
+ `presence/absence codes grouped by unit and conversation.`;
465
+ } else if (nCodes >= 2 && (hasUnits || hasConvo)) {
466
+ recommended = true;
467
+ confidence = 'low';
468
+ reason = `Found ${nCodes} binary code column(s) and partial structure. ENA may be applicable.`;
469
+ } else if (nCodes >= 2) {
470
+ recommended = true;
471
+ confidence = 'low';
472
+ reason = `Found ${nCodes} binary code columns but no clear unit/conversation columns by name.`;
473
+ } else {
474
+ recommended = false;
475
+ confidence = 'low';
476
+ reason = `Found only ${nCodes} binary column(s). ENA typically requires 4+ binary code columns.`;
477
+ }
478
+
479
+ let nextStep = {};
480
+ if (recommended && suggestedUnits.length && suggestedConvos.length) {
481
+ const bestUnit = suggestedUnits[0].column;
482
+ const bestConvo = suggestedConvos[0].column;
483
+ nextStep = {
484
+ tool: 'ena_fit',
485
+ parameters: {
486
+ data_path: data_path ?? '<save CSV to a path first>',
487
+ units: bestUnit,
488
+ conversations: bestConvo,
489
+ codes: codeCols,
490
+ window_size: 4,
491
+ rotation: 'svd',
492
+ },
493
+ };
494
+ const grpCol = Object.keys(suggestedGroups)[0];
495
+ if (grpCol && suggestedGroups[grpCol].length === 2) {
496
+ nextStep.alternative_tool = 'ena_compare_groups';
497
+ nextStep.alternative_parameters = {
498
+ ...nextStep.parameters,
499
+ group_col: grpCol,
500
+ group1_value: suggestedGroups[grpCol][0],
501
+ group2_value: suggestedGroups[grpCol][1],
502
+ };
503
+ }
504
+ }
505
+
506
+ const summaryLines = [
507
+ `ENA inspection: ${recommended ? 'RECOMMENDED' : 'NOT RECOMMENDED'} (confidence: ${confidence})`,
508
+ ` Dataset: ${nRows} rows × ${headers.length} columns`,
509
+ ` ${reason}`,
510
+ '',
511
+ `Binary code columns (${nCodes}): ${codeCols.join(', ') || 'none detected'}`,
512
+ ];
513
+ if (suggestedUnits.length)
514
+ summaryLines.push(`Top unit candidates: ` +
515
+ suggestedUnits.slice(0,3).map(u => `${u.column} (n=${u.n_unique}, score=${u.score})`).join(', '));
516
+ if (suggestedConvos.length)
517
+ summaryLines.push(`Top conversation candidates: ` +
518
+ suggestedConvos.slice(0,3).map(c => `${c.column} (n=${c.n_unique}, score=${c.score})`).join(', '));
519
+ for (const [col, vals] of Object.entries(suggestedGroups))
520
+ summaryLines.push(`Likely group column: '${col}' → ${JSON.stringify(vals)}`);
521
+
522
+ const privacyNote = isPrivacySafe
523
+ ? 'PRIVACY SAFE — data was read from a local file path. No raw row data was sent to Claude; only this analysis result was transmitted.'
524
+ : 'RAW DATA TRANSMITTED — data_csv was used, so row data passed through this conversation. For sensitive datasets, ask the user to save the file and provide data_path instead.';
525
+
526
+ return {
527
+ privacy_note: privacyNote,
528
+ ena_recommended: recommended,
529
+ confidence,
530
+ reason,
531
+ n_rows: nRows,
532
+ n_cols: headers.length,
533
+ suggested_codes: codeCols,
534
+ suggested_units: suggestedUnits,
535
+ suggested_conversations: suggestedConvos,
536
+ suggested_groups: suggestedGroups,
537
+ column_summary: colSummary,
538
+ next_step: nextStep,
539
+ summary: summaryLines.join('\n'),
540
+ };
541
+ }
542
+
543
+ async function toolFit(ena, params) {
544
+ const {
545
+ units, conversations, codes,
546
+ data_path, data_csv,
547
+ window_size = 4, window_forward = 0, binary = true,
548
+ dims = 2, rotation = 'svd',
549
+ group_col, group1_value, group2_value,
550
+ } = params;
551
+
552
+ // Normalize units/conversations to arrays (rena-wasm joins with '__')
553
+ const unitCols = Array.isArray(units) ? units : [units];
554
+ const convoCols = Array.isArray(conversations) ? conversations : [conversations];
555
+
556
+ // Return cached model if params are identical
557
+ const fKey = fitCacheKey(params);
558
+ if (fitCache.has(fKey)) {
559
+ const model_id = fitCache.get(fKey);
560
+ const entry = modelCache.get(model_id);
561
+ return {
562
+ model_id,
563
+ ...entry.result,
564
+ summary: `Using cached model ${model_id}.\n${entry.result.summary}`,
565
+ _vizData: entry.vizData,
566
+ _vizOpts: entry.vizOpts,
567
+ };
568
+ }
569
+
570
+ // Load data; reuse parsed rows if already cached for this accumulation key
571
+ const aKey = accumCacheKey(params);
572
+ let rows, acc;
573
+ if (accumCache.has(aKey)) {
574
+ ({ rows, acc } = accumCache.get(aKey));
575
+ } else {
576
+ rows = loadData(data_path, data_csv);
577
+ acc = ena.accumulate(rows, {
578
+ codes, units: unitCols, conversations: convoCols,
579
+ window: window_size, binary,
580
+ });
581
+ accumCache.set(aKey, { rows, acc });
582
+ }
583
+
584
+ const { unitLabels, nConnections, connectionNames, nUnits } = acc;
585
+
586
+ let groupA, groupB, uMap;
587
+ if (group_col) uMap = buildUnitGroupMap(rows, unitCols, group_col);
588
+
589
+ if (rotation === 'mean') {
590
+ if (!group_col || !group1_value || !group2_value)
591
+ throw new Error("rotation='mean' requires group_col, group1_value, and group2_value.");
592
+ groupA = unitLabels.flatMap((lbl, i) => uMap.get(lbl) === String(group1_value) ? [i] : []);
593
+ groupB = unitLabels.flatMap((lbl, i) => uMap.get(lbl) === String(group2_value) ? [i] : []);
594
+ if (!groupA.length) throw new Error(`No units found for ${group_col}=${group1_value}`);
595
+ if (!groupB.length) throw new Error(`No units found for ${group_col}=${group2_value}`);
596
+ } else if (rotation !== 'svd') {
597
+ throw new Error(`Unknown rotation '${rotation}'. WASM backend supports: svd, mean.`);
598
+ }
599
+
600
+ const model = ena.fit(rows, {
601
+ codes, units: unitCols, conversations: convoCols,
602
+ window: window_size, binary, dims,
603
+ rotation: rotation === 'mean' ? 'mean' : 'svd',
604
+ groupA, groupB,
605
+ });
606
+
607
+ const groupMap = uMap ?? null;
608
+
609
+ // Pre-compute per-group mean networks for ena_plot use
610
+ let groupNetworks = null;
611
+ if (groupMap) {
612
+ const allGroups = [...new Set(groupMap.values())];
613
+ groupNetworks = {};
614
+ for (const gv of allGroups) {
615
+ const idx = unitLabels.flatMap((lbl, i) => groupMap.get(lbl) === gv ? [i] : []);
616
+ groupNetworks[gv] = computeGroupMean(acc.networks, nConnections, idx);
617
+ }
618
+ }
619
+
620
+ const dimCols = Array.from({ length: dims }, (_, i) => `dim${i + 1}`);
621
+ const centroids = toRecords(model.centroids, nUnits, dims, unitLabels);
622
+ const nodePos = toRecords(model.positions, codes.length, dims, codes);
623
+
624
+ const nodeLines = nodePos.map(n =>
625
+ ` ${n.label.padEnd(32)}` + dimCols.map(d =>
626
+ ` ${(n[d] ?? 0) >= 0 ? '+' : ''}${(n[d] ?? 0).toFixed(4)}`).join('')
627
+ );
628
+
629
+ const model_id = nextModelId();
630
+ const summary = [
631
+ `ENA model fitted. model_id: ${model_id}`,
632
+ ` Units: ${nUnits} Codes: ${codes.length} Connections: ${nConnections} Dims: ${dims}`,
633
+ ` Rotation: ${rotation}`,
634
+ ` Window: back=${window_size}, forward=${window_forward}, binary=${binary}`,
635
+ '',
636
+ `Node positions (${dimCols.join(', ')}):`,
637
+ ...nodeLines,
638
+ '',
639
+ `Use ena_plot with model_id="${model_id}" for additional views (group, unit, subtraction, comparison).`,
640
+ ].join('\n');
641
+
642
+ const groups = group_col && group1_value && group2_value
643
+ ? { group1: String(group1_value), group2: String(group2_value) }
644
+ : group_col && groupMap
645
+ ? { group1: [...new Set(groupMap.values())][0] }
646
+ : {};
647
+ const vizData = buildQEVizData(model, unitLabels, codes, groupMap, group_col ?? null);
648
+ const vizOpts = groups;
649
+
650
+ const result = {
651
+ model_id,
652
+ centroids,
653
+ node_positions: nodePos,
654
+ connection_names: model.connectionNames,
655
+ units: unitLabels,
656
+ codes,
657
+ n_units: nUnits,
658
+ n_codes: codes.length,
659
+ n_connections: nConnections,
660
+ summary,
661
+ };
662
+
663
+ modelCache.set(model_id, {
664
+ model, codes, unitLabels,
665
+ groupMap, groupCol: group_col ?? null,
666
+ groupNetworks, connectionNames, nConnections, dims,
667
+ vizData, vizOpts, result,
668
+ });
669
+ fitCache.set(fKey, model_id);
670
+
671
+ return { ...result, _vizData: vizData, _vizOpts: vizOpts };
672
+ }
673
+
674
+ async function toolAccumulate(ena, {
675
+ units, conversations, codes,
676
+ data_path, data_csv,
677
+ window_size = 4, window_forward = 0, binary = true,
678
+ }) {
679
+ const unitCols = Array.isArray(units) ? units : [units];
680
+ const convoCols = Array.isArray(conversations) ? conversations : [conversations];
681
+ const rows = loadData(data_path, data_csv);
682
+ const result = ena.accumulate(rows, {
683
+ codes, units: unitCols, conversations: convoCols,
684
+ window: window_size, binary,
685
+ });
686
+
687
+ const { networks: connectionCounts, unitLabels, connectionNames, nUnits, nConnections } = result;
688
+
689
+ const networks = unitLabels.map((lbl, i) => {
690
+ const rec = { unit: lbl };
691
+ for (let j = 0; j < nConnections; j++)
692
+ rec[connectionNames[j]] = R(connectionCounts[i * nConnections + j], 4);
693
+ return rec;
694
+ });
695
+
696
+ const totals = new Float64Array(nConnections);
697
+ for (let i = 0; i < nUnits; i++)
698
+ for (let j = 0; j < nConnections; j++)
699
+ totals[j] += connectionCounts[i * nConnections + j];
700
+
701
+ const topIdx = Array.from({ length: nConnections }, (_, i) => i)
702
+ .sort((a, b) => totals[b] - totals[a])
703
+ .slice(0, 10)
704
+ .filter(i => totals[i] > 0);
705
+
706
+ const topConnections = topIdx.map(i => ({
707
+ connection: connectionNames[i],
708
+ total_weight: R(totals[i], 4),
709
+ }));
710
+
711
+ const summary = [
712
+ 'ENA accumulation complete.',
713
+ ` Units: ${nUnits} Connections: ${nConnections}`,
714
+ ` Window: back=${window_size}, forward=${window_forward}, binary=${binary}`,
715
+ '',
716
+ 'Top 10 connections by total weight:',
717
+ ...topConnections.map(t => ` ${t.connection.padEnd(40)} ${t.total_weight.toFixed(1)}`),
718
+ ].join('\n');
719
+
720
+ return {
721
+ networks,
722
+ connection_names: connectionNames,
723
+ units: unitLabels,
724
+ codes: [...codes],
725
+ n_units: nUnits,
726
+ n_codes: codes.length,
727
+ n_connections: nConnections,
728
+ top_connections: topConnections,
729
+ summary,
730
+ };
731
+ }
732
+
733
+ async function toolCompareGroups(ena, params) {
734
+ const {
735
+ units, conversations, codes,
736
+ group_col, group1_value, group2_value,
737
+ data_path, data_csv,
738
+ window_size = 4, window_forward = 0, binary = true,
739
+ dims = 2,
740
+ } = params;
741
+
742
+ // Return cached model if params are identical
743
+ const fKey = fitCacheKey({ ...params, rotation: 'mean' });
744
+ if (fitCache.has(fKey)) {
745
+ const model_id = fitCache.get(fKey);
746
+ const entry = modelCache.get(model_id);
747
+ return {
748
+ model_id,
749
+ ...entry.result,
750
+ summary: `Using cached model ${model_id}.\n${entry.result.summary}`,
751
+ _vizData: entry.vizData,
752
+ _vizOpts: entry.vizOpts,
753
+ };
754
+ }
755
+
756
+ // Normalize units/conversations to arrays
757
+ const unitCols = Array.isArray(units) ? units : [units];
758
+ const convoCols = Array.isArray(conversations) ? conversations : [conversations];
759
+
760
+ // Accumulate (or use cache)
761
+ const aKey = accumCacheKey(params);
762
+ let rows, acc;
763
+ if (accumCache.has(aKey)) {
764
+ ({ rows, acc } = accumCache.get(aKey));
765
+ } else {
766
+ rows = loadData(data_path, data_csv);
767
+ acc = ena.accumulate(rows, {
768
+ codes, units: unitCols, conversations: convoCols,
769
+ window: window_size, binary,
770
+ });
771
+ accumCache.set(aKey, { rows, acc });
772
+ }
773
+
774
+ const { unitLabels, networks: connectionCounts, connectionNames, nUnits, nConnections } = acc;
775
+
776
+ const uMap = buildUnitGroupMap(rows, unitCols, group_col);
777
+ const g1Idx = unitLabels.flatMap((lbl, i) => uMap.get(lbl) === String(group1_value) ? [i] : []);
778
+ const g2Idx = unitLabels.flatMap((lbl, i) => uMap.get(lbl) === String(group2_value) ? [i] : []);
779
+
780
+ if (!g1Idx.length) throw new Error(`No units found for ${group_col}=${group1_value}`);
781
+ if (!g2Idx.length) throw new Error(`No units found for ${group_col}=${group2_value}`);
782
+
783
+ const model = ena.fit(rows, {
784
+ codes, units: unitCols, conversations: convoCols,
785
+ window: window_size, binary, dims,
786
+ rotation: 'mean', groupA: g1Idx, groupB: g2Idx,
787
+ });
788
+
789
+ const dimCols = Array.from({ length: dims }, (_, i) => `dim${i + 1}`);
790
+ const allCentroids = toRecords(model.centroids, nUnits, dims, unitLabels);
791
+ const g1Set = new Set(g1Idx);
792
+ const g2Set = new Set(g2Idx);
793
+ const g1Centroids = allCentroids.filter((_, i) => g1Set.has(i));
794
+ const g2Centroids = allCentroids.filter((_, i) => g2Set.has(i));
795
+ const nodePos = toRecords(model.positions, codes.length, dims, codes);
796
+
797
+ const groupMeanPos = (centroids) => Object.fromEntries(
798
+ dimCols.map(d => [d, R(centroids.reduce((s, c) => s + (c[d] ?? 0), 0) / centroids.length)])
799
+ );
800
+ const g1Mean = groupMeanPos(g1Centroids);
801
+ const g2Mean = groupMeanPos(g2Centroids);
802
+ const diff = Object.fromEntries(dimCols.map(d => [d, R(g1Mean[d] - g2Mean[d])]));
803
+
804
+ const g1NetMean = computeGroupMean(connectionCounts, nConnections, g1Idx);
805
+ const g2NetMean = computeGroupMean(connectionCounts, nConnections, g2Idx);
806
+ const subNet = g1NetMean.map((v, j) => v - g2NetMean[j]);
807
+
808
+ const groupNetworks = {
809
+ [String(group1_value)]: g1NetMean,
810
+ [String(group2_value)]: g2NetMean,
811
+ };
812
+
813
+ const topIdx = Array.from({ length: nConnections }, (_, i) => i)
814
+ .sort((a, b) => Math.abs(subNet[b]) - Math.abs(subNet[a]))
815
+ .slice(0, 10);
816
+
817
+ const topDiff = topIdx.map(i => ({
818
+ connection: connectionNames[i],
819
+ [`${group1_value}_mean`]: R(g1NetMean[i], 4),
820
+ [`${group2_value}_mean`]: R(g2NetMean[i], 4),
821
+ difference: R(subNet[i], 4),
822
+ }));
823
+
824
+ const netDict = (arr) => Object.fromEntries(
825
+ connectionNames.map((n, i) => [n, R(arr[i], 4)])
826
+ );
827
+
828
+ const groupMap = uMap;
829
+ const vizData = buildQEVizData(model, unitLabels, codes, groupMap, group_col);
830
+ const vizOpts = { group1: String(group1_value), group2: String(group2_value) };
831
+ const model_id = nextModelId();
832
+
833
+ const summary = [
834
+ `ENA group comparison: '${group1_value}' vs '${group2_value}' model_id: ${model_id}`,
835
+ ` ${group1_value}: n=${g1Idx.length} centroid = ` +
836
+ dimCols.map(d => `${d}=${g1Mean[d] >= 0 ? '+' : ''}${g1Mean[d].toFixed(4)}`).join(', '),
837
+ ` ${group2_value}: n=${g2Idx.length} centroid = ` +
838
+ dimCols.map(d => `${d}=${g2Mean[d] >= 0 ? '+' : ''}${g2Mean[d].toFixed(4)}`).join(', '),
839
+ ` Difference (g1−g2): ` +
840
+ dimCols.map(d => `${d}=${diff[d] >= 0 ? '+' : ''}${diff[d].toFixed(4)}`).join(', '),
841
+ '',
842
+ 'Top differential connections:',
843
+ ...topDiff.map(t =>
844
+ ` ${t.connection.padEnd(40)} ` +
845
+ `${group1_value}=${t[`${group1_value}_mean`] >= 0 ? '+' : ''}${t[`${group1_value}_mean`].toFixed(3)} ` +
846
+ `${group2_value}=${t[`${group2_value}_mean`] >= 0 ? '+' : ''}${t[`${group2_value}_mean`].toFixed(3)} ` +
847
+ `Δ=${t.difference >= 0 ? '+' : ''}${t.difference.toFixed(3)}`
848
+ ),
849
+ '',
850
+ `Use ena_plot with model_id="${model_id}" for group, subtraction, comparison, or unit views.`,
851
+ ].join('\n');
852
+
853
+ const result = {
854
+ model_id,
855
+ group1_centroids: g1Centroids,
856
+ group2_centroids: g2Centroids,
857
+ group1_mean: g1Mean,
858
+ group2_mean: g2Mean,
859
+ difference: diff,
860
+ node_positions: nodePos,
861
+ connection_names: [...connectionNames],
862
+ group1_networks: netDict(g1NetMean),
863
+ group2_networks: netDict(g2NetMean),
864
+ subtraction_network: netDict(subNet),
865
+ top_differential: topDiff,
866
+ summary,
867
+ };
868
+
869
+ modelCache.set(model_id, {
870
+ model, codes, unitLabels,
871
+ groupMap, groupCol: group_col,
872
+ groupNetworks, connectionNames, nConnections, dims,
873
+ // Extra fields for compare models
874
+ subNet, g1Idx, g2Idx,
875
+ group1_value: String(group1_value),
876
+ group2_value: String(group2_value),
877
+ vizData, vizOpts, result,
878
+ });
879
+ fitCache.set(fKey, model_id);
880
+
881
+ return { ...result, _vizData: vizData, _vizOpts: vizOpts };
882
+ }
883
+
884
+ async function toolPlot({ model_id, type = 'all', group_value, group1, group2, unit_label }) {
885
+ const entry = modelCache.get(model_id);
886
+ if (!entry)
887
+ throw new Error(`Unknown model_id '${model_id}'. Call ena_fit or ena_compare_groups first.`);
888
+
889
+ const { model, codes, unitLabels, groupMap, groupCol, groupNetworks, nConnections, dims } = entry;
890
+
891
+ let vizData, vizOpts, label;
892
+
893
+ switch (type) {
894
+ case 'all':
895
+ vizData = buildQEVizData(model, unitLabels, codes, groupMap, groupCol);
896
+ injectSyntheticGroup(vizData);
897
+ vizOpts = { group1: '__g__', _noMeans: true };
898
+ label = `ENA model ${model_id} — all units`;
899
+ break;
900
+
901
+ case 'group': {
902
+ if (!group_value)
903
+ throw new Error("type='group' requires group_value");
904
+ if (!groupCol)
905
+ throw new Error(`Model ${model_id} has no group column. Refit with group_col.`);
906
+ vizData = buildQEVizData(model, unitLabels, codes, groupMap, groupCol);
907
+ vizOpts = { group1: String(group_value) };
908
+ label = `ENA mean network — ${group_value}`;
909
+ break;
910
+ }
911
+
912
+ case 'comparison': {
913
+ const g1 = group1 ?? entry.group1_value;
914
+ const g2 = group2 ?? entry.group2_value;
915
+ if (!g1 || !g2)
916
+ throw new Error("type='comparison' requires group1 and group2");
917
+ if (!groupCol)
918
+ throw new Error(`Model ${model_id} has no group column. Refit with group_col.`);
919
+ vizData = buildQEVizData(model, unitLabels, codes, groupMap, groupCol);
920
+ vizOpts = { group1: String(g1), group2: String(g2) };
921
+ label = `ENA comparison — ${g1} vs ${g2}`;
922
+ break;
923
+ }
924
+
925
+ case 'subtraction': {
926
+ const g1 = group1 ?? entry.group1_value;
927
+ const g2 = group2 ?? entry.group2_value;
928
+ if (!g1 || !g2)
929
+ throw new Error("type='subtraction' requires group1 and group2");
930
+ if (!groupNetworks || !groupNetworks[g1] || !groupNetworks[g2])
931
+ throw new Error(
932
+ `Group networks not found for '${g1}' / '${g2}'. ` +
933
+ `Refit with group_col specified, or use ena_compare_groups.`
934
+ );
935
+ const subNet = groupNetworks[g1].map((v, j) => v - groupNetworks[g2][j]);
936
+ const synLabel = [`${g1} − ${g2}`];
937
+ const synModel = {
938
+ ...model,
939
+ nUnits: 1,
940
+ networks: subNet,
941
+ centroids: new Float64Array(dims * 1),
942
+ };
943
+ vizData = buildQEVizData(synModel, synLabel, codes, null, null);
944
+ injectSyntheticGroup(vizData);
945
+ vizOpts = { group1: '__g__', _noMeans: true };
946
+ label = `ENA subtraction — ${g1} − ${g2}`;
947
+ break;
948
+ }
949
+
950
+ case 'unit': {
951
+ if (!unit_label)
952
+ throw new Error("type='unit' requires unit_label");
953
+ const unitIdx = unitLabels.indexOf(String(unit_label));
954
+ if (unitIdx === -1)
955
+ throw new Error(
956
+ `Unit '${unit_label}' not found. ` +
957
+ `Available: ${unitLabels.slice(0, 20).join(', ')}`
958
+ );
959
+ const synModel = {
960
+ ...model,
961
+ nUnits: 1,
962
+ networks: model.networks.slice(unitIdx * nConnections, (unitIdx + 1) * nConnections),
963
+ centroids: model.centroids.slice(unitIdx * dims, (unitIdx + 1) * dims),
964
+ };
965
+ vizData = buildQEVizData(synModel, [String(unit_label)], codes, null, null);
966
+ injectSyntheticGroup(vizData);
967
+ vizOpts = { group1: '__g__', _noMeans: true };
968
+ label = `ENA network — ${unit_label}`;
969
+ break;
970
+ }
971
+
972
+ default:
973
+ throw new Error(
974
+ `Unknown type '${type}'. Options: all, group, comparison, subtraction, unit`
975
+ );
976
+ }
977
+
978
+ return {
979
+ model_id,
980
+ plot_type: type,
981
+ label,
982
+ n_codes: codes.length,
983
+ n_units: unitLabels.length,
984
+ summary: label,
985
+ _vizData: vizData,
986
+ _vizOpts: vizOpts,
987
+ };
988
+ }
989
+
990
+ // ── Tool schemas ──────────────────────────────────────────────────────────────
991
+
992
+ const DATA_PROPS = {
993
+ data_path: {
994
+ type: 'string',
995
+ description:
996
+ 'Absolute path to a CSV file on the user\'s machine. ' +
997
+ 'PREFERRED for sensitive data — the file is read locally by the WASM server; ' +
998
+ 'no raw row data is transmitted to Claude.',
999
+ },
1000
+ data_csv: {
1001
+ type: 'string',
1002
+ description:
1003
+ 'Raw CSV text (use this OR data_path). ' +
1004
+ 'WARNING: the full dataset passes through this conversation. ' +
1005
+ 'Ask the user whether their data is sensitive before using this parameter.',
1006
+ },
1007
+ };
1008
+ const CORE_PROPS = {
1009
+ ...DATA_PROPS,
1010
+ units: { description: 'Column name(s) identifying units of analysis. Pass a string for a single column (e.g. "student_id") or an array for compound units (e.g. ["UserName","Condition"]). Compound units are joined with "__" to form labels like "alice__FirstGame".' },
1011
+ conversations: { description: 'Column name(s) segmenting conversations — accumulation resets between conversations. Pass a string or array (e.g. ["Condition","GameDay"]).' },
1012
+ codes: { type: 'array', items: { type: 'string' }, description: 'List of binary code column names' },
1013
+ window_size: { type: 'number', description: 'Stanza window — rows to look back (default 4)' },
1014
+ window_forward:{ type: 'number', description: 'Rows to look forward (default 0)' },
1015
+ binary: { type: 'boolean', description: 'Binarise co-occurrences (default true)' },
1016
+ };
1017
+
1018
+ const TOOLS = [
1019
+ {
1020
+ name: 'ena_profile',
1021
+ description:
1022
+ 'PRIVACY-SAFE dataset profiler: reads a CSV from the local file system and returns only column metadata ' +
1023
+ '(column names, unique-value counts, sample values, binary detection). ' +
1024
+ 'No raw row data is ever sent to Claude — use this when the user has sensitive data and wants to keep it local. ' +
1025
+ 'Returns enough information to determine ENA parameters (units, conversations, codes) without exposing the data itself. ' +
1026
+ 'WHEN TO USE: before ena_inspect or ena_fit, if the user says their data is sensitive, confidential, or private, ' +
1027
+ 'or if they ask whether their data will be sent to Claude.',
1028
+ inputSchema: {
1029
+ type: 'object',
1030
+ properties: {
1031
+ data_path: {
1032
+ type: 'string',
1033
+ description: 'Absolute path to the CSV file on the user\'s machine.',
1034
+ },
1035
+ },
1036
+ required: ['data_path'],
1037
+ },
1038
+ },
1039
+ {
1040
+ name: 'ena_inspect',
1041
+ description:
1042
+ 'Inspect a dataset and recommend whether ENA is an appropriate analysis. ' +
1043
+ 'Call this FIRST whenever a user provides tabular data and asks for analysis. ' +
1044
+ 'Returns ena_recommended, confidence, suggested codes/units/conversations, and a ready-to-use next_step call. ' +
1045
+ 'PRIVACY: prefer data_path over data_csv so raw data stays local. ' +
1046
+ 'If the user has not yet indicated whether their data is sensitive, ask before using data_csv. ' +
1047
+ 'If they indicate sensitivity, use ena_profile instead (no raw data transmitted).',
1048
+ inputSchema: {
1049
+ type: 'object',
1050
+ properties: DATA_PROPS,
1051
+ },
1052
+ },
1053
+ {
1054
+ name: 'ena_fit',
1055
+ description:
1056
+ 'Fit a full ENA model: accumulate → sphere-norm → center → rotate → project → LWS node positions. ' +
1057
+ 'Returns a model_id that can be passed to ena_plot for additional views (group mean, unit, subtraction, comparison) without re-fitting. ' +
1058
+ 'Repeated calls with identical parameters return the cached model instantly. ' +
1059
+ 'Supports rotation="svd" (default) or rotation="mean" (requires group_col, group1_value, group2_value). ' +
1060
+ 'An ENA network plot is returned as an inline image in this response — do not generate your own visualization. ' +
1061
+ 'PRIVACY: use data_path (file stays local, only model results reach Claude) rather than data_csv (raw data passes through conversation). ' +
1062
+ 'The WASM model runs entirely on the user\'s machine regardless of which input method is used.',
1063
+ inputSchema: {
1064
+ type: 'object',
1065
+ properties: {
1066
+ ...CORE_PROPS,
1067
+ dims: { type: 'number', description: 'Dimensions to return (default 2)' },
1068
+ rotation: { type: 'string', enum: ['svd', 'mean'], description: '"svd" (default) or "mean"' },
1069
+ group_col: { type: 'string', description: 'Metadata column for group membership (required for rotation="mean"; enables group/subtraction plots)' },
1070
+ group1_value: { type: 'string', description: 'Group 1 value (required for rotation="mean")' },
1071
+ group2_value: { type: 'string', description: 'Group 2 value (required for rotation="mean")' },
1072
+ },
1073
+ required: ['units', 'conversations', 'codes'],
1074
+ },
1075
+ },
1076
+ {
1077
+ name: 'ena_accumulate',
1078
+ description:
1079
+ 'Run only the ENA accumulation step — no rotation or projection. ' +
1080
+ 'Returns raw per-unit co-occurrence networks and top connections by weight. ' +
1081
+ 'Useful for inspecting co-occurrence patterns before committing to a model.',
1082
+ inputSchema: {
1083
+ type: 'object',
1084
+ properties: CORE_PROPS,
1085
+ required: ['units', 'conversations', 'codes'],
1086
+ },
1087
+ },
1088
+ {
1089
+ name: 'ena_compare_groups',
1090
+ description:
1091
+ 'Fit an ENA model with mean rotation and compare two groups. ' +
1092
+ 'Axis 1 passes through the mean difference between groups. ' +
1093
+ 'Returns a model_id for use with ena_plot (group, subtraction, comparison, unit views). ' +
1094
+ 'Repeated calls with identical parameters return the cached model instantly. ' +
1095
+ 'An ENA network plot is returned as an inline image in this response — do not generate your own visualization. ' +
1096
+ 'PRIVACY: use data_path (file stays local, only model results reach Claude) rather than data_csv (raw data passes through conversation). ' +
1097
+ 'The WASM model runs entirely on the user\'s machine regardless of which input method is used.',
1098
+ inputSchema: {
1099
+ type: 'object',
1100
+ properties: {
1101
+ ...CORE_PROPS,
1102
+ group_col: { type: 'string', description: 'Column identifying group membership' },
1103
+ group1_value: { type: 'string', description: 'Value for group 1' },
1104
+ group2_value: { type: 'string', description: 'Value for group 2' },
1105
+ dims: { type: 'number', description: 'Dimensions to return (default 2)' },
1106
+ },
1107
+ required: ['units', 'conversations', 'codes', 'group_col', 'group1_value', 'group2_value'],
1108
+ },
1109
+ },
1110
+ {
1111
+ name: 'ena_plot',
1112
+ description:
1113
+ 'Generate an ENA network plot from a previously fitted model without re-running the pipeline. ' +
1114
+ 'Use the model_id returned by ena_fit or ena_compare_groups. ' +
1115
+ 'View types: ' +
1116
+ '"all" — all units with group colouring if group_col was specified; ' +
1117
+ '"group" — mean network for a single group (requires group_value); ' +
1118
+ '"comparison" — overlay of two group mean networks (requires group1, group2); ' +
1119
+ '"subtraction" — difference network g1 − g2 (requires group1, group2, and group_col on the source model); ' +
1120
+ '"unit" — individual unit network (requires unit_label). ' +
1121
+ 'An ENA network plot is returned as an inline image.',
1122
+ inputSchema: {
1123
+ type: 'object',
1124
+ properties: {
1125
+ model_id: { type: 'string', description: 'model_id from ena_fit or ena_compare_groups' },
1126
+ type: {
1127
+ type: 'string',
1128
+ enum: ['all', 'group', 'comparison', 'subtraction', 'unit'],
1129
+ description: 'Plot view (default: all)',
1130
+ },
1131
+ group_value: { type: 'string', description: 'Group value for type="group"' },
1132
+ group1: { type: 'string', description: 'First group for type="comparison" or "subtraction"' },
1133
+ group2: { type: 'string', description: 'Second group for type="comparison" or "subtraction"' },
1134
+ unit_label: { type: 'string', description: 'Unit label for type="unit"' },
1135
+ },
1136
+ required: ['model_id'],
1137
+ },
1138
+ },
1139
+ ];
1140
+
1141
+ // ── Server setup ──────────────────────────────────────────────────────────────
1142
+
1143
+ const server = new Server(
1144
+ { name: 'ena', version: '0.1.0' },
1145
+ { capabilities: { tools: {} } }
1146
+ );
1147
+
1148
+ let enaPromise = null;
1149
+ function getENA() {
1150
+ if (!enaPromise) enaPromise = loadENA();
1151
+ return enaPromise;
1152
+ }
1153
+
1154
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1155
+
1156
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1157
+ const { name, arguments: args } = request.params;
1158
+ try {
1159
+ let result;
1160
+ if (name === 'ena_profile') {
1161
+ result = toolProfile(args);
1162
+ } else if (name === 'ena_inspect') {
1163
+ result = toolInspect(args);
1164
+ } else if (name === 'ena_plot') {
1165
+ result = await toolPlot(args);
1166
+ } else {
1167
+ const ena = await getENA();
1168
+ if (name === 'ena_fit') result = await toolFit(ena, args);
1169
+ else if (name === 'ena_accumulate') result = await toolAccumulate(ena, args);
1170
+ else if (name === 'ena_compare_groups') result = await toolCompareGroups(ena, args);
1171
+ else throw new Error(`Unknown tool: ${name}`);
1172
+ }
1173
+
1174
+ // Extract viz fields before stringifying so they never appear in the text block.
1175
+ const { _vizData, _vizOpts, ...cleanResult } = result;
1176
+
1177
+ const content = [{ type: 'text', text: JSON.stringify(cleanResult, null, 2) }];
1178
+
1179
+ if (_vizData) {
1180
+ let renderError = null;
1181
+ try {
1182
+ const svg = await renderSVG(_vizData, _vizOpts ?? {});
1183
+ if (svg) {
1184
+ const png = new Resvg(svg, { fitTo: { mode: 'width', value: SVG_SIZE }, background: '#ffffff' }).render().asPng();
1185
+ content.unshift({
1186
+ type: 'image',
1187
+ data: png.toString('base64'),
1188
+ mimeType: 'image/png',
1189
+ });
1190
+ } else {
1191
+ renderError = 'renderSVG returned null (qe-graph svg_element not found — jsdom/qeviz may have failed silently)';
1192
+ }
1193
+ } catch (e) {
1194
+ renderError = e.message;
1195
+ }
1196
+ if (renderError) {
1197
+ process.stderr.write(`[ena-mcp] render failed: ${renderError}\n`);
1198
+ // Surface the failure in the text block so it's visible in chat
1199
+ const parsed = JSON.parse(content[0].text);
1200
+ parsed._render_error = renderError;
1201
+ content[0].text = JSON.stringify(parsed, null, 2);
1202
+ }
1203
+
1204
+ // Also write an HTML file for clients that don't render inline images (e.g. Claude Code).
1205
+ // This is additive — the image block above still works for clients that do (e.g. Claude Chat).
1206
+ try {
1207
+ const vizLabel = cleanResult.label ?? name;
1208
+ const slug = [cleanResult.model_id, cleanResult.plot_type].filter(Boolean).join('_') || name;
1209
+ const html = generateHTML(_vizData, vizLabel, _vizOpts ?? {});
1210
+ const htmlPath = writeVizFile(html, slug);
1211
+ const textBlock = content.find(c => c.type === 'text');
1212
+ if (textBlock) {
1213
+ const parsed = JSON.parse(textBlock.text);
1214
+ parsed.interactive_plot = htmlPath;
1215
+ textBlock.text = JSON.stringify(parsed, null, 2);
1216
+ }
1217
+ } catch (e) {
1218
+ process.stderr.write(`[ena-mcp] HTML write failed: ${e.message}\n`);
1219
+ }
1220
+ }
1221
+
1222
+ return { content };
1223
+ } catch (err) {
1224
+ return {
1225
+ content: [{ type: 'text', text: `Error: ${err.message}` }],
1226
+ isError: true,
1227
+ };
1228
+ }
1229
+ });
1230
+
1231
+ // ── Start ─────────────────────────────────────────────────────────────────────
1232
+
1233
+ const transport = new StdioServerTransport();
1234
+ await server.connect(transport);