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