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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. 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 @state_dir, and may override the constants
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 = run_parsed(options)
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
- id = Session.resolve_id(given, state_dir: @state_dir)
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 = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
74
- owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
75
- queued: queued, ended_before: ended_before)
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 own.
80
- strategy = ParentReport.strategy_for(id, state_dir: @state_dir)
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: chi --attach #{id}")
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: 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:)
@@ -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
- def initialize(conversation: [], llm_context: nil)
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 / @chars_per_token).ceil + image_tokens
259
+ used_tokens = (prompt_chars / @estimate_chars_per_token).ceil + image_tokens
247
260
  source = "estimate"
248
261
  end
249
262
 
@@ -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 exits).
1889
- def stop_idle
1890
- @idle_scheduler&.stop
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 given, the turn's boundary events (:turn_started, :turn_completed,
1950
- # :turn_canceled, :turn_failed) carry it as `origin:`
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:}): at each request (the
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
- items = snapshot.items.map(&:to_h)
2977
+ shown = snapshot.to_h.except(:at)
2964
2978
  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]
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, items]
2972
- event = { type: :working_set_updated, items: items, at: snapshot.at }
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, and the git facts read afresh.
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 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
@@ -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
- def initialize(engine:, jobs: [])
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
- def stop
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
- @thread&.kill
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
- loop do
82
- break if @stopped
83
-
108
+ until @stopped
84
109
  tick
85
- sleep(POLL_INTERVAL_SECONDS)
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}")