samagotchi 0.110.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 +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- 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/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- 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/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- 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 +4 -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/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- 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/server.rb +26 -2
- 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/worker.rb +3 -2
- metadata +25 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time" # Time.iso8601
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "../analytics_file"
|
|
7
|
+
require_relative "../answer_refs"
|
|
8
|
+
require_relative "../answer_tail"
|
|
9
|
+
require_relative "../output_formatter"
|
|
10
|
+
require_relative "../question_asked"
|
|
11
|
+
require_relative "../session_commands"
|
|
12
|
+
require_relative "../session_metrics"
|
|
13
|
+
require_relative "../session_setting"
|
|
14
|
+
|
|
15
|
+
module Samagotchi
|
|
16
|
+
module Web
|
|
17
|
+
# GET /api/sessions/:id, the page's session read, and its light re-reads
|
|
18
|
+
# (?cards=1, ?tail=1, ?timing=1; ?brief=1 is SessionAccessRoutes'
|
|
19
|
+
# handle_brief_read), GET /api/sessions/:id/output, and the timing
|
|
20
|
+
# payload they carry. Included in App: its ROUTES name the handlers, and
|
|
21
|
+
# its helpers (json_response, error_response, bridge_get_json,
|
|
22
|
+
# session_owner, session_to_json, …) and ivars (@manager,
|
|
23
|
+
# @session_class, @state_dir, @markdown_renderer) answer. A participant's
|
|
24
|
+
# read goes to ParticipantAccess (participant_show). See
|
|
25
|
+
# docs/architecture.md (chi web's routes) and docs/internals/working-set.md.
|
|
26
|
+
module SessionShow
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def handle_show(req, id)
|
|
30
|
+
return participant_show(id) unless principal(req).owner?
|
|
31
|
+
|
|
32
|
+
# The page's light re-reads: never the whole conversation.
|
|
33
|
+
return handle_cards_read(id) if req.params["cards"] == "1"
|
|
34
|
+
return handle_tail_read(req, id) if req.params["tail"] == "1"
|
|
35
|
+
return handle_timing_read(id) if req.params["timing"] == "1"
|
|
36
|
+
return handle_brief_read(id) if req.params["brief"] == "1"
|
|
37
|
+
|
|
38
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
39
|
+
# Read-only preview: selecting a session never spawns a worker.
|
|
40
|
+
# The worker is woken only on POST /turn (handle_turn) via
|
|
41
|
+
# SessionManager.resume_session + write_turn_input. This avoids
|
|
42
|
+
# replaying last_prompt and spamming workers on preview scrub.
|
|
43
|
+
# last_event_seq is nil when no live bridge, so the frontend stays
|
|
44
|
+
# silent until the first Send.
|
|
45
|
+
#
|
|
46
|
+
# A live worker's snapshot is the truth (the session file lags its
|
|
47
|
+
# Engine): the messages, the turn in progress and the prompts queued
|
|
48
|
+
# behind it, all at one event_seq the client streams on from.
|
|
49
|
+
live = bridge_get_json(id, "snapshot")
|
|
50
|
+
turn_snapshot = live && live["snapshot"]
|
|
51
|
+
snapshot = live && live["session_state_snapshot"]
|
|
52
|
+
last_event_seq = live_or_saved(snapshot, "event_seq") { bridge_event_seq(id) }
|
|
53
|
+
current_turn = turn_snapshot && turn_snapshot["current_turn"]
|
|
54
|
+
owner = session_owner(id)
|
|
55
|
+
pending = shown_pending_question(session, turn_snapshot, owner)
|
|
56
|
+
# What the worker's server said it served for that model (after a turn);
|
|
57
|
+
# without a worker, what the session's analytics.json last saved.
|
|
58
|
+
persisted = snapshot ? nil : read_analytics(id)
|
|
59
|
+
session_json = shown_session_json(session, snapshot, owner, persisted)
|
|
60
|
+
raw_messages = live_or_saved(turn_snapshot, "messages") { session.messages }
|
|
61
|
+
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
|
|
62
|
+
json_response(200, {
|
|
63
|
+
session: session_json,
|
|
64
|
+
history: read_history(id),
|
|
65
|
+
messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
|
|
66
|
+
cwd: session.working_directory),
|
|
67
|
+
failed_turn: failed_turn_for(session, raw_messages),
|
|
68
|
+
current_turn: current_turn,
|
|
69
|
+
queued: snapshot_list(turn_snapshot, "queued"),
|
|
70
|
+
# Commands waiting for the running turn's end (their bubbles).
|
|
71
|
+
queued_commands: snapshot_list(turn_snapshot, "queued_commands"),
|
|
72
|
+
recap: turn_snapshot && turn_snapshot["recap"],
|
|
73
|
+
saved_recap: saved_recap_for(id, session, turn_snapshot),
|
|
74
|
+
continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
|
|
75
|
+
guardrail_warning: turn_snapshot && turn_snapshot["guardrail_warning"],
|
|
76
|
+
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
77
|
+
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
78
|
+
init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
|
|
79
|
+
cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }, cwd: session.working_directory),
|
|
80
|
+
# The composer's / autocomplete; the built-ins until a worker
|
|
81
|
+
# names its plugins' too.
|
|
82
|
+
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
83
|
+
markdown_warning: @markdown_renderer.warning,
|
|
84
|
+
**viewer_capabilities(req),
|
|
85
|
+
pending_question: pending,
|
|
86
|
+
last_event_seq: last_event_seq,
|
|
87
|
+
# The stream cursor `<seq>-<epoch>`: a later worker resets it
|
|
88
|
+
# instead of taking the seq as its own. Only the snapshot has it.
|
|
89
|
+
last_event_id: turn_snapshot && turn_snapshot["event_id"],
|
|
90
|
+
timing: timing
|
|
91
|
+
})
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A list in the worker's turn +snapshot+ (queued prompts, commands,
|
|
95
|
+
# init tasks); [] without a worker.
|
|
96
|
+
def snapshot_list(snapshot, key) = Array(snapshot&.[](key))
|
|
97
|
+
|
|
98
|
+
# A live worker's +key+ as its reading (+live+: a snapshot, a tail)
|
|
99
|
+
# says it, nil included; without a worker (+live+ nil) the block's,
|
|
100
|
+
# the server's own reading of the saved session. A nil value from a
|
|
101
|
+
# worker is not a missing one (contrast SessionSetting#snapshot_value's
|
|
102
|
+
# :or).
|
|
103
|
+
def live_or_saved(live, key)
|
|
104
|
+
live ? live[key] : yield
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The file's question only while a worker owns the session: one a
|
|
108
|
+
# dead worker saved can't be answered (its turn is gone), nor a chi
|
|
109
|
+
# REPL's (it shares nothing with the web).
|
|
110
|
+
# A question between turns (the step-limit one) is the snapshot's own.
|
|
111
|
+
# Its refs (the asking reply's, QuestionAsked) are checked again.
|
|
112
|
+
def shown_pending_question(session, turn_snapshot, owner)
|
|
113
|
+
pending = if turn_snapshot
|
|
114
|
+
current_turn = turn_snapshot["current_turn"]
|
|
115
|
+
current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
|
|
116
|
+
elsif owner&.worker?
|
|
117
|
+
session.pending_question&.to_h
|
|
118
|
+
end
|
|
119
|
+
with_question_refs(pending, cwd: session.working_directory)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A pending question (a snapshot's, the session file's or a saved
|
|
123
|
+
# card's) with its refs checked again, under a question's caps
|
|
124
|
+
# (QuestionAsked): a stored file is never trusted, and what isn't
|
|
125
|
+
# valid any more is dropped. The question as it was without refs.
|
|
126
|
+
def with_question_refs(pending, cwd:)
|
|
127
|
+
return pending unless pending.is_a?(Hash)
|
|
128
|
+
|
|
129
|
+
key = [:refs, "refs"].find { |name| pending.key?(name) } or return pending
|
|
130
|
+
refs = Samagotchi::AnswerRefs.for_display(pending[key], cwd: cwd, max_refs: Samagotchi::QuestionAsked::MAX_REFS,
|
|
131
|
+
max_bytes: Samagotchi::QuestionAsked::MAX_BYTES)
|
|
132
|
+
refs.empty? ? pending.except(key) : pending.merge(key => refs)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The full show's session part: the file's, with what a live worker's
|
|
136
|
+
# state +snapshot+ knows first (its model, status, served model,
|
|
137
|
+
# prompt notes and chips); without one, the served model from
|
|
138
|
+
# +persisted+ (analytics.json). Sets a worker's model on +session+:
|
|
139
|
+
# a /model in the worker changes it before the file catches up.
|
|
140
|
+
def shown_session_json(session, snapshot, owner, persisted)
|
|
141
|
+
session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
|
|
142
|
+
session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
|
|
143
|
+
if snapshot
|
|
144
|
+
session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
|
|
145
|
+
served_expected_by: snapshot["served_expected_by"])
|
|
146
|
+
# The model notes its prompt carries now (a /model there changes them).
|
|
147
|
+
session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
|
|
148
|
+
else
|
|
149
|
+
session_json = session_json.merge(saved_served_model(session, persisted))
|
|
150
|
+
end
|
|
151
|
+
# The per-session settings' chips (the info bar's llm ctx, think,
|
|
152
|
+
# reasoning and tools): the worker's, else worked out here from the
|
|
153
|
+
# session file (SessionSetting#snapshot_value, #saved_summary).
|
|
154
|
+
SessionSetting::ALL.each do |setting|
|
|
155
|
+
chip = setting.snapshot_value(snapshot) { setting.saved_summary(session, registry: host_registry) }
|
|
156
|
+
session_json = session_json.merge(setting.key => chip)
|
|
157
|
+
end
|
|
158
|
+
session_json
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ?cards=1: the page's re-read when a card arrives, for its rendered
|
|
162
|
+
# body (the stream carries the card's plain text only). The worker's
|
|
163
|
+
# cards from its light GET tail, else the saved ones; the session file
|
|
164
|
+
# is only stat'ed (the 404), never parsed.
|
|
165
|
+
def handle_cards_read(id)
|
|
166
|
+
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
167
|
+
|
|
168
|
+
tail = live_tail(id)
|
|
169
|
+
json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ?tail=1: the page's re-read at the end of a turn wants the final
|
|
173
|
+
# answer's rendered markdown, the status and the timing, not the whole
|
|
174
|
+
# history again (nor a render of every earlier answer). Live: the
|
|
175
|
+
# worker's GET tail alone (no session file read); else the file. The
|
|
176
|
+
# session part is what the page reads of it (id, status, used memory
|
|
177
|
+
# names, the model notes its prompt carried: the first turn's build or
|
|
178
|
+
# a /model records them). ?turn_id=: that turn's answer (a queued
|
|
179
|
+
# turn's page re-reads after the next one started). The timing's records
|
|
180
|
+
# are the newest turn's (#recent_timing), which the page merges by id
|
|
181
|
+
# (the page still sends ?recent=1, which is accepted and not needed).
|
|
182
|
+
def handle_tail_read(req, id)
|
|
183
|
+
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
184
|
+
|
|
185
|
+
turn_id = req.params["turn_id"].to_s.strip
|
|
186
|
+
turn_id = nil if turn_id.empty?
|
|
187
|
+
tail = live_tail(id, turn_id: turn_id)
|
|
188
|
+
state = tail && tail["session_state_snapshot"]
|
|
189
|
+
state = nil unless state.is_a?(Hash)
|
|
190
|
+
if state
|
|
191
|
+
status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
|
|
192
|
+
answer = tail["answer"]
|
|
193
|
+
cwd = tail_working_directory(id, tail, answer)
|
|
194
|
+
answer = without_file_refs(answer) unless cwd
|
|
195
|
+
messages = last_assistant_for_display([answer].compact, cwd: cwd)
|
|
196
|
+
else
|
|
197
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
198
|
+
status = displayed_status(session)
|
|
199
|
+
used = session.used_memory_names
|
|
200
|
+
notes = session.prompt_notes.map(&:to_h)
|
|
201
|
+
messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
|
|
202
|
+
end
|
|
203
|
+
session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
|
|
204
|
+
live_metrics = state && state["metrics"]
|
|
205
|
+
timing = recent_timing(id, live_metrics)
|
|
206
|
+
json_response(200, {
|
|
207
|
+
tail: true,
|
|
208
|
+
session: session_json,
|
|
209
|
+
messages: messages,
|
|
210
|
+
markdown_warning: @markdown_renderer.warning,
|
|
211
|
+
**viewer_capabilities(req),
|
|
212
|
+
timing: timing
|
|
213
|
+
})
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The folder the live tail's answer's file refs must stay under: the
|
|
217
|
+
# worker's (its tail's working_directory), else the hub's session list
|
|
218
|
+
# entry's (a worker from before the field, or read through its
|
|
219
|
+
# snapshot), else, only when the answer has refs to check, the session
|
|
220
|
+
# file's, as the full read takes it. nil when none is known: the
|
|
221
|
+
# caller drops the file refs rather than show them unchecked.
|
|
222
|
+
# @return [String, nil]
|
|
223
|
+
def tail_working_directory(id, tail, answer)
|
|
224
|
+
known = ->(dir) { dir.is_a?(String) && !dir.empty? }
|
|
225
|
+
cwd = tail["working_directory"]
|
|
226
|
+
cwd = @hub&.working_directory(id) unless known.call(cwd)
|
|
227
|
+
return cwd if known.call(cwd)
|
|
228
|
+
return nil unless answer.is_a?(Hash) && (answer[AnswerRefs::KEY.to_s] || answer[AnswerRefs::KEY])
|
|
229
|
+
|
|
230
|
+
cwd = @session_class.load(id, state_dir: default_state_dir).working_directory
|
|
231
|
+
known.call(cwd) ? cwd : nil
|
|
232
|
+
rescue ArgumentError, KeyError, SystemCallError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# ?timing=1: the whole timing (every record, analytics.json merged with
|
|
237
|
+
# the live ones) and turn_count, when the page's merge comes up short.
|
|
238
|
+
# The worker's GET state, never its messages.
|
|
239
|
+
def handle_timing_read(id)
|
|
240
|
+
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
241
|
+
|
|
242
|
+
state = bridge_get_json(id, "state")
|
|
243
|
+
json_response(200, { timing: timing_payload(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def session_exists?(id)
|
|
247
|
+
@session_class.exist?(id, state_dir: default_state_dir)
|
|
248
|
+
rescue ArgumentError
|
|
249
|
+
false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# The worker's light read (Bridge GET tail): {"session_state_snapshot",
|
|
253
|
+
# "working_directory", "answer", "cards", "event_seq", "event_id"}. A
|
|
254
|
+
# worker from before the route answers 404 not_found: its GET snapshot,
|
|
255
|
+
# in the same shape (the answer by AnswerTail; no working_directory). Anything else (no worker, a refused connect, a
|
|
256
|
+
# 500, a timeout) is nil: the caller reads the disk, with no second
|
|
257
|
+
# try at a worker that failed once.
|
|
258
|
+
# @return [Hash, nil]
|
|
259
|
+
def live_tail(id, turn_id: nil)
|
|
260
|
+
status, body = bridge_get(id, turn_id ? "tail?turn_id=#{URI.encode_www_form_component(turn_id)}" : "tail")
|
|
261
|
+
return body if status == 200 && body.is_a?(Hash)
|
|
262
|
+
return nil unless status == 404 && body.is_a?(Hash) && body["error"] == "not_found"
|
|
263
|
+
|
|
264
|
+
status, live = bridge_get(id, "snapshot")
|
|
265
|
+
return nil unless status == 200 && live.is_a?(Hash) && live["snapshot"].is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
snap = live["snapshot"]
|
|
268
|
+
{ "session_state_snapshot" => live["session_state_snapshot"],
|
|
269
|
+
"answer" => AnswerTail.find(snap["messages"], turn_id: turn_id),
|
|
270
|
+
"cards" => snap["cards"], "event_seq" => snap["event_seq"], "event_id" => snap["event_id"] }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def handle_output(req, id)
|
|
274
|
+
# Query ?since= may be an ISO8601 time or numeric mtime; we treat it as Time parse.
|
|
275
|
+
since_param = req.params["since"]
|
|
276
|
+
since_time = parse_since(since_param)
|
|
277
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
278
|
+
raw = @manager.read_responses(id, since_time: since_time, state_dir: @state_dir)
|
|
279
|
+
# Strip wire tokens server-side so the client never sees <|tool_call> etc.
|
|
280
|
+
rendered = raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
|
|
281
|
+
json_response(200, { session_id: session.id, output: rendered })
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The whole timing: analytics.json's records merged with a live
|
|
285
|
+
# worker's (by id), and turn_count, the finished turns (#turn_count).
|
|
286
|
+
# +persisted+: the session's analytics.json when the caller read it.
|
|
287
|
+
def timing_payload(session_id, live_metrics: nil, persisted: nil)
|
|
288
|
+
persisted ||= read_analytics(session_id)
|
|
289
|
+
live = live_metrics.is_a?(Hash)
|
|
290
|
+
source = if live
|
|
291
|
+
persisted.merge(live_metrics).merge(
|
|
292
|
+
"started_at" => persisted["started_at"] || live_metrics["started_at"],
|
|
293
|
+
"turn_records" => merge_timing_records(without_running_turn(persisted["turn_records"], live_metrics),
|
|
294
|
+
live_metrics["turn_records"]),
|
|
295
|
+
"tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
|
|
296
|
+
)
|
|
297
|
+
else
|
|
298
|
+
persisted
|
|
299
|
+
end
|
|
300
|
+
turns = Array(source["turn_records"])
|
|
301
|
+
timing_from(source, turns, Array(source["tool_records"]), live: live,
|
|
302
|
+
turn_count: (live && turn_count(live_metrics)) || turns.size)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The timing a ?tail=1 read sends: a live worker's recent
|
|
306
|
+
# records as its metrics hold them (the newest finished turn's and
|
|
307
|
+
# the unsaved ones, the running turn's finished calls), no
|
|
308
|
+
# analytics.json read; with no worker the disk's, trimmed to the
|
|
309
|
+
# newest turn. turn_count tells the page whether its merge missed one
|
|
310
|
+
# (it then reads ?timing=1).
|
|
311
|
+
def recent_timing(session_id, live_metrics)
|
|
312
|
+
if live_metrics.is_a?(Hash)
|
|
313
|
+
count = turn_count(live_metrics)
|
|
314
|
+
# A metrics hash without the count: the whole timing.
|
|
315
|
+
return timing_payload(session_id, live_metrics: live_metrics) unless count
|
|
316
|
+
|
|
317
|
+
turns = Array(live_metrics["turn_records"])
|
|
318
|
+
tools = Array(live_metrics["tool_records"])
|
|
319
|
+
# An older worker's metrics hold every record (before they held the
|
|
320
|
+
# recent ones only, never more than LIVE_RECORDS_CAP turns).
|
|
321
|
+
turns, tools = newest_turn_records(turns, tools, live_metrics["active_turn"]) if turns.size > SessionMetrics::LIVE_RECORDS_CAP
|
|
322
|
+
return timing_from(live_metrics, turns, tools, live: true, turn_count: count)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
persisted = read_analytics(session_id)
|
|
326
|
+
all = Array(persisted["turn_records"])
|
|
327
|
+
turns, tools = newest_turn_records(all, Array(persisted["tool_records"]), nil)
|
|
328
|
+
timing_from(persisted, turns, tools, live: false, turn_count: all.size)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# The finished turns a live worker counts: its metrics' turns (the
|
|
332
|
+
# loaded history, its own and the running one) less the running one.
|
|
333
|
+
# @return [Integer, nil]
|
|
334
|
+
def turn_count(metrics)
|
|
335
|
+
turns = metrics["turns"]
|
|
336
|
+
return nil unless turns.is_a?(Integer)
|
|
337
|
+
|
|
338
|
+
[turns - (metrics["active_turn"] ? 1 : 0), 0].max
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The newest turn record and its tool records (and the running turn's
|
|
342
|
+
# finished calls).
|
|
343
|
+
def newest_turn_records(turns, tools, active_turn)
|
|
344
|
+
kept = turns.last(1)
|
|
345
|
+
ids = kept.filter_map { |r| r["id"] if r.is_a?(Hash) }
|
|
346
|
+
ids << active_turn["id"] if active_turn.is_a?(Hash) && active_turn["id"]
|
|
347
|
+
[kept, tools.select { |r| r.is_a?(Hash) && ids.include?(r["turn_id"]) }]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def timing_from(source, turn_records, tool_records, live:, turn_count:)
|
|
351
|
+
started_at = source["started_at"]
|
|
352
|
+
last_activity_at = source["last_activity_at"]
|
|
353
|
+
{
|
|
354
|
+
started_at: started_at,
|
|
355
|
+
last_activity_at: last_activity_at,
|
|
356
|
+
session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live),
|
|
357
|
+
turn_records: turn_records,
|
|
358
|
+
tool_records: tool_records,
|
|
359
|
+
turn_count: turn_count,
|
|
360
|
+
active_turn: source["active_turn"],
|
|
361
|
+
active_tools: Array(source["active_tools"]),
|
|
362
|
+
# The context last seen and the token sums, for the ctx meter
|
|
363
|
+
# before the next turn streams (live over saved).
|
|
364
|
+
context: source["context"],
|
|
365
|
+
tokens: source["tokens"],
|
|
366
|
+
# The tokens, cost and speeds per model (SessionMetrics'
|
|
367
|
+
# model_segments; none with one model, or from an older file).
|
|
368
|
+
model_segments: Array(source["model_segments"]),
|
|
369
|
+
# The memory indexes the session's prompt holds (the ctx tooltip).
|
|
370
|
+
memory_index: source["memory_index"],
|
|
371
|
+
# The side requests (recap, /btw, ctx.ask_model), apart from the
|
|
372
|
+
# tokens: the ctx tooltip's own line (nil without any).
|
|
373
|
+
side: source["side"]
|
|
374
|
+
}
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def read_analytics(session_id)
|
|
378
|
+
AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# The saved turn records without the one the live worker is running:
|
|
382
|
+
# its turn checkpoint saves that turn's record so far as a killed
|
|
383
|
+
# turn's (canceled, worker_stopped, SessionMetrics#persist's
|
|
384
|
+
# running_turn), which is only true once the worker is gone.
|
|
385
|
+
def without_running_turn(records, live_metrics)
|
|
386
|
+
active = live_metrics["active_turn"]
|
|
387
|
+
id = active.is_a?(Hash) ? active["id"] || active[:id] : nil
|
|
388
|
+
return records if id.nil?
|
|
389
|
+
|
|
390
|
+
Array(records).reject { |record| record.is_a?(Hash) && (record["id"] || record[:id]) == id }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def merge_timing_records(persisted, live)
|
|
394
|
+
(Array(persisted) + Array(live)).each_with_object({}) do |record, records|
|
|
395
|
+
next unless record.is_a?(Hash)
|
|
396
|
+
|
|
397
|
+
id = record["id"] || record[:id]
|
|
398
|
+
records[id] = record if id
|
|
399
|
+
end.values
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def session_duration_ms(started_at, last_activity_at, active:)
|
|
403
|
+
started = Time.iso8601(started_at.to_s)
|
|
404
|
+
finished = active ? Time.now : Time.iso8601(last_activity_at.to_s)
|
|
405
|
+
[((finished - started) * 1000).round, 0].max
|
|
406
|
+
rescue ArgumentError
|
|
407
|
+
nil
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -607,10 +607,11 @@ module Samagotchi
|
|
|
607
607
|
end
|
|
608
608
|
|
|
609
609
|
# The first prompt (spawn_session's): a delegate child's task, which is
|
|
610
|
-
# its parent's although no client sent it.
|
|
610
|
+
# its parent's although no client sent it. Its turn carries the id
|
|
611
|
+
# spawn_session gave it, so a wait for it (--new --wait) names it.
|
|
611
612
|
def run_initial_prompt(prompt)
|
|
612
613
|
@initial_turn = true
|
|
613
|
-
run_prompt(WorkerInbound::Prompt.new(text: prompt))
|
|
614
|
+
run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: TurnOrigin.new(enqueued_id: @session.first_turn_id)))
|
|
614
615
|
ensure
|
|
615
616
|
@initial_turn = false
|
|
616
617
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.111.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -164,6 +164,7 @@ files:
|
|
|
164
164
|
- docs/internals/prompt-caching.md
|
|
165
165
|
- docs/internals/remote-sessions.md
|
|
166
166
|
- docs/internals/tool-guardrails.md
|
|
167
|
+
- docs/internals/web-push.md
|
|
167
168
|
- docs/internals/working-set.md
|
|
168
169
|
- docs/memory.md
|
|
169
170
|
- docs/plugins.md
|
|
@@ -325,6 +326,8 @@ files:
|
|
|
325
326
|
- lib/samagotchi/empty_answer_retry.rb
|
|
326
327
|
- lib/samagotchi/engine.rb
|
|
327
328
|
- lib/samagotchi/engine_peers.rb
|
|
329
|
+
- lib/samagotchi/engine_snapshots.rb
|
|
330
|
+
- lib/samagotchi/event_fanout.rb
|
|
328
331
|
- lib/samagotchi/event_routes.rb
|
|
329
332
|
- lib/samagotchi/events.rb
|
|
330
333
|
- lib/samagotchi/extension_load.rb
|
|
@@ -450,6 +453,7 @@ files:
|
|
|
450
453
|
- lib/samagotchi/memory_paths.rb
|
|
451
454
|
- lib/samagotchi/message_parts.rb
|
|
452
455
|
- lib/samagotchi/message_time.rb
|
|
456
|
+
- lib/samagotchi/model_binding.rb
|
|
453
457
|
- lib/samagotchi/model_catalog.rb
|
|
454
458
|
- lib/samagotchi/model_list_store.rb
|
|
455
459
|
- lib/samagotchi/model_match.rb
|
|
@@ -681,6 +685,7 @@ files:
|
|
|
681
685
|
- lib/samagotchi/tools/web_fetch.rb
|
|
682
686
|
- lib/samagotchi/tools/write.rb
|
|
683
687
|
- lib/samagotchi/turn_checkpoint.rb
|
|
688
|
+
- lib/samagotchi/turn_endings.rb
|
|
684
689
|
- lib/samagotchi/turn_flow.rb
|
|
685
690
|
- lib/samagotchi/turn_note.rb
|
|
686
691
|
- lib/samagotchi/turn_origin.rb
|
|
@@ -702,12 +707,14 @@ files:
|
|
|
702
707
|
- lib/samagotchi/web/lan.rb
|
|
703
708
|
- lib/samagotchi/web/link_codes.rb
|
|
704
709
|
- lib/samagotchi/web/markdown_renderer.rb
|
|
710
|
+
- lib/samagotchi/web/message_display.rb
|
|
705
711
|
- lib/samagotchi/web/participant_access.rb
|
|
706
712
|
- lib/samagotchi/web/participant_review.rb
|
|
707
713
|
- lib/samagotchi/web/participant_stream_filter.rb
|
|
708
714
|
- lib/samagotchi/web/participant_view.rb
|
|
709
715
|
- lib/samagotchi/web/participants_admin.rb
|
|
710
716
|
- lib/samagotchi/web/principal.rb
|
|
717
|
+
- lib/samagotchi/web/private_files.rb
|
|
711
718
|
- lib/samagotchi/web/proxy_gate.rb
|
|
712
719
|
- lib/samagotchi/web/public/activity.js
|
|
713
720
|
- lib/samagotchi/web/public/annotate_actions.js
|
|
@@ -716,6 +723,7 @@ files:
|
|
|
716
723
|
- lib/samagotchi/web/public/annotations.js
|
|
717
724
|
- lib/samagotchi/web/public/answer_refs.js
|
|
718
725
|
- lib/samagotchi/web/public/app.js
|
|
726
|
+
- lib/samagotchi/web/public/apple-touch-icon.png
|
|
719
727
|
- lib/samagotchi/web/public/auth_lost.js
|
|
720
728
|
- lib/samagotchi/web/public/bubbles.js
|
|
721
729
|
- lib/samagotchi/web/public/card.js
|
|
@@ -742,6 +750,9 @@ files:
|
|
|
742
750
|
- lib/samagotchi/web/public/favicon.svg
|
|
743
751
|
- lib/samagotchi/web/public/format.js
|
|
744
752
|
- lib/samagotchi/web/public/hold.js
|
|
753
|
+
- lib/samagotchi/web/public/icons/chi-180.png
|
|
754
|
+
- lib/samagotchi/web/public/icons/chi-192.png
|
|
755
|
+
- lib/samagotchi/web/public/icons/chi-512.png
|
|
745
756
|
- lib/samagotchi/web/public/images.js
|
|
746
757
|
- lib/samagotchi/web/public/index.html
|
|
747
758
|
- lib/samagotchi/web/public/info_bar.js
|
|
@@ -754,6 +765,7 @@ files:
|
|
|
754
765
|
- lib/samagotchi/web/public/live_timing.js
|
|
755
766
|
- lib/samagotchi/web/public/live_turn.js
|
|
756
767
|
- lib/samagotchi/web/public/llm_context_chip.js
|
|
768
|
+
- lib/samagotchi/web/public/manifest.webmanifest
|
|
757
769
|
- lib/samagotchi/web/public/markdown_place.js
|
|
758
770
|
- lib/samagotchi/web/public/model_pick.js
|
|
759
771
|
- lib/samagotchi/web/public/model_picker.js
|
|
@@ -767,6 +779,7 @@ files:
|
|
|
767
779
|
- lib/samagotchi/web/public/participants_panel.js
|
|
768
780
|
- lib/samagotchi/web/public/plain_line.js
|
|
769
781
|
- lib/samagotchi/web/public/prompt_history.js
|
|
782
|
+
- lib/samagotchi/web/public/push.js
|
|
770
783
|
- lib/samagotchi/web/public/question_card.js
|
|
771
784
|
- lib/samagotchi/web/public/question_cards.js
|
|
772
785
|
- lib/samagotchi/web/public/read_view.js
|
|
@@ -798,6 +811,7 @@ files:
|
|
|
798
811
|
- lib/samagotchi/web/public/stream_handlers.js
|
|
799
812
|
- lib/samagotchi/web/public/strip.js
|
|
800
813
|
- lib/samagotchi/web/public/strip_grip.js
|
|
814
|
+
- lib/samagotchi/web/public/sw.js
|
|
801
815
|
- lib/samagotchi/web/public/tally.js
|
|
802
816
|
- lib/samagotchi/web/public/task_stop.js
|
|
803
817
|
- lib/samagotchi/web/public/thinking_chip.js
|
|
@@ -824,11 +838,21 @@ files:
|
|
|
824
838
|
- lib/samagotchi/web/public/working_set_rail.js
|
|
825
839
|
- lib/samagotchi/web/public/working_set_view.js
|
|
826
840
|
- lib/samagotchi/web/public_url.rb
|
|
841
|
+
- lib/samagotchi/web/push.rb
|
|
842
|
+
- lib/samagotchi/web/push/attention.rb
|
|
843
|
+
- lib/samagotchi/web/push/client.rb
|
|
844
|
+
- lib/samagotchi/web/push/crypto.rb
|
|
845
|
+
- lib/samagotchi/web/push/notifier.rb
|
|
846
|
+
- lib/samagotchi/web/push/store.rb
|
|
847
|
+
- lib/samagotchi/web/push_routes.rb
|
|
827
848
|
- lib/samagotchi/web/qr.rb
|
|
828
849
|
- lib/samagotchi/web/running.rb
|
|
829
850
|
- lib/samagotchi/web/server.rb
|
|
830
851
|
- lib/samagotchi/web/session_access_routes.rb
|
|
852
|
+
- lib/samagotchi/web/session_control.rb
|
|
853
|
+
- lib/samagotchi/web/session_create.rb
|
|
831
854
|
- lib/samagotchi/web/session_hub.rb
|
|
855
|
+
- lib/samagotchi/web/session_show.rb
|
|
832
856
|
- lib/samagotchi/web/session_summary.rb
|
|
833
857
|
- lib/samagotchi/web/token.rb
|
|
834
858
|
- lib/samagotchi/web/tunnel.rb
|