samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -14,13 +14,12 @@ module Samagotchi
|
|
|
14
14
|
# event to its SessionObserver): the REPL, the -p/--non-interactive/--resume
|
|
15
15
|
# paths and SessionManager background workers.
|
|
16
16
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# - session wall-clock (first activity -> last activity)
|
|
17
|
+
# Each finished turn leaves a record (status, timings, model, prompt and
|
|
18
|
+
# completion tokens, tool calls, iterations, retries); session totals are
|
|
19
|
+
# sums over the records, so a worker that stops and wakes again (a new
|
|
20
|
+
# collector) keeps counting: #session_id= loads the records already saved.
|
|
21
|
+
# Only prompt and completion counts, which every backend reports the same
|
|
22
|
+
# way, are kept.
|
|
24
23
|
#
|
|
25
24
|
# A snapshot is surfaced via Engine#session_state_snapshot and (optionally)
|
|
26
25
|
# persisted to a sibling analytics.json next to the session file. The event
|
|
@@ -43,6 +42,21 @@ module Samagotchi
|
|
|
43
42
|
# Buffered chars/4 estimate for the current generation; committed to the
|
|
44
43
|
# turn total only if the generation ends with no server token data.
|
|
45
44
|
:gen_estimate_sum,
|
|
45
|
+
# This generation's prompt (the server's count; the largest seen, as it
|
|
46
|
+
# comes cumulative per request) and whether it is still streaming.
|
|
47
|
+
:gen_prompt_max,
|
|
48
|
+
:gen_open,
|
|
49
|
+
# The turn's generations, folded as each one ends: the count, the last
|
|
50
|
+
# one's prompt and completion, the sums, and which kinds of counts
|
|
51
|
+
# (server / estimate) they came from.
|
|
52
|
+
:generations,
|
|
53
|
+
:prompt_last,
|
|
54
|
+
:prompt_sum,
|
|
55
|
+
:completion_last,
|
|
56
|
+
:completion_sum,
|
|
57
|
+
:token_sources,
|
|
58
|
+
:retries,
|
|
59
|
+
:model,
|
|
46
60
|
:id,
|
|
47
61
|
:started_at,
|
|
48
62
|
:started_monotonic,
|
|
@@ -50,23 +64,37 @@ module Samagotchi
|
|
|
50
64
|
keyword_init: true
|
|
51
65
|
)
|
|
52
66
|
|
|
67
|
+
# How full the context was after the session's last counted turn, from
|
|
68
|
+
# its saved analytics.json: a percentage, or nil when the file, the count
|
|
69
|
+
# or the window is missing (the session lists read it per row).
|
|
70
|
+
# @param session_dir [String]
|
|
71
|
+
# @return [Float, nil]
|
|
72
|
+
def self.saved_context_pct(session_dir)
|
|
73
|
+
context = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["context"]
|
|
74
|
+
return nil unless context.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
used = context["used_tokens"]
|
|
77
|
+
window = context["window_tokens"]
|
|
78
|
+
return nil unless used.is_a?(Numeric) && window.is_a?(Numeric) && window.positive?
|
|
79
|
+
|
|
80
|
+
used * 100.0 / window
|
|
81
|
+
rescue JSON::ParserError, SystemCallError, TypeError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# "ctx 12%" for the session lists, "" when unknown.
|
|
86
|
+
# @param pct [Float, nil]
|
|
87
|
+
# @return [String]
|
|
88
|
+
def self.context_label(pct)
|
|
89
|
+
pct ? "ctx #{pct.round}%" : ""
|
|
90
|
+
end
|
|
91
|
+
|
|
53
92
|
def initialize(clock: nil, wall_clock: nil)
|
|
54
93
|
@mutex = Monitor.new
|
|
55
94
|
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
56
95
|
@wall_clock = wall_clock || -> { Time.now }
|
|
57
96
|
@session_id = nil
|
|
58
|
-
@
|
|
59
|
-
@tokens_in = 0
|
|
60
|
-
@tokens_out = 0
|
|
61
|
-
@tokens_total = 0
|
|
62
|
-
@token_source = nil # :server | :estimate
|
|
63
|
-
@tool_calls_total = 0
|
|
64
|
-
@tool_calls_by_tool = Hash.new(0)
|
|
65
|
-
@tool_errors = 0
|
|
66
|
-
@iterations_total = 0
|
|
67
|
-
@gen_latency_ms = 0
|
|
68
|
-
@cancellations = 0
|
|
69
|
-
@retries = 0
|
|
97
|
+
@state_dir = nil
|
|
70
98
|
@started_at = nil
|
|
71
99
|
@last_activity_at = nil
|
|
72
100
|
@turn = nil
|
|
@@ -83,14 +111,25 @@ module Samagotchi
|
|
|
83
111
|
end
|
|
84
112
|
end
|
|
85
113
|
|
|
114
|
+
# The sessions dir analytics.json is read from and saved to (nil: the
|
|
115
|
+
# XDG default). Set it before #session_id=.
|
|
116
|
+
attr_writer :state_dir
|
|
117
|
+
|
|
86
118
|
# Set the session id the collector is aggregating for. Safe to call multiple
|
|
87
|
-
# times; the first non-empty id wins so later turns don't clobber it.
|
|
119
|
+
# times; the first non-empty id wins so later turns don't clobber it. The
|
|
120
|
+
# first one loads the records a earlier process saved for it, once
|
|
121
|
+
# (#snapshot, on hot paths, never reads the disk).
|
|
88
122
|
# @param id [String, nil]
|
|
89
123
|
# @return [void]
|
|
90
124
|
def session_id=(id)
|
|
91
125
|
return if id.nil? || id.to_s.empty?
|
|
92
126
|
|
|
93
|
-
@mutex.synchronize
|
|
127
|
+
@mutex.synchronize do
|
|
128
|
+
next if @session_id
|
|
129
|
+
|
|
130
|
+
@session_id = id.to_s
|
|
131
|
+
load_persisted
|
|
132
|
+
end
|
|
94
133
|
end
|
|
95
134
|
|
|
96
135
|
# Event sink. Safe to call from any thread; errors are isolated by the
|
|
@@ -124,20 +163,25 @@ module Samagotchi
|
|
|
124
163
|
end
|
|
125
164
|
if @turn
|
|
126
165
|
@turn.gen_started_at = monotonic_time
|
|
127
|
-
@turn.
|
|
128
|
-
|
|
129
|
-
@turn.
|
|
166
|
+
@turn.generations += 1
|
|
167
|
+
reset_generation_tokens
|
|
168
|
+
@turn.gen_open = true
|
|
130
169
|
end
|
|
131
170
|
end
|
|
132
171
|
when :generation_completed, :generation_cancelled
|
|
133
172
|
record_served_model(event)
|
|
134
173
|
record_generation_completed
|
|
135
174
|
when :generation_retrying
|
|
136
|
-
|
|
175
|
+
# The retry streams from the start: its counts replace the ones so far.
|
|
176
|
+
@mutex.synchronize do
|
|
177
|
+
if @turn
|
|
178
|
+
@turn.retries += 1
|
|
179
|
+
reset_generation_tokens
|
|
180
|
+
end
|
|
181
|
+
end
|
|
137
182
|
when :turn_completed
|
|
138
183
|
end_turn(status: "completed")
|
|
139
184
|
when :turn_canceled
|
|
140
|
-
@mutex.synchronize { @cancellations += 1 }
|
|
141
185
|
end_turn(status: "canceled", reason: event[:cancellation_reason])
|
|
142
186
|
when :turn_failed
|
|
143
187
|
end_turn(status: "failed")
|
|
@@ -148,32 +192,36 @@ module Samagotchi
|
|
|
148
192
|
nil
|
|
149
193
|
end
|
|
150
194
|
|
|
195
|
+
# The session totals are sums over the turn records (the ones loaded from
|
|
196
|
+
# disk and this process's), with the running turn's calls, iterations and
|
|
197
|
+
# finished generations counted at once, so /stats agrees mid-turn.
|
|
151
198
|
# @return [Hash] the current summary snapshot
|
|
152
199
|
def snapshot
|
|
153
200
|
@mutex.synchronize do
|
|
201
|
+
records = @turn_records
|
|
202
|
+
turn = @turn
|
|
203
|
+
in_flight = turn ? turn.tool_calls_by_id.values : []
|
|
204
|
+
tools = @tool_records + in_flight
|
|
154
205
|
{
|
|
155
206
|
session_id: @session_id,
|
|
156
|
-
turns:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
207
|
+
turns: records.size + (turn ? 1 : 0),
|
|
208
|
+
cancellations: records.count { |record| record[:status] == "canceled" },
|
|
209
|
+
tokens: {
|
|
210
|
+
prompt_sum: sum(records, :prompt_tokens_sum) + (turn&.prompt_sum || 0),
|
|
211
|
+
completion_sum: sum(records, :completion_tokens) + (turn&.completion_sum || 0),
|
|
212
|
+
source: combined_source(records.map { |record| record[:token_source] } + (turn&.token_sources || []))
|
|
213
|
+
},
|
|
214
|
+
context: context_block(records),
|
|
163
215
|
profile: @profile,
|
|
164
216
|
profile_source: @profile_source,
|
|
165
217
|
served_model: @served_model,
|
|
166
218
|
served_model_for: @served_model_for,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
iterations_total: @iterations_total + (@turn&.iteration_count || 0),
|
|
174
|
-
gen_latency_ms: @gen_latency_ms + (@turn&.gen_latency_accum || 0),
|
|
175
|
-
cancellations: @cancellations,
|
|
176
|
-
retries: @retries,
|
|
219
|
+
tool_calls_total: tools.size,
|
|
220
|
+
tool_calls_by_tool: tools.map { |tool| tool[:tool].to_s }.reject(&:empty?).tally,
|
|
221
|
+
tool_errors: @tool_records.count { |tool| tool[:status] == "error" },
|
|
222
|
+
iterations_total: sum(records, :iterations) + (turn&.iteration_count || 0),
|
|
223
|
+
gen_latency_ms: (sum(records, :gen_ms) + (turn&.gen_latency_accum || 0)).round,
|
|
224
|
+
retries: sum(records, :retries) + (turn&.retries || 0),
|
|
177
225
|
started_at: @started_at,
|
|
178
226
|
last_activity_at: @last_activity_at,
|
|
179
227
|
session_duration_ms: elapsed_ms(@session_started_monotonic),
|
|
@@ -197,11 +245,10 @@ module Samagotchi
|
|
|
197
245
|
# Omitting state_dir lets Session.session_dir fall back to the default
|
|
198
246
|
# (XDG) location; passing an explicit nil would override it and break
|
|
199
247
|
# File.join.
|
|
200
|
-
dir =
|
|
201
|
-
FileUtils.mkdir_p(dir)
|
|
248
|
+
FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
|
|
202
249
|
path = File.join(dir, "analytics.json")
|
|
203
250
|
temp_path = "#{path}.tmp"
|
|
204
|
-
File.write(temp_path, JSON.pretty_generate(
|
|
251
|
+
File.write(temp_path, JSON.pretty_generate(snapshot.merge(active_turn: nil, active_tools: [])) + "\n")
|
|
205
252
|
File.rename(temp_path, path)
|
|
206
253
|
true
|
|
207
254
|
rescue StandardError
|
|
@@ -212,10 +259,12 @@ module Samagotchi
|
|
|
212
259
|
|
|
213
260
|
def begin_turn(event)
|
|
214
261
|
@mutex.synchronize do
|
|
215
|
-
@session_id
|
|
262
|
+
unless @session_id || event[:session_id].to_s.empty?
|
|
263
|
+
@session_id = event[:session_id].to_s
|
|
264
|
+
load_persisted
|
|
265
|
+
end
|
|
216
266
|
@started_at ||= now.iso8601(3)
|
|
217
267
|
@session_started_monotonic ||= monotonic_time
|
|
218
|
-
@turns += 1
|
|
219
268
|
@turn = TurnState.new(
|
|
220
269
|
session_id: @session_id,
|
|
221
270
|
iteration_count: 0,
|
|
@@ -226,6 +275,16 @@ module Samagotchi
|
|
|
226
275
|
gen_completion_max: 0,
|
|
227
276
|
gen_had_server: false,
|
|
228
277
|
gen_estimate_sum: 0,
|
|
278
|
+
gen_prompt_max: 0,
|
|
279
|
+
gen_open: false,
|
|
280
|
+
generations: 0,
|
|
281
|
+
prompt_last: nil,
|
|
282
|
+
prompt_sum: 0,
|
|
283
|
+
completion_last: 0,
|
|
284
|
+
completion_sum: 0,
|
|
285
|
+
token_sources: [],
|
|
286
|
+
retries: 0,
|
|
287
|
+
model: nil,
|
|
229
288
|
id: SecureRandom.uuid,
|
|
230
289
|
started_at: now.iso8601(3),
|
|
231
290
|
started_monotonic: monotonic_time,
|
|
@@ -239,6 +298,9 @@ module Samagotchi
|
|
|
239
298
|
def end_turn(status: "completed", reason: nil)
|
|
240
299
|
@mutex.synchronize do
|
|
241
300
|
if @turn
|
|
301
|
+
# A turn that failed mid-stream never saw its generation end:
|
|
302
|
+
# what it got to still counts.
|
|
303
|
+
close_generation if @turn.gen_open
|
|
242
304
|
finished_at = now
|
|
243
305
|
record = {
|
|
244
306
|
id: @turn.id,
|
|
@@ -248,12 +310,9 @@ module Samagotchi
|
|
|
248
310
|
duration_ms: elapsed_ms(@turn.started_monotonic)
|
|
249
311
|
}
|
|
250
312
|
record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
|
|
313
|
+
record.merge!(turn_token_fields(@turn))
|
|
251
314
|
@turn_records << record
|
|
252
315
|
end
|
|
253
|
-
@iterations_total += @turn.iteration_count if @turn
|
|
254
|
-
@gen_latency_ms += @turn.gen_latency_accum if @turn
|
|
255
|
-
@tool_calls_total += @turn.tool_calls if @turn
|
|
256
|
-
@tool_errors += @turn.tool_errors if @turn
|
|
257
316
|
@turn = nil
|
|
258
317
|
end
|
|
259
318
|
end
|
|
@@ -266,32 +325,69 @@ module Samagotchi
|
|
|
266
325
|
@mutex.synchronize do
|
|
267
326
|
@served_model = event[:served_model]
|
|
268
327
|
@served_model_for = event[:requested_model]
|
|
328
|
+
@turn.model = event[:served_model] if @turn
|
|
269
329
|
end
|
|
270
330
|
end
|
|
271
331
|
|
|
272
332
|
def record_generation_completed
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
# total. If the generation reported no server tokens we commit the buffered
|
|
276
|
-
# chars/4 estimate instead. The two paths are mutually exclusive per
|
|
277
|
-
# generation, so a final chunk carrying timings while earlier chunks did
|
|
278
|
-
# not will not double count.
|
|
279
|
-
@mutex.synchronize do
|
|
280
|
-
return unless @turn
|
|
333
|
+
@mutex.synchronize { close_generation if @turn&.gen_open }
|
|
334
|
+
end
|
|
281
335
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
336
|
+
# Finalize the open generation's tokens into the turn. Server counts are
|
|
337
|
+
# cumulative per generation, so its max is its count. If the generation
|
|
338
|
+
# reported no server tokens we commit the buffered chars/4 estimate
|
|
339
|
+
# instead (there is no prompt count then). The two paths are mutually
|
|
340
|
+
# exclusive per generation, so a final chunk carrying timings while
|
|
341
|
+
# earlier chunks did not will not double count. Caller holds the mutex.
|
|
342
|
+
def close_generation
|
|
343
|
+
turn = @turn
|
|
344
|
+
turn.gen_open = false
|
|
345
|
+
if turn.gen_had_server
|
|
346
|
+
completion = turn.gen_completion_max
|
|
347
|
+
turn.prompt_last = turn.gen_prompt_max
|
|
348
|
+
turn.prompt_sum += turn.gen_prompt_max
|
|
349
|
+
turn.token_sources |= ["server"]
|
|
350
|
+
else
|
|
351
|
+
completion = turn.gen_estimate_sum
|
|
352
|
+
turn.token_sources |= ["estimate"] if completion.positive?
|
|
294
353
|
end
|
|
354
|
+
turn.completion_last = completion
|
|
355
|
+
turn.completion_sum += completion
|
|
356
|
+
started = turn.gen_started_at
|
|
357
|
+
return unless started
|
|
358
|
+
|
|
359
|
+
elapsed_ms = (monotonic_time - started) * 1000.0
|
|
360
|
+
turn.gen_latency_accum += elapsed_ms if elapsed_ms > 0
|
|
361
|
+
turn.gen_started_at = nil
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Caller holds the mutex.
|
|
365
|
+
def reset_generation_tokens
|
|
366
|
+
@turn.gen_completion_max = 0
|
|
367
|
+
@turn.gen_prompt_max = 0
|
|
368
|
+
@turn.gen_had_server = false
|
|
369
|
+
@turn.gen_estimate_sum = 0
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# The turn record's model and token fields. The context used at the end
|
|
373
|
+
# is the last prompt plus the last answer (an estimated last generation
|
|
374
|
+
# has no prompt count, so the turn's last known prompt stands in).
|
|
375
|
+
def turn_token_fields(turn)
|
|
376
|
+
sources = turn.token_sources
|
|
377
|
+
{
|
|
378
|
+
model: turn.model,
|
|
379
|
+
generations: turn.generations,
|
|
380
|
+
prompt_tokens: turn.prompt_last,
|
|
381
|
+
prompt_tokens_sum: turn.prompt_sum,
|
|
382
|
+
completion_tokens: turn.completion_sum,
|
|
383
|
+
context_used_tokens: turn.prompt_last && (turn.prompt_last + turn.completion_last),
|
|
384
|
+
token_source: sources.size > 1 ? "mixed" : sources.first,
|
|
385
|
+
gen_ms: turn.gen_latency_accum.round,
|
|
386
|
+
tool_calls: turn.tool_calls,
|
|
387
|
+
tool_errors: turn.tool_errors,
|
|
388
|
+
iterations: turn.iteration_count,
|
|
389
|
+
retries: turn.retries
|
|
390
|
+
}
|
|
295
391
|
end
|
|
296
392
|
|
|
297
393
|
def record_tool_call_start(event)
|
|
@@ -300,7 +396,6 @@ module Samagotchi
|
|
|
300
396
|
|
|
301
397
|
@turn.tool_calls += 1
|
|
302
398
|
tool = event[:tool].to_s
|
|
303
|
-
@tool_calls_by_tool[tool] += 1 unless tool.empty?
|
|
304
399
|
iteration = event[:iteration].to_i
|
|
305
400
|
call_index = event[:call_index].to_i
|
|
306
401
|
key = tool_key(iteration, call_index)
|
|
@@ -339,10 +434,9 @@ module Samagotchi
|
|
|
339
434
|
end
|
|
340
435
|
|
|
341
436
|
# Accumulate token counts from a streamed generation_chunk payload.
|
|
342
|
-
# Server-first: prefer real timings/usage.
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
# multi-generation turns count every generation. When a generation reports
|
|
437
|
+
# Server-first: prefer real timings/usage. Prompt and completion tokens
|
|
438
|
+
# are tracked as a per-generation MAX (the server's counts are cumulative
|
|
439
|
+
# per request) and folded into the turn when the generation ends. When a generation reports
|
|
346
440
|
# no server tokens at all we fall back to the chars/4 estimate summed across
|
|
347
441
|
# its chunks (mutually exclusive with server counting to avoid double
|
|
348
442
|
# counting a final chunk that carries timings while earlier chunks do not).
|
|
@@ -353,13 +447,11 @@ module Samagotchi
|
|
|
353
447
|
usage = TokenUsage.from_payload(payload)
|
|
354
448
|
if usage
|
|
355
449
|
@mutex.synchronize do
|
|
356
|
-
@token_source = :server
|
|
357
|
-
@tokens_in = [@tokens_in, usage[:prompt_tokens].to_i].max
|
|
358
450
|
if @turn
|
|
451
|
+
@turn.gen_prompt_max = [@turn.gen_prompt_max, usage[:prompt_tokens].to_i].max
|
|
359
452
|
@turn.gen_completion_max = [@turn.gen_completion_max, usage[:completion_tokens].to_i].max
|
|
360
453
|
@turn.gen_had_server = true
|
|
361
454
|
end
|
|
362
|
-
@tokens_total = @tokens_in + @tokens_out
|
|
363
455
|
end
|
|
364
456
|
else
|
|
365
457
|
@mutex.synchronize do
|
|
@@ -367,9 +459,7 @@ module Samagotchi
|
|
|
367
459
|
return unless chars && chars.positive?
|
|
368
460
|
return if @turn && @turn.gen_had_server
|
|
369
461
|
|
|
370
|
-
@token_source ||= :estimate
|
|
371
462
|
@turn.gen_estimate_sum += TokenUsage.estimate(payload_content(payload, event)) if @turn
|
|
372
|
-
@tokens_total = @tokens_in + @tokens_out
|
|
373
463
|
end
|
|
374
464
|
end
|
|
375
465
|
end
|
|
@@ -425,30 +515,62 @@ module Samagotchi
|
|
|
425
515
|
end
|
|
426
516
|
end
|
|
427
517
|
|
|
428
|
-
def
|
|
429
|
-
|
|
430
|
-
|
|
518
|
+
def session_dir(sid, state_dir)
|
|
519
|
+
# Omitting state_dir lets Session.session_dir fall back to the default
|
|
520
|
+
# (XDG) location; passing an explicit nil would override it.
|
|
521
|
+
state_dir ? Session.session_dir(sid, state_dir: state_dir) : Session.session_dir(sid)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# The records an earlier process saved for this session (a missing or
|
|
525
|
+
# broken file, or an older shape: nothing, or zeros where keys are
|
|
526
|
+
# missing). Caller holds the mutex.
|
|
527
|
+
def load_persisted
|
|
528
|
+
path = File.join(session_dir(@session_id, @state_dir), "analytics.json")
|
|
529
|
+
return unless File.file?(path)
|
|
431
530
|
|
|
432
531
|
prior = JSON.parse(File.read(path))
|
|
433
|
-
return
|
|
532
|
+
return unless prior.is_a?(Hash)
|
|
533
|
+
|
|
534
|
+
@turn_records = loaded_records(prior["turn_records"]) + @turn_records
|
|
535
|
+
@tool_records = loaded_records(prior["tool_records"]) + @tool_records
|
|
536
|
+
@started_at = earliest_timestamp(prior["started_at"], @started_at)
|
|
537
|
+
@last_activity_at ||= prior["last_activity_at"]
|
|
538
|
+
# The window last seen, until this process's first generation reports.
|
|
539
|
+
window = prior["context"].is_a?(Hash) ? prior["context"] : {}
|
|
540
|
+
if window["window_tokens"] && @context_window_tokens.nil?
|
|
541
|
+
@context_window_tokens = window["window_tokens"]
|
|
542
|
+
@context_window_source = window["window_source"]
|
|
543
|
+
end
|
|
544
|
+
rescue JSON::ParserError, SystemCallError
|
|
545
|
+
nil
|
|
546
|
+
end
|
|
434
547
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
548
|
+
def loaded_records(records)
|
|
549
|
+
Array(records).filter_map { |record| record.transform_keys(&:to_sym) if record.is_a?(Hash) && record["id"] }
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# The context as the newest turn with a count left it, and the window
|
|
553
|
+
# now; readers compute the percentage.
|
|
554
|
+
def context_block(records)
|
|
555
|
+
last = records.reverse_each.find { |record| record[:context_used_tokens].is_a?(Numeric) }
|
|
556
|
+
{
|
|
557
|
+
used_tokens: last&.dig(:context_used_tokens),
|
|
558
|
+
window_tokens: @context_window_tokens,
|
|
559
|
+
window_source: @context_window_source&.to_s,
|
|
560
|
+
source: last&.dig(:token_source),
|
|
561
|
+
at: last&.dig(:completed_at)
|
|
562
|
+
}
|
|
443
563
|
end
|
|
444
564
|
|
|
445
|
-
def
|
|
446
|
-
|
|
447
|
-
|
|
565
|
+
def sum(records, key)
|
|
566
|
+
records.sum { |record| record[key].is_a?(Numeric) ? record[key] : 0 }
|
|
567
|
+
end
|
|
448
568
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
569
|
+
# server, estimate, or mixed when both kinds of counts (or a turn that
|
|
570
|
+
# was already mixed) are in; nil before any.
|
|
571
|
+
def combined_source(sources)
|
|
572
|
+
kinds = sources.compact.map(&:to_s).flat_map { |source| source == "mixed" ? %w[server estimate] : [source] }.uniq
|
|
573
|
+
kinds.size > 1 ? "mixed" : kinds.first
|
|
452
574
|
end
|
|
453
575
|
|
|
454
576
|
def earliest_timestamp(*timestamps)
|
|
@@ -456,11 +578,5 @@ module Samagotchi
|
|
|
456
578
|
rescue ArgumentError
|
|
457
579
|
timestamps.compact.first
|
|
458
580
|
end
|
|
459
|
-
|
|
460
|
-
def latest_timestamp(*timestamps)
|
|
461
|
-
timestamps.compact.max_by { |value| Time.iso8601(value.to_s) }
|
|
462
|
-
rescue ArgumentError
|
|
463
|
-
timestamps.compact.last
|
|
464
|
-
end
|
|
465
581
|
end
|
|
466
582
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A plugin's steer: text a plugin puts into the running turn (Engine#steer),
|
|
5
|
+
# the way a UI's steering does. The drain a loop gets returns items: a
|
|
6
|
+
# String (a user line, as ever) or {text:, source:} (a steer). The user
|
|
7
|
+
# lines merge into one user message as before; each steer follows it as its
|
|
8
|
+
# own user message marked kind: "steer" and source: (the ContextNote keys,
|
|
9
|
+
# which every message copy keeps). The model reads it as user text.
|
|
10
|
+
module Steer
|
|
11
|
+
KIND = "steer"
|
|
12
|
+
|
|
13
|
+
# What one drain brought: the merged user text (nil when none) and how
|
|
14
|
+
# many lines made it, and the steers in order.
|
|
15
|
+
Merge = Struct.new(:content, :count, :steers, keyword_init: true) do
|
|
16
|
+
def empty? = content.nil? && steers.empty?
|
|
17
|
+
|
|
18
|
+
# The messages it appends: the user's first, then each steer.
|
|
19
|
+
def messages
|
|
20
|
+
list = content ? [{ role: "user", content: content }] : []
|
|
21
|
+
list + steers.map { |steer| Steer.message(**steer) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The :pending_input_merged fields beside iteration and answer;
|
|
25
|
+
# steers: only when there are some, so plain merges stay as they were.
|
|
26
|
+
def event_fields
|
|
27
|
+
fields = { count: count, content: content }
|
|
28
|
+
fields[:steers] = steers unless steers.empty?
|
|
29
|
+
fields
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def message(text:, source:)
|
|
36
|
+
{ role: "user", kind: KIND, source: source.to_s, content: text.to_s }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def steer?(message)
|
|
40
|
+
(message[:kind] || message["kind"]).to_s == KIND
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A user message that is a prompt or the user's steering, not a steer:
|
|
44
|
+
# what "the last prompt" and "user turns" count.
|
|
45
|
+
def prompt?(message)
|
|
46
|
+
message.is_a?(Hash) && (message[:role] || message["role"]).to_s == "user" && !steer?(message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Call a loop's drain: +at_answer+ goes only to a drain that takes it
|
|
50
|
+
# (the Engine's); a caller's own drain (a queue's #drain) is called bare.
|
|
51
|
+
# A failing drain drains nothing (the loop keeps going).
|
|
52
|
+
def drain(pending_input, at_answer:)
|
|
53
|
+
takes = pending_input.respond_to?(:parameters) &&
|
|
54
|
+
pending_input.parameters.any? { |type, name| %i[key keyreq].include?(type) && name == :at_answer }
|
|
55
|
+
takes ? pending_input.call(at_answer: at_answer) : pending_input.call
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Merge]
|
|
61
|
+
def merge(items)
|
|
62
|
+
items = Array(items)
|
|
63
|
+
lines = items.grep_v(Hash)
|
|
64
|
+
steers = items.grep(Hash).filter_map do |item|
|
|
65
|
+
text = (item[:text] || item["text"]).to_s.strip
|
|
66
|
+
{ source: (item[:source] || item["source"]).to_s, text: text } unless text.empty?
|
|
67
|
+
end
|
|
68
|
+
content = lines.map { |line| line.to_s.strip }.reject(&:empty?).join("\n\n")
|
|
69
|
+
Merge.new(content: content.empty? ? nil : content, count: content.empty? ? 0 : lines.length, steers: steers)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|