@qe-mcp/ena 0.0.0-stage → 0.1.17

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/fonts/OFL.txt ADDED
@@ -0,0 +1,93 @@
1
+ Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,4 @@
1
+ Inter 4 (Regular, Bold) — SIL Open Font License 1.1, see OFL.txt.
2
+ From @expo-google-fonts/inter 0.4.2 (https://github.com/rsms/inter).
3
+ Used to draw plot labels when PNGs are rendered with @resvg/resvg-wasm,
4
+ which cannot read system fonts.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ena-privacy-gate.mjs — PreToolUse hook for ENA MCP tools.
3
+ *
4
+ * Allows data_csv only for small toy/demo datasets (≤ MAX_ROWS rows).
5
+ * Blocks larger payloads and instructs Claude to use data_path instead,
6
+ * keeping research-scale data off Anthropic's servers.
7
+ */
8
+
9
+ const MAX_ROWS = 50;
10
+
11
+ let raw = '';
12
+ process.stdin.on('data', chunk => { raw += chunk; });
13
+ process.stdin.on('end', () => {
14
+ let call;
15
+ try { call = JSON.parse(raw); } catch { process.exit(0); }
16
+
17
+ const csv = call.tool_input?.data_csv;
18
+ if (!csv) process.exit(0); // data_path used — allow
19
+
20
+ const rows = csv.trim().split('\n').length - 1; // subtract header row
21
+ if (rows <= MAX_ROWS) process.exit(0); // toy/demo data — allow
22
+
23
+ process.stderr.write(
24
+ `[ENA privacy gate] Blocked: data_csv contains ${rows} rows (limit: ${MAX_ROWS}).\n` +
25
+ `Raw data at this scale should not travel through the conversation.\n` +
26
+ `Ask the user to provide the file path and use data_path instead.`
27
+ );
28
+ // Exit code 2 blocks the call and sends stderr to Claude. Any other
29
+ // non-zero code is a non-blocking error: the tool call would still run.
30
+ process.exit(2);
31
+ });
package/lines.js ADDED
@@ -0,0 +1,349 @@
1
+ /**
2
+ * lines.js — the lines behind a connection: for each response line that
3
+ * contributed to a connection, the window of lines it was connected to.
4
+ *
5
+ * Response lines come from the model itself: rena-wasm's rowConnectionCounts
6
+ * (rows × connections), each line's finalised contribution, which sums per
7
+ * unit to the model's connection counts. The window around a response line is
8
+ * rebuilt with libtma's own rule (tma inst/include/libtma/accumulation.hpp,
9
+ * apply_tensor_unit): a ground line g before response line r, in the same
10
+ * context, is in r's window when
11
+ *
12
+ * times[g] + window(g, r) > times[r]
13
+ *
14
+ * where times are line positions (or parsed times for time windows) and
15
+ * window(g, r) is looked up in the tensor at g's factor values, with the
16
+ * receiver axes set to r's. Contexts, factor levels, times and horizons come
17
+ * from rena-wasm's own exported helpers, so the grouping is the fit's.
18
+ *
19
+ * Every window is then checked: the connection is recomputed from the rebuilt
20
+ * window and the response line's codes (libtma connection_matrix) and must be
21
+ * present exactly where the model's rowConnectionCounts says it is.
22
+ */
23
+
24
+ import { parseData } from '@qe-libs/rena-wasm/src/data.js';
25
+ import {
26
+ buildContextLookup, buildContextLookupWithTimes, inferFactorLevels,
27
+ parseTimes, resolveTensorData, buildHorizonContexts,
28
+ } from '@qe-libs/rena-wasm/src/tensor.js';
29
+
30
+ // Column-major flat index into the tensor (libtma flat_index).
31
+ function flatIndex(idx, dims) {
32
+ let linear = 0, stride = 1;
33
+ for (let v = 0; v < dims.length; v++) { linear += idx[v] * stride; stride *= dims[v]; }
34
+ return linear;
35
+ }
36
+
37
+ /**
38
+ * @param {object} o
39
+ * @param {Object[]} o.rows the rows the model was fitted on
40
+ * @param {string[]} o.codes the model's codes, in order
41
+ * @param {string[]} o.unitCols, o.convoCols
42
+ * @param {string[]} [o.unitsUsed]
43
+ * @param {object} o.tensorDef the tensor the fit used (defaultTensor(window) for ENA)
44
+ * @param {object} [o.horizons]
45
+ * @param {boolean} o.ordered
46
+ * @param {string[]} o.unitLabels the model's unit labels (must match the data's)
47
+ * @param {Float64Array} o.rowCounts the model's rowConnectionCounts
48
+ * @param {string[]} o.connectionNames
49
+ * @param {number|null} o.conn connection index, or null for every line
50
+ * @param {Set<number>} o.selected unit indices to list
51
+ * @returns {{ contexts, windows, check }}
52
+ * contexts: [{ rows: [globalRow...] }] — the contexts holding a window
53
+ * windows: [{ unit, context, response, members: [globalRow...], count }]
54
+ * check: { checked, mismatches: [{ row, model, rebuilt }] }
55
+ */
56
+ export function connectionWindows(o) {
57
+ const { rows, codes, unitCols, convoCols, unitsUsed, tensorDef, horizons, ordered,
58
+ unitLabels, rowCounts, connectionNames, conn, selected } = o;
59
+ const pd = parseData(rows, codes, unitCols, convoCols, unitsUsed ?? null);
60
+ if (pd.unitLabels.length !== unitLabels.length || pd.unitLabels.some((l, i) => l !== unitLabels[i]))
61
+ throw new Error('The data no longer matches the model (different units). Refit the model.');
62
+ const { codeMatrix, nCodes, unitOf, convoGroups } = pd;
63
+ const nConn = connectionNames.length;
64
+
65
+ // The tensor exactly as the accumulation resolves it.
66
+ const dims = tensorDef.dims;
67
+ const isDefault = dims.length === 1 && dims[0] === 2;
68
+ const tensor = resolveTensorData(tensorDef);
69
+ const factors = tensorDef.factors ?? [];
70
+ const factorLevels = tensorDef.factorLevels ?? inferFactorLevels(rows, factors);
71
+ const receiver = tensorDef.dimsReceiver ?? [];
72
+ const allTimes = tensorDef.timesCol ? parseTimes(rows, tensorDef.timesCol, tensorDef.timesEndCol || null) : null;
73
+ const nFactor = factors.length;
74
+ const WINDOW = 1, WEIGHT = 0;
75
+
76
+ // The connection's codes: ground i → response j (ordered), or the pair {i, j}.
77
+ let ci = -1, cj = -1;
78
+ if (conn != null) {
79
+ const [a, b] = connectionNames[conn].split(' & ');
80
+ ci = codes.indexOf(a); cj = codes.indexOf(b);
81
+ if (ci < 0 || cj < 0) throw new Error(`Connection '${connectionNames[conn]}' doesn't name two of the model's codes.`);
82
+ }
83
+
84
+ // Contexts: conversations, or each unit's horizon pieces.
85
+ const contexts = [];
86
+ if (horizons) {
87
+ const pieces = buildHorizonContexts(rows, unitOf, unitLabels.length, horizons);
88
+ pieces.forEach((list, u) => { if (selected.has(u)) for (const rowIdx of list) contexts.push({ rowIdx, owner: u }); });
89
+ } else {
90
+ for (const [, rowIdx] of convoGroups) contexts.push({ rowIdx, owner: null });
91
+ }
92
+
93
+ const windows = [], used = [];
94
+ const check = { checked: 0, mismatches: [] };
95
+ for (const ctx of contexts) {
96
+ const { rowIdx, owner } = ctx;
97
+ const responders = [];
98
+ for (let r = 0; r < rowIdx.length; r++) {
99
+ const u = unitOf[rowIdx[r]];
100
+ if (u >= 0 && selected.has(u) && (owner == null || owner === u)) responders.push(r);
101
+ }
102
+ if (!responders.length) continue;
103
+ const { contextLookup, times } = allTimes
104
+ ? buildContextLookupWithTimes(rows, rowIdx, factors, factorLevels, allTimes)
105
+ : buildContextLookup(rows, rowIdx, factors, factorLevels);
106
+ const lookup = (r, kind, respR) => {
107
+ if (isDefault) return kind === WINDOW ? Math.trunc(tensor[1]) : Math.trunc(tensor[0]);
108
+ const v = new Array(nFactor + 1);
109
+ for (let f = 0; f < nFactor; f++) v[f] = contextLookup[r * nFactor + f];
110
+ if (respR != null) for (const d of receiver) v[d] = contextLookup[respR * nFactor + d];
111
+ v[nFactor] = kind;
112
+ return tensor[flatIndex(v, dims)];
113
+ };
114
+
115
+ let any = false;
116
+ for (const r of responders) {
117
+ const g = rowIdx[r];
118
+ const count = conn == null ? null : rowCounts[g * nConn + conn];
119
+
120
+ const members = [], weights = [];
121
+ for (let gr = r - 1; gr >= 0; gr--) {
122
+ const win = lookup(gr, WINDOW, r);
123
+ if (times[gr] + win > times[r]) { members.push(gr); weights.push(lookup(gr, WEIGHT, r)); }
124
+ }
125
+
126
+ if (conn != null) {
127
+ // libtma connection_matrix on the rebuilt window: g⊗r plus the
128
+ // response self-term (directed accumulation, folded if unordered).
129
+ const resp = (k) => codeMatrix[g * nCodes + k];
130
+ let gi = 0, gj = 0;
131
+ members.forEach((m, k) => {
132
+ gi += weights[k] * codeMatrix[rowIdx[m] * nCodes + ci];
133
+ gj += weights[k] * codeMatrix[rowIdx[m] * nCodes + cj];
134
+ });
135
+ const rw = lookup(r, WEIGHT, null);
136
+ const self = ci === cj ? 0 : 0.5 * rw * resp(ci) * resp(cj);
137
+ const ij = gi * resp(cj) + self;
138
+ const ji = gj * resp(ci) + self;
139
+ const rebuilt = ordered ? ij > 0 : (ij + ji) > 0;
140
+ check.checked++;
141
+ if (rebuilt !== (count > 0)) check.mismatches.push({ row: g + 1, model: count, rebuilt: rebuilt ? 1 : 0 });
142
+ if (!(count > 0)) continue; // not a line behind this connection
143
+ }
144
+ windows.push({ unit: unitOf[g], context: contexts.indexOf(ctx), response: g,
145
+ members: members.map(m => rowIdx[m]).reverse(), count });
146
+ any = true;
147
+ }
148
+ if (any) used.push(contexts.indexOf(ctx));
149
+ }
150
+
151
+ // check covers every line of the selected units, both ways: a line the
152
+ // model counts must carry the connection in its rebuilt window, and a line
153
+ // it doesn't count must not.
154
+ return {
155
+ contexts: contexts.map((c, i) => ({ rows: Array.from(c.rowIdx), owner: c.owner, used: used.includes(i) })),
156
+ windows,
157
+ check,
158
+ };
159
+ }
160
+
161
+ // ── The viewer ───────────────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * A self-contained HTML page showing windows (from connectionWindows) with
165
+ * their conversations. Data goes in as JSON and is rendered with textContent,
166
+ * so no cell can inject markup. No external requests.
167
+ *
168
+ * @param {object} v
169
+ * @param {string} v.title, v.subtitle, v.dataFile, v.note
170
+ * @param {string[]} v.columns every column of the data, in file order
171
+ * @param {object} v.roles { units: [], conversations: [], codes: [], shown: [] }
172
+ * @param {string[]} v.highlight codes of the connection (highlighted)
173
+ * @param {boolean} v.ordered
174
+ * @param {Object[]} v.rows all data rows
175
+ * @param {object} v.result connectionWindows() output
176
+ * @param {string[]} v.unitLabels
177
+ * @param {number} v.maxWindows
178
+ * @param {function} [v.rowNumber] row index → the number shown (default index + 1)
179
+ */
180
+ export function viewerHtml(v) {
181
+ const { result } = v;
182
+ const windows = result.windows.slice(0, v.maxWindows);
183
+ const ctxIds = [...new Set(windows.map(w => w.context))];
184
+ const rowSet = new Set();
185
+ for (const c of ctxIds) for (const r of result.contexts[c].rows) rowSet.add(r);
186
+ const rowIds = [...rowSet].sort((a, b) => a - b);
187
+ const payload = {
188
+ title: v.title, subtitle: v.subtitle, note: v.note, dataFile: v.dataFile,
189
+ columns: v.columns, roles: v.roles, highlight: v.highlight, ordered: v.ordered,
190
+ total: result.windows.length,
191
+ rows: Object.fromEntries(rowIds.map(r => [r, v.columns.map(c => v.rows[r][c] ?? '')])),
192
+ rowNum: Object.fromEntries(rowIds.map(r => [r, v.rowNumber ? v.rowNumber(r) : r + 1])),
193
+ contexts: ctxIds.map(c => ({ id: c, rows: result.contexts[c].rows })),
194
+ windows: windows.map(w => ({ unit: v.unitLabels[w.unit].split('__').join('::'), context: w.context,
195
+ response: w.response, members: w.members, count: w.count })),
196
+ };
197
+ const json = JSON.stringify(payload).replace(/</g, '\\u003c').replace(/\u2028|\u2029/g, ' ');
198
+ return `<!DOCTYPE html>
199
+ <html lang="en">
200
+ <head>
201
+ <meta charset="utf-8">
202
+ <meta name="viewport" content="width=device-width, initial-scale=1">
203
+ <title>${escapeHtml(v.title)}</title>
204
+ <style>
205
+ :root { --bg:#fff; --fg:#1f2328; --muted:#59636e; --faint:#818b98; --line:#d1d9e0; --card:#f6f8fa;
206
+ --resp:#fff8c5; --win:#ddf4ff; --hi:#0969da; color-scheme: light; }
207
+ @media (prefers-color-scheme: dark) { :root { --bg:#0d1117; --fg:#e6edf3; --muted:#9198a1; --faint:#6e7681;
208
+ --line:#30363d; --card:#151b23; --resp:#3a2f00; --win:#0c2d4a; --hi:#4493f8; color-scheme: dark; } }
209
+ * { box-sizing: border-box; }
210
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
211
+ header { padding: 16px 16px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg); z-index: 2; }
212
+ h1 { font-size: 1.25em; margin: 0 0 2px; }
213
+ .sub, .note { color: var(--muted); font-size: .9em; margin: 2px 0; }
214
+ .note { color: var(--faint); }
215
+ .controls { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin-top: 8px; font-size: .9em; }
216
+ select, button { font: inherit; color: var(--fg); background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; }
217
+ button { cursor: pointer; }
218
+ details.cols { font-size: .9em; } details.cols label { margin-right: 10px; white-space: nowrap; }
219
+ main { padding: 8px 16px 40px; }
220
+ .convo { margin: 18px 0; }
221
+ .convo h2 { font-size: 1em; margin: 0 0 6px; color: var(--muted); font-weight: 600; }
222
+ .scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; }
223
+ table { border-collapse: collapse; width: 100%; font-size: .9em; }
224
+ th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
225
+ th { background: var(--card); position: sticky; top: 0; font-weight: 600; white-space: nowrap; }
226
+ td.num { color: var(--faint); white-space: nowrap; font-variant-numeric: tabular-nums; }
227
+ tr.win td { background: var(--win); }
228
+ tr.resp td { background: var(--resp); }
229
+ tr.resp td.num::before { content: "▶ "; }
230
+ td.role { white-space: nowrap; color: var(--muted); font-size: .85em; }
231
+ th.hi { box-shadow: inset 0 -2px 0 var(--hi); }
232
+ td.code { text-align: center; font-variant-numeric: tabular-nums; }
233
+ td.hi { box-shadow: inset 2px 0 0 var(--hi), inset -2px 0 0 var(--hi); font-weight: 600; }
234
+ tr.gap td { text-align: center; color: var(--faint); cursor: pointer; font-size: .85em; }
235
+ .empty { color: var(--muted); padding: 24px 0; }
236
+ @media (max-width: 700px) { header { position: static; } }
237
+ </style>
238
+ </head>
239
+ <body>
240
+ <header>
241
+ <h1 id="title"></h1>
242
+ <p class="sub" id="subtitle"></p>
243
+ <p class="note" id="note"></p>
244
+ <div class="controls">
245
+ <label>Unit <select id="unit"></select></label>
246
+ <span><button id="prev" type="button">◀</button> <span id="pos"></span> <button id="next" type="button">▶</button></span>
247
+ <details class="cols"><summary>Columns</summary><div id="cols"></div></details>
248
+ </div>
249
+ </header>
250
+ <main id="main"></main>
251
+ <script type="application/json" id="data">${json}</script>
252
+ <script>
253
+ (function () {
254
+ var D = JSON.parse(document.getElementById('data').textContent);
255
+ var $ = function (id) { return document.getElementById(id); };
256
+ var el = function (tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
257
+ $('title').textContent = D.title; $('subtitle').textContent = D.subtitle; $('note').textContent = D.note;
258
+ document.title = D.title;
259
+ var codeSet = new Set(D.roles.codes), hi = new Set(D.highlight);
260
+ var shown = new Set(D.roles.shown);
261
+ var colIdx = {}; D.columns.forEach(function (c, i) { colIdx[c] = i; });
262
+
263
+ var cols = $('cols');
264
+ D.columns.forEach(function (c) {
265
+ var l = el('label'), cb = el('input'); cb.type = 'checkbox'; cb.checked = shown.has(c);
266
+ cb.onchange = function () { if (cb.checked) shown.add(c); else shown.delete(c); render(); };
267
+ l.appendChild(cb); l.appendChild(document.createTextNode(' ' + c)); cols.appendChild(l);
268
+ });
269
+
270
+ var units = Array.from(new Set(D.windows.map(function (w) { return w.unit; })));
271
+ var sel = $('unit'); sel.appendChild(new Option('All units (' + units.length + ')', ''));
272
+ units.forEach(function (u) { sel.appendChild(new Option(u, u)); });
273
+ sel.onchange = function () { cursor = -1; render(); };
274
+
275
+ var cursor = -1;
276
+ function visibleWindows() { return D.windows.filter(function (w) { return !sel.value || w.unit === sel.value; }); }
277
+
278
+ function render() {
279
+ var main = $('main'); main.textContent = '';
280
+ var wins = visibleWindows();
281
+ var more = D.total > D.windows.length ? ' (first ' + D.windows.length + ' of ' + D.total + ')' : '';
282
+ $('pos').textContent = wins.length ? (cursor >= 0 ? (cursor + 1) + ' / ' : '') + wins.length + ' windows' + more : 'no windows';
283
+ if (!wins.length) { main.appendChild(el('p', 'empty', 'No lines behind this connection for the selection.')); return; }
284
+ var byCtx = new Map();
285
+ wins.forEach(function (w) { if (!byCtx.has(w.context)) byCtx.set(w.context, []); byCtx.get(w.context).push(w); });
286
+ // The web tool's Data View order: units, conversations, other columns, codes.
287
+ var lead = D.roles.units.concat(D.roles.conversations.filter(function (c) { return D.roles.units.indexOf(c) < 0; }));
288
+ var showCols = lead.filter(function (c) { return shown.has(c); })
289
+ .concat(D.columns.filter(function (c) { return shown.has(c) && lead.indexOf(c) < 0 && !codeSet.has(c); }))
290
+ .concat(D.roles.codes.filter(function (c) { return shown.has(c); }));
291
+ D.contexts.forEach(function (ctx) {
292
+ var ws = byCtx.get(ctx.id); if (!ws) return;
293
+ var resp = new Map(), win = new Set();
294
+ ws.forEach(function (w) { resp.set(w.response, w); w.members.forEach(function (m) { win.add(m); }); });
295
+ var keep = new Set(); resp.forEach(function (_, r) { keep.add(r); }); win.forEach(function (r) { keep.add(r); });
296
+ var box = el('section', 'convo');
297
+ var first = D.rows[ctx.rows[0]];
298
+ box.appendChild(el('h2', null, D.roles.conversations.map(function (c) { return c + ': ' + first[colIdx[c]]; }).join(' · ') +
299
+ ' — ' + ws.length + ' window' + (ws.length === 1 ? '' : 's')));
300
+ var sc = el('div', 'scroll'), t = el('table'), head = el('tr');
301
+ head.appendChild(el('th', null, 'Row')); head.appendChild(el('th', null, ''));
302
+ showCols.forEach(function (c) { head.appendChild(el('th', hi.has(c) ? 'hi' : null, c)); });
303
+ var thead = el('thead'); thead.appendChild(head); t.appendChild(thead);
304
+ var tb = el('tbody'); t.appendChild(tb);
305
+ var gap = [];
306
+ function flushGap() {
307
+ if (!gap.length) return;
308
+ var hidden = gap.slice(); gap = [];
309
+ var tr = el('tr', 'gap'), td = el('td', null, '… ' + hidden.length + ' line' + (hidden.length === 1 ? '' : 's') + ' …');
310
+ td.colSpan = 2 + showCols.length; tr.appendChild(td);
311
+ tr.onclick = function () { var anchor = tr; hidden.forEach(function (r) { var row = rowEl(r); anchor.after(row); anchor = row; }); tr.remove(); };
312
+ tb.appendChild(tr);
313
+ }
314
+ function rowEl(r) {
315
+ var vals = D.rows[r], tr = el('tr', resp.has(r) ? 'resp' : (win.has(r) ? 'win' : ''));
316
+ tr.id = 'row-' + r;
317
+ tr.appendChild(el('td', 'num', String(D.rowNum[r])));
318
+ var role = resp.has(r) ? (D.ordered ? 'response' : 'line') : (win.has(r) ? (D.ordered ? 'ground' : 'window') : '');
319
+ tr.appendChild(el('td', 'role', role));
320
+ showCols.forEach(function (c) {
321
+ var cls = codeSet.has(c) ? 'code' + (hi.has(c) ? ' hi' : '') : null;
322
+ tr.appendChild(el('td', cls, String(vals[colIdx[c]])));
323
+ });
324
+ return tr;
325
+ }
326
+ ctx.rows.forEach(function (r) { if (keep.has(r)) { flushGap(); tb.appendChild(rowEl(r)); } else gap.push(r); });
327
+ flushGap();
328
+ sc.appendChild(t); box.appendChild(sc); main.appendChild(box);
329
+ });
330
+ }
331
+ function go(step) {
332
+ var wins = visibleWindows(); if (!wins.length) return;
333
+ cursor = (cursor + step + wins.length) % wins.length;
334
+ render();
335
+ var row = document.getElementById('row-' + wins[cursor].response);
336
+ if (row) row.scrollIntoView({ block: 'center' });
337
+ }
338
+ $('prev').onclick = function () { go(-1); }; $('next').onclick = function () { go(1); };
339
+ render();
340
+ })();
341
+ </script>
342
+ </body>
343
+ </html>
344
+ `;
345
+ }
346
+
347
+ function escapeHtml(s) {
348
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
349
+ }
package/manifest.json ADDED
@@ -0,0 +1,120 @@
1
+ {
2
+ "manifest_version": "0.2",
3
+ "name": "qe-mcp-ena",
4
+ "display_name": "ENA — Epistemic Network Analysis",
5
+ "version": "0.1.17",
6
+ "description": "Epistemic Network Analysis on tabular coded data. Runs entirely on your machine — data never leaves your computer.",
7
+ "long_description": "Run Epistemic Network Analysis (ENA), ordered network analysis (ONA) and transmodal analysis (TMA) on your coded data from a conversation with Claude. No R or Python to install.\n\n**Your data stays on your machine**\n\n- The analysis runs locally. Only results — networks, points, statistics and plots — go back to the conversation.\n- Give Claude a file path rather than pasting data; the server reads the file itself.\n- It makes no network connections, starts no other programs, and writes files only when you ask for an export.\n\n**About the install warning**\n\nClaude Desktop shows \"access to everything on your computer\" for every extension outside Anthropic's reviewed directory. It means you are trusting the source, as with any program you install. The source is published on npm as @qe-mcp/ena.\n\nDocumentation and example prompts: https://qe-libs.org/mcp/qe-mcp-ena/",
8
+ "author": {
9
+ "name": "Cody L Marquart",
10
+ "email": "clmarquart@wisc.edu"
11
+ },
12
+ "homepage": "https://qe-libs.org/mcp/qe-mcp-ena/",
13
+ "documentation": "https://qe-libs.org/mcp/qe-mcp-ena/",
14
+ "license": "GPL-3.0-only",
15
+ "keywords": [
16
+ "ena",
17
+ "epistemic-network-analysis",
18
+ "quantitative-ethnography",
19
+ "discourse-analysis",
20
+ "learning-analytics",
21
+ "wasm"
22
+ ],
23
+ "server": {
24
+ "type": "node",
25
+ "entry_point": "server.js",
26
+ "mcp_config": {
27
+ "command": "node",
28
+ "args": [
29
+ "${__dirname}/server.js"
30
+ ],
31
+ "env": {}
32
+ }
33
+ },
34
+ "tools": [
35
+ {
36
+ "name": "ena_guide",
37
+ "description": "The analysis guide: privacy, workflow, rotations, ONA/TMA and reading results."
38
+ },
39
+ {
40
+ "name": "ena_profile",
41
+ "description": "Privacy-safe column metadata for a local CSV — no raw data transmitted."
42
+ },
43
+ {
44
+ "name": "ena_inspect",
45
+ "description": "Triage a dataset for ENA suitability; suggests codes, units, and conversations."
46
+ },
47
+ {
48
+ "name": "ena_fit",
49
+ "description": "Fit a full ENA model (SVD, means or generalized rotation); returns a reusable model_id, a network plot, and group tests when two groups are named."
50
+ },
51
+ {
52
+ "name": "ena_compare_groups",
53
+ "description": "Mean-rotation comparison of two groups; returns a model_id, plots, and per-dimension t / Mann-Whitney tests."
54
+ },
55
+ {
56
+ "name": "tma_fit",
57
+ "description": "Fit a directed TMA / ONA model with context-tensor accumulation; returns a model_id and group tests when two groups are named."
58
+ },
59
+ {
60
+ "name": "tma_accumulate",
61
+ "description": "TMA / ONA accumulation step only — raw per-unit directed networks."
62
+ },
63
+ {
64
+ "name": "ena_plot",
65
+ "description": "Render a network view (all, group, unit, comparison, subtraction) from a cached model_id."
66
+ },
67
+ {
68
+ "name": "ena_accumulate",
69
+ "description": "Accumulation step only — raw per-unit co-occurrence networks."
70
+ },
71
+ {
72
+ "name": "ena_window",
73
+ "description": "Estimate the window size from the data by Cross-Covariance Decay (= rENA ena.ccd)."
74
+ },
75
+ {
76
+ "name": "ena_export",
77
+ "description": "Write a fitted model as an ENA Web Tool project ZIP, to open there as the same model and plot."
78
+ },
79
+ {
80
+ "name": "ena_tables",
81
+ "description": "Write a fitted model's points, node positions, line weights and connection counts as CSV files (the ENA Web Tool's Download Model tables)."
82
+ },
83
+ {
84
+ "name": "ena_lines",
85
+ "description": "Show the lines behind a connection in a local viewer (the ENA Web Tool's Data View); returns counts and row numbers, never text."
86
+ },
87
+ {
88
+ "name": "ena_simplify",
89
+ "description": "Simplify a model with PRIA (the ENA Web Tool's Simplify Model): remove the codes it can do without and refit."
90
+ }
91
+ ],
92
+ "prompts": [
93
+ {
94
+ "name": "analyze_data",
95
+ "description": "Analyze a coded data file with ENA, following the analysis guide.",
96
+ "arguments": [
97
+ "data_path",
98
+ "question"
99
+ ],
100
+ "text": "Analyze the data at ${arguments.data_path} with Epistemic Network Analysis. ${arguments.question} Call ena_guide first and follow it: ask me whether the data is sensitive, learn its structure with ena_profile or ena_inspect, estimate a window, fit, plot, and interpret the result."
101
+ },
102
+ {
103
+ "name": "compare_groups",
104
+ "description": "Compare two groups' networks with ENA: means rotation, difference plot and statistics.",
105
+ "arguments": [
106
+ "data_path",
107
+ "group_column"
108
+ ],
109
+ "text": "Compare the groups in ${arguments.data_path} (group column: ${arguments.group_column}) with ENA. Call ena_guide first and follow it: ask me whether the data is sensitive, profile the data, fit with ena_compare_groups, show the difference plot, and report the group statistics and which connections drive the difference."
110
+ },
111
+ {
112
+ "name": "order_and_sequence",
113
+ "description": "Model order and sequence with directed ONA / transmodal TMA networks.",
114
+ "arguments": [
115
+ "data_path"
116
+ ],
117
+ "text": "Analyze order and sequence in ${arguments.data_path} with directed (ONA) or transmodal (TMA) networks. Call ena_guide with topic \"ordered-and-transmodal-networks-ona-tma\" first and follow it, after asking me whether the data is sensitive."
118
+ }
119
+ ]
120
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@qe-mcp/ena",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.17",
4
+ "description": "Epistemic Network Analysis for Claude and other assistants — runs on your machine",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://gitlab.com/epistemic-analytics/qe-packages/server-ena.git"
8
+ },
9
+ "type": "module",
10
+ "main": "server.js",
11
+ "bin": {
12
+ "qe-mcp-ena": "./server.js"
13
+ },
14
+ "files": [
15
+ "server.js",
16
+ "webena.js",
17
+ "xlsx.js",
18
+ "lines.js",
19
+ "manifest.json",
20
+ "fonts/",
21
+ "hooks/",
22
+ "skill/"
23
+ ],
24
+ "scripts": {
25
+ "start": "node server.js"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.4.0",
29
+ "@qe-libs/qeviz": "0.6.0",
30
+ "@qe-libs/rena-wasm": "^0.1.14",
31
+ "@resvg/resvg-wasm": "^2.6.2",
32
+ "jsdom": "^25.0.1"
33
+ },
34
+ "optionalDependencies": {
35
+ "@resvg/resvg-js": "^2.6.2"
36
+ },
37
+ "keywords": [
38
+ "mcp",
39
+ "ena",
40
+ "epistemic-network-analysis",
41
+ "wasm"
42
+ ],
43
+ "license": "GPL-3.0-only"
44
+ }