@creeperhost/modlens-mcp 1.6.19 → 1.6.21

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 (108) hide show
  1. package/ATTRIBUTION.md +8 -1
  2. package/README.md +82 -5
  3. package/RUNTIME.md +262 -0
  4. package/TESTING.md +8 -0
  5. package/dist/cli.js +28 -8
  6. package/dist/cli.js.map +1 -1
  7. package/dist/hosted-mod-license.d.ts +36 -0
  8. package/dist/hosted-mod-license.d.ts.map +1 -0
  9. package/dist/hosted-mod-license.js +65 -0
  10. package/dist/hosted-mod-license.js.map +1 -0
  11. package/dist/hosted-policy.d.ts +32 -0
  12. package/dist/hosted-policy.d.ts.map +1 -0
  13. package/dist/hosted-policy.js +257 -0
  14. package/dist/hosted-policy.js.map +1 -0
  15. package/dist/launcher.js +104 -89
  16. package/dist/launcher.js.map +1 -1
  17. package/dist/license-notices.d.ts +8 -0
  18. package/dist/license-notices.d.ts.map +1 -0
  19. package/dist/license-notices.js +10 -0
  20. package/dist/license-notices.js.map +1 -0
  21. package/dist/license-source-references.d.ts +12 -0
  22. package/dist/license-source-references.d.ts.map +1 -0
  23. package/dist/license-source-references.js +18 -0
  24. package/dist/license-source-references.js.map +1 -0
  25. package/dist/license-templates.d.ts +6 -0
  26. package/dist/license-templates.d.ts.map +1 -0
  27. package/dist/license-templates.js +55 -0
  28. package/dist/license-templates.js.map +1 -0
  29. package/dist/local-mod.d.ts +31 -0
  30. package/dist/local-mod.d.ts.map +1 -0
  31. package/dist/local-mod.js +71 -0
  32. package/dist/local-mod.js.map +1 -0
  33. package/dist/mod-license.d.ts +53 -0
  34. package/dist/mod-license.d.ts.map +1 -0
  35. package/dist/mod-license.js +617 -0
  36. package/dist/mod-license.js.map +1 -0
  37. package/dist/primer-catalog.d.ts +15 -0
  38. package/dist/primer-catalog.d.ts.map +1 -0
  39. package/dist/primer-catalog.js +43 -0
  40. package/dist/primer-catalog.js.map +1 -0
  41. package/dist/primer-content.d.ts +7 -0
  42. package/dist/primer-content.d.ts.map +1 -0
  43. package/dist/primer-content.js +185 -0
  44. package/dist/primer-content.js.map +1 -0
  45. package/dist/runtime/cli.d.ts +2 -0
  46. package/dist/runtime/cli.d.ts.map +1 -0
  47. package/dist/runtime/cli.js +163 -0
  48. package/dist/runtime/cli.js.map +1 -0
  49. package/dist/runtime/companion.d.ts +32 -0
  50. package/dist/runtime/companion.d.ts.map +1 -0
  51. package/dist/runtime/companion.js +135 -0
  52. package/dist/runtime/companion.js.map +1 -0
  53. package/dist/runtime/guidance.d.ts +73 -0
  54. package/dist/runtime/guidance.d.ts.map +1 -0
  55. package/dist/runtime/guidance.js +40 -0
  56. package/dist/runtime/guidance.js.map +1 -0
  57. package/dist/runtime/hub.d.ts +150 -0
  58. package/dist/runtime/hub.d.ts.map +1 -0
  59. package/dist/runtime/hub.js +561 -0
  60. package/dist/runtime/hub.js.map +1 -0
  61. package/dist/runtime/modlens-agent.jar +0 -0
  62. package/dist/runtime/protocol.d.ts +202 -0
  63. package/dist/runtime/protocol.d.ts.map +1 -0
  64. package/dist/runtime/protocol.js +91 -0
  65. package/dist/runtime/protocol.js.map +1 -0
  66. package/dist/runtime/requests.d.ts +367 -0
  67. package/dist/runtime/requests.d.ts.map +1 -0
  68. package/dist/runtime/requests.js +57 -0
  69. package/dist/runtime/requests.js.map +1 -0
  70. package/dist/server.js +141 -45
  71. package/dist/server.js.map +1 -1
  72. package/dist/setup.js +9 -4
  73. package/dist/setup.js.map +1 -1
  74. package/dist/tools/primers.d.ts +133 -22
  75. package/dist/tools/primers.d.ts.map +1 -1
  76. package/dist/tools/primers.js +391 -386
  77. package/dist/tools/primers.js.map +1 -1
  78. package/dist/tools/project.d.ts +11 -7
  79. package/dist/tools/project.d.ts.map +1 -1
  80. package/dist/tools/project.js +45 -3
  81. package/dist/tools/project.js.map +1 -1
  82. package/dist/tools/report-issue.d.ts +77 -0
  83. package/dist/tools/report-issue.d.ts.map +1 -0
  84. package/dist/tools/report-issue.js +92 -0
  85. package/dist/tools/report-issue.js.map +1 -0
  86. package/dist/tools/runtime.d.ts +6 -0
  87. package/dist/tools/runtime.d.ts.map +1 -0
  88. package/dist/tools/runtime.js +18 -0
  89. package/dist/tools/runtime.js.map +1 -0
  90. package/dist/tools/source.d.ts +1 -1
  91. package/dist/tools/source.d.ts.map +1 -1
  92. package/dist/tools/source.js +5 -1
  93. package/dist/tools/source.js.map +1 -1
  94. package/package.json +8 -1
  95. package/scripts/build-runtime-agent.mjs +64 -0
  96. package/scripts/fixtures/mod-license-corpus.json +154 -0
  97. package/scripts/gradle/modlens-runtime.init.gradle +17 -0
  98. package/scripts/runtime-test-client.mjs +36 -0
  99. package/scripts/test-hosted-policy.mjs +123 -0
  100. package/scripts/test-mod-licenses-live.mjs +86 -0
  101. package/scripts/test-package.mjs +141 -5
  102. package/scripts/test-primers-live.mjs +93 -0
  103. package/scripts/test-project-http.mjs +46 -3
  104. package/scripts/test-runtime-gradle.mjs +107 -0
  105. package/scripts/test-runtime-minecraft.mjs +190 -0
  106. package/scripts/test-runtime-native.mjs +120 -0
  107. package/scripts/test-runtime-remote.mjs +209 -0
  108. package/scripts/update-license-templates.mjs +14 -0
@@ -1,73 +1,18 @@
1
1
  /**
2
- * MCP tools for version migration "primers".
3
- *
4
- * Primers document how to migrate mods/projects from one Minecraft version to another
5
- * (e.g. NeoForge breaking changes, Forge migration guides).
6
- *
7
- * Stored in the `primers` Postgres table.
8
- * fromDataVersion / toDataVersion are the integer data_version values from mcmeta,
9
- * enabling numeric range queries without fragile string comparisons.
2
+ * Migration guides shared by the MCP, CLI and setup wizard.
3
+ * Content is fetched on demand and cached in the configured database.
10
4
  */
11
5
  import { readFile, writeFile } from "fs/promises";
12
6
  import { join } from "path";
7
+ import { createHash } from "node:crypto";
13
8
  import { getDb } from "../db.js";
14
9
  import { CACHE_ROOT, exists, ensureDir } from "../cache.js";
15
10
  import { caseInsensitive, deserializeArray, detectBackend, serializeArray } from "../db-backend.js";
16
11
  import { embed, isOllamaAvailable, chunkText } from "../embeddings.js";
17
12
  import { upsertPrimerEmbedding, searchPrimersByVector, countUnembedded } from "../repositories/embeddings.js";
18
- // ── Primer URL security ───────────────────────────────────────────────────────
19
- const DEFAULT_PRIMER_HOSTS = [
20
- "github.com", "raw.githubusercontent.com",
21
- "neoforged.net", "docs.neoforged.net",
22
- "fabricmc.net", "wiki.fabricmc.net",
23
- "modrinth.com",
24
- "curseforge.com",
25
- "minecraft.wiki",
26
- "docs.minecraftforge.net", "minecraftforge.net",
27
- "quiltmc.org", "wiki.quiltmc.org",
28
- "linuxcafe.net",
29
- "gist.github.com",
30
- "gitlab.com",
31
- "codeberg.org",
32
- ];
33
- function getPrimerAllowedHosts() {
34
- const extra = process.env.MODLENS_PRIMER_ALLOWED_HOSTS;
35
- const hosts = [...DEFAULT_PRIMER_HOSTS];
36
- if (extra)
37
- hosts.push(...extra.split(",").map(h => h.trim()).filter(Boolean));
38
- return new Set(hosts);
39
- }
40
- function validatePrimerUrl(url) {
41
- // Bypass mode: allow any HTTPS URL
42
- if (process.env.MODLENS_PRIMER_ALLOW_ANY_HTTPS === "1") {
43
- let parsed;
44
- try {
45
- parsed = new URL(url);
46
- }
47
- catch {
48
- throw new Error(`Invalid primer URL: ${url}`);
49
- }
50
- if (parsed.protocol !== "https:")
51
- throw new Error(`Primer URL must use HTTPS: ${url}`);
52
- return;
53
- }
54
- let parsed;
55
- try {
56
- parsed = new URL(url);
57
- }
58
- catch {
59
- throw new Error(`Invalid primer URL: ${url}`);
60
- }
61
- if (parsed.protocol !== "https:")
62
- throw new Error(`Primer URL must use HTTPS: ${url}`);
63
- const allowed = getPrimerAllowedHosts();
64
- if (!allowed.has(parsed.hostname) && ![...allowed].some(h => parsed.hostname.endsWith(`.${h}`))) {
65
- throw new Error(`Primer URL hostname "${parsed.hostname}" not in allowed list. ` +
66
- `Add it via MODLENS_PRIMER_ALLOWED_HOSTS env var, or set MODLENS_PRIMER_ALLOW_ANY_HTTPS=1 to allow any HTTPS URL.`);
67
- }
68
- }
69
- /** Exported for use in setup wizard. */
70
- export { DEFAULT_PRIMER_HOSTS };
13
+ import { SEED_PRIMERS, LEGACY_PRIMER_URLS } from "../primer-catalog.js";
14
+ import { fetchPrimerContent, validatePrimerUrl, MAX_PRIMER_CONTENT } from "../primer-content.js";
15
+ export { DEFAULT_PRIMER_HOSTS } from "../primer-content.js";
71
16
  function normalizePrimerTags(primer) {
72
17
  return { ...primer, tags: deserializeArray(primer.tags) };
73
18
  }
@@ -75,389 +20,449 @@ function serializePrimerTags(tags) {
75
20
  return serializeArray(tags);
76
21
  }
77
22
  function primerTagSearchFilters(query) {
78
- if (detectBackend() !== "sqlite") {
23
+ if (detectBackend() !== "sqlite")
79
24
  return [{ tags: { has: query } }];
80
- }
81
- const tagsContains = { contains: query, ...caseInsensitive() };
82
- return [{ tags: tagsContains }];
25
+ return [{ tags: { contains: query, ...caseInsensitive() } }];
83
26
  }
84
- // ── Version resolution ────────────────────────────────────────────────────────
85
27
  const VERSIONS_CACHE = join(CACHE_ROOT, "mcmeta", "_latest", "summary", "versions", "data.json");
86
28
  const VERSIONS_URL = "https://raw.githubusercontent.com/misode/mcmeta/summary/versions/data.json";
87
- let _versionsCache = null;
29
+ let versionsPending;
88
30
  async function getVersions() {
89
- if (_versionsCache)
90
- return _versionsCache;
91
- try {
92
- if (await exists(VERSIONS_CACHE)) {
93
- const text = (await readFile(VERSIONS_CACHE)).toString("utf8");
94
- _versionsCache = JSON.parse(text);
95
- return _versionsCache;
96
- }
31
+ if (!versionsPending) {
32
+ versionsPending = (async () => {
33
+ try {
34
+ if (await exists(VERSIONS_CACHE)) {
35
+ const cached = JSON.parse(await readFile(VERSIONS_CACHE, "utf8"));
36
+ if (Array.isArray(cached))
37
+ return cached;
38
+ }
39
+ }
40
+ catch { /* fetch if the cache is missing or corrupt */ }
41
+ const response = await fetch(VERSIONS_URL, { signal: AbortSignal.timeout(15_000) });
42
+ if (!response.ok)
43
+ throw new Error("Failed to fetch versions: " + response.status);
44
+ const data = await response.json();
45
+ if (!Array.isArray(data))
46
+ throw new Error("Invalid version catalogue");
47
+ // A read-only cache must not prevent version resolution.
48
+ try {
49
+ await ensureDir(VERSIONS_CACHE);
50
+ await writeFile(VERSIONS_CACHE, JSON.stringify(data));
51
+ }
52
+ catch { /* optional cache */ }
53
+ return data;
54
+ })().catch(error => { versionsPending = undefined; throw error; });
97
55
  }
98
- catch { /* fall through to fetch */ }
99
- const res = await fetch(VERSIONS_URL);
100
- if (!res.ok)
101
- throw new Error(`Failed to fetch versions: ${res.status}`);
102
- const data = await res.json();
103
- await ensureDir(VERSIONS_CACHE);
104
- await writeFile(VERSIONS_CACHE, JSON.stringify(data));
105
- _versionsCache = data;
106
- return data;
56
+ return versionsPending;
107
57
  }
108
- /** Resolve a version string to its integer data_version (null if unknown). */
109
- async function resolveDataVersion(versionId) {
58
+ async function resolveDataVersion(version) {
110
59
  try {
111
- const versions = await getVersions();
112
- const v = versions.find(v => v.id === versionId);
113
- return v?.data_version ?? null;
60
+ return (await getVersions()).find(v => v.id === version)?.data_version ?? null;
114
61
  }
115
62
  catch {
116
63
  return null;
117
64
  }
118
65
  }
119
- // ── Tools ─────────────────────────────────────────────────────────────────────
120
- /** Ingest one or more primers into the database. */
121
- export async function ingestPrimer(entries) {
122
- const results = [];
123
- for (const e of entries) {
124
- // Optionally fetch content from URL
125
- // Validate URL for both storage and fetch
126
- validatePrimerUrl(e.url);
127
- let content = e.content;
128
- if (e.fetchContent && !content) {
129
- try {
130
- const res = await fetch(e.url);
131
- if (res.ok) {
132
- const text = await res.text();
133
- // Strip HTML tags for readability (basic)
134
- content = text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 50_000);
66
+ /** Stable release fallback when mcmeta is unavailable or has not recorded a version yet. */
67
+ function compareRelease(a, b) {
68
+ if (![a, b].every(v => /^\d+(?:\.\d+){1,2}$/.test(v)))
69
+ return null;
70
+ const left = a.split(".").map(Number), right = b.split(".").map(Number);
71
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
72
+ const difference = (left[i] ?? 0) - (right[i] ?? 0);
73
+ if (difference)
74
+ return Math.sign(difference);
75
+ }
76
+ return 0;
77
+ }
78
+ async function resolveRange(fromVersion, toVersion) {
79
+ if (!fromVersion?.trim() || !toVersion?.trim())
80
+ throw new Error("fromVersion and toVersion are required");
81
+ const [fromDataVersion, toDataVersion] = await Promise.all([resolveDataVersion(fromVersion), resolveDataVersion(toVersion)]);
82
+ const order = fromDataVersion !== null && toDataVersion !== null
83
+ ? fromDataVersion - toDataVersion : compareRelease(fromVersion, toVersion);
84
+ if (order !== null && order > 0)
85
+ throw new Error("fromVersion must not be later than toVersion");
86
+ return { fromVersion, toVersion, fromDataVersion, toDataVersion };
87
+ }
88
+ function overlaps(primer, range) {
89
+ if (range.fromVersion === range.toVersion)
90
+ return false;
91
+ if ([primer.fromDataVersion, primer.toDataVersion, range.fromDataVersion, range.toDataVersion].every(v => v !== null)) {
92
+ return primer.fromDataVersion < range.toDataVersion && primer.toDataVersion > range.fromDataVersion;
93
+ }
94
+ const startsBeforeEnd = compareRelease(primer.fromVersion, range.toVersion);
95
+ const endsAfterStart = compareRelease(primer.toVersion, range.fromVersion);
96
+ if (startsBeforeEnd !== null && endsAfterStart !== null)
97
+ return startsBeforeEnd < 0 && endsAfterStart > 0;
98
+ return primer.fromVersion === range.fromVersion || primer.toVersion === range.toVersion;
99
+ }
100
+ function comparePrimerVersions(a, b) {
101
+ const order = a.fromDataVersion !== null && b.fromDataVersion !== null
102
+ ? a.fromDataVersion - b.fromDataVersion : compareRelease(a.fromVersion, b.fromVersion);
103
+ return order ?? a.fromVersion.localeCompare(b.fromVersion);
104
+ }
105
+ function discoveryWhere(modloader) {
106
+ return {
107
+ source: { not: "seed:legacy" },
108
+ ...(modloader ? { modloader: { in: [...new Set([modloader, "vanilla"])] } } : {}),
109
+ };
110
+ }
111
+ const summarySelect = {
112
+ id: true, fromVersion: true, toVersion: true, fromDataVersion: true, toDataVersion: true,
113
+ modloader: true, title: true, summary: true, url: true, tags: true,
114
+ };
115
+ /**
116
+ * Repair only exact, known legacy seeds. Keep IDs where possible, preserve stored
117
+ * content, and never replace a manually sourced entry sharing a canonical URL.
118
+ * Run lazily as well as during seed so an existing installation repairs on use.
119
+ */
120
+ let catalogWork = Promise.resolve();
121
+ async function repairPrimerCatalog(seedAll = false) {
122
+ const work = catalogWork.then(async () => {
123
+ const db = await getDb();
124
+ const legacy = await db.primer.findMany({
125
+ where: { source: "seed", url: { in: Object.keys(LEGACY_PRIMER_URLS) } },
126
+ });
127
+ if (!seedAll && !legacy.length)
128
+ return;
129
+ const seeds = await Promise.all(SEED_PRIMERS.map(async (seed) => ({
130
+ ...seed, ...await resolveRange(seed.fromVersion, seed.toVersion), tags: serializePrimerTags(seed.tags),
131
+ })));
132
+ await db.$transaction(async (tx) => {
133
+ for (const old of legacy) {
134
+ const target = seeds.find(seed => seed.url === LEGACY_PRIMER_URLS[old.url]);
135
+ const collision = target && await tx.primer.findUnique({ where: { url: target.url } });
136
+ if (target && !collision && !old.content?.trim()) {
137
+ await tx.primer.update({ where: { id: old.id }, data: { ...target, content: null } });
138
+ }
139
+ else {
140
+ // Retain duplicate/user-populated legacy rows for direct access, but omit them from discovery.
141
+ await tx.primer.update({ where: { id: old.id }, data: { source: "seed:legacy" } });
135
142
  }
136
143
  }
137
- catch { /* ignore fetch errors */ }
144
+ for (const seed of seeds) {
145
+ const existing = await tx.primer.findUnique({ where: { url: seed.url } });
146
+ if (!existing)
147
+ await tx.primer.create({ data: seed });
148
+ else if (existing.source === "seed")
149
+ await tx.primer.update({ where: { id: existing.id }, data: seed });
150
+ }
151
+ }, { timeout: 30_000 });
152
+ });
153
+ catalogWork = work.catch(() => { });
154
+ await work;
155
+ }
156
+ /** A requested fetch must succeed before the entry is written. */
157
+ export async function ingestPrimer(entries) {
158
+ if (!entries?.length)
159
+ throw new Error("entries must contain at least one primer");
160
+ const results = [];
161
+ const errors = [];
162
+ for (const entry of entries) {
163
+ try {
164
+ validatePrimerUrl(entry.url);
165
+ let content = entry.content?.trim() ? entry.content : undefined;
166
+ if (entry.fetchContent && !content)
167
+ content = await fetchPrimerContent(entry.url);
168
+ if (content && Buffer.byteLength(content, "utf8") > MAX_PRIMER_CONTENT)
169
+ throw new Error("Primer content exceeds 2 MiB");
170
+ const range = await resolveRange(entry.fromVersion, entry.toVersion);
171
+ const db = await getDb();
172
+ const primer = await db.primer.upsert({
173
+ where: { url: entry.url },
174
+ create: {
175
+ ...range, modloader: entry.modloader ?? "neoforge", title: entry.title, summary: entry.summary,
176
+ url: entry.url, content, tags: serializePrimerTags(entry.tags ?? []), source: entry.source ?? "manual",
177
+ },
178
+ update: {
179
+ ...range, modloader: entry.modloader, title: entry.title, summary: entry.summary,
180
+ content, tags: entry.tags === undefined ? undefined : serializePrimerTags(entry.tags), source: entry.source,
181
+ },
182
+ });
183
+ results.push({
184
+ id: primer.id, title: primer.title, fromVersion: primer.fromVersion, toVersion: primer.toVersion,
185
+ contentStatus: primer.content?.trim() ? "ready" : "missing",
186
+ });
187
+ await tryEmbedPrimer(primer.id, primer.title, primer.summary, primer.content);
138
188
  }
139
- // Resolve numeric data versions
140
- const [fromDV, toDV] = await Promise.all([
141
- resolveDataVersion(e.fromVersion),
142
- resolveDataVersion(e.toVersion),
143
- ]);
144
- // Upsert by url
145
- const db = await getDb();
146
- const primer = await db.primer.upsert({
147
- where: { url: e.url },
148
- create: {
149
- fromVersion: e.fromVersion,
150
- toVersion: e.toVersion,
151
- fromDataVersion: fromDV,
152
- toDataVersion: toDV,
153
- modloader: e.modloader ?? "neoforge",
154
- title: e.title,
155
- summary: e.summary,
156
- url: e.url,
157
- content,
158
- tags: serializePrimerTags(e.tags ?? []),
159
- source: e.source ?? "manual",
160
- },
161
- update: {
162
- fromVersion: e.fromVersion,
163
- toVersion: e.toVersion,
164
- fromDataVersion: fromDV,
165
- toDataVersion: toDV,
166
- modloader: e.modloader ?? "neoforge",
167
- title: e.title,
168
- summary: e.summary,
169
- url: e.url,
170
- content: content ?? undefined,
171
- tags: serializePrimerTags(e.tags ?? []),
172
- },
173
- });
174
- results.push({ id: primer.id, title: primer.title, fromVersion: primer.fromVersion, toVersion: primer.toVersion });
175
- await tryEmbedPrimer(primer.id, primer.title, primer.summary, content);
189
+ catch (error) {
190
+ errors.push({ url: entry.url, error: error instanceof Error ? error.message : String(error) });
191
+ }
192
+ }
193
+ return { ingested: results.length, failed: errors.length, primers: results, errors };
194
+ }
195
+ const contentLoads = new Map();
196
+ async function hydratePrimer(primer, refresh = false) {
197
+ if (!refresh && primer.content?.trim())
198
+ return primer;
199
+ let pending = contentLoads.get(primer.id);
200
+ if (!pending) {
201
+ pending = (async () => {
202
+ const content = await fetchPrimerContent(primer.url);
203
+ const db = await getDb();
204
+ // Do not overwrite content edited while the network request was in flight.
205
+ await db.primer.updateMany({
206
+ where: { id: primer.id, url: primer.url, content: primer.content }, data: { content },
207
+ });
208
+ const updated = await db.primer.findUnique({ where: { id: primer.id } });
209
+ if (!updated)
210
+ throw new Error("Primer was deleted while fetching content");
211
+ await tryEmbedPrimer(updated.id, updated.title, updated.summary, updated.content);
212
+ return updated;
213
+ })().finally(() => { contentLoads.delete(primer.id); });
214
+ contentLoads.set(primer.id, pending);
176
215
  }
177
- return { ingested: results.length, primers: results };
216
+ return pending;
178
217
  }
179
- /** Get a single primer by ID. */
180
- export async function getPrimer(id) {
218
+ /** Return cached Markdown, fetching it if missing. Pagination never truncates the stored guide. */
219
+ export async function getPrimer(id, options = {}) {
220
+ const { startLine = 1, maxLines = 400, refresh = false } = options;
221
+ if (!Number.isInteger(id) || id < 1)
222
+ throw new Error("id must be a positive integer");
223
+ if (!Number.isInteger(startLine) || startLine < 1)
224
+ throw new Error("startLine must be a positive integer");
225
+ if (!Number.isInteger(maxLines) || maxLines < 1 || maxLines > 2000)
226
+ throw new Error("maxLines must be between 1 and 2000");
227
+ await repairPrimerCatalog();
181
228
  const db = await getDb();
182
- const primer = await db.primer.findUnique({ where: { id } });
229
+ let primer = await db.primer.findUnique({ where: { id } });
183
230
  if (!primer)
184
231
  return { found: false, id };
185
- return normalizePrimerTags(primer);
232
+ let redirectedFrom;
233
+ if (primer.source === "seed:legacy") {
234
+ const targetUrl = LEGACY_PRIMER_URLS[primer.url];
235
+ const replacement = targetUrl && await db.primer.findUnique({ where: { url: targetUrl } });
236
+ if (replacement && options.fetchContent !== false && !primer.content?.trim()) {
237
+ redirectedFrom = id;
238
+ primer = replacement;
239
+ }
240
+ else {
241
+ return {
242
+ ...normalizePrimerTags(primer), contentStatus: "superseded",
243
+ replacementPrimers: (await getPrimersByVersionRange(primer.fromVersion, primer.toVersion, primer.modloader)).primers,
244
+ message: "This old seeded placeholder was retired. Use the replacement primers for the individual migration steps.",
245
+ };
246
+ }
247
+ }
248
+ if (options.fetchContent !== false || refresh)
249
+ primer = await hydratePrimer(primer, refresh);
250
+ const lines = primer.content?.split("\n") ?? [];
251
+ const end = Math.min(startLine - 1 + maxLines, lines.length);
252
+ return {
253
+ ...normalizePrimerTags(primer),
254
+ content: primer.content ? lines.slice(startLine - 1, end).join("\n") : null,
255
+ contentStatus: primer.content?.trim() ? "ready" : "missing",
256
+ startLine, totalLines: lines.length, truncated: end < lines.length,
257
+ nextStartLine: end < lines.length ? end + 1 : null,
258
+ ...(redirectedFrom === undefined ? {} : { redirectedFrom }),
259
+ };
186
260
  }
187
- /**
188
- * Get primers for a version range.
189
- * Returns all primers where the primer's version range overlaps with [fromVersion, toVersion].
190
- * If both data versions are resolvable, uses numeric comparison.
191
- * Otherwise falls back to exact string match on fromVersion/toVersion.
192
- */
193
- export async function getPrimersByVersionRange(fromVersion, toVersion, modloader) {
194
- const [fromDV, toDV] = await Promise.all([
195
- resolveDataVersion(fromVersion),
196
- resolveDataVersion(toVersion),
197
- ]);
198
- let where;
199
- if (fromDV !== null && toDV !== null) {
200
- // Overlap condition: primer.fromDV <= toDV AND primer.toDV >= fromDV
201
- where = {
202
- AND: [
203
- { fromDataVersion: { lte: toDV } },
204
- { toDataVersion: { gte: fromDV } },
205
- ...(modloader ? [{ modloader }] : []),
206
- ],
207
- };
261
+ const hashText = (text) => createHash("sha256").update(text).digest("hex");
262
+ function parsePrimerCursor(value) {
263
+ try {
264
+ if (typeof value !== "string" || value.length > 2048 || !/^[\w-]+$/.test(value))
265
+ throw new Error();
266
+ const cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
267
+ if (cursor.v !== 1 || typeof cursor.query !== "string" || !/^[a-f0-9]{64}$/.test(cursor.query) ||
268
+ !Number.isSafeInteger(cursor.id) || cursor.id < 1 || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0 ||
269
+ (cursor.offset === 0 ? cursor.hash !== null : typeof cursor.hash !== "string" || !/^[a-f0-9]{64}$/.test(cursor.hash)))
270
+ throw new Error();
271
+ return cursor;
208
272
  }
209
- else {
210
- // Fallback: primers where either bound matches exactly
211
- where = {
212
- OR: [
213
- { fromVersion },
214
- { toVersion },
215
- { fromVersion: toVersion },
216
- { toVersion: fromVersion },
217
- ],
218
- ...(modloader ? { modloader } : {}),
219
- };
273
+ catch {
274
+ throw new Error("Invalid primer cursor; restart by_version without a cursor");
220
275
  }
276
+ }
277
+ export async function getPrimersByVersionRange(fromVersion, toVersion, modloader, options = {}) {
278
+ if (!options.includeContent && (options.cursor !== undefined || options.maxChars !== undefined)) {
279
+ throw new Error("cursor and maxChars require includeContent: true");
280
+ }
281
+ const maxChars = options.maxChars ?? 60_000;
282
+ if (!Number.isInteger(maxChars) || maxChars < 1000 || maxChars > 200_000)
283
+ throw new Error("maxChars must be between 1000 and 200000");
284
+ const cursor = options.cursor === undefined ? undefined : parsePrimerCursor(options.cursor);
285
+ await repairPrimerCatalog();
286
+ const range = await resolveRange(fromVersion, toVersion);
221
287
  const db = await getDb();
222
- const primers = await db.primer.findMany({
223
- where,
224
- orderBy: [{ fromDataVersion: "asc" }, { fromVersion: "asc" }],
225
- select: {
226
- id: true,
227
- fromVersion: true,
228
- toVersion: true,
229
- fromDataVersion: true,
230
- toDataVersion: true,
231
- modloader: true,
232
- title: true,
233
- summary: true,
234
- url: true,
235
- tags: true,
236
- },
288
+ const rows = await db.primer.findMany({
289
+ where: discoveryWhere(modloader),
290
+ orderBy: [{ fromDataVersion: "asc" }, { fromVersion: "asc" }], select: summarySelect,
237
291
  });
238
- return {
239
- queryRange: { fromVersion, toVersion, fromDataVersion: fromDV, toDataVersion: toDV },
240
- count: primers.length,
241
- primers: primers.map(normalizePrimerTags),
242
- };
292
+ const primers = rows.filter(row => overlaps(row, range)).sort((a, b) => comparePrimerVersions(a, b)
293
+ || (compareRelease(a.toVersion, b.toVersion) ?? a.toVersion.localeCompare(b.toVersion))
294
+ || Number(b.modloader === "vanilla") - Number(a.modloader === "vanilla")
295
+ || a.modloader.localeCompare(b.modloader) || a.id - b.id).map(normalizePrimerTags);
296
+ const result = { queryRange: range, count: primers.length, primers };
297
+ if (!options.includeContent)
298
+ return result;
299
+ // Bind continuation to both the request and its ordered catalogue. A partly read
300
+ // guide also carries a content hash so edits cannot silently splice two revisions.
301
+ const query = hashText(JSON.stringify([fromVersion, toVersion, modloader ?? null, primers]));
302
+ let index = cursor ? primers.findIndex(primer => primer.id === cursor.id) : 0;
303
+ if (cursor && (cursor.query !== query || index < 0))
304
+ throw new Error("Primer range or catalogue changed; restart by_version without a cursor");
305
+ let offset = cursor?.offset ?? 0;
306
+ let contentHash = cursor?.hash ?? null;
307
+ let contentChars = 0;
308
+ const bundled = [];
309
+ // Bound metadata and concurrent upstream work as well as the combined text.
310
+ page: while (index < primers.length && bundled.length < 20 && contentChars < maxChars) {
311
+ const batch = primers.slice(index, index + Math.min(4, 20 - bundled.length));
312
+ const loaded = await Promise.allSettled(batch.map(async (summary) => {
313
+ const primer = await db.primer.findUnique({ where: { id: summary.id } });
314
+ if (!primer)
315
+ throw new Error("Primer was deleted while loading the range");
316
+ return options.fetchContent === false ? primer : hydratePrimer(primer);
317
+ }));
318
+ for (let i = 0; i < batch.length; i++) {
319
+ if (contentChars === maxChars)
320
+ break page;
321
+ const summary = batch[i], loadedPrimer = loaded[i];
322
+ if (loadedPrimer.status === "rejected") {
323
+ if (offset)
324
+ throw new Error("Partly read primer is unavailable; restart by_version without a cursor");
325
+ bundled.push({ ...summary, content: null, contentStatus: "fetch_failed", startOffset: 0, endOffset: 0,
326
+ totalChars: 0, truncated: false, error: loadedPrimer.reason instanceof Error ? loadedPrimer.reason.message : String(loadedPrimer.reason) });
327
+ index++;
328
+ continue;
329
+ }
330
+ const content = loadedPrimer.value.content;
331
+ if (offset && (!content || offset >= content.length || hashText(content) !== contentHash ||
332
+ /[\uD800-\uDBFF]/.test(content[offset - 1]) && /[\uDC00-\uDFFF]/.test(content[offset]))) {
333
+ throw new Error("Partly read primer changed or cursor offset is invalid; restart by_version without a cursor");
334
+ }
335
+ if (!content?.trim()) {
336
+ bundled.push({ ...summary, content: null, contentStatus: "missing", startOffset: 0, endOffset: 0, totalChars: 0, truncated: false });
337
+ index++;
338
+ continue;
339
+ }
340
+ let end = Math.min(content.length, offset + maxChars - contentChars);
341
+ // Never divide a Unicode surrogate pair, even on a single very long line.
342
+ if (end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]) && /[\uDC00-\uDFFF]/.test(content[end]))
343
+ end--;
344
+ if (end > offset)
345
+ bundled.push({ ...summary, content: content.slice(offset, end), contentStatus: "ready",
346
+ startOffset: offset, endOffset: end, totalChars: content.length, truncated: end < content.length });
347
+ contentChars += end - offset;
348
+ if (end < content.length) {
349
+ offset = end;
350
+ contentHash = offset ? hashText(content) : null;
351
+ break page;
352
+ }
353
+ index++;
354
+ offset = 0;
355
+ contentHash = null;
356
+ }
357
+ }
358
+ const nextCursor = index < primers.length
359
+ ? Buffer.from(JSON.stringify({ v: 1, query, id: primers[index].id, offset, hash: contentHash })).toString("base64url") : null;
360
+ return { ...result, primers: bundled, contentChars, maxChars, nextCursor, truncated: nextCursor !== null,
361
+ failed: bundled.filter(primer => primer.contentStatus === "fetch_failed").length,
362
+ missing: bundled.filter(primer => primer.contentStatus === "missing").length };
243
363
  }
244
- /** Search primers using full-text search on title, summary, and content. */
245
364
  export async function searchPrimers(query, modloader, fromVersion, toVersion, limit = 20) {
246
- // Resolve version bounds if provided
247
- const [fromDV, toDV] = await Promise.all([
248
- fromVersion ? resolveDataVersion(fromVersion) : Promise.resolve(null),
249
- toVersion ? resolveDataVersion(toVersion) : Promise.resolve(null),
250
- ]);
251
- const versionFilter = [];
252
- if (fromDV !== null && toDV !== null) {
253
- versionFilter.push({ fromDataVersion: { lte: toDV } });
254
- versionFilter.push({ toDataVersion: { gte: fromDV } });
255
- }
256
- else if (fromVersion) {
257
- versionFilter.push({ fromVersion });
258
- }
259
- const primers = await (await getDb()).primer.findMany({
365
+ await repairPrimerCatalog();
366
+ const range = fromVersion && toVersion ? await resolveRange(fromVersion, toVersion) : undefined;
367
+ const rows = await (await getDb()).primer.findMany({
260
368
  where: {
261
- AND: [
262
- {
263
- OR: [
264
- { title: { contains: query, ...caseInsensitive() } },
265
- { summary: { contains: query, ...caseInsensitive() } },
266
- { content: { contains: query, ...caseInsensitive() } },
267
- ...primerTagSearchFilters(query),
268
- ],
269
- },
270
- ...(modloader ? [{ modloader }] : []),
271
- ...versionFilter,
369
+ ...discoveryWhere(modloader),
370
+ ...(!range && fromVersion ? { fromVersion } : {}),
371
+ ...(!range && toVersion ? { toVersion } : {}),
372
+ OR: [
373
+ { title: { contains: query, ...caseInsensitive() } },
374
+ { summary: { contains: query, ...caseInsensitive() } },
375
+ { content: { contains: query, ...caseInsensitive() } },
376
+ ...primerTagSearchFilters(query),
272
377
  ],
273
378
  },
274
- orderBy: [{ fromDataVersion: "asc" }, { fromVersion: "asc" }],
275
- take: limit,
276
- select: {
277
- id: true,
278
- fromVersion: true,
279
- toVersion: true,
280
- modloader: true,
281
- title: true,
282
- summary: true,
283
- url: true,
284
- tags: true,
285
- },
379
+ orderBy: [{ fromDataVersion: "asc" }, { fromVersion: "asc" }], select: summarySelect,
286
380
  });
287
- return { query, count: primers.length, primers: primers.map(normalizePrimerTags) };
381
+ const primers = rows.filter(row => !range || overlaps(row, range)).sort(comparePrimerVersions).slice(0, limit).map(normalizePrimerTags);
382
+ return { query, count: primers.length, primers };
288
383
  }
289
- /** List all primers with optional filters. */
290
384
  export async function listPrimers(modloader, limit = 50) {
291
- const primers = await (await getDb()).primer.findMany({
292
- where: modloader ? { modloader } : {},
293
- orderBy: [{ fromDataVersion: "asc" }, { fromVersion: "asc" }],
294
- take: limit,
295
- select: {
296
- id: true,
297
- fromVersion: true,
298
- toVersion: true,
299
- modloader: true,
300
- title: true,
301
- summary: true,
302
- url: true,
303
- tags: true,
304
- },
385
+ await repairPrimerCatalog();
386
+ const rows = await (await getDb()).primer.findMany({
387
+ where: discoveryWhere(modloader), orderBy: [{ fromDataVersion: "asc" }, { fromVersion: "asc" }],
388
+ select: summarySelect,
305
389
  });
306
- return { count: primers.length, primers: primers.map(normalizePrimerTags) };
390
+ const primers = rows.sort(comparePrimerVersions).slice(0, limit).map(normalizePrimerTags);
391
+ return { count: primers.length, primers };
307
392
  }
308
- /** Delete a primer by ID. */
309
393
  export async function deletePrimer(id) {
310
394
  const deleted = await (await getDb()).primer.delete({ where: { id } }).catch(() => null);
311
395
  return { deleted: !!deleted, id };
312
396
  }
313
- // ── Default seed data ─────────────────────────────────────────────────────────
314
- const SEED_PRIMERS = [
315
- // ── NeoForge migration guides ─────────────────────────────────────────
316
- {
317
- fromVersion: "1.20.4",
318
- toVersion: "1.21.1",
319
- modloader: "neoforge",
320
- title: "NeoForge Migration Guide — 1.20.4 to 1.21.x",
321
- summary: "Official NeoForge migration documentation covering breaking API changes, event system overhauls, registry changes, and data component migration from 1.20.4 through 1.21.1.",
322
- url: "https://docs.neoforged.net/docs/1.21.x/migrationguide/",
323
- tags: ["neoforge", "migration", "1.20.4", "1.21.1", "events", "registries"],
324
- source: "seed",
325
- },
326
- {
327
- fromVersion: "1.20.1",
328
- toVersion: "1.20.4",
329
- modloader: "neoforge",
330
- title: "NeoForge Migration Guide — 1.20.1 to 1.20.4",
331
- summary: "NeoForge was forked from MinecraftForge during 1.20.1. This guide covers the initial NeoForge migration from Forge including package renames, event system changes, and new capability system.",
332
- url: "https://docs.neoforged.net/docs/1.20.4/migrationguide/",
333
- tags: ["neoforge", "migration", "1.20.1", "1.20.4", "forge-fork", "capabilities"],
334
- source: "seed",
335
- },
336
- {
337
- fromVersion: "1.21.1",
338
- toVersion: "1.21.5",
339
- modloader: "neoforge",
340
- title: "NeoForge Migration Guide — 1.21.1 to 1.21.5",
341
- summary: "Migration guide covering NeoForge API changes between 1.21.1 and 1.21.5, including inventory, recipe, and rendering API updates.",
342
- url: "https://docs.neoforged.net/docs/1.21.5/migrationguide/",
343
- tags: ["neoforge", "migration", "1.21.1", "1.21.5"],
344
- source: "seed",
345
- },
346
- {
347
- fromVersion: "1.21.5",
348
- toVersion: "26.1.2",
349
- modloader: "neoforge",
350
- title: "NeoForge Migration Guide — 1.21.5 to 26.1.2",
351
- summary: "Migration guide for the Minecraft version numbering change (1.21.x → 26.x) and corresponding NeoForge API updates.",
352
- url: "https://docs.neoforged.net/docs/current/migrationguide/",
353
- tags: ["neoforge", "migration", "1.21.5", "26.1.2", "versioning"],
354
- source: "seed",
355
- },
356
- // ── NeoForge breaking changes page ────────────────────────────────────
357
- {
358
- fromVersion: "1.20.1",
359
- toVersion: "26.1.2",
360
- modloader: "neoforge",
361
- title: "NeoForge Documentation — Getting Started",
362
- summary: "Main NeoForge documentation landing page covering setup, versioning, and links to all migration guides.",
363
- url: "https://docs.neoforged.net/docs/gettingstarted/",
364
- tags: ["neoforge", "setup", "docs"],
365
- source: "seed",
366
- },
367
- // ── MinecraftForge primers (pre-NeoForge split) ──────────────────────
368
- {
369
- fromVersion: "1.19.4",
370
- toVersion: "1.20.1",
371
- modloader: "forge",
372
- title: "MinecraftForge Migration — 1.19.4 to 1.20.1",
373
- summary: "MinecraftForge breaking changes from 1.19.4 to 1.20.1 including registry changes, chat changes, and creative tab API overhaul.",
374
- url: "https://github.com/MinecraftForge/MinecraftForge/blob/1.20.1/Changelog.md",
375
- tags: ["forge", "migration", "1.19.4", "1.20.1"],
376
- source: "seed",
377
- },
378
- {
379
- fromVersion: "1.18.2",
380
- toVersion: "1.19.4",
381
- modloader: "forge",
382
- title: "MinecraftForge Migration — 1.18.2 to 1.19.x",
383
- summary: "MinecraftForge breaking changes for 1.19.x series including the component damage system, fluid API, and rendering changes.",
384
- url: "https://github.com/MinecraftForge/MinecraftForge/blob/1.19.4/Changelog.md",
385
- tags: ["forge", "migration", "1.18.2", "1.19.4"],
386
- source: "seed",
387
- },
388
- // ── Fabric migration notes ────────────────────────────────────────────
389
- {
390
- fromVersion: "1.20.4",
391
- toVersion: "1.21.1",
392
- modloader: "fabric",
393
- title: "Fabric — Migration Primer 1.20.4 to 1.21.1",
394
- summary: "Fabric API breaking changes guide for 1.20.4 → 1.21.x covering rendering API updates, item stack changes, and the new item components system.",
395
- url: "https://fabricmc.net/wiki/tutorial:migration",
396
- tags: ["fabric", "migration", "1.20.4", "1.21.1"],
397
- source: "seed",
398
- },
399
- // ── NeoForge CHANGELOG ────────────────────────────────────────────────
400
- {
401
- fromVersion: "1.20.1",
402
- toVersion: "26.1.2",
403
- modloader: "neoforge",
404
- title: "NeoForge GitHub CHANGELOG",
405
- summary: "Full NeoForge changelog on GitHub tracking all API additions, removals, and fixes across all supported MC versions.",
406
- url: "https://github.com/neoforged/NeoForge/blob/main/CHANGELOG.md",
407
- tags: ["neoforge", "changelog", "all-versions"],
408
- source: "seed",
409
- },
410
- ];
411
- /** Populate the primers table with known NeoForge/Forge/Fabric migration guides. */
412
- export async function seedDefaultPrimers() {
413
- return ingestPrimer(SEED_PRIMERS);
397
+ /** Populate the corrected catalogue, fetch missing bodies, and report individual failures for retry. */
398
+ export async function seedDefaultPrimers(fetchContent = true) {
399
+ await repairPrimerCatalog(true);
400
+ const db = await getDb();
401
+ const queue = [...SEED_PRIMERS];
402
+ const results = [];
403
+ await Promise.all(Array.from({ length: 4 }, async () => {
404
+ let seed;
405
+ while ((seed = queue.shift())) {
406
+ let primer = await db.primer.findUnique({ where: { url: seed.url } });
407
+ if (!primer)
408
+ continue;
409
+ try {
410
+ if (fetchContent && primer.source === "seed")
411
+ primer = await hydratePrimer(primer);
412
+ results.push({
413
+ id: primer.id, title: primer.title, url: primer.url,
414
+ contentStatus: primer.content?.trim() ? "ready" : "missing",
415
+ });
416
+ }
417
+ catch (error) {
418
+ results.push({
419
+ id: primer.id, title: primer.title, url: primer.url, contentStatus: "fetch_failed",
420
+ error: error instanceof Error ? error.message : String(error),
421
+ });
422
+ }
423
+ }
424
+ }));
425
+ results.sort((a, b) => a.id - b.id);
426
+ return {
427
+ ingested: results.length, ready: results.filter(r => r.contentStatus === "ready").length,
428
+ failed: results.filter(r => r.contentStatus === "fetch_failed").length, primers: results,
429
+ };
414
430
  }
415
- // ── embedding helpers ─────────────────────────────────────────────────────────
416
431
  async function tryEmbedPrimer(id, title, summary, content) {
417
- if (!await isOllamaAvailable())
418
- return;
419
432
  try {
420
- // Embed title + summary + first chunk of content
433
+ if (!await isOllamaAvailable())
434
+ return;
421
435
  const parts = [title, summary, content ? chunkText(content, 1500)[0] : undefined].filter(Boolean);
422
- const vec = await embed(parts.join("\n\n"));
423
- await upsertPrimerEmbedding(id, vec);
436
+ await upsertPrimerEmbedding(id, await embed(parts.join("\n\n")));
424
437
  }
425
- catch { /* non-fatal */ }
438
+ catch { /* embeddings are optional */ }
426
439
  }
427
- // ── semantic_search ───────────────────────────────────────────────────────────
428
440
  export async function semanticSearchPrimers(query, limit = 10) {
429
- const vec = await embed(query);
430
- const rows = await searchPrimersByVector(vec, limit);
431
- if (!rows.length)
432
- return { query, semantic: true, count: 0, results: [] };
433
- const ids = rows.map(r => r.id);
441
+ await repairPrimerCatalog();
442
+ const rows = await searchPrimersByVector(await embed(query), limit);
434
443
  const primers = await (await getDb()).primer.findMany({
435
- where: { id: { in: ids } },
436
- select: { id: true, fromVersion: true, toVersion: true, modloader: true, title: true, summary: true, url: true, tags: true },
444
+ where: { ...discoveryWhere(), id: { in: rows.map(r => r.id) } }, select: summarySelect,
437
445
  });
438
- const byId = Object.fromEntries(primers.map(p => [p.id, p]));
439
- const results = rows.map(r => {
440
- const primer = byId[r.id];
441
- const similarity = Math.round(r.similarity * 1000) / 1000;
442
- return primer ? { similarity, ...normalizePrimerTags(primer) } : { similarity, id: r.id };
446
+ const byId = new Map(primers.map(p => [p.id, p]));
447
+ const results = rows.flatMap(row => {
448
+ const primer = byId.get(row.id);
449
+ return primer ? [{ similarity: Math.round(row.similarity * 1000) / 1000, ...normalizePrimerTags(primer) }] : [];
443
450
  });
444
451
  return { query, semantic: true, count: results.length, results };
445
452
  }
446
- // ── backfill_embeddings ───────────────────────────────────────────────────────
447
453
  export async function backfillPrimerEmbeddings() {
448
- if (!await isOllamaAvailable()) {
454
+ await repairPrimerCatalog();
455
+ if (!await isOllamaAvailable())
449
456
  return { error: "Ollama is not available. Set OLLAMA_URL and ensure Ollama is running." };
450
- }
451
- const db = await getDb();
452
- const rows = await db.primer.findMany({ select: { id: true, title: true, summary: true, content: true } });
457
+ const rows = await (await getDb()).primer.findMany({
458
+ where: discoveryWhere(), select: { id: true, title: true, summary: true, content: true },
459
+ });
453
460
  const unembedded = await countUnembedded("primers");
454
- let done = 0;
455
- let failed = 0;
461
+ let done = 0, failed = 0;
456
462
  for (const row of rows) {
457
463
  try {
458
464
  const parts = [row.title, row.summary, row.content ? chunkText(row.content, 1500)[0] : undefined].filter(Boolean);
459
- const vec = await embed(parts.join("\n\n"));
460
- await upsertPrimerEmbedding(row.id, vec);
465
+ await upsertPrimerEmbedding(row.id, await embed(parts.join("\n\n")));
461
466
  done++;
462
467
  }
463
468
  catch {