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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -1
- data/README.md +1 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +14 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +76 -17
- data/docs/configuration.md +16 -5
- data/docs/context.md +12 -4
- data/docs/guardrails.md +11 -8
- data/docs/hooks.md +47 -80
- data/docs/internals/bridge.md +2 -1
- data/docs/internals/bundles.md +51 -36
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +43 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/scheduling.md +228 -0
- data/docs/sessions.md +30 -6
- data/docs/testing.md +4 -4
- data/lib/samagotchi/bridge/card_places.rb +5 -13
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
- data/lib/samagotchi/bridge.rb +26 -2
- data/lib/samagotchi/bundle_command/check.rb +23 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +53 -44
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client_id.rb +13 -3
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_sources.rb +4 -2
- data/lib/samagotchi/engine.rb +1 -6
- data/lib/samagotchi/extension_load.rb +20 -41
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
- data/lib/samagotchi/memory_bundle/installer.rb +32 -14
- data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
- data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
- data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
- data/lib/samagotchi/memory_bundle/status.rb +30 -9
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/plugin/api.rb +1 -4
- data/lib/samagotchi/plugin/loader.rb +42 -5
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/recap_place.rb +3 -4
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +948 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +20 -0
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/session_manager/spawn.rb +25 -11
- data/lib/samagotchi/session_manager/worker_command.rb +13 -13
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -5
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/tool_declarations.rb +44 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/delegate.rb +12 -8
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -0
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +10 -2
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +24 -6
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +23 -0
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +10 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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, :
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
180
|
+
return :starting if StartingMarker.fresh?(session_id, state_dir: state_dir)
|
|
168
181
|
|
|
169
|
-
session = Session.load(session_id, state_dir:
|
|
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:
|
|
174
|
-
|
|
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:)
|
|
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)
|
|
32
|
-
#
|
|
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
|
|
98
|
-
# CONTRACT: spec/worker_contract_spec.rb): it
|
|
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
|
|
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/
|
|
122
|
-
"Samagotchi::
|
|
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/
|
|
149
|
+
home.boot_argv(run: "begin; #{activate}; require 'samagotchi/worker_boot'; " \
|
|
150
150
|
"#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
|
|
151
|
-
"Samagotchi::
|
|
151
|
+
"Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
|
|
152
152
|
end
|
|
153
153
|
end
|
|
154
154
|
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -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
|
|
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
|
|
628
|
-
#
|
|
629
|
-
# 2026-10-06), and in a linked worktree
|
|
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
|
-
|
|
648
|
+
found = MemoryPaths.lookup(cwd)
|
|
649
|
+
top = found.top
|
|
639
650
|
lines = ["Current working directory:", cwd]
|
|
640
|
-
if top
|
|
641
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
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)
|
|
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:
|
|
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'
|
|
27
|
-
# plugin's
|
|
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 (
|
|
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
|
|
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
|
|
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)
|
data/lib/samagotchi/testing.rb
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|