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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +3 -1
- data/docs/context.md +218 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +1 -0
- data/docs/plugins.md +77 -1
- data/docs/releasing.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +6 -0
- data/lib/samagotchi/child_reports.rb +0 -2
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/context_absorber.rb +179 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +226 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +452 -0
- data/lib/samagotchi/engine.rb +10 -5
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/question_desk.rb +0 -3
- data/lib/samagotchi/session.rb +7 -5
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +12 -3
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -3
- data/lib/samagotchi/terminal_ui.rb +0 -3
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_declarations.rb +21 -0
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +109 -1
- data/lib/samagotchi/web/public/app.js +40 -10
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +21 -0
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +5 -3
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +8 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -1
- data/lib/samagotchi/worker.rb +126 -0
- 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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
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
|
|
9
|
-
# hooks, rules, manifests)
|
|
10
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|