samagotchi 0.61.0 → 0.62.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/architecture.md +3 -0
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +8 -0
  9. data/docs/internals/engine.md +24 -11
  10. data/docs/internals/prompt-caching.md +6 -1
  11. data/docs/plugins.md +42 -3
  12. data/docs/sessions.md +4 -4
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  16. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  18. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  19. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/engine.rb +9 -0
  23. data/lib/samagotchi/engine_peers.rb +1 -0
  24. data/lib/samagotchi/kernel_loop.rb +7 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  26. data/lib/samagotchi/plugin/api.rb +36 -18
  27. data/lib/samagotchi/reasoning_history.rb +22 -4
  28. data/lib/samagotchi/row_refs.rb +126 -0
  29. data/lib/samagotchi/session_metrics.rb +24 -2
  30. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  31. data/lib/samagotchi/terminal_ui.rb +26 -3
  32. data/lib/samagotchi/tool_activity.rb +8 -4
  33. data/lib/samagotchi/tool_response.rb +5 -4
  34. data/lib/samagotchi/tool_row_fields.rb +4 -2
  35. data/lib/samagotchi/tool_runner.rb +16 -3
  36. data/lib/samagotchi/tools/builtins.rb +9 -2
  37. data/lib/samagotchi/tools/execute.rb +15 -3
  38. data/lib/samagotchi/tools/peers.rb +3 -2
  39. data/lib/samagotchi/tools/registry.rb +5 -3
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +14 -1
  42. data/lib/samagotchi/web/message_parts.rb +17 -9
  43. data/lib/samagotchi/web/public/activity.js +4 -1
  44. data/lib/samagotchi/web/public/app.js +59 -15
  45. data/lib/samagotchi/web/public/chains.js +18 -20
  46. data/lib/samagotchi/web/public/context_chips.js +92 -64
  47. data/lib/samagotchi/web/public/families_view.js +51 -39
  48. data/lib/samagotchi/web/public/index.html +61 -10
  49. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  50. data/lib/samagotchi/web/public/lists_view.js +13 -13
  51. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  52. data/lib/samagotchi/web/public/question_card.js +11 -0
  53. data/lib/samagotchi/web/public/question_cards.js +60 -5
  54. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  55. data/lib/samagotchi/web/public/refs.js +113 -15
  56. data/lib/samagotchi/web/public/scroll.js +42 -0
  57. data/lib/samagotchi/web/public/stage_model.js +22 -3
  58. data/lib/samagotchi/web/public/stage_view.js +299 -70
  59. data/lib/samagotchi/web/public/timing.js +1 -1
  60. data/lib/samagotchi/web/public/turn_html.js +5 -4
  61. data/lib/samagotchi/web/public/turn_model.js +49 -0
  62. data/lib/samagotchi/web/public/turn_view.js +104 -10
  63. data/lib/samagotchi/worker.rb +3 -2
  64. data/lib/samagotchi/worker_inbound.rb +5 -3
  65. metadata +3 -1
@@ -79,9 +79,11 @@ module Samagotchi
79
79
  # @param targets [#call, nil] args → what guardrail rules match: a Hash
80
80
  # with paths: (absolute or relative to the cwd), command: (a shell
81
81
  # command) and cwd:, each optional
82
- def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &)
83
- spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
84
- targets: targets, &)
82
+ # @param refs [#call, nil] args → the call's row's refs, display only:
83
+ # a list of {kind:, target:, label:, actions:} (RowRefs, Ref). Run
84
+ # once per call on the tool path, so it must be cheap (no I/O)
85
+ def tool(name, description, **, &)
86
+ spec = Api.tool_spec(name, description, **, &)
85
87
  if (taken = @registries.tools[spec[:name]])
86
88
  raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
87
89
  end
@@ -125,9 +127,8 @@ module Samagotchi
125
127
  end
126
128
 
127
129
  # As Api#tool.
128
- def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &)
129
- spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
130
- targets: targets, &)
130
+ def tool(name, description, **, &)
131
+ spec = Api.tool_spec(name, description, **, &)
131
132
  raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
132
133
 
133
134
  @specs << spec
@@ -249,34 +250,44 @@ module Samagotchi
249
250
  def counts = { commands: @commands.size, tools: @tools.size, hooks: @hooks.size, services: @services.size,
250
251
  inits: @inits.size }
251
252
 
252
- # A checked tool declaration (#tool's arguments).
253
- # @return [Hash] {name:, schema:, label:, preview:, targets:, block:}
254
- def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
253
+ # A checked tool declaration (#tool's arguments: Api#tool and
254
+ # ToolSet#tool pass theirs on, so a new keyword is added here).
255
+ # @return [Hash] {name:, schema:, label:, preview:, targets:, refs:, block:}
256
+ def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, refs: nil,
257
+ &block)
255
258
  raise ArgumentError, "tool #{name.inspect} needs a block" unless block
256
259
 
257
260
  name = name.to_s
258
261
  raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
259
262
  raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
260
263
  raise ArgumentError, "tool #{name}: targets must respond to #call" if targets && !targets.respond_to?(:call)
264
+ raise ArgumentError, "tool #{name}: refs must respond to #call" if refs && !refs.respond_to?(:call)
261
265
 
262
266
  parameters = schema ? schema_parameters(name, schema) : params_schema(name, params)
263
267
  { name: name, schema: { name: name, description: description.to_s, parameters: parameters },
264
- label: label&.to_s, preview: preview, targets: targets, block: block }
268
+ label: label&.to_s, preview: preview, targets: targets, refs: refs, block: block }
265
269
  end
266
270
 
267
271
  # Make +bundle+'s tools in +registry+ the +specs+: its tools not in
268
272
  # them go; a new one, or one whose schema or label changed, is
269
273
  # registered (a changed one again, in its place: Tools::Registry
270
274
  # orders bundle tools by bundle and name); an unchanged one is kept
271
- # as it is. A name another source has is left out. Called on
272
- # the turn thread (Engine#apply_staged_tools!).
275
+ # as it is, but for its block, preview:, targets: and refs:, swapped
276
+ # in place: none of them reaches the prompt, so not a change (that
277
+ # would build the prompts again). A name
278
+ # another source has is left out. Called on the turn thread
279
+ # (Engine#apply_staged_tools!).
273
280
  # @return [Hash] {changed: Boolean, skipped: [String] (why)}
274
281
  def self.apply_tools(registry, bundle, specs, context)
275
282
  wanted = specs.to_h { |spec| [spec[:name], spec] }
276
283
  changed = false
277
284
  registry.entries.each do |entry|
278
285
  next unless entry.source == bundle
279
- next if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
286
+
287
+ if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
288
+ entry_fields(spec, context).slice(:handler, :preview, :targets, :refs).each { |key, value| entry[key] = value }
289
+ next
290
+ end
280
291
 
281
292
  registry.unregister(entry.name)
282
293
  changed = true
@@ -295,23 +306,30 @@ module Samagotchi
295
306
  end
296
307
 
297
308
  # Registry#register's keywords for a tool declaration: its block
298
- # wrapped as a handler (typed args, the Context), preview, targets.
309
+ # wrapped as a handler (typed args, the Context), preview, targets, refs.
299
310
  def self.entry_fields(spec, context)
300
311
  block = spec[:block]
301
- preview = spec[:preview]
302
- targets = spec[:targets]
303
312
  parameters = spec[:schema][:parameters]
313
+ preview = per_call(spec[:preview], parameters)
304
314
  {
305
315
  schema: spec[:schema], label: spec[:label],
306
316
  handler: lambda { |call, _kctx|
307
317
  result = block.call(Api.args_of(call, parameters), context)
308
318
  Api.result_text(result)
309
319
  },
310
- preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
311
- targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
320
+ preview: preview && ->(call) { preview.call(call)&.to_s },
321
+ targets: per_call(spec[:targets], parameters),
322
+ refs: per_call(spec[:refs], parameters)
312
323
  }
313
324
  end
314
325
 
326
+ # A plugin's args → value lambda (+fn+) as call → value: it gets the
327
+ # call's typed args (Api.args_of), as the tool's block does. nil
328
+ # without one.
329
+ def self.per_call(fn, parameters)
330
+ fn && ->(call) { fn.call(Api.args_of(call, parameters)) }
331
+ end
332
+
315
333
  # A tool's result as the model reads it: a String (a ToolResult too,
316
334
  # with its images) as it is, a Hash or Array as JSON (its #to_s is
317
335
  # Hash#inspect, which changes with the Ruby version), else #to_s.
@@ -7,9 +7,9 @@ module Samagotchi
7
7
  # Which of its own reasoning a chat host's model gets back
8
8
  # (LLM::ChatLoop sends it as reasoning_content on its earlier replies):
9
9
  #
10
- # turn the running turn's replies only (the tool-call rounds since the
11
- # last user message); what Qwen's, Gemma 4's and DeepSeek's
12
- # templates and the hosted APIs expect
10
+ # turn the running turn's replies only (since the turn's start; a
11
+ # steer, a plugin's nudge or a Continue's text merged into the
12
+ # turn doesn't reset it, as on a native host: .run_start)
13
13
  # drop none
14
14
  # all every reply that has reasoning, earlier turns too (a direct
15
15
  # DeepSeek API with tools wants this)
@@ -17,7 +17,8 @@ module Samagotchi
17
17
  # Order (#resolve): the session's own level (/reasoning-history), then
18
18
  # SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
19
19
  # hosts.<name>.reasoning_history, then turn. Native hosts ignore it:
20
- # KernelLoop keeps the running turn's reasoning on its own.
20
+ # KernelLoop keeps the running turn's reasoning on its own, from the
21
+ # run's start (.run_start).
21
22
  module ReasoningHistory
22
23
  LEVELS = %i[drop turn all].freeze
23
24
  DEFAULT = :turn
@@ -118,6 +119,23 @@ module Samagotchi
118
119
  end
119
120
  end
120
121
 
122
+ # Where a run's own reasoning starts in +messages+: its start (+since+;
123
+ # nil without one). Inside one run nothing re-sanitizes, so an entry the
124
+ # run merged (a steer, a delegate report, the Qwen recovery prompt) left
125
+ # the reasoning before it in the prompt: it stays. Only a user or
126
+ # turn_start entry added after the run ended (after its last model
127
+ # message: a !cmd's output) starts it again past itself.
128
+ # @param messages [Array<Hash>] the conversation
129
+ # @param since [Integer, nil] the run's start
130
+ # @return [Integer, nil]
131
+ def run_start(messages, since)
132
+ return nil if since.nil?
133
+
134
+ last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
135
+ query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
136
+ query && query > last_reply ? [query + 1, since].max : since
137
+ end
138
+
121
139
  # "drop (models: qwen)", "turn (default)".
122
140
  def label(level, source) = "#{level} (#{source || "default"})"
123
141
 
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "log"
5
+ require_relative "ref"
6
+
7
+ module Samagotchi
8
+ # The refs a plugin tool gives its row (chi.tool's refs: lambda, traceable
9
+ # refs P5d): what the call traces to (a ticket, a PR, a file), each a Ref
10
+ # whose +to_h+ the row carries as +refs+ on tool_call_started and
11
+ # tool_call_completed (ToolRowFields::EVENT_KEYS), saved with the result
12
+ # as tool_refs (ToolResponse::SAVED_KEYS) for a reload, which has no
13
+ # plugins (Web::MessageParts). Display only: the model never sees them.
14
+ #
15
+ # A file ref is any file under the session's folder (plan D7): a relative
16
+ # path is resolved against it, a path outside it is dropped. The folder
17
+ # counts under its own spelling and its real path (macOS /tmp is
18
+ # /private/tmp), and a kept path is stored under the real one, so a
19
+ # reload under either spelling keeps it. The page
20
+ # shows the first ref on the row's title and the rest in its menu.
21
+ module RowRefs
22
+ # Per row, so a plugin can't bloat the events or session.json.
23
+ MAX_REFS = 20
24
+ # The row's refs' JSON; a ref that would cross it is left out.
25
+ MAX_BYTES = 64 * 1024
26
+
27
+ module_function
28
+
29
+ # Runs +fn+ (the tool's refs: lambda, as Api.per_call wraps it: call →
30
+ # a list of {kind:, target:, label:, actions:}) on +call+. Never
31
+ # raises: a lambda that raises or returns something other than a list
32
+ # gives none, and a bad ref is dropped; both are logged (warn, not
33
+ # echoed: the turn's live region is on screen).
34
+ # @param cwd [String, nil] the session's folder
35
+ # @return [Array<Hash>] Ref#to_h each, deduped, at most MAX_REFS
36
+ def for(fn, call, cwd:)
37
+ return [] unless fn
38
+
39
+ tool = call[:name].to_s
40
+ raw = fn.call(call)
41
+ return [] if raw.nil?
42
+ raise ArgumentError, "refs: returned #{raw.class}, not a list" unless raw.is_a?(Array)
43
+
44
+ build(raw, cwd) { |why| Log.warn(:plugins, "tool_refs_rejected", tool: tool, reason: why) }
45
+ rescue StandardError => e
46
+ Log.warn(:plugins, "tool_refs_failed", tool: tool, error: "#{e.class}: #{e.message}")
47
+ []
48
+ end
49
+
50
+ # Stored refs (+raw+, a saved tool_refs entry), each rebuilt and
51
+ # checked again as #for checks a lambda's: a stored file is never
52
+ # trusted. What isn't valid any more is dropped, a relative path too
53
+ # (it was stored absolute). Never raises: a reload must show the
54
+ # conversation.
55
+ # @return [Array<Hash>]
56
+ def for_display(raw, cwd:)
57
+ return [] unless raw.is_a?(Array)
58
+
59
+ build(raw, cwd, relative: false) { |_why| nil }
60
+ rescue StandardError
61
+ []
62
+ end
63
+
64
+ # +raw+'s valid refs as Ref#to_h; +rejected+ gets why each other one
65
+ # was dropped. +relative+: a relative file path is resolved against
66
+ # +cwd+ (else dropped).
67
+ def build(raw, cwd, relative: true, &)
68
+ roots = roots(cwd)
69
+ bytes = 2 # the list's []
70
+ raw.each_with_object([]) do |value, refs|
71
+ break refs if refs.length >= MAX_REFS
72
+
73
+ hash = inside(Ref.from(value), roots, relative).to_h
74
+ next if refs.include?(hash)
75
+
76
+ size = JSON.generate(hash).bytesize + (refs.empty? ? 0 : 1)
77
+ break refs if bytes + size > MAX_BYTES
78
+
79
+ bytes += size
80
+ refs << hash
81
+ # One junk ref ({1 => "x"}, a nil target) goes alone.
82
+ rescue ArgumentError, TypeError, NoMethodError => e
83
+ yield(e.message)
84
+ end
85
+ end
86
+ private_class_method :build
87
+
88
+ # +cwd+ as [its spelling, its real path] (one when they are the same,
89
+ # none without a +cwd+). absolute_path, not expand_path: a leading ~ is
90
+ # a name, never a home. One realpath per row: the lambda's refs are
91
+ # checked together.
92
+ def roots(cwd)
93
+ return [] if cwd.to_s.empty?
94
+
95
+ given = File.absolute_path(cwd.to_s)
96
+ real = begin
97
+ File.realpath(given)
98
+ rescue SystemCallError
99
+ given
100
+ end
101
+ [given, real].uniq
102
+ end
103
+ private_class_method :roots
104
+
105
+ # +ref+ with a file's path absolute, resolved against the folder
106
+ # (+roots+, #roots), under its real spelling.
107
+ # @raise [ArgumentError] a relative path with no folder (or not
108
+ # +relative+), or a path outside the folder
109
+ def inside(ref, roots, relative)
110
+ return ref unless ref.kind == Ref::FILE
111
+
112
+ path = ref.target[:path]
113
+ unless File.absolute_path?(path) || (relative && roots.any?)
114
+ raise ArgumentError, "a file ref's relative path needs the session folder"
115
+ end
116
+ return ref if roots.empty?
117
+
118
+ path = File.absolute_path(path, roots.first)
119
+ root = roots.find { |dir| path == dir || path.start_with?(dir.end_with?("/") ? dir : "#{dir}/") }
120
+ raise ArgumentError, "a file ref outside the session folder (#{path})" unless root
121
+
122
+ ref.with(target: ref.target.merge(path: "#{roots.last}#{path.delete_prefix(root)}").freeze)
123
+ end
124
+ private_class_method :inside
125
+ end
126
+ end
@@ -416,10 +416,20 @@ module Samagotchi
416
416
  # analytics.json in the session directory. Atomic write; failures are
417
417
  # swallowed (analytics must never break the running session).
418
418
  # @param state_dir [String, nil]
419
+ # @param running_turn [Boolean] also save the running turn's record so
420
+ # far (a worker's turn checkpoint): as the turn a worker killed now
421
+ # would leave, canceled with reason worker_stopped (TurnNote's), up to
422
+ # its completed_at. The next collector loads it with the others; the
423
+ # turn's own end writes over it. Only the file has it.
419
424
  # @return [Boolean] true on success
420
- def persist(state_dir: nil)
425
+ def persist(state_dir: nil, running_turn: false)
421
426
  sid, data, turn_count = @mutex.synchronize do
422
- [@session_id, build_snapshot(:all).merge(active_turn: nil, active_tools: []), @turn_records.size]
427
+ data = build_snapshot(:all).merge(active_turn: nil, active_tools: [])
428
+ if running_turn && @turn
429
+ data[:turn_records] << running_turn_record
430
+ data[:cancellations] += 1
431
+ end
432
+ [@session_id, data, @turn_records.size]
423
433
  end
424
434
  return false if sid.nil? || sid.to_s.empty?
425
435
 
@@ -611,6 +621,18 @@ module Samagotchi
611
621
  end
612
622
  end
613
623
 
624
+ # The running turn's record so far, as end_turn would make it for a
625
+ # turn a worker died in (#persist's running_turn). Its open generation
626
+ # (none at an iteration boundary) and running tool calls are left out.
627
+ # Caller holds the mutex.
628
+ def running_turn_record
629
+ {
630
+ id: @turn.id, status: "canceled", started_at: @turn.started_at, completed_at: now.iso8601(3),
631
+ duration_ms: elapsed_ms(@turn.started_monotonic), cancellation_reason: "worker_stopped",
632
+ continue: @turn.continue, resumed: @turn.resumed
633
+ }.merge(turn_token_fields(@turn))
634
+ end
635
+
614
636
  # What turn_failed's line is made of (format.js failedTurnText): a
615
637
  # provider error's summary, else the message (capped: a server's error
616
638
  # page can be long) and class, and the steps that stayed (kept_steps).
@@ -363,7 +363,7 @@ module Samagotchi
363
363
 
364
364
  # A cancel's reason as the UIs name it (anything else as it is);
365
365
  # spec/shared/labels_matrix.json pins it for both.
366
- CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
366
+ CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped" }.freeze
367
367
  # A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
368
368
  # not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
369
369
  STOP_MARK = "■"
@@ -885,12 +885,35 @@ module Samagotchi
885
885
  end
886
886
 
887
887
  # The REPL keeps the kernel's conversation as-is (no [No response]
888
- # placeholder), so /continue resumes from the tool results.
889
- session.messages = result.conversation
888
+ # placeholder), so /continue resumes from the tool results; the
889
+ # answer keeps what the after_turn hooks stored on it.
890
+ session.messages = with_answer_display(result.conversation, session.messages)
890
891
  @turn_flow.after_turn(result, continue: continue)
891
892
  save_session(session)
892
893
  end
893
894
 
895
+ # The answer's display keys (Engine#store_answer_display put them on the
896
+ # session's last message) carried onto +conversation+'s last message,
897
+ # when that is the same answer; +conversation+ as it is otherwise.
898
+ ANSWER_DISPLAY_KEYS = [AnswerDisplay::KEY, AnswerRefs::KEY].freeze
899
+ private_constant :ANSWER_DISPLAY_KEYS
900
+
901
+ def with_answer_display(conversation, stored)
902
+ answer = Array(conversation).last
903
+ kept = Array(stored).last
904
+ return conversation unless answer.is_a?(Hash) && kept.is_a?(Hash) && message_field(answer, :role).to_s == "model"
905
+ return conversation unless message_field(kept, :role).to_s == "model" &&
906
+ message_field(kept, :content) == message_field(answer, :content)
907
+
908
+ extra = ANSWER_DISPLAY_KEYS.each_with_object({}) do |key, found|
909
+ value = message_field(kept, key)
910
+ found[key] = value unless value.nil?
911
+ end
912
+ extra.empty? ? conversation : conversation[0...-1] + [answer.merge(extra)]
913
+ end
914
+
915
+ def message_field(message, key) = message.key?(key) ? message[key] : message[key.to_s]
916
+
894
917
  def save_session(session)
895
918
  @engine.store_model!(session)
896
919
  session.save
@@ -970,7 +993,7 @@ module Samagotchi
970
993
  end
971
994
  canceled = result&.canceled?
972
995
  if result && !canceled
973
- session.messages = result.conversation if result.conversation.is_a?(Array)
996
+ session.messages = with_answer_display(result.conversation, session.messages) if result.conversation.is_a?(Array)
974
997
  @engine.store_model!(session)
975
998
  session.save
976
999
  end
@@ -211,12 +211,16 @@ module Samagotchi
211
211
  # "stopped" for a
212
212
  # task_wait the user's Stop ended (the task itself runs on), for one
213
213
  # whose task was stopped (the stop-task button, or the model's task_stop),
214
- # and for a command the user's Stop killed or kept from starting: a
215
- # cancel, not a failure (its text still starts "Error:" for the model).
214
+ # and for a command the user's Stop (or a signal to chi) killed or kept
215
+ # from starting: a cancel, not a failure (its text still starts "Error:"
216
+ # for the model).
216
217
  def tool_activity_status(result, tool_name = nil, command: nil)
217
218
  text = result.to_s
218
- return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
219
- return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
219
+ return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::NOT_RUN_ON_SIGNAL].include?(text)
220
+ if tool_name == Tools::Execute::NAME &&
221
+ text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::STOPPED_BY_SIGNAL)
222
+ return "stopped"
223
+ end
220
224
  return "error" if text.start_with?("Error:")
221
225
  # Still running, as a task: its output tail may hold any "exit: N".
222
226
  return "ok" if handed_over?(tool_name, text)
@@ -9,11 +9,11 @@ module Samagotchi
9
9
  # (max_tool_output_chars).
10
10
  module ToolResponse
11
11
  SEPARATOR = "\n\n---\n\n"
12
- # Saved with a result for the web's reload (a plugin tool's params line
13
- # and label, an edit/write's diff) and for the LLMContextView (each
12
+ # Saved with a result for the web's reload (a plugin tool's params line,
13
+ # label and row refs, an edit/write's diff) and for the LLMContextView (each
14
14
  # run's chi-owned id, ToolIds, and a strategy's edits by id,
15
15
  # LLMContextEdit); the prompt never renders them.
16
- SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_ids edits].freeze
16
+ SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
17
17
  # A run's "[name]" lead: the name, then a newline, a space or the end.
18
18
  LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
19
19
 
@@ -60,7 +60,7 @@ module Samagotchi
60
60
  entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
61
61
  end
62
62
  saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
63
- tool_diffs: runs.map { |run| run[:diff] } }
63
+ tool_diffs: runs.map { |run| run[:diff] }, tool_refs: runs.map { |run| run[:refs] } }
64
64
  saved.each { |key, values| entry[key] = values if values.any? }
65
65
  entry[:tool_ids] = ids
66
66
  entry
@@ -75,6 +75,7 @@ module Samagotchi
75
75
  entry[:tool_params] = run[:shown_params] if run[:shown_params]
76
76
  entry[:tool_labels] = run[:shown_label] if run[:shown_label]
77
77
  entry[:tool_diffs] = run[:diff] if run[:diff]
78
+ entry[:tool_refs] = run[:refs] if run[:refs]
78
79
  entry[:tool_ids] = ids
79
80
  entry
80
81
  end
@@ -16,13 +16,15 @@ module Samagotchi
16
16
  # tool_call_completed (ToolActivity.tool_activity_event builds that
17
17
  # activity itself, for the TUI and the metrics);
18
18
  # * EVENT_KEYS: top-level on both, so a UI that missed the start (a
19
- # replay gap) builds its row from the completed event.
19
+ # replay gap) builds its row from the completed event. +refs+ (a
20
+ # plugin tool's, RowRefs) is one, but ToolRunner adds it: #for has no
21
+ # plugins, so a reload reads the saved tool_refs instead.
20
22
  #
21
23
  # params and label stay where they are: their sources differ per path
22
24
  # (the registry's preview, a plugin's label, the saved shown params).
23
25
  module ToolRowFields
24
26
  ACTIVITY_KEYS = %i[title called_as].freeze
25
- EVENT_KEYS = %i[view ref].freeze
27
+ EVENT_KEYS = %i[view ref refs].freeze
26
28
  KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
27
29
 
28
30
  module_function
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "tool_activity"
4
4
  require_relative "tool_row_fields"
5
+ require_relative "row_refs"
5
6
  require_relative "guardrails"
6
7
  require_relative "vision_context"
7
8
  require_relative "log"
@@ -41,7 +42,8 @@ module Samagotchi
41
42
  # shown_params: the params line the live row showed, and shown_label:
42
43
  # its label ("chrome: screenshot"), only for a tool that isn't built in
43
44
  # (the loops save them with the result, so a reload without the plugin
44
- # shows the same row), and diff: what an edit/write changed in its
45
+ # shows the same row), refs: its row's refs (a plugin tool's refs:,
46
+ # RowRefs; saved likewise), and diff: what an edit/write changed in its
45
47
  # file (EditPreview.change; also on tool_call_completed, never in the
46
48
  # model's output)
47
49
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
@@ -55,9 +57,12 @@ module Samagotchi
55
57
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
56
58
  tool: call[:name], call: call.dup, params: params }
57
59
  started[:label] = label if label
58
- # Its row fields (ToolRowFields: title, called_as, view, ref); the worker runs in
59
- # its session's working directory.
60
+ # Its row fields (ToolRowFields: title, called_as, view, ref, and a
61
+ # plugin tool's refs); the worker runs in its session's working
62
+ # directory.
60
63
  fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
64
+ refs = RowRefs.for(tools && !call[:name].nil? ? tools[call[:name]]&.refs : nil, call, cwd: session_cwd)
65
+ fields[:refs] = refs unless refs.empty?
61
66
  started.merge!(fields)
62
67
  emit(on_stream_event, started)
63
68
 
@@ -121,6 +126,7 @@ module Samagotchi
121
126
  run[:images] = images if images&.any?
122
127
  run[:shown_params] = params if params && plugin_tool?(call[:name])
123
128
  run[:shown_label] = label if label
129
+ run[:refs] = refs unless refs.empty?
124
130
  run[:diff] = diff if diff
125
131
  run
126
132
  end
@@ -249,6 +255,13 @@ module Samagotchi
249
255
  [result[:output], *notes].join("\n")
250
256
  end
251
257
 
258
+ # The session's folder (the Engine's, KernelLoop#session_cwd), else the
259
+ # process's: a bare kernel (specs) has none.
260
+ def session_cwd
261
+ cwd = @kernel.respond_to?(:session_cwd) && @kernel.session_cwd&.call
262
+ cwd.to_s.empty? ? Dir.pwd : cwd
263
+ end
264
+
252
265
  # The kernel's tools, for the activity line of a tool that isn't built in.
253
266
  def tools = @kernel.tools
254
267
 
@@ -77,7 +77,7 @@ module Samagotchi
77
77
  start_line: call[:start_line], end_line: call[:end_line])
78
78
  end,
79
79
  TaskCreate::NAME => lambda do |call, kctx|
80
- next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
80
+ next Execute.not_run(stop_reason_proc(kctx)) if cancelled_proc(kctx).call
81
81
 
82
82
  TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
83
83
  marker_env: task_marker_env(kctx, call[:env]))
@@ -87,7 +87,8 @@ module Samagotchi
87
87
  done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
88
88
  end,
89
89
  Execute::NAME => lambda do |call, kctx|
90
- Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), env: parent_env(kctx))
90
+ Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
91
+ env: parent_env(kctx))
91
92
  end,
92
93
  RegisterReminder::NAME => lambda do |call, kctx|
93
94
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
@@ -163,6 +164,12 @@ module Samagotchi
163
164
  -> { !!peers&.cancelled? }
164
165
  end
165
166
 
167
+ # The running turn's stop reason (:signal, :manual, …), nil without one.
168
+ def stop_reason_proc(kctx)
169
+ peers = kctx.peers
170
+ -> { peers.respond_to?(:cancel_reason) ? peers.cancel_reason : nil }
171
+ end
172
+
166
173
  # @param command_description [Boolean] false: execute declares no
167
174
  # description parameter (execute.description off)
168
175
  # @param policy [String, nil] llm_context.policy, the sentence
@@ -27,6 +27,10 @@ module Samagotchi
27
27
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
28
28
  # The first words of a command the user's Stop killed.
29
29
  STOPPED_BY_USER = "Error: command stopped by the user"
30
+ # The same two when a signal to chi (a worker's TERM, HUP or INT)
31
+ # stopped the turn: its cancel reason is :signal.
32
+ NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
33
+ STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
30
34
  # The first words of a command killed at the limit in kill mode (hooks
31
35
  # match on it).
32
36
  TIMEOUT_PREFIX = "Error: command timed out after"
@@ -73,11 +77,13 @@ module Samagotchi
73
77
 
74
78
  # @param cancelled [#call] true once the user stopped the turn: the
75
79
  # command is killed (or not started) and its output so far returned
80
+ # @param stop_reason [#call] the stop's reason (:signal: a signal to
81
+ # chi, which the result then names instead of the user)
76
82
  # @param env [Hash, nil] added to the command's environment
77
83
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
78
84
  # nil reads execute.on_timeout
79
- def self.call(command, cwd: nil, cancelled: -> { false }, env: nil, on_timeout: nil)
80
- return NOT_RUN_ON_STOP if cancelled.call
85
+ def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
86
+ return not_run(stop_reason) if cancelled.call
81
87
 
82
88
  command = command.strip
83
89
  resolved_cwd = resolve_cwd(cwd)
@@ -109,11 +115,17 @@ module Samagotchi
109
115
  # a long command, and return whatever it printed before it was killed.
110
116
  stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
111
117
  rescue CommandCancelled => e
112
- stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
118
+ first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
119
+ stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
113
120
  rescue StandardError => e
114
121
  "Error: #{e.message}"
115
122
  end
116
123
 
124
+ # What a command the turn's stop kept from starting returns.
125
+ def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
126
+
127
+ def self.signal?(stop_reason) = stop_reason.call == :signal
128
+
117
129
  # execute.on_timeout, or the caller's: :task (the default) or :kill.
118
130
  def self.on_timeout_mode(value)
119
131
  value = Samagotchi::Config.get("execute.on_timeout") if value.nil? || value.to_s.strip.empty?
@@ -10,11 +10,12 @@ module Samagotchi
10
10
  # can't host one), and the model the session runs on (model_ref: the
11
11
  # resolved ref, live after /model), and the session's own thinking level
12
12
  # (thinking: nil without one) and reasoning_history level
13
- # (reasoning_history: nil without one). Engine hands KernelLoop one that follows its current
13
+ # (reasoning_history: nil without one), and why the turn was canceled
14
+ # (cancel_reason: :signal, :manual, …; nil while it runs). Engine hands KernelLoop one that follows its current
14
15
  # session (Engine::PeerView); this plain one is for specs and a kernel
15
16
  # without an Engine.
16
17
  Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
17
- keyword_init: true) do
18
+ :cancel_reason, keyword_init: true) do
18
19
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
19
20
  def cancelled?
20
21
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -20,11 +20,13 @@ module Samagotchi
20
20
  # tool" when nil); ToolActivity knows the built-ins' own
21
21
  # @!attribute preview [#call, nil] call → the activity line's params
22
22
  # @!attribute targets [#call, nil] call → what guardrail rules match
23
+ # @!attribute refs [#call, nil] call → the row's refs (a plugin's
24
+ # refs:, RowRefs checks what it gives)
23
25
  # @!attribute source [String] "core", or the bundle that added it
24
26
  # @!attribute layer [Symbol, nil] the LLM context layer the tool
25
27
  # belongs to (LLMContextStrategy): offered only in a turn running
26
28
  # under it (forget_outputs, :forget); nil: always
27
- Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :source, :layer, keyword_init: true) do
29
+ Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, keyword_init: true) do
28
30
  def core? = source == "core"
29
31
 
30
32
  # Offered in a turn under +layers+ (the turn's LLM context layers).
@@ -36,11 +38,11 @@ module Samagotchi
36
38
  end
37
39
 
38
40
  # @return [Entry]
39
- def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, source: "core", layer: nil)
41
+ def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
40
42
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
41
43
 
42
44
  @entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
43
- targets: targets, source: source, layer: layer)
45
+ targets: targets, refs: refs, source: source, layer: layer)
44
46
  end
45
47
 
46
48
  # Remove a tool (a plugin's tool set changed after load).
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.61.0"
4
+ VERSION = "0.62.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end