samagotchi 0.53.0 → 0.55.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -43,8 +43,10 @@ module Samagotchi
43
43
  action: tool_activity_action(tool_name, registry: registry, call: call),
44
44
  tool: tool_name,
45
45
  params: tool_activity_params(tool_name, call, registry: registry),
46
- status: tool_activity_status(result, tool_name)
46
+ status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
47
47
  }
48
+ # A grep that found nothing: ok, and the web row says "no match".
49
+ event[:no_match] = true if event[:status] == "ok" && tool_name == Tools::Execute::NAME && execute_failed?(result.to_s)
48
50
  title = tool_title(tool_name, call, cwd: cwd)
49
51
  event[:title] = title if title
50
52
  # The name the model used when an alias ran a built-in (called_as: "bash"
@@ -181,13 +183,6 @@ module Samagotchi
181
183
  end
182
184
  end
183
185
 
184
- # "error" for an "Error:" result, and for an execute whose command
185
- # exited non-zero (its last "exit: N" line; no number: killed by a
186
- # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
187
- # task_wait the user's Stop ended (the task itself runs on), for one
188
- # whose task was stopped (the stop-task button, or the model's task_stop),
189
- # and for a command the user's Stop killed or kept from starting: a
190
- # cancel, not a failure (its text still starts "Error:" for the model).
191
186
  # memory_write's label: a call with only a description changes the index line.
192
187
  def memory_write_action(call)
193
188
  return "saving memory" unless call.is_a?(Hash)
@@ -205,23 +200,98 @@ module Samagotchi
205
200
  request.ids.empty? && request.restore? ? "restoring outputs" : "forgetting outputs"
206
201
  end
207
202
 
208
- def tool_activity_status(result, tool_name = nil)
203
+ # "error" for an "Error:" result, and for an execute whose command
204
+ # exited non-zero (its last "exit: N" line; no number: killed by a
205
+ # signal), so the turn tally's "(N failed)" counts it; but not a grep
206
+ # that found nothing (no_match?, given the +command+): that stays "ok".
207
+ # "stopped" for a
208
+ # task_wait the user's Stop ended (the task itself runs on), for one
209
+ # whose task was stopped (the stop-task button, or the model's task_stop),
210
+ # and for a command the user's Stop killed or kept from starting: a
211
+ # cancel, not a failure (its text still starts "Error:" for the model).
212
+ def tool_activity_status(result, tool_name = nil, command: nil)
209
213
  text = result.to_s
210
214
  return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
211
215
  return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
212
216
  return "error" if text.start_with?("Error:")
213
- return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
217
+ if tool_name == Tools::Execute::NAME && execute_failed?(text)
218
+ return no_match?(text, command) ? "ok" : "error"
219
+ end
214
220
  return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
215
221
 
216
222
  "ok"
217
223
  end
218
224
 
219
225
  def execute_failed?(text)
220
- exit_line = text.lines.reverse_each.find { |line| line.start_with?("exit: ") }
221
- return false unless exit_line
226
+ code = execute_exit_code(text)
227
+ !code.nil? && code != "0"
228
+ end
229
+
230
+ # The code on an execute result's last "exit: N" line ("" when killed
231
+ # by a signal); nil with no exit line.
232
+ def execute_exit_code(text)
233
+ exit_line = text.to_s.lines.reverse_each.find { |line| line.start_with?("exit: ") }
234
+ exit_line && exit_line[/\Aexit: (\d*)/, 1]
235
+ end
236
+
237
+ # The grep family: exit 1 means "searched, found nothing".
238
+ NO_MATCH_COMMANDS = %w[grep egrep fgrep rg ag pgrep].freeze
239
+ # Commands that can fail without a message, so an "&&" after them may
240
+ # hide which step exited 1.
241
+ SILENT_FAILERS = ["test", "[", "[[", "false", "diff", "cmp", "which", "type", "hash"].freeze
242
+ QUIET_FLAGS = %w[-q --quiet -s].freeze
243
+ # Any output sent to /dev/null (">", "1>", "2>", ">>"): its message may be gone.
244
+ TO_DEV_NULL = %r{>\s*/dev/null}
245
+ # Commands that end the script with their own code: the exit 1 may be theirs.
246
+ EXITS = %w[exit return].freeze
247
+
248
+ # An execute that exited 1 with no stderr block, whose last step is a
249
+ # grep-family command, and where no "&&" follows a step that could have
250
+ # failed silently: a search that found nothing, not a failure. Also
251
+ # false with a background "&" step (CommandSteps splits "&>" there too),
252
+ # an earlier exit/return, or set -e (any earlier step's failure ends the
253
+ # script). When unsure (no command, a command CommandSteps can't read),
254
+ # false.
255
+ def no_match?(text, command)
256
+ return false if command.nil? || execute_exit_code(text) != "1"
257
+ return false if text.to_s.each_line.any? { |line| line.chomp == "stderr:" }
258
+
259
+ steps = CommandSteps.parse(command)&.steps
260
+ return false unless steps && NO_MATCH_COMMANDS.include?(step_words(steps.last).first)
261
+ return false if steps.any? { |step| step.op == "&" }
262
+ return false if steps[0...-1].any? { |step| ends_script?(step) }
263
+
264
+ steps.each_cons(2).none? { |step, after| after.op == "&&" && silent_failer?(step) }
265
+ end
266
+
267
+ # A step's words from its command word on: no NAME=value prefixes, the
268
+ # command word without its path.
269
+ def step_words(step)
270
+ words = Guardrails::ShellLex.lex(step.text).filter_map { |kind, value| value if kind == :word }
271
+ words = words.drop_while { |word| word.match?(/\A[A-Za-z_][A-Za-z0-9_]*=/) }
272
+ return [] if words.empty?
273
+
274
+ [File.basename(words.first), *words.drop(1)]
275
+ end
276
+
277
+ # exit / return, or set -e (-e alone or in a flag group, -o errexit).
278
+ def ends_script?(step)
279
+ name, *args = step_words(step)
280
+ return true if EXITS.include?(name)
281
+ return false unless name == "set"
282
+
283
+ args.any? { |arg| arg.match?(/\A-[a-z]*e/) } || args.each_cons(2).include?(%w[-o errexit])
284
+ end
285
+
286
+ def silent_failer?(step)
287
+ return true if step.text.match?(TO_DEV_NULL) || step.text.lstrip.start_with?("((")
288
+
289
+ name, *args = step_words(step)
290
+ return true if SILENT_FAILERS.include?(name)
291
+ return true if name == "command" && args.intersect?(%w[-v -V])
292
+ return true if NO_MATCH_COMMANDS.include?(name) && args.intersect?(QUIET_FLAGS)
222
293
 
223
- code = exit_line[/\Aexit: (\d*)/, 1]
224
- code != "0"
294
+ name == "git" && args.first == "diff" && args.intersect?(%w[--quiet --exit-code])
225
295
  end
226
296
 
227
297
  def tool_activity_params(tool_name, call, registry: nil)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_activity"
4
+ require_relative "tool_view"
5
+ require_relative "file_ref"
6
+
7
+ module Samagotchi
8
+ # The fields a tool row shows beyond its params, built from the call in
9
+ # one place for every path: the live tool_call_started (ToolRunner#run)
10
+ # and a reload from the saved call (Web::MessageParts#tool_part). The
11
+ # bridge's snapshot and replay (Bridge::TurnAccumulator) copy them by
12
+ # these lists, so a new field is added here and travels everywhere.
13
+ #
14
+ # They travel in two places:
15
+ # * ACTIVITY_KEYS: top-level on tool_call_started, inside +activity+ on
16
+ # tool_call_completed (ToolActivity.tool_activity_event builds that
17
+ # activity itself, for the TUI and the metrics);
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.
20
+ #
21
+ # params and label stay where they are: their sources differ per path
22
+ # (the registry's preview, a plugin's label, the saved shown params).
23
+ module ToolRowFields
24
+ ACTIVITY_KEYS = %i[title called_as].freeze
25
+ EVENT_KEYS = %i[view ref].freeze
26
+ KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
27
+
28
+ module_function
29
+
30
+ # @param cwd [String, nil] the session's working directory (the title is
31
+ # relative to it when inside it)
32
+ # @return [Hash] title (a few words: a project-relative path, a command
33
+ # without its "cd … &&"), called_as (the name the model called it by
34
+ # when that was an alias's: bash), view (ToolView: the full command)
35
+ # and ref (FileRef: the file a file tool touched), each only when
36
+ # there is one
37
+ def for(tool_name, call, cwd:)
38
+ {
39
+ title: ToolActivity.tool_title(tool_name, call, cwd: cwd),
40
+ called_as: call[:called_as],
41
+ view: ToolView.for(tool_name, call)&.to_h,
42
+ ref: FileRef.for(tool_name, call, cwd: cwd)&.to_h
43
+ }.compact
44
+ end
45
+ end
46
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tool_activity"
4
- require_relative "tool_view"
4
+ require_relative "tool_row_fields"
5
5
  require_relative "guardrails"
6
6
  require_relative "vision_context"
7
7
  require_relative "log"
@@ -54,14 +54,10 @@ module Samagotchi
54
54
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
55
55
  tool: call[:name], call: call.dup, params: params }
56
56
  started[:label] = label if label
57
- # The worker runs in its session's working directory.
58
- title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
59
- started[:title] = title if title
60
- # The name the model called it by when that was an alias's (bash).
61
- started[:called_as] = call[:called_as] if call[:called_as]
62
- # The full command for a richer UI (params stays cut at 80).
63
- view = ToolView.for(call[:name], call)&.to_h
64
- started[:view] = view if view
57
+ # Its row fields (ToolRowFields: title, called_as, view, ref); the worker runs in
58
+ # its session's working directory.
59
+ fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
60
+ started.merge!(fields)
65
61
  emit(on_stream_event, started)
66
62
 
67
63
  # The ask comes after tool_call_started: the UI shows the tool line,
@@ -102,8 +98,8 @@ module Samagotchi
102
98
  completed[:diff] = diff if diff
103
99
  completed[:waited_ms] = waited_ms if waited_ms
104
100
  # Also here: a UI that missed the start (a replay gap) builds its row
105
- # from this event.
106
- completed[:view] = view if view
101
+ # from this event (its title is in the activity).
102
+ completed.merge!(fields.slice(*ToolRowFields::EVENT_KEYS))
107
103
  emit(on_stream_event, completed)
108
104
  # After the completed event: a hook's card (check-in, skills) prints
109
105
  # under the call's tool row, not before it.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.53.0"
4
+ VERSION = "0.55.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -40,6 +40,7 @@ require_relative "../context_providers"
40
40
  require_relative "../recap_store"
41
41
  require_relative "markdown_renderer"
42
42
  require_relative "capabilities"
43
+ require_relative "editor"
43
44
  require_relative "message_parts"
44
45
  require_relative "session_summary"
45
46
  require_relative "../log"
@@ -88,6 +89,9 @@ module Samagotchi
88
89
  # @param annotate_presets [String, Array] the quick replies next to
89
90
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
90
91
  # page parses them (annotate_presets.js).
92
+ # @param editor [String] what a ref opens on this machine (web.editor:
93
+ # a preset or a URL template, Web::Editor); only a local viewer gets
94
+ # it (#viewer_editor)
91
95
  # @param hub [SessionHub, nil] the session projection GET /api/sessions
92
96
  # lists and GET /api/events streams from (Server always builds one);
93
97
  # without one both routes answer 503
@@ -102,6 +106,7 @@ module Samagotchi
102
106
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
103
107
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
104
108
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
109
+ editor: Config::BY_KEY["web.editor"].default,
105
110
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
106
111
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
107
112
  @manager = manager || SessionManager
@@ -117,6 +122,7 @@ module Samagotchi
117
122
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
118
123
  @view = view
119
124
  @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
125
+ @editor_url = Editor.template(editor)
120
126
  @hub = hub
121
127
  @events_heartbeat = events_heartbeat
122
128
  @events_queue = events_queue
@@ -212,6 +218,23 @@ module Samagotchi
212
218
  ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
213
219
  end
214
220
 
221
+ # The payload's capabilities (Capabilities, per request) and, next to
222
+ # them, editor_url: the URL template a ref opens (web.editor), nil
223
+ # when this viewer can't open one.
224
+ def viewer_capabilities(req)
225
+ editor_url = @editor_url && local_viewer?(req) ? @editor_url : nil
226
+ { capabilities: Capabilities.for(@markdown_renderer, editor: !editor_url.nil?).to_h, editor_url: editor_url }
227
+ end
228
+
229
+ # A viewer on this machine, whose editor can open the session's
230
+ # paths: a loopback peer, or this Mac on its own LAN address (the LAN
231
+ # URL or its QR code opened here). The phone isn't. An SSH tunnel or
232
+ # a local reverse proxy looks like loopback too (a known gap).
233
+ def local_viewer?(req)
234
+ peer = req.get_header("REMOTE_ADDR").to_s
235
+ LOOPBACK_PEERS.include?(peer) || (!@lan.nil? && peer == @lan[:ip])
236
+ end
237
+
215
238
  LOOPBACK_NAMES = %w[127.0.0.1 [::1] localhost].freeze
216
239
  LOOPBACK_PEERS = %w[127.0.0.1 ::1 ::ffff:127.0.0.1].freeze
217
240
 
@@ -698,7 +721,7 @@ module Samagotchi
698
721
  # the new link started is not a refusal of it.
699
722
  def continue_from(given, prompt:, title:)
700
723
  [@manager.continue_session(given, prompt: prompt, title: title, state_dir: @state_dir), nil]
701
- rescue SessionManager::ContinueRefused => e
724
+ rescue SessionContinue::Refused => e
702
725
  extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
703
726
  [nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
704
727
  rescue SessionManager::ArchiveRefused => e
@@ -821,7 +844,7 @@ module Samagotchi
821
844
  # names its plugins' too.
822
845
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
823
846
  markdown_warning: @markdown_renderer.warning,
824
- capabilities: Capabilities.for(@markdown_renderer).to_h,
847
+ **viewer_capabilities(req),
825
848
  pending_question: pending,
826
849
  last_event_seq: last_event_seq,
827
850
  # The stream cursor `<seq>-<epoch>`: a later worker resets it
@@ -881,7 +904,7 @@ module Samagotchi
881
904
  session: session_json,
882
905
  messages: messages,
883
906
  markdown_warning: @markdown_renderer.warning,
884
- capabilities: Capabilities.for(@markdown_renderer).to_h,
907
+ **viewer_capabilities(req),
885
908
  timing: timing
886
909
  })
887
910
  rescue ArgumentError => e
@@ -3,16 +3,20 @@
3
3
  module Samagotchi
4
4
  module Web
5
5
  # What this web page renders that a notice may stand in for
6
- # (Engine#hook_notify's fallback_for:). The session and tail JSON carry
7
- # it as +capabilities+; the page leaves out a notice whose fallback_for
8
- # names a capability that is true here. +display+: the answer's display
9
- # is rendered as markdown, so its links are links (web.markdown on and
10
- # commonmarker installed). A later capability is a new member. (The
11
- # member hides Object#display, which nothing calls on it.)
12
- Capabilities = Data.define(:display) do # rubocop:disable Lint/DataDefineOverride
6
+ # (Engine#hook_notify's fallback_for:), and what this viewer can do. The
7
+ # session and tail JSON carry it as +capabilities+; the page leaves out
8
+ # a notice whose fallback_for names a capability that is true here.
9
+ # +display+: the answer's display is rendered as markdown, so its links
10
+ # are links (web.markdown on and commonmarker installed). +editor+: this
11
+ # viewer can open a path in this machine's editor (a viewer on this
12
+ # machine, web.editor not none); per request, the URL template goes next
13
+ # to it as the payload's editor_url. A later capability is a new member.
14
+ # (The member hides Object#display, which nothing calls on it.)
15
+ Capabilities = Data.define(:display, :editor) do # rubocop:disable Lint/DataDefineOverride
13
16
  # @param markdown_renderer [MarkdownRenderer]
14
- def self.for(markdown_renderer)
15
- new(display: markdown_renderer.available? == true)
17
+ # @param editor [Boolean] this request's viewer can open refs
18
+ def self.for(markdown_renderer, editor: false)
19
+ new(display: markdown_renderer.available? == true, editor: editor == true)
16
20
  end
17
21
  end
18
22
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../log"
4
+
5
+ module Samagotchi
6
+ module Web
7
+ # web.editor: the URL a ref's click opens in this machine's editor, as a
8
+ # template with {path} (the file's absolute path, each segment encoded
9
+ # by the page) and an optional {line}. A preset name, or a template of
10
+ # one's own; "none" (or an unknown name) turns refs off.
11
+ module Editor
12
+ PRESETS = {
13
+ "vscode" => "vscode://file{path}:{line}",
14
+ "vscode-insiders" => "vscode-insiders://file{path}:{line}",
15
+ "cursor" => "cursor://file{path}:{line}",
16
+ "zed" => "zed://file{path}:{line}"
17
+ }.freeze
18
+ NONE = "none"
19
+
20
+ module_function
21
+
22
+ # @param value [String, nil] the web.editor setting
23
+ # @return [String, nil] the URL template; nil for none. A value that
24
+ # is neither a preset nor has {path} logs a warning and is none.
25
+ def template(value)
26
+ text = value.to_s.strip
27
+ return text if text.include?("{path}")
28
+
29
+ name = text.downcase
30
+ return nil if name.empty? || name == NONE
31
+ return PRESETS[name] if PRESETS.key?(name)
32
+
33
+ Log.warn(:web, "editor_unknown", value: text)
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
@@ -3,7 +3,7 @@
3
3
  require_relative "../model_profile"
4
4
  require_relative "../tool_call_parser"
5
5
  require_relative "../tool_activity"
6
- require_relative "../tool_view"
6
+ require_relative "../tool_row_fields"
7
7
  require_relative "../llm/native_tool_normalizer"
8
8
  require_relative "../tools/builtins"
9
9
  require_relative "../image_store"
@@ -231,15 +231,10 @@ module Samagotchi
231
231
  name = call[:name].to_s
232
232
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
233
233
  part = { tool: name, params: params.to_s }
234
- title = ToolActivity.tool_title(name, call, cwd: cwd)
235
- part[:title] = title if title
236
- # The name the model called it by when that was an alias's (the
237
- # saved call is rebuilt by BuiltinCalls, which sets it again).
238
- part[:called_as] = call[:called_as] if call[:called_as]
239
- # Built from the saved call (never saved itself), so older sessions
240
- # get it too.
241
- view = ToolView.for(name, call)&.to_h
242
- part[:view] = view if view
234
+ # Its row fields (ToolRowFields: title, called_as, view, ref), built from the saved
235
+ # call (never saved themselves), so older sessions get them too. The
236
+ # saved call is rebuilt by BuiltinCalls, which sets called_as again.
237
+ part.merge!(ToolRowFields.for(name, call, cwd: cwd))
243
238
  part[:label] = label if label.is_a?(String) && !label.empty?
244
239
  unless output.nil?
245
240
  part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
@@ -30,6 +30,32 @@ export function newActivity() {
30
30
  return { rows: [] };
31
31
  }
32
32
 
33
+ // A row's fields beyond its params, as the server's ToolRowFields lists
34
+ // them; each list is copied in one place per path (addStarted, addCompleted's
35
+ // synthesized row, turn_events.js snapshotEvents), so a new field is added
36
+ // here. ACTIVITY_FIELDS: top-level on tool_call_started, inside `activity`
37
+ // on tool_call_completed. EVENT_FIELDS: top-level on both.
38
+ // title: what the call did in a few words (the server's tool title: a
39
+ // project-relative path, a command without its "cd … &&"); rows show
40
+ // it in place of the params when there is one.
41
+ // called_as: the name the model called the tool by when it was an
42
+ // alias's ("bash" run as execute); the row says so.
43
+ // view: the call's full arguments for the expanded row and the hovers
44
+ // (the server's ToolView: an execute/task_create's command, its cwd).
45
+ // ref: what the row traces to (the server's FileRef: {kind: "file",
46
+ // path, line?, end_line?}, the file a file tool touched), which
47
+ // refs.js makes the row's title and diff lines open.
48
+ export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
49
+ export const EVENT_FIELDS = Object.freeze(["view", "ref"]);
50
+ const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
51
+
52
+ // The +fields+ of +source+ that are set, as an object (for a spread).
53
+ export function pickFields(source, fields) {
54
+ const picked = {};
55
+ for (const field of fields) if (source?.[field]) picked[field] = source[field];
56
+ return picked;
57
+ }
58
+
33
59
  // Stable key for a call within a turn. iteration + call_index are both present
34
60
  // on :tool_call_started and :tool_call_completed, so the same key links a
35
61
  // "running" row to its completion.
@@ -52,9 +78,7 @@ export function addStarted(model, event = {}, now = null) {
52
78
  row.tool = event.tool || row.tool;
53
79
  row.label = event.label || row.label;
54
80
  row.params = event.params || row.params;
55
- row.title = event.title || row.title;
56
- row.called_as = event.called_as || row.called_as;
57
- row.view = event.view || row.view;
81
+ for (const field of ROW_FIELDS) row[field] = event[field] || row[field];
58
82
  markStart(row, event, now);
59
83
  return { row, created: false };
60
84
  }
@@ -65,16 +89,8 @@ export function addStarted(model, event = {}, now = null) {
65
89
  tool: event.tool || "tool",
66
90
  label: event.label || null,
67
91
  params: event.params || "",
68
- // What the call did in a few words (the server's tool title: a
69
- // project-relative path, a command without its "cd … &&"); rows show it
70
- // in place of the params when there is one.
71
- title: event.title || null,
72
- // The name the model called the tool by when it was an alias's ("bash"
73
- // run as execute); the row says so.
74
- called_as: event.called_as || null,
75
- // The call's full arguments for the expanded row and the hovers (the
76
- // server's ToolView: an execute/task_create's command, its cwd).
77
- view: event.view || null,
92
+ // ACTIVITY_FIELDS and EVENT_FIELDS (above), null when unset.
93
+ ...Object.fromEntries(ROW_FIELDS.map((field) => [field, event[field] || null])),
78
94
  status: ROW_STATUS.RUNNING,
79
95
  output: "",
80
96
  output_truncated: false,
@@ -107,9 +123,8 @@ export function addCompleted(model, event = {}, now = null) {
107
123
  tool: event.tool,
108
124
  label: event.label,
109
125
  params: event.activity?.params || "",
110
- title: event.activity?.title,
111
- called_as: event.activity?.called_as,
112
- view: event.view,
126
+ ...pickFields(event.activity, ACTIVITY_FIELDS),
127
+ ...pickFields(event, EVENT_FIELDS),
113
128
  });
114
129
  row = synthesized.row;
115
130
  }
@@ -117,8 +132,10 @@ export function addCompleted(model, event = {}, now = null) {
117
132
  row.output = event.output || "";
118
133
  row.output_truncated = !!event.output_truncated;
119
134
  if (event.activity?.params) row.params = event.activity.params;
120
- if (event.activity?.title) row.title = event.activity.title;
121
- if (event.activity?.called_as) row.called_as = event.activity.called_as;
135
+ // The completed event carries the title (ACTIVITY_FIELDS) in its activity.
136
+ Object.assign(row, pickFields(event.activity, ACTIVITY_FIELDS));
137
+ // A grep that found nothing (status ok): the row says "no match".
138
+ if (event.activity?.no_match) row.no_match = true;
122
139
  // The images the tool returned (a reload in the turn view draws them from
123
140
  // the saved parts, turn_html.js toolRowHtml).
124
141
  if (event.images?.length) row.images = event.images;