@el4cteo/rbx-studio-mcp 0.7.7 → 0.8.0

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 (59) hide show
  1. package/README.md +175 -175
  2. package/dist/bridge/harness.js +14 -5
  3. package/dist/bridge/harness.js.map +1 -1
  4. package/dist/index.js +5 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +38 -23
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/format.js +43 -4
  9. package/dist/lib/format.js.map +1 -1
  10. package/dist/lib/pluginbuild.js +10 -0
  11. package/dist/lib/pluginbuild.js.map +1 -1
  12. package/dist/lib/png.js +104 -0
  13. package/dist/lib/png.js.map +1 -1
  14. package/dist/resources.js +3 -2
  15. package/dist/resources.js.map +1 -1
  16. package/dist/tools/api.js +4 -3
  17. package/dist/tools/api.js.map +1 -1
  18. package/dist/tools/debug.js +2 -2
  19. package/dist/tools/debug.js.map +1 -1
  20. package/dist/tools/device.js +2 -2
  21. package/dist/tools/device.js.map +1 -1
  22. package/dist/tools/discover.js +7 -3
  23. package/dist/tools/discover.js.map +1 -1
  24. package/dist/tools/exec.js +4 -4
  25. package/dist/tools/exec.js.map +1 -1
  26. package/dist/tools/instances.js +32 -7
  27. package/dist/tools/instances.js.map +1 -1
  28. package/dist/tools/perf.js +35 -13
  29. package/dist/tools/perf.js.map +1 -1
  30. package/dist/tools/playtest.js +34 -13
  31. package/dist/tools/playtest.js.map +1 -1
  32. package/dist/tools/screenshot.js +55 -11
  33. package/dist/tools/screenshot.js.map +1 -1
  34. package/dist/tools/scripts.js +4 -4
  35. package/dist/tools/scripts.js.map +1 -1
  36. package/dist/tools/terrain.js +3 -3
  37. package/dist/tools/terrain.js.map +1 -1
  38. package/dist/tools/world.js +8 -8
  39. package/dist/tools/world.js.map +1 -1
  40. package/package.json +75 -75
  41. package/plugin/src/ClientRelay.luau +128 -1
  42. package/plugin/src/Config.luau +1 -1
  43. package/plugin/src/LogBuffer.luau +363 -277
  44. package/plugin/src/Paths.luau +15 -4
  45. package/plugin/src/Phrase.luau +1 -20
  46. package/plugin/src/ScriptEdit.luau +14 -0
  47. package/plugin/src/Serialize.luau +17 -0
  48. package/plugin/src/TextEdit.luau +6 -0
  49. package/plugin/src/handlers/Api.luau +8 -0
  50. package/plugin/src/handlers/Capture.luau +219 -3
  51. package/plugin/src/handlers/Perf.luau +913 -889
  52. package/plugin/src/handlers/Playtest.luau +6 -1
  53. package/plugin/src/init.server.luau +3 -2
  54. package/scripts/test-live.mjs +106 -1
  55. package/scripts/test-plugin.mjs +2 -0
  56. package/scripts/test-tools.mjs +259 -162
  57. package/dist/tools/anim.js +0 -159
  58. package/dist/tools/anim.js.map +0 -1
  59. package/plugin/src/handlers/Anim.luau +0 -897
@@ -1,277 +1,363 @@
1
- --!strict
2
- --[[
3
- The output log, kept ourselves.
4
-
5
- `LogService:GetLogHistory()` looks like the right API and cannot be relied on.
6
- Measured: in an editor session it returns a handful of lines from around
7
- startup and then stops growing -- prints made later never appear -- and in a
8
- playtest server's DataModel it succeeds and returns nothing at all, even
9
- immediately after something printed there. The call reports no error either
10
- way, so the tool built on it answered "the log is empty" for a place that was
11
- logging steadily, which is worse than answering nothing.
12
-
13
- `LogService.MessageOut` does fire reliably in both. So the buffer is filled
14
- from the event, seeded once from whatever history the API does offer, and kept
15
- from growing without bound.
16
-
17
- This has to be connected at plugin load rather than when a read arrives:
18
- anything logged before the first subscription is gone for good.
19
- ]]
20
-
21
- local LogService = game:GetService("LogService")
22
- local ScriptContext = game:GetService("ScriptContext")
23
-
24
- -- Roughly what the Output window keeps. Big enough that a playtest's worth of
25
- -- logging survives, small enough to stay cheap to hold and to scan.
26
- local CAPACITY = 2000
27
-
28
- -- Trimming in batches rather than one entry per insert: a chunk that logs in a
29
- -- loop would otherwise shift the whole array on every single line.
30
- local SLACK = 500
31
-
32
- -- How far back an arriving stack trace will look for the error line it belongs
33
- -- to. The two events fire within moments of each other, so anything beyond a
34
- -- handful of entries is a mismatch rather than a late delivery.
35
- local CORRELATION_WINDOW = 20
36
-
37
- export type Entry = {
38
- level: string,
39
- message: string,
40
- timestamp: number,
41
- -- Present on errors once the matching stack trace has been correlated.
42
- stack: string?,
43
- source: string?,
44
- }
45
-
46
- local LEVELS: { [number]: string } = {
47
- [Enum.MessageType.MessageOutput.Value] = "print",
48
- [Enum.MessageType.MessageInfo.Value] = "info",
49
- [Enum.MessageType.MessageWarning.Value] = "warning",
50
- [Enum.MessageType.MessageError.Value] = "error",
51
- }
52
-
53
- local LogBuffer = {}
54
-
55
- local entries: { Entry } = {}
56
- local dropped = 0
57
- local started = false
58
- local startedAt = 0
59
-
60
- --[[
61
- Stack traces that arrived before the error line they describe.
62
-
63
- `ScriptContext.Error` and `LogService.MessageOut` are independent events for
64
- the same failure and there is no guaranteed order between them, so the trace
65
- is held here until its line shows up rather than being dropped for arriving
66
- first.
67
- ]]
68
- local orphanedStacks: { [string]: { stack: string, source: string? } } = {}
69
- local orphanCount = 0
70
-
71
- --[[
72
- The engine also writes each trace to the log as plain lines -- "Stack Begin",
73
- one "Script 'X', Line N" per frame, then "Stack End" -- which is how the
74
- Output window renders it. Having attached the trace to its error already,
75
- echoing those lines as ordinary entries is duplication that reads like
76
- unrelated output sitting under the failure.
77
-
78
- They are collected instead of stored, and serve as the fallback source of a
79
- trace when ScriptContext.Error is unavailable.
80
- ]]
81
- local STACK_OPEN = "Stack Begin"
82
- local STACK_CLOSE = "Stack End"
83
-
84
- -- If a close never arrives -- a place that prints "Stack Begin" itself -- the
85
- -- collected lines are released rather than swallowed indefinitely.
86
- local MAX_STACK_LINES = 60
87
-
88
- local collecting: { string }? = nil
89
-
90
- local function pushEntry(entry: Entry)
91
- table.insert(entries, entry)
92
-
93
- if #entries > CAPACITY + SLACK then
94
- local excess = #entries - CAPACITY
95
- entries = table.move(entries, excess + 1, #entries, 1, {})
96
- dropped += excess
97
- end
98
- end
99
-
100
- local function push(message: string, messageType: Enum.MessageType, timestamp: number?)
101
- local level = LEVELS[messageType.Value] or "print"
102
- local entry: Entry = {
103
- level = level,
104
- message = message,
105
- timestamp = timestamp or os.time(),
106
- }
107
-
108
- -- Claim a trace that beat its own error line here.
109
- if level == "error" then
110
- local waiting = orphanedStacks[message]
111
- if waiting then
112
- entry.stack = waiting.stack
113
- entry.source = waiting.source
114
- orphanedStacks[message] = nil
115
- orphanCount -= 1
116
- end
117
- end
118
-
119
- pushEntry(entry)
120
- end
121
-
122
- --[[
123
- Attaches a stack trace to the error it belongs to.
124
-
125
- Correlated by message text because the engine offers nothing better: the two
126
- events carry no shared identifier. Matching the most recent unattached error
127
- with the same text is right in every case that matters, and the worst outcome
128
- if two identical errors interleave is that a trace lands on the wrong one of
129
- two identical lines.
130
- ]]
131
- local function attachStack(message: string, stack: string, source: string?)
132
- local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
133
- for index = #entries, first, -1 do
134
- local entry = entries[index]
135
- if entry.level == "error" and entry.message == message and entry.stack == nil then
136
- entry.stack = stack
137
- entry.source = source
138
- return
139
- end
140
- end
141
-
142
- -- Not logged yet. Held for the line to claim, with a cap so a flood of
143
- -- errors that never surface cannot grow this without bound.
144
- if orphanCount < CAPACITY then
145
- if orphanedStacks[message] == nil then
146
- orphanCount += 1
147
- end
148
- orphanedStacks[message] = { stack = stack, source = source }
149
- end
150
- end
151
-
152
- --[[
153
- Begins recording. Safe to call more than once; only the first call connects,
154
- so a reload cannot end up with two subscriptions writing every line twice.
155
- ]]
156
- function LogBuffer.start()
157
- if started then
158
- return
159
- end
160
- started = true
161
- startedAt = os.time()
162
-
163
- -- Seeded before subscribing so the backlog stays in order ahead of live
164
- -- lines. MessageOut only fires for messages after the connection, so nothing
165
- -- is counted twice.
166
- local ok, history = pcall(function()
167
- return LogService:GetLogHistory()
168
- end)
169
- if ok and typeof(history) == "table" then
170
- for _, item in history :: { { [string]: any } } do
171
- local kind = item.messageType
172
- if item.message ~= nil and typeof(kind) == "EnumItem" then
173
- push(tostring(item.message), kind :: Enum.MessageType, item.timestamp)
174
- end
175
- end
176
- end
177
-
178
- LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
179
- local buffered = collecting
180
-
181
- if buffered then
182
- if message == STACK_CLOSE then
183
- collecting = nil
184
- --[[
185
- ScriptContext.Error is preferred -- it names the originating
186
- instance too -- but not blindly. Measured on a nested failure
187
- the two agree exactly, one frame each, so folding loses
188
- nothing today. They are still compared by frame count rather
189
- than trusted to stay equal, because the cost of being wrong
190
- is a silently shallower trace and the check is one compare.
191
- ]]
192
- if #buffered > 0 then
193
- local recent = entries[#entries]
194
- if recent and recent.level == "error" then
195
- local existing = recent.stack
196
- local existingFrames = if existing then #string.split(existing, "\n") else 0
197
- if existing == nil or #buffered > existingFrames then
198
- recent.stack = table.concat(buffered, "\n")
199
- end
200
- end
201
- end
202
- return
203
- end
204
-
205
- if #buffered < MAX_STACK_LINES then
206
- table.insert(buffered, message)
207
- return
208
- end
209
-
210
- -- Not a stack after all. Release what was held, in order, and carry
211
- -- on treating this as ordinary output.
212
- collecting = nil
213
- pushEntry({ level = "print", message = STACK_OPEN, timestamp = os.time() })
214
- for _, held in buffered do
215
- pushEntry({ level = "print", message = held, timestamp = os.time() })
216
- end
217
- elseif message == STACK_OPEN then
218
- collecting = {}
219
- return
220
- end
221
-
222
- push(message, messageType)
223
- end)
224
-
225
- --[[
226
- Stack traces, which MessageOut does not carry.
227
-
228
- Without this an error in the log is a message and nothing else, while the
229
- trace arrives separately as loose "Stack Begin / Script X, Line N / Stack
230
- End" prints that read as unrelated output. Attaching it to the error turns
231
- three ambiguous lines into one answer with a script and a line number.
232
-
233
- `ErrorDetailed` would be the richer source and is closed to plugins --
234
- it needs the RobloxScript capability. `Error` is reachable and carries
235
- message, trace and originating script, which is the part that matters.
236
- ]]
237
- pcall(function()
238
- ScriptContext.Error:Connect(function(message: string, stack: string, source: Instance?)
239
- local origin: string? = nil
240
- if typeof(source) == "Instance" then
241
- local named = pcall(function()
242
- origin = source:GetFullName()
243
- end)
244
- if not named then
245
- origin = source.Name
246
- end
247
- end
248
- attachStack(message, stack, origin)
249
- end)
250
- end)
251
- end
252
-
253
- --[[
254
- Everything held, oldest first, plus how much fell off the front. Returned as a
255
- copy so a caller filtering it cannot disturb the buffer.
256
- ]]
257
- function LogBuffer.all(): ({ Entry }, number)
258
- return table.move(entries, 1, #entries, 1, {}), dropped
259
- end
260
-
261
- --[[
262
- How long this buffer has been recording, in seconds.
263
-
264
- An empty log is ambiguous without it. In a playtest the plugin loads at the
265
- same moment as the place's own scripts, so a script that logs on startup can
266
- beat the subscription -- and "nothing was logged" then looks identical to
267
- "recording began after the thing you are asking about". Reporting the window
268
- lets the two be told apart instead of guessed at.
269
- ]]
270
- function LogBuffer.recordingSeconds(): number
271
- if not started then
272
- return 0
273
- end
274
- return math.max(0, os.time() - startedAt)
275
- end
276
-
277
- return LogBuffer
1
+ --!strict
2
+ --[[
3
+ The output log, kept ourselves.
4
+
5
+ `LogService:GetLogHistory()` looks like the right API and cannot be relied on.
6
+ Measured: in an editor session it returns a handful of lines from around
7
+ startup and then stops growing -- prints made later never appear -- and in a
8
+ playtest server's DataModel it succeeds and returns nothing at all, even
9
+ immediately after something printed there. The call reports no error either
10
+ way, so the tool built on it answered "the log is empty" for a place that was
11
+ logging steadily, which is worse than answering nothing.
12
+
13
+ `LogService.MessageOut` does fire reliably in both. So the buffer is filled
14
+ from the event, seeded once from whatever history the API does offer, and kept
15
+ from growing without bound.
16
+
17
+ This has to be connected at plugin load rather than when a read arrives:
18
+ anything logged before the first subscription is gone for good.
19
+ ]]
20
+
21
+ local LogService = game:GetService("LogService")
22
+ local ScriptContext = game:GetService("ScriptContext")
23
+ local HttpService = game:GetService("HttpService")
24
+
25
+ -- Roughly what the Output window keeps. Big enough that a playtest's worth of
26
+ -- logging survives, small enough to stay cheap to hold and to scan.
27
+ local CAPACITY = 2000
28
+
29
+ -- Trimming in batches rather than one entry per insert: a chunk that logs in a
30
+ -- loop would otherwise shift the whole array on every single line.
31
+ local SLACK = 500
32
+
33
+ -- How far back an arriving stack trace will look for the error line it belongs
34
+ -- to. The two events fire within moments of each other, so anything beyond a
35
+ -- handful of entries is a mismatch rather than a late delivery.
36
+ local CORRELATION_WINDOW = 20
37
+
38
+ export type Entry = {
39
+ sequence: number?,
40
+ level: string,
41
+ message: string,
42
+ timestamp: number,
43
+ -- Present on errors once the matching stack trace has been correlated.
44
+ stack: string?,
45
+ source: string?,
46
+ }
47
+
48
+ local LEVELS: { [number]: string } = {
49
+ [Enum.MessageType.MessageOutput.Value] = "print",
50
+ [Enum.MessageType.MessageInfo.Value] = "info",
51
+ [Enum.MessageType.MessageWarning.Value] = "warning",
52
+ [Enum.MessageType.MessageError.Value] = "error",
53
+ }
54
+
55
+ local LogBuffer = {}
56
+
57
+ local entries: { Entry } = {}
58
+ local dropped = 0
59
+ local started = false
60
+ local startedAt = 0
61
+ local studioToken = HttpService:GenerateGUID(false)
62
+ local nextSequence = 0
63
+ local clientBuffers: { [Player]: { token: string, entries: { Entry }, dropped: number, sequence: number, startedAt: number, ready: boolean, orphan: { [string]: { stack: string, source: string? } } } } = {}
64
+
65
+ --[[
66
+ Stack traces that arrived before the error line they describe.
67
+
68
+ `ScriptContext.Error` and `LogService.MessageOut` are independent events for
69
+ the same failure and there is no guaranteed order between them, so the trace
70
+ is held here until its line shows up rather than being dropped for arriving
71
+ first.
72
+ ]]
73
+ local orphanedStacks: { [string]: { stack: string, source: string? } } = {}
74
+ local orphanCount = 0
75
+
76
+ --[[
77
+ The engine also writes each trace to the log as plain lines -- "Stack Begin",
78
+ one "Script 'X', Line N" per frame, then "Stack End" -- which is how the
79
+ Output window renders it. Having attached the trace to its error already,
80
+ echoing those lines as ordinary entries is duplication that reads like
81
+ unrelated output sitting under the failure.
82
+
83
+ They are collected instead of stored, and serve as the fallback source of a
84
+ trace when ScriptContext.Error is unavailable.
85
+ ]]
86
+ local STACK_OPEN = "Stack Begin"
87
+ local STACK_CLOSE = "Stack End"
88
+
89
+ -- If a close never arrives -- a place that prints "Stack Begin" itself -- the
90
+ -- collected lines are released rather than swallowed indefinitely.
91
+ local MAX_STACK_LINES = 60
92
+
93
+ local collecting: { string }? = nil
94
+
95
+ local function pushEntry(entry: Entry)
96
+ nextSequence += 1
97
+ entry.sequence = nextSequence
98
+ table.insert(entries, entry)
99
+
100
+ if #entries > CAPACITY + SLACK then
101
+ local excess = #entries - CAPACITY
102
+ entries = table.move(entries, excess + 1, #entries, 1, {})
103
+ dropped += excess
104
+ end
105
+ end
106
+
107
+ -- Each player gets a separate stream and token. Rejoining, reconnecting to a
108
+ -- different server DataModel, or a plugin reload invalidates old cursors.
109
+ function LogBuffer.startClient(player: Player)
110
+ -- A relay reinstalled after a respawn is the same player in the same session:
111
+ -- keep its log and cursor. Only a player that left (stopClient) starts fresh.
112
+ local existing = clientBuffers[player]
113
+ if existing then
114
+ existing.ready = false
115
+ return
116
+ end
117
+ clientBuffers[player] = { token = HttpService:GenerateGUID(false), entries = {}, dropped = 0, sequence = 0, startedAt = os.time(), ready = false, orphan = {} }
118
+ end
119
+
120
+ function LogBuffer.stopClient(player: Player)
121
+ clientBuffers[player] = nil
122
+ end
123
+
124
+ function LogBuffer.pushClient(player: Player, payload: any)
125
+ local buffer = clientBuffers[player]
126
+ if not buffer or typeof(payload) ~= "table" then return end
127
+ if payload.kind == "ready" then buffer.ready = true; return end
128
+ if payload.kind == "trace" then
129
+ if typeof(payload.message) ~= "string" or typeof(payload.stack) ~= "string" then return end
130
+ for index = #buffer.entries, math.max(1, #buffer.entries - CORRELATION_WINDOW + 1), -1 do
131
+ local item = buffer.entries[index]
132
+ if item.level == "error" and item.message == payload.message and item.stack == nil then
133
+ item.stack = string.sub(payload.stack, 1, 4000)
134
+ item.source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil
135
+ return
136
+ end
137
+ end
138
+ local orphanCount = 0
139
+ for _ in buffer.orphan do orphanCount += 1 end
140
+ if orphanCount < 20 or buffer.orphan[payload.message] ~= nil then
141
+ 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 }
142
+ end
143
+ return
144
+ end
145
+ local level = payload.level
146
+ if level ~= "print" and level ~= "info" and level ~= "warning" and level ~= "error" then return end
147
+ if typeof(payload.message) ~= "string" then return end
148
+ buffer.sequence += 1
149
+ local entry: Entry = {
150
+ sequence = buffer.sequence,
151
+ level = level,
152
+ message = string.sub(payload.message, 1, 2000),
153
+ timestamp = if typeof(payload.timestamp) == "number" and payload.timestamp >= 0 and payload.timestamp < 100000000000 then payload.timestamp else os.time(),
154
+ stack = if typeof(payload.stack) == "string" then string.sub(payload.stack, 1, 4000) else nil,
155
+ source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil,
156
+ }
157
+ local waiting = buffer.orphan[entry.message]
158
+ if level == "error" and waiting then
159
+ entry.stack, entry.source = waiting.stack, waiting.source
160
+ buffer.orphan[entry.message] = nil
161
+ end
162
+ table.insert(buffer.entries, entry)
163
+ if #buffer.entries > CAPACITY + SLACK then
164
+ local excess = #buffer.entries - CAPACITY
165
+ buffer.entries = table.move(buffer.entries, excess + 1, #buffer.entries, 1, {})
166
+ buffer.dropped += excess
167
+ end
168
+ end
169
+
170
+ function LogBuffer.clientReady(player: Player): boolean
171
+ local buffer = clientBuffers[player]
172
+ return buffer ~= nil and buffer.ready == true
173
+ end
174
+
175
+ function LogBuffer.stream(player: Player?): ({ Entry }, number, string, number, number)
176
+ if player then
177
+ local buffer = clientBuffers[player]
178
+ if buffer then
179
+ return table.move(buffer.entries, 1, #buffer.entries, 1, {}), buffer.dropped, buffer.token, buffer.sequence, math.max(0, os.time() - buffer.startedAt)
180
+ end
181
+ return {}, 0, "", 0, 0
182
+ end
183
+ return table.move(entries, 1, #entries, 1, {}), dropped, studioToken, nextSequence, LogBuffer.recordingSeconds()
184
+ end
185
+
186
+ local function push(message: string, messageType: Enum.MessageType, timestamp: number?)
187
+ local level = LEVELS[messageType.Value] or "print"
188
+ local entry: Entry = {
189
+ level = level,
190
+ message = message,
191
+ timestamp = timestamp or os.time(),
192
+ }
193
+
194
+ -- Claim a trace that beat its own error line here.
195
+ if level == "error" then
196
+ local waiting = orphanedStacks[message]
197
+ if waiting then
198
+ entry.stack = waiting.stack
199
+ entry.source = waiting.source
200
+ orphanedStacks[message] = nil
201
+ orphanCount -= 1
202
+ end
203
+ end
204
+
205
+ pushEntry(entry)
206
+ end
207
+
208
+ --[[
209
+ Attaches a stack trace to the error it belongs to.
210
+
211
+ Correlated by message text because the engine offers nothing better: the two
212
+ events carry no shared identifier. Matching the most recent unattached error
213
+ with the same text is right in every case that matters, and the worst outcome
214
+ if two identical errors interleave is that a trace lands on the wrong one of
215
+ two identical lines.
216
+ ]]
217
+ local function attachStack(message: string, stack: string, source: string?)
218
+ local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
219
+ for index = #entries, first, -1 do
220
+ local entry = entries[index]
221
+ if entry.level == "error" and entry.message == message and entry.stack == nil then
222
+ entry.stack = stack
223
+ entry.source = source
224
+ return
225
+ end
226
+ end
227
+
228
+ -- Not logged yet. Held for the line to claim, with a cap so a flood of
229
+ -- errors that never surface cannot grow this without bound.
230
+ if orphanCount < CAPACITY then
231
+ if orphanedStacks[message] == nil then
232
+ orphanCount += 1
233
+ end
234
+ orphanedStacks[message] = { stack = stack, source = source }
235
+ end
236
+ end
237
+
238
+ --[[
239
+ Begins recording. Safe to call more than once; only the first call connects,
240
+ so a reload cannot end up with two subscriptions writing every line twice.
241
+ ]]
242
+ function LogBuffer.start()
243
+ if started then
244
+ return
245
+ end
246
+ started = true
247
+ startedAt = os.time()
248
+
249
+ -- Seeded before subscribing so the backlog stays in order ahead of live
250
+ -- lines. MessageOut only fires for messages after the connection, so nothing
251
+ -- is counted twice.
252
+ local ok, history = pcall(function()
253
+ return LogService:GetLogHistory()
254
+ end)
255
+ if ok and typeof(history) == "table" then
256
+ for _, item in history :: { { [string]: any } } do
257
+ local kind = item.messageType
258
+ if item.message ~= nil and typeof(kind) == "EnumItem" then
259
+ push(tostring(item.message), kind :: Enum.MessageType, item.timestamp)
260
+ end
261
+ end
262
+ end
263
+
264
+ LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
265
+ local buffered = collecting
266
+
267
+ if buffered then
268
+ if message == STACK_CLOSE then
269
+ collecting = nil
270
+ --[[
271
+ ScriptContext.Error is preferred -- it names the originating
272
+ instance too -- but not blindly. Measured on a nested failure
273
+ the two agree exactly, one frame each, so folding loses
274
+ nothing today. They are still compared by frame count rather
275
+ than trusted to stay equal, because the cost of being wrong
276
+ is a silently shallower trace and the check is one compare.
277
+ ]]
278
+ if #buffered > 0 then
279
+ local recent = entries[#entries]
280
+ if recent and recent.level == "error" then
281
+ local existing = recent.stack
282
+ local existingFrames = if existing then #string.split(existing, "\n") else 0
283
+ if existing == nil or #buffered > existingFrames then
284
+ recent.stack = table.concat(buffered, "\n")
285
+ end
286
+ end
287
+ end
288
+ return
289
+ end
290
+
291
+ if #buffered < MAX_STACK_LINES then
292
+ table.insert(buffered, message)
293
+ return
294
+ end
295
+
296
+ -- Not a stack after all. Release what was held, in order, and carry
297
+ -- on treating this as ordinary output.
298
+ collecting = nil
299
+ pushEntry({ level = "print", message = STACK_OPEN, timestamp = os.time() })
300
+ for _, held in buffered do
301
+ pushEntry({ level = "print", message = held, timestamp = os.time() })
302
+ end
303
+ elseif message == STACK_OPEN then
304
+ collecting = {}
305
+ return
306
+ end
307
+
308
+ push(message, messageType)
309
+ end)
310
+
311
+ --[[
312
+ Stack traces, which MessageOut does not carry.
313
+
314
+ Without this an error in the log is a message and nothing else, while the
315
+ trace arrives separately as loose "Stack Begin / Script X, Line N / Stack
316
+ End" prints that read as unrelated output. Attaching it to the error turns
317
+ three ambiguous lines into one answer with a script and a line number.
318
+
319
+ `ErrorDetailed` would be the richer source and is closed to plugins --
320
+ it needs the RobloxScript capability. `Error` is reachable and carries
321
+ message, trace and originating script, which is the part that matters.
322
+ ]]
323
+ pcall(function()
324
+ ScriptContext.Error:Connect(function(message: string, stack: string, source: Instance?)
325
+ local origin: string? = nil
326
+ if typeof(source) == "Instance" then
327
+ local named = pcall(function()
328
+ origin = source:GetFullName()
329
+ end)
330
+ if not named then
331
+ origin = source.Name
332
+ end
333
+ end
334
+ attachStack(message, stack, origin)
335
+ end)
336
+ end)
337
+ end
338
+
339
+ --[[
340
+ Everything held, oldest first, plus how much fell off the front. Returned as a
341
+ copy so a caller filtering it cannot disturb the buffer.
342
+ ]]
343
+ function LogBuffer.all(): ({ Entry }, number)
344
+ return table.move(entries, 1, #entries, 1, {}), dropped
345
+ end
346
+
347
+ --[[
348
+ How long this buffer has been recording, in seconds.
349
+
350
+ An empty log is ambiguous without it. In a playtest the plugin loads at the
351
+ same moment as the place's own scripts, so a script that logs on startup can
352
+ beat the subscription -- and "nothing was logged" then looks identical to
353
+ "recording began after the thing you are asking about". Reporting the window
354
+ lets the two be told apart instead of guessed at.
355
+ ]]
356
+ function LogBuffer.recordingSeconds(): number
357
+ if not started then
358
+ return 0
359
+ end
360
+ return math.max(0, os.time() - startedAt)
361
+ end
362
+
363
+ return LogBuffer