samagotchi 0.157.0 → 0.159.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +1 -1
  4. data/bin/chi +5 -1
  5. data/docs/architecture.md +14 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +76 -17
  9. data/docs/configuration.md +16 -5
  10. data/docs/context.md +12 -4
  11. data/docs/guardrails.md +11 -8
  12. data/docs/hooks.md +47 -80
  13. data/docs/internals/bridge.md +2 -1
  14. data/docs/internals/bundles.md +51 -36
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +43 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/scheduling.md +228 -0
  20. data/docs/sessions.md +30 -6
  21. data/docs/testing.md +4 -4
  22. data/lib/samagotchi/bridge/card_places.rb +5 -13
  23. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  24. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  25. data/lib/samagotchi/bridge.rb +26 -2
  26. data/lib/samagotchi/bundle_command/check.rb +23 -10
  27. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  28. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  29. data/lib/samagotchi/bundle_command.rb +53 -44
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/client_id.rb +13 -3
  35. data/lib/samagotchi/config.rb +12 -0
  36. data/lib/samagotchi/context_command.rb +7 -5
  37. data/lib/samagotchi/context_move.rb +74 -0
  38. data/lib/samagotchi/context_sources.rb +4 -2
  39. data/lib/samagotchi/engine.rb +1 -6
  40. data/lib/samagotchi/extension_load.rb +20 -41
  41. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  42. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  43. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  44. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  45. data/lib/samagotchi/hook_routes.rb +2 -2
  46. data/lib/samagotchi/hooks.rb +2 -3
  47. data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
  48. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  49. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  50. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  51. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  52. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  54. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  55. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  56. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  57. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  58. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  59. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
  61. data/lib/samagotchi/memory_bundle/status.rb +30 -9
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  63. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  64. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  65. data/lib/samagotchi/memory_orphans.rb +4 -4
  66. data/lib/samagotchi/memory_paths.rb +102 -13
  67. data/lib/samagotchi/plugin/api.rb +1 -4
  68. data/lib/samagotchi/plugin/loader.rb +42 -5
  69. data/lib/samagotchi/project_command.rb +167 -0
  70. data/lib/samagotchi/project_init.rb +69 -0
  71. data/lib/samagotchi/project_scope.rb +6 -4
  72. data/lib/samagotchi/prompt_files.rb +10 -7
  73. data/lib/samagotchi/recap_place.rb +3 -4
  74. data/lib/samagotchi/run_options.rb +2 -0
  75. data/lib/samagotchi/schedule_command.rb +252 -0
  76. data/lib/samagotchi/schedules.rb +948 -0
  77. data/lib/samagotchi/session.rb +44 -18
  78. data/lib/samagotchi/session_access/contract.rb +1 -1
  79. data/lib/samagotchi/session_access/local.rb +20 -0
  80. data/lib/samagotchi/session_field.rb +5 -4
  81. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  82. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  83. data/lib/samagotchi/steer.rb +10 -0
  84. data/lib/samagotchi/system_prompt.rb +20 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  86. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  87. data/lib/samagotchi/testing/bundle.rb +5 -12
  88. data/lib/samagotchi/testing/install.rb +5 -5
  89. data/lib/samagotchi/testing/recorder.rb +1 -1
  90. data/lib/samagotchi/testing.rb +1 -1
  91. data/lib/samagotchi/tool_declarations.rb +44 -1
  92. data/lib/samagotchi/toolkit_binding.rb +1 -4
  93. data/lib/samagotchi/toolkit_command.rb +1 -4
  94. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  95. data/lib/samagotchi/tools/builtins.rb +17 -0
  96. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  97. data/lib/samagotchi/tools/delegate.rb +12 -8
  98. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  99. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +35 -0
  102. data/lib/samagotchi/web/project_list.rb +3 -2
  103. data/lib/samagotchi/web/public/chains.js +32 -8
  104. data/lib/samagotchi/web/public/index.html +10 -2
  105. data/lib/samagotchi/web/public/info_bar.js +4 -3
  106. data/lib/samagotchi/web/public/project_picker.js +86 -4
  107. data/lib/samagotchi/web/public/turn_events.js +4 -0
  108. data/lib/samagotchi/web/session_hub.rb +20 -0
  109. data/lib/samagotchi/worker.rb +24 -6
  110. data/lib/samagotchi/worker_boot.rb +3 -4
  111. data/lib/samagotchi/worker_inbound.rb +23 -0
  112. data/lib/samagotchi/worker_link.rb +5 -0
  113. data/lib/samagotchi/worker_wakes.rb +9 -1
  114. metadata +10 -2
  115. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -19,9 +19,10 @@ module Samagotchi
19
19
  module_function
20
20
 
21
21
  # The AGENTS.md files a session in +cwd+ loads, as absolute paths,
22
- # outermost first: inside a git work tree every one from its top (a
23
- # linked worktree's own checkout) down to the cwd; outside a repository
24
- # only the cwd's ([] with none).
22
+ # outermost first: inside a project every one from its top
23
+ # (MemoryPaths.project_top: a git work tree's, a linked worktree's own
24
+ # checkout, or a folder marked by `chi project init`) down to the cwd;
25
+ # outside a project only the cwd's ([] with none).
25
26
  # @param cwd [String] where the prompt is built (Dir.pwd, not the
26
27
  # session's working directory: /tmp vs /private/tmp would change the
27
28
  # prompt bytes)
@@ -33,10 +34,10 @@ module Samagotchi
33
34
  # The sections of the AGENTS.md chain (#agents_paths) with text: the
34
35
  # first keeps the plain header (a one-file session's prompt is as it
35
36
  # always was), each further one names its directory relative to the
36
- # work tree top ("Project specific description for pkg/:").
37
+ # project's top ("Project specific description for pkg/:").
37
38
  # @return [Array<Section>]
38
39
  def agents_sections(cwd)
39
- top = MemoryPaths.work_tree_root(cwd)
40
+ top = MemoryPaths.project_top(cwd)
40
41
  sections = []
41
42
  agents_paths(cwd).each do |path|
42
43
  dir = File.dirname(path)
@@ -47,10 +48,12 @@ module Samagotchi
47
48
  sections
48
49
  end
49
50
 
50
- # The directories whose AGENTS.md a session in +cwd+ reads, outermost first.
51
+ # The directories whose AGENTS.md a session in +cwd+ reads, outermost
52
+ # first: from its project's top (the work tree's, or the marked root)
53
+ # down to +cwd+.
51
54
  def chain_dirs(cwd)
52
55
  cwd = File.expand_path(cwd)
53
- top = MemoryPaths.work_tree_root(cwd)
56
+ top = MemoryPaths.project_top(cwd)
54
57
  return [cwd] unless top
55
58
 
56
59
  rel = relative_dir(cwd, top)
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "steer"
4
- require_relative "bridge/card_places"
5
4
 
6
5
  module Samagotchi
7
6
  # Where a saved recap sits among the turns a page draws: after the last
8
7
  # turn opening it covers (Steer.turn_opening?, the rule its turns_since
9
8
  # count goes by). Its stamp is that opening's id, as a card's is
10
- # (Bridge::CardPlaces.stamp: a prompt's turn_id, a wake note's, a
11
- # /continue no reason line's opening_id), so the page finds the bubble by
9
+ # (Steer.opening_stamp: a prompt's turn_id, a wake note's, a /continue no
10
+ # reason line's opening_id), so the page finds the bubble by
12
11
  # id, not by counting back (a rollback or a check-in could make the count
13
12
  # land a turn off). Read at read time from recap.json's `covered`: the file
14
13
  # is unchanged, an old one gets a stamp too. Pure.
@@ -21,7 +20,7 @@ module Samagotchi
21
20
  # messages; nil for none, or one with no id (a line an older chi wrote)
22
21
  def opening_id(messages, covered)
23
22
  opening = Array(messages).first([covered.to_i, 0].max).reverse_each.find { |message| Steer.turn_opening?(message) }
24
- opening && Bridge::CardPlaces.stamp(opening)
23
+ opening && Steer.opening_stamp(opening)
25
24
  end
26
25
  end
27
26
  end
@@ -213,6 +213,8 @@ module Samagotchi
213
213
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
214
214
  chi models [--format json] [TEXT] the models --model takes, from every host
215
215
  chi home [set [--ruby PATH] | doctor] the Ruby every chi launch boots
216
+ chi project [init [DIR]] this folder's project; init makes a folder in no git repository one
217
+ chi schedule <add|list|show|tick|run-now|enable|disable|cancel> durable schedules that outlive every worker
216
218
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
217
219
  chi --remote NAME <send|answer|note|context|sessions|share|review> … those commands on that machine (or CHI_REMOTE=NAME)
218
220
  chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "cli/command"
7
+ require_relative "cli/exit"
8
+ require_relative "cli/flags"
9
+ require_relative "schedules"
10
+ require_relative "session"
11
+
12
+ module Samagotchi
13
+ # `chi schedule`: durable schedules that outlive every worker (docs/scheduling.md).
14
+ # A tick run by cron, systemd or chi web's hub (`chi schedule tick`) fires the
15
+ # entries whose time has come against a session: the next link of its chain
16
+ # (the default), or a queued turn in the session itself.
17
+ # bin/chi dispatches here before OptionParser; this machine only.
18
+ class ScheduleCommand
19
+ include CLI::Command
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi schedule <add|list|show|tick|run-now|enable|disable|cancel> [flags]
23
+
24
+ add --session ID (--continues ID) (-m|--message|--prompt TEXT) [--title TEXT]
25
+ one of: --every 30m | --weekday 1 --time 09:00 | --date 2026-11-20T09:00
26
+ [--mode chain|same] [--catch-up once|none] [--keep-links N] [--max-failures N] [--max-skips N]
27
+ list [--all] [--format text|json]
28
+ show ID
29
+ tick [--dry-run] [--format text|json]
30
+ run-now ID
31
+ enable ID | disable ID | cancel ID
32
+
33
+ Recurrence: --every 45m / 2h / 1d (UTC interval math: an `every 1d` schedule drifts
34
+ an hour across a DST change — use --weekday for a wall-clock time);
35
+ --weekday 1..7 (1=Mon) with --time HH:MM in the machine's local zone;
36
+ --date one local moment.
37
+ Modes: chain (default) runs the prompt as the next link of the session's chain;
38
+ same queues it as a turn in the session itself.
39
+ A busy target is skipped and retried at the next tick; after max_skips skips
40
+ (default 12) or max_failures failures (default 3) the schedule is disabled.
41
+ TEXT
42
+
43
+ SUBCOMMANDS = %w[add list show tick run-now enable disable cancel].freeze
44
+
45
+ # A usage error names the subcommand it was given (CLI::Command's
46
+ # usage_error builds its line from this).
47
+ def command_name = SUBCOMMANDS.include?(@argv.first.to_s) ? "chi schedule #{@argv.first}" : "chi schedule"
48
+
49
+ FLAGS = {
50
+ "add" => CLI::Flags.new(args: true) do |f|
51
+ f.value "--session", "--continues", key: :target
52
+ f.value "-m", "--message", "--prompt", key: :prompt
53
+ f.value "--title"
54
+ f.value "--every"
55
+ f.value "--weekday"
56
+ f.value "--time"
57
+ f.value "--date"
58
+ f.value "--mode"
59
+ f.value "--catch-up"
60
+ f.value "--keep-links"
61
+ f.value "--max-failures"
62
+ f.value "--max-skips"
63
+ end,
64
+ "list" => CLI::Flags.new(args: false) do |f|
65
+ f.switch "--all", key: :include_disabled
66
+ f.value "--format"
67
+ end,
68
+ "show" => CLI::Flags.new(args: true),
69
+ "tick" => CLI::Flags.new(args: false) do |f|
70
+ f.switch "--dry-run"
71
+ f.value "--format"
72
+ end,
73
+ "run-now" => CLI::Flags.new(args: true),
74
+ "enable" => CLI::Flags.new(args: true),
75
+ "disable" => CLI::Flags.new(args: true),
76
+ "cancel" => CLI::Flags.new(args: true)
77
+ }.freeze
78
+
79
+ # @param argv [Array<String>] the arguments after "schedule"
80
+ # @param schedules [Schedules, nil] the store; default this machine's
81
+ def initialize(argv, stdout: $stdout, stderr: $stderr, schedules: nil)
82
+ @argv = argv.dup
83
+ @stdout = stdout
84
+ @stderr = stderr
85
+ @schedules = schedules || Schedules.new
86
+ end
87
+
88
+ # @return [Integer] exit status: 0 ok, 1 a missing session or schedule,
89
+ # 2 a usage error
90
+ def run
91
+ sub = @argv[0]
92
+ if sub.nil? || HELP_WORDS.include?(sub)
93
+ @stdout.puts(USAGE)
94
+ return 0
95
+ end
96
+ return usage_error("unknown subcommand #{sub}") unless SUBCOMMANDS.include?(sub)
97
+
98
+ parsed = parse_flags(FLAGS.fetch(sub), @argv[1..])
99
+ return parsed if parsed.is_a?(Integer)
100
+
101
+ @opts = parsed.options
102
+ @args = parsed.args
103
+
104
+ case sub
105
+ when "add" then add
106
+ when "list" then list
107
+ when "show" then show
108
+ when "tick" then tick
109
+ when "run-now" then act { |id| @schedules.run_now(id) }
110
+ when "enable" then act { |id| @schedules.enable(id) }
111
+ when "disable" then act { |id| @schedules.disable(id) }
112
+ when "cancel" then act { |id| @schedules.cancel(id) }
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def add
119
+ if @opts[:target].to_s.strip.empty? && !@args.empty?
120
+ return usage_error("add takes the session with --session ID, not #{@args.map { |arg| arg.inspect }.join(" ")}")
121
+ end
122
+ return usage_error("add needs --session ID (the session the schedule runs against)") if @opts[:target].to_s.strip.empty?
123
+ return usage_error("add needs a prompt: -m \"...\"") if @opts[:prompt].to_s.strip.empty?
124
+
125
+ begin
126
+ recurrence = Schedules.recurrence(every: @opts[:every], weekday: @opts[:weekday], time: @opts[:time], date: @opts[:date])
127
+ entry = @schedules.add(target: @opts[:target], prompt: @opts[:prompt], recurrence: recurrence,
128
+ title: @opts[:title], mode: @opts[:mode] || Schedules::CHAIN,
129
+ catch_up: @opts[:catch_up], keep_links: @opts[:keep_links],
130
+ max_failures: @opts[:max_failures], max_skips: @opts[:max_skips])
131
+ rescue Schedules::Usage => e
132
+ return usage_error(e.message)
133
+ rescue Session::AmbiguousId => e
134
+ return failed("which session? #{e.message}")
135
+ rescue Session::NotFound
136
+ return failed("no session #{@opts[:target]}")
137
+ end
138
+
139
+ @stdout.puts("#{entry.id} added: #{entry.target_words} · #{entry.recurrence_words} · next #{clock(entry.next_due)}")
140
+ CLI::Exit::OK
141
+ end
142
+
143
+ def list
144
+ entries = @schedules.entries(include_disabled: @opts[:include_disabled] ? true : false)
145
+ if @opts[:format] == "json"
146
+ @stdout.puts(JSON.pretty_generate(entries.map { |entry| entry_line_json(entry) }))
147
+ return CLI::Exit::OK
148
+ end
149
+ if entries.empty?
150
+ @stdout.puts("no schedules")
151
+ return CLI::Exit::OK
152
+ end
153
+ entries.each { |entry| @stdout.puts(entry_line(entry)) }
154
+ CLI::Exit::OK
155
+ end
156
+
157
+ def show
158
+ id = @args.first || @opts[:id]
159
+ return usage_error("show needs a schedule id") if id.to_s.strip.empty?
160
+
161
+ entry = @schedules.lookup(id)
162
+ return failed("no schedule #{id}") if entry.nil?
163
+
164
+ @stdout.puts("#{entry.id} #{entry.title || "(untitled)"}")
165
+ @stdout.puts(" anchor: #{entry.anchor} head: #{entry.head} mode: #{entry.mode}")
166
+ @stdout.puts(" recurrence: #{entry.recurrence_words} next due: #{clock(entry.next_due)}")
167
+ @stdout.puts(" last fired: #{clock(entry.last_fired)} last result: #{entry.last_result || "-"}")
168
+ @stdout.puts(" skips: #{entry.skips} of #{entry.max_skips} failures: #{entry.failures} of #{entry.max_failures}")
169
+ @stdout.puts(" keep_links: #{entry.keep_links} enabled: #{entry.enabled}")
170
+ @stdout.puts(" prompt: #{entry.prompt}")
171
+ CLI::Exit::OK
172
+ rescue Schedules::Usage => e
173
+ usage_error(e.message)
174
+ end
175
+
176
+ def tick
177
+ result = @schedules.tick(dry_run: @opts[:dry_run] ? true : false)
178
+ if @opts[:format] == "json"
179
+ @stdout.puts(JSON.pretty_generate({ "locked" => result.locked, "outcomes" => result.outcomes.map { |outcome| { "id" => outcome.id, "status" => outcome.status, "detail" => outcome.detail } } }))
180
+ return CLI::Exit::OK
181
+ end
182
+
183
+ if result.locked
184
+ @stdout.puts("no schedules due (another tick holds the lock)")
185
+ return CLI::Exit::OK
186
+ end
187
+ if result.outcomes.empty?
188
+ @stdout.puts("no schedules due")
189
+ return CLI::Exit::OK
190
+ end
191
+
192
+ result.outcomes.each do |outcome|
193
+ word = outcome.status == "dry" ? "would fire" : outcome.status
194
+ @stdout.puts("#{outcome.id} #{word} #{outcome.detail}")
195
+ end
196
+ CLI::Exit::OK
197
+ end
198
+
199
+ def act
200
+ id = @args.first
201
+ return usage_error("#{@argv[0]} needs a schedule id") if id.to_s.strip.empty?
202
+
203
+ result = yield(id)
204
+ return failed("no schedule #{id}") if result.nil?
205
+
206
+ if result.is_a?(Schedules::Outcome)
207
+ @stdout.puts("#{id}: #{result.status} · #{result.detail}")
208
+ return result.status == "failed" ? CLI::Exit::FAILED : CLI::Exit::OK
209
+ end
210
+
211
+ @stdout.puts("#{id} #{@argv[0]}: ok")
212
+ CLI::Exit::OK
213
+ rescue Schedules::Usage => e
214
+ usage_error(e.message)
215
+ end
216
+
217
+ def failed(message)
218
+ error_line(message)
219
+ CLI::Exit::FAILED
220
+ end
221
+
222
+ def clock(iso)
223
+ return "-" if iso.nil? || iso.to_s.empty?
224
+
225
+ Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
226
+ rescue ArgumentError
227
+ iso.to_s
228
+ end
229
+
230
+ def entry_line(entry)
231
+ note = ""
232
+ note += " skips #{entry.skips} of #{entry.max_skips} (#{skip_window(entry)})" if entry.skips.positive?
233
+ note += " disabled after #{entry.failures} failures" unless entry.enabled
234
+ "#{entry.id} #{entry.mode} #{entry.recurrence_words} #{clock(entry.next_due)} #{entry.head[0, 8]}#{note}"
235
+ end
236
+
237
+ # A skip budget as a time window: 3 of 12 at a 5 minute tick is about 1 hour
238
+ # of patience, which is what the count really measures.
239
+ def skip_window(entry)
240
+ minutes = entry.max_skips * Schedules.tick_interval_minutes
241
+ hours = minutes / 60.0
242
+ hours >= 1 ? "≈#{(hours * 10).round / 10} h at a #{Schedules.tick_interval_minutes} min tick" : "≈#{minutes} min"
243
+ end
244
+
245
+ def entry_line_json(entry)
246
+ entry.to_h.transform_keys(&:to_s).merge(
247
+ "recurrence_words" => entry.recurrence_words,
248
+ "skip_window" => skip_window(entry)
249
+ )
250
+ end
251
+ end
252
+ end