Class: Pikuri::Agent::History

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/agent/history.rb

Overview

A conversation, as a value — what #export_history hands out and #load_history! takes back. Two encodings, one in-memory value:

json = agent.export_history.to_json        # self-contained, base64 inside
agent.load_history!(History.parse(json))

dir = agent.export_history.save('/tmp/conv')  # dir/history.json + attachments/
agent.load_history!(History.load(dir))

Both round-trip to the same value, which is the property that keeps them from drifting into two formats:

History.load(dir) == History.parse(json)   # => true

The system prompt is not here — #clear_conversation re-assembles it from every extension on load, so a resumed conversation gets today's prompt (a re-read MACHINE.md, a fresh memory persona) rather than yesterday's.

The doors in are the validators

History.parse, History.from_h and History.load are the only ways to build one from untrusted bytes, and each raises Error rather than returning something half-built. There is deliberately no "accepts a String or a Hash or a History" convenience: one way in means one place validation could be skipped, i.e. none.

Implementation details

Attachment bytes are held eagerly, whichever door they came in through — never a lazy handle to a folder that can be deleted underneath it. So == compares content, and a History survives its folder being moved. The cost is memory bounded by attachment count; a lazy reference would reintroduce exactly the "the bytes changed since the model saw them" failure this format exists to avoid.

The container is one JSON object, not JSONL. Should append ever be worth it, going to JSONL is a sniff (does the whole file parse as one object?) rather than a version bump — see DECISIONS.md D_history_container.

Defined Under Namespace

Classes: Attachment, Error, Message, Thinking, Tokens, ToolCall

Constant Summary collapse

CURRENT_VERSION =

Bumped only for a change an old reader could not survive; an added optional key is not one (readers ignore unknown keys). A file claiming a higher version is refused, never best-effort loaded.

1
HISTORY_FILE =

Basename of the JSON written by #save, and read by load.

'history.json'
ATTACHMENTS_DIR =

Subfolder #save writes attachment bytes into. Fixed, so "in the folder" is one comparison rather than a policy.

'attachments'
ROLES =

Message roles that may appear in an exported history. :system cannot: the prompt is re-assembled, not replayed.

%w[user assistant tool].freeze
KINDS =

What a +user+-role message is: something the human typed (+"user"+), or reference text pikuri injected on its own initiative — recalled memory, a host-loaded skill, a path-activation promotion (+"reference"+, what Pikuri::Agent#append_reference_block appends).

Local only: both land on the wire as role: :user, and no provider is told which is which. It exists so a resumed conversation can tell a stale skill listing from something the human meant.

%w[user reference].freeze
INTERRUPTED_RESULT =

Observation synthesized for a tool call whose result never arrived.

'[Tool execution was interrupted]'

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(version: CURRENT_VERSION, messages: []) ⇒ History

Returns a new instance of History.

Parameters:

  • version (Integer) (defaults to: CURRENT_VERSION)

    must be CURRENT_VERSION; defaulted so callers building one in Ruby never restate it.

  • messages (Array<Message>) (defaults to: [])


513
514
515
# File 'lib/pikuri/agent/history.rb', line 513

def initialize(version: CURRENT_VERSION, messages: [])
  super
end

Class Method Details

.from_h(raw, dir: nil) ⇒ History

Validate a plain Hash — the shared gate parse and load both run through, and the only place a record's shape is checked.

Unknown keys are ignored, so a file written by a newer pikuri still loads; malformed known keys raise. That asymmetry is the whole forward-compatibility story, and it is why CURRENT_VERSION is expected to stay 1.

Parameters:

  • raw (Hash)

    parsed JSON, String keys.

  • dir (Pathname, nil) (defaults to: nil)

    folder to resolve file-backed attachments against; nil refuses them.

Returns:

Raises:

  • (Error)

    on a non-Hash, a version above CURRENT_VERSION, a message with a bad role/kind/id, a duplicate id, or a tool message whose tool_call_id no preceding assistant message issued.



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/pikuri/agent/history.rb', line 295

def from_h(raw, dir: nil)
  raise Error, "expected a Hash, got #{raw.class}" unless raw.is_a?(Hash)

  version = raw['version']
  raise Error, "missing version (expected #{CURRENT_VERSION})" if version.nil?
  if version.is_a?(Integer) && version > CURRENT_VERSION
    raise Error, "history version #{version} is newer than this pikuri understands " \
                 "(#{CURRENT_VERSION}); upgrade pikuri to read it"
  end
  raise Error, "version must be #{CURRENT_VERSION}, got #{version.inspect}" unless version == CURRENT_VERSION

  messages = raw['messages']
  raise Error, "messages must be an Array, got #{messages.class}" unless messages.is_a?(Array)

  built = messages.each_with_index.map { |m, i| message_from_h(m, i, dir) }
  check_ids!(built)
  check_tool_pairing!(built)
  new(version: CURRENT_VERSION, messages: built)
end

.load(dir) ⇒ History

Read a folder written by #save.

Parameters:

  • dir (String, Pathname)

    the folder holding HISTORY_FILE.

Returns:

Raises:

  • (Error)

    as from_h, plus a missing, unreadable, or digest-mismatched attachment.



267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/pikuri/agent/history.rb', line 267

def load(dir)
  dir = Pathname(dir)
  file = dir / HISTORY_FILE
  raise Error, "no #{HISTORY_FILE} in #{dir}" unless file.file?

  raw = begin
    JSON.parse(file.read)
  rescue JSON::ParserError => e
    raise Error, "#{file} is not valid JSON: #{e.message}"
  end
  from_h(raw, dir: dir)
end

.mint_idString

A fresh message id: lexicographically sortable, so ordering falls out of the id itself and a future tree format inherits identity for free.

History.mint_id   # => "0198c4f1a2b30001f3c9a2"

Not a wire value — no provider is ever shown one.

Returns:

  • (String)

    22 hex chars: 12 of millisecond timestamp, 4 of per-process counter (so two ids minted in one millisecond still sort), 6 random.



325
326
327
328
329
330
# File 'lib/pikuri/agent/history.rb', line 325

def mint_id
  MINT_LOCK.synchronize do
    @counter = ((@counter || -1) + 1) & 0xffff
    format('%012x%04x%s', (Time.now.to_f * 1000).to_i, @counter, SecureRandom.hex(3))
  end
end

.parse(json) ⇒ History

Parse the self-contained JSON form.

History.parse(File.read('conv.json'))

Parameters:

  • json (String)

    what #to_json produced.

Returns:

Raises:

  • (Error)

    on malformed JSON, a bad record, a future version, or an attachment stored as a file — a String has no folder to resolve one against, so that case names load rather than guessing.



252
253
254
255
256
257
258
259
# File 'lib/pikuri/agent/history.rb', line 252

def parse(json)
  raw = begin
    JSON.parse(json)
  rescue JSON::ParserError => e
    raise Error, "history is not valid JSON: #{e.message}"
  end
  from_h(raw)
end

Instance Method Details

#repair_interrupted_tool_callsHistory

A copy in which every tool call has an answer, synthesizing INTERRUPTED_RESULT for any the recorded conversation left hanging — what a process killed mid-batch leaves behind.

Providers reject an assistant tool_use with no matching result, so this has to happen before the history goes back into a chat. It runs at load, never at export: the file stays a faithful record of what happened, and the repair is applied on the way back in.

Telling the model "you asked, it did not finish" beats dropping the assistant turn, which would erase that it ever decided to call the tool and leave it to re-derive the decision from nothing.

Returns:

  • (History)

    self when nothing was dangling.



586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
# File 'lib/pikuri/agent/history.rb', line 586

def repair_interrupted_tool_calls
  out = []
  pending = []
  repaired = false
  messages.each_with_index do |m, i|
    # Hold synthetics until the batch's real results have gone by, so
    # the answering run stays contiguous and in the order it happened.
    unless pending.empty? || m.role == 'tool'
      out.concat(pending)
      pending = []
    end
    out << m
    next unless m.tool_call?

    answered = messages[(i + 1)..].take_while { |n| n.role == 'tool' }.map(&:tool_call_id)
    pending = m.tool_calls.reject { |c| answered.include?(c.id) }.map do |call|
      repaired = true
      Message.new(id: History.mint_id, role: 'tool', tool_call_id: call.id, content: INTERRUPTED_RESULT)
    end
  end
  out.concat(pending)
  repaired ? with(messages: out) : self
end

#save(dir) ⇒ Pathname

Write dir/history.json plus dir/attachments/, creating the folder if needed.

agent.export_history.save('~/conversations/2026-08-27-abc')

Attachments are written first: history.json is the commit point, so an interrupted save leaves unreferenced bytes (harmless) rather than a reference to bytes that are not there.

Never deletes. An attachment the history no longer references simply stays — pikuri writes into this folder but does not own everything in it, and removing a file a human put there is a worse failure than an orphan. Re-saving is idempotent: a content-addressed name that already exists is skipped after one stat, so cost tracks the history rather than the folder.

Parameters:

  • dir (String, Pathname)

    destination; created if absent.

Returns:

  • (Pathname)

    dir, so a caller can chain.



554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
# File 'lib/pikuri/agent/history.rb', line 554

def save(dir)
  dir = Pathname(dir).expand_path
  attachments = messages.flat_map(&:attachments)

  unless attachments.empty?
    root = dir / ATTACHMENTS_DIR
    root.mkpath
    attachments.each do |a|
      path = root / a.stored_name
      atomic_write(path, a.bytes) unless path.exist?
    end
  end

  dir.mkpath
  atomic_write(dir / HISTORY_FILE, JSON.pretty_generate(to_h(inline_attachments: false)))
  dir
end

#to_h(inline_attachments: true) ⇒ Hash{String => Object}

The self-contained Hash: attachments inline, as base64.

Parameters:

Returns:

  • (Hash{String => Object})

    JSON-safe, String keys.



523
524
525
526
527
528
# File 'lib/pikuri/agent/history.rb', line 523

def to_h(inline_attachments: true)
  {
    'version' => version,
    'messages' => messages.map { |m| message_to_h(m, inline_attachments) }
  }
end

#to_json(*_args) ⇒ String

Returns pretty-printed #to_h, so a stored conversation stays readable and diffs a turn at a time.

Returns:

  • (String)

    pretty-printed #to_h, so a stored conversation stays readable and diffs a turn at a time.



532
533
534
# File 'lib/pikuri/agent/history.rb', line 532

def to_json(*_args)
  JSON.pretty_generate(to_h)
end