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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. 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
- # Collected dimensions:
18
- # - tokens (input/output/total), with a provenance flag (:server|:estimate)
19
- # - turn count, tool calls (total / per-tool / error count)
20
- # - iterations (tool-call rounds) per turn, aggregated
21
- # - generation latency (monotonic clock across generation_* events)
22
- # - cancellations and network retries
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
- @turns = 0
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 { @session_id ||= id.to_s }
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.gen_completion_max = 0
128
- @turn.gen_had_server = false
129
- @turn.gen_estimate_sum = 0
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
- @mutex.synchronize { @retries += 1 }
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: @turns,
157
- tokens_in: @tokens_in,
158
- tokens_out: @tokens_out,
159
- tokens_total: @tokens_total,
160
- token_source: @token_source,
161
- context_window_tokens: @context_window_tokens,
162
- context_window_source: @context_window_source,
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
- # The running turn's calls, iterations and finished generations
168
- # count at once (the per-tool counts do too), so /stats agrees
169
- # mid-turn; end_turn moves them into the totals.
170
- tool_calls_total: @tool_calls_total + (@turn&.tool_calls || 0),
171
- tool_calls_by_tool: @tool_calls_by_tool.dup,
172
- tool_errors: @tool_errors + (@turn&.tool_errors || 0),
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 = state_dir ? Session.session_dir(sid, state_dir: state_dir) : Session.session_dir(sid)
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(merged_persisted_snapshot(path)) + "\n")
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 ||= event[:session_id].to_s if event[: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
- # Finalize this generation's completion tokens. Server timings are
274
- # cumulative per generation; we sum the per-generation max into the turn
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
- if @turn.gen_had_server
283
- @tokens_out += @turn.gen_completion_max
284
- else
285
- @tokens_out += @turn.gen_estimate_sum
286
- end
287
- @tokens_total = @tokens_in + @tokens_out
288
- started = @turn.gen_started_at
289
- if started
290
- elapsed_ms = (monotonic_time - started) * 1000.0
291
- @turn.gen_latency_accum += elapsed_ms if elapsed_ms > 0
292
- @turn.gen_started_at = nil
293
- end
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. Input tokens keep a running MAX
343
- # (the prompt grows across tool-call iterations); completion tokens are
344
- # tracked as a per-generation MAX and summed at generation_completed so
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 merged_persisted_snapshot(path)
429
- current = snapshot.merge(active_turn: nil, active_tools: [])
430
- return current unless File.file?(path)
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 current unless prior.is_a?(Hash)
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
- current.merge(
436
- started_at: earliest_timestamp(prior["started_at"], current[:started_at]),
437
- last_activity_at: latest_timestamp(prior["last_activity_at"], current[:last_activity_at]),
438
- turn_records: merge_records(prior["turn_records"], current[:turn_records]),
439
- tool_records: merge_records(prior["tool_records"], current[:tool_records])
440
- )
441
- rescue JSON::ParserError
442
- current
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 merge_records(prior, current)
446
- (Array(prior) + Array(current)).each_with_object({}) do |record, by_id|
447
- next unless record.is_a?(Hash)
565
+ def sum(records, key)
566
+ records.sum { |record| record[key].is_a?(Numeric) ? record[key] : 0 }
567
+ end
448
568
 
449
- id = record["id"] || record[:id]
450
- by_id[id] = record if id
451
- end.values
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