modwright 0.1.2 → 0.1.4

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 (45) hide show
  1. package/README.md +43 -4
  2. package/bridges/bg3-se/README.md +3 -2
  3. package/bridges/bg3-se/bridge.json +1 -1
  4. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +228 -1
  5. package/bridges/cp2077-cet/README.md +6 -3
  6. package/bridges/cp2077-cet/bridge.json +2 -2
  7. package/dist/core/bridge/remove.js +48 -0
  8. package/dist/core/build/stage.js +3 -2
  9. package/dist/core/ini.js +26 -0
  10. package/dist/core/knowledge/facts.js +0 -12
  11. package/dist/core/logs/registry.js +19 -0
  12. package/dist/core/logs/scan.js +44 -5
  13. package/dist/core/logs/triage.js +79 -2
  14. package/dist/core/managers/mo2.js +121 -0
  15. package/dist/core/managers/vortex.js +52 -0
  16. package/dist/core/ownership/index.js +364 -0
  17. package/dist/core/project/index.js +1 -1
  18. package/dist/core/project/load.js +5 -0
  19. package/dist/core/testplan/registry.js +84 -2
  20. package/dist/core/writes/index.js +206 -0
  21. package/dist/index.js +0 -2
  22. package/dist/server.js +180 -15
  23. package/dist/surfaces/baldursgate3/surface.js +3 -0
  24. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  25. package/dist/surfaces/baldursgate3/writes.js +152 -0
  26. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  27. package/dist/surfaces/cyberpunk2077/logs.js +82 -3
  28. package/dist/surfaces/cyberpunk2077/surface.js +11 -0
  29. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  30. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  31. package/dist/surfaces/skyrimse/surface.js +1 -0
  32. package/dist/surfaces/stardewvalley/logs.js +41 -0
  33. package/dist/surfaces/stardewvalley/surface.js +2 -0
  34. package/dist/surfaces/stardewvalley/writes.js +136 -0
  35. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  36. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  37. package/knowledge/cyberpunk2077/cet.natives.yaml +48 -0
  38. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  39. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  40. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  41. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  42. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  43. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  44. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  45. 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";
@@ -14,6 +14,7 @@ import { buildContext, createValidatorRegistry, runValidators } from "./core/val
14
14
  import { runBuild } from "./core/build/index.js";
15
15
  import { classifyReload, detectRedHotTools, runDeploy } from "./core/deploy/index.js";
16
16
  import { armBridge, bridgeCandidateDirs, bridgeStatus, cetLogCandidates, listPendingArms, PROTOCOL_VERSION as BRIDGE_PROTOCOL_VERSION, pollCyberpunk, pollForResult, probeSchema as bridgeProbeSchema, readPendingArm, stageBridge } from "./core/bridge/index.js";
17
+ import { removeBridge } from "./core/bridge/remove.js";
17
18
  import { runTestPlan } from "./core/testplan/index.js";
18
19
  import { applyBuildLedgerHook, applyClaimTransition, applyDecisionRecord, applyValidateLedgerHook, checkLedger, claimsFilePath, listClaims, loadClaims, loadDecisions, saveClaims, saveDecisions, showClaim, } from "./core/ledger/index.js";
19
20
  import { IndexStore, defaultCacheDir, findLegacyIndex, indexFile, legacyIndexHint, loadKnowledge, resolveIndexFile, searchFacts, } from "./core/knowledge/index.js";
@@ -22,6 +23,8 @@ import { readTextIfExists } from "./core/fsutil.js";
22
23
  import * as path from "node:path";
23
24
  import { promises as fs } from "node:fs";
24
25
  import { crossCheckIdentities, findConflicts, listMods, sharedIdentities } from "./core/mods.js";
26
+ import { buildOwnershipIndex, lookupOwnership, managerConflicts } from "./core/ownership/index.js";
27
+ import { collectWrites, contestedKeys, duplicateCopies, whatTouches, whoTouches } from "./core/writes/index.js";
25
28
  import { resolveLogs, resolveRoots } from "./core/surfaceutil.js";
26
29
  import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds } from "./surfaces/index.js";
27
30
  import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
@@ -183,8 +186,12 @@ server.registerTool("find_conflicts", {
183
186
  installPath: installPathArg,
184
187
  rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
185
188
  limit: z.number().int().positive().default(100).describe("Max conflicts to return."),
189
+ mo2Instance: z
190
+ .string()
191
+ .optional()
192
+ .describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
186
193
  },
187
- }, async ({ game, installPath, rootIds, limit }) => {
194
+ }, async ({ game, installPath, rootIds, limit, mo2Instance }) => {
188
195
  const surface = getSurface(game);
189
196
  if (!surface)
190
197
  return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
@@ -202,10 +209,22 @@ server.registerTool("find_conflicts", {
202
209
  identityUnreadableCount = scanned.unreadable.length;
203
210
  identityUnreadable = scanned.unreadable.slice(0, IDENTITY_UNREADABLE_LIMIT);
204
211
  }
212
+ let managed;
213
+ if (surface.managedDataDir !== undefined) {
214
+ const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
215
+ const mo2 = index.sources.find((src) => src.id === "mo2");
216
+ if (mo2?.active) {
217
+ const found = managerConflicts(index, limit);
218
+ managed = { source: mo2.detail, total: found.total, conflicts: found.conflicts };
219
+ if (found.total > limit)
220
+ managed.truncated = true;
221
+ }
222
+ }
205
223
  return json({
206
224
  game: surface.id,
207
225
  modsAnalyzed: mods.length,
208
226
  conflictCount: conflicts.length,
227
+ managerConflicts: managed,
209
228
  skippedRoots,
210
229
  suppressedNoiseFiles,
211
230
  truncated: conflicts.length > limit,
@@ -215,10 +234,108 @@ server.registerTool("find_conflicts", {
215
234
  identityUnreadable,
216
235
  identityUnreadableTruncated: identityUnreadableCount > IDENTITY_UNREADABLE_LIMIT ? true : undefined,
217
236
  note: "File-level analysis, and only for roots where mods share one deploy tree. " +
218
- "See skippedRoots for what was excluded and why.",
237
+ "See skippedRoots for what was excluded and why." +
238
+ (managed
239
+ ? " managerConflicts are between Mod Organizer 2's mod folders, each with the winner the game sees and the mods it overrides."
240
+ : ""),
219
241
  guidance: surface.guidance,
220
242
  });
221
243
  });
244
+ server.registerTool("owner_of", {
245
+ title: "Which mod owns this file",
246
+ description: "Say which installed mod a file comes from, and which copy the game actually sees when several provide it. " +
247
+ "Accepts absolute paths, paths relative to the game folder, a trailing part of a path (SKSE/Plugins/foo.dll), " +
248
+ "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 " +
249
+ "roots (a mod folder owns what is under it), Mod Organizer 2 (the active profile's mod folders in priority " +
250
+ "order, plus Overwrite; for games where the mapping is known), and Vortex deployment manifests. Each result " +
251
+ "says how the winner was decided; `sources` says what each source could see, and `unattributedRoots` lists " +
252
+ "loose-file folders whose files no source can attribute. Files inside packed archives are not indexed yet.",
253
+ inputSchema: {
254
+ game: gameArg,
255
+ installPath: installPathArg,
256
+ paths: z.array(z.string().min(1)).min(1).max(50).describe("Files to attribute."),
257
+ mo2Instance: z
258
+ .string()
259
+ .optional()
260
+ .describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
261
+ limit: z.number().int().positive().max(200).default(20).describe("Max matches per path (a bare file name can match many)."),
262
+ },
263
+ }, async ({ game, installPath, paths, mo2Instance, limit }) => {
264
+ const surface = getSurface(game);
265
+ if (!surface)
266
+ return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
267
+ const install = await resolveInstall(surface, installPath);
268
+ if (!install)
269
+ return fail(`No install detected for ${surface.name}.`);
270
+ const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
271
+ return json({
272
+ game: surface.id,
273
+ install: install.root,
274
+ sources: index.sources,
275
+ indexedPaths: index.entries.size,
276
+ unattributedRoots: index.unattributedRoots.length > 0 ? index.unattributedRoots : undefined,
277
+ results: paths.map((p) => lookupOwnership(index, p, limit)),
278
+ note: "Owners come only from what is on disk: a mod folder, a manager's own records. Nothing is inferred from " +
279
+ "file names. A path no mod claims is reported as such, with the likely reasons.",
280
+ });
281
+ });
282
+ server.registerTool("who_touches", {
283
+ title: "Which mods write this record or asset",
284
+ description: "Read every installed mod's data and report who writes a given key — at the level the game merges on, not the " +
285
+ "file level: a TweakDB record or flat (Cyberpunk 2077, TweakXL YAML and .tweak files), a stats entry or field " +
286
+ "(Baldur's Gate 3, inside paks and unpacked mods), an asset or data entry/field (Stardew Valley Content Patcher " +
287
+ "packs, Includes followed). Give `key` for who writes it (a record returns its flats too; an asset its entries), " +
288
+ "`mod` for everything one mod writes and where another mod writes the same key, or neither for every key two or " +
289
+ "more mods contend for. This says who touches a key, not who wins: the winner depends on the game's load order. " +
290
+ "Writes behind a Content Patcher When, or with {{tokens}}, are marked conditional.",
291
+ inputSchema: {
292
+ game: gameArg,
293
+ installPath: installPathArg,
294
+ 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."),
295
+ mod: z.string().min(1).optional().describe("A mod (or unit) id or name, for what it writes."),
296
+ rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
297
+ limit: z.number().int().positive().max(500).default(50).describe("Max keys (or writes per mod) to return."),
298
+ },
299
+ }, async ({ game, installPath, key, mod, rootIds, limit }) => {
300
+ const surface = getSurface(game);
301
+ if (!surface)
302
+ return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
303
+ const install = await resolveInstall(surface, installPath);
304
+ if (!install)
305
+ return fail(`No install detected for ${surface.name}.`);
306
+ if (!surface.extractWrites) {
307
+ return json({
308
+ game: surface.id,
309
+ supported: false,
310
+ note: `No write-set extraction for ${surface.name} yet (supported: Cyberpunk 2077 tweaks, Baldur's Gate 3 stats, Stardew Valley Content Patcher).`,
311
+ });
312
+ }
313
+ const collection = await collectWrites(surface, install, rootIds ? { rootIds } : {});
314
+ const { errors, ...coverage } = collection.coverage;
315
+ const copies = duplicateCopies(collection);
316
+ const result = key
317
+ ? { mode: "key", ...whoTouches(collection, key, limit) }
318
+ : mod
319
+ ? { mode: "mod", query: mod, writers: whatTouches(collection, mod, limit) }
320
+ : { mode: "contested", ...contestedKeys(collection, limit) };
321
+ return json({
322
+ game: surface.id,
323
+ install: install.root,
324
+ coverage: {
325
+ ...coverage,
326
+ writers: collection.writers.length,
327
+ errorCount: errors.length,
328
+ errors: errors.slice(0, 25),
329
+ },
330
+ ...(copies.length > 0 ? { duplicateCopies: copies } : {}),
331
+ ...result,
332
+ note: "Static: read from the mods' files, never from the running game. 'contested' means two or more mods write the " +
333
+ "key and not only by appending; which one the game ends up with depends on load order, which this tool does " +
334
+ "not decide. A mod the game does not load (marked 'inactive') is listed but never contests, and copies of one " +
335
+ "mod ('duplicateCopies') count once: the game loads one of them. Code that writes at runtime (redscript, CET, " +
336
+ "SMAPI C# mods, Script Extender Lua) is not seen here.",
337
+ });
338
+ });
222
339
  server.registerTool("load_order", {
223
340
  title: "Read load order",
224
341
  description: "Read the game's explicit load order and cross-check it against what is " +
@@ -340,9 +457,7 @@ server.registerTool("project_info", {
340
457
  }, async ({ path: start }) => {
341
458
  const from = start ?? process.cwd();
342
459
  try {
343
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json")
344
- ? from
345
- : await findProjectFile(from);
460
+ const file = await locateProjectFile(from);
346
461
  if (!file) {
347
462
  return json({
348
463
  found: false,
@@ -601,7 +716,11 @@ server.registerTool("triage_logs", {
601
716
  "which failed and why, with the first error's explanation and usual fix. Pass the " +
602
717
  "returned offsets and offsetKeys back as offsets/offsetKeys for incremental triage after " +
603
718
  "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.",
719
+ "Stale and zero-byte logs are reported as such rather than trusted. Where a framework " +
720
+ "logs who did what (SMAPI's 'X edited Data/Objects', TweakXL's file read order, ArchiveXL " +
721
+ "merges and localization overwrites, Red Hot Tools' missing resources), the verdict's " +
722
+ "`attribution` groups those lines by subject, contested subjects first: the log's own answer " +
723
+ "to 'which mod is doing this'. Pass attributionFor to narrow it to one asset, record or mod.",
605
724
  inputSchema: {
606
725
  game: gameArg,
607
726
  installPath: installPathArg,
@@ -619,15 +738,20 @@ server.registerTool("triage_logs", {
619
738
  .describe("Per log id file identities (offsetKeys) from the same previous result."),
620
739
  maxLines: z.number().int().positive().max(20000).optional(),
621
740
  includeFindings: z.boolean().default(true),
741
+ attributionFor: z
742
+ .string()
743
+ .optional()
744
+ .describe("Keep only attribution entries whose subject, actor or content pack contains this text " +
745
+ "(case-insensitive): an asset or record for 'who touched this', a mod name for 'what did it touch'."),
622
746
  },
623
- }, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings }) => {
747
+ }, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings, attributionFor }) => {
624
748
  const surface = getSurface(game);
625
749
  if (!surface)
626
750
  return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
627
751
  const install = await resolveInstall(surface, installPath);
628
752
  if (!install)
629
753
  return fail(`No install detected for ${surface.name}.`);
630
- const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines });
754
+ const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines }, { attributionFor });
631
755
  return json({
632
756
  ...verdict,
633
757
  signatureCount: registry.forGame(surface.id).length,
@@ -677,7 +801,7 @@ server.registerTool("load_order_diff", {
677
801
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
678
802
  let snapshotDir = path.join(userDataDir(), "snapshots");
679
803
  if (projectPath) {
680
- const projectFile = (await pathExists(projectPath)) && projectPath.toLowerCase().endsWith(".json") ? projectPath : await findProjectFile(projectPath);
804
+ const projectFile = await locateProjectFile(projectPath);
681
805
  if (!projectFile)
682
806
  return fail(`No modwright.json found at or above ${projectPath}.`);
683
807
  snapshotDir = path.join(path.dirname(path.resolve(projectFile)), ".modwright", "snapshots");
@@ -912,7 +1036,7 @@ server.registerTool("build_index", {
912
1036
  let toolOverrides;
913
1037
  if (projectPath) {
914
1038
  try {
915
- const file = (await pathExists(projectPath)) && projectPath.toLowerCase().endsWith(".json") ? projectPath : await findProjectFile(projectPath);
1039
+ const file = await locateProjectFile(projectPath);
916
1040
  if (!file)
917
1041
  return fail(`No modwright.json found at or above ${projectPath}.`);
918
1042
  const loaded = await loadProject(file);
@@ -1278,10 +1402,13 @@ server.registerTool("bridge", {
1278
1402
  "(dry-run by default for both; refuses on a protocolVersion mismatch rather than guessing). 'poll': wait for " +
1279
1403
  "that request's result up to a budget of max(15s, 3s x probe count) + window, capped at 300s, " +
1280
1404
  "reporting one of four named timeout outcomes rather than a bare 'timed out' — plus, on the CET log transport, " +
1281
- "'truncated' when the result line was cut mid-write. Test-plan execution is run_tests; claims are the " +
1405
+ "'truncated' when the result line was cut mid-write. 'remove': take a deployed bridge back out of the game " +
1406
+ "(every folder the project's withBridge deploy targets name), backed up under the project; dry-run by default. " +
1407
+ "A deploy without the withBridge variant leaves an installed bridge in place. " +
1408
+ "Test-plan execution is run_tests; claims are the " +
1282
1409
  "ledger tool.",
1283
1410
  inputSchema: {
1284
- action: z.enum(["status", "arm", "poll", "stage"]),
1411
+ action: z.enum(["status", "arm", "poll", "stage", "remove"]),
1285
1412
  game: z
1286
1413
  .enum(BRIDGE_GAMES)
1287
1414
  .optional()
@@ -1312,12 +1439,18 @@ server.registerTool("bridge", {
1312
1439
  timeoutSeconds: z.number().positive().max(300).optional().describe("'poll': override the computed budget; still capped at 300s."),
1313
1440
  },
1314
1441
  }, async ({ action, game, installPath, path: start, mode, probes, armForSeconds, grant, allowMutate, windowSeconds, planId, planPath, runId, id, nonce, probeCount, timeoutSeconds }) => {
1442
+ const planFields = { planId, planPath, runId };
1443
+ const givenPlan = Object.entries(planFields).filter(([, v]) => v !== undefined);
1444
+ if (givenPlan.length > 0 && givenPlan.length < 3) {
1445
+ const missing = Object.entries(planFields).filter(([, v]) => v === undefined).map(([k]) => k);
1446
+ return fail(`planId, planPath and runId go together; ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} missing.`);
1447
+ }
1315
1448
  let projectGame;
1316
1449
  let projectRoot;
1317
1450
  if (start) {
1318
1451
  try {
1319
1452
  const from = start;
1320
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json") ? from : await findProjectFile(from);
1453
+ const file = await locateProjectFile(from);
1321
1454
  if (!file)
1322
1455
  return fail(`No modwright.json found at or above ${from}.`);
1323
1456
  const loaded = await loadProject(file);
@@ -1346,6 +1479,38 @@ server.registerTool("bridge", {
1346
1479
  return fail(`bridge stage failed: ${error.message}`);
1347
1480
  }
1348
1481
  }
1482
+ if (action === "remove") {
1483
+ if (!start)
1484
+ return fail('bridge remove requires "path" (the project whose deployed bridge to remove).');
1485
+ const surfaceId = game ?? projectGame;
1486
+ const removeSurface = surfaceId ? getSurface(surfaceId) : undefined;
1487
+ if (!removeSurface)
1488
+ return fail(`Could not determine the game for ${start}; pass game explicitly.`);
1489
+ const install = await resolveInstall(removeSurface, installPath);
1490
+ if (!install)
1491
+ return fail(`No install detected for ${removeSurface.name}.`);
1492
+ try {
1493
+ const resolved = await loadProject(start);
1494
+ const report = await removeBridge(resolved, removeSurface.modRoots(install), { mode });
1495
+ const toRemove = report.removals.filter((r) => r.action === "remove").length;
1496
+ return json({
1497
+ action,
1498
+ game: removeSurface.id,
1499
+ project: resolved.project.name,
1500
+ ...report,
1501
+ note: report.removals.length === 0
1502
+ ? "The project has no withBridge deploy target: nothing to remove."
1503
+ : toRemove === 0
1504
+ ? "No deployed bridge folder was found: nothing to remove."
1505
+ : report.applied
1506
+ ? `Removed ${toRemove} bridge folder(s); the backup is under the project's .modwright/backups (rollback restores it).`
1507
+ : `Dry run: ${toRemove} bridge folder(s) would be removed, with a backup. Repeat with mode 'apply'.`,
1508
+ });
1509
+ }
1510
+ catch (error) {
1511
+ return fail(`bridge remove failed: ${error.message}`);
1512
+ }
1513
+ }
1349
1514
  const gameId = game ?? projectGame;
1350
1515
  if (!gameId)
1351
1516
  return fail('bridge status/arm/poll requires "game" (or a "path" to a project that names one).');
@@ -1618,7 +1783,7 @@ const TEMPLATE_SURFACES = ["baldursgate3", "cyberpunk2077"];
1618
1783
  const TEMPLATE_PERMISSIONS = ["never-redistribute", "maintainer-own", "declared-dependency"];
1619
1784
  async function resolveProject(start) {
1620
1785
  const from = start ?? process.cwd();
1621
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json") ? from : await findProjectFile(from);
1786
+ const file = await locateProjectFile(from);
1622
1787
  if (!file)
1623
1788
  throw new NoProjectError(`No modwright.json found at or above ${from}.`);
1624
1789
  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) {