@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
@@ -0,0 +1,56 @@
1
+ /**
2
+ * QLM's public LabPath datasets, read from play.quantumlearningmachines.com: GET only, one host, a
3
+ * timeout, no redirects, and each dataset fetched at most once per process (kept in memory).
4
+ * Source: qlm-games src/app/api/labpath/dataset-export/route.ts.
5
+ */
6
+ import { Refusal } from "../files.js";
7
+ export const DATASET_URL = "https://play.quantumlearningmachines.com/api/labpath/dataset-export";
8
+ export const ARCHIVE = "QLM LabPath Dataset v1.0, Zenodo, https://doi.org/10.5281/zenodo.21800324 (CC BY 4.0; the archived v1.0 holds the 423 released misconceptions)";
9
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10
+ const LIST = { misconceptions: "misconceptions", "learning-graph": "learningGraph", standards: "standardsAlignment" };
11
+ export class DatasetClient {
12
+ fetchImpl;
13
+ cache = new Map();
14
+ constructor(fetchImpl = fetch) {
15
+ this.fetchImpl = fetchImpl;
16
+ }
17
+ get(dataset) {
18
+ let p = this.cache.get(dataset);
19
+ if (!p) {
20
+ p = this.load(dataset);
21
+ this.cache.set(dataset, p);
22
+ p.catch(() => this.cache.delete(dataset));
23
+ }
24
+ return p;
25
+ }
26
+ async load(dataset) {
27
+ const url = `${DATASET_URL}?dataset=${dataset}`;
28
+ let res;
29
+ try {
30
+ res = await this.fetchImpl(url, { method: "GET", redirect: "error", headers: { accept: "application/json" }, signal: AbortSignal.timeout(30_000) });
31
+ }
32
+ catch (e) {
33
+ throw new Refusal(`Couldn't reach play.quantumlearningmachines.com: ${e instanceof Error ? e.message : String(e)}`);
34
+ }
35
+ if (res.status !== 200)
36
+ throw new Refusal(`The LabPath dataset service answered HTTP ${res.status}.`);
37
+ const text = await res.text();
38
+ if (text.length > 50_000_000)
39
+ throw new Refusal("The LabPath dataset service's answer was too large.");
40
+ let body = null;
41
+ try {
42
+ body = JSON.parse(text);
43
+ }
44
+ catch {
45
+ // checked below
46
+ }
47
+ if (!isObj(body) || !isObj(body._meta) || !Array.isArray(body[LIST[dataset]]))
48
+ throw new Refusal(`The service's answer wasn't the ${dataset} export this tool expects.`);
49
+ return body;
50
+ }
51
+ }
52
+ /** The dataset's own description, for citing it. */
53
+ export function datasetInfo(body, dataset) {
54
+ const meta = body._meta;
55
+ return { source: `${DATASET_URL}?dataset=${dataset}`, version: meta.version, license: meta.license, citation: meta.citation, exported_at: meta.exportedAt, archived: ARCHIVE };
56
+ }
@@ -0,0 +1,61 @@
1
+ /** learnlog_transform: a local export into Learnlog records, with source and capture details kept. */
2
+ import { basename } from "node:path";
3
+ import { Refusal, readLocalText } from "../files.js";
4
+ import { ANSWER_BUDGET, answerSize } from "../respond.js";
5
+ import { engine, formatStages, profileOf, sourceWarnings, unknownsOf } from "../transformer.js";
6
+ /** Runs the transformer on a local file or the format's bundled sample. Nothing is written. */
7
+ export async function runTransform(input) {
8
+ const p = profileOf(input.format);
9
+ if ((input.path === undefined) === (input.use_sample !== true))
10
+ throw new Refusal("Give either path or use_sample: true, not both and not neither.");
11
+ const text = input.path === undefined ? engine.getSample(p.id) : readLocalText(input.path, "path", 2_000_000);
12
+ try {
13
+ return await engine.transform({ profileId: p.id, text, tenant: input.tenant ?? "local", sourceName: input.path === undefined ? p.sampleName : basename(input.path) });
14
+ }
15
+ catch (e) {
16
+ throw new Refusal(`The transformer refused this input: ${e.message}`);
17
+ }
18
+ }
19
+ export async function learnlogTransform(input) {
20
+ const run = await runTransform(input);
21
+ const s = run.profile.source;
22
+ const first = run.records[0];
23
+ const body = {
24
+ format: run.profile.id,
25
+ source: {
26
+ file: run.sourceName,
27
+ sha256: run.sourceHash,
28
+ kind: run.sourceKind,
29
+ verified_public_sample: run.sourceVerified,
30
+ ...(run.sourceVerified ? { provenance: { citation: s.citation, license: s.license, url: s.url, revision: s.revision } } : {}),
31
+ },
32
+ summary: { input_rows: run.summary.inputRowCount, records: run.summary.recordCount, entries: run.summary.eventCount, unit: run.summary.unit, event_types: run.summary.eventTypes },
33
+ help_stages: formatStages(run.profile),
34
+ unknowns: unknownsOf(run.records),
35
+ warnings: sourceWarnings(run.records),
36
+ heads: run.checkpoint,
37
+ pseudonyms: "Learner IDs are replaced with HMAC pseudonyms keyed by a random secret made when this server started. They don't match across restarts or other tools.",
38
+ preview: { header: first.header, entries: first.entries.slice(0, 1) },
39
+ does_not_show: [
40
+ "Help that happened outside what the source recorded. A Learnlog record keeps what the export asserts, and nothing else.",
41
+ "Whether the source's assertions are true. The transformer checks structure and keeps provenance; it can't check a recording against the world.",
42
+ "Learning or the effect of help. These are records, not estimates.",
43
+ ],
44
+ };
45
+ const limits = body.does_not_show;
46
+ delete body.does_not_show;
47
+ if (!input.include_document)
48
+ return { ...body, document_note: "Set include_document: true to get the whole Learnlog document. This tool never writes it to disk.", does_not_show: limits };
49
+ const whole = { ...body, document: run.document, does_not_show: limits };
50
+ if (answerSize(whole) <= ANSWER_BUDGET)
51
+ return whole;
52
+ const characters = JSON.stringify(run.document, null, 2).length;
53
+ return {
54
+ ...body,
55
+ document_omitted: {
56
+ characters,
57
+ note: `The whole Learnlog document is ${characters.toLocaleString("en-US")} characters, too large to send in one answer (the limit is ${ANSWER_BUDGET.toLocaleString("en-US")}). The summary, heads, unknowns and preview here describe it. Nothing was written to disk.`,
58
+ },
59
+ does_not_show: limits,
60
+ };
61
+ }
@@ -0,0 +1,47 @@
1
+ /** learnlog_validate: is this a valid Learnlog file, and what does each record leave unknown? */
2
+ import { readLocalText } from "../files.js";
3
+ import { VENDORED_FROM } from "../package.js";
4
+ import { engine, sourceWarnings, unknownsOf } from "../transformer.js";
5
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6
+ export const NOT_CHECKED = [
7
+ "Signed heads and signatures: the transformer's verifier refuses files that carry them, so they are not verified here.",
8
+ "Truncated or redacted records: refused, not checked.",
9
+ "Learnlog files from other producers: their header bindings won't match this transformer's profile, so they fail here even when their own chains are sound.",
10
+ "Whether any asserted event happened. Passing shows structural consistency under the stated rules, not that an event occurred (draft Section 2).",
11
+ ];
12
+ export async function learnlogValidate(input) {
13
+ let text = readLocalText(input.path, "path", 50_000_000);
14
+ const warnings = [];
15
+ if (text.startsWith("")) {
16
+ text = text.slice(1);
17
+ warnings.push("The file starts with a byte-order mark, which was ignored.");
18
+ }
19
+ const base = {
20
+ checked_with: `QLM transformer verifier ${engine.ENGINE_VERSION} (vendored from ${VENDORED_FROM})`,
21
+ not_checked: NOT_CHECKED,
22
+ does_not_show: ["What a valid record means for learning. Validation checks the chain, the profile bindings and the import rules, not the learner."],
23
+ };
24
+ let doc;
25
+ try {
26
+ doc = engine.parseStrictJSON(text);
27
+ }
28
+ catch (e) {
29
+ return { valid: false, errors: [`Not valid JSON: ${e.message}`], warnings, records: [], ...base };
30
+ }
31
+ const list = isObj(doc) && Array.isArray(doc.records) ? doc.records : [];
32
+ const records = list.map((r, i) => {
33
+ const h = isObj(r) && isObj(r.header) ? r.header : null;
34
+ const entries = isObj(r) && Array.isArray(r.entries) ? r.entries.length : 0;
35
+ return h
36
+ ? { record: i + 1, record_id: h.record_id, source: h.source_platform, import_mode: h.import_mode, source_kind: h.source_kind, entries, unknowns: unknownsOf([r]) }
37
+ : { record: i + 1, unknowns: [] };
38
+ });
39
+ warnings.push(...sourceWarnings(list));
40
+ try {
41
+ const heads = await engine.verify(doc);
42
+ return { valid: true, errors: [], warnings, records, heads, ...base };
43
+ }
44
+ catch (e) {
45
+ return { valid: false, errors: [e.message], warnings, records, ...base };
46
+ }
47
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * verify_credential: is this QLM credential real and current? The one tool that uses the network.
3
+ * It sends one GET to https://app.qlmdev.com/v1/verify?id=<credential id> and nothing else, and it
4
+ * shows only the fields of the report it knows. The service's report carries no personal data
5
+ * (verification.ts: no credential subject, no evidence events), and this tool keeps it that way by
6
+ * copying known fields one by one and listing the names of any others it dropped.
7
+ */
8
+ import { Refusal } from "../files.js";
9
+ import { NOT_RECORDED, byStage } from "../stages.js";
10
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
11
+ const str = (v) => typeof v === "string";
12
+ export const VERIFY_URL = "https://app.qlmdev.com/v1/verify";
13
+ const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
14
+ const URN = new RegExp(`urn:uuid:(${UUID})`, "gi");
15
+ const ONLY_URN = new RegExp(`^urn:uuid:(${UUID})$`, "i");
16
+ const ONLY_UUID = new RegExp(`^(${UUID})$`, "i");
17
+ /** The credential ID in what the person gave: the ID itself, a bare UUID, or a link containing one. */
18
+ export function credentialIdFrom(input) {
19
+ const text = input.trim();
20
+ if (text.length > 2048)
21
+ throw new Refusal("That's too long to be a credential ID or a link to one.");
22
+ // eslint-disable-next-line no-control-regex
23
+ const isId = !/[\u0000-\u001f\u007f]/.test(text);
24
+ const urn = isId ? ONLY_URN.exec(text) : null;
25
+ if (urn)
26
+ return { id: `urn:uuid:${urn[1].toLowerCase()}` };
27
+ const bare = isId ? ONLY_UUID.exec(text) : null;
28
+ if (bare) {
29
+ const id = `urn:uuid:${bare[1].toLowerCase()}`;
30
+ return { id, read_as: `A bare UUID was read as ${id}.` };
31
+ }
32
+ if (isId && /^https?:\/\//i.test(text)) {
33
+ const url = new URL(text);
34
+ if (url.pathname.startsWith("/v1/evidence"))
35
+ throw new Refusal("That's an evidence record link, not a credential. Give the credential's ID (urn:uuid:...) or a link containing it.");
36
+ let decoded = text;
37
+ try {
38
+ decoded = decodeURIComponent(text);
39
+ }
40
+ catch {
41
+ // keep the link as written
42
+ }
43
+ const found = new Set([...decoded.matchAll(URN)].map((m) => m[1].toLowerCase()));
44
+ if (found.size > 1)
45
+ throw new Refusal("That link holds more than one credential ID. Give the one you mean.");
46
+ const idParam = url.searchParams.get("id") ?? "";
47
+ const one = found.size === 1 ? [...found][0] : ONLY_UUID.test(idParam) ? idParam.toLowerCase() : null;
48
+ if (!one)
49
+ throw new Refusal("I couldn't find a credential ID in that link. Give the ID itself (urn:uuid:...).");
50
+ const id = `urn:uuid:${one}`;
51
+ return { id, read_as: `Read the ID ${id} from the link.` };
52
+ }
53
+ throw new Refusal("That isn't a credential ID (urn:uuid:...) or a link that contains one.");
54
+ }
55
+ const POLICY = {
56
+ not_permitted: "AI help: not permitted",
57
+ permitted: "AI help: permitted",
58
+ not_reviewed: "AI help: not reviewed (the issuer hasn't said whether AI help is permitted)",
59
+ };
60
+ /** The platform's three summary stages, in the draft's terms (summary.ts SUMMARY_STAGES). */
61
+ const SUMMARY_KEY = { requested: "asked", generated: "generated", displayed: "shown" };
62
+ function aiHelp(a, dropped) {
63
+ for (const k of Object.keys(a))
64
+ if (!["type", "learnlog", "coverage", "instrument", "boundary", "exclusions", "asked", "generated", "shown", "policy", "policyVersion"].includes(k))
65
+ dropped.push(`credential.aiHelp.${k}`);
66
+ const stages = byStage((stage) => {
67
+ const key = SUMMARY_KEY[stage];
68
+ if (!key)
69
+ return { status: NOT_RECORDED, note: "The badge's summary doesn't report this stage." };
70
+ const s = isObj(a[key]) ? a[key] : {};
71
+ if (s.capture === "complete")
72
+ return { status: "recorded", count: s.count, capture: "complete within the stated boundary" };
73
+ if (s.capture === "partial")
74
+ return { status: "recorded", count: s.count, capture: "partial: at least this many" };
75
+ return { status: NOT_RECORDED };
76
+ });
77
+ const recordedBy = a.coverage === "not_recorded"
78
+ ? "No AI-help record was kept with this evidence, so nothing is known about AI help. Not recorded is not the same as none."
79
+ : { learnlog: a.learnlog, instrument: a.instrument, coverage: str(a.coverage) ? a.coverage.replaceAll("_", " ") : a.coverage, boundary: a.boundary, not_covered: a.exclusions };
80
+ return { policy: str(a.policy) && POLICY[a.policy] ? POLICY[a.policy] : "not stated on this badge", stages, recorded_by: recordedBy };
81
+ }
82
+ const DOES_NOT_SHOW = [
83
+ "Who holds the badge. The service's report carries no personal data, and this tool shows none.",
84
+ "The evidence events themselves. Only their count and the seal check are reported.",
85
+ "Whether the person can do the work today. A credential is a record of a past assessment.",
86
+ "Anything about AI help beyond the badge's own summary. A stage the summary doesn't cover is not recorded, never none.",
87
+ ];
88
+ async function readCapped(res) {
89
+ const text = await res.text();
90
+ if (text.length > 1_000_000)
91
+ throw new Refusal("The verify service's answer was too large to be a verification report.");
92
+ return text;
93
+ }
94
+ export async function verifyCredential(input, fetchImpl = fetch) {
95
+ const { id, read_as } = credentialIdFrom(input.credential);
96
+ const url = `${VERIFY_URL}?id=${encodeURIComponent(id)}`;
97
+ let res;
98
+ try {
99
+ res = await fetchImpl(url, { method: "GET", redirect: "error", headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
100
+ }
101
+ catch (e) {
102
+ throw new Refusal(`Couldn't reach app.qlmdev.com: ${e instanceof Error ? e.message : String(e)}`);
103
+ }
104
+ const text = await readCapped(res);
105
+ let body = null;
106
+ try {
107
+ body = JSON.parse(text);
108
+ }
109
+ catch {
110
+ // handled by status below
111
+ }
112
+ const service = { url, http_status: res.status };
113
+ const base = { credential_id: id, ...(read_as ? { read_as } : {}), service };
114
+ if (res.status === 404 && isObj(body) && body.error === "not_found") {
115
+ return { ...base, verified: false, result: "not_found", meaning: "app.qlmdev.com has no credential with exactly this ID. It may be mistyped, or not issued by this platform.", does_not_show: DOES_NOT_SHOW };
116
+ }
117
+ if (res.status === 429)
118
+ throw new Refusal(`The verify service's rate limit was reached. Try again in ${res.headers.get("retry-after") ? `${res.headers.get("retry-after")} seconds` : "a little while"}.`);
119
+ if (res.status === 400 && isObj(body) && str(body.error))
120
+ throw new Refusal(`The verify service refused the request (${body.error})${str(body.message) ? `: ${body.message}` : "."}`);
121
+ if (res.status !== 200)
122
+ throw new Refusal(`The verify service answered HTTP ${res.status}.`);
123
+ const checksOk = isObj(body) && Array.isArray(body.checks) && body.checks.every((c) => isObj(c) && str(c.check) && (c.result === "pass" || c.result === "fail"));
124
+ if (!isObj(body) || typeof body.verified !== "boolean" || !checksOk)
125
+ throw new Refusal("The verify service's answer wasn't the JSON report this tool expects.");
126
+ const dropped = Object.keys(body).filter((k) => !["verified", "credentialId", "checks", "credential", "notices"].includes(k));
127
+ const checks = body.checks.map((c) => ({ check: c.check, result: c.result, ...(str(c.detail) ? { detail: c.detail } : {}) }));
128
+ const out = {
129
+ ...base,
130
+ verified: body.verified,
131
+ checks,
132
+ failed_checks: checks.filter((c) => c.result === "fail").map((c) => ({ check: c.check, detail: c.detail })),
133
+ };
134
+ const c = body.credential;
135
+ if (isObj(c)) {
136
+ for (const k of Object.keys(c))
137
+ if (!["issuer", "achievement", "validFrom", "evidenceEventCount", "evidenceLevel", "reissuedFromIssuerRecords", "awardedDate", "aiHelp"].includes(k))
138
+ dropped.push(`credential.${k}`);
139
+ const issuer = c.issuer;
140
+ const achievement = c.achievement;
141
+ out.issuer = { id: issuer.id, name: issuer.name };
142
+ out.achievement = { id: achievement.id, name: achievement.name, version: achievement.version };
143
+ out.date = { valid_from: c.validFrom, ...(c.awardedDate !== undefined ? { awarded_date: c.awardedDate, reissued_from_issuer_records: c.reissuedFromIssuerRecords === true } : {}) };
144
+ out.evidence = { event_count: c.evidenceEventCount, ...(c.evidenceLevel !== undefined ? { level: c.evidenceLevel } : {}) };
145
+ out.ai_help = isObj(c.aiHelp) ? aiHelp(c.aiHelp, dropped) : "not recorded: this badge carries no AI-help summary (it was issued before the summary existed, or under an older context)";
146
+ }
147
+ else {
148
+ out.issuer = "not given: the service names the issuer, achievement and date only when every check passes";
149
+ }
150
+ out.notices = Array.isArray(body.notices) ? body.notices.filter(str) : [];
151
+ if (dropped.length)
152
+ out.fields_not_shown = dropped;
153
+ out.does_not_show = DOES_NOT_SHOW;
154
+ return out;
155
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * verify_evidence: can I trust this result? Runs qlm-measure's own verifier (vendored 0.9.0 build,
3
+ * unchanged) on a local evidence record or a product hash chain, the way its CLI does, and reports
4
+ * pass, or each named check that failed. The CLI's --report option, which writes a file, is never used.
5
+ */
6
+ import { CATALOG, CATALOG_V03 } from "../../vendor/qlm-measure/checks.js";
7
+ import { main } from "../../vendor/qlm-measure/cli.js";
8
+ import { Refusal, localFile } from "../files.js";
9
+ import { VENDORED_FROM } from "../package.js";
10
+ const CHECK = new Map([...CATALOG_V03, ...CATALOG].map((c) => [c.id, c]));
11
+ /** The verifier's stdin reader. This tool only ever passes file paths, so it is never asked to read stdin. */
12
+ export function noStdin() {
13
+ throw new Refusal("verify_evidence reads files, not standard input.");
14
+ }
15
+ function run(argv) {
16
+ let out = "";
17
+ let err = "";
18
+ const code = main(argv, { out: (t) => (out += t), err: (t) => (err += t), readStdin: noStdin });
19
+ if (code === 2)
20
+ throw new Refusal(`The verifier stopped: ${err.trim()}`);
21
+ return { code, out, err };
22
+ }
23
+ const DOES_NOT_SHOW = [
24
+ "Whether the estimates in a record are right. The verifier checks bookkeeping integrity (hashes, order, schema, how posteriors chain), not how anything was estimated.",
25
+ "Whether the recorded events happened as described. A clean chain shows the records weren't changed after they were sealed.",
26
+ "Who the learner is.",
27
+ ];
28
+ export function verifyEvidence(input) {
29
+ const kind = input.kind ?? "record";
30
+ const verifier = `qlm-measure 0.9.0 (vendored from ${VENDORED_FROM})`;
31
+ if (kind === "list_schemes") {
32
+ const schemes = run(["verify-chain", "--list"]).out.trim().split("\n").map((line) => {
33
+ const [id, family, since, fields] = line.split("\t");
34
+ return { id, family, since: since.replace(/^since /, ""), fields };
35
+ });
36
+ return { verifier, schemes, does_not_show: ["Anything about a file. This lists the chain schemes the verifier knows."] };
37
+ }
38
+ const { path } = localFile(input.path, "path", 100_000_000);
39
+ if (kind === "chain") {
40
+ if (input.family === undefined && input.scheme === undefined)
41
+ throw new Refusal("Name the chain's family (for example tpc/clinical) or its exact scheme (for example tpc/clinical-v2). verify_evidence with kind list_schemes lists them.");
42
+ const argv = ["verify-chain", path, ...(input.scheme !== undefined ? ["--scheme", input.scheme] : ["--family", input.family]), "--format", "json", ...(input.allow_mixed ? ["--allow-mixed"] : [])];
43
+ const { code, out } = run(argv);
44
+ const report = JSON.parse(out);
45
+ return { verdict: code === 0 ? "pass" : "fail", verifier, failed_checks: report.errors, report, does_not_show: DOES_NOT_SHOW };
46
+ }
47
+ const { code, out } = run(["verify", path, "--format", "json"]);
48
+ const report = JSON.parse(out);
49
+ const failed = report.records.flatMap((r, i) => r.checks.filter((c) => c.status === "fail").map((c) => ({ record: i + 1, check: c.id, failures: c.failures, first_failure: c.first_failure, what_it_checks: CHECK.get(c.id)?.description, how_to_pass: CHECK.get(c.id)?.howToPass })));
50
+ return { verdict: code === 0 ? "pass" : "fail", verifier, records_checked: report.records.length, failed_checks: failed, report, does_not_show: DOES_NOT_SHOW };
51
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * LabPath worlds and their public pages. The names come from each world's page on
3
+ * play.quantumlearningmachines.com (data/labpath-worlds.json, rebuilt by scripts/refresh-worlds.mjs).
4
+ * A world not in that list gets no link: a page is never made up.
5
+ */
6
+ import { readJsonFile } from "../package.js";
7
+ const TABLE = readJsonFile("data/labpath-worlds.json");
8
+ export const WORLD_URL = TABLE.url_pattern.replace("{id}", "");
9
+ export function worldLink(id) {
10
+ const name = Object.hasOwn(TABLE.worlds, id) ? TABLE.worlds[id] : null;
11
+ return name === null ? { id, name: null, url: null, note: "No public page is known for this world." } : { id, name, url: WORLD_URL + id };
12
+ }
13
+ /** Links for a list of world ids, in order, without repeats; anything that isn't text is skipped. */
14
+ export function worldLinks(ids) {
15
+ const seen = new Set();
16
+ const out = [];
17
+ for (const id of Array.isArray(ids) ? ids : []) {
18
+ if (typeof id !== "string" || seen.has(id))
19
+ continue;
20
+ seen.add(id);
21
+ out.push(worldLink(id));
22
+ }
23
+ return out;
24
+ }
@@ -0,0 +1,92 @@
1
+ /** The vendored QLM transformer (qlm-measure demo/transformer/dist/engine.mjs), used unchanged. */
2
+ import * as engine from "../vendor/transformer/engine.mjs";
3
+ import { Refusal } from "./files.js";
4
+ import { NOT_RECORDED, byStage } from "./stages.js";
5
+ export { engine };
6
+ /** Every adapter the engine accepts, including sandpiper, which the transformer's demo page doesn't list. */
7
+ export const FORMAT_IDS = engine.ADAPTER_PROFILES.map((p) => p.id);
8
+ export function profileOf(id) {
9
+ const p = engine.ADAPTER_PROFILES.find((x) => x.id === id);
10
+ if (!p)
11
+ throw new Refusal(`Unknown format "${id}". Use one of: ${FORMAT_IDS.join(", ")}.`);
12
+ return p;
13
+ }
14
+ const LIFECYCLE_TYPE = { requested: "help_requested", generated: "help_generated", transmitted: "help_transmitted", displayed: "help_displayed" };
15
+ /** What a format can record at each stage, before looking at any data. */
16
+ export function formatStages(p) {
17
+ return byStage((stage) => {
18
+ if (p.mode === "dialogue")
19
+ return `${NOT_RECORDED}: the adapter keeps who spoke and what they said; it doesn't classify any turn as help`;
20
+ if (p.mode === "summary")
21
+ return `${NOT_RECORDED}: the table gives each participant's assigned help condition, not help events`;
22
+ if (p.mode === "annotated_dialogue")
23
+ return `${NOT_RECORDED}: the export keeps who spoke and what they said; annotation codes are kept as an annotator's inferred assertions and never set a help stage`;
24
+ const type = LIFECYCLE_TYPE[stage];
25
+ return type ? `recorded when the export asserts it (${type})` : `${NOT_RECORDED}: the transformer's lifecycle input has no event type for this stage`;
26
+ });
27
+ }
28
+ const words = (s) => s.replaceAll("_", " ");
29
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
30
+ const CONTEXT_LABEL = { phase: "Phase", permission: "Help policy (permission)", observed_use: "Observed help use", capture: "Capture coverage", capture_boundary: "Capture boundary", product_version: "Product version", model_version: "Model version" };
31
+ /** The unknowns a set of transformer-made records keep, in plain words, counted across their entries. */
32
+ export function unknownsOf(records) {
33
+ const out = new Set();
34
+ const counts = new Map();
35
+ let entries = 0;
36
+ const count = (k) => counts.set(k, (counts.get(k) ?? 0) + 1);
37
+ for (const r of records) {
38
+ if (!isObj(r) || !isObj(r.header))
39
+ continue;
40
+ const h = r.header;
41
+ if (typeof h.assistance_coverage === "string")
42
+ out.add(`Help coverage: ${words(h.assistance_coverage)}`);
43
+ const m = isObj(h.semantic_mapping) ? h.semantic_mapping : {};
44
+ const cov = isObj(m.coverage) ? m.coverage : {};
45
+ if (typeof cov.status === "string")
46
+ out.add(`Coverage of the mapping: ${words(cov.status)}${typeof cov.boundary === "string" ? ` (${cov.boundary})` : ""}`);
47
+ if (m.measurement_profile === null)
48
+ out.add("No measurement profile: these records don't define a measure");
49
+ if (m.denominator === null)
50
+ out.add("No denominator: counts from these records are not rates");
51
+ for (const e of Array.isArray(r.entries) ? r.entries : []) {
52
+ const data = isObj(e) && isObj(e.event) && isObj(e.event.data) ? e.event.data : null;
53
+ if (!data)
54
+ continue;
55
+ entries++;
56
+ if (data.original_source_timestamp === null)
57
+ count("Source event time not recorded");
58
+ const utterance = isObj(data.utterance) ? data.utterance : {};
59
+ if (utterance.start_time === null)
60
+ count("Utterance start time not recorded");
61
+ if (utterance.end_time === null)
62
+ count("Utterance end time not recorded");
63
+ const ctx = isObj(data.assistance_context) ? data.assistance_context : {};
64
+ for (const [k, label] of Object.entries(CONTEXT_LABEL)) {
65
+ if (ctx[k] === "unknown")
66
+ count(`${label} not recorded (unknown)`);
67
+ if (ctx[k] === null)
68
+ count(`${label} not recorded`);
69
+ }
70
+ for (const name of Array.isArray(data.missing_metrics) ? data.missing_metrics : [])
71
+ count(`Metric ${String(name)} missing`);
72
+ const help = isObj(data.help_evidence) ? data.help_evidence.actual_help_use : undefined;
73
+ if (typeof help === "string")
74
+ count(`Actual help use ${words(help)}`);
75
+ }
76
+ }
77
+ for (const [k, n] of counts)
78
+ out.add(`${k} on ${n} of ${entries} entries`);
79
+ return [...out];
80
+ }
81
+ /** Warnings about where a set of records came from. */
82
+ export function sourceWarnings(records) {
83
+ const out = new Set();
84
+ for (const r of records) {
85
+ const kind = isObj(r) && isObj(r.header) ? r.header.source_kind : undefined;
86
+ if (kind === "unverified_user_input")
87
+ out.add("The source bytes don't match a pinned public sample, so where they came from is the producer's claim.");
88
+ if (kind === "synthetic_fixture")
89
+ out.add("This is a SYNTHETIC contract fixture, not learner data.");
90
+ }
91
+ return [...out];
92
+ }
package/dist/zip.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A small zip reader: the central directory, then one member at a time, so a 1 GB archive of HTML
3
+ * reports is never held decompressed all at once. It reads what Python's zipfile reads for the
4
+ * pinned Zenodo archive: stored and deflated members, cp437 or UTF-8 names, CRC-32 checked. ZIP64 and
5
+ * encrypted members are refused rather than guessed at.
6
+ */
7
+ import { crc32, inflateRawSync } from "node:zlib";
8
+ const CP437_HIGH = "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u00a0";
9
+ function cp437(bytes) {
10
+ let s = "";
11
+ for (const b of bytes)
12
+ s += b < 0x80 ? String.fromCharCode(b) : CP437_HIGH[b - 0x80];
13
+ return s;
14
+ }
15
+ /** The members listed in the central directory, in its order (Python's ZipFile.namelist()). */
16
+ export function zipEntries(buf) {
17
+ let end = -1;
18
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
19
+ if (buf.readUInt32LE(i) === 0x06054b50) {
20
+ end = i;
21
+ break;
22
+ }
23
+ }
24
+ if (end < 0)
25
+ throw new Error("File is not a zip file");
26
+ const count = buf.readUInt16LE(end + 10);
27
+ const cdSize = buf.readUInt32LE(end + 12);
28
+ const cdOffset = buf.readUInt32LE(end + 16);
29
+ if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff)
30
+ throw new Error("ZIP64 archives are not supported by this reader");
31
+ const entries = [];
32
+ let p = cdOffset;
33
+ for (let k = 0; k < count; k++) {
34
+ if (buf.readUInt32LE(p) !== 0x02014b50)
35
+ throw new Error("Bad magic number for central directory");
36
+ const flags = buf.readUInt16LE(p + 8);
37
+ const nameLength = buf.readUInt16LE(p + 28);
38
+ const raw = buf.subarray(p + 46, p + 46 + nameLength);
39
+ entries.push({
40
+ name: flags & 0x800 ? raw.toString("utf8") : cp437(raw),
41
+ flags,
42
+ method: buf.readUInt16LE(p + 10),
43
+ crc: buf.readUInt32LE(p + 16),
44
+ compressedSize: buf.readUInt32LE(p + 20),
45
+ size: buf.readUInt32LE(p + 24),
46
+ localOffset: buf.readUInt32LE(p + 42),
47
+ });
48
+ p += 46 + nameLength + buf.readUInt16LE(p + 30) + buf.readUInt16LE(p + 32);
49
+ }
50
+ return entries;
51
+ }
52
+ /** One member's bytes (Python's ZipFile.read(name)), with its CRC-32 checked. */
53
+ export function zipRead(buf, e) {
54
+ if (e.flags & 1)
55
+ throw new Error(`File '${e.name}' is encrypted; encrypted members are not supported`);
56
+ const p = e.localOffset;
57
+ if (buf.readUInt32LE(p) !== 0x04034b50)
58
+ throw new Error("Bad magic number for file header");
59
+ const start = p + 30 + buf.readUInt16LE(p + 26) + buf.readUInt16LE(p + 28);
60
+ const body = buf.subarray(start, start + e.compressedSize);
61
+ let data;
62
+ if (e.method === 0)
63
+ data = Buffer.from(body);
64
+ else if (e.method === 8)
65
+ data = inflateRawSync(body);
66
+ else
67
+ throw new Error(`compression method ${e.method} is not supported`);
68
+ if (crc32(data) !== e.crc)
69
+ throw new Error(`Bad CRC-32 for file '${e.name}'`);
70
+ return data;
71
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@qlm/learnlog-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Learnlog MCP: ask your AI assistant what learning records actually show about help. Read-only tools for Learnlog records, QLM credential verification, and QLM's public misconception and learning-graph datasets.",
5
+ "license": "Apache-2.0",
6
+ "author": "Kumar Srivastava <kumar@quantumlearningmachines.com> (https://quantumlearningmachines.com)",
7
+ "type": "module",
8
+ "bin": {
9
+ "learnlog-mcp": "dist/bin.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "vendor",
14
+ "data",
15
+ "skills",
16
+ "README.md",
17
+ "CHANGELOG.md",
18
+ "LICENSE",
19
+ "NOTICE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.12"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json && node -e \"require('fs').chmodSync('dist/bin.js', 0o755)\"",
26
+ "typecheck": "tsc -p tsconfig.json",
27
+ "lint": "eslint .",
28
+ "test": "npm run build && vitest run --coverage",
29
+ "vendor:check": "node scripts/vendor.mjs --check ..",
30
+ "smoke:live": "node scripts/live-smoke.mjs",
31
+ "prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run vendor:check",
32
+ "prepack": "npm run build",
33
+ "parity:real": "node scripts/parity-real.mjs"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "1.32.1",
37
+ "zod": "4.6.7"
38
+ },
39
+ "devDependencies": {
40
+ "@eslint/js": "10.0.1",
41
+ "@types/node": "22.20.5",
42
+ "@vitest/coverage-v8": "5.0.3",
43
+ "eslint": "10.12.0",
44
+ "typescript": "6.0.3",
45
+ "typescript-eslint": "8.71.1",
46
+ "vitest": "5.0.3"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/quantumlearningmachines/qlm-measure.git",
51
+ "directory": "mcp"
52
+ },
53
+ "homepage": "https://github.com/quantumlearningmachines/qlm-measure/tree/main/mcp#readme",
54
+ "keywords": [
55
+ "mcp",
56
+ "model-context-protocol",
57
+ "learnlog",
58
+ "learning-analytics",
59
+ "education",
60
+ "assistance",
61
+ "evidence",
62
+ "misconceptions",
63
+ "learning-graph"
64
+ ],
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }