samagotchi 0.31.0 → 0.32.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -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 +218 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +1 -0
  10. data/docs/plugins.md +77 -1
  11. data/docs/releasing.md +1 -1
  12. data/lib/samagotchi/bootstrap_command.rb +4 -1
  13. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  16. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  23. data/lib/samagotchi/child_reports.rb +0 -2
  24. data/lib/samagotchi/config.rb +6 -0
  25. data/lib/samagotchi/context_absorber.rb +179 -0
  26. data/lib/samagotchi/context_command.rb +473 -0
  27. data/lib/samagotchi/context_fetch.rb +226 -0
  28. data/lib/samagotchi/context_note.rb +11 -5
  29. data/lib/samagotchi/context_poller.rb +100 -0
  30. data/lib/samagotchi/context_providers.rb +159 -0
  31. data/lib/samagotchi/context_sources.rb +452 -0
  32. data/lib/samagotchi/engine.rb +10 -5
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  37. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  38. data/lib/samagotchi/log_line.rb +1 -1
  39. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  40. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  41. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  42. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  43. data/lib/samagotchi/plugin/context.rb +22 -0
  44. data/lib/samagotchi/question_desk.rb +0 -3
  45. data/lib/samagotchi/session.rb +7 -5
  46. data/lib/samagotchi/session_commands.rb +18 -1
  47. data/lib/samagotchi/session_manager.rb +5 -2
  48. data/lib/samagotchi/session_retention.rb +3 -0
  49. data/lib/samagotchi/steer.rb +12 -3
  50. data/lib/samagotchi/system_prompt.rb +1 -1
  51. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -4
  52. data/lib/samagotchi/terminal_ui/formatting.rb +14 -3
  53. data/lib/samagotchi/terminal_ui.rb +0 -3
  54. data/lib/samagotchi/tool_activity.rb +8 -0
  55. data/lib/samagotchi/tool_declarations.rb +21 -0
  56. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  57. data/lib/samagotchi/tools/builtins.rb +5 -0
  58. data/lib/samagotchi/tools/context_read.rb +163 -0
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +109 -1
  61. data/lib/samagotchi/web/public/app.js +40 -10
  62. data/lib/samagotchi/web/public/composer_size.js +7 -6
  63. data/lib/samagotchi/web/public/context_chips.js +285 -0
  64. data/lib/samagotchi/web/public/data.js +21 -0
  65. data/lib/samagotchi/web/public/index.html +49 -3
  66. data/lib/samagotchi/web/public/notify.js +5 -3
  67. data/lib/samagotchi/web/public/timing.js +7 -3
  68. data/lib/samagotchi/web/public/turn_events.js +8 -2
  69. data/lib/samagotchi/web/public/turn_view.js +4 -1
  70. data/lib/samagotchi/worker.rb +126 -0
  71. metadata +14 -1
@@ -0,0 +1,452 @@
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
+ # +fetched_at+ is the last success, +checked_at+ the last try (the
72
+ # poller's clock: a failing source isn't run again every tick).
73
+ Snapshot = Data.define(:text, :summary, :revision, :serial, :fetched_at, :checked_at, :wake, :hint, :error,
74
+ :error_since) do
75
+ def self.empty
76
+ new(text: nil, summary: nil, revision: nil, serial: 0, fetched_at: nil, checked_at: nil, wake: false, hint: nil,
77
+ error: nil, error_since: nil)
78
+ end
79
+
80
+ def to_h
81
+ { "text" => text, "summary" => summary, "revision" => revision, "serial" => serial,
82
+ "fetched_at" => fetched_at, "checked_at" => checked_at, "wake" => (wake ? true : nil), "hint" => hint,
83
+ "error" => error, "error_since" => error_since }.compact
84
+ end
85
+
86
+ def self.from_h(data)
87
+ new(text: data["text"], summary: data["summary"], revision: data["revision"], serial: data["serial"].to_i,
88
+ fetched_at: data["fetched_at"], checked_at: data["checked_at"] || data["fetched_at"],
89
+ wake: data["wake"] == true, hint: data["hint"], error: data["error"], error_since: data["error_since"])
90
+ end
91
+
92
+ def text? = !text.nil?
93
+
94
+ # Seconds since the last try; nil for never.
95
+ def age(now = Time.now)
96
+ checked_at && (now - Time.iso8601(checked_at))
97
+ rescue ArgumentError
98
+ nil
99
+ end
100
+ end
101
+
102
+ # What one session has had of a source: +seen+ the revision its last
103
+ # note was about (+seen_serial+ that revision's serial), +read+ the
104
+ # revision context_read last returned, +error_seen+ the error_since it
105
+ # was told about, +wakes_at+ when a change last woke it (C4).
106
+ Subscription = Data.define(:name, :seen, :seen_serial, :read, :error_seen, :wakes_at) do
107
+ def self.blank(name)
108
+ new(name: name, seen: nil, seen_serial: 0, read: nil, error_seen: nil, wakes_at: nil)
109
+ end
110
+
111
+ def to_h
112
+ { "seen" => seen, "seen_serial" => seen_serial, "read" => read, "error_seen" => error_seen,
113
+ "wakes_at" => wakes_at }.compact
114
+ end
115
+
116
+ def self.from_h(name, data)
117
+ new(name: name, seen: data["seen"], seen_serial: data["seen_serial"].to_i, read: data["read"],
118
+ error_seen: data["error_seen"], wakes_at: data["wakes_at"])
119
+ end
120
+ end
121
+
122
+ # What a command printed or a push sent (the contract, ContextSources.parse_output).
123
+ Fetched = Data.define(:text, :summary, :wake, :hint)
124
+
125
+ # A source as one session sees it: the definition, where it lives, and
126
+ # +shadowed+ for a project source hidden by a session one of its name.
127
+ Attached = Data.define(:source, :location, :shadowed) do
128
+ def name = source.name
129
+ def snapshot = location.snapshot(source.name)
130
+ end
131
+
132
+ # A folder of sources: a project's or a session's.
133
+ Location = Data.define(:scope, :dir, :key) do
134
+ def session? = scope == "session"
135
+
136
+ def source_path(name) = File.join(dir, "#{ContextSources.check_name!(name)}.json")
137
+ def snapshot_path(name) = File.join(dir, "#{ContextSources.check_name!(name)}#{SNAPSHOT_SUFFIX}")
138
+ def lock_path(name) = File.join(dir, "#{ContextSources.check_name!(name)}.lock")
139
+
140
+ # @return [Array<Source>] by name
141
+ def sources
142
+ return [] unless Dir.exist?(dir)
143
+
144
+ Dir.children(dir).sort.filter_map do |file|
145
+ next unless file.end_with?(".json") && !file.end_with?(SNAPSHOT_SUFFIX) && file != SUBSCRIPTIONS_FILE
146
+
147
+ name = file.delete_suffix(".json")
148
+ name.match?(NAME_RE) ? source(name) : nil
149
+ end
150
+ end
151
+
152
+ # @return [Source, nil]
153
+ def source(name)
154
+ data = ContextSources.read_json(source_path(name))
155
+ data && Source.from_h(data.merge("name" => name, "scope" => scope))
156
+ end
157
+
158
+ # @raise [Invalid] a source of that name exists here
159
+ def add(source)
160
+ FileUtils.mkdir_p(dir)
161
+ path = source_path(source.name)
162
+ raise Invalid, "#{source.name} is already attached here (chi context rm #{source.name} first)" if File.exist?(path)
163
+
164
+ AtomicFile.write(path, "#{JSON.pretty_generate(source.to_h)}\n")
165
+ # Added again on purpose: no longer declined.
166
+ FileUtils.rm_f(declined_path(source.name))
167
+ source
168
+ end
169
+
170
+ # @return [Boolean] whether there was a source to remove
171
+ # The lock file stays: a fetch running now still holds it, and a new
172
+ # source of the name must wait for that fetch (which then finds its
173
+ # source gone and writes nothing: ContextFetch) instead of opening a
174
+ # fresh lock beside it.
175
+ # A session's source removed leaves a declined marker holding its hint
176
+ # (#declined?): an auto-attach (ctx.context, the github-pr bundle) on
177
+ # the worker's next start must not bring back what the user took off.
178
+ def remove(name)
179
+ removed = source(name)
180
+ [source_path(name), snapshot_path(name)].each { |path| FileUtils.rm_f(path) }
181
+ decline(name, removed.hint) if removed && session?
182
+ !removed.nil?
183
+ end
184
+
185
+ # Whether the user removed +name+ from this session (#remove).
186
+ def declined?(name) = File.exist?(declined_path(name))
187
+
188
+ # The hints (often URLs) of the sources removed here.
189
+ def declined_hints
190
+ folder = File.join(dir, DECLINED_DIR)
191
+ return [] unless Dir.exist?(folder)
192
+
193
+ Dir.children(folder).sort.filter_map do |file|
194
+ hint = File.read(File.join(folder, file)).strip
195
+ hint.empty? ? nil : hint
196
+ rescue SystemCallError
197
+ nil
198
+ end
199
+ end
200
+
201
+ # @return [Snapshot] Snapshot.empty when there is none yet
202
+ def snapshot(name)
203
+ data = ContextSources.read_json(snapshot_path(name))
204
+ data ? Snapshot.from_h(data) : Snapshot.empty
205
+ end
206
+
207
+ # @return [Time, nil] the snapshot file's mtime: the worker's cheap "anything new?"
208
+ def snapshot_mtime(name)
209
+ File.mtime(snapshot_path(name))
210
+ rescue SystemCallError
211
+ nil
212
+ end
213
+
214
+ def write_snapshot(name, snapshot)
215
+ FileUtils.mkdir_p(dir)
216
+ AtomicFile.write(snapshot_path(name), JSON.generate(snapshot.to_h))
217
+ snapshot
218
+ end
219
+
220
+ # A fetch or a push that worked: a new revision, or the same text
221
+ # fetched again (only fetched_at moves; its summary and wake are
222
+ # ignored). Clears the error.
223
+ # @return [Snapshot] the one written
224
+ def record_text(name, fetched, now: Time.now)
225
+ previous = snapshot(name)
226
+ revision = ContextSources.revision_of(fetched.text)
227
+ stamp = now.utc.iso8601
228
+ written = if revision == previous.revision
229
+ previous.with(fetched_at: stamp, checked_at: stamp, error: nil, error_since: nil)
230
+ else
231
+ Snapshot.new(text: fetched.text, revision: revision, serial: previous.serial + 1, fetched_at: stamp,
232
+ checked_at: stamp, summary: fetched.summary || ContextSources.plain_summary(previous.text, fetched.text),
233
+ wake: fetched.wake, hint: fetched.hint || previous.hint, error: nil, error_since: nil)
234
+ end
235
+ write_snapshot(name, written)
236
+ end
237
+
238
+ # Run the block holding <name>.lock (blocking), the lock a fetch
239
+ # holds while it runs and records: a push's read-modify-write of the
240
+ # snapshot (its serial) doesn't interleave with another writer.
241
+ def locked(name)
242
+ FileUtils.mkdir_p(dir)
243
+ File.open(lock_path(name), File::RDWR | File::CREAT, 0o644) do |lock|
244
+ lock.flock(File::LOCK_EX)
245
+ yield
246
+ end
247
+ end
248
+
249
+ # A fetch that failed: the last good text stays.
250
+ # @return [Snapshot] the one written
251
+ def record_error(name, message, now: Time.now)
252
+ previous = snapshot(name)
253
+ # Microseconds: two runs of failures never share a start.
254
+ stamp = now.utc.iso8601(6)
255
+ write_snapshot(name, previous.with(error: ContextSources.one_line(message, LINE_MAX_CHARS),
256
+ error_since: previous.error_since || stamp, checked_at: stamp))
257
+ end
258
+
259
+ def declined_path(name) = File.join(dir, DECLINED_DIR, ContextSources.check_name!(name))
260
+
261
+ def decline(name, hint)
262
+ FileUtils.mkdir_p(File.join(dir, DECLINED_DIR))
263
+ AtomicFile.write(declined_path(name), "#{hint}\n")
264
+ end
265
+
266
+ # Session only: the markers and the worker's subscription file.
267
+ def muted?(name) = File.exist?(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
268
+
269
+ def mute(name)
270
+ FileUtils.mkdir_p(File.join(dir, MUTED_DIR))
271
+ FileUtils.touch(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
272
+ end
273
+
274
+ def unmute(name) = FileUtils.rm_f(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
275
+
276
+ # @return [Hash{String => Subscription}]
277
+ def subscriptions
278
+ data = ContextSources.read_json(File.join(dir, SUBSCRIPTIONS_FILE)) || {}
279
+ data.to_h { |name, value| [name, Subscription.from_h(name, value.is_a?(Hash) ? value : {})] }
280
+ end
281
+
282
+ def subscription(name) = subscriptions[name] || Subscription.blank(name)
283
+
284
+ # Only the session's worker (its absorb step, its context_read) calls this.
285
+ def write_subscriptions(subs)
286
+ FileUtils.mkdir_p(dir)
287
+ AtomicFile.write(File.join(dir, SUBSCRIPTIONS_FILE), JSON.generate(subs.transform_values(&:to_h)))
288
+ end
289
+
290
+ def update_subscription(name)
291
+ subs = subscriptions
292
+ subs[name] = yield(subs[name] || Subscription.blank(name))
293
+ write_subscriptions(subs)
294
+ subs[name]
295
+ end
296
+ end
297
+
298
+ module_function
299
+
300
+ # <state dir>/context; +state_dir+ is the sessions dir (Session.default_state_dir).
301
+ def root(state_dir: nil)
302
+ File.join(File.dirname(state_dir || Session.default_state_dir), DIR)
303
+ end
304
+
305
+ def session_location(session_id, state_dir: nil)
306
+ Location.new(scope: "session", dir: File.join(root(state_dir: state_dir), "sessions", Session.check_id!(session_id)),
307
+ key: session_id)
308
+ end
309
+
310
+ def project_location(project_key, state_dir: nil)
311
+ Location.new(scope: "project", dir: File.join(root(state_dir: state_dir), "projects", project_key.to_s), key: project_key)
312
+ end
313
+
314
+ # The project a session's sources come from: nil when it is in no repo.
315
+ # @param project_root [String, nil] Session#project_root
316
+ def project_location_for(project_root, state_dir: nil)
317
+ return nil if project_root.to_s.empty?
318
+
319
+ project_location(project_key_of(project_root), state_dir: state_dir)
320
+ end
321
+
322
+ # MemoryPaths.project_key for a root already resolved.
323
+ def project_key_of(root)
324
+ "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
325
+ end
326
+
327
+ # A session's sources: its own, then the project's it doesn't shadow
328
+ # (+shadowed+ lists those too, marked).
329
+ # @return [Array<Attached>]
330
+ def attached(session_id, project_root:, state_dir: nil, shadowed: false)
331
+ own = session_location(session_id, state_dir: state_dir)
332
+ list = own.sources.map { |source| Attached.new(source: source, location: own, shadowed: false) }
333
+ project = project_location_for(project_root, state_dir: state_dir)
334
+ return list unless project
335
+
336
+ names = list.map(&:name)
337
+ project.sources.each do |source|
338
+ hidden = names.include?(source.name)
339
+ list << Attached.new(source: source, location: project, shadowed: hidden) if !hidden || shadowed
340
+ end
341
+ list
342
+ end
343
+
344
+ # Where a project's source runs: the project root when it is a checkout
345
+ # (its .git is there), else +fallback+ (the session's folder): a bare
346
+ # repo, proj/.bare or --separate-git-dir makes the root the git dir
347
+ # itself (MemoryPaths.project_root), and a command run there sees no
348
+ # files.
349
+ def project_cwd(project_root, fallback)
350
+ root = project_root.to_s
351
+ !root.empty? && File.exist?(File.join(root, ".git")) ? root : fallback
352
+ end
353
+
354
+ # A deleted session's sources, snapshots, subscriptions and markers.
355
+ # @return [Array<String>] the paths removed
356
+ def remove_session(session_id, state_dir: nil)
357
+ return [] unless Session.valid_id?(session_id.to_s)
358
+
359
+ dir = session_location(session_id, state_dir: state_dir).dir
360
+ return [] unless Dir.exist?(dir)
361
+
362
+ FileUtils.rm_rf(dir)
363
+ [dir]
364
+ rescue SystemCallError
365
+ []
366
+ end
367
+
368
+ # @return [String] +name+
369
+ # @raise [Invalid]
370
+ def check_name!(name)
371
+ name = name.to_s
372
+ unless name.match?(NAME_RE)
373
+ raise Invalid, "#{name.inspect} isn't a source name: lowercase letters, digits and -, up to 40, starting with a letter or digit"
374
+ end
375
+ raise Invalid, "#{name} is a reserved name" if RESERVED_NAMES.include?(name)
376
+
377
+ name
378
+ end
379
+
380
+ # @raise [Invalid] below MIN_EVERY_SECONDS or not a number
381
+ def check_every!(value)
382
+ return nil if value.nil?
383
+
384
+ seconds = Integer(value.to_s, 10, exception: false)
385
+ raise Invalid, "--every takes seconds, not #{value.inspect}" unless seconds
386
+ raise Invalid, "--every is #{seconds}; the least is #{MIN_EVERY_SECONDS} seconds" if seconds < MIN_EVERY_SECONDS
387
+
388
+ seconds
389
+ end
390
+
391
+ def revision_of(text) = Digest::SHA256.hexdigest(text.to_s)
392
+
393
+ # The contract: stdout that parses as a JSON object with a string "text"
394
+ # is {text, summary, wake, hint}; anything else is all text. Unknown
395
+ # keys are ignored; summary and hint become one line, cut.
396
+ # @return [Fetched]
397
+ # @raise [Invalid] nothing to keep (empty, or over TEXT_MAX_BYTES)
398
+ def parse_output(raw)
399
+ raw = raw.to_s.dup.force_encoding(Encoding::UTF_8).scrub
400
+ data = json_object(raw)
401
+ fetched = if data.is_a?(Hash) && data["text"].is_a?(String)
402
+ Fetched.new(text: data["text"].scrub, summary: one_line(data["summary"], SUMMARY_MAX_CHARS),
403
+ wake: data["wake"] == true, hint: one_line(data["hint"], LINE_MAX_CHARS))
404
+ else
405
+ Fetched.new(text: raw, summary: nil, wake: false, hint: nil)
406
+ end
407
+ raise Invalid, "the output is empty" if fetched.text.strip.empty?
408
+ if fetched.text.bytesize > TEXT_MAX_BYTES
409
+ raise Invalid, "the text is #{fetched.text.bytesize} bytes; the limit is 1 MiB (#{TEXT_MAX_BYTES} bytes)"
410
+ end
411
+
412
+ fetched
413
+ end
414
+
415
+ def json_object(raw)
416
+ return nil unless raw.lstrip.start_with?("{")
417
+
418
+ JSON.parse(raw)
419
+ rescue JSON::ParserError
420
+ nil
421
+ end
422
+
423
+ # chi's summary of a plain-text change: "content changed (+12/−3 lines)",
424
+ # or the size of the first text.
425
+ def plain_summary(before, after)
426
+ return "#{after.lines.size} #{after.lines.size == 1 ? "line" : "lines"} of text" if before.nil?
427
+
428
+ require_relative "text_diff"
429
+ diff = TextDiff.unified(before, after, max_lines: 0, max_bytes: 0)
430
+ "content changed (+#{diff[:added]}/−#{diff[:removed]} lines)"
431
+ end
432
+
433
+ # One line, whitespace collapsed, cut at +max+ chars with "…"; nil for blank.
434
+ def one_line(value, max)
435
+ return nil unless value.is_a?(String)
436
+
437
+ line = value.scrub.gsub(/\s+/, " ").strip
438
+ return nil if line.empty?
439
+
440
+ line.length > max ? "#{line[0, max - 1]}…" : line
441
+ end
442
+
443
+ def read_json(path)
444
+ return nil unless File.file?(path)
445
+
446
+ data = JSON.parse(File.read(path))
447
+ data.is_a?(Hash) ? data : nil
448
+ rescue JSON::ParserError, SystemCallError
449
+ nil
450
+ end
451
+ end
452
+ end
@@ -660,7 +660,7 @@ module Samagotchi
660
660
  replace_session_messages(session, Array(session.messages) + [message])
661
661
  announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
662
662
  label: ContextNote.label_of(message), from_session: note[:from_session],
663
- from_cwd: note[:from_cwd], text: note[:text],
663
+ from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
664
664
  created_at: note[:created_at] }.compact)
665
665
  message
666
666
  end
@@ -1192,6 +1192,7 @@ module Samagotchi
1192
1192
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1193
1193
  source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1194
1194
  elsif client_id.start_with?(ChildReports::CLIENT_PREFIX) then "delegate_report"
1195
+ elsif client_id.start_with?(Steer::CONTEXT_CLIENT_PREFIX) then "context"
1195
1196
  elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
1196
1197
  else "client"
1197
1198
  end
@@ -1462,6 +1463,7 @@ module Samagotchi
1462
1463
  PeerView = Struct.new(:engine) do
1463
1464
  def session_id = engine.session&.id
1464
1465
  def cwd = engine.session&.working_directory
1466
+ def project_root = engine.session&.project_root
1465
1467
  def state_dir = engine.peer_state_dir
1466
1468
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
1467
1469
  def relay = engine.relay_peer
@@ -1598,6 +1600,9 @@ module Samagotchi
1598
1600
  # stored/validated before :turn_started (which carries their refs), and
1599
1601
  # a model known not to see images fails the turn before anything of it
1600
1602
  # is kept (VisionUnsupported).
1603
+ # @param id [String, nil] the turn's id (a new uuid when nil): a caller
1604
+ # that put the turn's first message in the session already (a context
1605
+ # wake's note) names it with this id
1601
1606
  #
1602
1607
  # An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
1603
1608
  # the prompt is kept in the session and :turn_canceled is emitted, then the
@@ -1608,9 +1613,9 @@ module Samagotchi
1608
1613
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1609
1614
  # one-line summary:.
1610
1615
  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: [])
1616
+ images: [], id: nil)
1612
1617
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1613
- continue: continue)
1618
+ continue: continue, id: id)
1614
1619
  begin
1615
1620
  # A Stop cuts the /props probes this thread makes for the turn
1616
1621
  # (window, served model, vision) instead of waiting their timeout.
@@ -1654,7 +1659,7 @@ module Samagotchi
1654
1659
  # The turn's state before anything can fail it: the session running,
1655
1660
  # the turn flag, its cancel controller and sink. The cross-thread state
1656
1661
  # is set here and cleared in #release_turn only.
1657
- def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
1662
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil)
1658
1663
  # Track the active session for recap and status snapshot.
1659
1664
  @session = session
1660
1665
  # status is turn state: running now, idle again before the turn's end
@@ -1677,7 +1682,7 @@ module Samagotchi
1677
1682
 
1678
1683
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
1679
1684
  controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1680
- id: SecureRandom.uuid)
1685
+ id: id || SecureRandom.uuid)
1681
1686
  end
1682
1687
  private :begin_turn
1683
1688
 
@@ -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
 
@@ -40,12 +41,13 @@ module Samagotchi
40
41
  # They survive a PTY wrapper (script, expect). The docs list them too
41
42
  # (guardrails.md, sub-agent.md).
42
43
  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}
44
+ # Rules only the user may allow (Verdict::PROTECTED_RULES).
45
+ PROTECTED_RULES = Verdict::PROTECTED_RULES
46
+ # A shell command naming chi's config, hooks, guardrails or attached
47
+ # context as text (broad: a parent may not allow what only looks like
48
+ # one). samagotchi/context stops before a word character or a dot, so
49
+ # chi's own lib/samagotchi/context_*.rb isn't one.
50
+ CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|samagotchi/context(?![\w.])|memories/\.bundles}
49
51
  # The guardrails bundle's shell-touches-chi, for a word it can't
50
52
  # resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
51
53
  CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
@@ -110,7 +112,7 @@ module Samagotchi
110
112
  def chi_dirs
111
113
  hooks = begin Hooks::Loader.hooks_dir rescue nil end
112
114
  bundles = begin MemoryPaths.bundles_dir rescue nil end
113
- [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles]
115
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
114
116
  .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
115
117
  end
116
118
 
@@ -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"],
@@ -33,15 +33,6 @@ module Samagotchi
33
33
  Lexer.new(text).tokens
34
34
  end
35
35
 
36
- # lex's tokens, each with the range of +text+ it was read from
37
- # appended ([:word, "x", specials, 0...1] / [:op, "&&", 2...4]), for a
38
- # display that slices the original text. A heredoc body's range is
39
- # the body and its terminator line. lex itself is unchanged by this.
40
- def self.lex_with_spans(text)
41
- lexer = Lexer.new(text)
42
- lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
43
- end
44
-
45
36
  # The token stream split at operators into word lists; ( and ) become
46
37
  # :open / :close markers.
47
38
  def self.simple_commands(tokens)
@@ -5,9 +5,17 @@ module Samagotchi
5
5
  # The gate's verdict for one tool call. Voters (hooks now, rules later)
6
6
  # call #deny! or #ask!; the strictest vote wins (deny > ask > allow) and
7
7
  # a vote never relaxes it, so a deny is sticky. Of two equal votes the
8
- # first one stays.
8
+ # first one stays, except that a protected rule's ask (PROTECTED_RULES)
9
+ # takes over an unprotected ask: its rule id is what a parent's answer
10
+ # and a stored approval are checked against, and the offered scopes
11
+ # narrow to both asks' common ones.
9
12
  class Verdict
10
13
  STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
14
+ # Rules that ask about chi's own config and hooks: the core
15
+ # ProtectedPaths asks and the guardrails bundle's shell rule; and a
16
+ # command source for attached context (it runs later, ungated). Only
17
+ # the user may allow them (ParentApprovals).
18
+ PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd].freeze
11
19
  SCOPES = %w[once session repo rule].freeze
12
20
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
13
21
 
@@ -46,13 +54,18 @@ module Samagotchi
46
54
  # @param scopes [Array<String>, nil] which approval scopes the user may
47
55
  # pick (default all of SCOPES)
48
56
  def ask!(reason, scopes: nil, rule: nil, source: nil, decided_by: "hook")
57
+ earlier = ask? ? @scopes : nil
49
58
  return self unless vote(:ask, reason, rule: rule, source: source, decided_by: decided_by)
50
59
 
51
60
  picked = Array(scopes).map(&:to_s) & SCOPES
52
- @scopes = picked.empty? ? SCOPES : picked
61
+ picked = SCOPES if picked.empty?
62
+ picked &= earlier if earlier
63
+ @scopes = picked.empty? ? %w[once] : picked
53
64
  self
54
65
  end
55
66
 
67
+ def self.protected_rule?(rule) = PROTECTED_RULES.include?(rule.to_s)
68
+
56
69
  # A before_tool_call hook set the old event[:blocked] flag. Its model
57
70
  # text stays "blocked by guardrail: <reason>".
58
71
  def legacy_deny!(reason)
@@ -118,7 +131,7 @@ module Samagotchi
118
131
  end
119
132
 
120
133
  def vote(decision, reason, decided_by:, rule: nil, source: nil)
121
- return false unless STRICTNESS.fetch(decision) > STRICTNESS.fetch(@decision)
134
+ return false unless STRICTNESS.fetch(decision) > STRICTNESS.fetch(@decision) || takes_over?(decision, rule)
122
135
 
123
136
  @decision = decision
124
137
  @reason = reason.to_s.strip
@@ -129,6 +142,11 @@ module Samagotchi
129
142
  @legacy = false
130
143
  true
131
144
  end
145
+
146
+ # A protected rule's ask over an unprotected one.
147
+ def takes_over?(decision, rule)
148
+ decision == :ask && ask? && Verdict.protected_rule?(rule) && !Verdict.protected_rule?(@rule)
149
+ end
132
150
  end
133
151
  end
134
152
  end