samagotchi 0.65.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +50 -28
- data/docs/cli.md +14 -2
- data/docs/configuration.md +101 -16
- data/docs/context.md +39 -2
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +75 -30
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +36 -13
- data/docs/memory.md +1 -1
- data/docs/plugins.md +289 -25
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +16 -6
- data/docs/sub-agent.md +6 -1
- data/docs/testing.md +17 -3
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +23 -8
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/engine.rb +120 -107
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +138 -156
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +5 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +41 -15
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -9
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +52 -43
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/commands.js +6 -4
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +17 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_model.js +2 -2
- data/lib/samagotchi/web/public/stream_handlers.js +13 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +13 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +23 -1
|
@@ -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,
|
|
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 (
|
|
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
|
|
@@ -1067,9 +1067,17 @@ module Samagotchi
|
|
|
1067
1067
|
# The error an unknown tool name gets back. A shell-like name with
|
|
1068
1068
|
# `execute` offered this turn points at `execute`; every other unknown
|
|
1069
1069
|
# name lists what is offered. Neither echoes the wrong name.
|
|
1070
|
+
#
|
|
1071
|
+
# A tool the session's toolkit leaves out is a real one, so its error
|
|
1072
|
+
# does name it (deliberately, against the rule above): the model learns
|
|
1073
|
+
# it exists and that only the user can load it.
|
|
1070
1074
|
def unknown_tool_error(name, layers)
|
|
1071
1075
|
names = @tools.names(layers: layers)
|
|
1072
|
-
if
|
|
1076
|
+
if @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
|
|
1077
|
+
toolkit = @tools.toolkit&.name
|
|
1078
|
+
"Error: #{name} isn't loaded in this session#{" (toolkit #{toolkit})" if toolkit} and can't be loaded from " \
|
|
1079
|
+
"here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
|
|
1080
|
+
elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
|
|
1073
1081
|
"Error: no such tool. Shell commands run with `execute` (same arguments)."
|
|
1074
1082
|
else
|
|
1075
1083
|
"Error: no such tool. Available: #{names.join(", ")}"
|
|
@@ -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
|
|
@@ -102,9 +102,11 @@ module Samagotchi
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# The usage so far as an OpenAI-shaped `usage` object, nil when the
|
|
105
|
-
# server sent no counts
|
|
105
|
+
# server sent no counts, or only zeros (Fireworks' message_start: its
|
|
106
|
+
# counts come in the last message_delta; a 0 read as an empty context
|
|
107
|
+
# in the web's ctx meter while the answer streamed).
|
|
106
108
|
def usage_payload
|
|
107
|
-
return nil if @usage.
|
|
109
|
+
return nil if @usage.values.none?(&:positive?)
|
|
108
110
|
|
|
109
111
|
read = @usage["cache_read_input_tokens"].to_i
|
|
110
112
|
write = @usage["cache_creation_input_tokens"].to_i
|