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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +44 -6
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +54 -4
  6. data/docs/configuration.md +8 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +20 -4
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +4 -3
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +8 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  23. data/lib/samagotchi/bridge.rb +76 -12
  24. data/lib/samagotchi/bridge_client.rb +14 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  36. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  37. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  38. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  39. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  40. data/lib/samagotchi/client_id.rb +1 -1
  41. data/lib/samagotchi/config.rb +195 -115
  42. data/lib/samagotchi/context_status.rb +1 -1
  43. data/lib/samagotchi/continue_offer.rb +2 -1
  44. data/lib/samagotchi/engine.rb +5 -5
  45. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  46. data/lib/samagotchi/guardrails/approval.rb +12 -2
  47. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  48. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  49. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  50. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  51. data/lib/samagotchi/hook_routes.rb +5 -3
  52. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  53. data/lib/samagotchi/hooks/registry.rb +13 -6
  54. data/lib/samagotchi/hooks.rb +5 -0
  55. data/lib/samagotchi/kernel_loop.rb +10 -3
  56. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  57. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  58. data/lib/samagotchi/log_subscriber.rb +1 -0
  59. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  60. data/lib/samagotchi/memory_bundle.rb +1 -0
  61. data/lib/samagotchi/parent_report.rb +92 -16
  62. data/lib/samagotchi/pending_question.rb +8 -4
  63. data/lib/samagotchi/plugin/api.rb +16 -0
  64. data/lib/samagotchi/plugin/context.rb +22 -12
  65. data/lib/samagotchi/plugin/loader.rb +5 -3
  66. data/lib/samagotchi/question_desk.rb +37 -24
  67. data/lib/samagotchi/queued_command.rb +5 -4
  68. data/lib/samagotchi/relay.rb +5 -1
  69. data/lib/samagotchi/relay_verifier.rb +4 -5
  70. data/lib/samagotchi/reply_wait.rb +22 -6
  71. data/lib/samagotchi/send_command.rb +34 -8
  72. data/lib/samagotchi/session_commands.rb +21 -7
  73. data/lib/samagotchi/session_inbox.rb +68 -0
  74. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
  76. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  77. data/lib/samagotchi/terminal_ui.rb +15 -9
  78. data/lib/samagotchi/testing/boundary.rb +0 -3
  79. data/lib/samagotchi/testing/bundle.rb +7 -0
  80. data/lib/samagotchi/testing/host.rb +6 -4
  81. data/lib/samagotchi/testing/install.rb +119 -0
  82. data/lib/samagotchi/testing/recorder.rb +13 -4
  83. data/lib/samagotchi/testing.rb +1 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  86. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  87. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  88. data/lib/samagotchi/turn_flow.rb +10 -0
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/web/app.rb +76 -10
  91. data/lib/samagotchi/web/public/app.js +17 -0
  92. data/lib/samagotchi/web/public/bubbles.js +9 -0
  93. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  94. data/lib/samagotchi/web/public/commands.js +1 -0
  95. data/lib/samagotchi/web/public/ctx.js +12 -26
  96. data/lib/samagotchi/web/public/data.js +11 -0
  97. data/lib/samagotchi/web/public/index.html +9 -0
  98. data/lib/samagotchi/web/public/stage_view.js +6 -0
  99. data/lib/samagotchi/web/public/stream_handlers.js +4 -1
  100. data/lib/samagotchi/web/public/turn_events.js +4 -0
  101. data/lib/samagotchi/web/public/turn_html.js +9 -1
  102. data/lib/samagotchi/web/public/unqueue.js +56 -0
  103. data/lib/samagotchi/web/server.rb +4 -0
  104. data/lib/samagotchi/worker.rb +19 -5
  105. data/lib/samagotchi/worker_inbound.rb +4 -4
  106. metadata +12 -2
  107. 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
- @commands.run(input, confirm: confirm)
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 = parent_answers? ? { client_id: ClientId::CLI_ANSWER } : {}
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 allow guardrails.parent_approvals doesn't let through:
1585
- # the question stays open for a deny (or the end of the input).
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(Guardrails::ParentApprovals.message(e.reason, typed: true))
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 guardrails.parent_approvals.
1601
- def parent_answers?
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
- Question = Data.define(:question, :options, :header, :allow_freeform, :hook)
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
- def ask_user(question:, options:, header:, allow_freeform:, hook:)
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
@@ -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?), even under a PTY
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) })
@@ -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?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.84.0"
4
+ VERSION = "0.86.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- # No session file read here: the folder the answer's file refs must
929
- # stay under is the worker's (its tail's working_directory), else the
930
- # hub's session list entry's (a worker from before the field, or read
931
- # through its snapshot); nil then without a hub or before it saw the
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.query_string.to_s.empty? ? "" : "?#{req.query_string}"
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.query_string.to_s.empty? ? "" : "?#{req.query_string}"
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;