samagotchi 0.60.0 → 0.61.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/docs/architecture.md +7 -4
  4. data/docs/hooks.md +72 -1
  5. data/docs/internals/engine.md +34 -5
  6. data/docs/internals/prompt-caching.md +3 -1
  7. data/docs/plugins.md +7 -3
  8. data/docs/sessions.md +7 -3
  9. data/lib/samagotchi/answer_display.rb +4 -3
  10. data/lib/samagotchi/answer_refs.rb +210 -0
  11. data/lib/samagotchi/bridge/card_store.rb +6 -1
  12. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +38 -9
  16. data/lib/samagotchi/hooks/registry.rb +3 -1
  17. data/lib/samagotchi/interrupted_turn.rb +39 -0
  18. data/lib/samagotchi/kernel_loop.rb +6 -4
  19. data/lib/samagotchi/llm/chat_loop.rb +5 -2
  20. data/lib/samagotchi/log_subscriber.rb +6 -3
  21. data/lib/samagotchi/ref.rb +25 -8
  22. data/lib/samagotchi/reply_wait.rb +6 -2
  23. data/lib/samagotchi/session.rb +40 -4
  24. data/lib/samagotchi/session_manager.rb +64 -6
  25. data/lib/samagotchi/terminal_ui.rb +4 -0
  26. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/app.rb +5 -0
  29. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  30. data/lib/samagotchi/web/public/app.js +15 -18
  31. data/lib/samagotchi/web/public/index.html +5 -3
  32. data/lib/samagotchi/web/public/ref_menu.js +9 -1
  33. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  34. data/lib/samagotchi/web/public/refs.js +28 -6
  35. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  36. data/lib/samagotchi/web/public/turn_view.js +17 -7
  37. data/lib/samagotchi/worker.rb +168 -21
  38. data/lib/samagotchi/worker_signals.rb +81 -0
  39. metadata +6 -1
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "context_note"
9
+ require_relative "interrupted_turn"
9
10
  require_relative "last_turn"
10
11
  require_relative "llm_context_override"
11
12
  require_relative "session_setup"
@@ -107,6 +108,26 @@ module Samagotchi
107
108
  def last_turn=(value)
108
109
  @last_turn = LastTurn.from_file(value)
109
110
  end
111
+
112
+ # The mark of a turn a worker checkpointed and never finished
113
+ # (InterruptedTurn), as loaded; the next worker settles it and sets it
114
+ # to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
115
+ attr_reader :interrupted_turn
116
+
117
+ # @param value [InterruptedTurn, Hash, nil]
118
+ def interrupted_turn=(value)
119
+ @interrupted_turn = InterruptedTurn.from_file(value)
120
+ end
121
+
122
+ # The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
123
+ # (marker an InterruptedTurn). While +status+ is running, every save
124
+ # writes these messages and the marker instead of #messages and
125
+ # #interrupted_turn, so a save in the middle of the turn (a question
126
+ # asked, the checkpoint's own) keeps the turn so far on disk; #messages
127
+ # itself is never touched. The turn's end sets status idle before it
128
+ # saves, so that save writes the real messages, without a marker. Not
129
+ # saved; the worker clears it after each turn.
130
+ attr_accessor :turn_checkpoint
110
131
  # The session's own llm_context values (LLMContextOverride: strategy,
111
132
  # apply rule, budget), before the model's; nil when it has none.
112
133
  # Saved, so a --resume and a respawned worker keep them; a plugin's
@@ -191,7 +212,8 @@ module Samagotchi
191
212
  used_memory_names: [], project_root: nil,
192
213
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
193
214
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
194
- continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
215
+ continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
216
+ interrupted_turn: nil)
195
217
  @id = id
196
218
  @metadata_version = metadata_version
197
219
  @mode = mode
@@ -220,6 +242,8 @@ module Samagotchi
220
242
  self.thinking = thinking
221
243
  self.reasoning_history = reasoning_history
222
244
  self.appended_prompt_files = appended_prompt_files
245
+ self.interrupted_turn = interrupted_turn
246
+ @turn_checkpoint = nil
223
247
  @archived = false
224
248
  end
225
249
 
@@ -363,7 +387,8 @@ module Samagotchi
363
387
  "continues" => nil,
364
388
  "thinking" => nil,
365
389
  "reasoning_history" => nil,
366
- "appended_prompt_files" => []
390
+ "appended_prompt_files" => [],
391
+ "interrupted_turn" => nil
367
392
  }.freeze
368
393
 
369
394
  # A session from a parsed session file. +messages+ false leaves the
@@ -475,11 +500,15 @@ module Samagotchi
475
500
  self
476
501
  end
477
502
 
478
- # The session as #save writes it (FIELDS, in their order).
503
+ # The session as #save writes it (FIELDS, in their order); a running
504
+ # turn's checkpoint (#turn_checkpoint) in place of its messages.
479
505
  def to_h
506
+ checkpoint = checkpointed
480
507
  FIELDS.keys.to_h do |key|
481
508
  value = case key
482
- when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
509
+ when "messages"
510
+ (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
511
+ when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
483
512
  when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
484
513
  when "test_run" then !!@test_run
485
514
  when "delegate" then @delegate
@@ -496,6 +525,13 @@ module Samagotchi
496
525
  end
497
526
  end
498
527
 
528
+ # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
529
+ def checkpointed
530
+ checkpoint = @turn_checkpoint
531
+ checkpoint if checkpoint && @status == STATUS_RUNNING
532
+ end
533
+ private :checkpointed
534
+
499
535
  # Mark a session as errored.
500
536
  def self.mark_error(session_id, reason:, state_dir: default_state_dir)
501
537
  session = load(session_id, state_dir: state_dir)
@@ -22,6 +22,7 @@ require_relative "turn_note"
22
22
  require_relative "owner_lock"
23
23
  require_relative "bridge_client"
24
24
  require_relative "worker_sidecar"
25
+ require_relative "worker_signals"
25
26
  require_relative "log"
26
27
  require_relative "log_path"
27
28
  require_relative "model_profile"
@@ -210,7 +211,8 @@ module Samagotchi
210
211
  # wakes the worker picks which config.yml it reads (docs/guardrails.md).
211
212
  private_class_method def self.spawn_options(session)
212
213
  # Own process group: workers outlive `chi web`, and a Ctrl-C in its
213
- # terminal must not reach them.
214
+ # terminal must not reach them (the worker then leaves the process
215
+ # tree too: WORKER_DETACH_ENV).
214
216
  opts = { out: File::NULL, err: File::NULL, pgroup: true }
215
217
  # The worker's tools (and `!cmd`) run in the session's directory, not
216
218
  # in the cwd of whoever woke it (`chi web`, another terminal).
@@ -253,6 +255,10 @@ module Samagotchi
253
255
  SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
254
256
  # A smoke's fake "newest installed" is for this process's notices only.
255
257
  child_env[InstalledVersions::ENV_KEY] = nil
258
+ # A worker process: run_session_loop traps signals, and leaves this
259
+ # process's tree.
260
+ child_env[WORKER_PROCESS_ENV] = "1"
261
+ child_env[WORKER_DETACH_ENV] = "1"
256
262
  # A gem-mode worker activates the newest installed chi itself: under
257
263
  # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
258
264
  # hold it to the bundle's version, and its badge would never clear.
@@ -688,8 +694,8 @@ module Samagotchi
688
694
  # so a resume right after spawns a fresh worker instead of finding the
689
695
  # dying one.
690
696
  # A turn still running is canceled first (STOP_CANCEL_WAIT), as the
691
- # web's Cancel does: its Engine saves the prompt, which the TERM alone
692
- # would lose with the worker.
697
+ # web's Cancel does: its worker saves it. The TERM stops (and saves)
698
+ # what still runs (WorkerSignals), with a short grace after the marker.
693
699
  # @return [Boolean, nil] with +wait+: whether the owner was gone in time
694
700
  # @raise [OwnedByTUI] when the interactive TUI owns the session
695
701
  def self.stop_session(session_id, state_dir: nil, wait: nil)
@@ -930,7 +936,8 @@ module Samagotchi
930
936
  # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
931
937
  # @param poll_interval [Numeric, nil] seconds between the loop's fallback
932
938
  # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
933
- # A stopped session's worker exits 0 and a crashed one 1, there.
939
+ # A stopped session's worker exits 0 (a signalled one too) and a
940
+ # crashed one 1, there.
934
941
  # @return [Symbol] :idle_exit, :exit_requested or :restart
935
942
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
936
943
  idle_exit_minutes: nil, poll_interval: nil)
@@ -942,6 +949,12 @@ module Samagotchi
942
949
  Log.configure(stderr: false)
943
950
  Log.session_id = session_id
944
951
  session_dir = Session.session_dir(session_id, state_dir: sd)
952
+ # Read (and taken out of ENV, so nothing the worker runs inherits it)
953
+ # before anything else: set by spawn_options for a spawned worker.
954
+ spawned = ENV.delete(WORKER_PROCESS_ENV) == "1"
955
+ # Out of the spawner's process tree before the lock (it records this
956
+ # process's pid) and before any thread starts.
957
+ detach_from_spawner(session_dir) if ENV.delete(WORKER_DETACH_ENV) == "1"
945
958
  # Kept in a class ivar so the lock's File lives as long as the worker.
946
959
  @owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
947
960
  exit(0) unless @owner_lock
@@ -949,8 +962,11 @@ module Samagotchi
949
962
  fallback = ENV.delete(BOOT_FALLBACK_ENV)
950
963
  Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
951
964
  Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
965
+ # A spawned worker stops its turn on TERM/HUP/INT and saves it; a
966
+ # loop run in-process (specs) traps nothing.
967
+ signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
952
968
  worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
953
- idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
969
+ idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals)
954
970
  result = begin
955
971
  worker.run
956
972
  rescue StandardError, ScriptError => e
@@ -959,11 +975,19 @@ module Samagotchi
959
975
  raise
960
976
  ensure
961
977
  @owner_lock.release
978
+ # A signal from here on ends the process as Ruby's default does.
979
+ signals.restore!
962
980
  end
963
981
  Log.info(:worker, "stop", reason: result)
964
982
  # A stopped session is left as it is: no resume for input that came
965
- # in, no discard.
983
+ # in, no discard. One whose worker a signal stopped isn't discarded
984
+ # either; input that came in (queued behind the turn it stopped)
985
+ # wakes a new worker, as after an idle exit.
966
986
  exit(0) if result == :stopped
987
+ if result == :signal
988
+ resume_session(session_id, state_dir: sd) unless SessionInbox.find_new_input_files(session_dir).empty?
989
+ exit(0)
990
+ end
967
991
  exit(1) if result == :crashed
968
992
  # Only after the release: a writer that still saw this worker as the
969
993
  # owner may have queued input since the last check. Either it finds no
@@ -981,6 +1005,31 @@ module Samagotchi
981
1005
  result
982
1006
  end
983
1007
 
1008
+ # A spawned worker leaves its spawner's process tree: a killer that
1009
+ # walks the tree by parent pid (a terminal closing, a tool's teardown
1010
+ # of a `chi send --wait` it ran in the background) no longer reaches it.
1011
+ # This process (the spawner's child, which its Process.detach waits on)
1012
+ # forks the worker, which starts a session of its own (setsid: no
1013
+ # controlling terminal, no SIGHUP from one) and returns to run the loop;
1014
+ # this one waits until the worker holds the session's OwnerLock, or has
1015
+ # exited, or WORKER_START_GRACE passed, and exits: the spawner's waiter
1016
+ # stays alive exactly while the worker starts (#still_starting?). The
1017
+ # worker is reparented to init (launchd), which reaps it.
1018
+ private_class_method def self.detach_from_spawner(session_dir)
1019
+ worker = Process.fork
1020
+ unless worker
1021
+ Process.setsid
1022
+ return
1023
+ end
1024
+
1025
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_START_GRACE
1026
+ until Process.waitpid(worker, Process::WNOHANG) || OwnerLock.owner(session_dir)&.pid == worker ||
1027
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1028
+ sleep(OwnerLock::RETRY_INTERVAL)
1029
+ end
1030
+ Process.exit!(0)
1031
+ end
1032
+
984
1033
  # Delete a session its worker left empty. Checked again now the lock is
985
1034
  # free: a note or input may have come in since the worker looked, and a
986
1035
  # worker woken meanwhile owns it (delete_session refuses). A `chi send`
@@ -1024,6 +1073,13 @@ module Samagotchi
1024
1073
  # format are read by a NEWER chi than the one writing them. Change them
1025
1074
  # only additively (spec/worker_contract_spec.rb).
1026
1075
  BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
1076
+ # "1" in a worker spawn_options started: it traps signals (WorkerSignals).
1077
+ # run_session_loop deletes it from ENV as it reads it.
1078
+ WORKER_PROCESS_ENV = "SAMAGOTCHI_WORKER_PROCESS"
1079
+ # "1" in a worker spawn_options started: run_session_loop leaves the
1080
+ # spawner's process tree first (detach_from_spawner); deleted from ENV
1081
+ # as it is read, so an rspec run by the worker's execute never forks.
1082
+ WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
1027
1083
 
1028
1084
  def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
1029
1085
  boot = "require 'samagotchi/session_manager'; " \
@@ -1046,6 +1102,8 @@ module Samagotchi
1046
1102
  command = worker_command(session.id, state_dir: state_dir)
1047
1103
  pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
1048
1104
  # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
1105
+ # +pid+ is the worker's starter, which exits once the worker it forked
1106
+ # holds the lock (detach_from_spawner): the lock's pid is the worker's.
1049
1107
  note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
1050
1108
  Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
1051
1109
  pid
@@ -9,6 +9,7 @@ require_relative "client_id"
9
9
  require_relative "iteration_limit"
10
10
  require_relative "model_profile"
11
11
  require_relative "turn_note"
12
+ require_relative "turn_checkpoint"
12
13
  require_relative "config"
13
14
  require_relative "context_note"
14
15
  require_relative "cancellation_controller"
@@ -277,6 +278,9 @@ module Samagotchi
277
278
  session.save if @resume_session
278
279
  end
279
280
  claim_session!(session.id) unless @owner_lock
281
+ # A turn a worker died in (TurnCheckpoint): ended as canceled, with its
282
+ # note, before this REPL's first turn appends after it.
283
+ session.save if @resume_session && TurnCheckpoint.settle(session)
280
284
  keep_cards(session)
281
285
  # Saved at once, so a process killed before its first turn leaves a
282
286
  # file the sweep knows to delete.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "interrupted_turn"
5
+ require_relative "log"
6
+ require_relative "session"
7
+ require_relative "steer"
8
+ require_relative "turn_note"
9
+
10
+ module Samagotchi
11
+ # A worker's running turn, kept on disk at the loop's iteration
12
+ # boundaries, so a worker killed mid-turn (SIGKILL, a crash of the
13
+ # process) loses at most INTERVAL seconds of it instead of all of it.
14
+ #
15
+ # At each boundary (Worker#pending_input_drain, on the turn's thread) the
16
+ # turn's live conversation (Engine#live_conversation) is copied, with the
17
+ # lines that boundary merges (their input files are deleted as they are
18
+ # claimed), into the session's overlay (Session#turn_checkpoint), marked
19
+ # with an InterruptedTurn. Every save while the turn runs writes the
20
+ # overlay instead of the session's messages: this one's, written at the
21
+ # first boundary (the prompt reaches the disk) and then at most every
22
+ # INTERVAL (or when the boundary merged input), and any other save in the
23
+ # turn (a question asked). The session's in-memory messages never change
24
+ # (a Bridge snapshot shows the turn in progress or in the messages, never
25
+ # both). The turn's end saves the real messages without a marker.
26
+ #
27
+ # The copy is what a Stop at that boundary would keep, minus its note, so
28
+ # the history before the turn is never rewritten (prompt caching); later
29
+ # steps of the same turn may still edit their own entries in place.
30
+ class TurnCheckpoint
31
+ INTERVAL = 10.0
32
+
33
+ # A session loaded with an InterruptedTurn mark (its worker died in the
34
+ # turn): the turn ends now, as canceled. Its note goes at the tail
35
+ # (append-only: the model reads on its next turn that the previous one
36
+ # was cut, after how long), the status goes idle and the mark goes. The
37
+ # dead turn is not run again. The caller saves.
38
+ #
39
+ # last_turn is left as it was: a new last_turn saved idle here, before
40
+ # the turn a `chi send --wait` started this worker for, would end that
41
+ # wait at once as the sent turn's (ReplyWait compares last_turn), with
42
+ # the dead turn's outcome.
43
+ # @return [InterruptedTurn, nil] the mark settled, nil when there was none
44
+ def self.settle(session)
45
+ mark = session.interrupted_turn
46
+ return nil unless mark
47
+
48
+ messages = Array(session.messages)
49
+ # How long it ran up to its last checkpoint (it died later); none
50
+ # when that was its first boundary ("after 0s" would mislead).
51
+ seconds = mark.seconds
52
+ seconds = nil if seconds && seconds < 1
53
+ note = TurnNote.cancelled(:worker_stopped, seconds: seconds, shown: TurnNote.interrupted_tail?(messages))
54
+ session.messages = messages + [note]
55
+ session.status = Session::STATUS_IDLE if session.status == Session::STATUS_RUNNING
56
+ session.interrupted_turn = nil
57
+ mark
58
+ end
59
+
60
+ # @param session [Session] the worker's session (saved as a whole)
61
+ # @param clock [#call] monotonic seconds (specs)
62
+ def initialize(session:, state_dir:, interval: INTERVAL, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
63
+ @session = session
64
+ @state_dir = state_dir
65
+ @interval = interval
66
+ @clock = clock
67
+ @boundaries = 0
68
+ @written_at = nil
69
+ @started_at = nil
70
+ end
71
+
72
+ # A turn begins (before Engine#run_turn and the worker's running save):
73
+ # the overlay starts as the history plus +prompt+ (text only: no
74
+ # images, no turn id), marked at iteration 0, so a worker killed before
75
+ # the loop's first boundary leaves the prompt and a mark the next one
76
+ # settles. A continue turn (no prompt) is the history, marked.
77
+ # @param prompt [String, nil]
78
+ def begin_turn(prompt: nil)
79
+ @boundaries = 0
80
+ @written_at = nil
81
+ @started_at = Time.now.iso8601(3)
82
+ messages = Array(@session.messages).dup
83
+ messages << { role: "user", content: prompt } unless prompt.to_s.empty?
84
+ @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0) }
85
+ end
86
+
87
+ # The turn ended (or never began): the session saves its own messages.
88
+ def end_turn
89
+ @session.turn_checkpoint = nil
90
+ end
91
+
92
+ # An iteration boundary of the running turn.
93
+ # @param conversation [Array<Hash>, nil] the loop's live conversation;
94
+ # nil (no turn): nothing
95
+ # @param merging [Array<Steer::Line>] the lines this boundary merges
96
+ # (appended after the conversation, as Steer.inject! will)
97
+ # @param turn_id [String, nil]
98
+ # @return [Boolean] whether the session file was written
99
+ def boundary(conversation, merging: [], turn_id: nil)
100
+ return false unless conversation
101
+
102
+ @boundaries += 1
103
+ merged = merging.empty? ? [] : Steer.merge(merging).messages
104
+ # One level deep: the loop may edit an entry in place later (a
105
+ # forget stub) while a save on another thread serializes this one.
106
+ messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
107
+ marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries)
108
+ @session.turn_checkpoint = { messages: messages, marker: marker }
109
+ return false unless due? || !merged.empty?
110
+
111
+ write
112
+ end
113
+
114
+ private
115
+
116
+ def due? = @written_at.nil? || @clock.call - @written_at >= @interval
117
+
118
+ def write
119
+ @session.save(state_dir: @state_dir)
120
+ @written_at = @clock.call
121
+ true
122
+ rescue StandardError => e
123
+ # The turn goes on; the next boundary tries again.
124
+ Log.warn(:worker, "checkpoint_failed", error: e.class.name, msg: e.message)
125
+ false
126
+ end
127
+ end
128
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.60.0"
4
+ VERSION = "0.61.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -13,6 +13,7 @@ require_relative "../analytics_file"
13
13
  require_relative "../client_id"
14
14
  require_relative "../delivery"
15
15
  require_relative "../answer_tail"
16
+ require_relative "../answer_refs"
16
17
  require_relative "../bridge_client"
17
18
  require_relative "../bridge/bounded_queue"
18
19
  require_relative "../bridge/card_store"
@@ -1907,6 +1908,10 @@ module Samagotchi
1907
1908
  message[:display] = shown unless shown.strip.empty?
1908
1909
  rendered = message[:display] || stripped
1909
1910
  message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
1911
+ # The refs after_turn hooks attached (AnswerRefs), checked again:
1912
+ # a stored file is never trusted. Never raises.
1913
+ refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
1914
+ message[:display_refs] = refs unless refs.empty?
1910
1915
  end
1911
1916
  message[:parts] = did if did
1912
1917
  filtered << message
@@ -0,0 +1,217 @@
1
+ // The refs a plugin attached to an answer (the message's display_refs,
2
+ // traceable refs P5b): each {match, kind, target, label?, actions} is
3
+ // paired with the answer's bubble, then bound to what it stands for
4
+ // (refs.js bindRef), so its actions join the ref's menu.
5
+ //
6
+ // A bubble knows its message's refs through setAnswerRefs (a reload's
7
+ // render, a turn-end read's re-render); pairAnswerRefs pairs them once per
8
+ // setAnswerRefs. Three passes over all the refs, strongest evidence
9
+ // first, so a weak text match of one ref never takes another's link:
10
+ // 1. a link whose href is one of the ref's action URLs;
11
+ // 2. a link whose text is the match;
12
+ // 3. the match as a whole token inside one plain text node (not in pre,
13
+ // a link, inline code or another ref), or an inline code that is the
14
+ // match alone.
15
+ // A ref binds every occurrence its first finding pass finds; a ref no
16
+ // pass finds is dropped. A label that differs from the element's text is
17
+ // its hover title (unless the element has one: a markdown link's).
18
+ // The match and label are a plugin's text: they only ever go through
19
+ // textContent, setAttribute and bindRef, never markup or a RegExp.
20
+
21
+ import { COPY_SOURCE_ATTR, copySource, showsAnswer } from "./copy.js";
22
+ import { bindRef, boundRef, httpUrl, refKindOk } from "./refs.js";
23
+
24
+ // bubble → {refs, paired}. A WeakMap: a dropped bubble takes its refs with
25
+ // it; the stage hand-off moves the bubble, so they move along.
26
+ const ANSWER_REFS = new WeakMap();
27
+
28
+ // Text never paired: code blocks, links (passes 1 and 2 are theirs), a ref
29
+ // already made, inline code (only as the match alone).
30
+ const SKIP = "pre, a, [data-ref-kind]";
31
+
32
+ // A token boundary, as the `path:line` scanner's (ref_scan.js REF): not
33
+ // after a path-ish character, not before a word character or a slash.
34
+ const BEFORE = /[\w./@+~-]/;
35
+ const AFTER = /[\w/]/;
36
+
37
+ // +bubble+'s refs are +refs+ (a message's display_refs: entries with a
38
+ // match and a kind bindRef binds); a non-array or one without a usable
39
+ // entry clears them. They pair on the next
40
+ // pairAnswerRefs, even when the same ones were paired before.
41
+ export function setAnswerRefs(bubble, refs) {
42
+ if (!bubble) return;
43
+ const list = (Array.isArray(refs) ? refs : []).filter(
44
+ (ref) => ref && typeof ref === "object" && refKindOk(ref.kind) && typeof ref.match === "string" && ref.match.trim(),
45
+ );
46
+ if (list.length) ANSWER_REFS.set(bubble, { refs: list, paired: false });
47
+ else ANSWER_REFS.delete(bubble);
48
+ }
49
+
50
+ // +bubble+'s refs (setAnswerRefs'), or null.
51
+ export function answerRefsOf(bubble) {
52
+ return ANSWER_REFS.get(bubble)?.refs || null;
53
+ }
54
+
55
+ // The turn's final answer on its bubble (a turn-end read's +messages+):
56
+ // the last assistant message with text, its bubble the last of +bubbles+
57
+ // that shows it (copy.js showsAnswer, with +normalize+). Its rendered html
58
+ // replaces the bubble's when it has one (not with web.markdown off or no
59
+ // renderer: the text stays); its refs are the bubble's either way (a
60
+ // later read without any clears them). The bubble, or null.
61
+ export function applyFinalAnswer(messages, bubbles, normalize) {
62
+ const message = [...(Array.isArray(messages) ? messages : [])].reverse().find(
63
+ (item) => item?.role === "assistant" && (typeof item.html === "string" || String(item.content ?? "").trim()),
64
+ );
65
+ const bubble = message && [...bubbles].reverse().find((item) => showsAnswer(item, message, normalize));
66
+ if (!bubble) return null;
67
+ if (typeof message.html === "string") {
68
+ bubble.classList.add("markdown");
69
+ bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
70
+ bubble.innerHTML = message.html;
71
+ }
72
+ setAnswerRefs(bubble, message.display_refs);
73
+ return bubble;
74
+ }
75
+
76
+ // Pairs +bubble+'s refs with its links and text and binds them (once per
77
+ // setAnswerRefs; again a no-op).
78
+ export function pairAnswerRefs(bubble) {
79
+ const entry = bubble && ANSWER_REFS.get(bubble);
80
+ if (!entry || entry.paired) return;
81
+ entry.paired = true;
82
+ let left = entry.refs;
83
+ left = left.filter((ref) => !pairByHref(bubble, ref));
84
+ left = left.filter((ref) => !pairByLinkText(bubble, ref));
85
+ pairByText(bubble, left);
86
+ }
87
+
88
+ // The links a ref may take: not inside a code block or another ref, not
89
+ // bound by a plugin's ref already (a link the regex scanner adopted has
90
+ // attributes only: it may be bound).
91
+ function freeLinks(bubble) {
92
+ return [...bubble.querySelectorAll("a[href]")].filter((a) => !boundRef(a) && !a.parentElement?.closest("pre, [data-ref-kind]"));
93
+ }
94
+
95
+ // Pass 1: true when +ref+ took a link to one of its action URLs.
96
+ function pairByHref(bubble, ref) {
97
+ const urls = new Set((Array.isArray(ref.actions) ? ref.actions : []).map((action) => httpUrl(action?.url)).filter(Boolean));
98
+ if (!urls.size) return false;
99
+ const links = freeLinks(bubble).filter((a) => urls.has(httpUrl(a.getAttribute("href"))));
100
+ return bindAll(links, ref);
101
+ }
102
+
103
+ // Pass 2: true when +ref+ took a link whose text is its match. The link
104
+ // goes somewhere the ref doesn't name (the model's URL): the ref's actions
105
+ // join its menu, but its label isn't the link's title.
106
+ function pairByLinkText(bubble, ref) {
107
+ const match = ref.match.trim();
108
+ return bindAll(freeLinks(bubble).filter((a) => a.textContent.trim() === match), ref, { label: false });
109
+ }
110
+
111
+ // Pass 3, for all the +refs+ left at once: a ref the path:line scan made
112
+ // (attributes only, no plugin data) whose text is a match is bound in
113
+ // place (the scan ran first on a bubble that wasn't re-rendered: an answer
114
+ // without html); an inline code that is a match alone is wrapped whole;
115
+ // then each text node's hits (refs in list order, a hit overlapping an
116
+ // earlier one dropped) are wrapped in one go.
117
+ function pairByText(bubble, refs) {
118
+ if (!refs.length) return;
119
+ const doc = bubble.ownerDocument;
120
+ const matching = (el) => {
121
+ const text = el.textContent.trim();
122
+ return refs.find((candidate) => candidate.match.trim() === text);
123
+ };
124
+ for (const scanned of [...bubble.querySelectorAll("[data-ref-kind]:not(a)")]) {
125
+ if (boundRef(scanned) || scanned.parentElement?.closest("pre, a, [data-ref-kind]")) continue;
126
+ const ref = matching(scanned);
127
+ if (ref) bindAll([scanned], ref);
128
+ }
129
+ for (const code of [...bubble.querySelectorAll("code")]) {
130
+ if (code.closest(SKIP)) continue;
131
+ const ref = matching(code);
132
+ if (!ref) continue;
133
+ const span = refSpan(doc, ref);
134
+ if (!span) continue;
135
+ code.replaceWith(span);
136
+ span.append(code);
137
+ labelOn(span, ref);
138
+ }
139
+ const walker = doc.createTreeWalker(bubble, 4 /* NodeFilter.SHOW_TEXT */);
140
+ const nodes = [];
141
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
142
+ const skip = node.parentElement?.closest(`${SKIP}, code`);
143
+ if (!skip || !bubble.contains(skip)) nodes.push(node);
144
+ }
145
+ const hits = new Map();
146
+ for (const ref of refs) {
147
+ const match = ref.match.trim();
148
+ for (const node of nodes) {
149
+ for (const [start, end] of tokenHits(node.data, match)) {
150
+ const list = hits.get(node) || [];
151
+ if (list.some(([s, e]) => start < e && end > s)) continue;
152
+ list.push([start, end, ref]);
153
+ hits.set(node, list);
154
+ }
155
+ }
156
+ }
157
+ for (const [node, list] of hits) wrap(node, list.sort((a, b) => a[0] - b[0]));
158
+ }
159
+
160
+ // [start, end] of each whole-token +match+ in +text+ (indexOf: the match
161
+ // is a plugin's text, never a pattern).
162
+ function tokenHits(text, match) {
163
+ const found = [];
164
+ for (let at = text.indexOf(match); at >= 0; at = text.indexOf(match, at + 1)) {
165
+ const end = at + match.length;
166
+ if (at > 0 && BEFORE.test(text[at - 1])) continue;
167
+ if (end < text.length && AFTER.test(text[end])) continue;
168
+ found.push([at, end]);
169
+ at = end - 1;
170
+ }
171
+ return found;
172
+ }
173
+
174
+ function wrap(node, hits) {
175
+ const doc = node.ownerDocument;
176
+ const frag = doc.createDocumentFragment();
177
+ let pos = 0;
178
+ for (const [start, end, ref] of hits) {
179
+ const span = refSpan(doc, ref);
180
+ if (!span) continue;
181
+ if (start > pos) frag.append(node.data.slice(pos, start));
182
+ span.textContent = node.data.slice(start, end);
183
+ labelOn(span, ref);
184
+ frag.append(span);
185
+ pos = end;
186
+ }
187
+ if (!pos) return;
188
+ if (pos < node.data.length) frag.append(node.data.slice(pos));
189
+ node.replaceWith(frag);
190
+ }
191
+
192
+ // A span bound to +ref+, or null when bindRef refuses it (a bad kind).
193
+ function refSpan(doc, ref) {
194
+ const span = doc.createElement("span");
195
+ bindRef(span, ref);
196
+ return boundRef(span) ? span : null;
197
+ }
198
+
199
+ // Binds each of +els+ to +ref+; true when one took it.
200
+ function bindAll(els, ref, { label = true } = {}) {
201
+ let bound = false;
202
+ for (const el of els) {
203
+ bindRef(el, ref);
204
+ if (!boundRef(el)) continue;
205
+ if (label) labelOn(el, ref);
206
+ bound = true;
207
+ }
208
+ return bound;
209
+ }
210
+
211
+ // The ref's label as the element's hover title, when it says more than
212
+ // the element's text and the element has no title of its own.
213
+ function labelOn(el, ref) {
214
+ const label = typeof ref.label === "string" ? ref.label.trim() : "";
215
+ if (!label || label === el.textContent.trim() || el.hasAttribute("title")) return;
216
+ el.setAttribute("title", label);
217
+ }