samagotchi 0.31.0 → 0.33.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +3 -1
  7. data/docs/context.md +220 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +7 -3
  10. data/docs/plugins.md +79 -2
  11. data/docs/releasing.md +3 -2
  12. data/lib/samagotchi/answer_command.rb +2 -1
  13. data/lib/samagotchi/archive_store.rb +1 -15
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge.rb +8 -3
  16. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  17. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  18. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  19. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  20. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  21. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  26. data/lib/samagotchi/child_reports.rb +31 -12
  27. data/lib/samagotchi/client_id.rb +52 -0
  28. data/lib/samagotchi/config.rb +6 -0
  29. data/lib/samagotchi/context_absorber.rb +180 -0
  30. data/lib/samagotchi/context_command.rb +473 -0
  31. data/lib/samagotchi/context_fetch.rb +195 -0
  32. data/lib/samagotchi/context_note.rb +11 -5
  33. data/lib/samagotchi/context_poller.rb +100 -0
  34. data/lib/samagotchi/context_providers.rb +159 -0
  35. data/lib/samagotchi/context_sources.rb +462 -0
  36. data/lib/samagotchi/engine.rb +19 -11
  37. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  38. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
  39. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  40. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  41. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  45. data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
  46. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  47. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  48. data/lib/samagotchi/plugin/context.rb +22 -0
  49. data/lib/samagotchi/plugin/sessions.rb +2 -1
  50. data/lib/samagotchi/process_group.rb +160 -0
  51. data/lib/samagotchi/prompt_warmup.rb +20 -1
  52. data/lib/samagotchi/question_desk.rb +2 -4
  53. data/lib/samagotchi/relay_verifier.rb +2 -4
  54. data/lib/samagotchi/reply_wait.rb +9 -4
  55. data/lib/samagotchi/send_command.rb +2 -1
  56. data/lib/samagotchi/session.rb +7 -5
  57. data/lib/samagotchi/session_commands.rb +18 -1
  58. data/lib/samagotchi/session_manager.rb +7 -6
  59. data/lib/samagotchi/session_retention.rb +3 -0
  60. data/lib/samagotchi/steer.rb +36 -19
  61. data/lib/samagotchi/system_prompt.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
  63. data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
  64. data/lib/samagotchi/terminal_ui.rb +2 -4
  65. data/lib/samagotchi/tool_activity.rb +8 -0
  66. data/lib/samagotchi/tool_declarations.rb +21 -0
  67. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  68. data/lib/samagotchi/tools/builtins.rb +5 -0
  69. data/lib/samagotchi/tools/context_read.rb +163 -0
  70. data/lib/samagotchi/tools/delegate.rb +2 -2
  71. data/lib/samagotchi/tools/execute.rb +10 -81
  72. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +111 -2
  75. data/lib/samagotchi/web/public/app.js +57 -28
  76. data/lib/samagotchi/web/public/composer_size.js +7 -6
  77. data/lib/samagotchi/web/public/context_chips.js +285 -0
  78. data/lib/samagotchi/web/public/data.js +24 -0
  79. data/lib/samagotchi/web/public/index.html +49 -3
  80. data/lib/samagotchi/web/public/notify.js +5 -3
  81. data/lib/samagotchi/web/public/question_card.js +3 -4
  82. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  83. data/lib/samagotchi/web/public/stable_html.js +18 -0
  84. data/lib/samagotchi/web/public/timing.js +7 -3
  85. data/lib/samagotchi/web/public/turn_events.js +16 -3
  86. data/lib/samagotchi/web/public/turn_view.js +5 -2
  87. data/lib/samagotchi/worker.rb +154 -6
  88. metadata +18 -1
@@ -0,0 +1,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "time"
7
+ require_relative "atomic_file"
8
+ require_relative "memory_paths"
9
+ require_relative "paths"
10
+ require_relative "session"
11
+
12
+ module Samagotchi
13
+ # Attached context: named external sources a session keeps fresh (a PR, a
14
+ # thread, a script's output). A Source says how to get the text (a command,
15
+ # or nothing for a pushed one) and why it's attached; its Snapshot is the
16
+ # last good text; a session's Subscription is what it has seen and read.
17
+ # Everything lives under one root, <state dir>/context/, apart from the
18
+ # session dirs:
19
+ # projects/<project_key>/<name>.json a project's Source
20
+ # projects/<project_key>/<name>.snapshot.json its Snapshot
21
+ # sessions/<id>/<name>.json, …snapshot.json a session's own
22
+ # sessions/<id>/subscriptions.json {name => Subscription}; the worker writes it
23
+ # sessions/<id>/muted/<name> a marker: this session ignores <name>
24
+ # sessions/<id>/declined/<name> a marker (its hint inside): the user removed <name>
25
+ # The guardrails protect the root (ProtectedPaths, CHI_TEXT): a source's
26
+ # command runs later, outside the gate.
27
+ module ContextSources
28
+ DIR = "context"
29
+ NAME_RE = /\A[a-z0-9][a-z0-9-]{0,39}\z/
30
+ # subscriptions.json shares a session's folder with its sources.
31
+ RESERVED_NAMES = %w[subscriptions].freeze
32
+ SCOPES = %w[session project].freeze
33
+ MIN_EVERY_SECONDS = 30
34
+ DEFAULT_EVERY_SECONDS = 300
35
+ TEXT_MAX_BYTES = 1024 * 1024
36
+ SUMMARY_MAX_CHARS = 200
37
+ LINE_MAX_CHARS = 300
38
+ SNAPSHOT_SUFFIX = ".snapshot.json"
39
+ SUBSCRIPTIONS_FILE = "subscriptions.json"
40
+ MUTED_DIR = "muted"
41
+ DECLINED_DIR = "declined"
42
+
43
+ # A name, a command, a value the store can't take.
44
+ class Invalid < ArgumentError; end
45
+
46
+ # A source's definition. +cmd+ nil: pushed only (chi context push).
47
+ # +provider+: the bundle whose context provider made it (a URL), whose
48
+ # folder its cmd's {bundle_dir} names (ContextProviders.command_for).
49
+ Source = Data.define(:name, :cmd, :every_seconds, :why, :hint, :scope, :added_by, :created_at, :provider) do
50
+ def initialize(name:, cmd:, every_seconds:, why:, hint:, scope:, added_by:, created_at:, provider: nil) = super
51
+
52
+ def push? = cmd.nil?
53
+
54
+ def to_h
55
+ { "name" => name, "cmd" => cmd, "every_seconds" => every_seconds, "why" => why, "hint" => hint,
56
+ "scope" => scope, "added_by" => added_by, "created_at" => created_at, "provider" => provider }.compact
57
+ end
58
+
59
+ def self.from_h(data)
60
+ new(name: data["name"].to_s, cmd: data["cmd"], every_seconds: data["every_seconds"]&.to_i,
61
+ why: data["why"], hint: data["hint"], scope: data["scope"].to_s, added_by: data["added_by"],
62
+ created_at: data["created_at"], provider: data["provider"])
63
+ end
64
+ end
65
+
66
+ # A source's last result. +text+ nil until a fetch or push succeeds;
67
+ # +serial+ counts the revisions (an absorb that missed some says how
68
+ # many); +error+ is the last failure, +error_since+ when the failures
69
+ # began (cleared by a success), so a session hears about each run of
70
+ # failures once. +summary+, +wake+ and +hint+ come with the revision;
71
+ # +wake_serial+ is the serial of the newest revision that asked to wake
72
+ # (nil: none), so a change that doesn't ask, recorded before an absorb,
73
+ # doesn't hide a wake asked since the session's last one.
74
+ # +fetched_at+ is the last success, +checked_at+ the last try (the
75
+ # poller's clock: a failing source isn't run again every tick).
76
+ Snapshot = Data.define(:text, :summary, :revision, :serial, :fetched_at, :checked_at, :wake, :wake_serial, :hint,
77
+ :error, :error_since) do
78
+ def self.empty
79
+ new(text: nil, summary: nil, revision: nil, serial: 0, fetched_at: nil, checked_at: nil, wake: false,
80
+ wake_serial: nil, hint: nil, error: nil, error_since: nil)
81
+ end
82
+
83
+ def to_h
84
+ { "text" => text, "summary" => summary, "revision" => revision, "serial" => serial,
85
+ "fetched_at" => fetched_at, "checked_at" => checked_at, "wake" => (wake ? true : nil),
86
+ "wake_serial" => wake_serial, "hint" => hint,
87
+ "error" => error, "error_since" => error_since }.compact
88
+ end
89
+
90
+ # A snapshot 0.32.0 wrote has no wake_serial: its wake is the latest
91
+ # revision's.
92
+ def self.from_h(data)
93
+ serial = data["serial"].to_i
94
+ wake = data["wake"] == true
95
+ new(text: data["text"], summary: data["summary"], revision: data["revision"], serial: serial,
96
+ fetched_at: data["fetched_at"], checked_at: data["checked_at"] || data["fetched_at"], wake: wake,
97
+ wake_serial: data["wake_serial"]&.to_i || (serial if wake), hint: data["hint"], error: data["error"],
98
+ error_since: data["error_since"])
99
+ end
100
+
101
+ def text? = !text.nil?
102
+
103
+ # Seconds since the last try; nil for never.
104
+ def age(now = Time.now)
105
+ checked_at && (now - Time.iso8601(checked_at))
106
+ rescue ArgumentError
107
+ nil
108
+ end
109
+ end
110
+
111
+ # What one session has had of a source: +seen+ the revision its last
112
+ # note was about (+seen_serial+ that revision's serial), +read+ the
113
+ # revision context_read last returned, +error_seen+ the error_since it
114
+ # was told about, +wakes_at+ when a change last woke it (C4).
115
+ Subscription = Data.define(:name, :seen, :seen_serial, :read, :error_seen, :wakes_at) do
116
+ def self.blank(name)
117
+ new(name: name, seen: nil, seen_serial: 0, read: nil, error_seen: nil, wakes_at: nil)
118
+ end
119
+
120
+ def to_h
121
+ { "seen" => seen, "seen_serial" => seen_serial, "read" => read, "error_seen" => error_seen,
122
+ "wakes_at" => wakes_at }.compact
123
+ end
124
+
125
+ def self.from_h(name, data)
126
+ new(name: name, seen: data["seen"], seen_serial: data["seen_serial"].to_i, read: data["read"],
127
+ error_seen: data["error_seen"], wakes_at: data["wakes_at"])
128
+ end
129
+ end
130
+
131
+ # What a command printed or a push sent (the contract, ContextSources.parse_output).
132
+ Fetched = Data.define(:text, :summary, :wake, :hint)
133
+
134
+ # A source as one session sees it: the definition, where it lives, and
135
+ # +shadowed+ for a project source hidden by a session one of its name.
136
+ Attached = Data.define(:source, :location, :shadowed) do
137
+ def name = source.name
138
+ def snapshot = location.snapshot(source.name)
139
+ end
140
+
141
+ # A folder of sources: a project's or a session's.
142
+ Location = Data.define(:scope, :dir, :key) do
143
+ def session? = scope == "session"
144
+
145
+ def source_path(name) = File.join(dir, "#{ContextSources.check_name!(name)}.json")
146
+ def snapshot_path(name) = File.join(dir, "#{ContextSources.check_name!(name)}#{SNAPSHOT_SUFFIX}")
147
+ def lock_path(name) = File.join(dir, "#{ContextSources.check_name!(name)}.lock")
148
+
149
+ # @return [Array<Source>] by name
150
+ def sources
151
+ return [] unless Dir.exist?(dir)
152
+
153
+ Dir.children(dir).sort.filter_map do |file|
154
+ next unless file.end_with?(".json") && !file.end_with?(SNAPSHOT_SUFFIX) && file != SUBSCRIPTIONS_FILE
155
+
156
+ name = file.delete_suffix(".json")
157
+ name.match?(NAME_RE) ? source(name) : nil
158
+ end
159
+ end
160
+
161
+ # @return [Source, nil]
162
+ def source(name)
163
+ data = ContextSources.read_json(source_path(name))
164
+ data && Source.from_h(data.merge("name" => name, "scope" => scope))
165
+ end
166
+
167
+ # @raise [Invalid] a source of that name exists here
168
+ def add(source)
169
+ FileUtils.mkdir_p(dir)
170
+ path = source_path(source.name)
171
+ raise Invalid, "#{source.name} is already attached here (chi context rm #{source.name} first)" if File.exist?(path)
172
+
173
+ AtomicFile.write(path, "#{JSON.pretty_generate(source.to_h)}\n")
174
+ # Added again on purpose: no longer declined.
175
+ FileUtils.rm_f(declined_path(source.name))
176
+ source
177
+ end
178
+
179
+ # @return [Boolean] whether there was a source to remove
180
+ # The lock file stays: a fetch running now still holds it, and a new
181
+ # source of the name must wait for that fetch (which then finds its
182
+ # source gone and writes nothing: ContextFetch) instead of opening a
183
+ # fresh lock beside it.
184
+ # A session's source removed leaves a declined marker holding its hint
185
+ # (#declined?): an auto-attach (ctx.context, the github-pr bundle) on
186
+ # the worker's next start must not bring back what the user took off.
187
+ def remove(name)
188
+ removed = source(name)
189
+ [source_path(name), snapshot_path(name)].each { |path| FileUtils.rm_f(path) }
190
+ decline(name, removed.hint) if removed && session?
191
+ !removed.nil?
192
+ end
193
+
194
+ # Whether the user removed +name+ from this session (#remove).
195
+ def declined?(name) = File.exist?(declined_path(name))
196
+
197
+ # The hints (often URLs) of the sources removed here.
198
+ def declined_hints
199
+ folder = File.join(dir, DECLINED_DIR)
200
+ return [] unless Dir.exist?(folder)
201
+
202
+ Dir.children(folder).sort.filter_map do |file|
203
+ hint = File.read(File.join(folder, file)).strip
204
+ hint.empty? ? nil : hint
205
+ rescue SystemCallError
206
+ nil
207
+ end
208
+ end
209
+
210
+ # @return [Snapshot] Snapshot.empty when there is none yet
211
+ def snapshot(name)
212
+ data = ContextSources.read_json(snapshot_path(name))
213
+ data ? Snapshot.from_h(data) : Snapshot.empty
214
+ end
215
+
216
+ # @return [Time, nil] the snapshot file's mtime: the worker's cheap "anything new?"
217
+ def snapshot_mtime(name)
218
+ File.mtime(snapshot_path(name))
219
+ rescue SystemCallError
220
+ nil
221
+ end
222
+
223
+ def write_snapshot(name, snapshot)
224
+ FileUtils.mkdir_p(dir)
225
+ AtomicFile.write(snapshot_path(name), JSON.generate(snapshot.to_h))
226
+ snapshot
227
+ end
228
+
229
+ # A fetch or a push that worked: a new revision, or the same text
230
+ # fetched again (only fetched_at moves; its summary and wake are
231
+ # ignored). Clears the error.
232
+ # @return [Snapshot] the one written
233
+ def record_text(name, fetched, now: Time.now)
234
+ previous = snapshot(name)
235
+ revision = ContextSources.revision_of(fetched.text)
236
+ stamp = now.utc.iso8601
237
+ written = if revision == previous.revision
238
+ previous.with(fetched_at: stamp, checked_at: stamp, error: nil, error_since: nil)
239
+ else
240
+ Snapshot.new(text: fetched.text, revision: revision, serial: previous.serial + 1, fetched_at: stamp,
241
+ checked_at: stamp, summary: fetched.summary || ContextSources.plain_summary(previous.text, fetched.text),
242
+ wake: fetched.wake, wake_serial: fetched.wake ? previous.serial + 1 : previous.wake_serial,
243
+ hint: fetched.hint || previous.hint, error: nil, error_since: nil)
244
+ end
245
+ write_snapshot(name, written)
246
+ end
247
+
248
+ # Run the block holding <name>.lock (blocking), the lock a fetch
249
+ # holds while it runs and records: a push's read-modify-write of the
250
+ # snapshot (its serial) doesn't interleave with another writer.
251
+ def locked(name)
252
+ FileUtils.mkdir_p(dir)
253
+ File.open(lock_path(name), File::RDWR | File::CREAT, 0o644) do |lock|
254
+ lock.flock(File::LOCK_EX)
255
+ yield
256
+ end
257
+ end
258
+
259
+ # A fetch that failed: the last good text stays.
260
+ # @return [Snapshot] the one written
261
+ def record_error(name, message, now: Time.now)
262
+ previous = snapshot(name)
263
+ # Microseconds: two runs of failures never share a start.
264
+ stamp = now.utc.iso8601(6)
265
+ write_snapshot(name, previous.with(error: ContextSources.one_line(message, LINE_MAX_CHARS),
266
+ error_since: previous.error_since || stamp, checked_at: stamp))
267
+ end
268
+
269
+ def declined_path(name) = File.join(dir, DECLINED_DIR, ContextSources.check_name!(name))
270
+
271
+ def decline(name, hint)
272
+ FileUtils.mkdir_p(File.join(dir, DECLINED_DIR))
273
+ AtomicFile.write(declined_path(name), "#{hint}\n")
274
+ end
275
+
276
+ # Session only: the markers and the worker's subscription file.
277
+ def muted?(name) = File.exist?(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
278
+
279
+ def mute(name)
280
+ FileUtils.mkdir_p(File.join(dir, MUTED_DIR))
281
+ FileUtils.touch(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
282
+ end
283
+
284
+ def unmute(name) = FileUtils.rm_f(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
285
+
286
+ # @return [Hash{String => Subscription}]
287
+ def subscriptions
288
+ data = ContextSources.read_json(File.join(dir, SUBSCRIPTIONS_FILE)) || {}
289
+ data.to_h { |name, value| [name, Subscription.from_h(name, value.is_a?(Hash) ? value : {})] }
290
+ end
291
+
292
+ def subscription(name) = subscriptions[name] || Subscription.blank(name)
293
+
294
+ # Only the session's worker (its absorb step, its context_read) calls this.
295
+ def write_subscriptions(subs)
296
+ FileUtils.mkdir_p(dir)
297
+ AtomicFile.write(File.join(dir, SUBSCRIPTIONS_FILE), JSON.generate(subs.transform_values(&:to_h)))
298
+ end
299
+
300
+ def update_subscription(name)
301
+ subs = subscriptions
302
+ subs[name] = yield(subs[name] || Subscription.blank(name))
303
+ write_subscriptions(subs)
304
+ subs[name]
305
+ end
306
+ end
307
+
308
+ module_function
309
+
310
+ # <state dir>/context; +state_dir+ is the sessions dir (Session.default_state_dir).
311
+ def root(state_dir: nil)
312
+ File.join(File.dirname(state_dir || Session.default_state_dir), DIR)
313
+ end
314
+
315
+ def session_location(session_id, state_dir: nil)
316
+ Location.new(scope: "session", dir: File.join(root(state_dir: state_dir), "sessions", Session.check_id!(session_id)),
317
+ key: session_id)
318
+ end
319
+
320
+ def project_location(project_key, state_dir: nil)
321
+ Location.new(scope: "project", dir: File.join(root(state_dir: state_dir), "projects", project_key.to_s), key: project_key)
322
+ end
323
+
324
+ # The project a session's sources come from: nil when it is in no repo.
325
+ # @param project_root [String, nil] Session#project_root
326
+ def project_location_for(project_root, state_dir: nil)
327
+ return nil if project_root.to_s.empty?
328
+
329
+ project_location(project_key_of(project_root), state_dir: state_dir)
330
+ end
331
+
332
+ # MemoryPaths.project_key for a root already resolved.
333
+ def project_key_of(root)
334
+ "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
335
+ end
336
+
337
+ # A session's sources: its own, then the project's it doesn't shadow
338
+ # (+shadowed+ lists those too, marked).
339
+ # @return [Array<Attached>]
340
+ def attached(session_id, project_root:, state_dir: nil, shadowed: false)
341
+ own = session_location(session_id, state_dir: state_dir)
342
+ list = own.sources.map { |source| Attached.new(source: source, location: own, shadowed: false) }
343
+ project = project_location_for(project_root, state_dir: state_dir)
344
+ return list unless project
345
+
346
+ names = list.map(&:name)
347
+ project.sources.each do |source|
348
+ hidden = names.include?(source.name)
349
+ list << Attached.new(source: source, location: project, shadowed: hidden) if !hidden || shadowed
350
+ end
351
+ list
352
+ end
353
+
354
+ # Where a project's source runs: the project root when it is a checkout
355
+ # (its .git is there), else +fallback+ (the session's folder): a bare
356
+ # repo, proj/.bare or --separate-git-dir makes the root the git dir
357
+ # itself (MemoryPaths.project_root), and a command run there sees no
358
+ # files.
359
+ def project_cwd(project_root, fallback)
360
+ root = project_root.to_s
361
+ !root.empty? && File.exist?(File.join(root, ".git")) ? root : fallback
362
+ end
363
+
364
+ # A deleted session's sources, snapshots, subscriptions and markers.
365
+ # @return [Array<String>] the paths removed
366
+ def remove_session(session_id, state_dir: nil)
367
+ return [] unless Session.valid_id?(session_id.to_s)
368
+
369
+ dir = session_location(session_id, state_dir: state_dir).dir
370
+ return [] unless Dir.exist?(dir)
371
+
372
+ FileUtils.rm_rf(dir)
373
+ [dir]
374
+ rescue SystemCallError
375
+ []
376
+ end
377
+
378
+ # @return [String] +name+
379
+ # @raise [Invalid]
380
+ def check_name!(name)
381
+ name = name.to_s
382
+ unless name.match?(NAME_RE)
383
+ raise Invalid, "#{name.inspect} isn't a source name: lowercase letters, digits and -, up to 40, starting with a letter or digit"
384
+ end
385
+ raise Invalid, "#{name} is a reserved name" if RESERVED_NAMES.include?(name)
386
+
387
+ name
388
+ end
389
+
390
+ # @raise [Invalid] below MIN_EVERY_SECONDS or not a number
391
+ def check_every!(value)
392
+ return nil if value.nil?
393
+
394
+ seconds = Integer(value.to_s, 10, exception: false)
395
+ raise Invalid, "--every takes seconds, not #{value.inspect}" unless seconds
396
+ raise Invalid, "--every is #{seconds}; the least is #{MIN_EVERY_SECONDS} seconds" if seconds < MIN_EVERY_SECONDS
397
+
398
+ seconds
399
+ end
400
+
401
+ def revision_of(text) = Digest::SHA256.hexdigest(text.to_s)
402
+
403
+ # The contract: stdout that parses as a JSON object with a string "text"
404
+ # is {text, summary, wake, hint}; anything else is all text. Unknown
405
+ # keys are ignored; summary and hint become one line, cut.
406
+ # @return [Fetched]
407
+ # @raise [Invalid] nothing to keep (empty, or over TEXT_MAX_BYTES)
408
+ def parse_output(raw)
409
+ raw = raw.to_s.dup.force_encoding(Encoding::UTF_8).scrub
410
+ data = json_object(raw)
411
+ fetched = if data.is_a?(Hash) && data["text"].is_a?(String)
412
+ Fetched.new(text: data["text"].scrub, summary: one_line(data["summary"], SUMMARY_MAX_CHARS),
413
+ wake: data["wake"] == true, hint: one_line(data["hint"], LINE_MAX_CHARS))
414
+ else
415
+ Fetched.new(text: raw, summary: nil, wake: false, hint: nil)
416
+ end
417
+ raise Invalid, "the output is empty" if fetched.text.strip.empty?
418
+ if fetched.text.bytesize > TEXT_MAX_BYTES
419
+ raise Invalid, "the text is #{fetched.text.bytesize} bytes; the limit is 1 MiB (#{TEXT_MAX_BYTES} bytes)"
420
+ end
421
+
422
+ fetched
423
+ end
424
+
425
+ def json_object(raw)
426
+ return nil unless raw.lstrip.start_with?("{")
427
+
428
+ JSON.parse(raw)
429
+ rescue JSON::ParserError
430
+ nil
431
+ end
432
+
433
+ # chi's summary of a plain-text change: "content changed (+12/−3 lines)",
434
+ # or the size of the first text.
435
+ def plain_summary(before, after)
436
+ return "#{after.lines.size} #{after.lines.size == 1 ? "line" : "lines"} of text" if before.nil?
437
+
438
+ require_relative "text_diff"
439
+ diff = TextDiff.unified(before, after, max_lines: 0, max_bytes: 0)
440
+ "content changed (+#{diff[:added]}/−#{diff[:removed]} lines)"
441
+ end
442
+
443
+ # One line, whitespace collapsed, cut at +max+ chars with "…"; nil for blank.
444
+ def one_line(value, max)
445
+ return nil unless value.is_a?(String)
446
+
447
+ line = value.scrub.gsub(/\s+/, " ").strip
448
+ return nil if line.empty?
449
+
450
+ line.length > max ? "#{line[0, max - 1]}…" : line
451
+ end
452
+
453
+ def read_json(path)
454
+ return nil unless File.file?(path)
455
+
456
+ data = JSON.parse(File.read(path))
457
+ data.is_a?(Hash) ? data : nil
458
+ rescue JSON::ParserError, SystemCallError
459
+ nil
460
+ end
461
+ end
462
+ end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
  require "yaml"
8
8
 
9
+ require_relative "client_id"
9
10
  require_relative "iteration_limit"
10
11
  require_relative "config"
11
12
  require_relative "context_note"
@@ -349,7 +350,8 @@ module Samagotchi
349
350
  # again with the message, no nudge, no retry spent. Too early, the
350
351
  # message waits (WaitingSteer) and cuts once the thinking passes it
351
352
  # (#recheck_waiting_steer, on each thinking chunk), unless a boundary
352
- # hands it to the model first. A plugin's message never cuts.
353
+ # hands it to the model first. A plugin's message never cuts, nor an
354
+ # unknown client's (automatic:<id>).
353
355
  # Call it after the message is queued, with the #input_epoch read
354
356
  # before queueing it. Outside any lock; a cut that lands just after the
355
357
  # generation ended is harmless (the loop re-asks).
@@ -660,7 +662,7 @@ module Samagotchi
660
662
  replace_session_messages(session, Array(session.messages) + [message])
661
663
  announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
662
664
  label: ContextNote.label_of(message), from_session: note[:from_session],
663
- from_cwd: note[:from_cwd], text: note[:text],
665
+ from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
664
666
  created_at: note[:created_at] }.compact)
665
667
  message
666
668
  end
@@ -811,6 +813,7 @@ module Samagotchi
811
813
  # #stop_idle's work (the scheduler's stop is idempotent), where the
812
814
  # callers haven't stopped it already.
813
815
  @idle_scheduler&.stop
816
+ @prompt_warmup.stop
814
817
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + join_timeout
815
818
  threads.each do |thread|
816
819
  next if thread == Thread.current
@@ -1183,16 +1186,18 @@ module Samagotchi
1183
1186
  private :hook_messages
1184
1187
 
1185
1188
  # How the turn ended, on the session (Session#last_turn): the caller's
1186
- # save puts it in the file the session hub watches.
1189
+ # save puts it in the file the session hub watches. Public for the
1190
+ # Worker's turn that failed before it began (no #end_turn ran).
1187
1191
  # @param limit [Integer, nil] the turn ran out of iterations at this
1188
1192
  # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1189
1193
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1190
1194
  # @param event [Hash] the turn's end event (#stop_facts)
1191
1195
  def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1192
1196
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1193
- source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1194
- elsif client_id.start_with?(ChildReports::CLIENT_PREFIX) then "delegate_report"
1195
- elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
1197
+ source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
1198
+ elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
1199
+ elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
1200
+ elsif client_id == ClientId::REMINDER then "reminder"
1196
1201
  else "client"
1197
1202
  end
1198
1203
  session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
@@ -1200,7 +1205,6 @@ module Samagotchi
1200
1205
  session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1201
1206
  session.last_turn.merge!(stop_facts(event))
1202
1207
  end
1203
- private :record_last_turn
1204
1208
 
1205
1209
  # Why a turn stopped, from its end event, for a parent agent's wait
1206
1210
  # (ReplyWait): a provider error's kind and whether a retry may help, a
@@ -1462,6 +1466,7 @@ module Samagotchi
1462
1466
  PeerView = Struct.new(:engine) do
1463
1467
  def session_id = engine.session&.id
1464
1468
  def cwd = engine.session&.working_directory
1469
+ def project_root = engine.session&.project_root
1465
1470
  def state_dir = engine.peer_state_dir
1466
1471
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
1467
1472
  def relay = engine.relay_peer
@@ -1598,6 +1603,9 @@ module Samagotchi
1598
1603
  # stored/validated before :turn_started (which carries their refs), and
1599
1604
  # a model known not to see images fails the turn before anything of it
1600
1605
  # is kept (VisionUnsupported).
1606
+ # @param id [String, nil] the turn's id (a new uuid when nil): a caller
1607
+ # that put the turn's first message in the session already (a context
1608
+ # wake's note) names it with this id
1601
1609
  #
1602
1610
  # An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
1603
1611
  # the prompt is kept in the session and :turn_canceled is emitted, then the
@@ -1608,9 +1616,9 @@ module Samagotchi
1608
1616
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1609
1617
  # one-line summary:.
1610
1618
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
1611
- images: [])
1619
+ images: [], id: nil)
1612
1620
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1613
- continue: continue)
1621
+ continue: continue, id: id)
1614
1622
  begin
1615
1623
  # A Stop cuts the /props probes this thread makes for the turn
1616
1624
  # (window, served model, vision) instead of waiting their timeout.
@@ -1654,7 +1662,7 @@ module Samagotchi
1654
1662
  # The turn's state before anything can fail it: the session running,
1655
1663
  # the turn flag, its cancel controller and sink. The cross-thread state
1656
1664
  # is set here and cleared in #release_turn only.
1657
- def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
1665
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil)
1658
1666
  # Track the active session for recap and status snapshot.
1659
1667
  @session = session
1660
1668
  # status is turn state: running now, idle again before the turn's end
@@ -1677,7 +1685,7 @@ module Samagotchi
1677
1685
 
1678
1686
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
1679
1687
  controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1680
- id: SecureRandom.uuid)
1688
+ id: id || SecureRandom.uuid)
1681
1689
  end
1682
1690
  private :begin_turn
1683
1691
 
@@ -182,11 +182,13 @@ module Samagotchi
182
182
  def protected_paths
183
183
  @protected ||= begin
184
184
  require_relative "memory_bundle/provenance"
185
+ require_relative "context_sources"
185
186
  Guardrails::ProtectedPaths.new(
186
187
  store_dir: File.dirname(@approvals.path),
187
188
  bundles_dir: MemoryBundle::Provenance.bundles_dir,
188
189
  config_path: Samagotchi::ConfigFile.global_path,
189
- hooks_dir: Hooks::Loader.hooks_dir
190
+ hooks_dir: Hooks::Loader.hooks_dir,
191
+ context_dir: ContextSources.root
190
192
  )
191
193
  end
192
194
  end
@@ -5,6 +5,7 @@ require_relative "../paths"
5
5
  require_relative "../memory_paths"
6
6
  require_relative "../hooks/loader"
7
7
  require_relative "approval"
8
+ require_relative "verdict"
8
9
  require_relative "protected_paths"
9
10
  require_relative "parent_continue"
10
11
 
@@ -29,8 +30,6 @@ module Samagotchi
29
30
  # rewrite would be "allow everything" from then on.
30
31
  module ParentApprovals
31
32
  KEY = "guardrails.parent_approvals"
32
- # The client id chi answer posts its answers with.
33
- CLIENT_ID = "cli:answer"
34
33
  # Set by chi's execute and task_create for the commands they run: a
35
34
  # chi started there answers as a parent (#parent_process?).
36
35
  PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
@@ -40,12 +39,13 @@ module Samagotchi
40
39
  # They survive a PTY wrapper (script, expect). The docs list them too
41
40
  # (guardrails.md, sub-agent.md).
42
41
  AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", "CODEX_THREAD_ID", PARENT_SESSION_ENV].freeze
43
- # Rules that ask about chi's own config and hooks: the core
44
- # ProtectedPaths asks and the guardrails bundle's shell rule.
45
- PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
46
- # A shell command naming chi's config, hooks or guardrails as text
47
- # (broad: a parent may not allow what only looks like one).
48
- CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
42
+ # Rules only the user may allow (Verdict::PROTECTED_RULES).
43
+ PROTECTED_RULES = Verdict::PROTECTED_RULES
44
+ # A shell command naming chi's config, hooks, guardrails or attached
45
+ # context as text (broad: a parent may not allow what only looks like
46
+ # one). samagotchi/context stops before a word character or a dot, so
47
+ # chi's own lib/samagotchi/context_*.rb isn't one.
48
+ CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|samagotchi/context(?![\w.])|memories/\.bundles}
49
49
  # The guardrails bundle's shell-touches-chi, for a word it can't
50
50
  # resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
51
51
  CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
@@ -110,7 +110,7 @@ module Samagotchi
110
110
  def chi_dirs
111
111
  hooks = begin Hooks::Loader.hooks_dir rescue nil end
112
112
  bundles = begin MemoryPaths.bundles_dir rescue nil end
113
- [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles]
113
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
114
114
  .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
115
115
  end
116
116
 
@@ -5,9 +5,10 @@ module Samagotchi
5
5
  # Core checks on what the file tools (write, edit, memory_write) touch,
6
6
  # after the hooks and before the rules. Paths are compared with
7
7
  # symlinks resolved (on the longest existing parent).
8
- # deny: the approval store's dir and installed bundles (.bundles/:
9
- # hooks, rules, manifests); nothing legitimate writes there
10
- # through the file tools.
8
+ # deny: the approval store's dir, installed bundles (.bundles/:
9
+ # hooks, rules, manifests) and attached context (a source's
10
+ # command runs later, outside the gate; chi context writes
11
+ # it); nothing legitimate writes there through the file tools.
11
12
  # ask (once / session): config.yml and the plain hooks dir. The
12
13
  # system bundle's config protocol edits config.yml with
13
14
  # write/edit, so this keeps the user in the loop without
@@ -22,10 +23,12 @@ module Samagotchi
22
23
  # @param bundles_dir [String] <memories>/.bundles
23
24
  # @param config_path [String] config.yml
24
25
  # @param hooks_dir [String] the plain hooks dir
25
- def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:)
26
+ # @param context_dir [String, nil] <state dir>/context (ContextSources.root)
27
+ def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil)
26
28
  @deny = [
27
29
  ["guardrail-store", store_dir, "the approval store belongs to the user"],
28
- ["installed-bundles", bundles_dir, "installed bundles (hooks, rules, manifests) are changed by chi bundle install"]
30
+ ["installed-bundles", bundles_dir, "installed bundles (hooks, rules, manifests) are changed by chi bundle install"],
31
+ ["chi-context", context_dir, "attached context sources run commands; change them with chi context"]
29
32
  ]
30
33
  @ask = [
31
34
  ["chi-config", config_path, "changes chi's config.yml"],