samagotchi 0.54.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/bin/chi +3 -2
  4. data/docs/configuration.md +29 -0
  5. data/docs/internals/context-telemetry.md +23 -5
  6. data/docs/internals/engine.md +40 -18
  7. data/docs/sessions.md +2 -2
  8. data/docs/testing.md +23 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/config.rb +2 -0
  12. data/lib/samagotchi/file_ref.rb +59 -0
  13. data/lib/samagotchi/tool_row_fields.rb +46 -0
  14. data/lib/samagotchi/tool_runner.rb +7 -11
  15. data/lib/samagotchi/version.rb +1 -1
  16. data/lib/samagotchi/web/app.rb +25 -2
  17. data/lib/samagotchi/web/capabilities.rb +13 -9
  18. data/lib/samagotchi/web/editor.rb +38 -0
  19. data/lib/samagotchi/web/message_parts.rb +5 -10
  20. data/lib/samagotchi/web/public/activity.js +33 -18
  21. data/lib/samagotchi/web/public/app.js +237 -1133
  22. data/lib/samagotchi/web/public/commands.js +236 -0
  23. data/lib/samagotchi/web/public/composer.js +101 -0
  24. data/lib/samagotchi/web/public/diff_view.js +30 -4
  25. data/lib/samagotchi/web/public/index.html +5 -0
  26. data/lib/samagotchi/web/public/info_bar.js +78 -0
  27. data/lib/samagotchi/web/public/lists_view.js +725 -0
  28. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  29. data/lib/samagotchi/web/public/question_cards.js +2 -1
  30. data/lib/samagotchi/web/public/refs.js +134 -0
  31. data/lib/samagotchi/web/public/restore.js +81 -0
  32. data/lib/samagotchi/web/public/session_state.js +52 -0
  33. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  34. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  35. data/lib/samagotchi/web/public/turn_events.js +17 -258
  36. data/lib/samagotchi/web/public/turn_html.js +5 -1
  37. data/lib/samagotchi/web/server.rb +4 -4
  38. metadata +11 -2
  39. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -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
 
@@ -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,8 @@ 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));
122
137
  // A grep that found nothing (status ok): the row says "no match".
123
138
  if (event.activity?.no_match) row.no_match = true;
124
139
  // The images the tool returned (a reload in the turn view draws them from