@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
@@ -1,168 +1,190 @@
1
- --!strict
2
- -- Shared bounded execution results in the VM that calls run.
3
- local LogService = game:GetService("LogService")
4
- local LogBuffer = require(script.Parent.LogBuffer)
5
- local Serialize = require(script.Parent.Serialize)
6
- -- A chunk that prints in a loop would otherwise return a response no context
7
- -- window can hold.
8
- local MAX_OUTPUT_LINES = 200
9
- local MAX_RETURN_VALUES = 10
10
-
11
- -- Returned tables are walked rather than counted, but not without limit: a
12
- -- chunk that returns a whole config tree, or one with a cycle in it, must not be
13
- -- able to produce a response nothing can read.
14
- local MAX_TABLE_ENTRIES = 50
15
- local MAX_TABLE_DEPTH = 4
16
-
17
- local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
18
- local level = depth or 0
19
- local visited = seen or {}
20
- local kind = typeof(value)
21
-
22
- if kind == "function" or kind == "thread" then
23
- return string.format("<%s>", kind)
24
- end
25
- if kind ~= "table" then
26
- return Serialize.value(value)
27
- end
28
-
29
- local source = value :: { [any]: any }
30
- -- Named separately from the depth cap, because they mean opposite things to
31
- -- a reader. The depth cap says "there is more below this"; a cycle says
32
- -- "this is the table you are already inside". Reporting the second as the
33
- -- first printed `t.self.self.self` as three distinct nested tables, which
34
- -- reads as real structure that does not exist.
35
- if visited[source] then
36
- return "<circular reference>"
37
- end
38
- if level >= MAX_TABLE_DEPTH then
39
- return "<nested table>"
40
- end
41
- visited[source] = true
42
-
43
- local count = 0
44
- for _ in source do
45
- count += 1
46
- end
47
-
48
- -- Arrays keep their shape so they read as lists on the other side; anything
49
- -- with non-sequential keys becomes a map with stringified keys.
50
- if count == #source then
51
- local list: { any } = {}
52
- for index, item in ipairs(source) do
53
- if index > MAX_TABLE_ENTRIES then
54
- table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
55
- break
56
- end
57
- table.insert(list, describe(item, level + 1, visited))
58
- end
59
- -- Cleared on the way out so the same table appearing twice side by side
60
- -- still renders twice; only a table containing itself is a cycle.
61
- visited[source] = nil
62
- return list
63
- end
64
-
65
- local map: { [string]: any } = {}
66
- local shown = 0
67
- for key, item in source do
68
- shown += 1
69
- if shown > MAX_TABLE_ENTRIES then
70
- map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
71
- break
72
- end
73
- map[tostring(key)] = describe(item, level + 1, visited)
74
- end
75
- visited[source] = nil
76
- return map
77
- end
78
-
79
- local Runtime = {}
80
- Runtime.describe = describe
81
- function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
82
- -- Captured rather than inferred: a chunk's prints are usually the point, and
83
- -- reading them back from the log afterwards would also pick up whatever else
84
- -- the place logged in the meantime.
85
- local output: { { [string]: any } } = {}
86
- local connection = LogService.MessageOut:Connect(function(message, messageType, context)
87
- if #output < MAX_OUTPUT_LINES then
88
- -- A structured log's context rides on the line, as it does in `console`.
89
- local encoded = LogBuffer.encodeContext(context)
90
- if encoded then
91
- message ..= " " .. encoded
92
- end
93
- table.insert(output, {
94
- message = message,
95
- level = if messageType == Enum.MessageType.MessageError
96
- then "error"
97
- elseif messageType == Enum.MessageType.MessageWarning then "warning"
98
- elseif messageType == Enum.MessageType.MessageInfo then "info"
99
- else "print",
100
- })
101
- end
102
- end)
103
-
104
- local started = os.clock()
105
- local results: any = nil
106
- if timeout then
107
- local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
108
- local deadline = started + timeout
109
- while results == nil and os.clock() < deadline do task.wait() end
110
- if results == nil then
111
- task.cancel(worker)
112
- results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
113
- end
114
- else
115
- results = table.pack(pcall(chunk :: () -> ...any))
116
- end
117
- local elapsed = os.clock() - started
118
-
119
- --[[
120
- MessageOut is deferred: `print` returns before the event fires, so
121
- disconnecting straight after the chunk finished captured nothing at all.
122
- A short settle picks the lines up.
123
-
124
- It is bounded and stops as soon as the flow stops, rather than waiting a
125
- fixed period, so a chunk that logged nothing costs almost no time and one
126
- that logged plenty is not truncated.
127
- ]]
128
- local settleDeadline = os.clock() + 0.5
129
- local seen = #output
130
- local quietFrames = 0
131
- while os.clock() < settleDeadline and quietFrames < 3 do
132
- task.wait()
133
- if #output == seen then
134
- quietFrames += 1
135
- else
136
- seen = #output
137
- quietFrames = 0
138
- end
139
- end
140
-
141
- connection:Disconnect()
142
-
143
- if not results[1] then
144
- return {
145
- ok = false,
146
- error = tostring(results[2]),
147
- output = output,
148
- milliseconds = math.floor(elapsed * 1000 + 0.5),
149
- }
150
- end
151
-
152
- local returned: { any } = {}
153
- for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
154
- -- A nil return is written out rather than dropped. `table.insert` of nil
155
- -- does nothing, so `return a, nil, b` came back as two values and every
156
- -- one after the nil shifted into the wrong position.
157
- local value = results[index]
158
- table.insert(returned, if value == nil then "nil" else describe(value))
159
- end
160
-
161
- return {
162
- ok = true,
163
- returned = returned,
164
- output = output,
165
- milliseconds = math.floor(elapsed * 1000 + 0.5),
166
- }
167
- end
168
- return Runtime
1
+ --!strict
2
+ -- Shared bounded execution results in the VM that calls run.
3
+ local LogService = game:GetService("LogService")
4
+ local LogBuffer = require(script.Parent.LogBuffer)
5
+ local Serialize = require(script.Parent.Serialize)
6
+ -- A chunk that prints in a loop would otherwise return a response no context
7
+ -- window can hold.
8
+ local MAX_OUTPUT_LINES = 200
9
+ local MAX_RETURN_VALUES = 10
10
+
11
+ -- Returned tables are walked rather than counted, but not without limit: a
12
+ -- chunk that returns a whole config tree, or one with a cycle in it, must not be
13
+ -- able to produce a response nothing can read.
14
+ local MAX_TABLE_ENTRIES = 50
15
+ local MAX_TABLE_DEPTH = 4
16
+ type Budget = { nodes: number, chars: number }
17
+
18
+ local function describe(value: any, depth: number?, seen: { [any]: boolean }?, shared: Budget?): any
19
+ local budget = shared or {nodes = 300, chars = 10000}
20
+ budget.nodes -= 1
21
+ budget.chars -= 30
22
+ if budget.nodes < 0 or budget.chars < 100 then return "<budget exceeded>" end
23
+ local level = depth or 0
24
+ local visited = seen or {}
25
+ local kind = typeof(value)
26
+
27
+ if kind == "function" or kind == "thread" then
28
+ return string.format("<%s>", kind)
29
+ end
30
+ if kind ~= "table" then
31
+ local result = Serialize.value(value)
32
+ if typeof(result) == "string" then
33
+ local cap = math.min(2000, math.floor(budget.chars / 6))
34
+ budget.chars -= math.min(#result, cap) * 6
35
+ return if #result > cap then string.sub(result, 1, cap) .. "<truncated>" else result
36
+ end
37
+ return result
38
+ end
39
+
40
+ local source = value :: { [any]: any }
41
+ -- Named separately from the depth cap, because they mean opposite things to
42
+ -- a reader. The depth cap says "there is more below this"; a cycle says
43
+ -- "this is the table you are already inside". Reporting the second as the
44
+ -- first printed `t.self.self.self` as three distinct nested tables, which
45
+ -- reads as real structure that does not exist.
46
+ if visited[source] then
47
+ return "<circular reference>"
48
+ end
49
+ if level >= MAX_TABLE_DEPTH then
50
+ return "<nested table>"
51
+ end
52
+ visited[source] = true
53
+
54
+ local count = 0
55
+ local isArray = true
56
+ for key in source do
57
+ if typeof(key) ~= "number" or key < 1 or key > #source or key % 1 ~= 0 then isArray = false end
58
+ count += 1
59
+ if count > MAX_TABLE_ENTRIES then break end
60
+ end
61
+
62
+ -- Arrays keep their shape so they read as lists on the other side; anything
63
+ -- with non-sequential keys becomes a map with stringified keys.
64
+ if isArray and (count == #source or count > MAX_TABLE_ENTRIES) then
65
+ local list: { any } = {}
66
+ for index, item in ipairs(source) do
67
+ if index > MAX_TABLE_ENTRIES or budget.nodes <= 0 or budget.chars < 100 then
68
+ table.insert(list, "<more entries>")
69
+ break
70
+ end
71
+ table.insert(list, describe(item, level + 1, visited, budget))
72
+ end
73
+ -- Cleared on the way out so the same table appearing twice side by side
74
+ -- still renders twice; only a table containing itself is a cycle.
75
+ visited[source] = nil
76
+ return list
77
+ end
78
+
79
+ local map: { [string]: any } = {}
80
+ local shown = 0
81
+ for key, item in source do
82
+ shown += 1
83
+ if shown > MAX_TABLE_ENTRIES or budget.nodes <= 0 or budget.chars < 100 then
84
+ map["..."] = "<more entries>"
85
+ break
86
+ end
87
+ local name = string.sub(tostring(key), 1, 100)
88
+ budget.chars -= #name * 6
89
+ map[name] = describe(item, level + 1, visited, budget)
90
+ end
91
+ visited[source] = nil
92
+ return map
93
+ end
94
+
95
+ local Runtime = {}
96
+ Runtime.describe = describe
97
+ function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
98
+ -- Captured rather than inferred: a chunk's prints are usually the point, and
99
+ -- reading them back from the log afterwards would also pick up whatever else
100
+ -- the place logged in the meantime.
101
+ local output: { { [string]: any } } = {}
102
+ local outputChars, outputDropped = 0, 0
103
+ local connection = LogService.MessageOut:Connect(function(message, messageType, context)
104
+ if #output < MAX_OUTPUT_LINES and outputChars < 8000 then
105
+ -- A structured log's context rides on the line, as it does in `console`.
106
+ local encoded = LogBuffer.encodeContext(context)
107
+ if encoded then
108
+ message ..= " " .. encoded
109
+ end
110
+ if #message > math.min(2000, 8000 - outputChars) then message = string.sub(message, 1, math.min(2000, 8000 - outputChars)) .. "<truncated>" end
111
+ outputChars += #message
112
+ table.insert(output, {
113
+ message = message,
114
+ level = if messageType == Enum.MessageType.MessageError
115
+ then "error"
116
+ elseif messageType == Enum.MessageType.MessageWarning then "warning"
117
+ elseif messageType == Enum.MessageType.MessageInfo then "info"
118
+ else "print",
119
+ })
120
+ else outputDropped += 1 end
121
+ end)
122
+
123
+ local started = os.clock()
124
+ local results: any = nil
125
+ if timeout then
126
+ local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
127
+ local deadline = started + timeout
128
+ while results == nil and os.clock() < deadline do task.wait() end
129
+ if results == nil then
130
+ task.cancel(worker)
131
+ results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
132
+ end
133
+ else
134
+ results = table.pack(pcall(chunk :: () -> ...any))
135
+ end
136
+ local elapsed = os.clock() - started
137
+
138
+ --[[
139
+ MessageOut is deferred: `print` returns before the event fires, so
140
+ disconnecting straight after the chunk finished captured nothing at all.
141
+ A short settle picks the lines up.
142
+
143
+ It is bounded and stops as soon as the flow stops, rather than waiting a
144
+ fixed period, so a chunk that logged nothing costs almost no time and one
145
+ that logged plenty is not truncated.
146
+ ]]
147
+ local settleDeadline = os.clock() + 0.5
148
+ local seen = #output
149
+ local quietFrames = 0
150
+ while os.clock() < settleDeadline and quietFrames < 3 do
151
+ task.wait()
152
+ if #output == seen then
153
+ quietFrames += 1
154
+ else
155
+ seen = #output
156
+ quietFrames = 0
157
+ end
158
+ end
159
+
160
+ connection:Disconnect()
161
+
162
+ if not results[1] then
163
+ return {
164
+ ok = false,
165
+ error = string.sub(tostring(results[2]), 1, 2000),
166
+ output = output,
167
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
168
+ }
169
+ end
170
+
171
+ local returned: { any } = {}
172
+ local budget: Budget = {nodes = 300, chars = 6000}
173
+ for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
174
+ -- A nil return is written out rather than dropped. `table.insert` of nil
175
+ -- does nothing, so `return a, nil, b` came back as two values and every
176
+ -- one after the nil shifted into the wrong position.
177
+ local value = results[index]
178
+ table.insert(returned, if value == nil then "nil" else describe(value, nil, nil, budget))
179
+ end
180
+
181
+ return {
182
+ ok = true,
183
+ returned = returned,
184
+ outputDropped = if outputDropped > 0 then outputDropped else nil,
185
+ returnsDropped = if results.n > MAX_RETURN_VALUES + 1 then results.n - MAX_RETURN_VALUES - 1 else nil,
186
+ output = output,
187
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
188
+ }
189
+ end
190
+ return Runtime
@@ -41,6 +41,8 @@ local MAX_CONTEXT = 1000
41
41
 
42
42
  export type Entry = {
43
43
  sequence: number?,
44
+ id: number?,
45
+ updated: boolean?,
44
46
  level: string,
45
47
  message: string,
46
48
  timestamp: number,
@@ -101,6 +103,10 @@ local collecting: { string }? = nil
101
103
  local function pushEntry(entry: Entry)
102
104
  nextSequence += 1
103
105
  entry.sequence = nextSequence
106
+ entry.id = nextSequence
107
+ entry.message = string.sub(entry.message, 1, 2000)
108
+ if entry.stack then entry.stack = string.sub(entry.stack, 1, 4000) end
109
+ if entry.source then entry.source = string.sub(entry.source, 1, 500) end
104
110
  table.insert(entries, entry)
105
111
 
106
112
  if #entries > CAPACITY + SLACK then
@@ -133,18 +139,24 @@ function LogBuffer.pushClient(player: Player, payload: any)
133
139
  if payload.kind == "ready" then buffer.ready = true; return end
134
140
  if payload.kind == "trace" then
135
141
  if typeof(payload.message) ~= "string" or typeof(payload.stack) ~= "string" then return end
142
+ local traceMessage = string.sub(payload.message, 1, 2000)
136
143
  for index = #buffer.entries, math.max(1, #buffer.entries - CORRELATION_WINDOW + 1), -1 do
137
144
  local item = buffer.entries[index]
138
- if item.level == "error" and item.message == payload.message and item.stack == nil then
145
+ if item.level == "error" and item.message == string.sub(payload.message, 1, 2000) and item.stack == nil then
139
146
  item.stack = string.sub(payload.stack, 1, 4000)
140
147
  item.source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil
148
+ buffer.sequence += 1
149
+ item.sequence = buffer.sequence
150
+ item.updated = true
151
+ table.remove(buffer.entries, index)
152
+ table.insert(buffer.entries, item)
141
153
  return
142
154
  end
143
155
  end
144
156
  local orphanCount = 0
145
157
  for _ in buffer.orphan do orphanCount += 1 end
146
- if orphanCount < 20 or buffer.orphan[payload.message] ~= nil then
147
- buffer.orphan[payload.message] = { stack = string.sub(payload.stack, 1, 4000), source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil }
158
+ if orphanCount < 20 or buffer.orphan[traceMessage] ~= nil then
159
+ buffer.orphan[traceMessage] = { stack = string.sub(payload.stack, 1, 4000), source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil }
148
160
  end
149
161
  return
150
162
  end
@@ -154,6 +166,7 @@ function LogBuffer.pushClient(player: Player, payload: any)
154
166
  buffer.sequence += 1
155
167
  local entry: Entry = {
156
168
  sequence = buffer.sequence,
169
+ id = buffer.sequence,
157
170
  level = level,
158
171
  message = string.sub(payload.message, 1, 2000),
159
172
  timestamp = if typeof(payload.timestamp) == "number" and payload.timestamp >= 0 and payload.timestamp < 100000000000 then payload.timestamp else os.time(),
@@ -202,17 +215,20 @@ end
202
215
  ]]
203
216
  -- JSONEncode writes a Vector3 or an Instance as `null` without complaint, so
204
217
  -- anything JSON has no shape for is turned into its text first.
205
- local function plain(value: any, depth: number): any
218
+ local function plain(value: any, depth: number, budget: { nodes: number }): any
219
+ budget.nodes -= 1
220
+ if budget.nodes <= 0 then return "<truncated>" end
206
221
  local kind = typeof(value)
207
222
  if kind == "string" or kind == "number" or kind == "boolean" then
208
- return value
223
+ return if kind == "string" then string.sub(value, 1, MAX_CONTEXT) else value
209
224
  elseif kind == "table" then
210
225
  if depth >= 4 then
211
226
  return "<table>"
212
227
  end
213
228
  local copy = {}
214
229
  for key, item in value do
215
- copy[if typeof(key) == "number" then key else tostring(key)] = plain(item, depth + 1)
230
+ if budget.nodes <= 0 then copy["..."] = "<truncated>"; break end
231
+ copy[if typeof(key) == "number" then key else string.sub(tostring(key), 1, 100)] = plain(item, depth + 1, budget)
216
232
  end
217
233
  return copy
218
234
  elseif kind == "Instance" then
@@ -229,15 +245,17 @@ function LogBuffer.encodeContext(context: any): string?
229
245
  return nil
230
246
  end
231
247
  local ok, encoded = pcall(function()
232
- return HttpService:JSONEncode(plain(context, 0))
248
+ return HttpService:JSONEncode(plain(context, 0, {nodes = 50}))
233
249
  end)
234
250
  if not ok then
235
251
  return nil
236
252
  end
237
- return string.sub(encoded :: string, 1, MAX_CONTEXT)
253
+ if #encoded > MAX_CONTEXT then return HttpService:JSONEncode({truncated = string.sub(encoded, 1, 100)}) end
254
+ return encoded
238
255
  end
239
256
 
240
257
  local function push(message: string, messageType: Enum.MessageType, timestamp: number?, context: any?)
258
+ message = string.sub(message, 1, 2000)
241
259
  local level = LEVELS[messageType.Value] or "print"
242
260
  local entry: Entry = {
243
261
  level = level,
@@ -270,12 +288,20 @@ end
270
288
  two identical lines.
271
289
  ]]
272
290
  local function attachStack(message: string, stack: string, source: string?)
291
+ message = string.sub(message, 1, 2000)
292
+ stack = string.sub(stack, 1, 4000)
293
+ if source then source = string.sub(source, 1, 500) end
273
294
  local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
274
295
  for index = #entries, first, -1 do
275
296
  local entry = entries[index]
276
297
  if entry.level == "error" and entry.message == message and entry.stack == nil then
277
298
  entry.stack = stack
278
299
  entry.source = source
300
+ nextSequence += 1
301
+ entry.sequence = nextSequence
302
+ entry.updated = true
303
+ table.remove(entries, index)
304
+ table.insert(entries, entry)
279
305
  return
280
306
  end
281
307
  end
@@ -336,7 +362,10 @@ function LogBuffer.start()
336
362
  local existing = recent.stack
337
363
  local existingFrames = if existing then #string.split(existing, "\n") else 0
338
364
  if existing == nil or #buffered > existingFrames then
339
- recent.stack = table.concat(buffered, "\n")
365
+ recent.stack = string.sub(table.concat(buffered, "\n"), 1, 4000)
366
+ nextSequence += 1
367
+ recent.sequence = nextSequence
368
+ recent.updated = true
340
369
  end
341
370
  end
342
371
  end
@@ -67,6 +67,40 @@ local function childrenByName(parent: Instance, memo: NameIndex?): { [string]: {
67
67
  table.insert(list, child)
68
68
  end
69
69
 
70
+ --[[
71
+ Same-named siblings put into a fixed order.
72
+
73
+ `GetChildren` does not return a stable order (see `stableOrder` in
74
+ Discover), so `Part[2]` could name a different part on the call that
75
+ reads the path back than on the one that printed it. The engine's debug
76
+ id is fixed for an instance's lifetime, which makes it the tiebreak.
77
+
78
+ Guarded, because this module also runs in the client relay, where
79
+ `GetDebugId` is not callable -- there the order is simply the engine's,
80
+ as it always was, rather than an error on every path.
81
+ ]]
82
+ for _, list in byName do
83
+ if #list > 1 then
84
+ local ids: { [Instance]: string } = {}
85
+ local stable = true
86
+ for _, child in list do
87
+ local ok, id = pcall(function()
88
+ return child:GetDebugId()
89
+ end)
90
+ if not ok then
91
+ stable = false
92
+ break
93
+ end
94
+ ids[child] = id
95
+ end
96
+ if stable then
97
+ table.sort(list, function(a: Instance, b: Instance): boolean
98
+ return ids[a] < ids[b]
99
+ end)
100
+ end
101
+ end
102
+ end
103
+
70
104
  if memo then
71
105
  memo[parent] = byName
72
106
  end
@@ -315,6 +349,14 @@ end
315
349
  The index is positional, so it shifts if same-named siblings are inserted or
316
350
  removed between calls. Read a fresh path after any structural change.
317
351
  ]]
352
+ --[[
353
+ The position among same-named siblings, or nil when the name is unique --
354
+ the `n` in `Name[n]`. Exposed for `sync`, which names files after it.
355
+ ]]
356
+ function Paths.ordinal(instance: Instance, memo: NameIndex?): number?
357
+ return siblingIndex(instance, memo)
358
+ end
359
+
318
360
  function Paths.of(instance: Instance, memo: NameIndex?): string
319
361
  --[[
320
362
  Refuses anything that is not an Instance, rather than duck-typing it.
@@ -91,6 +91,42 @@ type Describer = (params: { [string]: any }) -> string
91
91
  playtest is about a mode, a find is about what was searched for.
92
92
  ]]
93
93
  local DESCRIBERS: { [string]: Describer } = {
94
+ ["sync.scan"] = function(params)
95
+ return if params.revisions == false then "Check script layout for sync" else "Scan scripts for sync"
96
+ end,
97
+ ["sync.read"] = function(params)
98
+ return "Read " .. subjectOf(params.paths, "script") .. " for sync"
99
+ end,
100
+ ["sync.apply"] = function(params)
101
+ local total = count(params.writes) + count(params.creates) + count(params.deletes) + count(params.moves)
102
+ return string.format("Sync %d change%s from files", total, if total == 1 then "" else "s")
103
+ end,
104
+ ["sync.shape"] = function()
105
+ return "Check script layout for sync"
106
+ end,
107
+ ["sync.revisions"] = function()
108
+ return "Check script revisions"
109
+ end,
110
+ ["sync.changes"] = function()
111
+ return "Check for script changes"
112
+ end,
113
+ ["sync.stop"] = function()
114
+ return "Stop sync tracking"
115
+ end,
116
+ ["sync.log"] = function()
117
+ return "Sync note"
118
+ end,
119
+ ["sync.classes"] = function(params)
120
+ return "List classes in " .. (leaf(params.path) or "tree")
121
+ end,
122
+ ["sync.export"] = function(params)
123
+ return "Export " .. (leaf(params.path) or "tree") .. " to a build file"
124
+ end,
125
+ ["sync.build"] = function(params)
126
+ local spec = params.spec
127
+ local name = if typeof(spec) == "table" then spec.name or spec.className else nil
128
+ return "Build " .. tostring(name or "tree") .. " from a build file"
129
+ end,
94
130
  ["script.read"] = function(params)
95
131
  return "Read " .. subjectOf(params.paths, "script")
96
132
  end,
@@ -756,6 +792,8 @@ local KINDS: { [string]: string } = {
756
792
  terrain = "write",
757
793
  generate = "write",
758
794
  data = "write",
795
+ -- Applies files to the place; the reads inside the group are carved out below.
796
+ sync = "write",
759
797
  }
760
798
 
761
799
  function Phrase.kindOf(op: string): string
@@ -770,6 +808,9 @@ function Phrase.kindOf(op: string): string
770
808
  if op == "audio.inspect" then
771
809
  return "read"
772
810
  end
811
+ if op == "sync.scan" or op == "sync.shape" or op == "sync.revisions" or op == "sync.read" or op == "sync.changes" or op == "sync.classes" or op == "sync.export" then
812
+ return "read"
813
+ end
773
814
  return KINDS[group] or "read"
774
815
  end
775
816
 
@@ -162,6 +162,10 @@ type Runtime = {
162
162
  -- back returns it rather than an empty line.
163
163
  draft: string,
164
164
  busy: boolean,
165
+ -- Whether a line that is not a command goes to an agent. Off until the user
166
+ -- runs `chat on`; only used to word the placeholder, since `Commands` is what
167
+ -- actually refuses the line.
168
+ chat: boolean,
165
169
  --[[
166
170
  What is selected in Studio right now, already worded for display.
167
171
 
@@ -195,6 +199,7 @@ local runtime: Runtime = {
195
199
  offset = 0,
196
200
  draft = "",
197
201
  busy = false,
202
+ chat = false,
198
203
  selection = "",
199
204
  complete = nil,
200
205
  suggest = nil,
@@ -218,9 +223,12 @@ local function paintCaret()
218
223
  end
219
224
 
220
225
  local function placeholder(): string
221
- return if runtime.busy
222
- then "running -- type stop to cancel"
223
- else "type a command, or ask for something"
226
+ if runtime.busy then
227
+ return "running -- type stop to cancel"
228
+ end
229
+ return if runtime.chat
230
+ then "type a command, or ask for something"
231
+ else "type a command -- `chat on` lets an agent answer"
224
232
  end
225
233
 
226
234
  --[[
@@ -903,6 +911,18 @@ function Prompt.setBusy(busy: boolean)
903
911
  end
904
912
  end
905
913
 
914
+ --[[
915
+ Reports whether sentences are answered, so the placeholder does not invite
916
+ a request the console is about to decline.
917
+ ]]
918
+ function Prompt.setChat(enabled: boolean)
919
+ runtime.chat = enabled
920
+ local field = runtime.field
921
+ if field ~= nil then
922
+ field.PlaceholderText = placeholder()
923
+ end
924
+ end
925
+
906
926
  --[[
907
927
  Reports what Studio has selected, as a phrase rather than a list.
908
928