samagotchi 0.121.0 → 0.122.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -1
  3. data/bin/chi +7 -3
  4. data/docs/architecture.md +37 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +11 -4
  7. data/docs/configuration.md +36 -7
  8. data/docs/guardrails.md +63 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/engine.md +2 -0
  12. data/docs/internals/participants.md +16 -8
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/memory.md +33 -0
  16. data/docs/plugins.md +8 -3
  17. data/docs/sessions.md +21 -0
  18. data/docs/testing.md +41 -1
  19. data/lib/samagotchi/answer_command.rb +1 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  21. data/lib/samagotchi/bridge.rb +12 -11
  22. data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
  23. data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
  24. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  25. data/lib/samagotchi/bridge_client.rb +52 -65
  26. data/lib/samagotchi/bridge_secret.rb +9 -7
  27. data/lib/samagotchi/bundle_command/check.rb +27 -2
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  32. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/config.rb +5 -0
  39. data/lib/samagotchi/engine.rb +107 -1
  40. data/lib/samagotchi/event_fanout.rb +0 -1
  41. data/lib/samagotchi/guardrail_wiring.rb +24 -6
  42. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  43. data/lib/samagotchi/guardrails_command.rb +41 -6
  44. data/lib/samagotchi/image_store.rb +6 -3
  45. data/lib/samagotchi/interrupted_turn.rb +6 -3
  46. data/lib/samagotchi/live_versions.rb +6 -7
  47. data/lib/samagotchi/log_line.rb +1 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  50. data/lib/samagotchi/memory_orphans.rb +11 -0
  51. data/lib/samagotchi/participant_mode.rb +11 -0
  52. data/lib/samagotchi/plugin/api.rb +14 -8
  53. data/lib/samagotchi/plugin/sessions.rb +13 -4
  54. data/lib/samagotchi/process_group.rb +13 -0
  55. data/lib/samagotchi/push_command.rb +1 -1
  56. data/lib/samagotchi/relay_verifier.rb +2 -3
  57. data/lib/samagotchi/relay_watcher.rb +2 -2
  58. data/lib/samagotchi/remote_attach_command.rb +1 -1
  59. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  60. data/lib/samagotchi/reply_wait.rb +4 -3
  61. data/lib/samagotchi/review_command.rb +1 -1
  62. data/lib/samagotchi/review_start.rb +6 -2
  63. data/lib/samagotchi/run_options.rb +2 -0
  64. data/lib/samagotchi/session.rb +18 -1
  65. data/lib/samagotchi/session_access/contract.rb +1 -1
  66. data/lib/samagotchi/session_access/local.rb +18 -8
  67. data/lib/samagotchi/session_access/remote.rb +37 -12
  68. data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
  69. data/lib/samagotchi/session_access.rb +21 -9
  70. data/lib/samagotchi/session_continue.rb +2 -3
  71. data/lib/samagotchi/session_manager.rb +133 -29
  72. data/lib/samagotchi/session_setup.rb +8 -4
  73. data/lib/samagotchi/sessions_command.rb +10 -5
  74. data/lib/samagotchi/steer.rb +7 -6
  75. data/lib/samagotchi/system_one/client.rb +12 -6
  76. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  77. data/lib/samagotchi/system_one/log.rb +44 -9
  78. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  79. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  80. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  81. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  82. data/lib/samagotchi/terminal_ui.rb +2 -1
  83. data/lib/samagotchi/tool_runner.rb +2 -1
  84. data/lib/samagotchi/toolkit.rb +95 -10
  85. data/lib/samagotchi/toolkit_binding.rb +115 -7
  86. data/lib/samagotchi/toolkit_command.rb +15 -3
  87. data/lib/samagotchi/toolkit_report_command.rb +318 -0
  88. data/lib/samagotchi/toolkit_suggest.rb +211 -0
  89. data/lib/samagotchi/tools/delegate.rb +1 -1
  90. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  91. data/lib/samagotchi/tools/memory.rb +1 -1
  92. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +127 -69
  95. data/lib/samagotchi/web/participant_access.rb +30 -8
  96. data/lib/samagotchi/web/participant_review.rb +8 -5
  97. data/lib/samagotchi/web/participant_view.rb +7 -3
  98. data/lib/samagotchi/web/participants_admin.rb +13 -0
  99. data/lib/samagotchi/web/principal.rb +3 -4
  100. data/lib/samagotchi/web/public/app.js +1 -1
  101. data/lib/samagotchi/web/public/bubbles.js +3 -2
  102. data/lib/samagotchi/web/public/notifications.js +21 -1
  103. data/lib/samagotchi/web/public/participant.html +3 -2
  104. data/lib/samagotchi/web/public/participant.js +25 -10
  105. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  106. data/lib/samagotchi/web/public/push.js +4 -3
  107. data/lib/samagotchi/web/public/push_devices.js +17 -3
  108. data/lib/samagotchi/web/public/review_history.js +14 -2
  109. data/lib/samagotchi/web/public/review_start.js +1 -3
  110. data/lib/samagotchi/web/public/session_actions.js +14 -2
  111. data/lib/samagotchi/web/public/storage.js +16 -0
  112. data/lib/samagotchi/web/public/turn_events.js +2 -1
  113. data/lib/samagotchi/web/public/turn_html.js +1 -1
  114. data/lib/samagotchi/web/push/client.rb +5 -0
  115. data/lib/samagotchi/web/push/store.rb +16 -3
  116. data/lib/samagotchi/web/push_routes.rb +3 -2
  117. data/lib/samagotchi/web/server.rb +2 -2
  118. data/lib/samagotchi/web/session_access_routes.rb +28 -20
  119. data/lib/samagotchi/web/session_control.rb +4 -2
  120. data/lib/samagotchi/web/session_create.rb +4 -4
  121. data/lib/samagotchi/web/session_hub.rb +6 -4
  122. data/lib/samagotchi/web/session_summary.rb +8 -16
  123. data/lib/samagotchi/web/start_dir.rb +10 -5
  124. data/lib/samagotchi/worker.rb +17 -3
  125. data/lib/samagotchi/worker_link.rb +214 -0
  126. data/lib/samagotchi/worker_sidecar.rb +15 -37
  127. metadata +6 -1
@@ -41,6 +41,8 @@ require_relative "llm/openai_chat"
41
41
  require_relative "llm/turn_settings"
42
42
  require_relative "session"
43
43
  require_relative "toolkit_binding"
44
+ require_relative "participant_mode"
45
+ require_relative "toolkit_suggest"
44
46
  require_relative "tool_loads"
45
47
  require_relative "answer_tail"
46
48
  require_relative "archive_store"
@@ -156,6 +158,7 @@ module Samagotchi
156
158
  @no_interrupt = no_interrupt
157
159
  @chat_backend = nil
158
160
  @chat_backend_mutex = Mutex.new
161
+ @toolkit_audit_thread = nil
159
162
  @host_registry = host_registry || HostRegistry.new
160
163
  # A model given here (a worker's session model) is the one it runs,
161
164
  # so its host is checked; the config default is only checked once it
@@ -1082,6 +1085,10 @@ module Samagotchi
1082
1085
 
1083
1086
  # How long #shutdown waits for the running anytime commands, in all.
1084
1087
  SHUTDOWN_JOIN_SECONDS = 3.0
1088
+ # How long #shutdown waits for a running toolkit audit ask (a one-shot
1089
+ # -p --non-interactive exits right after its turn); a slower ask goes
1090
+ # unlogged.
1091
+ TOOLKIT_AUDIT_JOIN_SECONDS = 1.0
1085
1092
 
1086
1093
  # The REPL or the session's worker is leaving (/exit, an idle exit, a
1087
1094
  # crash, TERM): stop the idle jobs, give the running anytime commands
@@ -1107,6 +1114,7 @@ module Samagotchi
1107
1114
 
1108
1115
  thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
1109
1116
  end
1117
+ @toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
1110
1118
  left = threads.count(&:alive?)
1111
1119
  Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
1112
1120
  @plugin_tasks.kill_leftovers
@@ -1516,6 +1524,13 @@ module Samagotchi
1516
1524
  Steer.author_fields(author, @participants.name_for(author, session: session))
1517
1525
  end
1518
1526
 
1527
+ # A participant's opening message keeps the rules it was delivered under
1528
+ # (ParticipantMode), stamped before any request carries it, so its header
1529
+ # never changes when the session's mode does.
1530
+ def participant_mode_fields(author, session: @session)
1531
+ Steer.participant_author?(author) ? { participant_mode: ParticipantMode.of(session).to_s } : {}
1532
+ end
1533
+
1519
1534
  # Why a turn stopped (TurnEndings#stop_facts).
1520
1535
  def stop_facts(event) = @turn_endings.stop_facts(event)
1521
1536
  private :stop_facts
@@ -2128,6 +2143,8 @@ module Samagotchi
2128
2143
  emit_event(turn.on_event, turn.tag(turn_started))
2129
2144
  raise image_error if image_error
2130
2145
 
2146
+ start_toolkit_suggestion_audit(turn)
2147
+
2131
2148
  # The window and the profile probes run after run_turn's probe-cancel
2132
2149
  # swap, so a Stop cuts them; a failure ends the turn as turn_failed.
2133
2150
  # The cache itself was dropped at the previous turn's end, so this
@@ -2289,7 +2306,7 @@ module Samagotchi
2289
2306
 
2290
2307
  unless turn.continue
2291
2308
  user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
2292
- MessageTime::KEY => turn.at }
2309
+ **participant_mode_fields(turn.origin.author, session: session), MessageTime::KEY => turn.at }
2293
2310
  user_message[:images] = image_refs unless image_refs.empty?
2294
2311
  turn.messages << user_message
2295
2312
  session.last_prompt = turn.prompt
@@ -2712,6 +2729,95 @@ module Samagotchi
2712
2729
  def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
2713
2730
  private :apply_toolkit!
2714
2731
 
2732
+ # Start an audit-only classifier after :turn_started. All session and
2733
+ # tool-registry reads happen here; the thread receives only this snapshot.
2734
+ def start_toolkit_suggestion_audit(turn)
2735
+ mode = Config.get("toolkit.suggest.mode").to_s
2736
+ return if mode == "off"
2737
+
2738
+ session = turn.session
2739
+ default = Toolkit.of(session.toolkit)
2740
+ path = ToolkitSuggest.log_path(session_state_dir)
2741
+ log = SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
2742
+ warn: lambda { |reason|
2743
+ Log.warn(:toolkit, "toolkit_suggest_log_failed",
2744
+ echo: "[samagotchi:toolkit] #{reason}")
2745
+ })
2746
+ # The predicate checks the session first; the kits (memory kits are
2747
+ # read from disk) and the log (read whole) only when it gets there.
2748
+ kits = nil
2749
+ candidates = nil
2750
+ return unless ToolkitSuggest.eligible?(
2751
+ session: session, prompt: turn.prompt, author: turn.origin.author,
2752
+ candidate_count: lambda {
2753
+ kits = @toolkit_binding.kits
2754
+ candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
2755
+ candidates.size
2756
+ },
2757
+ last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
2758
+ defer_turns: Config.get("toolkit.suggest.defer_turns"),
2759
+ defer_tokens: Config.get("toolkit.suggest.defer_tokens")
2760
+ )
2761
+
2762
+ # One ask at a time: an eligible turn while the last one still runs
2763
+ # goes unasked, and says so.
2764
+ if @toolkit_audit_thread&.alive?
2765
+ Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
2766
+ return
2767
+ end
2768
+
2769
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
2770
+ return unless client
2771
+
2772
+ if toolkit_judge_shares_local_host?(client.host)
2773
+ Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
2774
+ return
2775
+ end
2776
+
2777
+ # Rules, not tool lists: this runs before await_init_tasks and
2778
+ # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
2779
+ # registered yet; the report expands the rules (ToolkitSuggest.rules).
2780
+ snapshots = candidates.map do |kit|
2781
+ ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
2782
+ rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
2783
+ end.freeze
2784
+ default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
2785
+ rules: ToolkitSuggest.rules(default.resolve(kits)))
2786
+ core_tools = @tools.entries(toolkit: nil).select(&:core?).map { |entry| entry.name.dup.freeze }.freeze
2787
+ state = ToolkitSuggest.question(
2788
+ default: default, kits: kits, project: File.basename(session.project_root.to_s),
2789
+ prompt: turn.prompt
2790
+ ).last
2791
+ snapshot = ToolkitSuggest::Snapshot.new(
2792
+ session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
2793
+ default: default_snapshot, candidates: snapshots, core_tools: core_tools,
2794
+ state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
2795
+ )
2796
+ threshold = Config.get("toolkit.suggest.threshold")
2797
+ @toolkit_audit_thread = Thread.new do
2798
+ ToolkitSuggest.ask(client: client, log: log, snapshot: snapshot, threshold: threshold)
2799
+ rescue StandardError => e
2800
+ ToolkitSuggest.warn(e)
2801
+ end
2802
+ rescue StandardError => e
2803
+ ToolkitSuggest.warn(e)
2804
+ nil
2805
+ end
2806
+ private :start_toolkit_suggestion_audit
2807
+
2808
+ # D7: System 1's host is the session's model server, and a local one
2809
+ # (a single-slot Splash or llama.cpp): the same name, or two hosts:
2810
+ # entries for one server root (scheme, host, port).
2811
+ def toolkit_judge_shares_local_host?(judge_name)
2812
+ model_host, = @host_registry.host_for_model(effective_model_name)
2813
+ return false if model_host.nil? || model_host.remote?
2814
+ return true if judge_name.to_s.strip.casecmp?(model_host.name.to_s)
2815
+
2816
+ judge = @host_registry.find_entry(judge_name)
2817
+ !judge.nil? && judge.root_url.downcase == model_host.root_url.downcase
2818
+ end
2819
+ private :toolkit_judge_shares_local_host?
2820
+
2715
2821
  # The session's toolkit (see #initialize for when).
2716
2822
  def build_toolkit_binding
2717
2823
  ToolkitBinding.new(
@@ -56,7 +56,6 @@ module Samagotchi
56
56
  @announce_or_hold = announce_or_hold
57
57
  @working_set_emitted = nil
58
58
  end
59
- # rubocop:enable Metrics/ParameterLists
60
59
 
61
60
  # Always return a handler so raw kernel loop events reach the persistent
62
61
  # SessionObserver (and thus the analytics collector) even when there is no
@@ -15,6 +15,7 @@ require_relative "edit_preview"
15
15
  require_relative "system_one"
16
16
  require_relative "tool_activity"
17
17
  require_relative "turn_origin"
18
+ require_relative "participant_mode"
18
19
 
19
20
  module Samagotchi
20
21
  # An Engine's tool guardrails: who can approve (the interface), the YAML
@@ -126,12 +127,12 @@ module Samagotchi
126
127
  # The gate's core checks, in order. A delegate child's boundary comes
127
128
  # before the rules, so its ask keeps the child-boundary rule id (and
128
129
  # its once/session scopes) when a strict rule asks too. The review
129
- # boundary comes before both: a participant's turn is denied what it
130
- # may not do, never asked about it (deny is sticky, so no later vote
131
- # can turn it into a question).
130
+ # boundary comes before both: a participant's review turn is denied
131
+ # what it may not do, never asked about it.
132
132
  def checks
133
133
  rules = self.rules
134
- [@failures, review_boundary, rules.hook_asks, protected_paths,
134
+ boundary = ParticipantMode.of(@session_lookup.call) == :review ? review_boundary : nil
135
+ [@failures, boundary, rules.hook_asks, protected_paths,
135
136
  (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
136
137
  end
137
138
 
@@ -271,7 +272,14 @@ module Samagotchi
271
272
  # System 1: the ask is judged before the user sees it, and in `on` mode
272
273
  # one that passes every gate is allowed once without reaching them
273
274
  # (docs/guardrails.md, "System 1").
274
- judgment = system_one_judge(payload)
275
+ judgment = begin
276
+ system_one_judge(payload)
277
+ rescue LLM::RequestCancelled
278
+ return turn_stopped(verdict)
279
+ end
280
+ # A turn stopped while the judge was asked opens no card.
281
+ return turn_stopped(verdict) if @cancelled_lookup.call
282
+
275
283
  if judgment && judgment[:auto]
276
284
  verdict.judgment = { id: judgment[:id], p: judgment[:p] }
277
285
  verdict.settle!(:allow, decided_by: "system one")
@@ -286,6 +294,12 @@ module Samagotchi
286
294
 
287
295
  private
288
296
 
297
+ # The deny for a call whose turn was stopped before its ask reached the
298
+ # user (as Guardrails::Gate words one stopped before the vote).
299
+ def turn_stopped(verdict)
300
+ verdict.settle!(:deny, decided_by: "core", note: "The turn was stopped before the user was asked.")
301
+ end
302
+
289
303
  # The judgments log (SystemOne::Log), beside the approvals store; nil
290
304
  # while there is no store dir.
291
305
  def system_one_log
@@ -319,6 +333,8 @@ module Samagotchi
319
333
  entries: entries),
320
334
  questions: { SystemOne::ApprovalQuestion::ID => SystemOne::ApprovalQuestion.question },
321
335
  cancel_controller: @cancel_controller.call)
336
+ rescue LLM::RequestCancelled
337
+ raise
322
338
  rescue StandardError => e
323
339
  # The client already turns every failure it knows into a nil; this is
324
340
  # the unexpected one. Either way the ask goes to the user.
@@ -338,6 +354,8 @@ module Samagotchi
338
354
  latency_ms: latency_ms, decision: decision, origin: "live")
339
355
  # A judgment the log cannot hold is no judgment: the user is asked.
340
356
  { id: id, p: p, auto: auto && !id.nil? }
357
+ rescue LLM::RequestCancelled
358
+ raise
341
359
  rescue StandardError => e
342
360
  # The approval never fails because of System 1.
343
361
  Samagotchi::Log.warn(:guardrails, "system_one_judgment_failed", error: "#{e.class}: #{e.message}")
@@ -408,7 +426,7 @@ module Samagotchi
408
426
  "deny"
409
427
  end
410
428
  end
411
- system_one_log.label(id, label)
429
+ system_one_log.label(id, label, question_id: (answer.id if answer.is_a?(Samagotchi::QuestionAnswer)))
412
430
  rescue StandardError => e
413
431
  Samagotchi::Log.warn(:guardrails, "system_one_label_failed", error: "#{e.class}: #{e.message}")
414
432
  nil
@@ -69,6 +69,15 @@ module Samagotchi
69
69
  # memory-remove rule's ask, which a parent may answer.
70
70
  return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
71
71
 
72
+ chi_files?(facts)
73
+ end
74
+
75
+ # Whether an approval's paths or command name chi's own dirs (#chi_dirs)
76
+ # or CHI_TEXT, whatever its rule: the part of #protected_facts? that
77
+ # System 1's SystemOne::SelfProtect shares.
78
+ def chi_files?(facts)
79
+ return false unless facts.is_a?(Hash)
80
+
72
81
  dirs = chi_dirs
73
82
  paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
74
83
  return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
@@ -61,6 +61,7 @@ module Samagotchi
61
61
  # uses.
62
62
  SKIP_WORDS = {
63
63
  already_replayed: "already replayed",
64
+ judged_live: "already judged live",
64
65
  answered_by_parent: "answered by a parent agent",
65
66
  parent_cannot_allow: "answered by a parent that could not allow",
66
67
  unanswered: "unanswered",
@@ -73,6 +74,36 @@ module Samagotchi
73
74
  # and when the user was asked.
74
75
  Ask = Data.define(:question_id, :session_id, :approval, :label, :asked_at)
75
76
 
77
+ # What the judgments log already holds, so no ask is judged twice: the
78
+ # question ids its judgments name, with their origin (a replay's own, a
79
+ # live one's through its label), and, for live judgments with none (an ask that was
80
+ # cancelled, or a line older than the id), how many each (session, call
81
+ # digest) has. A card with no id match takes one of those (#take_live).
82
+ Judged = Data.define(:question_ids, :live_calls) do
83
+ # @param entries [Array<Hash>] SystemOne::Log#entries
84
+ def self.of(entries)
85
+ ids = entries.filter_map { |entry| [entry["question_id"], entry["origin"]] if entry["question_id"] }.to_h
86
+ live = Hash.new(0)
87
+ entries.each do |entry|
88
+ next unless entry["origin"] == "live" && entry["question_id"].nil?
89
+
90
+ live[[entry["session_id"], entry["call_digest"]]] += 1
91
+ end
92
+ new(question_ids: ids, live_calls: live)
93
+ end
94
+
95
+ # Whether a live judgment without a question id is this card's: true
96
+ # (and that judgment counted off) while one with its session and call
97
+ # digest is left.
98
+ def take_live(session_id, approval)
99
+ key = [session_id, SystemOne::ApprovalQuestion.call_fields(approval)["call_digest"]]
100
+ return false unless live_calls[key].positive?
101
+
102
+ live_calls[key] -= 1
103
+ true
104
+ end
105
+ end
106
+
76
107
  # @param argv [Array<String>] the arguments after "guardrails"
77
108
  # @param client [SystemOne::Client, nil] the judgment client; nil: the
78
109
  # one config.yml describes (the seam the specs pass a fake through)
@@ -122,7 +153,7 @@ module Samagotchi
122
153
 
123
154
  sessions_dir = @sessions_dir || Session.default_state_dir
124
155
  log = @log || SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(sessions_dir))
125
- replayed = File.exist?(log.path) ? log.entries.filter_map { |entry| entry["question_id"] if entry["origin"] == "replay" }.to_set : Set.new
156
+ judged = Judged.of(File.exist?(log.path) ? log.entries : [])
126
157
  @dry_run = options[:dry_run] ? true : false
127
158
 
128
159
  asks = []
@@ -140,7 +171,7 @@ module Samagotchi
140
171
  end
141
172
 
142
173
  entries.each do |entry|
143
- ask = ask_of(entry, name, replayed, sessions_dir)
174
+ ask = ask_of(entry, name, judged, sessions_dir)
144
175
  next if ask.nil?
145
176
 
146
177
  ask.is_a?(Symbol) ? skipped[ask] += 1 : asks << ask
@@ -168,9 +199,10 @@ module Samagotchi
168
199
  end
169
200
 
170
201
  # One card entry as an ask to judge: an Ask, a skip reason (:already_replayed,
171
- # :answered_by_parent, :parent_cannot_allow, :unanswered, :no_approval,
172
- # :no_question_id), or nil for an entry that is not a question at all.
173
- def ask_of(entry, session_id, replayed, sessions_dir)
202
+ # :judged_live, :answered_by_parent, :parent_cannot_allow, :unanswered,
203
+ # :no_approval, :no_question_id), or nil for an entry that is not a
204
+ # question at all.
205
+ def ask_of(entry, session_id, judged, sessions_dir)
174
206
  return nil unless entry.is_a?(Hash) && entry[:type] == "question"
175
207
 
176
208
  pending = entry[:pending_question]
@@ -178,7 +210,10 @@ module Samagotchi
178
210
 
179
211
  question_id = pending[:id].to_s
180
212
  return :no_question_id if question_id.empty?
181
- return :already_replayed if replayed.include?(question_id)
213
+
214
+ origin = judged.question_ids[question_id]
215
+ return origin == "replay" ? :already_replayed : :judged_live if origin
216
+ return :judged_live if judged.take_live(session_id, pending[:approval])
182
217
 
183
218
  answer = entry[:answer]
184
219
  label = if entry[:cancelled]
@@ -201,6 +201,9 @@ module Samagotchi
201
201
  # a request is sent.
202
202
  module ImageStore
203
203
  class Error < StandardError
204
+ # A ref to a file this session doesn't hold as an image: one wording
205
+ # for a turn's refs (#check_refs) and a web upload's (#ref_for).
206
+ def self.unknown_image(file) = new("unknown image #{file.to_s[0, 80]}")
204
207
  end
205
208
 
206
209
  DIR = "images"
@@ -278,7 +281,7 @@ module Samagotchi
278
281
 
279
282
  ref = symbolize(image)
280
283
  return "images are refs to uploaded files, not paths" if ref.key?(:path)
281
- return "unknown image #{ref[:file].to_s[0, 80]}" unless valid_ref?(session_dir, ref)
284
+ return Error.unknown_image(ref[:file]).message unless valid_ref?(session_dir, ref)
282
285
 
283
286
  { file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
284
287
  end
@@ -321,11 +324,11 @@ module Samagotchi
321
324
  # A ref for an image already stored in this session (a web upload),
322
325
  # rebuilt from the file itself: only its name comes from the caller.
323
326
  def self.ref_for(session_dir, file:, name: nil, source: "user")
324
- raise Error, "unknown image #{file.to_s[0, 80]}" unless valid_ref?(session_dir, { file: file })
327
+ raise Error.unknown_image(file) unless valid_ref?(session_dir, { file: file })
325
328
 
326
329
  bytes = File.binread(File.join(session_dir.to_s, file.to_s))
327
330
  info = ImageHeader.read(bytes)
328
- raise Error, "unknown image #{file}" unless info && MIME.key?(info.format) && info.width
331
+ raise Error.unknown_image(file) unless info && MIME.key?(info.format) && info.width
329
332
 
330
333
  name = File.basename(name.to_s).gsub(/[[:cntrl:]]/, "")[0, 120]
331
334
  { file: file.to_s, mime: MIME.fetch(info.format), width: info.width, height: info.height,
@@ -13,9 +13,12 @@ module Samagotchi
13
13
  # runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
14
14
  # carries its offer turn's), so any process that loads the session can
15
15
  # tell which turn is running (ReplyWait.own_turn); nil when it ran none,
16
- # or a worker from before it wrote one.
17
- InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id) do
18
- def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil) = super
16
+ # or a worker from before it wrote one. author: a participant's turn's
17
+ # "p:<id>" (nil for the owner's), which its settled record keeps, so a
18
+ # chi web restarted since still knows whose wait it is
19
+ # (ParticipantAccess#turn_author).
20
+ InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id, :author) do
21
+ def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil, author: nil) = super
19
22
 
20
23
  # A saved record (string or symbol keys), an InterruptedTurn as it is;
21
24
  # nil for anything else.
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "session"
4
4
  require_relative "version"
5
- require_relative "worker_sidecar"
5
+ require_relative "worker_link"
6
6
  require_relative "web/info_probe"
7
7
 
8
8
  module Samagotchi
@@ -21,13 +21,12 @@ module Samagotchi
21
21
 
22
22
  # @return [Array<Worker>] the workers whose Bridge answers, by session id
23
23
  def workers(state_dir: Session.default_state_dir)
24
- Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
25
- session_dir = File.dirname(path)
26
- sidecar = WorkerSidecar.live(session_dir, unlink: false)
27
- next unless sidecar
24
+ WorkerLink.each_dir(state_dir).filter_map do |session_dir|
25
+ link = WorkerLink.resolve(session_dir, unlink: false)
26
+ next unless link.live?
28
27
 
29
- Worker.new(session_id: File.basename(session_dir), version: sidecar.version,
30
- restart: sidecar.features.include?("restart"))
28
+ Worker.new(session_id: File.basename(session_dir), version: link.version,
29
+ restart: link.features.include?("restart"))
31
30
  end
32
31
  end
33
32
 
@@ -16,7 +16,7 @@ module Samagotchi
16
16
  # (debug dumps) are indented by four spaces and belong to the record above.
17
17
  module LogLine
18
18
  LEVELS = %w[DEBUG INFO WARN ERROR].freeze
19
- TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast].freeze
19
+ TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast toolkit].freeze
20
20
  INDENT = " "
21
21
 
22
22
  Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
@@ -152,7 +152,7 @@ module Samagotchi
152
152
  # @raise [Uninstaller::UninstallError] when +name+ isn't installed
153
153
  def uninstall(name, shipped_dir: SourceNormalizer::SHIPPED_DIR, force: false)
154
154
  bundle = Provenance.new(name: name).record
155
- raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless bundle
155
+ raise Uninstaller::UninstallError.not_installed(name) unless bundle
156
156
 
157
157
  shipped = File.join(shipped_dir, name)
158
158
  manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
@@ -12,7 +12,11 @@ require_relative "../model_overlay"
12
12
  module Samagotchi
13
13
  module MemoryBundle
14
14
  class Uninstaller
15
- class UninstallError < StandardError; end
15
+ class UninstallError < StandardError
16
+ # The bundle +name+ has no install record: one wording for the
17
+ # uninstall of a bundle and of a profile (Profile#uninstall).
18
+ def self.not_installed(name) = new("Bundle '#{name}' is not installed")
19
+ end
16
20
 
17
21
  # removed_files: the bundle's own hooks/ and plugin/ files (deleted);
18
22
  # trashed_files: its memory files, moved to trash_dir (Trash), nil
@@ -34,7 +38,7 @@ module Samagotchi
34
38
  def run
35
39
  provenance = Provenance.new(name: @name)
36
40
  bundle = provenance.record
37
- raise UninstallError, "Bundle '#{@name}' is not installed" unless bundle
41
+ raise UninstallError.not_installed(@name) unless bundle
38
42
 
39
43
  # The scope from the flag, else the record's
40
44
  target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
@@ -51,6 +51,17 @@ module Samagotchi
51
51
  found && text(found)
52
52
  end
53
53
 
54
+ # A model-facing hint: report the name collision without giving the model
55
+ # a command that could move another live clone's memories.
56
+ def model_message(env: ENV, cwd: Dir.pwd)
57
+ found = find(env: env, cwd: cwd)
58
+ return unless found
59
+
60
+ name = File.basename(found.computed).sub(/_\h{8}\z/, "")
61
+ "a folder with the same project name (#{name}) exists with another path's hash; ask the user whether its " \
62
+ "memories belong to this project before using them."
63
+ end
64
+
54
65
  def text(found)
55
66
  computed = found.computed
56
67
  lead = "no project memories in #{computed}"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The rules for a participant's turn. P0 keeps every participant in review
5
+ # mode; this is the single resolver for the later mode split.
6
+ module ParticipantMode
7
+ module_function
8
+
9
+ def of(_session) = :review
10
+ end
11
+ end
@@ -24,9 +24,11 @@ module Samagotchi
24
24
  # A call not listed gets no floor check: it is in the first tag (v0.2.0:
25
25
  # chi.command/tool/on/service/init, ctx.settings, ctx.sessions,
26
26
  # ctx.ask_model, ctx.notify, ctx.card, ctx.messages…). A member of
27
- # one (ctx.sessions.children) is listed when it came later. Keys have
28
- # no "?" (API_CALL drops it). A keyword added later (ctx.ask_model's
29
- # model: in 0.80.0, ctx.sessions.send's delivery: in 0.64.0) has none.
27
+ # one (ctx.sessions.children) is listed when it came later; fork, send
28
+ # and read are in v0.2.0. Keys have no "?" (API_CALL drops it). A
29
+ # keyword added later is "call(keyword:)", the call's receiver as the
30
+ # plugin writes it (Check#keyword_calls: `ctx.sessions.send(…,
31
+ # delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
30
32
  # Events aren't listed: chi.on takes any symbol and an older chi never
31
33
  # fires one it doesn't know (harmless; chi bundle check warns on it),
32
34
  # and a manifest hook's events: list (BundleHook) needs the bundle's
@@ -42,7 +44,8 @@ module Samagotchi
42
44
  "chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
43
45
  "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
44
46
  "ctx.usage" => "0.100.0", "ctx.sessions.stop" => "0.34.0", "ctx.turn_author" => "0.106.0",
45
- "ctx.sessions.review" => "0.107.0"
47
+ "ctx.sessions.review" => "0.107.0", "ctx.sessions.send(delivery:)" => "0.64.0",
48
+ "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0"
46
49
  }.freeze
47
50
 
48
51
  # @param bundle [String]
@@ -309,17 +312,20 @@ module Samagotchi
309
312
  # a bundle has) with +add+ declared and +remove+ left out, and
310
313
  # +prompt+: text the sessions on it get in their system prompt (nil:
311
314
  # its base's, "" none; at most Toolkit::PROMPT_MAX characters);
312
- # +groups+: MCP source group names/globs (nil inherits its base).
315
+ # +groups+: MCP source group names/globs (nil inherits its base);
316
+ # +description+: optional classifier text, not included in prompts
317
+ # (at most Toolkit::DESCRIPTION_MAX characters).
313
318
  # A
314
319
  # name chi (full, lean, child) or another bundle has is a load error,
315
320
  # and so is a prompt that isn't text or is too long; a base that
316
321
  # doesn't resolve leaves the sessions on it with every tool
317
322
  # (Toolkit#resolve).
318
- def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
323
+ def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil, description: nil)
319
324
  name = name.to_s
320
325
  raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
321
326
 
322
- problem = Toolkit.prompt_error(prompt)
327
+ problem = Toolkit.prompt_error(prompt) || Toolkit.description_error(description) ||
328
+ (Toolkit.groups_error(groups) unless groups == :all)
323
329
  raise ArgumentError, "toolkit #{name}: #{problem}" if problem
324
330
  raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
325
331
  if Toolkit::BUILTIN_KITS.key?(name)
@@ -330,7 +336,7 @@ module Samagotchi
330
336
  raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
331
337
 
332
338
  @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
333
- groups: groups, source: @bundle)
339
+ groups: groups, source: @bundle, description: description)
334
340
  nil
335
341
  end
336
342
 
@@ -3,7 +3,7 @@
3
3
  require_relative "../client_id"
4
4
  require_relative "../delivery"
5
5
  require_relative "../session"
6
- require_relative "../bridge_client"
6
+ require_relative "../worker_link"
7
7
  require_relative "../bridge/turn_accumulator"
8
8
  require_relative "../tools/delegate"
9
9
  require_relative "../children_status"
@@ -27,6 +27,11 @@ module Samagotchi
27
27
  # A fork, send, read or stop that couldn't be done; the message says why.
28
28
  class Error < StandardError; end
29
29
 
30
+ # What starting a session can be refused by, answered as Error with
31
+ # chi's own message: a model that is gone, a toolkit the config refuses,
32
+ # a bad argument. #review adds its own: a subject it won't take.
33
+ REFUSED = [ModelProfile::MissingModel, Toolkit::Refused, ArgumentError].freeze
34
+
30
35
  # @param host [Host] session_id, cwd, model_name and state_dir
31
36
  def initialize(host)
32
37
  @host = host
@@ -44,7 +49,9 @@ module Samagotchi
44
49
  # @param prompt [String, nil] the child's first turn
45
50
  # @return [String] the child's id
46
51
  # @raise [Error] no session yet, a scratch session (the child would
47
- # outlive it), or too many children running
52
+ # outlive it), too many children running, or the model or toolkit the
53
+ # child starts on being refused (a config kit with an invalid groups:,
54
+ # a model that is gone)
48
55
  def fork(messages:, title: nil, prompt: nil)
49
56
  parent_id = @host.session_id.call or raise Error, "this session has no id yet"
50
57
  raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
@@ -60,6 +67,8 @@ module Samagotchi
60
67
  setup: @host.setup&.call || SessionSetup.new
61
68
  )
62
69
  child.id
70
+ rescue *REFUSED => e
71
+ raise Error, e.message
63
72
  end
64
73
 
65
74
  # A review of this session (ReviewStart.fork, chi review --from): a new
@@ -81,7 +90,7 @@ module Samagotchi
81
90
 
82
91
  ReviewStart.fork(from: source, subjects: Array(subjects), seed: seed, state_dir: state_dir,
83
92
  recap_wait: recap_wait).id
84
- rescue ReviewStart::Refused, ModelProfile::MissingModel, ArgumentError => e
93
+ rescue *REFUSED, ReviewStart::Refused => e
85
94
  raise Error, e.message
86
95
  end
87
96
 
@@ -212,7 +221,7 @@ module Samagotchi
212
221
  end
213
222
 
214
223
  def live_snapshot(sid, state_dir)
215
- BridgeClient.discover(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
224
+ WorkerLink.client(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
216
225
  rescue StandardError
217
226
  nil
218
227
  end
@@ -87,6 +87,19 @@ module Samagotchi
87
87
  nil
88
88
  end
89
89
 
90
+ # Whether +pid+ is stopped (SIGSTOP, Ctrl-Z; t: under a tracer): ps's
91
+ # state starts with T or t on macOS and Linux alike. Such a process
92
+ # takes no signal but KILL and answers no socket until it is continued.
93
+ # false when it isn't running or ps can't say.
94
+ def stopped?(pid)
95
+ return false unless signalable?(pid)
96
+
97
+ out, status = Open3.capture2("ps", "-o", "state=", "-p", pid.to_s, err: File::NULL)
98
+ status.success? && out.lstrip.start_with?("T", "t")
99
+ rescue SystemCallError, IOError
100
+ false
101
+ end
102
+
90
103
  # Field 22 (starttime); the command name (field 2) may hold spaces and
91
104
  # parentheses, so the fields are counted after its last ")".
92
105
  def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
@@ -135,7 +135,7 @@ module Samagotchi
135
135
  # route does). @return [Boolean] delivered
136
136
  def test(sub)
137
137
  result = client_for(sub).deliver(sub, Web::Push::TEST_PAYLOAD, ttl: Web::Push::TEST_TTL, urgency: "high")
138
- words = "#{result.status} #{result.code}#{" (#{result.ms} ms)" if result.ms}"
138
+ words = [result.status, result.code, ("(#{result.ms} ms)" if result.ms)].reject { |part| part.to_s.empty? }.join(" ")
139
139
  words += ": #{result.error}" if result.error && !result.ok?
140
140
  if result.status == :gone
141
141
  @store.remove(sub.id)