samagotchi 0.159.0 → 0.161.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -52,12 +52,15 @@ module Samagotchi
52
52
  end
53
53
 
54
54
  # Ring +child+'s parent; best effort (a failure is logged, never
55
- # raised). A parent with no worker is woken on a thread
56
- # (SessionManager.wake_for_report decides whether it may be).
55
+ # raised): a ring delivery (SessionInbox.deliver :ring). The ring file
56
+ # goes to the parent parent.json names; its WakePolicy decides whether
57
+ # a worker starts (a parent with none, idle, in wake mode), and for an
58
+ # archived parent with a later link, that link (the one whose
59
+ # ChildReports reads the ring). A start runs on a thread.
57
60
  # @param child [Session] a delegate child
58
61
  # @param why [String] "turn_end", "question" or "crash"
59
- # @param wake [#call, nil] (parent id) wakes a parent with no worker;
60
- # nil: SessionManager.wake_for_report on a thread
62
+ # @param wake [#call, nil] (parent id) starts the worker the policy
63
+ # decided on; nil: SessionManager.start_for_inbox on a thread
61
64
  # @return [String, nil] the ring file, nil when none was written
62
65
  # The parent is read fresh: a continue may have moved the child to the
63
66
  # chain's new link (Session::PARENT_FILE) since its worker loaded it.
@@ -70,33 +73,36 @@ module Samagotchi
70
73
 
71
74
  parent_id = Session.parent_override(child.id, state_dir: state_dir) || child.parent_id
72
75
  return nil unless parent_id
73
- # A deleted parent gets no orphan dir.
74
- return nil unless Session.exist?(parent_id, state_dir: state_dir)
75
76
 
76
- parent_dir = Session.session_dir(parent_id, state_dir: state_dir)
77
- path = SessionInbox.write_ring(parent_dir, child_id: child.id, why: why)
78
- Log.info(:worker, "delegate_rang", parent: parent_id[0, 8], why: why)
79
- wake_parent(parent_id, state_dir: state_dir, wake: wake) if mode == "wake"
77
+ # A deleted parent gets no orphan dir: the delivery refuses it (:gone).
78
+ path = nil
79
+ SessionInbox.deliver(:ring, parent_id, state_dir: state_dir, wake: mode == "wake",
80
+ start: starter(state_dir, wake)) do |at|
81
+ path = SessionInbox.write_ring(Session.session_dir(at, state_dir: state_dir), child_id: child.id, why: why)
82
+ Log.info(:worker, "delegate_rang", parent: at[0, 8], why: why)
83
+ true
84
+ end
80
85
  path
81
86
  rescue StandardError => e
82
87
  Log.warn(:worker, "delegate_ring_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
83
88
  nil
84
89
  end
85
90
 
86
- # A live parent worker sees the ring on its next pass; one with no
87
- # worker is woken.
88
- def wake_parent(parent_id, state_dir:, wake:)
89
- return if SessionManager.session_owner(parent_id, state_dir: state_dir)
90
-
91
- return wake.call(parent_id) if wake
92
-
93
- thread = Thread.new do
94
- SessionManager.wake_for_report(parent_id, state_dir: state_dir)
95
- rescue StandardError => e
96
- Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
91
+ # The ring delivery's start: +wake+, or SessionManager.start_for_inbox
92
+ # on a thread (#await_wakes joins it), so the child's turn doesn't wait
93
+ # for a spawn.
94
+ def starter(state_dir, wake)
95
+ return ->(id) { wake.call(id).then { :woken } } if wake
96
+
97
+ lambda do |id|
98
+ thread = Thread.new do
99
+ SessionManager.start_for_inbox(id, state_dir: state_dir, event: :delegate_woke_parent)
100
+ rescue StandardError => e
101
+ Log.warn(:worker, "delegate_wake_failed", parent: id[0, 8], error: e.class.name, msg: e.message)
102
+ end
103
+ @wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
104
+ :woken
97
105
  end
98
- @wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
99
- thread
100
106
  end
101
107
 
102
108
  # Waits up to +timeout+ for the parent wakes this process started: a
@@ -9,6 +9,8 @@ module Samagotchi
9
9
  # A web tab (web:<random>, per tab); web:restart, the page's Restart.
10
10
  WEB_PREFIX = "web:"
11
11
  WEB_RESTART = "web:restart"
12
+ # A web page's first prompt when creating a session.
13
+ WEB_CREATE = "web:create"
12
14
  # An attached chi TUI: tui:<pid>.
13
15
  TUI_PREFIX = "tui:"
14
16
  # chi send's turns and commands.
@@ -98,6 +100,10 @@ module Samagotchi
98
100
  # Whether +client_id+ is a participant's (review:).
99
101
  def review?(client_id) = client_id.to_s.start_with?(REVIEW_PREFIX)
100
102
 
103
+ # The client id a delegating parent's task and follow-ups carry:
104
+ # delegate:<parent8>.
105
+ def delegate(parent_id) = "#{DELEGATE_PREFIX}#{parent_id.to_s[0, 8]}"
106
+
101
107
  # The client id a durable schedule's turn carries: system:schedule:<id>.
102
108
  def schedule(schedule_id) = "#{SCHEDULE_PREFIX}#{schedule_id}"
103
109
 
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_id"
4
+
5
+ module Samagotchi
6
+ # Who may send a session command as a message (a turn's text: chi send -m
7
+ # "/model x", a web page's first message, an input file, a session's
8
+ # first prompt), and what a line from anyone else is. The one rule the
9
+ # two intakes ask (WorkerInbound#queue_as_command, Bridge#handle_post_turn)
10
+ # and the senders that refuse up front (Tools::Delegate, Schedules#add).
11
+ # No Engine needed: a schedule's tick and the CLI have none.
12
+ #
13
+ # From a sender that may not, a /word line is plain prompt text (the
14
+ # model reads it) and a ! line (a shell command: SessionCommands#shell
15
+ # runs it with no Gate) is refused with REFUSED, never run.
16
+ module CommandIntake
17
+ # A shell line's prefix: !<cmd>, and !rollback (SessionCommands::
18
+ # SHELL_BANG_PREFIX, which a spec keeps the same: SessionCommands is too
19
+ # big a load for the bundle test kit's budget).
20
+ SHELL_PREFIX = "!"
21
+
22
+ # The one text a refused shell line gets: the Bridge's 403 detail, the
23
+ # worker's command_ran error, a delegate's or a schedule's refusal.
24
+ REFUSED = "a message that starts with ! is a shell command, which only a person runs as a message " \
25
+ "(a web tab, a chi TUI, chi send); from a delegate, a schedule or another automatic sender it is not run. " \
26
+ "Say what to run in words instead (\"run `ls`\"), or leave out the leading !"
27
+
28
+ # The Bridge's error code for it (403).
29
+ REFUSED_ERROR = "command_not_permitted"
30
+
31
+ # chi answer's marker (a parent agent's /continue yes and
32
+ # /parent-answers; Worker#as_parent? holds those to its own rules): any
33
+ # command, with chi send's trust.
34
+ ALLOWED_IDS = [ClientId::CLI_ANSWER].freeze
35
+
36
+ module_function
37
+
38
+ # Whether a message from +client_id+ may run as a session command: a
39
+ # human's input (ClientId.human?: a web tab, a chi TUI, chi send, no
40
+ # client) but a participant's (review:, refused on its own), and chi
41
+ # answer's. A delegate, a child's report, a context or task wake, a
42
+ # schedule, a reminder, a relay and a plugin may not (the bundles that
43
+ # send, coordinator and skills, send prose).
44
+ def allowed?(client_id)
45
+ ALLOWED_IDS.include?(client_id.to_s) || (ClientId.human?(client_id) && !ClientId.review?(client_id))
46
+ end
47
+
48
+ # Whether +text+ would be a shell line (!<cmd>, !rollback) if a command:
49
+ # what a delegate's task or a schedule's prompt may not start with.
50
+ def shell_line?(text) = text.to_s.lstrip.start_with?(SHELL_PREFIX)
51
+
52
+ # Whether the command line +line+ from +client_id+ is a shell line that
53
+ # must not run.
54
+ def refused?(line, client_id) = shell_line?(line) && !allowed?(client_id)
55
+ end
56
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/version"
4
+ require_relative "version"
5
+
6
+ module Samagotchi
7
+ # The compatibility decision for state and bridge data from a chi version.
8
+ module Compat
9
+ module_function
10
+
11
+ def verdict(worker_version:, version: VERSION, worker_protocol: nil, protocol: nil)
12
+ return :refused if protocol && worker_protocol.to_i > protocol
13
+
14
+ worker_version = worker_version.to_s
15
+ version = version.to_s
16
+ return :restart_suggested if worker_version.empty? || version.empty?
17
+ return :restart_suggested unless Gem::Version.correct?(worker_version) && Gem::Version.correct?(version)
18
+
19
+ worker = Gem::Version.new(worker_version)
20
+ current = Gem::Version.new(version)
21
+ return :restart_suggested if worker < previous_minor(current)
22
+
23
+ :ok
24
+ end
25
+
26
+ def previous_minor(version)
27
+ major, minor = version.segments.first(2)
28
+ Gem::Version.new("#{major}.#{[(minor || 0) - 1, 0].max}.0")
29
+ end
30
+ private_class_method :previous_minor
31
+ end
32
+ end
@@ -59,17 +59,19 @@ module Samagotchi
59
59
  VERDICT = /\bVERDICT:?\s*([A-Z]+)(?![A-Za-z|])/
60
60
 
61
61
  # @param project_root [String, nil] Session#project_root
62
+ # @param project_id [String, nil] Session#project_id
62
63
  # @param peer_sources [PeerSources, nil] the peer bundles' declared sources
63
64
  # @param peer_turns [#call, nil] the peer wake turns in a row
64
65
  # (WorkerWakes#peer_turns); 0 or nil: no source is a peer's
65
- def initialize(session_id:, state_dir:, project_root:, peer_sources: nil, peer_turns: nil)
66
+ def initialize(session_id:, state_dir:, project_root:, project_id: nil, peer_sources: nil, peer_turns: nil)
66
67
  @session_id = session_id
67
68
  @peer_sources = peer_sources
68
69
  @peer_turns = peer_turns
69
70
  @state_dir = state_dir
70
71
  @own = ContextSources.session_location(session_id, state_dir: state_dir)
71
- @project = ContextSources.project_location_for(project_root, state_dir: state_dir)
72
+ @project = ContextSources.project_location_for(project_root, project_id: project_id, state_dir: state_dir)
72
73
  @project_root = project_root
74
+ @project_id = project_id
73
75
  @signature = nil
74
76
  end
75
77
 
@@ -79,7 +81,8 @@ module Samagotchi
79
81
  return nil if signature == @signature
80
82
 
81
83
  subs = @own.subscriptions
82
- visible = ContextSources.attached(@session_id, project_root: @project_root, state_dir: @state_dir)
84
+ visible = ContextSources.attached(@session_id, project_root: @project_root, project_id: @project_id,
85
+ state_dir: @state_dir)
83
86
  deliveries = visible.filter_map do |attached|
84
87
  next if @own.muted?(attached.name)
85
88
 
@@ -56,11 +56,12 @@ module Samagotchi
56
56
  # @param show_card [#call] (**card) → shows it; false/nil: it wasn't
57
57
  # shown (the UI is on another session), so no carded line
58
58
  # @return [Symbol, nil] :attached, :carded or nil
59
- def after_judged(job:, fields:, settings:, project_root:, state_dir:, log:, show_card:)
59
+ def after_judged(job:, fields:, settings:, project_root:, state_dir:, log:, show_card:, project_id: nil)
60
60
  return nil unless settings.mode == "on" && fields && fields["stated"]
61
61
  return nil unless %w[attach offer].include?(fields["would"])
62
62
 
63
- candidate = proposed(job.session_id, job.name, project_root: project_root, state_dir: state_dir) or return nil
63
+ candidate = proposed(job.session_id, job.name, project_root: project_root, project_id: project_id,
64
+ state_dir: state_dir) or return nil
64
65
  if fields["would"] == "attach"
65
66
  judge_attach(candidate, job: job, p: fields["p"], state_dir: state_dir, log: log, show_card: show_card)
66
67
  else
@@ -72,8 +73,9 @@ module Samagotchi
72
73
  end
73
74
 
74
75
  # The candidate +name+ when it is still proposed in the session.
75
- def proposed(session_id, name, project_root:, state_dir:)
76
- rows = ContextCandidates.with_status(session_id, project_root: project_root, state_dir: state_dir)
76
+ def proposed(session_id, name, project_root:, state_dir:, project_id: nil)
77
+ rows = ContextCandidates.with_status(session_id, project_root: project_root, project_id: project_id,
78
+ state_dir: state_dir)
77
79
  rows.find { |candidate, status| candidate.name == name && status == "proposed" }&.first
78
80
  end
79
81
 
@@ -128,7 +130,8 @@ module Samagotchi
128
130
  end
129
131
 
130
132
  def find(session, name, state_dir:)
131
- ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: state_dir)
133
+ ContextCandidates.with_status(session.id, project_root: session.project_root, project_id: session.project_id,
134
+ state_dir: state_dir)
132
135
  .find { |candidate, _status| candidate.name == name }
133
136
  end
134
137
 
@@ -58,18 +58,19 @@ module Samagotchi
58
58
 
59
59
  # The session's candidates with their status, by name.
60
60
  # @return [Array<Array(ContextSources::Candidate, String)>]
61
- def with_status(session_id, project_root:, state_dir: nil)
61
+ def with_status(session_id, project_root:, project_id: nil, state_dir: nil)
62
62
  own = ContextSources.session_location(session_id, state_dir: state_dir)
63
63
  candidates = own.candidates
64
64
  return [] if candidates.empty?
65
65
 
66
- attached = ContextSources.attached(session_id, project_root: project_root, state_dir: state_dir, shadowed: true)
66
+ attached = ContextSources.attached(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir,
67
+ shadowed: true)
67
68
  candidates.map { |candidate| [candidate, status(candidate, own: own, attached: attached)] }
68
69
  end
69
70
 
70
71
  # The candidates section of /context, nil for none.
71
- def listing(session_id, project_root:, state_dir: nil)
72
- rows = with_status(session_id, project_root: project_root, state_dir: state_dir)
72
+ def listing(session_id, project_root:, project_id: nil, state_dir: nil)
73
+ rows = with_status(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir)
73
74
  return nil if rows.empty?
74
75
 
75
76
  lines = rows.map { |candidate, state| "- #{candidate.name} · #{state} · #{candidate.title}" }
@@ -171,8 +172,8 @@ module Samagotchi
171
172
  end
172
173
 
173
174
  # The proposed candidates (no source of theirs attached, not declined).
174
- def proposed(session_id, project_root:, state_dir: nil)
175
- with_status(session_id, project_root: project_root, state_dir: state_dir)
175
+ def proposed(session_id, project_root:, project_id: nil, state_dir: nil)
176
+ with_status(session_id, project_root: project_root, project_id: project_id, state_dir: state_dir)
176
177
  .filter_map { |candidate, state| candidate if state == "proposed" }
177
178
  end
178
179
 
@@ -194,7 +195,8 @@ module Samagotchi
194
195
  # The earlier owner prompts too: a proposal that came while one ran
195
196
  # (a one-shot's plugins never see a running turn's prompt).
196
197
  texts = owner_texts(session.messages) + (prompt.is_a?(String) ? [prompt] : [])
197
- found = hits(texts, proposed(session.id, project_root: session.project_root, state_dir: state_dir))
198
+ found = hits(texts, proposed(session.id, project_root: session.project_root, project_id: session.project_id,
199
+ state_dir: state_dir))
198
200
  names = found.filter_map { |hit| attach(hit, own: own, state_dir: state_dir) }
199
201
  first_fetch(own, unfetched(own, names), cwd: session.working_directory, seconds: seconds, cancelled: cancelled)
200
202
  names
@@ -213,7 +215,8 @@ module Samagotchi
213
215
  names = own.candidates.map(&:name)
214
216
  return 0 if names.empty?
215
217
 
216
- absorber = ContextAbsorber.new(session_id: session.id, state_dir: state_dir, project_root: session.project_root)
218
+ absorber = ContextAbsorber.new(session_id: session.id, state_dir: state_dir, project_root: session.project_root,
219
+ project_id: session.project_id)
217
220
  batch = absorber.pending or return 0
218
221
  picked = batch.deliveries.select { |delivery| names.include?(delivery.name) }
219
222
  picked.each { |delivery| engine.add_context_note(session, delivery.note) if delivery.note }
@@ -65,7 +65,16 @@ module Samagotchi
65
65
  # its prompts. Never raises.
66
66
  # @return [Integer] how many lines were dropped
67
67
  def purge_session(session_id, state_dir: nil)
68
- log_for(state_dir).prune { |line| line["session_id"] != session_id.to_s }
68
+ purge_sessions([session_id], state_dir: state_dir)
69
+ end
70
+
71
+ # Many sessions' lines, pruned in one pass through the log. Never raises.
72
+ # @return [Integer] how many lines were dropped
73
+ def purge_sessions(session_ids, state_dir: nil)
74
+ ids = Array(session_ids).map(&:to_s).reject(&:empty?).to_h { |id| [id, true] }
75
+ return 0 if ids.empty?
76
+
77
+ log_for(state_dir).prune { |line| !ids[line["session_id"]] }
69
78
  rescue StandardError => e
70
79
  Log.warn(:context, "context_candidates_log_failed", error: e.class.name, msg: e.message.to_s[0, 200])
71
80
  0
@@ -80,7 +80,12 @@ module Samagotchi
80
80
  # Where a command acts: a session (its own sources, then its project's)
81
81
  # or a project's sources. +label+ names it in the output, +cwd+ is
82
82
  # where its session's sources run (ContextPoller's choice).
83
- Target = Data.define(:session_id, :project_root, :cwd, :label) do
83
+ # +project_id+ is the project's id (Session#project_id, a marked
84
+ # project's ProjectLookup#id): with +project_root+ it keys the project's
85
+ # sources (MemoryPaths.key_for).
86
+ Target = Data.define(:session_id, :project_root, :cwd, :label, :project_id) do
87
+ def initialize(session_id:, project_root:, cwd:, label:, project_id: nil) = super
88
+
84
89
  def project? = session_id.nil?
85
90
  end
86
91
 
@@ -234,7 +239,7 @@ module Samagotchi
234
239
  end
235
240
 
236
241
  @stdout.puts("#{target.label} #{name}: #{pushed.revision == pushed.before ? "unchanged" : "new text"} " \
237
- "(#{pushed.revision[0, 12]})")
242
+ "(#{pushed.revision[0, 12]})#{" · #{pushed.wake_line}" if pushed.wake_line}")
238
243
  true
239
244
  end
240
245
  end
@@ -421,14 +426,15 @@ module Samagotchi
421
426
  root = found.root
422
427
  # Its sources run in the root, or in this checkout when the root is a
423
428
  # git dir (ContextSources.project_cwd).
424
- Target.new(session_id: nil, project_root: root, cwd: found.top || @cwd,
429
+ Target.new(session_id: nil, project_root: root, project_id: found.id, cwd: found.top || @cwd,
425
430
  label: "project #{File.basename(root)}")
426
431
  end
427
432
 
428
433
  def session_target(given)
429
434
  id = @access.resolve_id(given)
430
435
  session = @access.show(id)
431
- Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory, label: id[0, 8])
436
+ Target.new(session_id: id, project_root: session.project_root, project_id: session.project_id,
437
+ cwd: session.working_directory, label: id[0, 8])
432
438
  rescue ArgumentError => e
433
439
  error_line("#{command_name}: #{e.message}")
434
440
  nil
@@ -70,13 +70,18 @@ module Samagotchi
70
70
  reserve = CARRY_MORE_TRUNCATION.bytesize
71
71
  text_limit = [CARRY_SOURCE_MAX_BYTES, remaining - heading.bytesize - reserve].min
72
72
  if text_limit < CARRY_SOURCE_TRUNCATION.bytesize
73
+ # Not even room for a trimmed source: this one is dropped, so the
74
+ # note says text is missing even when it was the last.
73
75
  truncated = true
74
76
  break
75
77
  end
76
78
 
77
79
  bounded, source_truncated = bound_source_text(text, text_limit)
78
80
  note << heading << bounded
79
- if source_truncated
81
+ # Only a cut by the note's own budget ends the note: a source trimmed to
82
+ # CARRY_SOURCE_MAX_BYTES still leaves room for the next one, and one that
83
+ # fits the smaller room left is carried whole.
84
+ if source_truncated && text_limit < CARRY_SOURCE_MAX_BYTES
80
85
  truncated = more
81
86
  break
82
87
  end
@@ -23,12 +23,14 @@ module Samagotchi
23
23
  TICK_SECONDS = 5
24
24
 
25
25
  # @param project_root [String, nil] Session#project_root
26
+ # @param project_id [String, nil] Session#project_id
26
27
  # @param cwd [String] the session's folder (a session source runs there)
27
28
  # @param on_change [#call] after a fetch brought a new text or failed
28
- def initialize(session_id:, state_dir:, project_root:, cwd:, on_change:, tick: TICK_SECONDS)
29
+ def initialize(session_id:, state_dir:, project_root:, cwd:, on_change:, project_id: nil, tick: TICK_SECONDS)
29
30
  @session_id = session_id
30
31
  @state_dir = state_dir
31
32
  @project_root = project_root
33
+ @project_id = project_id
32
34
  @cwd = cwd
33
35
  @on_change = on_change
34
36
  @tick = tick
@@ -56,7 +58,8 @@ module Samagotchi
56
58
  # One pass (the thread's loop body; specs call it).
57
59
  # @param first [Boolean] the worker's first pass
58
60
  def poll(first: false)
59
- ContextSources.attached(@session_id, project_root: @project_root, state_dir: @state_dir).each do |attached|
61
+ ContextSources.attached(@session_id, project_root: @project_root, project_id: @project_id,
62
+ state_dir: @state_dir).each do |attached|
60
63
  break if @stopping
61
64
  next if attached.source.push? || @own.muted?(attached.name)
62
65
 
@@ -634,19 +634,21 @@ module Samagotchi
634
634
 
635
635
  # The project a session's sources come from: nil when it is in no repo.
636
636
  # @param project_root [String, nil] Session#project_root
637
- def project_location_for(project_root, state_dir: nil)
637
+ # @param project_id [String, nil] Session#project_id (a marked project's
638
+ # id keys its folder: MemoryPaths.key_for)
639
+ def project_location_for(project_root, project_id: nil, state_dir: nil)
638
640
  return nil if project_root.to_s.empty?
639
641
 
640
- project_location(MemoryPaths.key_for_root(project_root), state_dir: state_dir)
642
+ project_location(MemoryPaths.key_for(root: project_root, id: project_id), state_dir: state_dir)
641
643
  end
642
644
 
643
645
  # A session's sources: its own, then the project's it doesn't shadow
644
646
  # (+shadowed+ lists those too, marked).
645
647
  # @return [Array<Attached>]
646
- def attached(session_id, project_root:, state_dir: nil, shadowed: false)
648
+ def attached(session_id, project_root:, project_id: nil, state_dir: nil, shadowed: false)
647
649
  own = session_location(session_id, state_dir: state_dir)
648
650
  list = own.sources.map { |source| Attached.new(source: source, location: own, shadowed: false) }
649
- project = project_location_for(project_root, state_dir: state_dir)
651
+ project = project_location_for(project_root, project_id: project_id, state_dir: state_dir)
650
652
  return list unless project
651
653
 
652
654
  names = list.map(&:name)
@@ -51,6 +51,18 @@ module Samagotchi
51
51
  @session_observer.synchronize(&)
52
52
  end
53
53
 
54
+ def reserve_enqueued_id(id) = @turn_state.reserve_enqueued_id(id)
55
+
56
+ def claim_enqueued_id(id) = @turn_state.claim_enqueued_id(id)
57
+
58
+ def finish_enqueued_id(id) = @turn_state.finish_enqueued_id(id)
59
+
60
+ def forget_enqueued_id(id) = @turn_state.forget_enqueued_id(id)
61
+
62
+ def drop_enqueued_id(id) = @turn_state.drop_enqueued_id(id)
63
+
64
+ def enqueued_id?(id) = @turn_state.enqueued_id?(id)
65
+
54
66
  # The working set's :working_set_updated, announced between turns
55
67
  # (after a command changed what the next request sends), when it
56
68
  # differs from the last one emitted (#emit_working_set). Under the
@@ -79,8 +91,7 @@ module Samagotchi
79
91
  announce({ type: :guardrail_warning, message: message }) if message
80
92
  plugins = @plugin_failures.message
81
93
  announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
82
- memory = MemoryOrphans.message
83
- announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
94
+ memory_load_warnings.each { |event| announce(event) }
84
95
  @extension_load.held_events.each do |event|
85
96
  announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
86
97
  end
@@ -88,6 +99,18 @@ module Samagotchi
88
99
  nil
89
100
  end
90
101
 
102
+ # The session-start memory warnings (label "memory"), for both load
103
+ # announcements (a worker's, a one-shot's first turn): an orphaned
104
+ # project memory folder (MemoryOrphans), a copied project sharing its
105
+ # id (ProjectIdClash, which also claims the id folder for this root).
106
+ # @return [Array<Hash>] the :guardrail_warning events
107
+ def memory_load_warnings
108
+ [MemoryOrphans.message, ProjectIdClash.check&.message].compact.map do |message|
109
+ { type: :guardrail_warning, message: message, label: "memory" }
110
+ end
111
+ end
112
+ private :memory_load_warnings
113
+
91
114
  # Run the block holding back the cards and between-turns notices it
92
115
  # announces on this thread, so the caller announces them after its own
93
116
  # event (a worker's command: its command_ran first, so a UI prints the
@@ -46,8 +46,8 @@ module Samagotchi
46
46
 
47
47
  # Once per Engine, on its first turn: what failed to load, so every UI
48
48
  # (the attached TUI, the web, a one-shot's stderr) shows it: the guardrails, then the plugins
49
- # (label: "plugins"; the UIs say guardrails without one), an orphaned
50
- # project memory folder (label: "memory"), then the notices and cards plugins showed as they loaded.
49
+ # (label: "plugins"; the UIs say guardrails without one), the memory
50
+ # warnings (label: "memory": #memory_load_warnings), then the notices and cards plugins showed as they loaded.
51
51
  def announce_guardrail_failures(on_event)
52
52
  return if @guardrail_failures_announced
53
53
 
@@ -56,8 +56,7 @@ module Samagotchi
56
56
  emit_event(on_event, { type: :guardrail_warning, message: message }) if message
57
57
  plugins = @plugin_failures.message
58
58
  emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
59
- memory = MemoryOrphans.message
60
- emit_event(on_event, { type: :guardrail_warning, message: memory, label: "memory" }) if memory
59
+ memory_load_warnings.each { |event| emit_event(on_event, event) }
61
60
  @extension_load.held_events.each { |event| emit_event(on_event, event) }
62
61
  end
63
62
  private :announce_guardrail_failures
@@ -166,7 +166,7 @@ module Samagotchi
166
166
  end,
167
167
  state_dir: -> { session_state_dir },
168
168
  scratch: -> { @scratch },
169
- setup: -> { SessionSetup.of(@session) },
169
+ setup: -> { SessionSetup.of(@session, forked: true) },
170
170
  # The files whose text the built system prompt holds; [] until a
171
171
  # build and after a rebuild that drops them (SystemPrompt#prompt_files
172
172
  # returns [] itself: the || keeps a nil builder safe).
@@ -126,8 +126,8 @@ module Samagotchi
126
126
  # Cancel the currently running turn, if any.
127
127
  # @param reason [Symbol] cancellation reason
128
128
  # @return [Boolean] whether a cancellation was triggered
129
- def cancel_current_turn!(reason = :manual)
130
- @turn_state.cancel!(reason)
129
+ def cancel_current_turn!(reason = :manual, turn_id: nil)
130
+ @turn_state.cancel!(reason, turn_id: turn_id)
131
131
  end
132
132
  end
133
133
  end
@@ -83,6 +83,7 @@ require_relative "guardrails"
83
83
  require_relative "reminder_store"
84
84
  require_relative "reminder_queue"
85
85
  require_relative "memory_orphans"
86
+ require_relative "project_id_clash"
86
87
  require_relative "tools/memory"
87
88
  require_relative "muted_memories"
88
89
  require_relative "used_memories"
@@ -17,6 +17,7 @@ module Samagotchi
17
17
  def session_id = engine.session&.id
18
18
  def cwd = engine.session&.working_directory
19
19
  def project_root = engine.session&.project_root
20
+ def project_id = engine.session&.project_id
20
21
  def state_dir = engine.peer_state_dir
21
22
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
22
23
  def cancel_reason = engine.active_cancel_controller&.reason
@@ -22,6 +22,77 @@ module Samagotchi
22
22
  STREAM_FRAMES = %i[snapshot reset stream_closed].freeze
23
23
  SYNTHETIC = %i[merged_input].freeze
24
24
 
25
+ # The client-facing lifetime of each event. Rows are drawn in the
26
+ # conversation and survive reload; state updates the live UI; transient
27
+ # is live-only; internal is not sent to clients.
28
+ CLASSIFICATION = {
29
+ after_generation: :internal,
30
+ after_tool_call: :internal,
31
+ after_turn: :internal,
32
+ answer_display: :state,
33
+ before_generation: :internal,
34
+ before_tool_call: :internal,
35
+ before_turn: :internal,
36
+ card: :row,
37
+ command_queued: :state,
38
+ command_ran: :state,
39
+ context_added: :row, # Reloaded from the session messages, not a turn fold or card store.
40
+ context_status: :state,
41
+ continue_offered: :state,
42
+ continue_resolved: :state,
43
+ empty_answer_retry: :row,
44
+ generation_cancelled: :state,
45
+ generation_chunk: :transient,
46
+ generation_completed: :state,
47
+ generation_progress: :internal,
48
+ generation_retrying: :state, # Updates live retry status; a restart also removes invalidated chunks.
49
+ generation_started: :state,
50
+ guardrail_warning: :state, # Worker startup state; not a persistent conversation notice.
51
+ hook_notice: :row,
52
+ input_merged: :state,
53
+ llm_context_edited: :row,
54
+ merged_input: :row,
55
+ objectives_updated: :state,
56
+ participants_changed: :state,
57
+ pending_input_merged: :row,
58
+ plugin_init_finished: :state,
59
+ plugin_init_started: :state,
60
+ plugin_init_wait: :state,
61
+ prompt_restored: :state,
62
+ question_answered: :state,
63
+ question_cancelled: :state,
64
+ question_relay: :state,
65
+ question_requested: :state,
66
+ reasoning_history_refused: :internal,
67
+ recap_ready: :state,
68
+ reminder_injected: :row,
69
+ reset: :state,
70
+ sampling_refused: :internal,
71
+ session_end: :internal,
72
+ session_start: :internal,
73
+ side_usage: :state,
74
+ steer_cut: :row,
75
+ stream_closed: :state,
76
+ stream_resumed: :transient,
77
+ stream_retrying: :transient,
78
+ thinking_dropped: :transient,
79
+ thinking_refused: :internal,
80
+ tool_call_completed: :row,
81
+ tool_call_started: :row,
82
+ tool_dispatch_completed: :state,
83
+ tool_dispatch_started: :state,
84
+ turn_canceled: :row,
85
+ turn_completed: :row,
86
+ turn_dequeued: :state,
87
+ turn_enqueued: :state,
88
+ turn_failed: :row,
89
+ turn_started: :row,
90
+ used_memories_updated: :state,
91
+ verdict_recorded: :state,
92
+ worker_changed: :state,
93
+ working_set_updated: :state
94
+ }.freeze
95
+
25
96
  # Whether +event+ ends a turn that stays in the conversation: completed,
26
97
  # canceled, or failed with its work kept (turn_failed's kept_steps).
27
98
  def self.kept_turn?(event)