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,549 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "monitor"
6
+ require "time"
7
+
8
+ require_relative "idle_client"
9
+ require_relative "output_formatter"
10
+ require_relative "recap_store"
11
+ require_relative "log"
12
+
13
+ module Samagotchi
14
+ # Idle job for the session-recap feature — polled by the shared
15
+ # IdleScheduler (one background thread for the whole idle layer).
16
+ #
17
+ # Owns the inactivity clock bookkeeping shared across UIs: it reads the
18
+ # Engine's `last_activity_at` / `activity_seq` / `turn_running?` seam (the
19
+ # single source of truth) rather than tracking its own timeline, so the
20
+ # clock behaves identically for the interactive REPL and any Engine-backed
21
+ # worker.
22
+ #
23
+ # When the session has been idle for `@inactivity` seconds, no turn is
24
+ # running, and there are >= `@min_user_turns` user turns, the job
25
+ # snapshots the conversation (as a JSON string, never mutating it), summarizes
26
+ # it with a decoupled #IdleClient on a background thread, and emits a
27
+ # `:recap_ready` event (with a generation id so an invalidated recap can't
28
+ # render). Failures (server down, timeout, short history) are isolated and
29
+ # never break the active session.
30
+ class IdleRecap
31
+ DEFAULT_INACTIVITY_SECONDS = 180.0
32
+ DEFAULT_TIMEOUT_SECONDS = 30.0
33
+ DEFAULT_MIN_USER_TURNS = 2
34
+ # Above this many tool calls we ask only for a goal/achievement summary
35
+ # (never enumerate calls).
36
+ LARGE_TOOL_THRESHOLD = 10
37
+ # The most new transcript one attempt sends (the tail is kept): a first
38
+ # recap of a long session, or a long stretch since the last one.
39
+ MAX_NEW_CHARS = 16_000
40
+
41
+ # Build a cleaned recap transcript + the prompt text from a JSON snapshot.
42
+ # Drops the system prompt and tool internals (call markup with its
43
+ # arguments, tool outputs), keeps user turns and model prose (thoughts
44
+ # stripped), and collects the tool names for the prompt.
45
+ module TranscriptFilter
46
+ # A tool call as the model wrote it inline: gemma, qwen, or the qwen
47
+ # prompt-literal form. An unterminated gemma call runs to the end.
48
+ TOOL_CALL_RE = /<\|tool_call>(?:.*?<tool_call\|>|.*\z)|<tool_call>.*?<\/tool_call>|\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN\]\].*?\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE\]\]/m
49
+ # Each dispatched call's output starts with "[name]"; the kernel loop
50
+ # joins one step's outputs into a single tool_response with this
51
+ # separator, while the chat loop writes one message per call.
52
+ TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
53
+ TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
54
+
55
+ module_function
56
+
57
+ def build(messages)
58
+ Array(messages).filter_map do |message|
59
+ next unless message.is_a?(Hash)
60
+
61
+ case message["role"]
62
+ when "user"
63
+ # An image is a line naming it (refs only, never its bytes).
64
+ [message["content"].to_s, *image_lines(message["images"])].reject(&:empty?).join("\n")
65
+ when "model", "assistant"
66
+ strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
67
+ else
68
+ # Drop system prompt + tool_response contents + anything else.
69
+ nil
70
+ end
71
+ end.reject { |line| line.to_s.strip.empty? }.join("\n\n")
72
+ end
73
+
74
+ # @return [Array<String>] one tool name per dispatched call, in order
75
+ def tool_names(messages)
76
+ Array(messages).flat_map do |message|
77
+ next [] unless message.is_a?(Hash) && message["role"] == "tool_response"
78
+
79
+ message["content"].to_s.split(TOOL_OUTPUT_SEPARATOR).filter_map do |chunk|
80
+ chunk[TOOL_OUTPUT_HEADER_RE, 1]
81
+ end
82
+ end
83
+ end
84
+
85
+ def image_lines(images)
86
+ Array(images).filter_map { |ref| "[image #{ref["name"] || File.basename(ref["file"].to_s)}]" if ref.is_a?(Hash) }
87
+ end
88
+
89
+ def strip_thought(text)
90
+ OutputFormatter.strip(IdleClient.strip_thinking(text))
91
+ end
92
+ end
93
+
94
+ # Builds the recap prompt: the instructions as a system message and the
95
+ # transcript as the user message (R0 spike: with one user message Ornith
96
+ # opened 3/12 recaps with notes about the task; 0/12 this way). Short (2-4
97
+ # sentences) and centred on outcomes: the task, what came of it, what is
98
+ # open. Passive voice — no actors at all (voice spike 2026-09-25: the
99
+ # 3rd-person "the user was…/the assistant…" projection read as a third
100
+ # party watching; "we" was consistent but the user preferred outcomes
101
+ # without persons; the small model occasionally drifts back to naming
102
+ # actors, which is tolerated). Names the handful of tool calls only when
103
+ # they matter to the result; states only the count when it is large (never
104
+ # enumerates). With a previous recap it asks for an updated recap of the
105
+ # whole session from that recap plus the transcript since.
106
+ module RecapPrompt
107
+ # Recap-length S0 spike (Ornith 35B-A3B, 4 sessions): without the
108
+ # example opening, first recaps still began "The user asked the
109
+ # assistant to…" (goal first 25% → 100% with it); without "never by
110
+ # name" it named the user and Chi; without the side-details list it
111
+ # kept personal details, repo counts, paths and versions.
112
+ # Recap-polish spike (same 4 sessions): without the "lacks" line 4/20
113
+ # first recaps at 2-4 said "…weren't captured in the transcript"; 0/80
114
+ # with it. (A "short sentences, no dashes" line was dropped: with this
115
+ # one it brought back "Chi" as an actor in 15-18/80.)
116
+ # Voice spike (same 4 sessions, 2026-09-25): the passive "no actors"
117
+ # rule reads like a personal changelog ("Explored… then implemented…");
118
+ # it drifted to "the user and assistant" on 1/4 (a session with clear
119
+ # user decisions) — tolerated. The "we" variant was 4/4 consistent.
120
+ # %<plain>s is the range, e.g. "2-4 plain sentences"; %<shape>s is SHAPE
121
+ # or ONE_SHAPE.
122
+ SYSTEM = "You write short recaps of a chat between a user and an assistant, for the user " \
123
+ "coming back to it later. Reply with the recap only: %<plain>s, no heading, " \
124
+ "no preamble, no notes about the task or the transcript. Do not mention what the " \
125
+ "transcript lacks or does not say; leave it out. Centre it on outcomes, not on " \
126
+ "the order of events. %<shape>s Keep project names " \
127
+ "and facts that matter for continuing. Do not retell the chat turn by turn (\"the user " \
128
+ "asked..., then the assistant...\"). Never name who did something: no \"the user\", no " \
129
+ "\"the assistant\", no \"I\", no \"we\", no personal names — state outcomes and decisions " \
130
+ "without actors (\"a 28-day threshold was chosen\", \"the plan was saved\"). Leave out " \
131
+ "side details: personal " \
132
+ "details about the user (where they live, their accounts, how many repos they have), " \
133
+ "file paths and version numbers, unless they are the point. If there was no clear task, " \
134
+ "just say what was talked about."
135
+ SHAPE = "The first sentence names the task itself (\"Checking how the parser handles tabs\"), " \
136
+ "not who asked for it. Then say what came of it: results, decisions, findings. End with " \
137
+ "what is still open or the next step, if anything."
138
+ # recap.sentences: 1. Asking for "1 plain sentence" next to SHAPE's
139
+ # first sentence, then results, then what is open gave 3.4 sentences
140
+ # (1 exactly in 0/20, spike); with this shape 1 in 40/40, goal first.
141
+ ONE_SHAPE = "The sentence names the task itself (\"Checking how the parser handles tabs\"), not " \
142
+ "who asked for it, and where it stands: the result, or what is still open."
143
+ # "do not just repeat": otherwise Ornith returned the earlier recap
144
+ # word for word after a short turn.
145
+ UPDATE = " You are given the earlier recap and the conversation since the earlier recap: " \
146
+ "write an updated recap of the whole session that also covers what happened since " \
147
+ "(do not just repeat the earlier recap)."
148
+ # Holds the length once the recap covers more (S0: an updated recap ran
149
+ # to 6 sentences for 2-4 without it, within range+1 in 95%+ with it).
150
+ # The first-sentence line keeps the list preview on the task.
151
+ UPDATE_LENGTH = " Keep it to %<sentences>s even though it now covers more: merge or drop older " \
152
+ "details rather than adding sentences. Keep %<focus>s on the task (change it " \
153
+ "if the focus moved)."
154
+ # At an open continue offer (the last turn ran out of steps with a
155
+ # tool call pending). Recap-next F1 spike: without it the recap stated
156
+ # the stop in 5-10% and said "no open items" 7/40; with this line
157
+ # after the transcript 95-100% and 0. A system sentence as well made
158
+ # it worse (a chit-chat session judged there was no task). Worded to
159
+ # stay true after a worker restart, when the offer itself is gone.
160
+ OFFER_LINE = "Where it stands now: the last turn stopped at its step limit before the task was finished."
161
+ OMITTED = "(earlier part omitted)"
162
+ DEFAULT_SENTENCES = [2, 4].freeze
163
+ MAX_SENTENCES = 10
164
+ SENTENCES_RE = /\A(\d+)(?:\s*[-–]\s*(\d+))?\z/
165
+
166
+ module_function
167
+
168
+ # The recap.sentences setting as [min, max]: "2-3", "3" or 3 (an en
169
+ # dash and spaces are fine). DEFAULT_SENTENCES when unset; nil when
170
+ # invalid (outside 1..MAX_SENTENCES, min above max, not a number).
171
+ def sentences_range(value)
172
+ text = value.to_s.strip
173
+ return DEFAULT_SENTENCES if text.empty?
174
+
175
+ match = SENTENCES_RE.match(text)
176
+ return nil unless match
177
+
178
+ min = match[1].to_i
179
+ max = (match[2] || match[1]).to_i
180
+ return nil unless min.between?(1, MAX_SENTENCES) && max.between?(min, MAX_SENTENCES)
181
+
182
+ [min, max]
183
+ end
184
+
185
+ # @return [Array<Hash>, nil] chat messages; nil when there is no
186
+ # transcript to summarize
187
+ # @param sentences [Array(Integer, Integer)] the range, from #sentences_range
188
+ # @param offer [Boolean] a continue offer is open: add OFFER_LINE
189
+ def build(transcript, tool_count: nil, tool_names: [], previous: nil, sentences: DEFAULT_SENTENCES, offer: false)
190
+ body = cap(transcript.to_s.strip)
191
+ return nil if body.empty?
192
+
193
+ tool_count ||= tool_names.size
194
+ count_word = tool_count == 1 ? "1 tool call" : "#{tool_count} tool calls"
195
+ tools = if tool_count > LARGE_TOOL_THRESHOLD
196
+ " #{count_word}#{tools_used(tool_names)} were made. Do not enumerate the tool calls."
197
+ elsif tool_count.positive?
198
+ " About #{count_word}#{tools_used(tool_names)} were made; name them only if they " \
199
+ "matter to the result."
200
+ else
201
+ ""
202
+ end
203
+ one = sentences.last == 1
204
+ range = { plain: sentences_text(sentences, "plain "), sentences: sentences_text(sentences),
205
+ shape: one ? ONE_SHAPE : SHAPE, focus: one ? "it" : "the first sentence" }
206
+ system = format(SYSTEM, range) + (previous ? UPDATE + format(UPDATE_LENGTH, range) : "") + tools
207
+ user = +""
208
+ user << "Earlier recap:\n#{previous}\n\n" if previous
209
+ user << "Transcript#{previous ? ' since the earlier recap' : ''} (the system prompt and tool " \
210
+ "internals were removed; only user turns and assistant prose remain):\n---\n#{body}\n---\n"
211
+ user << "#{OFFER_LINE}\n" if offer
212
+ user << "Write the recap now."
213
+ [{ role: "system", content: system }, { role: "user", content: user }]
214
+ end
215
+
216
+ # "2-4 sentences", or "3 sentences" for [3, 3] ("1 sentence" for [1, 1])
217
+ def sentences_text((min, max), adjective = "")
218
+ count = min == max ? min.to_s : "#{min}-#{max}"
219
+ "#{count} #{adjective}#{max == 1 ? 'sentence' : 'sentences'}"
220
+ end
221
+
222
+ # The tail of +body+ when it is over MAX_NEW_CHARS, from a paragraph
223
+ # start when one is near, after an "(earlier part omitted)" line.
224
+ def cap(body)
225
+ return body if body.size <= MAX_NEW_CHARS
226
+
227
+ tail = body[-MAX_NEW_CHARS..]
228
+ cut = tail.index("\n\n")
229
+ tail = tail[(cut + 2)..] if cut && cut < 2_000
230
+ "#{OMITTED}\n\n#{tail}"
231
+ end
232
+
233
+ # " (execute x3, read_file)", or "" when no names are known
234
+ def tools_used(names)
235
+ return "" if names.empty?
236
+
237
+ " (#{names.tally.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(', ')})"
238
+ end
239
+ end
240
+
241
+ # @return [Array(Integer, Integer)] the recap length range, e.g. [2, 4]
242
+ attr_reader :generation, :inactivity, :min_user_turns, :sentences
243
+
244
+ # @param callable [#call] true while a continue offer waits for an
245
+ # answer (the Worker's or the REPL's TurnFlow); read at each attempt
246
+ attr_writer :awaiting_continue
247
+
248
+ # @return [Hash, nil] the last recap written: {text:, covered:,
249
+ # covered_digest:, model:, created_at:}, where covered counts the
250
+ # session messages it summarizes and covered_digest fingerprints the
251
+ # last of them. With a store, loaded from it for each new session.
252
+ def state
253
+ @mutex.synchronize do
254
+ if @store && @store_key != (key = @store.key)
255
+ @store_key = key
256
+ @state = @store.load
257
+ end
258
+ @state&.dup
259
+ end
260
+ end
261
+
262
+ # The recap asks either a fixed model (+model+, +base_url+,
263
+ # +api_key_env+: an explicit recap: config) or +target+, called at each
264
+ # attempt (the session's current model, so a /model switch counts).
265
+ # @param base_url [String] the OpenAI API base the recap asks
266
+ # @param api_key_env [String, nil] the variable holding its key
267
+ # @param target [#call, nil] -> {base_url:, api_key_env:, model:, label:}
268
+ def initialize(engine:, model: nil, base_url: nil, api_key_env: nil, target: nil,
269
+ inactivity: DEFAULT_INACTIVITY_SECONDS,
270
+ min_user_turns: DEFAULT_MIN_USER_TURNS,
271
+ timeout: DEFAULT_TIMEOUT_SECONDS,
272
+ sentences: RecapPrompt::DEFAULT_SENTENCES,
273
+ client: nil,
274
+ store: nil,
275
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
276
+ raise ArgumentError, "IdleRecap requires an engine" unless engine
277
+
278
+ @engine = engine
279
+ @target = target || lambda {
280
+ { base_url: base_url, api_key_env: api_key_env, model: model, label: model }
281
+ }
282
+ @inactivity = inactivity
283
+ @min_user_turns = min_user_turns
284
+ @timeout = timeout
285
+ @sentences = sentences
286
+ # Specs inject a client; otherwise one IdleClient per target, rebuilt
287
+ # when the target changes.
288
+ @client_override = client
289
+ @client = nil
290
+ @client_key = nil
291
+ @clock = clock
292
+ @store = store
293
+
294
+ @mutex = Monitor.new
295
+ # Serializes the attempt state machine (#tick on the scheduler thread,
296
+ # #request_now on a Bridge or REPL thread, #write_now). Never held by
297
+ # #state, so a snapshot reading it doesn't wait on an emit.
298
+ @run_mutex = Monitor.new
299
+ @generation = 0
300
+ @last_fire_activity_seq = nil
301
+ @awaiting_continue = -> { false }
302
+ @in_flight = nil
303
+ @state = nil
304
+ end
305
+
306
+ # Mark any in-flight recap stale (called when a new turn starts). The
307
+ # generation id bumps so the summarizing worker sees the mismatch and drops
308
+ # its result instead of rendering a recap for a turn that is now running.
309
+ def invalidate!
310
+ @mutex.synchronize { @generation += 1 }
311
+ end
312
+
313
+ # One detector step, called by the shared IdleScheduler. Public so specs
314
+ # can drive it deterministically. Never waits on the summarizer: it
315
+ # collects a finished (or overdue) request, else starts one when
316
+ # eligible, so the other idle jobs keep ticking meanwhile.
317
+ def tick
318
+ @run_mutex.synchronize do
319
+ next collect if in_flight?
320
+ next unless should_fire?
321
+
322
+ start
323
+ end
324
+ end
325
+
326
+ # Ask for a recap now (/recap): start an attempt at once, without the
327
+ # inactivity window; the scheduler collects it as usual.
328
+ # @return [Symbol] :started, :in_flight, :busy (a turn runs),
329
+ # :too_short, :nothing_new or :failed
330
+ def request_now
331
+ @run_mutex.synchronize do
332
+ next :in_flight if in_flight?
333
+ next :busy if @engine.turn_running?
334
+
335
+ start
336
+ end
337
+ end
338
+
339
+ # Write a recap now and wait for it, bounded by the timeout: the worker
340
+ # (or the REPL) leaving. Skips the inactivity window and the once-per-
341
+ # window latch, not the rules on what to recap (minimum user turns,
342
+ # something new). Takes over an attempt already in flight. Call it with
343
+ # the idle scheduler stopped.
344
+ # @param on_start [#call, nil] called when a request goes out
345
+ # @return [String, nil] the recap written, nil when none was
346
+ def write_now(on_start: nil)
347
+ @run_mutex.synchronize do
348
+ unless in_flight?
349
+ start
350
+ on_start&.call if in_flight?
351
+ end
352
+ job = @in_flight
353
+ next nil unless job
354
+
355
+ job[:thread].join([job[:deadline] - @clock.call, 0].max)
356
+ if job[:thread].alive?
357
+ @in_flight = nil # overdue: left to its IdleClient timeout
358
+ next nil
359
+ end
360
+ before = @state
361
+ collect
362
+ @state.equal?(before) ? nil : @state[:text]
363
+ end
364
+ end
365
+
366
+ # @return [Hash] the model the next attempt asks: {base_url:,
367
+ # api_key_env:, model:, label:}
368
+ def target
369
+ @target.call
370
+ end
371
+
372
+ # @return [Boolean] true while a summarize request is running
373
+ def in_flight?
374
+ !@in_flight.nil?
375
+ end
376
+
377
+ # @return [Boolean] true when a recap is eligible to fire right now.
378
+ def should_fire?
379
+ return false if @engine.turn_running?
380
+ return false unless last_idle_seconds >= @inactivity
381
+ # Fire at most once per idle window: require that activity advanced the
382
+ # shared seq since the last fire. Pure monotonic time passing does NOT
383
+ # bump the seq, so a long idle window only summarizes once.
384
+ return true if @last_fire_activity_seq.nil?
385
+
386
+ @engine.activity_seq > @last_fire_activity_seq
387
+ end
388
+
389
+ private
390
+
391
+ # A failing check writes the recap without the offer line.
392
+ def offer_open?
393
+ @awaiting_continue.call ? true : false
394
+ rescue StandardError
395
+ false
396
+ end
397
+
398
+ def last_idle_seconds
399
+ @clock.call - @engine.last_activity_at
400
+ end
401
+
402
+ # Start an attempt: snapshot, build the prompt, and spawn the summarize
403
+ # thread. The result is picked up by #collect on a later tick.
404
+ # @return [Symbol] :started, :too_short, :nothing_new or :failed
405
+ def start
406
+ gen = bump_generation
407
+ # Latch the attempt, not the success: a short history, a failed or
408
+ # empty summary, or an invalidated run must not re-fire on every
409
+ # scheduler tick. The next recorded activity re-arms the window.
410
+ @last_fire_activity_seq = @engine.activity_seq
411
+ parsed = safe_parse(@engine.messages_json_for_recap)
412
+ return :too_short if parsed.nil?
413
+
414
+ user_turns = parsed.count { |message| message.is_a?(Hash) && message["role"] == "user" }
415
+ if parsed.empty? || user_turns < @min_user_turns
416
+ drop_stale(parsed)
417
+ return :too_short
418
+ end
419
+ previous = continuable_state(parsed)
420
+ fresh = parsed.drop(previous ? previous[:covered] : 0)
421
+ transcript = TranscriptFilter.build(fresh)
422
+ # Nothing new said (only notes, tool traffic, or no messages at all):
423
+ # the recap still stands, so no request.
424
+ return :nothing_new if transcript.strip.empty?
425
+ prompt = RecapPrompt.build(transcript, tool_names: TranscriptFilter.tool_names(fresh), previous: previous&.dig(:text),
426
+ sentences: @sentences, offer: offer_open?)
427
+ return :nothing_new if prompt.nil?
428
+ asked = target
429
+ @in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
430
+ covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked[:label] }
431
+ :started
432
+ rescue StandardError
433
+ :failed
434
+ end
435
+
436
+ # Pick up the in-flight attempt. Save/emit happens only here, on the
437
+ # scheduler thread, never from the summarize thread: a stale (turn
438
+ # started) or overdue attempt is dropped. An overdue thread is left to
439
+ # its IdleClient timeout (the same budget), not killed mid-request.
440
+ def collect
441
+ job = @in_flight
442
+ unless valid_generation?(job[:generation])
443
+ @in_flight = nil
444
+ return
445
+ end
446
+ if job[:thread].alive?
447
+ @in_flight = nil if @clock.call >= job[:deadline]
448
+ return
449
+ end
450
+ @in_flight = nil
451
+ recap = safe_value(job[:thread])
452
+ return if recap.nil? || recap.to_s.strip.empty?
453
+ # An IdleClient::Summary names the model that answered; a plain String
454
+ # (a stubbed client) doesn't, and the asked label stands in.
455
+ served = recap.model if recap.respond_to?(:model)
456
+ text = recap.to_s
457
+ saved = { text: text, covered: job[:covered], covered_digest: job[:covered_digest],
458
+ model: served || job[:model], created_at: Time.now.utc.iso8601 }
459
+ @mutex.synchronize { @state = saved }
460
+ save(saved)
461
+ @engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered])
462
+ rescue StandardError
463
+ @in_flight = nil
464
+ end
465
+
466
+ # A history too short for a recap that no longer holds what the saved one
467
+ # covers (a "no" at a continue offer took the offered turn back): the
468
+ # saved recap would keep describing that turn, so it goes.
469
+ def drop_stale(messages)
470
+ return unless state
471
+ return if continuable_state(messages)
472
+
473
+ @mutex.synchronize { @state = nil }
474
+ @store&.delete
475
+ rescue StandardError => e
476
+ Log.warn(:recap, "delete_failed", echo: "[IdleRecap] dropping a stale recap failed: #{e.class}: #{e.message}", error: e.class.name)
477
+ end
478
+
479
+ def save(state)
480
+ @store&.save(state)
481
+ rescue StandardError => e
482
+ Log.warn(:recap, "save_failed", echo: "[IdleRecap] saving the recap failed: #{e.class}: #{e.message}", error: e.class.name)
483
+ end
484
+
485
+ # The saved state when it still describes a prefix of +messages+; nil
486
+ # (start over) when the history got shorter or was rewritten (a
487
+ # rollback, a cancelled or failed turn replaced).
488
+ def continuable_state(messages)
489
+ saved = state
490
+ return nil unless saved && saved[:covered].to_i.positive?
491
+ return nil if saved[:covered] > messages.size
492
+ return nil unless self.class.digest(messages[saved[:covered] - 1]) == saved[:covered_digest]
493
+
494
+ saved
495
+ end
496
+
497
+ # SHA1 of one message's role and text: detects a rewrite a count alone
498
+ # misses. Model text is taken without its thinking, which is dropped from
499
+ # older model messages when the next turn starts.
500
+ def self.digest(message)
501
+ return nil unless message.is_a?(Hash)
502
+
503
+ text = message["content"].to_s
504
+ text = TranscriptFilter.strip_thought(text) if %w[model assistant].include?(message["role"])
505
+ Digest::SHA1.hexdigest("#{message['role']}\0#{text}")
506
+ end
507
+
508
+ def bump_generation
509
+ @mutex.synchronize { @generation += 1 }
510
+ end
511
+
512
+ def valid_generation?(gen)
513
+ @mutex.synchronize { @generation == gen }
514
+ end
515
+
516
+ def client_for(asked)
517
+ return @client_override if @client_override
518
+
519
+ key = asked.values_at(:base_url, :api_key_env, :model)
520
+ unless @client && @client_key == key
521
+ @client = IdleClient.new(model: asked[:model], base_url: asked[:base_url], api_key_env: asked[:api_key_env], timeout: @timeout)
522
+ @client_key = key
523
+ end
524
+ @client
525
+ end
526
+
527
+ def spawn_summarize(client, prompt)
528
+ Thread.new do
529
+ client.summarize(prompt)
530
+ rescue StandardError => e
531
+ # No recap this time (the next idle window tries again); say why.
532
+ Log.exception(:recap, "summarize_failed", e)
533
+ nil
534
+ end
535
+ end
536
+
537
+ def safe_value(worker)
538
+ worker.value
539
+ rescue StandardError
540
+ nil
541
+ end
542
+
543
+ def safe_parse(json)
544
+ JSON.parse(json)
545
+ rescue StandardError
546
+ []
547
+ end
548
+ end
549
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ require_relative "reminder_store"
6
+
7
+ module Samagotchi
8
+ # Idle job for periodic reminders — polled by the shared IdleScheduler
9
+ # (one background thread for the whole idle layer).
10
+ #
11
+ # Two delivery paths cooperate:
12
+ # 1. Pull-based: Engine#maybe_inject_reminders / collect_due_reminders
13
+ # reads ReminderStore#due_reminders at turn start → injects
14
+ # [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
15
+ # 2. Synthetic turns: when this job's tick finds due reminders while
16
+ # idle, it latches @due_reminder_name and fires @auto_turn_callback
17
+ # (the TUI/SessionManager queue a synthetic turn from the latch).
18
+ class IdleReminders
19
+ DEFAULT_MIN_INACTIVITY_SECONDS = 60.0 # Minimum idle time before checking for reminders
20
+
21
+ attr_reader :inactivity
22
+
23
+ def initialize(engine:, inactivity: DEFAULT_MIN_INACTIVITY_SECONDS,
24
+ reminder_store: nil,
25
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
26
+ callback: nil)
27
+ raise ArgumentError, "IdleReminders requires an engine" unless engine
28
+
29
+ @engine = engine
30
+ @inactivity = inactivity
31
+ @reminder_store = reminder_store || engine.instance_variable_get(:@reminder_store)
32
+ @clock = clock
33
+ @auto_turn_callback = callback
34
+
35
+ @mutex = Monitor.new
36
+ @due_reminder_name = nil
37
+ end
38
+
39
+ # Get the currently pending due reminder name (for Engine to read).
40
+ # Kept for backward compatibility.
41
+ # Called by Engine#collect_due_reminders. Thread-safe.
42
+ def due_reminder_name
43
+ @mutex.synchronize { @due_reminder_name }
44
+ end
45
+
46
+ # Get all due reminders (from ReminderStore). Called by Engine#maybe_inject_reminders.
47
+ # Thread-safe. Returns all due reminders, not just one.
48
+ # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
49
+ def due_reminders
50
+ @reminder_store&.due_reminders || []
51
+ end
52
+ # Get the names of all due reminders. Called by the background thread to
53
+ # determine which reminders need a synthetic turn.
54
+ # @return [Array<String>] reminder names that are due
55
+ def due_reminder_names
56
+ due_reminders.map { |r| r[:name] }
57
+ end
58
+
59
+ # Clear the pending due reminder (after it has been delivered).
60
+ # Called by Engine after injecting the reminder into the system prompt.
61
+ def clear_due
62
+ @mutex.synchronize { @due_reminder_name = nil }
63
+ end
64
+
65
+ # One detector step, called by the shared IdleScheduler. Public so specs
66
+ # can drive it deterministically.
67
+ def tick
68
+ return if @due_reminder_name # already due, waiting for engine to deliver
69
+ return unless should_check?
70
+
71
+ check_due_reminders
72
+ end
73
+
74
+ # @return [Boolean] true when it's time to check for reminders.
75
+ def should_check?
76
+ return false if @engine.turn_running?
77
+ return false unless last_idle_seconds >= @inactivity
78
+ true
79
+ end
80
+
81
+ private
82
+
83
+ def last_idle_seconds
84
+ @clock.call - @engine.last_activity_at
85
+ end
86
+
87
+ def check_due_reminders
88
+ return unless @reminder_store
89
+ due_names = @reminder_store.due_reminders.map { |r| r[:name] }
90
+ return if due_names.empty?
91
+ # Signal the engine to create a synthetic turn via callback.
92
+ # The callback is responsible for triggering a turn (e.g. SessionManager
93
+ # writes a file, TerminalUI queues input). The engine's run_turn or
94
+ # REPL injection point then picks up and delivers the reminders.
95
+ @mutex.synchronize { @due_reminder_name = due_names.first }
96
+ if @auto_turn_callback
97
+ @auto_turn_callback.call(due_names)
98
+ end
99
+ end
100
+ end
101
+ end