@qlm/learnlog-mcp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +43 -0
  4. package/README.md +121 -2
  5. package/data/SOURCE_CORRECTION.md +9 -0
  6. package/data/THIRD_PARTY_DATA.md +148 -0
  7. package/data/examples/chain-tpc-clinical-v2-tampered.json +97 -0
  8. package/data/examples/chain-tpc-clinical-v2.json +95 -0
  9. package/data/examples/evidence-record-clean.json +173 -0
  10. package/data/examples/evidence-record-tampered.json +173 -0
  11. package/data/labpath-worlds.json +107 -0
  12. package/data/preprint/README.md +10 -0
  13. package/data/preprint/assistments_episodes.json +37 -0
  14. package/data/preprint/cross_source_episodes.json +63 -0
  15. package/data/provenance/oatutor.json +84 -0
  16. package/data/provenance/source-audit.json +69 -0
  17. package/data/provenance/tutormoments.json +91 -0
  18. package/data/samples/explicit-assistance-SYNTHETIC.jsonl +5 -0
  19. package/data/samples/oatutor-real-participants.csv +13 -0
  20. package/data/samples/sandpiper-SYNTHETIC.csv +9 -0
  21. package/data/samples/tutormoments-real-transcripts.jsonl +2 -0
  22. package/dist/bin.js +6 -0
  23. package/dist/episodes.js +488 -0
  24. package/dist/files.js +39 -0
  25. package/dist/package.js +14 -0
  26. package/dist/py.js +419 -0
  27. package/dist/respond.js +47 -0
  28. package/dist/sealed.js +31 -0
  29. package/dist/server.js +115 -0
  30. package/dist/stages.js +35 -0
  31. package/dist/tools/formats.js +35 -0
  32. package/dist/tools/inventory.js +179 -0
  33. package/dist/tools/learning-graph.js +111 -0
  34. package/dist/tools/misconceptions.js +54 -0
  35. package/dist/tools/preprint.js +241 -0
  36. package/dist/tools/qlm-datasets.js +56 -0
  37. package/dist/tools/transform.js +61 -0
  38. package/dist/tools/validate.js +47 -0
  39. package/dist/tools/verify-credential.js +155 -0
  40. package/dist/tools/verify-evidence.js +51 -0
  41. package/dist/tools/worlds.js +24 -0
  42. package/dist/transformer.js +92 -0
  43. package/dist/zip.js +71 -0
  44. package/package.json +66 -4
  45. package/skills/learnlog-assistments/SKILL.md +38 -0
  46. package/skills/learnlog-export-to-action/SKILL.md +31 -0
  47. package/skills/learnlog-learning-commons/SKILL.md +33 -0
  48. package/skills/learnlog-nto-sandpiper/SKILL.md +31 -0
  49. package/vendor/PROVENANCE.json +259 -0
  50. package/vendor/qlm-measure/checks.d.ts +24 -0
  51. package/vendor/qlm-measure/checks.js +42 -0
  52. package/vendor/qlm-measure/cli.d.ts +17 -0
  53. package/vendor/qlm-measure/cli.js +461 -0
  54. package/vendor/qlm-measure/rules.d.ts +10 -0
  55. package/vendor/qlm-measure/rules.js +46 -0
  56. package/vendor/qlm-measure/schemes/index.d.ts +133 -0
  57. package/vendor/qlm-measure/schemes/index.js +339 -0
  58. package/vendor/qlm-measure/schemes/node.d.ts +8 -0
  59. package/vendor/qlm-measure/schemes/node.js +15 -0
  60. package/vendor/qlm-measure/schemes/products.d.ts +38 -0
  61. package/vendor/qlm-measure/schemes/products.js +725 -0
  62. package/vendor/qlm-measure/verifier/hash.d.ts +27 -0
  63. package/vendor/qlm-measure/verifier/hash.js +163 -0
  64. package/vendor/qlm-measure/verifier/verify-record.d.ts +22 -0
  65. package/vendor/qlm-measure/verifier/verify-record.js +123 -0
  66. package/vendor/qlm-measure/verifier-v03.d.ts +24 -0
  67. package/vendor/qlm-measure/verifier-v03.js +205 -0
  68. package/vendor/transformer/LICENSE.txt +29 -0
  69. package/vendor/transformer/cross-source-analysis-data.d.mts +3 -0
  70. package/vendor/transformer/cross-source-analysis-data.mjs +238 -0
  71. package/vendor/transformer/engine.d.mts +61 -0
  72. package/vendor/transformer/engine.mjs +445 -0
  73. package/vendor/transformer/profiles.mjs +309 -0
  74. package/vendor/transformer/research-semantics.mjs +1453 -0
package/dist/py.js ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Python behaviors the preprint's episode scripts depend on, reproduced exactly so that the counts this
3
+ * package computes are the counts the published Python scripts compute on the same files:
4
+ * float(), int(float()), round(x, n), datetime.fromisoformat().timestamp(), str ordering, the json
5
+ * module (raw_decode, NaN, int versus float), re's whitespace and word classes, and the csv module's
6
+ * excel dialect with strict=False.
7
+ */
8
+ // ── float() and int(float()) ─────────────────────────────────────────────
9
+ const PY_SPACE = "\\t\\n\\x0b\\x0c\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
10
+ const STRIP = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, "g");
11
+ const DIGITS = "\\d(?:_?\\d)*";
12
+ const DECIMAL = new RegExp(`^[+-]?(?:${DIGITS}(?:\\.(?:${DIGITS})?)?|\\.${DIGITS})(?:[eE][+-]?${DIGITS})?$`);
13
+ const SPECIAL = /^([+-]?)(nan|inf|infinity)$/i;
14
+ /** Python's float(text) for a str argument. */
15
+ export function pyFloat(text) {
16
+ const s = text.replace(STRIP, "");
17
+ const special = SPECIAL.exec(s);
18
+ if (special) {
19
+ if (special[2].toLowerCase() === "nan")
20
+ return NaN;
21
+ return special[1] === "-" ? -Infinity : Infinity;
22
+ }
23
+ if (!DECIMAL.test(s))
24
+ throw new Error(`could not convert string to float: '${text}'`);
25
+ return Number(s.replaceAll("_", ""));
26
+ }
27
+ /** Python's int(float(text)). */
28
+ export function pyIntOfFloat(text) {
29
+ const f = pyFloat(text);
30
+ if (Number.isNaN(f))
31
+ throw new Error("cannot convert float NaN to integer");
32
+ if (!Number.isFinite(f))
33
+ throw new Error("cannot convert float infinity to integer");
34
+ return Math.trunc(f) + 0; // + 0 turns -0 into 0, as an int has no sign of zero
35
+ }
36
+ // ── round(x, n) ──────────────────────────────────────────────────────────
37
+ /** Python's round(x, n) for a float: half to even, on the exact binary value. */
38
+ export function pyRound(x, n) {
39
+ if (!Number.isFinite(x))
40
+ return x;
41
+ const view = new DataView(new ArrayBuffer(8));
42
+ view.setFloat64(0, x);
43
+ const hi = view.getUint32(0);
44
+ const lo = view.getUint32(4);
45
+ const negative = hi >>> 31 === 1;
46
+ const exponentBits = (hi >>> 20) & 0x7ff;
47
+ let mantissa = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo);
48
+ let exponent;
49
+ if (exponentBits === 0)
50
+ exponent = -1074;
51
+ else {
52
+ mantissa |= 1n << 52n;
53
+ exponent = exponentBits - 1075;
54
+ }
55
+ if (exponent >= 0)
56
+ return x; // already an integer
57
+ const scaled = mantissa * 10n ** BigInt(n);
58
+ const divisor = 1n << BigInt(-exponent);
59
+ let q = scaled / divisor;
60
+ const twice = (scaled % divisor) * 2n;
61
+ if (twice > divisor || (twice === divisor && q % 2n === 1n))
62
+ q += 1n;
63
+ const result = Number(`${q}e-${n}`);
64
+ return negative ? -result : result;
65
+ }
66
+ // ── datetime.fromisoformat(text).timestamp() ─────────────────────────────
67
+ const ISO = /^(\d{4})-(\d{2})-(\d{2})(?:[\s\S](\d{2})(?::(\d{2})(?::(\d{2})(?:[.,](\d+))?)?)?(Z|[+-]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?)?)?$/u;
68
+ function utcMillis(year, month, day, hour, minute, second) {
69
+ const d = new Date(0);
70
+ d.setUTCFullYear(year, month - 1, day);
71
+ d.setUTCHours(hour, minute, second, 0);
72
+ return d.getTime();
73
+ }
74
+ /** Python 3.11+ datetime.fromisoformat(text).timestamp(). A time without an offset is local time, as in Python. */
75
+ export function pyIsoTimestamp(text) {
76
+ const m = ISO.exec(text);
77
+ const invalid = () => new Error(`Invalid isoformat string: '${text}'`);
78
+ if (!m)
79
+ throw invalid();
80
+ const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
81
+ const [hour, minute, second] = [Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)];
82
+ const micro = Number((m[7] ?? "").slice(0, 6).padEnd(6, "0"));
83
+ const check = new Date(0);
84
+ check.setUTCFullYear(year, month - 1, day);
85
+ if (month < 1 || month > 12 || check.getUTCMonth() !== month - 1 || hour > 23 || minute > 59 || second > 59)
86
+ throw invalid();
87
+ const zone = m[8];
88
+ if (zone === undefined) {
89
+ const local = new Date(2000, 0, 1);
90
+ local.setFullYear(year, month - 1, day);
91
+ local.setHours(hour, minute, second, 0);
92
+ return local.getTime() / 1000 + micro / 1e6;
93
+ }
94
+ let offsetMicros = 0;
95
+ if (zone !== "Z") {
96
+ const sign = zone.startsWith("-") ? -1 : 1;
97
+ const [oh, om, os] = zone.slice(1).split(":");
98
+ const [whole, fraction] = (os ?? "0").split(".");
99
+ offsetMicros = sign * ((Number(oh) * 3600 + Number(om) * 60 + Number(whole)) * 1e6 + Number((fraction ?? "").padEnd(6, "0")));
100
+ if (Math.abs(offsetMicros) >= 86400e6)
101
+ throw invalid(); // Python normalizes minutes and seconds over 59
102
+ }
103
+ const micros = utcMillis(year, month, day, hour, minute, second) * 1000 + micro - offsetMicros;
104
+ return micros / 1e6;
105
+ }
106
+ // ── str ordering ─────────────────────────────────────────────────────────
107
+ /** Python's ordering of str values: by code point, which differs from JavaScript's UTF-16 order above U+FFFF. */
108
+ export function compareCodePoints(a, b) {
109
+ const x = Array.from(a);
110
+ const y = Array.from(b);
111
+ for (let i = 0; i < Math.min(x.length, y.length); i++) {
112
+ const d = x[i].codePointAt(0) - y[i].codePointAt(0);
113
+ if (d !== 0)
114
+ return d < 0 ? -1 : 1;
115
+ }
116
+ return x.length === y.length ? 0 : x.length < y.length ? -1 : 1;
117
+ }
118
+ // ── re.search for the two eventsData patterns ────────────────────────────
119
+ const PATTERNS = {
120
+ /** cross_source.py: re.search(r"eventsData\s*=\s*", h) */
121
+ eventsData: new RegExp(`eventsData[${PY_SPACE}]*=[${PY_SPACE}]*`, "u"),
122
+ /** aggregate.py: re.search(r"\bconst\s+eventsData\s*=\s*", text) */
123
+ constEventsData: new RegExp(`(?<![\\p{L}\\p{N}_])const[${PY_SPACE}]+eventsData[${PY_SPACE}]*=[${PY_SPACE}]*`, "u"),
124
+ };
125
+ /** The end index of the first match of one of the scripts' patterns (Python's match.end()), or -1. */
126
+ export function findPy(text, pattern) {
127
+ const m = PATTERNS[pattern].exec(text);
128
+ return m ? m.index + m[0].length : -1;
129
+ }
130
+ // ── json ─────────────────────────────────────────────────────────────────
131
+ const kinds = new WeakMap();
132
+ /** Whether a number member was written as an int or a float in the JSON text. */
133
+ export function numberKind(container, key) {
134
+ return kinds.get(container)?.get(key);
135
+ }
136
+ /** type(obj.get(key)).__name__ as Python would print it for a value decoded by the json module. */
137
+ export function pyTypeName(container, key) {
138
+ const v = Object.hasOwn(container, key) ? container[key] : null;
139
+ if (v === null || v === undefined)
140
+ return "NoneType";
141
+ if (typeof v === "number")
142
+ return numberKind(container, key) ?? (Number.isInteger(v) ? "int" : "float");
143
+ if (typeof v === "string")
144
+ return "str";
145
+ if (typeof v === "boolean")
146
+ return "bool";
147
+ return Array.isArray(v) ? "list" : "dict";
148
+ }
149
+ const NUMBER = /-?(?:0|[1-9]\d*)(\.\d+)?([eE][-+]?\d+)?/y;
150
+ const ESC = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
151
+ /**
152
+ * Python's json.JSONDecoder().raw_decode(text, start): the first JSON value starting exactly at
153
+ * `start` (no whitespace is skipped there), and the index just after it.
154
+ */
155
+ export function parsePyJson(text, start = 0) {
156
+ let i = start;
157
+ const fail = (what) => {
158
+ throw new Error(`JSON: ${what} at ${i}`);
159
+ };
160
+ const ws = () => {
161
+ for (let c = text.charCodeAt(i); c === 32 || c === 9 || c === 10 || c === 13; c = text.charCodeAt(++i))
162
+ ;
163
+ };
164
+ const str = () => {
165
+ i++;
166
+ let out = "";
167
+ let run = i;
168
+ for (;;) {
169
+ if (i >= text.length)
170
+ fail("unterminated string");
171
+ const c = text.charCodeAt(i);
172
+ if (c === 34) {
173
+ out += text.slice(run, i);
174
+ i++;
175
+ return out;
176
+ }
177
+ if (c < 32)
178
+ fail("invalid control character");
179
+ if (c === 92) {
180
+ out += text.slice(run, i);
181
+ const e = text[i + 1] ?? "";
182
+ if (e === "u") {
183
+ const hex = text.slice(i + 2, i + 6);
184
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
185
+ fail("invalid \\uXXXX escape");
186
+ out += String.fromCharCode(parseInt(hex, 16));
187
+ i += 6;
188
+ }
189
+ else {
190
+ if (!Object.hasOwn(ESC, e))
191
+ fail("invalid \\escape");
192
+ out += ESC[e];
193
+ i += 2;
194
+ }
195
+ run = i;
196
+ continue;
197
+ }
198
+ i++;
199
+ }
200
+ };
201
+ const value = () => {
202
+ const c = text[i];
203
+ if (c === "{") {
204
+ i++;
205
+ const obj = {};
206
+ const k = new Map();
207
+ kinds.set(obj, k);
208
+ ws();
209
+ if (text[i] === "}") {
210
+ i++;
211
+ return obj;
212
+ }
213
+ for (;;) {
214
+ if (text[i] !== '"')
215
+ fail("expecting property name enclosed in double quotes");
216
+ const key = str();
217
+ ws();
218
+ if (text[i] !== ":")
219
+ fail("expecting ':' delimiter");
220
+ i++;
221
+ ws();
222
+ const at = i;
223
+ const v = value();
224
+ Object.defineProperty(obj, key, { value: v, enumerable: true, writable: true, configurable: true });
225
+ if (typeof v === "number") {
226
+ NUMBER.lastIndex = at;
227
+ const m = NUMBER.exec(text);
228
+ k.set(key, m && m.index === at && m[1] === undefined && m[2] === undefined ? "int" : "float");
229
+ }
230
+ else
231
+ k.delete(key);
232
+ ws();
233
+ if (text[i] === "}") {
234
+ i++;
235
+ return obj;
236
+ }
237
+ if (text[i] !== ",")
238
+ fail("expecting ',' delimiter");
239
+ i++;
240
+ ws();
241
+ }
242
+ }
243
+ if (c === "[") {
244
+ i++;
245
+ const arr = [];
246
+ ws();
247
+ if (text[i] === "]") {
248
+ i++;
249
+ return arr;
250
+ }
251
+ for (;;) {
252
+ arr.push(value());
253
+ ws();
254
+ if (text[i] === "]") {
255
+ i++;
256
+ return arr;
257
+ }
258
+ if (text[i] !== ",")
259
+ fail("expecting ',' delimiter");
260
+ i++;
261
+ ws();
262
+ }
263
+ }
264
+ if (c === '"')
265
+ return str();
266
+ for (const [word, v] of [["null", null], ["true", true], ["false", false], ["NaN", NaN], ["Infinity", Infinity], ["-Infinity", -Infinity]]) {
267
+ if (text.startsWith(word, i)) {
268
+ i += word.length;
269
+ return v;
270
+ }
271
+ }
272
+ NUMBER.lastIndex = i;
273
+ const m = NUMBER.exec(text);
274
+ if (!m)
275
+ return fail("expecting value");
276
+ i += m[0].length;
277
+ const n = Number(m[0]);
278
+ return m[1] === undefined && m[2] === undefined ? n + 0 : n;
279
+ };
280
+ const v = value();
281
+ return { value: v, end: i };
282
+ }
283
+ /** Python's json.loads(text): whitespace around one value, and nothing else. */
284
+ export function loadsPyJson(text) {
285
+ let i = 0;
286
+ while (" \t\n\r".includes(text[i] ?? "x"))
287
+ i++;
288
+ const { value, end } = parsePyJson(text, i);
289
+ let j = end;
290
+ while (" \t\n\r".includes(text[j] ?? "x"))
291
+ j++;
292
+ if (j !== text.length)
293
+ throw new Error(`JSON: extra data at ${j}`);
294
+ return value;
295
+ }
296
+ // ── csv ──────────────────────────────────────────────────────────────────
297
+ const FIELD_LIMIT = 131072;
298
+ /**
299
+ * Python's csv.reader with the excel dialect and strict=False, fed text in chunks. Blank lines are
300
+ * dropped, as csv.DictReader drops them. A field over Python's default limit of 131072 characters
301
+ * raises the error Python raises.
302
+ */
303
+ export class PyCsv {
304
+ state = 0 /* S.StartRecord */;
305
+ field = "";
306
+ row = [];
307
+ push(chunk) {
308
+ const rows = [];
309
+ let i = 0;
310
+ const n = chunk.length;
311
+ const add = (s) => {
312
+ if (this.field.length + s.length > FIELD_LIMIT)
313
+ throw new Error(`field larger than field limit (${FIELD_LIMIT})`);
314
+ this.field += s;
315
+ };
316
+ const save = () => {
317
+ this.row.push(this.field);
318
+ this.field = "";
319
+ };
320
+ const endRow = () => {
321
+ save();
322
+ rows.push(this.row);
323
+ this.row = [];
324
+ };
325
+ while (i < n) {
326
+ const c = chunk.charCodeAt(i);
327
+ switch (this.state) {
328
+ case 0 /* S.StartRecord */:
329
+ case 5 /* S.EatCrnl */:
330
+ if (c === 13 || c === 10) {
331
+ this.state = 5 /* S.EatCrnl */;
332
+ i++;
333
+ break;
334
+ }
335
+ this.state = 1 /* S.StartField */;
336
+ break;
337
+ case 1 /* S.StartField */:
338
+ if (c === 34)
339
+ this.state = 3 /* S.InQuoted */;
340
+ else if (c === 44)
341
+ save();
342
+ else if (c === 13 || c === 10) {
343
+ endRow();
344
+ this.state = 5 /* S.EatCrnl */;
345
+ }
346
+ else
347
+ this.state = 2 /* S.InField */;
348
+ if (this.state !== 2 /* S.InField */)
349
+ i++;
350
+ break;
351
+ case 2 /* S.InField */: {
352
+ let j = i;
353
+ while (j < n) {
354
+ const d = chunk.charCodeAt(j);
355
+ if (d === 44 || d === 13 || d === 10)
356
+ break;
357
+ j++;
358
+ }
359
+ add(chunk.slice(i, j));
360
+ i = j;
361
+ if (i < n) {
362
+ if (chunk.charCodeAt(i) === 44) {
363
+ save();
364
+ this.state = 1 /* S.StartField */;
365
+ }
366
+ else {
367
+ endRow();
368
+ this.state = 5 /* S.EatCrnl */;
369
+ }
370
+ i++;
371
+ }
372
+ break;
373
+ }
374
+ case 3 /* S.InQuoted */: {
375
+ const j = chunk.indexOf('"', i);
376
+ const stop = j < 0 ? n : j;
377
+ add(chunk.slice(i, stop));
378
+ i = stop;
379
+ if (j >= 0) {
380
+ this.state = 4 /* S.QuoteInQuoted */;
381
+ i++;
382
+ }
383
+ break;
384
+ }
385
+ case 4 /* S.QuoteInQuoted */:
386
+ if (c === 34) {
387
+ add('"');
388
+ this.state = 3 /* S.InQuoted */;
389
+ i++;
390
+ }
391
+ else if (c === 44) {
392
+ save();
393
+ this.state = 1 /* S.StartField */;
394
+ i++;
395
+ }
396
+ else if (c === 13 || c === 10) {
397
+ endRow();
398
+ this.state = 5 /* S.EatCrnl */;
399
+ i++;
400
+ }
401
+ else
402
+ this.state = 2 /* S.InField */;
403
+ break;
404
+ }
405
+ }
406
+ return rows;
407
+ }
408
+ /** The last record, when the text does not end with a newline. */
409
+ end() {
410
+ if (this.state === 0 /* S.StartRecord */ || this.state === 5 /* S.EatCrnl */)
411
+ return [];
412
+ this.row.push(this.field);
413
+ const last = this.row;
414
+ this.row = [];
415
+ this.field = "";
416
+ this.state = 0 /* S.StartRecord */;
417
+ return [last];
418
+ }
419
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * How every tool answers. An answer is JSON text, passes through the sealed-content filter, and must
3
+ * carry does_not_show: what the answer cannot tell you. A refusal is an error result with one plain reason.
4
+ */
5
+ import { Refusal } from "./files.js";
6
+ import { withholdSealed } from "./sealed.js";
7
+ /**
8
+ * The most characters of JSON text one answer may carry. Assistants take in only so much at once:
9
+ * Claude Code warns above about 10,000 tokens and rejects above 25,000. 30,000 characters stays under the warning.
10
+ */
11
+ export const ANSWER_BUDGET = 30_000;
12
+ const n = (x) => x.toLocaleString("en-US");
13
+ export function answer(body) {
14
+ const limits = body.does_not_show;
15
+ if (!Array.isArray(limits) || limits.length === 0)
16
+ throw new Error("every answer must say what it does not show (does_not_show)");
17
+ const text = sealedText(body);
18
+ if (text.length > ANSWER_BUDGET)
19
+ return refuse(`This answer would be ${n(text.length)} characters, more than the ${n(ANSWER_BUDGET)} an assistant can take in at once. Ask for less: a smaller limit, one direction, or fewer kinds.`);
20
+ return { content: [{ type: "text", text }] };
21
+ }
22
+ /** How many characters of JSON text this body's answer would be (after the sealed-content filter). */
23
+ export function answerSize(body) {
24
+ return sealedText(body).length;
25
+ }
26
+ /** The answer's JSON text after the sealed-content filter, with what was withheld named. */
27
+ function sealedText(body) {
28
+ const { value, withheld } = withholdSealed(body);
29
+ const out = value;
30
+ if (withheld.length)
31
+ out.sealed_content_withheld = withheld;
32
+ return JSON.stringify(out, null, 2);
33
+ }
34
+ export function refuse(message) {
35
+ return { content: [{ type: "text", text: JSON.stringify({ error: message }, null, 2) }], isError: true };
36
+ }
37
+ /** Runs a tool body and turns what it throws into a refusal. */
38
+ export async function guard(body) {
39
+ try {
40
+ return answer(await body());
41
+ }
42
+ catch (e) {
43
+ if (e instanceof Refusal)
44
+ return refuse(e.message);
45
+ return refuse(`Unexpected error: ${e instanceof Error ? e.message : String(e)}`);
46
+ }
47
+ }
package/dist/sealed.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Sealed content (answer keys, sealed case variants) never appears in a tool's output. Every answer
3
+ * passes through withholdSealed, which replaces the value of any member with one of these names, at
4
+ * any depth, and reports where it did so.
5
+ */
6
+ const SEALED = new Set([
7
+ "answerkey", "answerkeys", "correctanswer", "correctanswers", "expectedanswer", "expectedanswers", "solutionkey", "solutionkeys",
8
+ "sealed", "sealedvariant", "sealedvariants", "sealedcase", "sealedcases", "sealedcontent", "sealeditem", "sealeditems", "sealedanswer", "sealedanswers",
9
+ ]);
10
+ export const WITHHELD = "[withheld: sealed content]";
11
+ const isSealed = (key) => SEALED.has(key.toLowerCase().replace(/[^a-z0-9]/g, ""));
12
+ const member = (path, key) => (/^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`);
13
+ export function withholdSealed(value) {
14
+ const withheld = [];
15
+ const walk = (v, path) => {
16
+ if (Array.isArray(v))
17
+ return v.map((x, i) => walk(x, `${path}[${i}]`));
18
+ if (v === null || typeof v !== "object")
19
+ return v;
20
+ const out = {};
21
+ for (const [k, x] of Object.entries(v)) {
22
+ // defineProperty keeps a member named "__proto__" (source text can use any name) as data.
23
+ const sealed = isSealed(k);
24
+ if (sealed)
25
+ withheld.push(member(path, k));
26
+ Object.defineProperty(out, k, { value: sealed ? WITHHELD : walk(x, member(path, k)), enumerable: true, writable: true, configurable: true });
27
+ }
28
+ return out;
29
+ };
30
+ return { value: walk(value, "$"), withheld };
31
+ }
package/dist/server.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The Learnlog MCP server: six read-only tools over stdio. Five run on this machine and read local
3
+ * files only. verify_credential sends one request to app.qlmdev.com. Nothing is uploaded or written.
4
+ */
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { z } from "zod";
8
+ import { NAME, VERSION } from "./package.js";
9
+ import { guard } from "./respond.js";
10
+ import { LEARNLOG_DRAFT } from "./stages.js";
11
+ import { learnlogFormats } from "./tools/formats.js";
12
+ import { assistanceInventory } from "./tools/inventory.js";
13
+ import { learnlogTransform } from "./tools/transform.js";
14
+ import { learnlogValidate } from "./tools/validate.js";
15
+ import { verifyCredential } from "./tools/verify-credential.js";
16
+ import { verifyEvidence } from "./tools/verify-evidence.js";
17
+ import { EDGE_KINDS, qlmLearningGraph } from "./tools/learning-graph.js";
18
+ import { qlmMisconceptions } from "./tools/misconceptions.js";
19
+ import { DatasetClient } from "./tools/qlm-datasets.js";
20
+ import { FORMAT_IDS } from "./transformer.js";
21
+ export const TOOL_NAMES = ["learnlog_formats", "learnlog_transform", "learnlog_validate", "assistance_inventory", "verify_evidence", "verify_credential", "qlm_misconceptions", "qlm_learning_graph"];
22
+ const INSTRUCTIONS = `Learnlog MCP answers questions about what learning records actually show about help, using Learnlog (${LEARNLOG_DRAFT}). Keep the six assistance stages apart: requested, offered, generated, transmitted, displayed, accepted. One never implies the next. A stage that wasn't captured is "not recorded", never "none" or zero. Every answer has a does_not_show list: pass those limits on to the person. All tools are read-only. Five read local files only. verify_credential sends one request to app.qlmdev.com; qlm_misconceptions and qlm_learning_graph read QLM's public LabPath datasets from play.quantumlearningmachines.com.`;
23
+ const LOCAL = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
24
+ const path = (what) => z.string().describe(`Full path to ${what} on this computer (absolute, or starting with ~/).`);
25
+ export function createServer(options = {}) {
26
+ const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS });
27
+ const datasets = new DatasetClient(options.fetch);
28
+ const NETWORK = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
29
+ server.registerTool("learnlog_formats", {
30
+ title: "Learnlog formats",
31
+ description: "Which exports can I convert to Learnlog? Lists the transformer's source formats, what each one records at each of the six assistance stages, its bundled public sample, and the three released datasets the Learnlog preprint uses. No input.",
32
+ inputSchema: {},
33
+ annotations: LOCAL,
34
+ }, async () => guard(() => learnlogFormats()));
35
+ server.registerTool("learnlog_transform", {
36
+ title: "Transform to Learnlog",
37
+ description: "Turn an export into Learnlog records, keeping source and capture details. Give a format (see learnlog_formats) and either a local file path or use_sample: true for the format's bundled public sample. Returns a summary, the records' hash-chain heads, the unknowns they keep and a preview; set include_document for the whole Learnlog document, which comes back only when it fits in one answer (small exports). Nothing is written to disk.",
38
+ inputSchema: {
39
+ format: z.enum(FORMAT_IDS).describe("The source format."),
40
+ path: path("the export file").optional(),
41
+ use_sample: z.boolean().optional().describe("Use the format's bundled public sample instead of a file."),
42
+ tenant: z.string().min(1).max(200).optional().describe("A scope name for the learner pseudonyms. Default: local."),
43
+ include_document: z.boolean().optional().describe("Return the whole Learnlog document if it fits in one answer; otherwise its size is reported instead."),
44
+ },
45
+ annotations: LOCAL,
46
+ }, async (args) => guard(() => learnlogTransform(args)));
47
+ server.registerTool("learnlog_validate", {
48
+ title: "Validate Learnlog",
49
+ description: "Is this a valid Learnlog file? Replays each record's hash chain and checks the transformer's profile bindings, then lists errors, warnings and the unknowns each record keeps (for example source time, capture coverage or model version not recorded).",
50
+ inputSchema: { path: path("the Learnlog JSON file") },
51
+ annotations: LOCAL,
52
+ }, async (args) => guard(() => learnlogValidate(args)));
53
+ server.registerTool("assistance_inventory", {
54
+ title: "Assistance inventory",
55
+ description: "What does this dataset actually record about help? Reports, for each source, which of the six assistance stages are recorded (requested, offered, generated, transmitted, displayed, accepted), the counts, and what can't be concluded. Three ways to use it: (1) an export: format with path or use_sample: true; (2) a Learnlog file: learnlog_path; (3) the Learnlog preprint's released datasets, from your own copies: zenodo_zip, idetrace_json (with allow_idetrace_research: true), assistments_csv, which recomputes the preprint's inventory and episode counts and compares them with the published numbers when the files are the pinned releases. With no input it returns the preprint's published figures, labeled as not recomputed.",
56
+ inputSchema: {
57
+ format: z.enum(FORMAT_IDS).optional().describe("The export's format, with path or use_sample."),
58
+ path: path("the export file").optional(),
59
+ use_sample: z.boolean().optional().describe("Use the format's bundled public sample."),
60
+ tenant: z.string().min(1).max(200).optional(),
61
+ learnlog_path: path("a Learnlog JSON file").optional(),
62
+ zenodo_zip: path("the Zenodo 17551130 archive (ai_hints_interaction.zip)").optional(),
63
+ idetrace_json: path("IDETrace dataset/raw_telemetry/deployment_8.json").optional(),
64
+ allow_idetrace_research: z.boolean().optional().describe("Required to read the IDETrace file: it holds learner telemetry with unclear redistribution terms. Only counts are returned."),
65
+ assistments_csv: path("the ASSISTments 2012-13 School Data with Affect CSV").optional(),
66
+ },
67
+ annotations: LOCAL,
68
+ }, async (args) => guard(() => assistanceInventory(args)));
69
+ server.registerTool("verify_evidence", {
70
+ title: "Verify evidence",
71
+ description: "Can I trust this result? Replays a local evidence record or product hash chain with qlm-measure's own verifier and returns pass, or each named check that failed and where. kind: record (default), chain (with family or scheme), or list_schemes.",
72
+ inputSchema: {
73
+ path: path("the evidence record or chain JSON").optional(),
74
+ kind: z.enum(["record", "chain", "list_schemes"]).optional(),
75
+ family: z.string().min(1).optional().describe("Chain family, for example tpc/clinical."),
76
+ scheme: z.string().min(1).optional().describe("Exact chain scheme, for example tpc/clinical-v2."),
77
+ allow_mixed: z.boolean().optional().describe("Allow a chain that mixes scheme versions."),
78
+ },
79
+ annotations: LOCAL,
80
+ }, async (args) => guard(() => verifyEvidence(args)));
81
+ server.registerTool("verify_credential", {
82
+ title: "Verify a QLM credential",
83
+ description: "Is this QLM credential real and current? Give its ID (urn:uuid:...) or a link that contains it. Sends one request to https://app.qlmdev.com/v1/verify and returns whether it verified, each check with its result, the issuer, the achievement, the date and the AI-help summary by stage. The report has no personal data.",
84
+ inputSchema: { credential: z.string().min(1).max(2048).describe("The credential ID, or a link containing it.") },
85
+ annotations: NETWORK,
86
+ }, async (args) => guard(() => verifyCredential(args, options.fetch)));
87
+ server.registerTool("qlm_misconceptions", {
88
+ title: "QLM misconceptions",
89
+ description: "Which student misconception might be behind this? Searches QLM's public, research-based misconception dataset (LabPath Dataset v1.0, CC BY 4.0) by search words, domain, grade band or id. Returns each misconception's student belief, the scientific reality, a counterexample, a teacher intervention, its research source, severity, grade bands and LabPath modes, with the citation. Reads https://play.quantumlearningmachines.com once per session.",
90
+ inputSchema: {
91
+ search: z.string().max(200).optional().describe("Words that must all appear, for example: force motion."),
92
+ domain: z.string().max(50).optional().describe("For example physics, chemistry, biology, math, cs, health."),
93
+ grade_band: z.string().max(30).optional().describe("For example K-2, 3-5, 6-8, 9-12, postsecondary."),
94
+ id: z.string().max(50).optional().describe("A misconception id, for example from qlm_learning_graph."),
95
+ limit: z.number().int().min(1).max(50).optional().describe("How many to return. Default 10."),
96
+ },
97
+ annotations: NETWORK,
98
+ }, async (args) => guard(() => qlmMisconceptions(args, datasets)));
99
+ server.registerTool("qlm_learning_graph", {
100
+ title: "QLM learning graph",
101
+ description: "What comes before or after this LabPath activity? Give a LabPath mode id (from qlm_misconceptions) or a standard code. Returns the linked activities in LabPath's designed learning graph, with the teacher-facing reason for each link, its first 8 concepts, the misconception ids it carries, the full standards for the asked-about activity, and the standard codes of each linked one. Reads https://play.quantumlearningmachines.com once per session.",
102
+ inputSchema: {
103
+ mode_id: z.string().min(1).max(100).optional().describe("A LabPath mode id, for example from a misconception's modes."),
104
+ standard: z.string().min(1).max(50).optional().describe("A standard code, for example HS-PS2-1."),
105
+ direction: z.enum(["before", "after", "both"]).optional().describe("Default both."),
106
+ kinds: z.array(z.enum(EDGE_KINDS)).min(1).optional().describe("Edge kinds to include. Default all."),
107
+ limit: z.number().int().min(1).max(50).optional().describe("Most edges per direction. Default 10. Lowered, and said so, if the answer would not fit."),
108
+ },
109
+ annotations: NETWORK,
110
+ }, async (args) => guard(() => qlmLearningGraph(args, datasets)));
111
+ return server;
112
+ }
113
+ export async function start() {
114
+ await createServer().connect(new StdioServerTransport());
115
+ }
package/dist/stages.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The six assistance observations of draft-srivastava-learnlog-03, Section 2.2. Each is its own
3
+ * observation: one never implies the next, and a stage that wasn't captured is "not recorded".
4
+ */
5
+ export const LEARNLOG_DRAFT = "draft-srivastava-learnlog-03 (IETF Internet-Draft, work in progress)";
6
+ export const STAGES = [
7
+ { stage: "requested", meaning: "A learner or other identified actor asked for assistance. A request doesn't show that help was produced." },
8
+ { stage: "offered", meaning: "The instrument recorded a system or another identified actor starting assistance, for example steps given after a wrong answer. An offer doesn't show that help was displayed." },
9
+ { stage: "generated", meaning: "A system produced assistance content. Generation doesn't show that it was sent or displayed." },
10
+ { stage: "transmitted", meaning: "The instrument recorded assistance being sent to a stated destination. Sending doesn't show display or use." },
11
+ { stage: "displayed", meaning: "The instrument recorded assistance being shown in a stated interface. Display doesn't show attention, understanding or learning." },
12
+ { stage: "accepted", meaning: "The instrument recorded the action the product defines as accepting assistance. That doesn't by itself show use, agreement or learning." },
13
+ ];
14
+ export const STAGE_IDS = STAGES.map((s) => s.stage);
15
+ export const NOT_RECORDED = "not recorded";
16
+ /** The transformer's lifecycle event types, and the stage each one asserts. offered and accepted have none. */
17
+ export const TRANSFORMER_EVENT_STAGE = {
18
+ help_requested: "requested",
19
+ help_generated: "generated",
20
+ help_transmitted: "transmitted",
21
+ help_displayed: "displayed",
22
+ };
23
+ /** The past participle for "no help was ___" in a stage's note. */
24
+ export const STAGE_VERB = {
25
+ requested: "asked for",
26
+ offered: "offered",
27
+ generated: "generated",
28
+ transmitted: "sent",
29
+ displayed: "shown",
30
+ accepted: "accepted",
31
+ };
32
+ /** An object with one value per stage, in the draft's order. */
33
+ export function byStage(value) {
34
+ return Object.fromEntries(STAGE_IDS.map((s) => [s, value(s)]));
35
+ }