samagotchi 0.157.0 → 0.159.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +1 -1
  4. data/bin/chi +5 -1
  5. data/docs/architecture.md +14 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +76 -17
  9. data/docs/configuration.md +16 -5
  10. data/docs/context.md +12 -4
  11. data/docs/guardrails.md +11 -8
  12. data/docs/hooks.md +47 -80
  13. data/docs/internals/bridge.md +2 -1
  14. data/docs/internals/bundles.md +51 -36
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +43 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/scheduling.md +228 -0
  20. data/docs/sessions.md +30 -6
  21. data/docs/testing.md +4 -4
  22. data/lib/samagotchi/bridge/card_places.rb +5 -13
  23. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  24. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  25. data/lib/samagotchi/bridge.rb +26 -2
  26. data/lib/samagotchi/bundle_command/check.rb +23 -10
  27. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  28. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  29. data/lib/samagotchi/bundle_command.rb +53 -44
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/client_id.rb +13 -3
  35. data/lib/samagotchi/config.rb +12 -0
  36. data/lib/samagotchi/context_command.rb +7 -5
  37. data/lib/samagotchi/context_move.rb +74 -0
  38. data/lib/samagotchi/context_sources.rb +4 -2
  39. data/lib/samagotchi/engine.rb +1 -6
  40. data/lib/samagotchi/extension_load.rb +20 -41
  41. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  42. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  43. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  44. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  45. data/lib/samagotchi/hook_routes.rb +2 -2
  46. data/lib/samagotchi/hooks.rb +2 -3
  47. data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
  48. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  49. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  50. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  51. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  52. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  54. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  55. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  56. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  57. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  58. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  59. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
  61. data/lib/samagotchi/memory_bundle/status.rb +30 -9
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  63. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  64. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  65. data/lib/samagotchi/memory_orphans.rb +4 -4
  66. data/lib/samagotchi/memory_paths.rb +102 -13
  67. data/lib/samagotchi/plugin/api.rb +1 -4
  68. data/lib/samagotchi/plugin/loader.rb +42 -5
  69. data/lib/samagotchi/project_command.rb +167 -0
  70. data/lib/samagotchi/project_init.rb +69 -0
  71. data/lib/samagotchi/project_scope.rb +6 -4
  72. data/lib/samagotchi/prompt_files.rb +10 -7
  73. data/lib/samagotchi/recap_place.rb +3 -4
  74. data/lib/samagotchi/run_options.rb +2 -0
  75. data/lib/samagotchi/schedule_command.rb +252 -0
  76. data/lib/samagotchi/schedules.rb +948 -0
  77. data/lib/samagotchi/session.rb +44 -18
  78. data/lib/samagotchi/session_access/contract.rb +1 -1
  79. data/lib/samagotchi/session_access/local.rb +20 -0
  80. data/lib/samagotchi/session_field.rb +5 -4
  81. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  82. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  83. data/lib/samagotchi/steer.rb +10 -0
  84. data/lib/samagotchi/system_prompt.rb +20 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  86. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  87. data/lib/samagotchi/testing/bundle.rb +5 -12
  88. data/lib/samagotchi/testing/install.rb +5 -5
  89. data/lib/samagotchi/testing/recorder.rb +1 -1
  90. data/lib/samagotchi/testing.rb +1 -1
  91. data/lib/samagotchi/tool_declarations.rb +44 -1
  92. data/lib/samagotchi/toolkit_binding.rb +1 -4
  93. data/lib/samagotchi/toolkit_command.rb +1 -4
  94. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  95. data/lib/samagotchi/tools/builtins.rb +17 -0
  96. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  97. data/lib/samagotchi/tools/delegate.rb +12 -8
  98. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  99. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +35 -0
  102. data/lib/samagotchi/web/project_list.rb +3 -2
  103. data/lib/samagotchi/web/public/chains.js +32 -8
  104. data/lib/samagotchi/web/public/index.html +10 -2
  105. data/lib/samagotchi/web/public/info_bar.js +4 -3
  106. data/lib/samagotchi/web/public/project_picker.js +86 -4
  107. data/lib/samagotchi/web/public/turn_events.js +4 -0
  108. data/lib/samagotchi/web/session_hub.rb +20 -0
  109. data/lib/samagotchi/worker.rb +24 -6
  110. data/lib/samagotchi/worker_boot.rb +3 -4
  111. data/lib/samagotchi/worker_inbound.rb +23 -0
  112. data/lib/samagotchi/worker_link.rb +5 -0
  113. data/lib/samagotchi/worker_wakes.rb +9 -1
  114. metadata +10 -2
  115. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -81,6 +81,9 @@ module Samagotchi
81
81
  LIST = ->(value) { Array(value) }
82
82
  ID = ->(value) { value&.to_s }
83
83
  MESSAGES = ->(messages) { SessionField.scrub_utf8(messages.map { |msg| SessionField.stringify_keys(msg) }) }
84
+ # A text the user typed or pasted (a prompt, a title): the file gets it
85
+ # as valid UTF-8 (nil stays nil), the session keeps what it was given.
86
+ SCRUBBED = ->(value) { SessionField.scrub_utf8(value) }
84
87
  VERDICTS = lambda do |records|
85
88
  records&.map do |record|
86
89
  SessionField.scrub_utf8(record.to_h do |key, entry|
@@ -92,6 +95,10 @@ module Samagotchi
92
95
  origin = value.to_s
93
96
  TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
94
97
  end
98
+ TOOLKIT_ORIGIN_UNREAD = ->(value) { value if value.is_a?(String) && !TOOLKIT_ORIGINS.include?(value) }
99
+ # The kept value while there is one: the written one is the cast
100
+ # (never nil), so it can't come first as a level's does.
101
+ KEPT_FIRST = ->(written, kept) { kept || written }
95
102
  PARENT_ANSWERS = lambda do |value|
96
103
  case (level = Guardrails::ParentPolicy.parse_level(value))
97
104
  when :unset then nil
@@ -99,7 +106,8 @@ module Samagotchi
99
106
  else level
100
107
  end
101
108
  end
102
- private_constant :BOOLEAN, :NAMES, :LIST, :ID, :MESSAGES, :VERDICTS, :TOOLKIT_ORIGIN, :PARENT_ANSWERS
109
+ private_constant :BOOLEAN, :NAMES, :LIST, :ID, :MESSAGES, :SCRUBBED, :VERDICTS, :TOOLKIT_ORIGIN, :TOOLKIT_ORIGIN_UNREAD, :KEPT_FIRST,
110
+ :PARENT_ANSWERS
103
111
 
104
112
  # The fields of <id>.json, in the order #save writes them: key => its
105
113
  # SessionField (the value for a file without it, REQUIRED for the ones
@@ -141,12 +149,12 @@ module Samagotchi
141
149
  SessionField.plain("created_at", REQUIRED),
142
150
  SessionField.plain("updated_at", REQUIRED),
143
151
  SessionField.plain("status", STATUS_IDLE),
144
- SessionField.plain("last_prompt", ""),
145
- SessionField.plain("first_preview", ""),
152
+ SessionField.plain("last_prompt", "", write: SCRUBBED),
153
+ SessionField.plain("first_preview", "", write: SCRUBBED),
146
154
  # The name the user gave the session (/title, TitleCommand), else nil:
147
155
  # every list, card and tab shows it in place of #first_preview (see
148
156
  # #display_title). A continue's new link inherits it.
149
- SessionField.plain("title", nil),
157
+ SessionField.plain("title", nil, write: SCRUBBED),
150
158
  SessionField.plain("test_run", false, cast: BOOLEAN, write: BOOLEAN),
151
159
  # The question this session waits on (PendingQuestion), saved as
152
160
  # session.json's "pending_question"; nil with none. A Hash (a saved
@@ -225,7 +233,11 @@ module Samagotchi
225
233
  # Why this session is on its toolkit (TOOLKIT_ORIGINS; anything else
226
234
  # reads as chosen). Files from before the field read as chosen, so an
227
235
  # old default session is never newly suggested. Cast on every set too.
228
- SessionField.plain("toolkit_origin", "chosen", cast: TOOLKIT_ORIGIN),
236
+ # An unknown String (a newer chi's origin) is written back as it was
237
+ # (the Unread rule), until the session sets its own: the kept value
238
+ # wins over the cast chosen.
239
+ SessionField.plain("toolkit_origin", "chosen", cast: TOOLKIT_ORIGIN, unread: TOOLKIT_ORIGIN_UNREAD,
240
+ keep: KEPT_FIRST),
229
241
  # The two parts of #settings (Session.new's settings:, .from_h composes
230
242
  # them): its bundles part, then its core part.
231
243
  SessionField.file_only("bundle_settings", {}, from: :settings, write: ->(settings) { settings.bundles }),
@@ -272,16 +284,6 @@ module Samagotchi
272
284
  # Session.new's keywords: every FIELDS row's attr.
273
285
  ATTRS = FIELDS.values.filter_map(&:attr).freeze
274
286
 
275
- # @raise [ArgumentError] a keyword Session.new doesn't take, or a
276
- # required one missing (Ruby's own wording)
277
- def self.check_attrs!(attrs)
278
- unknown = attrs.keys - ATTRS
279
- raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
280
-
281
- missing = FIELDS.values.select(&:fresh_required?).map(&:attr) - attrs.keys
282
- raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
283
- end
284
-
285
287
  # The plain fields' readers and writers (each one's meaning is at its
286
288
  # FIELDS row): a writer takes the value as given (Session.new and
287
289
  # .from_h cast it), except the ones defined below.
@@ -362,6 +364,18 @@ module Samagotchi
362
364
  @toolkit_origin = load_field("toolkit_origin", value)
363
365
  end
364
366
 
367
+ # The user chose the toolkit (/toolkit, the toolkit binding's use): the
368
+ # origin is chosen. Whether that changes what the file holds, so the
369
+ # caller saves only then: a kept unknown origin (the Unread rule) reads
370
+ # as chosen but is written as it was, so it counts as a change.
371
+ # @return [Boolean]
372
+ def choose_toolkit_origin!
373
+ field = FIELDS.fetch("toolkit_origin")
374
+ was = field.dump(@toolkit_origin, @unread["toolkit_origin"])
375
+ self.toolkit_origin = "chosen"
376
+ was != field.dump(@toolkit_origin, @unread["toolkit_origin"])
377
+ end
378
+
365
379
  def parent_answers=(value)
366
380
  @parent_answers = load_field("parent_answers", value)
367
381
  end
@@ -446,7 +460,7 @@ module Samagotchi
446
460
  # (cast by its row, as the writers cast); +settings+ is the
447
461
  # settings pair's one value, +extra_fields+ the file's unknown keys.
448
462
  def initialize(extra_fields: {}, settings: nil, **attrs)
449
- self.class.check_attrs!(attrs)
463
+ check_attrs!(attrs)
450
464
  # What the level fields couldn't read, by key (SessionField.level).
451
465
  @unread = {}
452
466
  FIELDS.each_value do |field|
@@ -793,10 +807,21 @@ module Samagotchi
793
807
  def load_field(key, value)
794
808
  field = FIELDS.fetch(key)
795
809
  @unread[key] = field.unread.call(value) if field.unread
796
- field.load(value)
810
+ field.cast_value(value)
797
811
  end
798
812
  private :load_field
799
813
 
814
+ # @raise [ArgumentError] a keyword Session.new doesn't take, or a
815
+ # required one missing (Ruby's own wording)
816
+ def check_attrs!(attrs)
817
+ unknown = attrs.keys - ATTRS
818
+ raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
819
+
820
+ missing = FIELDS.values.select(&:fresh_required?).map(&:attr) - attrs.keys
821
+ raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
822
+ end
823
+ private :check_attrs!
824
+
800
825
  # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
801
826
  def checkpointed
802
827
  checkpoint = @turn_checkpoint
@@ -884,8 +909,9 @@ module Samagotchi
884
909
  # A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
885
910
  # A quoted or annotated message previews by what was typed under the
886
911
  # quote; a quote alone by its words, without the "> " markers or label.
912
+ # Bytes that aren't UTF-8 preview as ?, as the file saves them.
887
913
  def self.preview_of(text)
888
- norm = preview_words(text.to_s).gsub(/\s+/, " ").strip
914
+ norm = preview_words(SessionField.scrub_utf8(text.to_s)).gsub(/\s+/, " ").strip
889
915
  norm.length > 80 ? "#{norm[0, 80]}…" : norm
890
916
  end
891
917
 
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # bin/chi subcommands that act on this machine: never sessions. chi
27
27
  # remote (this machine's list of remotes) runs here under a remote too
28
28
  # (Dispatch); the others are refused there.
29
- MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home].freeze
29
+ MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home project schedule].freeze
30
30
 
31
31
  # "command", "command sub" or either with a flag (its long name) →
32
32
  # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
@@ -11,6 +11,7 @@ require_relative "../parent_report"
11
11
  require_relative "../note_delivery"
12
12
  require_relative "../image_store"
13
13
  require_relative "../context_candidates"
14
+ require_relative "../worker_inbound"
14
15
  require_relative "../context_sources"
15
16
  require_relative "../archive_store"
16
17
  require_relative "../session_chain"
@@ -24,6 +25,7 @@ require_relative "../participants"
24
25
  require_relative "../config"
25
26
  require_relative "../live_versions"
26
27
  require_relative "../grants"
28
+ require_relative "../log"
27
29
  require_relative "../web/info_probe"
28
30
  require_relative "../web/public_url"
29
31
  require_relative "../web/link_codes"
@@ -314,6 +316,16 @@ module Samagotchi
314
316
  before, written = attached.location.locked(name) do
315
317
  [attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
316
318
  end
319
+ if fetched.wake && before != written.revision && !target.project? && attached.location.session? &&
320
+ attached.source.scope == "session"
321
+ begin
322
+ subscription = attached.location.subscription(name)
323
+ recent_wake = context_wake_recent?(subscription)
324
+ SessionManager.wake_for_context(target.session_id, **dir) unless recent_wake || Config.get("context.wake") == false
325
+ rescue StandardError => e
326
+ Log.exception(:context, "context_push_wake_failed", e, sid: target.session_id, source: name)
327
+ end
328
+ end
317
329
  ContextPush.new(status: :pushed, before: before, revision: written.revision)
318
330
  end
319
331
 
@@ -513,6 +525,14 @@ module Samagotchi
513
525
  def state_dir! = @state_dir || Session.default_state_dir
514
526
  def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
515
527
 
528
+ def context_wake_recent?(subscription)
529
+ return false unless subscription.wakes_at
530
+
531
+ Time.now - Time.iso8601(subscription.wakes_at) < WorkerInbound::CONTEXT_WAKE_WINDOW
532
+ rescue ArgumentError
533
+ false
534
+ end
535
+
516
536
  def attached_to(target, shadowed: false)
517
537
  ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
518
538
  shadowed: shadowed)
@@ -15,10 +15,11 @@ module Samagotchi
15
15
  # block its lambdas call (`type.call`, not `yield`: never while the
16
16
  # constructor runs, so Performance/RedundantBlockCall is off for them).
17
17
  SessionField = Data.define(:key, :attr, :from, :default, :fresh, :optional, :cast, :write, :unread, :keep) do
18
- # A field read as given, or through +cast+.
19
- def self.plain(key, default, cast: nil, write: nil, fresh: default, optional: false)
18
+ # A field read as given, or through +cast+; +unread+ and +keep+ give
19
+ # it the Unread rule (see .level).
20
+ def self.plain(key, default, cast: nil, write: nil, fresh: default, optional: false, unread: nil, keep: nil)
20
21
  new(key: key, attr: key.to_sym, from: nil, default: default, fresh: fresh, optional: optional, cast: cast,
21
- write: write, unread: nil, keep: nil)
22
+ write: write, unread: unread, keep: keep)
22
23
  end
23
24
 
24
25
  # rubocop:disable Performance/RedundantBlockCall
@@ -98,7 +99,7 @@ module Samagotchi
98
99
  def ivar = :"@#{attr || from}"
99
100
 
100
101
  # +value+ as the attribute holds it.
101
- def load(value) = cast ? cast.call(value) : value
102
+ def cast_value(value) = cast ? cast.call(value) : value
102
103
 
103
104
  # The attribute's +value+ as the file holds it, with what the session
104
105
  # kept of a value it couldn't read (+kept+, .level's).
@@ -155,23 +155,37 @@ module Samagotchi
155
155
  # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
156
156
  # :scratch, :owned, :starting)
157
157
  def wake_for_report(session_id, state_dir: nil, forward: true)
158
- sd = state_dir || Session.default_state_dir
159
- return :gone unless Session.exist?(session_id, state_dir: sd)
160
- return :owned if session_owner(session_id, state_dir: sd)
161
- return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
158
+ wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: forward,
159
+ event: :delegate_woke_parent)
160
+ end
161
+
162
+ # A pushed context source asked for a wake: start an idle-exited
163
+ # session, but never clear a stop or forward an archived link.
164
+ # @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
165
+ # :scratch, :owned, :starting)
166
+ def wake_for_context(session_id, state_dir: nil)
167
+ wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: false,
168
+ event: :context_woke_session)
169
+ end
162
170
 
163
- if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: sd))
164
- following = forward && SessionChain.next_of(session_id, state_dir: sd)
165
- return following ? wake_for_report(following, state_dir: sd, forward: false) : :archived
171
+ private def wake_existing_session(session_id, state_dir:, forward:, event:)
172
+ return :gone unless Session.exist?(session_id, state_dir: state_dir)
173
+ return :owned if session_owner(session_id, state_dir: state_dir)
174
+ return :stopped if Session.stopped_marker?(session_id, state_dir: state_dir)
175
+
176
+ if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
177
+ following = forward && SessionChain.next_of(session_id, state_dir: state_dir)
178
+ return following ? wake_existing_session(following, state_dir: state_dir, forward: false, event: event) : :archived
166
179
  end
167
- return :starting if StartingMarker.fresh?(session_id, state_dir: sd)
180
+ return :starting if StartingMarker.fresh?(session_id, state_dir: state_dir)
168
181
 
169
- session = Session.load(session_id, state_dir: sd)
182
+ session = Session.load(session_id, state_dir: state_dir)
170
183
  return :stopped if session.status == Session::STATUS_STOPPED
171
184
  return :scratch if session.scratch
172
185
 
173
- resume_session(session_id, state_dir: sd)
174
- Log.info(:worker, "delegate_woke_parent", parent: session_id[0, 8])
186
+ resume_session(session_id, state_dir: state_dir)
187
+ details = event == :delegate_woke_parent ? { parent: session_id[0, 8] } : { sid: session_id[0, 8] }
188
+ Log.info(:worker, event, **details)
175
189
  :woken
176
190
  rescue OwnedByTUI
177
191
  :owned
@@ -3,8 +3,8 @@
3
3
  module Samagotchi
4
4
  class SessionManager
5
5
  # CROSS-VERSION CONTRACT: this boot string, the entry it names
6
- # (run_session_loop(id, state_dir:), the forwarder below), the env keys
7
- # spawn_options sets and the input/ file
6
+ # (WorkerBoot.run_session_loop(id, state_dir:)), the legacy forwarder
7
+ # below, the env keys spawn_options sets and the input/ file
8
8
  # format are read by a NEWER chi than the one writing them. Change them
9
9
  # only additively (spec/worker_contract_spec.rb).
10
10
  BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
@@ -28,9 +28,9 @@ module Samagotchi
28
28
 
29
29
  # WorkerCommand: the worker's command line and env (CROSS-VERSION
30
30
  # CONTRACT: spec/worker_contract_spec.rb); the worker process itself is
31
- # WorkerBoot (worker_boot.rb), which only the forwarder (run_session_loop)
32
- # loads, as a worker boots. Calls no other part. Meant to be loaded
33
- # through samagotchi/session_manager.
31
+ # WorkerBoot (worker_boot.rb). The boot command loads it directly; the
32
+ # forwarder (run_session_loop) remains for older boot strings. Calls no
33
+ # other part. Meant to be loaded through samagotchi/session_manager.
34
34
  module WorkerCommandPart
35
35
  # Build the opts hash passed to Process.spawn for a forked worker. The
36
36
  # child inherits this process's ENV and reads config.yml itself (as it
@@ -94,13 +94,13 @@ module Samagotchi
94
94
  opts
95
95
  end
96
96
 
97
- # The worker's entry, the one every boot string names (CROSS-VERSION
98
- # CONTRACT: spec/worker_contract_spec.rb): it only forwards to
97
+ # The worker's legacy entry for boot strings written by older chi
98
+ # (CROSS-VERSION CONTRACT: spec/worker_contract_spec.rb): it forwards to
99
99
  # WorkerBoot.run_session_loop, which runs the worker process. Looked up
100
100
  # by name on purpose: a constant reference here would put SessionManager
101
101
  # back into one cycle with Worker, Engine and Bridge (the class map).
102
- # REMOVE one release after the boot string switches (see
103
- # ~/.claude/plans/arch-worker-boot.md).
102
+ # REMOVE in the next release: boot strings name WorkerBoot since this
103
+ # one (see ~/.claude/plans/arch-worker-boot.md).
104
104
  def run_session_loop(session_id, state_dir: nil, **)
105
105
  require_relative "../worker_boot"
106
106
  Samagotchi.const_get(:WorkerBoot).run_session_loop(session_id, state_dir: state_dir, **)
@@ -118,8 +118,8 @@ module Samagotchi
118
118
  # there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
119
119
  def worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
120
120
  home: ChiHome.current(gem_spec: gem_spec))
121
- boot = "require 'samagotchi/session_manager'; " \
122
- "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
121
+ boot = "require 'samagotchi/worker_boot'; " \
122
+ "Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
123
123
  return [RbConfig.ruby, "-I", File.expand_path("../..", __dir__), "-e", boot] unless gem_spec
124
124
  return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
125
125
 
@@ -146,9 +146,9 @@ module Samagotchi
146
146
  else
147
147
  "gem 'samagotchi', '>= 0.a'"
148
148
  end
149
- home.boot_argv(run: "begin; #{activate}; require 'samagotchi/session_manager'; " \
149
+ home.boot_argv(run: "begin; #{activate}; require 'samagotchi/worker_boot'; " \
150
150
  "#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
151
- "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
151
+ "Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
152
152
  end
153
153
  end
154
154
 
@@ -364,6 +364,16 @@ module Samagotchi
364
364
  turn_prompt?(message) && !shell_line?(message)
365
365
  end
366
366
 
367
+ # An opening's stamp: its turn_id (a prompt's, a wake note's), else its
368
+ # own opening_id (/continue no's reason line); nil for neither (a line
369
+ # an older chi wrote). Where a card (Bridge::CardPlaces) and the recap
370
+ # (RecapPlace) sit.
371
+ # @return [String, nil]
372
+ def opening_stamp(message)
373
+ id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
374
+ id.is_a?(String) && !id.empty? ? id : nil
375
+ end
376
+
367
377
  # Call a loop's drain: +at_answer+ goes only to a drain that takes it
368
378
  # (the Engine's); a caller's own drain (a queue's #drain) is called bare.
369
379
  # A failing drain drains nothing (the loop keeps going).
@@ -291,7 +291,7 @@ module Samagotchi
291
291
  6. Use write for full-file rewrites or creating new files.
292
292
 
293
293
  Memory convention:
294
- Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder" line below)
294
+ Project scope: one folder per project (a git repository, shared by its worktrees and subdirectories, or a folder marked by `chi project init` and its subfolders; its path is on the "Project memories folder" line below)
295
295
  System scope: ~/.config/samagotchi/memories/ (cross-project)
296
296
  memory_read accepts optional scope (project|system).
297
297
  memory_write requires explicit scope and entry name.
@@ -623,10 +623,20 @@ module Samagotchi
623
623
  File.expand_path(path)
624
624
  end
625
625
 
626
+ # The project_location line over its top, for a checkout (git) or a
627
+ # marked project: [below the top, at the top].
628
+ TOP_LINES = {
629
+ git: ["Top of this checkout (where the project's commands run from; stay inside it):",
630
+ "This checkout (the project's files; its commands run here):"],
631
+ marker: ["Top of this project (where its commands run from; stay inside it):",
632
+ "This project (its files; its commands run here):"]
633
+ }.freeze
634
+
626
635
  # Where the session runs and which project memory folder it uses. Only
627
- # this checkout is named: its top (at its root too, so a model doesn't
628
- # take the memories folder for the project and cd there, Qwen
629
- # 2026-10-06), and in a linked worktree a line that work stays in it. The repository's main
636
+ # this checkout (or marked project, MemoryPaths.lookup) is named: its
637
+ # top (at its root too, so a model doesn't take the memories folder for
638
+ # the project and cd there, Qwen 2026-10-06), and in a linked worktree
639
+ # a line that work stays in it. The repository's main
630
640
  # checkout (MemoryPaths.project_root, which scopes memories and session
631
641
  # lists) is never shown: a model told its path went to work there
632
642
  # (a worktree child committed onto main, 2026-10-03 and 2026-10-06).
@@ -635,16 +645,15 @@ module Samagotchi
635
645
  def project_location
636
646
  Degrade.call(:prompt, "location_failed") do
637
647
  cwd = Dir.pwd
638
- top = MemoryPaths.work_tree_root(cwd)
648
+ found = MemoryPaths.lookup(cwd)
649
+ top = found.top
639
650
  lines = ["Current working directory:", cwd]
640
- if top && top != cwd
641
- lines << "Top of this checkout (where the project's commands run from; stay inside it):"
642
- lines << top
643
- elsif top
644
- lines << "This checkout (the project's files; its commands run here):"
651
+ if top
652
+ below, at = TOP_LINES.fetch(found.kind)
653
+ lines << (top == cwd ? at : below)
645
654
  lines << top
646
655
  end
647
- if top && MemoryPaths.project_root(cwd) != top
656
+ if found.git? && found.root != top
648
657
  lines << "This checkout is a linked git worktree: read, edit, run and commit only in it, never in the " \
649
658
  "repository's other checkouts."
650
659
  end
@@ -242,6 +242,8 @@ module Samagotchi
242
242
  when "task"
243
243
  task = id.delete_prefix(ClientId::TASK_PREFIX)
244
244
  return "task #{Tools::TaskRecord.short_id(task)} finished" unless task.empty?
245
+ when "schedule"
246
+ return "schedule"
245
247
  end
246
248
 
247
249
  prefix = id[/\A[^:]*:/]
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../bridge/turn_accumulator"
4
+ require_relative "../bridge/turn_drawings"
4
5
  require_relative "../context_note"
5
6
  require_relative "../image_store"
6
7
  require_relative "../log"
@@ -163,13 +164,17 @@ module Samagotchi
163
164
 
164
165
  # The snapshot's drawing as [turn, end event] when it is +prompt+'s
165
166
  # turn (a Continue's or a reminder's drawing isn't the prompt's: the
166
- # rows are rebuilt), else nil.
167
+ # rows are rebuilt), else nil; nil too for one whose parts aren't a
168
+ # list of rows.
167
169
  def drawn_turn(drawing, prompt)
168
170
  return nil unless drawing.is_a?(Hash) && drawing[:drawing].is_a?(Hash) && drawing[:end].is_a?(Hash)
169
171
 
170
172
  turn = Bridge::TurnAccumulator.from_wire(drawing[:drawing])
171
173
  id = prompt[:turn_id] || prompt["turn_id"]
172
174
  return nil unless id && turn[:turn_id].to_s == id.to_s
175
+ # An older worker sends a line its parse didn't check (the unfold
176
+ # would raise after the prompt line is drawn).
177
+ return nil unless Bridge::TurnDrawings.rows?(turn[:parts])
173
178
 
174
179
  [turn, EventRenderer.symbolize(Bridge::TurnAccumulator.from_wire(drawing[:end]))]
175
180
  end
@@ -25,7 +25,7 @@ require_relative "recording_api"
25
25
 
26
26
  module Samagotchi
27
27
  module Testing
28
- # A hook (a plugin's chi.on block or a hook file) raised while the kit
28
+ # A hook (a plugin's chi.on block) raised while the kit
29
29
  # fired it: chi would log it and go on, a test fails with it.
30
30
  class HookFailed < StandardError
31
31
  # @return [Hooks::Failure]
@@ -53,8 +53,7 @@ module Samagotchi
53
53
 
54
54
  # One bundle loaded for a test through chi's own seams: its plugin
55
55
  # through Plugin::Loader.register_file (Plugin::Api, a real
56
- # Plugin::Context) and its hook files through
57
- # Hooks::BundleLoader.load_hook_file, into real registries (commands
56
+ # Plugin::Context), into real registries (commands
58
57
  # and tools with chi's built-ins in them, so a name clash fails as in
59
58
  # chi). Only the edges are recorded: the Host and hook Runtime
60
59
  # (Recorder), ctx.sessions (Testing::Sessions), chi.init tasks and
@@ -130,8 +129,6 @@ module Samagotchi
130
129
  # @return [Module, nil] the module the plugin was evaluated in
131
130
  # (bundle.namespace::ThinkingWatch)
132
131
  attr_reader :namespace
133
- # @return [Module] the module the hook files were evaluated in
134
- attr_reader :hooks_namespace
135
132
  # @return [Array<Init>]
136
133
  attr_reader :inits
137
134
  # @return [Array<StagedTools>]
@@ -152,6 +149,9 @@ module Samagotchi
152
149
  @session_moves = 1
153
150
  own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
154
151
  @own_sandbox = sandbox.nil? && Sandbox.current.nil?
152
+ # With no active sandbox, this one saves ENV at construction and
153
+ # restores that copy at shutdown; don't lazily build it inside a block
154
+ # that sets ENV for one example (your own helper, or ClimateControl).
155
155
  @sandbox = sandbox || Sandbox.current || Sandbox.enter
156
156
  build(host, own)
157
157
  rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
@@ -184,13 +184,6 @@ module Samagotchi
184
184
  @plugin = built
185
185
  end
186
186
  end
187
- @hooks_namespace = Module.new
188
- @manifest.hooks.each do |basename, hook|
189
- next if hook.events.empty?
190
-
191
- Hooks::BundleLoader.load_hook_file(@name, basename, File.join(@dir, "hooks", basename), hook, hooks,
192
- settings: @settings, namespace: @hooks_namespace)
193
- end
194
187
  self
195
188
  rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
196
189
  shutdown
@@ -15,7 +15,7 @@ module Samagotchi
15
15
  # A bundle as chi installs and loads it, unlike Bundle (its source as it
16
16
  # is): MemoryBundle::Installer copies it into the sandbox's state (sha256
17
17
  # and requires_chi checked), then chi's own loaders load what is
18
- # installed (ExtensionLoad: the bundle hooks, then the plugins). What
18
+ # installed (ExtensionLoad: config hooks, then the plugins). What
19
19
  # failed names the bundle's source files, not the installed copies.
20
20
  #
21
21
  # result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
@@ -23,8 +23,8 @@ module Samagotchi
23
23
  module Install
24
24
  # warnings: the Installer's; plugin_failures / hook_failures:
25
25
  # [Guardrails::LoadFailures::Failure] of this bundle, each location
26
- # in the source dir (hook_failures: the guardrails', a required
27
- # plugin's among them); registered: what it registered (#registered).
26
+ # in the source dir (hook_failures: the guardrails': a required
27
+ # plugin's load failure, a config hook's); registered: what it registered (#registered).
28
28
  Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
29
29
  def failures = plugin_failures + hook_failures
30
30
  end
@@ -35,7 +35,7 @@ module Samagotchi
35
35
  Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources, :prompt_sections)
36
36
  # A command: its mid_turn policy and its selection-bar labels (chi.annotation).
37
37
  Command = Data.define(:name, :mid_turn, :labels)
38
- # A hook on an event: the bundle, the hook (its file, or the plugin's) and its priority.
38
+ # A hook on an event: the bundle, the hook (the plugin's label) and its priority.
39
39
  Hook = Data.define(:bundle, :hook, :priority)
40
40
 
41
41
  module_function
@@ -119,7 +119,7 @@ module Samagotchi
119
119
  # installed copy to the source dir.
120
120
  def own(failures, name, installed, dir)
121
121
  failures.list.select { |f| f.what.end_with?("(bundle #{name})") }.map do |failure|
122
- # The plugin is installed into plugin/, the hooks into hooks/.
122
+ # The plugin is installed into plugin/.
123
123
  location = failure.location&.sub(%r{\A#{Regexp.escape(installed)}/(?:plugin/)?}, "#{dir}/")
124
124
  failure.dup.tap { |copy| copy.location = location }
125
125
  end
@@ -9,7 +9,7 @@ require_relative "judgments"
9
9
  module Samagotchi
10
10
  module Testing
11
11
  # A line to the user (ctx.notify, event[:notify]); +hook+ is the
12
- # plugin's or hook file's label ("plugin.rb (bundle x)").
12
+ # plugin's label ("plugin.rb (bundle x)").
13
13
  Notice = Data.define(:text, :level, :hook, :fallback_for)
14
14
  # Text put into the running turn (ctx.steer, event[:steer]).
15
15
  Steer = Data.define(:text, :hook)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # The bundle test kit (docs/plugins.md, "Testing a bundle"): load a bundle's
4
- # plugin and hook files through chi's own loaders, registries, Context and
4
+ # plugin through chi's own loaders, registries, Context and
5
5
  # guardrail Gate, with only the edges recorded (the session's Host, the hook
6
6
  # runtime, ctx.sessions, init tasks), in a sandboxed environment.
7
7
  #
@@ -303,6 +303,49 @@ module Samagotchi
303
303
  properties: {}
304
304
  }
305
305
  },
306
+ {
307
+ name: "schedule_task",
308
+ description: "Register a durable schedule for this session: a prompt that runs on a clock outside the worker " \
309
+ "(chi schedule tick, run by cron, systemd or chi web's hub). Unlike register_reminder it needs no " \
310
+ "live worker and never holds one up. Give exactly one of every, weekday+time or date.",
311
+ parameters: {
312
+ type: "object",
313
+ properties: {
314
+ title: { type: "string", description: "A short name for the schedule" },
315
+ prompt: { type: "string", description: "What each firing does (non-empty, at most 16 KiB)" },
316
+ every: { type: "string", description: "Interval: \"30m\", \"2h\", \"1d\" (UTC math: drifts an hour across a DST change)" },
317
+ weekday: { type: "integer", description: "1=Mon … 7=Sun, used with time" },
318
+ time: { type: "string", description: "HH:MM in the machine's local zone (with weekday)" },
319
+ date: { type: "string", description: "One local moment: 2026-11-20T09:00" },
320
+ mode: { type: "string", description: "chain (default): the next link of this session's chain. same: a queued turn in this session" },
321
+ keep_links: { type: "integer", description: "Chain links to keep (default 5; 0 keeps all)" }
322
+ },
323
+ required: ["prompt"]
324
+ }
325
+ },
326
+ {
327
+ name: "list_schedules",
328
+ description: "List the durable schedules of this chain (all: true for every schedule on this machine): id, mode, recurrence, next due and target.",
329
+ parameters: {
330
+ type: "object",
331
+ properties: {
332
+ include_disabled: { type: "boolean", description: "Include the disabled schedules and the fired one-offs" },
333
+ all: { type: "boolean", description: "Every schedule on this machine, not just this chain" }
334
+ }
335
+ }
336
+ },
337
+ {
338
+ name: "cancel_schedule",
339
+ description: "Cancel a durable schedule of this chain by its id, a unique prefix of it, or its title (all: true for one from another chain).",
340
+ parameters: {
341
+ type: "object",
342
+ properties: {
343
+ name: { type: "string", description: "The schedule's id or title" },
344
+ all: { type: "boolean", description: "Allow cancelling a schedule of another session's chain" }
345
+ },
346
+ required: ["name"]
347
+ }
348
+ },
306
349
  {
307
350
  name: "list_sessions",
308
351
  description: "List the other chi sessions of this project (newest first, up to 20): id, whether a worker runs it, its folder and last prompt; other projects: cwd \"/\". Use it to find the session send_note should go to.",
@@ -375,7 +418,7 @@ module Samagotchi
375
418
  },
376
419
  cwd: {
377
420
  type: "string",
378
- description: "The new child's working folder: a worktree or subfolder of this session's repository (create a worktree with execute `git worktree add ../<repo>-<name> -b <branch>` first). Default: this session's folder"
421
+ description: "The new child's working folder: a worktree or subfolder of this session's project (a repository, or a `chi project init` folder; create a worktree with execute `git worktree add ../<repo>-<name> -b <branch>` first). Default: this session's folder"
379
422
  },
380
423
  toolkit: {
381
424
  type: "string",
@@ -139,10 +139,7 @@ module Samagotchi
139
139
 
140
140
  toolkit = Toolkit.new(name: name)
141
141
  if session.toolkit == toolkit
142
- if session.toolkit_origin != "chosen"
143
- session.toolkit_origin = "chosen"
144
- session.save(state_dir: @state_dir.call)
145
- end
142
+ session.save(state_dir: @state_dir.call) if session.choose_toolkit_origin!
146
143
  return true
147
144
  end
148
145
 
@@ -47,10 +47,7 @@ module Samagotchi
47
47
  # Nothing the prompt carries changes (full +delegate on full): only
48
48
  # the origin is saved, so a no-op choice still counts as user-selected.
49
49
  if after.prompt_key == before.prompt_key
50
- if session.toolkit_origin != "chosen"
51
- session.toolkit_origin = "chosen"
52
- @save.call(session)
53
- end
50
+ @save.call(session) if session.choose_toolkit_origin!
54
51
  return [listing(before) + "\n(no change to what the next turn declares)", false, nil]
55
52
  end
56
53