samagotchi 0.30.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -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 +7 -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/internals/engine.md +1 -1
  11. data/docs/plugins.md +77 -1
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +9 -2
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  19. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  20. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  25. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/self_map.md +8 -1
  28. data/lib/samagotchi/child_reports.rb +212 -0
  29. data/lib/samagotchi/client.rb +35 -4
  30. data/lib/samagotchi/config.rb +11 -0
  31. data/lib/samagotchi/context_absorber.rb +179 -0
  32. data/lib/samagotchi/context_command.rb +473 -0
  33. data/lib/samagotchi/context_fetch.rb +226 -0
  34. data/lib/samagotchi/context_note.rb +11 -5
  35. data/lib/samagotchi/context_poller.rb +100 -0
  36. data/lib/samagotchi/context_providers.rb +159 -0
  37. data/lib/samagotchi/context_sources.rb +452 -0
  38. data/lib/samagotchi/engine.rb +12 -5
  39. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  41. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  42. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  43. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  44. data/lib/samagotchi/idle_recap.rb +4 -0
  45. data/lib/samagotchi/kernel_loop.rb +44 -5
  46. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  47. data/lib/samagotchi/llm/errors.rb +9 -0
  48. data/lib/samagotchi/log_line.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +1 -1
  50. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  51. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  52. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  53. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  54. data/lib/samagotchi/plugin/context.rb +22 -0
  55. data/lib/samagotchi/prompt_warmup.rb +30 -5
  56. data/lib/samagotchi/question_desk.rb +0 -3
  57. data/lib/samagotchi/reply_wait.rb +39 -8
  58. data/lib/samagotchi/session.rb +30 -7
  59. data/lib/samagotchi/session_commands.rb +18 -1
  60. data/lib/samagotchi/session_inbox.rb +34 -1
  61. data/lib/samagotchi/session_manager.rb +37 -5
  62. data/lib/samagotchi/session_retention.rb +3 -0
  63. data/lib/samagotchi/steer.rb +48 -7
  64. data/lib/samagotchi/system_prompt.rb +1 -1
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
  66. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  67. data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
  68. data/lib/samagotchi/terminal_ui.rb +9 -3
  69. data/lib/samagotchi/tool_activity.rb +8 -0
  70. data/lib/samagotchi/tool_call_parser.rb +40 -0
  71. data/lib/samagotchi/tool_declarations.rb +28 -7
  72. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  73. data/lib/samagotchi/tools/builtins.rb +5 -0
  74. data/lib/samagotchi/tools/context_read.rb +163 -0
  75. data/lib/samagotchi/tools/delegate.rb +13 -4
  76. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  77. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  78. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  79. data/lib/samagotchi/tools/task_wait.rb +2 -2
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +117 -3
  82. data/lib/samagotchi/web/public/app.js +52 -14
  83. data/lib/samagotchi/web/public/composer_size.js +7 -6
  84. data/lib/samagotchi/web/public/context_chips.js +285 -0
  85. data/lib/samagotchi/web/public/data.js +21 -0
  86. data/lib/samagotchi/web/public/format.js +8 -1
  87. data/lib/samagotchi/web/public/index.html +49 -3
  88. data/lib/samagotchi/web/public/notify.js +6 -2
  89. data/lib/samagotchi/web/public/timing.js +7 -3
  90. data/lib/samagotchi/web/public/turn_events.js +34 -10
  91. data/lib/samagotchi/web/public/turn_view.js +26 -4
  92. data/lib/samagotchi/worker.rb +343 -8
  93. metadata +16 -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
@@ -61,6 +61,7 @@ require_relative "thinking"
61
61
  require_relative "answer_display"
62
62
  require_relative "edit_preview"
63
63
  require_relative "question_desk"
64
+ require_relative "child_reports"
64
65
  require_relative "relay_desk"
65
66
  require_relative "guardrail_wiring"
66
67
  require_relative "plugin_tasks"
@@ -659,7 +660,7 @@ module Samagotchi
659
660
  replace_session_messages(session, Array(session.messages) + [message])
660
661
  announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
661
662
  label: ContextNote.label_of(message), from_session: note[:from_session],
662
- from_cwd: note[:from_cwd], text: note[:text],
663
+ from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
663
664
  created_at: note[:created_at] }.compact)
664
665
  message
665
666
  end
@@ -1190,6 +1191,8 @@ module Samagotchi
1190
1191
  def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1191
1192
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1192
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.start_with?(Steer::CONTEXT_CLIENT_PREFIX) then "context"
1193
1196
  elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
1194
1197
  else "client"
1195
1198
  end
@@ -1460,6 +1463,7 @@ module Samagotchi
1460
1463
  PeerView = Struct.new(:engine) do
1461
1464
  def session_id = engine.session&.id
1462
1465
  def cwd = engine.session&.working_directory
1466
+ def project_root = engine.session&.project_root
1463
1467
  def state_dir = engine.peer_state_dir
1464
1468
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
1465
1469
  def relay = engine.relay_peer
@@ -1596,6 +1600,9 @@ module Samagotchi
1596
1600
  # stored/validated before :turn_started (which carries their refs), and
1597
1601
  # a model known not to see images fails the turn before anything of it
1598
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
1599
1606
  #
1600
1607
  # An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
1601
1608
  # the prompt is kept in the session and :turn_canceled is emitted, then the
@@ -1606,9 +1613,9 @@ module Samagotchi
1606
1613
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1607
1614
  # one-line summary:.
1608
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,
1609
- images: [])
1616
+ images: [], id: nil)
1610
1617
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1611
- continue: continue)
1618
+ continue: continue, id: id)
1612
1619
  begin
1613
1620
  # A Stop cuts the /props probes this thread makes for the turn
1614
1621
  # (window, served model, vision) instead of waiting their timeout.
@@ -1652,7 +1659,7 @@ module Samagotchi
1652
1659
  # The turn's state before anything can fail it: the session running,
1653
1660
  # the turn flag, its cancel controller and sink. The cross-thread state
1654
1661
  # is set here and cleared in #release_turn only.
1655
- 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)
1656
1663
  # Track the active session for recap and status snapshot.
1657
1664
  @session = session
1658
1665
  # status is turn state: running now, idle again before the turn's end
@@ -1675,7 +1682,7 @@ module Samagotchi
1675
1682
 
1676
1683
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
1677
1684
  controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1678
- id: SecureRandom.uuid)
1685
+ id: id || SecureRandom.uuid)
1679
1686
  end
1680
1687
  private :begin_turn
1681
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)