Class: Pikuri::Agent::History
- Inherits:
-
Object
- Object
- Pikuri::Agent::History
- 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 =
'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.
:systemcannot: 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
-
.from_h(raw, dir: nil) ⇒ History
Validate a plain Hash — the shared gate History.parse and History.load both run through, and the only place a record's shape is checked.
-
.load(dir) ⇒ History
Read a folder written by #save.
-
.mint_id ⇒ String
A fresh message id: lexicographically sortable, so ordering falls out of the id itself and a future tree format inherits identity for free.
-
.parse(json) ⇒ History
Parse the self-contained JSON form.
Instance Method Summary collapse
-
#initialize(version: CURRENT_VERSION, messages: []) ⇒ History
constructor
A new instance of History.
-
#repair_interrupted_tool_calls ⇒ History
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.
-
#save(dir) ⇒ Pathname
Write
dir/history.jsonplusdir/attachments/, creating the folder if needed. -
#to_h(inline_attachments: true) ⇒ Hash{String => Object}
The self-contained Hash: attachments inline, as base64.
-
#to_json(*_args) ⇒ String
Pretty-printed #to_h, so a stored conversation stays readable and diffs a turn at a time.
Constructor Details
#initialize(version: CURRENT_VERSION, messages: []) ⇒ History
Returns a new instance of History.
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.
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 = raw['messages'] raise Error, "messages must be an Array, got #{.class}" unless .is_a?(Array) built = .each_with_index.map { |m, i| (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.
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.}" end from_h(raw, dir: dir) end |
.mint_id ⇒ String
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.
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'))
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.}" end from_h(raw) end |
Instance Method Details
#repair_interrupted_tool_calls ⇒ History
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.
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 .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 = [(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.
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). = .flat_map(&:attachments) unless .empty? root = dir / ATTACHMENTS_DIR root.mkpath .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.
523 524 525 526 527 528 |
# File 'lib/pikuri/agent/history.rb', line 523 def to_h(inline_attachments: true) { 'version' => version, 'messages' => .map { |m| (m, ) } } end |
#to_json(*_args) ⇒ String
Returns 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 |