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.
Files changed (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -1,18 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "cache_confirm"
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): with no
8
- # argument, the level the next turn runs at and where it came from;
9
- # `/thinking off|low|medium|high` sets the session's own, `/thinking
10
- # default` unsets it (the session follows the process default, the
11
- # model, its host, then thinking.level). A change takes effect at the
12
- # next turn's start (the REPL and the worker run it between turns), and
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
- # @param engine [Engine] #thinking_explained(own:), #thinking_override(=),
28
- # #session, #thinking_cache_cost, #history?
29
- # @param save [#call] saves the session after a change
30
- def initialize(engine:, save:)
31
- @engine = engine
32
- @save = save
33
- end
34
-
35
- # @param args [String] what follows /thinking (--yes anywhere)
36
- # @param confirm [Boolean] ask before a costly change (CacheConfirm);
37
- # false applies it at once (chi send, a delegate, a start flag)
38
- # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
39
- # whether the level changed, and the confirm it asks for (nothing
40
- # changed then)
41
- # @raise [ArgumentError] a word that isn't a level (with USAGE)
42
- def run(args, confirm: true)
43
- word, yes = CacheConfirm.split(args)
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::Api::TOOL_NAME).
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
- text = hand_over(id: id, command: command, cwd: cwd, pid: pid, waiter: wait_thr, started_at: started_at,
207
- timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
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; a back-pointer in its dir names
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(id:, command:, cwd:, pid:, waiter:, started_at:, timeout_sec:, dir:, logs:, exit_code_path:)
346
+ def self.hand_over(task, waiter:, timeout_sec:, dir:)
346
347
  return nil unless waiter.alive?
347
348
 
348
- record, error = TaskRuntime.adopt(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at,
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["id"])))
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" => logs[0], "stderr" => logs[1] }.each do |label, path|
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::Api.apply_tools).
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.fetch("id")}",
19
- "status: #{record.fetch("status")}",
20
- "pid: #{record.fetch("pid")}",
21
- "created_at: #{record.fetch("created_at")}",
22
- "output_path: #{record.fetch("output_path")}",
23
- "cwd: #{record.fetch("cwd")}"
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.fetch("id")}",
24
- "status: #{record.fetch("status")}",
25
- "command: #{record.fetch("command")}",
26
- "cwd: #{record.fetch("cwd")}",
27
- "pid: #{record.fetch("pid")}",
28
- "created_at: #{record.fetch("created_at")}",
29
- "started_at: #{record.fetch("started_at")}",
30
- "finished_at: #{record["finished_at"]}",
31
- "exit_code: #{record["exit_code"]}",
32
- "stop_reason: #{record["stop_reason"]}",
33
- "output_path: #{record.fetch("output_path")}",
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["stderr_path"]}" if record["stderr_path"]),
36
- (TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
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.fetch("id")}",
25
- "status: #{record.fetch("status")}",
26
- "pid: #{record.fetch("pid")}",
27
- "created_at: #{record.fetch("created_at")}",
28
- "command: #{preview(record.fetch("command"))}",
29
- "output_path: #{record.fetch("output_path")}"
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