pikuri-core 0.0.7 → 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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/pikuri/agent/chat_transport.rb +73 -93
  4. data/lib/pikuri/agent/configurator.rb +46 -106
  5. data/lib/pikuri/agent/context_window_detector.rb +44 -85
  6. data/lib/pikuri/agent/control/cancellable.rb +87 -66
  7. data/lib/pikuri/agent/control/interloper.rb +127 -105
  8. data/lib/pikuri/agent/control/step_limit.rb +25 -41
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +123 -188
  11. data/lib/pikuri/agent/extension.rb +118 -94
  12. data/lib/pikuri/agent/extension_context.rb +50 -77
  13. data/lib/pikuri/agent/history.rb +653 -0
  14. data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
  15. data/lib/pikuri/agent/listener/terminal.rb +143 -117
  16. data/lib/pikuri/agent/listener/token_log.rb +101 -140
  17. data/lib/pikuri/agent/listener.rb +23 -43
  18. data/lib/pikuri/agent/listener_list.rb +26 -47
  19. data/lib/pikuri/agent/synthesizer.rb +45 -87
  20. data/lib/pikuri/agent.rb +816 -474
  21. data/lib/pikuri/bundler_env.rb +68 -0
  22. data/lib/pikuri/extractor/html.rb +63 -110
  23. data/lib/pikuri/extractor/passthrough.rb +20 -30
  24. data/lib/pikuri/extractor.rb +93 -154
  25. data/lib/pikuri/file_type.rb +63 -135
  26. data/lib/pikuri/finalizers.rb +32 -47
  27. data/lib/pikuri/paths.rb +104 -13
  28. data/lib/pikuri/ruby_llm_patches.rb +106 -0
  29. data/lib/pikuri/sanitizer.rb +45 -67
  30. data/lib/pikuri/subprocess.rb +75 -119
  31. data/lib/pikuri/testing.rb +296 -0
  32. data/lib/pikuri/tool/calculator.rb +56 -66
  33. data/lib/pikuri/tool/execute_context.rb +42 -0
  34. data/lib/pikuri/tool/fetch.rb +51 -77
  35. data/lib/pikuri/tool/parameters.rb +21 -29
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +52 -80
  38. data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
  39. data/lib/pikuri/tool/search/engines.rb +230 -97
  40. data/lib/pikuri/tool/search/exa.rb +56 -90
  41. data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
  42. data/lib/pikuri/tool/search/result.rb +10 -15
  43. data/lib/pikuri/tool/trifecta_legs.rb +217 -0
  44. data/lib/pikuri/tool/web_scrape.rb +38 -54
  45. data/lib/pikuri/tool/web_search.rb +100 -24
  46. data/lib/pikuri/tool.rb +140 -65
  47. data/lib/pikuri/trifecta/contribution.rb +43 -0
  48. data/lib/pikuri/trifecta/node.rb +47 -0
  49. data/lib/pikuri/trifecta/report.rb +230 -0
  50. data/lib/pikuri/trifecta.rb +127 -0
  51. data/lib/pikuri/url_cache.rb +33 -49
  52. data/lib/pikuri/version.rb +1 -1
  53. data/lib/pikuri-core.rb +72 -88
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +14 -3
@@ -0,0 +1,653 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+ require 'pathname'
6
+ require 'securerandom'
7
+
8
+ module Pikuri
9
+ class Agent
10
+ # A conversation, as a value — what {Agent#export_history} hands out and
11
+ # {Agent#load_history!} takes back. Two encodings, one in-memory value:
12
+ #
13
+ # json = agent.export_history.to_json # self-contained, base64 inside
14
+ # agent.load_history!(History.parse(json))
15
+ #
16
+ # dir = agent.export_history.save('/tmp/conv') # dir/history.json + attachments/
17
+ # agent.load_history!(History.load(dir))
18
+ #
19
+ # Both round-trip to the same value, which is the property that keeps them
20
+ # from drifting into two formats:
21
+ #
22
+ # History.load(dir) == History.parse(json) # => true
23
+ #
24
+ # The system prompt is *not* here — {Agent#clear_conversation} re-assembles
25
+ # it from every extension on load, so a resumed conversation gets today's
26
+ # prompt (a re-read +MACHINE.md+, a fresh memory persona) rather than
27
+ # yesterday's.
28
+ #
29
+ # == The doors in are the validators
30
+ #
31
+ # {parse}, {from_h} and {load} are the only ways to build one from
32
+ # untrusted bytes, and each raises {Error} rather than returning something
33
+ # half-built. There is deliberately no "accepts a String or a Hash or a
34
+ # History" convenience: one way in means one place validation could be
35
+ # skipped, i.e. none.
36
+ #
37
+ # == Implementation details
38
+ #
39
+ # Attachment bytes are held **eagerly**, whichever door they came in
40
+ # through — never a lazy handle to a folder that can be deleted underneath
41
+ # it. So +==+ compares content, and a +History+ survives its folder being
42
+ # moved. The cost is memory bounded by attachment count; a lazy reference
43
+ # would reintroduce exactly the "the bytes changed since the model saw
44
+ # them" failure this format exists to avoid.
45
+ #
46
+ # The container is one JSON object, not JSONL. Should append ever be worth
47
+ # it, going to JSONL is a *sniff* (does the whole file parse as one
48
+ # object?) rather than a version bump — see +DECISIONS.md+ +D_history_container+.
49
+ class History < Data.define(:version, :messages)
50
+ # Bumped only for a change an old reader could not survive; an added
51
+ # optional key is not one (readers ignore unknown keys). A file claiming
52
+ # a higher version is refused, never best-effort loaded.
53
+ CURRENT_VERSION = 1
54
+
55
+ # Basename of the JSON written by {#save}, and read by {load}.
56
+ HISTORY_FILE = 'history.json'
57
+
58
+ # Subfolder {#save} writes attachment bytes into. Fixed, so
59
+ # "in the folder" is one comparison rather than a policy.
60
+ ATTACHMENTS_DIR = 'attachments'
61
+
62
+ # Message roles that may appear in an exported history. +:system+ cannot:
63
+ # the prompt is re-assembled, not replayed.
64
+ ROLES = %w[user assistant tool].freeze
65
+
66
+ # What a +user+-role message *is*: something the human typed
67
+ # (+"user"+), or reference text pikuri injected on its own initiative —
68
+ # recalled memory, a host-loaded skill, a path-activation promotion
69
+ # (+"reference"+, what {Agent#append_reference_block} appends).
70
+ #
71
+ # Local only: both land on the wire as +role: :user+, and no provider is
72
+ # told which is which. It exists so a resumed conversation can tell a
73
+ # stale skill listing from something the human meant.
74
+ KINDS = %w[user reference].freeze
75
+
76
+ # Raised by every door in: a malformed record, a dangling tool result, an
77
+ # attachment outside the folder, a version from the future.
78
+ class Error < StandardError; end
79
+
80
+ # One tool call the assistant asked for. +id+ is the provider's own
81
+ # identifier and is load-bearing — the +tool+ message answering this call
82
+ # repeats it, and providers reject a pair that does not match.
83
+ ToolCall = Data.define(:id, :name, :arguments)
84
+
85
+ # An assistant reasoning block, as the provider reported it.
86
+ #
87
+ # The two fields are not symmetric, and which one carries the substance
88
+ # flips by provider: on Anthropic +signature+ is the full reasoning,
89
+ # encrypted, and +text+ a summary that is empty by default; on an
90
+ # OpenAI-compatible server +text+ is the reasoning itself and
91
+ # +signature+ is usually absent. So the whole block, never one field, is
92
+ # what {Agent} withholds from a model that did not produce it — see
93
+ # +DECISIONS.md+ +D_thinking_block_replay+.
94
+ Thinking = Data.define(:text, :signature)
95
+
96
+ # Per-message token usage, as the provider reported it. Carried so a
97
+ # host's context readout can be reseeded after a load — nothing on the
98
+ # wire needs it.
99
+ Tokens = Data.define(:input, :output, :cached, :cache_creation, :thinking)
100
+
101
+ # A file the model was shown — an image from +read+, a pasted document.
102
+ # Holds the actual bytes; {#stored_name} is what {History#save} calls it
103
+ # on disk.
104
+ Attachment = Data.define(:mime, :filename, :bytes) do
105
+ # @param mime [String] e.g. +"image/png"+
106
+ # @param filename [String] the name the model was shown
107
+ # @param bytes [String] the file's contents; re-tagged binary, since
108
+ # the same image arrives as UTF-8 from a source file and as
109
+ # ASCII-8BIT from base64, and two differently-tagged copies of one
110
+ # PNG are not +==+ — which would quietly break the round-trip
111
+ # property this whole type exists to hold.
112
+ def initialize(mime:, filename:, bytes:)
113
+ super(mime: mime, filename: filename, bytes: bytes.b)
114
+ end
115
+
116
+ # Content-addressed filename: the digest prefix makes it unique and
117
+ # self-verifying, so the same image read twice is stored once and a
118
+ # tampered file is caught at load.
119
+ #
120
+ # Attachment.new(mime: 'image/png', filename: 'shot.png', bytes: png).stored_name
121
+ # # => "3f9a1c8e2b-shot.png"
122
+ #
123
+ # @return [String] a bare basename, never a path.
124
+ def stored_name
125
+ ext = File.extname(filename.to_s)
126
+ stem = File.basename(filename.to_s, ext).gsub(/[^A-Za-z0-9._-]+/, '-')
127
+ stem = 'attachment' if stem.empty?
128
+ "#{digest[0, 10]}-#{stem}#{ext}"
129
+ end
130
+
131
+ # @return [String] SHA-256 of {#bytes}, hex.
132
+ def digest
133
+ Digest::SHA256.hexdigest(bytes)
134
+ end
135
+ end
136
+
137
+ # One message. +id+ and +kind+ are pikuri's own and never reach a
138
+ # provider; everything else is what the model saw.
139
+ #
140
+ # +content+ is always a String (empty on a pure tool-call turn, which is
141
+ # what ruby_llm stores); anything the model was *shown* beyond text is an
142
+ # {Attachment} in +attachments+.
143
+ Message = Data.define(
144
+ :id, :role, :kind, :content, :attachments,
145
+ :tool_calls, :tool_call_id, :model_id, :thinking, :tokens
146
+ ) do
147
+ # @param id [String] see {History.mint_id}
148
+ # @param role [String] one of {ROLES}
149
+ # @param kind [String] one of {KINDS}; defaults to +"user"+
150
+ # @param content [String] the text the model saw; +""+ is legal
151
+ # @param attachments [Array<Attachment>]
152
+ # @param tool_calls [Array<ToolCall>] empty unless the assistant asked
153
+ # for tools
154
+ # @param tool_call_id [String, nil] set exactly on a +tool+ message
155
+ # @param model_id [String, nil] which model produced this
156
+ # @param thinking [Thinking, nil]
157
+ # @param tokens [Tokens, nil]
158
+ def initialize(id:, role:, content:, kind: 'user', attachments: [], tool_calls: [],
159
+ tool_call_id: nil, model_id: nil, thinking: nil, tokens: nil)
160
+ super
161
+ end
162
+
163
+ # @return [Boolean] whether this message asked for tools.
164
+ def tool_call? = !tool_calls.empty?
165
+
166
+ # Snapshot a live +RubyLLM::Message+. Attachment bytes are read *now*
167
+ # — the file a tool showed the model may be gone or changed by the
168
+ # time anyone loads this back.
169
+ #
170
+ # @param msg [RubyLLM::Message] must not be +role: :system+.
171
+ # @param id [String] see {History.mint_id}.
172
+ # @param kind [String] one of {KINDS}.
173
+ # @return [Message]
174
+ def self.from_ruby_llm(msg, id:, kind: 'user')
175
+ text, attachments = split_content(msg.content)
176
+ new(
177
+ id: id, role: msg.role.to_s, kind: kind, content: text, attachments: attachments,
178
+ tool_calls: (msg.tool_calls || {}).values.map do |c|
179
+ ToolCall.new(id: c.id, name: c.name, arguments: c.arguments || {})
180
+ end,
181
+ tool_call_id: msg.tool_call_id,
182
+ model_id: msg.model_id,
183
+ thinking: msg.thinking && Thinking.new(text: msg.thinking.text, signature: msg.thinking.signature),
184
+ tokens: msg.tokens && Tokens.new(
185
+ input: msg.tokens.input, output: msg.tokens.output, cached: msg.tokens.cached,
186
+ cache_creation: msg.tokens.cache_creation, thinking: msg.tokens.thinking
187
+ )
188
+ )
189
+ end
190
+
191
+ # @param content [String, RubyLLM::Content, nil]
192
+ # @return [Array(String, Array<Attachment>)]
193
+ def self.split_content(content)
194
+ return ['', []] if content.nil?
195
+ return [content, []] if content.is_a?(String)
196
+ return [content.to_s, []] unless content.respond_to?(:attachments)
197
+
198
+ [content.text.to_s, content.attachments.map do |a|
199
+ Attachment.new(mime: a.mime_type, filename: a.filename.to_s, bytes: a.content)
200
+ end]
201
+ end
202
+ private_class_method :split_content
203
+
204
+ # Rebuild the +RubyLLM::Message+ this was taken from.
205
+ #
206
+ # @param keep_thinking [Boolean] +false+ replays the turn without its
207
+ # {Thinking} block at all — what {Agent} passes for a turn some
208
+ # *other* model produced, since a thinking block lands in a slot the
209
+ # receiving model reads as its own scratchpad. Dropped from the
210
+ # *message*, not from the record: {Agent#export_history} still
211
+ # writes it. See +DECISIONS.md+ +D_thinking_block_replay+.
212
+ # @return [RubyLLM::Message]
213
+ def to_ruby_llm(keep_thinking: true)
214
+ calls = tool_calls.to_h do |c|
215
+ [c.id, RubyLLM::ToolCall.new(id: c.id, name: c.name, arguments: c.arguments)]
216
+ end
217
+ replayed = thinking if keep_thinking
218
+ RubyLLM::Message.new(
219
+ role: role.to_sym,
220
+ content: ruby_llm_content,
221
+ tool_calls: calls.empty? ? nil : calls,
222
+ tool_call_id: tool_call_id,
223
+ model_id: model_id,
224
+ thinking: replayed && RubyLLM::Thinking.build(
225
+ text: replayed.text, signature: replayed.signature
226
+ ),
227
+ tokens: tokens && RubyLLM::Tokens.build(**tokens.to_h)
228
+ )
229
+ end
230
+
231
+ private
232
+
233
+ def ruby_llm_content
234
+ return content if attachments.empty?
235
+
236
+ RubyLLM::Content.new(content).tap do |c|
237
+ attachments.each { |a| c.add_attachment(StringIO.new(a.bytes), filename: a.filename) }
238
+ end
239
+ end
240
+ end
241
+
242
+ class << self
243
+ # Parse the self-contained JSON form.
244
+ #
245
+ # History.parse(File.read('conv.json'))
246
+ #
247
+ # @param json [String] what {#to_json} produced.
248
+ # @return [History]
249
+ # @raise [Error] on malformed JSON, a bad record, a future version, or
250
+ # an attachment stored as a file — a String has no folder to resolve
251
+ # one against, so that case names {load} rather than guessing.
252
+ def parse(json)
253
+ raw = begin
254
+ JSON.parse(json)
255
+ rescue JSON::ParserError => e
256
+ raise Error, "history is not valid JSON: #{e.message}"
257
+ end
258
+ from_h(raw)
259
+ end
260
+
261
+ # Read a folder written by {#save}.
262
+ #
263
+ # @param dir [String, Pathname] the folder holding {HISTORY_FILE}.
264
+ # @return [History]
265
+ # @raise [Error] as {from_h}, plus a missing, unreadable, or
266
+ # digest-mismatched attachment.
267
+ def load(dir)
268
+ dir = Pathname(dir)
269
+ file = dir / HISTORY_FILE
270
+ raise Error, "no #{HISTORY_FILE} in #{dir}" unless file.file?
271
+
272
+ raw = begin
273
+ JSON.parse(file.read)
274
+ rescue JSON::ParserError => e
275
+ raise Error, "#{file} is not valid JSON: #{e.message}"
276
+ end
277
+ from_h(raw, dir: dir)
278
+ end
279
+
280
+ # Validate a plain Hash — the shared gate {parse} and {load} both run
281
+ # through, and the only place a record's shape is checked.
282
+ #
283
+ # Unknown *keys* are ignored, so a file written by a newer pikuri
284
+ # still loads; malformed *known* keys raise. That asymmetry is the
285
+ # whole forward-compatibility story, and it is why {CURRENT_VERSION}
286
+ # is expected to stay +1+.
287
+ #
288
+ # @param raw [Hash] parsed JSON, String keys.
289
+ # @param dir [Pathname, nil] folder to resolve file-backed attachments
290
+ # against; +nil+ refuses them.
291
+ # @return [History]
292
+ # @raise [Error] on a non-Hash, a version above {CURRENT_VERSION}, a
293
+ # message with a bad role/kind/id, a duplicate id, or a +tool+
294
+ # message whose +tool_call_id+ no preceding assistant message issued.
295
+ def from_h(raw, dir: nil)
296
+ raise Error, "expected a Hash, got #{raw.class}" unless raw.is_a?(Hash)
297
+
298
+ version = raw['version']
299
+ raise Error, "missing version (expected #{CURRENT_VERSION})" if version.nil?
300
+ if version.is_a?(Integer) && version > CURRENT_VERSION
301
+ raise Error, "history version #{version} is newer than this pikuri understands " \
302
+ "(#{CURRENT_VERSION}); upgrade pikuri to read it"
303
+ end
304
+ raise Error, "version must be #{CURRENT_VERSION}, got #{version.inspect}" unless version == CURRENT_VERSION
305
+
306
+ messages = raw['messages']
307
+ raise Error, "messages must be an Array, got #{messages.class}" unless messages.is_a?(Array)
308
+
309
+ built = messages.each_with_index.map { |m, i| message_from_h(m, i, dir) }
310
+ check_ids!(built)
311
+ check_tool_pairing!(built)
312
+ new(version: CURRENT_VERSION, messages: built)
313
+ end
314
+
315
+ # A fresh message id: lexicographically sortable, so ordering falls out
316
+ # of the id itself and a future tree format inherits identity for free.
317
+ #
318
+ # History.mint_id # => "0198c4f1a2b30001f3c9a2"
319
+ #
320
+ # Not a wire value — no provider is ever shown one.
321
+ #
322
+ # @return [String] 22 hex chars: 12 of millisecond timestamp, 4 of
323
+ # per-process counter (so two ids minted in one millisecond still
324
+ # sort), 6 random.
325
+ def mint_id
326
+ MINT_LOCK.synchronize do
327
+ @counter = ((@counter || -1) + 1) & 0xffff
328
+ format('%012x%04x%s', (Time.now.to_f * 1000).to_i, @counter, SecureRandom.hex(3))
329
+ end
330
+ end
331
+
332
+ private
333
+
334
+ def message_from_h(raw, index, dir)
335
+ raise Error, "message #{index}: expected a Hash, got #{raw.class}" unless raw.is_a?(Hash)
336
+
337
+ role = raw['role']
338
+ raise Error, "message #{index}: role must be one of #{ROLES.join('/')}, got #{role.inspect}" unless
339
+ ROLES.include?(role)
340
+
341
+ # Absent kind reads as "user": additive fields must not break an
342
+ # older file, and this is the only field with a sensible default.
343
+ kind = raw['kind'] || 'user'
344
+ raise Error, "message #{index}: kind must be one of #{KINDS.join('/')}, got #{kind.inspect}" unless
345
+ KINDS.include?(kind)
346
+
347
+ id = raw['id']
348
+ raise Error, "message #{index}: id must be a non-empty String, got #{id.inspect}" unless
349
+ id.is_a?(String) && !id.empty?
350
+
351
+ tool_call_id = raw['tool_call_id']
352
+ if role == 'tool' && !(tool_call_id.is_a?(String) && !tool_call_id.empty?)
353
+ raise Error, "message #{index} (#{id}): a tool message needs a tool_call_id, got #{tool_call_id.inspect}"
354
+ end
355
+ if role != 'tool' && !tool_call_id.nil?
356
+ raise Error, "message #{index} (#{id}): only a tool message may carry a tool_call_id"
357
+ end
358
+
359
+ Message.new(
360
+ id: id, role: role, kind: kind,
361
+ content: string_field(raw['content'], index, id, 'content'),
362
+ attachments: (raw['attachments'] || []).map { |a| attachment_from_h(a, index, id, dir) },
363
+ tool_calls: (raw['tool_calls'] || []).map { |t| tool_call_from_h(t, index, id) },
364
+ tool_call_id: tool_call_id,
365
+ model_id: raw['model_id'],
366
+ thinking: thinking_from_h(raw['thinking'], index, id),
367
+ tokens: tokens_from_h(raw['tokens'], index, id)
368
+ )
369
+ end
370
+
371
+ def string_field(value, index, id, name)
372
+ return value if value.is_a?(String)
373
+ return '' if value.nil?
374
+
375
+ raise Error, "message #{index} (#{id}): #{name} must be a String, got #{value.class}"
376
+ end
377
+
378
+ def tool_call_from_h(raw, index, id)
379
+ raise Error, "message #{index} (#{id}): each tool_call must be a Hash" unless raw.is_a?(Hash)
380
+
381
+ call_id = raw['id']
382
+ raise Error, "message #{index} (#{id}): a tool_call needs a non-empty id" unless
383
+ call_id.is_a?(String) && !call_id.empty?
384
+ raise Error, "message #{index} (#{id}): tool_call #{call_id} needs a name" unless raw['name'].is_a?(String)
385
+
386
+ args = raw['arguments'] || {}
387
+ raise Error, "message #{index} (#{id}): tool_call #{call_id} arguments must be a Hash" unless args.is_a?(Hash)
388
+
389
+ ToolCall.new(id: call_id, name: raw['name'], arguments: args)
390
+ end
391
+
392
+ def thinking_from_h(raw, index, id)
393
+ return nil if raw.nil?
394
+ raise Error, "message #{index} (#{id}): thinking must be a Hash" unless raw.is_a?(Hash)
395
+
396
+ Thinking.new(text: raw['text'], signature: raw['signature'])
397
+ end
398
+
399
+ def tokens_from_h(raw, index, id)
400
+ return nil if raw.nil?
401
+ raise Error, "message #{index} (#{id}): tokens must be a Hash" unless raw.is_a?(Hash)
402
+
403
+ Tokens.new(
404
+ input: raw['input'], output: raw['output'], cached: raw['cached'],
405
+ cache_creation: raw['cache_creation'], thinking: raw['thinking']
406
+ )
407
+ end
408
+
409
+ def attachment_from_h(raw, index, id, dir)
410
+ raise Error, "message #{index} (#{id}): each attachment must be a Hash" unless raw.is_a?(Hash)
411
+
412
+ filename = raw['filename'].to_s
413
+ bytes =
414
+ case raw['kind']
415
+ when 'inline' then decode_inline(raw['bytes'], index, id)
416
+ when 'file' then read_confined(dir, raw['file'], index, id)
417
+ else raise Error, "message #{index} (#{id}): attachment kind must be inline or file, " \
418
+ "got #{raw['kind'].inspect}"
419
+ end
420
+
421
+ Attachment.new(mime: raw['mime'], filename: filename, bytes: bytes)
422
+ end
423
+
424
+ def decode_inline(data, index, id)
425
+ raise Error, "message #{index} (#{id}): inline attachment needs base64 bytes" unless data.is_a?(String)
426
+
427
+ data.unpack1('m0')
428
+ rescue ArgumentError => e
429
+ raise Error, "message #{index} (#{id}): attachment base64 is malformed: #{e.message}"
430
+ end
431
+
432
+ # Read one attachment, refusing anything that is not a plain file
433
+ # directly inside +dir/attachments+.
434
+ #
435
+ # The comparison is on the **resolved** path, not the string: a file
436
+ # sitting in the folder may itself be a symlink to +~/.ssh/id_ed25519+,
437
+ # which every textual check passes. That is the case this method
438
+ # exists for.
439
+ def read_confined(dir, name, index, id)
440
+ where = "message #{index} (#{id})"
441
+ if dir.nil?
442
+ raise Error, "#{where}: this history references the external attachment #{name.inspect}; " \
443
+ 'use History.load(dir) rather than History.parse'
444
+ end
445
+ raise Error, "#{where}: attachment file must be a String" unless name.is_a?(String)
446
+ unless !name.empty? && name == File.basename(name) && !name.start_with?('.')
447
+ raise Error, "#{where}: attachment file #{name.inspect} must be a bare filename"
448
+ end
449
+
450
+ root = dir / ATTACHMENTS_DIR
451
+ path = root / name
452
+ raise Error, "#{where}: attachment #{name.inspect} is missing from #{root}" unless path.file?
453
+
454
+ real = path.realpath
455
+ unless real.dirname == root.realpath
456
+ raise Error, "#{where}: attachment #{name.inspect} resolves to #{real}, outside #{root}"
457
+ end
458
+
459
+ bytes = real.binread
460
+ expected = name[/\A[0-9a-f]{10}/]
461
+ if expected && !Digest::SHA256.hexdigest(bytes).start_with?(expected)
462
+ raise Error, "#{where}: attachment #{name.inspect} does not match its content digest"
463
+ end
464
+
465
+ bytes
466
+ end
467
+
468
+ def check_ids!(messages)
469
+ seen = {}
470
+ messages.each_with_index do |m, i|
471
+ if (first = seen[m.id])
472
+ raise Error, "message #{i}: duplicate id #{m.id} (already used by message #{first})"
473
+ end
474
+
475
+ seen[m.id] = i
476
+ end
477
+ end
478
+
479
+ # Refuse a tool result nothing asked for, and one answered twice.
480
+ #
481
+ # The mirror case — a call with *no* result — is deliberately legal
482
+ # here: that is what a process killed mid-batch leaves behind, and
483
+ # {Agent#load_history!} repairs it by synthesizing an interrupted
484
+ # result. Refusing it would make the crash case the one thing that
485
+ # cannot be loaded.
486
+ def check_tool_pairing!(messages)
487
+ issued = {}
488
+ answered = {}
489
+ messages.each_with_index do |m, i|
490
+ m.tool_calls.each { |c| issued[c.id] = i }
491
+ next unless m.role == 'tool'
492
+
493
+ unless issued.key?(m.tool_call_id)
494
+ raise Error, "message #{i} (#{m.id}): tool_call_id #{m.tool_call_id} " \
495
+ 'was never issued by a preceding assistant message'
496
+ end
497
+ if (prev = answered[m.tool_call_id])
498
+ raise Error, "message #{i} (#{m.id}): tool_call_id #{m.tool_call_id} " \
499
+ "was already answered by message #{prev}"
500
+ end
501
+
502
+ answered[m.tool_call_id] = i
503
+ end
504
+ end
505
+ end
506
+
507
+ MINT_LOCK = Mutex.new
508
+ private_constant :MINT_LOCK
509
+
510
+ # @param version [Integer] must be {CURRENT_VERSION}; defaulted so
511
+ # callers building one in Ruby never restate it.
512
+ # @param messages [Array<Message>]
513
+ def initialize(version: CURRENT_VERSION, messages: [])
514
+ super
515
+ end
516
+
517
+ # The self-contained Hash: attachments inline, as base64.
518
+ #
519
+ # @param inline_attachments [Boolean] +false+ renders each attachment as
520
+ # a reference to its {Attachment#stored_name}, which only {#save}
521
+ # wants — the bytes then live in the folder, not the Hash.
522
+ # @return [Hash{String => Object}] JSON-safe, String keys.
523
+ def to_h(inline_attachments: true)
524
+ {
525
+ 'version' => version,
526
+ 'messages' => messages.map { |m| message_to_h(m, inline_attachments) }
527
+ }
528
+ end
529
+
530
+ # @return [String] pretty-printed {#to_h}, so a stored conversation stays
531
+ # readable and diffs a turn at a time.
532
+ def to_json(*_args)
533
+ JSON.pretty_generate(to_h)
534
+ end
535
+
536
+ # Write +dir/history.json+ plus +dir/attachments/+, creating the folder if
537
+ # needed.
538
+ #
539
+ # agent.export_history.save('~/conversations/2026-08-27-abc')
540
+ #
541
+ # Attachments are written **first**: +history.json+ is the commit point,
542
+ # so an interrupted save leaves unreferenced bytes (harmless) rather than
543
+ # a reference to bytes that are not there.
544
+ #
545
+ # Never deletes. An attachment the history no longer references simply
546
+ # stays — pikuri writes into this folder but does not own everything in
547
+ # it, and removing a file a human put there is a worse failure than an
548
+ # orphan. Re-saving is idempotent: a content-addressed name that already
549
+ # exists is skipped after one +stat+, so cost tracks the history rather
550
+ # than the folder.
551
+ #
552
+ # @param dir [String, Pathname] destination; created if absent.
553
+ # @return [Pathname] +dir+, so a caller can chain.
554
+ def save(dir)
555
+ dir = Pathname(dir).expand_path
556
+ attachments = messages.flat_map(&:attachments)
557
+
558
+ unless attachments.empty?
559
+ root = dir / ATTACHMENTS_DIR
560
+ root.mkpath
561
+ attachments.each do |a|
562
+ path = root / a.stored_name
563
+ atomic_write(path, a.bytes) unless path.exist?
564
+ end
565
+ end
566
+
567
+ dir.mkpath
568
+ atomic_write(dir / HISTORY_FILE, JSON.pretty_generate(to_h(inline_attachments: false)))
569
+ dir
570
+ end
571
+
572
+ # A copy in which every tool call has an answer, synthesizing
573
+ # {INTERRUPTED_RESULT} for any the recorded conversation left hanging —
574
+ # what a process killed mid-batch leaves behind.
575
+ #
576
+ # Providers reject an assistant +tool_use+ with no matching result, so
577
+ # this has to happen before the history goes back into a chat. It runs
578
+ # at *load*, never at export: the file stays a faithful record of what
579
+ # happened, and the repair is applied on the way back in.
580
+ #
581
+ # Telling the model "you asked, it did not finish" beats dropping the
582
+ # assistant turn, which would erase that it ever decided to call the
583
+ # tool and leave it to re-derive the decision from nothing.
584
+ #
585
+ # @return [History] +self+ when nothing was dangling.
586
+ def repair_interrupted_tool_calls
587
+ out = []
588
+ pending = []
589
+ repaired = false
590
+ messages.each_with_index do |m, i|
591
+ # Hold synthetics until the batch's real results have gone by, so
592
+ # the answering run stays contiguous and in the order it happened.
593
+ unless pending.empty? || m.role == 'tool'
594
+ out.concat(pending)
595
+ pending = []
596
+ end
597
+ out << m
598
+ next unless m.tool_call?
599
+
600
+ answered = messages[(i + 1)..].take_while { |n| n.role == 'tool' }.map(&:tool_call_id)
601
+ pending = m.tool_calls.reject { |c| answered.include?(c.id) }.map do |call|
602
+ repaired = true
603
+ Message.new(id: History.mint_id, role: 'tool', tool_call_id: call.id, content: INTERRUPTED_RESULT)
604
+ end
605
+ end
606
+ out.concat(pending)
607
+ repaired ? with(messages: out) : self
608
+ end
609
+
610
+ # Observation synthesized for a tool call whose result never arrived.
611
+ INTERRUPTED_RESULT = '[Tool execution was interrupted]'
612
+
613
+ private
614
+
615
+ def message_to_h(msg, inline)
616
+ h = {
617
+ 'id' => msg.id,
618
+ 'role' => msg.role,
619
+ 'kind' => msg.kind,
620
+ 'content' => msg.content
621
+ }
622
+ h['attachments'] = msg.attachments.map { |a| attachment_to_h(a, inline) } unless msg.attachments.empty?
623
+ h['tool_calls'] = msg.tool_calls.map { |c| c.to_h.transform_keys(&:to_s) } unless msg.tool_calls.empty?
624
+ h['tool_call_id'] = msg.tool_call_id if msg.tool_call_id
625
+ h['model_id'] = msg.model_id if msg.model_id
626
+ h['thinking'] = compact_h(msg.thinking) if msg.thinking
627
+ h['tokens'] = compact_h(msg.tokens) if msg.tokens
628
+ h
629
+ end
630
+
631
+ def attachment_to_h(att, inline)
632
+ base = { 'mime' => att.mime, 'filename' => att.filename }
633
+ if inline
634
+ base.merge('kind' => 'inline', 'bytes' => [att.bytes].pack('m0'))
635
+ else
636
+ base.merge('kind' => 'file', 'file' => att.stored_name)
637
+ end
638
+ end
639
+
640
+ def compact_h(value)
641
+ value.to_h.compact.transform_keys(&:to_s)
642
+ end
643
+
644
+ # Write via a sibling temp file and rename, so a reader never sees a
645
+ # half-written file and a crash cannot leave one.
646
+ def atomic_write(path, content)
647
+ tmp = path.dirname / ".#{path.basename}.tmp"
648
+ tmp.binwrite(content)
649
+ tmp.rename(path.to_s)
650
+ end
651
+ end
652
+ end
653
+ end