samagotchi 0.158.0 → 0.160.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 +60 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -7
- data/docs/cli.md +57 -2
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +24 -3
- data/docs/hooks.md +3 -0
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +46 -14
- data/docs/scheduling.md +241 -0
- data/docs/sessions.md +28 -2
- data/lib/samagotchi/bridge.rb +37 -4
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +39 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/client_id.rb +17 -3
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_command.rb +9 -3
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +13 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -11
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
- data/lib/samagotchi/memory_bundle/status.rb +22 -3
- data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
- data/lib/samagotchi/memory_orphans.rb +14 -4
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +1008 -0
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +28 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +4 -1
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +10 -6
- data/lib/samagotchi/session_manager/spawn.rb +34 -13
- data/lib/samagotchi/session_manager/worker_command.rb +11 -23
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- 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/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/update_command.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -4
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +42 -12
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +44 -2
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +12 -1
data/lib/samagotchi/session.rb
CHANGED
|
@@ -166,6 +166,11 @@ module Samagotchi
|
|
|
166
166
|
# deleted folder no longer leads to its repository. nil outside a repo,
|
|
167
167
|
# and in files written before the field existed (see #project_root).
|
|
168
168
|
SessionField.plain("project_root", nil),
|
|
169
|
+
# The project's id (ProjectId: a marked project's `id:`), saved beside
|
|
170
|
+
# project_root at creation, else nil: its memories and project context
|
|
171
|
+
# sources are keyed by it (MemoryPaths.key_for), so a session resumed
|
|
172
|
+
# after its folder moved finds them under the id, not its dead path.
|
|
173
|
+
SessionField.plain("project_id", nil, optional: true),
|
|
169
174
|
# Memories the session was started with (--memory) and memories hidden
|
|
170
175
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
171
176
|
# Names as given; the engine normalizes them.
|
|
@@ -271,6 +276,14 @@ module Samagotchi
|
|
|
271
276
|
# chi web's create) can name it (`--wait --turn T ID`). nil for a
|
|
272
277
|
# session created idle, and for one saved before the field existed.
|
|
273
278
|
SessionField.plain("first_turn_id", nil, optional: true),
|
|
279
|
+
# Who sent that prompt when no person did (a ClientId: a delegating
|
|
280
|
+
# parent's delegate:<parent8>, a schedule's chain link
|
|
281
|
+
# system:schedule:<id>): the first turn's origin, so the command
|
|
282
|
+
# intake treats it as theirs (CommandIntake) and last_turn names
|
|
283
|
+
# them. nil for a person's prompt (chi send --new, the web, a TUI, a
|
|
284
|
+
# continue) and an idle session. Set once, by spawn_session; a
|
|
285
|
+
# continue and a fork don't carry it.
|
|
286
|
+
SessionField.plain("first_turn_client_id", nil, optional: true),
|
|
274
287
|
# A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
|
|
275
288
|
# nil for any other session. Set once, at spawn (VerifyFresh.start).
|
|
276
289
|
# Autoloaded on the first one (the bundle test kit's load budget).
|
|
@@ -290,7 +303,7 @@ module Samagotchi
|
|
|
290
303
|
attr_accessor :id, :metadata_version, :mode, :model_name, :model_typed, :working_directory, :messages,
|
|
291
304
|
:created_at, :updated_at, :status, :last_prompt, :first_preview, :title, :test_run,
|
|
292
305
|
:used_memory_names, :preloaded_memory_names, :muted_memory_names, :parent_id, :scratch,
|
|
293
|
-
:continues, :first_turn_id
|
|
306
|
+
:continues, :first_turn_id, :first_turn_client_id, :project_id
|
|
294
307
|
attr_writer :project_root, :delegate
|
|
295
308
|
attr_reader :appended_prompt_files, :written_by
|
|
296
309
|
|
|
@@ -572,6 +585,7 @@ module Samagotchi
|
|
|
572
585
|
review: nil, now: Time.now)
|
|
573
586
|
own = setup.to_h
|
|
574
587
|
own = own.merge(toolkit_origin: own[:toolkit_origin] || "chosen")
|
|
588
|
+
project = ProjectScope.project_for(working_directory)
|
|
575
589
|
Toolkit.check_new!(own[:toolkit] || Toolkit.configured(delegate: delegate))
|
|
576
590
|
now = now.iso8601(3)
|
|
577
591
|
resolved_test = if test_run.nil?
|
|
@@ -591,7 +605,8 @@ module Samagotchi
|
|
|
591
605
|
status: STATUS_IDLE,
|
|
592
606
|
first_preview: "",
|
|
593
607
|
test_run: resolved_test,
|
|
594
|
-
project_root:
|
|
608
|
+
project_root: project&.root,
|
|
609
|
+
project_id: project&.id,
|
|
595
610
|
preloaded_memory_names: preloaded_memory_names,
|
|
596
611
|
muted_memory_names: muted_memory_names,
|
|
597
612
|
parent_id: parent_id,
|
|
@@ -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 project].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
|
|
|
@@ -341,7 +353,8 @@ module Samagotchi
|
|
|
341
353
|
def context_candidates(target)
|
|
342
354
|
return [] if target.project?
|
|
343
355
|
|
|
344
|
-
ContextCandidates.with_status(target.session_id, project_root: target.project_root,
|
|
356
|
+
ContextCandidates.with_status(target.session_id, project_root: target.project_root, project_id: target.project_id,
|
|
357
|
+
state_dir: @state_dir)
|
|
345
358
|
.map { |candidate, status| ContextCandidate.new(candidate: candidate, status: status) }
|
|
346
359
|
end
|
|
347
360
|
|
|
@@ -359,7 +372,9 @@ module Samagotchi
|
|
|
359
372
|
ContextSources.check_name!(name)
|
|
360
373
|
return :removed if context_location(target).remove(name)
|
|
361
374
|
|
|
362
|
-
project = !target.project? && ContextSources.project_location_for(target.project_root,
|
|
375
|
+
project = !target.project? && ContextSources.project_location_for(target.project_root,
|
|
376
|
+
project_id: target.project_id,
|
|
377
|
+
state_dir: @state_dir)
|
|
363
378
|
project&.source(name) ? :project : :missing
|
|
364
379
|
end
|
|
365
380
|
|
|
@@ -382,7 +397,7 @@ module Samagotchi
|
|
|
382
397
|
# live in.
|
|
383
398
|
def context_location(target)
|
|
384
399
|
if target.project?
|
|
385
|
-
ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
|
|
400
|
+
ContextSources.project_location_for(target.project_root, project_id: target.project_id, state_dir: @state_dir)
|
|
386
401
|
else
|
|
387
402
|
ContextSources.session_location(target.session_id, state_dir: @state_dir)
|
|
388
403
|
end
|
|
@@ -513,9 +528,17 @@ module Samagotchi
|
|
|
513
528
|
def state_dir! = @state_dir || Session.default_state_dir
|
|
514
529
|
def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
|
|
515
530
|
|
|
531
|
+
def context_wake_recent?(subscription)
|
|
532
|
+
return false unless subscription.wakes_at
|
|
533
|
+
|
|
534
|
+
Time.now - Time.iso8601(subscription.wakes_at) < WorkerInbound::CONTEXT_WAKE_WINDOW
|
|
535
|
+
rescue ArgumentError
|
|
536
|
+
false
|
|
537
|
+
end
|
|
538
|
+
|
|
516
539
|
def attached_to(target, shadowed: false)
|
|
517
|
-
ContextSources.attached(target.session_id, project_root: target.project_root,
|
|
518
|
-
shadowed: shadowed)
|
|
540
|
+
ContextSources.attached(target.session_id, project_root: target.project_root, project_id: target.project_id,
|
|
541
|
+
state_dir: @state_dir, shadowed: shadowed)
|
|
519
542
|
end
|
|
520
543
|
|
|
521
544
|
# The block's post to the session's live worker, again on a 408 (the
|
|
@@ -809,7 +809,8 @@ module Samagotchi
|
|
|
809
809
|
owner = json["owner"] && OwnerLock::Owner.new(pid: nil, kind: json["owner"].to_s, started_at: nil)
|
|
810
810
|
SessionView.new(id: json["id"], status: json["status"], title: json["title"],
|
|
811
811
|
display_title: json["display_title"] || json["title"] || json["first_preview"].to_s,
|
|
812
|
-
project_root: json["project_root"],
|
|
812
|
+
project_root: json["project_root"], project_id: json["project_id"],
|
|
813
|
+
working_directory: json["working_directory"],
|
|
813
814
|
model_name: json["model_name"], model_typed: json["model_typed"],
|
|
814
815
|
parent_answers: json["parent_answers"], owner: owner,
|
|
815
816
|
pending_question: PendingQuestion.from_file(pending))
|
|
@@ -166,7 +166,10 @@ module Samagotchi
|
|
|
166
166
|
# @!attribute owner [OwnerLock::Owner, nil] its worker or one-turn chi run
|
|
167
167
|
# @!attribute pending_question [PendingQuestion, nil] the file's
|
|
168
168
|
SessionView = Data.define(:id, :status, :title, :display_title, :project_root, :working_directory, :model_name,
|
|
169
|
-
:model_typed, :parent_answers, :owner, :pending_question)
|
|
169
|
+
:model_typed, :parent_answers, :owner, :pending_question, :project_id) do
|
|
170
|
+
def initialize(id:, status:, title:, display_title:, project_root:, working_directory:, model_name:,
|
|
171
|
+
model_typed:, parent_answers:, owner:, pending_question:, project_id: nil) = super
|
|
172
|
+
end
|
|
170
173
|
|
|
171
174
|
# What chi send --new created (Remote; Local gives the Session): its
|
|
172
175
|
# id, a model warning, the session it continues, the delegates it took
|
|
@@ -473,7 +473,8 @@ module Samagotchi
|
|
|
473
473
|
|
|
474
474
|
peers = Tools::Peers.new(session_id: session.id, cwd: session.working_directory, state_dir: @engine.peer_state_dir)
|
|
475
475
|
listing = Tools::ContextRead.call("", peers: peers)
|
|
476
|
-
candidates = ContextCandidates.listing(session.id, project_root: session.project_root,
|
|
476
|
+
candidates = ContextCandidates.listing(session.id, project_root: session.project_root, project_id: session.project_id,
|
|
477
|
+
state_dir: @engine.peer_state_dir)
|
|
477
478
|
candidates ? "#{listing}\n\n#{candidates}" : listing
|
|
478
479
|
end
|
|
479
480
|
|
|
@@ -618,9 +619,12 @@ module Samagotchi
|
|
|
618
619
|
|
|
619
620
|
def approval_line(entry)
|
|
620
621
|
what = entry["key"] ? entry["key"].tr("\n", " ") : "any call rule #{entry["rule"]} asks about"
|
|
621
|
-
where = if entry["scope"] == "session"
|
|
622
|
+
where = if entry["scope"] == "session"
|
|
623
|
+
place = entry["repo_root"] ? " in #{entry["repo_root"]}" : ""
|
|
624
|
+
"session #{entry["session_id"].to_s[0, 8]}#{place}#{" with its env:" if entry["env"]}"
|
|
622
625
|
elsif entry["repo"] then "in repo #{entry["repo"]}"
|
|
623
|
-
else
|
|
626
|
+
else
|
|
627
|
+
"in #{entry["repo_root"]}"
|
|
624
628
|
end
|
|
625
629
|
rule = entry["rule"] ? " (rule #{entry["rule"]}, #{entry["source"]})" : ""
|
|
626
630
|
"#{entry["scope"]}: #{what} — #{where}#{rule}, #{entry["created_at"]}"
|
|
@@ -91,6 +91,9 @@ module Samagotchi
|
|
|
91
91
|
# @param prompt [String, nil] its first message (nil: it starts idle)
|
|
92
92
|
# @param preview [String, nil] what the lists show before the first turn;
|
|
93
93
|
# by default the prompt, else the previous link's preview
|
|
94
|
+
# @param client_id [String, nil] who sends +prompt+ when no person does
|
|
95
|
+
# (a schedule's chain firing: ClientId.schedule), the first turn's
|
|
96
|
+
# origin (spawn_session's client_id:)
|
|
94
97
|
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
95
98
|
# #moved_children: the delegates it took over, #moved_context: the
|
|
96
99
|
# attached context it took (ContextMove), #opener:
|
|
@@ -99,7 +102,8 @@ module Samagotchi
|
|
|
99
102
|
# @raise [Refused] continued already, delegates open that can't
|
|
100
103
|
# move, or its folder is gone
|
|
101
104
|
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
102
|
-
def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5
|
|
105
|
+
def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5,
|
|
106
|
+
client_id: nil)
|
|
103
107
|
sd = state_dir || Session.default_state_dir
|
|
104
108
|
TRIES.times do |try|
|
|
105
109
|
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
@@ -108,7 +112,7 @@ module Samagotchi
|
|
|
108
112
|
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
109
113
|
|
|
110
114
|
run_locked(id, prompt: prompt, preview: preview, model: model, state_dir: sd, recap_wait: recap_wait,
|
|
111
|
-
archive_wait: archive_wait)
|
|
115
|
+
archive_wait: archive_wait, client_id: client_id)
|
|
112
116
|
end
|
|
113
117
|
return result unless result == :moved
|
|
114
118
|
end
|
|
@@ -123,7 +127,7 @@ module Samagotchi
|
|
|
123
127
|
end
|
|
124
128
|
end
|
|
125
129
|
|
|
126
|
-
private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:)
|
|
130
|
+
private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:, client_id:)
|
|
127
131
|
previous = Session.load(id, state_dir: state_dir)
|
|
128
132
|
checked = check!(previous, state_dir, model: model)
|
|
129
133
|
plan = checked.plan
|
|
@@ -140,7 +144,7 @@ module Samagotchi
|
|
|
140
144
|
archived = archive.archived - already
|
|
141
145
|
begin
|
|
142
146
|
spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
|
|
143
|
-
move: plan.move)
|
|
147
|
+
move: plan.move, client_id: client_id)
|
|
144
148
|
rescue StandardError
|
|
145
149
|
# Nothing started: the delegates go back, what this archived goes
|
|
146
150
|
# back to the lists, and a new link saved before its worker failed
|
|
@@ -201,7 +205,7 @@ module Samagotchi
|
|
|
201
205
|
|
|
202
206
|
# The new link, holding off any worker but its own (StartingMarker.fresh?)
|
|
203
207
|
# while +move+ goes to it.
|
|
204
|
-
private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:)
|
|
208
|
+
private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:, client_id: nil)
|
|
205
209
|
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
206
210
|
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
207
211
|
preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
|
|
@@ -218,7 +222,7 @@ module Samagotchi
|
|
|
218
222
|
prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
|
|
219
223
|
model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
|
|
220
224
|
continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
|
|
221
|
-
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
225
|
+
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn, client_id: client_id
|
|
222
226
|
)
|
|
223
227
|
link.moved_children = move
|
|
224
228
|
link.moved_context = moved_context
|
|
@@ -54,12 +54,16 @@ module Samagotchi
|
|
|
54
54
|
# @param scratch [Boolean] a `chi scratch` session (its terminal's
|
|
55
55
|
# AttachLauncher): its worker runs a scratch Engine and deletes it
|
|
56
56
|
# once its last client is gone
|
|
57
|
+
# @param client_id [String, nil] who sent +prompt+ when no person did
|
|
58
|
+
# (a ClientId: a delegating parent, a schedule's chain link): its
|
|
59
|
+
# first turn's origin (Session#first_turn_client_id)
|
|
57
60
|
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
58
61
|
# and #model_warning: an id its host's saved list doesn't have
|
|
59
62
|
def spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
60
63
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
61
64
|
preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
|
|
62
|
-
note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false
|
|
65
|
+
note_source: "chi", before_spawn: nil, review: nil, verify_fresh: nil, scratch: false,
|
|
66
|
+
client_id: nil)
|
|
63
67
|
sd = state_dir || Session.default_state_dir
|
|
64
68
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
65
69
|
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
@@ -98,7 +102,10 @@ module Samagotchi
|
|
|
98
102
|
# for it can name it (chi send --new --wait, chi web's create): the
|
|
99
103
|
# worker runs it with that origin, and its reply file and last_turn
|
|
100
104
|
# carry the id.
|
|
101
|
-
|
|
105
|
+
unless prompt.to_s.strip.empty?
|
|
106
|
+
session.first_turn_id = SecureRandom.uuid
|
|
107
|
+
session.first_turn_client_id = client_id
|
|
108
|
+
end
|
|
102
109
|
# The worker takes last_prompt and clears it, and messages are saved at
|
|
103
110
|
# the turn's end: until then this is the only preview a list has.
|
|
104
111
|
session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
|
|
@@ -155,23 +162,37 @@ module Samagotchi
|
|
|
155
162
|
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
156
163
|
# :scratch, :owned, :starting)
|
|
157
164
|
def wake_for_report(session_id, state_dir: nil, forward: true)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return :stopped if Session.stopped_marker?(session_id, state_dir: sd)
|
|
165
|
+
wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: forward,
|
|
166
|
+
event: :delegate_woke_parent)
|
|
167
|
+
end
|
|
162
168
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
# A pushed context source asked for a wake: start an idle-exited
|
|
170
|
+
# session, but never clear a stop or forward an archived link.
|
|
171
|
+
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
172
|
+
# :scratch, :owned, :starting)
|
|
173
|
+
def wake_for_context(session_id, state_dir: nil)
|
|
174
|
+
wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: false,
|
|
175
|
+
event: :context_woke_session)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private def wake_existing_session(session_id, state_dir:, forward:, event:)
|
|
179
|
+
return :gone unless Session.exist?(session_id, state_dir: state_dir)
|
|
180
|
+
return :owned if session_owner(session_id, state_dir: state_dir)
|
|
181
|
+
return :stopped if Session.stopped_marker?(session_id, state_dir: state_dir)
|
|
182
|
+
|
|
183
|
+
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
|
|
184
|
+
following = forward && SessionChain.next_of(session_id, state_dir: state_dir)
|
|
185
|
+
return following ? wake_existing_session(following, state_dir: state_dir, forward: false, event: event) : :archived
|
|
166
186
|
end
|
|
167
|
-
return :starting if StartingMarker.fresh?(session_id, state_dir:
|
|
187
|
+
return :starting if StartingMarker.fresh?(session_id, state_dir: state_dir)
|
|
168
188
|
|
|
169
|
-
session = Session.load(session_id, state_dir:
|
|
189
|
+
session = Session.load(session_id, state_dir: state_dir)
|
|
170
190
|
return :stopped if session.status == Session::STATUS_STOPPED
|
|
171
191
|
return :scratch if session.scratch
|
|
172
192
|
|
|
173
|
-
resume_session(session_id, state_dir:
|
|
174
|
-
|
|
193
|
+
resume_session(session_id, state_dir: state_dir)
|
|
194
|
+
details = event == :delegate_woke_parent ? { parent: session_id[0, 8] } : { sid: session_id[0, 8] }
|
|
195
|
+
Log.info(:worker, event, **details)
|
|
175
196
|
:woken
|
|
176
197
|
rescue OwnedByTUI
|
|
177
198
|
:owned
|
|
@@ -3,10 +3,10 @@
|
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# (WorkerBoot.run_session_loop(id, state_dir:)), the env keys
|
|
7
|
+
# spawn_options sets and the input/ file format are read by a NEWER chi
|
|
8
|
+
# than the one writing them. Change them only additively
|
|
9
|
+
# (spec/worker_contract_spec.rb).
|
|
10
10
|
BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
|
|
11
11
|
# "1" in a worker spawn_options started: it traps signals (WorkerSignals).
|
|
12
12
|
# WorkerBoot.run_session_loop deletes it from ENV as it reads it.
|
|
@@ -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
|
|
32
|
-
#
|
|
33
|
-
#
|
|
31
|
+
# WorkerBoot (worker_boot.rb), which the boot command loads directly.
|
|
32
|
+
# Calls no other part. Meant to be loaded through
|
|
33
|
+
# 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,18 +94,6 @@ 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
|
|
99
|
-
# WorkerBoot.run_session_loop, which runs the worker process. Looked up
|
|
100
|
-
# by name on purpose: a constant reference here would put SessionManager
|
|
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).
|
|
104
|
-
def run_session_loop(session_id, state_dir: nil, **)
|
|
105
|
-
require_relative "../worker_boot"
|
|
106
|
-
Samagotchi.const_get(:WorkerBoot).run_session_loop(session_id, state_dir: state_dir, **)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
97
|
# The worker's command line. Run from an installed gem, the worker
|
|
110
98
|
# activates the newest installed samagotchi (`>= 0.a`, prereleases
|
|
111
99
|
# count), whoever spawns it: an old chi web, chi send or --attach then
|
|
@@ -118,8 +106,8 @@ module Samagotchi
|
|
|
118
106
|
# there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
|
|
119
107
|
def worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
|
|
120
108
|
home: ChiHome.current(gem_spec: gem_spec))
|
|
121
|
-
boot = "require 'samagotchi/
|
|
122
|
-
"Samagotchi::
|
|
109
|
+
boot = "require 'samagotchi/worker_boot'; " \
|
|
110
|
+
"Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
|
|
123
111
|
return [RbConfig.ruby, "-I", File.expand_path("../..", __dir__), "-e", boot] unless gem_spec
|
|
124
112
|
return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
|
|
125
113
|
|
|
@@ -146,9 +134,9 @@ module Samagotchi
|
|
|
146
134
|
else
|
|
147
135
|
"gem 'samagotchi', '>= 0.a'"
|
|
148
136
|
end
|
|
149
|
-
home.boot_argv(run: "begin; #{activate}; require 'samagotchi/
|
|
137
|
+
home.boot_argv(run: "begin; #{activate}; require 'samagotchi/worker_boot'; " \
|
|
150
138
|
"#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
|
|
151
|
-
"Samagotchi::
|
|
139
|
+
"Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
|
|
152
140
|
end
|
|
153
141
|
end
|
|
154
142
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A versioned JSON state-file schema. Missing members must be required or
|
|
5
|
+
# declare the format version and default that introduced them.
|
|
6
|
+
class StateFormat
|
|
7
|
+
REQUIRED = Object.new.freeze
|
|
8
|
+
|
|
9
|
+
Member = Data.define(:name, :introduced_in, :default, :persisted) do
|
|
10
|
+
def required? = introduced_in.nil?
|
|
11
|
+
def defaulted? = !required?
|
|
12
|
+
def persisted? = persisted
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Record = Data.define(:attributes, :extra, :raw, :missing) do
|
|
16
|
+
def readable? = missing.empty?
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
return raw unless readable?
|
|
20
|
+
|
|
21
|
+
extra.transform_keys(&:to_s).merge(attributes.transform_keys(&:to_s))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Document = Data.define(:version, :extra, :entries)
|
|
26
|
+
|
|
27
|
+
class InvalidDocument < StandardError; end
|
|
28
|
+
|
|
29
|
+
def self.required(name)
|
|
30
|
+
Member.new(name: name.to_sym, introduced_in: nil, default: REQUIRED, persisted: true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.added(name, in_version:, default:)
|
|
34
|
+
Member.new(name: name.to_sym, introduced_in: in_version, default: default, persisted: true)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.extra(name, in_version:)
|
|
38
|
+
Member.new(name: name.to_sym, introduced_in: in_version, default: ->(_attributes) { {} }, persisted: false)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :members
|
|
42
|
+
|
|
43
|
+
def initialize(version:, members:)
|
|
44
|
+
@version = version
|
|
45
|
+
@members = members.freeze
|
|
46
|
+
@extra_member = members.find { |member| !member.persisted? }
|
|
47
|
+
raise ArgumentError, "a state format can declare only one extra member" if members.count { |member| !member.persisted? } > 1
|
|
48
|
+
raise ArgumentError, "duplicate state format members" unless members.map(&:name).uniq == members.map(&:name)
|
|
49
|
+
|
|
50
|
+
members.each do |member|
|
|
51
|
+
required = member.required? && member.default.equal?(REQUIRED)
|
|
52
|
+
defaulted = member.defaulted? && member.introduced_in.is_a?(Integer) &&
|
|
53
|
+
member.introduced_in.positive? && member.introduced_in <= version &&
|
|
54
|
+
!member.default.equal?(REQUIRED)
|
|
55
|
+
next if required || defaulted
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "#{member.name} must be required or have an introduction version and default"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read(data, collection:)
|
|
62
|
+
raise InvalidDocument, "the state file must contain a JSON object" unless data.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
collection = collection.to_s
|
|
65
|
+
entries = Array(data[collection]).map { |raw| read_record(raw) }
|
|
66
|
+
known = %w[version updated_at] + [collection]
|
|
67
|
+
stored_version = data["version"]
|
|
68
|
+
stored_version = 0 unless stored_version.is_a?(Integer) && stored_version >= 0
|
|
69
|
+
|
|
70
|
+
Document.new(version: [@version, stored_version].max,
|
|
71
|
+
extra: data.reject { |key, _value| known.include?(key.to_s) },
|
|
72
|
+
entries: entries)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write(document, collection:, updated_at:)
|
|
76
|
+
document.extra.transform_keys(&:to_s).merge(
|
|
77
|
+
"version" => [document.version, @version].max,
|
|
78
|
+
"updated_at" => updated_at,
|
|
79
|
+
collection.to_s => document.entries.map(&:to_h)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def read_record(raw)
|
|
86
|
+
unless raw.is_a?(Hash)
|
|
87
|
+
missing = members.select(&:required?).map(&:name)
|
|
88
|
+
return Record.new(attributes: {}, extra: {}, raw: raw, missing: missing)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
attributes = {}
|
|
92
|
+
missing = []
|
|
93
|
+
members.each do |member|
|
|
94
|
+
next unless member.persisted?
|
|
95
|
+
|
|
96
|
+
key = member.name.to_s
|
|
97
|
+
if raw.key?(key)
|
|
98
|
+
attributes[member.name] = raw[key]
|
|
99
|
+
elsif member.required?
|
|
100
|
+
missing << member.name
|
|
101
|
+
else
|
|
102
|
+
attributes[member.name] = default_value(member.default, attributes)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
known = members.select(&:persisted?).map { |member| member.name.to_s }
|
|
107
|
+
extra = raw.reject { |key, _value| known.include?(key.to_s) }
|
|
108
|
+
extra = default_value(@extra_member.default, attributes).merge(extra) if @extra_member
|
|
109
|
+
Record.new(attributes: attributes, extra: extra, raw: raw, missing: missing)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def default_value(default, attributes)
|
|
113
|
+
value = default.respond_to?(:call) ? default.call(attributes) : default
|
|
114
|
+
value = value.dup if value.is_a?(Hash) || value.is_a?(Array)
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -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.",
|
|
@@ -47,6 +47,8 @@ module Samagotchi
|
|
|
47
47
|
"web_fetch" => { content: "url", fallback: :prefix_or_raw },
|
|
48
48
|
"register_reminder" => { content: "name", fallback: :prefix_or_raw },
|
|
49
49
|
"cancel_reminder" => { content: "name", fallback: :prefix_or_raw },
|
|
50
|
+
"schedule_task" => { content: "prompt", fallback: :prefix_or_raw },
|
|
51
|
+
"cancel_schedule" => { content: "name", fallback: :prefix_or_raw },
|
|
50
52
|
"list_sessions" => { fallback: :prefix, fallback_key: "cwd" },
|
|
51
53
|
"send_note" => { content: "text" },
|
|
52
54
|
"context_read" => { content: "name", fallback: :prefix_or_raw },
|
|
@@ -18,6 +18,9 @@ require_relative "web_fetch"
|
|
|
18
18
|
require_relative "register_reminder"
|
|
19
19
|
require_relative "cancel_reminder"
|
|
20
20
|
require_relative "list_reminders"
|
|
21
|
+
require_relative "schedule_task"
|
|
22
|
+
require_relative "list_schedules"
|
|
23
|
+
require_relative "cancel_schedule"
|
|
21
24
|
require_relative "list_sessions"
|
|
22
25
|
require_relative "send_note"
|
|
23
26
|
require_relative "context_read"
|
|
@@ -54,6 +57,9 @@ module Samagotchi
|
|
|
54
57
|
RegisterReminder,
|
|
55
58
|
CancelReminder,
|
|
56
59
|
ListReminders,
|
|
60
|
+
ScheduleTask,
|
|
61
|
+
ListSchedules,
|
|
62
|
+
CancelSchedule,
|
|
57
63
|
ListSessions,
|
|
58
64
|
SendNote,
|
|
59
65
|
ContextRead,
|
|
@@ -103,6 +109,17 @@ module Samagotchi
|
|
|
103
109
|
end,
|
|
104
110
|
CancelReminder::NAME => ->(call, kctx) { CancelReminder.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
105
111
|
ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
112
|
+
ScheduleTask::NAME => lambda do |call, kctx|
|
|
113
|
+
ScheduleTask.call(call[:content], title: call[:title], prompt: call[:prompt], every: call[:every],
|
|
114
|
+
weekday: call[:weekday], time: call[:time], date: call[:date], mode: call[:mode],
|
|
115
|
+
keep_links: call[:keep_links], peers: kctx.peers)
|
|
116
|
+
end,
|
|
117
|
+
ListSchedules::NAME => lambda do |call, kctx|
|
|
118
|
+
ListSchedules.call(call[:content], include_disabled: call[:include_disabled], all: call[:all], peers: kctx.peers)
|
|
119
|
+
end,
|
|
120
|
+
CancelSchedule::NAME => lambda do |call, kctx|
|
|
121
|
+
CancelSchedule.call(call[:content], all: call[:all], peers: kctx.peers)
|
|
122
|
+
end,
|
|
106
123
|
ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
|
|
107
124
|
SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
|
|
108
125
|
ContextRead::NAME => lambda do |call, kctx|
|