samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "time"
6
+ require "fileutils"
7
+ require "securerandom"
8
+ require_relative "session"
9
+
10
+ module Samagotchi
11
+ # SessionMetrics is a persistent, error-isolated collector of per-session
12
+ # analytics. It implements the same #call(event) sink contract as
13
+ # SessionObserver subscribers, so Engine#run_turn feeds it (it forwards every
14
+ # event to its SessionObserver): the REPL, the -p/--non-interactive/--resume
15
+ # paths and SessionManager background workers.
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)
24
+ #
25
+ # A snapshot is surfaced via Engine#session_state_snapshot and (optionally)
26
+ # persisted to a sibling analytics.json next to the session file. The event
27
+ # trail itself goes to the debug log (LogSubscriber).
28
+ class SessionMetrics
29
+ # Per-turn transient state, reset on each turn_started.
30
+ TurnState = Struct.new(
31
+ :session_id,
32
+ :iteration_count,
33
+ :gen_started_at,
34
+ :gen_latency_accum,
35
+ :tool_calls,
36
+ :tool_errors,
37
+ # Per-generation completion-token tracking. A turn may contain several
38
+ # generations (one per tool-call iteration); each generation's predicted_n
39
+ # is cumulative within that generation and resets afterwards, so totals
40
+ # must be summed per-generation (not taken as a global max).
41
+ :gen_completion_max,
42
+ :gen_had_server,
43
+ # Buffered chars/4 estimate for the current generation; committed to the
44
+ # turn total only if the generation ends with no server token data.
45
+ :gen_estimate_sum,
46
+ :id,
47
+ :started_at,
48
+ :started_monotonic,
49
+ :tool_calls_by_id,
50
+ keyword_init: true
51
+ )
52
+
53
+ def initialize(clock: nil, wall_clock: nil)
54
+ @mutex = Monitor.new
55
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
56
+ @wall_clock = wall_clock || -> { Time.now }
57
+ @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
70
+ @started_at = nil
71
+ @last_activity_at = nil
72
+ @turn = nil
73
+ @turn_records = []
74
+ @tool_records = []
75
+ end
76
+
77
+ # /model: the window and prompt profile a turn reported were the old
78
+ # model's; /stats resolves the new one's until its first turn reports.
79
+ def forget_model_reports!
80
+ @mutex.synchronize do
81
+ @context_window_tokens = @context_window_source = nil
82
+ @profile = @profile_source = nil
83
+ end
84
+ end
85
+
86
+ # 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.
88
+ # @param id [String, nil]
89
+ # @return [void]
90
+ def session_id=(id)
91
+ return if id.nil? || id.to_s.empty?
92
+
93
+ @mutex.synchronize { @session_id ||= id.to_s }
94
+ end
95
+
96
+ # Event sink. Safe to call from any thread; errors are isolated by the
97
+ # caller (SessionObserver / TerminalUI) but we still guard internally.
98
+ # @param event [Hash]
99
+ # @return [void]
100
+ def call(event)
101
+ return unless event.is_a?(Hash)
102
+
103
+ type = event[:type]
104
+ case type
105
+ when :turn_started
106
+ begin_turn(event)
107
+ when :generation_chunk
108
+ accumulate_tokens(event)
109
+ when :tool_dispatch_started
110
+ @mutex.synchronize { @turn&.iteration_count += 1 }
111
+ when :tool_call_started
112
+ record_tool_call_start(event)
113
+ when :tool_call_completed
114
+ record_tool_call_completed(event)
115
+ when :generation_started
116
+ @mutex.synchronize do
117
+ if event[:context_window_tokens]
118
+ @context_window_tokens = event[:context_window_tokens]
119
+ @context_window_source = event[:context_window_source]
120
+ end
121
+ if event[:profile]
122
+ @profile = event[:profile]
123
+ @profile_source = event[:profile_source]
124
+ end
125
+ if @turn
126
+ @turn.gen_started_at = monotonic_time
127
+ @turn.gen_completion_max = 0
128
+ @turn.gen_had_server = false
129
+ @turn.gen_estimate_sum = 0
130
+ end
131
+ end
132
+ when :generation_completed, :generation_cancelled
133
+ record_served_model(event)
134
+ record_generation_completed
135
+ when :generation_retrying
136
+ @mutex.synchronize { @retries += 1 }
137
+ when :turn_completed
138
+ end_turn(status: "completed")
139
+ when :turn_canceled
140
+ @mutex.synchronize { @cancellations += 1 }
141
+ end_turn(status: "canceled", reason: event[:cancellation_reason])
142
+ when :turn_failed
143
+ end_turn(status: "failed")
144
+ end
145
+
146
+ @mutex.synchronize { @last_activity_at = now.iso8601(3) }
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ # @return [Hash] the current summary snapshot
152
+ def snapshot
153
+ @mutex.synchronize do
154
+ {
155
+ 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,
163
+ profile: @profile,
164
+ profile_source: @profile_source,
165
+ served_model: @served_model,
166
+ 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,
177
+ started_at: @started_at,
178
+ last_activity_at: @last_activity_at,
179
+ session_duration_ms: elapsed_ms(@session_started_monotonic),
180
+ turn_records: @turn_records.map(&:dup),
181
+ tool_records: @tool_records.map(&:dup),
182
+ active_turn: active_turn_snapshot,
183
+ active_tools: active_tool_snapshots
184
+ }
185
+ end
186
+ end
187
+
188
+ # Persist the summary snapshot to a sibling analytics.json in the session
189
+ # directory. Atomic write; failures are swallowed (analytics must never
190
+ # break the running session).
191
+ # @param state_dir [String, nil]
192
+ # @return [Boolean] true on success
193
+ def persist(state_dir: nil)
194
+ sid = @mutex.synchronize { @session_id }
195
+ return false if sid.nil? || sid.to_s.empty?
196
+
197
+ # Omitting state_dir lets Session.session_dir fall back to the default
198
+ # (XDG) location; passing an explicit nil would override it and break
199
+ # File.join.
200
+ dir = state_dir ? Session.session_dir(sid, state_dir: state_dir) : Session.session_dir(sid)
201
+ FileUtils.mkdir_p(dir)
202
+ path = File.join(dir, "analytics.json")
203
+ temp_path = "#{path}.tmp"
204
+ File.write(temp_path, JSON.pretty_generate(merged_persisted_snapshot(path)) + "\n")
205
+ File.rename(temp_path, path)
206
+ true
207
+ rescue StandardError
208
+ false
209
+ end
210
+
211
+ private
212
+
213
+ def begin_turn(event)
214
+ @mutex.synchronize do
215
+ @session_id ||= event[:session_id].to_s if event[:session_id]
216
+ @started_at ||= now.iso8601(3)
217
+ @session_started_monotonic ||= monotonic_time
218
+ @turns += 1
219
+ @turn = TurnState.new(
220
+ session_id: @session_id,
221
+ iteration_count: 0,
222
+ gen_started_at: nil,
223
+ gen_latency_accum: 0,
224
+ tool_calls: 0,
225
+ tool_errors: 0,
226
+ gen_completion_max: 0,
227
+ gen_had_server: false,
228
+ gen_estimate_sum: 0,
229
+ id: SecureRandom.uuid,
230
+ started_at: now.iso8601(3),
231
+ started_monotonic: monotonic_time,
232
+ tool_calls_by_id: {}
233
+ )
234
+ end
235
+ end
236
+
237
+ # A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...), so
238
+ # a reloaded web history can show the cancel line the live one did.
239
+ def end_turn(status: "completed", reason: nil)
240
+ @mutex.synchronize do
241
+ if @turn
242
+ finished_at = now
243
+ record = {
244
+ id: @turn.id,
245
+ status: status,
246
+ started_at: @turn.started_at,
247
+ completed_at: finished_at.iso8601(3),
248
+ duration_ms: elapsed_ms(@turn.started_monotonic)
249
+ }
250
+ record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
251
+ @turn_records << record
252
+ 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
+ @turn = nil
258
+ end
259
+ end
260
+
261
+ # The model the server says answered, and the name asked for then (a
262
+ # /model switch makes it stale until the next generation reports).
263
+ def record_served_model(event)
264
+ return unless event[:served_model]
265
+
266
+ @mutex.synchronize do
267
+ @served_model = event[:served_model]
268
+ @served_model_for = event[:requested_model]
269
+ end
270
+ end
271
+
272
+ 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
281
+
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
294
+ end
295
+ end
296
+
297
+ def record_tool_call_start(event)
298
+ @mutex.synchronize do
299
+ return unless @turn
300
+
301
+ @turn.tool_calls += 1
302
+ tool = event[:tool].to_s
303
+ @tool_calls_by_tool[tool] += 1 unless tool.empty?
304
+ iteration = event[:iteration].to_i
305
+ call_index = event[:call_index].to_i
306
+ key = tool_key(iteration, call_index)
307
+ @turn.tool_calls_by_id[key] = {
308
+ id: "#{@turn.id}:#{key}",
309
+ turn_id: @turn.id,
310
+ iteration: iteration,
311
+ call_index: call_index,
312
+ tool: tool,
313
+ started_at: now.iso8601(3),
314
+ started_monotonic: monotonic_time
315
+ }
316
+ end
317
+ end
318
+
319
+ def record_tool_call_completed(event)
320
+ @mutex.synchronize do
321
+ return unless @turn
322
+
323
+ status = event.dig(:activity, :status).to_s
324
+ status = event[:status].to_s if status.empty?
325
+ @turn.tool_errors += 1 if status == "error"
326
+ key = tool_key(event[:iteration].to_i, event[:call_index].to_i)
327
+ active = @turn.tool_calls_by_id.delete(key)
328
+ return unless active
329
+
330
+ finished_at = now
331
+ @tool_records << active.slice(
332
+ :id, :turn_id, :iteration, :call_index, :tool, :started_at
333
+ ).merge(
334
+ status: status.empty? ? "ok" : status,
335
+ completed_at: finished_at.iso8601(3),
336
+ duration_ms: elapsed_ms(active[:started_monotonic])
337
+ )
338
+ end
339
+ end
340
+
341
+ # 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
346
+ # no server tokens at all we fall back to the chars/4 estimate summed across
347
+ # its chunks (mutually exclusive with server counting to avoid double
348
+ # counting a final chunk that carries timings while earlier chunks do not).
349
+ def accumulate_tokens(event)
350
+ payload = event[:payload]
351
+ payload = {} unless payload.is_a?(Hash)
352
+
353
+ usage = TokenUsage.from_payload(payload)
354
+ if usage
355
+ @mutex.synchronize do
356
+ @token_source = :server
357
+ @tokens_in = [@tokens_in, usage[:prompt_tokens].to_i].max
358
+ if @turn
359
+ @turn.gen_completion_max = [@turn.gen_completion_max, usage[:completion_tokens].to_i].max
360
+ @turn.gen_had_server = true
361
+ end
362
+ @tokens_total = @tokens_in + @tokens_out
363
+ end
364
+ else
365
+ @mutex.synchronize do
366
+ chars = event_content_chars(payload, event)
367
+ return unless chars && chars.positive?
368
+ return if @turn && @turn.gen_had_server
369
+
370
+ @token_source ||= :estimate
371
+ @turn.gen_estimate_sum += TokenUsage.estimate(payload_content(payload, event)) if @turn
372
+ @tokens_total = @tokens_in + @tokens_out
373
+ end
374
+ end
375
+ end
376
+
377
+ def event_content_chars(payload, event)
378
+ len = payload_content(payload, event).length
379
+ len.positive? ? len : nil
380
+ end
381
+
382
+ def payload_content(payload, event = nil)
383
+ content = payload["content"] || payload[:content]
384
+ return content if content.is_a?(String)
385
+
386
+ event_content = event && event[:content]
387
+ event_content.is_a?(String) ? event_content : ""
388
+ end
389
+
390
+ def monotonic_time
391
+ @clock.call
392
+ end
393
+
394
+ def now
395
+ @wall_clock.call
396
+ end
397
+
398
+ def elapsed_ms(started)
399
+ return 0 unless started
400
+
401
+ [((monotonic_time - started) * 1000.0).round, 0].max
402
+ end
403
+
404
+ def tool_key(iteration, call_index)
405
+ "#{iteration}:#{call_index}"
406
+ end
407
+
408
+ def active_turn_snapshot
409
+ return nil unless @turn
410
+
411
+ {
412
+ id: @turn.id,
413
+ started_at: @turn.started_at,
414
+ duration_ms: elapsed_ms(@turn.started_monotonic)
415
+ }
416
+ end
417
+
418
+ def active_tool_snapshots
419
+ return [] unless @turn
420
+
421
+ @turn.tool_calls_by_id.values.map do |tool|
422
+ tool.slice(:id, :turn_id, :iteration, :call_index, :tool, :started_at).merge(
423
+ duration_ms: elapsed_ms(tool[:started_monotonic])
424
+ )
425
+ end
426
+ end
427
+
428
+ def merged_persisted_snapshot(path)
429
+ current = snapshot.merge(active_turn: nil, active_tools: [])
430
+ return current unless File.file?(path)
431
+
432
+ prior = JSON.parse(File.read(path))
433
+ return current unless prior.is_a?(Hash)
434
+
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
443
+ end
444
+
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)
448
+
449
+ id = record["id"] || record[:id]
450
+ by_id[id] = record if id
451
+ end.values
452
+ end
453
+
454
+ def earliest_timestamp(*timestamps)
455
+ timestamps.compact.min_by { |value| Time.iso8601(value.to_s) }
456
+ rescue ArgumentError
457
+ timestamps.compact.first
458
+ 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
+ end
466
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Samagotchi
6
+ # SessionObserver is a small persistent subscriber registry.
7
+ #
8
+ # Unlike the turn-scoped `Engine#run_turn` `on_event:` sink (passed once per
9
+ # turn and gone when the turn ends), a subscriber registered here keeps
10
+ # receiving events across every `run_turn` call on the same Engine instance.
11
+ #
12
+ # Each delivery carries a locally-monotonic `event_seq` so a subscriber can
13
+ # track ordering and how far behind it is. Subscribers are error-isolated:
14
+ # a subscriber that raises does not stop other subscribers from receiving the
15
+ # same event, and does not break the running turn.
16
+ #
17
+ # Documented event vocabulary (subscribers match on `:type`):
18
+ # * turn_started / turn_completed / turn_canceled / turn_failed — turn
19
+ # boundaries (turn_completed also carries a JSON-safe `turn_summary:`)
20
+ # * generation_* / tool_call_* / tool_dispatch_* — raw kernel-loop events
21
+ # * session_activity — an activity tick was recorded (see Engine#record_activity)
22
+ # * recap_ready — an idle session-recap finished generating
23
+ # (`recap:` prose, `generation:` id). Purely additive: it never mutates
24
+ # session.messages; each UI renders it (or not) however it likes.
25
+ class SessionObserver
26
+ # Handle returned by #subscribe. Holds the observer reference and carries
27
+ # #unsubscribe so a caller can deregister without keeping the registry.
28
+ class SubscribedObserver
29
+ # @return [#call] the wrapped observer
30
+ attr_reader :observer
31
+
32
+ def initialize(observer, registry)
33
+ @observer = observer
34
+ @registry = registry
35
+ end
36
+
37
+ # @return [Boolean, nil] true when this handle has unsubscribed, otherwise nil
38
+ def unsubscribed?
39
+ @unsubscribed
40
+ end
41
+
42
+ # Deregister this subscriber. Idempotent and safe to call multiple times.
43
+ # @return [Boolean] true if it was removed, false if already gone
44
+ def unsubscribe
45
+ return false if @unsubscribed
46
+
47
+ @unsubscribed = true
48
+ @registry&.unsubscribe(handle: self)
49
+ end
50
+ end
51
+
52
+ def initialize
53
+ @mutex = Monitor.new
54
+ @observers = [] # Array<SubscribedObserver>
55
+ @seq = 0
56
+ end
57
+
58
+ # Register a persistent subscriber (any object responding to #call(event)).
59
+ # @param observer [#call] the sink
60
+ # @return [SubscribedObserver] handle usable with #unsubscribe
61
+ def subscribe(observer:)
62
+ handle = SubscribedObserver.new(observer, self)
63
+ @mutex.synchronize { @observers << handle }
64
+ handle
65
+ end
66
+
67
+ # Remove a previously-registered subscriber.
68
+ # @param handle [SubscribedObserver, nil]
69
+ # @return [Boolean] true if it was removed, false otherwise (nil/unknown/
70
+ # already-unsubscribed never raises)
71
+ def unsubscribe(handle:)
72
+ return false unless handle.is_a?(SubscribedObserver)
73
+ @mutex.synchronize { !!@observers.delete(handle) }
74
+ end
75
+
76
+ # Assign a monotonic event_seq and fan out to every current subscriber.
77
+ # @param event [Hash] the raw emitted event (delivered to observers as a
78
+ # copy with `event_seq:` merged in; never mutated in place)
79
+ # @return [void]
80
+ def notify(event)
81
+ # Number AND deliver under one lock. Events arrive from several threads
82
+ # (turn, idle scheduler, bridge HTTP); delivering outside the lock let
83
+ # seq N reach a subscriber after N+1 (the SSE writer then dropped it as
84
+ # below its high-water mark) and let a numbered event fall between a
85
+ # new connection's replay snapshot and its live queue. Subscribers must
86
+ # stay non-blocking (enqueue-only), so holding the lock is cheap. The
87
+ # Monitor is reentrant, so a subscriber that emits on the same thread
88
+ # does not deadlock.
89
+ @mutex.synchronize do
90
+ payload = event.merge(event_seq: @seq += 1)
91
+
92
+ @observers.dup.each do |handle|
93
+ next if handle.unsubscribed?
94
+
95
+ begin
96
+ handle.observer.call(payload)
97
+ rescue StandardError
98
+ # Error-isolate: a throwing subscriber must not stop others or break
99
+ # the running turn.
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ # @return [Integer] total events emitted so far (Engine-local sequence)
106
+ def event_count
107
+ @mutex.synchronize { @seq }
108
+ end
109
+
110
+ # Run the block while no event is being numbered or delivered, so it sees
111
+ # (and can change) state consistently with the event log. Reentrant: the
112
+ # block may notify. Keep it short; every emitter waits for it.
113
+ def synchronize(&block)
114
+ @mutex.synchronize(&block)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../config"
5
+ require_relative "../model_profile"
6
+ require_relative "../session_manager"
7
+ require_relative "../bridge_client"
8
+ require_relative "live_region"
9
+ require_relative "plain_surface"
10
+ require_relative "attached_loop"
11
+
12
+ module Samagotchi
13
+ class TerminalUI
14
+ # `chi --attach ID` and `chi --shared [--resume ID]`: find or start the
15
+ # session's worker, then run the TUI as a client of its Bridge
16
+ # (AttachedLoop). It takes no OwnerLock and builds no Engine: the worker
17
+ # owns the session, and any number of UIs can attach to it.
18
+ module AttachLauncher
19
+ # Why attaching failed, worded for the terminal.
20
+ class Error < StandardError; end
21
+
22
+ BRIDGE_WAIT = 10.0
23
+
24
+ module_function
25
+
26
+ # Attach until the user detaches or the worker goes away.
27
+ # @param prompt [String, nil] sent as the first prompt once attached (`-p`)
28
+ # @param model [String, nil] --model: a new session starts on it; a
29
+ # resumed or attached one's worker gets a /model before the prompt
30
+ # @param no_interrupt [Boolean] --no-interrupt: every turn this TUI
31
+ # posts runs with the raised iteration limit
32
+ # @param default_input [Boolean] a new session with no -p gets
33
+ # SAMAGOTCHI_DEFAULT_INPUT in its first read (--no-default-input: no)
34
+ # @param memories [Array<String>] --memory: a new session's worker
35
+ # preloads them (an existing session keeps its own list)
36
+ # @param muted_memories [Array<String>] --mute: hidden from a new session
37
+ # @return [Symbol] :detached, :closed when the worker went away, or
38
+ # :failed when the --model switch didn't go through
39
+ def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
40
+ memories: [], muted_memories: [])
41
+ client = connect(attach: attach, shared: shared, resume: resume, model: model,
42
+ memories: memories, muted_memories: muted_memories)
43
+ first_command = model && (attach || resume) ? "/model #{model}" : nil
44
+ surface = open_surface
45
+ begin
46
+ AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
47
+ first_command: first_command, no_interrupt: no_interrupt,
48
+ default_input: default_input && !prompt && !attach && !resume).run
49
+ ensure
50
+ close_surface(surface)
51
+ end
52
+ end
53
+
54
+ # A live region (Screen, with Reline drawing into it) on a terminal
55
+ # that can show one, else plain append-only output.
56
+ # @return [Screen, PlainSurface]
57
+ def open_surface(out: $stdout, input: $stdin, env: ENV)
58
+ LiveRegion.open(out: out, input: input, env: env) || PlainSurface.new(out: out)
59
+ end
60
+
61
+ def close_surface(surface)
62
+ LiveRegion.close(surface)
63
+ end
64
+
65
+ # @return [BridgeClient] a client for the live Bridge of the session
66
+ # @raise [Error]
67
+ def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
68
+ memories: [], muted_memories: [], err: $stderr)
69
+ sd = state_dir || Session.default_state_dir
70
+ warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
71
+ if attach
72
+ live = connect_existing(attach, sd)
73
+ return live if live
74
+
75
+ # Its worker exited when nobody used it (or never ran): wake one.
76
+ resume = attach
77
+ end
78
+
79
+ session = if resume
80
+ SessionManager.resume_session(resume, state_dir: sd)
81
+ else
82
+ SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
83
+ memories: memories, muted_memories: muted_memories)
84
+ end
85
+ client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
86
+ client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
87
+ rescue SessionManager::OwnedByTUI
88
+ raise Error, "session #{resume} is open in a chi REPL; close it there first"
89
+ rescue ArgumentError => e
90
+ raise Error, e.message
91
+ end
92
+
93
+ # --model as the REPL reads it: an alias resolved, a host prefix kept.
94
+ def model_ref(model)
95
+ ModelProfile.required_model_name(ConfigFile.resolve_model_alias(model))
96
+ end
97
+
98
+ # An existing session's prompt is built from its own session fields:
99
+ # --memory/--mute given with --attach or --resume are ignored, with a
100
+ # line saying so (printed before the live region opens).
101
+ def warn_memory_flags_ignored(session_id, memories, muted_memories, err)
102
+ flags = []
103
+ flags << "--memory" unless Array(memories).empty?
104
+ flags << "--mute" unless Array(muted_memories).empty?
105
+ return if flags.empty?
106
+
107
+ verb = flags.size == 1 ? "applies" : "apply"
108
+ err.puts "(#{flags.join(' and ')} #{verb} to a new session; #{session_id}'s prompt is already built)"
109
+ end
110
+
111
+ # @return [BridgeClient, nil] the running worker's, or nil when none runs
112
+ def connect_existing(session_id, state_dir)
113
+ Session.load(session_id, state_dir: state_dir)
114
+ BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
115
+ end
116
+ end
117
+ end
118
+ end