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.
- package/README.md +43 -4
- package/bridges/bg3-se/README.md +3 -2
- package/bridges/bg3-se/bridge.json +1 -1
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +228 -1
- package/bridges/cp2077-cet/README.md +6 -3
- package/bridges/cp2077-cet/bridge.json +2 -2
- package/dist/core/bridge/remove.js +48 -0
- package/dist/core/build/stage.js +3 -2
- package/dist/core/ini.js +26 -0
- package/dist/core/knowledge/facts.js +0 -12
- package/dist/core/logs/registry.js +19 -0
- package/dist/core/logs/scan.js +44 -5
- package/dist/core/logs/triage.js +79 -2
- package/dist/core/managers/mo2.js +121 -0
- package/dist/core/managers/vortex.js +52 -0
- package/dist/core/ownership/index.js +364 -0
- package/dist/core/project/index.js +1 -1
- package/dist/core/project/load.js +5 -0
- package/dist/core/testplan/registry.js +84 -2
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +0 -2
- package/dist/server.js +180 -15
- package/dist/surfaces/baldursgate3/surface.js +3 -0
- package/dist/surfaces/baldursgate3/validators/story.js +3 -3
- package/dist/surfaces/baldursgate3/writes.js +152 -0
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
- package/dist/surfaces/cyberpunk2077/logs.js +82 -3
- package/dist/surfaces/cyberpunk2077/surface.js +11 -0
- package/dist/surfaces/cyberpunk2077/writes.js +67 -0
- package/dist/surfaces/skyrimse/loadorder.js +2 -26
- package/dist/surfaces/skyrimse/surface.js +1 -0
- package/dist/surfaces/stardewvalley/logs.js +41 -0
- package/dist/surfaces/stardewvalley/surface.js +2 -0
- package/dist/surfaces/stardewvalley/writes.js +136 -0
- package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
- package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
- package/knowledge/cyberpunk2077/cet.natives.yaml +48 -0
- package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
- package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
- package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
- package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
- package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
- package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
- package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
- 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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
232
|
-
`
|
|
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) {
|