@qe-mcp/ena 0.0.0-stage → 0.1.18
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 +648 -2
- package/fonts/Inter-Bold.ttf +0 -0
- package/fonts/Inter-Regular.ttf +0 -0
- package/fonts/OFL.txt +93 -0
- package/fonts/README.md +4 -0
- package/hooks/ena-privacy-gate.mjs +31 -0
- package/lines.js +349 -0
- package/manifest.json +120 -0
- package/package.json +41 -4
- package/server.js +3605 -0
- package/skill/ena-analysis/SKILL.md +348 -0
- package/webena.js +651 -0
- package/xlsx.js +181 -0
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.
|
package/fonts/README.md
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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.18",
|
|
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,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qe-mcp/ena",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.18",
|
|
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.7.0",
|
|
30
|
+
"@qe-libs/rena-wasm": "^0.1.14",
|
|
31
|
+
"@resvg/resvg-wasm": "^2.6.2"
|
|
32
|
+
},
|
|
33
|
+
"optionalDependencies": {
|
|
34
|
+
"@resvg/resvg-js": "^2.6.2"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"mcp",
|
|
38
|
+
"ena",
|
|
39
|
+
"epistemic-network-analysis",
|
|
40
|
+
"wasm"
|
|
41
|
+
],
|
|
42
|
+
"license": "GPL-3.0-only"
|
|
43
|
+
}
|