samagotchi 0.66.0 → 0.67.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +50 -28
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +71 -29
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +17 -3
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/engine.rb +96 -191
  51. data/lib/samagotchi/engine_peers.rb +3 -0
  52. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  53. data/lib/samagotchi/guardrails/approval.rb +1 -1
  54. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  55. data/lib/samagotchi/guardrails/rules.rb +1 -1
  56. data/lib/samagotchi/guardrails/targets.rb +1 -1
  57. data/lib/samagotchi/hook_routes.rb +151 -0
  58. data/lib/samagotchi/hooks/registry.rb +2 -2
  59. data/lib/samagotchi/host_keys.rb +147 -0
  60. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  61. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  62. data/lib/samagotchi/llm/api_key.rb +23 -7
  63. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  64. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  65. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  66. data/lib/samagotchi/llm_context_override.rb +1 -0
  67. data/lib/samagotchi/peer_sources.rb +58 -0
  68. data/lib/samagotchi/plugin/api.rb +64 -155
  69. data/lib/samagotchi/plugin/loader.rb +4 -2
  70. data/lib/samagotchi/plugin/peer.rb +394 -0
  71. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  72. data/lib/samagotchi/plugin_tasks.rb +2 -2
  73. data/lib/samagotchi/row_refs.rb +1 -1
  74. data/lib/samagotchi/self_report.rb +5 -3
  75. data/lib/samagotchi/send_command.rb +21 -11
  76. data/lib/samagotchi/session_continue.rb +58 -15
  77. data/lib/samagotchi/session_inbox.rb +0 -13
  78. data/lib/samagotchi/tool_call_parser.rb +1 -1
  79. data/lib/samagotchi/toolkit_binding.rb +138 -0
  80. data/lib/samagotchi/tools/context_read.rb +14 -0
  81. data/lib/samagotchi/tools/execute.rb +12 -12
  82. data/lib/samagotchi/tools/registry.rb +1 -1
  83. data/lib/samagotchi/tools/task_create.rb +6 -6
  84. data/lib/samagotchi/tools/task_get.rb +13 -13
  85. data/lib/samagotchi/tools/task_list.rb +6 -6
  86. data/lib/samagotchi/tools/task_record.rb +52 -0
  87. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  88. data/lib/samagotchi/tools/task_stop.rb +6 -6
  89. data/lib/samagotchi/tools/task_wait.rb +12 -13
  90. data/lib/samagotchi/tools/tool_index.rb +454 -0
  91. data/lib/samagotchi/update_command.rb +9 -6
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +16 -9
  94. data/lib/samagotchi/web/capabilities.rb +1 -1
  95. data/lib/samagotchi/web/public/activity.js +2 -2
  96. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  97. data/lib/samagotchi/web/public/app.js +31 -36
  98. data/lib/samagotchi/web/public/chains.js +1 -1
  99. data/lib/samagotchi/web/public/commands.js +2 -2
  100. data/lib/samagotchi/web/public/composer_size.js +2 -2
  101. data/lib/samagotchi/web/public/copy.js +1 -1
  102. data/lib/samagotchi/web/public/ctx.js +1 -1
  103. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  104. data/lib/samagotchi/web/public/families_view.js +1 -1
  105. data/lib/samagotchi/web/public/format.js +11 -7
  106. data/lib/samagotchi/web/public/images.js +25 -2
  107. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  108. data/lib/samagotchi/web/public/notify.js +6 -4
  109. data/lib/samagotchi/web/public/question_card.js +1 -1
  110. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  111. data/lib/samagotchi/web/public/session_state.js +14 -0
  112. data/lib/samagotchi/web/public/session_title.js +2 -3
  113. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  114. data/lib/samagotchi/web/public/stage_model.js +2 -2
  115. data/lib/samagotchi/web/public/strip.js +3 -3
  116. data/lib/samagotchi/web/public/task_stop.js +3 -2
  117. data/lib/samagotchi/web/public/turn_events.js +1 -1
  118. data/lib/samagotchi/web/public/turn_html.js +2 -2
  119. data/lib/samagotchi/web/public/turn_model.js +2 -2
  120. data/lib/samagotchi/web/public/turn_view.js +1 -1
  121. data/lib/samagotchi/web/server.rb +0 -4
  122. data/lib/samagotchi/worker.rb +11 -8
  123. data/lib/samagotchi/worker_inbound.rb +36 -4
  124. data/lib/samagotchi/worker_wakes.rb +67 -7
  125. metadata +14 -1
@@ -20,6 +20,9 @@ module Samagotchi
20
20
  def model_ref = engine.effective_model_ref
21
21
  def thinking = engine.thinking_override
22
22
  def reasoning_history = engine.reasoning_history_override
23
+ # The peer's name when +name+ is a source a peer bundle declared
24
+ # (PeerSources), for context_read's header.
25
+ def peer_label(name) = engine.peer_sources&.label_for(name)
23
26
  end
24
27
 
25
28
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -228,7 +228,7 @@ module Samagotchi
228
228
 
229
229
  # A plugin tool is asked about by its targets:' label:, else its
230
230
  # label, as its row shows it; a call that acts as another tool (an MCP
231
- # tool behind mcp_call) by that tool's label, else (none registered)
231
+ # tool behind call_tool) by that tool's label, else (none registered)
232
232
  # by its name (Approval.payload).
233
233
  acts_as = verdict.targets&.acts_as
234
234
  label = verdict.targets&.label ||
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  kind: KIND,
39
39
  approval: {
40
40
  tool: targets&.tool || verdict.call[:name].to_s,
41
- # The tool the call acts as (an MCP tool behind mcp_call).
41
+ # The tool the call acts as (an MCP tool behind call_tool).
42
42
  acts_as: targets&.acts_as,
43
43
  label: label,
44
44
  command: targets&.command,
@@ -111,7 +111,7 @@ module Samagotchi
111
111
  # tool + the normalized command, or the sorted paths; for a plugin
112
112
  # tool with neither (an MCP tool), its arguments, so "this call" is
113
113
  # this call. A call that acts as another tool keys as
114
- # `mcp_call>mcp_github_x`: the real name stays in, so it never reuses
114
+ # `call_tool>mcp_github_x`: the real name stays in, so it never reuses
115
115
  # that tool's own approvals (nor they its).
116
116
  def self.key_for(verdict)
117
117
  t = verdict.targets
@@ -12,7 +12,7 @@ module Samagotchi
12
12
  # tool: a tool name, a list, or "shell" (execute + task_create);
13
13
  # a name may be a glob ("mcp_*", "mcp_{git,gh}_*"); it
14
14
  # matches the call's name or the tool a plugin tool's call
15
- # acts as (Targets#acts_as: an MCP tool behind mcp_call)
15
+ # acts as (Targets#acts_as: an MCP tool behind call_tool)
16
16
  # command: a Ruby regex on a shell tool's command
17
17
  # path: "outside_repo", or a glob on the resolved paths (absolute
18
18
  # or "**/…" globs match the absolute path; others the path
@@ -50,7 +50,7 @@ module Samagotchi
50
50
  attr_reader :args
51
51
 
52
52
  # @return [String, nil] the tool a plugin tool's call acts as (its
53
- # targets:' acts_as:, an MCP tool behind mcp_call): a rule's tool:
53
+ # targets:' acts_as:, an MCP tool behind call_tool): a rule's tool:
54
54
  # matches it as well as the call's name. Never a core tool's or
55
55
  # another bundle's (.plugin_targets drops those). #tool stays the
56
56
  # call's name.
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hooks"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # The Engine's routes to the UIs for what a hook or a plugin does beyond
8
+ # reading its event (Hooks::Runtime, Plugin::Host): a notice (#notify),
9
+ # a question (#ask_user), stopping the turn or cutting its generation,
10
+ # and a steer; also the guardrails' line after load (#guardrail_notify).
11
+ #
12
+ # What it needs from the Engine comes through lookups, called at use.
13
+ class HookRoutes
14
+ # What a notice may stand in for (#notify's fallback_for:).
15
+ NOTICE_FALLBACKS = %i[display editor].freeze
16
+
17
+ # @param extension_load [#call] → ExtensionLoad (#loading?, #hold)
18
+ # @param in_turn_sink [#call] → [Boolean, sink] (TurnState#in_turn_sink)
19
+ # @param anytime_thread [#call] → whether this thread runs an anytime command
20
+ # @param announce_anytime [#call] (event) → announces an anytime command's event
21
+ # @param init_task [#call] → the init task running on this thread, nil
22
+ # @param announce [#call] (event) → announces an event
23
+ # @param announce_or_hold [#call] (event) → announces it, or holds it for the caller
24
+ # @param emit [#call] (sink, event) → emits to a turn's sink + observers
25
+ # @param question_desk [#call] → QuestionDesk
26
+ # @param cancel_controller [#call] → the running turn's CancellationController, nil
27
+ # @param steer [#call] (text, source) → steers the running turn
28
+ def initialize(extension_load:, in_turn_sink:, anytime_thread:, announce_anytime:, init_task:, announce:,
29
+ announce_or_hold:, emit:, question_desk:, cancel_controller:, steer:)
30
+ @extension_load = extension_load
31
+ @in_turn_sink = in_turn_sink
32
+ @anytime_thread = anytime_thread
33
+ @announce_anytime = announce_anytime
34
+ @init_task = init_task
35
+ @announce = announce
36
+ @announce_or_hold = announce_or_hold
37
+ @emit = emit
38
+ @question_desk = question_desk
39
+ @cancel_controller = cancel_controller
40
+ @steer = steer
41
+ @unknown_fallbacks = Set.new
42
+ end
43
+
44
+ # The things a hook can do beyond reading its event. Each gets the
45
+ # hook's label (event[:hook]) from the registry.
46
+ # @return [Hooks::Runtime]
47
+ def runtime
48
+ Hooks::Runtime.new(
49
+ notify: ->(text:, level:, hook:, fallback_for: nil) { notify(text, level, hook, fallback_for: fallback_for) },
50
+ ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { ask_user(question, options, header, allow_freeform, hook) },
51
+ stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
52
+ steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
53
+ stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
54
+ )
55
+ end
56
+
57
+ # What a steer is attributed to: the bundle of a bundle hook's label
58
+ # ("<file> (bundle <name>)"), the label of any other.
59
+ def source(hook)
60
+ hook.to_s[/\(bundle (.+)\)\z/, 1] || hook.to_s
61
+ end
62
+
63
+ # One line to the user (:hook_notice). During a turn it is a turn
64
+ # event: the turn's sink (the REPL) and the observers (bridge, log).
65
+ # Outside one (a plugin's command at the prompt) it is announced with
66
+ # between_turns: true, which every UI shows as cards are shown.
67
+ #
68
+ # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
69
+ # :display, the answer's display (AnswerDisplay), for a UI that doesn't
70
+ # render its links; :editor, a ref the viewer could open in its editor
71
+ # (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
72
+ # does leaves the line out; the others, the log among them, show it. An
73
+ # unknown value is dropped, so the line shows everywhere.
74
+ # +extra+: more fields for the UIs (a toolkit change's summary).
75
+ def notify(text, level, hook, fallback_for: nil, extra: {})
76
+ level = (level || :info).to_sym
77
+ level = :info unless %i[info warn].include?(level)
78
+ notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
79
+ fallback_for: notice_fallback(fallback_for, hook) }.compact.merge(extra)
80
+ extension_load = @extension_load.call
81
+ return extension_load.hold(notice) && nil if extension_load.loading?
82
+
83
+ in_turn, sink = @in_turn_sink.call
84
+ if @anytime_thread.call
85
+ @announce_anytime.call(notice.merge(between_turns: true))
86
+ elsif @init_task.call
87
+ @announce.call(notice.merge(between_turns: true))
88
+ elsif in_turn
89
+ @emit.call(sink, notice)
90
+ else
91
+ @announce_or_hold.call(notice.merge(between_turns: true))
92
+ end
93
+ nil
94
+ end
95
+
96
+ # A guardrails line to the user after load (:guardrail_warning, as the
97
+ # load failures show): a running turn's event, else announced.
98
+ def guardrail_notify(message)
99
+ event = { type: :guardrail_warning, message: message.to_s }
100
+ in_turn, sink = @in_turn_sink.call
101
+ in_turn ? @emit.call(sink, event) : @announce_or_hold.call(event)
102
+ nil
103
+ end
104
+
105
+ # A question through the question flow (REPL sync handler, attached TUI,
106
+ # web), single-select, kind "hook". A --non-interactive run has no one
107
+ # to ask: nil at once. Anything but an answer (a sync handler's text, no
108
+ # answer, cancelled) is nil too.
109
+ # @return [Hash, nil] {selected:, freeform:, selected_indices:}
110
+ def ask_user(question, options, header, allow_freeform, hook)
111
+ @question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook)
112
+ end
113
+
114
+ # Cancel the running turn (reason :hook), after a notice that says why.
115
+ # The gate denies the rest of a tool batch once the controller is
116
+ # cancelled; the next request ends the turn as :turn_canceled.
117
+ # @return [Boolean] true when a running turn was cancelled now
118
+ def stop_turn(reason, hook)
119
+ ctrl = @cancel_controller.call
120
+ return false unless ctrl && !ctrl.cancelled?
121
+
122
+ notify("stopped the turn: #{reason}", :warn, hook)
123
+ ctrl.cancel!(:hook, { by: source(hook), reason: reason.to_s })
124
+ end
125
+
126
+ # Cut the streaming generation (reason :hook); the turn goes on and asks
127
+ # the model again (the loops' cut path). No notice: the plugin posts its
128
+ # own. The bundle and +reason+ go to the log and the retry's nudge.
129
+ # @return [Boolean] true when a streaming generation was cut now
130
+ def stop_generation(reason, hook)
131
+ ctrl = @cancel_controller.call
132
+ return false unless ctrl
133
+
134
+ ctrl.cancel_generation!(:hook, { by: source(hook), reason: reason.to_s })
135
+ end
136
+
137
+ private
138
+
139
+ # A known fallback_for (a String is symbolized), else nil; an unknown
140
+ # one is logged once at debug.
141
+ def notice_fallback(value, hook)
142
+ return if value.nil?
143
+
144
+ fallback = value.to_s.to_sym
145
+ return fallback if NOTICE_FALLBACKS.include?(fallback)
146
+
147
+ Log.debug(:hooks, "notice_unknown_fallback", hook: hook.to_s, value: value.to_s) if @unknown_fallbacks.add?(fallback)
148
+ nil
149
+ end
150
+ end
151
+ end
@@ -84,7 +84,7 @@ module Samagotchi
84
84
  # What a hook can do beyond reading its event: the Engine's
85
85
  # callables, each given the hook's label. +notify+ takes
86
86
  # (text:, level:, hook:, and fallback_for: when the hook gave one: what
87
- # the line stands in for, Engine#hook_notify) and shows one line to the
87
+ # the line stands in for, HookRoutes#notify) and shows one line to the
88
88
  # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:) and
89
89
  # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
90
90
  # cancels the running turn (true when it did); +steer+ takes (text:,
@@ -243,7 +243,7 @@ module Samagotchi
243
243
  # event[:notify].call(text, level: :info, fallback_for: nil) one line
244
244
  # to the user; fallback_for: :display marks a line that stands in
245
245
  # for the answer's display (event[:present]), which a UI that
246
- # renders the display's links leaves out (Engine#hook_notify)
246
+ # renders the display's links leaves out (HookRoutes#notify)
247
247
  # event[:ask_user].call(question:, options:, header: nil, allow_freeform: false)
248
248
  # -> {selected:, freeform:, selected_indices:} or nil (no one to
249
249
  # ask, cancelled, bad options)
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # Where a host's API key named VAR (its api_key_env:) comes from: the
8
+ # environment variable when set, else, on macOS, the login Keychain's
9
+ # generic password with service `chi` and account VAR (apps macOS starts,
10
+ # like the Chi Helper, get a bare environment). SAMAGOTCHI_KEYCHAIN=off
11
+ # skips the Keychain (specs set it, so nothing they start reads it).
12
+ #
13
+ # Keychain hits are cached for the process (every request authorizes,
14
+ # and a `security` run costs ~0.1 s); misses are not, so a key added to
15
+ # the Keychain works on the next try. A key is never logged, raised or
16
+ # shown by inspect.
17
+ module HostKeys
18
+ SERVICE = "chi"
19
+ OFF_ENV = "SAMAGOTCHI_KEYCHAIN"
20
+
21
+ # Reads one generic password through /usr/bin/security (by absolute
22
+ # path: the helper's PATH isn't the shell's) in its own process group,
23
+ # killed at the deadline: a locked keychain or an access dialog must not
24
+ # hang a turn. nil on a miss, a non-zero exit, a timeout, no binary, or
25
+ # off macOS.
26
+ class Keychain
27
+ SECURITY = "/usr/bin/security"
28
+ TIMEOUT = 5
29
+
30
+ def initialize(security: SECURITY, timeout: TIMEOUT, host_os: RbConfig::CONFIG["host_os"])
31
+ @security = security
32
+ @timeout = timeout
33
+ @macos = host_os.to_s.include?("darwin")
34
+ end
35
+
36
+ # @return [String, nil]
37
+ def read(name)
38
+ return nil unless @macos && File.executable?(@security)
39
+
40
+ run(name)
41
+ end
42
+
43
+ def inspect = "#<#{self.class.name} #{@security}>"
44
+
45
+ private
46
+
47
+ def run(name)
48
+ reader, writer = IO.pipe
49
+ pid = Process.spawn(@security, "find-generic-password", "-s", SERVICE, "-a", name, "-w",
50
+ in: File::NULL, out: writer, err: File::NULL, pgroup: true)
51
+ writer.close
52
+ waiter = Process.detach(pid)
53
+ unless waiter.join(@timeout)
54
+ stop(pid, waiter)
55
+ Log.warn(:config, "keychain_timeout", name: name, seconds: @timeout)
56
+ return nil
57
+ end
58
+ return nil unless waiter.value.success?
59
+
60
+ key = reader.read.to_s.chomp
61
+ key.strip.empty? ? nil : key
62
+ rescue SystemCallError
63
+ nil
64
+ ensure
65
+ writer&.close unless writer&.closed?
66
+ reader&.close
67
+ end
68
+
69
+ def stop(pid, waiter)
70
+ Process.kill(:KILL, -pid)
71
+ rescue SystemCallError
72
+ nil
73
+ ensure
74
+ waiter.join(1)
75
+ end
76
+ end
77
+
78
+ # Never reads anything (specs).
79
+ module NullKeychain
80
+ def self.read(_name) = nil
81
+ end
82
+
83
+ @keychain = Keychain.new
84
+ @cache = {}
85
+ @mutex = Mutex.new
86
+
87
+ class << self
88
+ # @return [#read] where Keychain reads go (specs inject a fake)
89
+ attr_reader :keychain
90
+
91
+ def keychain=(reader)
92
+ @mutex.synchronize do
93
+ @keychain = reader
94
+ @cache.clear
95
+ end
96
+ end
97
+
98
+ # @return [String, nil] the key: the variable when set, else the Keychain's
99
+ def fetch(name, env: ENV)
100
+ from_env(name, env) || from_keychain(name, env: env)
101
+ end
102
+
103
+ # @return [Symbol, nil] :env, :keychain or nil (never the key)
104
+ def source(name, env: ENV)
105
+ return :env if from_env(name, env)
106
+
107
+ from_keychain(name, env: env) ? :keychain : nil
108
+ end
109
+
110
+ # The Keychain's key for +name+ whatever the environment holds (chi
111
+ # desktop status asks what the helper will get), nil when off.
112
+ def from_keychain(name, env: ENV)
113
+ return nil if off?(env)
114
+
115
+ name = name.to_s
116
+ cached = @mutex.synchronize { @cache[name] }
117
+ return cached if cached
118
+
119
+ key = keychain.read(name)
120
+ @mutex.synchronize { @cache[name] = key } if key
121
+ key
122
+ end
123
+
124
+ # SAMAGOTCHI_KEYCHAIN=off (any case).
125
+ def off?(env = ENV) = env[OFF_ENV].to_s.strip.downcase == "off"
126
+
127
+ # Whether this process reads the Keychain: macOS and not switched off
128
+ # (the messages name the Keychain only then).
129
+ def available?(host_os = RbConfig::CONFIG["host_os"], env: ENV) = host_os.to_s.include?("darwin") && !off?(env)
130
+
131
+ # The command that stores the key for +name+ (prompting for it).
132
+ def store_command(name) = "security add-generic-password -s #{SERVICE} -a #{name} -w"
133
+
134
+ def clear_cache! = @mutex.synchronize { @cache.clear }
135
+
136
+ # Names only: the cache holds keys.
137
+ def inspect = "#<#{name} cached=#{@mutex.synchronize { @cache.keys }.inspect}>"
138
+
139
+ private
140
+
141
+ def from_env(name, env)
142
+ value = env[name.to_s].to_s
143
+ value.strip.empty? ? nil : value
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require_relative "../config"
6
+ require_relative "api_key"
7
+ require_relative "errors"
8
+ require_relative "http"
9
+
10
+ module Samagotchi
11
+ module LLM
12
+ # What the chat adapters (OpenAIChat, AnthropicMessages) share: the HTTP
13
+ # layer and its timeouts, the paged model list, the SSE line loop with
14
+ # its plain-JSON-error check, and JSON parsing into ProtocolError. The
15
+ # includer sets @host_name and @http (#build_http); the wire itself (the
16
+ # body, the headers, the assembly) stays in each adapter.
17
+ module AdapterCommon
18
+ MAX_MODEL_PAGES = 20
19
+ DEFAULT_MODELS_TTL = 60
20
+ # How much of a 200's non-stream body is read as a possible JSON error.
21
+ PLAIN_ERROR_LIMIT = 64 * 1024
22
+
23
+ private
24
+
25
+ # The adapter's LLM::HTTP: the timeouts (#timeouts), one attempt when
26
+ # +retries+ is false and no +retry_policy+ is given.
27
+ def build_http(timeout:, open_timeout:, retries:, retry_policy:, sleeper:, first_token_timeout:, api_key:)
28
+ open_timeout, read_timeout = timeouts(timeout, open_timeout)
29
+ policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
30
+ HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout, retry_policy: policy,
31
+ sleeper: sleeper, first_token_timeout: first_token_timeout, api_key: api_key)
32
+ end
33
+
34
+ # [open, read] seconds: server.open_timeout / server.read_timeout,
35
+ # the open one capped by +open_limit+; a +timeout+ is the read timeout
36
+ # and caps the open one.
37
+ def timeouts(timeout, open_limit = nil)
38
+ open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
39
+ read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
40
+ open_timeout = [open_timeout, open_limit].min if open_limit
41
+ return [open_timeout, read_timeout] unless timeout
42
+
43
+ [[open_timeout, timeout].min, timeout]
44
+ end
45
+
46
+ # Every page of the host's model list (MAX_MODEL_PAGES at most): the
47
+ # block gives a page's URI for the last page's last_id (nil first).
48
+ # @return [Array<ModelInfo>]
49
+ def list_model_pages
50
+ models = []
51
+ after = nil
52
+ MAX_MODEL_PAGES.times do
53
+ uri = yield(after)
54
+ body = parse_json(@http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" }).body, "model list")
55
+ models.concat(model_entries(body).map { |raw| model_info(raw) })
56
+ break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
57
+
58
+ after = body["last_id"].to_s
59
+ end
60
+ models
61
+ end
62
+
63
+ # The streamed answer: each `data:` line's parsed payload goes to the
64
+ # block with the assembly (a fresh +assembly_class+ per attempt: a
65
+ # retry streams the answer from the start again) and the stream's
66
+ # shown callback; the assembly's response at the end. +what+ names a
67
+ # payload in a malformed-JSON error.
68
+ def stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class:, what:)
69
+ assembly = assembly_class.new
70
+ events = 0
71
+ other = +""
72
+ plain = +""
73
+ restart = lambda do |**event|
74
+ assembly = assembly_class.new
75
+ plain = +""
76
+ on_retry&.call(**event)
77
+ end
78
+ @http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
79
+ log_fields: log_fields) do |line, shown|
80
+ events += 1 if line.start_with?("data:")
81
+ other << line[0, 200] if !line.start_with?("data:") && other.length < 200
82
+ raise_plain_error(plain, line) if events.zero?
83
+ payload = parse_line(line, what)
84
+ next unless payload
85
+
86
+ yield assembly, payload, shown
87
+ end
88
+ # A server that ignores stream: true, or answers with something else
89
+ # entirely, would otherwise end the turn with no answer and no error.
90
+ raise ProtocolError.new("#{@host_name}: the response had no stream events: #{other}", host: @host_name) if events.zero?
91
+
92
+ assembly.response
93
+ end
94
+
95
+ # A 200 whose body is a plain JSON error instead of a stream (OpenRouter
96
+ # sends an upstream 503 so): raised, from inside the stream so it is
97
+ # retried by its kind, once the lines so far parse as an error object.
98
+ def raise_plain_error(plain, line)
99
+ return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
100
+ return if plain.bytesize > PLAIN_ERROR_LIMIT
101
+
102
+ plain << line << "\n"
103
+ error = ProviderErrors.from_error_body(plain, host: @host_name)
104
+ raise error if error
105
+ end
106
+
107
+ # The parsed payload of a `data:` line; nil for `event:` lines, blank
108
+ # lines, comments and [DONE]. An error event raises its ProviderError.
109
+ def parse_line(line, what)
110
+ error = HTTP.sse_error(line, host: @host_name)
111
+ raise error if error
112
+ return nil unless line.start_with?("data:")
113
+
114
+ data = line.delete_prefix("data:").strip
115
+ return nil if data.empty? || data == "[DONE]"
116
+
117
+ payload = parse_json(data, what)
118
+ payload.is_a?(Hash) ? payload : nil
119
+ end
120
+
121
+ def parse_json(text, what)
122
+ JSON.parse(text.to_s)
123
+ rescue JSON::ParserError => e
124
+ raise ProtocolError.new("#{@host_name}: malformed #{what}: #{e.message[0, 200]}", host: @host_name)
125
+ end
126
+
127
+ # A JSON POST to +url+: Content-Type, then +headers+ in order, the
128
+ # body generated from +body+.
129
+ def json_post(url, body, headers = {})
130
+ Net::HTTP::Post.new(URI(url)).tap do |request|
131
+ request["Content-Type"] = "application/json"
132
+ headers.each { |name, value| request[name] = value }
133
+ request.body = JSON.generate(body)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end