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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tools/memory"
4
+
5
+ module Samagotchi
6
+ # Model-specific memory overlays.
7
+ #
8
+ # A memory entry `<name>` may have a companion file `<name>.<modelkey>.md` in
9
+ # the same scope directory. When the entry is read under a matching model, the
10
+ # overlay body is appended automatically.
11
+ #
12
+ # Key invariants:
13
+ # - The base entry is the contract. An overlay only adds model-specific guidance
14
+ # and never contradicts the base protocol.
15
+ # - One key function is shared by both read and write paths.
16
+ # - Overlays are resolved only implicitly (via a read of the base); they are
17
+ # not first-class memories.
18
+ class ModelOverlay
19
+ # Derive the lowercase-dashed key from a bare model name.
20
+ #
21
+ # Transform: downcase → replace every non-alphanumeric run with `-` →
22
+ # squeeze consecutive `-` → trim leading/trailing `-`.
23
+ #
24
+ # Examples:
25
+ # "qwen3.6-35b-a3b" => "qwen3-6-35b-a3b"
26
+ # "gemma4o" => "gemma4o"
27
+ # nil / "" => nil
28
+ #
29
+ # This is THE key function — used by BOTH read and write paths.
30
+ def self.key_for(model_name)
31
+ return nil if model_name.nil? || model_name.to_s.strip.empty?
32
+
33
+ key = model_name.to_s
34
+ .downcase
35
+ .gsub(/[^a-z0-9]+/, "-")
36
+ .gsub(/-+/, "-")
37
+ .sub(/\A-/, "")
38
+ .sub(/-\z/, "")
39
+ key.empty? ? nil : key
40
+ end
41
+
42
+ # Return the expected file path for an overlay in the given scope.
43
+ # Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
44
+ def self.overlay_path_for(name, key, scope)
45
+ return nil unless key && !key.to_s.strip.empty?
46
+ File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
47
+ end
48
+
49
+ # Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
50
+ # Useful for tooling, tests, and future index suppression.
51
+ OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9\-]+)\.md\z/
52
+ end
53
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log"
4
+ module Samagotchi
5
+ # Encapsulates all model-specific token formats and parsing behavior.
6
+ # Each profile maps to a model family; ModelProfile.resolve picks one
7
+ # (config, the server's chat template, the name, then qwen36).
8
+ #
9
+ # Current profiles:
10
+ # qwen36 — Qwen 3.6 chat template with function calling (the default)
11
+ # gemma4 — Gemma 4 format
12
+ class ModelProfile
13
+ MODEL_ENV = "SAMAGOTCHI_DEFAULT_MODEL"
14
+ # No model anywhere (--model, default.model in config.yml, the env): a
15
+ # first run before any config. An ArgumentError, as before.
16
+ MissingModel = Class.new(ArgumentError)
17
+
18
+ attr_reader :name, :turn_start, :turn_end,
19
+ :tool_call_open, :tool_call_close,
20
+ :tool_response_open, :tool_response_close,
21
+ :string_delim,
22
+ :thought_open, :thought_close,
23
+ :system_prefix, :user_prefix, :assistant_prefix,
24
+ :model_prefix,
25
+ :stop_sequences, :tool_decl_format,
26
+ :image_template
27
+
28
+ def initialize(config)
29
+ @name = config[:name]
30
+ @turn_start = config[:turn_start]
31
+ @turn_end = config[:turn_end]
32
+ @tool_call_open = config[:tool_call_open]
33
+ @tool_call_close = config[:tool_call_close]
34
+ @tool_response_open = config[:tool_response_open]
35
+ @tool_response_close = config[:tool_response_close]
36
+ @string_delim = config[:string_delim]
37
+ @thought_open = config[:thought_open]
38
+ @thought_close = config[:thought_close]
39
+ @system_prefix = config[:system_prefix]
40
+ @user_prefix = config[:user_prefix]
41
+ @assistant_prefix = config[:assistant_prefix]
42
+ @model_prefix = config[:model_prefix]
43
+ @stop_sequences = config[:stop_sequences]
44
+ @tool_decl_format = config[:tool_decl_format]
45
+ # How the chat template wraps an image's media marker ("%{marker}"),
46
+ # or nil when chi doesn't know it yet (native images are refused).
47
+ @image_template = config[:image_template]
48
+ end
49
+
50
+ # The token the image template opens with, e.g. "<|vision_start|>": the
51
+ # server's chat template must use it too (VisionSupport).
52
+ def image_open_token
53
+ image_template&.split("%{marker}")&.first.to_s
54
+ end
55
+
56
+ def self.gemma4
57
+ new(
58
+ name: "gemma4",
59
+ turn_start: "<|turn>",
60
+ turn_end: "<end_of_turn>",
61
+ tool_call_open: "<|tool_call>",
62
+ tool_call_close: "<tool_call|>",
63
+ tool_response_open: "<|tool_response>",
64
+ tool_response_close: "<tool_response|>",
65
+ string_delim: '<|"|>',
66
+ thought_open: "<|think|>",
67
+ thought_close: nil,
68
+ system_prefix: "",
69
+ user_prefix: "",
70
+ assistant_prefix: "",
71
+ model_prefix: "",
72
+ stop_sequences: ["<end_of_turn>", "<|tool_response>"],
73
+ tool_decl_format: :gemma4
74
+ )
75
+ end
76
+
77
+ def self.qwen36
78
+ new(
79
+ name: "qwen36",
80
+ turn_start: "",
81
+ turn_end: "",
82
+ tool_call_open: "<tool_call>",
83
+ tool_call_close: "</tool_call>",
84
+ tool_response_open: "<tool_response>",
85
+ tool_response_close: "</tool_response>",
86
+ string_delim: nil,
87
+ thought_open: "<think>",
88
+ thought_close: "</think>",
89
+ system_prefix: "<|im_start|>system\n",
90
+ user_prefix: "<|im_start|>user\n",
91
+ assistant_prefix: "<|im_start|>assistant\n",
92
+ model_prefix: "<|im_start|>assistant\n",
93
+ stop_sequences: ["<|im_end|>"],
94
+ tool_decl_format: :qwen36,
95
+ image_template: "<|vision_start|>%{marker}<|vision_end|>"
96
+ )
97
+ end
98
+
99
+ def self.default
100
+ gemma4
101
+ end
102
+
103
+ def self.normalize(value)
104
+ return value if value.is_a?(self)
105
+
106
+ case value.to_s.strip.downcase
107
+ when "qwen", "qwen3", "qwen36", "qwen3.6"
108
+ qwen36
109
+ when "gemma", "gemma4", "gemma4o"
110
+ gemma4
111
+ else
112
+ gemma4
113
+ end
114
+ end
115
+
116
+ def self.required_model_name(model_name = nil, env: ENV)
117
+ value = model_name.to_s.strip
118
+ if value.empty?
119
+ begin
120
+ require_relative "config"
121
+ cfg = Samagotchi::Config.get("default.model") rescue nil
122
+ value = cfg.to_s.strip unless cfg.nil? || cfg.to_s.strip.empty?
123
+ rescue StandardError
124
+ nil
125
+ end
126
+ end
127
+ value = env[MODEL_ENV].to_s.strip if value.empty?
128
+ raise MissingModel, missing_model_message if value.empty?
129
+
130
+ value
131
+ end
132
+
133
+ # One line for the user: where to set the model.
134
+ def self.missing_model_message
135
+ path = begin
136
+ require_relative "config"
137
+ Samagotchi::ConfigFile.global_path
138
+ rescue StandardError, LoadError
139
+ "~/.config/samagotchi/config.yml"
140
+ end
141
+ "no model configured: set default.model in #{path} to the model id your server serves " \
142
+ "(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md"
143
+ end
144
+
145
+ def self.from_model_name(model_name)
146
+ named(inferred_profile_name(model_name))
147
+ end
148
+
149
+ # qwen → qwen36, gemma → gemma4, anything else → DEFAULT_NAME: many
150
+ # models with other names are Qwen-based, and under the gemma4 profile a
151
+ # ChatML model never hits a stop sequence.
152
+ def self.inferred_profile_name(model_name)
153
+ normalized = model_name.to_s.strip.downcase
154
+ return "qwen36" if normalized.include?("qwen")
155
+ return "gemma4" if normalized.include?("gemma")
156
+
157
+ DEFAULT_NAME
158
+ end
159
+
160
+ def self.from_env
161
+ from_model_name(required_model_name(nil, env: ENV))
162
+ end
163
+
164
+ # ── Resolution: which profile a model gets ───────────────────────────
165
+
166
+ NAMES = %w[qwen36 gemma4].freeze
167
+
168
+ # The last layer, when nothing else says.
169
+ DEFAULT_NAME = "qwen36"
170
+
171
+ # What the server's chat template must contain for each profile. The
172
+ # template tells the families apart where eos_token doesn't (Gemma 4's is
173
+ # <eos> or <turn|>, depending on who packed it). llama.cpp itself detects
174
+ # Gemma 4 by '<|tool_call>call:'.
175
+ FINGERPRINTS = {
176
+ "qwen36" => ["<|im_start|>", "<function="],
177
+ "gemma4" => ["<|turn>", "<|tool_call>"]
178
+ }.freeze
179
+
180
+ # A resolved profile and where it came from. source: :cli, :env,
181
+ # :config, :server, :name or :default; detail: the config key, the
182
+ # template evidence or the matched name. retry: the server probe failed
183
+ # (unreachable, or loading and answering 503), so the caller should
184
+ # resolve again before its next turn.
185
+ Resolution = Data.define(:profile, :source, :detail, :retry) do
186
+ def retry? = self.retry
187
+
188
+ # How /stats, /model and chi self name the source.
189
+ def label
190
+ case source
191
+ when :config then "config (#{detail})"
192
+ when :server then "server (chat_template)"
193
+ else source.to_s
194
+ end
195
+ end
196
+ end
197
+
198
+ # The profile called +value+, or nil (unlike .normalize, which always
199
+ # gives one).
200
+ def self.named(value)
201
+ name = value.to_s.strip.downcase
202
+ NAMES.include?(name) ? public_send(name) : nil
203
+ end
204
+
205
+ # [profile name, evidence] from a llama.cpp /props body, or nil when its
206
+ # chat template says neither family. A ChatML template without Qwen's
207
+ # <function= calls (older Qwen3, Hermes, ...) still gets qwen36: its
208
+ # <|im_end|> stops generation, where gemma4's stop sequences never occur.
209
+ def self.fingerprint(props)
210
+ template = props.is_a?(Hash) ? props["chat_template"].to_s : ""
211
+ FINGERPRINTS.each do |name, markers|
212
+ return [name, markers.join(" + ")] if markers.all? { |marker| template.include?(marker) }
213
+ end
214
+ return ["qwen36", "ChatML"] if template.include?("<|im_start|>")
215
+
216
+ nil
217
+ end
218
+
219
+ # Which profile a model gets, first match wins:
220
+ # 1. override: --profile / SAMAGOTCHI_MODEL_PROFILE ([value, origin];
221
+ # a nil value is not set, whatever its origin)
222
+ # 2. models: in the config file, under any of +names+
223
+ # 3. hosts.<name>.profile of +entry+
224
+ # 4. the server's chat template (native llama.cpp hosts only)
225
+ # 5. the name: qwen → qwen36, gemma → gemma4
226
+ # 6. DEFAULT_NAME
227
+ # An unknown configured value warns and is skipped.
228
+ #
229
+ # @param names [Array<String>] the model as typed, alias-resolved, bare
230
+ # @param entry [HostRegistry::HostEntry, nil] the model's host
231
+ # @param client [#server_props, nil] the host's client (nil: no probe)
232
+ # @param bare_model [String, nil] the name the probe asks the server about
233
+ # @return [Resolution]
234
+ def self.resolve(names:, entry:, client:, bare_model:, override: nil, models: nil)
235
+ require_relative "config"
236
+ override ||= Samagotchi::Config.get_with_origin("model.profile")
237
+ models ||= Samagotchi::ConfigFile.model_settings
238
+ names = Array(names).map { |n| n.to_s.strip }.reject(&:empty?).uniq
239
+
240
+ value, origin = override
241
+ if (profile = named(value))
242
+ return Resolution.new(profile: profile, source: origin == :env ? :env : :cli, detail: nil, retry: false)
243
+ end
244
+
245
+ names.map(&:downcase).uniq.each do |key|
246
+ setting = models[key]
247
+ next unless setting && setting[:profile]
248
+
249
+ profile = configured(setting[:profile], "models: #{key}")
250
+ return Resolution.new(profile: profile, source: :config, detail: "models: #{key}", retry: false) if profile
251
+ end
252
+
253
+ if entry&.profile && (profile = configured(entry.profile, "hosts.#{entry.name}"))
254
+ return Resolution.new(profile: profile, source: :config, detail: "hosts.#{entry.name}", retry: false)
255
+ end
256
+
257
+ retry_later = false
258
+ if probe?(entry, client)
259
+ props = client.server_props(model: bare_model)
260
+ if props && !props.answered?
261
+ retry_later = true
262
+ elsif props && (found = fingerprint(props.body))
263
+ return Resolution.new(profile: named(found[0]), source: :server, detail: found[1], retry: false)
264
+ end
265
+ end
266
+
267
+ names.each do |name|
268
+ lowered = name.downcase
269
+ family = if lowered.include?("qwen") then "qwen36"
270
+ elsif lowered.include?("gemma") then "gemma4"
271
+ end
272
+ return Resolution.new(profile: named(family), source: :name, detail: name, retry: retry_later) if family
273
+ end
274
+
275
+ Resolution.new(profile: named(DEFAULT_NAME), source: :default, detail: nil, retry: retry_later)
276
+ end
277
+
278
+ def self.configured(value, where)
279
+ profile = named(value)
280
+ Log.warn(:config, "unknown_profile", echo: "Warning: unknown profile #{value.to_s.inspect} in #{where} (allowed: #{NAMES.join(', ')}) — ignored", profile: value.to_s, where: where) unless profile
281
+ profile
282
+ end
283
+ private_class_method :configured
284
+
285
+ # Only a native llama.cpp host has /props with a chat template; a chat
286
+ # host (api: openai) barely uses a profile, and mlx/oMLX expose none.
287
+ def self.probe?(entry, client)
288
+ return false if client.nil? || !client.respond_to?(:server_props)
289
+ return false if entry&.chat?
290
+
291
+ !client.respond_to?(:transport) || client.transport.name == :llama_cpp
292
+ end
293
+ private_class_method :probe?
294
+
295
+ # ── Thought channel support ──────────────────────────────────────────
296
+
297
+ def thought_channel_open
298
+ nil
299
+ end
300
+
301
+ def uses_channel_thoughts?
302
+ false
303
+ end
304
+
305
+ def uses_role_prefixes?
306
+ !system_prefix.empty? && !user_prefix.empty?
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tools/memory"
4
+
5
+ module Samagotchi
6
+ # Memories hidden from one session (`--mute NAME`): name normalization and
7
+ # the index-text filter, shared by the Engine (the prompt's index, the
8
+ # identity auto-load, the preload list) and the KernelLoop (a memory_read
9
+ # guard). Nothing on disk changes: a muted memory keeps its file and its
10
+ # index line.
11
+ module MutedMemories
12
+ # A managed index line (IndexUpdater.managed_line), plus the legacy
13
+ # `- **name**: desc` and `- **name.md** ·` shapes still found in indexes.
14
+ INDEX_LINE = /\A- \*\*(.+?)\*\*[ \t]*(?:[·•].*|:.*)?\z/
15
+
16
+ # `project/gh-helper`, `gh-helper.md`, ` gh-helper ` → `gh-helper`.
17
+ # nil for a blank name.
18
+ def self.normalize(raw)
19
+ value = raw.to_s.strip
20
+ return nil if value.empty?
21
+
22
+ if value.include?("/")
23
+ scope, rest = value.split("/", 2)
24
+ value = rest if Tools::VALID_SCOPES.include?(scope)
25
+ end
26
+ base = File.basename(value, ".md").strip
27
+ base.empty? ? nil : base
28
+ end
29
+
30
+ # Names as given (comma lists allowed) → normalized, deduped.
31
+ def self.normalize_list(names)
32
+ Array(names).flat_map { |raw| raw.to_s.split(",") }.map { |n| normalize(n) }.compact.uniq
33
+ end
34
+
35
+ def self.muted?(name, muted)
36
+ return false if muted.nil? || muted.empty?
37
+
38
+ key = normalize(name)
39
+ !key.nil? && muted.include?(key)
40
+ end
41
+
42
+ # The index text without the lines that name a muted memory: a managed
43
+ # line (`- **name** · …`) or a bare name (the "no index yet" fallback).
44
+ # Every other byte stays.
45
+ def self.filter_index(text, muted)
46
+ return text if muted.nil? || muted.empty? || text.nil?
47
+
48
+ text.each_line.reject { |line| muted_index_line?(line, muted) }.join
49
+ end
50
+
51
+ def self.muted_index_line?(line, muted)
52
+ muted?(index_line_name(line), muted)
53
+ end
54
+
55
+ # The memory an index line names, normalized: a managed line's
56
+ # `**name**`, else the bare line (the "no index yet" list). nil for a
57
+ # line that names none (a heading, prose with spaces).
58
+ def self.index_line_name(line)
59
+ stripped = line.chomp
60
+ name = stripped.match(INDEX_LINE)&.[](1) || stripped
61
+ return nil if name.include?(" ")
62
+
63
+ normalize(name)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_manager"
5
+
6
+ module Samagotchi
7
+ # `chi note`: push text into sessions as a context note (background the
8
+ # model sees on its next turn), never as a prompt; nothing wakes. For a
9
+ # script, e.g. an Automator action:
10
+ # pbpaste | chi note --source slack $(chi sessions list --live --scope=all --format tsv | cut -f1)
11
+ class NoteCommand
12
+ USAGE = <<~TEXT
13
+ Usage: chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all
14
+ Adds TEXT (or stdin) to each session as a context note: background
15
+ the model sees on its next turn, not a prompt. It starts no turn.
16
+ --source NAME where it came from, shown to the model (default: cli)
17
+ -m TEXT the note; without it, stdin is read
18
+ --all every session a worker runs now, in every project
19
+ Find ids with: chi sessions list --live [--scope=all] [--format tsv]
20
+ TEXT
21
+
22
+ # @param argv [Array<String>] the arguments after "note"
23
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
24
+ @argv = argv.dup
25
+ @stdin = stdin
26
+ @stdout = stdout
27
+ @stderr = stderr
28
+ @state_dir = state_dir || Session.default_state_dir
29
+ end
30
+
31
+ # @return [Integer] exit status: 0 all queued, 1 any refused or failed,
32
+ # 2 usage
33
+ def run
34
+ options = parse or return 2
35
+ return 0 if options[:help]
36
+
37
+ text = utf8(options[:text] || read_stdin)
38
+ unless text
39
+ usage_error("no note text: pass -m TEXT or pipe it in")
40
+ return 2
41
+ end
42
+ begin
43
+ text = SessionManager.checked_text(text)
44
+ rescue SessionManager::NoteRejected => e
45
+ @stderr.puts("chi note: #{e.message}")
46
+ return 1
47
+ end
48
+
49
+ return deliver_to_live(text, options[:source]) if options[:all]
50
+
51
+ # One id at a time, so each line follows the order of the ids given.
52
+ seen = {}
53
+ results = options[:ids].uniq.map do |given|
54
+ id = resolve(given)
55
+ next false unless id
56
+ next true if seen[id]
57
+
58
+ seen[id] = true
59
+ deliver(id, text, options[:source])
60
+ end
61
+ results.all? ? 0 : 1
62
+ end
63
+
64
+ private
65
+
66
+ def parse
67
+ options = { source: "cli", ids: [] }
68
+ until @argv.empty?
69
+ arg = @argv.shift
70
+ case arg
71
+ when "-h", "--help", "help"
72
+ @stdout.puts(USAGE)
73
+ return { help: true }
74
+ when "--all" then options[:all] = true
75
+ when "--source", "-m", "--message"
76
+ value = @argv.shift or return usage_error("#{arg} needs a value")
77
+ options[arg == "--source" ? :source : :text] = value
78
+ when /\A--source=(.*)\z/m then options[:source] = Regexp.last_match(1)
79
+ when /\A--message=(.*)\z/m then options[:text] = Regexp.last_match(1)
80
+ when /\A-/ then return usage_error("unknown option #{arg}")
81
+ else options[:ids] << arg
82
+ end
83
+ end
84
+ return usage_error("give session ids or --all") if options[:ids].empty? && !options[:all]
85
+ return usage_error("--all takes no ids") if options[:all] && options[:ids].any?
86
+
87
+ options
88
+ end
89
+
90
+ # Only a pipe or a file is read, as chi send does. A terminal means
91
+ # nobody piped a note in, and a socket a launcher or an agent's shell
92
+ # passes down may never close: waiting on either would hang a script.
93
+ def read_stdin
94
+ return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
95
+ if @stdin.respond_to?(:stat)
96
+ stat = @stdin.stat
97
+ return nil unless stat.pipe? || stat.file?
98
+ end
99
+
100
+ @stdin.read
101
+ end
102
+
103
+ # --all: every live session. @return [Integer] exit status
104
+ def deliver_to_live(text, source)
105
+ ids = SessionManager.session_summaries(live: true, include_tests: false, state_dir: @state_dir).map { |s| s[:id] }
106
+ if ids.empty?
107
+ error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
108
+ return 1
109
+ end
110
+
111
+ ids.map { |id| deliver(id, text, source) }.all? ? 0 : 1
112
+ end
113
+
114
+ def resolve(given)
115
+ id = Session.resolve_id(given, state_dir: @state_dir)
116
+ Session.load(id, state_dir: @state_dir)
117
+ id
118
+ rescue ArgumentError => e
119
+ message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
120
+ error_line("chi note: #{message}")
121
+ nil
122
+ end
123
+
124
+ # @return [Boolean] whether the note was queued
125
+ def deliver(id, text, source)
126
+ short = id[0, 8]
127
+ owner = SessionManager.session_owner(id, state_dir: @state_dir)
128
+ if owner && owner["kind"] == "tui"
129
+ @stdout.puts("#{short} refused: it is open in a chi REPL; notes need attached mode")
130
+ return false
131
+ end
132
+
133
+ path = SessionManager.write_note(id, text: text, source: source, state_dir: @state_dir)
134
+ if owner
135
+ @stdout.puts("#{short} queued: its worker adds it within a few seconds")
136
+ else
137
+ queued = SessionManager.find_new_note_files(File.dirname(File.dirname(path))).size
138
+ @stdout.puts("#{short} waits for the session's next start (#{queued} #{queued == 1 ? "note" : "notes"} queued)")
139
+ end
140
+ true
141
+ end
142
+
143
+ # The note as UTF-8 whatever the locale says: with no LANG/LC_* (an app
144
+ # started from Finder, launchd) stdin reads as US-ASCII and ARGV as
145
+ # binary. Invalid bytes become U+FFFD rather than an error.
146
+ def utf8(text)
147
+ text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
148
+ end
149
+
150
+ # stderr isn't buffered, stdout is when it's a pipe: flush the lines
151
+ # already printed, so the output keeps the order of the ids given.
152
+ def error_line(text)
153
+ @stdout.flush
154
+ @stderr.puts(text)
155
+ end
156
+
157
+ def usage_error(message)
158
+ error_line("chi note: #{message}")
159
+ @stderr.puts(USAGE)
160
+ nil
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+ module Samagotchi
3
+ # OutputFormatter strips model wire-format tokens from raw engine output at
4
+ # render time, so downstream renderers (web UI, terminal UI) never show the
5
+ # model's internal control/literal vocabulary.
6
+ #
7
+ # Two token families survive KernelLoop#run's strip_thought_blocks():
8
+ # * Gemma control tokens: <|turn>, <|tool_call>...<tool_call|>,
9
+ # <|tool_response>...<tool_response|>, <|think|>, <|channel>thought...,
10
+ # <channel|>, <|tool>declaration...<tool|> blocks, <|"|> delimiter.
11
+ # * Qwen prompt-literal blocks: [[SAMAGOTCHI_LITERAL_*]]
12
+ # (<tool_call>, </think>,
13
+ # …) which strip_thought_blocks does NOT remove.
14
+ #
15
+ # IMPORTANT: control-token matching is ENUMERATED by name, never a broad
16
+ # <[a-z_]{1,40}> wildcard. A wildcard eats arbitrary <word> content in
17
+ # tool/file output (e.g. a tool result containing "<div>" or "<resp>" would be
18
+ # silently mangled). If a new control token is added elsewhere, add its name to
19
+ # CONTROL_NAMES (and any new bare-literal form) here before it can appear in
20
+ # rendered output.
21
+ module OutputFormatter
22
+ # The complete vocabulary of known control-token names. Matched inside their
23
+ # wrapper forms; bare <name> content (no pipe) is deliberately preserved.
24
+ CONTROL_NAMES = %w[
25
+ turn
26
+ end_of_turn
27
+ tool_call
28
+ tool_response
29
+ tool
30
+ think
31
+ channel
32
+ ].freeze
33
+
34
+ # Tokens are stripped individually by name (INDIVIDUAL_RE), which preserves the
35
+ # tool-call / declaration *body* content while removing only the markers. The
36
+ # body (e.g. `call:read{path:'x'}`) is real, readable output and must remain.
37
+
38
+ # Individual control tokens matched inside their wrapper forms. Each
39
+ # alternative requires at least one pipe (leading or trailing) so a bare
40
+ # <word> in the surrounding content is NOT stripped:
41
+ # <|name> / <name|> / <|name|>
42
+ INDIVIDUAL_RE = Regexp.new(
43
+ '<\\|(?:' + CONTROL_NAMES.join('|') + ')\\|?>' \
44
+ '|<(?:' + CONTROL_NAMES.join('|') + ')\\|>'
45
+ )
46
+ INDIVIDUAL_RE.freeze
47
+
48
+ # Angle-bracketed literal tokens stripped wholesale (including the thought
49
+ # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
50
+ # are removed as a unit.
51
+ # Angle-bracketed literal tokens stripped wholesale (including the thought
52
+ # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
53
+ # are removed as a unit. Pipes are escaped; angle brackets are literal.
54
+ LITERALS_RE = /<\|"\|>|<\|\w*channel>thought|<end_of_turn>/.freeze
55
+
56
+ # Qwen prompt-literal placeholders, e.g. </think>.
57
+ PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
58
+
59
+ # Qwen3.6 thinking blocks: <think>...</think> (including empty)
60
+ QWEN_THINK_RE = /<think>.*?<\/think>/m.freeze
61
+ # Qwen tool_call XML blocks that survive when a tool call is rendered as text
62
+ # e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
63
+ QWEN_TOOL_CALL_RE = /<tool_call>.*?<\/tool_call>/m.freeze
64
+ module_function
65
+
66
+ # Remove both control-token and prompt-literal token families from +text+.
67
+ # Known literals are stripped first (as whole units), then remaining tokens are
68
+ # stripped by name inside their wrappers (bodies preserved). Whitespace runs
69
+ # are collapsed and leading/trailing whitespace trimmed so removing a mid-sentence
70
+ # tag leaves no stray double-space or blank line. Internal newlines are preserved
71
+ # so a multi-line response stays readable. Blank / token-only input returns "".
72
+ def strip(text)
73
+ cleaned =
74
+ remove_tokens(text)
75
+ .gsub(/[ \t]+/, " ")
76
+ .gsub(/ *\n */, "\n")
77
+ .strip
78
+ cleaned.empty? ? '' : cleaned
79
+ end
80
+
81
+ # The same tokens as #strip, but the text keeps its layout (indentation,
82
+ # runs of spaces); only the blank lines removed blocks leave collapse.
83
+ # For showing a whole saved message (the attached TUI's join, the web history).
84
+ def strip_markup(text)
85
+ remove_tokens(text).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
86
+ end
87
+
88
+ def remove_tokens(text)
89
+ text.to_s
90
+ .gsub(QWEN_THINK_RE, '')
91
+ .gsub(QWEN_TOOL_CALL_RE, '')
92
+ .gsub(LITERALS_RE, '')
93
+ .gsub(INDIVIDUAL_RE, '')
94
+ .gsub(PROMPT_LITERALS, '')
95
+ # orphaned closing tags that may remain after block removal
96
+ .gsub(/<\/?think>/, '')
97
+ .gsub(/<\/?tool_call>/, '')
98
+ end
99
+ end
100
+ end