samagotchi 0.58.0 → 0.59.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/docs/architecture.md +3 -3
  4. data/docs/cli.md +5 -4
  5. data/docs/configuration.md +35 -20
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/background-tasks.md +7 -0
  8. data/docs/internals/engine.md +5 -5
  9. data/docs/internals/prompt-caching.md +2 -2
  10. data/docs/internals/tool-guardrails.md +2 -1
  11. data/docs/plugins.md +26 -6
  12. data/docs/sessions.md +1 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  14. data/lib/samagotchi/bridge.rb +6 -2
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/config.rb +11 -5
  20. data/lib/samagotchi/engine.rb +26 -1
  21. data/lib/samagotchi/host_config.rb +3 -1
  22. data/lib/samagotchi/host_registry.rb +2 -1
  23. data/lib/samagotchi/model_settings.rb +4 -2
  24. data/lib/samagotchi/process_group.rb +76 -3
  25. data/lib/samagotchi/send_command.rb +1 -1
  26. data/lib/samagotchi/session_commands.rb +15 -3
  27. data/lib/samagotchi/steer_cut.rb +12 -8
  28. data/lib/samagotchi/steer_cut_after.rb +69 -0
  29. data/lib/samagotchi/system_prompt.rb +5 -5
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  31. data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
  32. data/lib/samagotchi/terminal_ui.rb +1 -1
  33. data/lib/samagotchi/tool_activity.rb +18 -1
  34. data/lib/samagotchi/tool_declarations.rb +1 -1
  35. data/lib/samagotchi/tools/execute.rb +318 -70
  36. data/lib/samagotchi/tools/task_get.rb +2 -0
  37. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  38. data/lib/samagotchi/tools/task_wait.rb +23 -5
  39. data/lib/samagotchi/utf8_default.rb +1 -1
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/public/read_view.js +3 -1
  42. data/lib/samagotchi/web/public/task_stop.js +25 -7
  43. metadata +2 -1
@@ -30,19 +30,23 @@ module Samagotchi
30
30
  end
31
31
 
32
32
  # +clock+: monotonic seconds; +controller+: the running turn's cancel
33
- # controller (nil between turns), read only when a cut is due.
34
- def initialize(clock:, controller:)
33
+ # controller (nil between turns), read only when a cut is due;
34
+ # +cut_after+: seconds a thinking-only generation may stream before a
35
+ # cut, called on every check (the Engine passes the model's own
36
+ # SteerCutAfter; the global steer.cut_after here).
37
+ def initialize(clock:, controller:, cut_after: -> { self.class.cut_after })
35
38
  # What the running generation streams: whether a steer may cut it.
36
39
  @phase = GenerationPhase.new(clock: clock)
37
40
  # A message that may cut it but came too early: it cuts later.
38
41
  @waiting = WaitingSteer.new
42
+ @cut_after = cut_after
39
43
  @controller = controller
40
44
  end
41
45
 
42
46
  # A message for the running turn from +source+ (Steer.source_for_client:
43
47
  # nil for the user, "chi_send", "parent_agent") cuts the streaming
44
- # generation when it has streamed only thinking for steer.cut_after
45
- # seconds (GenerationPhase): the loops' cut path (CutPolicy) then starts
48
+ # generation when it has streamed only thinking for cut_after seconds
49
+ # (GenerationPhase): the loops' cut path (CutPolicy) then starts
46
50
  # the step again with the message, no nudge, no retry spent. Too early,
47
51
  # the message waits (WaitingSteer) and cuts once the thinking passes it
48
52
  # (#recheck, on each thinking chunk), unless a boundary hands it to the
@@ -52,13 +56,13 @@ module Samagotchi
52
56
  # before queueing it. Outside any lock; a cut that lands just after the
53
57
  # generation ended is harmless (the loop re-asks).
54
58
  # @return [Symbol] :now (a generation was cut now), :waits (the message
55
- # waits for the thinking to pass steer.cut_after), or :off (cutting
59
+ # waits for the thinking to pass cut_after), or :off (cutting
56
60
  # off for this source: a plugin, no turn, cut_after 0, a generation
57
61
  # that isn't thinking-only, or a drain already took the message)
58
62
  def cut_for_steer(source, epoch: nil)
59
63
  return :off unless Steer.cuts?(source)
60
64
 
61
- after = self.class.cut_after
65
+ after = @cut_after.call.to_i
62
66
  return :off unless after.positive?
63
67
 
64
68
  ctrl = @controller.call
@@ -107,11 +111,11 @@ module Samagotchi
107
111
  private
108
112
 
109
113
  # A thinking chunk streamed: a waiting message cuts once the thinking
110
- # passes steer.cut_after.
114
+ # passes cut_after.
111
115
  def recheck
112
116
  return unless @waiting.waiting?
113
117
 
114
- after = self.class.cut_after
118
+ after = @cut_after.call.to_i
115
119
  return unless after.positive? && @phase.cuttable?(after)
116
120
 
117
121
  source = @waiting.take
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # How long a message sent as a cut waits for the generation it came during:
7
+ # the seconds a generation must have streamed only thinking before a cut may
8
+ # land (SteerCut), one per model, host or run, like Thinking for the level.
9
+ #
10
+ # Order (#resolve): the env (SAMAGOTCHI_STEER_CUT_AFTER), then
11
+ # models.<key>.steer_cut_after, hosts.<name>.steer_cut_after,
12
+ # steer.cut_after in config.yml, then 20. 0 = never cut (a slow local
13
+ # thinker is left alone); it is a value, not an unset.
14
+ module SteerCutAfter
15
+ KEY = "steer_cut_after"
16
+ ENV_KEY = "SAMAGOTCHI_STEER_CUT_AFTER"
17
+ DEFAULT = 20
18
+
19
+ module_function
20
+
21
+ # A `steer_cut_after:` setting (hosts: entry or models: entry): whole
22
+ # seconds ≥ 0 (0 = never), nil when unset; anything else warns once,
23
+ # naming +where+, and counts as unset.
24
+ # @return [Integer, nil]
25
+ def seconds(value, where)
26
+ return nil if value.nil?
27
+
28
+ text = value.to_s.strip
29
+ return text.to_i if text.match?(/\A\d+\z/)
30
+
31
+ ConfigFile.warn_once "Warning: #{where}: steer_cut_after must be whole seconds (0 = never); ignored"
32
+ nil
33
+ end
34
+
35
+ # The seconds for +target+ and where they came from
36
+ # ("SAMAGOTCHI_STEER_CUT_AFTER", "models: qwen", "hosts.splash",
37
+ # "steer.cut_after"; nil for the default).
38
+ # @param target [HostRegistry::ModelTarget]
39
+ # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
40
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
41
+ # @return [Array(Integer, String|nil)]
42
+ def resolve(target, names:, models: nil)
43
+ value, origin = Config.get_with_origin("steer.cut_after")
44
+ return [value.to_i, ENV_KEY] if origin == :env
45
+
46
+ models ||= begin
47
+ ConfigFile.model_settings
48
+ rescue StandardError
49
+ {}
50
+ end
51
+ setting = ConfigFile.model_setting(names, :steer_cut_after, models: models)
52
+ return [setting.value, "models: #{setting.key}"] if setting
53
+
54
+ host = target.entry.steer_cut_after
55
+ return [host, "hosts.#{target.entry.name}"] unless host.nil?
56
+
57
+ return [value.to_i, "steer.cut_after"] if origin == :file
58
+
59
+ [DEFAULT, nil]
60
+ end
61
+
62
+ # "after 60 s (models: qwen)"; 0 or less is "never (hosts.splash)".
63
+ # No source: just the seconds.
64
+ def label(seconds, source)
65
+ text = seconds.to_i <= 0 ? "never" : "after #{seconds} s"
66
+ source ? "#{text} (#{source})" : text
67
+ end
68
+ end
69
+ end
@@ -16,7 +16,7 @@ module Samagotchi
16
16
  # The system prompt an Engine gives its model: the base prompt (tool
17
17
  # declarations and call syntax for the raw-prompt loop, none for the chat
18
18
  # loop, the shared guidance) and around it the thinking token, rg
19
- # guidance, the identity memory, the model notes, the preloaded memories, AGENT.md, the
19
+ # guidance, the identity memory, the model notes, the preloaded memories, AGENTS.md, the
20
20
  # memory indexes and, last, the model, where the session runs and the
21
21
  # session (#system_prompt_with_index has the order and why).
22
22
  #
@@ -24,7 +24,7 @@ module Samagotchi
24
24
  # tools a plugin replaces, the attached session, the thinking level) is
25
25
  # read at build time through the lookups; the memory lists are fixed.
26
26
  class SystemPrompt
27
- AGENT_DESCRIPTION_FILE = "AGENT.md"
27
+ AGENT_DESCRIPTION_FILE = "AGENTS.md"
28
28
 
29
29
  # The model a session runs on, as the prompt names it: the resolved
30
30
  # ref, its host ("name, host:port" or url), the memory overlay key, and
@@ -282,7 +282,7 @@ module Samagotchi
282
282
  # Ordered for the servers' prompt caches (they reuse only an exact token
283
283
  # prefix): what every session shares first, what changes least before
284
284
  # what changes more, and the per-session lines last. The global parts
285
- # (identity, preloads) come before the project's AGENT.md, the memory
285
+ # (identity, preloads) come before the project's AGENTS.md, the memory
286
286
  # indexes (a memory write changes them) after both; the model, the
287
287
  # location and the session close the prompt (Gemma's tool declarations
288
288
  # still follow them).
@@ -464,7 +464,7 @@ module Samagotchi
464
464
 
465
465
  # ── Project / rg helpers ───────────────────────────────────────────────────
466
466
 
467
- # AGENT.md in the cwd, else at the top of its git work tree.
467
+ # AGENTS.md in the cwd, else at the top of its git work tree.
468
468
  def project_specific_description
469
469
  return nil if skip_agent_description?
470
470
 
@@ -566,7 +566,7 @@ module Samagotchi
566
566
  end
567
567
 
568
568
  def skip_agent_description?
569
- Config.get("skip_agent_md") == true
569
+ Config.get("skip_agents_md") == true
570
570
  end
571
571
 
572
572
  def rg_available?
@@ -712,7 +712,7 @@ module Samagotchi
712
712
 
713
713
  return NEXT_STEP_NOTE unless delivery == Delivery::CUT
714
714
 
715
- steer_cut_note(ack["cut"].to_s.to_sym)
715
+ steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
716
716
  end
717
717
 
718
718
  def send_prompt(text, delivery: nil)
@@ -99,8 +99,9 @@ module Samagotchi
99
99
  # What a line submitted at the open prompt says became of it: a plain
100
100
  # line goes in at the running turn's next step boundary (nothing cuts
101
101
  # unless the message asked for it), /cut's cutting now or once the
102
- # thinking passes steer.cut_after, and /queue's waiting for the end.
103
- # Both TUIs and chi send --cut print the same words
102
+ # thinking passes the model's cut wait (the run's cut_after; the global
103
+ # steer.cut_after when the worker didn't say), and /queue's waiting for
104
+ # the end. Both TUIs and chi send --cut print the same words
104
105
  # (SteerCut#cut_for_steer's answer).
105
106
  NEXT_STEP_NOTE = "(goes in at the next step)"
106
107
  CUT_NOW_NOTE = "(cut in now)"
@@ -116,17 +117,20 @@ module Samagotchi
116
117
  STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
117
118
 
118
119
  # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
120
+ # @param after [Integer, nil] the run's cut wait (the model's own
121
+ # SteerCutAfter): the seconds a :waits waits for; the global
122
+ # steer.cut_after when not given.
119
123
  # @return [String] the dim line to show for a /cut
120
- def self.steer_cut_note(outcome)
124
+ def self.steer_cut_note(outcome, after = nil)
121
125
  case outcome
122
126
  when :now then CUT_NOW_NOTE
123
- when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
127
+ when :waits then format(CUT_WAITS_NOTE, after || SteerCut.cut_after)
124
128
  else CUT_OFF_NOTE
125
129
  end
126
130
  end
127
131
 
128
132
  # The same words, for a UI that includes the module.
129
- def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
133
+ def steer_cut_note(outcome, after = nil) = Formatting.steer_cut_note(outcome, after)
130
134
 
131
135
  # The choice a mid-turn line carries, as [:cut, "TEXT"] or
132
136
  # [:queue, "TEXT"]; nil for any other line. The text is "" when the
@@ -1199,7 +1199,7 @@ module Samagotchi
1199
1199
  @pending_input_queue.push(text)
1200
1200
  # With no session running the cut is :off; the text still waits for
1201
1201
  # the running turn's next step.
1202
- @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
1202
+ @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
1203
1203
  true
1204
1204
  end
1205
1205
 
@@ -46,7 +46,11 @@ module Samagotchi
46
46
  status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
47
47
  }
48
48
  # A grep that found nothing: ok, and the web row says "no match".
49
- event[:no_match] = true if event[:status] == "ok" && tool_name == Tools::Execute::NAME && execute_failed?(result.to_s)
49
+ # Not a hand-over: an "exit: 1" in its output tail is the command's.
50
+ if event[:status] == "ok" && tool_name == Tools::Execute::NAME && !handed_over?(tool_name, result) &&
51
+ execute_failed?(result.to_s)
52
+ event[:no_match] = true
53
+ end
50
54
  title = tool_title(tool_name, call, cwd: cwd)
51
55
  event[:title] = title if title
52
56
  # The name the model used when an alias ran a built-in (called_as: "bash"
@@ -214,6 +218,8 @@ module Samagotchi
214
218
  return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
215
219
  return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
216
220
  return "error" if text.start_with?("Error:")
221
+ # Still running, as a task: its output tail may hold any "exit: N".
222
+ return "ok" if handed_over?(tool_name, text)
217
223
  if tool_name == Tools::Execute::NAME && execute_failed?(text)
218
224
  return no_match?(text, command) ? "ok" : "error"
219
225
  end
@@ -222,6 +228,17 @@ module Samagotchi
222
228
  "ok"
223
229
  end
224
230
 
231
+ # An execute still running at its limit, handed over as a task
232
+ # (Tools::Execute::HANDED_OVER).
233
+ def handed_over?(tool_name, text)
234
+ return false unless tool_name == Tools::Execute::NAME && text.to_s.start_with?(Tools::Execute::HANDED_OVER)
235
+
236
+ # The first line only, scrubbed: a regexp raises on bytes that aren't UTF-8.
237
+ text.to_s.each_line.first.to_s.scrub.match?(HANDED_OVER_START)
238
+ end
239
+
240
+ HANDED_OVER_START = /\A#{Regexp.escape(Tools::Execute::HANDED_OVER)} \d+s limit:/
241
+
225
242
  def execute_failed?(text)
226
243
  code = execute_exit_code(text)
227
244
  !code.nil? && code != "0"
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  TOOL_SCHEMAS = [
21
21
  {
22
22
  name: "execute",
23
- description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. Don't background with &; use task_create.",
23
+ description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. A command still running at the time limit keeps running as a background task (by default): the result gives its task_id, and task_wait waits for it. Don't background with &; use task_create.",
24
24
  parameters: {
25
25
  type: "object",
26
26
  properties: {