modwright 0.1.2 → 0.1.3

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 (41) hide show
  1. package/README.md +41 -2
  2. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
  3. package/bridges/cp2077-cet/README.md +3 -1
  4. package/bridges/cp2077-cet/bridge.json +1 -1
  5. package/dist/core/build/stage.js +3 -2
  6. package/dist/core/ini.js +26 -0
  7. package/dist/core/knowledge/facts.js +0 -12
  8. package/dist/core/logs/registry.js +19 -0
  9. package/dist/core/logs/scan.js +44 -5
  10. package/dist/core/logs/triage.js +79 -2
  11. package/dist/core/managers/mo2.js +121 -0
  12. package/dist/core/managers/vortex.js +52 -0
  13. package/dist/core/ownership/index.js +364 -0
  14. package/dist/core/project/index.js +1 -1
  15. package/dist/core/project/load.js +5 -0
  16. package/dist/core/testplan/registry.js +51 -2
  17. package/dist/core/writes/index.js +206 -0
  18. package/dist/index.js +0 -2
  19. package/dist/server.js +142 -13
  20. package/dist/surfaces/baldursgate3/surface.js +3 -0
  21. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  22. package/dist/surfaces/baldursgate3/writes.js +152 -0
  23. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  24. package/dist/surfaces/cyberpunk2077/logs.js +82 -3
  25. package/dist/surfaces/cyberpunk2077/surface.js +11 -0
  26. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  27. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  28. package/dist/surfaces/skyrimse/surface.js +1 -0
  29. package/dist/surfaces/stardewvalley/logs.js +41 -0
  30. package/dist/surfaces/stardewvalley/surface.js +2 -0
  31. package/dist/surfaces/stardewvalley/writes.js +136 -0
  32. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  33. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  34. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  35. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  36. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  37. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  38. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  39. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  40. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  41. package/package.json +1 -1
@@ -0,0 +1,206 @@
1
+ import { listMods } from "../mods.js";
2
+ const VALUE_CHARS = 200;
3
+ export function shortValue(value) {
4
+ if (value === undefined)
5
+ return undefined;
6
+ const text = typeof value === "string" ? value : JSON.stringify(value);
7
+ if (text === undefined)
8
+ return undefined;
9
+ if (text.length <= VALUE_CHARS)
10
+ return value;
11
+ return `${text.slice(0, VALUE_CHARS)}…`;
12
+ }
13
+ export async function collectWrites(surface, install, options = {}) {
14
+ const collection = {
15
+ writers: [],
16
+ coverage: { modsScanned: 0, writesFound: 0, rootsScanned: [], errors: [], unitNotes: [] },
17
+ };
18
+ if (!surface.extractWrites)
19
+ return collection;
20
+ const listOptions = { fileLimit: 20000 };
21
+ if (options.rootIds)
22
+ listOptions.rootIds = options.rootIds;
23
+ const { mods, roots } = await listMods(surface, install, listOptions);
24
+ const rootById = new Map(roots.map((r) => [r.id, r]));
25
+ const scannedRoots = new Set();
26
+ const byWriter = new Map();
27
+ const scanned = new Set();
28
+ for (const mod of mods) {
29
+ const root = rootById.get(mod.rootId);
30
+ if (!root)
31
+ continue;
32
+ let extraction;
33
+ try {
34
+ extraction = await surface.extractWrites(mod, root, install);
35
+ }
36
+ catch (error) {
37
+ collection.coverage.errors.push({ writerId: mod.id, file: mod.path, error: error.message });
38
+ continue;
39
+ }
40
+ if (!extraction)
41
+ continue;
42
+ scannedRoots.add(root.id);
43
+ if (extraction.writes.length === 0)
44
+ scanned.add(mod.id);
45
+ if (extraction.unitNote && !collection.coverage.unitNotes.includes(extraction.unitNote)) {
46
+ collection.coverage.unitNotes.push(extraction.unitNote);
47
+ }
48
+ for (const err of extraction.errors)
49
+ collection.coverage.errors.push({ writerId: mod.id, ...err });
50
+ for (const write of extraction.writes) {
51
+ const writerId = write.unit ? `${mod.id}/${write.unit}` : mod.id;
52
+ scanned.add(writerId);
53
+ let writer = byWriter.get(writerId);
54
+ if (!writer) {
55
+ writer = { writerId, name: write.unit ?? mod.name, rootId: mod.rootId, writes: [] };
56
+ const state = write.unit ? extraction.unitStates?.[write.unit] : undefined;
57
+ const inactive = state?.inactive ?? extraction.inactive;
58
+ const identity = state?.identity ?? extraction.identity;
59
+ if (inactive)
60
+ writer.inactive = inactive;
61
+ if (identity)
62
+ writer.identity = identity;
63
+ byWriter.set(writerId, writer);
64
+ }
65
+ writer.writes.push(write);
66
+ collection.coverage.writesFound++;
67
+ }
68
+ }
69
+ collection.writers = [...byWriter.values()];
70
+ collection.coverage.modsScanned = scanned.size;
71
+ const inactive = collection.writers.filter((w) => w.inactive).length;
72
+ if (inactive > 0)
73
+ collection.coverage.inactiveWriters = inactive;
74
+ collection.coverage.rootsScanned = [...scannedRoots];
75
+ return collection;
76
+ }
77
+ function groupByKey(collection, keep) {
78
+ const byKey = new Map();
79
+ for (const writer of collection.writers) {
80
+ for (const write of writer.writes) {
81
+ if (!keep(write))
82
+ continue;
83
+ const k = write.key.toLowerCase();
84
+ let entry = byKey.get(k);
85
+ if (!entry) {
86
+ entry = { key: write.key, keyKind: write.keyKind, contested: false, writers: [] };
87
+ byKey.set(k, entry);
88
+ }
89
+ const row = {
90
+ writerId: writer.writerId,
91
+ name: writer.name,
92
+ op: write.op,
93
+ file: write.file,
94
+ certainty: write.certainty,
95
+ };
96
+ const value = shortValue(write.value);
97
+ if (value !== undefined)
98
+ row.value = value;
99
+ if (write.line !== undefined)
100
+ row.line = write.line;
101
+ if (write.condition !== undefined)
102
+ row.condition = write.condition;
103
+ if (writer.inactive)
104
+ row.inactive = writer.inactive;
105
+ if (writer.identity)
106
+ row.identity = writer.identity;
107
+ entry.writers.push(row);
108
+ }
109
+ }
110
+ for (const entry of byKey.values()) {
111
+ const live = entry.writers.filter((w) => !w.inactive);
112
+ const writers = new Set(live.map((w) => w.identity ?? w.writerId));
113
+ if (writers.size < 2)
114
+ continue;
115
+ if (new Set(live.map((w) => JSON.stringify([w.op, w.value]))).size === 1) {
116
+ entry.agree = true;
117
+ continue;
118
+ }
119
+ if (live.every((w) => w.op === "append" || w.op === "remove")) {
120
+ const items = (op) => new Set(live.filter((w) => w.op === op).flatMap((w) => (Array.isArray(w.value) ? w.value : [w.value])).map((v) => JSON.stringify(v)));
121
+ const appended = items("append");
122
+ entry.contested = [...items("remove")].some((v) => appended.has(v));
123
+ continue;
124
+ }
125
+ entry.contested = true;
126
+ }
127
+ return byKey;
128
+ }
129
+ export function whoTouches(collection, query, limit = 50) {
130
+ const q = query.trim().toLowerCase();
131
+ const all = groupByKey(collection, () => true);
132
+ const pick = (matchedBy, keys) => {
133
+ keys.sort((a, b) => Number(b.contested) - Number(a.contested) || a.key.localeCompare(b.key));
134
+ const result = { query, matchedBy, keys: keys.slice(0, limit) };
135
+ if (keys.length > limit)
136
+ result.omittedKeys = keys.length - limit;
137
+ return result;
138
+ };
139
+ const exact = all.get(q);
140
+ if (exact) {
141
+ const under = [...all.entries()].filter(([k]) => k !== q && isUnder(k, q)).map(([, v]) => v);
142
+ return pick("exact", [exact, ...under]);
143
+ }
144
+ const prefixed = [...all.entries()].filter(([k]) => isUnder(k, q)).map(([, v]) => v);
145
+ if (prefixed.length > 0)
146
+ return pick("prefix", prefixed);
147
+ const containing = [...all.entries()].filter(([k]) => k.includes(q)).map(([, v]) => v);
148
+ if (containing.length > 0)
149
+ return pick("substring", containing);
150
+ return { query, matchedBy: "none", keys: [] };
151
+ }
152
+ function isUnder(key, parent) {
153
+ if (!key.startsWith(parent) || key.length === parent.length)
154
+ return false;
155
+ const next = key[parent.length];
156
+ return next === "." || next === "#" || next === "/";
157
+ }
158
+ export function whatTouches(collection, modQuery, limit = 100) {
159
+ const q = modQuery.trim().toLowerCase();
160
+ const exact = collection.writers.filter((w) => w.writerId.toLowerCase() === q || w.name.toLowerCase() === q);
161
+ const matches = exact.length > 0 ? exact : collection.writers.filter((w) => w.writerId.toLowerCase().includes(q) || w.name.toLowerCase().includes(q));
162
+ const byKey = groupByKey(collection, () => true);
163
+ return matches.map((writer) => {
164
+ const sharedWith = [];
165
+ const seen = new Set();
166
+ for (const write of writer.writes) {
167
+ const k = write.key.toLowerCase();
168
+ if (seen.has(k))
169
+ continue;
170
+ seen.add(k);
171
+ const entry = byKey.get(k);
172
+ if (!entry?.contested)
173
+ continue;
174
+ const others = [...new Set(entry.writers.filter((w) => w.writerId !== writer.writerId).map((w) => w.name))];
175
+ if (others.length > 0)
176
+ sharedWith.push({ key: entry.key, others });
177
+ }
178
+ const summary = {
179
+ writerId: writer.writerId,
180
+ name: writer.name,
181
+ rootId: writer.rootId,
182
+ writeCount: writer.writes.length,
183
+ sharedWith,
184
+ writes: writer.writes.slice(0, limit).map((w) => ({ ...w, value: shortValue(w.value) })),
185
+ };
186
+ if (writer.writes.length > limit)
187
+ summary.omittedWrites = writer.writes.length - limit;
188
+ return summary;
189
+ });
190
+ }
191
+ export function duplicateCopies(collection) {
192
+ const byIdentity = new Map();
193
+ for (const writer of collection.writers) {
194
+ if (!writer.identity || writer.inactive)
195
+ continue;
196
+ const names = byIdentity.get(writer.identity) ?? [];
197
+ names.push(writer.name);
198
+ byIdentity.set(writer.identity, names);
199
+ }
200
+ return [...byIdentity].filter(([, names]) => names.length > 1).map(([identity, writers]) => ({ identity, writers: writers.sort() }));
201
+ }
202
+ export function contestedKeys(collection, limit = 100) {
203
+ const keys = [...groupByKey(collection, () => true).values()].filter((k) => k.contested);
204
+ keys.sort((a, b) => new Set(b.writers.map((w) => w.writerId)).size - new Set(a.writers.map((w) => w.writerId)).size || a.key.localeCompare(b.key));
205
+ return { total: keys.length, keys: keys.slice(0, limit) };
206
+ }
package/dist/index.js CHANGED
@@ -116,7 +116,6 @@ class Supervisor {
116
116
  swapQueued = false;
117
117
  closing = false;
118
118
  initializeRaw;
119
- initializeId;
120
119
  initializedSeen = false;
121
120
  routes = new Map();
122
121
  serverRequests = new Map();
@@ -151,7 +150,6 @@ class Supervisor {
151
150
  const active = this.active;
152
151
  if (msg.method === "initialize" && msg.id !== undefined && msg.id !== null) {
153
152
  this.initializeRaw = raw;
154
- this.initializeId = msg.id;
155
153
  }
156
154
  else if (msg.method === "notifications/initialized") {
157
155
  this.initializedSeen = true;
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  import { pathExists, tail } from "./core/fsutil.js";
6
- import { describeProject, findProjectFile, loadProject } from "./core/project/index.js";
6
+ import { describeProject, findProjectFile, loadProject, locateProjectFile } from "./core/project/index.js";
7
7
  import { userDataDir } from "./core/home.js";
8
8
  import { applyDataFetch, applyManagedInstall, checkToolchain, featureReadiness, locateTool, planDataFetch, planDataMemory, planManagedInstall, planToolMemory, readinessSummary, TOOL_SPECS, toolsToLocate } from "./core/toolchain/index.js";
9
9
  import { loadUserConfig } from "./core/userconfig.js";
@@ -22,6 +22,8 @@ import { readTextIfExists } from "./core/fsutil.js";
22
22
  import * as path from "node:path";
23
23
  import { promises as fs } from "node:fs";
24
24
  import { crossCheckIdentities, findConflicts, listMods, sharedIdentities } from "./core/mods.js";
25
+ import { buildOwnershipIndex, lookupOwnership, managerConflicts } from "./core/ownership/index.js";
26
+ import { collectWrites, contestedKeys, duplicateCopies, whatTouches, whoTouches } from "./core/writes/index.js";
25
27
  import { resolveLogs, resolveRoots } from "./core/surfaceutil.js";
26
28
  import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds } from "./surfaces/index.js";
27
29
  import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
@@ -183,8 +185,12 @@ server.registerTool("find_conflicts", {
183
185
  installPath: installPathArg,
184
186
  rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
185
187
  limit: z.number().int().positive().default(100).describe("Max conflicts to return."),
188
+ mo2Instance: z
189
+ .string()
190
+ .optional()
191
+ .describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
186
192
  },
187
- }, async ({ game, installPath, rootIds, limit }) => {
193
+ }, async ({ game, installPath, rootIds, limit, mo2Instance }) => {
188
194
  const surface = getSurface(game);
189
195
  if (!surface)
190
196
  return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
@@ -202,10 +208,22 @@ server.registerTool("find_conflicts", {
202
208
  identityUnreadableCount = scanned.unreadable.length;
203
209
  identityUnreadable = scanned.unreadable.slice(0, IDENTITY_UNREADABLE_LIMIT);
204
210
  }
211
+ let managed;
212
+ if (surface.managedDataDir !== undefined) {
213
+ const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
214
+ const mo2 = index.sources.find((src) => src.id === "mo2");
215
+ if (mo2?.active) {
216
+ const found = managerConflicts(index, limit);
217
+ managed = { source: mo2.detail, total: found.total, conflicts: found.conflicts };
218
+ if (found.total > limit)
219
+ managed.truncated = true;
220
+ }
221
+ }
205
222
  return json({
206
223
  game: surface.id,
207
224
  modsAnalyzed: mods.length,
208
225
  conflictCount: conflicts.length,
226
+ managerConflicts: managed,
209
227
  skippedRoots,
210
228
  suppressedNoiseFiles,
211
229
  truncated: conflicts.length > limit,
@@ -215,10 +233,108 @@ server.registerTool("find_conflicts", {
215
233
  identityUnreadable,
216
234
  identityUnreadableTruncated: identityUnreadableCount > IDENTITY_UNREADABLE_LIMIT ? true : undefined,
217
235
  note: "File-level analysis, and only for roots where mods share one deploy tree. " +
218
- "See skippedRoots for what was excluded and why.",
236
+ "See skippedRoots for what was excluded and why." +
237
+ (managed
238
+ ? " managerConflicts are between Mod Organizer 2's mod folders, each with the winner the game sees and the mods it overrides."
239
+ : ""),
219
240
  guidance: surface.guidance,
220
241
  });
221
242
  });
243
+ server.registerTool("owner_of", {
244
+ title: "Which mod owns this file",
245
+ description: "Say which installed mod a file comes from, and which copy the game actually sees when several provide it. " +
246
+ "Accepts absolute paths, paths relative to the game folder, a trailing part of a path (SKSE/Plugins/foo.dll), " +
247
+ "or a bare file name — e.g. a DLL named in a crash log, a path from triage_logs. Sources: the game's own mod " +
248
+ "roots (a mod folder owns what is under it), Mod Organizer 2 (the active profile's mod folders in priority " +
249
+ "order, plus Overwrite; for games where the mapping is known), and Vortex deployment manifests. Each result " +
250
+ "says how the winner was decided; `sources` says what each source could see, and `unattributedRoots` lists " +
251
+ "loose-file folders whose files no source can attribute. Files inside packed archives are not indexed yet.",
252
+ inputSchema: {
253
+ game: gameArg,
254
+ installPath: installPathArg,
255
+ paths: z.array(z.string().min(1)).min(1).max(50).describe("Files to attribute."),
256
+ mo2Instance: z
257
+ .string()
258
+ .optional()
259
+ .describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
260
+ limit: z.number().int().positive().max(200).default(20).describe("Max matches per path (a bare file name can match many)."),
261
+ },
262
+ }, async ({ game, installPath, paths, mo2Instance, limit }) => {
263
+ const surface = getSurface(game);
264
+ if (!surface)
265
+ return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
266
+ const install = await resolveInstall(surface, installPath);
267
+ if (!install)
268
+ return fail(`No install detected for ${surface.name}.`);
269
+ const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
270
+ return json({
271
+ game: surface.id,
272
+ install: install.root,
273
+ sources: index.sources,
274
+ indexedPaths: index.entries.size,
275
+ unattributedRoots: index.unattributedRoots.length > 0 ? index.unattributedRoots : undefined,
276
+ results: paths.map((p) => lookupOwnership(index, p, limit)),
277
+ note: "Owners come only from what is on disk: a mod folder, a manager's own records. Nothing is inferred from " +
278
+ "file names. A path no mod claims is reported as such, with the likely reasons.",
279
+ });
280
+ });
281
+ server.registerTool("who_touches", {
282
+ title: "Which mods write this record or asset",
283
+ description: "Read every installed mod's data and report who writes a given key — at the level the game merges on, not the " +
284
+ "file level: a TweakDB record or flat (Cyberpunk 2077, TweakXL YAML and .tweak files), a stats entry or field " +
285
+ "(Baldur's Gate 3, inside paks and unpacked mods), an asset or data entry/field (Stardew Valley Content Patcher " +
286
+ "packs, Includes followed). Give `key` for who writes it (a record returns its flats too; an asset its entries), " +
287
+ "`mod` for everything one mod writes and where another mod writes the same key, or neither for every key two or " +
288
+ "more mods contend for. This says who touches a key, not who wins: the winner depends on the game's load order. " +
289
+ "Writes behind a Content Patcher When, or with {{tokens}}, are marked conditional.",
290
+ inputSchema: {
291
+ game: gameArg,
292
+ installPath: installPathArg,
293
+ key: z.string().min(1).optional().describe("A record, flat, stats entry, asset or entry, e.g. Items.Preset_Masamune_Default, stats:Projectile_Fireball, Data/Objects#128."),
294
+ mod: z.string().min(1).optional().describe("A mod (or unit) id or name, for what it writes."),
295
+ rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
296
+ limit: z.number().int().positive().max(500).default(50).describe("Max keys (or writes per mod) to return."),
297
+ },
298
+ }, async ({ game, installPath, key, mod, rootIds, limit }) => {
299
+ const surface = getSurface(game);
300
+ if (!surface)
301
+ return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
302
+ const install = await resolveInstall(surface, installPath);
303
+ if (!install)
304
+ return fail(`No install detected for ${surface.name}.`);
305
+ if (!surface.extractWrites) {
306
+ return json({
307
+ game: surface.id,
308
+ supported: false,
309
+ note: `No write-set extraction for ${surface.name} yet (supported: Cyberpunk 2077 tweaks, Baldur's Gate 3 stats, Stardew Valley Content Patcher).`,
310
+ });
311
+ }
312
+ const collection = await collectWrites(surface, install, rootIds ? { rootIds } : {});
313
+ const { errors, ...coverage } = collection.coverage;
314
+ const copies = duplicateCopies(collection);
315
+ const result = key
316
+ ? { mode: "key", ...whoTouches(collection, key, limit) }
317
+ : mod
318
+ ? { mode: "mod", query: mod, writers: whatTouches(collection, mod, limit) }
319
+ : { mode: "contested", ...contestedKeys(collection, limit) };
320
+ return json({
321
+ game: surface.id,
322
+ install: install.root,
323
+ coverage: {
324
+ ...coverage,
325
+ writers: collection.writers.length,
326
+ errorCount: errors.length,
327
+ errors: errors.slice(0, 25),
328
+ },
329
+ ...(copies.length > 0 ? { duplicateCopies: copies } : {}),
330
+ ...result,
331
+ note: "Static: read from the mods' files, never from the running game. 'contested' means two or more mods write the " +
332
+ "key and not only by appending; which one the game ends up with depends on load order, which this tool does " +
333
+ "not decide. A mod the game does not load (marked 'inactive') is listed but never contests, and copies of one " +
334
+ "mod ('duplicateCopies') count once: the game loads one of them. Code that writes at runtime (redscript, CET, " +
335
+ "SMAPI C# mods, Script Extender Lua) is not seen here.",
336
+ });
337
+ });
222
338
  server.registerTool("load_order", {
223
339
  title: "Read load order",
224
340
  description: "Read the game's explicit load order and cross-check it against what is " +
@@ -340,9 +456,7 @@ server.registerTool("project_info", {
340
456
  }, async ({ path: start }) => {
341
457
  const from = start ?? process.cwd();
342
458
  try {
343
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json")
344
- ? from
345
- : await findProjectFile(from);
459
+ const file = await locateProjectFile(from);
346
460
  if (!file) {
347
461
  return json({
348
462
  found: false,
@@ -601,7 +715,11 @@ server.registerTool("triage_logs", {
601
715
  "which failed and why, with the first error's explanation and usual fix. Pass the " +
602
716
  "returned offsets and offsetKeys back as offsets/offsetKeys for incremental triage after " +
603
717
  "the next launch (the keys let a re-created log of identical size be read again). " +
604
- "Stale and zero-byte logs are reported as such rather than trusted.",
718
+ "Stale and zero-byte logs are reported as such rather than trusted. Where a framework " +
719
+ "logs who did what (SMAPI's 'X edited Data/Objects', TweakXL's file read order, ArchiveXL " +
720
+ "merges and localization overwrites, Red Hot Tools' missing resources), the verdict's " +
721
+ "`attribution` groups those lines by subject, contested subjects first: the log's own answer " +
722
+ "to 'which mod is doing this'. Pass attributionFor to narrow it to one asset, record or mod.",
605
723
  inputSchema: {
606
724
  game: gameArg,
607
725
  installPath: installPathArg,
@@ -619,15 +737,20 @@ server.registerTool("triage_logs", {
619
737
  .describe("Per log id file identities (offsetKeys) from the same previous result."),
620
738
  maxLines: z.number().int().positive().max(20000).optional(),
621
739
  includeFindings: z.boolean().default(true),
740
+ attributionFor: z
741
+ .string()
742
+ .optional()
743
+ .describe("Keep only attribution entries whose subject, actor or content pack contains this text " +
744
+ "(case-insensitive): an asset or record for 'who touched this', a mod name for 'what did it touch'."),
622
745
  },
623
- }, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings }) => {
746
+ }, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings, attributionFor }) => {
624
747
  const surface = getSurface(game);
625
748
  if (!surface)
626
749
  return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
627
750
  const install = await resolveInstall(surface, installPath);
628
751
  if (!install)
629
752
  return fail(`No install detected for ${surface.name}.`);
630
- const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines });
753
+ const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines }, { attributionFor });
631
754
  return json({
632
755
  ...verdict,
633
756
  signatureCount: registry.forGame(surface.id).length,
@@ -677,7 +800,7 @@ server.registerTool("load_order_diff", {
677
800
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
678
801
  let snapshotDir = path.join(userDataDir(), "snapshots");
679
802
  if (projectPath) {
680
- const projectFile = (await pathExists(projectPath)) && projectPath.toLowerCase().endsWith(".json") ? projectPath : await findProjectFile(projectPath);
803
+ const projectFile = await locateProjectFile(projectPath);
681
804
  if (!projectFile)
682
805
  return fail(`No modwright.json found at or above ${projectPath}.`);
683
806
  snapshotDir = path.join(path.dirname(path.resolve(projectFile)), ".modwright", "snapshots");
@@ -912,7 +1035,7 @@ server.registerTool("build_index", {
912
1035
  let toolOverrides;
913
1036
  if (projectPath) {
914
1037
  try {
915
- const file = (await pathExists(projectPath)) && projectPath.toLowerCase().endsWith(".json") ? projectPath : await findProjectFile(projectPath);
1038
+ const file = await locateProjectFile(projectPath);
916
1039
  if (!file)
917
1040
  return fail(`No modwright.json found at or above ${projectPath}.`);
918
1041
  const loaded = await loadProject(file);
@@ -1312,12 +1435,18 @@ server.registerTool("bridge", {
1312
1435
  timeoutSeconds: z.number().positive().max(300).optional().describe("'poll': override the computed budget; still capped at 300s."),
1313
1436
  },
1314
1437
  }, async ({ action, game, installPath, path: start, mode, probes, armForSeconds, grant, allowMutate, windowSeconds, planId, planPath, runId, id, nonce, probeCount, timeoutSeconds }) => {
1438
+ const planFields = { planId, planPath, runId };
1439
+ const givenPlan = Object.entries(planFields).filter(([, v]) => v !== undefined);
1440
+ if (givenPlan.length > 0 && givenPlan.length < 3) {
1441
+ const missing = Object.entries(planFields).filter(([, v]) => v === undefined).map(([k]) => k);
1442
+ return fail(`planId, planPath and runId go together; ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} missing.`);
1443
+ }
1315
1444
  let projectGame;
1316
1445
  let projectRoot;
1317
1446
  if (start) {
1318
1447
  try {
1319
1448
  const from = start;
1320
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json") ? from : await findProjectFile(from);
1449
+ const file = await locateProjectFile(from);
1321
1450
  if (!file)
1322
1451
  return fail(`No modwright.json found at or above ${from}.`);
1323
1452
  const loaded = await loadProject(file);
@@ -1618,7 +1747,7 @@ const TEMPLATE_SURFACES = ["baldursgate3", "cyberpunk2077"];
1618
1747
  const TEMPLATE_PERMISSIONS = ["never-redistribute", "maintainer-own", "declared-dependency"];
1619
1748
  async function resolveProject(start) {
1620
1749
  const from = start ?? process.cwd();
1621
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json") ? from : await findProjectFile(from);
1750
+ const file = await locateProjectFile(from);
1622
1751
  if (!file)
1623
1752
  throw new NoProjectError(`No modwright.json found at or above ${from}.`);
1624
1753
  return loadProject(file);
@@ -6,6 +6,7 @@ import { dedupeByRoot, logs, roots } from "../../core/surfaceutil.js";
6
6
  import { bg3Index } from "./index/index.js";
7
7
  import { scanModIdentities as scanBg3ModIdentities } from "./identity.js";
8
8
  import { SIGNATURES } from "./logs.js";
9
+ import { extractBg3Writes } from "./writes.js";
9
10
  import { describeModSettingsDiff, diffModSettings, parseModSettings } from "./modsettings.js";
10
11
  import { BRIDGE_VALIDATORS } from "./validators/bridge.js";
11
12
  import { FILE_VALIDATORS } from "./validators/files.js";
@@ -112,6 +113,8 @@ export const baldursgate3 = {
112
113
  };
113
114
  },
114
115
  logSignatures: SIGNATURES,
116
+ extractWrites: (mod, root) => extractBg3Writes(mod, root),
117
+ looseUnitOf: (rootId, rel) => (rootId === "loose" ? /^(?:Mods|Public|Generated\/Public)\/([^/]+)\//i.exec(rel)?.[1] : undefined),
115
118
  index: bg3Index,
116
119
  validators: [
117
120
  ...ITEM_VALIDATORS,
@@ -228,8 +228,9 @@ const osirisNamingConvention = {
228
228
  const base = path.posix.basename(lower(ctx.target.path), ".txt");
229
229
  if (!/^[a-z0-9]+(_[a-z0-9]+){2,}$/i.test(base)) {
230
230
  findings.push(finding(osirisNamingConvention.id, "info", ctx.target.path, `Goal file name '${path.basename(ctx.target.path)}' does not obviously follow the Act_Region_Situation convention. ` +
231
- `This is a naming convention, not a known build failure — no fact in knowledge/baldursgate3/ confirms a missing ` +
232
- `prefix disables the story component (still an open question), so this stays informational.`, osirisNamingConvention.evidence));
231
+ `This is a naming convention, not a known build failure: LSLib's story compiler only warns on a name missing ` +
232
+ `its DB/PROC/QRY prefix (bg3.dialogue.osiris-goals.prefix-convention-is-a-compiler-warning-in-lslib), ` +
233
+ `so this stays informational.`, osirisNamingConvention.evidence));
233
234
  }
234
235
  const text = stripBom(await ctx.readText(ctx.target.path)).replace(/\r\n/g, "\n");
235
236
  const badPrefixRe = /\b(?!DB_|QRY_|PROC_)([A-Za-z][A-Za-z0-9_]*)\s*\(/g;
@@ -482,7 +483,6 @@ const questAddQuestCloseDiscouraged = {
482
483
  return goal.questAddClose.map((occ) => finding(questAddQuestCloseDiscouraged.id, "info", targetPath, `${occ.name}(...) is used here. The documentation recommends relying on QuestUpdate instead of manually adding/closing quests.`, questAddQuestCloseDiscouraged.evidence, { line: occ.line }));
483
484
  },
484
485
  };
485
- const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
486
486
  function collectOrigins(doc) {
487
487
  const origins = [];
488
488
  for (const region of doc.regions) {
@@ -0,0 +1,152 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { parseStats } from "./index/parsers/stats.js";
4
+ import { localAppData, readTextIfExists } from "../../core/fsutil.js";
5
+ import { parseMetaLsxModuleInfo } from "../../core/lsx.js";
6
+ import { parseModSettings } from "./modsettings.js";
7
+ import { readPakEntry, readPakListing, readPakModuleInfos } from "./pak.js";
8
+ const STATS_FILE = /^Public\/([^/]+)\/Stats\/Generated\/Data\/[^/]+\.txt$/i;
9
+ const MAX_STATS_BYTES = 16 * 1024 * 1024;
10
+ export const LOOSE_UNIT_NOTE = "In the game's Data folder each Public/<Mod> folder with stats files is taken as one unpacked mod.";
11
+ function entryWrites(entries, file, unit) {
12
+ const writes = [];
13
+ const withUnit = (w) => (unit ? { ...w, unit } : w);
14
+ for (const entry of entries) {
15
+ const value = { type: entry.type };
16
+ if (entry.using !== undefined)
17
+ value.using = entry.using;
18
+ writes.push(withUnit({ key: `stats:${entry.name}`, keyKind: "stats-entry", op: "create", value, file, line: entry.line, certainty: "definite" }));
19
+ for (const [field, fieldValue] of Object.entries(entry.fields)) {
20
+ writes.push(withUnit({ key: `stats:${entry.name}.${field}`, keyKind: "stats-field", op: "set", value: fieldValue, file, line: entry.line, certainty: "definite" }));
21
+ }
22
+ }
23
+ return writes;
24
+ }
25
+ const MODSETTINGS = localAppData("Larian Studios", "Baldur's Gate 3", "PlayerProfiles", "Public", "modsettings.lsx");
26
+ const activeCache = new Map();
27
+ async function activeModuleUuids(file) {
28
+ let mtimeMs;
29
+ try {
30
+ mtimeMs = (await fs.stat(file)).mtimeMs;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ const cached = activeCache.get(file);
36
+ if (cached && cached.mtimeMs === mtimeMs)
37
+ return cached.uuids;
38
+ try {
39
+ const uuids = new Set(parseModSettings(await fs.readFile(file, "utf8")).map((e) => e.uuid.toLowerCase()).filter(Boolean));
40
+ activeCache.set(file, { mtimeMs, uuids });
41
+ return uuids;
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ async function moduleState(infos, modsettings) {
48
+ const modules = infos.filter((i) => i.uuid);
49
+ if (modules.length === 0)
50
+ return {};
51
+ const identity = `bg3-module:${modules.map((i) => i.uuid.toLowerCase()).sort().join("+")}`;
52
+ const active = await activeModuleUuids(modsettings);
53
+ if (!active || modules.some((i) => active.has(i.uuid.toLowerCase())))
54
+ return { identity };
55
+ const named = modules.map((i) => `${i.name ?? i.folder ?? "?"} (${i.uuid})`).join(", ");
56
+ return { identity, inactive: `module ${named} is not in modsettings.lsx, so the game does not load it` };
57
+ }
58
+ async function pakModuleState(pakPath, modsettings) {
59
+ try {
60
+ return moduleState(await readPakModuleInfos(pakPath), modsettings);
61
+ }
62
+ catch {
63
+ return {};
64
+ }
65
+ }
66
+ async function fromPak(pakPath, modsettings) {
67
+ const extraction = { writes: [], errors: [] };
68
+ const state = await pakModuleState(pakPath, modsettings);
69
+ if (state.inactive)
70
+ extraction.inactive = state.inactive;
71
+ if (state.identity)
72
+ extraction.identity = state.identity;
73
+ let listing;
74
+ try {
75
+ listing = await readPakListing(pakPath);
76
+ }
77
+ catch (error) {
78
+ extraction.errors.push({ file: pakPath, error: error.message });
79
+ return extraction;
80
+ }
81
+ for (const entry of listing.entries) {
82
+ if (!STATS_FILE.test(entry.name))
83
+ continue;
84
+ const file = `${pakPath}!${entry.name}`;
85
+ try {
86
+ const text = (await readPakEntry(pakPath, entry, MAX_STATS_BYTES)).toString("utf8");
87
+ extraction.writes.push(...entryWrites(parseStats(text, file), file, undefined));
88
+ }
89
+ catch (error) {
90
+ extraction.errors.push({ file, error: error.message });
91
+ }
92
+ }
93
+ return extraction;
94
+ }
95
+ async function looseStatsFiles(dataDir) {
96
+ const out = [];
97
+ let mods;
98
+ try {
99
+ mods = await fs.readdir(path.join(dataDir, "Public"));
100
+ }
101
+ catch {
102
+ return out;
103
+ }
104
+ for (const folder of mods) {
105
+ const dir = path.join("Public", folder, "Stats", "Generated", "Data");
106
+ let names;
107
+ try {
108
+ names = await fs.readdir(path.join(dataDir, dir));
109
+ }
110
+ catch {
111
+ continue;
112
+ }
113
+ for (const name of names) {
114
+ const rel = `${dir}/${name}`.split(path.sep).join("/");
115
+ if (STATS_FILE.test(rel))
116
+ out.push(rel);
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ async function fromLooseData(mod, modsettings) {
122
+ const extraction = { writes: [], errors: [], unitNote: LOOSE_UNIT_NOTE, unitStates: {} };
123
+ for (const rel of await looseStatsFiles(mod.path)) {
124
+ const match = STATS_FILE.exec(rel);
125
+ if (!match)
126
+ continue;
127
+ const unit = match[1];
128
+ if (!(unit in extraction.unitStates)) {
129
+ const meta = await readTextIfExists(path.join(mod.path, "Mods", unit, "meta.lsx")).catch(() => undefined);
130
+ const info = meta ? parseMetaLsxModuleInfo(meta) : undefined;
131
+ extraction.unitStates[unit] = info ? await moduleState([info], modsettings) : {};
132
+ }
133
+ const abs = path.join(mod.path, rel);
134
+ try {
135
+ const stat = await fs.stat(abs);
136
+ if (stat.size > MAX_STATS_BYTES)
137
+ throw new Error(`larger than ${MAX_STATS_BYTES} bytes; skipped`);
138
+ extraction.writes.push(...entryWrites(parseStats(await fs.readFile(abs, "utf8"), abs), abs, match[1]));
139
+ }
140
+ catch (error) {
141
+ extraction.errors.push({ file: abs, error: error.message });
142
+ }
143
+ }
144
+ return extraction;
145
+ }
146
+ export async function extractBg3Writes(mod, root, options = {}) {
147
+ if (root.id === "paks")
148
+ return fromPak(mod.path, options.modsettings ?? MODSETTINGS);
149
+ if (root.id === "loose")
150
+ return fromLooseData(mod, options.modsettings ?? MODSETTINGS);
151
+ return undefined;
152
+ }