samagotchi 0.154.0 → 0.155.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +10 -8
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/bridge.md +18 -5
  12. data/docs/internals/engine.md +221 -100
  13. data/docs/internals/inline-widgets.md +136 -0
  14. data/docs/internals/objectives.md +8 -6
  15. data/docs/internals/participants.md +5 -5
  16. data/docs/internals/prompt-caching.md +12 -6
  17. data/docs/internals/web-frontend.md +2 -2
  18. data/docs/internals/working-set.md +2 -1
  19. data/docs/plugins.md +2 -0
  20. data/docs/releasing.md +1 -1
  21. data/docs/sessions.md +3 -3
  22. data/docs/testing.md +16 -1
  23. data/lib/samagotchi/answer_text.rb +1 -2
  24. data/lib/samagotchi/block_renderers.rb +5 -0
  25. data/lib/samagotchi/bridge/card_places.rb +56 -7
  26. data/lib/samagotchi/bridge/card_store.rb +24 -9
  27. data/lib/samagotchi/bridge.rb +9 -0
  28. data/lib/samagotchi/bridge_client.rb +9 -5
  29. data/lib/samagotchi/bundle_command/check.rb +1 -1
  30. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  33. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  35. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  36. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  38. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  39. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  40. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  41. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  42. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  43. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +19 -125
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/objectives.rb +22 -90
  48. data/lib/samagotchi/engine/participants.rb +15 -10
  49. data/lib/samagotchi/engine/plugins.rb +3 -1
  50. data/lib/samagotchi/engine/questions.rb +1 -1
  51. data/lib/samagotchi/engine/side_requests.rb +5 -2
  52. data/lib/samagotchi/engine/toolkit.rb +33 -190
  53. data/lib/samagotchi/engine/turn_end.rb +1 -1
  54. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  55. data/lib/samagotchi/engine/verify.rb +7 -143
  56. data/lib/samagotchi/engine.rb +162 -99
  57. data/lib/samagotchi/engine_peers.rb +1 -1
  58. data/lib/samagotchi/engine_snapshots.rb +9 -8
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +6 -4
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/idle_recap.rb +3 -1
  67. data/lib/samagotchi/inline_widgets.rb +325 -0
  68. data/lib/samagotchi/kernel_loop.rb +194 -216
  69. data/lib/samagotchi/llm/backend.rb +3 -1
  70. data/lib/samagotchi/llm/chat_loop.rb +66 -42
  71. data/lib/samagotchi/llm/native_backend.rb +3 -2
  72. data/lib/samagotchi/llm_context_forget.rb +5 -3
  73. data/lib/samagotchi/model_binding.rb +3 -3
  74. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  75. data/lib/samagotchi/question_desk.rb +1 -1
  76. data/lib/samagotchi/revise_anchor.rb +1 -1
  77. data/lib/samagotchi/session_manager.rb +8 -13
  78. data/lib/samagotchi/session_retention.rb +24 -4
  79. data/lib/samagotchi/steer.rb +12 -1
  80. data/lib/samagotchi/stream_event.rb +22 -0
  81. data/lib/samagotchi/system_prompt.rb +84 -61
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  83. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  84. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  85. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  86. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  87. data/lib/samagotchi/testing/boundary.rb +7 -0
  88. data/lib/samagotchi/testing/bundle.rb +7 -5
  89. data/lib/samagotchi/testing/install.rb +3 -2
  90. data/lib/samagotchi/tool_activity.rb +8 -6
  91. data/lib/samagotchi/tool_declarations.rb +4 -4
  92. data/lib/samagotchi/tool_response.rb +4 -3
  93. data/lib/samagotchi/tool_runner.rb +93 -101
  94. data/lib/samagotchi/toolkit_binding.rb +40 -14
  95. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  96. data/lib/samagotchi/tools/registry.rb +43 -40
  97. data/lib/samagotchi/tools/tool_index.rb +135 -88
  98. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  99. data/lib/samagotchi/turn_context.rb +67 -0
  100. data/lib/samagotchi/turn_flow.rb +7 -2
  101. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  102. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  103. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  104. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  105. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  106. data/lib/samagotchi/turn_state.rb +46 -4
  107. data/lib/samagotchi/version.rb +1 -1
  108. data/lib/samagotchi/web/app.rb +3 -1
  109. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  110. data/lib/samagotchi/web/message_display.rb +8 -1
  111. data/lib/samagotchi/web/public/app.js +5 -1
  112. data/lib/samagotchi/web/public/block_host.js +74 -199
  113. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  114. data/lib/samagotchi/web/public/block_source.js +7 -4
  115. data/lib/samagotchi/web/public/block_watch.js +256 -0
  116. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  117. data/lib/samagotchi/web/public/cards_place.js +5 -3
  118. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  119. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  120. data/lib/samagotchi/web/public/format.js +3 -2
  121. data/lib/samagotchi/web/public/index.html +28 -9
  122. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  123. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  124. data/lib/samagotchi/web/public/participant.js +1 -1
  125. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  126. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  127. data/lib/samagotchi/web/public/text_roots.js +1 -1
  128. data/lib/samagotchi/web/public/turn_html.js +8 -4
  129. data/lib/samagotchi/web/public/verify_target.js +1 -1
  130. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  131. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  132. data/lib/samagotchi/web/public/widgets.css +70 -0
  133. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  134. data/lib/samagotchi/web/session_show.rb +4 -0
  135. data/lib/samagotchi/worker.rb +8 -5
  136. data/lib/samagotchi.rb +0 -1
  137. metadata +19 -2
  138. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "psych"
5
+
6
+ module Samagotchi
7
+ # A ```chi-ui fence's YAML, read once on the server (Web::MarkdownRenderer):
8
+ # what the page draws (a picks or a walk) as plain, JSON-ready data, or the
9
+ # one-line reason it stays source. Pure: no web, no Engine.
10
+ #
11
+ # The YAML is read from Psych's node tree, never loaded, so a value stays the
12
+ # text the model wrote (YAML 1.1 would make `- no` false, `1.10` 1.1 and a
13
+ # date a Date), and nothing beyond plain data gets through: an anchor, an
14
+ # alias or a tag is refused. A block that isn't YAML gets one more try after
15
+ # the lenient pass (Lenient), which quotes the free-text values models leave
16
+ # bare; one that is, but has an option or a free-text value read as a
17
+ # one-pair mapping (`- fix now: drop it`), gets that text back (Tree#folds).
18
+ # See docs/internals/inline-widgets.md.
19
+ module InlineWidgets
20
+ # error is nil when the block draws; type is set whenever the root names a
21
+ # known one, so a refused block still says what it tried to be.
22
+ Result = Data.define(:type, :data, :lenient, :error) do
23
+ def ok? = error.nil?
24
+ def lenient? = lenient
25
+
26
+ # The page's input (data-widget): the data with its type.
27
+ def widget_json = JSON.generate({ "type" => type }.merge(data))
28
+ end
29
+
30
+ # The one-line reason a block doesn't draw.
31
+ class Refused < StandardError; end
32
+
33
+ # The fence's language. Core draws it, so no bundle can claim it
34
+ # (BlockRenderers::RESERVED).
35
+ LANG = "chi-ui"
36
+ TYPES = %w[picks walk].freeze
37
+ MAX_SOURCE_BYTES = 16 * 1024
38
+ MAX_ROWS = 12
39
+ MAX_STEPS = 30
40
+ ACTORS = (2..5)
41
+ OPTIONS = (2..4)
42
+ MAX_STRING = 300
43
+ # A step's `next: end`: the walk ends there (a branch that ends early).
44
+ # No step may take it as its id.
45
+ END_NEXT = "end"
46
+ # The deepest legal block nests 6 (root, steps, a step, cases, a case, a
47
+ # folded option); past this it is refused before the stack can overflow.
48
+ MAX_DEPTH = 8
49
+ # How much of a bad type value an error repeats.
50
+ MAX_ECHO = 40
51
+
52
+ module_function
53
+
54
+ # @return [Result]
55
+ def parse(source)
56
+ source = source.to_s.dup.force_encoding(Encoding::UTF_8)
57
+ lenient = false
58
+ raise Refused, "the block is over #{MAX_SOURCE_BYTES / 1024} KB" if source.bytesize > MAX_SOURCE_BYTES
59
+ raise Refused, "the block is not valid text (UTF-8)" unless source.valid_encoding?
60
+
61
+ tree, lenient = load(source)
62
+ raise Refused, "the block isn't a mapping (type: picks or walk first)" unless tree.root.is_a?(Hash)
63
+
64
+ type = tree.root["type"]
65
+ raise Refused, "the block needs type: picks or walk" if type.nil?
66
+ raise Refused, "unknown type #{echo(type)} (picks or walk)" unless TYPES.include?(type)
67
+
68
+ schema = Schema.new(tree.folds)
69
+ data = type == "picks" ? schema.picks(tree.root) : schema.walk(tree.root)
70
+ Result.new(type: type, data: data, lenient: lenient || schema.folded?, error: nil)
71
+ rescue Refused => e
72
+ Result.new(type: TYPES.include?(type) ? type : nil, data: nil, lenient: lenient, error: e.message)
73
+ rescue SystemStackError
74
+ # MAX_DEPTH stops a deep block first; this is the backstop, since a stack
75
+ # overflow is no StandardError and would break every render of the text.
76
+ Result.new(type: nil, data: nil, lenient: lenient, error: "the block nests too deep")
77
+ end
78
+
79
+ def echo(type)
80
+ return "(not text)" unless type.is_a?(String)
81
+
82
+ type.size > MAX_ECHO ? "#{type[0, MAX_ECHO].inspect}..." : type.inspect
83
+ end
84
+
85
+ # [Tree, lenient?]: strict first; only a block that isn't YAML gets the
86
+ # lenient pass. A refused feature (an alias) is final either way.
87
+ def load(source)
88
+ [Tree.new(source, Psych.parse_stream(source)), false]
89
+ rescue Psych::Exception
90
+ begin
91
+ quoted = Lenient.quote(source)
92
+ [Tree.new(quoted, Psych.parse_stream(quoted)), true]
93
+ rescue Psych::SyntaxError => e
94
+ raise Refused, "not YAML: #{e.problem} at line #{e.line}"
95
+ rescue Psych::Exception => e
96
+ raise Refused, "not YAML: #{e.message.lines.first.to_s.strip}"
97
+ end
98
+ end
99
+
100
+ # Psych's node tree to Hashes, Arrays and Strings (nil for a plain empty
101
+ # or null value), the scalars' own text. folds: each one-pair mapping of
102
+ # scalars (`fix now: drop it`), by identity, to the text it was written as,
103
+ # which Schema takes where it wants text.
104
+ class Tree
105
+ NULLS = ["", "~", "null", "Null", "NULL"].freeze
106
+
107
+ attr_reader :root, :folds
108
+
109
+ def initialize(source, stream)
110
+ @lines = source.lines
111
+ @folds = {}.compare_by_identity
112
+ documents = stream.children
113
+ raise Refused, "the block is empty" if documents.empty?
114
+ raise Refused, "one YAML document only" if documents.size > 1
115
+
116
+ @root = node(documents.first.root, 1)
117
+ end
118
+
119
+ private
120
+
121
+ def node(node, depth)
122
+ raise Refused, "the block nests too deep" if depth > MAX_DEPTH
123
+ raise Refused, "YAML aliases and anchors aren't allowed" if node.is_a?(Psych::Nodes::Alias) || node.anchor
124
+ raise Refused, "YAML tags aren't allowed" if node.tag
125
+
126
+ case node
127
+ when Psych::Nodes::Mapping then mapping(node, depth)
128
+ when Psych::Nodes::Sequence then node.children.map { |child| node(child, depth + 1) }
129
+ else scalar(node)
130
+ end
131
+ end
132
+
133
+ def mapping(node, depth)
134
+ hash = node.children.each_slice(2).to_h do |key, value|
135
+ key = node(key, depth + 1)
136
+ raise Refused, "a key must be a plain word" unless key.is_a?(String)
137
+
138
+ [key, node(value, depth + 1)]
139
+ end
140
+ fold(hash, *node.children) if node.children.size == 2
141
+ hash
142
+ end
143
+
144
+ def scalar(node)
145
+ return nil if node.plain && NULLS.include?(node.value)
146
+
147
+ node.value
148
+ end
149
+
150
+ # The pair's text as written when it sits on one line, else key: value.
151
+ def fold(hash, key, value)
152
+ return unless key.is_a?(Psych::Nodes::Scalar) && key.plain && value.is_a?(Psych::Nodes::Scalar)
153
+ return unless hash.values.first.is_a?(String)
154
+
155
+ line = @lines[key.start_line] if key.start_line == value.end_line
156
+ written = line&.[](key.start_column...value.end_column)
157
+ @folds[hash] = written&.start_with?(key.value) ? written : "#{key.value}: #{value.value}"
158
+ end
159
+ end
160
+
161
+ # Eval 0013's lenient pass: a free-text value, or a list item that isn't a
162
+ # mapping, goes in quotes unless it is one clean scalar already, so
163
+ # `does: Asked: ...`, `- B: keep 1 h` and `text: "x" is called` all parse.
164
+ # Line by line, so a YAML error's line still points at the source.
165
+ module Lenient
166
+ TEXT_KEYS = %w[title text why does sees behind when recommended].freeze
167
+ ITEM_KEYS = %w[id when goto actor does sees].freeze
168
+ VALUE = /^([ \t]*(?:- )?(?:#{TEXT_KEYS.join("|")}):[ \t]+)(.+?)[ \t]*$/
169
+ ITEM = /^([ \t]*- )(?!(?:#{ITEM_KEYS.join("|")}):)(.+?)[ \t]*$/
170
+
171
+ module_function
172
+
173
+ def quote(source)
174
+ source.gsub(VALUE) { "#{::Regexp.last_match(1)}#{scalar(::Regexp.last_match(2))}" }
175
+ .gsub(ITEM) { "#{::Regexp.last_match(1)}#{scalar(::Regexp.last_match(2))}" }
176
+ end
177
+
178
+ # A value as one double-quoted scalar. Kept as written: one quoted
179
+ # string, or a flow list, mapping or block scalar with no ": " in it.
180
+ def scalar(value)
181
+ return value if value.match?(/\A"[^"]*"\z/) || value.match?(/\A'[^']*'\z/)
182
+ return value if value.match?(/\A[\[{|>]/) && !value.include?(": ")
183
+
184
+ wrapped = value.size > 1 && value.start_with?('"') && value.end_with?('"')
185
+ JSON.generate(wrapped ? value[1..-2] : value)
186
+ end
187
+ end
188
+
189
+ # The two schemas, as the page reads them: string keys, ids as strings,
190
+ # every other key dropped, and an optional value absent rather than nil.
191
+ # Where free text is wanted (an option, title, text, why, does, sees,
192
+ # behind, when, recommended), a folded one-pair mapping (Tree) is taken as
193
+ # its text, and folded? says one was.
194
+ class Schema
195
+ def initialize(folds)
196
+ @folds = folds
197
+ @folded = false
198
+ end
199
+
200
+ def folded? = @folded
201
+
202
+ def picks(tree)
203
+ rows = list(tree["rows"], "rows")
204
+ raise Refused, "picks has #{rows.size} rows (at most #{MAX_ROWS})" if rows.size > MAX_ROWS
205
+
206
+ rows = rows.each_with_index.map { |row, index| row(row, index) }
207
+ unique(rows, "row")
208
+ compact("title" => text(tree["title"], "the title", free: true), "rows" => rows)
209
+ end
210
+
211
+ def row(row, index)
212
+ where = "row #{index + 1}"
213
+ raise Refused, "#{where} isn't a mapping" unless row.is_a?(Hash)
214
+
215
+ id = text(row["id"], "#{where}'s id", required: true)
216
+ where = "row #{id}"
217
+ options = list(row["options"], "#{where}'s options")
218
+ raise Refused, "#{where} has #{options.size} options (#{OPTIONS.min}-#{OPTIONS.max})" unless OPTIONS.cover?(options.size)
219
+
220
+ options = options.map { |option| text(option, "#{where}'s option", required: true, free: true) }
221
+ twice = options.tally.find { |_option, count| count > 1 }
222
+ raise Refused, "#{where}: option #{twice.first.inspect} is listed twice" if twice
223
+
224
+ recommended = text(row["recommended"], "#{where}'s recommended", free: true)
225
+ if recommended && !options.include?(recommended)
226
+ raise Refused, "#{where}: recommended #{recommended.inspect} isn't one of its options"
227
+ end
228
+
229
+ compact("id" => id, "text" => text(row["text"], "#{where}'s text", required: true, free: true), "options" => options,
230
+ "recommended" => recommended, "why" => text(row["why"], "#{where}'s why", free: true))
231
+ end
232
+
233
+ def walk(tree)
234
+ title = text(tree["title"], "the title", required: true, free: true)
235
+ actors = list(tree["actors"], "actors").map { |actor| text(actor, "an actor", required: true) }
236
+ raise Refused, "walk has #{actors.size} actors (#{ACTORS.min}-#{ACTORS.max})" unless ACTORS.cover?(actors.size)
237
+
238
+ twice = actors.tally.find { |_actor, count| count > 1 }
239
+ raise Refused, "actor #{twice.first.inspect} is listed twice" if twice
240
+
241
+ steps = list(tree["steps"], "steps")
242
+ raise Refused, "walk has #{steps.size} steps (at most #{MAX_STEPS})" if steps.size > MAX_STEPS
243
+
244
+ steps = steps.each_with_index.map { |step, index| step(step, index, actors) }
245
+ ids = unique(steps, "step")
246
+ raise Refused, "step id #{END_NEXT.inspect} is taken by next: #{END_NEXT}" if ids.include?(END_NEXT)
247
+
248
+ steps.each { |step| targets(step, ids) }
249
+ { "title" => title, "actors" => actors, "steps" => steps }
250
+ end
251
+
252
+ def step(step, index, actors)
253
+ where = "step #{index + 1}"
254
+ raise Refused, "#{where} isn't a mapping" unless step.is_a?(Hash)
255
+
256
+ id = text(step["id"], "#{where}'s id", required: true)
257
+ where = "step #{id}"
258
+ actor = text(step["actor"], "#{where}'s actor", required: true)
259
+ raise Refused, "#{where}: actor #{actor.inspect} isn't one of the actors" unless actors.include?(actor)
260
+
261
+ does = text(step["does"], "#{where}'s does", free: true)
262
+ sees = text(step["sees"], "#{where}'s sees", free: true)
263
+ raise Refused, "#{where} needs does or sees" unless does || sees
264
+
265
+ nxt = text(step["next"], "#{where}'s next")
266
+ raise Refused, "#{where}: next is the step itself" if nxt == id
267
+
268
+ compact("id" => id, "actor" => actor, "does" => does, "sees" => sees,
269
+ "behind" => text(step["behind"], "#{where}'s behind", free: true), "next" => nxt,
270
+ "cases" => cases(step, where))
271
+ end
272
+
273
+ # cases, and eval round 1's name for them (branches), as one list.
274
+ def cases(step, where)
275
+ given = %w[cases branches].filter_map { |key| list(step[key], "#{where}'s #{key}") unless step[key].nil? }
276
+ return nil if given.empty?
277
+
278
+ given.flatten(1).map do |item|
279
+ raise Refused, "#{where}: each case needs when and goto" unless item.is_a?(Hash)
280
+
281
+ { "when" => text(item["when"], "#{where}'s when", required: true, free: true),
282
+ "goto" => text(item["goto"], "#{where}'s goto", required: true) }
283
+ end
284
+ end
285
+
286
+ def targets(step, ids)
287
+ nxt = step["next"] unless step["next"] == END_NEXT
288
+ names = [nxt, *Array(step["cases"]).map { |item| item["goto"] }].compact
289
+ missing = names.find { |name| !ids.include?(name) }
290
+ raise Refused, "step #{step["id"]}: #{missing.inspect} isn't a step" if missing
291
+ end
292
+
293
+ def unique(items, noun)
294
+ ids = items.map { |item| item["id"] }
295
+ twice = ids.tally.find { |_id, count| count > 1 }
296
+ raise Refused, "#{noun} id #{twice.first.inspect} is used twice" if twice
297
+
298
+ ids
299
+ end
300
+
301
+ def list(value, where)
302
+ raise Refused, "#{where} must be a non-empty list" unless value.is_a?(Array) && !value.empty?
303
+
304
+ value
305
+ end
306
+
307
+ def text(value, where, required: false, free: false)
308
+ if free && value.is_a?(Hash) && @folds.key?(value)
309
+ value = @folds[value]
310
+ @folded = true
311
+ end
312
+ raise Refused, "#{where} must be text, not a #{value.is_a?(Hash) ? "mapping" : "list"}" if value.is_a?(Hash) || value.is_a?(Array)
313
+
314
+ value = value&.strip
315
+ value = nil if value && value.empty?
316
+ raise Refused, "#{where} is missing" if required && value.nil?
317
+ raise Refused, "#{where} is over #{MAX_STRING} characters" if value && value.size > MAX_STRING
318
+
319
+ value
320
+ end
321
+
322
+ def compact(hash) = hash.compact
323
+ end
324
+ end
325
+ end