samagotchi 0.102.0 → 0.104.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 +93 -1
- data/bin/chi +60 -191
- data/docs/architecture.md +67 -1
- data/docs/cli.md +190 -9
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +4 -2
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +5 -2
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +429 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +33 -3
- data/docs/sessions.md +16 -7
- data/docs/sub-agent.md +16 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +31 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +26 -39
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +40 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +28 -14
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +8 -2
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +21 -2
- data/lib/samagotchi/launch_mode.rb +26 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +245 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +38 -9
- data/lib/samagotchi/session_access/contract.rb +167 -0
- data/lib/samagotchi/session_access/dispatch.rb +152 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +400 -0
- data/lib/samagotchi/session_access/remote.rb +734 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
- data/lib/samagotchi/session_access.rb +223 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +27 -6
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
- data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
- data/lib/samagotchi/terminal_ui.rb +6 -3
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +530 -126
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +459 -0
- data/lib/samagotchi/worker.rb +26 -5
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- metadata +28 -1
|
@@ -4,6 +4,7 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../reply_wait"
|
|
5
5
|
require_relative "../parent_report"
|
|
6
6
|
require_relative "exit"
|
|
7
|
+
require_relative "../session_access"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
module CLI
|
|
@@ -12,8 +13,8 @@ module Samagotchi
|
|
|
12
13
|
# for it, and the wait for what comes next, reported the ParentReport
|
|
13
14
|
# way (Ctrl-C included). The including class (a CLI::Command) defines
|
|
14
15
|
# #parse (options, or an exit status) and #run_parsed(options), sets
|
|
15
|
-
# @stdout, @stderr and @
|
|
16
|
-
# (specs stub them on the class).
|
|
16
|
+
# @stdout, @stderr and @access (a SessionAccess backend), and may
|
|
17
|
+
# override the constants (specs stub them on the class).
|
|
17
18
|
module ParentWait
|
|
18
19
|
POLL_INTERVAL = ReplyWait::POLL_INTERVAL
|
|
19
20
|
# No live worker this long while waiting: it died before it could mark
|
|
@@ -30,7 +31,14 @@ module Samagotchi
|
|
|
30
31
|
# With --format json stdout is one JSON object, whatever the end: a
|
|
31
32
|
# failure before the wait is reported from stderr's last line.
|
|
32
33
|
@stderr = ParentReport::LastLine.new(@stderr) if @json
|
|
33
|
-
status =
|
|
34
|
+
status = begin
|
|
35
|
+
run_parsed(options)
|
|
36
|
+
rescue SessionAccess::RemoteFailed => e
|
|
37
|
+
# A remote that went away mid-run: the command's line, and with
|
|
38
|
+
# --format json its object.
|
|
39
|
+
error_line("#{command_name}: #{e.message}")
|
|
40
|
+
Exit::FAILED
|
|
41
|
+
end
|
|
34
42
|
if @json && !@reported && status == 1
|
|
35
43
|
detail = @stderr.last.to_s.delete_prefix("#{command_name}: ")
|
|
36
44
|
@stdout.puts(ParentReport.error_line(detail, session_id: @session_id))
|
|
@@ -45,9 +53,7 @@ module Samagotchi
|
|
|
45
53
|
# error line; nil after the error line.
|
|
46
54
|
# @return [String, nil]
|
|
47
55
|
def resolve(given)
|
|
48
|
-
|
|
49
|
-
Session.load(id, state_dir: @state_dir)
|
|
50
|
-
@session_id = id
|
|
56
|
+
@session_id = @access.resolve(given)
|
|
51
57
|
rescue ArgumentError => e
|
|
52
58
|
error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
|
|
53
59
|
nil
|
|
@@ -70,16 +76,19 @@ module Samagotchi
|
|
|
70
76
|
# @return [Integer] the exit status
|
|
71
77
|
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
|
|
72
78
|
queued: false, ended_before: false, report_turn_id: turn)
|
|
73
|
-
result =
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
result = @access.wait_reply(id, cursor: cursor, timeout: timeout, baseline: baseline, owner_grace: owner_grace,
|
|
80
|
+
poll_interval: self.class::POLL_INTERVAL, turn: turn, queued: queued,
|
|
81
|
+
ended_before: ended_before)
|
|
76
82
|
result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
|
|
77
83
|
result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
78
84
|
@reported = true
|
|
79
|
-
# What a parent may pick there: config.yml tightened by the session's
|
|
80
|
-
|
|
85
|
+
# What a parent may pick there: config.yml tightened by the session's
|
|
86
|
+
# own. Machine-local (this machine's config.yml): remotely the
|
|
87
|
+
# server's (P2's wait route).
|
|
88
|
+
strategy = @access.parent_strategy(id)
|
|
81
89
|
ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
|
|
82
|
-
json: @json, timeout: timeout, extra: report_extra, strategy: strategy
|
|
90
|
+
json: @json, timeout: timeout, extra: report_extra, strategy: strategy,
|
|
91
|
+
via: @access.invocation)
|
|
83
92
|
rescue Interrupt
|
|
84
93
|
if @json
|
|
85
94
|
@stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
|
|
@@ -87,7 +96,7 @@ module Samagotchi
|
|
|
87
96
|
@stdout.flush
|
|
88
97
|
@reported = true
|
|
89
98
|
end
|
|
90
|
-
error_line("#{command_name}: still running:
|
|
99
|
+
error_line("#{command_name}: still running: #{@access.invocation.shows(id)}")
|
|
91
100
|
Exit::INTERRUPTED
|
|
92
101
|
rescue ArgumentError
|
|
93
102
|
error_line("#{command_name}: the session is gone (deleted while waiting)")
|
|
@@ -11,6 +11,7 @@ require_relative "cli/command"
|
|
|
11
11
|
require_relative "cli/exit"
|
|
12
12
|
require_relative "cli/flags"
|
|
13
13
|
require_relative "redacted_inspect"
|
|
14
|
+
require_relative "session_access/local"
|
|
14
15
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# `chi context`: attach live external text to sessions (ContextSources).
|
|
@@ -76,10 +77,16 @@ module Samagotchi
|
|
|
76
77
|
def project? = session_id.nil?
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# Why chi context rm NAME ID (and chi web's DELETE …/context/NAME?own=1)
|
|
81
|
+
# leaves a source the session's project has.
|
|
82
|
+
def self.project_source_message(name) = "#{name} is the project's: chi context rm #{name} --project, or mute it for this session"
|
|
83
|
+
|
|
79
84
|
# @param argv [Array<String>] the arguments after "context"
|
|
80
85
|
# @param clock [#call] now, for ls's ages ("4s ago")
|
|
86
|
+
# @param access [SessionAccess::Local, nil] where the sessions are;
|
|
87
|
+
# default this machine's, in +state_dir+
|
|
81
88
|
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd,
|
|
82
|
-
clock: -> { Time.now })
|
|
89
|
+
clock: -> { Time.now }, access: nil)
|
|
83
90
|
@argv = argv.dup
|
|
84
91
|
@stdin = stdin
|
|
85
92
|
@stdout = stdout
|
|
@@ -88,6 +95,7 @@ module Samagotchi
|
|
|
88
95
|
@env = env
|
|
89
96
|
@cwd = cwd
|
|
90
97
|
@clock = clock
|
|
98
|
+
@access = access || SessionAccess::Local.new(state_dir: @state_dir)
|
|
91
99
|
end
|
|
92
100
|
|
|
93
101
|
# @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
|
|
@@ -127,11 +135,10 @@ module Samagotchi
|
|
|
127
135
|
name: name, cmd: options[:cmd], every_seconds: ContextSources.check_every!(options[:every]),
|
|
128
136
|
why: ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS),
|
|
129
137
|
hint: ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS),
|
|
130
|
-
scope: nil, added_by:
|
|
138
|
+
scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601
|
|
131
139
|
)
|
|
132
140
|
each_target(options, args) do |target|
|
|
133
|
-
|
|
134
|
-
location.add(source.with(scope: location.scope))
|
|
141
|
+
@access.context_add(target, source)
|
|
135
142
|
@stdout.puts("#{target.label} attached #{name}")
|
|
136
143
|
true
|
|
137
144
|
end
|
|
@@ -144,6 +151,7 @@ module Samagotchi
|
|
|
144
151
|
if options[:cmd] || options[:push] || options[:every]
|
|
145
152
|
return usage_error("a URL's command comes from its provider: give no --cmd, --push or --every")
|
|
146
153
|
end
|
|
154
|
+
return add_remote_url(url, options, args) if @access.remote?
|
|
147
155
|
|
|
148
156
|
resolved = ContextProviders.resolve(url)
|
|
149
157
|
return fail_line("no installed bundle resolves #{url} (chi bundle install github-pr for GitHub PRs)") unless resolved
|
|
@@ -152,11 +160,10 @@ module Samagotchi
|
|
|
152
160
|
name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds,
|
|
153
161
|
why: ContextSources.one_line(options[:why] || resolved.why, ContextSources::LINE_MAX_CHARS),
|
|
154
162
|
hint: ContextSources.one_line(options[:hint] || resolved.hint, ContextSources::LINE_MAX_CHARS),
|
|
155
|
-
scope: nil, added_by:
|
|
163
|
+
scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601, provider: resolved.bundle
|
|
156
164
|
)
|
|
157
165
|
each_target(options, args) do |target|
|
|
158
|
-
|
|
159
|
-
location.add(source.with(scope: location.scope))
|
|
166
|
+
@access.context_add(target, source)
|
|
160
167
|
@stdout.puts("#{target.label} attached #{source.name}")
|
|
161
168
|
true
|
|
162
169
|
end
|
|
@@ -164,6 +171,19 @@ module Samagotchi
|
|
|
164
171
|
fail_line(e.message)
|
|
165
172
|
end
|
|
166
173
|
|
|
174
|
+
# A URL on a remote: its installed bundles' providers resolve it there.
|
|
175
|
+
def add_remote_url(url, options, args)
|
|
176
|
+
why = ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS)
|
|
177
|
+
hint = ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS)
|
|
178
|
+
each_target(options, args) do |target|
|
|
179
|
+
name = @access.context_add_url(target, url, why: why, hint: hint, added_by: added_by)
|
|
180
|
+
@stdout.puts("#{target.label} attached #{name}")
|
|
181
|
+
true
|
|
182
|
+
end
|
|
183
|
+
rescue ContextProviders::Invalid => e
|
|
184
|
+
fail_line(e.message)
|
|
185
|
+
end
|
|
186
|
+
|
|
167
187
|
def run_push(options, args)
|
|
168
188
|
name = args.shift
|
|
169
189
|
return usage_error("give the source's NAME") unless name
|
|
@@ -173,18 +193,20 @@ module Samagotchi
|
|
|
173
193
|
|
|
174
194
|
fetched = ContextSources.parse_output(text)
|
|
175
195
|
each_target(options, args) do |target|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
196
|
+
pushed = @access.context_push(target, name, fetched)
|
|
197
|
+
case pushed.status
|
|
198
|
+
when :missing
|
|
199
|
+
no_source(target, name)
|
|
200
|
+
next false
|
|
201
|
+
when :not_push
|
|
202
|
+
# A command source's text is its command's (R2): a push between
|
|
203
|
+
# two fetches would read as a change and then vanish.
|
|
204
|
+
error_line("#{command_name}: #{SessionAccess::ContextPush.not_push_message(name)}")
|
|
181
205
|
next false
|
|
182
206
|
end
|
|
183
207
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
end
|
|
187
|
-
@stdout.puts("#{target.label} #{name}: #{written.revision == before ? "unchanged" : "new text"} (#{written.revision[0, 12]})")
|
|
208
|
+
@stdout.puts("#{target.label} #{name}: #{pushed.revision == pushed.before ? "unchanged" : "new text"} " \
|
|
209
|
+
"(#{pushed.revision[0, 12]})")
|
|
188
210
|
true
|
|
189
211
|
end
|
|
190
212
|
end
|
|
@@ -194,7 +216,7 @@ module Samagotchi
|
|
|
194
216
|
return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
|
|
195
217
|
|
|
196
218
|
targets = targets(options, args) or return CLI::Exit::FAILED
|
|
197
|
-
rows = targets.flat_map { |target|
|
|
219
|
+
rows = targets.flat_map { |target| @access.context_entries(target).map { |entry| row(target, entry) } }
|
|
198
220
|
if format == "json"
|
|
199
221
|
@stdout.puts(JSON.pretty_generate(rows))
|
|
200
222
|
elsif rows.empty?
|
|
@@ -212,10 +234,10 @@ module Samagotchi
|
|
|
212
234
|
targets = targets(options, args) or return CLI::Exit::FAILED
|
|
213
235
|
return usage_error("show takes one target") if targets.size > 1
|
|
214
236
|
|
|
215
|
-
|
|
216
|
-
snapshot =
|
|
237
|
+
entry = @access.context_entry(targets.first, name) or return no_source(targets.first, name)
|
|
238
|
+
snapshot = entry.snapshot
|
|
217
239
|
if options[:json]
|
|
218
|
-
@stdout.puts(JSON.pretty_generate({ "source" =>
|
|
240
|
+
@stdout.puts(JSON.pretty_generate({ "source" => entry.source.to_h, "snapshot" => snapshot.to_h }))
|
|
219
241
|
return 0
|
|
220
242
|
end
|
|
221
243
|
unless snapshot.text?
|
|
@@ -229,7 +251,8 @@ module Samagotchi
|
|
|
229
251
|
|
|
230
252
|
# Runs the command in this process, not the worker's: the result shows
|
|
231
253
|
# here, and the worker sees the new snapshot on its next loop. The
|
|
232
|
-
# source's lock keeps the two from running it at once.
|
|
254
|
+
# source's lock keeps the two from running it at once. Local only
|
|
255
|
+
# (SessionAccess::Contract): it runs the command on this machine.
|
|
233
256
|
def run_refresh(options, args)
|
|
234
257
|
name = args.shift
|
|
235
258
|
return usage_error("give the source's NAME") unless name
|
|
@@ -271,15 +294,12 @@ module Samagotchi
|
|
|
271
294
|
|
|
272
295
|
ContextSources.check_name!(name)
|
|
273
296
|
each_target(options, args) do |target|
|
|
274
|
-
|
|
275
|
-
|
|
297
|
+
case @access.context_remove(target, name)
|
|
298
|
+
when :removed
|
|
276
299
|
@stdout.puts("#{target.label} detached #{name}")
|
|
277
300
|
next true
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
project = !target.project? && ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
|
|
281
|
-
if project&.source(name)
|
|
282
|
-
error_line("#{command_name}: #{name} is the project's: chi context rm #{name} --project, or mute it for this session")
|
|
301
|
+
when :project
|
|
302
|
+
error_line("#{command_name}: #{self.class.project_source_message(name)}")
|
|
283
303
|
else
|
|
284
304
|
error_line("#{command_name}: no source #{name} for #{target.label}")
|
|
285
305
|
end
|
|
@@ -287,6 +307,7 @@ module Samagotchi
|
|
|
287
307
|
end
|
|
288
308
|
end
|
|
289
309
|
|
|
310
|
+
# Local only (SessionAccess::Contract): the session's muted/ files here.
|
|
290
311
|
def run_mute(options, args) = mute_or_unmute(options, args, mute: true)
|
|
291
312
|
def run_unmute(options, args) = mute_or_unmute(options, args, mute: false)
|
|
292
313
|
|
|
@@ -327,10 +348,11 @@ module Samagotchi
|
|
|
327
348
|
return [target]
|
|
328
349
|
end
|
|
329
350
|
if args.empty?
|
|
330
|
-
own = inside_session
|
|
351
|
+
own = inside_session unless @access.remote?
|
|
331
352
|
unless own
|
|
332
|
-
|
|
333
|
-
|
|
353
|
+
# Remotely no default: the session chi runs in is this machine's.
|
|
354
|
+
error_line("#{command_name}: give session ids (or unique prefixes)" \
|
|
355
|
+
"#{", or --project; inside a chi session it's that session" unless @access.remote?}")
|
|
334
356
|
return nil
|
|
335
357
|
end
|
|
336
358
|
args = [own]
|
|
@@ -340,6 +362,8 @@ module Samagotchi
|
|
|
340
362
|
resolved.all? ? resolved.uniq : nil
|
|
341
363
|
end
|
|
342
364
|
|
|
365
|
+
# A project's sources: local only (SessionAccess::Contract), this
|
|
366
|
+
# machine's checkout.
|
|
343
367
|
def project_target
|
|
344
368
|
unless MemoryPaths.in_repo?(@cwd)
|
|
345
369
|
error_line("#{command_name}: --project needs a git repository, and #{@cwd} is in none")
|
|
@@ -354,8 +378,8 @@ module Samagotchi
|
|
|
354
378
|
end
|
|
355
379
|
|
|
356
380
|
def session_target(given)
|
|
357
|
-
id =
|
|
358
|
-
session =
|
|
381
|
+
id = @access.resolve_id(given)
|
|
382
|
+
session = @access.show(id)
|
|
359
383
|
Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory, label: id[0, 8])
|
|
360
384
|
rescue ArgumentError => e
|
|
361
385
|
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
@@ -363,64 +387,47 @@ module Samagotchi
|
|
|
363
387
|
nil
|
|
364
388
|
end
|
|
365
389
|
|
|
390
|
+
# Who adds a source: an agent (chi's execute runs this in a session)
|
|
391
|
+
# or the user's cli.
|
|
392
|
+
def added_by = inside_session ? "agent" : "cli"
|
|
393
|
+
|
|
366
394
|
# The session chi's execute runs this in (SAMAGOTCHI_PARENT_SESSION;
|
|
367
|
-
# "chi" is a run with no session).
|
|
395
|
+
# "chi" is a run with no session). Machine-local (a session on this
|
|
396
|
+
# machine): remotely it is no default target (#targets), the ids are
|
|
397
|
+
# given.
|
|
368
398
|
def inside_session
|
|
369
399
|
id = @env.fetch("SAMAGOTCHI_PARENT_SESSION", "").to_s.strip
|
|
370
400
|
id.empty? || id == "chi" ? nil : id
|
|
371
401
|
end
|
|
372
402
|
|
|
373
|
-
# The folder a target's own sources live in.
|
|
374
|
-
def location_of(target)
|
|
375
|
-
if target.project?
|
|
376
|
-
ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
|
|
377
|
-
else
|
|
378
|
-
ContextSources.session_location(target.session_id, state_dir: @state_dir)
|
|
379
|
-
end
|
|
380
|
-
end
|
|
403
|
+
# The folder a target's own sources live in (refresh, mute: local only).
|
|
404
|
+
def location_of(target) = @access.context_location(target)
|
|
381
405
|
|
|
382
|
-
# A source as the target sees it (a session: its own, then its
|
|
406
|
+
# A source as the target sees it (a session: its own, then its
|
|
407
|
+
# project's), for refresh and mute (local only).
|
|
383
408
|
# @return [ContextSources::Attached, nil] nil after an error line
|
|
384
409
|
def find(target, name)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
location = location_of(target)
|
|
388
|
-
source = location.source(name)
|
|
389
|
-
source && ContextSources::Attached.new(source: source, location: location, shadowed: false)
|
|
390
|
-
else
|
|
391
|
-
attached_to(target).find { |a| a.name == name }
|
|
392
|
-
end
|
|
393
|
-
error_line("#{command_name}: no source #{name} for #{target.label}") unless found
|
|
410
|
+
found = @access.context_attached(target, name)
|
|
411
|
+
no_source(target, name) unless found
|
|
394
412
|
found
|
|
395
413
|
end
|
|
396
414
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def rows_for(target)
|
|
403
|
-
if target.project?
|
|
404
|
-
location = location_of(target)
|
|
405
|
-
return location.sources.map { |source| row(target, source, location.snapshot(source.name)) }
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
own = ContextSources.session_location(target.session_id, state_dir: @state_dir)
|
|
409
|
-
subs = own.subscriptions
|
|
410
|
-
attached_to(target, shadowed: true).map do |a|
|
|
411
|
-
snapshot = a.snapshot
|
|
412
|
-
read = subs[a.name]&.read
|
|
413
|
-
row(target, a.source, snapshot, muted: own.muted?(a.name), shadowed: a.shadowed,
|
|
414
|
-
unread: snapshot.text? && read != snapshot.revision)
|
|
415
|
-
end
|
|
415
|
+
# @return [Integer] 1, after the line
|
|
416
|
+
def no_source(target, name)
|
|
417
|
+
error_line("#{command_name}: no source #{name} for #{target.label}")
|
|
418
|
+
CLI::Exit::FAILED
|
|
416
419
|
end
|
|
417
420
|
|
|
418
|
-
|
|
421
|
+
# One line of ls (its --format json object).
|
|
422
|
+
# @param entry [SessionAccess::ContextEntry]
|
|
423
|
+
def row(target, entry)
|
|
424
|
+
source = entry.source
|
|
425
|
+
snapshot = entry.snapshot
|
|
419
426
|
{ "target" => target.label, "session_id" => target.session_id, "name" => source.name, "scope" => source.scope,
|
|
420
427
|
"cmd" => source.cmd, "every_seconds" => source.every_seconds, "why" => source.why, "hint" => source.hint,
|
|
421
428
|
"added_by" => source.added_by, "created_at" => source.created_at, "fetched_at" => snapshot.fetched_at,
|
|
422
429
|
"revision" => snapshot.revision, "summary" => snapshot.summary, "error" => snapshot.error,
|
|
423
|
-
"muted" => muted, "shadowed" => shadowed, "unread" => unread }.compact
|
|
430
|
+
"muted" => entry.muted, "shadowed" => entry.shadowed, "unread" => entry.unread }.compact
|
|
424
431
|
end
|
|
425
432
|
|
|
426
433
|
def print_rows(rows, labels:)
|
|
@@ -116,13 +116,20 @@ module Samagotchi
|
|
|
116
116
|
# last status line's bucket is where rises count from)
|
|
117
117
|
# @param llm_context [LLMContextStrategy::Resolved, nil] the turn's
|
|
118
118
|
# strategy (its budget)
|
|
119
|
-
|
|
119
|
+
# @param counted_chars_per_token [Float, nil] the last turn's
|
|
120
|
+
# #counted_chars_per_token: the turn's estimate before its first count
|
|
121
|
+
# uses it instead of the configured ratio, so the value doesn't dip at
|
|
122
|
+
# a turn's start (the chat loop's chars leave out the tool schemas the
|
|
123
|
+
# server counts)
|
|
124
|
+
def initialize(conversation: [], llm_context: nil, counted_chars_per_token: nil)
|
|
120
125
|
@budget = llm_context&.budget_tokens
|
|
121
126
|
@forget = llm_context&.forget? || false
|
|
122
127
|
@offered = self.class.last_line(conversation).to_s.include?(OFFER_TOOL)
|
|
123
128
|
@enabled = self.class.enabled?
|
|
124
129
|
chars_per_token = Config.get("context.chars_per_token").to_f
|
|
125
130
|
@chars_per_token = chars_per_token.positive? ? chars_per_token : DEFAULT_CHARS_PER_TOKEN
|
|
131
|
+
@estimate_chars_per_token = counted_chars_per_token.to_f.positive? ? counted_chars_per_token.to_f : @chars_per_token
|
|
132
|
+
@counted_chars_per_token = nil
|
|
126
133
|
parsed = Config.get("context.status_thresholds").to_s.split(",").map { |value| value.strip.to_i }
|
|
127
134
|
.select { |value| value.between?(1, 99) }.uniq.sort
|
|
128
135
|
@thresholds = parsed.empty? ? DEFAULT_THRESHOLDS : parsed
|
|
@@ -137,6 +144,10 @@ module Samagotchi
|
|
|
137
144
|
# @return [Hash, nil] the status line's value ({est_pct:, bucket:});
|
|
138
145
|
# nil with context.status off or before the first request
|
|
139
146
|
attr_reader :display
|
|
147
|
+
# @return [Float, nil] the chars per token of the last request the
|
|
148
|
+
# server counted (its prompt chars over its prompt tokens, images
|
|
149
|
+
# aside), for the next turn's tracker; nil before a count
|
|
150
|
+
attr_reader :counted_chars_per_token
|
|
140
151
|
|
|
141
152
|
def enabled? = @enabled
|
|
142
153
|
|
|
@@ -212,6 +223,8 @@ module Samagotchi
|
|
|
212
223
|
|
|
213
224
|
@counted = { chars: prompt_chars, image_tokens: image_tokens }
|
|
214
225
|
@edited = false
|
|
226
|
+
text_tokens = usage[:prompt_tokens].to_i - image_tokens.to_i
|
|
227
|
+
@counted_chars_per_token = prompt_chars.to_f / text_tokens if prompt_chars.positive? && text_tokens.positive?
|
|
215
228
|
value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
|
|
216
229
|
@display = value if value
|
|
217
230
|
end
|
|
@@ -243,7 +256,7 @@ module Samagotchi
|
|
|
243
256
|
source = "server"
|
|
244
257
|
else
|
|
245
258
|
window_tokens = counted_against(window.tokens)
|
|
246
|
-
used_tokens = (prompt_chars / @
|
|
259
|
+
used_tokens = (prompt_chars / @estimate_chars_per_token).ceil + image_tokens
|
|
247
260
|
source = "estimate"
|
|
248
261
|
end
|
|
249
262
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -347,6 +347,8 @@ module Samagotchi
|
|
|
347
347
|
# A plugin tool's row refs resolve against the session's folder, as
|
|
348
348
|
# answer refs do (AnswerRefs), not the process's.
|
|
349
349
|
@kernel.session_cwd = -> { @session&.working_directory }
|
|
350
|
+
# forget_outputs refuses what the user pinned in the working set.
|
|
351
|
+
@kernel.working_set_pins = -> { @session&.working_set_pins || [] }
|
|
350
352
|
# A cut tool output is saved whole in the session's folder.
|
|
351
353
|
@kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
|
|
352
354
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
@@ -1856,6 +1858,11 @@ module Samagotchi
|
|
|
1856
1858
|
@recap&.write_now(on_start: on_start)
|
|
1857
1859
|
end
|
|
1858
1860
|
|
|
1861
|
+
# The longest #write_recap_now waits (recap.timeout), nil when no recap
|
|
1862
|
+
# is set up.
|
|
1863
|
+
# @return [Float, nil]
|
|
1864
|
+
def recap_timeout = @recap&.timeout
|
|
1865
|
+
|
|
1859
1866
|
# Ask for a recap now (/recap), without waiting for the idle window.
|
|
1860
1867
|
# @return [Symbol] IdleRecap#request_now's answer, or :off
|
|
1861
1868
|
def request_recap
|
|
@@ -1885,9 +1892,12 @@ module Samagotchi
|
|
|
1885
1892
|
self
|
|
1886
1893
|
end
|
|
1887
1894
|
|
|
1888
|
-
# Stop the shared idle scheduler (TerminalUI calls this when the REPL
|
|
1889
|
-
|
|
1890
|
-
|
|
1895
|
+
# Stop the shared idle scheduler (TerminalUI calls this when the REPL
|
|
1896
|
+
# exits). +join+: wait for a tick under way (IdleScheduler#stop); a
|
|
1897
|
+
# caller holding #synchronize_events passes false, since a recap's emit
|
|
1898
|
+
# in that tick waits for the same lock.
|
|
1899
|
+
def stop_idle(join: true)
|
|
1900
|
+
@idle_scheduler&.stop(join: join)
|
|
1891
1901
|
self
|
|
1892
1902
|
end
|
|
1893
1903
|
|
|
@@ -1945,9 +1955,11 @@ module Samagotchi
|
|
|
1945
1955
|
# run it resumes started (ModelResult#run_start, kept in the offer by
|
|
1946
1956
|
# TurnFlow), so it keeps that run's reasoning; nil starts cold (a
|
|
1947
1957
|
# model change while the offer stood, every other turn).
|
|
1948
|
-
# @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:}
|
|
1949
|
-
# when
|
|
1950
|
-
#
|
|
1958
|
+
# @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:},
|
|
1959
|
+
# and author: when someone other than the session's owner wrote it, from
|
|
1960
|
+
# its input file; absent for the owner); when given, the turn's boundary
|
|
1961
|
+
# events (:turn_started, :turn_completed, :turn_canceled, :turn_failed)
|
|
1962
|
+
# carry it as `origin:`, and GuardrailWiring#begin_turn gets it
|
|
1951
1963
|
# @param images [Array<Hash>] the prompt's images: {path:} (a file on this
|
|
1952
1964
|
# machine; in-process and attached-TUI callers only) or {file:, name:}
|
|
1953
1965
|
# (already in the session's images/, e.g. a web upload). They are
|
|
@@ -2943,7 +2955,8 @@ module Samagotchi
|
|
|
2943
2955
|
end
|
|
2944
2956
|
|
|
2945
2957
|
# The working set (WorkingSet) as the model has it, as a
|
|
2946
|
-
# :working_set_updated event ({items:, at:}
|
|
2958
|
+
# :working_set_updated event ({items:, at:}, adds: while the user's
|
|
2959
|
+
# adds are pending): at each request (the
|
|
2947
2960
|
# stream handler's :generation_started, from the running turn's
|
|
2948
2961
|
# conversation) and at turn end (#end_turn, from the session's kept
|
|
2949
2962
|
# messages). Only when it differs from the last one emitted for this
|
|
@@ -2958,18 +2971,19 @@ module Samagotchi
|
|
|
2958
2971
|
def emit_working_set(on_event, session, conversation)
|
|
2959
2972
|
return unless session && conversation
|
|
2960
2973
|
|
|
2961
|
-
snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy
|
|
2974
|
+
snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy,
|
|
2975
|
+
pins: session.working_set_pins)
|
|
2962
2976
|
|
|
2963
|
-
|
|
2977
|
+
shown = snapshot.to_h.except(:at)
|
|
2964
2978
|
last = @working_set_emitted
|
|
2965
|
-
if
|
|
2966
|
-
return unless last && last[0] == session.id &&
|
|
2967
|
-
elsif last == [session.id,
|
|
2979
|
+
if snapshot.empty?
|
|
2980
|
+
return unless last && last[0] == session.id && last[1] != { items: [] }
|
|
2981
|
+
elsif last == [session.id, shown]
|
|
2968
2982
|
return
|
|
2969
2983
|
end
|
|
2970
2984
|
|
|
2971
|
-
@working_set_emitted = [session.id,
|
|
2972
|
-
event = { type: :working_set_updated,
|
|
2985
|
+
@working_set_emitted = [session.id, shown]
|
|
2986
|
+
event = { type: :working_set_updated, **shown, at: snapshot.at }
|
|
2973
2987
|
on_event ? emit_event(on_event, event) : announce_or_hold(event)
|
|
2974
2988
|
rescue StandardError => e
|
|
2975
2989
|
Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
@@ -83,12 +83,18 @@ module Samagotchi
|
|
|
83
83
|
@protected = nil
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
# A turn starts: its origin
|
|
86
|
+
# A turn starts: its origin (Engine#run_turn's: {client_id:,
|
|
87
|
+
# enqueued_id:, author:}, each optional), and the git facts read afresh.
|
|
87
88
|
def begin_turn(origin)
|
|
88
89
|
@origin = origin
|
|
89
90
|
@git = Guardrails::GitInfo.new
|
|
90
91
|
end
|
|
91
92
|
|
|
93
|
+
# Who wrote the running turn when it isn't the session's owner (its
|
|
94
|
+
# origin's author); nil for the owner's turns and every other origin.
|
|
95
|
+
# @return [String, nil]
|
|
96
|
+
def turn_author = @origin.is_a?(Hash) ? @origin[:author] : nil
|
|
97
|
+
|
|
92
98
|
# The gate every tool call asks first.
|
|
93
99
|
# @return [Guardrails::Gate]
|
|
94
100
|
def gate
|
|
@@ -254,7 +254,7 @@ module Samagotchi
|
|
|
254
254
|
end
|
|
255
255
|
|
|
256
256
|
# @return [Array(Integer, Integer)] the recap length range, e.g. [2, 4]
|
|
257
|
-
attr_reader :generation, :inactivity, :min_user_turns, :sentences
|
|
257
|
+
attr_reader :generation, :inactivity, :min_user_turns, :sentences, :timeout
|
|
258
258
|
|
|
259
259
|
# @param callable [#call] true while a continue offer waits for an
|
|
260
260
|
# answer (the Worker's or the REPL's TurnFlow); read at each attempt
|
|
@@ -550,10 +550,16 @@ module Samagotchi
|
|
|
550
550
|
@client
|
|
551
551
|
end
|
|
552
552
|
|
|
553
|
+
# The summarize thread: its recap, or nil when it failed. A failure of
|
|
554
|
+
# any kind (a LoadError, a stack overflow too) ends it quietly, logged:
|
|
555
|
+
# no recap this time, and no dead thread's trace on the worker's
|
|
556
|
+
# stderr. A real exit or interrupt goes on as raised.
|
|
553
557
|
def spawn_summarize(client, prompt)
|
|
554
558
|
Thread.new do
|
|
555
559
|
client.summarize(prompt, session_id: @session_id.call, on_usage: @on_usage)
|
|
556
|
-
rescue
|
|
560
|
+
rescue SystemExit, SignalException
|
|
561
|
+
raise
|
|
562
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- a recap's failure, whatever it is, is no recap
|
|
557
563
|
# No recap this time (the next idle window tries again); say why.
|
|
558
564
|
Log.exception(:recap, "summarize_failed", e)
|
|
559
565
|
nil
|
|
@@ -18,23 +18,37 @@ module Samagotchi
|
|
|
18
18
|
# raises is logged and isolated — the remaining jobs keep polling.
|
|
19
19
|
class IdleScheduler
|
|
20
20
|
POLL_INTERVAL_SECONDS = 0.5
|
|
21
|
+
# How long #stop waits for the thread to finish its tick before it
|
|
22
|
+
# kills it.
|
|
23
|
+
STOP_JOIN_SECONDS = 1.0
|
|
24
|
+
THREAD_NAME = "idle_scheduler"
|
|
21
25
|
|
|
22
|
-
attr_reader :engine, :jobs
|
|
26
|
+
attr_reader :engine, :jobs, :interval
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
# +interval+: the pause between ticks (a spec passes a long one, so its
|
|
29
|
+
# own #tick is the only one after the first).
|
|
30
|
+
def initialize(engine:, jobs: [], interval: POLL_INTERVAL_SECONDS)
|
|
25
31
|
raise ArgumentError, "IdleScheduler requires an engine" unless engine
|
|
26
32
|
|
|
27
33
|
@engine = engine
|
|
28
34
|
@jobs = jobs.compact.freeze
|
|
35
|
+
@interval = interval
|
|
36
|
+
# The loop's pause: a pop with the interval as its timeout, which
|
|
37
|
+
# #stop ends at once by pushing.
|
|
38
|
+
@wake = Queue.new
|
|
29
39
|
@thread = nil
|
|
40
|
+
# The thread a stop(join: false) left to finish its tick.
|
|
41
|
+
@unjoined = nil
|
|
30
42
|
@stopped = false
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
# Spawn the polling thread (idempotent; no-op when there are no jobs).
|
|
46
|
+
# A stopped scheduler doesn't start again.
|
|
34
47
|
def start
|
|
35
48
|
return self if running? || @jobs.empty?
|
|
36
49
|
|
|
37
50
|
@thread = Thread.new { run_loop }
|
|
51
|
+
@thread.name = THREAD_NAME
|
|
38
52
|
self
|
|
39
53
|
end
|
|
40
54
|
|
|
@@ -42,11 +56,24 @@ module Samagotchi
|
|
|
42
56
|
!@thread.nil? && @thread.alive? && !@stopped
|
|
43
57
|
end
|
|
44
58
|
|
|
45
|
-
# Stop the polling thread (idempotent)
|
|
46
|
-
|
|
59
|
+
# Stop the polling thread (idempotent): it ticks no more and its pause
|
|
60
|
+
# ends at once. +join+: wait up to STOP_JOIN_SECONDS for a tick under
|
|
61
|
+
# way to finish, then kill the thread. A caller holding a lock a tick
|
|
62
|
+
# may take (Worker#leave_idle holds the event log, which a recap's emit
|
|
63
|
+
# takes) passes join: false: the thread finishes its tick and exits by
|
|
64
|
+
# itself, and a later joining stop (Engine#shutdown, the worker's
|
|
65
|
+
# teardown) still joins it. From the scheduler's own thread (a job's
|
|
66
|
+
# tick) it never joins.
|
|
67
|
+
def stop(join: true)
|
|
47
68
|
@stopped = true
|
|
48
|
-
@
|
|
69
|
+
@wake << :stop
|
|
70
|
+
thread = @thread || @unjoined
|
|
49
71
|
@thread = nil
|
|
72
|
+
@unjoined = join ? nil : thread
|
|
73
|
+
return if thread.nil? || !join || thread == Thread.current
|
|
74
|
+
|
|
75
|
+
thread.kill unless thread.join(STOP_JOIN_SECONDS)
|
|
76
|
+
nil
|
|
50
77
|
end
|
|
51
78
|
|
|
52
79
|
# Swap the job +old+ for +new+ (Engine#session= rebuilding the recap):
|
|
@@ -78,11 +105,9 @@ module Samagotchi
|
|
|
78
105
|
private
|
|
79
106
|
|
|
80
107
|
def run_loop
|
|
81
|
-
|
|
82
|
-
break if @stopped
|
|
83
|
-
|
|
108
|
+
until @stopped
|
|
84
109
|
tick
|
|
85
|
-
|
|
110
|
+
@wake.pop(timeout: @interval)
|
|
86
111
|
end
|
|
87
112
|
rescue StandardError => e
|
|
88
113
|
Log.exception(:idle, "scheduler_crashed", e, echo: "[IdleScheduler] scheduler thread crashed: #{e.class}: #{e.message}")
|