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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +9 -6
  5. data/docs/cli.md +43 -1
  6. data/docs/configuration.md +140 -3
  7. data/docs/internals/engine.md +58 -14
  8. data/docs/internals/participants.md +7 -4
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +3 -3
  11. data/docs/internals/web-push.md +200 -0
  12. data/docs/internals/working-set.md +7 -6
  13. data/docs/plugins.md +1 -1
  14. data/docs/sessions.md +4 -2
  15. data/docs/sub-agent.md +1 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +4 -0
  19. data/lib/samagotchi/context_note.rb +7 -3
  20. data/lib/samagotchi/engine.rb +166 -499
  21. data/lib/samagotchi/engine_snapshots.rb +223 -0
  22. data/lib/samagotchi/event_fanout.rb +236 -0
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_reminders.rb +1 -1
  25. data/lib/samagotchi/kernel_loop.rb +12 -7
  26. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  27. data/lib/samagotchi/message_time.rb +104 -0
  28. data/lib/samagotchi/model_binding.rb +138 -0
  29. data/lib/samagotchi/reminder_queue.rb +20 -3
  30. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  31. data/lib/samagotchi/run_options.rb +4 -1
  32. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  33. data/lib/samagotchi/send_command.rb +2 -1
  34. data/lib/samagotchi/session.rb +12 -3
  35. data/lib/samagotchi/session_access/contract.rb +1 -1
  36. data/lib/samagotchi/session_access/local.rb +11 -5
  37. data/lib/samagotchi/session_access/remote.rb +2 -1
  38. data/lib/samagotchi/session_access.rb +7 -3
  39. data/lib/samagotchi/session_inbox.rb +7 -3
  40. data/lib/samagotchi/session_manager.rb +5 -0
  41. data/lib/samagotchi/steer.rb +5 -3
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  43. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  44. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  46. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  47. data/lib/samagotchi/terminal_ui.rb +40 -5
  48. data/lib/samagotchi/tool_activity.rb +11 -0
  49. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  50. data/lib/samagotchi/turn_endings.rb +137 -0
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/app.rb +28 -1369
  53. data/lib/samagotchi/web/grants.rb +3 -12
  54. data/lib/samagotchi/web/message_display.rb +274 -0
  55. data/lib/samagotchi/web/participant_view.rb +12 -5
  56. data/lib/samagotchi/web/private_files.rb +31 -0
  57. data/lib/samagotchi/web/public/app.js +41 -9
  58. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  59. data/lib/samagotchi/web/public/bubbles.js +29 -7
  60. data/lib/samagotchi/web/public/format.js +72 -6
  61. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  62. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  63. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  64. data/lib/samagotchi/web/public/index.html +6 -0
  65. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  66. data/lib/samagotchi/web/public/notifications.js +137 -3
  67. data/lib/samagotchi/web/public/notify.js +1 -1
  68. data/lib/samagotchi/web/public/participant.html +2 -0
  69. data/lib/samagotchi/web/public/participant.js +30 -7
  70. data/lib/samagotchi/web/public/participant_model.js +25 -2
  71. data/lib/samagotchi/web/public/push.js +95 -0
  72. data/lib/samagotchi/web/public/stage_view.js +8 -0
  73. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  74. data/lib/samagotchi/web/public/sw.js +101 -0
  75. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  76. data/lib/samagotchi/web/public/tokens.css +2 -2
  77. data/lib/samagotchi/web/public/turn_events.js +25 -1
  78. data/lib/samagotchi/web/public/turn_html.js +15 -8
  79. data/lib/samagotchi/web/public/turn_view.js +8 -3
  80. data/lib/samagotchi/web/push/attention.rb +105 -0
  81. data/lib/samagotchi/web/push/client.rb +103 -0
  82. data/lib/samagotchi/web/push/crypto.rb +126 -0
  83. data/lib/samagotchi/web/push/notifier.rb +230 -0
  84. data/lib/samagotchi/web/push/store.rb +285 -0
  85. data/lib/samagotchi/web/push.rb +73 -0
  86. data/lib/samagotchi/web/push_routes.rb +99 -0
  87. data/lib/samagotchi/web/running.rb +39 -4
  88. data/lib/samagotchi/web/server.rb +112 -32
  89. data/lib/samagotchi/web/session_control.rb +441 -0
  90. data/lib/samagotchi/web/session_create.rb +384 -0
  91. data/lib/samagotchi/web/session_show.rb +411 -0
  92. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  93. data/lib/samagotchi/web/tunnel.rb +133 -0
  94. data/lib/samagotchi/worker.rb +3 -2
  95. 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
- def initialize(store:)
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: "[SYSTEM: REMINDERS DUE]\n#{lines}\n[END REMINDERS]" }
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) if @turn
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
- def self.first_message(delivered)
26
- return new unless delivered
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], **WaitTarget.first_message(@delivered).to_h)
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
@@ -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 (the
368
- # token in the fragment, as chi web's own invite), else its LAN address
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; nil when unset or
435
- # not a valid one (chi web refuses to start on that).
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
- Web::PublicUrl.from_config(url: Config.get("web.public_url"))
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, and the server's word on a --parent-answers looser than its
164
- # config.yml's (capped).
165
- Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning)
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
- # Whether a claimed input file (*.json.processing: a turn's, now or
375
- # left by a crash, or a removal's before it deletes it) carries +id+.
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
- Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
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)
@@ -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
- def messages
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;