samagotchi 0.109.0 → 0.110.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 +34 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -1
- data/docs/configuration.md +97 -3
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +6 -3
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +15 -6
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/public/app.js +11 -3
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +30 -7
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +87 -31
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- metadata +6 -1
|
@@ -504,7 +504,7 @@ module Samagotchi
|
|
|
504
504
|
model: served || job[:model], created_at: Time.now.utc.iso8601 }
|
|
505
505
|
@mutex.synchronize { @state = saved }
|
|
506
506
|
save(saved)
|
|
507
|
-
@engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered])
|
|
507
|
+
@engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered], created_at: saved[:created_at])
|
|
508
508
|
rescue StandardError
|
|
509
509
|
@in_flight = nil
|
|
510
510
|
end
|
|
@@ -8,7 +8,7 @@ module Samagotchi
|
|
|
8
8
|
#
|
|
9
9
|
# Two delivery paths cooperate:
|
|
10
10
|
# 1. Every turn injects what is due (ReminderQueue#inject!, from
|
|
11
|
-
# Engine#run_turn) as [SYSTEM: REMINDERS DUE] and marks it fired.
|
|
11
|
+
# Engine#run_turn) as [SYSTEM: REMINDERS DUE <local time>] and marks it fired.
|
|
12
12
|
# 2. Synthetic turns: when this job's tick finds due reminders while
|
|
13
13
|
# idle, it fires @auto_turn_callback, which queues their names
|
|
14
14
|
# (Engine#note_due_reminders) for the worker's or the REPL's
|
|
@@ -33,6 +33,7 @@ require_relative "llm_context_forget"
|
|
|
33
33
|
require_relative "working_set"
|
|
34
34
|
require_relative "llm_context_notice"
|
|
35
35
|
require_relative "answer_display"
|
|
36
|
+
require_relative "message_time"
|
|
36
37
|
require_relative "llm/model_result"
|
|
37
38
|
require_relative "llm/turn_settings"
|
|
38
39
|
require_relative "llm/stream_drops"
|
|
@@ -360,7 +361,8 @@ module Samagotchi
|
|
|
360
361
|
return after_malformed(turn, generation, reason)
|
|
361
362
|
end
|
|
362
363
|
|
|
363
|
-
turn.conversation << { role: "model", content: request.prefill + generation.response.to_s
|
|
364
|
+
turn.conversation << { role: "model", content: request.prefill + generation.response.to_s,
|
|
365
|
+
MessageTime::KEY => MessageTime.iso(MessageTime.now) }
|
|
364
366
|
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
365
367
|
# returned fragment (non-nil only for Qwen) is fed back on the next
|
|
366
368
|
# iteration if the model opened a tool-call block it did not close.
|
|
@@ -867,7 +869,8 @@ module Samagotchi
|
|
|
867
869
|
# cannot leak) so a follow-up steering message continues with the model's
|
|
868
870
|
# half-finished work in context instead of losing it.
|
|
869
871
|
unless partial.empty?
|
|
870
|
-
message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true
|
|
872
|
+
message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true,
|
|
873
|
+
MessageTime::KEY => MessageTime.iso(MessageTime.now) }
|
|
871
874
|
# The thinking this generation streamed goes with it (the chat loop's
|
|
872
875
|
# with_thinking key, which MessageParts reads and the prompt doesn't):
|
|
873
876
|
# without it, reloading the cut step shows the empty answer it isn't.
|
|
@@ -1088,16 +1091,18 @@ module Samagotchi
|
|
|
1088
1091
|
|
|
1089
1092
|
# +keep_thought_from+: model messages from this index on keep their
|
|
1090
1093
|
# thought blocks (a resumed run's); nil strips every one.
|
|
1094
|
+
# A model message is rebuilt from an allow-list: `display`,
|
|
1095
|
+
# `display_refs` and its wall time (MessageTime::KEY) ride along so the
|
|
1096
|
+
# stored conversation keeps them; the prompt formatter never reads them
|
|
1097
|
+
# (AnswerDisplay, AnswerRefs, MessageTime).
|
|
1091
1098
|
def sanitize_history(messages, keep_thought_from: nil)
|
|
1092
1099
|
messages.each_with_index.map do |m, index|
|
|
1093
1100
|
if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
|
|
1094
|
-
{ role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs]
|
|
1101
|
+
{ role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs],
|
|
1102
|
+
MessageTime::KEY => m[MessageTime::KEY] }.compact
|
|
1095
1103
|
elsif m[:role] == "model"
|
|
1096
|
-
# `display` and `display_refs` ride along so the stored
|
|
1097
|
-
# conversation keeps them; the prompt formatter never reads them
|
|
1098
|
-
# (AnswerDisplay, AnswerRefs).
|
|
1099
1104
|
{ role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display],
|
|
1100
|
-
display_refs: m[:display_refs] }.compact
|
|
1105
|
+
display_refs: m[:display_refs], MessageTime::KEY => m[MessageTime::KEY] }.compact
|
|
1101
1106
|
else
|
|
1102
1107
|
m.dup
|
|
1103
1108
|
end
|
|
@@ -30,6 +30,7 @@ require_relative "../empty_answer_retry"
|
|
|
30
30
|
require_relative "../steer"
|
|
31
31
|
require_relative "../reasoning_history"
|
|
32
32
|
require_relative "../turn_note"
|
|
33
|
+
require_relative "../message_time"
|
|
33
34
|
require_relative "../thinking"
|
|
34
35
|
|
|
35
36
|
module Samagotchi
|
|
@@ -432,7 +433,8 @@ module Samagotchi
|
|
|
432
433
|
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
433
434
|
# and display_refs (AnswerDisplay, AnswerRefs; never sent), a
|
|
434
435
|
# find_tools result's loaded names and a tool change's TOOL_CHANGE
|
|
435
|
-
# (sent only to a wire that loads tools)
|
|
436
|
+
# (sent only to a wire that loads tools), and an entry's wall time
|
|
437
|
+
# (MessageTime::KEY, display-only, never sent).
|
|
436
438
|
def plain(conversation)
|
|
437
439
|
conversation.map do |entry|
|
|
438
440
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -454,6 +456,7 @@ module Samagotchi
|
|
|
454
456
|
Steer::AUTHOR_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
455
457
|
message[ToolResponse::LOADED] = entry[ToolResponse::LOADED] if entry[ToolResponse::LOADED].is_a?(Array)
|
|
456
458
|
message[TOOL_CHANGE] = entry[TOOL_CHANGE] if entry[TOOL_CHANGE].is_a?(Hash)
|
|
459
|
+
message[MessageTime::KEY] = entry[MessageTime::KEY] if entry[MessageTime::KEY]
|
|
457
460
|
message
|
|
458
461
|
end
|
|
459
462
|
end
|
|
@@ -704,7 +707,10 @@ module Samagotchi
|
|
|
704
707
|
# (reasoning_history). A wire that sends its blocks back keeps them
|
|
705
708
|
# as +anthropic_content+ (ChatResponse#provider_content: string
|
|
706
709
|
# keys, as received, so a resumed session sends the same bytes).
|
|
710
|
+
# Every reply built from a response comes through here, so it is
|
|
711
|
+
# also where a reply gets its wall time (MessageTime::KEY).
|
|
707
712
|
def with_thinking(message, response)
|
|
713
|
+
MessageTime.stamp(message, MessageTime.now)
|
|
708
714
|
reasoning = response.reasoning.to_s
|
|
709
715
|
message = message.merge(thinking: reasoning) unless reasoning.strip.empty?
|
|
710
716
|
blocks = response.provider_content
|
|
@@ -1038,7 +1044,8 @@ module Samagotchi
|
|
|
1038
1044
|
emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
|
|
1039
1045
|
visible = @loop.strip_model_thought(partial.to_s).strip
|
|
1040
1046
|
unless visible.empty?
|
|
1041
|
-
message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true
|
|
1047
|
+
message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true,
|
|
1048
|
+
MessageTime::KEY => MessageTime.iso(MessageTime.now) }
|
|
1042
1049
|
thought = thinking.to_s
|
|
1043
1050
|
message[:thinking] = thought unless thought.strip.empty?
|
|
1044
1051
|
@conversation << message
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# When a history entry happened: its wall-clock stamp (KEY), and the
|
|
7
|
+
# dividers the UIs print when a quiet gap passes between two entries.
|
|
8
|
+
#
|
|
9
|
+
# The stamp is display-only: no wire format reads it, so the bytes the
|
|
10
|
+
# model gets (and its prompt cache) are the same with or without it. A
|
|
11
|
+
# time the model should read is baked into the message's content when the
|
|
12
|
+
# message is made (ReminderQueue's header), never rendered at wire time.
|
|
13
|
+
#
|
|
14
|
+
# Everything here is a pure function of the times passed in; the one wall
|
|
15
|
+
# clock is #now (a seam the specs pin to a fixed time).
|
|
16
|
+
module MessageTime
|
|
17
|
+
# The history entry key: a local-offset ISO 8601 time with milliseconds.
|
|
18
|
+
KEY = :at
|
|
19
|
+
# The quiet time after which the UIs mark a gap.
|
|
20
|
+
GAP_SECONDS = 600
|
|
21
|
+
|
|
22
|
+
DEFAULT_SOURCE = -> { Time.now }
|
|
23
|
+
private_constant :DEFAULT_SOURCE
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# The wall clock: a callable returning a Time (nil: Time.now).
|
|
27
|
+
attr_writer :source
|
|
28
|
+
|
|
29
|
+
# @return [Time] now, by the wall clock seam
|
|
30
|
+
def now = (@source || DEFAULT_SOURCE).call
|
|
31
|
+
|
|
32
|
+
# Back to Time.now.
|
|
33
|
+
def reset! = @source = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @param time [Time]
|
|
39
|
+
# @return [String] the stamp's text
|
|
40
|
+
def iso(time) = time.iso8601(3)
|
|
41
|
+
|
|
42
|
+
# The stamp of an ISO text from elsewhere (a note's created_at), or nil
|
|
43
|
+
# when it doesn't parse.
|
|
44
|
+
def iso_of(text)
|
|
45
|
+
text && iso(Time.iso8601(text.to_s))
|
|
46
|
+
rescue ArgumentError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# +message+ with KEY set to +time+'s stamp, unless it has one already
|
|
51
|
+
# (a Symbol- or String-keyed message). Changes +message+ and returns it.
|
|
52
|
+
def stamp(message, time)
|
|
53
|
+
return message if message.key?(KEY) || message.key?(KEY.to_s)
|
|
54
|
+
|
|
55
|
+
message[KEY] = time.is_a?(String) ? time : iso(time)
|
|
56
|
+
message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String, nil] the stamp of +message+ (Symbol or String keys)
|
|
60
|
+
def of(message)
|
|
61
|
+
return nil unless message.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
message[KEY] || message[KEY.to_s]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String, nil] "14:32", local time
|
|
67
|
+
def clock(iso) = local(iso)&.strftime("%H:%M")
|
|
68
|
+
|
|
69
|
+
# @return [String, nil] "Sat 24 Oct 09:10", local time
|
|
70
|
+
def day_clock(iso) = local(iso)&.strftime("%a %-d %b %H:%M")
|
|
71
|
+
|
|
72
|
+
# The divider between activity that ended at +prev_iso+ and activity
|
|
73
|
+
# starting at +cur_iso+: nil below GAP_SECONDS (or when either time is
|
|
74
|
+
# unknown), else "── 14:32 · 47 min later ──", the date added when the
|
|
75
|
+
# local day changed.
|
|
76
|
+
def gap_line(prev_iso, cur_iso)
|
|
77
|
+
prev = local(prev_iso)
|
|
78
|
+
cur = local(cur_iso)
|
|
79
|
+
return nil unless prev && cur
|
|
80
|
+
|
|
81
|
+
seconds = cur - prev
|
|
82
|
+
return nil if seconds < GAP_SECONDS
|
|
83
|
+
|
|
84
|
+
time = prev.to_date == cur.to_date ? cur.strftime("%H:%M") : cur.strftime("%a %-d %b %H:%M")
|
|
85
|
+
"── #{time} · #{ago(seconds)} later ──"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [String] "47 min", "3 h", "2 d" (whole units, rounded down;
|
|
89
|
+
# the web's relativeTime scale)
|
|
90
|
+
def ago(seconds)
|
|
91
|
+
seconds = seconds.to_f
|
|
92
|
+
return "#{[(seconds / 60).floor, 0].max} min" if seconds < 3600
|
|
93
|
+
return "#{(seconds / 3600).floor} h" if seconds < 86_400
|
|
94
|
+
|
|
95
|
+
"#{(seconds / 86_400).floor} d"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def local(iso)
|
|
99
|
+
iso && Time.iso8601(iso.to_s).localtime
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "monitor"
|
|
4
|
+
require_relative "message_time"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# The session's due reminders, on their way into a turn.
|
|
@@ -11,9 +12,16 @@ module Samagotchi
|
|
|
11
12
|
# turn while they are pending. A turn injects whatever is due then
|
|
12
13
|
# (#inject!), which marks it fired and clears the pending names.
|
|
13
14
|
class ReminderQueue
|
|
15
|
+
# The reminders message's first line at +time+ (a Time in the zone to
|
|
16
|
+
# show: Time.now's is the local one).
|
|
17
|
+
def self.header(time) = "[SYSTEM: REMINDERS DUE #{time.strftime("%Y-%m-%d %H:%M")} local]"
|
|
18
|
+
|
|
14
19
|
# @param store [ReminderStore, nil]
|
|
15
|
-
|
|
20
|
+
# @param now [#call] the wall clock (a Time) for the injected message's
|
|
21
|
+
# stamp, when #inject! is given none
|
|
22
|
+
def initialize(store:, now: -> { MessageTime.now })
|
|
16
23
|
@store = store
|
|
24
|
+
@now = now
|
|
17
25
|
@lock = Monitor.new
|
|
18
26
|
@pending = []
|
|
19
27
|
end
|
|
@@ -55,14 +63,23 @@ module Samagotchi
|
|
|
55
63
|
# A tail message, not the head system prompt: changing the head would
|
|
56
64
|
# make the model server re-evaluate the whole conversation's prefix
|
|
57
65
|
# (its KV cache) every interval; a tail costs only the new suffix.
|
|
66
|
+
# The header names the local time the reminders came due at, for the
|
|
67
|
+
# model ("[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]"): baked into
|
|
68
|
+
# the content here, never rendered at wire time, so a replay sends the
|
|
69
|
+
# same bytes (the prompt cache) whatever the time zone or DST. The
|
|
70
|
+
# message also carries that time as its stamp (MessageTime::KEY,
|
|
71
|
+
# display-only).
|
|
58
72
|
# @param messages [Array<Hash>] the turn's messages (appended to)
|
|
73
|
+
# @param now [Time, nil] the turn's wall time (nil: the clock's)
|
|
59
74
|
# @return [Array<Hash>] the reminders injected (maybe none)
|
|
60
|
-
def inject!(messages)
|
|
75
|
+
def inject!(messages, now: nil)
|
|
61
76
|
reminders = due
|
|
62
77
|
return [] if reminders.empty?
|
|
63
78
|
|
|
79
|
+
time = now || @now.call
|
|
64
80
|
lines = reminders.map { |r| " #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)" }.join("\n")
|
|
65
|
-
messages << { role: "system", content: "
|
|
81
|
+
messages << { role: "system", content: "#{self.class.header(time)}\n#{lines}\n[END REMINDERS]",
|
|
82
|
+
MessageTime::KEY => MessageTime.iso(time) }
|
|
66
83
|
@store.mark_fired_batch(reminders.map { |r| r[:name] })
|
|
67
84
|
# Without this a turn's injection leaves a stale name, and the next
|
|
68
85
|
# reminder turn would run with nothing due.
|
|
@@ -27,7 +27,7 @@ module Samagotchi
|
|
|
27
27
|
@ended_before = ended_before
|
|
28
28
|
@deadline = timeout && (ReplyWait.monotonic + timeout.to_f)
|
|
29
29
|
@turn = turn.to_s.empty? ? nil : turn.to_s
|
|
30
|
-
@session_dir = Session.session_dir(id, state_dir: state_dir)
|
|
30
|
+
@session_dir = Session.session_dir(id, state_dir: state_dir)
|
|
31
31
|
# Whether the turn's input file has been taken (latched): looked at
|
|
32
32
|
# before each load, so a running session seen after it is that turn
|
|
33
33
|
# or a later one, never the turn that ran when the message was sent.
|
|
@@ -40,6 +40,11 @@ module Samagotchi
|
|
|
40
40
|
@seen_own = false
|
|
41
41
|
@own_base = nil
|
|
42
42
|
@seen_running = false
|
|
43
|
+
# A turn seen running with its checkpoint mark (a wait with no
|
|
44
|
+
# turn:): last_turn's ended_at then and the input files claimed
|
|
45
|
+
# then, so its settle by a next worker (#settled_running) tells
|
|
46
|
+
# from a turn that ended or an input that runs next.
|
|
47
|
+
@running_look = nil
|
|
43
48
|
@gone_since = nil
|
|
44
49
|
end
|
|
45
50
|
|
|
@@ -216,8 +221,13 @@ module Samagotchi
|
|
|
216
221
|
|
|
217
222
|
if running?(session)
|
|
218
223
|
@seen_running = true
|
|
224
|
+
note_running(session)
|
|
219
225
|
return nil
|
|
220
226
|
end
|
|
227
|
+
if (settled = settled_running(session))
|
|
228
|
+
return settled
|
|
229
|
+
end
|
|
230
|
+
|
|
221
231
|
# It ran and is idle again with no new reply: canceled, failed or
|
|
222
232
|
# empty. Before it was ever seen running, idle means a
|
|
223
233
|
# file-delivered message its worker has not picked up yet. With a
|
|
@@ -254,6 +264,34 @@ module Samagotchi
|
|
|
254
264
|
nil
|
|
255
265
|
end
|
|
256
266
|
|
|
267
|
+
RunningLook = Data.define(:last_end, :claimed)
|
|
268
|
+
|
|
269
|
+
def note_running(session)
|
|
270
|
+
return unless session.interrupted_turn
|
|
271
|
+
|
|
272
|
+
@running_look = RunningLook.new(last_end: session.last_turn&.ended_at,
|
|
273
|
+
claimed: SessionInbox.claimed_input_files(@session_dir))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The turn seen running is gone with no turn's end: its worker died
|
|
277
|
+
# in it and the next one settled it (TurnCheckpoint.settle: idle, the
|
|
278
|
+
# mark gone, last_turn left alone), which ends a wait for it (chi
|
|
279
|
+
# send --wait with no message, a message merged into it as a steer)
|
|
280
|
+
# as canceled, worker_stopped, rather than at the timeout. Not while
|
|
281
|
+
# an input waits or a new one was claimed since (the sent message,
|
|
282
|
+
# unmerged, runs as its own turn next: its end is the answer), nor
|
|
283
|
+
# for a settle never seen running (a message sent to a session a dead
|
|
284
|
+
# worker left running goes in after the settle).
|
|
285
|
+
def settled_running(session)
|
|
286
|
+
look = @running_look
|
|
287
|
+
return nil unless look && session.status == Session::STATUS_IDLE && session.interrupted_turn.nil? &&
|
|
288
|
+
session.last_turn&.ended_at == look.last_end
|
|
289
|
+
return nil unless SessionInbox.find_new_input_files(@session_dir).empty?
|
|
290
|
+
return nil unless (SessionInbox.claimed_input_files(@session_dir) - look.claimed).empty?
|
|
291
|
+
|
|
292
|
+
Result.new(status: :no_reply, outcome: "canceled", cancel_reason: TurnCheckpoint::WORKER_STOPPED, session: session)
|
|
293
|
+
end
|
|
294
|
+
|
|
257
295
|
def owner_gone(session)
|
|
258
296
|
return nil unless @owner_grace
|
|
259
297
|
|
|
@@ -14,7 +14,7 @@ module Samagotchi
|
|
|
14
14
|
module RunOptions
|
|
15
15
|
# The flags an underscored spelling is refused for (--recap_base_url),
|
|
16
16
|
# with the config registry's.
|
|
17
|
-
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
|
|
17
|
+
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel].freeze
|
|
18
18
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
@@ -219,6 +219,9 @@ module Samagotchi
|
|
|
219
219
|
opts.on("--port PORT", "Port for `chi web` (default: 4567, env SAMAGOTCHI_WEB_PORT)") { |p| options[:web_port] = p.to_i; cli_overrides["web.port"] = p.to_s }
|
|
220
220
|
opts.on("--open", "Open browser for `chi web`") { options[:web_open] = true }
|
|
221
221
|
opts.on("--new-token", "`chi web`: replace the LAN access token (web.host: lan); links and QR codes made with the old one stop working") { options[:new_token] = true }
|
|
222
|
+
opts.on("--tunnel PROVIDER", %w[tailscale],
|
|
223
|
+
"`chi web`: reach it over https from your tailnet through Tailscale Serve (never Funnel), for this run; " \
|
|
224
|
+
"turned off on exit") { |v| options[:tunnel] = v }
|
|
222
225
|
opts.on("--scope VALUE", %w[project all],
|
|
223
226
|
"Which sessions `chi web` and `chi sessions list` show: project (the default: this folder's git project; " \
|
|
224
227
|
"every session outside a repo) or all") { |v| options[:scope] = v }
|
|
@@ -150,7 +150,7 @@ module Samagotchi
|
|
|
150
150
|
--llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
|
|
151
151
|
--no-default-input --dir --version -h --help],
|
|
152
152
|
LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
|
|
153
|
-
--port --open --new-token --scope --web-markdown --no-web-markdown]
|
|
153
|
+
--port --open --new-token --tunnel --scope --web-markdown --no-web-markdown]
|
|
154
154
|
}.freeze
|
|
155
155
|
# A short run flag's name in a refusal.
|
|
156
156
|
RUN_WORDS = { "-v" => "--verbose" }.freeze
|
|
@@ -364,8 +364,9 @@ module Samagotchi
|
|
|
364
364
|
end
|
|
365
365
|
|
|
366
366
|
# The link names this machine's chi web: web.public_url from this
|
|
367
|
-
# machine's config (never what the port answers) when it is set
|
|
368
|
-
#
|
|
367
|
+
# machine's config (never what the port answers) when it is set, or
|
|
368
|
+
# the one a running chi web --tunnel recorded (the code in the
|
|
369
|
+
# fragment, as chi web's own invite), else its LAN address
|
|
369
370
|
# and port when one runs on the LAN (the port it recorded, else
|
|
370
371
|
# web.port), else 127.0.0.1 (+lan+ false: the link opens on this
|
|
371
372
|
# machine only).
|
|
@@ -431,10 +432,15 @@ module Samagotchi
|
|
|
431
432
|
Web::LinkCodes.for_grants(grants.path).issue(kind: :grant, token: issued.token, ttl: ttl)
|
|
432
433
|
end
|
|
433
434
|
|
|
434
|
-
# web.public_url as this machine's config has it
|
|
435
|
-
#
|
|
435
|
+
# web.public_url as this machine's config has it (nil when not a
|
|
436
|
+
# valid one: chi web refuses to start on that), else the one the
|
|
437
|
+
# running chi web recorded (chi web --tunnel's), only while that same
|
|
438
|
+
# process answers on its port (Web::Running.live_public_url).
|
|
436
439
|
def configured_public_url
|
|
437
|
-
|
|
440
|
+
configured = Config.get("web.public_url")
|
|
441
|
+
return Web::Running.live_public_url(path: Web::Running.path(state_dir: state_dir!)) if configured.to_s.strip.empty?
|
|
442
|
+
|
|
443
|
+
Web::PublicUrl.from_config(url: configured)
|
|
438
444
|
rescue Web::PublicUrl::Invalid
|
|
439
445
|
nil
|
|
440
446
|
end
|
|
@@ -371,10 +371,14 @@ module Samagotchi
|
|
|
371
371
|
claimed_input?(session_dir, id) ? [:claimed, nil] : [:none, nil]
|
|
372
372
|
end
|
|
373
373
|
|
|
374
|
-
#
|
|
375
|
-
#
|
|
374
|
+
# The claimed input files (*.json.processing: a turn's, now or left by
|
|
375
|
+
# a crash, or a removal's before it deletes it).
|
|
376
|
+
# @return [Array<String>] their paths
|
|
377
|
+
def self.claimed_input_files(session_dir) = Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing"))
|
|
378
|
+
|
|
379
|
+
# Whether a claimed input file carries +id+.
|
|
376
380
|
def self.claimed_input?(session_dir, id)
|
|
377
|
-
|
|
381
|
+
claimed_input_files(session_dir).any? do |file|
|
|
378
382
|
data = JSON.parse(File.read(file))
|
|
379
383
|
data.is_a?(Hash) && data["enqueued_id"] == id
|
|
380
384
|
rescue JSON::ParserError, SystemCallError
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "client_id"
|
|
4
4
|
require_relative "participant_name"
|
|
5
|
+
require_relative "message_time"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# A plugin's steer: text a plugin puts into the running turn (Engine#steer),
|
|
@@ -53,15 +54,16 @@ module Samagotchi
|
|
|
53
54
|
|
|
54
55
|
# The messages it appends: the merged input first (one message per
|
|
55
56
|
# sender run, marked kind input, source: when not the user's), then
|
|
56
|
-
# each steer
|
|
57
|
-
|
|
57
|
+
# each steer; all stamped with +at+ (MessageTime::KEY), the drain's
|
|
58
|
+
# wall time.
|
|
59
|
+
def messages(at: MessageTime.iso(MessageTime.now))
|
|
58
60
|
list = inputs.map do |input|
|
|
59
61
|
message = { role: "user", kind: INPUT_KIND }
|
|
60
62
|
message[:source] = input[:source] if input[:source]
|
|
61
63
|
message.merge!(input[:mark]) if input[:mark]
|
|
62
64
|
message.merge(content: input[:content], **Steer.author_fields(input[:author], input[:author_name]))
|
|
63
65
|
end
|
|
64
|
-
list + steers.map { |steer| Steer.message(**steer) }
|
|
66
|
+
(list + steers.map { |steer| Steer.message(**steer) }).map { |message| MessageTime.stamp(message, at) }
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
# The :pending_input_merged fields beside iteration and answer;
|
|
@@ -14,6 +14,7 @@ require_relative "line_reader"
|
|
|
14
14
|
require_relative "question_prompt"
|
|
15
15
|
require_relative "reline_seam"
|
|
16
16
|
require_relative "version_lines"
|
|
17
|
+
require_relative "gap_clock"
|
|
17
18
|
require_relative "participant_commands"
|
|
18
19
|
require_relative "local_link"
|
|
19
20
|
require_relative "../bridge/turn_accumulator"
|
|
@@ -100,6 +101,7 @@ module Samagotchi
|
|
|
100
101
|
# @param default_input [Boolean] type SAMAGOTCHI_DEFAULT_INPUT into
|
|
101
102
|
# the first read (a new session with no -p, as the REPL)
|
|
102
103
|
# @param clock [#call] monotonic seconds (the Ctrl-C detach window)
|
|
104
|
+
# @param now [#call] the wall clock, a Time (the gap dividers)
|
|
103
105
|
# @param delete_session [#call] session id -> deletes it (/exit --delete)
|
|
104
106
|
# @param archive_session [#call] session id -> archives it (/archive);
|
|
105
107
|
# SessionManager.archive_session's result
|
|
@@ -114,7 +116,7 @@ module Samagotchi
|
|
|
114
116
|
# +client+ and the keywords that make a LocalLink
|
|
115
117
|
def initialize(screen:, client_id:, client: nil, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
116
118
|
default_input: false, wait_at_eof: false, as_parent: false,
|
|
117
|
-
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
119
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, now: -> { MessageTime.now },
|
|
118
120
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: LocalLink::DELETE_WAIT) },
|
|
119
121
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: LocalLink::DELETE_WAIT) },
|
|
120
122
|
installed_version: -> { InstalledVersions.new.newest },
|
|
@@ -152,6 +154,8 @@ module Samagotchi
|
|
|
152
154
|
@no_interrupt = no_interrupt
|
|
153
155
|
@no_default_input = !default_input
|
|
154
156
|
@clock = clock
|
|
157
|
+
# The session's last activity, for the gap dividers (GapClock).
|
|
158
|
+
@gaps = GapClock.new(now: now)
|
|
155
159
|
@last_idle_interrupt_at = nil
|
|
156
160
|
@next_input_prefill = nil
|
|
157
161
|
# Prompts this run sent, by enqueued_id: only those come back into
|
|
@@ -208,7 +212,12 @@ module Samagotchi
|
|
|
208
212
|
end
|
|
209
213
|
read = input || method(:read_input_line)
|
|
210
214
|
# A command typed after /unqueue's text echoes as the command alone.
|
|
211
|
-
|
|
215
|
+
# A typed prompt after a quiet gap has its divider above its echo.
|
|
216
|
+
echoed_read = lambda do |prompt, prefill|
|
|
217
|
+
RelineSeam.echo_as(method(:prefilled_echo)) do
|
|
218
|
+
RelineSeam.echo_above(method(:gap_above_echo)) { read.call(prompt, prefill) }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
212
221
|
@reader = LineReader.new(queue, prompt: method(:prompt_text), read: echoed_read, prefill: default_input_text).start
|
|
213
222
|
loop do
|
|
214
223
|
kind, payload = next_item(queue)
|
|
@@ -280,10 +289,12 @@ module Samagotchi
|
|
|
280
289
|
when :turn_dequeued then turn_dequeued(event)
|
|
281
290
|
when :turn_started
|
|
282
291
|
@status.take_event(event)
|
|
292
|
+
mark_activity
|
|
283
293
|
start_turn(event)
|
|
284
294
|
when :turn_completed
|
|
285
295
|
@status.take_event(event)
|
|
286
296
|
complete_turn(event)
|
|
297
|
+
@gaps.touch
|
|
287
298
|
refresh_children
|
|
288
299
|
own_turn_ended(event)
|
|
289
300
|
# The renderer says how it ended (the REPL's words too).
|
|
@@ -291,6 +302,7 @@ module Samagotchi
|
|
|
291
302
|
@status.take_event(event)
|
|
292
303
|
@renderer.call(event)
|
|
293
304
|
end_turn
|
|
305
|
+
@gaps.touch
|
|
294
306
|
refresh_children
|
|
295
307
|
own_turn_ended(event)
|
|
296
308
|
when :prompt_restored then restore_prompt(event)
|
|
@@ -307,10 +319,14 @@ module Samagotchi
|
|
|
307
319
|
when :command_ran
|
|
308
320
|
command_ran(event)
|
|
309
321
|
return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
|
|
310
|
-
when :reminder_injected
|
|
322
|
+
when :reminder_injected
|
|
323
|
+
mark_activity
|
|
324
|
+
@screen.commit(reminder_line(event[:reminders]))
|
|
311
325
|
# A context note joined the conversation (between turns). One dim
|
|
312
326
|
# line in the scrollback; the notes slot is for question choices.
|
|
313
|
-
when :context_added
|
|
327
|
+
when :context_added
|
|
328
|
+
mark_activity
|
|
329
|
+
@screen.commit(context_note_line(event[:label], event[:text]))
|
|
314
330
|
when :continue_offered then offer_continue(event)
|
|
315
331
|
when :continue_resolved then continue_resolved(event)
|
|
316
332
|
# The note comes with the kernel's :pending_input_merged (EventRenderer),
|
|
@@ -331,7 +347,7 @@ module Samagotchi
|
|
|
331
347
|
when :question_relay then question_relayed(event)
|
|
332
348
|
# Written while the session sat idle: shown at the open prompt. One
|
|
333
349
|
# collected just as a turn started describes the chat before it.
|
|
334
|
-
when :recap_ready then
|
|
350
|
+
when :recap_ready then show_recap_ready(event) unless @running || event[:recap].to_s.strip.empty?
|
|
335
351
|
when :guardrail_warning then @screen.commit(EventRenderer.load_warning_line(event))
|
|
336
352
|
# A plugin's slow setup: the activity row turns while it runs; a
|
|
337
353
|
# line when it is done (a failure is a warn card).
|
|
@@ -359,6 +375,32 @@ module Samagotchi
|
|
|
359
375
|
|
|
360
376
|
private
|
|
361
377
|
|
|
378
|
+
# A recap written while idle, with the time it was written.
|
|
379
|
+
def show_recap_ready(event)
|
|
380
|
+
mark_activity
|
|
381
|
+
@screen.commit(recap_block(event[:recap], at: event[:created_at]))
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Activity starts now: a gap's divider first when one passed since the
|
|
385
|
+
# last (none while a turn runs: its steps are no gap).
|
|
386
|
+
def mark_activity
|
|
387
|
+
line = @gaps.divider(quiet: @running)
|
|
388
|
+
@screen.commit(gap_divider(line)) if line
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# The divider above a typed line's echo (RelineSeam.echo_above, on the
|
|
392
|
+
# reader thread): a prompt typed after a quiet gap, at an idle prompt.
|
|
393
|
+
# It marks the activity, so the turn's start marks no second one.
|
|
394
|
+
def gap_above_echo(text)
|
|
395
|
+
return nil if @running
|
|
396
|
+
|
|
397
|
+
route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
|
|
398
|
+
return nil unless %i[prompt deliver].include?(route.kind)
|
|
399
|
+
|
|
400
|
+
line = @gaps.divider
|
|
401
|
+
line && gap_divider(line)
|
|
402
|
+
end
|
|
403
|
+
|
|
362
404
|
# The newest chi installed, looked at once, at the join.
|
|
363
405
|
def installed_version
|
|
364
406
|
@link.installed_version
|
|
@@ -936,6 +978,9 @@ module Samagotchi
|
|
|
936
978
|
return own_prompt_failed if images.nil?
|
|
937
979
|
|
|
938
980
|
running = @running
|
|
981
|
+
# Off a live region (no echo seam) the divider comes here; after
|
|
982
|
+
# #gap_above_echo it marks none.
|
|
983
|
+
mark_activity
|
|
939
984
|
options = { prompt: text, client_id: client_id_for(text) }
|
|
940
985
|
options[:no_interrupt] = true if @no_interrupt
|
|
941
986
|
options[:images] = images unless images.empty?
|
|
@@ -1315,8 +1360,9 @@ module Samagotchi
|
|
|
1315
1360
|
@attached = true
|
|
1316
1361
|
# The recap first: what the session was about, then where it stopped.
|
|
1317
1362
|
saved = snapshot[:saved_recap]
|
|
1318
|
-
@screen.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved && !saved[:text].to_s.strip.empty?
|
|
1363
|
+
@screen.commit(recap_block(saved[:text], turns_since: saved[:turns_since], at: saved[:created_at])) if saved && !saved[:text].to_s.strip.empty?
|
|
1319
1364
|
render_join_header(Array(snapshot[:messages]))
|
|
1365
|
+
render_join_gap(snapshot, saved)
|
|
1320
1366
|
@screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
|
|
1321
1367
|
@screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
|
|
1322
1368
|
version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
|
|
@@ -1431,6 +1477,17 @@ module Samagotchi
|
|
|
1431
1477
|
end
|
|
1432
1478
|
end
|
|
1433
1479
|
|
|
1480
|
+
# Where the session's last activity was: its newest stamped entry,
|
|
1481
|
+
# else its saved recap's time. A gap ago (and no turn running): one
|
|
1482
|
+
# line after the join block says when.
|
|
1483
|
+
def render_join_gap(snapshot, saved)
|
|
1484
|
+
@gaps.seed(snapshot[:messages], fallback: saved&.dig(:created_at))
|
|
1485
|
+
return if snapshot[:current_turn]
|
|
1486
|
+
|
|
1487
|
+
line = @gaps.join_line
|
|
1488
|
+
@screen.commit(gap_divider(line)) if line
|
|
1489
|
+
end
|
|
1490
|
+
|
|
1434
1491
|
def render_join_header(messages)
|
|
1435
1492
|
exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
|
|
1436
1493
|
# The id stays findable: the detach line and chi sessions list show it.
|
|
@@ -1537,17 +1594,19 @@ module Samagotchi
|
|
|
1537
1594
|
# A saved call's row; "no result" for a call the turn never answered
|
|
1538
1595
|
# (it ended first). Its status is the saved record's, as live and the
|
|
1539
1596
|
# web's reload (a guardrail's "blocked" is no error), else read from
|
|
1540
|
-
# its result.
|
|
1597
|
+
# its result (a guardrail's deny text reads blocked too).
|
|
1541
1598
|
def join_tool_line(part, duration_ms: nil, saved_status: nil)
|
|
1542
1599
|
output = part[:output]
|
|
1543
1600
|
command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
|
|
1601
|
+
text = ToolActivity.saved_result_text(output) unless output.nil?
|
|
1544
1602
|
status = if output.nil?
|
|
1545
1603
|
"no result"
|
|
1546
1604
|
elsif !saved_status.to_s.empty?
|
|
1547
1605
|
saved_status.to_s
|
|
1606
|
+
elsif ToolActivity.guardrail_denied?(text)
|
|
1607
|
+
"blocked"
|
|
1548
1608
|
else
|
|
1549
|
-
ToolActivity.tool_activity_status(
|
|
1550
|
-
command: command)
|
|
1609
|
+
ToolActivity.tool_activity_status(text, part[:tool], command: command)
|
|
1551
1610
|
end
|
|
1552
1611
|
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
1553
1612
|
params: part[:params], status: status,
|
|
@@ -1658,6 +1717,8 @@ module Samagotchi
|
|
|
1658
1717
|
def show_enqueued(event)
|
|
1659
1718
|
return if own?(event[:client_id])
|
|
1660
1719
|
|
|
1720
|
+
# Another client's prompt shows here, before its turn starts.
|
|
1721
|
+
mark_activity
|
|
1661
1722
|
@shown_enqueued << event[:enqueued_id]
|
|
1662
1723
|
line = prompt_line(event[:client_id], event[:prompt], **author_of(event[:author]))
|
|
1663
1724
|
@screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
|