samagotchi 0.8.1 → 0.10.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +9 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +47 -17
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +29 -29
  12. data/lib/samagotchi/bridge/card_store.rb +4 -4
  13. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  14. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  16. data/lib/samagotchi/bridge.rb +59 -112
  17. data/lib/samagotchi/bridge_client.rb +11 -28
  18. data/lib/samagotchi/bundle_command.rb +158 -212
  19. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  21. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  22. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  25. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  27. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  32. data/lib/samagotchi/bundles/system/identity.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
  35. data/lib/samagotchi/cli/command.rb +51 -0
  36. data/lib/samagotchi/cli/flags.rb +116 -0
  37. data/lib/samagotchi/config.rb +70 -55
  38. data/lib/samagotchi/config_text_edit.rb +107 -0
  39. data/lib/samagotchi/desktop_command.rb +24 -18
  40. data/lib/samagotchi/edit_preview.rb +2 -2
  41. data/lib/samagotchi/engine.rb +20 -75
  42. data/lib/samagotchi/events.rb +23 -0
  43. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  44. data/lib/samagotchi/guardrails/targets.rb +7 -1
  45. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  46. data/lib/samagotchi/hooks/loader.rb +23 -33
  47. data/lib/samagotchi/hooks/registry.rb +41 -0
  48. data/lib/samagotchi/hooks.rb +0 -3
  49. data/lib/samagotchi/host_registry.rb +15 -0
  50. data/lib/samagotchi/idle_reminders.rb +0 -7
  51. data/lib/samagotchi/image_store.rb +22 -0
  52. data/lib/samagotchi/kernel_loop.rb +12 -16
  53. data/lib/samagotchi/live_versions.rb +2 -13
  54. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  55. data/lib/samagotchi/log_subscriber.rb +5 -1
  56. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  57. data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  59. data/lib/samagotchi/memory_bundle/installer.rb +25 -30
  60. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  61. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  63. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  64. data/lib/samagotchi/memory_paths.rb +10 -0
  65. data/lib/samagotchi/note_command.rb +23 -37
  66. data/lib/samagotchi/owner_lock.rb +14 -3
  67. data/lib/samagotchi/plugin/api.rb +11 -17
  68. data/lib/samagotchi/plugin/context.rb +70 -15
  69. data/lib/samagotchi/plugin/loader.rb +2 -3
  70. data/lib/samagotchi/sampling_settings.rb +3 -5
  71. data/lib/samagotchi/self_report.rb +6 -9
  72. data/lib/samagotchi/send_command.rb +33 -52
  73. data/lib/samagotchi/session.rb +88 -92
  74. data/lib/samagotchi/session_archive_command.rb +8 -14
  75. data/lib/samagotchi/session_commands.rb +3 -21
  76. data/lib/samagotchi/session_delete_command.rb +15 -29
  77. data/lib/samagotchi/session_manager.rb +29 -15
  78. data/lib/samagotchi/session_metrics.rb +2 -1
  79. data/lib/samagotchi/sessions_command.rb +3 -2
  80. data/lib/samagotchi/system_prompt.rb +2 -1
  81. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  83. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  85. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  86. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  87. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  88. data/lib/samagotchi/terminal_ui.rb +156 -234
  89. data/lib/samagotchi/thinking.rb +2 -3
  90. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  91. data/lib/samagotchi/tool_call_parser.rb +56 -292
  92. data/lib/samagotchi/tool_declarations.rb +1 -1
  93. data/lib/samagotchi/tool_runner.rb +8 -0
  94. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  95. data/lib/samagotchi/tools/builtins.rb +2 -1
  96. data/lib/samagotchi/tools/edit.rb +21 -29
  97. data/lib/samagotchi/tools/memory.rb +2 -13
  98. data/lib/samagotchi/tools/send_note.rb +1 -1
  99. data/lib/samagotchi/update_command.rb +32 -22
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/vision_support.rb +4 -5
  102. data/lib/samagotchi/web/app.rb +89 -151
  103. data/lib/samagotchi/web/public/activity.js +1 -11
  104. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  105. data/lib/samagotchi/web/public/app.js +158 -785
  106. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  107. data/lib/samagotchi/web/public/command_list.js +99 -0
  108. data/lib/samagotchi/web/public/data.js +22 -66
  109. data/lib/samagotchi/web/public/format.js +0 -23
  110. data/lib/samagotchi/web/public/index.html +0 -2
  111. data/lib/samagotchi/web/public/model_pick.js +0 -7
  112. data/lib/samagotchi/web/public/notifications.js +169 -0
  113. data/lib/samagotchi/web/public/question_cards.js +352 -0
  114. data/lib/samagotchi/web/public/session_state.js +34 -0
  115. data/lib/samagotchi/web/public/timing.js +1 -1
  116. data/lib/samagotchi/web/public/turn_events.js +11 -2
  117. data/lib/samagotchi/web/public/turn_model.js +0 -14
  118. data/lib/samagotchi/web/session_hub.rb +6 -4
  119. data/lib/samagotchi/web/session_summary.rb +5 -3
  120. data/lib/samagotchi/worker.rb +59 -53
  121. data/lib/samagotchi/worker_sidecar.rb +27 -1
  122. metadata +13 -1
@@ -5,24 +5,23 @@ require_relative "tool_path"
5
5
 
6
6
  module Samagotchi
7
7
  module Tools
8
- # Replaces an exact block of text in an existing file.
8
+ # Replaces an exact block of text in an existing file (old_text →
9
+ # new_text), or a range of lines (start_line..end_line → new_text).
9
10
  #
10
- # XML syntax (used by the model):
11
- # <tool name="edit" path="path/to/file">
12
- # <old>exact text to replace</old>
13
- # <new>replacement text</new>
14
- # </tool>
15
- #
16
- # The <old> block must match exactly once in the file. The tool returns an
17
- # error if the text is not found or appears more than once.
11
+ # old_text must match exactly once in the file. The tool returns an error
12
+ # if the text is not found or appears more than once. new_text is
13
+ # required in both modes; "" deletes.
18
14
  class Edit
19
15
  NAME = "edit"
20
16
 
21
17
  def self.name = NAME
22
18
 
23
- def self.call(content, path:, start_line: nil, end_line: nil)
19
+ OLD_TEXT_REQUIRED = "Error: old_text is required (the exact text to replace), or give start_line for a range edit"
20
+ NEW_TEXT_REQUIRED = 'Error: new_text is required (use "" to delete)'
21
+
22
+ def self.call(path:, old_text: nil, new_text: nil, start_line: nil, end_line: nil)
24
23
  path = ToolPath.normalize(path)
25
- result = apply(content, path: path, start_line: start_line, end_line: end_line)
24
+ result = apply(path: path, old_text: old_text, new_text: new_text, start_line: start_line, end_line: end_line)
26
25
  return result if result.is_a?(String)
27
26
 
28
27
  updated, message = result
@@ -35,22 +34,21 @@ module Samagotchi
35
34
  # The edit without the write: [updated, message] or "Error: …", with
36
35
  # exactly the strings call returns. +read+ reads the file, so a dry run
37
36
  # (EditPreview) can reuse a copy it already has; each mode keeps its own
38
- # check order (exact: tags first; range: file not found first).
39
- def self.apply(content, path:, start_line: nil, end_line: nil, read: ->(p) { File.read(p) })
37
+ # check order (exact: the texts first; range: file not found first).
38
+ def self.apply(path:, old_text: nil, new_text: nil, start_line: nil, end_line: nil, read: ->(p) { File.read(p) })
40
39
  path = ToolPath.normalize(path)
41
40
 
42
41
  if range_requested?(start_line, end_line)
43
- return apply_range_mode(content, path: path, start_line: start_line, end_line: end_line, read: read)
42
+ return apply_range_mode(new_text, path: path, start_line: start_line, end_line: end_line, read: read)
44
43
  end
45
44
 
46
- old_text = extract_tag(content, "old")
47
- new_text = extract_tag(content, "new")
48
-
49
- return "Error: missing <old>...</old> block" if old_text.nil?
50
- return "Error: missing <new>...</new> block" if new_text.nil?
51
- return "Error: <old> block is empty" if old_text.empty?
45
+ return OLD_TEXT_REQUIRED if old_text.nil? || old_text.to_s.empty?
46
+ return NEW_TEXT_REQUIRED if new_text.nil?
52
47
  return "Error: file not found: #{path}" unless File.exist?(path)
53
48
 
49
+ old_text = old_text.to_s
50
+ new_text = new_text.to_s
51
+
54
52
  source = read.(path)
55
53
  count = count_occurrences(source, old_text)
56
54
 
@@ -64,11 +62,11 @@ module Samagotchi
64
62
  "Error: #{e.message}"
65
63
  end
66
64
 
67
- def self.apply_range_mode(content, path:, start_line:, end_line:, read:)
65
+ def self.apply_range_mode(new_text, path:, start_line:, end_line:, read:)
68
66
  return "Error: file not found: #{path}" unless File.exist?(path)
67
+ return NEW_TEXT_REQUIRED if new_text.nil?
69
68
 
70
- new_text = extract_tag(content, "new")
71
- return "Error: missing <new>...</new> block" if new_text.nil?
69
+ new_text = new_text.to_s
72
70
 
73
71
  start_num = parse_positive_line_number(start_line, "start_line")
74
72
  return start_num if start_num.is_a?(String)
@@ -139,12 +137,6 @@ module Samagotchi
139
137
  end
140
138
  private_class_method :count_occurrences
141
139
 
142
- def self.extract_tag(content, tag)
143
- m = content.match(/<#{tag}>(.*?)<\/#{tag}>/m)
144
- m ? m[1] : nil
145
- end
146
- private_class_method :extract_tag
147
-
148
140
  def self.range_requested?(start_line, end_line)
149
141
  !blank?(start_line) || !blank?(end_line)
150
142
  end
@@ -125,17 +125,7 @@ module Samagotchi
125
125
  end
126
126
 
127
127
  def self.memories_dir(scope, env: ENV)
128
- # Respect MemoryBundle overrides for test isolation (e.g. SystemBundle ensure in specs).
129
- if defined?(Samagotchi::MemoryBundle::Installer) &&
130
- Samagotchi::MemoryBundle::Installer.system_dir_override
131
- if scope == "project"
132
- base = Samagotchi::MemoryBundle::Installer.project_dir_base_override
133
- return base if base
134
- else
135
- return Samagotchi::MemoryBundle::Installer.system_dir
136
- end
137
- end
138
- scope == "project" ? MemoryPaths.project_dir(env: env) : MemoryPaths.system_dir(env: env)
128
+ MemoryPaths.scope_dir(scope == "project" ? "project" : "system", env: env)
139
129
  end
140
130
  end
141
131
 
@@ -204,9 +194,8 @@ module Samagotchi
204
194
  # The verbatim "write to index.md" behavior (path: "index") must not
205
195
  # trigger upsert logic.
206
196
  unless entry_name == MEMORY_INDEX
207
- index_path = self.index_path_for(resolved_scope)
208
197
  if manage_index(resolved_scope, entry_name, bytes, description)
209
- message += " Index updated: #{index_path}"
198
+ message += " Index line refreshed automatically."
210
199
  end
211
200
  end
212
201
 
@@ -30,7 +30,7 @@ module Samagotchi
30
30
  return "Error: #{id[0, 8]} is this session; send_note is for other sessions" if id == peers.session_id
31
31
 
32
32
  owner = SessionManager.session_owner(id, state_dir: peers.state_dir)
33
- if owner && owner["kind"] == "tui"
33
+ if owner&.tui?
34
34
  return "Error: session #{id[0, 8]} is open in a chi REPL, which can't take notes"
35
35
  end
36
36
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cli/command"
4
+ require_relative "cli/flags"
3
5
  require_relative "config"
4
6
  require_relative "desktop"
5
7
  require_relative "gem_update"
@@ -19,6 +21,8 @@ module Samagotchi
19
21
  # workers and a running chi web are reported, never restarted. Prints one
20
22
  # table; a second run changes nothing and says so.
21
23
  class UpdateCommand
24
+ include CLI::Command
25
+
22
26
  USAGE = <<~TEXT
23
27
  Usage: chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]
24
28
  Updates this chi's install: the gem (from rubygems.org), then the
@@ -34,9 +38,22 @@ module Samagotchi
34
38
  chi sessions stop ID; a running chi web needs a restart.
35
39
  TEXT
36
40
 
37
- FLAGS = { "--dry-run" => :dry_run, "--no-gem" => :no_gem, "--no-bundles" => :no_bundles, "--no-desktop" => :no_desktop,
38
- # Hidden: no pbs/lsregister/open/pkill for a helper under HOME=<tmp>.
39
- "--no-register" => :no_register }.freeze
41
+ # Help is a flag here: it doesn't stop the parse (an unknown option
42
+ # after it is still an error).
43
+ FLAGS = CLI::Flags.new(args: false) do |f|
44
+ f.switch "-h", "--help", "help"
45
+ f.switch "--dry-run"
46
+ f.switch "--no-gem"
47
+ f.switch "--no-bundles"
48
+ f.switch "--no-desktop"
49
+ # Hidden: no pbs/lsregister/open/pkill for a helper under HOME=<tmp>.
50
+ f.switch "--no-register"
51
+ # Hidden: the old version, passed by the chi that installed this one.
52
+ f.value "--gem-from"
53
+ end
54
+ # The switches the newer gem's update gets again (hand_over).
55
+ HANDED_OVER = { "--no-gem" => :no_gem, "--no-bundles" => :no_bundles, "--no-desktop" => :no_desktop,
56
+ "--no-register" => :no_register }.freeze
40
57
 
41
58
  # A table row. info rows (live workers, chi web) say what runs; they
42
59
  # never fail.
@@ -70,7 +87,9 @@ module Samagotchi
70
87
 
71
88
  # @return [Integer] 0 nothing failed, 1 a part failed (or a checkout), 2 usage
72
89
  def run
73
- options = parse or return 2
90
+ options = parse
91
+ return options if options.is_a?(Integer)
92
+
74
93
  if options[:help]
75
94
  @stdout.puts(USAGE)
76
95
  return 0
@@ -92,24 +111,15 @@ module Samagotchi
92
111
 
93
112
  private
94
113
 
114
+ def command_name = "chi update"
115
+
116
+ # @return [Hash, Integer] the options, or the usage error's exit status
117
+ # (every error reads "unknown option", a --gem-from without its value too)
95
118
  def parse
96
- argv = @argv.dup
97
- options = {}
98
- while (arg = argv.shift)
99
- if %w[-h --help help].include?(arg)
100
- options[:help] = true
101
- elsif arg == "--gem-from" && argv.first
102
- # Hidden: the old version, passed by the chi that installed this one.
103
- options[:gem_from] = argv.shift
104
- elsif FLAGS.key?(arg)
105
- options[FLAGS[arg]] = true
106
- else
107
- @stderr.puts("chi update: unknown option #{arg}")
108
- @stderr.puts(USAGE)
109
- return nil
110
- end
111
- end
112
- options
119
+ parsed = FLAGS.parse(@argv)
120
+ return usage_error("unknown option #{parsed.error.arg}") if parsed.error
121
+
122
+ parsed.options
113
123
  end
114
124
 
115
125
  # Installs a newer gem and hands over to it (exec: the new code syncs
@@ -150,7 +160,7 @@ module Samagotchi
150
160
  wrapper = InstalledGem.wrapper(@gem_spec)
151
161
  return done(row, :update, "run chi update again to sync with it") unless wrapper
152
162
 
153
- passed = FLAGS.select { |_, key| options[key] && key != :dry_run }.keys
163
+ passed = HANDED_OVER.select { |_, key| options[key] }.keys
154
164
  @stdout.flush
155
165
  @exec.call([RbConfig.ruby, wrapper, "update", "--no-gem", "--gem-from", VERSION, *passed])
156
166
  done(row, :update) # only when exec is a spec's stand-in
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.8.1"
4
+ VERSION = "0.10.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -25,10 +25,9 @@ module Samagotchi
25
25
  # @param profile [ModelProfile, nil] the model's prompt profile (native)
26
26
  # @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
27
27
  # @param models [Hash, nil] ConfigFile.model_settings (specs)
28
- # @param names [Array<String>, nil] the model's lookup names, the typed
29
- # alias first (Engine#model_lookup_names); default: the target's names
28
+ # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
30
29
  # @return [Answer]
31
- def self.for(target, profile: nil, adapter: nil, models: nil, names: nil)
30
+ def self.for(target, names:, profile: nil, adapter: nil, models: nil)
32
31
  entry = target.entry
33
32
  configured = configured(target, entry, models, names)
34
33
  return configured if configured&.no?
@@ -46,13 +45,13 @@ module Samagotchi
46
45
  marker.is_a?(String) && !marker.empty? ? marker : nil
47
46
  end
48
47
 
49
- def self.configured(target, entry, models, names = nil)
48
+ def self.configured(target, entry, models, names)
50
49
  models ||= begin
51
50
  ConfigFile.model_settings
52
51
  rescue StandardError
53
52
  {}
54
53
  end
55
- key, value = ConfigFile.model_setting(names || [target.model, target.bare_model], :vision, models: models)
54
+ key, value = ConfigFile.model_setting(names, :vision, models: models)
56
55
  return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false") if key
57
56
  return nil if entry.vision.nil?
58
57
 
@@ -47,9 +47,6 @@ module Samagotchi
47
47
  IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
48
48
  IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
49
49
  "webp" => "image/webp" }.freeze
50
- # The cancel reasons a client may give, as the Bridge takes them
51
- # (Bridge::CANCEL_REASONS); anything else is sent and echoed as manual.
52
- CANCEL_REASONS = %w[manual user ctrl_c].freeze
53
50
 
54
51
  # POST /stop waits this long for the worker to let go of the session.
55
52
  STOP_WAIT_SECONDS = 2.0
@@ -76,8 +73,9 @@ module Samagotchi
76
73
  # @param annotate_presets [String, Array] the quick replies next to
77
74
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
78
75
  # page parses them (annotate_presets.js).
79
- # @param hub [SessionHub, nil] the session projection GET /api/events
80
- # streams from; without one the route answers 503
76
+ # @param hub [SessionHub, nil] the session projection GET /api/sessions
77
+ # lists and GET /api/events streams from (Server always builds one);
78
+ # without one both routes answer 503
81
79
  # @param registry [HostRegistry, nil] the hosts GET /api/models lists
82
80
  # (built from the config on first use)
83
81
  # @param models_wait_timeout [Float] bounded seconds GET /api/models
@@ -324,14 +322,16 @@ module Samagotchi
324
322
 
325
323
  # The page's scope: ?dir=<folder> lists that folder's project (every
326
324
  # session when the folder is in no repo). A dir that isn't an existing
327
- # absolute folder answers 400 before anything else runs.
325
+ # absolute folder answers 400 before anything else runs. The list is
326
+ # the hub's projection (archived ones too: the page filters them at
327
+ # render), with Session.list's sort, paging and total.
328
328
  def handle_list(req)
329
+ return error_response(503, "no_hub", "this chi web has no session hub") unless @hub
330
+
329
331
  dir, error = scope_dir(req.params["dir"])
330
332
  return error if error
331
333
 
332
- scope = {}
333
334
  root = dir && ProjectScope.root_for(dir)
334
- scope[:project_root] = root if root
335
335
  # Lazy retention sweep (once per 24h)
336
336
  if @manager.respond_to?(:retention_sweep_if_due)
337
337
  begin
@@ -340,45 +340,9 @@ module Samagotchi
340
340
  nil
341
341
  end
342
342
  end
343
- sort = sanitize_sort(req.params["sort"])
344
- order = sanitize_order(req.params["order"])
345
343
  limit = sanitize_limit(req.params["limit"])
346
344
  offset = sanitize_offset(req.params["offset"])
347
- return list_from_hub(root, sort: sort, order: order, limit: limit, offset: offset) if @hub
348
-
349
- # Archived ones too, as the hub's: the page filters them at render.
350
- scope[:include_archived] = true
351
- sessions = if @state_dir
352
- @manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, limit: limit, offset: offset,
353
- **scope)
354
- else
355
- @manager.list_sessions(sort: sort, order: order, limit: limit, offset: offset, **scope)
356
- end
357
- roots = {}
358
- payload = sessions.map do |s|
359
- owner = session_owner(s.id)
360
- session_to_json(s, status: displayed_status(s, owner: owner), owner: owner, root_cache: roots)
361
- end
362
- # Expose total via header for pagination (total unordered count)
363
- headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store" }
364
- # Compute total without limit/offset for header
365
- if limit || offset.positive?
366
- total = if @state_dir
367
- @manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, **scope).size
368
- else
369
- @manager.list_sessions(sort: sort, order: order, **scope).size
370
- end
371
- headers["X-Total-Count"] = total.to_s
372
- end
373
- body = JSON.generate(payload)
374
- headers["Content-Length"] = body.bytesize.to_s
375
- [200, headers, [body]]
376
- end
377
-
378
- # The list from the hub's projection (the page's fallback and the
379
- # first paint): the same sort, paging and total as from the files.
380
- def list_from_hub(root, sort:, order:, limit:, offset:)
381
- all = @hub.snapshot(project_root: root, sort: sort, order: order)
345
+ all = @hub.snapshot(project_root: root, sort: sanitize_sort(req.params["sort"]), order: sanitize_order(req.params["order"]))
382
346
  page = all.drop(offset)
383
347
  page = page.first(limit) if limit
384
348
  headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store" }
@@ -647,39 +611,20 @@ module Samagotchi
647
611
  return error_response(404, "not_found", "Session not found: #{id}") unless session
648
612
 
649
613
  body = parse_json(request_body(req))
650
- unless body.is_a?(Hash)
651
- return error_response(400, "invalid_json", "invalid JSON body")
652
- end
614
+ return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
615
+
653
616
  qid = body["id"]
654
- selected = body["selected"]
655
- freeform = body["freeform"]
656
- if qid.to_s.strip.empty?
657
- return error_response(400, "missing_fields", "id is required")
658
- end
659
- # Try live Engine via Bridge first (in-process answer without file IPC)
660
- client = bridge_client(id)
661
- if client
662
- begin
663
- reply = client.answer(id: qid, selected: selected, freeform: freeform)
664
- return json_response(200, { status: "answered", session_id: id, id: qid }) if reply.ok?
665
- # Read after its deadline and dropped (see #worker_timeout).
666
- return worker_timeout("the answer was not sent") if reply.status == 408
667
-
668
- # Pass the bridge's verdict through: 409 = another client answered
669
- # first (or the question was cancelled), 400 = invalid selection.
670
- if [400, 409].include?(reply.status)
671
- detail = reply.json&.dig("detail") || "answer rejected"
672
- return error_response(reply.status, reply.status == 409 ? "question_not_pending" : "invalid_answer", detail)
673
- end
674
- rescue Errno::ETIMEDOUT
675
- return worker_timeout("the answer was not sent")
676
- rescue StandardError
677
- nil
617
+ return error_response(400, "missing_fields", "id is required") if qid.to_s.strip.empty?
618
+
619
+ request = ->(client) { client.answer(id: qid, selected: body["selected"], freeform: body["freeform"]) }
620
+ relay(id, bridge_client(id), request, what: "the answer was not sent", cant: "answer questions") do |reply|
621
+ case reply.status
622
+ when 200 then json_response(200, { status: "answered", session_id: id, id: qid })
623
+ # Another client answered first (or the question was cancelled).
624
+ when 409 then error_response(409, "question_not_pending", reply_detail(reply, "question not pending"))
625
+ when 400 then error_response(400, "invalid_answer", reply_detail(reply, "answer rejected"))
678
626
  end
679
627
  end
680
- error_response(503, "not_live", "no live bridge for session #{id}")
681
- rescue ArgumentError => e
682
- error_response(404, "not_found", e.message)
683
628
  end
684
629
 
685
630
  # Leave the pending question unanswered (the card's Dismiss). A question
@@ -691,22 +636,13 @@ module Samagotchi
691
636
  qid = body["id"].to_s
692
637
  return error_response(400, "missing_fields", "id is required") if qid.strip.empty?
693
638
 
694
- client = bridge_client(id)
695
- return error_response(503, "not_live", "no live bridge for session #{id}") unless client
696
-
697
- reply = client.dismiss_question(id: qid)
698
- case reply.status
699
- when 200 then json_response(200, { status: "dismissed", session_id: id, id: qid })
700
- when 409 then error_response(409, "question_not_pending", reply.json&.dig("detail") || "question not pending")
701
- when 408 then worker_timeout("the question was not dismissed")
702
- when 404
703
- error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "dismiss questions"))
704
- else error_response(503, "not_live", "no live bridge for session #{id}")
639
+ request = ->(client) { client.dismiss_question(id: qid) }
640
+ relay(id, bridge_client(id), request, what: "the question was not dismissed", cant: "dismiss questions") do |reply|
641
+ case reply.status
642
+ when 200 then json_response(200, { status: "dismissed", session_id: id, id: qid })
643
+ when 409 then error_response(409, "question_not_pending", reply_detail(reply, "question not pending"))
644
+ end
705
645
  end
706
- rescue Errno::ETIMEDOUT
707
- worker_timeout("the question was not dismissed")
708
- rescue StandardError
709
- error_response(503, "not_live", "no live bridge for session #{id}")
710
646
  end
711
647
 
712
648
  # A session command typed in the composer (/model, /models, !rollback,
@@ -720,29 +656,60 @@ module Samagotchi
720
656
  return error_response(400, "missing_fields", "line is required") if line.empty?
721
657
 
722
658
  @manager.resume_session(id, state_dir: @state_dir) if @manager.respond_to?(:resume_session)
723
- client = live_bridge_client(id)
724
- return error_response(503, "not_live", "no live bridge for session #{id}") unless client
725
-
726
659
  # card: a card's action; its command events say so (no echo).
727
- reply = client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true)
728
- case reply.status
729
- when 202 then json_response(202, reply.json || { status: "accepted" })
730
- when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply.json&.dig("detail") || "not a session command")
731
- when 408 then worker_timeout("the command was not run")
732
- when 404
733
- error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "run commands"))
734
- else error_response(503, "not_live", "no live bridge for session #{id}")
660
+ request = ->(client) { client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true) }
661
+ relay(id, live_bridge_client(id), request, what: "the command was not run", cant: "run commands") do |reply|
662
+ case reply.status
663
+ when 202 then json_response(202, reply.json || { status: "accepted" })
664
+ when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
665
+ end
735
666
  end
736
667
  rescue SessionManager::OwnedByTUI => e
737
668
  error_response(409, "owned_by_tui", e.message)
738
669
  rescue ArgumentError => e
739
670
  error_response(404, "not_found", e.message)
671
+ end
672
+
673
+ # Hand a request to the session's live Bridge (+client+, nil without
674
+ # one) and map its reply. The block gets the reply and answers the
675
+ # statuses its route knows (nil for the rest); every relay maps the
676
+ # rest the same way: 408 deadline_passed or a read timeout → 504
677
+ # worker_timeout (+what+ didn't happen), a 404 for a route the worker
678
+ # doesn't have → 501 not_supported (it runs an older chi that +cant+;
679
+ # 501, not 503: it is live, and the page reads 503 as "not running"),
680
+ # and no live bridge, a refused connection or anything else → 503
681
+ # not_live.
682
+ # @param request [#call] sends with the client, answers its Response
683
+ def relay(id, client, request, what:, cant:)
684
+ return not_live(id) unless client
685
+
686
+ reply = request.call(client)
687
+ mapped = yield(reply)
688
+ return mapped if mapped
689
+
690
+ case reply.status
691
+ when 408 then worker_timeout(what)
692
+ when 404
693
+ return not_live(id) if reply.json&.dig("error") == "unknown_session"
694
+
695
+ error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: cant))
696
+ else not_live(id)
697
+ end
740
698
  rescue Errno::ETIMEDOUT
741
- worker_timeout("the command was not run")
742
- rescue SystemCallError, IOError
699
+ worker_timeout(what)
700
+ rescue SystemCallError, IOError, SocketError
701
+ not_live(id)
702
+ end
703
+
704
+ def not_live(id)
743
705
  error_response(503, "not_live", "no live bridge for session #{id}")
744
706
  end
745
707
 
708
+ # The Bridge's detail for a refusal, or +fallback+.
709
+ def reply_detail(reply, fallback)
710
+ reply.json&.dig("detail") || fallback
711
+ end
712
+
746
713
  # The worker didn't take a request in time: its read timed out, or the
747
714
  # Bridge read it after its deadline (408 deadline_passed) and dropped
748
715
  # it. Either way +what+ didn't happen, and a frozen worker that wakes
@@ -795,18 +762,11 @@ module Samagotchi
795
762
  # session's images/), or a String saying what's wrong. Never a path.
796
763
  def turn_images(id, raw)
797
764
  return [] if raw.nil?
798
- return "images must be a list" unless raw.is_a?(Array)
799
765
 
800
766
  session_dir = image_session_dir(id)
801
767
  return "unknown session" unless session_dir
802
768
 
803
- raw.map do |image|
804
- return "each image must be {file:, name:}" unless image.is_a?(Hash)
805
- return "images are refs to uploaded files, not paths" if image.key?("path") || image.key?(:path)
806
- return "unknown image" unless ImageStore.valid_ref?(session_dir, image)
807
-
808
- { file: (image["file"] || image[:file]).to_s, name: File.basename((image["name"] || image[:name]).to_s)[0, 120] }
809
- end
769
+ ImageStore.check_refs(session_dir, raw)
810
770
  end
811
771
 
812
772
  # The session's folder, or nil for an id that isn't one.
@@ -864,8 +824,7 @@ module Samagotchi
864
824
  port && BridgeClient.new(session_id: id, port: port, host: DEFAULT_HOST)
865
825
  end
866
826
 
867
- # The process holding the session: {"pid", "kind" => "worker"|"tui"}, or
868
- # nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
827
+ # The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
869
828
  def session_owner(id)
870
829
  return nil unless @manager.respond_to?(:session_owner)
871
830
 
@@ -874,46 +833,25 @@ module Samagotchi
874
833
  nil
875
834
  end
876
835
 
836
+ # The Bridge decides the reason (Bridge::CANCEL_REASONS; anything
837
+ # else is manual) and the reply echoes its choice. No reason: user.
877
838
  def handle_cancel(req, id)
878
- # Validate session exists
879
- begin
880
- @session_class.load(id, state_dir: default_state_dir)
881
- rescue ArgumentError => e
882
- return error_response(404, "not_found", e.message)
883
- end
839
+ @session_class.load(id, state_dir: default_state_dir)
884
840
 
885
841
  body = request_body(req)
886
- reason = "user"
887
- unless body.nil? || body.strip.empty?
888
- parsed = parse_json(body)
889
- if parsed.is_a?(Hash)
890
- r = parsed["reason"] || parsed[:reason] || parsed["cancellation_reason"]
891
- r = r.to_s.strip
892
- reason = if r.empty? then "user"
893
- elsif CANCEL_REASONS.include?(r) then r
894
- else "manual"
895
- end
896
- end
897
- end
898
-
899
- # Try direct bridge cancel first (in-process, low latency)
900
- client = bridge_client(id)
901
- if client
902
- begin
903
- reply = client.cancel(reason: reason)
904
- if reply.status == 202
905
- return json_response(202, { status: "cancel_requested", session_id: id, reason: reason, via: "bridge" })
906
- end
907
- # The bridge answers 409 when there is no active turn to cancel.
908
- if reply.status == 409
909
- return json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id })
910
- end
911
- rescue StandardError
912
- nil
842
+ parsed = body.nil? || body.strip.empty? ? nil : parse_json(body)
843
+ reason = (parsed["reason"] if parsed.is_a?(Hash)).to_s.strip
844
+ reason = "user" if reason.empty?
845
+
846
+ request = ->(client) { client.cancel(reason: reason) }
847
+ relay(id, bridge_client(id), request, what: "the turn was not cancelled", cant: "cancel turns") do |reply|
848
+ case reply.status
849
+ when 202
850
+ json_response(202, { status: "cancel_requested", session_id: id, reason: reply.json&.dig("reason"), via: "bridge" })
851
+ # No active turn to cancel.
852
+ when 409 then json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id })
913
853
  end
914
854
  end
915
-
916
- error_response(503, "not_live", "no live bridge for session #{id}")
917
855
  rescue ArgumentError => e
918
856
  error_response(404, "not_found", e.message)
919
857
  end
@@ -1174,9 +1112,9 @@ module Samagotchi
1174
1112
  SessionSummary.displayed_status(session, snapshot, owner: owner)
1175
1113
  end
1176
1114
 
1177
- # @param owner [Hash, nil] #session_owner; its kind is shown as `owner`
1178
- def session_to_json(s, status: s.status, owner: nil, root_cache: nil)
1179
- SessionSummary.build(s, status: status, owner: owner, root_cache: root_cache,
1115
+ # @param owner [OwnerLock::Owner, nil] #session_owner; its kind is shown as `owner`
1116
+ def session_to_json(s, status: s.status, owner: nil)
1117
+ SessionSummary.build(s, status: status, owner: owner,
1180
1118
  session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir))
1181
1119
  end
1182
1120
 
@@ -23,7 +23,7 @@ export function toolName(row) {
23
23
 
24
24
  // Create a fresh activity model for one turn.
25
25
  export function newActivity() {
26
- return { finalized: false, rows: [] };
26
+ return { rows: [] };
27
27
  }
28
28
 
29
29
  // Stable key for a call within a turn. iteration + call_index are both present
@@ -107,13 +107,3 @@ export function completedStatus(status) {
107
107
  if (status === "stopped") return ROW_STATUS.STOPPED;
108
108
  return ROW_STATUS.OK;
109
109
  }
110
-
111
- // Mark a turn's activity model as finalized (rows stay; rendering collapses).
112
- export function finalizeActivity(model) {
113
- if (model) model.finalized = true;
114
- return model;
115
- }
116
-
117
- export function activityCount(model) {
118
- return model ? model.rows.length : 0;
119
- }