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,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "memory_paths"
4
+
5
+ module Samagotchi
6
+ # Which project a folder belongs to, for scoping session lists (web and
7
+ # terminal alike): the project root memories use (MemoryPaths.project_root),
8
+ # so every worktree and subfolder of one repository is one project. A
9
+ # folder in no git repository has no project (nil): its scope is "all".
10
+ module ProjectScope
11
+ module_function
12
+
13
+ # @param cache [Hash, nil] folder → root, reused across one listing
14
+ # @return [String, nil]
15
+ def root_for(dir, cache: nil)
16
+ dir = dir.to_s
17
+ return nil if dir.empty?
18
+ return cache[dir] if cache&.key?(dir)
19
+
20
+ root = MemoryPaths.in_repo?(dir) ? MemoryPaths.project_root(File.expand_path(dir)) : nil
21
+ cache[dir] = root if cache
22
+ root
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_profile"
4
+ require_relative "prompt_literal_guard"
5
+ require_relative "vision_context"
6
+
7
+ module Samagotchi
8
+ # Formats a message list into a prompt string ready for the /completion endpoint.
9
+ #
10
+ # Supports multiple model profiles (Gemma 4, Qwen 3.6, etc.) with different
11
+ # token formats, role prefixing strategies, and tool response handling.
12
+ #
13
+ # Gemma 4:
14
+ # - Turn markers: <|turn>ROLE\n...<end_of_turn>\n
15
+ # - Tool response: standalone <|tool_response>...<tool_response|>
16
+ # - Generation cue: <|turn>model\n
17
+ #
18
+ # Qwen 3.6:
19
+ # - Role framing: <|im_start|>ROLE\n...<|im_end|>
20
+ # - Tool response: wrapped in user message with <tool_response>...</tool_response>
21
+ # - Generation cue: <|im_start|>assistant\n
22
+ module Prompt
23
+ # Tail system messages whose text isn't the harness's own.
24
+ ESCAPED_KINDS = %w[note turn_note context].freeze
25
+
26
+ # @param messages [Array<Hash>] each element has :role and :content.
27
+ # Recognised roles: "system", "user", "model", "tool_response".
28
+ # @param profile [ModelProfile] model token configuration
29
+ # @return [String] formatted prompt ready for the /completion endpoint.
30
+ def self.format(messages, profile: ModelProfile.default)
31
+ format_with_images(messages, profile: profile).first
32
+ end
33
+
34
+ # The prompt plus the base64 images it carries, in marker order. Each
35
+ # image a message sends is a line after its text: "[image N: name] "
36
+ # and the profile's image template around ImagePlan::NATIVE_PLACEHOLDER
37
+ # (Client#complete swaps in the server's media marker); an image left
38
+ # out is its placeholder line. With no images the prompt is exactly
39
+ # what it was before images existed.
40
+ #
41
+ # @param vision [VisionContext, nil] the turn's images context
42
+ # @return [Array(String, Array<String>)]
43
+ def self.format_with_images(messages, profile: ModelProfile.default, vision: nil)
44
+ plan = ImagePlan.new(messages, vision, data: :base64)
45
+ images = []
46
+ suffixes = messages.each_with_index.map { |m, index| image_lines(plan.items(m, index), profile, images) }
47
+ text = if profile.uses_role_prefixes?
48
+ format_with_prefixes(messages, profile, suffixes)
49
+ else
50
+ format_with_turn_markers(messages, profile, suffixes)
51
+ end
52
+ [text, images]
53
+ end
54
+
55
+ private
56
+
57
+ def self.image_lines(items, profile, images)
58
+ return "" if items.empty?
59
+
60
+ lines = items.each_with_index.map do |item, index|
61
+ next item.placeholder unless item.sent?
62
+ next ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE) unless profile.image_template
63
+
64
+ images << item.data
65
+ "[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
66
+ "#{Kernel.format(profile.image_template, marker: ImagePlan::NATIVE_PLACEHOLDER)}"
67
+ end
68
+ "\n#{lines.join("\n")}"
69
+ end
70
+
71
+ def self.format_with_turn_markers(messages, profile, suffixes)
72
+ # Gemma 4 style: <|turn>ROLE\n...<end_of_turn>\n
73
+ parts = messages.each_with_index.map do |m, index|
74
+ content = prompt_content_for(m, profile) + suffixes[index]
75
+ if m[:role] == "tool_response"
76
+ "#{profile.tool_response_open}\n#{content}#{profile.tool_response_close}\n"
77
+ else
78
+ "#{profile.turn_start}#{m[:role]}\n#{content}#{profile.turn_end}\n"
79
+ end
80
+ end
81
+ parts << "#{profile.turn_start}model\n"
82
+ parts.join
83
+ end
84
+
85
+ def self.format_with_prefixes(messages, profile, suffixes)
86
+ # Qwen 3.6 style: <|im_start|>ROLE\n...<|im_end|>
87
+ parts = []
88
+
89
+ messages.each_with_index do |m, index|
90
+ content = prompt_content_for(m, profile) + suffixes[index]
91
+ case m[:role]
92
+ when "system"
93
+ parts << "#{profile.system_prefix}#{content}<|im_end|>\n"
94
+ when "user"
95
+ parts << "#{profile.user_prefix}#{content}<|im_end|>\n"
96
+ when "model"
97
+ parts << "#{profile.model_prefix}#{content}<|im_end|>\n"
98
+ when "tool_response"
99
+ # Wrap tool response in user message for Qwen
100
+ parts << "#{profile.user_prefix}#{profile.tool_response_open}\n#{content}#{profile.tool_response_close}\n<|im_end|>\n"
101
+ end
102
+ end
103
+
104
+ # Cue generation as assistant
105
+ parts << profile.assistant_prefix
106
+ parts.join
107
+ end
108
+
109
+ def self.prompt_content_for(message, profile)
110
+ return message[:content].to_s if message[:preserve_literals]
111
+
112
+ # A context note is system-framed but its text is untrusted (Slack, a
113
+ # peer session), and a turn note carries an error's text: escaped like
114
+ # user content so it can't close its turn.
115
+ role = ESCAPED_KINDS.include?(message[:kind].to_s) ? "user" : message[:role]
116
+ PromptLiteralGuard.escape(message[:content], profile: profile, role: role)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Escapes literal control-token text when arbitrary source/output is embedded
5
+ # inside prompts, then restores those placeholders when model text is used.
6
+ module PromptLiteralGuard
7
+ GEMMA_TOKEN_PAIRS = [
8
+ ["[[SAMAGOTCHI_LITERAL_TURN_START]]", "<|turn>"],
9
+ ["[[SAMAGOTCHI_LITERAL_TURN_END]]", "<end_of_turn>"],
10
+ ["[[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN]]", "<|tool_call>"],
11
+ ["[[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE]]", "<tool_call|>"],
12
+ ["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_OPEN]]", "<|tool_response>"],
13
+ ["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "<tool_response|>"],
14
+ ["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
15
+ ["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
16
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", "<|channel>thought"],
17
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", "<channel|>"]
18
+ ].freeze
19
+
20
+ QWEN_TOKEN_PAIRS = [
21
+ ["[[SAMAGOTCHI_LITERAL_IM_START]]", "<|im_start|>"],
22
+ ["[[SAMAGOTCHI_LITERAL_IM_END]]", "<|im_end|>"],
23
+ ["[[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN]]", "<tool_call>"],
24
+ ["[[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE]]", "</tool_call>"],
25
+ ["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_OPEN]]", "<tool_response>"],
26
+ ["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "</tool_response>"],
27
+ ["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<think>"],
28
+ ["[[SAMAGOTCHI_LITERAL_THINK_CLOSE]]", "</think>"],
29
+ ["[[SAMAGOTCHI_LITERAL_VISION_START]]", "<|vision_start|>"],
30
+ ["[[SAMAGOTCHI_LITERAL_VISION_END]]", "<|vision_end|>"]
31
+ ].freeze
32
+
33
+ def self.escape(text, profile:, role: nil)
34
+ value = text.to_s
35
+ return value if value.empty? || !escapable_role?(role)
36
+
37
+ token_pairs_for(profile).reduce(value) do |memo, (placeholder, token)|
38
+ memo.gsub(token, placeholder)
39
+ end
40
+ end
41
+
42
+ def self.restore(text, profile:)
43
+ value = text.to_s
44
+ return value if value.empty?
45
+
46
+ token_pairs_for(profile).reduce(value) do |memo, (placeholder, token)|
47
+ memo.gsub(placeholder, token)
48
+ end
49
+ end
50
+
51
+ def self.restore_call(call, profile:)
52
+ call.each_with_object({}) do |(key, value), restored|
53
+ restored[key] = value.is_a?(String) ? restore(value, profile: profile) : value
54
+ end
55
+ end
56
+
57
+ def self.token_pairs_for(profile)
58
+ case profile.name
59
+ when "qwen36"
60
+ QWEN_TOKEN_PAIRS
61
+ else
62
+ GEMMA_TOKEN_PAIRS
63
+ end
64
+ end
65
+
66
+ def self.escapable_role?(role)
67
+ %w[user tool_response].include?(role.to_s)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ require_relative "session"
7
+
8
+ module Samagotchi
9
+ # The idle recap saved with its session, in <session dir>/recap.json:
10
+ # {text, covered, covered_digest, model, created_at}
11
+ # A separate file, so it never races the session-file save at the end of a
12
+ # turn. Follows the Engine's current session (the REPL can switch).
13
+ class RecapStore
14
+ FILE = "recap.json"
15
+ # A session card's or the picker's recap line.
16
+ PREVIEW_CHARS = 140
17
+ # "The user was testing…" / "The user and the assistant were exploring…":
18
+ # the recap prompt opens with the user's task this way, which spent ~30
19
+ # of a card's visible characters on the same words every time. Only a
20
+ # subject plus an -ing verb ("was testing") or a regular past tense
21
+ # ("explored"); not one that names a wish or a request ("asked the
22
+ # assistant to", "wanted"), which reads wrong without its subject.
23
+ # Irregular past tenses ("ran", "made") keep their subject.
24
+ NOT_STRIPPED = %w[asked wanted requested needed hoped preferred expected seemed].freeze
25
+ SUBJECT_RE = /\AThe user(?: and (?:the )?assistant)? (?:(?:was|were|is|are) (?=[a-z]+ing\b)|(?=[a-z]+ed\b)(?!(?:#{NOT_STRIPPED.join("|")})\b))/
26
+
27
+ # @param session_id_lookup [#call] the current session's id, or nil
28
+ # @param state_dir_lookup [#call] the state dir holding the sessions
29
+ def initialize(session_id_lookup:, state_dir_lookup:)
30
+ @session_id_lookup = session_id_lookup
31
+ @state_dir_lookup = state_dir_lookup
32
+ end
33
+
34
+ # @return [String, nil] the session the store reads and writes now
35
+ def key
36
+ @session_id_lookup.call
37
+ end
38
+
39
+ # @return [Hash, nil] the saved recap (symbol keys), nil when none
40
+ def load
41
+ id = key
42
+ id && self.class.read(Session.session_dir(id, state_dir: @state_dir_lookup.call))
43
+ end
44
+
45
+ # Write +state+ atomically. Skipped when the session file is gone (deleted,
46
+ # or discarded as empty), so this never recreates a removed session's dir.
47
+ def save(state)
48
+ id = key
49
+ return unless id
50
+
51
+ state_dir = @state_dir_lookup.call
52
+ return unless File.exist?(File.join(state_dir, "#{id}.json"))
53
+
54
+ dir = Session.session_dir(id, state_dir: state_dir)
55
+ FileUtils.mkdir_p(dir)
56
+ path = File.join(dir, FILE)
57
+ File.write("#{path}.tmp", JSON.generate(state))
58
+ File.rename("#{path}.tmp", path)
59
+ end
60
+
61
+ # Remove the saved recap (it no longer describes the session).
62
+ def delete
63
+ id = key
64
+ return unless id
65
+
66
+ FileUtils.rm_f(File.join(Session.session_dir(id, state_dir: @state_dir_lookup.call), FILE))
67
+ end
68
+
69
+ # @return [Hash, nil] the recap saved in +session_dir+, nil when there is
70
+ # none or it can't be read
71
+ def self.read(session_dir)
72
+ data = JSON.parse(File.read(File.join(session_dir, FILE)), symbolize_names: true)
73
+ return nil unless data.is_a?(Hash) && !data[:text].to_s.strip.empty?
74
+
75
+ data
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
80
+ # @return [String, nil] the first sentence of the recap saved in
81
+ # +session_dir+, on one line, without the subject phrase (SUBJECT_RE)
82
+ # and cut to PREVIEW_CHARS. The full recap keeps its sentence.
83
+ def self.preview(session_dir)
84
+ text = read(session_dir)&.dig(:text).to_s.gsub(/\s+/, " ").strip
85
+ return nil if text.empty?
86
+
87
+ first = text[/\A.*?[.!?](?=\s|\z)/] || text
88
+ first = first.sub(SUBJECT_RE, "").sub(/\A[a-z]/, &:upcase)
89
+ first.length > PREVIEW_CHARS ? "#{first[0, PREVIEW_CHARS - 1]}…" : first
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "securerandom"
5
+
6
+ module Samagotchi
7
+ # Thread-safe store for periodic reminders registered by the agent.
8
+ #
9
+ # The agent registers reminders via the `register_reminder` tool during a
10
+ # turn. The `IdleReminders` detector thread polls this store to find due
11
+ # reminders and signals the Engine to auto-create a turn when one is due.
12
+ #
13
+ # The store is owned by the Engine and injected into KernelLoop so both
14
+ # loops (native and chat) use the same dispatch path for tool calls.
15
+ #
16
+ # Reminder lifecycle:
17
+ # 1. Agent calls register_reminder → store adds entry
18
+ # 2. IdleReminders detects due → sets @due_reminder_name on Engine
19
+ # 3. Engine#run_turn reads @due_reminder_name, injects [SYSTEM:] message
20
+ # 4. Agent acts on reminder → calls cancel_reminder → store removes entry
21
+ class ReminderStore
22
+ # @return [Hash{String => Hash}] id => {id:, name:, description:, interval_minutes:, next_fire_at:}
23
+ attr_reader :reminders
24
+
25
+ def initialize
26
+ @mutex = Monitor.new
27
+ @reminders = {}
28
+ end
29
+
30
+ # Register a new reminder.
31
+ # @param call [Hash] the tool call params (from the model)
32
+ # @return [String] confirmation message
33
+ def register(call)
34
+ name = (call[:name] || call[:content]).to_s.strip
35
+ description = (call[:description] || "").to_s.strip
36
+ interval = (call[:interval_minutes] || 1).to_i
37
+
38
+ return "Error: name is required" if name.empty?
39
+ return "Error: description is required" if description.empty?
40
+ return "Error: interval_minutes must be >= 1" if interval < 1
41
+ return "Error: interval_minutes must be <= 1440" if interval > 1440
42
+
43
+ id = SecureRandom.hex(6)
44
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ @mutex.synchronize do
46
+ @reminders[name] = {
47
+ id: id,
48
+ name: name,
49
+ description: description,
50
+ interval_minutes: interval,
51
+ last_fire_at: now,
52
+ next_fire_at: now + (interval * 60)
53
+ }
54
+ end
55
+
56
+ "Reminder '#{name}' registered (interval: #{interval}m, id: #{id})."
57
+ end
58
+
59
+ # Cancel a reminder by name.
60
+ # @param name [String] the reminder name to cancel
61
+ # @return [String] result message
62
+ def cancel(name)
63
+ name = name.to_s.strip
64
+ @mutex.synchronize do
65
+ if @reminders.delete(name)
66
+ "Reminder '#{name}' canceled."
67
+ else
68
+ "Error: no such reminder '#{name}'."
69
+ end
70
+ end
71
+ end
72
+
73
+ # @return [Boolean] whether any reminder is registered
74
+ def any?
75
+ @mutex.synchronize { !@reminders.empty? }
76
+ end
77
+
78
+ # List all active reminders.
79
+ # @return [String] formatted list
80
+ def list
81
+ @mutex.synchronize do
82
+ return "No active reminders." if @reminders.empty?
83
+
84
+ lines = @reminders.map do |name, r|
85
+ " #{r[:name]} (interval: #{r[:interval_minutes]}m, id: #{r[:id]})"
86
+ end
87
+ "Active reminders:\n" + lines.join("\n")
88
+ end
89
+ end
90
+
91
+ # Get all due reminders whose interval has elapsed.
92
+ # Returns an Array of {name:, description:, interval_minutes:} hashes.
93
+ # Called by Engine#maybe_inject_reminders (synchronously, at run_turn start).
94
+ # Thread-safe. Returns a frozen copy so the caller can't mutate internal state.
95
+ # @return [Array<Hash>] array of due reminder hashes (may be empty)
96
+ def due_reminders
97
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ @mutex.synchronize do
99
+ @reminders.each_value.filter_map do |r|
100
+ next unless r[:next_fire_at] && now >= r[:next_fire_at]
101
+ { name: r[:name], description: r[:description], interval_minutes: r[:interval_minutes] }
102
+ end.freeze
103
+ end
104
+ end
105
+
106
+ # Get the name of the next due reminder (if any) whose interval has elapsed.
107
+ # Kept for backward compatibility with IdleReminders thread.
108
+ # @return [String, nil] the reminder name, or nil if none are due
109
+ def next_due_name
110
+ due = due_reminders
111
+ due.first&.fetch(:name)
112
+ end
113
+
114
+ # Mark a reminder as fired — resets its next_fire_at to now + interval.
115
+ # Called by Engine after a due reminder has been delivered.
116
+ # @param name [String] the reminder name
117
+ def mark_fired(name)
118
+ name = name.to_s.strip
119
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
+ @mutex.synchronize do
121
+ r = @reminders[name]
122
+ if r
123
+ r[:last_fire_at] = now
124
+ r[:next_fire_at] = now + (r[:interval_minutes] * 60)
125
+ end
126
+ end
127
+ end
128
+
129
+ # Mark multiple reminders as fired — resets each next_fire_at to now + interval.
130
+ # Called by Engine after injecting multiple due reminders (batch operation).
131
+ # Thread-safe, single lock acquisition.
132
+ # @param names [Array<String>] reminder names to mark as fired
133
+ def mark_fired_batch(names)
134
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
135
+ @mutex.synchronize do
136
+ names.each do |name|
137
+ name = name.to_s.strip
138
+ r = @reminders[name]
139
+ if r
140
+ r[:last_fire_at] = now
141
+ r[:next_fire_at] = now + (r[:interval_minutes] * 60)
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ # Get the description of a due reminder (for injection into the system prompt).
148
+ # Called by Engine during run_turn. Thread-safe.
149
+ # @param name [String] the reminder name
150
+ # @return [String, nil] the description, or nil if not found
151
+ def get_description(name)
152
+ name = name.to_s.strip
153
+ @mutex.synchronize do
154
+ r = @reminders[name]
155
+ r[:description] if r
156
+ end
157
+ end
158
+
159
+ # Clear all reminders.
160
+ # Called when the session ends or the engine is reset.
161
+ def clear_all
162
+ @mutex.synchronize { @reminders.clear }
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "version"
5
+ require_relative "config"
6
+ require_relative "context_window"
7
+ require_relative "session"
8
+ require_relative "log_path"
9
+ require_relative "model_profile"
10
+ require_relative "served_model"
11
+ require_relative "host_registry"
12
+ require_relative "tools/memory"
13
+ require_relative "hooks/loader"
14
+ require_relative "memory_bundle/provenance"
15
+ require_relative "memory_bundle/manifest"
16
+ require_relative "memory_bundle/system_bundle"
17
+ require_relative "desktop"
18
+
19
+ module Samagotchi
20
+ # `chi self`: where this chi lives and what it is configured to use.
21
+ #
22
+ # Read-only and nearly offline (one short /props GET for the served model,
23
+ # nothing else asks a model server), so the agent can run it via `execute`
24
+ # to orient itself — source dir to rg, config path, memory dirs, sessions,
25
+ # model/host, bundle versions — without guessing from $PATH.
26
+ module SelfReport
27
+ SOURCE_DIR = File.expand_path("../..", __dir__)
28
+
29
+ module_function
30
+
31
+ def text(env: ENV)
32
+ rows = fields(env: env)
33
+ width = rows.map { |label, _| label.length }.max
34
+ rows.map { |label, value| "#{label.ljust(width)} #{value}" }.join("\n")
35
+ end
36
+
37
+ def fields(env: ENV)
38
+ model = model_name
39
+ [
40
+ ["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
41
+ ["source", "#{SOURCE_DIR} (#{install_kind})"],
42
+ ["config", with_presence(ConfigFile.global_path(env: env))],
43
+ ["hooks dir", hooks_dir(env)],
44
+ ["memories", Tools::MemoryRead.memories_dir("system", env: env)],
45
+ ["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
46
+ ["sessions", Session.default_state_dir(env: env)],
47
+ ["log", log_path(env)],
48
+ ["model", model || "(not configured)"],
49
+ ["host", model ? host_for(model, env) : "-"],
50
+ ["api key", model ? api_key_for(model, env) : "-"],
51
+ ["loop", model ? loop_for(model, env) : "-"],
52
+ ["profile", model ? profile_for(model, env) : "-"],
53
+ ["served model", model ? served_model_for(model, env) : "-"],
54
+ ["context window", context_window(env)],
55
+ ["bundles", bundles_summary],
56
+ ["desktop", desktop_summary(env)]
57
+ ]
58
+ end
59
+
60
+ # "git checkout" when running from a repo (bin/chi), "installed gem" when
61
+ # under a gem path; the installed gem's files are not meant to be edited.
62
+ def install_kind(dir = SOURCE_DIR)
63
+ return "git checkout" if File.exist?(File.join(dir, ".git"))
64
+ return "installed gem" if Gem.path.any? { |p| dir.start_with?(File.join(p, "gems") + File::SEPARATOR) }
65
+
66
+ "directory"
67
+ end
68
+
69
+ # The debug log every chi process appends to (LogPath): the sessions
70
+ # dir's sibling by default, so a chi that wants to read its own trail
71
+ # doesn't guess ~/.local/state.
72
+ def log_path(env)
73
+ LogPath.resolve(env: env) || "(disabled: log.disable)"
74
+ end
75
+
76
+ def with_presence(path)
77
+ File.exist?(path) ? path : "#{path} (missing)"
78
+ end
79
+
80
+ def hooks_dir(env)
81
+ data = ConfigFile.read_yaml(path: ConfigFile.global_path(env: env))
82
+ configured = data.is_a?(Hash) && data["hooks"].is_a?(Hash) ? data["hooks"]["hooks_dir"] : nil
83
+ Hooks::Loader.expand_path(configured || Hooks::Loader.default_hooks_dir(env), env)
84
+ end
85
+
86
+ # Offline, so only the fallback: the running server's n_ctx wins at runtime.
87
+ def context_window(env)
88
+ window = ContextWindow.configured(env: env)
89
+ "#{window.tokens} (#{window.source}; the server's n_ctx wins at runtime)"
90
+ end
91
+
92
+ def model_name
93
+ ModelProfile.required_model_name
94
+ rescue ArgumentError
95
+ nil
96
+ end
97
+
98
+ # Routed without model discovery, so an unqualified name may land on
99
+ # another host at runtime after /models.
100
+ def loop_for(model, env)
101
+ entry, = HostRegistry.new(env: env).host_for_model(model)
102
+ return "-" unless entry
103
+
104
+ entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
105
+ end
106
+
107
+ # chi self's one server call: a single-model llama.cpp answers any name
108
+ # with the model it loaded, and its /props names it (model_alias). One
109
+ # GET with the probe's short timeouts; a remote host isn't asked.
110
+ def served_model_for(model, env)
111
+ registry = HostRegistry.new(env: env)
112
+ entry, bare = registry.host_for_model(model)
113
+ return "-" unless entry
114
+ return "reported per turn (remote host)" if entry.remote?
115
+
116
+ client = registry.client_for(entry)
117
+ props = client.server_props(model: bare) if client.respond_to?(:server_props)
118
+ return "reported per turn (the server has no /props)" if props.nil?
119
+
120
+ served = ServedModel.from_props(props)
121
+ return "unknown (no answer from the server's /props)" unless served
122
+
123
+ ServedModel.differs?(bare, served) ? "#{served} (not #{bare}: the server serves its own model)" : served
124
+ rescue StandardError => e
125
+ "unknown (#{e.class})"
126
+ end
127
+
128
+ # Offline, so no /props probe: where nothing is configured, a native
129
+ # llama.cpp host's chat template decides at runtime.
130
+ def profile_for(model, env)
131
+ registry = HostRegistry.new(env: env)
132
+ entry, bare = registry.host_for_model(model)
133
+ return "name-based (chat API: only strips thoughts)" if entry&.chat?
134
+
135
+ # As typed, the part after a host prefix (maybe an alias), alias-resolved, bare.
136
+ names = [model, registry.parse_qualified_model(model).last, ConfigFile.resolve_model_alias(model, env: env), bare]
137
+ override = Config.resolve_with_origin("model.profile", env: env, cli_overrides: Config.cli_overrides)
138
+ result = ModelProfile.resolve(names: names, entry: entry, client: nil, bare_model: bare, override: override,
139
+ models: ConfigFile.model_settings(env: env))
140
+ name = result.profile.name
141
+ case result.source
142
+ when :config then "#{name} (config #{result.detail})"
143
+ when :cli, :env then "#{name} (#{result.source})"
144
+ else
145
+ return "#{name} (#{result.source})" unless entry.nil? || entry.transport.nil? || entry.transport == :llama_cpp
146
+
147
+ given = result.source == :name ? "name says #{name}" : "default #{name}"
148
+ "from the server at runtime (#{given})"
149
+ end
150
+ end
151
+
152
+ def host_for(model, env)
153
+ entry, bare = HostRegistry.new(env: env).host_for_model(model)
154
+ return "(no hosts configured)" unless entry
155
+
156
+ suffix = bare && bare != model ? " as #{bare}" : ""
157
+ location = entry.url || "#{entry.host}:#{entry.port}"
158
+ "#{entry.name} #{location}#{suffix}"
159
+ end
160
+
161
+ # The variable the host's API key comes from and whether it is set; the
162
+ # key itself is never shown.
163
+ def api_key_for(model, env)
164
+ entry, = HostRegistry.new(env: env).host_for_model(model)
165
+ name = entry&.api_key_env
166
+ return "-" unless name
167
+
168
+ "#{name} (#{env[name].to_s.empty? ? "unset" : "set"})"
169
+ end
170
+
171
+ # The Chi Helper app's version against this chi's (read from its
172
+ # Info.plist; no process or Services checks: chi desktop status has those).
173
+ def desktop_summary(env)
174
+ return "- (macOS only)" unless Desktop.supported?
175
+
176
+ version = Desktop::MacOS.new(env: env).app_version
177
+ return "not installed" unless version
178
+
179
+ version == VERSION ? "#{version} (matches)" : "#{version} (chi is #{VERSION}: chi desktop upgrade)"
180
+ end
181
+
182
+ # "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
183
+ def bundles_summary
184
+ shipped = MemoryBundle::Manifest.read(dir: MemoryBundle::SystemBundle::GEM_BUNDLE_DIR).version rescue nil
185
+ installed = Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "manifest.json")].sort.map do |path|
186
+ name = File.basename(File.dirname(path))
187
+ version = (JSON.parse(File.read(path))["version"] rescue nil) || "?"
188
+ label = "#{name} #{version}"
189
+ label += " (shipped #{shipped})" if name == MemoryBundle::SystemBundle::BUNDLE_NAME
190
+ label
191
+ end
192
+ installed.empty? ? "(none installed; shipped system bundle #{shipped || "?"})" : installed.join(", ")
193
+ end
194
+ end
195
+ end