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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/docs/architecture.md +7 -4
- data/docs/hooks.md +72 -1
- data/docs/internals/engine.md +34 -5
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/plugins.md +7 -3
- data/docs/sessions.md +7 -3
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +38 -9
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +6 -4
- data/lib/samagotchi/llm/chat_loop.rb +5 -2
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/terminal_ui.rb +4 -0
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -0
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +15 -18
- data/lib/samagotchi/web/public/index.html +5 -3
- data/lib/samagotchi/web/public/ref_menu.js +9 -1
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +28 -6
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/turn_view.js +17 -7
- data/lib/samagotchi/worker.rb +168 -21
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +6 -1
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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"
|
|
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
|
|
692
|
-
#
|
|
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
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
+
}
|