samagotchi 0.60.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/architecture.md +10 -4
  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 +80 -1
  9. data/docs/internals/engine.md +57 -15
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/plugins.md +49 -6
  12. data/docs/sessions.md +7 -3
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/answer_display.rb +4 -3
  15. data/lib/samagotchi/answer_refs.rb +210 -0
  16. data/lib/samagotchi/bridge/card_store.rb +6 -1
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/engine.rb +47 -9
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/hooks/registry.rb +3 -1
  30. data/lib/samagotchi/interrupted_turn.rb +39 -0
  31. data/lib/samagotchi/kernel_loop.rb +13 -20
  32. data/lib/samagotchi/llm/chat_loop.rb +28 -17
  33. data/lib/samagotchi/log_subscriber.rb +6 -3
  34. data/lib/samagotchi/plugin/api.rb +36 -18
  35. data/lib/samagotchi/reasoning_history.rb +22 -4
  36. data/lib/samagotchi/ref.rb +25 -8
  37. data/lib/samagotchi/reply_wait.rb +6 -2
  38. data/lib/samagotchi/row_refs.rb +126 -0
  39. data/lib/samagotchi/session.rb +40 -4
  40. data/lib/samagotchi/session_manager.rb +64 -6
  41. data/lib/samagotchi/session_metrics.rb +24 -2
  42. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  43. data/lib/samagotchi/terminal_ui.rb +30 -3
  44. data/lib/samagotchi/tool_activity.rb +8 -4
  45. data/lib/samagotchi/tool_response.rb +5 -4
  46. data/lib/samagotchi/tool_row_fields.rb +4 -2
  47. data/lib/samagotchi/tool_runner.rb +16 -3
  48. data/lib/samagotchi/tools/builtins.rb +9 -2
  49. data/lib/samagotchi/tools/execute.rb +15 -3
  50. data/lib/samagotchi/tools/peers.rb +3 -2
  51. data/lib/samagotchi/tools/registry.rb +5 -3
  52. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +19 -1
  55. data/lib/samagotchi/web/message_parts.rb +17 -9
  56. data/lib/samagotchi/web/public/activity.js +4 -1
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +74 -33
  59. data/lib/samagotchi/web/public/chains.js +18 -20
  60. data/lib/samagotchi/web/public/context_chips.js +92 -64
  61. data/lib/samagotchi/web/public/families_view.js +51 -39
  62. data/lib/samagotchi/web/public/index.html +66 -13
  63. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  64. data/lib/samagotchi/web/public/lists_view.js +13 -13
  65. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  66. data/lib/samagotchi/web/public/question_card.js +11 -0
  67. data/lib/samagotchi/web/public/question_cards.js +60 -5
  68. data/lib/samagotchi/web/public/ref_menu.js +24 -3
  69. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  70. data/lib/samagotchi/web/public/refs.js +140 -20
  71. data/lib/samagotchi/web/public/scroll.js +42 -0
  72. data/lib/samagotchi/web/public/stage_model.js +22 -3
  73. data/lib/samagotchi/web/public/stage_view.js +299 -70
  74. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  75. data/lib/samagotchi/web/public/timing.js +1 -1
  76. data/lib/samagotchi/web/public/turn_html.js +5 -4
  77. data/lib/samagotchi/web/public/turn_model.js +49 -0
  78. data/lib/samagotchi/web/public/turn_view.js +121 -17
  79. data/lib/samagotchi/worker.rb +169 -21
  80. data/lib/samagotchi/worker_inbound.rb +5 -3
  81. data/lib/samagotchi/worker_signals.rb +81 -0
  82. metadata +8 -1
@@ -11,6 +11,7 @@ require_relative "native_tool_normalizer"
11
11
  require_relative "stream_drops"
12
12
  require_relative "../kernel_loop"
13
13
  require_relative "../answer_display"
14
+ require_relative "../answer_refs"
14
15
  require_relative "../cut_policy"
15
16
  require_relative "../context_status"
16
17
  require_relative "../context_window"
@@ -23,6 +24,7 @@ require_relative "../vision_context"
23
24
  require_relative "../log"
24
25
  require_relative "../empty_answer_retry"
25
26
  require_relative "../steer"
27
+ require_relative "../reasoning_history"
26
28
  require_relative "../turn_note"
27
29
  require_relative "../thinking"
28
30
 
@@ -69,7 +71,7 @@ module Samagotchi
69
71
  def provider = :chat
70
72
 
71
73
  # +reasoning_since+ (ModelBackend#complete): the resumed run's start,
72
- # the Run's own start then (#reasoning_boundary).
74
+ # the Run's own start then (Run#initialize, #reasoning_boundary).
73
75
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
74
76
  model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
75
77
  raise ArgumentError, "ChatLoop has no adapter" unless @adapter
@@ -136,7 +138,8 @@ module Samagotchi
136
138
  # A model turn carries its saved reasoning as reasoning_content when
137
139
  # +reasoning+ (a ReasoningHistory level; the turn's by default) says
138
140
  # so: under turn the running turn's only (#reasoning_boundary, with
139
- # +since+ the Run's start), under all every one.
141
+ # +since+ where the Run's reasoning starts; a steer it merged doesn't
142
+ # move that), under all every one.
140
143
  #
141
144
  # The conversation goes through the kernel's LLMContextView first,
142
145
  # as the native prompt does (it keeps every entry in place).
@@ -193,19 +196,24 @@ module Samagotchi
193
196
  TOOL_IMAGES_TEXT = "[images from tool results]"
194
197
 
195
198
  # The first index whose model entries carry their reasoning (nil:
196
- # none do) under +level+. Under turn: past both the last query (a
197
- # user message: a prompt, a steer, merged input, the chat template's
198
- # last query; or an entry that starts a turn, turn_start) and +since+
199
- # (the Run's start, so a continue turn -- a reminder, a context wake,
200
- # a delegate report's wake -- sends none of the run before it; a
201
- # step-limit Continue's Run starts where the run it resumes did).
199
+ # none do) under +level+. Under turn: +since+ when given, where a
200
+ # Run's reasoning starts (ReasoningHistory.run_start, the native
201
+ # loop's rule: an entry the run merged -- a person's line, a plugin's
202
+ # steer, a Continue's text -- keeps the reasoning before it, so the
203
+ # request after it extends the one before; a continue turn -- a
204
+ # reminder, a context wake, a delegate report's wake -- starts a Run
205
+ # of its own and sends none of the run before it). Without +since+,
206
+ # past the last query (a user message or an entry that starts a turn,
207
+ # turn_start).
202
208
  # @return [Integer, nil]
203
209
  def reasoning_boundary(conversation, since: nil, level: reasoning_history)
204
210
  case level
205
211
  when :all then 0
206
212
  when :turn
213
+ return since if since
214
+
207
215
  query = conversation.rindex { |entry| entry[:role].to_s == "user" || entry[:turn_start] == true }
208
- [query ? query + 1 : 0, since.to_i].max
216
+ query ? query + 1 : 0
209
217
  end
210
218
  end
211
219
 
@@ -302,8 +310,9 @@ module Samagotchi
302
310
  # refs of a user message or a tool result, a prompt's turn_id (Engine,
303
311
  # never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
304
312
  # (the live row's params line and label, never sent back), an
305
- # edit/write result's tool_diffs (never sent), and an empty-answer
306
- # note's marker (TurnNote.empty, never sent).
313
+ # edit/write result's tool_diffs (never sent), an empty-answer
314
+ # note's marker (TurnNote.empty, never sent), and an answer's display
315
+ # and display_refs (AnswerDisplay, AnswerRefs; never sent).
307
316
  def plain(conversation)
308
317
  conversation.map do |entry|
309
318
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -319,6 +328,7 @@ module Samagotchi
319
328
  marker = TurnNote.empty_answer(entry)
320
329
  message[TurnNote::EMPTY_ANSWER] = marker if marker
321
330
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
331
+ message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
322
332
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
323
333
  message
324
334
  end
@@ -414,9 +424,10 @@ module Samagotchi
414
424
  @cancel_controller = cancel_controller
415
425
  @model_name = model_name
416
426
  @pending_input = pending_input
417
- # Where this run's entries start: the reasoning it sends back
418
- # (ChatLoop#reasoning_boundary) is its own, a resumed run's too.
419
- @start = start || conversation.length
427
+ # Where the reasoning this run sends back starts
428
+ # (ChatLoop#reasoning_boundary): its own replies', a resumed run's
429
+ # too, fixed for the run; a steer it merges doesn't move it.
430
+ @reasoning_start = ReasoningHistory.run_start(conversation, start || conversation.length)
420
431
  @tool_activity = []
421
432
  @empty_retry = EmptyAnswerRetry.new
422
433
  # The empty generations, never saved: the UIs draw the turn's
@@ -593,7 +604,7 @@ module Samagotchi
593
604
 
594
605
  def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
595
606
  @loop.adapter.chat(
596
- messages: @loop.wire_messages(@conversation, since: @start, reasoning: reasoning_level), tools: @loop.tool_definitions, model: @model_name,
607
+ messages: @loop.wire_messages(@conversation, since: @reasoning_start, reasoning: reasoning_level), tools: @loop.tool_definitions, model: @model_name,
597
608
  cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
598
609
  options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
599
610
  on_delta: lambda { |content:, reasoning:, payload:|
@@ -667,7 +678,7 @@ module Samagotchi
667
678
 
668
679
  # Whether this step's request sends reasoning back.
669
680
  def reasoning_sent?
670
- !@without_reasoning && @loop.reasoning_chars(@conversation, since: @start, reasoning: reasoning_level).positive?
681
+ !@without_reasoning && @loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level).positive?
671
682
  end
672
683
 
673
684
  # The level this step's request sends reasoning back under: none
@@ -730,7 +741,7 @@ module Samagotchi
730
741
  # names and arguments, and the reasoning sent back.
731
742
  def prompt_chars
732
743
  LLMContextView.chars(@loop.llm_context_view.messages(@conversation)) +
733
- @loop.reasoning_chars(@conversation, since: @start, reasoning: reasoning_level)
744
+ @loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level)
734
745
  end
735
746
 
736
747
  # The server's counts for this request (prompt + answer): the status
@@ -152,10 +152,13 @@ module Samagotchi
152
152
  log(level, :hook_notice, hook: event[:hook], fallback_for: event[:fallback_for], msg: event[:text].to_s[0, 300])
153
153
  end
154
154
 
155
- # after_turn hooks presented the answer (AnswerDisplay): its size only
156
- # (none for the display: nil that says nothing was presented).
155
+ # after_turn hooks presented the answer (AnswerDisplay) or attached refs
156
+ # to it (AnswerRefs): the display's size and the refs' count only (none
157
+ # for the display: nil that says nothing was presented).
157
158
  def on_answer_display(event)
158
- log(:info, :answer_display, chars: event[:display].to_s.length) if event[:display]
159
+ return unless event[:display] || event[:refs]
160
+
161
+ log(:info, :answer_display, chars: event[:display]&.length, refs: event[:refs])
159
162
  end
160
163
 
161
164
  # A card shown to the user (Engine#show_card): whose, which, and its
@@ -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
 
@@ -10,14 +10,17 @@ module Samagotchi
10
10
  # (refs.js httpUrl): the page opens it as is, so a javascript:, data: or
11
11
  # editor URL never gets there.
12
12
  RefAction = Data.define(:id, :label, :url) do
13
- # @raise [ArgumentError] an empty id or label, or a URL that isn't an
14
- # http(s) one with a host, at most MAX_URL characters
13
+ # @raise [ArgumentError] an empty id or label, one over its cap
14
+ # (MAX_ID, MAX_LABEL), or a URL that isn't an http(s) one with a host,
15
+ # at most MAX_URL characters
15
16
  def initialize(id:, label:, url:)
16
17
  id = id.to_s.strip
17
18
  label = label.to_s.strip
18
19
  url = url.to_s.strip
19
20
  raise ArgumentError, "a ref action needs an id" if id.empty?
20
21
  raise ArgumentError, "a ref action needs a label" if label.empty?
22
+ raise ArgumentError, "a ref action's id is at most #{RefAction::MAX_ID} characters" if id.length > RefAction::MAX_ID
23
+ raise ArgumentError, "a ref action's label is at most #{RefAction::MAX_LABEL} characters" if label.length > RefAction::MAX_LABEL
21
24
  raise ArgumentError, "a ref action's url must be http(s) with a host, at most #{RefAction::MAX_URL} characters" unless RefAction.http_url?(url)
22
25
 
23
26
  super
@@ -26,13 +29,17 @@ module Samagotchi
26
29
 
27
30
  class RefAction
28
31
  MAX_URL = 2_000
32
+ MAX_ID = 64
33
+ MAX_LABEL = 100
29
34
 
30
- # @return [Boolean] +url+ is an http or https URL with a host
35
+ # @return [Boolean] +url+ is an http or https URL with a host and no
36
+ # userinfo (a "https://github.com@evil.example/" shows one host and
37
+ # opens another, and a token in it would be copied around)
31
38
  def self.http_url?(url)
32
39
  return false if url.length > MAX_URL
33
40
 
34
41
  uri = URI.parse(url)
35
- uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
42
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
36
43
  rescue URI::InvalidURIError
37
44
  false
38
45
  end
@@ -61,13 +68,17 @@ module Samagotchi
61
68
  # rows' own ref; this is the one a plugin gives.
62
69
  Ref = Data.define(:kind, :target, :label, :actions) do
63
70
  # @raise [ArgumentError] a kind that isn't a short lowercase word, a
64
- # target without its kind's key, a non-list of actions or a bad action
71
+ # target without its kind's key, a non-list of actions, a bad action,
72
+ # or a value over its cap (MAX_LABEL, MAX_ACTIONS, MAX_ID, MAX_PATH)
65
73
  def initialize(kind:, target:, label: nil, actions: [])
66
74
  kind = kind.to_s
67
75
  raise ArgumentError, "a ref's kind is a short lowercase word (#{kind.inspect})" unless Ref::KIND.match?(kind)
68
76
  raise ArgumentError, "a ref's actions are a list" unless actions.nil? || actions.is_a?(Array)
77
+ raise ArgumentError, "a ref has at most #{Ref::MAX_ACTIONS} actions" if Array(actions).length > Ref::MAX_ACTIONS
69
78
 
70
79
  label = label.to_s.strip
80
+ raise ArgumentError, "a ref's label is at most #{Ref::MAX_LABEL} characters" if label.length > Ref::MAX_LABEL
81
+
71
82
  super(kind: kind, target: Ref.target_for(kind, target), label: label.empty? ? nil : label,
72
83
  actions: Array(actions).map { |action| RefAction.from(action) }.freeze)
73
84
  end
@@ -81,6 +92,11 @@ module Samagotchi
81
92
  KIND = /\A[a-z][a-z0-9_-]{0,31}\z/
82
93
  # FileRef::KIND, not required here (it loads the file tools).
83
94
  FILE = "file"
95
+ # Caps, so a plugin can't bloat session.json or the page.
96
+ MAX_LABEL = 200
97
+ MAX_ACTIONS = 8
98
+ MAX_ID = 200
99
+ MAX_PATH = 4_096
84
100
 
85
101
  # A plugin's {kind:, target:, label:, actions:} (String or Symbol keys).
86
102
  # @raise [ArgumentError] as +new+
@@ -97,19 +113,20 @@ module Samagotchi
97
113
  raise ArgumentError, "a ref's target is a Hash" unless raw.is_a?(Hash)
98
114
 
99
115
  raw = raw.transform_keys(&:to_sym)
100
- kind == FILE ? file_target(raw) : { id: required(raw[:id], :id) }.freeze
116
+ kind == FILE ? file_target(raw) : { id: required(raw[:id], :id, MAX_ID) }.freeze
101
117
  end
102
118
 
103
119
  def self.file_target(raw)
104
120
  line = positive(raw[:line])
105
121
  end_line = line && positive(raw[:end_line])
106
- { path: required(raw[:path], :path), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
122
+ { path: required(raw[:path], :path, MAX_PATH), line: line, end_line: end_line && end_line > line ? end_line : nil }.compact.freeze
107
123
  end
108
124
  private_class_method :file_target
109
125
 
110
- def self.required(value, key)
126
+ def self.required(value, key, max)
111
127
  value = value.to_s.strip
112
128
  raise ArgumentError, "a ref's target needs #{key}" if value.empty?
129
+ raise ArgumentError, "a ref's target #{key} is at most #{max} characters" if value.length > max
113
130
 
114
131
  value
115
132
  end
@@ -106,10 +106,14 @@ module Samagotchi
106
106
 
107
107
  if session.status == Session::STATUS_RUNNING
108
108
  seen_running = true
109
- elsif seen_running || turn_ended_since?(session, baseline)
109
+ elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
110
110
  # It ran and is idle again with no new reply: canceled, failed or
111
111
  # empty. Before it was ever seen running, idle means a
112
- # file-delivered message its worker has not picked up yet.
112
+ # file-delivered message its worker has not picked up yet. With
113
+ # the baseline's last_turn only a turn that ended since counts:
114
+ # a session left running by a worker that died goes idle when the
115
+ # next worker settles that turn (TurnCheckpoint.settle), before
116
+ # the sent message's turn runs.
113
117
  # The worker writes the reply before its idle save, so the read
114
118
  # above has it; one more look costs nothing should that change.
115
119
  return reply_past(id, state_dir: state_dir, cursor: cursor)&.tap { |r| r.session = session } ||
@@ -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
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "context_note"
9
+ require_relative "interrupted_turn"
9
10
  require_relative "last_turn"
10
11
  require_relative "llm_context_override"
11
12
  require_relative "session_setup"
@@ -107,6 +108,26 @@ module Samagotchi
107
108
  def last_turn=(value)
108
109
  @last_turn = LastTurn.from_file(value)
109
110
  end
111
+
112
+ # The mark of a turn a worker checkpointed and never finished
113
+ # (InterruptedTurn), as loaded; the next worker settles it and sets it
114
+ # to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
115
+ attr_reader :interrupted_turn
116
+
117
+ # @param value [InterruptedTurn, Hash, nil]
118
+ def interrupted_turn=(value)
119
+ @interrupted_turn = InterruptedTurn.from_file(value)
120
+ end
121
+
122
+ # The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
123
+ # (marker an InterruptedTurn). While +status+ is running, every save
124
+ # writes these messages and the marker instead of #messages and
125
+ # #interrupted_turn, so a save in the middle of the turn (a question
126
+ # asked, the checkpoint's own) keeps the turn so far on disk; #messages
127
+ # itself is never touched. The turn's end sets status idle before it
128
+ # saves, so that save writes the real messages, without a marker. Not
129
+ # saved; the worker clears it after each turn.
130
+ attr_accessor :turn_checkpoint
110
131
  # The session's own llm_context values (LLMContextOverride: strategy,
111
132
  # apply rule, budget), before the model's; nil when it has none.
112
133
  # Saved, so a --resume and a respawned worker keep them; a plugin's
@@ -191,7 +212,8 @@ module Samagotchi
191
212
  used_memory_names: [], project_root: nil,
192
213
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
193
214
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
194
- continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
215
+ continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
216
+ interrupted_turn: nil)
195
217
  @id = id
196
218
  @metadata_version = metadata_version
197
219
  @mode = mode
@@ -220,6 +242,8 @@ module Samagotchi
220
242
  self.thinking = thinking
221
243
  self.reasoning_history = reasoning_history
222
244
  self.appended_prompt_files = appended_prompt_files
245
+ self.interrupted_turn = interrupted_turn
246
+ @turn_checkpoint = nil
223
247
  @archived = false
224
248
  end
225
249
 
@@ -363,7 +387,8 @@ module Samagotchi
363
387
  "continues" => nil,
364
388
  "thinking" => nil,
365
389
  "reasoning_history" => nil,
366
- "appended_prompt_files" => []
390
+ "appended_prompt_files" => [],
391
+ "interrupted_turn" => nil
367
392
  }.freeze
368
393
 
369
394
  # A session from a parsed session file. +messages+ false leaves the
@@ -475,11 +500,15 @@ module Samagotchi
475
500
  self
476
501
  end
477
502
 
478
- # The session as #save writes it (FIELDS, in their order).
503
+ # The session as #save writes it (FIELDS, in their order); a running
504
+ # turn's checkpoint (#turn_checkpoint) in place of its messages.
479
505
  def to_h
506
+ checkpoint = checkpointed
480
507
  FIELDS.keys.to_h do |key|
481
508
  value = case key
482
- when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
509
+ when "messages"
510
+ (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
511
+ when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
483
512
  when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
484
513
  when "test_run" then !!@test_run
485
514
  when "delegate" then @delegate
@@ -496,6 +525,13 @@ module Samagotchi
496
525
  end
497
526
  end
498
527
 
528
+ # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
529
+ def checkpointed
530
+ checkpoint = @turn_checkpoint
531
+ checkpoint if checkpoint && @status == STATUS_RUNNING
532
+ end
533
+ private :checkpointed
534
+
499
535
  # Mark a session as errored.
500
536
  def self.mark_error(session_id, reason:, state_dir: default_state_dir)
501
537
  session = load(session_id, state_dir: state_dir)