graphmind 0.6.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.
@@ -0,0 +1,787 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "loop_guard"
5
+
6
+ module Graphmind
7
+ # The payload budget of the GraphMind protocol, and the one algorithm that
8
+ # enforces it: a port of packages/schema/src/shrink.ts (SHRINK-V2), pinned
9
+ # byte for byte by packages/schema/test/fixtures/shrink.json.
10
+ #
11
+ # A single embedding array or scraped page can be enormous. Anything whose
12
+ # payload JSON is over MAX_PAYLOAD_BYTES of UTF-8 is degraded to a
13
+ # type-preserving preview so one event cannot blow the server's frame cap,
14
+ # evict the replay buffer, or vanish. With the event type, a VALID event of a
15
+ # known type always shrinks to a valid event within budget (for any
16
+ # max_bytes >= 4096), so its node finishes in the viewer instead of hanging
17
+ # "running".
18
+ #
19
+ # UNITS — they must match the TypeScript reference exactly:
20
+ # * the budget check and every top-level `bytes` are UTF-8 BYTES of the
21
+ # payload's JSON;
22
+ # * field sizes, a shrunk field's own `bytes`, every preview and every cut
23
+ # string count UTF-16 CODE UNITS (an astral character is 2). A cut can
24
+ # split a surrogate pair; Ruby cannot hold a lone surrogate in a valid
25
+ # UTF-8 String, so the half is kept as its 3-byte generalised-UTF-8 form
26
+ # ("WTF-8", e.g. "\xED\xA0\xBD") in a UTF-8 String whose
27
+ # `valid_encoding?` is false, and #stringify writes it as the escape
28
+ # JavaScript writes (\ud83d). `bytesize` of that form is 3, exactly
29
+ # what JavaScript counts for a lone surrogate.
30
+ # * key caps keep the first keys in Hash (insertion) order. JavaScript
31
+ # enumerates integer-like keys first; the fixture never mixes the two in
32
+ # one object, and a real payload that does may keep different keys per
33
+ # language (the result is valid and bounded either way).
34
+ #
35
+ # JSON text is byte-identical to JavaScript's JSON.stringify for JSON-shaped
36
+ # values: Hash (String or Symbol keys), Array, String, Symbol (its name),
37
+ # Integer, Float (ECMAScript number formatting; non-finite -> null; integers
38
+ # beyond 2**53 as the double JavaScript would parse), true, false, nil.
39
+ # Anything else — another object, a String whose bytes are not UTF-8, a
40
+ # cycle, nesting deeper than MAX_JSON_DEPTH — is "unserializable" and takes
41
+ # the same path JSON.stringify throwing takes in TypeScript.
42
+ #
43
+ # TIERS of serialize_payload(payload, max_bytes, type):
44
+ # 1. JSON within max_bytes -> returned unchanged (same object).
45
+ # 2. A Hash with at most MAX_TRIM_FIELDS top-level fields -> the field
46
+ # trim; with a known type the result must also validate.
47
+ # 3. A known type and a Hash -> the skeleton (schema fields only).
48
+ # 4. Otherwise -> the whole-payload marker {__graphmindTruncated, bytes, preview}.
49
+ #
50
+ # Pure functions: never mutates its input, keeps no state, thread-safe.
51
+ module Shrink
52
+ # Largest payload, as UTF-8 bytes of its JSON, stored or sent unchanged.
53
+ MAX_PAYLOAD_BYTES = 512 * 1024
54
+ # Length, in UTF-16 code units, of every preview and shortened string.
55
+ PREVIEW_CHARS = 2000
56
+ # Appended to a string that had to be cut short.
57
+ TRUNCATION_SUFFIX = "\xE2\x80\xA6[graphmind: truncated]".dup.force_encoding(Encoding::UTF_8).freeze
58
+ # How deep the field shrink recurses before it marks the subtree.
59
+ MAX_SHRINK_DEPTH = 6
60
+ # Most keys the field shrink keeps from any one object.
61
+ MAX_SHRINK_KEYS = 256
62
+ # Most top-level fields a payload may have for the field trim (tier 2).
63
+ MAX_TRIM_FIELDS = 4096
64
+ # Longest string, in UTF-16 units, the skeleton keeps; then its last attempt.
65
+ SKELETON_CHARS = 256
66
+ SKELETON_MIN_CHARS = 32
67
+ # Nesting the serializer walks before calling a value unserializable
68
+ # (JavaScript's limit is its stack; Ruby's must not be a SystemStackError).
69
+ MAX_JSON_DEPTH = 1000
70
+
71
+ MAX_SAFE_INTEGER = (2**53) - 1
72
+
73
+ # The skeleton plan of every known event type: its schema keys in
74
+ # declaration order. nil = required; a Hash = a required loose object with
75
+ # its own plan; :optional = kept only when a string, number or boolean.
76
+ # Asserted equal to the fixture's `skeletonPlans` by test/test_shrink.rb.
77
+ PLANS = {
78
+ "run.started" => { "app" => nil, "sdk" => { "name" => nil, "version" => nil }.freeze, "meta" => :optional },
79
+ "run.finished" => { "status" => nil, "error" => :optional },
80
+ "graph.hint" => { "nodes" => nil },
81
+ "node.started" => {
82
+ "nodeId" => nil, "parentId" => :optional, "kind" => nil, "name" => nil, "instanceId" => nil,
83
+ "input" => :optional, "collapsed" => :optional, "redaction" => :optional
84
+ },
85
+ "node.token" => { "nodeId" => nil, "deltas" => nil, "redaction" => :optional },
86
+ "node.finished" => {
87
+ "nodeId" => nil, "instanceId" => :optional, "output" => :optional, "usage" => :optional,
88
+ "durationMs" => nil, "heldMs" => :optional, "status" => nil, "redaction" => :optional
89
+ },
90
+ "node.error" => {
91
+ "nodeId" => nil, "instanceId" => :optional,
92
+ "error" => { "name" => nil, "message" => nil, "stack" => :optional }.freeze, "heldMs" => :optional
93
+ },
94
+ "exec.paused" => {
95
+ "pauseId" => nil, "nodeId" => nil, "point" => nil, "reason" => :optional, "loop" => :optional,
96
+ "smart" => :optional, "editable" => :optional, "instanceId" => :optional
97
+ },
98
+ "exec.refused" => { "pauseId" => nil, "code" => nil, "message" => :optional, "requestId" => :optional },
99
+ "exec.resumed" => {
100
+ "pauseId" => nil, "action" => nil, "edited" => :optional, "requestId" => :optional, "principal" => :optional
101
+ }
102
+ }.transform_values(&:freeze).freeze
103
+
104
+ # Raised internally when a value has no JSON text (see the module comment).
105
+ class Unserializable < StandardError; end
106
+
107
+ module_function
108
+
109
+ # -- public API ------------------------------------------------------------
110
+
111
+ # Serialize a payload, shrinking it when its JSON exceeds `max_bytes` UTF-8
112
+ # bytes. Returns [json_text, payload, truncated]: the JSON to send or store,
113
+ # the effective payload it is the text of, and whether anything was cut.
114
+ #
115
+ # Pass the event `type` whenever you have it: for a known type the result
116
+ # is then a valid payload of that type within max_bytes (max_bytes >= 4096)
117
+ # whenever the input was a valid event. Idempotent: a result fed back in
118
+ # comes back unchanged (truncated false, the same object).
119
+ def serialize_payload(payload, max_bytes = MAX_PAYLOAD_BYTES, type = nil)
120
+ type = type.name if type.is_a?(Symbol)
121
+ type = nil unless type.is_a?(String)
122
+ plan = type.nil? ? nil : PLANS[type]
123
+ begin
124
+ json, spans = stringify_with_spans(payload, payload.is_a?(Hash) && payload.size <= MAX_TRIM_FIELDS)
125
+ rescue Unserializable
126
+ return unserializable_payload(payload, max_bytes, type, plan)
127
+ end
128
+ bytes = json.bytesize
129
+ return [json, payload, false] if bytes <= max_bytes
130
+
131
+ if payload.is_a?(Hash)
132
+ if payload.size <= MAX_TRIM_FIELDS
133
+ trimmed = truncate_fields(payload, max_bytes, bytes, json, spans)
134
+ return [trimmed[0], trimmed[1], true] if trimmed && valid_for?(type, trimmed[1])
135
+ end
136
+ if plan
137
+ skeleton = skeleton_payload(payload, type, plan, max_bytes, bytes, prefix(json, PREVIEW_CHARS))
138
+ return [skeleton[0], skeleton[1], true] if skeleton
139
+ end
140
+ end
141
+
142
+ marker = { "__graphmindTruncated" => true, "bytes" => bytes, "preview" => prefix(json, PREVIEW_CHARS) }
143
+ [stringify(marker), marker, true]
144
+ end
145
+
146
+ # Whether `payload` is a valid payload of `type` (the zod schema in
147
+ # packages/schema/src/events.ts). Unknown types are valid (their payload is
148
+ # opaque), as in the TypeScript reference.
149
+ def valid_for?(type, payload)
150
+ type = type.name if type.is_a?(Symbol)
151
+ return true unless type.is_a?(String) && PLANS.key?(type)
152
+
153
+ Schema.valid?(type, payload)
154
+ rescue StandardError
155
+ false
156
+ end
157
+
158
+ # JSON.stringify's text of a JSON-shaped value. Raises Unserializable.
159
+ def stringify(value)
160
+ writer = Writer.new
161
+ writer.value(value, 0)
162
+ writer.out
163
+ end
164
+
165
+ # JSON text, or nil when the value has none.
166
+ def safe_stringify(value)
167
+ stringify(value)
168
+ rescue Unserializable
169
+ nil
170
+ end
171
+
172
+ # Length of a string in UTF-16 code units (JavaScript's `.length`).
173
+ def utf16_length(text)
174
+ return text.bytesize if text.ascii_only?
175
+
176
+ raw = text.b
177
+ # One unit per character start (every byte that is not a continuation
178
+ # byte), plus one more for each 4-byte (astral) character. A kept lone
179
+ # surrogate (3 bytes, one start) counts 1, as in JavaScript.
180
+ raw.bytesize - raw.count("\x80-\xBF".b) + raw.count("\xF0-\xF7".b)
181
+ end
182
+
183
+ # The first `count` UTF-16 code units of `text` (JavaScript's
184
+ # `text.slice(0, count)`); the text itself when it is not longer.
185
+ def prefix(text, count)
186
+ if text.ascii_only?
187
+ return text if text.bytesize <= count
188
+
189
+ return text.byteslice(0, count)
190
+ end
191
+ return text if utf16_length(text) <= count
192
+
193
+ window = text.byteslice(0, (count * 3) + 4).b
194
+ bytes = window.unpack("C*")
195
+ index = 0
196
+ units = 0
197
+ length = bytes.length
198
+ while units < count && index < length
199
+ lead = bytes[index]
200
+ step = if lead < 0x80 then 1
201
+ elsif lead < 0xE0 then 2
202
+ elsif lead < 0xF0 then 3
203
+ else 4
204
+ end
205
+ if step == 4
206
+ if units + 2 > count
207
+ # The cut falls between the halves of a surrogate pair: keep the
208
+ # lone high half, as JavaScript's slice does.
209
+ code = ((lead & 0x07) << 18) | (((bytes[index + 1] || 0x80) & 0x3F) << 12) |
210
+ (((bytes[index + 2] || 0x80) & 0x3F) << 6) | ((bytes[index + 3] || 0x80) & 0x3F)
211
+ high = 0xD800 + ((code - 0x10000) >> 10)
212
+ half = [0xE0 | (high >> 12), 0x80 | ((high >> 6) & 0x3F), 0x80 | (high & 0x3F)].pack("C*")
213
+ return (window.byteslice(0, index) + half).force_encoding(Encoding::UTF_8)
214
+ end
215
+ units += 2
216
+ else
217
+ units += 1
218
+ end
219
+ index += step
220
+ end
221
+ window.byteslice(0, index).force_encoding(Encoding::UTF_8)
222
+ end
223
+
224
+ # -- JSON writer -----------------------------------------------------------
225
+
226
+ ESCAPES = (0x00..0x1F).to_h { |code| [code.chr, format("\\u%04x", code)] }
227
+ .merge("\b" => "\\b", "\t" => "\\t", "\n" => "\\n", "\f" => "\\f", "\r" => "\\r",
228
+ '"' => '\\"', "\\" => "\\\\").freeze
229
+ ESCAPE_RE = /["\\\x00-\x1F]/
230
+ SURROGATE_PAIR_RE = /\xED([\xA0-\xAF])([\x80-\xBF])\xED([\xB0-\xBF])([\x80-\xBF])/n
231
+ LONE_SURROGATE_RE = /(\xED[\xA0-\xBF][\x80-\xBF])/n
232
+
233
+ # Appends JSON text to one buffer. Top-level Hash field value spans are
234
+ # recorded (byte offsets) so the field trim never serializes a field twice.
235
+ class Writer
236
+ attr_reader :out
237
+
238
+ def initialize
239
+ @out = +""
240
+ @path = {}.compare_by_identity
241
+ end
242
+
243
+ def value(value, depth, spans = nil)
244
+ case value
245
+ when String then string(value)
246
+ when Symbol then string(value.name)
247
+ when Integer then @out << Shrink.integer_text(value)
248
+ when Float then @out << Shrink.float_text(value)
249
+ when true then @out << "true"
250
+ when false then @out << "false"
251
+ when nil then @out << "null"
252
+ when Hash then object(value, depth, spans)
253
+ when Array then array(value, depth)
254
+ else raise Unserializable, "not a JSON value"
255
+ end
256
+ end
257
+
258
+ def string(text)
259
+ @out << Shrink.quote(text)
260
+ end
261
+
262
+ private
263
+
264
+ def enter(container, depth)
265
+ raise Unserializable, "nested too deeply" if depth >= MAX_JSON_DEPTH
266
+ raise Unserializable, "cyclic" if @path.key?(container)
267
+
268
+ @path[container] = true
269
+ end
270
+
271
+ def object(hash, depth, spans)
272
+ enter(hash, depth)
273
+ @out << "{"
274
+ first = true
275
+ hash.each_pair do |key, item|
276
+ @out << "," unless first
277
+ first = false
278
+ string(Shrink.key_string(key))
279
+ @out << ":"
280
+ start = @out.bytesize
281
+ value(item, depth + 1)
282
+ spans << [start, @out.bytesize] if spans
283
+ end
284
+ @out << "}"
285
+ @path.delete(hash)
286
+ end
287
+
288
+ def array(list, depth)
289
+ enter(list, depth)
290
+ @out << "["
291
+ list.each_with_index do |item, i|
292
+ @out << "," unless i.zero?
293
+ value(item, depth + 1)
294
+ end
295
+ @out << "]"
296
+ @path.delete(list)
297
+ end
298
+ end
299
+
300
+ # [json, spans]: spans (for a Hash) lists each field value's byte range,
301
+ # in field order.
302
+ def stringify_with_spans(value, want_spans = true)
303
+ writer = Writer.new
304
+ spans = want_spans && value.is_a?(Hash) ? [] : nil
305
+ writer.value(value, 0, spans)
306
+ [writer.out, spans]
307
+ end
308
+
309
+ def key_string(key)
310
+ case key
311
+ when String then key
312
+ when Symbol then key.name
313
+ else
314
+ begin
315
+ text = key.to_s
316
+ rescue StandardError
317
+ raise Unserializable, "a key has no string form"
318
+ end
319
+ raise Unserializable, "a key has no string form" unless text.is_a?(String)
320
+
321
+ text
322
+ end
323
+ end
324
+
325
+ # A JSON string literal, escaped exactly like JSON.stringify.
326
+ def quote(text)
327
+ text = utf8(text)
328
+ if text.valid_encoding?
329
+ body = text.match?(ESCAPE_RE) ? text.gsub(ESCAPE_RE, ESCAPES) : text
330
+ return "\"#{body}\""
331
+ end
332
+ # Kept surrogate halves (see the module comment). A high half directly
333
+ # followed by a low half is one character, as in JavaScript.
334
+ raw = text.b.gsub(SURROGATE_PAIR_RE) do
335
+ high = 0xD000 | ((Regexp.last_match(1).ord & 0x3F) << 6) | (Regexp.last_match(2).ord & 0x3F)
336
+ low = 0xD000 | ((Regexp.last_match(3).ord & 0x3F) << 6) | (Regexp.last_match(4).ord & 0x3F)
337
+ [0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)].pack("U").b
338
+ end
339
+ body = +""
340
+ raw.split(LONE_SURROGATE_RE).each do |part|
341
+ if part.bytesize == 3 && LONE_SURROGATE_RE.match?(part)
342
+ bytes = part.unpack("C*")
343
+ code = ((bytes[0] & 0x0F) << 12) | ((bytes[1] & 0x3F) << 6) | (bytes[2] & 0x3F)
344
+ body << format("\\u%04x", code)
345
+ else
346
+ piece = part.force_encoding(Encoding::UTF_8)
347
+ raise Unserializable, "string is not UTF-8" unless piece.valid_encoding?
348
+
349
+ body << (piece.match?(ESCAPE_RE) ? piece.gsub(ESCAPE_RE, ESCAPES) : piece)
350
+ end
351
+ end
352
+ "\"#{body}\""
353
+ end
354
+
355
+ def utf8(text)
356
+ encoding = text.encoding
357
+ return text if encoding == Encoding::UTF_8
358
+ return text.dup.force_encoding(Encoding::UTF_8) if encoding == Encoding::BINARY || encoding == Encoding::US_ASCII
359
+
360
+ text.encode(Encoding::UTF_8)
361
+ rescue EncodingError
362
+ raise Unserializable, "string cannot be converted to UTF-8"
363
+ end
364
+
365
+ TWO_53 = 2**53
366
+
367
+ # JSON.stringify of a Float. Ruby's Float#to_s is the shortest round-trip
368
+ # spelling, as JavaScript's is; the two differ only in form: Ruby writes
369
+ # "5.0" for 5 and an exponent outside 1e-4 <= |x| < 1e16. The positional
370
+ # form is taken as is (minus a ".0"); only an exponent form pays for the
371
+ # general conversion. (Measurably 10x faster on a 2,000,000-float array.)
372
+ def float_text(value)
373
+ return "null" unless value.finite?
374
+ return "0" if value.zero?
375
+
376
+ text = value.to_s
377
+ return LoopGuard.js_number(value) if text.include?("e")
378
+
379
+ text.end_with?(".0") ? text.byteslice(0, text.bytesize - 2) : text
380
+ end
381
+
382
+ # JSON.stringify of an integer JavaScript would hold as a double.
383
+ def integer_text(value)
384
+ return value.to_s if value.abs <= TWO_53
385
+
386
+ double = integer_double(value)
387
+ double.finite? ? LoopGuard.js_number(double) : "null"
388
+ end
389
+
390
+ # The double JavaScript's JSON.parse makes of an integer's digits.
391
+ def integer_double(value)
392
+ return value.to_f if value.abs <= TWO_53
393
+
394
+ value.bit_length > 1100 ? Float::INFINITY : Float(value.to_s)
395
+ end
396
+
397
+ # -- tier 2: the field trim ------------------------------------------------
398
+
399
+ # Shrink one oversized field, PRESERVING ITS JSON TYPE (a required string
400
+ # stays a string, an object an object), so the event still validates.
401
+ def shrink_value(value, depth = 0, encoded = nil)
402
+ case value
403
+ when String, Symbol
404
+ text = value.is_a?(Symbol) ? value.name : value
405
+ return text if utf16_length(text) <= PREVIEW_CHARS
406
+
407
+ prefix(text, PREVIEW_CHARS) + TRUNCATION_SUFFIX
408
+ when Array then []
409
+ when Hash
410
+ if depth >= MAX_SHRINK_DEPTH
411
+ return { "__graphmindTruncated" => true, "bytes" => 0, "preview" => "[deeply nested]" }
412
+ end
413
+
414
+ shrunk = {}
415
+ kept = 0
416
+ value.each_pair do |key, item|
417
+ break if kept == MAX_SHRINK_KEYS
418
+
419
+ shrunk[key_string(key)] = shrink_value(item, depth + 1)
420
+ kept += 1
421
+ end
422
+ dropped = value.size - kept
423
+ if depth.positive?
424
+ if dropped.positive?
425
+ shrunk["__graphmindTruncated"] = true
426
+ shrunk["keysDropped"] = dropped
427
+ end
428
+ return shrunk
429
+ end
430
+ text = encoded || safe_stringify(value)
431
+ shrunk["__graphmindTruncated"] = true
432
+ shrunk["bytes"] = text.nil? ? 0 : utf16_length(text)
433
+ shrunk["preview"] = text.nil? ? "[unserializable field]" : prefix(text, PREVIEW_CHARS)
434
+ shrunk["keysDropped"] = dropped if dropped.positive?
435
+ shrunk
436
+ else
437
+ value
438
+ end
439
+ end
440
+
441
+ # Trim the biggest fields of an oversized Hash payload until the rest is at
442
+ # most half the budget; nil when the result still does not fit.
443
+ def truncate_fields(payload, max_bytes, total_bytes, json, spans)
444
+ sizes = []
445
+ payload.each_pair.with_index do |(key, item), index|
446
+ span = spans && spans[index]
447
+ encoded = span ? json.byteslice(span[0], span[1] - span[0]) : safe_stringify(item)
448
+ size = encoded.nil? ? MAX_SAFE_INTEGER : utf16_length(encoded)
449
+ sizes << [key_string(key), size, encoded, item, index]
450
+ end
451
+ # Biggest first; equal sizes keep key order (a stable sort).
452
+ sizes.sort_by! { |entry| [-entry[1], entry[4]] }
453
+
454
+ trimmed = {}
455
+ payload.each_pair { |key, item| trimmed[key_string(key)] = item }
456
+ dropped = []
457
+ remaining = total_bytes
458
+ sizes.each do |key, size, encoded, item, _index|
459
+ break if remaining * 2 <= max_bytes # leave room for the marker itself
460
+
461
+ trimmed[key] = shrink_value(item, 0, encoded)
462
+ dropped << key
463
+ remaining -= size
464
+ end
465
+ return nil if dropped.empty?
466
+
467
+ trimmed["__graphmindTruncated"] = true
468
+ trimmed["bytes"] = total_bytes
469
+ trimmed["preview"] = prefix(json, PREVIEW_CHARS)
470
+ trimmed["fields"] = dropped
471
+ encoded = safe_stringify(trimmed)
472
+ return nil if encoded.nil? || encoded.bytesize > max_bytes
473
+
474
+ [encoded, trimmed]
475
+ end
476
+
477
+ # -- unserializable payloads -----------------------------------------------
478
+
479
+ def unserializable_payload(payload, max_bytes, type, plan)
480
+ begin
481
+ if payload.is_a?(Hash)
482
+ trimmed = truncate_unserializable_fields(payload)
483
+ return [trimmed[0], trimmed[1], true] if trimmed && plan.nil?
484
+
485
+ if trimmed
486
+ bytes = trimmed[0].bytesize
487
+ return [trimmed[0], trimmed[1], true] if bytes <= max_bytes && valid_for?(type, trimmed[1])
488
+
489
+ if bytes > max_bytes && trimmed[1].size <= MAX_TRIM_FIELDS
490
+ fitted = truncate_fields(trimmed[1], max_bytes, bytes, trimmed[0], trimmed[2])
491
+ return [fitted[0], fitted[1], true] if fitted && valid_for?(type, fitted[1])
492
+ end
493
+ end
494
+ if plan
495
+ skeleton = skeleton_payload(payload, type, plan, max_bytes, 0, "[unserializable payload]")
496
+ return [skeleton[0], skeleton[1], true] if skeleton
497
+ end
498
+ end
499
+ rescue Unserializable
500
+ nil # a key with no JSON text: fall through to the whole-payload marker
501
+ end
502
+ marker = { "__graphmindTruncated" => true, "bytes" => 0, "preview" => "[unserializable payload]" }
503
+ [stringify(marker), marker, true]
504
+ end
505
+
506
+ # Keep every field that serializes; replace the others with a marker
507
+ # (arrays with []). [json, payload, spans], or nil when nothing was to blame.
508
+ def truncate_unserializable_fields(payload)
509
+ trimmed = {}
510
+ dropped = []
511
+ payload.each_pair do |key, item|
512
+ name = key_string(key)
513
+ unless safe_stringify(item).nil?
514
+ trimmed[name] = item
515
+ next
516
+ end
517
+ dropped << name
518
+ trimmed[name] =
519
+ if item.is_a?(Array)
520
+ []
521
+ else
522
+ { "__graphmindTruncated" => true, "bytes" => 0, "preview" => "[unserializable value]" }
523
+ end
524
+ end
525
+ return nil if dropped.empty?
526
+
527
+ trimmed["__graphmindTruncated"] = true
528
+ trimmed["fields"] = dropped
529
+ json, spans = stringify_with_spans(trimmed)
530
+ [json, trimmed, spans]
531
+ rescue Unserializable
532
+ nil
533
+ end
534
+
535
+ # -- tier 3: the skeleton --------------------------------------------------
536
+
537
+ SKELETON_ATTEMPTS = [[SKELETON_CHARS, true], [SKELETON_CHARS, false], [SKELETON_MIN_CHARS, false]].freeze
538
+
539
+ def omitted_marker = { "__graphmindTruncated" => true, "bytes" => 0, "preview" => "[omitted]" }
540
+
541
+ # [found, value] for a planned key (a Symbol key counts as its name).
542
+ def lookup(hash, key)
543
+ return [true, hash[key]] if hash.key?(key)
544
+
545
+ symbol = key.to_sym
546
+ return [true, hash[symbol]] if hash.key?(symbol)
547
+
548
+ [false, nil]
549
+ end
550
+
551
+ def scalar?(value)
552
+ value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer) || value.is_a?(Float) ||
553
+ true.equal?(value) || false.equal?(value)
554
+ end
555
+
556
+ # [out, verbatim_keys, kept]: the planned keys of `value`, hard-shrunk.
557
+ def keep_planned(value, plan, chars)
558
+ out = {}
559
+ verbatim = Set.new
560
+ kept = 0
561
+ plan.each do |key, sub|
562
+ found, child = lookup(value, key)
563
+ next unless found
564
+ next if sub == :optional && !scalar?(child)
565
+
566
+ shrunk, same = hard_shrink(child, sub == :optional ? nil : sub, chars)
567
+ out[key] = shrunk
568
+ kept += 1
569
+ verbatim << key if same
570
+ end
571
+ [out, verbatim, kept]
572
+ end
573
+
574
+ # [value, verbatim] for one kept skeleton value.
575
+ def hard_shrink(value, plan, chars)
576
+ case value
577
+ when String, Symbol
578
+ text = value.is_a?(Symbol) ? value.name : value
579
+ return [text, true] if utf16_length(text) <= chars
580
+
581
+ [prefix(text, chars) + TRUNCATION_SUFFIX, false]
582
+ when Integer, Float, true, false, nil then [value, true]
583
+ when Array then [[], value.empty?]
584
+ else
585
+ if value.is_a?(Hash) && plan.is_a?(Hash)
586
+ out, verbatim, kept = keep_planned(value, plan, chars)
587
+ [out, verbatim.size == kept && value.size <= kept]
588
+ else
589
+ [omitted_marker, false]
590
+ end
591
+ end
592
+ end
593
+
594
+ def skeleton_payload(payload, type, plan, max_bytes, bytes, preview)
595
+ SKELETON_ATTEMPTS.each do |chars, full|
596
+ out, verbatim, = keep_planned(payload, plan, chars)
597
+ out["__graphmindTruncated"] = true
598
+ out["bytes"] = bytes
599
+ out["preview"] = full ? preview : ""
600
+ if full
601
+ fields = []
602
+ units = 0
603
+ payload.each_key do |key|
604
+ name = key_string(key)
605
+ next if verbatim.include?(name)
606
+
607
+ fields << name
608
+ units += utf16_length(name)
609
+ # Every name costs at least its length in bytes: once the names
610
+ # alone exceed the budget this attempt cannot fit.
611
+ break if units > max_bytes
612
+ end
613
+ next if units > max_bytes
614
+
615
+ out["fields"] = fields
616
+ end
617
+ json = safe_stringify(out)
618
+ next if json.nil? || json.bytesize > max_bytes
619
+ next unless valid_for?(type, out)
620
+
621
+ return [json, out]
622
+ end
623
+ nil
624
+ end
625
+
626
+ # The event payload schemas (packages/schema/src/events.ts and
627
+ # primitives.ts), as zod v4 applies them: loose objects (extra keys kept),
628
+ # an optional key may be absent but not null, numbers are finite,
629
+ # `.int()` means a safe integer.
630
+ module Schema
631
+ NODE_KINDS = %w[agent llm tool chain retriever server resource prompt custom].freeze
632
+ RUN_STATUSES = %w[ok error aborted].freeze
633
+ PAUSE_POINTS = %w[before after error].freeze
634
+ PAUSE_REASONS = %w[breakpoint error step loop].freeze
635
+ RESUME_ACTIONS = %w[continue retry inject abort].freeze
636
+ TOKEN_CHANNELS = %w[text reasoning tool-args].freeze
637
+ LOOP_KINDS = %w[repeat cycle error-repeat].freeze
638
+ SMART_RULES = %w[error-result truncated-tool-call].freeze
639
+ REFUSAL_CODES = %w[schema shape placeholder truncated disabled unsupported].freeze
640
+
641
+ module_function
642
+
643
+ def valid?(type, payload)
644
+ return false unless payload.is_a?(Hash)
645
+
646
+ case type
647
+ when "run.started"
648
+ required(payload, "app") { |v| str?(v) } &&
649
+ required(payload, "sdk") { |v| sdk?(v) } &&
650
+ optional(payload, "meta") { |v| v.is_a?(Hash) }
651
+ when "run.finished"
652
+ required(payload, "status") { |v| enum?(v, RUN_STATUSES) } &&
653
+ optional(payload, "error") { |v| error_info?(v) }
654
+ when "graph.hint"
655
+ required(payload, "nodes") { |v| v.is_a?(Array) && v.all? { |n| node_hint?(n) } }
656
+ when "node.started"
657
+ required(payload, "nodeId") { |v| str?(v) } &&
658
+ optional(payload, "parentId") { |v| str?(v) } &&
659
+ required(payload, "kind") { |v| enum?(v, NODE_KINDS) } &&
660
+ required(payload, "name") { |v| str?(v) } &&
661
+ required(payload, "instanceId") { |v| str?(v) } &&
662
+ optional(payload, "collapsed") { |v| true.equal?(v) || false.equal?(v) } &&
663
+ optional(payload, "redaction") { |v| redaction?(v) }
664
+ when "node.token"
665
+ required(payload, "nodeId") { |v| str?(v) } &&
666
+ required(payload, "deltas") { |v| v.is_a?(Array) && v.all? { |d| delta?(d) } } &&
667
+ optional(payload, "redaction") { |v| redaction?(v) }
668
+ when "node.finished"
669
+ required(payload, "nodeId") { |v| str?(v) } &&
670
+ optional(payload, "instanceId") { |v| str?(v) } &&
671
+ optional(payload, "usage") { |v| usage?(v) } &&
672
+ required(payload, "durationMs") { |v| nonnegative?(v) } &&
673
+ optional(payload, "heldMs") { |v| nonnegative?(v) } &&
674
+ required(payload, "status") { |v| enum?(v, RUN_STATUSES) } &&
675
+ optional(payload, "redaction") { |v| redaction?(v) }
676
+ when "node.error"
677
+ required(payload, "nodeId") { |v| str?(v) } &&
678
+ optional(payload, "instanceId") { |v| str?(v) } &&
679
+ required(payload, "error") { |v| error_info?(v) } &&
680
+ optional(payload, "heldMs") { |v| nonnegative?(v) }
681
+ when "exec.paused"
682
+ required(payload, "pauseId") { |v| str?(v) } &&
683
+ required(payload, "nodeId") { |v| str?(v) } &&
684
+ required(payload, "point") { |v| enum?(v, PAUSE_POINTS) } &&
685
+ optional(payload, "reason") { |v| enum?(v, PAUSE_REASONS) } &&
686
+ optional(payload, "loop") { |v| loop_info?(v) } &&
687
+ optional(payload, "smart") { |v| smart_info?(v) } &&
688
+ optional(payload, "editable") { |v| bool?(v) } &&
689
+ optional(payload, "instanceId") { |v| str?(v) }
690
+ when "exec.refused"
691
+ required(payload, "pauseId") { |v| str?(v) } &&
692
+ required(payload, "code") { |v| enum?(v, REFUSAL_CODES) } &&
693
+ optional(payload, "message") { |v| str?(v) } &&
694
+ optional(payload, "requestId") { |v| str?(v) }
695
+ when "exec.resumed"
696
+ required(payload, "pauseId") { |v| str?(v) } &&
697
+ required(payload, "action") { |v| enum?(v, RESUME_ACTIONS) } &&
698
+ # z.looseObject({after: z.unknown()}): zod v4 requires the key.
699
+ optional(payload, "edited") { |v| v.is_a?(Hash) && Shrink.lookup(v, "after")[0] } &&
700
+ optional(payload, "requestId") { |v| str?(v) } &&
701
+ optional(payload, "principal") { |v| str?(v) }
702
+ else
703
+ true
704
+ end
705
+ end
706
+
707
+ def required(hash, key)
708
+ found, value = Shrink.lookup(hash, key)
709
+ found && yield(value)
710
+ end
711
+
712
+ def optional(hash, key)
713
+ found, value = Shrink.lookup(hash, key)
714
+ !found || yield(value)
715
+ end
716
+
717
+ def str?(value) = value.is_a?(String) || value.is_a?(Symbol)
718
+
719
+ def enum?(value, list) = str?(value) && list.include?(value.is_a?(Symbol) ? value.name : value)
720
+
721
+ def number?(value)
722
+ case value
723
+ when Float then value.finite?
724
+ when Integer then Shrink.integer_double(value).finite?
725
+ else false
726
+ end
727
+ end
728
+
729
+ def nonnegative?(value) = number?(value) && value >= 0
730
+
731
+ def safe_integer?(value)
732
+ number?(value) && value.abs <= MAX_SAFE_INTEGER && (value.is_a?(Integer) || value == value.truncate)
733
+ end
734
+
735
+ def sdk?(value) = value.is_a?(Hash) && required(value, "name") { |v| str?(v) } &&
736
+ required(value, "version") { |v| str?(v) }
737
+
738
+ def error_info?(value)
739
+ value.is_a?(Hash) && required(value, "name") { |v| str?(v) } &&
740
+ required(value, "message") { |v| str?(v) } && optional(value, "stack") { |v| str?(v) }
741
+ end
742
+
743
+ def node_hint?(value)
744
+ value.is_a?(Hash) && required(value, "nodeId") { |v| str?(v) } &&
745
+ required(value, "kind") { |v| enum?(v, NODE_KINDS) } && required(value, "name") { |v| str?(v) } &&
746
+ optional(value, "parentId") { |v| str?(v) }
747
+ end
748
+
749
+ def delta?(value)
750
+ value.is_a?(Hash) && required(value, "t") { |v| enum?(v, TOKEN_CHANNELS) } &&
751
+ required(value, "v") { |v| str?(v) }
752
+ end
753
+
754
+ def usage?(value)
755
+ value.is_a?(Hash) && required(value, "inputTokens") { |v| count?(v) } &&
756
+ required(value, "outputTokens") { |v| count?(v) } &&
757
+ optional(value, "inclusive") { |v| bool?(v) } &&
758
+ optional(value, "cacheReadTokens") { |v| count?(v) } &&
759
+ optional(value, "cacheWriteTokens") { |v| count?(v) } &&
760
+ optional(value, "reasoningTokens") { |v| count?(v) }
761
+ end
762
+
763
+ def bool?(value) = true.equal?(value) || false.equal?(value)
764
+
765
+ def smart_info?(value)
766
+ value.is_a?(Hash) && required(value, "rule") { |v| enum?(v, SMART_RULES) } &&
767
+ optional(value, "detail") { |v| str?(v) }
768
+ end
769
+
770
+ def redaction?(value)
771
+ value.is_a?(Hash) && required(value, "count") { |v| count?(v) } &&
772
+ required(value, "keys") { |v| v.is_a?(Array) && v.all? { |k| str?(k) } }
773
+ end
774
+
775
+ def loop_info?(value)
776
+ value.is_a?(Hash) && required(value, "repeats") { |v| count?(v) } &&
777
+ required(value, "firstSeq") { |v| count?(v) } && required(value, "lastSeq") { |v| count?(v) } &&
778
+ required(value, "fingerprint") { |v| str?(v) } &&
779
+ optional(value, "kind") { |v| enum?(v, LOOP_KINDS) } &&
780
+ optional(value, "period") { |v| count?(v) && v.positive? } &&
781
+ optional(value, "laps") { |v| count?(v) && v.positive? }
782
+ end
783
+
784
+ def count?(value) = safe_integer?(value) && value >= 0
785
+ end
786
+ end
787
+ end