samagotchi 0.102.0 → 0.103.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 +40 -1
- data/bin/chi +23 -191
- data/docs/architecture.md +54 -0
- data/docs/cli.md +114 -7
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +26 -2
- data/docs/sessions.md +12 -5
- data/docs/sub-agent.md +8 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +24 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- 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 +43 -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/engine.rb +12 -8
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -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 +30 -5
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +360 -69
- 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/session_access_routes.rb +343 -0
- 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 +19 -1
|
@@ -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:)
|
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
|
|
@@ -2943,7 +2945,8 @@ module Samagotchi
|
|
|
2943
2945
|
end
|
|
2944
2946
|
|
|
2945
2947
|
# The working set (WorkingSet) as the model has it, as a
|
|
2946
|
-
# :working_set_updated event ({items:, at:}
|
|
2948
|
+
# :working_set_updated event ({items:, at:}, adds: while the user's
|
|
2949
|
+
# adds are pending): at each request (the
|
|
2947
2950
|
# stream handler's :generation_started, from the running turn's
|
|
2948
2951
|
# conversation) and at turn end (#end_turn, from the session's kept
|
|
2949
2952
|
# messages). Only when it differs from the last one emitted for this
|
|
@@ -2958,18 +2961,19 @@ module Samagotchi
|
|
|
2958
2961
|
def emit_working_set(on_event, session, conversation)
|
|
2959
2962
|
return unless session && conversation
|
|
2960
2963
|
|
|
2961
|
-
snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy
|
|
2964
|
+
snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy,
|
|
2965
|
+
pins: session.working_set_pins)
|
|
2962
2966
|
|
|
2963
|
-
|
|
2967
|
+
shown = snapshot.to_h.except(:at)
|
|
2964
2968
|
last = @working_set_emitted
|
|
2965
|
-
if
|
|
2966
|
-
return unless last && last[0] == session.id &&
|
|
2967
|
-
elsif last == [session.id,
|
|
2969
|
+
if snapshot.empty?
|
|
2970
|
+
return unless last && last[0] == session.id && last[1] != { items: [] }
|
|
2971
|
+
elsif last == [session.id, shown]
|
|
2968
2972
|
return
|
|
2969
2973
|
end
|
|
2970
2974
|
|
|
2971
|
-
@working_set_emitted = [session.id,
|
|
2972
|
-
event = { type: :working_set_updated,
|
|
2975
|
+
@working_set_emitted = [session.id, shown]
|
|
2976
|
+
event = { type: :working_set_updated, **shown, at: snapshot.at }
|
|
2973
2977
|
on_event ? emit_event(on_event, event) : announce_or_hold(event)
|
|
2974
2978
|
rescue StandardError => e
|
|
2975
2979
|
Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
@@ -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
|
|
@@ -30,6 +30,7 @@ require_relative "tool_ids"
|
|
|
30
30
|
require_relative "llm_context_view"
|
|
31
31
|
require_relative "llm_context_apply"
|
|
32
32
|
require_relative "llm_context_forget"
|
|
33
|
+
require_relative "working_set"
|
|
33
34
|
require_relative "llm_context_notice"
|
|
34
35
|
require_relative "answer_display"
|
|
35
36
|
require_relative "llm/model_result"
|
|
@@ -178,6 +179,10 @@ module Samagotchi
|
|
|
178
179
|
# a plugin tool's row refs resolve their files against (ToolRunner,
|
|
179
180
|
# RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
|
|
180
181
|
attr_accessor :session_cwd
|
|
182
|
+
# The session's working-set pins (a callable → Array<WorkingSetPin>),
|
|
183
|
+
# which forget_outputs refuses (LLMContextForget); the Engine sets it.
|
|
184
|
+
# nil: none.
|
|
185
|
+
attr_accessor :working_set_pins
|
|
181
186
|
# The session's folder (a callable → String or nil), where ToolRunner
|
|
182
187
|
# saves a cut output whole (ToolOutputFiles); the Engine sets it. nil:
|
|
183
188
|
# none (a bare kernel), the cut says the output wasn't saved.
|
|
@@ -945,9 +950,16 @@ module Samagotchi
|
|
|
945
950
|
end
|
|
946
951
|
|
|
947
952
|
apply = -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit) }
|
|
948
|
-
LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply)
|
|
953
|
+
LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply, pinned: method(:pinned_ids))
|
|
949
954
|
end
|
|
950
955
|
|
|
956
|
+
# The ids of +conversation+'s outputs the session's pins hold.
|
|
957
|
+
def pinned_ids(conversation)
|
|
958
|
+
pins = Array(@working_set_pins&.call)
|
|
959
|
+
WorkingSet.pinned_ids(conversation, pins: pins, cwd: @session_cwd&.call)
|
|
960
|
+
end
|
|
961
|
+
private :pinned_ids
|
|
962
|
+
|
|
951
963
|
# Before each request of both loops (+moment+ :request; the warm-up
|
|
952
964
|
# too, on its own copy, as the next turn's first request) and at the
|
|
953
965
|
# end of a turn the model answered (:turn_end): the strategy's layers
|
|
@@ -11,6 +11,14 @@ module Samagotchi
|
|
|
11
11
|
# reach the worker: a /model command, and no_interrupt on each posted
|
|
12
12
|
# turn; --memory and --mute are session fields the worker reads.)
|
|
13
13
|
REPL_ONLY = { verbose: "--verbose" }.freeze
|
|
14
|
+
# What an attached run can't be combined with (the worker runs its turns).
|
|
15
|
+
ATTACHED_CONFLICTS = { non_interactive: "--non-interactive", verbose: "--verbose" }.freeze
|
|
16
|
+
# Opening a `chi scratch` session a killed REPL left behind would turn it
|
|
17
|
+
# into a kept one.
|
|
18
|
+
SCRATCH_LEFTOVER = "that's a leftover scratch session; it is deleted at the next sweep (chi sessions clean)"
|
|
19
|
+
# A worker spawned or resumed for an attach whose Bridge never came (%s:
|
|
20
|
+
# the session id).
|
|
21
|
+
BRIDGE_LATE = "the worker for session %s did not start its Bridge in time"
|
|
14
22
|
|
|
15
23
|
module_function
|
|
16
24
|
|
|
@@ -30,5 +38,19 @@ module Samagotchi
|
|
|
30
38
|
|
|
31
39
|
[:attached, nil]
|
|
32
40
|
end
|
|
41
|
+
|
|
42
|
+
# Why an attached run's flags don't go together, or nil when they do.
|
|
43
|
+
# @param options [Hash] bin/chi's parsed options
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
def attached_conflict(options)
|
|
46
|
+
conflict = ATTACHED_CONFLICTS.find { |key, _flag| options[key] }
|
|
47
|
+
if options[:attach] && options[:shared]
|
|
48
|
+
"use either --attach ID or --shared"
|
|
49
|
+
elsif options[:attach] && options[:resume]
|
|
50
|
+
"--attach takes the session id; --resume goes with --shared"
|
|
51
|
+
elsif conflict
|
|
52
|
+
"--attach/--shared can't be combined with #{conflict.last}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
33
55
|
end
|
|
34
56
|
end
|
|
@@ -36,7 +36,10 @@ module Samagotchi
|
|
|
36
36
|
# to free anything (SMALL), an output already stubbed or forgotten, an
|
|
37
37
|
# id nothing in context has;
|
|
38
38
|
# - an output the user dropped (WorkingSetSteer): neither forgotten nor
|
|
39
|
-
# restored by the model, in WorkingSetSteer::Refused's words
|
|
39
|
+
# restored by the model, in WorkingSetSteer::Refused's words;
|
|
40
|
+
# - an output of an item the user pinned (+pinned+: WorkingSet.pinned_ids
|
|
41
|
+
# of the session's pins), in WorkingSetSteer::Refused's words too (a
|
|
42
|
+
# restore of one is fine: it brings the output back).
|
|
40
43
|
# A read is never restored: reading the file again is its restore, and
|
|
41
44
|
# the file may have changed.
|
|
42
45
|
module LLMContextForget
|
|
@@ -74,8 +77,10 @@ module Samagotchi
|
|
|
74
77
|
# call (both loops add the model's entry before its calls run).
|
|
75
78
|
# @param apply [#call] → LLMContextApply::Outcome: the apply rule at a
|
|
76
79
|
# request, on the turn's conversation (KernelLoop#apply_llm_context!)
|
|
80
|
+
# @param pinned [#call, nil] conversation → the ids the user's pins
|
|
81
|
+
# hold (WorkingSet.pinned_ids), asked only when a forget is asked
|
|
77
82
|
# @return [String] the tool result
|
|
78
|
-
def call(turn, call, llm_context:, apply:, root: Dir.pwd, now: Time.now.utc.iso8601(3))
|
|
83
|
+
def call(turn, call, llm_context:, apply:, root: Dir.pwd, pinned: nil, now: Time.now.utc.iso8601(3))
|
|
79
84
|
request = Tools::ForgetOutputs.parse(call)
|
|
80
85
|
conversation = turn.conversation
|
|
81
86
|
return restore(turn, request.restore, llm_context) if request.restore? && request.ids.empty?
|
|
@@ -85,7 +90,8 @@ module Samagotchi
|
|
|
85
90
|
"what's ruled out, NEXT), since it is all that stays of them"
|
|
86
91
|
end
|
|
87
92
|
|
|
88
|
-
|
|
93
|
+
pinned_ids = pinned ? pinned.call(conversation) : Set.new
|
|
94
|
+
forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now, pinned_ids)
|
|
89
95
|
lines = if forgotten.empty?
|
|
90
96
|
["nothing forgotten"]
|
|
91
97
|
else
|
|
@@ -99,7 +105,7 @@ module Samagotchi
|
|
|
99
105
|
# Saves a staged forget on each output +request+ names that may go.
|
|
100
106
|
# @return [Array(Array<String>, Hash{String => String})] the ids
|
|
101
107
|
# forgotten, and the refused ones with why
|
|
102
|
-
def forget(conversation, request, protect_steps, root, now)
|
|
108
|
+
def forget(conversation, request, protect_steps, root, now, pinned_ids)
|
|
103
109
|
outputs = outputs(conversation)
|
|
104
110
|
last = LLMContextStale.step_count(conversation)
|
|
105
111
|
# The steps before the call's own: protected_ids counts that one too.
|
|
@@ -107,7 +113,7 @@ module Samagotchi
|
|
|
107
113
|
root: root)
|
|
108
114
|
refused = {}
|
|
109
115
|
forgotten = request.ids.select do |id|
|
|
110
|
-
why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads)
|
|
116
|
+
why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads, pinned_ids)
|
|
111
117
|
refused[id] = why if why
|
|
112
118
|
why.nil?
|
|
113
119
|
end
|
|
@@ -124,11 +130,12 @@ module Samagotchi
|
|
|
124
130
|
# Why +output+ can't go (a String for the result), nil when it can.
|
|
125
131
|
# +last+: the call's own step; the protect_steps steps before it are
|
|
126
132
|
# the model's latest outputs.
|
|
127
|
-
def refusal(conversation, output, keep, last, protect_steps, kept_reads)
|
|
133
|
+
def refusal(conversation, output, keep, last, protect_steps, kept_reads, pinned_ids)
|
|
128
134
|
return "no output with that id in context (ids look like t42)" unless output
|
|
129
135
|
|
|
130
136
|
held = LLMContextEdit.on(conversation[output.index])[output.ref.id]
|
|
131
137
|
return WorkingSetSteer::Refused.text(:dropped) if held&.kind == LLMContextEdit::DROP
|
|
138
|
+
return WorkingSetSteer::Refused.text(:pinned_by_user) if pinned_ids.include?(output.ref.id)
|
|
132
139
|
return "already forgotten or stubbed" if held
|
|
133
140
|
if protect_steps.positive? && output.step >= last - protect_steps
|
|
134
141
|
return "from your last #{protect_steps} step#{"s" unless protect_steps == 1}; you may still need it, forget it later"
|
|
@@ -6,6 +6,7 @@ require_relative "note_delivery"
|
|
|
6
6
|
require_relative "session_manager"
|
|
7
7
|
require_relative "cli/command"
|
|
8
8
|
require_relative "cli/flags"
|
|
9
|
+
require_relative "session_access/local"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
# `chi note`: push text into sessions as a context note (background the
|
|
@@ -33,12 +34,15 @@ module Samagotchi
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# @param argv [Array<String>] the arguments after "note"
|
|
36
|
-
|
|
37
|
+
# @param access [SessionAccess::Local, nil] where the sessions are;
|
|
38
|
+
# default this machine's, in +state_dir+
|
|
39
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
|
|
37
40
|
@argv = argv.dup
|
|
38
41
|
@stdin = stdin
|
|
39
42
|
@stdout = stdout
|
|
40
43
|
@stderr = stderr
|
|
41
44
|
@state_dir = state_dir || Session.default_state_dir
|
|
45
|
+
@access = access || SessionAccess::Local.new(state_dir: @state_dir)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# @return [Integer] exit status: 0 all queued, 1 any refused or failed,
|
|
@@ -93,8 +97,7 @@ module Samagotchi
|
|
|
93
97
|
|
|
94
98
|
# --all: every live session. @return [Integer] exit status
|
|
95
99
|
def deliver_to_live(text, source)
|
|
96
|
-
ids =
|
|
97
|
-
state_dir: @state_dir).map { |s| s[:id] }
|
|
100
|
+
ids = @access.summaries(live: true, include_tests: Session.test_session_env?).map { |s| s[:id] }
|
|
98
101
|
if ids.empty?
|
|
99
102
|
error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
|
|
100
103
|
return 1
|
|
@@ -104,9 +107,7 @@ module Samagotchi
|
|
|
104
107
|
end
|
|
105
108
|
|
|
106
109
|
def resolve(given)
|
|
107
|
-
|
|
108
|
-
Session.load(id, state_dir: @state_dir)
|
|
109
|
-
id
|
|
110
|
+
@access.resolve(given)
|
|
110
111
|
rescue ArgumentError => e
|
|
111
112
|
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
112
113
|
error_line("chi note: #{message}")
|
|
@@ -115,7 +116,7 @@ module Samagotchi
|
|
|
115
116
|
|
|
116
117
|
# @return [Boolean] whether the note was queued
|
|
117
118
|
def deliver(id, text, source)
|
|
118
|
-
result =
|
|
119
|
+
result = @access.note(id, text: text, source: source)
|
|
119
120
|
@stdout.puts("#{id[0, 8]} #{outcome(result)}")
|
|
120
121
|
result.delivered?
|
|
121
122
|
end
|