samagotchi 0.84.0 → 0.86.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 +80 -1
- data/docs/architecture.md +44 -6
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +54 -4
- data/docs/configuration.md +8 -5
- data/docs/guardrails.md +63 -35
- data/docs/hooks.md +20 -4
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +4 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +35 -24
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +76 -12
- data/lib/samagotchi/bridge_client.rb +14 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +5 -5
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +10 -3
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_report.rb +92 -16
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +22 -12
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +37 -24
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +22 -6
- data/lib/samagotchi/send_command.rb +34 -8
- data/lib/samagotchi/session_commands.rb +21 -7
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +15 -9
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +6 -1
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -10
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +4 -1
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -67,6 +67,8 @@ module Samagotchi
|
|
|
67
67
|
COMMAND_QUEUED = "(queued: runs after this turn)"
|
|
68
68
|
# /detach is the attached terminal's; the REPL owns its session.
|
|
69
69
|
REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
|
|
70
|
+
# /unqueue is the attached terminal's (its worker's queued input files).
|
|
71
|
+
REPL_UNQUEUE_NOTE = "(/unqueue is the attached terminal's; here a line queued with /queue TEXT runs after the turn)"
|
|
70
72
|
IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
|
|
71
73
|
|
|
72
74
|
# Raised when another process (a `chi web` worker or another chi) owns the
|
|
@@ -293,7 +295,7 @@ module Samagotchi
|
|
|
293
295
|
claim_session!(session.id) unless @owner_lock
|
|
294
296
|
# A turn a worker died in (TurnCheckpoint): ended as canceled, with its
|
|
295
297
|
# note, before this REPL's first turn appends after it.
|
|
296
|
-
session.save if @resume_session && TurnCheckpoint.settle(session)
|
|
298
|
+
session.save if @resume_session && TurnCheckpoint.settle(session, session_dir: Session.session_dir(session.id))
|
|
297
299
|
keep_cards(session)
|
|
298
300
|
# Saved at once, so a process killed before its first turn leaves a
|
|
299
301
|
# file the sweep knows to delete.
|
|
@@ -828,7 +830,7 @@ module Samagotchi
|
|
|
828
830
|
end
|
|
829
831
|
|
|
830
832
|
def answer_continue_offer(session, input)
|
|
831
|
-
answer = @commands.continue_answer(input)
|
|
833
|
+
answer = @commands.continue_answer(input, as_parent: as_parent?)
|
|
832
834
|
if answer.decision == :invalid
|
|
833
835
|
# The read left no echo: the line shows above its error.
|
|
834
836
|
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
|
|
@@ -874,7 +876,9 @@ module Samagotchi
|
|
|
874
876
|
command = if command_registry.anytime?(input)
|
|
875
877
|
@engine.running_anytime { @commands.run(input, confirm: confirm) }
|
|
876
878
|
else
|
|
877
|
-
|
|
879
|
+
# A parent's /continue yes is held to guardrails.parent_answers.
|
|
880
|
+
parent = TurnFlow.continue_yes?(input) && as_parent? ? { as_parent: true } : {}
|
|
881
|
+
@commands.run(input, confirm: confirm, **parent)
|
|
878
882
|
end
|
|
879
883
|
if command
|
|
880
884
|
show_command_result(command)
|
|
@@ -1159,6 +1163,7 @@ module Samagotchi
|
|
|
1159
1163
|
case local_command(input)
|
|
1160
1164
|
when :stats then @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
|
|
1161
1165
|
when :recap then @surface.commit("\nmodel> #{handle_recap_command}")
|
|
1166
|
+
when :unqueue then @surface.commit(REPL_UNQUEUE_NOTE)
|
|
1162
1167
|
else return false
|
|
1163
1168
|
end
|
|
1164
1169
|
true
|
|
@@ -1576,15 +1581,16 @@ module Samagotchi
|
|
|
1576
1581
|
end
|
|
1577
1582
|
|
|
1578
1583
|
begin
|
|
1579
|
-
parent =
|
|
1584
|
+
parent = as_parent? ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
1580
1585
|
@engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
1581
1586
|
close_question_widget(prompt, prompt.answer_text(answer))
|
|
1582
1587
|
return true
|
|
1583
1588
|
rescue QuestionDesk::Refused => e
|
|
1584
|
-
# A parent's
|
|
1585
|
-
# the question stays open for a deny
|
|
1589
|
+
# A parent's pick its strategy doesn't let through
|
|
1590
|
+
# (Guardrails::ParentPolicy): the question stays open for a deny
|
|
1591
|
+
# (or the end of the input).
|
|
1586
1592
|
echo_choice(raw)
|
|
1587
|
-
@surface.commit(
|
|
1593
|
+
@surface.commit(e.detail(typed: true))
|
|
1588
1594
|
next
|
|
1589
1595
|
rescue ArgumentError => e
|
|
1590
1596
|
@surface.commit("Invalid: #{e.message}. Try again.")
|
|
@@ -1597,8 +1603,8 @@ module Samagotchi
|
|
|
1597
1603
|
end
|
|
1598
1604
|
|
|
1599
1605
|
# Whether the answers typed here are a parent agent's (piped stdin, an
|
|
1600
|
-
# agent marker): QuestionDesk holds them to
|
|
1601
|
-
def
|
|
1606
|
+
# agent marker): QuestionDesk holds them to Guardrails::ParentPolicy.
|
|
1607
|
+
def as_parent?
|
|
1602
1608
|
Guardrails::ParentApprovals.parent_process?
|
|
1603
1609
|
end
|
|
1604
1610
|
|
|
@@ -79,9 +79,6 @@ module Samagotchi
|
|
|
79
79
|
"Samagotchi::Session.new_session" => "spec: #{SESSION} (ctx.delegate?, ctx.fork?, ctx.context read it)",
|
|
80
80
|
"Samagotchi::ContextSources.session_location" => "spec: reads and removes the session's sources (added_by), " \
|
|
81
81
|
"which ctx.context.list doesn't show",
|
|
82
|
-
'require "samagotchi/memory_bundle/installer"' => "spec: for Installer",
|
|
83
|
-
"Samagotchi::MemoryBundle::Installer.new" => "spec: installs the bundle so its context provider resolves: " \
|
|
84
|
-
"needs a kit install helper",
|
|
85
82
|
'require "samagotchi/context_providers"' => "spec: for ContextProviders",
|
|
86
83
|
"Samagotchi::ContextProviders.resolve" => "spec: the manifest's URL provider through chi's own resolver",
|
|
87
84
|
'require "samagotchi/answer_display"' => "spec: for AnswerDisplay",
|
|
@@ -240,6 +240,13 @@ module Samagotchi
|
|
|
240
240
|
failing { entry.handler.call({ name: name.to_s, args: stringify(args) }, nil) }
|
|
241
241
|
end
|
|
242
242
|
|
|
243
|
+
# Install this bundle's dir into its sandbox as chi does and load the
|
|
244
|
+
# installed copy with chi's loaders (Install.run, with its settings):
|
|
245
|
+
# what a test of the installed bundle needs (its scripts, context
|
|
246
|
+
# providers), and the install warnings and load failures.
|
|
247
|
+
# @return [Install::Result]
|
|
248
|
+
def install(force: false) = Install.run(@dir, settings: @settings, sandbox: @sandbox, force: force)
|
|
249
|
+
|
|
243
250
|
# Run the chi.init blocks in order, as chi's init tasks do.
|
|
244
251
|
# @return [Array] their results
|
|
245
252
|
def run_inits = failing { @inits.map { |init| init.block.call } }
|
|
@@ -27,8 +27,9 @@ module Samagotchi
|
|
|
27
27
|
messages: -> { recorder[:messages] },
|
|
28
28
|
messages_partial: -> { recorder[:messages_partial] },
|
|
29
29
|
notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
|
|
30
|
-
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook
|
|
31
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook
|
|
30
|
+
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
|
|
31
|
+
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
32
|
+
effects: effects)
|
|
32
33
|
},
|
|
33
34
|
cancelled: -> { recorder[:cancelled] },
|
|
34
35
|
card: ->(**card) { recorder.card(**card) },
|
|
@@ -88,8 +89,9 @@ module Samagotchi
|
|
|
88
89
|
def build(recorder)
|
|
89
90
|
Hooks::Runtime.new(
|
|
90
91
|
notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
|
|
91
|
-
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook
|
|
92
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook
|
|
92
|
+
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
|
|
93
|
+
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
94
|
+
effects: effects)
|
|
93
95
|
},
|
|
94
96
|
stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
|
|
95
97
|
steer: ->(text:, hook:) { recorder.steer(text, hook) },
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../extension_load"
|
|
4
|
+
require_relative "../guardrails/load_failures"
|
|
5
|
+
require_relative "../memory_bundle/installer"
|
|
6
|
+
require_relative "../memory_bundle/provenance"
|
|
7
|
+
require_relative "../hooks"
|
|
8
|
+
require_relative "bundle"
|
|
9
|
+
require_relative "sandbox"
|
|
10
|
+
require_relative "recorder"
|
|
11
|
+
require_relative "host"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
module Testing
|
|
15
|
+
# A bundle as chi installs and loads it, unlike Bundle (its source as it
|
|
16
|
+
# is): MemoryBundle::Installer copies it into the sandbox's state (sha256
|
|
17
|
+
# and requires_chi checked), then chi's own loaders load what is
|
|
18
|
+
# installed (ExtensionLoad: the bundle hooks, then the plugins). What
|
|
19
|
+
# failed names the bundle's source files, not the installed copies.
|
|
20
|
+
#
|
|
21
|
+
# result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
|
|
22
|
+
# result.warnings, result.plugin_failures, result.hook_failures, result.registered[:commands]
|
|
23
|
+
module Install
|
|
24
|
+
# warnings: the Installer's; plugin_failures / hook_failures:
|
|
25
|
+
# [Guardrails::LoadFailures::Failure] of this bundle, each location
|
|
26
|
+
# in the source dir; registered: what it registered (#registered).
|
|
27
|
+
Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
|
|
28
|
+
def failures = plugin_failures + hook_failures
|
|
29
|
+
def ok? = failures.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Install the bundle at +dir+ into +sandbox+ (default Sandbox.current,
|
|
35
|
+
# else one of its own, left before returning) and load it.
|
|
36
|
+
# @param settings [Hash] config.yml `bundles: <name>:` (written to the
|
|
37
|
+
# sandbox's config.yml)
|
|
38
|
+
# @param force [Boolean] the Installer's --force (a name chi ships)
|
|
39
|
+
# @return [Result]
|
|
40
|
+
# @raise [MemoryBundle::Installer::InstallError] what the Installer refused
|
|
41
|
+
def run(dir, settings: {}, sandbox: nil, force: false)
|
|
42
|
+
return Sandbox.with { |own| run(dir, settings: settings, sandbox: own, force: force) } if sandbox.nil? && Sandbox.current.nil?
|
|
43
|
+
|
|
44
|
+
sandbox ||= Sandbox.current
|
|
45
|
+
dir = File.expand_path(dir)
|
|
46
|
+
name = MemoryBundle::Manifest.read(dir: dir).name
|
|
47
|
+
sandbox.config("bundles" => { name => settings })
|
|
48
|
+
installer = MemoryBundle::Installer.new(source: dir, name: name, scope: "system", force: force)
|
|
49
|
+
installer.run
|
|
50
|
+
load_installed(name, dir, installer.warnings, sandbox)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def load_installed(name, dir, warnings, sandbox)
|
|
54
|
+
inits = []
|
|
55
|
+
# A session's values, as an Engine's host gives them (only what the loaders register matters).
|
|
56
|
+
host = Host.build(Recorder.new(cwd: sandbox.work_dir))
|
|
57
|
+
hook_failures = Guardrails::LoadFailures.new
|
|
58
|
+
plugin_failures = Guardrails::LoadFailures.new
|
|
59
|
+
extension = ExtensionLoad.new(hook_failures: hook_failures)
|
|
60
|
+
registries = Plugin::Registries.new(
|
|
61
|
+
commands: SessionCommands.register_builtins(Commands::Registry.new),
|
|
62
|
+
tools: Tools::Builtins.registry, hooks: extension.hooks,
|
|
63
|
+
context_for: lambda { |bundle, bundle_settings, label|
|
|
64
|
+
Plugin::Context.new(bundle: bundle, label: label, settings: bundle_settings, host: host)
|
|
65
|
+
},
|
|
66
|
+
services: Plugin::Services.new, stage_tools: ->(*) {},
|
|
67
|
+
init: ->(_bundle, label, _plugin_label, **, &_block) { inits << { label: label } },
|
|
68
|
+
toolkits: [], tool_index: Tools::ToolIndex.new, peer_sources: PeerSources.new
|
|
69
|
+
)
|
|
70
|
+
extension.load_plugins(registries, failures: plugin_failures)
|
|
71
|
+
installed = File.join(MemoryBundle::Provenance.bundles_dir, name)
|
|
72
|
+
Result.new(name: name, warnings: warnings.dup,
|
|
73
|
+
plugin_failures: own(plugin_failures, name, installed, dir),
|
|
74
|
+
hook_failures: own(hook_failures, name, installed, dir),
|
|
75
|
+
registered: registered(registries, name, inits))
|
|
76
|
+
ensure
|
|
77
|
+
registries&.services&.stop_all
|
|
78
|
+
end
|
|
79
|
+
private_class_method :load_installed
|
|
80
|
+
|
|
81
|
+
# What bundle +name+ registered, from the registries it loaded into:
|
|
82
|
+
# commands ([name, mid_turn, annotation labels]), tools, hooks per
|
|
83
|
+
# event ([bundle, hook, priority]; Hooks::EVENTS, then any other event
|
|
84
|
+
# the bundle registered for), inits, toolkits, tool sources and
|
|
85
|
+
# peer sources.
|
|
86
|
+
# @param inits [Array<Hash>] {label:} of each chi.init
|
|
87
|
+
# @return [Hash]
|
|
88
|
+
def registered(registries, name, inits)
|
|
89
|
+
{
|
|
90
|
+
commands: registries.commands.entries.select { |e| e.source == name }.map { |e| [e.name, e.mid_turn, e.annotations.map(&:label)] },
|
|
91
|
+
tools: registries.tools.by_source(name).map(&:name),
|
|
92
|
+
hooks: hooks_of(registries.hooks, name),
|
|
93
|
+
inits: inits.map { |init| init[:label] },
|
|
94
|
+
toolkits: Array(registries.toolkits).map(&:name),
|
|
95
|
+
tool_sources: registries.tool_index.sources.map(&:label),
|
|
96
|
+
peer_sources: registries.peer_sources.names
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def hooks_of(hooks, name)
|
|
101
|
+
own = ->(event) { hooks.bundle_hook_names(event).select { |bundle, _, _| bundle == name } }
|
|
102
|
+
others = (hooks.bundle_events - Hooks::EVENTS).to_h { |event| [event, own.call(event)] }.reject { |_, list| list.empty? }
|
|
103
|
+
Hooks::EVENTS.to_h { |event| [event, own.call(event)] }.merge(others)
|
|
104
|
+
end
|
|
105
|
+
private_class_method :hooks_of
|
|
106
|
+
|
|
107
|
+
# +failures+' entries for bundle +name+, their location moved from the
|
|
108
|
+
# installed copy to the source dir.
|
|
109
|
+
def own(failures, name, installed, dir)
|
|
110
|
+
failures.list.select { |f| f.what.end_with?("(bundle #{name})") }.map do |failure|
|
|
111
|
+
# The plugin is installed into plugin/, the hooks into hooks/.
|
|
112
|
+
location = failure.location&.sub(%r{\A#{Regexp.escape(installed)}/(?:plugin/)?}, "#{dir}/")
|
|
113
|
+
failure.dup.tap { |copy| copy.location = location }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :own
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../plugin/card"
|
|
4
4
|
require_relative "../plugin/context"
|
|
5
|
+
require_relative "../guardrails/parent_policy"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Testing
|
|
@@ -12,8 +13,11 @@ module Samagotchi
|
|
|
12
13
|
Steer = Data.define(:text, :hook)
|
|
13
14
|
# A stop_turn or stop_generation (+kind+ :turn or :generation).
|
|
14
15
|
Stop = Data.define(:kind, :reason, :hook)
|
|
15
|
-
# An ask_user question
|
|
16
|
-
|
|
16
|
+
# An ask_user question; effects: its options' tags as the bundle gave
|
|
17
|
+
# them (label => effect), nil for none.
|
|
18
|
+
Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
|
|
19
|
+
def initialize(effects: nil, **) = super
|
|
20
|
+
end
|
|
17
21
|
# An ask_model request: the request Context built (SideQuestion), its
|
|
18
22
|
# limits, cancel controller and the model: it named (nil: the session's;
|
|
19
23
|
# the kit doesn't check it against any hosts), and +source+, the asking
|
|
@@ -100,9 +104,14 @@ module Samagotchi
|
|
|
100
104
|
end
|
|
101
105
|
|
|
102
106
|
# The next queued answer (#answers), nil with none (no one to ask).
|
|
103
|
-
|
|
107
|
+
# Effects chi would refuse (a label the options don't have, an
|
|
108
|
+
# unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
|
|
109
|
+
# in chi: nil, nothing recorded, no answer taken.
|
|
110
|
+
def ask_user(question:, options:, header:, allow_freeform:, hook:, effects: nil)
|
|
111
|
+
return nil if Guardrails::ParentPolicy.option_tags(options, effects) == false
|
|
112
|
+
|
|
104
113
|
record(@questions, Question.new(question: question, options: options, header: header,
|
|
105
|
-
allow_freeform: allow_freeform, hook: hook))
|
|
114
|
+
allow_freeform: allow_freeform, hook: hook, effects: effects))
|
|
106
115
|
answer = @mutex.synchronize { @answers.shift }
|
|
107
116
|
answer.respond_to?(:call) ? answer.call(@questions.last) : answer
|
|
108
117
|
end
|
data/lib/samagotchi/testing.rb
CHANGED
|
@@ -22,5 +22,6 @@ require_relative "testing/recording_api"
|
|
|
22
22
|
require_relative "testing/waiting"
|
|
23
23
|
require_relative "testing/fake_executable"
|
|
24
24
|
require_relative "testing/bundle"
|
|
25
|
+
require_relative "testing/install"
|
|
25
26
|
require_relative "testing/helpers"
|
|
26
27
|
require_relative "testing/boundary"
|
|
@@ -118,7 +118,8 @@ module Samagotchi
|
|
|
118
118
|
|
|
119
119
|
# What execute and task_create export into the commands they run: a
|
|
120
120
|
# chi started there answers as a parent agent
|
|
121
|
-
# (Guardrails::ParentApprovals.parent_process
|
|
121
|
+
# (Guardrails::ParentApprovals.parent_process?, held to
|
|
122
|
+
# Guardrails::ParentPolicy), even under a PTY
|
|
122
123
|
# wrapper. The session's id, "chi" without one. And the model the
|
|
123
124
|
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
124
125
|
# /model), which chi self reports; unset when unknown, so a value
|
|
@@ -305,10 +305,15 @@ module Samagotchi
|
|
|
305
305
|
# The tool's last line follows #reports_mode.
|
|
306
306
|
def waiting_text(child_id, pending, report: false, reports: "off")
|
|
307
307
|
text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
|
|
308
|
-
if ParentReport.continue?(pending)
|
|
308
|
+
if ParentReport.continue?(pending) && ParentReport.may_continue?(pending)
|
|
309
309
|
text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
|
|
310
310
|
"above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
|
|
311
311
|
"session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
|
|
312
|
+
elsif ParentReport.continue?(pending)
|
|
313
|
+
# guardrails.parent_answers: hard: continuing is the user's.
|
|
314
|
+
text += "The child ran out of steps before it answered. Decide: send it a narrower follow-up with delegate " \
|
|
315
|
+
"session: #{child_id} (that drops the question); or stop it (the chi answer Stop command above, " \
|
|
316
|
+
"with execute) and report back to your user, who may continue it.\n"
|
|
312
317
|
end
|
|
313
318
|
return "#{text}#{REPORT_NEXT_REPLY}" if report
|
|
314
319
|
return "#{text}delegate_result #{child_id} waits again once it is answered." if reports == "off"
|
|
@@ -100,15 +100,15 @@ module Samagotchi
|
|
|
100
100
|
# between (failed, no exit code) is corrected; a stopped task (its
|
|
101
101
|
# stop flag on disk) is left as task_stop records it.
|
|
102
102
|
# The thread is named EXIT_STATUS_THREAD (a spec joins them before it
|
|
103
|
-
# removes the tasks' folder)
|
|
103
|
+
# removes the tasks' folder) here, before it returns: named from
|
|
104
|
+
# inside, a thread not yet scheduled was missed by that join.
|
|
104
105
|
# @return [Thread]
|
|
105
106
|
def record_exit_status(waiter, path, task_id:)
|
|
106
107
|
# Absolute now: safe against a later chdir.
|
|
107
108
|
record_path = File.expand_path(metadata_path(task_id))
|
|
108
109
|
path = File.expand_path(path)
|
|
109
|
-
Thread.new do
|
|
110
|
+
thread = Thread.new do
|
|
110
111
|
Thread.current.report_on_exception = false
|
|
111
|
-
Thread.current.name = EXIT_STATUS_THREAD
|
|
112
112
|
code = status_code(waiter.value)
|
|
113
113
|
next if code.nil? || record_at(record_path)&.stop_requested_by
|
|
114
114
|
|
|
@@ -117,6 +117,8 @@ module Samagotchi
|
|
|
117
117
|
rescue StandardError => e
|
|
118
118
|
Log.debug(:turn, "task_exit_code_failed", id: task_id, error: e.class.name, msg: e.message)
|
|
119
119
|
end
|
|
120
|
+
thread.name = EXIT_STATUS_THREAD
|
|
121
|
+
thread
|
|
120
122
|
end
|
|
121
123
|
|
|
122
124
|
# A Process::Status as a shell reports one: its exit status, or
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
3
5
|
require "time"
|
|
6
|
+
require_relative "atomic_file"
|
|
4
7
|
require_relative "interrupted_turn"
|
|
8
|
+
require_relative "last_turn"
|
|
5
9
|
require_relative "log"
|
|
6
10
|
require_relative "session"
|
|
11
|
+
require_relative "session_inbox"
|
|
7
12
|
require_relative "steer"
|
|
8
13
|
require_relative "turn_note"
|
|
9
14
|
|
|
@@ -39,9 +44,12 @@ module Samagotchi
|
|
|
39
44
|
# last_turn is left as it was: a new last_turn saved idle here, before
|
|
40
45
|
# the turn a `chi send --wait` started this worker for, would end that
|
|
41
46
|
# wait at once as the sent turn's (ReplyWait compares last_turn), with
|
|
42
|
-
# the dead turn's outcome.
|
|
47
|
+
# the dead turn's outcome. A mark with an enqueued_id records the dead
|
|
48
|
+
# turn's end under that id instead (+session_dir+, #settled_turn): only
|
|
49
|
+
# a wait for that very turn (ReplyWait's turn:) reads it.
|
|
50
|
+
# @param session_dir [String, nil] where to record it; nil: nowhere
|
|
43
51
|
# @return [InterruptedTurn, nil] the mark settled, nil when there was none
|
|
44
|
-
def self.settle(session)
|
|
52
|
+
def self.settle(session, session_dir: nil)
|
|
45
53
|
mark = session.interrupted_turn
|
|
46
54
|
return nil unless mark
|
|
47
55
|
|
|
@@ -54,9 +62,63 @@ module Samagotchi
|
|
|
54
62
|
session.messages = messages + [note]
|
|
55
63
|
session.status = Session::STATUS_IDLE if session.status == Session::STATUS_RUNNING
|
|
56
64
|
session.interrupted_turn = nil
|
|
65
|
+
record_settled(session_dir, mark, seconds) if session_dir
|
|
57
66
|
mark
|
|
58
67
|
end
|
|
59
68
|
|
|
69
|
+
# Where a per-input terminal outcome is kept: input/settled/<enqueued_id>,
|
|
70
|
+
# outside the *.json glob like SessionInbox's removed tombstones.
|
|
71
|
+
SETTLED_DIR = "settled"
|
|
72
|
+
WORKER_STOPPED = "worker_stopped"
|
|
73
|
+
|
|
74
|
+
# The terminal outcome recorded for +enqueued_id+.
|
|
75
|
+
# @return [LastTurn, nil] nil when no settle recorded one
|
|
76
|
+
def self.settled_turn(session_dir, enqueued_id)
|
|
77
|
+
id = enqueued_id.to_s
|
|
78
|
+
return nil unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
79
|
+
|
|
80
|
+
raw = File.read(File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR, id))
|
|
81
|
+
LastTurn.from_file(JSON.parse(raw)) || settled_record(id)
|
|
82
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
83
|
+
nil
|
|
84
|
+
rescue JSON::ParserError, SystemCallError
|
|
85
|
+
# The file says the turn was settled even when it can't be read.
|
|
86
|
+
settled_record(id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.settled_record(id, ended_at: nil, seconds: nil)
|
|
90
|
+
LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id)
|
|
91
|
+
end
|
|
92
|
+
private_class_method :settled_record
|
|
93
|
+
|
|
94
|
+
# A file-delivered command ends its sender's wait without running a
|
|
95
|
+
# turn. Keep a terminal outcome under the same per-input settled path.
|
|
96
|
+
def self.record_command_outcome(session_dir, enqueued_id)
|
|
97
|
+
id = enqueued_id.to_s
|
|
98
|
+
return if id.empty? || !SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
99
|
+
|
|
100
|
+
dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
|
|
101
|
+
FileUtils.mkdir_p(dir)
|
|
102
|
+
record = LastTurn.new(outcome: "completed", ended_at: Time.now.iso8601(3), enqueued_id: id)
|
|
103
|
+
AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
|
|
104
|
+
rescue SystemCallError, IOError => e
|
|
105
|
+
Log.warn(:worker, "command_outcome_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.record_settled(session_dir, mark, seconds)
|
|
109
|
+
id = mark.enqueued_id.to_s
|
|
110
|
+
return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
111
|
+
|
|
112
|
+
dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
|
|
113
|
+
FileUtils.mkdir_p(dir)
|
|
114
|
+
record = settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds)
|
|
115
|
+
AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
|
|
116
|
+
rescue SystemCallError => e
|
|
117
|
+
# The settle goes on; a wait for that turn runs to its timeout.
|
|
118
|
+
Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
|
|
119
|
+
end
|
|
120
|
+
private_class_method :record_settled
|
|
121
|
+
|
|
60
122
|
# @param session [Session] the worker's session (saved as a whole)
|
|
61
123
|
# @param clock [#call] monotonic seconds (specs)
|
|
62
124
|
def initialize(session:, state_dir:, interval: INTERVAL, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -23,6 +23,16 @@ module Samagotchi
|
|
|
23
23
|
|
|
24
24
|
# Read an answer to the continue offer.
|
|
25
25
|
# @return [Array(Symbol, String|nil)] [:resume | :abort | :abort_with_reason | :invalid, reason]
|
|
26
|
+
# Whether +line+ is a /continue that continues the turn (/continue,
|
|
27
|
+
# /continue yes): what a parent agent's must be checked for.
|
|
28
|
+
def self.continue_yes?(line)
|
|
29
|
+
text = line.to_s.strip
|
|
30
|
+
return false unless text.match?(/\A#{Regexp.escape(CONTINUE_COMMAND)}(\s|\z)/)
|
|
31
|
+
|
|
32
|
+
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
33
|
+
continue_decision(answer.empty? ? CONTINUE_COMMAND : answer).first == :resume
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def self.continue_decision(input)
|
|
27
37
|
normalized = input.to_s.strip
|
|
28
38
|
return [:resume, nil] if normalized.empty?
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -166,6 +166,7 @@ module Samagotchi
|
|
|
166
166
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
167
167
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
168
168
|
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
|
|
169
|
+
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn],
|
|
169
170
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
170
171
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
|
|
171
172
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
@@ -925,14 +926,10 @@ module Samagotchi
|
|
|
925
926
|
state = nil unless state.is_a?(Hash)
|
|
926
927
|
if state
|
|
927
928
|
status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
# session: refs aren't checked.
|
|
933
|
-
cwd = tail["working_directory"]
|
|
934
|
-
cwd = @hub&.working_directory(id) unless cwd.is_a?(String) && !cwd.empty?
|
|
935
|
-
messages = last_assistant_for_display([tail["answer"]].compact, cwd: cwd)
|
|
929
|
+
answer = tail["answer"]
|
|
930
|
+
cwd = tail_working_directory(id, tail, answer)
|
|
931
|
+
answer = without_file_refs(answer) unless cwd
|
|
932
|
+
messages = last_assistant_for_display([answer].compact, cwd: cwd)
|
|
936
933
|
else
|
|
937
934
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
938
935
|
status = displayed_status(session)
|
|
@@ -955,6 +952,35 @@ module Samagotchi
|
|
|
955
952
|
error_response(404, "not_found", e.message)
|
|
956
953
|
end
|
|
957
954
|
|
|
955
|
+
# The folder the live tail's answer's file refs must stay under: the
|
|
956
|
+
# worker's (its tail's working_directory), else the hub's session list
|
|
957
|
+
# entry's (a worker from before the field, or read through its
|
|
958
|
+
# snapshot), else, only when the answer has refs to check, the session
|
|
959
|
+
# file's, as the full read takes it. nil when none is known: the
|
|
960
|
+
# caller drops the file refs rather than show them unchecked.
|
|
961
|
+
# @return [String, nil]
|
|
962
|
+
def tail_working_directory(id, tail, answer)
|
|
963
|
+
known = ->(dir) { dir.is_a?(String) && !dir.empty? }
|
|
964
|
+
cwd = tail["working_directory"]
|
|
965
|
+
cwd = @hub&.working_directory(id) unless known.call(cwd)
|
|
966
|
+
return cwd if known.call(cwd)
|
|
967
|
+
return nil unless answer.is_a?(Hash) && (answer[AnswerRefs::KEY.to_s] || answer[AnswerRefs::KEY])
|
|
968
|
+
|
|
969
|
+
cwd = @session_class.load(id, state_dir: default_state_dir).working_directory
|
|
970
|
+
known.call(cwd) ? cwd : nil
|
|
971
|
+
rescue ArgumentError, KeyError, SystemCallError
|
|
972
|
+
nil
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# +answer+ without its file refs (the rest kept), for a read that
|
|
976
|
+
# knows no folder to check them against.
|
|
977
|
+
def without_file_refs(answer)
|
|
978
|
+
return answer unless answer.is_a?(Hash)
|
|
979
|
+
|
|
980
|
+
key = [AnswerRefs::KEY.to_s, AnswerRefs::KEY].find { |name| answer[name].is_a?(Array) } or return answer
|
|
981
|
+
answer.merge(key => answer[key].reject { |ref| ref.is_a?(Hash) && (ref["kind"] || ref[:kind]).to_s == Ref::FILE })
|
|
982
|
+
end
|
|
983
|
+
|
|
958
984
|
# ?timing=1: the whole timing (every record, analytics.json merged with
|
|
959
985
|
# the live ones) and turn_count, when the page's merge comes up short.
|
|
960
986
|
# The worker's GET state, never its messages.
|
|
@@ -1339,6 +1365,33 @@ module Samagotchi
|
|
|
1339
1365
|
error_response(404, "not_found", e.message)
|
|
1340
1366
|
end
|
|
1341
1367
|
|
|
1368
|
+
# POST /api/sessions/:id/turn/:enqueued_id/dequeue {client_id?}: the ×
|
|
1369
|
+
# on a queued bubble takes the message back before it runs
|
|
1370
|
+
# (Bridge#handle_dequeue_turn). Straight to the live worker, never
|
|
1371
|
+
# through deliver_turn: a removal must not wake one, and a stopped
|
|
1372
|
+
# worker's queued file is the next worker's (503 not_live). 200
|
|
1373
|
+
# {status, enqueued_id, prompt}; 409 already_started / not_queued and
|
|
1374
|
+
# 404 unknown_enqueued_id pass through; the rest as every relay.
|
|
1375
|
+
def handle_dequeue_turn(req, id, enqueued_id)
|
|
1376
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
1377
|
+
body = request_body(req)
|
|
1378
|
+
parsed = body.nil? || body.strip.empty? ? nil : parse_json(body)
|
|
1379
|
+
client_id = (parsed["client_id"] if parsed.is_a?(Hash))
|
|
1380
|
+
|
|
1381
|
+
request = ->(client) { client.dequeue(enqueued_id: enqueued_id, client_id: client_id) }
|
|
1382
|
+
relay(id, bridge_client(id), request, what: "the message was not removed", cant: "remove queued messages") do |reply|
|
|
1383
|
+
body = reply.json || {}
|
|
1384
|
+
case reply.status
|
|
1385
|
+
when 200 then json_response(200, { status: body["status"], enqueued_id: enqueued_id, prompt: body["prompt"].to_s, session_id: id })
|
|
1386
|
+
when 409 then json_response(409, { error: body["error"] || "already_started", detail: body["detail"], enqueued_id: enqueued_id, session_id: id }.compact)
|
|
1387
|
+
when 404
|
|
1388
|
+
json_response(404, { error: "unknown_enqueued_id", enqueued_id: enqueued_id, session_id: id }) if body["error"] == "unknown_enqueued_id"
|
|
1389
|
+
end
|
|
1390
|
+
end
|
|
1391
|
+
rescue ArgumentError => e
|
|
1392
|
+
error_response(404, "not_found", e.message)
|
|
1393
|
+
end
|
|
1394
|
+
|
|
1342
1395
|
def handle_stop(_req, id)
|
|
1343
1396
|
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
1344
1397
|
# after it just means an immediate resume finds it (rare).
|
|
@@ -1544,7 +1597,7 @@ module Samagotchi
|
|
|
1544
1597
|
return [200, headers.merge("rack.hijack" => hijack), []]
|
|
1545
1598
|
end
|
|
1546
1599
|
|
|
1547
|
-
query = req
|
|
1600
|
+
query = stream_query(req)
|
|
1548
1601
|
[200, headers, ProxyStreamBody.new(host: DEFAULT_HOST, port: bridge_port, session_id: id, query: query, headers: req.env,
|
|
1549
1602
|
server_running: @server_running)]
|
|
1550
1603
|
end
|
|
@@ -1588,13 +1641,26 @@ module Samagotchi
|
|
|
1588
1641
|
[200, headers, body]
|
|
1589
1642
|
end
|
|
1590
1643
|
|
|
1644
|
+
# The stream's query as the worker's Bridge gets it ("?..." or ""):
|
|
1645
|
+
# every parameter but `raw`. `?raw=1` is the worker port's debugging view
|
|
1646
|
+
# (a chunk's provider payload); a browser never gets it through here.
|
|
1647
|
+
# Keys are compared decoded, as the Bridge reads them.
|
|
1648
|
+
def stream_query(req)
|
|
1649
|
+
pairs = req.query_string.to_s.split("&").reject do |pair|
|
|
1650
|
+
URI.decode_www_form_component(pair.split("=", 2).first.to_s) == "raw"
|
|
1651
|
+
rescue ArgumentError
|
|
1652
|
+
false
|
|
1653
|
+
end
|
|
1654
|
+
pairs.empty? ? "" : "?#{pairs.join("&")}"
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1591
1657
|
# Rack hijack lambda (env["rack.hijack?"] truthy): pipes the bridge's SSE
|
|
1592
1658
|
# frames directly to the client socket until it disconnects. The handler
|
|
1593
1659
|
# (WEBrick via rackup) has already sent the status line + headers, so
|
|
1594
1660
|
# only body bytes go to the socket here.
|
|
1595
1661
|
def sse_hijack(id, req, port)
|
|
1596
1662
|
lambda do |io|
|
|
1597
|
-
query = req
|
|
1663
|
+
query = stream_query(req)
|
|
1598
1664
|
body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
|
|
1599
1665
|
server_running: @server_running)
|
|
1600
1666
|
body.each { |chunk| io.write(chunk) }
|
|
@@ -92,6 +92,7 @@ import { currentGen } from "./turn_model.js";
|
|
|
92
92
|
import { toolName } from "./activity.js";
|
|
93
93
|
import { toastBottom } from "./toast_place.js";
|
|
94
94
|
import { createToasts } from "./toasts.js";
|
|
95
|
+
import { createUnqueue } from "./unqueue.js";
|
|
95
96
|
|
|
96
97
|
const $ = (s) => document.querySelector(s);
|
|
97
98
|
const topStripEl = $("#topStrip");
|
|
@@ -1801,6 +1802,22 @@ composerEl.addEventListener("drop", (e) => {
|
|
|
1801
1802
|
e.preventDefault();
|
|
1802
1803
|
addImageFiles(files);
|
|
1803
1804
|
});
|
|
1805
|
+
// The × on a message queued for after the turn takes it back (unqueue.js).
|
|
1806
|
+
const unqueue = createUnqueue({
|
|
1807
|
+
sessionId: () => selected,
|
|
1808
|
+
clientId,
|
|
1809
|
+
toast: (text) => showToast(text),
|
|
1810
|
+
// Edit: the removed text back into an empty composer (never over a draft).
|
|
1811
|
+
putBack: (text) => {
|
|
1812
|
+
if (promptEl.value.trim() || chips.length) return false;
|
|
1813
|
+
openForVisit();
|
|
1814
|
+
promptEl.value = text;
|
|
1815
|
+
fitPrompt();
|
|
1816
|
+
promptEl.focus();
|
|
1817
|
+
return true;
|
|
1818
|
+
},
|
|
1819
|
+
});
|
|
1820
|
+
turnRoots().forEach((root) => root.addEventListener("click", (e) => { unqueue.onClick(e); }));
|
|
1804
1821
|
turnRoots().forEach((root) => root.addEventListener("click", (e) => {
|
|
1805
1822
|
const img = e.target.closest("img.thumb");
|
|
1806
1823
|
if (!img) return;
|