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
|
@@ -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
|
+
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:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.")
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
1482
|
-
#
|
|
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
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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,
|
|
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
|
|
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`
|
|
1009
|
-
# prompt formatter never reads
|
|
1010
|
-
|
|
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),
|
|
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)
|
|
156
|
-
# (
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/ref.rb
CHANGED
|
@@ -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,
|
|
14
|
-
# http(s) one with a host,
|
|
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
|
|
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 } ||
|