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
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "log"
5
+ require_relative "ref"
6
+
7
+ module Samagotchi
8
+ # One ref on the answer: the text it stands for (+match+, what the page
9
+ # pairs it with) and the Ref itself. +to_h+ is the stored and sent shape,
10
+ # {match:, kind:, target:, label:, actions:}.
11
+ AnswerRef = Data.define(:match, :ref) do
12
+ # @raise [ArgumentError] an empty match, one over MAX_MATCH, or a ref
13
+ # that isn't a Ref
14
+ def initialize(match:, ref:)
15
+ match = match.to_s.strip
16
+ raise ArgumentError, "a ref needs a match (the text it stands for)" if match.empty?
17
+ raise ArgumentError, "a ref's match is at most #{AnswerRef::MAX_MATCH} characters" if match.length > AnswerRef::MAX_MATCH
18
+ raise ArgumentError, "not a Ref" unless ref.is_a?(Ref)
19
+
20
+ super
21
+ end
22
+
23
+ def to_h = { match: match, **ref.to_h }
24
+ end
25
+
26
+ class AnswerRef
27
+ MAX_MATCH = 500
28
+
29
+ # A hook's {match:, kind:, target:, label:, actions:}, or a stored
30
+ # entry (String or Symbol keys, nested ones too). A file ref's relative
31
+ # path is resolved against +cwd+, so the stored path is absolute; with
32
+ # no +cwd+ it is refused. An absolute path stays as given.
33
+ # @raise [ArgumentError] anything invalid
34
+ def self.from(value, cwd: nil)
35
+ raise ArgumentError, "a ref is a Hash {match:, kind:, target:, label:, actions:}" unless value.is_a?(Hash)
36
+
37
+ value = value.transform_keys(&:to_sym)
38
+ ref = Ref.new(kind: value[:kind], target: value[:target], label: value[:label], actions: value[:actions] || [])
39
+ new(match: value[:match], ref: absolute(ref, cwd))
40
+ end
41
+
42
+ def self.absolute(ref, cwd)
43
+ return ref unless ref.kind == Ref::FILE
44
+
45
+ path = ref.target[:path]
46
+ return ref if File.absolute_path?(path)
47
+ raise ArgumentError, "relative path, no session folder" if cwd.to_s.empty?
48
+
49
+ # absolute_path, not expand_path: a leading ~ is a name, never a home.
50
+ Ref.new(kind: ref.kind, target: ref.target.merge(path: File.absolute_path(path, cwd.to_s)), label: ref.label,
51
+ actions: ref.actions)
52
+ end
53
+ private_class_method :absolute
54
+ end
55
+
56
+ # The refs after_turn hooks attach to a turn's answer (event[:refs]),
57
+ # kept as `display_refs` on the answer's model message next to its
58
+ # `display` (AnswerDisplay). Display only, like `display`: the model
59
+ # never sees them (AnswerDisplay::DISPLAY_ONLY), and a UI pairs each with
60
+ # its +match+ in the shown answer (or drops it).
61
+ #
62
+ # The target is AnswerDisplay's: the stored conversation's last message
63
+ # when it is the model's. The list starts from the refs it already has
64
+ # (re-validated), so a later hook run never loses them.
65
+ class AnswerRefs
66
+ KEY = :display_refs
67
+ MAX_REFS = 200
68
+ # The list's JSON; a ref that would cross it is refused.
69
+ MAX_BYTES = 256 * 1024
70
+
71
+ # Stored refs (+raw+, as read from session.json or a snapshot), each
72
+ # rebuilt and checked again, the caps applied; what isn't valid any
73
+ # more is dropped. Never raises: a reload must show the conversation.
74
+ # A relative file path is dropped (there is no folder to resolve it).
75
+ # @return [Array<Hash>] the refs to send ({match:, kind:, target:, …})
76
+ def self.for_display(raw)
77
+ return [] if raw.nil?
78
+
79
+ kept = new([{ role: "model", KEY => raw }], cwd: nil).to_a
80
+ dropped = raw.is_a?(Array) ? raw.length - kept.length : 1
81
+ Log.debug(:web, "display_refs_dropped", count: dropped) if dropped.positive?
82
+ kept
83
+ rescue StandardError
84
+ []
85
+ end
86
+
87
+ # @return [Hash, nil] the message event[:refs] adds to
88
+ attr_reader :target
89
+
90
+ # @param messages [Array<Hash>] the conversation the turn stored
91
+ # @param cwd [String, nil] the session's folder, for relative file paths
92
+ def initialize(messages, cwd:)
93
+ last = Array(messages).last
94
+ @target = last if last.is_a?(Hash) && (last[:role] || last["role"]).to_s == "model"
95
+ @cwd = cwd
96
+ @refs = []
97
+ @seen = {}
98
+ @bytes = 2 # the list's []
99
+ @added = false
100
+ @closed = false
101
+ @rejections = Hash.new(0)
102
+ seed(@target) if @target
103
+ end
104
+
105
+ # True when a hook added a ref the answer didn't have.
106
+ def added? = @added
107
+
108
+ def count = @refs.length
109
+
110
+ # @return [Array<Hash>] the refs as stored ({match:, kind:, target:, …})
111
+ def to_a = @refs.dup
112
+
113
+ # The list is stored (Engine#store_answer_display): a later call (a
114
+ # plugin that kept event[:refs]) would be lost, so it returns nil.
115
+ def close! = @closed = true
116
+
117
+ def closed? = @closed
118
+
119
+ # event[:refs]: call it with the ref's keywords,
120
+ # +call(match:, kind:, target:, label: nil, actions: [])+.
121
+ # @param event [Hash] the after_turn event (for its :hook label)
122
+ # The hook's label is read at call time: one adder serves every hook of
123
+ # the fire, and the registry sets event[:hook] before each.
124
+ # @return [Proc] true when the ref is kept (or was already there), false
125
+ # when it is refused (logged, named by event[:hook]), nil when the
126
+ # turn has no answer or the list was already stored (#close!)
127
+ def adder(event)
128
+ lambda do |*args, **ref|
129
+ next nil unless @target
130
+ next late(event[:hook]) if @closed
131
+ next reject(event[:hook], "takes keywords (match:, kind:, target:, label:, actions:)") unless args.empty?
132
+
133
+ add(ref, event[:hook])
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ KEYS = %i[match kind target label actions].freeze
140
+ REASON_VALUES = /\(.*?\)|\[.*?\]|".*?"|\d+/
141
+ private_constant :KEYS, :REASON_VALUES
142
+
143
+ def seed(target)
144
+ raw = target[KEY] || target[KEY.to_s]
145
+ raw.each { |entry| keep(entry) } if raw.is_a?(Array)
146
+ end
147
+
148
+ def add(value, hook)
149
+ unknown = value.keys - KEYS
150
+ return reject(hook, "has unknown keys #{unknown.map(&:to_s).inspect}") unless unknown.empty?
151
+
152
+ entry = AnswerRef.from(value, cwd: @cwd).to_h
153
+ return true if @seen.key?(entry)
154
+ return over_cap(hook, "#{MAX_REFS} refs") if @refs.length >= MAX_REFS
155
+
156
+ size = JSON.generate(entry).bytesize + (@refs.empty? ? 0 : 1)
157
+ return over_cap(hook, "#{MAX_BYTES / 1024} KiB") if @bytes + size > MAX_BYTES
158
+
159
+ push(entry, size)
160
+ @added = true
161
+ rescue StandardError => e
162
+ reject(hook, e.message)
163
+ end
164
+
165
+ # A stored entry back in the list, checked again (a relative path is
166
+ # dropped: it was stored absolute). Duplicates and what is over the
167
+ # caps go quietly.
168
+ # @return [Boolean] kept
169
+ def keep(raw)
170
+ entry = AnswerRef.from(raw, cwd: nil).to_h
171
+ return false if @seen.key?(entry) || @refs.length >= MAX_REFS
172
+
173
+ size = JSON.generate(entry).bytesize + (@refs.empty? ? 0 : 1)
174
+ return false if @bytes + size > MAX_BYTES
175
+
176
+ push(entry, size)
177
+ true
178
+ rescue StandardError
179
+ false
180
+ end
181
+
182
+ def push(entry, size)
183
+ @refs << entry
184
+ @seen[entry] = true
185
+ @bytes += size
186
+ end
187
+
188
+ def over_cap(hook, cap) = reject(hook, "over the cap (#{cap})")
189
+
190
+ def late(hook)
191
+ Log.debug(:hooks, "refs_late", hook: hook.to_s)
192
+ nil
193
+ end
194
+
195
+ # Each reason is said once per hook per answer (a warn and its echo);
196
+ # the same again goes to debug with its count, so a hook looping over
197
+ # bad refs can't flood the terminal. The reason's values (a kind, a
198
+ # key list, a cap) don't make it a new one.
199
+ def reject(hook, why)
200
+ count = @rejections[[hook.to_s, why.gsub(REASON_VALUES, "")]] += 1
201
+ if count == 1
202
+ Log.warn(:hooks, "refs_rejected", echo: "[samagotchi:hooks] #{hook}: ref #{why}; dropped",
203
+ hook: hook.to_s, reason: why)
204
+ else
205
+ Log.debug(:hooks, "refs_rejected", hook: hook.to_s, reason: why, count: count)
206
+ end
207
+ false
208
+ end
209
+ end
210
+ end
@@ -45,7 +45,11 @@ module Samagotchi
45
45
  # its own), and a question still open when the worker went waits for no
46
46
  # one: neither is saved or seeded. Same caps as in memory. An entry from
47
47
  # an earlier worker lists with +earlier+: true (an attached TUI's resync
48
- # onto a new worker doesn't print those again).
48
+ # onto a new worker doesn't print those again). An earlier card shown
49
+ # during a turn (+in_turn+) comes back without its actions: they belong
50
+ # to a turn no worker runs any more (check-in's Nudge would steer the
51
+ # new worker's turn). A card shown between turns (github-pr's offer,
52
+ # btw, coordinator) keeps its buttons.
49
53
  class CardStore
50
54
  CAPACITY = 20
51
55
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
@@ -137,6 +141,7 @@ module Samagotchi
137
141
  next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
138
142
 
139
143
  entry = entry.merge(type: entry[:type].to_sym, earlier: true)
144
+ entry = entry.merge(actions: []) if entry[:type] == :card && entry[:in_turn] && entry.key?(:actions)
140
145
  entry unless entry[:type] == :guardrail_warning || open_question?(entry)
141
146
  end
142
147
  settle_during(after_turn: false)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.2.3
3
+ version: 0.2.4
4
4
  scope: system
5
5
  description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:821aea2821df12f278db9a94f8150244e67bf9453735d19a5abe9d94253eefa6
9
+ sha256: sha256:42fe075c664d256ecb8623b76a25d5c389e42c0534a41c56fe17b073d521e095
10
10
  requires_chi: ">= 0.3.0"
@@ -11,6 +11,10 @@
11
11
  # final answer is dropped: the card's Nudge then says so at the turn's end
12
12
  # (after_turn's messages have no steer with its text).
13
13
  #
14
+ # One nudge per check-in: a second Nudge (a repeat click, a stale card's
15
+ # button, a typed /checkin nudge) for the same check-in only says it was
16
+ # already nudged; before the turn's first check-in, one nudge in all.
17
+ #
14
18
  # The count is per turn: before_turn resets it (and closes a card left open
15
19
  # by a turn that failed or was interrupted, where after_turn doesn't fire);
16
20
  # steering merged mid-turn does not. The /checkin commands are anytime ones
@@ -76,6 +80,8 @@ class Plugin
76
80
  @card_id = nil # this turn's card, once shown
77
81
  @card_open = false
78
82
  @nudge = nil # the card's Nudge this turn: { text:, count: }
83
+ @checked_in = nil # the count at this turn's latest check-in
84
+ @nudged_for = nil # the check-in (its count, :none before one) nudged
79
85
  end
80
86
 
81
87
  def before_turn(ctx)
@@ -97,6 +103,7 @@ class Plugin
97
103
  next nil unless @enabled && @count >= @next_at
98
104
 
99
105
  @next_at = @count + @every
106
+ @checked_in = @count
100
107
  [@count, @mode]
101
108
  end
102
109
  check_in(ctx, *due) if due
@@ -107,7 +114,10 @@ class Plugin
107
114
  when "nudge"
108
115
  text = message(count)
109
116
  if ctx.steer(text)
110
- @mutex.synchronize { @nudge = { text: text, count: count } }
117
+ @mutex.synchronize do
118
+ @nudge = { text: text, count: count }
119
+ @nudged_for = count
120
+ end
111
121
  ctx.notify("nudged the model after #{count} tool calls")
112
122
  end
113
123
  when "notify"
@@ -232,10 +242,24 @@ class Plugin
232
242
  "check-in after #{n} tool calls, then every #{n}, for this session"
233
243
  end
234
244
 
245
+ # At most one nudge per check-in; the slot is taken before the steer (two
246
+ # clicks race on the anytime command's threads) and given back when no
247
+ # turn took it.
235
248
  def nudge(ctx)
236
- count = calls
249
+ count, key, earlier = @mutex.synchronize do
250
+ key = @checked_in || :none
251
+ next [nil, nil, @nudge&.dig(:count) || @count] if @nudged_for == key
252
+
253
+ @nudged_for = key
254
+ [@count, key, nil]
255
+ end
256
+ return "check-in: already nudged at #{earlier} tool calls" if earlier
257
+
237
258
  text = message(count)
238
- return "check-in: no turn running" unless ctx.steer(text)
259
+ unless ctx.steer(text)
260
+ @mutex.synchronize { @nudged_for = nil if @nudged_for == key }
261
+ return "check-in: no turn running"
262
+ end
239
263
 
240
264
  @mutex.synchronize { @nudge = { text: text, count: count } }
241
265
  close_card(ctx, "Nudged the model at #{count} tool calls.")
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.60.0
3
+ version: 0.61.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -64,6 +64,7 @@ require_relative "sampling_settings"
64
64
  require_relative "thinking"
65
65
  require_relative "reasoning_history"
66
66
  require_relative "answer_display"
67
+ require_relative "answer_refs"
67
68
  require_relative "edit_preview"
68
69
  require_relative "question_desk"
69
70
  require_relative "child_reports"
@@ -1478,19 +1479,31 @@ module Samagotchi
1478
1479
  private :save_quietly
1479
1480
 
1480
1481
  # Keep what the after_turn hooks presented as the answer's `display`
1481
- # and tell the observers (:answer_display), after the turn_completed
1482
- # they already had: the web re-reads the answer then. A turn_completed
1482
+ # and the refs they attached as its `display_refs` (AnswerRefs, one
1483
+ # replace for both), and tell the observers (:answer_display, the
1484
+ # display or nil, and how many refs), after the turn_completed they
1485
+ # already had: the web re-reads the answer then. A turn_completed
1483
1486
  # with display_pending (after_turn hooks were about to run) always gets
1484
1487
  # one, `display: nil` when nothing was presented, so the web holds the
1485
1488
  # answer's pop until then and never swaps it after. Terminals are not
1486
1489
  # sinks of it; they printed the answer already. The session is saved by
1487
1490
  # the caller, as after every turn.
1488
- def store_answer_display(session, display, pending: false)
1491
+ def store_answer_display(session, display, refs, pending: false)
1489
1492
  synchronize_events do
1493
+ # A plugin that kept event[:refs] and calls it later gets nil: the
1494
+ # list is stored now (and the turn is over on every path).
1495
+ refs.close!
1490
1496
  messages = Array(session.messages)
1491
- if display.changed? && messages.last.equal?(display.target)
1492
- replace_session_messages(session, messages[0...-1] + [display.target.merge(AnswerDisplay::KEY => display.text)])
1493
- @session_observer.notify({ type: :answer_display, display: display.text })
1497
+ changed = display.changed? && messages.last.equal?(display.target)
1498
+ added = refs.added? && messages.last.equal?(refs.target)
1499
+ if changed || added
1500
+ merged = messages.last.dup
1501
+ merged[AnswerDisplay::KEY] = display.text if changed
1502
+ merged[AnswerRefs::KEY] = refs.to_a if added
1503
+ replace_session_messages(session, messages[0...-1] + [merged])
1504
+ event = { type: :answer_display, display: changed ? display.text : nil }
1505
+ event[:refs] = refs.count if added
1506
+ @session_observer.notify(event)
1494
1507
  elsif pending
1495
1508
  @session_observer.notify({ type: :answer_display, display: nil })
1496
1509
  end
@@ -2228,15 +2241,18 @@ module Samagotchi
2228
2241
 
2229
2242
  # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
2230
2243
  # ends the turn in #run_turn without it), with a read-only
2231
- # copy of the conversation the turn stored, and event[:present] for
2232
- # a display version of the answer (AnswerDisplay).
2244
+ # copy of the conversation the turn stored, event[:present] for
2245
+ # a display version of the answer (AnswerDisplay) and event[:refs]
2246
+ # for refs on it (AnswerRefs).
2233
2247
  session = turn.session
2234
2248
  display = AnswerDisplay.new(session.messages)
2249
+ refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
2235
2250
  after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
2236
2251
  messages: hook_messages(session.messages) }
2237
2252
  after_turn[:present] = display.presenter(after_turn)
2253
+ after_turn[:refs] = refs.adder(after_turn)
2238
2254
  @hooks.fire(:after_turn, after_turn)
2239
- store_answer_display(session, display, pending: display_pending)
2255
+ store_answer_display(session, display, refs, pending: display_pending)
2240
2256
 
2241
2257
  # Fire :session_end after the turn (turn-level lifecycle), as :after_turn
2242
2258
  @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
@@ -2547,6 +2563,19 @@ module Samagotchi
2547
2563
  clone_messages(@session&.messages)
2548
2564
  end
2549
2565
 
2566
+ # The running turn's conversation as its loop builds it (both loops
2567
+ # register it on the kernel's llm_context_turn): the same array the loop
2568
+ # appends to, not a copy, so read it on the turn's thread (the pending
2569
+ # input drain, at an iteration boundary: TurnCheckpoint). nil when no
2570
+ # turn runs (llm_context_turn is never cleared, and still holds the
2571
+ # last turn's then).
2572
+ # @return [Array<Hash>, nil]
2573
+ def live_conversation
2574
+ return nil unless turn_running? && @kernel.respond_to?(:llm_context_turn)
2575
+
2576
+ @kernel.llm_context_turn&.conversation
2577
+ end
2578
+
2550
2579
  # Append messages to the current session's conversation.
2551
2580
  # @param messages [Array<Hash>]
2552
2581
  # @return [Array<Hash>] the session's messages
@@ -253,9 +253,11 @@ module Samagotchi
253
253
  # event[:steer].call(text) -> true when the text was queued for the
254
254
  # running turn's next boundary (its own user message, source: the
255
255
  # hook's bundle); from after_turn / session_end false
256
- # and after_turn's fire site adds one more (AnswerDisplay):
256
+ # and after_turn's fire site adds two more (AnswerDisplay, AnswerRefs):
257
257
  # event[:present].call { |text| new_text } -> the answer's display
258
258
  # text after the call (chained in hook order), nil without an answer
259
+ # event[:refs].call(match:, kind:, target:, label: nil, actions: [])
260
+ # -> true (kept), false (refused, logged), nil without an answer
259
261
  #
260
262
  # @param name [Symbol] the hook name to fire
261
263
  # @param event [Hash] the event payload (may be mutated by hooks)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The mark of a turn a worker checkpointed and never finished
5
+ # (Session#interrupted_turn, saved as session.json's "interrupted_turn"):
6
+ # TurnCheckpoint writes it with the turn's messages so far while the turn
7
+ # runs; the turn's end saves without it; the next worker to start finds it
8
+ # and settles the turn (Worker#settle_interrupted_turn).
9
+ #
10
+ # id: the turn's id (nil when unknown); started_at / at: iso8601, when the
11
+ # turn began and when this checkpoint was taken; iteration: the loop
12
+ # boundaries it had passed (Integer).
13
+ InterruptedTurn = Data.define(:id, :started_at, :at, :iteration) do
14
+ def initialize(id: nil, started_at: nil, at: nil, iteration: nil) = super
15
+
16
+ # A saved record (string or symbol keys), an InterruptedTurn as it is;
17
+ # nil for anything else.
18
+ # @return [InterruptedTurn, nil]
19
+ def self.from_file(raw)
20
+ return raw if raw.is_a?(InterruptedTurn)
21
+ return nil unless raw.is_a?(Hash)
22
+
23
+ new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
24
+ end
25
+
26
+ # @return [Hash{String => Object}] string keys, unset fields left out
27
+ def to_file = to_h.compact.transform_keys(&:to_s)
28
+
29
+ # Seconds from the turn's start to this checkpoint (nil when unknown).
30
+ # @return [Float, nil]
31
+ def seconds
32
+ return nil unless started_at && at
33
+
34
+ (Time.iso8601(at) - Time.iso8601(started_at)).clamp(0, Float::INFINITY)
35
+ rescue ArgumentError
36
+ nil
37
+ end
38
+ end
39
+ end
@@ -1003,11 +1003,13 @@ module Samagotchi
1003
1003
  def sanitize_history(messages, keep_thought_from: nil)
1004
1004
  messages.each_with_index.map do |m, index|
1005
1005
  if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
1006
- { role: m[:role], content: m[:content].to_s, display: m[:display] }.compact
1006
+ { role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs] }.compact
1007
1007
  elsif m[:role] == "model"
1008
- # `display` rides along so the stored conversation keeps it; the
1009
- # prompt formatter never reads it (AnswerDisplay).
1010
- { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
1008
+ # `display` and `display_refs` ride along so the stored
1009
+ # conversation keeps them; the prompt formatter never reads them
1010
+ # (AnswerDisplay, AnswerRefs).
1011
+ { role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display],
1012
+ display_refs: m[:display_refs] }.compact
1011
1013
  else
1012
1014
  m.dup
1013
1015
  end
@@ -11,6 +11,7 @@ require_relative "native_tool_normalizer"
11
11
  require_relative "stream_drops"
12
12
  require_relative "../kernel_loop"
13
13
  require_relative "../answer_display"
14
+ require_relative "../answer_refs"
14
15
  require_relative "../cut_policy"
15
16
  require_relative "../context_status"
16
17
  require_relative "../context_window"
@@ -302,8 +303,9 @@ module Samagotchi
302
303
  # refs of a user message or a tool result, a prompt's turn_id (Engine,
303
304
  # never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
304
305
  # (the live row's params line and label, never sent back), an
305
- # edit/write result's tool_diffs (never sent), and an empty-answer
306
- # note's marker (TurnNote.empty, never sent).
306
+ # edit/write result's tool_diffs (never sent), an empty-answer
307
+ # note's marker (TurnNote.empty, never sent), and an answer's display
308
+ # and display_refs (AnswerDisplay, AnswerRefs; never sent).
307
309
  def plain(conversation)
308
310
  conversation.map do |entry|
309
311
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -319,6 +321,7 @@ module Samagotchi
319
321
  marker = TurnNote.empty_answer(entry)
320
322
  message[TurnNote::EMPTY_ANSWER] = marker if marker
321
323
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
324
+ message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
322
325
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
323
326
  message
324
327
  end
@@ -152,10 +152,13 @@ module Samagotchi
152
152
  log(level, :hook_notice, hook: event[:hook], fallback_for: event[:fallback_for], msg: event[:text].to_s[0, 300])
153
153
  end
154
154
 
155
- # after_turn hooks presented the answer (AnswerDisplay): its size only
156
- # (none for the display: nil that says nothing was presented).
155
+ # after_turn hooks presented the answer (AnswerDisplay) or attached refs
156
+ # to it (AnswerRefs): the display's size and the refs' count only (none
157
+ # for the display: nil that says nothing was presented).
157
158
  def on_answer_display(event)
158
- log(:info, :answer_display, chars: event[:display].to_s.length) if event[:display]
159
+ return unless event[:display] || event[:refs]
160
+
161
+ log(:info, :answer_display, chars: event[:display]&.length, refs: event[:refs])
159
162
  end
160
163
 
161
164
  # A card shown to the user (Engine#show_card): whose, which, and its
@@ -10,14 +10,17 @@ module Samagotchi
10
10
  # (refs.js httpUrl): the page opens it as is, so a javascript:, data: or
11
11
  # editor URL never gets there.
12
12
  RefAction = Data.define(:id, :label, :url) do
13
- # @raise [ArgumentError] an empty id or label, or a URL that isn't an
14
- # http(s) one with a host, at most MAX_URL characters
13
+ # @raise [ArgumentError] an empty id or label, one over its cap
14
+ # (MAX_ID, MAX_LABEL), or a URL that isn't an http(s) one with a host,
15
+ # at most MAX_URL characters
15
16
  def initialize(id:, label:, url:)
16
17
  id = id.to_s.strip
17
18
  label = label.to_s.strip
18
19
  url = url.to_s.strip
19
20
  raise ArgumentError, "a ref action needs an id" if id.empty?
20
21
  raise ArgumentError, "a ref action needs a label" if label.empty?
22
+ raise ArgumentError, "a ref action's id is at most #{RefAction::MAX_ID} characters" if id.length > RefAction::MAX_ID
23
+ raise ArgumentError, "a ref action's label is at most #{RefAction::MAX_LABEL} characters" if label.length > RefAction::MAX_LABEL
21
24
  raise ArgumentError, "a ref action's url must be http(s) with a host, at most #{RefAction::MAX_URL} characters" unless RefAction.http_url?(url)
22
25
 
23
26
  super
@@ -26,13 +29,17 @@ module Samagotchi
26
29
 
27
30
  class RefAction
28
31
  MAX_URL = 2_000
32
+ MAX_ID = 64
33
+ MAX_LABEL = 100
29
34
 
30
- # @return [Boolean] +url+ is an http or https URL with a host
35
+ # @return [Boolean] +url+ is an http or https URL with a host and no
36
+ # userinfo (a "https://github.com@evil.example/" shows one host and
37
+ # opens another, and a token in it would be copied around)
31
38
  def self.http_url?(url)
32
39
  return false if url.length > MAX_URL
33
40
 
34
41
  uri = URI.parse(url)
35
- uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
42
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
36
43
  rescue URI::InvalidURIError
37
44
  false
38
45
  end
@@ -61,13 +68,17 @@ module Samagotchi
61
68
  # rows' own ref; this is the one a plugin gives.
62
69
  Ref = Data.define(:kind, :target, :label, :actions) do
63
70
  # @raise [ArgumentError] a kind that isn't a short lowercase word, a
64
- # target without its kind's key, a non-list of actions or a bad action
71
+ # target without its kind's key, a non-list of actions, a bad action,
72
+ # or a value over its cap (MAX_LABEL, MAX_ACTIONS, MAX_ID, MAX_PATH)
65
73
  def initialize(kind:, target:, label: nil, actions: [])
66
74
  kind = kind.to_s
67
75
  raise ArgumentError, "a ref's kind is a short lowercase word (#{kind.inspect})" unless Ref::KIND.match?(kind)
68
76
  raise ArgumentError, "a ref's actions are a list" unless actions.nil? || actions.is_a?(Array)
77
+ raise ArgumentError, "a ref has at most #{Ref::MAX_ACTIONS} actions" if Array(actions).length > Ref::MAX_ACTIONS
69
78
 
70
79
  label = label.to_s.strip
80
+ raise ArgumentError, "a ref's label is at most #{Ref::MAX_LABEL} characters" if label.length > Ref::MAX_LABEL
81
+
71
82
  super(kind: kind, target: Ref.target_for(kind, target), label: label.empty? ? nil : label,
72
83
  actions: Array(actions).map { |action| RefAction.from(action) }.freeze)
73
84
  end
@@ -81,6 +92,11 @@ module Samagotchi
81
92
  KIND = /\A[a-z][a-z0-9_-]{0,31}\z/
82
93
  # FileRef::KIND, not required here (it loads the file tools).
83
94
  FILE = "file"
95
+ # Caps, so a plugin can't bloat session.json or the page.
96
+ MAX_LABEL = 200
97
+ MAX_ACTIONS = 8
98
+ MAX_ID = 200
99
+ MAX_PATH = 4_096
84
100
 
85
101
  # A plugin's {kind:, target:, label:, actions:} (String or Symbol keys).
86
102
  # @raise [ArgumentError] as +new+
@@ -97,19 +113,20 @@ module Samagotchi
97
113
  raise ArgumentError, "a ref's target is a Hash" unless raw.is_a?(Hash)
98
114
 
99
115
  raw = raw.transform_keys(&:to_sym)
100
- kind == FILE ? file_target(raw) : { id: required(raw[:id], :id) }.freeze
116
+ kind == FILE ? file_target(raw) : { id: required(raw[:id], :id, MAX_ID) }.freeze
101
117
  end
102
118
 
103
119
  def self.file_target(raw)
104
120
  line = positive(raw[:line])
105
121
  end_line = line && positive(raw[:end_line])
106
- { path: required(raw[:path], :path), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
122
+ { path: required(raw[:path], :path, MAX_PATH), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
107
123
  end
108
124
  private_class_method :file_target
109
125
 
110
- def self.required(value, key)
126
+ def self.required(value, key, max)
111
127
  value = value.to_s.strip
112
128
  raise ArgumentError, "a ref's target needs #{key}" if value.empty?
129
+ raise ArgumentError, "a ref's target #{key} is at most #{max} characters" if value.length > max
113
130
 
114
131
  value
115
132
  end
@@ -106,10 +106,14 @@ module Samagotchi
106
106
 
107
107
  if session.status == Session::STATUS_RUNNING
108
108
  seen_running = true
109
- elsif seen_running || turn_ended_since?(session, baseline)
109
+ elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
110
110
  # It ran and is idle again with no new reply: canceled, failed or
111
111
  # empty. Before it was ever seen running, idle means a
112
- # file-delivered message its worker has not picked up yet.
112
+ # file-delivered message its worker has not picked up yet. With
113
+ # the baseline's last_turn only a turn that ended since counts:
114
+ # a session left running by a worker that died goes idle when the
115
+ # next worker settles that turn (TurnCheckpoint.settle), before
116
+ # the sent message's turn runs.
113
117
  # The worker writes the reply before its idle save, so the read
114
118
  # above has it; one more look costs nothing should that change.
115
119
  return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||