samagotchi 0.158.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +7 -6
  5. data/docs/cli.md +56 -1
  6. data/docs/configuration.md +9 -0
  7. data/docs/context.md +7 -1
  8. data/docs/guardrails.md +3 -1
  9. data/docs/hooks.md +3 -0
  10. data/docs/internals/bridge.md +1 -0
  11. data/docs/internals/bundles.md +9 -7
  12. data/docs/memory.md +5 -1
  13. data/docs/scheduling.md +228 -0
  14. data/docs/sessions.md +25 -1
  15. data/lib/samagotchi/bridge.rb +26 -2
  16. data/lib/samagotchi/bundle_command.rb +38 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/client_id.rb +13 -3
  19. data/lib/samagotchi/config.rb +12 -0
  20. data/lib/samagotchi/context_move.rb +74 -0
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  22. data/lib/samagotchi/memory_bundle/asset_installer.rb +84 -103
  23. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  24. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  25. data/lib/samagotchi/memory_bundle/shipped_update.rb +17 -12
  26. data/lib/samagotchi/memory_bundle/status.rb +20 -1
  27. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  28. data/lib/samagotchi/run_options.rb +1 -0
  29. data/lib/samagotchi/schedule_command.rb +252 -0
  30. data/lib/samagotchi/schedules.rb +948 -0
  31. data/lib/samagotchi/session_access/contract.rb +1 -1
  32. data/lib/samagotchi/session_access/local.rb +20 -0
  33. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  34. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  35. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  36. data/lib/samagotchi/tool_declarations.rb +43 -0
  37. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  38. data/lib/samagotchi/tools/builtins.rb +17 -0
  39. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  40. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  41. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/public/chains.js +32 -8
  44. data/lib/samagotchi/web/public/index.html +4 -1
  45. data/lib/samagotchi/web/public/info_bar.js +4 -3
  46. data/lib/samagotchi/web/public/turn_events.js +4 -0
  47. data/lib/samagotchi/web/session_hub.rb +20 -0
  48. data/lib/samagotchi/worker.rb +24 -6
  49. data/lib/samagotchi/worker_boot.rb +3 -4
  50. data/lib/samagotchi/worker_inbound.rb +23 -0
  51. data/lib/samagotchi/worker_link.rb +5 -0
  52. data/lib/samagotchi/worker_wakes.rb +9 -1
  53. metadata +8 -1
@@ -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
 
@@ -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)
@@ -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
 
@@ -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[^:]*:/]
@@ -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|
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # Cancel a durable schedule of this session's chain by its id, a unique
6
+ # prefix of it, or its title (docs/scheduling.md). A schedule of another
7
+ # session's chain is refused unless the call says `all: true`: `chi
8
+ # schedule cancel` is the machine-wide way.
9
+ class CancelSchedule
10
+ NAME = "cancel_schedule"
11
+
12
+ def self.name = NAME
13
+
14
+ def self.call(content, all: false, peers: nil)
15
+ # Required here, not at the top of the file: the kit does not pay
16
+ # for the session lifecycle until a schedule is actually used.
17
+ require_relative "../schedules"
18
+ name = content.to_s.strip
19
+ return "Error: cancel_schedule needs the schedule's id or title" if name.empty?
20
+
21
+ want_all = all == true || all.to_s.strip.casecmp?("true")
22
+ schedules = Schedules.new(state_dir: peers&.state_dir)
23
+ entry = schedules.lookup(name)
24
+ return "No schedule named #{name}." if entry.nil?
25
+
26
+ if want_all || peers&.session_id.nil?
27
+ schedules.cancel(name)
28
+ return "Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
29
+ end
30
+
31
+ unless schedules.chain_of(peers.session_id).any? { |id| id == entry.anchor || id == entry.head }
32
+ return "Error: #{entry.id} belongs to another session's chain. " \
33
+ "chi schedule cancel #{entry.id} cancels it, or say all: true if you meant it."
34
+ end
35
+
36
+ schedules.cancel(name)
37
+ "Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
38
+ rescue StandardError => e
39
+ "Error: #{e.message}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # The durable schedules of this session's chain (docs/scheduling.md): id,
6
+ # mode, recurrence, next due and target. Another session's schedules are
7
+ # out of reach unless the call says `all: true`; `chi schedule list` is the
8
+ # machine-wide view. Schedules is required inside the call, so the testing
9
+ # kit does not pay for the session lifecycle at load time.
10
+ class ListSchedules
11
+ NAME = "list_schedules"
12
+
13
+ def self.name = NAME
14
+
15
+ def self.call(_content, include_disabled: false, all: false, peers: nil)
16
+ # Required here, not at the top of the file: the kit does not pay
17
+ # for the session lifecycle until a schedule is actually used.
18
+ require_relative "../schedules"
19
+ wanted = include_disabled == true || include_disabled.to_s.strip.casecmp?("true")
20
+ want_all = all == true || all.to_s.strip.casecmp?("true")
21
+ schedules = Samagotchi::Schedules.new(state_dir: peers&.state_dir)
22
+
23
+ scoped = !want_all && peers&.session_id
24
+ entries = scoped ? schedules.for_chain(peers.session_id, include_disabled: wanted) : schedules.entries(include_disabled: wanted)
25
+
26
+ if entries.empty?
27
+ scoped ? "No schedules in this session's chain. `chi schedule list` shows every schedule on this machine." : "No schedules."
28
+ else
29
+ lines = entries.map do |entry|
30
+ flags = []
31
+ flags << "skips #{entry.skips} of #{entry.max_skips}" if entry.skips.positive?
32
+ flags << "disabled after #{entry.failures} failures" unless entry.enabled
33
+ "#{entry.id} #{entry.target_words} #{entry.recurrence_words} next #{entry.next_due} #{flags.join(", ")}".rstrip
34
+ end
35
+ lines << "These are this session's chain only; `chi schedule list` shows every schedule on this machine." if scoped
36
+ lines.join("\n")
37
+ end
38
+ rescue StandardError => e
39
+ "Error: #{e.message}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # Register a durable schedule for this session (docs/scheduling.md): a
6
+ # prompt that runs on a clock, out of the worker's memory, until it is
7
+ # cancelled. The default mode is `chain`: each firing is the next link of
8
+ # this session's chain, so a long-running routine keeps a fresh context.
9
+ # Unlike register_reminder, a schedule needs no live worker — a tick run by
10
+ # cron (or chi web's hub) wakes one — and it never holds an idle worker up.
11
+ class ScheduleTask
12
+ NAME = "schedule_task"
13
+
14
+ def self.name = NAME
15
+
16
+ def self.call(content, title: nil, prompt: nil, every: nil, weekday: nil, time: nil, date: nil,
17
+ mode: nil, keep_links: nil, peers: nil)
18
+ # Required here, not at the top of the file: the kit does not pay
19
+ # for the session lifecycle until a schedule is actually used.
20
+ require_relative "../schedules"
21
+ return "Error: this session's id is not known here" unless peers&.session_id
22
+
23
+ prompt = content.to_s if prompt.to_s.strip.empty?
24
+ schedules = Schedules.new(state_dir: peers.state_dir)
25
+ entry = schedules.add(
26
+ target: peers.session_id, prompt: prompt, title: title,
27
+ recurrence: Schedules.recurrence(every: every, weekday: weekday, time: time, date: date),
28
+ mode: mode.nil? || mode.to_s.strip.empty? ? Schedules::CHAIN : mode.to_s.strip,
29
+ keep_links: keep_links
30
+ )
31
+ "Scheduled #{entry.id}: #{entry.target_words} · #{entry.recurrence_words} · next #{entry.next_due}. " \
32
+ "It fires outside this worker (chi schedule tick, run by cron or chi web's hub); " \
33
+ "chi schedule list and cancel_schedule show and stop it."
34
+ rescue Schedules::Usage, Session::NotFound, Session::AmbiguousId => e
35
+ "Error: #{e.message}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.158.0"
4
+ VERSION = "0.159.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -128,10 +128,21 @@ export function chainLinks(list, id, nexts = nextsOf(list), byId = new Map(list.
128
128
  return links;
129
129
  }
130
130
 
131
+ // Whether the chain's oldest link the page holds still names a link it does
132
+ // not: a schedule's prune (docs/scheduling.md, keep_links) or a delete. The
133
+ // walk stops there, so every count comes from the surviving root, and the page
134
+ // says "earlier links pruned" rather than linking to a session that is gone.
135
+ export function linksPruned(links, byId) {
136
+ const root = links[0];
137
+ return !!root?.continues && !byId.has(root.continues);
138
+ }
139
+
131
140
  // The session page's neighbours of +id+: {prev, next}, each {id, session}
132
- // or null. +continues+: the open session's own `continues`, when the list
133
- // may not have caught up with it. A previous link the list doesn't hold
134
- // (deleted) is null: its link would open a not-found page.
141
+ // or null, and +pruned+: the session's own `continues` names a link the list
142
+ // does not hold (a schedule pruned it, or it was deleted). +continues+: the
143
+ // open session's own `continues`, when the list may not have caught up with it.
144
+ // A previous link the list doesn't hold is null: its link would open a
145
+ // not-found page.
135
146
  export function chainNeighbours(list, id, { continues = null } = {}) {
136
147
  const self = list.find((s) => s.id === id);
137
148
  const prevId = self?.continues || continues || null;
@@ -139,7 +150,7 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
139
150
  const prev = prevSession ? { id: prevId, session: prevSession } : null;
140
151
  const nextSession = nextsOf(list).get(id) || null;
141
152
  const next = nextSession ? { id: nextSession.id, session: nextSession } : null;
142
- return { prev, next };
153
+ return { prev, next, pruned: !!prevId && !prevSession };
143
154
  }
144
155
 
145
156
  const pad = (n) => String(n).padStart(2, "0");
@@ -188,14 +199,22 @@ export function foldChains(fams, all, { fold = true } = {}) {
188
199
  let changed = false;
189
200
  for (const f of fams) {
190
201
  const links = chainLinks(all, f.head.id, nexts, byId);
202
+ const pruned = linksPruned(links, byId);
191
203
  if (links.length < 2) {
192
- out.push(f);
204
+ // A chain pruned to one link is still a chain: its card carries the chip
205
+ // and the popover says what is behind it.
206
+ if (pruned) {
207
+ changed = true;
208
+ out.push({ ...f, chain: { links, at: 0, folded: fold, pruned } });
209
+ } else {
210
+ out.push(f);
211
+ }
193
212
  continue;
194
213
  }
195
214
  changed = true;
196
215
  const at = links.findIndex((s) => s.id === f.head.id);
197
216
  if (fold && links.slice(at + 1).some((s) => heads.has(s.id))) continue;
198
- out.push({ ...f, chain: { links, at, folded: fold } });
217
+ out.push({ ...f, chain: { links, at, folded: fold, pruned } });
199
218
  }
200
219
  return changed ? out : fams;
201
220
  }
@@ -233,7 +252,8 @@ export function chainChipTemplate(f, { open = false } = {}) {
233
252
  const text = n === links.length ? `#${n}` : `#${n} of ${links.length}`;
234
253
  const earlier = at;
235
254
  const later = links.length - 1 - at;
236
- const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
255
+ const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : "",
256
+ f.chain.pruned ? "earlier links pruned" : ""].filter(Boolean);
237
257
  const title = `Session chain: link ${n} of ${links.length} (${parts.join(", ")}); open the list`;
238
258
  const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
239
259
  const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
@@ -275,7 +295,11 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
275
295
  date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
276
296
  rowBadgesTemplate(waiting, stopped, s.status)}${s.archived ? archivedBadge : nothing}</button></div>`;
277
297
  });
278
- return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
298
+ const prunedRow = f?.chain?.pruned
299
+ ? html`<div class="chain-row chain-pruned" role="listitem"><span class="cr-n" aria-hidden="true">↩</span>
300
+ <span class="fr-preview">earlier links pruned</span></div>`
301
+ : nothing;
302
+ return html`<div class="family-rows chain-rows" role="list">${rows}${prunedRow}</div>`;
279
303
  }
280
304
 
281
305
  // What the page does with a refused continue (POST /api/sessions
@@ -1384,7 +1384,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1384
1384
  /* One run: the groups' chips are the meta's. */
1385
1385
  #infoBar .meta .bar-group{display:contents}
1386
1386
  #infoBar .meta .bar-sep{display:none}
1387
- #infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
1387
+ #infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.chain-pruned,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
1388
1388
  #infoBar .info-actions{display:none}
1389
1389
  #infoBar .meta .status+.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
1390
1390
  /* ⚙ alone, the start page's dot when the session set its own (its values in the tooltip and accessible name). */
@@ -1577,6 +1577,9 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1577
1577
  .chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
1578
1578
  /* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
1579
1579
  .chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
1580
+ /* A chain pruned by a schedule (docs/scheduling.md): the popover's last line and the info bar say the earlier links are gone. */
1581
+ .chain-row.chain-pruned,.chain-row.chain-pruned .cr-n{color:var(--text-muted)}
1582
+ #infoBar .meta .chain-pruned{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap}
1580
1583
  /* The session page's links to the previous and the next link of its chain. */
1581
1584
  #infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
1582
1585
  @media(hover:hover){#infoBar .meta a.chain-link:hover{background:var(--accent-bg)}}
@@ -98,10 +98,11 @@ export function infoBarHtml(bar) {
98
98
  if (!bar) return nothing;
99
99
  const memories = bar.memories || [];
100
100
  const setup = html`<button type="button" class="id" title=${`Session id ${bar.id}: click to copy it`}>${String(bar.id).slice(0, 8)}</button><span class=${bar.status ? `status ${bar.status}` : "status"}><span>${bar.status || ""}</span></span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${settingsChip(bar.settings)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}`;
101
- const related = !!(bar.chain?.prev || bar.chain?.next || bar.parent || bar.kids || bar.participants);
102
- // The parent reads "↑ <short id>"; its title says "delegated by …".
101
+ const related = !!(bar.chain?.prev || bar.chain?.next || bar.chain?.pruned || bar.parent || bar.kids || bar.participants);
102
+ // A chain pruned by a schedule (docs/scheduling.md): the link before this one
103
+ // is gone, so there is nothing to link to — the bar says so instead.
103
104
  const relations = related
104
- ? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
105
+ ? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.pruned && !bar.chain?.prev ? html`<span class="model chain-pruned" title="The links before this one were pruned by a schedule">↩ earlier links pruned</span>` : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
105
106
  : nothing;
106
107
  const telemetry = html`<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class=${`model speed${bar.speed.empty ? " empty" : ""}`} title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}<span class=${`model session-time${bar.clock ? "" : " empty"}`} title=${bar.clock ? "This session's time: its turns, and the running one" : "This session's time: no turn yet"}>${bar.clock || CLOCK_NONE}</span>${workerBadgeTemplate(bar.worker)}`;
107
108
  return html`<div class="meta"><span class="bar-group setup">${setup}</span>${relations}<span class="bar-sep" aria-hidden="true"></span><span class="bar-group telemetry">${telemetry}</span></div>`;
@@ -133,6 +133,9 @@ const CONTEXT_CLIENT = "context:";
133
133
  // A turn chi ran because a background task the session started finished
134
134
  // unseen: task:<task id> (the Worker's finish wake).
135
135
  const TASK_CLIENT = "task:";
136
+ // A durable schedule's firing (chi schedule, the schedule tools):
137
+ // system:schedule:<schedule id>.
138
+ const SCHEDULE_CLIENT = "system:schedule:";
136
139
 
137
140
  // A task id as people read it: the random part a task_create id ends with
138
141
  // (it starts with the time), else its first 8 chars (TaskRecord.short_id).
@@ -149,6 +152,7 @@ export function clientLabel(clientId) {
149
152
  const id = String(clientId);
150
153
  if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
151
154
  if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
155
+ if (id.startsWith(SCHEDULE_CLIENT)) return "schedule";
152
156
  const colon = id.indexOf(":");
153
157
  return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
154
158
  }
@@ -6,6 +6,7 @@ require "time"
6
6
 
7
7
  require_relative "../session"
8
8
  require_relative "../session_manager"
9
+ require_relative "../schedules"
9
10
  require_relative "../bridge_client"
10
11
  require_relative "../worker_link"
11
12
  require_relative "../config"
@@ -105,6 +106,7 @@ module Samagotchi
105
106
  @dir_mtime = nil # the state dir's mtime at the last glob
106
107
  @root_cache = {} # folder → project root, for sessions saved before project_root
107
108
  @next_full_probe = nil
109
+ @next_schedule_tick = nil
108
110
  @wakeup = Queue.new # stop wakes the tick thread out of its sleep
109
111
  @thread = nil
110
112
  @stopped = false
@@ -148,6 +150,8 @@ module Samagotchi
148
150
  check_installed
149
151
  end
150
152
  end
153
+ # Outside the projection's lock: a firing can wait for a recap.
154
+ schedule_tick
151
155
  nil
152
156
  end
153
157
 
@@ -314,6 +318,22 @@ module Samagotchi
314
318
  nil
315
319
  end
316
320
 
321
+ # chi web can be the scheduler when the user opts in (schedule.auto_tick):
322
+ # the same tick cron runs, gated by schedule.tick_interval_minutes and
323
+ # sharing schedules.lock, so cron and the hub never both fire.
324
+ def schedule_tick
325
+ return unless Config.get("schedule.auto_tick")
326
+
327
+ now = @now.call
328
+ return if @next_schedule_tick && now < @next_schedule_tick
329
+
330
+ @next_schedule_tick = now + (Samagotchi::Schedules.tick_interval_minutes * 60)
331
+ Samagotchi::Schedules.new(state_dir: @state_dir).tick
332
+ rescue StandardError => e
333
+ Log.warn(:web, "schedule_tick_failed", error: e.class.name, msg: e.message)
334
+ nil
335
+ end
336
+
317
337
  # A change (the first look too, unless it finds nothing) is a `chi`
318
338
  # event: chi web's terminal line and the open tabs hear it.
319
339
  def check_installed