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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. 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: inside_session ? "agent" : "cli", created_at: Time.now.utc.iso8601
138
+ scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601
131
139
  )
132
140
  each_target(options, args) do |target|
133
- location = location_of(target)
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: inside_session ? "agent" : "cli", created_at: Time.now.utc.iso8601, provider: resolved.bundle
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
- location = location_of(target)
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
- attached = find(target, name) or next false
177
- # A command source's text is its command's (R2): a push between
178
- # two fetches would read as a change and then vanish.
179
- unless attached.source.push?
180
- error_line("#{command_name}: #{name} runs a command, which writes its text: push goes to a --push source")
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
- before, written = attached.location.locked(name) do
185
- [attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
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| rows_for(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
- attached = find(targets.first, name) or return CLI::Exit::FAILED
216
- snapshot = attached.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" => attached.source.to_h, "snapshot" => snapshot.to_h }))
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
- location = location_of(target)
275
- if location.remove(name)
297
+ case @access.context_remove(target, name)
298
+ when :removed
276
299
  @stdout.puts("#{target.label} detached #{name}")
277
300
  next true
278
- end
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
- error_line("#{command_name}: give session ids (or unique prefixes), or --project; " \
333
- "inside a chi session it's that session")
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 = Session.resolve_id(given, state_dir: @state_dir)
358
- session = Session.load(id, state_dir: @state_dir)
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 project's).
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
- ContextSources.check_name!(name)
386
- found = if target.project?
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
- def attached_to(target, shadowed: false)
398
- ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
399
- shadowed: shadowed)
400
- end
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
- def row(target, source, snapshot, muted: nil, shadowed: nil, unread: nil)
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:)
@@ -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:}): at each request (the
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
- items = snapshot.items.map(&:to_h)
2967
+ shown = snapshot.to_h.except(:at)
2964
2968
  last = @working_set_emitted
2965
- if items.empty?
2966
- return unless last && last[0] == session.id && !last[1].empty?
2967
- elsif last == [session.id, items]
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, items]
2972
- event = { type: :working_set_updated, items: items, at: snapshot.at }
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 StandardError => e
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
- forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now)
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
- def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
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 = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
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
- id = Session.resolve_id(given, state_dir: @state_dir)
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 = NoteDelivery.deliver(id, text: text, source: source, state_dir: @state_dir)
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