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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../token_usage"
4
+
5
+ module Samagotchi
6
+ module LLM
7
+ # A turn's token counts, never nil: what the server reported (:server),
8
+ # else a chars/4 estimate (:estimate), else zeros (:none).
9
+ Usage = Data.define(:prompt_tokens, :completion_tokens, :source) do
10
+ # @return [Usage, nil] nil when the payload carries no counts
11
+ def self.from_payload(payload)
12
+ counts = TokenUsage.from_payload(payload)
13
+ return nil unless counts
14
+
15
+ new(prompt_tokens: counts[:prompt_tokens].to_i, completion_tokens: counts[:completion_tokens].to_i,
16
+ source: :server)
17
+ end
18
+
19
+ # +extra_prompt_tokens+: what the prompt holds besides text (images).
20
+ def self.estimate(prompt_text:, completion_text:, extra_prompt_tokens: 0)
21
+ new(prompt_tokens: TokenUsage.estimate(prompt_text.to_s) + extra_prompt_tokens.to_i,
22
+ completion_tokens: TokenUsage.estimate(completion_text.to_s),
23
+ source: :estimate)
24
+ end
25
+
26
+ def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
27
+
28
+ def total_tokens = prompt_tokens + completion_tokens
29
+ end
30
+
31
+ # Builds a turn's Usage from its stream events, the way SessionMetrics
32
+ # counts them: server counts are cumulative per request, so each
33
+ # generation keeps its highest; the prompt grows across a turn's
34
+ # generations, so the last generation's prompt counts, and completions
35
+ # add up. Without server counts, the streamed text is estimated.
36
+ class UsageCollector
37
+ def initialize
38
+ @generations = []
39
+ end
40
+
41
+ def observe(event)
42
+ case event[:type]
43
+ when :generation_started
44
+ @generations << { prompt: 0, completion: 0, server: false, text: +"" }
45
+ when :generation_chunk
46
+ @generations << { prompt: 0, completion: 0, server: false, text: +"" } if @generations.empty?
47
+ record(@generations.last, event)
48
+ end
49
+ end
50
+
51
+ # @param prompt_text [String, nil] what an estimate counts as the prompt
52
+ # @param extra_prompt_tokens [Integer] estimated non-text prompt tokens
53
+ def usage(prompt_text: nil, extra_prompt_tokens: 0)
54
+ server = @generations.select { |generation| generation[:server] }
55
+ unless server.empty?
56
+ return Usage.new(prompt_tokens: server.last[:prompt], completion_tokens: server.sum { |g| g[:completion] },
57
+ source: :server)
58
+ end
59
+
60
+ text = @generations.sum("") { |generation| generation[:text] }
61
+ return Usage.none if text.empty?
62
+
63
+ Usage.estimate(prompt_text: prompt_text, completion_text: text, extra_prompt_tokens: extra_prompt_tokens)
64
+ end
65
+
66
+ private
67
+
68
+ def record(generation, event)
69
+ counts = Usage.from_payload(event[:payload])
70
+ if counts
71
+ generation[:server] = true
72
+ generation[:prompt] = [generation[:prompt], counts.prompt_tokens].max
73
+ generation[:completion] = [generation[:completion], counts.completion_tokens].max
74
+ end
75
+ generation[:text] << event[:content].to_s
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "log_line"
5
+ require_relative "debug_log"
6
+
7
+ module Samagotchi
8
+ autoload :Config, File.expand_path("config", __dir__)
9
+ autoload :LogPath, File.expand_path("log_path", __dir__)
10
+
11
+ # The one logging facade: tagged, levelled records in the debug log
12
+ # (LogPath, format in LogLine), one process-wide sink.
13
+ #
14
+ # Log.info(:worker, "idle_exit", idle_s: 1800)
15
+ # Log.warn(:hooks, "hook_failed", echo: "[samagotchi:hooks] …")
16
+ # Log.debug(:model, "response", payload: text, model: "qwen")
17
+ #
18
+ # echo: is for what used to be a plain `warn`: that text still goes to
19
+ # stderr unchanged (whatever the level; not in a worker, see configure)
20
+ # and becomes the record's msg= field.
21
+ #
22
+ # Unconfigured, the first record resolves the file and level from Config
23
+ # (once, until Config.reload!). While that runs (Config can warn itself)
24
+ # records only echo. Nothing here raises into the caller: a record that
25
+ # can't be formatted is dropped, an unwritable file pauses (DebugLog).
26
+ module Log
27
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
28
+ DEFAULT_LEVEL = :info
29
+ MAX_PAYLOAD_BYTES = 64 * 1024
30
+ SID_LENGTH = 8
31
+ BACKTRACE_FRAMES = 20
32
+ # A field named like a credential never shows its value. Whole name
33
+ # segments, so counts like prompt_tokens stay readable.
34
+ SECRET_KEY = /(?:\A|_)(?:key|api_?key|token|secret|authorization|auth|password|passwd|cookie)(?:\z|_)/
35
+ URL = %r{\A[a-z][a-z0-9+.-]*://}i
36
+
37
+ @monitor = Monitor.new
38
+ @options = {}
39
+ @state = nil
40
+ @resolving = false
41
+ @session_id = nil
42
+
43
+ class << self
44
+ # @param path [String, nil, :auto] the file; :auto → LogPath.resolve
45
+ # @param level [Symbol, String, nil] nil → log.level
46
+ # @param stderr [Boolean] false in a worker: echo: text goes nowhere
47
+ # (its stderr is /dev/null anyway), only to the file
48
+ # @param mirror [Boolean] -v: every record written also goes to stderr
49
+ def configure(path: :auto, level: nil, stderr: true, mirror: false)
50
+ @monitor.synchronize do
51
+ close_state
52
+ @options = { path: path, level: level, stderr: stderr, mirror: mirror }
53
+ end
54
+ self
55
+ end
56
+
57
+ # Back to unconfigured (specs: before each example).
58
+ def reset!
59
+ @monitor.synchronize do
60
+ close_state
61
+ @options = {}
62
+ @session_id = nil
63
+ end
64
+ end
65
+
66
+ # Config changed (Config.reload!): resolve :auto parts again.
67
+ def invalidate!
68
+ @monitor.synchronize { close_state }
69
+ end
70
+
71
+ # The session this process works for (a worker, the REPL); records
72
+ # carry its first SID_LENGTH chars unless they pass their own sid:.
73
+ attr_accessor :session_id
74
+
75
+ def debug(tag, event, **kw) = log(:debug, tag, event, **kw)
76
+ def info(tag, event, **kw) = log(:info, tag, event, **kw)
77
+ def warn(tag, event, **kw) = log(:warn, tag, event, **kw)
78
+ def error(tag, event, **kw) = log(:error, tag, event, **kw)
79
+
80
+ # ERROR with the exception and its first BACKTRACE_FRAMES frames as
81
+ # the payload (crashes of threads and workers).
82
+ def exception(tag, event, error, echo: nil, **fields)
83
+ frames = Array(error.backtrace).first(BACKTRACE_FRAMES)
84
+ log(:error, tag, event, echo: echo, payload: frames.join("\n"),
85
+ error: error.class.name, msg: error.message.to_s[0, 500], **fields)
86
+ end
87
+
88
+ # Whether a record at this level reaches the file or stderr (to skip
89
+ # building a big payload for nothing).
90
+ def level?(level)
91
+ state = resolved_state
92
+ state ? LEVELS.fetch(level.to_sym) >= state[:level] : false
93
+ end
94
+
95
+ def path
96
+ resolved_state&.dig(:writer)&.path
97
+ end
98
+
99
+ def log(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
100
+ options = @options
101
+ echo_to_stderr(echo) if echo && options.fetch(:stderr, true)
102
+ state = resolved_state
103
+ return nil unless state && LEVELS.fetch(level) >= state[:level]
104
+
105
+ fields = { msg: echo }.merge(fields) if echo && !fields.key?(:msg)
106
+ text = build(level, tag, event, payload: payload, sid: sid || @session_id, fields: fields)
107
+ return nil unless text
108
+
109
+ state[:writer].write(text)
110
+ mirror(text) if state[:mirror] && !echo
111
+ text
112
+ rescue StandardError
113
+ nil
114
+ end
115
+
116
+ private
117
+
118
+ def build(level, tag, event, payload:, sid:, fields:)
119
+ tag = tag.to_s
120
+ event = event.to_s
121
+ raise ArgumentError, "unknown log tag #{tag}" unless LogLine::TAGS.include?(tag)
122
+ raise ArgumentError, "bad log event #{event}" unless event.match?(LogLine::SLUG)
123
+
124
+ fields = fields.each_with_object({}) do |(key, value), out|
125
+ key = key.to_s
126
+ next if value.nil? || !key.match?(LogLine::KEY)
127
+
128
+ out[key] = safe_value(key, value)
129
+ end
130
+ payload = payload.to_s if payload
131
+ if payload && payload.bytesize > MAX_PAYLOAD_BYTES
132
+ fields["truncated"] = payload.bytesize - MAX_PAYLOAD_BYTES
133
+ payload = payload.byteslice(0, MAX_PAYLOAD_BYTES)
134
+ end
135
+ LogLine.format(LogLine::Record.new(
136
+ ts: Time.now, level: level.to_s.upcase, tag: tag, pid: Process.pid,
137
+ sid: sid && sid.to_s[0, SID_LENGTH], event: event, fields: fields, payload: payload
138
+ ))
139
+ rescue StandardError
140
+ nil
141
+ end
142
+
143
+ def safe_value(key, value)
144
+ return "[redacted]" if key.match?(SECRET_KEY)
145
+
146
+ value = value.is_a?(Float) ? value.round(3) : value
147
+ value.is_a?(String) && value.match?(URL) ? safe_url(value) : value
148
+ end
149
+
150
+ # No userinfo, no query or fragment (keys ride there sometimes).
151
+ def safe_url(url)
152
+ url.sub(%r{\A([a-z][a-z0-9+.-]*://)[^/?#@]*@}i, "\\1").sub(/[?#].*\z/m, "")
153
+ end
154
+
155
+ def echo_to_stderr(text)
156
+ $stderr.puts(text)
157
+ rescue StandardError
158
+ nil
159
+ end
160
+
161
+ def mirror(text)
162
+ $stderr.write(text)
163
+ rescue StandardError
164
+ nil
165
+ end
166
+
167
+ def resolved_state
168
+ state = @state
169
+ return state if state
170
+
171
+ @monitor.synchronize do
172
+ return @state if @state
173
+ # Config.get below may log a warning: that one only echoes.
174
+ return nil if @resolving
175
+
176
+ @resolving = true
177
+ begin
178
+ @state = build_state
179
+ ensure
180
+ @resolving = false
181
+ end
182
+ end
183
+ end
184
+
185
+ def build_state
186
+ options = @options
187
+ path = options.fetch(:path, :auto)
188
+ path = (LogPath.resolve rescue nil) if path == :auto
189
+ level = options[:level] || (Config.get("log.level") rescue nil)
190
+ level = LEVELS.key?(level.to_s.downcase.to_sym) ? level.to_s.downcase.to_sym : DEFAULT_LEVEL
191
+ { writer: DebugLog.new(path: path), level: LEVELS.fetch(level), mirror: options[:mirror] ? true : false }
192
+ end
193
+
194
+ def close_state
195
+ @state&.dig(:writer)&.close
196
+ @state = nil
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Samagotchi
7
+ # The debug log's line format: the one contract between the writer (Log)
8
+ # and every reader (the web's later log view, `chi log`, grep and awk).
9
+ #
10
+ # <ts> <LEVEL> <tag> pid=<n> [sid=<8>] <event> [k=v ...]
11
+ # <payload line>
12
+ #
13
+ # ts is UTC ISO8601 with milliseconds, LEVEL is padded to 5, tag and event
14
+ # are slugs. A value is bare when it has no space, quote or `=`, else a
15
+ # JSON string, so a newline in a value never splits a record. Payload lines
16
+ # (debug dumps) are indented by four spaces and belong to the record above.
17
+ module LogLine
18
+ LEVELS = %w[DEBUG INFO WARN ERROR].freeze
19
+ TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model].freeze
20
+ INDENT = " "
21
+
22
+ Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
23
+
24
+ SLUG = /\A[a-z0-9_.-]+\z/
25
+ KEY = /\A[a-z0-9_]+\z/
26
+ BARE = /\A[^"=\p{Z}\p{Cc}\p{Cf}]+\z/
27
+ HEADER = /\A(?<ts>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z) (?<level>DEBUG|INFO|WARN|ERROR) +(?<tag>[a-z]+) pid=(?<pid>\d+)(?: sid=(?<sid>[^\s"=]+))? (?<event>[a-z0-9_.-]+)(?<rest>.*)\z/
28
+ FIELD = /\G (?<key>[a-z0-9_]+)=(?:"(?<quoted>(?:[^"\\]|\\.)*)"|(?<bare>[^\s"=]+))/
29
+ # C1 controls and the line/paragraph separators JSON.generate leaves as is.
30
+ EXTRA_ESCAPES = /[\u007f-\u009f\u2028\u2029]/
31
+
32
+ module_function
33
+
34
+ # The record as one string: header, payload lines, trailing newline.
35
+ def format(record)
36
+ head = [format_ts(record.ts), record.level.to_s.upcase.ljust(5), record.tag.to_s, "pid=#{record.pid}"]
37
+ head << "sid=#{record.sid}" if record.sid && !record.sid.to_s.empty?
38
+ head << record.event.to_s
39
+ line = +head.join(" ")
40
+ (record.fields || {}).each { |key, value| line << " #{key}=#{format_value(value)}" unless value.nil? }
41
+ line << "\n"
42
+ payload = record.payload
43
+ line << format_payload(payload) if payload && !payload.to_s.empty?
44
+ line
45
+ end
46
+
47
+ def format_ts(time)
48
+ return time if time.is_a?(String)
49
+
50
+ time.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
51
+ end
52
+
53
+ def format_value(value)
54
+ text = clean(value.to_s)
55
+ return text if text.match?(BARE)
56
+
57
+ JSON.generate(text).gsub(EXTRA_ESCAPES) { |c| Kernel.format("\\u%04x", c.ord) }
58
+ end
59
+
60
+ # Every payload line indented; control characters (ANSI colours from a
61
+ # tool, a stray \r) shown escaped so the log can be cat'ed safely.
62
+ def format_payload(payload)
63
+ clean(payload.to_s).split("\n", -1).map { |line| "#{INDENT}#{escape_controls(line.chomp("\r"))}\n" }.join
64
+ end
65
+
66
+ def escape_controls(text)
67
+ text.gsub(/[\p{Cc}&&[^\t]]|#{EXTRA_ESCAPES}/o) { |c| c == "\e" ? "\\e" : Kernel.format("\\u%04x", c.ord) }
68
+ end
69
+
70
+ # Valid UTF-8 whatever the source (binary tool output included).
71
+ def clean(text)
72
+ text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
73
+ text.valid_encoding? ? text : text.scrub("?")
74
+ end
75
+
76
+ # One header line → Record (payload nil), or nil when it isn't one.
77
+ def parse(line)
78
+ match = HEADER.match(line.to_s.chomp)
79
+ return nil unless match
80
+
81
+ fields = parse_fields(match[:rest])
82
+ return nil unless fields
83
+
84
+ Record.new(ts: match[:ts], level: match[:level], tag: match[:tag], pid: Integer(match[:pid]),
85
+ sid: match[:sid], event: match[:event], fields: fields, payload: nil)
86
+ end
87
+
88
+ def parse_fields(rest)
89
+ fields = {}
90
+ pos = 0
91
+ while pos < rest.length
92
+ m = FIELD.match(rest, pos)
93
+ return nil unless m && m.begin(0) == pos
94
+
95
+ fields[m[:key]] = m[:quoted] ? JSON.parse("[\"#{m[:quoted]}\"]").first : m[:bare]
96
+ pos = m.end(0)
97
+ end
98
+ fields
99
+ rescue JSON::ParserError
100
+ nil
101
+ end
102
+
103
+ def payload_line?(line)
104
+ line.start_with?(INDENT)
105
+ end
106
+
107
+ # Yields each Record of a log (payload lines joined onto theirs). Lines
108
+ # that are neither (an older format, a torn write) go to on_invalid.
109
+ def each_record(io, on_invalid: nil)
110
+ return enum_for(:each_record, io, on_invalid: on_invalid) unless block_given?
111
+
112
+ current = nil
113
+ io.each_line do |raw|
114
+ line = clean(raw).chomp
115
+ if current && payload_line?(line)
116
+ text = line.delete_prefix(INDENT)
117
+ current.payload = current.payload ? "#{current.payload}\n#{text}" : text
118
+ next
119
+ end
120
+ yield current if current
121
+ current = parse(line)
122
+ on_invalid&.call(line) if current.nil? && !line.empty?
123
+ end
124
+ yield current if current
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # Where the debug log (DebugLog) goes, for the REPL, the attached terminal
7
+ # and the background worker alike: nil when log.disable is set, else log.file
8
+ # (~ and relative paths expanded against cwd), else
9
+ # $XDG_STATE_HOME/samagotchi/samagotchi.log (~/.local/state/... by default),
10
+ # next to the sessions and prompt history. Never inside the gem or checkout.
11
+ module LogPath
12
+ FILENAME = "samagotchi.log"
13
+
14
+ module_function
15
+
16
+ def resolve(env: ENV)
17
+ return nil if Config.get("log.disable")
18
+
19
+ configured = Config.get("log.file").to_s.strip
20
+ return File.expand_path(configured) unless configured.empty?
21
+
22
+ default_path(env: env)
23
+ end
24
+
25
+ def default_path(env: ENV)
26
+ xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
27
+ base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
28
+ File.join(base, "samagotchi", FILENAME)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log"
4
+
5
+ module Samagotchi
6
+ # The event trail: a SessionObserver subscriber (next to SessionMetrics)
7
+ # that writes what happened in a session, in order, as `turn` records.
8
+ # Both loops (native and chat) emit the same vocabulary, so every host
9
+ # leaves the same trail. Event names are the observer's types.
10
+ #
11
+ # INFO carries sizes and timings, never the text of a prompt, answer or
12
+ # tool output (DEBUG dumps are KernelLoop's). It runs under the observer's
13
+ # lock: it only formats and appends (Log never blocks on rotation).
14
+ class LogSubscriber
15
+ TAG = :turn
16
+ # One line each, with a few of their own fields (never text).
17
+ ANNOUNCED = {
18
+ turn_enqueued: %i[enqueued_id client_id],
19
+ command_queued: %i[command_id client_id],
20
+ command_ran: %i[command_id client_id status],
21
+ input_merged: %i[count],
22
+ prompt_restored: [],
23
+ continue_offered: %i[no_interrupt],
24
+ continue_resolved: [],
25
+ context_added: %i[note_id source],
26
+ reminder_injected: [],
27
+ question_requested: [],
28
+ question_answered: %i[id],
29
+ question_cancelled: %i[id reason],
30
+ pending_input_merged: %i[iteration count],
31
+ generation_cancelled: %i[iteration]
32
+ }.freeze
33
+
34
+ # @param session_id [#call] the session the events are about (the
35
+ # Engine's current one), as the records' sid
36
+ def initialize(session_id: -> {}, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
37
+ @session_id = session_id
38
+ @clock = clock
39
+ @turn_started_at = nil
40
+ @generation_started_at = {}
41
+ @tool_started_at = {}
42
+ end
43
+
44
+ def call(event)
45
+ type = event[:type]&.to_sym
46
+ return unless type
47
+
48
+ if respond_to?(handler = :"on_#{type}", true)
49
+ send(handler, event)
50
+ elsif ANNOUNCED.key?(type)
51
+ log(:info, type, **event.slice(*ANNOUNCED[type]), **origin(event), **items(event))
52
+ end
53
+ rescue StandardError
54
+ nil
55
+ end
56
+
57
+ private
58
+
59
+ def on_turn_started(event)
60
+ @turn_started_at = @clock.call
61
+ @generation_started_at.clear
62
+ @tool_started_at.clear
63
+ log(:info, :turn_started, session: event[:session_id], prompt_chars: event[:prompt].to_s.length,
64
+ continue: event[:continue] || nil, **items(event), **origin(event))
65
+ end
66
+
67
+ def on_turn_completed(event)
68
+ summary = event[:turn_summary] || {}
69
+ log(:info, :turn_completed, ms: since(@turn_started_at), result_chars: summary[:output].to_s.length,
70
+ tools: Array(summary[:tool_activity]).size,
71
+ exhausted: summary[:exhausted] || nil, **origin(event))
72
+ end
73
+
74
+ def on_turn_canceled(event)
75
+ log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], **origin(event))
76
+ end
77
+
78
+ def on_turn_failed(event)
79
+ log(:warn, :turn_failed, ms: since(@turn_started_at), error: event[:error_class], error_kind: event[:error_kind],
80
+ host: event[:host], retryable: event[:retryable],
81
+ msg: (event[:summary] || event[:message]).to_s[0, 300], **origin(event))
82
+ end
83
+
84
+ def on_generation_started(event)
85
+ @generation_started_at[event[:iteration]] = @clock.call
86
+ log(:debug, :generation_started, iteration: event[:iteration], profile: event[:profile],
87
+ context_window: event[:context_window_tokens])
88
+ end
89
+
90
+ def on_generation_completed(event)
91
+ log(:info, :generation_completed, iteration: event[:iteration],
92
+ ms: since(@generation_started_at.delete(event[:iteration])),
93
+ served_model: event[:served_model], requested_model: event[:requested_model],
94
+ content_length: event[:content_length],
95
+ thinking_chars: event[:thinking_chars])
96
+ end
97
+
98
+ def on_generation_retrying(event)
99
+ log(:warn, :generation_retrying, iteration: event[:iteration], attempt: event[:attempt],
100
+ max_retries: event[:max_retries], delay_s: event[:next_delay],
101
+ error: event[:error_class], msg: event[:error_message].to_s[0, 300])
102
+ end
103
+
104
+ def on_tool_call_started(event)
105
+ @tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
106
+ end
107
+
108
+ def on_tool_call_completed(event)
109
+ # A tool's output can be any bytes (invalid UTF-8 would fail the match).
110
+ output = event[:output].to_s.scrub
111
+ log(:info, :tool_call_completed, iteration: event[:iteration], tool: event[:tool],
112
+ ms: since(@tool_started_at.delete([event[:iteration], event[:call_index]])),
113
+ output_chars: output.length, truncated: event[:output_truncated] || nil,
114
+ error: tool_error?(output) || nil)
115
+ end
116
+
117
+ def on_guardrail_warning(event)
118
+ log(:warn, :guardrail_warning, label: event[:label], msg: event[:message].to_s[0, 300])
119
+ end
120
+
121
+ # A hook's notice to the user: who said it and what (its text is the
122
+ # hook's, not the user's or the model's).
123
+ def on_hook_notice(event)
124
+ level = event[:level].to_s == "warn" ? :warn : :info
125
+ log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
126
+ end
127
+
128
+ # A card shown to the user (Engine#show_card): whose, which, and its
129
+ # title (the body is the plugin's text; not logged).
130
+ def on_card(event)
131
+ log(:info, :card, source: event[:source], id: event[:id], actions: Array(event[:actions]).size,
132
+ msg: event[:title].to_s[0, 300])
133
+ end
134
+
135
+ def on_recap_ready(event)
136
+ log(:info, :recap_ready, chars: event[:recap].to_s.length, generation: event[:generation], covered: event[:covered])
137
+ end
138
+
139
+ # "[read] Error: …" (the dispatch failed) or "[read]\nError: …" (the
140
+ # tool said so), and an unknown tool's bare "Error: …".
141
+ def tool_error?(output)
142
+ output.match?(/\A(?:\[[^\]\n]*\]\s*)?Error:/)
143
+ end
144
+
145
+ def origin(event)
146
+ client = event.dig(:origin, :client_id) if event[:origin].is_a?(Hash)
147
+ client ? { client_id: client } : {}
148
+ end
149
+
150
+ def items(event)
151
+ images = Array(event[:images]).size
152
+ images.positive? ? { images: images } : {}
153
+ end
154
+
155
+ def since(started)
156
+ started ? ((@clock.call - started) * 1000).round : nil
157
+ end
158
+
159
+ def log(level, event, **fields)
160
+ Log.public_send(level, TAG, event, sid: @session_id.call, **fields)
161
+ end
162
+ end
163
+ end