@el4cteo/rbx-studio-mcp 0.8.3 → 0.8.6

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 (68) hide show
  1. package/README.md +24 -6
  2. package/dist/bridge/rpc.js +1 -1
  3. package/dist/bridge/rpc.js.map +1 -1
  4. package/dist/index.js +7 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +75 -0
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/errors.js +5 -4
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/format.js +92 -25
  11. package/dist/lib/format.js.map +1 -1
  12. package/dist/lib/notices.js +29 -0
  13. package/dist/lib/notices.js.map +1 -0
  14. package/dist/lib/protocol.js.map +1 -1
  15. package/dist/lib/sync.js +1141 -0
  16. package/dist/lib/sync.js.map +1 -0
  17. package/dist/lib/syncplan.js +338 -0
  18. package/dist/lib/syncplan.js.map +1 -0
  19. package/dist/lib/tool.js +9 -2
  20. package/dist/lib/tool.js.map +1 -1
  21. package/dist/tools/discover.js +6 -1
  22. package/dist/tools/discover.js.map +1 -1
  23. package/dist/tools/exec.js +5 -0
  24. package/dist/tools/exec.js.map +1 -1
  25. package/dist/tools/instances.js +2 -2
  26. package/dist/tools/instances.js.map +1 -1
  27. package/dist/tools/perf.js +28 -3
  28. package/dist/tools/perf.js.map +1 -1
  29. package/dist/tools/screenshot.js +7 -3
  30. package/dist/tools/screenshot.js.map +1 -1
  31. package/dist/tools/scripts.js +110 -28
  32. package/dist/tools/scripts.js.map +1 -1
  33. package/dist/tools/sync.js +169 -0
  34. package/dist/tools/sync.js.map +1 -0
  35. package/package.json +4 -4
  36. package/plugin/src/Commands.luau +72 -5
  37. package/plugin/src/Config.luau +65 -65
  38. package/plugin/src/Console.luau +5 -0
  39. package/plugin/src/Dispatch.luau +131 -90
  40. package/plugin/src/ExecRuntime.luau +190 -168
  41. package/plugin/src/LogBuffer.luau +38 -9
  42. package/plugin/src/Paths.luau +42 -0
  43. package/plugin/src/Phrase.luau +41 -0
  44. package/plugin/src/Prompt.luau +23 -3
  45. package/plugin/src/ScriptEdit.luau +94 -8
  46. package/plugin/src/Serialize.luau +16 -1
  47. package/plugin/src/Transport.luau +5 -2
  48. package/plugin/src/Undo.luau +74 -10
  49. package/plugin/src/handlers/Capture.luau +818 -809
  50. package/plugin/src/handlers/Debug.luau +19 -16
  51. package/plugin/src/handlers/Discover.luau +31 -1
  52. package/plugin/src/handlers/Perf.luau +100 -21
  53. package/plugin/src/handlers/Scripts.luau +274 -90
  54. package/plugin/src/handlers/Sync.luau +968 -0
  55. package/plugin/src/init.server.luau +47 -2
  56. package/scripts/build.mjs +14 -0
  57. package/scripts/sync-fake.mjs +191 -0
  58. package/scripts/test-live-sync-scale.mjs +150 -0
  59. package/scripts/test-live-sync.mjs +232 -0
  60. package/scripts/test-live-tools.mjs +6 -1
  61. package/scripts/test-plugin.mjs +17 -0
  62. package/scripts/test-results.mjs +41 -0
  63. package/scripts/test-sync-more.mjs +228 -0
  64. package/scripts/test-sync.mjs +245 -0
  65. package/dist/tools/spatial.js +0 -135
  66. package/dist/tools/spatial.js.map +0 -1
  67. package/dist/tools/upload.js +0 -294
  68. package/dist/tools/upload.js.map +0 -1
@@ -38,6 +38,70 @@ function ScriptEdit.read(target: LuaSourceContainer): string
38
38
  return (target :: SourceContainer).Source
39
39
  end
40
40
 
41
+ --[[
42
+ A short fingerprint of a script's source, for detecting that it moved.
43
+
44
+ Handed out by `script_read` and passed back to `script_edit`, and kept by
45
+ `sync` to tell which side of a synced script changed. `edit`, which refuses to write when
46
+ the live source no longer matches. That is the only thing standing between
47
+ two agents on one place and a silent overwrite: a line-range edit computed
48
+ against source somebody has since changed still applies cleanly, it just
49
+ applies to the wrong lines, and nothing anywhere reports it.
50
+
51
+ FNV-1a over the whole string, with the length appended. Not a security
52
+ hash and does not need to be -- it is guarding against ordinary concurrent
53
+ editing, not against someone constructing a collision. The length is there
54
+ because it is free and rules out the whole class of same-length accidents.
55
+
56
+ The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
57
+ a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
58
+ so the low bits -- the ones that carry the mixing -- would be quietly
59
+ rounded away.
60
+ ]]
61
+ function ScriptEdit.fingerprint(source: string): string
62
+ local hash = 2166136261
63
+ local length = #source
64
+ local index = 1
65
+ while index <= length do
66
+ local last = math.min(index + 511, length)
67
+ local chunk = { string.byte(source, index, last) }
68
+ for _, byte in chunk do
69
+ hash = bit32.bxor(hash, byte)
70
+ local low = bit32.band(hash, 0xFFFF)
71
+ local high = bit32.rshift(hash, 16)
72
+ -- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
73
+ hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
74
+ end
75
+ index = last + 1
76
+ end
77
+ return string.format("%08x-%x", hash, length)
78
+ end
79
+
80
+ --[[
81
+ A script's revision, computed once per distinct text.
82
+
83
+ Fingerprinting walks every byte in Luau, and `sync` asks for the revision of
84
+ every script in a place on each run -- so a place with megabytes of code paid
85
+ for hashing all of it after every save, to learn that one file changed. The
86
+ text is kept beside its revision per script, and a lookup whose text is the
87
+ same string skips the hash: Luau interns strings, so that comparison is a
88
+ pointer check, not a second pass over the source.
89
+
90
+ Weak keys, so a deleted script's entry goes with it.
91
+ ]]
92
+ local revisions: { [Instance]: { source: string, revision: string } } = setmetatable({}, { __mode = "k" }) :: any
93
+
94
+ function ScriptEdit.revisionOf(target: LuaSourceContainer, source: string?): string
95
+ local text = source or ScriptEdit.read(target)
96
+ local cached = revisions[target]
97
+ if cached ~= nil and cached.source == text then
98
+ return cached.revision
99
+ end
100
+ local revision = ScriptEdit.fingerprint(text)
101
+ revisions[target] = { source = text, revision = revision }
102
+ return revision
103
+ end
104
+
41
105
  --[[
42
106
  Makes sure a write that reported success actually stuck.
43
107
 
@@ -59,11 +123,16 @@ end
59
123
  caused this is only reachable when the USER opens one in the same moment we
60
124
  write to it -- rare, and cheap enough to cover anyway. One settle, one read,
61
125
  and a rewrite if it did not take.
126
+
127
+ A rewrite only ever replaces `original`, the text our write replaced. That
128
+ is what a finishing load puts back. Anything else in the buffer is somebody
129
+ typing after the write landed, and rewriting it "to make ours stick" would
130
+ delete their work -- so that is reported instead, and their text is kept.
62
131
  ]]
63
132
  local SETTLE = 0.2
64
133
 
65
- local function confirm(target: LuaSourceContainer, produced: string?)
66
- if produced == nil then
134
+ local function confirm(target: LuaSourceContainer, produced: string?, original: string?)
135
+ if produced == nil or produced == original then
67
136
  return
68
137
  end
69
138
 
@@ -86,20 +155,35 @@ local function confirm(target: LuaSourceContainer, produced: string?)
86
155
  -- repeating will not fix.
87
156
  for _ = 1, 3 do
88
157
  task.wait(SETTLE)
89
- if ScriptEdit.read(target) == produced then
158
+ local current = ScriptEdit.read(target)
159
+ if current == produced then
90
160
  return
91
161
  end
162
+ if current ~= original then
163
+ break
164
+ end
92
165
  pcall(function()
93
- ScriptEditorService:UpdateSourceAsync(target, function()
94
- return produced
166
+ ScriptEditorService:UpdateSourceAsync(target, function(source)
167
+ -- Checked again at commit time: the user may have typed since the read.
168
+ return if source == original then produced :: string else source
95
169
  end)
96
170
  end)
97
171
  end
172
+
173
+ if ScriptEdit.read(target) ~= produced then
174
+ Dispatch.fail(
175
+ "VERIFY_FAILED",
176
+ string.format("%s changed while the write was being confirmed.", target:GetFullName()),
177
+ "The newer text was kept, not overwritten. Read the script again before retrying."
178
+ )
179
+ end
98
180
  end
99
181
 
100
182
  --[[
101
- Replaces source through the editor. `transform` receives the current text and
102
- returns the replacement; returning the input unchanged is a no-op.
183
+ Replaces source through the editor. `transform` receives the current text --
184
+ read at commit time, so it is where a "has this changed since I read it"
185
+ check belongs -- and returns the replacement; returning the input unchanged
186
+ is a no-op.
103
187
 
104
188
  `transform` is allowed to raise a structured Dispatch failure -- a bad line
105
189
  range is only discoverable once the authoritative source is in hand. Such a
@@ -115,10 +199,12 @@ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> str
115
199
  local pending: any = nil
116
200
 
117
201
  local produced: string? = nil
202
+ local original: string? = nil
118
203
  local ok, err = pcall(function()
119
204
  ScriptEditorService:UpdateSourceAsync(target, function(source)
120
205
  local applied, result = pcall(transform, source)
121
206
  if applied then
207
+ original = source
122
208
  produced = result
123
209
  return result
124
210
  end
@@ -131,7 +217,7 @@ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> str
131
217
  error(pending, 0)
132
218
  end
133
219
  if ok then
134
- confirm(target, produced)
220
+ confirm(target, produced, original)
135
221
  return
136
222
  end
137
223
 
@@ -413,7 +413,22 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
413
413
  end
414
414
  -- 0-255 is a common mistake and unambiguous to detect, so accept it.
415
415
  local scale = if numbers[1] > 1 or numbers[2] > 1 or numbers[3] > 1 then 255 else 1
416
- return true, Color3.new(numbers[1] / scale, numbers[2] / scale, numbers[3] / scale), nil
416
+ --[[
417
+ Snapped back to an exact 1/255 step when the text is that step
418
+ rounded to PRECISION places.
419
+
420
+ Part colours are stored as bytes. "0.7686" is how 196/255 prints,
421
+ but it is a hair under it, and the engine truncates on the way in
422
+ -- so a colour read and written back came out as 195, one step
423
+ darker each round trip. A value genuinely between steps, like 0.5,
424
+ is further than half a printed digit from one and is left alone.
425
+ ]]
426
+ local function component(value: number): number
427
+ local unit = value / scale
428
+ local step = math.round(unit * 255) / 255
429
+ return if math.abs(unit - step) <= 0.5 * 10 ^ -PRECISION then step else unit
430
+ end
431
+ return true, Color3.new(component(numbers[1]), component(numbers[2]), component(numbers[3])), nil
417
432
  end
418
433
  if valueType == "BrickColor" then
419
434
  -- Cast because the API definitions type this overload as a literal union
@@ -26,7 +26,9 @@ local Transport = {}
26
26
  export type Status = "disconnected" | "connecting" | "connected"
27
27
 
28
28
  type Callbacks = {
29
- onCommand: (id: string, op: string, params: { [string]: any }?) -> (),
29
+ -- `deadlineMs` is when the server stops waiting for the reply, on this
30
+ -- machine's clock: the server sends a duration, never a time (see Command.timeoutMs).
31
+ onCommand: (id: string, op: string, params: { [string]: any }?, deadlineMs: number?) -> (),
30
32
  onStatus: (status: Status, detail: string?) -> (),
31
33
  --[[
32
34
  Anything the bridge wants to tell this plugin that is not a command.
@@ -243,7 +245,8 @@ local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
243
245
  local id = frame.id
244
246
  local op = frame.op
245
247
  if typeof(id) == "string" and typeof(op) == "string" then
246
- callbacks.onCommand(id, op, frame.params)
248
+ local deadline = if typeof(frame.timeoutMs) == "number" then DateTime.now().UnixTimestampMillis + frame.timeoutMs else nil
249
+ callbacks.onCommand(id, op, frame.params, deadline)
247
250
  return
248
251
  end
249
252
  if typeof(frame.event) == "string" then
@@ -18,6 +18,8 @@
18
18
 
19
19
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
20
20
 
21
+ local Dispatch = require(script.Parent.Dispatch)
22
+
21
23
  local Undo = {}
22
24
 
23
25
  --[[
@@ -132,13 +134,57 @@ end
132
134
  is something tools tell the agent, and therefore the user, so it has to be
133
135
  observed rather than assumed: a claim of "one Ctrl+Z" that silently degrades
134
136
  to no undo entry at all is worse than no claim.
137
+
138
+ Refusing instead is not an option either: Studio opens no recording at all
139
+ inside a running playtest (measured), so a refusal would make every write
140
+ tool unusable against the runtime session.
141
+ ]]
142
+
143
+ -- Opens a recording, or nil. Guarded because an engine error here must not
144
+ -- escape with the lock held.
145
+ local function begin(name: string, displayName: string): string?
146
+ local ok, identifier = pcall(function()
147
+ return ChangeHistoryService:TryBeginRecording(name, displayName)
148
+ end)
149
+ return if ok then identifier else nil
150
+ end
151
+
152
+ --[[
153
+ Runs `body` after the lock is ours, unless the request expired while it
154
+ waited. The server stops waiting at its timeout, so work that starts later
155
+ would be reported to nobody -- and an agent told "timed out" is likely to
156
+ retry it.
135
157
  ]]
158
+ local function run<T>(body: () -> T): (boolean, any)
159
+ return pcall(function()
160
+ Dispatch.checkDeadline()
161
+ return body()
162
+ end)
163
+ end
164
+
165
+ -- Closes a recording. A failure here is reported, with the lock released, as
166
+ -- an outcome the agent must check rather than assume.
167
+ local function finish(identifier: string, operation: Enum.FinishRecordingOperation, acquired: boolean)
168
+ local ok, err = pcall(function()
169
+ ChangeHistoryService:FinishRecording(identifier, operation)
170
+ end)
171
+ if ok then
172
+ return
173
+ end
174
+ release(acquired)
175
+ Dispatch.fail(
176
+ "UNDO_FAILED",
177
+ string.format("Studio could not close the undo recording: %s", tostring(err)),
178
+ "The change may or may not have applied. Inspect the affected instances before retrying."
179
+ )
180
+ end
181
+
136
182
  function Undo.record<T>(name: string, displayName: string, body: () -> T): (T, boolean)
137
183
  local acquired = acquire()
138
184
 
139
- local identifier = ChangeHistoryService:TryBeginRecording(name, displayName)
185
+ local identifier = begin(name, displayName)
140
186
  if not identifier then
141
- local ok, result = pcall(body)
187
+ local ok, result = run(body)
142
188
  release(acquired)
143
189
  if not ok then
144
190
  error(result, 0)
@@ -146,15 +192,15 @@ function Undo.record<T>(name: string, displayName: string, body: () -> T): (T, b
146
192
  return result, false
147
193
  end
148
194
 
149
- local ok, result = pcall(body)
195
+ local ok, result = run(body)
150
196
  if ok then
151
- ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
197
+ finish(identifier, Enum.FinishRecordingOperation.Commit, acquired)
152
198
  release(acquired)
153
199
  return result, true
154
200
  end
155
201
 
156
- ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Cancel)
157
- dropRedo()
202
+ finish(identifier, Enum.FinishRecordingOperation.Cancel, acquired)
203
+ pcall(dropRedo)
158
204
  release(acquired)
159
205
  error(result, 0)
160
206
  end
@@ -167,9 +213,9 @@ end
167
213
  function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T): (T, boolean)
168
214
  local acquired = acquire()
169
215
 
170
- local identifier = ChangeHistoryService:TryBeginRecording(name, displayName)
216
+ local identifier = begin(name, displayName)
171
217
  if not identifier then
172
- local ok, result = pcall(body)
218
+ local ok, result = run(body)
173
219
  release(acquired)
174
220
  if not ok then
175
221
  error(result, 0)
@@ -177,8 +223,8 @@ function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T)
177
223
  return result, false
178
224
  end
179
225
 
180
- local ok, result = pcall(body)
181
- ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
226
+ local ok, result = run(body)
227
+ finish(identifier, Enum.FinishRecordingOperation.Commit, acquired)
182
228
  release(acquired)
183
229
  if not ok then
184
230
  error(result, 0)
@@ -186,4 +232,22 @@ function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T)
186
232
  return result, true
187
233
  end
188
234
 
235
+ --[[
236
+ Holds the mutation lock without opening a recording.
237
+
238
+ For script source, whose undo is the script editor's own, per document --
239
+ a recording would add an empty step, not make the edit undoable. The lock
240
+ is what matters: two batches writing the same scripts must not interleave
241
+ their writes and their rollbacks.
242
+ ]]
243
+ function Undo.exclusive<T>(body: () -> T): T
244
+ local acquired = acquire()
245
+ local ok, result = run(body)
246
+ release(acquired)
247
+ if not ok then
248
+ error(result, 0)
249
+ end
250
+ return result
251
+ end
252
+
189
253
  return Undo