@el4cteo/rbx-studio-mcp 0.1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,90 @@
1
+ --!strict
2
+ --[[
3
+ Handler registry and the single error boundary around every command.
4
+
5
+ Handlers raise failures with `Dispatch.fail(code, message, hint)`. The hint is
6
+ forwarded verbatim to the agent, so it should say what to do next ("call find
7
+ first to get a valid path"), not just what went wrong.
8
+ ]]
9
+
10
+ local Dispatch = {}
11
+
12
+ export type Error = {
13
+ code: string,
14
+ message: string,
15
+ hint: string?,
16
+ }
17
+
18
+ export type Handler = (params: { [string]: any }) -> any
19
+
20
+ local handlers: { [string]: Handler } = {}
21
+
22
+ function Dispatch.register(op: string, handler: Handler)
23
+ handlers[op] = handler
24
+ end
25
+
26
+ --[[
27
+ Registers every entry of `map` under the prefix, e.g. `registerAll("script",
28
+ { read = fn })` exposes "script.read".
29
+ ]]
30
+ function Dispatch.registerAll(prefix: string, map: { [string]: Handler })
31
+ for name, handler in map do
32
+ handlers[prefix .. "." .. name] = handler
33
+ end
34
+ end
35
+
36
+ --[[
37
+ Raises a structured failure. Always call this rather than `error("...")` so
38
+ the agent receives a code it can branch on and a hint it can act on.
39
+ ]]
40
+ function Dispatch.fail(code: string, message: string, hint: string?): never
41
+ error({ code = code, message = message, hint = hint }, 0)
42
+ end
43
+
44
+ export type Result = {
45
+ id: string,
46
+ ok: boolean,
47
+ data: any?,
48
+ error: Error?,
49
+ }
50
+
51
+ --[[
52
+ Runs one command and returns the frame to POST back. Never throws: an
53
+ unhandled error inside a handler becomes a HANDLER_ERROR result, because a
54
+ crashed dispatch would leave the server's promise hanging until it times out.
55
+ ]]
56
+ function Dispatch.invoke(id: string, op: string, params: { [string]: any }?): Result
57
+ local handler = handlers[op]
58
+ if not handler then
59
+ return {
60
+ id = id,
61
+ ok = false,
62
+ error = {
63
+ code = "UNKNOWN_OP",
64
+ message = string.format('This Studio plugin has no handler for "%s".', op),
65
+ hint = "The plugin is older than the MCP server. Update the Studio MCP "
66
+ .. "plugin to the version matching your server.",
67
+ },
68
+ }
69
+ end
70
+
71
+ local ok, result = pcall(handler, params or {})
72
+ if ok then
73
+ return { id = id, ok = true, data = result }
74
+ end
75
+
76
+ if typeof(result) == "table" and (result :: any).code then
77
+ return { id = id, ok = false, error = result :: Error }
78
+ end
79
+
80
+ return {
81
+ id = id,
82
+ ok = false,
83
+ error = {
84
+ code = "HANDLER_ERROR",
85
+ message = string.format('"%s" failed: %s', op, tostring(result)),
86
+ },
87
+ }
88
+ end
89
+
90
+ return Dispatch
@@ -0,0 +1,142 @@
1
+ --!strict
2
+ --[[
3
+ What the user is currently looking at in the script editor.
4
+
5
+ `ScriptEditorService` is normally used only to read and write source. The
6
+ document layer underneath it -- open tabs, cursor position, selected text,
7
+ which lines are on screen -- is left untouched by every other Studio MCP
8
+ server, and it answers the question an agent most often has to guess at: when
9
+ the user says "fix this function", which one do they mean.
10
+
11
+ Everything here is read-only and defensive. A ScriptDocument can close between
12
+ being listed and being read, and the accessors throw rather than return nil
13
+ when that happens, so each one is wrapped.
14
+ ]]
15
+
16
+ local ScriptEditorService = game:GetService("ScriptEditorService")
17
+
18
+ local Paths = require(script.Parent.Paths)
19
+
20
+ -- Selected text is included so the agent can act on it without a second call,
21
+ -- but a user can select an entire file. Past this it is summarised instead.
22
+ local MAX_SELECTED_TEXT = 400
23
+
24
+ local Editor = {}
25
+
26
+ export type Document = { [string]: any }
27
+
28
+ --[[
29
+ Reads an accessor, yielding nil rather than raising when the document closed
30
+ underneath us. Values come back boxed in a table so a multi-value accessor
31
+ survives the pcall boundary intact.
32
+ ]]
33
+ local function safe(accessor: () -> any): any
34
+ local ok, value = pcall(accessor)
35
+ if ok then
36
+ return value
37
+ end
38
+ return nil
39
+ end
40
+
41
+ local function describe(document: ScriptDocument): Document?
42
+ -- The command bar is a ScriptDocument too, and it is always open. Reporting
43
+ -- it would put a line of noise in every studio_status -- the call agents make
44
+ -- most -- for something that has no backing script and cannot be edited.
45
+ local isCommandBar = safe(function()
46
+ return document:IsCommandBar()
47
+ end)
48
+ if isCommandBar ~= false then
49
+ return nil
50
+ end
51
+
52
+ local found = safe(function()
53
+ return document:GetScript()
54
+ end)
55
+ if typeof(found) ~= "Instance" then
56
+ return nil
57
+ end
58
+
59
+ local target = found :: Instance
60
+ local entry: Document = {
61
+ path = Paths.of(target),
62
+ className = target.ClassName,
63
+ }
64
+
65
+ entry.lineCount = safe(function()
66
+ return document:GetLineCount()
67
+ end)
68
+
69
+ -- GetSelection returns cursor line/character followed by the anchor: the
70
+ -- cursor is where the caret is, the anchor where the selection started, and
71
+ -- they are equal when nothing is selected.
72
+ local raw = safe(function()
73
+ local line, character, anchorLine, anchorCharacter = document:GetSelection()
74
+ return { line, character, anchorLine, anchorCharacter }
75
+ end)
76
+ if typeof(raw) == "table" then
77
+ local selection = raw :: { number }
78
+ entry.cursorLine = selection[1]
79
+ entry.cursorColumn = selection[2]
80
+
81
+ local hasSelection = selection[1] ~= selection[3] or selection[2] ~= selection[4]
82
+ if hasSelection then
83
+ -- Normalised, because a selection dragged upwards reports its anchor
84
+ -- below its cursor and a raw start/end pair would read as inverted.
85
+ local startLine = math.min(selection[1], selection[3])
86
+ local endLine = math.max(selection[1], selection[3])
87
+ entry.selectedLines = if startLine == endLine
88
+ then tostring(startLine)
89
+ else string.format("%d-%d", startLine, endLine)
90
+
91
+ local selected = safe(function()
92
+ return document:GetSelectedText()
93
+ end)
94
+ if typeof(selected) == "string" then
95
+ entry.selectedText = if #selected > MAX_SELECTED_TEXT
96
+ then string.sub(selected, 1, MAX_SELECTED_TEXT) .. "..."
97
+ else selected
98
+ end
99
+ end
100
+ end
101
+
102
+ local viewport = safe(function()
103
+ local first, last = document:GetViewport()
104
+ return { first, last }
105
+ end)
106
+ if typeof(viewport) == "table" then
107
+ local bounds = viewport :: { number }
108
+ if bounds[1] and bounds[2] then
109
+ entry.visibleLines = string.format("%d-%d", bounds[1], bounds[2])
110
+ end
111
+ end
112
+
113
+ return entry
114
+ end
115
+
116
+ --[[
117
+ Every script the user has open, most recently interesting first. Studio gives
118
+ no "focused document" API, so the caller is told what is open rather than
119
+ which tab has focus -- claiming focus we cannot observe would be worse than
120
+ saying nothing.
121
+ ]]
122
+ function Editor.documents(): { Document }
123
+ local ok, documents = pcall(function()
124
+ return ScriptEditorService:GetScriptDocuments()
125
+ end)
126
+ if not ok or typeof(documents) ~= "table" then
127
+ return {}
128
+ end
129
+
130
+ local entries: { Document } = {}
131
+ for _, document in documents :: { Instance } do
132
+ if document:IsA("ScriptDocument") then
133
+ local entry = describe(document)
134
+ if entry then
135
+ table.insert(entries, entry)
136
+ end
137
+ end
138
+ end
139
+ return entries
140
+ end
141
+
142
+ return Editor
@@ -0,0 +1,151 @@
1
+ --!strict
2
+ --[[
3
+ What device, if any, Studio's viewport is currently pretending to be.
4
+
5
+ Three places need this and none of them can afford to disagree: the `device`
6
+ tool reports it, `screenshot` captions it, and `studio_status` mentions it so
7
+ an agent that never set a device still learns why the viewport is a strange
8
+ shape. Emulation persists until it is switched off, which is exactly the kind
9
+ of state that goes wrong quietly.
10
+
11
+ `StudioDeviceSimulatorService` is undocumented on its return shapes, so what
12
+ is here was read off a live session. Two things are worth knowing:
13
+
14
+ * Every read THROWS while no device is active -- "no device is active, call
15
+ SetDeviceAsync() first" -- rather than returning a default, so each one is
16
+ guarded separately and costs only its own field.
17
+ * `GetResolutionAsync` reports the panel's native size, which does not turn
18
+ when the device does. An iPhone 16 emulated in portrait still answers
19
+ 852x393. See `orientedSize`.
20
+ ]]
21
+
22
+ local StudioDeviceSimulatorService = game:GetService("StudioDeviceSimulatorService")
23
+
24
+ local Emulation = {}
25
+
26
+ --[[
27
+ The resolution as it actually appears, rather than as the panel is specified.
28
+
29
+ Reporting 852x393 for a screen that is 393 wide and 852 tall is worse than
30
+ reporting nothing: getting the shape of the screen right is the entire reason
31
+ device emulation exists, and a caller sizing a menu against those numbers
32
+ would place it off the bottom of the display it was told it fits.
33
+
34
+ Only swapped when the orientation names a direction. "Sensor" means the
35
+ device decides, and guessing on its behalf would trade a knowable error for
36
+ an unknowable one.
37
+ ]]
38
+ local function orientedSize(size: Vector2, orientation: string?): (number, number)
39
+ local width = math.floor(size.X)
40
+ local height = math.floor(size.Y)
41
+
42
+ local wantsPortrait: boolean? = nil
43
+ if orientation ~= nil then
44
+ if string.find(orientation, "Portrait") ~= nil then
45
+ wantsPortrait = true
46
+ elseif string.find(orientation, "Landscape") ~= nil then
47
+ wantsPortrait = false
48
+ end
49
+ end
50
+
51
+ -- `width > height` is the shape we have; `wantsPortrait` is the shape we
52
+ -- want. Equal means they disagree, because portrait is the narrow one.
53
+ if wantsPortrait ~= nil and wantsPortrait == (width > height) then
54
+ return height, width
55
+ end
56
+ return width, height
57
+ end
58
+
59
+ --[[
60
+ The active device id, or nil when the viewport is the ordinary editor.
61
+
62
+ "default" is the simulator's own word for not emulating; it is turned into
63
+ nil here so callers test one thing rather than two.
64
+ ]]
65
+ function Emulation.deviceId(): string?
66
+ local ok, id = pcall(function()
67
+ return (StudioDeviceSimulatorService :: any):GetDeviceAsync()
68
+ end)
69
+ if not ok or id == nil or tostring(id) == "default" then
70
+ return nil
71
+ end
72
+ return tostring(id)
73
+ end
74
+
75
+ --[[
76
+ Orientation with the enum prefix removed: "Portrait", "LandscapeRight".
77
+ ]]
78
+ function Emulation.orientation(): string?
79
+ local ok, orientation = pcall(function()
80
+ return (StudioDeviceSimulatorService :: any):GetOrientationAsync()
81
+ end)
82
+ if not ok then
83
+ return nil
84
+ end
85
+ local name = tostring(orientation):gsub("Enum%.ScreenOrientation%.", "")
86
+ return name
87
+ end
88
+
89
+ --[[
90
+ "393x852", turned the way the screen is actually turned.
91
+ ]]
92
+ function Emulation.resolution(orientation: string?): string?
93
+ local ok, size = pcall(function()
94
+ return (StudioDeviceSimulatorService :: any):GetResolutionAsync()
95
+ end)
96
+ if not ok or typeof(size) ~= "Vector2" then
97
+ return nil
98
+ end
99
+ local width, height = orientedSize(size :: Vector2, orientation)
100
+ return string.format("%dx%d", width, height)
101
+ end
102
+
103
+ --[[
104
+ Everything the simulator will say, for the `device` tool.
105
+ ]]
106
+ function Emulation.state(): { [string]: any }
107
+ local id = Emulation.deviceId()
108
+ local orientation = Emulation.orientation()
109
+
110
+ local state: { [string]: any } = {
111
+ device = id or "default",
112
+ emulating = id ~= nil,
113
+ }
114
+ state.resolution = Emulation.resolution(orientation)
115
+ state.orientation = orientation
116
+
117
+ local okDensity, density = pcall(function()
118
+ return (StudioDeviceSimulatorService :: any):GetPixelDensityAsync()
119
+ end)
120
+ if okDensity then
121
+ state.pixelDensity = math.round((tonumber(density) :: number) * 100) / 100
122
+ end
123
+
124
+ local okScaling, scaling = pcall(function()
125
+ return (StudioDeviceSimulatorService :: any):GetScalingModeAsync()
126
+ end)
127
+ if okScaling then
128
+ state.scalingMode = tostring(scaling)
129
+ end
130
+
131
+ return state
132
+ end
133
+
134
+ --[[
135
+ The short form carried by `studio_status`, or nil when nothing is emulated.
136
+ ]]
137
+ function Emulation.summary(): { [string]: any }?
138
+ local id = Emulation.deviceId()
139
+ if id == nil then
140
+ return nil
141
+ end
142
+ local orientation = Emulation.orientation()
143
+ return {
144
+ id = id,
145
+ resolution = Emulation.resolution(orientation),
146
+ orientation = orientation,
147
+ note = 'The viewport is emulating this device. `device op="stop"` returns it to normal.',
148
+ }
149
+ end
150
+
151
+ return Emulation
@@ -0,0 +1,277 @@
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