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
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "level_setting_command"
|
|
4
4
|
require_relative "thinking"
|
|
5
5
|
|
|
6
6
|
module Samagotchi
|
|
7
|
-
# /thinking, the session's own thinking level (Session#thinking):
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
# the reply says what it costs the prompt cache (Thinking.cache_cost).
|
|
14
|
-
# A costly change asks first (CacheConfirm); --yes (or -y) applies it.
|
|
15
|
-
class ThinkingCommand
|
|
7
|
+
# /thinking, the session's own thinking level (Session#thinking):
|
|
8
|
+
# `/thinking off|low|medium|high` sets it, `/thinking default` unsets it
|
|
9
|
+
# (the session follows the process default, the model, its host, then
|
|
10
|
+
# thinking.level). What a change costs the prompt cache:
|
|
11
|
+
# Thinking.cache_cost. See LevelSettingCommand.
|
|
12
|
+
class ThinkingCommand < LevelSettingCommand
|
|
16
13
|
NAME = "/thinking"
|
|
17
14
|
SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
|
|
18
15
|
USAGE = "usage: #{NAME} [#{Thinking::LEVELS.join("|")}]".freeze
|
|
@@ -24,71 +21,22 @@ module Samagotchi
|
|
|
24
21
|
tail: "Only the prompt's tail changes: the cache keeps the rest."
|
|
25
22
|
}.freeze
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return [listing(@engine.thinking_explained), false, nil] if word.empty?
|
|
45
|
-
|
|
46
|
-
level = word.to_sym
|
|
47
|
-
raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
|
|
48
|
-
|
|
49
|
-
session = @engine.session or raise ArgumentError, "no session yet"
|
|
50
|
-
own = SETTABLE.include?(level) ? level : nil
|
|
51
|
-
before = @engine.thinking_explained
|
|
52
|
-
ask = confirm && !yes ? ask(before, own, word) : nil
|
|
53
|
-
return ask if ask
|
|
54
|
-
|
|
55
|
-
@engine.thinking_override = own
|
|
56
|
-
@save.call(session)
|
|
57
|
-
after = @engine.thinking_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 "thinking: can't be resolved for this model" unless explained
|
|
64
|
-
|
|
65
|
-
lines = ["thinking: #{explained.label}"]
|
|
66
|
-
unless explained.own
|
|
67
|
-
lines << " this session's own: none (follows chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, " \
|
|
68
|
-
"its host, then thinking.level)"
|
|
69
|
-
end
|
|
70
|
-
lines.join("\n")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
# The reply that asks before a costly change, nil when it needn't.
|
|
76
|
-
def ask(before, own, word)
|
|
77
|
-
would = @engine.thinking_explained(own: own)
|
|
78
|
-
return nil unless before && would
|
|
79
|
-
|
|
80
|
-
cost = @engine.thinking_cache_cost
|
|
81
|
-
return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
|
|
82
|
-
|
|
83
|
-
step = CacheConfirm.confirm(NAME, word)
|
|
84
|
-
[[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def notes(before, after)
|
|
88
|
-
return [] unless before && after
|
|
89
|
-
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
90
|
-
|
|
91
|
-
["From the next turn's start (a running turn keeps its own).", COST_NOTES[@engine.thinking_cache_cost]].compact
|
|
92
|
-
end
|
|
24
|
+
SPEC = Spec.new(
|
|
25
|
+
name: NAME, label: "thinking", usage: USAGE, cost_notes: COST_NOTES,
|
|
26
|
+
parse: lambda { |word|
|
|
27
|
+
level = word.to_sym
|
|
28
|
+
if Thinking::LEVELS.include?(level)
|
|
29
|
+
[true, SETTABLE.include?(level) ? level : nil]
|
|
30
|
+
else
|
|
31
|
+
[false, nil]
|
|
32
|
+
end
|
|
33
|
+
},
|
|
34
|
+
own: lambda(&:thinking_override),
|
|
35
|
+
explained: ->(engine, own) { engine.thinking_explained(own: own) },
|
|
36
|
+
set: ->(engine, own) { engine.thinking_override = own },
|
|
37
|
+
cache_cost: ->(engine, _from, _to) { engine.thinking_cache_cost },
|
|
38
|
+
follows: "chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, its host, then thinking.level",
|
|
39
|
+
extra_line: nil
|
|
40
|
+
)
|
|
93
41
|
end
|
|
94
42
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effective_model"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "thinking"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# What the thinking level a turn runs at leads to, said to the user: a
|
|
9
|
+
# level its host can't do (an effort on a native host, or on a llama.cpp
|
|
10
|
+
# chat template that takes none), off that the model didn't honour, and a
|
|
11
|
+
# host refusing the thinking fields or the reasoning sent back. Each
|
|
12
|
+
# notice once per session and host; the log lines each time.
|
|
13
|
+
#
|
|
14
|
+
# Turn thread only (the turn's settings and its stream handler): no lock.
|
|
15
|
+
# What it needs from the Engine comes through lookups, called at use.
|
|
16
|
+
class ThinkingNotices
|
|
17
|
+
# @param session [#call] → Session, nil (its id: once per session)
|
|
18
|
+
# @param profile [#call] → ModelProfile (a native host's)
|
|
19
|
+
# @param notify [#call] (text, level, source) → a notice
|
|
20
|
+
def initialize(session:, profile:, notify:)
|
|
21
|
+
@session = session
|
|
22
|
+
@profile = profile
|
|
23
|
+
@notify = notify
|
|
24
|
+
@said = Set.new
|
|
25
|
+
@turn = nil
|
|
26
|
+
@generation_thought = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# [level, HostRegistry::ModelTarget] the turn runs at, nil before one.
|
|
30
|
+
attr_reader :turn
|
|
31
|
+
|
|
32
|
+
# The turn's thinking level on +target+ (its EffectiveModel's), what
|
|
33
|
+
# the notices after it read; an effort a native host can't do is said
|
|
34
|
+
# now.
|
|
35
|
+
def turn_started(level, target)
|
|
36
|
+
@turn = [level, target]
|
|
37
|
+
announce_level(level, target)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A generation starts (or a dropped one streams again from the start).
|
|
41
|
+
def generation_started = @generation_thought = nil
|
|
42
|
+
|
|
43
|
+
# Whether this generation's thinking lane said anything: nil while it
|
|
44
|
+
# streamed none, false while only whitespace (Gemma 4's empty thought
|
|
45
|
+
# with thinking off), true once a real thought came.
|
|
46
|
+
def note_thought(thinking)
|
|
47
|
+
return if thinking.empty?
|
|
48
|
+
|
|
49
|
+
@generation_thought ||= thinking.match?(/\S/)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A generation completed: off that the model didn't honour, an effort
|
|
53
|
+
# its chat template ignores.
|
|
54
|
+
def generation_completed(event)
|
|
55
|
+
check_honoured(event)
|
|
56
|
+
announce_effort_ignored
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The host refused the level's request fields (gpt-oss can't turn
|
|
60
|
+
# thinking off) and the chat loop sent the request without them: said
|
|
61
|
+
# once per session and host, standing in for the not-honoured notice.
|
|
62
|
+
def thinking_refused(event)
|
|
63
|
+
_level, target = @turn
|
|
64
|
+
return unless target
|
|
65
|
+
|
|
66
|
+
Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
67
|
+
detail: event[:detail])
|
|
68
|
+
@said << [session_id, target.entry.name, :not_honoured]
|
|
69
|
+
notice_once(:refused, target, :warn,
|
|
70
|
+
"#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
|
|
71
|
+
"sent without it, so thinking stays as the model has it")
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
failed(:thinking_refused, e)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The host refused a sampling field (the empty-answer retry's
|
|
77
|
+
# temperature on a model that takes none) and the chat loop asked again
|
|
78
|
+
# without them, as it will for the model from now on: a log line.
|
|
79
|
+
def sampling_refused(event)
|
|
80
|
+
Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
failed(:sampling_refused, e)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The host refused the model's reasoning sent back (reasoning_history)
|
|
86
|
+
# and the chat loop asked the step again without it, as it will from
|
|
87
|
+
# now on: logged, and said once per session and host.
|
|
88
|
+
def reasoning_history_refused(event)
|
|
89
|
+
_level, target = @turn
|
|
90
|
+
return unless target
|
|
91
|
+
|
|
92
|
+
Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
93
|
+
detail: event[:detail])
|
|
94
|
+
return unless @said.add?([session_id, target.entry.name, :reasoning_history_refused])
|
|
95
|
+
|
|
96
|
+
@notify.call("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
|
|
97
|
+
"#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
failed(:reasoning_history_refused, e)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# An effort on a native host, which has no knob for it: said once per
|
|
105
|
+
# session and host.
|
|
106
|
+
def announce_level(level, target)
|
|
107
|
+
return if target.entry.chat?
|
|
108
|
+
|
|
109
|
+
profile = @profile.call
|
|
110
|
+
return if Thinking.native(level, profile).honoured
|
|
111
|
+
|
|
112
|
+
notice_once(:unsupported, target, :info,
|
|
113
|
+
"#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
|
|
114
|
+
"thinking stays as the model has it")
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
failed(:announce_level, e)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# An effort on a llama.cpp chat host whose chat template takes none
|
|
120
|
+
# (its /props says so): said once per session and host, as for a native
|
|
121
|
+
# host. Read from the /props answer the turn's window probe left in the
|
|
122
|
+
# cache, so it asks the server nothing.
|
|
123
|
+
def announce_effort_ignored
|
|
124
|
+
level, target = @turn
|
|
125
|
+
return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
|
|
126
|
+
|
|
127
|
+
props = target.client.cached_server_props(model: target.bare_model)
|
|
128
|
+
return unless Thinking.effort_ignored?(level, props)
|
|
129
|
+
|
|
130
|
+
notice_once(:unsupported, target, :info,
|
|
131
|
+
"#{level} isn't supported by #{target.bare_model}'s chat template on #{target.entry.name} " \
|
|
132
|
+
"(/props: supports_reasoning_effort false); thinking stays as the model has it")
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
failed(:effort_ignored, e)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Thinking off, and the model thought anyway: logged each time, said
|
|
138
|
+
# once per session and host. A thinking lane of whitespace alone is no
|
|
139
|
+
# thought.
|
|
140
|
+
def check_honoured(event)
|
|
141
|
+
level, target = @turn
|
|
142
|
+
chars = event[:thinking_chars].to_i
|
|
143
|
+
return unless level == :off && chars.positive? && target
|
|
144
|
+
return if @generation_thought == false
|
|
145
|
+
|
|
146
|
+
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
147
|
+
notice_once(:not_honoured, target, :warn,
|
|
148
|
+
"off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
|
|
149
|
+
"(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
|
|
150
|
+
"(see Thinking in docs/configuration.md)")
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
failed(:check_honoured, e)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def notice_once(kind, target, level, text)
|
|
156
|
+
return unless @said.add?([session_id, target.entry.name, kind])
|
|
157
|
+
|
|
158
|
+
@notify.call(text, level, "thinking")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def session_id = @session.call&.id
|
|
162
|
+
|
|
163
|
+
# A notice that failed: none is said, the turn goes on; a log line
|
|
164
|
+
# (once per process for the notice, model and error class).
|
|
165
|
+
def failed(notice, error)
|
|
166
|
+
EffectiveModel::Unresolved.fallback(:thinking_notice_failed, @turn&.last&.model || notice.to_s, error, nil)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -36,7 +36,7 @@ module Samagotchi
|
|
|
36
36
|
# <|channel>thought......<channel|>
|
|
37
37
|
class Gemma
|
|
38
38
|
# Digits after the first character: plugin tools may have them
|
|
39
|
-
# (Plugin::
|
|
39
|
+
# (Plugin::ToolSpec::TOOL_NAME).
|
|
40
40
|
TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
|
|
41
41
|
THOUGHT_CHANNEL_OPEN = ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN
|
|
42
42
|
THOUGHT_CHANNEL_CLOSE = ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log"
|
|
4
|
+
require_relative "toolkit"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# An Engine's session toolkit: the kits it resolves against, what the
|
|
8
|
+
# session's next turn declares (#explained, the UIs' summary), a plugin's
|
|
9
|
+
# ctx.use_toolkit (#use) and the turn-start apply into the tool Registry
|
|
10
|
+
# (#apply!), with its load warnings said once per process.
|
|
11
|
+
#
|
|
12
|
+
# What it needs from the Engine comes through lookups, called at use.
|
|
13
|
+
class ToolkitBinding
|
|
14
|
+
# @param tools [#call] → Tools::Registry
|
|
15
|
+
# @param session [#call] → Session, nil
|
|
16
|
+
# @param state_dir [#call] → the sessions' state dir (#use saves there)
|
|
17
|
+
# @param can_stage [#call] → whether a plugin may change the toolkit now
|
|
18
|
+
# (no turn runs, no model reply yet)
|
|
19
|
+
# @param reset_prompt [#call] → build the system prompts again
|
|
20
|
+
# @param emit [#call] (sink, event) → emits to a turn's sink + observers
|
|
21
|
+
# @param notify [#call] (text, level, source, extra:) → a notice
|
|
22
|
+
def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:)
|
|
23
|
+
@tools = tools
|
|
24
|
+
@session = session
|
|
25
|
+
@state_dir = state_dir
|
|
26
|
+
@can_stage = can_stage
|
|
27
|
+
@reset_prompt = reset_prompt
|
|
28
|
+
@emit = emit
|
|
29
|
+
@notify = notify
|
|
30
|
+
# The toolkits plugins declare (chi.toolkit), beside the built-in and
|
|
31
|
+
# config ones (#kits); Plugin::Registries appends to it.
|
|
32
|
+
@plugin_kits = []
|
|
33
|
+
@warned = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Array<Toolkit::Kit>] the plugins' kits (Plugin::Registries' toolkits:)
|
|
37
|
+
attr_reader :plugin_kits
|
|
38
|
+
|
|
39
|
+
# The kits a session's toolkit resolves against: the built-ins, config's
|
|
40
|
+
# toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
|
|
41
|
+
# process, like the plugins': an edit to toolkit.kits never changes a
|
|
42
|
+
# live session's tools mid-session (a new worker reads it).
|
|
43
|
+
# @param errors [Array<String>] gets a line per config kit left out
|
|
44
|
+
# @return [Hash{String => Toolkit::Kit}]
|
|
45
|
+
def kits(errors = [])
|
|
46
|
+
@config_kits = Toolkit.config_kits unless defined?(@config_kits)
|
|
47
|
+
Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The session's toolkit resolved (Toolkit::Resolved): what its next
|
|
51
|
+
# turn declares. A plain full toolkit is nil, the Registry's "every tool".
|
|
52
|
+
def resolved(session, errors = [])
|
|
53
|
+
resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
|
|
54
|
+
resolved.everything? ? nil : resolved
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# +toolkit+ (the session's by default) as its next turn would declare
|
|
58
|
+
# it (/toolkit, the UIs' chip).
|
|
59
|
+
# @return [Toolkit::Explained]
|
|
60
|
+
def explained(toolkit = nil)
|
|
61
|
+
toolkit ||= Toolkit.of(@session.call&.toolkit)
|
|
62
|
+
resolved = toolkit.resolve(kits)
|
|
63
|
+
filter = resolved.everything? ? nil : resolved
|
|
64
|
+
tools = @tools.call
|
|
65
|
+
Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: tools.entries(toolkit: filter),
|
|
66
|
+
left_out: tools.left_out_names(toolkit: filter), pending: filter != tools.toolkit)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The session's toolkit for the UIs (the snapshot, command_ran): nil on
|
|
70
|
+
# full, or with no session.
|
|
71
|
+
def summary
|
|
72
|
+
@session.call && explained.summary
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The session's toolkit name, nil with no session (ctx.toolkit).
|
|
76
|
+
def name
|
|
77
|
+
session = @session.call
|
|
78
|
+
session && Toolkit.of(session.toolkit).name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A plugin's ctx.use_toolkit: put the session on toolkit +name+, saved,
|
|
82
|
+
# when it has no model reply yet and no turn runs (D3: no confirm from
|
|
83
|
+
# plugins), and tell the user as /toolkit would; it applies at the next
|
|
84
|
+
# turn's start.
|
|
85
|
+
# @return [Boolean] whether it was staged
|
|
86
|
+
# @raise [ArgumentError] an unknown toolkit
|
|
87
|
+
def use(name)
|
|
88
|
+
raise ArgumentError, "no toolkit named #{name}" unless kits.key?(name)
|
|
89
|
+
|
|
90
|
+
session = @session.call
|
|
91
|
+
return false if session.nil? || !@can_stage.call
|
|
92
|
+
|
|
93
|
+
toolkit = Toolkit.new(name: name)
|
|
94
|
+
return true if session.toolkit == toolkit
|
|
95
|
+
|
|
96
|
+
session.toolkit = toolkit
|
|
97
|
+
session.save(state_dir: @state_dir.call)
|
|
98
|
+
@notify.call("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: summary })
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Resolve the session's toolkit into the Registry, on the turn thread at
|
|
103
|
+
# a turn's start (before its prompt is built), and build the prompts
|
|
104
|
+
# again when what it declares changed. A name that doesn't resolve (a
|
|
105
|
+
# typo, an uninstalled bundle) declares everything for now and says so
|
|
106
|
+
# once; the saved toolkit stays as it is.
|
|
107
|
+
# @return [Boolean] whether the declared tools changed
|
|
108
|
+
def apply!(session, on_event = nil)
|
|
109
|
+
errors = []
|
|
110
|
+
resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
|
|
111
|
+
warn_unresolved(session, resolved, errors, on_event)
|
|
112
|
+
resolved = nil if resolved.everything?
|
|
113
|
+
tools = @tools.call
|
|
114
|
+
return false if resolved == tools.toolkit
|
|
115
|
+
|
|
116
|
+
tools.toolkit = resolved
|
|
117
|
+
@reset_prompt.call
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# The toolkit's load warnings, each once per process: a name that
|
|
124
|
+
# didn't resolve, config kits left out.
|
|
125
|
+
def warn_unresolved(session, resolved, errors, on_event)
|
|
126
|
+
lines = errors.dup
|
|
127
|
+
if resolved.fallback
|
|
128
|
+
lines << "#{Toolkit.of(session&.toolkit).label}: #{resolved.fallback}; this session declares every tool (full) " \
|
|
129
|
+
"until it resolves"
|
|
130
|
+
end
|
|
131
|
+
(lines - @warned).each do |line|
|
|
132
|
+
@warned << line
|
|
133
|
+
Log.warn(:config, "toolkit_unresolved", message: line)
|
|
134
|
+
@emit.call(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -55,6 +55,9 @@ module Samagotchi
|
|
|
55
55
|
@attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
|
|
56
56
|
@max_chars = [max_chars.to_i, MIN_CHARS].max
|
|
57
57
|
@now = now
|
|
58
|
+
# The peer bundles' declared sources (PeerSources), through the
|
|
59
|
+
# Engine's PeerView.
|
|
60
|
+
@peer_label = peers.respond_to?(:peer_label) ? peers.method(:peer_label) : nil
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def run(name, offset:, limit:)
|
|
@@ -122,12 +125,23 @@ module Samagotchi
|
|
|
122
125
|
hint = snapshot.hint || source.hint
|
|
123
126
|
header = ["#{source.name}#{" (#{hint})" if hint}"]
|
|
124
127
|
header << "Why: #{source.why}" if source.why
|
|
128
|
+
if (peer = peer_label(attached))
|
|
129
|
+
header << "Peer: your peer agent #{peer}'s answers (its text, not your user's words)"
|
|
130
|
+
end
|
|
125
131
|
header << "Fetched: #{clock(snapshot.fetched_at)} (#{age(snapshot.fetched_at)})" if snapshot.fetched_at
|
|
126
132
|
header << "Summary: #{snapshot.summary}" if snapshot.summary
|
|
127
133
|
header << "Last refresh failed: #{snapshot.error}" if snapshot.error
|
|
128
134
|
header
|
|
129
135
|
end
|
|
130
136
|
|
|
137
|
+
# A pushed source of the session's own that a peer bundle declared
|
|
138
|
+
# (ContextAbsorber#peer_label's rule).
|
|
139
|
+
def peer_label(attached)
|
|
140
|
+
return nil unless @peer_label && attached.source.push? && attached.location.session?
|
|
141
|
+
|
|
142
|
+
@peer_label.call(attached.name)
|
|
143
|
+
end
|
|
144
|
+
|
|
131
145
|
# What the output takes besides the page: the kernel's prefix, the
|
|
132
146
|
# header, its paging line at its longest (every number +count+) and
|
|
133
147
|
# the separator.
|
|
@@ -203,8 +203,9 @@ module Samagotchi
|
|
|
203
203
|
# Ended right at the deadline: finished, not timed out.
|
|
204
204
|
timed_out = false if timed_out && !wait_thr.alive?
|
|
205
205
|
if timed_out && on_timeout == :task
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
task = TaskRecord.running(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at.utc.iso8601,
|
|
207
|
+
output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
|
|
208
|
+
text = hand_over(task, waiter: wait_thr, timeout_sec: timeout_sec, dir: dir)
|
|
208
209
|
if text
|
|
209
210
|
handed_over = true
|
|
210
211
|
Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
|
|
@@ -337,25 +338,24 @@ module Samagotchi
|
|
|
337
338
|
private_class_method :read_log
|
|
338
339
|
|
|
339
340
|
# A command still running at the limit becomes a task (TaskRuntime.
|
|
340
|
-
# adopt) with the logs where they are
|
|
341
|
-
# the record for the sweep. nil when it can't be adopted (the caller
|
|
341
|
+
# adopt +task+, a TaskRecord.running) with the logs where they are
|
|
342
|
+
# (+dir+); a back-pointer in that dir names the record for the sweep. nil when it can't be adopted (the caller
|
|
342
343
|
# then kills it as in :kill mode) or when it ended meanwhile (the
|
|
343
344
|
# caller then reads it as finished).
|
|
344
345
|
# @return [String, nil] the HANDED_OVER result
|
|
345
|
-
def self.hand_over(
|
|
346
|
+
def self.hand_over(task, waiter:, timeout_sec:, dir:)
|
|
346
347
|
return nil unless waiter.alive?
|
|
347
348
|
|
|
348
|
-
record, error = TaskRuntime.adopt(
|
|
349
|
-
output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
|
|
349
|
+
record, error = TaskRuntime.adopt(task)
|
|
350
350
|
return nil if error
|
|
351
351
|
|
|
352
|
-
File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record
|
|
353
|
-
write_missing_exit_code(waiter, exit_code_path)
|
|
352
|
+
File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record.id)))
|
|
353
|
+
write_missing_exit_code(waiter, record.exit_code_path)
|
|
354
354
|
# Ended between the deadline and the adopt: the record says how.
|
|
355
|
-
TaskRuntime.get_record(id) unless waiter.alive?
|
|
356
|
-
lines = [handed_over_line(timeout_sec), "task_id: #{id}",
|
|
355
|
+
TaskRuntime.get_record(record.id) unless waiter.alive?
|
|
356
|
+
lines = [handed_over_line(timeout_sec), "task_id: #{record.id}",
|
|
357
357
|
"Wait for it with task_wait (it may take minutes); task_stop stops it."]
|
|
358
|
-
{ "stdout" =>
|
|
358
|
+
{ "stdout" => record.output_path, "stderr" => record.stderr_path }.each do |label, path|
|
|
359
359
|
tail = TaskRuntime.output_tail_lines(path, HAND_OVER_TAIL_LINES)
|
|
360
360
|
lines << "#{label} (so far, tail):\n#{tail.chomp}" unless tail.empty?
|
|
361
361
|
end
|
|
@@ -85,7 +85,7 @@ module Samagotchi
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
# Every tool +source+ registered, declared or not (a plugin's tool set
|
|
88
|
-
# changing: Plugin::
|
|
88
|
+
# changing: Plugin::ToolSpec.apply).
|
|
89
89
|
# @return [Array<Entry>]
|
|
90
90
|
def by_source(source) = @entries.values.select { |entry| entry.source == source }
|
|
91
91
|
|
|
@@ -15,12 +15,12 @@ module Samagotchi
|
|
|
15
15
|
return error if error
|
|
16
16
|
|
|
17
17
|
[
|
|
18
|
-
"task_id: #{record.
|
|
19
|
-
"status: #{record.
|
|
20
|
-
"pid: #{record.
|
|
21
|
-
"created_at: #{record.
|
|
22
|
-
"output_path: #{record.
|
|
23
|
-
"cwd: #{record.
|
|
18
|
+
"task_id: #{record.id}",
|
|
19
|
+
"status: #{record.status}",
|
|
20
|
+
"pid: #{record.pid}",
|
|
21
|
+
"created_at: #{record.created_at}",
|
|
22
|
+
"output_path: #{record.output_path}",
|
|
23
|
+
"cwd: #{record.cwd}"
|
|
24
24
|
].join("\n")
|
|
25
25
|
rescue StandardError => e
|
|
26
26
|
"Error: #{e.message}"
|
|
@@ -20,20 +20,20 @@ module Samagotchi
|
|
|
20
20
|
|
|
21
21
|
def self.format_record(record)
|
|
22
22
|
[
|
|
23
|
-
"task_id: #{record.
|
|
24
|
-
"status: #{record.
|
|
25
|
-
"command: #{record.
|
|
26
|
-
"cwd: #{record.
|
|
27
|
-
"pid: #{record.
|
|
28
|
-
"created_at: #{record.
|
|
29
|
-
"started_at: #{record.
|
|
30
|
-
"finished_at: #{record
|
|
31
|
-
"exit_code: #{record
|
|
32
|
-
"stop_reason: #{record
|
|
33
|
-
"output_path: #{record.
|
|
23
|
+
"task_id: #{record.id}",
|
|
24
|
+
"status: #{record.status}",
|
|
25
|
+
"command: #{record.command}",
|
|
26
|
+
"cwd: #{record.cwd}",
|
|
27
|
+
"pid: #{record.pid}",
|
|
28
|
+
"created_at: #{record.created_at}",
|
|
29
|
+
"started_at: #{record.started_at}",
|
|
30
|
+
"finished_at: #{record.finished_at}",
|
|
31
|
+
"exit_code: #{record.exit_code}",
|
|
32
|
+
"stop_reason: #{record.stop_reason}",
|
|
33
|
+
"output_path: #{record.output_path}",
|
|
34
34
|
# A handed-over execute's stderr has a log of its own.
|
|
35
|
-
("stderr_path: #{record
|
|
36
|
-
(TaskRuntime::USER_STOP_NOTE if
|
|
35
|
+
("stderr_path: #{record.stderr_path}" if record.stderr_path),
|
|
36
|
+
(TaskRuntime::USER_STOP_NOTE if record.stopped_by_user?)
|
|
37
37
|
].compact.join("\n")
|
|
38
38
|
end
|
|
39
39
|
private_class_method :format_record
|
|
@@ -21,12 +21,12 @@ module Samagotchi
|
|
|
21
21
|
|
|
22
22
|
def self.format_entry(record)
|
|
23
23
|
[
|
|
24
|
-
"task_id: #{record.
|
|
25
|
-
"status: #{record.
|
|
26
|
-
"pid: #{record.
|
|
27
|
-
"created_at: #{record.
|
|
28
|
-
"command: #{preview(record.
|
|
29
|
-
"output_path: #{record.
|
|
24
|
+
"task_id: #{record.id}",
|
|
25
|
+
"status: #{record.status}",
|
|
26
|
+
"pid: #{record.pid}",
|
|
27
|
+
"created_at: #{record.created_at}",
|
|
28
|
+
"command: #{preview(record.command)}",
|
|
29
|
+
"output_path: #{record.output_path}"
|
|
30
30
|
].join("\n")
|
|
31
31
|
end
|
|
32
32
|
private_class_method :format_entry
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# A background task's record, as TaskRuntime keeps it in
|
|
6
|
+
# tmp/tasks/<id>/task.json. Read with .from_file, written with #to_file;
|
|
7
|
+
# an update is a #with. The values are as on disk: times are iso8601
|
|
8
|
+
# strings, +pid+ whatever the file holds (a hand-edited one may not be
|
|
9
|
+
# a number; TaskRuntime never signals that).
|
|
10
|
+
#
|
|
11
|
+
# The file keeps its key order (the members' order) and leaves out the
|
|
12
|
+
# optional keys while unset: +stderr_path+ and +handed_over_by+ (only a
|
|
13
|
+
# handed-over execute has them) and +stop_requested_by+ (only once a
|
|
14
|
+
# stop started). bundles/slow-commands reads these files on its own.
|
|
15
|
+
TaskRecord = Data.define(
|
|
16
|
+
:metadata_version, :id, :command, :cwd, :workspace_root, :status, :pid, :pid_started,
|
|
17
|
+
:created_at, :started_at, :finished_at, :exit_code, :stop_reason,
|
|
18
|
+
:output_path, :stderr_path, :exit_code_path, :handed_over_by, :stop_requested_by
|
|
19
|
+
) do
|
|
20
|
+
self::METADATA_VERSION = 1
|
|
21
|
+
self::OPTIONAL = %i[stderr_path handed_over_by stop_requested_by].freeze
|
|
22
|
+
|
|
23
|
+
# A record for a task that just started (task_create), or that an
|
|
24
|
+
# execute hands over (TaskRuntime.adopt fills in +pid_started+ then).
|
|
25
|
+
# @param started_at [String] iso8601; also its +created_at+
|
|
26
|
+
def self.running(id:, command:, cwd:, pid:, started_at:, output_path:, exit_code_path:, pid_started: nil,
|
|
27
|
+
stderr_path: nil, handed_over_by: nil)
|
|
28
|
+
new(metadata_version: self::METADATA_VERSION, id: id, command: command, cwd: cwd, workspace_root: Dir.pwd,
|
|
29
|
+
status: "running", pid: pid, pid_started: pid_started, created_at: started_at, started_at: started_at,
|
|
30
|
+
finished_at: nil, exit_code: nil, stop_reason: nil, output_path: output_path, stderr_path: stderr_path,
|
|
31
|
+
exit_code_path: exit_code_path, handed_over_by: handed_over_by, stop_requested_by: nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param data [Hash] the parsed task.json; a key it lacks reads nil
|
|
35
|
+
# @return [TaskRecord, nil] nil when +data+ isn't an object
|
|
36
|
+
def self.from_file(data)
|
|
37
|
+
return nil unless data.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
new(**members.to_h { |key| [key, data[key.to_s]] })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash] the task.json object, string keys in member order
|
|
43
|
+
def to_file
|
|
44
|
+
to_h.reject { |key, value| value.nil? && self.class::OPTIONAL.include?(key) }.transform_keys(&:to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def running? = status == "running"
|
|
48
|
+
|
|
49
|
+
def stopped_by_user? = stop_reason == "stopped_by_user"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|