samagotchi 0.66.0 → 0.68.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 +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- 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 +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- 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 +34 -313
- 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 +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- 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/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- 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/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- 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/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- 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/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -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/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- 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 +71 -93
- 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 +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- 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 +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- 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 +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -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_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -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/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/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +27 -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
|
|
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
|
|
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
|
-
# `
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cache_confirm"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A command for a session's own level of a setting (/thinking,
|
|
7
|
+
# /reasoning-history): with no argument, the level the next turn runs at
|
|
8
|
+
# and where it came from; with a level, sets the session's own (the
|
|
9
|
+
# setting's unset word unsets it). A change takes effect at the next
|
|
10
|
+
# turn's start (the REPL and the worker run it between turns), and the
|
|
11
|
+
# reply says what it costs the prompt cache. A costly change asks first
|
|
12
|
+
# (CacheConfirm); --yes (or -y) applies it.
|
|
13
|
+
#
|
|
14
|
+
# A subclass gives its Spec (#spec): what differs between the settings.
|
|
15
|
+
class LevelSettingCommand
|
|
16
|
+
# name: the command; label: the reply's ("thinking: low (session)");
|
|
17
|
+
# usage: the usage line an unknown word gets;
|
|
18
|
+
# parse: word → [known?, the session's own level (nil unsets)];
|
|
19
|
+
# own: engine → the session's own level now;
|
|
20
|
+
# explained: (engine, own) → SettingExplained, nil when it can't be resolved;
|
|
21
|
+
# set: (engine, own) → sets the session's own;
|
|
22
|
+
# cache_cost: (engine, from_level, to_level) → a cost_notes key, nil for none;
|
|
23
|
+
# cost_notes: {cost => the reply's line};
|
|
24
|
+
# follows: what the session follows with no own level;
|
|
25
|
+
# extra_line: engine → one more listing line, nil for none.
|
|
26
|
+
Spec = Data.define(:name, :label, :usage, :parse, :own, :explained, :set, :cache_cost, :cost_notes, :follows,
|
|
27
|
+
:extra_line)
|
|
28
|
+
|
|
29
|
+
# @param engine [Engine] the session's (#session, #history? and the spec's readers)
|
|
30
|
+
# @param save [#call] saves the session after a change
|
|
31
|
+
def initialize(engine:, save:)
|
|
32
|
+
@engine = engine
|
|
33
|
+
@save = save
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param args [String] what follows the command (--yes anywhere)
|
|
37
|
+
# @param confirm [Boolean] ask before a costly change (CacheConfirm);
|
|
38
|
+
# false applies it at once (chi send, a delegate, a start flag)
|
|
39
|
+
# @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
|
|
40
|
+
# whether the level changed, and the confirm it asks for (nothing
|
|
41
|
+
# changed then)
|
|
42
|
+
# @raise [ArgumentError] a word that isn't a level (with the usage)
|
|
43
|
+
def run(args, confirm: true)
|
|
44
|
+
word, yes = CacheConfirm.split(args)
|
|
45
|
+
return [listing(explained), false, nil] if word.empty?
|
|
46
|
+
|
|
47
|
+
known, own = spec.parse.call(word)
|
|
48
|
+
raise ArgumentError, "unknown #{spec.label} level #{word}\n#{spec.usage}" unless known
|
|
49
|
+
|
|
50
|
+
session = @engine.session or raise ArgumentError, "no session yet"
|
|
51
|
+
before = explained
|
|
52
|
+
ask = confirm && !yes ? ask(before, own, word) : nil
|
|
53
|
+
return ask if ask
|
|
54
|
+
|
|
55
|
+
spec.set.call(@engine, own)
|
|
56
|
+
@save.call(session)
|
|
57
|
+
after = explained
|
|
58
|
+
[[listing(after), *notes(before, after)].join("\n"), true, nil]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The level, where it came from, and what the session sets itself.
|
|
62
|
+
def listing(explained)
|
|
63
|
+
return "#{spec.label}: can't be resolved for this model" unless explained
|
|
64
|
+
|
|
65
|
+
lines = ["#{spec.label}: #{explained.label}"]
|
|
66
|
+
lines << " this session's own: none (follows #{spec.follows})" unless explained.own
|
|
67
|
+
extra = spec.extra_line&.call(@engine)
|
|
68
|
+
lines << extra if extra
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def spec = self.class::SPEC
|
|
75
|
+
|
|
76
|
+
def explained(own: spec.own.call(@engine)) = spec.explained.call(@engine, own)
|
|
77
|
+
|
|
78
|
+
# The reply that asks before a costly change, nil when it needn't.
|
|
79
|
+
def ask(before, own, word)
|
|
80
|
+
would = explained(own: own)
|
|
81
|
+
return nil unless before && would
|
|
82
|
+
|
|
83
|
+
cost = spec.cache_cost.call(@engine, before.level, would.level)
|
|
84
|
+
return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
|
|
85
|
+
|
|
86
|
+
step = CacheConfirm.confirm(spec.name, word)
|
|
87
|
+
[[listing(before), "Switching to #{would.level} restarts the prompt cache: #{spec.cost_notes[cost]}", step.text].join("\n"),
|
|
88
|
+
false, step]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def notes(before, after)
|
|
92
|
+
return [] unless before && after
|
|
93
|
+
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
94
|
+
|
|
95
|
+
cost = spec.cache_cost.call(@engine, before.level, after.level)
|
|
96
|
+
["From the next turn's start (a running turn keeps its own).", spec.cost_notes[cost]].compact
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
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
|