samagotchi 0.103.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +86 -12
  6. data/docs/guardrails.md +2 -1
  7. data/docs/internals/context-telemetry.md +6 -2
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/participants.md +137 -0
  10. data/docs/internals/remote-sessions.md +153 -27
  11. data/docs/plugins.md +7 -1
  12. data/docs/sessions.md +4 -2
  13. data/docs/sub-agent.md +8 -0
  14. data/docs/testing.md +68 -4
  15. data/lib/samagotchi/bridge.rb +7 -3
  16. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_invocation.rb +13 -16
  20. data/lib/samagotchi/context_status.rb +15 -2
  21. data/lib/samagotchi/engine.rb +16 -6
  22. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_scheduler.rb +34 -9
  25. data/lib/samagotchi/kernel_loop.rb +8 -1
  26. data/lib/samagotchi/launch_mode.rb +4 -0
  27. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  28. data/lib/samagotchi/owner_lock.rb +65 -5
  29. data/lib/samagotchi/participants.rb +122 -0
  30. data/lib/samagotchi/remote_attach_command.rb +119 -0
  31. data/lib/samagotchi/run_options.rb +14 -2
  32. data/lib/samagotchi/send_command.rb +1 -1
  33. data/lib/samagotchi/session.rb +8 -4
  34. data/lib/samagotchi/session_access/contract.rb +42 -4
  35. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  36. data/lib/samagotchi/session_access/local.rb +52 -1
  37. data/lib/samagotchi/session_access/remote.rb +112 -16
  38. data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
  39. data/lib/samagotchi/session_access.rb +85 -3
  40. data/lib/samagotchi/session_continue.rb +1 -1
  41. data/lib/samagotchi/session_inbox.rb +18 -9
  42. data/lib/samagotchi/session_manager.rb +21 -5
  43. data/lib/samagotchi/sessions_command.rb +4 -4
  44. data/lib/samagotchi/share_command.rb +174 -0
  45. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  46. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
  48. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  49. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  50. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  51. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  52. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  53. data/lib/samagotchi/terminal_ui.rb +4 -1
  54. data/lib/samagotchi/testing/waiting.rb +92 -9
  55. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +187 -74
  58. data/lib/samagotchi/web/grants.rb +302 -0
  59. data/lib/samagotchi/web/participant_access.rb +286 -0
  60. data/lib/samagotchi/web/principal.rb +29 -0
  61. data/lib/samagotchi/web/server.rb +3 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  63. data/lib/samagotchi/worker.rb +26 -5
  64. metadata +10 -1
@@ -10,6 +10,9 @@ require_relative "../context_command"
10
10
  require_relative "../send_command"
11
11
  require_relative "../launch_mode"
12
12
  require_relative "../model_profile"
13
+ require_relative "../client_id"
14
+ require_relative "../children_status"
15
+ require_relative "../session_metrics"
13
16
 
14
17
  module Samagotchi
15
18
  module Web
@@ -234,7 +237,9 @@ module Samagotchi
234
237
  # waited for up to +wait+ seconds (WAKE_MAX_WAIT, also the most). 200
235
238
  # {session_id, woke, worker: {epoch, started_at, version, features},
236
239
  # model_name, default_model (this machine's config, for the TUI's
237
- # status row), parent_strategy, installed (the newest chi here)}; 409
240
+ # status row), parent_strategy, installed (the newest chi here)}. A
241
+ # worker still leaving is waited for first (Local#wake: its recap's
242
+ # timeout and a margin, on top of +wait+); 409
238
243
  # owned_by_tui (a chi REPL has it), 409 scratch (a leftover scratch
239
244
  # session, as chi --attach here says); 504 worker_timeout (its Bridge
240
245
  # didn't come in time); 404 not_found.
@@ -262,13 +267,20 @@ module Samagotchi
262
267
 
263
268
  # GET /api/sessions/:id/worker: the session's worker now, never
264
269
  # started (SessionAccess::Local#worker): a remote attach's
265
- # rediscovery and its features after a restart. 200 {session_id,
266
- # live, stopped, owner: worker|tui|null, worker: {epoch, started_at,
267
- # version, features}|null, parent_strategy}; 404 not_found.
270
+ # rediscovery and its features after a restart, and before its wake
271
+ # whether the worker is leaving (its recap on leave: the wake waits
272
+ # for it, so the client says so). 200 {session_id, live, stopped,
273
+ # owner: worker|tui|null, leaving, leave_seconds (its leave's budget
274
+ # left, nil unless leaving), worker: {epoch, started_at, version,
275
+ # features}|null, default_model (this machine's config, as the
276
+ # wake's), parent_strategy}; 404 not_found.
268
277
  def handle_worker(_req, id)
269
278
  state = session_access.worker(id)
279
+ leaving = state.leaving?
270
280
  json_response(200, { session_id: id, live: state.live?, stopped: state.stopped, owner: state.owner&.kind,
281
+ leaving: leaving, leave_seconds: (state.owner.leave_left.round(1) if leaving),
271
282
  worker: state.sidecar && worker_json(state.sidecar),
283
+ default_model: ModelProfile.default_model_name,
272
284
  parent_strategy: strategy_json(session_access.parent_strategy(id)) })
273
285
  end
274
286
 
@@ -278,6 +290,110 @@ module Samagotchi
278
290
  { epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
279
291
  end
280
292
 
293
+ # POST /api/sessions/:id/exit {client_id, delete}: a remote attached
294
+ # TUI's /exit (and the worker's half of /exit --delete and /archive),
295
+ # relayed to the live worker's exit (BridgeClient#request_exit; never
296
+ # wakes one). client_id is passed on only when it is an attached TUI's
297
+ # (tui:…): any other is dropped, and then every stream counts as a UI,
298
+ # which only keeps the worker up. 200 the Bridge's {status: exiting,
299
+ # session_id, discard}; 409 held with its {status, reason, tasks,
300
+ # hint}; 503 not_live, 501 not_supported (an older worker), 504
301
+ # worker_timeout.
302
+ def handle_exit(req, id)
303
+ body = body_hash(req) or return refusal(400, "invalid_json", "invalid JSON body")
304
+ @session_class.load(id, state_dir: default_state_dir)
305
+ client_id = body["client_id"] if body["client_id"].is_a?(String) && body["client_id"].start_with?(ClientId::TUI_PREFIX)
306
+ delete = body["delete"] == true
307
+
308
+ request = ->(client) { client.request_exit(client_id: client_id, delete: delete) }
309
+ with_message(relay(id, bridge_client(id), request, what: "the worker was not asked to exit", cant: "exit from here") do |reply|
310
+ case reply.status
311
+ when 200 then json_response(200, reply.json || { status: "exiting", session_id: id })
312
+ when 409
313
+ held = reply.json || {}
314
+ words = "the session's worker stays up (#{held["reason"] || "held"})"
315
+ json_response(409, held.merge("error" => "held", "detail" => words, "message" => words, "session_id" => id))
316
+ end
317
+ end)
318
+ end
319
+
320
+ # POST /api/sessions/:id/recap: the saved recap, and a new one asked for
321
+ # (BridgeClient#request_recap; it arrives on the stream as
322
+ # :recap_ready). 200 the Bridge's {enabled, saved, request,
323
+ # min_user_turns, session_off}; 503, 501, 504 as every relay.
324
+ def handle_recap(_req, id)
325
+ @session_class.load(id, state_dir: default_state_dir)
326
+ request = lambda(&:request_recap)
327
+ with_message(relay(id, bridge_client(id), request, what: "no recap was read", cant: "recap") do |reply|
328
+ json_response(200, reply.json || {}) if reply.status == 200
329
+ end)
330
+ end
331
+
332
+ # GET /api/sessions/:id/stats: the live worker's metrics for /stats
333
+ # (its stats route; a worker from before it answers only its state's
334
+ # metrics). 200 {session_id, metrics}; 503 not_live, 504
335
+ # worker_timeout, 502 bridge_error.
336
+ def handle_stats(_req, id)
337
+ @session_class.load(id, state_dir: default_state_dir)
338
+ client = bridge_client(id) or return with_message(not_live(id))
339
+
340
+ status, json = client.get("stats")
341
+ metrics = json["metrics"] if status == 200 && json.is_a?(Hash)
342
+ if status == 404 && !(json.is_a?(Hash) && json["error"] == "unknown_session")
343
+ status, json = client.get("state")
344
+ metrics = json.dig("session_state_snapshot", "metrics") if status == 200 && json.is_a?(Hash)
345
+ end
346
+ return json_response(200, { session_id: id, metrics: metrics }) if status == 200
347
+ return with_message(worker_timeout("the stats were not read")) if status.nil?
348
+ return with_message(not_live(id)) if status == 404
349
+
350
+ with_message(error_response(502, "bridge_error", "the stats were not read: the session's worker answered #{status}"))
351
+ end
352
+
353
+ # GET /api/sessions/:id/children/counts: the session's delegates'
354
+ # counts (ChildrenStatus.counts, from their files here), a remote
355
+ # TUI's status row segment. 200 {running, waiting, unreported}; 404.
356
+ def handle_children_counts(_req, id)
357
+ @session_class.load(id, state_dir: default_state_dir)
358
+ json_response(200, ChildrenStatus.counts(id, state_dir: default_state_dir).to_h)
359
+ end
360
+
361
+ # A turn id as a session's prompts carry it (a uuid; any word-ish id).
362
+ TURN_ID = /\A[\w.:-]{1,128}\z/
363
+
364
+ # GET /api/sessions/:id/turns/:turn_id/records: a turn's saved tool
365
+ # records and turn record (SessionMetrics, from analytics.json here),
366
+ # as saved: what a remote TUI's join draws the steps' durations and a
367
+ # failed turn's line from. 200 {tool_records: […], turn_record: {…} |
368
+ # null}; 400 invalid_turn_id; 404 not_found.
369
+ def handle_turn_records(_req, id, turn_id)
370
+ return refusal(400, "invalid_turn_id", "not a turn id: #{turn_id.to_s[0, 80]}") unless TURN_ID.match?(turn_id)
371
+
372
+ @session_class.load(id, state_dir: default_state_dir)
373
+ dir = Session.session_dir(id, state_dir: default_state_dir)
374
+ json_response(200, { tool_records: SessionMetrics.saved_tool_records(dir, turn_id),
375
+ turn_record: SessionMetrics.saved_turn_record(dir, turn_id) })
376
+ end
377
+
378
+ # {error, detail, message}: the words a remote client raises with.
379
+ def refusal(status, code, words, **fields)
380
+ json_response(status, { error: code, detail: words, message: words, **fields })
381
+ end
382
+
383
+ # A relay's error answer (#relay's {error, detail}) with its message
384
+ # too, as a remote attach's routes answer every refusal.
385
+ def with_message(response)
386
+ status, _headers, chunks = response
387
+ return response if status < 400
388
+
389
+ body = JSON.parse(chunks.join)
390
+ return response unless body.is_a?(Hash) && body["error"] && !body.key?("message")
391
+
392
+ json_response(status, body.merge("message" => body["detail"]))
393
+ rescue JSON::ParserError
394
+ response
395
+ end
396
+
281
397
  # POST /api/sessions/:id/notes {text, source}: a context note for the
282
398
  # session (NoteDelivery, as chi note; source: where it came from,
283
399
  # "cli" by default, never "broadcast"). 200 {status: "queued" (a
@@ -122,9 +122,14 @@ module Samagotchi
122
122
  # trapped signals (SessionManager.run_session_loop); none by default
123
123
  # @param signal_grace [Array(Numeric, Numeric, Numeric)] the grace, the
124
124
  # short grace and the hard exit's delay (specs shorten them)
125
+ # @param owner_lock [OwnerLock, nil] the session's, which this process
126
+ # holds (SessionManager.run_session_loop): marked leaving as the Bridge
127
+ # stops, so an attach waits for the recap on leave (OwnerLock.await_release)
125
128
  def initialize(session_id:, state_dir:, session_dir:, idle_exit_minutes: nil, poll_interval: nil,
126
- signals: WorkerSignals::NONE, signal_grace: [SIGNAL_GRACE, SIGNAL_SHORT_GRACE, SIGNAL_HARD_EXIT])
129
+ signals: WorkerSignals::NONE, signal_grace: [SIGNAL_GRACE, SIGNAL_SHORT_GRACE, SIGNAL_HARD_EXIT],
130
+ owner_lock: nil)
127
131
  @session_id = session_id
132
+ @owner_lock = owner_lock
128
133
  @state_dir = state_dir
129
134
  @session_dir = session_dir
130
135
  @idle_exit_minutes = idle_exit_minutes
@@ -1245,7 +1250,8 @@ module Samagotchi
1245
1250
  next false unless @idle_exit.due?
1246
1251
 
1247
1252
  @bridge&.close_intake
1248
- @engine.stop_idle
1253
+ # Not joined: a recap tick's emit waits for the event log held here.
1254
+ @engine.stop_idle(join: false)
1249
1255
  log_idle_exit
1250
1256
  true
1251
1257
  end
@@ -1255,14 +1261,28 @@ module Samagotchi
1255
1261
  # The Bridge stops once the block (which closed its intake with the
1256
1262
  # event log held) answers true, outside the log: Bridge#stop waits for
1257
1263
  # the requests it is answering, and one waiting on the log would hold
1258
- # it for the whole grace before being killed.
1264
+ # it for the whole grace before being killed. The owner lock says it is
1265
+ # leaving first, so a client that finds no Bridge from here on waits
1266
+ # for the lock (OwnerLock.await_release) instead of giving up.
1259
1267
  # @return [Boolean] what the block answered
1260
1268
  def stop_bridge_after
1261
1269
  leaving = yield
1262
- @bridge&.stop if leaving
1270
+ if leaving
1271
+ mark_leaving
1272
+ @bridge&.stop
1273
+ end
1263
1274
  leaving
1264
1275
  end
1265
1276
 
1277
+ # How long the lock stays held after the Bridge: the recap on leave's
1278
+ # timeout (#left), none for a restart (#left writes none). A delete
1279
+ # writes none either, but the bound only has to be an upper one.
1280
+ def mark_leaving
1281
+ @owner_lock&.mark_leaving(wait: @exit_restart ? 0 : @engine.recap_timeout.to_f)
1282
+ rescue StandardError => e
1283
+ Log.warn(:worker, "mark_leaving_failed", error: e.class.name, msg: e.message)
1284
+ end
1285
+
1266
1286
  # Bridge POST /exit, on a Bridge thread with the event log held.
1267
1287
  # +delete+: the session is deleted after (/exit --delete), so no recap.
1268
1288
  # +restart+: a new worker takes over (on the newest chi installed), so
@@ -1313,7 +1333,8 @@ module Samagotchi
1313
1333
  end
1314
1334
 
1315
1335
  @bridge&.close_intake
1316
- @engine.stop_idle
1336
+ # Not joined: a recap tick's emit waits for the event log held here.
1337
+ @engine.stop_idle(join: false)
1317
1338
  Log.info(:worker, @exit_restart ? "restart_requested" : "exit_requested", by: @exit_requested_by || "a client")
1318
1339
  true
1319
1340
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.103.0
4
+ version: 0.104.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -160,6 +160,7 @@ files:
160
160
  - docs/internals/llm-context-bench.md
161
161
  - docs/internals/llm-context-forget.md
162
162
  - docs/internals/llm-context-live.md
163
+ - docs/internals/participants.md
163
164
  - docs/internals/prompt-caching.md
164
165
  - docs/internals/remote-sessions.md
165
166
  - docs/internals/tool-guardrails.md
@@ -462,6 +463,7 @@ files:
462
463
  - lib/samagotchi/owner_lock.rb
463
464
  - lib/samagotchi/parent_answers_command.rb
464
465
  - lib/samagotchi/parent_report.rb
466
+ - lib/samagotchi/participants.rb
465
467
  - lib/samagotchi/paths.rb
466
468
  - lib/samagotchi/peer_sources.rb
467
469
  - lib/samagotchi/pending_input_queue.rb
@@ -506,6 +508,7 @@ files:
506
508
  - lib/samagotchi/relay_watcher.rb
507
509
  - lib/samagotchi/reminder_queue.rb
508
510
  - lib/samagotchi/reminder_store.rb
511
+ - lib/samagotchi/remote_attach_command.rb
509
512
  - lib/samagotchi/remote_command.rb
510
513
  - lib/samagotchi/remotes.rb
511
514
  - lib/samagotchi/reply_wait.rb
@@ -549,6 +552,7 @@ files:
549
552
  - lib/samagotchi/session_setup.rb
550
553
  - lib/samagotchi/sessions_command.rb
551
554
  - lib/samagotchi/setting_explained.rb
555
+ - lib/samagotchi/share_command.rb
552
556
  - lib/samagotchi/side_usage.rb
553
557
  - lib/samagotchi/steer.rb
554
558
  - lib/samagotchi/steer_cut.rb
@@ -576,6 +580,8 @@ files:
576
580
  - lib/samagotchi/terminal_ui/question_slot.rb
577
581
  - lib/samagotchi/terminal_ui/reline_history_hook.rb
578
582
  - lib/samagotchi/terminal_ui/reline_seam.rb
583
+ - lib/samagotchi/terminal_ui/remote_connector.rb
584
+ - lib/samagotchi/terminal_ui/remote_link.rb
579
585
  - lib/samagotchi/terminal_ui/repl_input.rb
580
586
  - lib/samagotchi/terminal_ui/screen.rb
581
587
  - lib/samagotchi/terminal_ui/status_row.rb
@@ -675,9 +681,12 @@ files:
675
681
  - lib/samagotchi/web/app.rb
676
682
  - lib/samagotchi/web/capabilities.rb
677
683
  - lib/samagotchi/web/editor.rb
684
+ - lib/samagotchi/web/grants.rb
678
685
  - lib/samagotchi/web/info_probe.rb
679
686
  - lib/samagotchi/web/lan.rb
680
687
  - lib/samagotchi/web/markdown_renderer.rb
688
+ - lib/samagotchi/web/participant_access.rb
689
+ - lib/samagotchi/web/principal.rb
681
690
  - lib/samagotchi/web/public/activity.js
682
691
  - lib/samagotchi/web/public/annotate_actions.js
683
692
  - lib/samagotchi/web/public/annotate_bar.js