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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Samagotchi
6
+ # A context note: background text pushed into a session from outside (the
7
+ # user's `chi note`, or another session's send_note). It is kept in the
8
+ # conversation as a tail system message marked kind: "note", framed so the
9
+ # model reads it as information, not as a request.
10
+ module ContextNote
11
+ KIND = "note"
12
+ # The message keys a note carries besides role and content; everything
13
+ # that copies messages field by field must keep them.
14
+ KEYS = %i[kind note_id source from_session from_cwd].freeze
15
+
16
+ module_function
17
+
18
+ # Takes SessionManager.read_note's keys.
19
+ # @return [Hash] the conversation message
20
+ def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, **)
21
+ { role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
22
+ from_cwd: from_cwd, content: frame(text, source: source, created_at: created_at,
23
+ from_session: from_session, from_cwd: from_cwd) }.compact
24
+ end
25
+
26
+ def frame(text, source:, created_at: nil, from_session: nil, from_cwd: nil)
27
+ time = clock(created_at)
28
+ from = label(source: source, from_session: from_session, from_cwd: from_cwd)
29
+ "[CONTEXT NOTE from #{from}#{", #{time}" if time}]\n#{text}\n[END NOTE]"
30
+ end
31
+
32
+ # Who sent a note: its source, or the sending session and its folder.
33
+ def label(source:, from_session: nil, from_cwd: nil)
34
+ return source.to_s unless from_session
35
+
36
+ "session #{from_session.to_s[0, 6]}#{" (#{home_relative(from_cwd)})" if from_cwd}"
37
+ end
38
+
39
+ def label_of(message)
40
+ label(source: fetch(message, :source), from_session: fetch(message, :from_session), from_cwd: fetch(message, :from_cwd))
41
+ end
42
+
43
+ # The note's own text, without its frame.
44
+ def text_of(message)
45
+ fetch(message, :content).to_s.sub(/\A\[CONTEXT NOTE[^\n]*\n/, "").sub(/\n\[END NOTE\]\z/, "")
46
+ end
47
+
48
+ def fetch(message, key)
49
+ message.key?(key) ? message[key] : message[key.to_s]
50
+ end
51
+
52
+ def note?(message)
53
+ fetch(message, :kind).to_s == KIND
54
+ end
55
+
56
+ # The conversation with +system_message+ at its head: an old system
57
+ # prompt there (a system message of no kind) is replaced, anything else
58
+ # (a note that arrived before the first turn, a turn note left by a
59
+ # failed first turn) stays and the prompt goes before it.
60
+ def with_system_head(messages, system_message)
61
+ first = messages.first
62
+ replace = first && fetch(first, :role).to_s == "system" && fetch(first, :kind).to_s.empty?
63
+ [system_message] + (replace ? messages.drop(1) : messages)
64
+ end
65
+
66
+ def clock(created_at)
67
+ created_at && Time.iso8601(created_at.to_s).localtime.strftime("%H:%M")
68
+ rescue ArgumentError
69
+ nil
70
+ end
71
+
72
+ def home_relative(path)
73
+ home = Dir.home
74
+ path.to_s.start_with?("#{home}/") || path.to_s == home ? path.to_s.sub(home, "~") : path.to_s
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Context shown to the model as a quote above the user's own words, the
5
+ # shape the web annotations use (annotations.js quoteBlock), minus their
6
+ # source label: `pbpaste | chi send -m "same bug?" ID`.
7
+ module ContextQuote
8
+ # @param text [String, nil]
9
+ # @return [String, nil] each line as "> line" ("> " dropped for an empty
10
+ # one), ending in a blank line so the message follows as is; nil when
11
+ # the text is blank
12
+ def self.block(text)
13
+ lines = text.to_s.gsub(/\r\n?/, "\n").split("\n", -1).map { |line| line.sub(/[[:space:]]+\z/, "") }
14
+ lines.shift while lines.first&.empty?
15
+ lines.pop while lines.last&.empty?
16
+ return nil if lines.empty?
17
+
18
+ "#{lines.map { |line| line.empty? ? ">" : "> #{line}" }.join("\n")}\n\n"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "token_usage"
4
+
5
+ module Samagotchi
6
+ # Normalizes raw model-server stream payloads into a uniform context-usage
7
+ # snapshot. Shared by the kernel loop (to inform the model of real token
8
+ # usage) and the terminal UI (to render the status line) so both agree on
9
+ # token accounting instead of duplicating the parsing logic.
10
+ module ContextUsage
11
+ module_function
12
+
13
+ # Numbers read like TokenUsage's: integers, floats and numeric strings,
14
+ # decimal ("010" is 10).
15
+ def first_positive_integer(*values)
16
+ TokenUsage.first_positive(*values)
17
+ end
18
+
19
+ # `window_tokens` is the caller's resolved context window; a window the
20
+ # payload reports itself (n_ctx / context_window) wins over it. Without
21
+ # either, context_window_tokens and ctx_pct stay nil: the parser does not
22
+ # invent a default.
23
+ def normalize(payload, window_tokens: nil)
24
+ payload_hash = payload.is_a?(Hash) ? payload : {}
25
+ usage = payload_hash["usage"] || payload_hash[:usage]
26
+ usage = {} unless usage.is_a?(Hash)
27
+
28
+ # The token counts come from the same parser SessionMetrics uses.
29
+ counts = TokenUsage.from_payload(payload_hash) || {}
30
+ prompt_tokens = counts[:prompt_tokens]
31
+ completion_tokens = counts[:completion_tokens]
32
+
33
+ total_tokens = first_positive_integer(
34
+ usage["total_tokens"], usage[:total_tokens],
35
+ payload_hash["total_tokens"], payload_hash[:total_tokens],
36
+ payload_hash["n_past"], payload_hash[:n_past]
37
+ )
38
+ total_tokens ||= prompt_tokens.to_i + completion_tokens.to_i if prompt_tokens || completion_tokens
39
+
40
+ context_window_tokens = first_positive_integer(
41
+ payload_hash["n_ctx"], payload_hash[:n_ctx],
42
+ payload_hash["context_window"], payload_hash[:context_window],
43
+ window_tokens
44
+ )
45
+
46
+ context_used_tokens = first_positive_integer(
47
+ payload_hash["n_past"], payload_hash[:n_past],
48
+ total_tokens
49
+ )
50
+
51
+ ctx_pct = if context_window_tokens && context_used_tokens
52
+ (context_used_tokens.to_f / context_window_tokens) * 100.0
53
+ end
54
+
55
+ return nil unless prompt_tokens || completion_tokens || total_tokens || ctx_pct
56
+
57
+ {
58
+ prompt_tokens: prompt_tokens,
59
+ completion_tokens: completion_tokens,
60
+ total_tokens: total_tokens,
61
+ context_window_tokens: context_window_tokens,
62
+ ctx_pct: ctx_pct
63
+ }
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # The model's context window in tokens, and where that number came from.
7
+ #
8
+ # Lookup order:
9
+ # 1. the running server (Client#context_window, e.g. llama.cpp's n_ctx)
10
+ # 2. the host's model list (a chat adapter's #context_window, e.g. a
11
+ # provider's context_length)
12
+ # 3. context.window_tokens (CLI, SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the
13
+ # config file); it only fills in when the server reports nothing
14
+ # 4. DEFAULT_TOKENS
15
+ #
16
+ # Sources: :server, :model_list, :config (CLI or file), :env, :default.
17
+ module ContextWindow
18
+ DEFAULT_TOKENS = 256_000
19
+
20
+ Resolved = Struct.new(:tokens, :source, keyword_init: true)
21
+
22
+ module_function
23
+
24
+ # @param adapter [#context_window, nil] the host's chat adapter
25
+ def resolve(client: nil, model: nil, adapter: nil)
26
+ server_tokens = client.context_window(model: model) if client.respond_to?(:context_window)
27
+ if positive_integer?(server_tokens)
28
+ @last_server = Resolved.new(tokens: server_tokens, source: :server)
29
+ return @last_server
30
+ end
31
+
32
+ listed = adapter.context_window(model: model) if adapter.respond_to?(:context_window)
33
+ if positive_integer?(listed)
34
+ @last_server = Resolved.new(tokens: listed, source: :model_list)
35
+ return @last_server
36
+ end
37
+
38
+ configured
39
+ end
40
+
41
+ # The window without asking a server: config, env, then the default.
42
+ # `env` resolves against that env's config file instead of the live
43
+ # process config (for `chi self`).
44
+ def configured(env: nil)
45
+ value, origin = begin
46
+ if env
47
+ file_data = ConfigFile.read_yaml(path: ConfigFile.global_path(env: env))
48
+ Config.resolve_with_origin("context.window_tokens", file_data: file_data, env: env)
49
+ else
50
+ Config.get_with_origin("context.window_tokens")
51
+ end
52
+ rescue StandardError
53
+ [nil, :default]
54
+ end
55
+ return Resolved.new(tokens: DEFAULT_TOKENS, source: :default) unless positive_integer?(value)
56
+
57
+ Resolved.new(tokens: value, source: origin == :env ? :env : :config)
58
+ end
59
+
60
+ # For callers with no client at hand (tool output guardrails): the last
61
+ # window a server reported in this process, else #configured. With
62
+ # several hosts in one process this can be another host's window, which
63
+ # is good enough for a size heuristic.
64
+ def current
65
+ @last_server || configured
66
+ end
67
+
68
+ def reset!
69
+ @last_server = nil
70
+ end
71
+
72
+ def positive_integer?(value)
73
+ value.is_a?(Integer) && value.positive?
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Samagotchi
6
+ # The debug log file (Log's writer). One #write per record, so records
7
+ # from concurrent processes appending to the same file (O_APPEND) don't
8
+ # interleave. Failures never break the caller: an IO error (a full disk,
9
+ # a lost permission) pauses writing for RETRY_SECONDS, then it tries again.
10
+ #
11
+ # Rotation: a file over MAX_BYTES becomes <file>.1 (one kept), whichever
12
+ # process notices first. It takes <file>.lock without waiting (busy: the
13
+ # next write checks again) and looks again under it, so two processes
14
+ # never both rotate. Every write stats the path: a writer whose file was
15
+ # rotated away (another inode) reopens the new one.
16
+ class DebugLog
17
+ RETRY_SECONDS = 60
18
+ MAX_BYTES = 5 * 1024 * 1024
19
+
20
+ attr_reader :path
21
+
22
+ def initialize(path:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, max_bytes: MAX_BYTES)
23
+ @path = path.nil? || path.to_s.strip.empty? ? nil : path.to_s
24
+ @clock = clock
25
+ @max_bytes = max_bytes
26
+ @io = nil
27
+ @ino = nil
28
+ @paused_until = nil
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ def enabled?
33
+ !@path.nil?
34
+ end
35
+
36
+ # @param record [String] one formatted record, newline-terminated
37
+ # @return [Boolean] whether it was written
38
+ def write(record)
39
+ return false unless @path
40
+
41
+ @mutex.synchronize do
42
+ return false if @paused_until && @clock.call < @paused_until
43
+
44
+ @paused_until = nil
45
+ begin
46
+ ensure_io
47
+ follow_rotation
48
+ ensure_io.write(record)
49
+ true
50
+ rescue StandardError
51
+ pause
52
+ false
53
+ end
54
+ end
55
+ end
56
+
57
+ def close
58
+ @mutex.synchronize { close_io }
59
+ end
60
+
61
+ private
62
+
63
+ def ensure_io
64
+ return @io if @io
65
+
66
+ dir = File.dirname(@path)
67
+ FileUtils.mkdir_p(dir) unless dir.empty?
68
+ @io = File.open(@path, File::WRONLY | File::APPEND | File::CREAT)
69
+ @io.sync = true
70
+ @ino = @io.stat.ino
71
+ @io
72
+ end
73
+
74
+ # Reopen when the path is another file now (rotated by someone else, or
75
+ # deleted); rotate when it grew over the cap. Regular files only: a
76
+ # FIFO or /dev/stderr is left alone.
77
+ def follow_rotation
78
+ stat = File.stat(@path)
79
+ return close_io unless stat.ino == @ino
80
+ return unless stat.file? && stat.size >= @max_bytes
81
+
82
+ rotate
83
+ rescue Errno::ENOENT
84
+ close_io
85
+ end
86
+
87
+ def rotate
88
+ File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
89
+ return unless lock.flock(File::LOCK_EX | File::LOCK_NB)
90
+
91
+ stat = File.stat(@path)
92
+ File.rename(@path, "#{@path}.1") if stat.ino == @ino && stat.size >= @max_bytes
93
+ close_io
94
+ end
95
+ end
96
+
97
+ def pause
98
+ @paused_until = @clock.call + RETRY_SECONDS
99
+ close_io
100
+ end
101
+
102
+ def close_io
103
+ @io&.close
104
+ rescue StandardError
105
+ nil
106
+ ensure
107
+ @io = nil
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,102 @@
1
+ // Chi Helper: the "Send to chi" Service opens the panel with the selection,
2
+ // the hotkey with the clipboard. No Dock icon (LSUIElement); it stays
3
+ // running after the first launch. `ChiHelper --login on|off|status` (run
4
+ // directly by chi desktop) manages the login item and exits.
5
+ import AppKit
6
+ import ServiceManagement
7
+
8
+ final class ServiceProvider: NSObject {
9
+ weak var app: AppDelegate?
10
+
11
+ // Info.plist NSServices: NSMessage = sendToChi.
12
+ @objc func sendToChi(_ pboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString>) {
13
+ guard let text = pboard.string(forType: .string) else {
14
+ error.pointee = "No text in the selection" as NSString
15
+ return
16
+ }
17
+ app?.open(text: text)
18
+ }
19
+ }
20
+
21
+ final class AppDelegate: NSObject, NSApplicationDelegate {
22
+ let panel = PanelController()
23
+ let provider = ServiceProvider()
24
+ private var hotkey: Hotkey?
25
+ /// A Service activates this app before the handler runs, so the app the
26
+ /// text came from is the last other app that was frontmost.
27
+ private var lastOtherApp: NSRunningApplication?
28
+
29
+ func applicationDidFinishLaunching(_ notification: Notification) {
30
+ provider.app = self
31
+ NSApp.servicesProvider = provider
32
+ NSUpdateDynamicServices()
33
+ hotkey = Hotkey { [weak self] in self?.openFromClipboard() }
34
+ hotkey?.register()
35
+
36
+ lastOtherApp = otherApp(NSWorkspace.shared.frontmostApplication)
37
+ NSWorkspace.shared.notificationCenter.addObserver(
38
+ forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
39
+ ) { [weak self] note in
40
+ if let app = self?.otherApp(note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication) {
41
+ self?.lastOtherApp = app
42
+ }
43
+ }
44
+ }
45
+
46
+ private func otherApp(_ app: NSRunningApplication?) -> NSRunningApplication? {
47
+ app?.processIdentifier == getpid() ? nil : app
48
+ }
49
+
50
+ /// The app the text comes from: frontmost unless that is us.
51
+ var sourceApp: NSRunningApplication? {
52
+ otherApp(NSWorkspace.shared.frontmostApplication) ?? lastOtherApp
53
+ }
54
+
55
+ /// The hotkey doesn't activate us, so the frontmost app is the source.
56
+ func openFromClipboard() {
57
+ open(text: NSPasteboard.general.string(forType: .string) ?? "")
58
+ }
59
+
60
+ func open(text: String) {
61
+ let from = sourceApp
62
+ panel.show(text: text, source: from?.localizedName?.lowercased() ?? "desktop", returnTo: from)
63
+ }
64
+ }
65
+
66
+ /// SMAppService only lets an app register itself, so chi desktop runs the
67
+ /// binary with --login. Prints the status name; exit 1 on failure.
68
+ func loginCommand(_ arg: String) -> Int32 {
69
+ let service = SMAppService.mainApp
70
+ do {
71
+ switch arg {
72
+ case "on": if service.status != .enabled { try service.register() }
73
+ case "off": if service.status == .enabled || service.status == .requiresApproval { try service.unregister() }
74
+ case "status": break
75
+ default:
76
+ FileHandle.standardError.write("usage: ChiHelper --login on|off|status\n".data(using: .utf8)!)
77
+ return 2
78
+ }
79
+ } catch {
80
+ FileHandle.standardError.write("\(error.localizedDescription)\n".data(using: .utf8)!)
81
+ return 1
82
+ }
83
+ let names: [SMAppService.Status: String] = [.notRegistered: "notRegistered", .enabled: "enabled",
84
+ .requiresApproval: "requiresApproval", .notFound: "notFound"]
85
+ print(names[service.status] ?? "unknown")
86
+ return 0
87
+ }
88
+
89
+ @main
90
+ struct ChiHelperMain {
91
+ static func main() {
92
+ let args = CommandLine.arguments
93
+ if args.count >= 2, args[1] == "--login" {
94
+ exit(loginCommand(args.count >= 3 ? args[2] : ""))
95
+ }
96
+ let app = NSApplication.shared
97
+ let delegate = AppDelegate()
98
+ app.delegate = delegate
99
+ app.setActivationPolicy(.accessory)
100
+ app.run()
101
+ }
102
+ }
@@ -0,0 +1,201 @@
1
+ // Runs chi the way `chi desktop install` wrote down in launch.json: an
2
+ // absolute ruby + bin/chi and a few env vars, since an app started by
3
+ // launchd has none of the shell's setup. Talks to chi only through its CLI.
4
+ import Foundation
5
+
6
+ struct LaunchConfig: Decodable {
7
+ let version: String?
8
+ let argv: [String]
9
+ let env: [String: String]?
10
+ }
11
+
12
+ struct ChiResult {
13
+ let status: Int32
14
+ let stdout: String
15
+ let stderr: String
16
+ let timedOut: Bool
17
+ }
18
+
19
+ enum ChiError: Error, CustomStringConvertible {
20
+ case noLaunchFile(String)
21
+ case missing(String)
22
+ case failed(String)
23
+
24
+ var description: String {
25
+ switch self {
26
+ case .noLaunchFile(let path): return "No launch file at \(path). Run `chi desktop upgrade` in a terminal."
27
+ case .missing(let path): return "chi not found at \(path). Run `chi desktop upgrade` in a terminal."
28
+ case .failed(let message): return message
29
+ }
30
+ }
31
+ }
32
+
33
+ /// One session from `chi sessions list --format json`: a live one, or a
34
+ /// recent stopped one (listed below the live ones; a message wakes it).
35
+ struct LiveSession: Decodable, Identifiable, Equatable {
36
+ let id: String
37
+ let desc: String?
38
+ let cwd: String?
39
+ let busy: Bool?
40
+ let owner: String?
41
+ let updatedAt: String?
42
+ var recent = false
43
+
44
+ enum CodingKeys: String, CodingKey {
45
+ case id, desc, cwd, busy, owner
46
+ case updatedAt = "updated_at"
47
+ }
48
+
49
+ /// Only UUID-shaped ids go into chi's argv, so nothing can turn into a flag.
50
+ var valid: Bool { UUID(uuidString: id) != nil }
51
+
52
+ var shortCwd: String {
53
+ guard let cwd else { return "" }
54
+ let home = NSHomeDirectory()
55
+ return cwd.hasPrefix(home) ? "~" + cwd.dropFirst(home.count) : cwd
56
+ }
57
+
58
+ /// "just now", "5m ago", "2h ago", "yesterday", "3d ago", or "".
59
+ func age(now: Date = Date()) -> String {
60
+ guard let updatedAt else { return "" }
61
+ let precise = ISO8601DateFormatter()
62
+ precise.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
63
+ guard let date = precise.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt) else { return "" }
64
+ let seconds = max(0, now.timeIntervalSince(date))
65
+ switch seconds {
66
+ case ..<60: return "just now"
67
+ case ..<3600: return "\(Int(seconds / 60))m ago"
68
+ case ..<86_400: return "\(Int(seconds / 3600))h ago"
69
+ case ..<172_800: return "yesterday"
70
+ default: return "\(Int(seconds / 86_400))d ago"
71
+ }
72
+ }
73
+ }
74
+
75
+ final class ChiRunner {
76
+ static let supportDir = NSString(string: "~/Library/Application Support/Chi Helper").expandingTildeInPath
77
+ static let launchPath = supportDir + "/launch.json"
78
+
79
+ let timeout: TimeInterval
80
+
81
+ init(timeout: TimeInterval = 10) {
82
+ self.timeout = timeout
83
+ }
84
+
85
+ func loadLaunch() throws -> LaunchConfig {
86
+ guard let data = FileManager.default.contents(atPath: Self.launchPath) else {
87
+ throw ChiError.noLaunchFile(Self.launchPath)
88
+ }
89
+ let config: LaunchConfig
90
+ do { config = try JSONDecoder().decode(LaunchConfig.self, from: data) } catch {
91
+ throw ChiError.failed("Unreadable launch file \(Self.launchPath): \(error.localizedDescription)")
92
+ }
93
+ guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(Self.launchPath)") }
94
+ for path in config.argv where path.hasPrefix("/") && !FileManager.default.fileExists(atPath: path) {
95
+ throw ChiError.missing(path)
96
+ }
97
+ return config
98
+ }
99
+
100
+ /// Runs `chi <args>`, with stdin written from a background queue and
101
+ /// stdout/stderr drained as they come (no pipe deadlock near the 16 KiB
102
+ /// note cap). Calls back on the main queue.
103
+ func run(_ args: [String], stdin: Data? = nil, completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
104
+ let config: LaunchConfig
105
+ do { config = try loadLaunch() } catch let error as ChiError {
106
+ completion(.failure(error)); return
107
+ } catch {
108
+ completion(.failure(.failed("\(error)"))); return
109
+ }
110
+
111
+ let process = Process()
112
+ process.executableURL = URL(fileURLWithPath: config.argv[0])
113
+ process.arguments = Array(config.argv.dropFirst()) + args
114
+ process.environment = ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked }
115
+ process.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory())
116
+ let out = Pipe(), err = Pipe(), input = Pipe()
117
+ process.standardOutput = out
118
+ process.standardError = err
119
+ process.standardInput = stdin == nil ? FileHandle.nullDevice : input
120
+
121
+ let lock = NSLock()
122
+ var outData = Data(), errData = Data()
123
+ out.fileHandleForReading.readabilityHandler = { handle in
124
+ let chunk = handle.availableData
125
+ lock.lock(); outData.append(chunk); lock.unlock()
126
+ }
127
+ err.fileHandleForReading.readabilityHandler = { handle in
128
+ let chunk = handle.availableData
129
+ lock.lock(); errData.append(chunk); lock.unlock()
130
+ }
131
+
132
+ var timedOut = false
133
+ process.terminationHandler = { proc in
134
+ out.fileHandleForReading.readabilityHandler = nil
135
+ err.fileHandleForReading.readabilityHandler = nil
136
+ let restOut = out.fileHandleForReading.readDataToEndOfFile()
137
+ let restErr = err.fileHandleForReading.readDataToEndOfFile()
138
+ lock.lock()
139
+ outData.append(restOut); errData.append(restErr)
140
+ let result = ChiResult(status: proc.terminationStatus,
141
+ stdout: String(decoding: outData, as: UTF8.self),
142
+ stderr: String(decoding: errData, as: UTF8.self),
143
+ timedOut: timedOut)
144
+ lock.unlock()
145
+ DispatchQueue.main.async { completion(.success(result)) }
146
+ }
147
+
148
+ do { try process.run() } catch {
149
+ completion(.failure(.failed("Could not start \(config.argv[0]): \(error.localizedDescription)")))
150
+ return
151
+ }
152
+ if let stdin {
153
+ DispatchQueue.global().async {
154
+ try? input.fileHandleForWriting.write(contentsOf: stdin)
155
+ try? input.fileHandleForWriting.close()
156
+ }
157
+ }
158
+ DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
159
+ if process.isRunning {
160
+ lock.lock(); timedOut = true; lock.unlock()
161
+ process.terminate()
162
+ }
163
+ }
164
+ }
165
+
166
+ /// The live sessions, then up to +recentCount+ stopped ones, newest
167
+ /// first. A session a chi REPL holds is in neither: it takes no notes or
168
+ /// messages. A failed second call just leaves "recent" empty.
169
+ func sessions(recentCount: Int = 3, completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
170
+ list(["--live"]) { result in
171
+ guard case .success(let live) = result else { completion(result); return }
172
+ self.list(["--limit", "20"]) { recent in
173
+ let liveIds = Set(live.map(\.id))
174
+ let stopped = ((try? recent.get()) ?? [])
175
+ .filter { $0.owner == nil && !liveIds.contains($0.id) }
176
+ .prefix(recentCount)
177
+ .map { session -> LiveSession in var session = session; session.recent = true; return session }
178
+ completion(.success(live + stopped))
179
+ }
180
+ }
181
+ }
182
+
183
+ private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
184
+ // Every project's sessions: the helper isn't in any one project (an
185
+ // older chi ignores the flag).
186
+ run(["sessions", "list"] + flags + ["--scope=all", "--format", "json"]) { result in
187
+ switch result {
188
+ case .failure(let error): completion(.failure(error))
189
+ case .success(let r):
190
+ if r.timedOut { completion(.failure(.failed("chi sessions list took over \(Int(self.timeout)) s"))); return }
191
+ guard r.status == 0, let data = r.stdout.data(using: .utf8),
192
+ let sessions = try? JSONDecoder().decode([LiveSession].self, from: data) else {
193
+ let detail = r.stderr.isEmpty ? r.stdout : r.stderr
194
+ completion(.failure(.failed("chi sessions list failed (exit \(r.status)): \(detail.prefix(400))")))
195
+ return
196
+ }
197
+ completion(.success(sessions.filter(\.valid)))
198
+ }
199
+ }
200
+ }
201
+ }