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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Live thinking as one sentence at a time, for the terminal: the Ruby side
5
+ # of the web's sentences.js + thinking_ticker.js, with the same rules.
6
+ #
7
+ # Sentence boundary: [.!?…] optionally followed by closing quotes or
8
+ # brackets, then whitespace; a newline is also a boundary, and so is the end
9
+ # of the text (the ticker corrects itself on the next chunk). A bare list
10
+ # number ("1.", "118.") at the start of a line is a marker, not a sentence.
11
+ # Whitespace is collapsed. A trailing fragment longer than MAX_LEN (a
12
+ # code-like run with no punctuation) counts as the current sentence.
13
+ #
14
+ # The ticker changes its line no sooner than DWELL seconds after the last
15
+ # change, so a fast stream doesn't flicker: in between, the newest sentence
16
+ # waits and #tick shows it when the dwell is over.
17
+ class ThinkingTicker
18
+ MAX_LEN = 160
19
+ DWELL = 1.5
20
+ BOUNDARY = /([.!?…][)"'\]]*)([[:space:]]|\z)|\n/
21
+ LIST_MARKER = /\A\d{1,4}\.\z/
22
+
23
+ # @return [Array<String>] every complete sentence of +text+ (the end
24
+ # counts as a boundary), collapsed, blanks skipped
25
+ def self.sentences(text)
26
+ raw = text.to_s
27
+ found = []
28
+ start = 0
29
+ raw.to_enum(:scan, BOUNDARY).each do
30
+ m = Regexp.last_match
31
+ candidate = raw[start...(m.begin(0) + m[1].to_s.length)]
32
+ # A list number is no sentence: keep reading from the same start.
33
+ next if LIST_MARKER.match?(candidate.strip)
34
+
35
+ collapsed = collapse(candidate)
36
+ found << collapsed unless collapsed.empty?
37
+ start = m.end(0)
38
+ end
39
+ found
40
+ end
41
+
42
+ # The newest complete sentence of +text+, else the trailing fragment when
43
+ # longer than +max_len+, else "".
44
+ def self.current_sentence(text, max_len: MAX_LEN)
45
+ last = sentences(text).last
46
+ return last if last
47
+
48
+ fragment = collapse(text)
49
+ fragment.length > max_len ? fragment : ""
50
+ end
51
+
52
+ def self.collapse(text)
53
+ text.to_s.gsub(/[[:space:]]+/, " ").strip
54
+ end
55
+
56
+ attr_reader :line
57
+
58
+ # @param clock [#call] seconds, monotonic
59
+ def initialize(clock:, dwell: DWELL)
60
+ @clock = clock
61
+ @dwell = dwell
62
+ reset
63
+ end
64
+
65
+ # Forget the line (a new generation).
66
+ def reset
67
+ @line = ""
68
+ @pending = nil
69
+ @changed_at = nil
70
+ end
71
+
72
+ # Show the newest sentence of +text+ now, or keep it pending for the
73
+ # dwell. @return [Boolean] whether the line changed
74
+ def feed(text)
75
+ candidate = self.class.current_sentence(text)
76
+ return false if candidate.empty?
77
+
78
+ if candidate == @line
79
+ @pending = nil
80
+ false
81
+ elsif @line.empty? || due?
82
+ show(candidate)
83
+ else
84
+ @pending = candidate
85
+ false
86
+ end
87
+ end
88
+
89
+ # Show a pending sentence once the dwell is over.
90
+ # @return [Boolean] whether the line changed
91
+ def tick
92
+ return false unless @pending && due?
93
+
94
+ show(@pending)
95
+ end
96
+
97
+ private
98
+
99
+ def due?
100
+ @changed_at.nil? || @clock.call - @changed_at >= @dwell
101
+ end
102
+
103
+ def show(candidate)
104
+ @line = candidate
105
+ @pending = nil
106
+ @changed_at = @clock.call
107
+ true
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_profile"
4
+
5
+ module Samagotchi
6
+ # Incremental, profile-aware splitter for a raw model stream.
7
+ #
8
+ # It feeds raw streamed chunks (the `:generation_chunk` `content`, which is
9
+ # never mutated) and routes each byte into one of two additive lanes:
10
+ # * :thinking — content inside a thinking block
11
+ # * :text — visible prose (everything outside thinking/tool_call blocks)
12
+ #
13
+ # tool_call block bodies are dropped: the web activity panel renders those from
14
+ # the `:tool_call_started` / `:tool_call_completed` events, so duplicating them
15
+ # in the text bubble would be noise.
16
+ #
17
+ # The splitter is stateful across chunks: it carries partial open/close marker
18
+ # fragments at the end of each chunk so a marker split across a stream boundary
19
+ # is still recognized. `ThoughtStreamSplitter.for_profile` derives the block
20
+ # markers from the active ModelProfile, so the same splitter works for the
21
+ # Qwen literal-marker family and (tool_call only) the Gemma control-token family.
22
+ #
23
+ # For a profile whose `thought_close` is nil (Gemma 4), thinking blocks have no
24
+ # explicit close marker and are NOT split out — they remain in `:text` and the
25
+ # renderer's existing strip still hides them from the bubble. Qwen 3.6 (the
26
+ # default runtime profile) has explicit open/close markers for both families,
27
+ # so thinking is split cleanly.
28
+ class ThoughtStreamSplitter
29
+ # @return [Array<Hash>] each entry { open: String, close: String, lane: :thinking|:drop }
30
+ def self.for_profile(profile)
31
+ blocks = []
32
+ # Only register a thinking block when the profile has an explicit close
33
+ # marker (Qwen). A nil close (Gemma) means we can't bound the block, so we
34
+ # leave thinking in :text and let the renderer's strip handle it.
35
+ blocks << { open: profile.thought_open, close: profile.thought_close, lane: :thinking } if profile.thought_close
36
+ blocks << { open: profile.tool_call_open, close: profile.tool_call_close, lane: :drop }
37
+ new(blocks)
38
+ end
39
+
40
+ # @param blocks [Array<Hash>] as produced by #for_profile
41
+ def initialize(blocks)
42
+ @blocks = blocks
43
+ @carry = +""
44
+ @state = :normal
45
+ @active = nil
46
+ end
47
+
48
+ # Feed one raw chunk. Returns the newly-routed deltas for this chunk only.
49
+ # @return [Hash{ text: String, thinking: String }]
50
+ def feed(chunk)
51
+ @carry << chunk.to_s
52
+ text = +""
53
+ thinking = +""
54
+ loop do
55
+ if @state == :normal
56
+ hit = earliest_open
57
+ if hit.nil?
58
+ flush_normal(text)
59
+ break
60
+ end
61
+ # Everything before the open marker is visible prose.
62
+ text << @carry[0...hit[:pos]]
63
+ @carry = @carry[hit[:pos]..]
64
+ @active = hit[:block]
65
+ @state = :in_block
66
+ # Drop the open marker itself; its body follows.
67
+ @carry = @carry[@active[:open].length..].to_s
68
+ else
69
+ close_pos = @carry.index(@active[:close])
70
+ if close_pos.nil?
71
+ flush_in_block(thinking)
72
+ break
73
+ end
74
+ body = @carry[0...close_pos]
75
+ route_body(body, thinking)
76
+ @carry = @carry[close_pos + @active[:close].length..].to_s
77
+ @active = nil
78
+ @state = :normal
79
+ end
80
+ end
81
+ { text: text, thinking: thinking }
82
+ end
83
+
84
+ # Flush any residual state at end of stream (e.g. an unterminated thinking
85
+ # block: its content is still thinking; a partial trailing marker is
86
+ # discarded since it can no longer complete).
87
+ # @return [Hash{ text: String, thinking: String }]
88
+ def finalize
89
+ text = +""
90
+ thinking = +""
91
+ if @state == :in_block
92
+ route_body(@carry, thinking)
93
+ else
94
+ text << @carry
95
+ end
96
+ @carry = +""
97
+ @active = nil
98
+ @state = :normal
99
+ { text: text, thinking: thinking }
100
+ end
101
+
102
+ private
103
+
104
+ # Normal state: the earliest complete open marker among @blocks, or nil.
105
+ def earliest_open
106
+ best = nil
107
+ @blocks.each do |block|
108
+ pos = @carry.index(block[:open])
109
+ next if pos.nil?
110
+ best = { pos: pos, block: block } if best.nil? || pos < best[:pos]
111
+ end
112
+ best
113
+ end
114
+
115
+ # Normal state: emit everything except a trailing fragment that could be the
116
+ # start of an open marker (retained for the next chunk to complete it).
117
+ def flush_normal(text)
118
+ partial = @blocks.map { |block| longest_suffix_prefix_of(@carry, block[:open]) }.max.to_i
119
+ text << @carry[0...(@carry.length - partial)]
120
+ @carry = @carry[@carry.length - partial..].to_s
121
+ end
122
+
123
+ # In-block state: emit the body seen so far to the active lane, retaining a
124
+ # trailing fragment that could be the start of the close marker.
125
+ def flush_in_block(thinking)
126
+ close = @active[:close]
127
+ partial = longest_suffix_prefix_of(@carry, close)
128
+ route_body(@carry[0...(@carry.length - partial)], thinking)
129
+ @carry = @carry[@carry.length - partial..].to_s
130
+ end
131
+
132
+ # Route a body fragment to the active block's lane (:thinking or :drop).
133
+ def route_body(body, thinking)
134
+ return if body.nil? || body.empty?
135
+ thinking << body if @active && @active[:lane] == :thinking
136
+ end
137
+
138
+ # Length of the longest suffix of +str+ that is a prefix of +marker+.
139
+ # Used to hold back a fragment that may complete into a marker next chunk.
140
+ def longest_suffix_prefix_of(str, marker)
141
+ mlen = marker.length
142
+ return 0 if mlen.zero? || str.empty?
143
+ [str.length, mlen].min.downto(1) do |n|
144
+ return n if str[-n..] == marker[0, n]
145
+ end
146
+ 0
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # TokenUsage extracts real server-reported token counts from a model API
5
+ # stream payload, transport-agnostically.
6
+ #
7
+ # llama.cpp's /completion stream emits per-chunk `timings.prompt_n` /
8
+ # `timings.predicted_n` (cumulative) and a final top-level
9
+ # `tokens_evaluated` / `tokens_predicted`. mlx_lm.server (OpenAI-compatible)
10
+ # emits a `usage` object with `prompt_tokens` / `completion_tokens`.
11
+ #
12
+ # Returns nil when no server token data is present, so a caller can fall back
13
+ # to a chars/4 heuristic. The reported values are cumulative per request, so
14
+ # callers should take the running MAX across chunks (see SessionMetrics).
15
+ class TokenUsage
16
+ CHARS_PER_TOKEN = 4.0
17
+
18
+ class << self
19
+ # @param payload [Hash, nil] the raw streamed chunk payload
20
+ # @return [Hash, nil] { prompt_tokens:, completion_tokens:, source: :server }
21
+ def from_payload(payload)
22
+ payload_hash = payload.is_a?(Hash) ? payload : {}
23
+ usage = payload_hash["usage"] || payload_hash[:usage]
24
+ usage = {} unless usage.is_a?(Hash)
25
+
26
+ prompt_tokens = first_positive(
27
+ usage["prompt_tokens"],
28
+ usage[:prompt_tokens],
29
+ payload_hash["prompt_tokens"],
30
+ payload_hash[:prompt_tokens],
31
+ payload_hash["prompt_n"],
32
+ payload_hash[:prompt_n],
33
+ payload_hash["tokens_evaluated"],
34
+ payload_hash[:tokens_evaluated],
35
+ payload_hash.dig("timings", "prompt_n"),
36
+ payload_hash.dig(:timings, :prompt_n)
37
+ )
38
+
39
+ completion_tokens = first_positive(
40
+ usage["completion_tokens"],
41
+ usage[:completion_tokens],
42
+ payload_hash["completion_tokens"],
43
+ payload_hash[:completion_tokens],
44
+ payload_hash["predicted_n"],
45
+ payload_hash[:predicted_n],
46
+ payload_hash["tokens_predicted"],
47
+ payload_hash[:tokens_predicted],
48
+ payload_hash.dig("timings", "predicted_n"),
49
+ payload_hash.dig(:timings, :predicted_n)
50
+ )
51
+
52
+ return nil unless prompt_tokens || completion_tokens
53
+
54
+ {
55
+ prompt_tokens: prompt_tokens,
56
+ completion_tokens: completion_tokens,
57
+ source: :server
58
+ }
59
+ end
60
+
61
+ # Estimate token count from raw text length using the chars/4 heuristic.
62
+ # @param text [String]
63
+ # @return [Integer]
64
+ def estimate(text)
65
+ return 0 unless text.is_a?(String)
66
+
67
+ (text.length / CHARS_PER_TOKEN).ceil
68
+ end
69
+
70
+ # Coerce each candidate to an integer (ContextUsage reads its own fields
71
+ # with this too), accepting integers, numeric strings,
72
+ # and floats (e.g. a JSON backend emitting 50.0). Truncates via to_i so a
73
+ # float never raises RangeError; non-numeric values are skipped.
74
+ def first_positive(*values)
75
+ values.each do |value|
76
+ next if value.nil?
77
+
78
+ n = Float(value).to_i
79
+ return n if n.positive?
80
+ rescue ArgumentError, TypeError
81
+ next
82
+ end
83
+
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "log"
5
+ require_relative "tools/execute"
6
+ require_relative "tools/read"
7
+ require_relative "tools/write"
8
+ require_relative "tools/memory"
9
+ require_relative "tools/edit"
10
+ require_relative "tools/task_create"
11
+ require_relative "tools/task_get"
12
+ require_relative "tools/task_list"
13
+ require_relative "tools/task_stop"
14
+ require_relative "tools/task_wait"
15
+ require_relative "tools/web_fetch"
16
+ require_relative "tools/ask_user_question"
17
+ require_relative "tools/list_sessions"
18
+ require_relative "tools/send_note"
19
+ require_relative "tools/delegate"
20
+ require_relative "tools/delegate_result"
21
+
22
+ module Samagotchi
23
+ # The one-line summary of a tool call that the UIs show ("reading file",
24
+ # path="…", ok/error/blocked). Both loops and KernelLoop#dispatch build it
25
+ # from here. The built-ins have their own words below; any other tool in
26
+ # the given Tools::Registry gets its entry's label and preview, or
27
+ # "calling tool" and its arguments as key=value.
28
+ module ToolActivity
29
+ TOOL_ACTIVITY_PREVIEW_LIMIT = 80
30
+
31
+ module_function
32
+
33
+ # @param registry [Tools::Registry, nil] the session's tools
34
+ def tool_activity_event(tool_name, call, result, registry: nil)
35
+ {
36
+ action: tool_activity_action(tool_name, registry: registry),
37
+ tool: tool_name,
38
+ params: tool_activity_params(tool_name, call, registry: registry),
39
+ status: tool_activity_status(result, tool_name)
40
+ }
41
+ end
42
+
43
+ def tool_activity_action(tool_name, registry: nil)
44
+ case tool_name
45
+ when Tools::Execute::NAME then "running command"
46
+ when Tools::Read::NAME then "reading file"
47
+ when Tools::Write::NAME then "writing file"
48
+ when Tools::Edit::NAME then "editing file"
49
+ when Tools::MemoryRead::NAME then "reading memory"
50
+ when Tools::MemoryWrite::NAME then "saving memory"
51
+ when Tools::TaskCreate::NAME then "starting background task"
52
+ when Tools::TaskGet::NAME then "checking task"
53
+ when Tools::TaskList::NAME then "listing tasks"
54
+ when Tools::TaskStop::NAME then "stopping task"
55
+ when Tools::TaskWait::NAME then "waiting for task"
56
+ when Tools::WebFetch::NAME then "fetching URL"
57
+ when Tools::AskUserQuestion::NAME then "asking user"
58
+ when Tools::ListSessions::NAME then "listing sessions"
59
+ when Tools::SendNote::NAME then "sending a note"
60
+ when Tools::Delegate::NAME then "delegating"
61
+ when Tools::DelegateResult::NAME then "waiting for a delegate"
62
+ else registry_entry(registry, tool_name)&.label || "calling tool"
63
+ end
64
+ end
65
+
66
+ # "error" for an "Error:" result, and for an execute whose command
67
+ # exited non-zero (its last "exit: N" line; no number: killed by a
68
+ # signal), so the turn tally's "(N failed)" counts it.
69
+ def tool_activity_status(result, tool_name = nil)
70
+ text = result.to_s
71
+ return "error" if text.start_with?("Error:")
72
+ return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
73
+
74
+ "ok"
75
+ end
76
+
77
+ def execute_failed?(text)
78
+ exit_line = text.lines.reverse_each.find { |line| line.start_with?("exit: ") }
79
+ return false unless exit_line
80
+
81
+ code = exit_line[/\Aexit: (\d*)/, 1]
82
+ code != "0"
83
+ end
84
+
85
+ def tool_activity_params(tool_name, call, registry: nil)
86
+ case tool_name
87
+ when Tools::Execute::NAME
88
+ "command=#{preview_tool_param(call[:content])}"
89
+ when Tools::Read::NAME
90
+ parts = ["path=#{preview_tool_param(call[:content])}"]
91
+ range = format_line_range(call)
92
+ parts << "lines=#{range}" if range
93
+ parts.join(" ")
94
+ when Tools::Write::NAME
95
+ "path=#{preview_tool_param(call[:path])}"
96
+ when Tools::Edit::NAME
97
+ parts = ["path=#{preview_tool_param(call[:path])}"]
98
+ range = format_line_range(call)
99
+ parts << "lines=#{range}" if range
100
+ parts.join(" ")
101
+ when Tools::MemoryRead::NAME
102
+ parts = []
103
+ name = call[:content].to_s.strip
104
+ parts << "name=#{preview_tool_param(name)}" unless name.empty?
105
+ scope = call[:scope].to_s.strip
106
+ parts << "scope=#{preview_tool_param(scope)}" unless scope.empty?
107
+ parts.join(" ")
108
+ when Tools::MemoryWrite::NAME
109
+ parts = []
110
+ path = call[:path].to_s.strip
111
+ parts << "name=#{preview_tool_param(path)}" unless path.empty?
112
+ scope = call[:scope].to_s.strip
113
+ parts << "scope=#{preview_tool_param(scope)}" unless scope.empty?
114
+ desc = call[:description].to_s.strip
115
+ parts << "description=#{preview_tool_param(desc)}" unless desc.empty?
116
+ parts.join(" ")
117
+ when Tools::TaskCreate::NAME
118
+ parts = ["command=#{preview_tool_param(call[:content])}"]
119
+ cwd = call[:cwd].to_s.strip
120
+ parts << "cwd=#{preview_tool_param(cwd)}" unless cwd.empty?
121
+ env = call[:env].to_s.strip
122
+ parts << "env=#{preview_tool_param(env)}" unless env.empty?
123
+ parts.join(" ")
124
+ when Tools::TaskGet::NAME, Tools::TaskStop::NAME
125
+ "id=#{preview_tool_param(call[:content])}"
126
+ when Tools::TaskWait::NAME
127
+ parts = ["id=#{preview_tool_param(call[:content])}"]
128
+ timeout = call[:timeout].to_s.strip
129
+ tail_lines = call[:tail_lines].to_s.strip
130
+ done_pattern = call[:done_pattern].to_s.strip
131
+ parts << "timeout=#{preview_tool_param(timeout)}" unless timeout.empty?
132
+ parts << "tail_lines=#{preview_tool_param(tail_lines)}" unless tail_lines.empty?
133
+ parts << "done_pattern=#{preview_tool_param(done_pattern)}" unless done_pattern.empty?
134
+ parts.join(" ")
135
+ when Tools::TaskList::NAME
136
+ nil
137
+ when Tools::WebFetch::NAME
138
+ "url=#{preview_tool_param(call[:content])}"
139
+ when Tools::ListSessions::NAME
140
+ call[:cwd].to_s.strip.empty? ? nil : "cwd=#{preview_tool_param(call[:cwd])}"
141
+ when Tools::SendNote::NAME
142
+ "session=#{preview_tool_param(call[:session])} text=#{preview_tool_param(call[:content])}"
143
+ when Tools::Delegate::NAME
144
+ parts = []
145
+ parts << "session=#{preview_tool_param(call[:session])}" unless call[:session].to_s.strip.empty?
146
+ parts << "model=#{preview_tool_param(call[:model])}" unless call[:model].to_s.strip.empty?
147
+ parts << "task=#{preview_tool_param(call[:content])}"
148
+ parts << "wait=#{preview_tool_param(call[:wait])}" unless call[:wait].nil? || call[:wait].to_s.strip.empty?
149
+ parts.join(" ")
150
+ when Tools::DelegateResult::NAME
151
+ call[:session].to_s.strip.empty? ? nil : "session=#{preview_tool_param(call[:session])}"
152
+ when Tools::AskUserQuestion::NAME
153
+ parts = ["question=#{preview_tool_param(call[:question] || call[:content])}"]
154
+ opts = call[:options]
155
+ parts << "options=#{preview_tool_param(Array(opts).join(","))}" if opts && !Array(opts).empty?
156
+ parts.join(" ")
157
+ else
158
+ registry_params(registry_entry(registry, tool_name), call)
159
+ end
160
+ end
161
+
162
+ def registry_entry(registry, tool_name) = registry && !tool_name.nil? ? registry[tool_name] : nil
163
+
164
+ # A plugin tool's label ("chrome: screenshot"), what the UIs show for
165
+ # its raw name; nil for a built-in, an unknown tool or an empty label.
166
+ def plugin_label(tool_name, registry:)
167
+ entry = registry_entry(registry, tool_name)
168
+ label = entry && !entry.core? ? entry.label.to_s.strip : ""
169
+ label.empty? ? nil : label
170
+ end
171
+
172
+ # A registry tool's params: its preview, else each given argument as
173
+ # key="value" (the parsers' args:, which a registry-less reader such as
174
+ # the web's reload also has); nil for a tool the registry doesn't know
175
+ # that has no args:, and for a built-in without its own words above
176
+ # (the reminder tools).
177
+ def registry_params(entry, call)
178
+ return nil if entry&.core?
179
+ return nil if entry.nil? && !call[:args].is_a?(Hash)
180
+ if entry&.preview
181
+ begin
182
+ return entry.preview.call(call)
183
+ rescue StandardError => e
184
+ Log.warn(:plugins, "plugin_preview_failed", tool: entry.name, error: e.class.name,
185
+ msg: "#{entry.name} preview failed: #{e.message}")
186
+ end
187
+ end
188
+
189
+ given = call[:args].is_a?(Hash) ? call[:args] : call.except(:name)
190
+ parts = given.filter_map do |key, value|
191
+ next if value.nil? || value.to_s.strip.empty?
192
+
193
+ "#{key}=#{preview_tool_param(value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value)}"
194
+ end
195
+ parts.empty? ? nil : parts.join(" ")
196
+ end
197
+
198
+ def format_line_range(call)
199
+ start_line = call[:start_line].to_s.strip
200
+ end_line = call[:end_line].to_s.strip
201
+ return nil if start_line.empty? && end_line.empty?
202
+
203
+ "#{start_line.empty? ? "?" : start_line}-#{end_line.empty? ? "?" : end_line}"
204
+ end
205
+
206
+ def preview_tool_param(value)
207
+ text = value.to_s.gsub(/\s+/, " ").strip
208
+ return '""' if text.empty?
209
+
210
+ if text.length > TOOL_ACTIVITY_PREVIEW_LIMIT
211
+ text = "#{text[0, TOOL_ACTIVITY_PREVIEW_LIMIT - 1]}…"
212
+ end
213
+ text.inspect
214
+ end
215
+ end
216
+ end