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
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_access"
5
+ require_relative "../reply_wait"
6
+ require_relative "../bridge_client"
7
+ require_relative "../parent_report"
8
+ require_relative "../note_delivery"
9
+ require_relative "../image_store"
10
+ require_relative "../context_sources"
11
+ require_relative "../send_command/busy"
12
+ require_relative "../chi_invocation"
13
+ require_relative "../worker_sidecar"
14
+ require_relative "../launch_mode"
15
+
16
+ module Samagotchi
17
+ # Loaded on first use: SessionManager and its ~80 files only when a
18
+ # command reaches it (chi context never does).
19
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
20
+
21
+ module SessionAccess
22
+ # This machine's sessions: the calls the session commands made before
23
+ # the seam, moved here unchanged. The errors are the callees' own
24
+ # (ArgumentError and Session::AmbiguousId for an id, SessionManager's
25
+ # refusals), which the commands word.
26
+ class Local
27
+ # @param state_dir [String, nil] the sessions' folder; nil: the
28
+ # callees' own default (Session.default_state_dir), and no
29
+ # state_dir: is passed on (chi sessions stop/restart/list)
30
+ def initialize(state_dir: nil)
31
+ @state_dir = state_dir
32
+ end
33
+
34
+ def remote? = false
35
+
36
+ # The printed commands' spelling: this machine's chi.
37
+ # @return [ChiInvocation]
38
+ def invocation = ChiInvocation::HERE
39
+
40
+ # --- ids and sessions
41
+
42
+ # The full id of +given+ (an id or a unique prefix); the session
43
+ # need not exist (Session.resolve_id).
44
+ # @raise [Session::AmbiguousId]
45
+ def resolve_id(given) = Session.resolve_id(given, **dir)
46
+
47
+ # The full id of +given+, of a session that exists.
48
+ # @raise [ArgumentError] no such session (Session::AmbiguousId: a
49
+ # prefix of several)
50
+ def resolve(given)
51
+ id = resolve_id(given)
52
+ show(id)
53
+ id
54
+ end
55
+
56
+ # @return [Session]
57
+ # @raise [ArgumentError] no such session
58
+ def show(id) = Session.load(id, **dir)
59
+
60
+ # @return [OwnerLock::Owner, nil] the worker or chi REPL that owns it
61
+ def owner(id) = SessionManager.session_owner(id, **dir)
62
+
63
+ # SessionManager.list_sessions: Session objects, for chi sessions list.
64
+ def list(**filters) = SessionManager.list_sessions(**filters, **dir)
65
+
66
+ # SessionManager.session_summaries: the summary hashes (chi sessions
67
+ # list --live/--format, chi note --all).
68
+ def summaries(**filters) = SessionManager.session_summaries(**filters, **dir)
69
+
70
+ # The session's worker for an attach: the live one as is, else
71
+ # resumed and waited for up to +wait+ seconds (chi --attach on this
72
+ # machine; chi web's POST …/wake for a remote one).
73
+ # @return [Woken] its sidecar nil when the Bridge didn't come in time
74
+ # @raise [ArgumentError] no such session
75
+ # @raise [Refused] code scratch: a leftover chi scratch session
76
+ # @raise [SessionManager::OwnedByTUI] a chi REPL has it
77
+ def wake(id, wait:)
78
+ session = show(id)
79
+ raise Refused.new(LaunchMode::SCRATCH_LEFTOVER, code: "scratch") if session.scratch
80
+
81
+ live = WorkerSidecar.live(session_dir(id), unlink: true)
82
+ return Woken.new(woke: false, sidecar: live) if live
83
+
84
+ SessionManager.resume_session(id, **dir)
85
+ found = BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: wait)
86
+ Woken.new(woke: true, sidecar: found && WorkerSidecar.read(session_dir(id)))
87
+ end
88
+
89
+ # The session's worker now, never started (chi web's GET …/worker).
90
+ # @return [WorkerState]
91
+ # @raise [ArgumentError] no such session
92
+ def worker(id)
93
+ show(id)
94
+ WorkerState.new(sidecar: WorkerSidecar.live(session_dir(id), unlink: true),
95
+ stopped: Session.stopped_marker?(id, state_dir: state_dir!), owner: owner(id))
96
+ end
97
+
98
+ # --- create and send
99
+
100
+ # @param spec [NewSession]
101
+ # @return [Session] the session created (its id, model_warning,
102
+ # continues, moved_children)
103
+ # @raise [SessionContinue::Refused, SessionManager::ArchiveRefused,
104
+ # SessionManager::OwnedByTUI] refused; StandardError: could not start
105
+ def create(spec)
106
+ start = spec.preview ? { prompt: spec.prompt, preview: spec.preview } : { prompt: spec.prompt }
107
+ if spec.continues
108
+ return SessionManager.continue_session(spec.continues, **start, model: spec.model_name, **dir)
109
+ end
110
+
111
+ SessionManager.spawn_session(**start, working_directory: spec.working_directory, model_name: spec.model_name,
112
+ setup: spec.setup, **dir)
113
+ end
114
+
115
+ # Whether a just-created session's worker came up (its Bridge
116
+ # answers) within +timeout+ seconds.
117
+ def await_worker(id, timeout:)
118
+ BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: timeout)
119
+ end
120
+
121
+ # Copies of +refs+ (ImageStore refs stored in +from+) in the
122
+ # session's folder, as the refs a turn takes.
123
+ # @return [Array<Hash>] {file:, name:} each
124
+ def upload_images(id, refs, from:)
125
+ to = session_dir(id)
126
+ refs.map do |ref|
127
+ ImageStore.copy_file(ref, from: from, to: to)
128
+ { file: ref[:file], name: ref[:name] }
129
+ end
130
+ end
131
+
132
+ # @return [SessionManager::DeliverResult]
133
+ # @raise [SessionManager::OwnedByTUI] a chi REPL owns it
134
+ def deliver_turn(id, prompt:, client_id:, images:, delivery: nil)
135
+ SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id, images: images, delivery: delivery, **dir)
136
+ end
137
+
138
+ # @return [NoteDelivery::Result]
139
+ def note(id, text:, source:)
140
+ NoteDelivery.deliver(id, text: text, source: source, state_dir: state_dir!)
141
+ end
142
+
143
+ # --- waiting for the reply
144
+
145
+ # What a wait needs from before anything is sent (WaitStart).
146
+ # @param session [Session, nil] the session as the caller just read
147
+ # it (its checks and the baseline then see the same state); nil
148
+ # reads it here
149
+ # @param question_id [String, nil, :pending] the question the
150
+ # baseline counts as already reported (ReplyWait.baseline_of);
151
+ # :pending, the one pending now
152
+ # @param turn [String, nil] a turn asked for (--turn): known_turn
153
+ # says whether it can be waited for
154
+ # @param created [Boolean] +session+ was just created: no reply, no
155
+ # question and no ended turn yet, nothing else read (read later, as
156
+ # chi web's prepare route does, its first turn may have ended: that
157
+ # end is the answer, not the baseline's)
158
+ # @return [WaitStart]
159
+ def prepare_wait(id, session: nil, question_id: :pending, turn: nil, created: false)
160
+ session ||= show(id)
161
+ if created
162
+ return WaitStart.new(cursor: nil, baseline: ReplyWait::Baseline.new(messages: session.messages.size),
163
+ own_turn: nil, known_turn: nil, running: false, live: false, settled: false,
164
+ last_turn_id: nil)
165
+ end
166
+
167
+ cursor = ReplyWait.newest_reply(id, state_dir: state_dir!)
168
+ baseline = question_id == :pending ? ReplyWait.baseline_of(session) : ReplyWait.baseline_of(session, question_id: question_id)
169
+ session_dir = session_dir(id)
170
+ running = session.status == Session::STATUS_RUNNING
171
+ WaitStart.new(
172
+ cursor: cursor, baseline: baseline, own_turn: ReplyWait.own_turn(session, session_dir: session_dir),
173
+ known_turn: (ReplyWait.known_turn?(session, session_dir: session_dir, turn: turn) if turn),
174
+ running: running, live: running || !owner(id).nil?,
175
+ settled: session.status == Session::STATUS_IDLE && !session.last_turn.nil? && session.pending_question.nil?,
176
+ last_turn_id: session.last_turn&.enqueued_id
177
+ )
178
+ end
179
+
180
+ # Why chi send --wait may not send into the session now (a turn runs,
181
+ # or a question waits on it with --queue), or nil.
182
+ # @param session [Session, nil] the session as the caller just read
183
+ # it; nil reads it here
184
+ # @return [SendCommand::Busy, nil]
185
+ def busy(id, queue:, session: nil)
186
+ session ||= show(id)
187
+ owner = owner(id) if session.status == Session::STATUS_RUNNING
188
+ SendCommand::Busy.for(session, owner: owner, queue: queue)
189
+ end
190
+
191
+ # ReplyWait.call: blocks until the reply, a question, the turn's end
192
+ # or +timeout+.
193
+ # @return [ReplyWait::Result]
194
+ # @raise [ArgumentError] the session is gone
195
+ def wait_reply(id, **)
196
+ ReplyWait.call(id, state_dir: state_dir!, **)
197
+ end
198
+
199
+ # What a parent may pick on the session's questions: this machine's
200
+ # guardrails.parent_answers tightened by the session's own
201
+ # (ParentReport.strategy_for).
202
+ def parent_strategy(id) = ParentReport.strategy_for(id, state_dir: state_dir!)
203
+
204
+ # --- questions
205
+
206
+ # The answer posted to the session's worker (its Bridge), once more
207
+ # after a 408.
208
+ # @return [BridgeClient::Response, nil] the Bridge's reply; nil when
209
+ # no worker runs it
210
+ # @raise [SystemCallError, IOError, SocketError] the worker went away
211
+ def answer(id, question_id:, selected:, freeform:, client_id:)
212
+ on_worker(id) { |client| client.answer(id: question_id, selected: selected, freeform: freeform, client_id: client_id) }
213
+ end
214
+
215
+ # The question dismissed on the session's worker; as #answer.
216
+ def dismiss(id, question_id:)
217
+ on_worker(id) { |client| client.dismiss_question(id: question_id) }
218
+ end
219
+
220
+ # --- sessions
221
+
222
+ # @return [Boolean] whether the worker released the session
223
+ def stop(id, wait:) = SessionManager.stop_session(id, wait: wait, **dir)
224
+
225
+ # @return [SessionManager::RestartResult]
226
+ def restart(id) = SessionManager.restart_session(id, **dir)
227
+
228
+ # @return [Hash] SessionManager.archive_session's
229
+ def archive(id) = SessionManager.archive_session(id, **dir)
230
+
231
+ # @return [Hash] SessionManager.unarchive_session's
232
+ def unarchive(id) = SessionManager.unarchive_session(id, **dir)
233
+
234
+ # @param force [Boolean] stop a live worker first (else refused)
235
+ # @return [Hash] SessionManager.delete_session's
236
+ def delete(id, force:) = SessionManager.delete_session(id, stop: force, **dir)
237
+
238
+ # --- context (a session's sources, or a project's: local only)
239
+ #
240
+ # +target+ is chi context's Target (session_id, project_root; a
241
+ # project's when session_id is nil).
242
+
243
+ # Attaches +source+ to the target's own sources.
244
+ def context_add(target, source)
245
+ location = context_location(target)
246
+ location.add(source.with(scope: location.scope))
247
+ nil
248
+ end
249
+
250
+ # New text for the target's --push source +name+.
251
+ # @param fetched [ContextSources::Fetched]
252
+ # @return [ContextPush]
253
+ def context_push(target, name, fetched)
254
+ attached = context_attached(target, name) or return ContextPush.new(status: :missing)
255
+ return ContextPush.new(status: :not_push) unless attached.source.push?
256
+
257
+ before, written = attached.location.locked(name) do
258
+ [attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
259
+ end
260
+ ContextPush.new(status: :pushed, before: before, revision: written.revision)
261
+ end
262
+
263
+ # The target's sources: a session's own, then its project's (shadowed
264
+ # ones too, marked), muted and unread; a project's own.
265
+ # @return [Array<ContextEntry>]
266
+ def context_entries(target)
267
+ if target.project?
268
+ location = context_location(target)
269
+ return location.sources.map { |source| ContextEntry.new(source: source, snapshot: location.snapshot(source.name)) }
270
+ end
271
+
272
+ own = context_location(target)
273
+ subs = own.subscriptions
274
+ attached_to(target, shadowed: true).map do |a|
275
+ snapshot = a.snapshot
276
+ read = subs[a.name]&.read
277
+ ContextEntry.new(source: a.source, snapshot: snapshot, muted: own.muted?(a.name), shadowed: a.shadowed,
278
+ unread: snapshot.text? && read != snapshot.revision)
279
+ end
280
+ end
281
+
282
+ # The source +name+ as the target sees it, with its snapshot.
283
+ # @return [ContextEntry, nil]
284
+ def context_entry(target, name)
285
+ attached = context_attached(target, name) or return nil
286
+ ContextEntry.new(source: attached.source, snapshot: attached.snapshot)
287
+ end
288
+
289
+ # Detaches the target's own source +name+.
290
+ # @return [Symbol] :removed, :project (a session's project has it:
291
+ # rm --project, or mute it) or :missing
292
+ def context_remove(target, name)
293
+ ContextSources.check_name!(name)
294
+ return :removed if context_location(target).remove(name)
295
+
296
+ project = !target.project? && ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
297
+ project&.source(name) ? :project : :missing
298
+ end
299
+
300
+ # Local only (chi context refresh, mute, unmute, which work on the
301
+ # files here; Contract: local_only): the source +name+ as the target
302
+ # sees it, with the folder it lives in.
303
+ # @return [ContextSources::Attached, nil]
304
+ def context_attached(target, name)
305
+ ContextSources.check_name!(name)
306
+ if target.project?
307
+ location = context_location(target)
308
+ source = location.source(name)
309
+ return source && ContextSources::Attached.new(source: source, location: location, shadowed: false)
310
+ end
311
+
312
+ attached_to(target).find { |a| a.name == name }
313
+ end
314
+
315
+ # Local only, as #context_attached: the folder a target's own sources
316
+ # live in.
317
+ def context_location(target)
318
+ if target.project?
319
+ ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
320
+ else
321
+ ContextSources.session_location(target.session_id, state_dir: @state_dir)
322
+ end
323
+ end
324
+
325
+ private
326
+
327
+ def dir = @state_dir ? { state_dir: @state_dir } : {}
328
+ def state_dir! = @state_dir || Session.default_state_dir
329
+ def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
330
+
331
+ def attached_to(target, shadowed: false)
332
+ ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
333
+ shadowed: shadowed)
334
+ end
335
+
336
+ # The block's post to the session's live worker, again on a 408 (the
337
+ # worker didn't take it in time).
338
+ def on_worker(id)
339
+ client = BridgeClient.discover(id, session_dir: session_dir(id)) or return nil
340
+ reply = nil
341
+ 2.times do
342
+ reply = yield client
343
+ break unless reply.status == 408
344
+ end
345
+ reply
346
+ end
347
+ end
348
+ end
349
+ end