samagotchi 0.9.0 → 0.10.1
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 +68 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +5 -3
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +36 -13
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +12 -1
|
@@ -125,17 +125,7 @@ module Samagotchi
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
def self.memories_dir(scope, env: ENV)
|
|
128
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 =
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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/
|
|
80
|
-
# streams from
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
end
|
|
614
|
+
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
615
|
+
|
|
653
616
|
qid = body["id"]
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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 [
|
|
1178
|
-
def session_to_json(s, status: s.status, owner: nil
|
|
1179
|
-
SessionSummary.build(s, status: status, owner: owner,
|
|
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 {
|
|
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
|
-
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// ── Annotate ───────────────────────────────────────────────────────────────
|
|
2
|
+
// Selecting text in an answer, thinking, a tool row or an own message shows
|
|
3
|
+
// "Annotate" and the presets (web.annotate_presets); Annotate appends the
|
|
4
|
+
// quote (with where it came from) to the composer, a preset the quote with
|
|
5
|
+
// its text as the note. Nothing is sent. The bar lives outside #history,
|
|
6
|
+
// which is re-rendered whole. The selection is read when it changes: a live
|
|
7
|
+
// bubble rewrites its text every frame, which would collapse it by the time
|
|
8
|
+
// a button is clicked.
|
|
9
|
+
//
|
|
10
|
+
// The quoting rules are annotations.js'; this is the bar.
|
|
11
|
+
|
|
12
|
+
import { annotateBarLeft, annotationSource, quoteBlock } from "./annotations.js";
|
|
13
|
+
import { presetNote } from "./annotate_presets.js";
|
|
14
|
+
|
|
15
|
+
// @param presets the preset texts (annotate_presets.js parsePresets)
|
|
16
|
+
// @param canAnnotate () => whether the open session takes annotations (one
|
|
17
|
+
// is open and the composer can send)
|
|
18
|
+
// @param roots () => the elements a selection may start in (the history,
|
|
19
|
+
// the stage)
|
|
20
|
+
// @param isLive (root) => whether a source root is still streaming (refused
|
|
21
|
+
// until its step closes)
|
|
22
|
+
// @param onQuote (text) runs with the quote (and a preset's note) to append
|
|
23
|
+
// to the composer; the selection is cleared and the bar hidden first
|
|
24
|
+
// @param doc, win the page's document and window
|
|
25
|
+
// @return {schedule(), hide(), el}; schedule() re-reads the selection on the
|
|
26
|
+
// next frame (call it on scroll)
|
|
27
|
+
export function createAnnotateBar({
|
|
28
|
+
presets, canAnnotate, roots, isLive, onQuote, doc = globalThis.document, win = globalThis.window,
|
|
29
|
+
}) {
|
|
30
|
+
const bar = doc.createElement("div");
|
|
31
|
+
bar.id = "annotateBar";
|
|
32
|
+
bar.hidden = true;
|
|
33
|
+
bar.setAttribute("role", "toolbar");
|
|
34
|
+
bar.setAttribute("aria-label", "Annotate");
|
|
35
|
+
function button(text, cls) {
|
|
36
|
+
const btn = doc.createElement("button");
|
|
37
|
+
btn.type = "button";
|
|
38
|
+
btn.className = cls;
|
|
39
|
+
btn.textContent = text;
|
|
40
|
+
return btn;
|
|
41
|
+
}
|
|
42
|
+
bar.appendChild(button("Annotate", "annotate-main"));
|
|
43
|
+
presets.forEach((text, i) => {
|
|
44
|
+
const btn = button(text, "annotate-preset");
|
|
45
|
+
btn.dataset.index = String(i);
|
|
46
|
+
btn.title = text;
|
|
47
|
+
bar.appendChild(btn);
|
|
48
|
+
});
|
|
49
|
+
doc.body.appendChild(bar);
|
|
50
|
+
let pendingQuote = null;
|
|
51
|
+
|
|
52
|
+
function hide() {
|
|
53
|
+
pendingQuote = null;
|
|
54
|
+
bar.hidden = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function update() {
|
|
58
|
+
const sel = win.getSelection();
|
|
59
|
+
if (!canAnnotate() || !sel || sel.isCollapsed || !sel.rangeCount) return hide();
|
|
60
|
+
const range = sel.getRangeAt(0);
|
|
61
|
+
if (!roots().some((root) => root.contains(range.startContainer))) return hide();
|
|
62
|
+
const source = annotationSource(range.startContainer);
|
|
63
|
+
// A running turn's narration box shows complete sentences and its thinking
|
|
64
|
+
// is appended by delta; a selection in either is refused while the step is
|
|
65
|
+
// live — the quote lands once it closes (then on the full text).
|
|
66
|
+
if (!source || isLive(source.root)) return hide();
|
|
67
|
+
// Clip to where the selection starts: one tool row, one bubble.
|
|
68
|
+
const clipped = range.cloneRange();
|
|
69
|
+
if (!source.root.contains(range.endContainer)) clipped.setEnd(source.root, source.root.childNodes.length);
|
|
70
|
+
const block = quoteBlock(clipped.toString(), source);
|
|
71
|
+
if (!block) return hide();
|
|
72
|
+
pendingQuote = block;
|
|
73
|
+
const rects = clipped.getClientRects();
|
|
74
|
+
const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
|
|
75
|
+
bar.hidden = false;
|
|
76
|
+
// Measured at the left edge: a fixed element's shrink-to-fit width is
|
|
77
|
+
// capped by what's right of it, so at the last (right) spot it would
|
|
78
|
+
// measure wrapped.
|
|
79
|
+
bar.style.left = "8px";
|
|
80
|
+
const w = bar.offsetWidth;
|
|
81
|
+
const h = bar.offsetHeight;
|
|
82
|
+
const maxTop = win.innerHeight - h - 8;
|
|
83
|
+
const top = Math.min(rect.bottom + 6, maxTop);
|
|
84
|
+
bar.style.top = `${Math.max(top, 8)}px`;
|
|
85
|
+
bar.style.left = `${annotateBarLeft(rect, w, win.innerWidth)}px`;
|
|
86
|
+
// Not over a copy button (this bubble's, one inside it, the next
|
|
87
|
+
// bubble's).
|
|
88
|
+
const box = bar.getBoundingClientRect();
|
|
89
|
+
const bubble = source.root.closest(".bubble");
|
|
90
|
+
const near = [bubble, bubble?.nextElementSibling].filter(Boolean);
|
|
91
|
+
for (const btn of near.flatMap((el) => [...el.querySelectorAll(".copy-btn")])) {
|
|
92
|
+
const r = btn.getBoundingClientRect();
|
|
93
|
+
if (r.bottom < box.top || r.top > box.bottom || r.right < box.left || r.left > box.right) continue;
|
|
94
|
+
bar.style.top = `${Math.max(Math.min(r.bottom + 4, maxTop), 8)}px`;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let raf = false;
|
|
100
|
+
function schedule() {
|
|
101
|
+
if (raf) return;
|
|
102
|
+
raf = true;
|
|
103
|
+
win.requestAnimationFrame(() => { raf = false; update(); });
|
|
104
|
+
}
|
|
105
|
+
doc.addEventListener("selectionchange", schedule);
|
|
106
|
+
win.addEventListener("resize", schedule);
|
|
107
|
+
// Keep the selection when a button is pressed.
|
|
108
|
+
bar.addEventListener("mousedown", (e) => e.preventDefault());
|
|
109
|
+
bar.addEventListener("click", (e) => {
|
|
110
|
+
const btn = e.target.closest("button");
|
|
111
|
+
if (!btn || !pendingQuote) return;
|
|
112
|
+
const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
|
|
113
|
+
const text = preset ? presetNote(pendingQuote, preset) : pendingQuote;
|
|
114
|
+
win.getSelection()?.removeAllRanges();
|
|
115
|
+
hide();
|
|
116
|
+
onQuote(text);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { schedule, hide, el: bar };
|
|
120
|
+
}
|