samagotchi 0.109.0 → 0.111.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 +57 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +9 -6
- data/docs/cli.md +43 -1
- data/docs/configuration.md +140 -3
- data/docs/internals/engine.md +58 -14
- data/docs/internals/participants.md +7 -4
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +4 -2
- 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/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +166 -499
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- 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/model_binding.rb +138 -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/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- 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/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -1369
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +41 -9
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +6 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- 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/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/sw.js +101 -0
- 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 +25 -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/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +112 -32
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +30 -1
|
@@ -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
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effective_model"
|
|
4
|
+
require_relative "model_overlay"
|
|
5
|
+
require_relative "model_profile"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# An Engine's model choice (ModelChoice: the model as given and as typed)
|
|
9
|
+
# and the prompt profile resolved for it, under one mutex; the counterpart
|
|
10
|
+
# of ToolkitBinding. The memory overlay keys follow the choice
|
|
11
|
+
# (#sync_model_key!).
|
|
12
|
+
#
|
|
13
|
+
# The choice changes in one assignment under the mutex (#switch!), so a
|
|
14
|
+
# reader on another thread sees the old pair or the new one. A resolution
|
|
15
|
+
# is kept and applied only for the choice it was resolved for, by
|
|
16
|
+
# identity (#store_profile: a /stats on a Bridge thread probing while
|
|
17
|
+
# /model runs keeps nothing). Nothing else takes the mutex.
|
|
18
|
+
#
|
|
19
|
+
# What it needs from the Engine comes through lookups, called at use; the
|
|
20
|
+
# Engine forwards each method here.
|
|
21
|
+
class ModelBinding
|
|
22
|
+
# @param choice [ModelChoice] the starting model
|
|
23
|
+
# @param given_profile [ModelProfile, nil] a caller's profile (until a switch)
|
|
24
|
+
# @param registry [HostRegistry]
|
|
25
|
+
# @param kernel [#call] → KernelLoop
|
|
26
|
+
# @param prompt_builder [#call] → SystemPrompt, nil while the Engine is built
|
|
27
|
+
# @param bare_name [#call] (ref) → the model id sent for +ref+
|
|
28
|
+
def initialize(choice:, given_profile:, registry:, kernel:, prompt_builder:, bare_name:)
|
|
29
|
+
@choice = choice
|
|
30
|
+
@given_profile = given_profile
|
|
31
|
+
@registry = registry
|
|
32
|
+
@kernel = kernel
|
|
33
|
+
@prompt_builder = prompt_builder
|
|
34
|
+
@bare_name = bare_name
|
|
35
|
+
# Guards the model choice and the profile cached for it (#store_profile).
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
# [choice, resolution]: the resolution kept, with the choice it is for.
|
|
38
|
+
@resolution = nil
|
|
39
|
+
@model_key = nil
|
|
40
|
+
@fallback_model_key = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [ModelChoice] the model turns run on now
|
|
44
|
+
attr_reader :choice
|
|
45
|
+
|
|
46
|
+
# @return [ModelProfile, nil] the profile given to the Engine, nil after a switch
|
|
47
|
+
attr_reader :given_profile
|
|
48
|
+
|
|
49
|
+
# The effective model's key (ModelOverlay.key_for its bare name), and
|
|
50
|
+
# the typed name's when that differs (#sync_model_key!).
|
|
51
|
+
# @return [String, nil]
|
|
52
|
+
attr_reader :model_key, :fallback_model_key
|
|
53
|
+
|
|
54
|
+
# Moves to +choice+ (Engine#switch_model!): the given profile and the
|
|
55
|
+
# kept resolution go with the old one.
|
|
56
|
+
def switch!(choice)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
# One assignment: a reader on another thread sees the old pair or the new one.
|
|
59
|
+
@choice = choice
|
|
60
|
+
# A profile given to .new was for the starting model.
|
|
61
|
+
@given_profile = nil
|
|
62
|
+
@resolution = nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Which profile the effective model gets and where that came from
|
|
67
|
+
# (ModelProfile.resolve: --profile/env, models:, hosts.<name>.profile,
|
|
68
|
+
# the server's chat template, the name, qwen36). Resolved on first need
|
|
69
|
+
# and kept, so the system prompt and the server's KV prefix stay stable;
|
|
70
|
+
# #switch! starts over, and a failed server probe is retried before
|
|
71
|
+
# the next turn (#refresh_profile!). The kernel follows each resolution.
|
|
72
|
+
# Kept with the model choice it was resolved for: one that finishes
|
|
73
|
+
# after a switch is neither kept nor applied (#store_profile).
|
|
74
|
+
# @return [ModelProfile::Resolution]
|
|
75
|
+
def profile_resolution
|
|
76
|
+
choice = @choice
|
|
77
|
+
cached = @resolution
|
|
78
|
+
return cached.last if cached&.first.equal?(choice)
|
|
79
|
+
|
|
80
|
+
store_profile(choice, resolve_profile(choice))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# +choice+'s profile (ModelProfile.resolve; may probe /props), or the
|
|
84
|
+
# one given to .new.
|
|
85
|
+
def resolve_profile(choice)
|
|
86
|
+
given = @given_profile
|
|
87
|
+
return ModelProfile::Resolution.new(profile: given, source: :given, detail: nil, retry: false) if given
|
|
88
|
+
|
|
89
|
+
EffectiveModel.of(@registry, choice).profile_resolution
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Keeps +resolution+ as +choice+'s and applies it, unless the model
|
|
93
|
+
# switched while it was resolved (a /stats on a Bridge thread probing
|
|
94
|
+
# as a /model ran): then it is only returned, and the new model's is
|
|
95
|
+
# resolved on its first need.
|
|
96
|
+
# @return [ModelProfile::Resolution]
|
|
97
|
+
def store_profile(choice, resolution)
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
return resolution unless @choice.equal?(choice)
|
|
100
|
+
|
|
101
|
+
apply_profile(resolution)
|
|
102
|
+
@resolution = [choice, resolution]
|
|
103
|
+
end
|
|
104
|
+
resolution
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Everything that holds a profile follows the resolution: the kernel's
|
|
108
|
+
# prompt format and parser, and the system prompts built for the old one.
|
|
109
|
+
def apply_profile(resolution)
|
|
110
|
+
kept = @resolution&.last
|
|
111
|
+
@prompt_builder.call&.reset! if kept && kept.profile.name != resolution.profile.name
|
|
112
|
+
@kernel.call.use_profile!(resolution)
|
|
113
|
+
resolution
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Before a turn: resolve now if nothing has yet, or again if the last
|
|
117
|
+
# server probe failed (unreachable, or 503 while loading a model). A
|
|
118
|
+
# profile that changes here drops the cached system prompts.
|
|
119
|
+
def refresh_profile!
|
|
120
|
+
choice, kept = @resolution
|
|
121
|
+
return profile_resolution unless choice.equal?(@choice) && kept.retry?
|
|
122
|
+
|
|
123
|
+
store_profile(choice, resolve_profile(choice))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The memory overlay key follows the model sent (ModelOverlay.key_for
|
|
127
|
+
# its id). A model typed as an alias used to key overlays by the alias
|
|
128
|
+
# (before aliases were applied there): that key is read when the
|
|
129
|
+
# target's overlay is missing (KernelLoop#sync_model_key!).
|
|
130
|
+
def sync_model_key!
|
|
131
|
+
choice = @choice
|
|
132
|
+
@model_key = ModelOverlay.key_for(@bare_name.call(choice.name))
|
|
133
|
+
typed_key = ModelOverlay.key_for(@registry.parse_qualified_model(choice.typed).last)
|
|
134
|
+
@fallback_model_key = typed_key == @model_key ? nil : typed_key
|
|
135
|
+
@kernel.call.sync_model_key!(@model_key, fallback: @fallback_model_key)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
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 }
|
|
@@ -19,11 +19,18 @@ module Samagotchi
|
|
|
19
19
|
# settle end the wait (TurnCheckpoint.settled_turn). A message merged
|
|
20
20
|
# into a turn starting then falls back to that turn's reply
|
|
21
21
|
# (ReplyWait's turn: without queued:); no id (an older worker), the
|
|
22
|
-
# next reply.
|
|
22
|
+
# next reply. A prompt the session was created with has no delivery:
|
|
23
|
+
# its turn is the id the create gave it (+direct_turn+,
|
|
24
|
+
# Session#first_turn_id), and nothing can merge into it, so the wait
|
|
25
|
+
# is for that turn alone (ReplyWait's queued:).
|
|
23
26
|
# @param delivered [SessionManager::DeliverResult, nil] nil when the
|
|
24
27
|
# message started the session itself (no delivery)
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
# @param direct_turn [String, nil] that first turn's id, for a direct
|
|
29
|
+
# start
|
|
30
|
+
def self.first_message(delivered, direct_turn: nil)
|
|
31
|
+
return new unless delivered || !direct_turn.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
return new(turn: direct_turn, report_turn_id: direct_turn, queued: true) if delivered.nil?
|
|
27
34
|
|
|
28
35
|
new(turn: delivered.enqueued_id, report_turn_id: delivered.enqueued_id)
|
|
29
36
|
end
|
|
@@ -521,7 +521,8 @@ module Samagotchi
|
|
|
521
521
|
|
|
522
522
|
start = @access.prepare_wait(session.id, session: session, created: true)
|
|
523
523
|
wait_for_reply(session.id, cursor: start.cursor, baseline: start.baseline,
|
|
524
|
-
timeout: options[:timeout],
|
|
524
|
+
timeout: options[:timeout],
|
|
525
|
+
**WaitTarget.first_message(@delivered, direct_turn: session.first_turn_id).to_h)
|
|
525
526
|
end
|
|
526
527
|
|
|
527
528
|
# What chi send --new asks to create: through the worker (images, a
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -54,6 +54,13 @@ module Samagotchi
|
|
|
54
54
|
attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
|
|
55
55
|
:created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
|
|
56
56
|
:used_memory_names
|
|
57
|
+
# The id the prompt a session is created with gets (SessionManager
|
|
58
|
+
# .spawn_session), so its first turn is a turn with an id like one sent
|
|
59
|
+
# later: the worker's first turn takes it as its origin, its reply file
|
|
60
|
+
# and last_turn carry it, and a wait for it (`chi send --new --wait`,
|
|
61
|
+
# chi web's create) can name it (`--wait --turn T ID`). nil for a
|
|
62
|
+
# session created idle, and for one saved before the field existed.
|
|
63
|
+
attr_accessor :first_turn_id
|
|
57
64
|
# Memories the session was started with (--memory) and memories hidden
|
|
58
65
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
59
66
|
# Names as given; the engine normalizes them.
|
|
@@ -310,7 +317,7 @@ module Samagotchi
|
|
|
310
317
|
|
|
311
318
|
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
312
319
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
313
|
-
first_preview: "", test_run: false, pending_question: nil,
|
|
320
|
+
first_preview: "", first_turn_id: nil, test_run: false, pending_question: nil,
|
|
314
321
|
used_memory_names: [], project_root: nil,
|
|
315
322
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
316
323
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
@@ -329,6 +336,7 @@ module Samagotchi
|
|
|
329
336
|
@status = status
|
|
330
337
|
@last_prompt = last_prompt
|
|
331
338
|
@first_preview = first_preview
|
|
339
|
+
@first_turn_id = first_turn_id
|
|
332
340
|
@title = title
|
|
333
341
|
@test_run = !!test_run
|
|
334
342
|
self.pending_question = pending_question
|
|
@@ -525,12 +533,13 @@ module Samagotchi
|
|
|
525
533
|
"interrupted_turn" => nil,
|
|
526
534
|
"parent_answers" => nil,
|
|
527
535
|
"working_set_pins" => [],
|
|
528
|
-
"review" => nil
|
|
536
|
+
"review" => nil,
|
|
537
|
+
"first_turn_id" => nil
|
|
529
538
|
}.freeze
|
|
530
539
|
|
|
531
540
|
# FIELDS a session file has only when set: a session without them saves
|
|
532
541
|
# as it did before they existed.
|
|
533
|
-
OPTIONAL_FIELDS = %w[review].freeze
|
|
542
|
+
OPTIONAL_FIELDS = %w[review first_turn_id].freeze
|
|
534
543
|
|
|
535
544
|
# A session from a parsed session file. +messages+ false leaves the
|
|
536
545
|
# conversation out (the lists' lightweight summaries).
|
|
@@ -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
|
|
@@ -179,7 +179,8 @@ module Samagotchi
|
|
|
179
179
|
|
|
180
180
|
json = reply.json
|
|
181
181
|
Created.new(id: json.fetch("id"), model_warning: json["model_warning"], continues: json["continues"],
|
|
182
|
-
moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"]
|
|
182
|
+
moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"],
|
|
183
|
+
first_turn_id: json["first_turn_id"])
|
|
183
184
|
end
|
|
184
185
|
|
|
185
186
|
# Whether a just-created session's worker came up within +timeout+
|
|
@@ -160,9 +160,13 @@ module Samagotchi
|
|
|
160
160
|
|
|
161
161
|
# What chi send --new created (Remote; Local gives the Session): its
|
|
162
162
|
# id, a model warning, the session it continues, the delegates it took
|
|
163
|
-
# over,
|
|
164
|
-
# config.yml's (capped)
|
|
165
|
-
|
|
163
|
+
# over, the server's word on a --parent-answers looser than its
|
|
164
|
+
# config.yml's (capped), and the id the prompt it started with gets for
|
|
165
|
+
# its first turn (nil for one created idle).
|
|
166
|
+
Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning, :first_turn_id) do
|
|
167
|
+
def initialize(id:, model_warning: nil, continues: nil, moved_children: [], parent_answers_warning: nil,
|
|
168
|
+
first_turn_id: nil) = super
|
|
169
|
+
end
|
|
166
170
|
|
|
167
171
|
# A session's worker as an attach finds it (Local#worker, chi web's GET
|
|
168
172
|
# …/worker): +sidecar+ the live worker's WorkerSidecar (port, version,
|
|
@@ -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
|
|
@@ -197,6 +197,11 @@ module Samagotchi
|
|
|
197
197
|
# the prompts), so the session starts idle.
|
|
198
198
|
session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
|
|
199
199
|
session.last_prompt = prompt
|
|
200
|
+
# The first prompt's turn gets an id like a sent message's, so a wait
|
|
201
|
+
# for it can name it (chi send --new --wait, chi web's create): the
|
|
202
|
+
# worker runs it with that origin, and its reply file and last_turn
|
|
203
|
+
# carry the id.
|
|
204
|
+
session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
|
|
200
205
|
# The worker takes last_prompt and clears it, and messages are saved at
|
|
201
206
|
# the turn's end: until then this is the only preview a list has.
|
|
202
207
|
session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
|
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;
|