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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -1
- data/README.md +1 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +30 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +27 -6
- data/docs/internals/engine.md +55 -24
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +2 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +26 -3
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +35 -18
- data/lib/samagotchi/web/public/app.js +240 -1136
- data/lib/samagotchi/web/public/commands.js +236 -0
- data/lib/samagotchi/web/public/composer.js +101 -0
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +9 -2
- data/lib/samagotchi/web/public/info_bar.js +78 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +7 -4
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +52 -0
- data/lib/samagotchi/web/public/stream_handlers.js +324 -0
- data/lib/samagotchi/web/public/thinking_chip.js +6 -7
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +15 -7
- data/lib/samagotchi/web/server.rb +4 -4
- metadata +17 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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.
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:)
|
|
7
|
-
# it as +capabilities+; the page leaves out
|
|
8
|
-
# names a capability that is true here.
|
|
9
|
-
# is rendered as markdown, so its links
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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 "../
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
#
|
|
237
|
-
|
|
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
|
|
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
|
-
//
|
|
69
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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;
|