samagotchi 0.61.0 → 0.63.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +26 -9
  7. data/docs/configuration.md +33 -20
  8. data/docs/context.md +21 -3
  9. data/docs/guardrails.md +2 -1
  10. data/docs/hooks.md +14 -3
  11. data/docs/internals/engine.md +24 -11
  12. data/docs/internals/prompt-caching.md +6 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +131 -5
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +4 -4
  17. data/docs/sub-agent.md +6 -0
  18. data/docs/testing.md +10 -4
  19. data/lib/samagotchi/answer_refs.rb +18 -26
  20. data/lib/samagotchi/bridge.rb +23 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  29. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  31. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  35. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/engine.rb +9 -0
  40. data/lib/samagotchi/engine_peers.rb +1 -0
  41. data/lib/samagotchi/idle_recap.rb +2 -3
  42. data/lib/samagotchi/image_store.rb +8 -0
  43. data/lib/samagotchi/kernel_loop.rb +7 -16
  44. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  45. data/lib/samagotchi/plugin/api.rb +36 -18
  46. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  47. data/lib/samagotchi/reasoning_history.rb +22 -4
  48. data/lib/samagotchi/recap_setup.rb +3 -3
  49. data/lib/samagotchi/row_refs.rb +125 -0
  50. data/lib/samagotchi/session.rb +12 -0
  51. data/lib/samagotchi/session_metrics.rb +24 -2
  52. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  54. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  55. data/lib/samagotchi/terminal_ui.rb +57 -14
  56. data/lib/samagotchi/tool_activity.rb +8 -4
  57. data/lib/samagotchi/tool_response.rb +5 -4
  58. data/lib/samagotchi/tool_row_fields.rb +4 -2
  59. data/lib/samagotchi/tool_runner.rb +16 -3
  60. data/lib/samagotchi/tools/builtins.rb +9 -2
  61. data/lib/samagotchi/tools/execute.rb +37 -7
  62. data/lib/samagotchi/tools/peers.rb +3 -2
  63. data/lib/samagotchi/tools/registry.rb +5 -3
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +20 -5
  66. data/lib/samagotchi/web/message_parts.rb +17 -9
  67. data/lib/samagotchi/web/public/activity.js +6 -3
  68. data/lib/samagotchi/web/public/app.js +106 -106
  69. data/lib/samagotchi/web/public/card.js +73 -23
  70. data/lib/samagotchi/web/public/chains.js +24 -24
  71. data/lib/samagotchi/web/public/command_view.js +39 -35
  72. data/lib/samagotchi/web/public/context_chips.js +92 -64
  73. data/lib/samagotchi/web/public/copy.js +7 -15
  74. data/lib/samagotchi/web/public/diff_view.js +20 -23
  75. data/lib/samagotchi/web/public/families_view.js +51 -39
  76. data/lib/samagotchi/web/public/format.js +0 -48
  77. data/lib/samagotchi/web/public/images.js +12 -16
  78. data/lib/samagotchi/web/public/index.html +61 -10
  79. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  80. data/lib/samagotchi/web/public/lists_view.js +13 -13
  81. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  82. data/lib/samagotchi/web/public/question_card.js +11 -0
  83. data/lib/samagotchi/web/public/question_cards.js +63 -7
  84. data/lib/samagotchi/web/public/read_view.js +10 -9
  85. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  86. data/lib/samagotchi/web/public/refs.js +113 -15
  87. data/lib/samagotchi/web/public/scope.js +23 -0
  88. data/lib/samagotchi/web/public/scroll.js +42 -0
  89. data/lib/samagotchi/web/public/stage_model.js +22 -3
  90. data/lib/samagotchi/web/public/stage_view.js +299 -70
  91. data/lib/samagotchi/web/public/timing.js +1 -8
  92. data/lib/samagotchi/web/public/turn_html.js +122 -61
  93. data/lib/samagotchi/web/public/turn_model.js +49 -0
  94. data/lib/samagotchi/web/public/turn_view.js +167 -116
  95. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  96. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  97. data/lib/samagotchi/worker.rb +49 -32
  98. data/lib/samagotchi/worker_inbound.rb +17 -14
  99. metadata +10 -1
@@ -18,6 +18,7 @@ require_relative "thinking"
18
18
  require_relative "hooks"
19
19
  require_relative "pending_input_queue"
20
20
  require_relative "steer"
21
+ require_relative "reasoning_history"
21
22
  require_relative "thought_stream_splitter"
22
23
  require_relative "tools/builtins"
23
24
  require_relative "muted_memories"
@@ -172,6 +173,10 @@ module Samagotchi
172
173
  # too); the thinking level is its own field, since the native path
173
174
  # sends the sampling as is.
174
175
  attr_accessor :turn_settings
176
+ # The session's working directory (a callable → String or nil), which
177
+ # a plugin tool's row refs resolve their files against (ToolRunner,
178
+ # RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
179
+ attr_accessor :session_cwd
175
180
  # Tools::Peers (or the Engine's live view of it): the session
176
181
  # list_sessions and send_note speak for; nil outside a session.
177
182
  attr_accessor :peers
@@ -291,7 +296,7 @@ module Samagotchi
291
296
  private_constant :Turn, :Request, :Generation
292
297
 
293
298
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
294
- conversation = prepare_conversation(messages, keep_thought_from: thought_floor(messages, reasoning_since))
299
+ conversation = prepare_conversation(messages, keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since))
295
300
  context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
296
301
  emit = ->(event) { emit_stream_event(on_stream_event, event) }
297
302
  @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
@@ -1017,24 +1022,10 @@ module Samagotchi
1017
1022
  end
1018
1023
 
1019
1024
  # Standard multi-turn compliance: never pass prior raw thought blocks,
1020
- # but a resumed run's (+keep_thought_from+, #thought_floor): the same
1025
+ # but a resumed run's (+keep_thought_from+, ReasoningHistory.run_start): the same
1021
1026
  # run going on after a step-limit Continue.
1022
1027
  def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
1023
1028
 
1024
- # Where a resumed run's thought blocks start: its start (+since+). Inside
1025
- # one native run nothing re-sanitizes, so a user entry the run merged (a
1026
- # steer, a delegate report, the Qwen recovery prompt) left the thoughts
1027
- # before it in the prompt: they stay. Only a user or turn_start entry
1028
- # added after the run ended (after its last model message: a !cmd's
1029
- # output) starts them again past itself. nil without +since+.
1030
- def thought_floor(messages, since)
1031
- return nil if since.nil?
1032
-
1033
- last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
1034
- query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
1035
- query && query > last_reply ? [query + 1, since].max : since
1036
- end
1037
-
1038
1029
  def duplicate_conversation(messages)
1039
1030
  messages.map(&:dup)
1040
1031
  end
@@ -24,6 +24,7 @@ require_relative "../vision_context"
24
24
  require_relative "../log"
25
25
  require_relative "../empty_answer_retry"
26
26
  require_relative "../steer"
27
+ require_relative "../reasoning_history"
27
28
  require_relative "../turn_note"
28
29
  require_relative "../thinking"
29
30
 
@@ -70,7 +71,7 @@ module Samagotchi
70
71
  def provider = :chat
71
72
 
72
73
  # +reasoning_since+ (ModelBackend#complete): the resumed run's start,
73
- # the Run's own start then (#reasoning_boundary).
74
+ # the Run's own start then (Run#initialize, #reasoning_boundary).
74
75
  def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
75
76
  model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
76
77
  raise ArgumentError, "ChatLoop has no adapter" unless @adapter
@@ -137,7 +138,8 @@ module Samagotchi
137
138
  # A model turn carries its saved reasoning as reasoning_content when
138
139
  # +reasoning+ (a ReasoningHistory level; the turn's by default) says
139
140
  # so: under turn the running turn's only (#reasoning_boundary, with
140
- # +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.
141
143
  #
142
144
  # The conversation goes through the kernel's LLMContextView first,
143
145
  # as the native prompt does (it keeps every entry in place).
@@ -194,19 +196,24 @@ module Samagotchi
194
196
  TOOL_IMAGES_TEXT = "[images from tool results]"
195
197
 
196
198
  # The first index whose model entries carry their reasoning (nil:
197
- # none do) under +level+. Under turn: past both the last query (a
198
- # user message: a prompt, a steer, merged input, the chat template's
199
- # last query; or an entry that starts a turn, turn_start) and +since+
200
- # (the Run's start, so a continue turn -- a reminder, a context wake,
201
- # a delegate report's wake -- sends none of the run before it; a
202
- # 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).
203
208
  # @return [Integer, nil]
204
209
  def reasoning_boundary(conversation, since: nil, level: reasoning_history)
205
210
  case level
206
211
  when :all then 0
207
212
  when :turn
213
+ return since if since
214
+
208
215
  query = conversation.rindex { |entry| entry[:role].to_s == "user" || entry[:turn_start] == true }
209
- [query ? query + 1 : 0, since.to_i].max
216
+ query ? query + 1 : 0
210
217
  end
211
218
  end
212
219
 
@@ -417,9 +424,10 @@ module Samagotchi
417
424
  @cancel_controller = cancel_controller
418
425
  @model_name = model_name
419
426
  @pending_input = pending_input
420
- # Where this run's entries start: the reasoning it sends back
421
- # (ChatLoop#reasoning_boundary) is its own, a resumed run's too.
422
- @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)
423
431
  @tool_activity = []
424
432
  @empty_retry = EmptyAnswerRetry.new
425
433
  # The empty generations, never saved: the UIs draw the turn's
@@ -596,7 +604,7 @@ module Samagotchi
596
604
 
597
605
  def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
598
606
  @loop.adapter.chat(
599
- 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,
600
608
  cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
601
609
  options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
602
610
  on_delta: lambda { |content:, reasoning:, payload:|
@@ -670,7 +678,7 @@ module Samagotchi
670
678
 
671
679
  # Whether this step's request sends reasoning back.
672
680
  def reasoning_sent?
673
- !@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?
674
682
  end
675
683
 
676
684
  # The level this step's request sends reasoning back under: none
@@ -733,7 +741,7 @@ module Samagotchi
733
741
  # names and arguments, and the reasoning sent back.
734
742
  def prompt_chars
735
743
  LLMContextView.chars(@loop.llm_context_view.messages(@conversation)) +
736
- @loop.reasoning_chars(@conversation, since: @start, reasoning: reasoning_level)
744
+ @loop.reasoning_chars(@conversation, since: @reasoning_start, reasoning: reasoning_level)
737
745
  end
738
746
 
739
747
  # The server's counts for this request (prompt + answer): the status
@@ -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.
@@ -94,18 +94,21 @@ module Samagotchi
94
94
  nil
95
95
  end
96
96
 
97
- # This session's sources (its own, then its project's).
98
- # @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:}
97
+ # This session's sources (its own, then its project's); none before
98
+ # the session has an id or a saved file.
99
+ # +summary+ is the last text's summary (nil before any text).
100
+ # @return [Array<Hash>] {name:, scope:, why:, hint:, fetched_at:, error:, provider:, summary:}
99
101
  def list
100
102
  id = @host.session_id.call
101
- return [] unless id
103
+ # No id, or no saved file yet (a command before the first turn).
104
+ return [] unless id && Session.exist?(id, state_dir: state_dir)
102
105
 
103
106
  session = Session.load(id, state_dir: state_dir)
104
107
  ContextSources.attached(id, project_root: session.project_root, state_dir: state_dir).map do |attached|
105
108
  snapshot = attached.snapshot
106
109
  { name: attached.name, scope: attached.location.scope, why: attached.source.why,
107
110
  hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
108
- provider: attached.source.provider }
111
+ provider: attached.source.provider, summary: snapshot.summary }
109
112
  end
110
113
  end
111
114
 
@@ -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
 
@@ -31,12 +31,12 @@ module Samagotchi
31
31
  # @return [IdleRecap, nil]
32
32
  def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
33
33
  return nil if recap == false
34
- # The TUI passes the config file's section; a worker passes nothing, so
35
- # read it here too (a scalar `recap: false` is only seen this way).
34
+ # The REPL and a worker pass nothing: the config is read here alone
35
+ # (a scalar `recap: false` is only seen this way).
36
36
  return nil if recap.nil? && ConfigFile.recap_config == false
37
37
  return nil if Samagotchi::Config.get("recap.enabled") == false
38
38
 
39
- # Normalize kwarg (TerminalUI passes recap: recap_config hash or nil)
39
+ # An explicit kwarg (a spec, an embedder) over the registry.
40
40
  kwarg_config = recap.is_a?(Hash) ? recap : {}
41
41
 
42
42
  base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
@@ -0,0 +1,125 @@
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. The answer's refs (AnswerRef) check against it too.
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
+
104
+ # +ref+ with a file's path absolute, resolved against the folder
105
+ # (+roots+, #roots), under its real spelling: plan D7, for a plugin
106
+ # row's refs and an answer's (AnswerRef) alike.
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
+ end
125
+ end
@@ -275,6 +275,18 @@ module Samagotchi
275
275
  { id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
276
276
  end
277
277
 
278
+ # A question saved by a process that no longer runs this session (the
279
+ # one that loads it now owns it): nobody waits on its answer, so it is
280
+ # dropped. Unsaved; the caller saves.
281
+ # @return [String, nil] the dropped question's id, nil with none
282
+ def drop_dead_question
283
+ pending = pending_question
284
+ return nil unless pending
285
+
286
+ @pending_question = nil
287
+ pending[:id].to_s
288
+ end
289
+
278
290
  # The stored project root, else (a file from before the field) the
279
291
  # project of the working directory now: nil when that is in no repo or
280
292
  # gone. +cache+ is ProjectScope.root_for's, shared across one listing.
@@ -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).
@@ -46,12 +46,15 @@ module Samagotchi
46
46
  # @param thinking [String, nil] --thinking as typed: a new session
47
47
  # starts with it as its own level; a resumed or attached one's
48
48
  # worker gets a /thinking, after those
49
+ # @param state_dir [String, nil] where the sessions are (the default
50
+ # state dir by default): the worker's, for the attached loop too
49
51
  # @return [Symbol] :detached, :closed when the worker went away,
50
52
  # :failed when the --model switch didn't go through, or (input from a
51
53
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
52
54
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
53
- memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
54
- client = connect(attach: attach, shared: shared, resume: resume, model: model,
55
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], state_dir: nil)
56
+ state_dir ||= Session.default_state_dir
57
+ client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
55
58
  memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
56
59
  appended_prompt_files: appended_prompt_files)
57
60
  commands = []
@@ -65,7 +68,7 @@ module Samagotchi
65
68
  first_command: first_command, no_interrupt: no_interrupt,
66
69
  default_input: default_input && !prompt && !attach && !resume,
67
70
  wait_at_eof: !$stdin.tty?,
68
- parent_answers: Guardrails::ParentApprovals.parent_process?)
71
+ parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
69
72
  ended = begin
70
73
  attached.run
71
74
  ensure