maquina_remend 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3ae80dab37d601a1b8570d73e4d29e2f10296205ae349cdace4bfe2f8d17cb53
4
+ data.tar.gz: b97c54a013f0c2dc60616bc1af16ecd3a66786dacf162535c47ed2ec8976ea93
5
+ SHA512:
6
+ metadata.gz: fc6b594c251f168ac266659d123d3fddf3a43c10b3b7470a076b3496c75ee9477f86da915634e9aa32b9964fe338b4d0d8039e7398328db1ed3ab15af03d384b
7
+ data.tar.gz: 3fa25bb6cb8cb511338fafbd7261115a52a4f8203a7c91ef2ea293bb3bf595b43ae2b4c2e79078749d429e59a7baff824b4a7cb4d7931a22be46aeb99349d667
data/.rdoc_options ADDED
@@ -0,0 +1,16 @@
1
+ # RDoc configuration. Comments in this gem are written in Markdown, not RDoc
2
+ # markup, so `markup` has to be set here as well as in the gemspec's
3
+ # rdoc_options: `gem rdoc` reads this file, rubygems.org's doc build reads the
4
+ # gemspec, and a bare `rdoc` in a checkout reads only this one.
5
+ ---
6
+ markup: markdown
7
+ title: maquina_remend
8
+ main_page: README.md
9
+ exclude:
10
+ - "/sdd/"
11
+ - "CLAUDE\\.md$"
12
+ - "/test/"
13
+ - "remend-patterns\\.md$"
14
+ - "Gemfile"
15
+ - "Rakefile$"
16
+ - "\\.gemspec$"
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-07
4
+
5
+ First working version. `MaquinaRemend.call` repairs the tail of a markdown
6
+ buffer that is still arriving, so a renderer never sees a broken document
7
+ mid-stream.
8
+
9
+ - Fourteen completion cases: emphasis, strong, inline code, links and images,
10
+ block math, setext headings, HTML tags, strikethrough, a dangling escape.
11
+ - Application tags — `<thinking>`, `<answer>`, `<tool_call>`, `<citation>`,
12
+ `<scratchpad>` — closed while the model is still inside them, so the answer
13
+ that follows a blank line does not escape the tag. `app_tags:` takes the list
14
+ of names, or `false`.
15
+ - Nine guard cases, including comparison operators and single tildes that must
16
+ not be mistaken for markup.
17
+ - A scanner that tracks fence, inline-code and math state, so a repair never
18
+ fires inside a code block.
19
+ - Per-handler options and a custom handler contract, with `Context` exposing
20
+ the state a handler needs to decide for itself.
21
+ - Idempotent, and byte-identical on well-formed input. Both are asserted over
22
+ the whole corpus, at every truncation point of every document.
23
+ - Application-meaning tags a model leaves open — `<thinking>`, `<answer>`,
24
+ `<tool_call>` and any name you configure — are closed, so a renderer is never
25
+ handed a document whose AST and DOM disagree about where the tag ends.
26
+ - Zero runtime dependencies and no Rails, asserted in a bare
27
+ `ruby --disable-gems` process.
28
+
29
+ Verified against 24 real agent transcripts (14,434 stream chunks), which is how
30
+ the dangling-escape case was found: a trailing backslash escapes whatever the
31
+ next handler appends, so the repair grew on every pass.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mario Alberto Chávez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # maquina_remend
2
+
3
+ Repairs the tail of a markdown buffer that is still being streamed.
4
+
5
+ A model emits markdown a token at a time. Rendered naively, the tail of the
6
+ buffer is broken for most of the message's life. The model has typed `**bol`
7
+ and the reader sees two asterisks; it has typed `[the guide](https://exa` and
8
+ the reader sees a link to the wrong host. `maquina_remend` completes that tail,
9
+ so the renderer always receives a well-formed document.
10
+
11
+ ```ruby
12
+ MaquinaRemend.call("**unclosed bold") # => "**unclosed bold**"
13
+ MaquinaRemend.call("`code") # => "`code`"
14
+ MaquinaRemend.call("[label](https://exa") # => "[label](#)"
15
+ MaquinaRemend.call("<thinking>\nstill reasoning")
16
+ # => "<thinking>\nstill reasoning</thinking>"
17
+ ```
18
+
19
+ It is a pure function. It is idempotent. A document that is already well formed
20
+ comes back byte for byte. **Zero runtime dependencies, and no Rails** — it runs
21
+ in a plain Ruby process with nothing else loaded.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ bundle add maquina_remend
27
+ ```
28
+
29
+ or, in a Gemfile:
30
+
31
+ ```ruby
32
+ gem "maquina_remend"
33
+ ```
34
+
35
+ Ruby >= 3.1.
36
+
37
+ ## Usage
38
+
39
+ ```ruby
40
+ require "maquina_remend"
41
+
42
+ MaquinaRemend.call(markdown, **options) # => String
43
+ ```
44
+
45
+ Call it on every frame of the stream, on the whole buffer so far, and render
46
+ the result. Nothing is remembered between calls.
47
+
48
+ ```ruby
49
+ buffer = +""
50
+
51
+ stream.each do |chunk|
52
+ buffer << chunk
53
+ render MaquinaRemend.call(buffer)
54
+ end
55
+ ```
56
+
57
+ `nil` and `""` come back exactly as they went in. An unrecognised option raises
58
+ `MaquinaRemend::UnknownOption` rather than being ignored, so a typo in a host's
59
+ configuration surfaces at once.
60
+
61
+ ## Options
62
+
63
+ Every repair is individually disableable, and one is off by default.
64
+
65
+ | Option | Default | Broken input | Repaired to |
66
+ |---|---|---|---|
67
+ | `bold:` | `true` | `**unclosed bold` | `**unclosed bold**` |
68
+ | `italic:` | `true` | `*unclosed` / `_unclosed` | `*unclosed*` / `_unclosed_` |
69
+ | `bold_italic:` | `true` | `***both` | `***both***` |
70
+ | `inline_code:` | `true` | `` `code `` | `` `code` `` |
71
+ | `strikethrough:` | `true` | `~~struck` | `~~struck~~` |
72
+ | `links:` | `true` | `[label` | `[label]()` |
73
+ | `images:` | `true` | `![alt` | `![alt]()` |
74
+ | `block_math:` | `true` | `$$x = 1` | `$$x = 1$$` |
75
+ | `inline_math:` | **`false`** | `$x = 1` | `$x = 1$` |
76
+ | `setext_headings:` | `true` | `Title\n=` | `Title\n=====` |
77
+ | `comparison_operators:` | `true` | `- a > b` | `- a \> b` |
78
+ | `html_tags:` | `true` | `text <div cla` | `text` |
79
+ | `single_tilde:` | `true` | `20~25°C` | `20\~25°C` |
80
+ | `dangling_escape:` | `true` | `media ecuación \` | `media ecuación` and a space |
81
+ | `app_tags:` | five names | `<thinking>\nstill reasoning` | `<thinking>\nstill reasoning</thinking>` |
82
+ | `link_mode:` | `:protocol` | `[label](http` | `[label](#)` |
83
+ | `handlers:` | `[]` | — | your own handlers, run last |
84
+
85
+ Pass `false` to switch a repair off:
86
+
87
+ ```ruby
88
+ MaquinaRemend.call("**unclosed bold", bold: false) # => "**unclosed bold"
89
+ MaquinaRemend.call("![alt", images: false) # => "![alt"
90
+ ```
91
+
92
+ **`inline_math:` is off because a bare dollar sign is currency far more often
93
+ than it is mathematics.** Turn it on only if you know your corpus.
94
+
95
+ **`app_tags:` is a list, not a boolean.** It names the tags a model emits to
96
+ carry application meaning — `thinking`, `answer`, `tool_call`, `citation`,
97
+ `scratchpad` by default — and closes the ones the model has left open. Pass
98
+ your own names to match the host's tag registry, or `false` to switch it off:
99
+
100
+ ```ruby
101
+ MaquinaRemend.call("<plan>\nstep one", app_tags: %w[plan])
102
+ # => "<plan>\nstep one</plan>"
103
+ ```
104
+
105
+ Every repair, with the input it fires on and the input it refuses to touch, is
106
+ in **[docs/repairs.md](docs/repairs.md)**. The reasoning behind the `app_tags:`
107
+ list is under [Application tags](docs/repairs.md#application-tags).
108
+
109
+ ## What it will not do
110
+
111
+ Guards matter more than completions. A false repair corrupts a message that was
112
+ never broken, and there is no frame later in the stream that undoes it.
113
+
114
+ ```ruby
115
+ MaquinaRemend.call("```\n**not bold") # => "```\n**not bold"
116
+ MaquinaRemend.call("`a * b`") # => "`a * b`"
117
+ MaquinaRemend.call("$$a_1 + b_2$$") # => "$$a_1 + b_2$$"
118
+ MaquinaRemend.call("some_var_name") # => "some_var_name"
119
+ MaquinaRemend.call("costs $5 and $10") # => "costs $5 and $10"
120
+ ```
121
+
122
+ Nothing is completed inside a fenced code block, inside an inline code span, or
123
+ inside math, because in those places broken-looking markup is the content. The
124
+ full list is in [docs/repairs.md](docs/repairs.md#guards).
125
+
126
+ ## Documentation
127
+
128
+ * **[docs/repairs.md](docs/repairs.md)** — every repair, one section each, with
129
+ the input it fires on and the guards that hold it back.
130
+ * **[docs/handlers.md](docs/handlers.md)** — writing a handler of your own: the
131
+ contract, the `Context` object, ordering, and a worked example.
132
+ * **[docs/streaming.md](docs/streaming.md)** — using it in a stream, what
133
+ idempotence and prefix safety buy a caller, and what the gem deliberately
134
+ does not do.
135
+
136
+ API documentation for every class is on
137
+ [rubydoc.info](https://rubydoc.info/gems/maquina_remend).
138
+
139
+ ## Guarantees
140
+
141
+ Three properties, asserted over a corpus of whole documents at every truncation
142
+ point rather than over hand-picked inputs:
143
+
144
+ 1. **Idempotence** — `call(call(x)) == call(x)`.
145
+ 2. **No-op on well-formed input** — a complete document comes back
146
+ byte-identical.
147
+ 3. **Prefix safety** — every truncation point of a document is repairable
148
+ without raising.
149
+
150
+ A fourth is measured rather than proved: an 8KB buffer is repaired in under a
151
+ millisecond.
152
+
153
+ Run them with `bundle exec rake test`. The prefix-safety property also accepts a
154
+ local corpus of real transcripts:
155
+
156
+ ```sh
157
+ MAQUINA_REMEND_CORPUS=~/some/transcripts bundle exec rake test
158
+ ```
159
+
160
+ That corpus is deliberately not committed: a transcript contains whatever the
161
+ session contained.
162
+
163
+ ## maquina
164
+
165
+ Part of [maquina](https://maquina.app) — open source for Ruby and Ruby AI.
166
+
167
+ ## License
168
+
169
+ MIT, © Mario Alberto Chávez. See [LICENSE.txt](LICENSE.txt).
data/docs/handlers.md ADDED
@@ -0,0 +1,223 @@
1
+ # Custom handlers
2
+
3
+ The built-in repairs are listed in [repairs.md](repairs.md). When a host needs
4
+ one of its own — a template placeholder, a shortcode, a marker the model emits
5
+ that no markdown parser knows about — it passes a handler.
6
+
7
+ ```ruby
8
+ MaquinaRemend.call(markdown, handlers: [MyHandler.new])
9
+ ```
10
+
11
+ ## The contract
12
+
13
+ A handler is **any object that responds to `#call(text, context)` and returns a
14
+ String.** There is no class to inherit from and no registration step.
15
+
16
+ ```ruby
17
+ class MyHandler
18
+ def call(text, context) # context: MaquinaRemend::Context
19
+ text
20
+ end
21
+ end
22
+ ```
23
+
24
+ * **`text`** is the buffer as the built-ins left it — already repaired.
25
+ * **`context`** is a `MaquinaRemend::Context` describing where the tail sits.
26
+ * **Return a String.** Returning the argument unchanged is the normal outcome;
27
+ most passes over most buffers repair nothing.
28
+ * **Handlers run in the order given**, each seeing the previous one's output.
29
+ * **Stay idempotent, and stay byte-identical on well-formed input.** Those two
30
+ properties belong to the library, not just to the built-ins. A handler that
31
+ appends unconditionally breaks both: the text it appended dangles on the next
32
+ pass and gets appended to again.
33
+
34
+ ## The `Context` object
35
+
36
+ ```ruby
37
+ context = MaquinaRemend.context("~~~ruby\ndef call")
38
+ context.in_code_fence? # => true
39
+ context.open_fence_info # => "ruby"
40
+ context.in_inline_code? # => false
41
+ context.in_math? # => false
42
+
43
+ MaquinaRemend.context("a `code").in_inline_code? # => true
44
+ MaquinaRemend.context("$$x = 1").in_math? # => true
45
+ ```
46
+
47
+ Three predicates and two readers, and nothing else:
48
+
49
+ | Member | Answers |
50
+ |---|---|
51
+ | `#in_code_fence?` | is the tail inside an unclosed fenced code block? |
52
+ | `#in_inline_code?` | is an unclosed backtick span open in this paragraph? |
53
+ | `#in_math?` | is an unclosed `$$` block-math span open? |
54
+ | `#open_fence_info` | the open fence's info string, or `nil` |
55
+ | `#options` | the resolved option hash the pipeline was built with |
56
+
57
+ The three predicates are the three places where broken-looking markup is the
58
+ content rather than a defect. A repair that needs more state than they expose
59
+ means the scanner is missing a state, not that `Context` should grow.
60
+
61
+ `#open_fence_info` is `nil` when no fence is open, and also when the open fence
62
+ carried no info string:
63
+
64
+ ```ruby
65
+ MaquinaRemend.context("~~~ruby\ndef call").open_fence_info # => "ruby"
66
+ MaquinaRemend.context("~~~\nplain").open_fence_info # => nil
67
+ MaquinaRemend.context("plain text").open_fence_info # => nil
68
+ ```
69
+
70
+ `MaquinaRemend.context` is public for callers that want the reading without the
71
+ repair — a renderer deciding whether to run a syntax highlighter over a block
72
+ whose closing fence has not streamed in yet:
73
+
74
+ ```ruby
75
+ context = MaquinaRemend.context(buffer)
76
+ highlight(buffer, language: context.open_fence_info) if context.in_code_fence?
77
+ ```
78
+
79
+ Its `#options` is empty, because it did no repair and therefore had no options.
80
+
81
+ ## Ordering
82
+
83
+ **Custom handlers run last, after every built-in.** Two consequences.
84
+
85
+ First, the text a handler receives already carries the built-ins' completions.
86
+ An `upcase` handler sees the closer the emphasis repair appended:
87
+
88
+ ```ruby
89
+ shouty = Object.new
90
+ def shouty.call(text, _context) = text.upcase
91
+
92
+ MaquinaRemend.call("**bold", handlers: [shouty]) # => "**BOLD**"
93
+ ```
94
+
95
+ If your handler anchors on the end of the buffer, remember that the end of the
96
+ buffer may no longer be where the model stopped typing.
97
+
98
+ Second, **a custom handler still runs inside a fenced code block.** The
99
+ built-ins all return early there; a custom handler does not, because it is given
100
+ the context and trusted to decide — which it could not do if it never ran. Ask
101
+ for yourself:
102
+
103
+ ```ruby
104
+ return text if context.in_code_fence?
105
+ ```
106
+
107
+ ## A worked example
108
+
109
+ A host renders emoji shortcodes, `:smile:`. Mid-stream the model has typed
110
+ `:smi`, and the reader sees a stray colon and three letters that will change
111
+ under them a frame later. Drop the partial shortcode until it is whole.
112
+
113
+ ```ruby
114
+ class PartialShortcode
115
+ TRAILING = /(?<=\A|\s):[a-z][a-z0-9_+-]*\z/
116
+
117
+ def call(text, context)
118
+ return text if context.in_code_fence?
119
+
120
+ text.sub(TRAILING, "").rstrip
121
+ end
122
+ end
123
+
124
+ handler = PartialShortcode.new
125
+ ```
126
+
127
+ It fires on a shortcode that is still arriving:
128
+
129
+ ```ruby
130
+ MaquinaRemend.call("Nice work :smi", handlers: [handler])
131
+ # => "Nice work"
132
+ ```
133
+
134
+ It leaves a finished one alone — the pattern cannot match a trailing `:`, so a
135
+ complete document passes through untouched:
136
+
137
+ ```ruby
138
+ MaquinaRemend.call("Nice work :smile:", handlers: [handler])
139
+ # => "Nice work :smile:"
140
+ MaquinaRemend.call("Nice work", handlers: [handler])
141
+ # => "Nice work"
142
+ ```
143
+
144
+ It leaves a fence alone, because it asked:
145
+
146
+ ```ruby
147
+ MaquinaRemend.call("```\nNice work :smi", handlers: [handler])
148
+ # => "```\nNice work :smi"
149
+ ```
150
+
151
+ The lookbehind is what keeps it off a colon that was never a shortcode:
152
+
153
+ ```ruby
154
+ MaquinaRemend.call("ratio 3:2", handlers: [handler])
155
+ # => "ratio 3:2"
156
+ ```
157
+
158
+ And it is idempotent: running it over its own output changes nothing, in every
159
+ case above.
160
+
161
+ The one case it does not catch is the ordering rule from the previous section.
162
+ The emphasis repair has already appended `**`, so the shortcode is no longer at
163
+ the end of the buffer and the anchor misses:
164
+
165
+ ```ruby
166
+ MaquinaRemend.call("**Nice work :smi", handlers: [handler])
167
+ # => "**Nice work :smi**"
168
+ ```
169
+
170
+ That is the trade the ordering makes, and it is the right way round: a custom
171
+ handler that ran *before* the built-ins would be repairing a buffer the
172
+ built-ins were about to rewrite underneath it. A handler that must see the raw
173
+ tail should match on the paragraph rather than on `\z`.
174
+
175
+ ## Reusing a validated pipeline
176
+
177
+ `MaquinaRemend.call` builds a `Pipeline` per call and throws it away. Options
178
+ are validated in the constructor, so building one yourself is worth it when the
179
+ same options are used for every frame of a long stream:
180
+
181
+ ```ruby
182
+ pipeline = MaquinaRemend::Pipeline.new(inline_math: true)
183
+ pipeline.call("$x = 1") # => "$x = 1$"
184
+
185
+ deltas.map { |buffer| pipeline.call(buffer) }
186
+ ```
187
+
188
+ It holds no state between buffers. Validation happening in the constructor also
189
+ means a bad option is a startup error for the host rather than a surprise
190
+ halfway through a stream:
191
+
192
+ ```ruby
193
+ MaquinaRemend::Pipeline.new(bolder: true)
194
+ # MaquinaRemend::UnknownOption: unknown option(s): bolder
195
+ ```
196
+
197
+ ## Where a custom handler runs in the order
198
+
199
+ The built-ins run in a fixed order, and then yours:
200
+
201
+ ```ruby
202
+ MaquinaRemend::Pipeline::BUILT_INS.map(&:name)
203
+ # => ["MaquinaRemend::Handlers::DanglingEscape",
204
+ # "MaquinaRemend::Handlers::HtmlTags",
205
+ # "MaquinaRemend::Handlers::InlineCode",
206
+ # "MaquinaRemend::Handlers::Links",
207
+ # "MaquinaRemend::Handlers::Math",
208
+ # "MaquinaRemend::Handlers::Emphasis",
209
+ # "MaquinaRemend::Handlers::Strikethrough",
210
+ # "MaquinaRemend::Handlers::SetextHeading",
211
+ # "MaquinaRemend::Handlers::ComparisonOperators",
212
+ # "MaquinaRemend::Handlers::SingleTilde",
213
+ # "MaquinaRemend::Handlers::AppTags"]
214
+ ```
215
+
216
+ The order is load-bearing. A dangling backslash is dropped first, because it
217
+ would escape whatever any later handler appends. Links run before emphasis,
218
+ because an emphasis closer appended into a half-typed `[label(` lands inside
219
+ the destination. Application tags run last, because their closer has to sit
220
+ outside every other completion.
221
+
222
+ The buffer is rescanned after any handler that changed it, so no handler reads
223
+ a view of the text that an earlier one has already invalidated.