samagotchi 0.103.0 → 0.105.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 +76 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +97 -21
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +15 -1
  8. data/docs/internals/anthropic.md +2 -1
  9. data/docs/internals/context-telemetry.md +6 -2
  10. data/docs/internals/engine.md +22 -1
  11. data/docs/internals/participants.md +167 -0
  12. data/docs/internals/prompt-caching.md +8 -0
  13. data/docs/internals/remote-sessions.md +153 -27
  14. data/docs/internals/working-set.md +4 -3
  15. data/docs/plugins.md +7 -1
  16. data/docs/sessions.md +4 -2
  17. data/docs/sub-agent.md +8 -0
  18. data/docs/testing.md +89 -4
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
  20. data/lib/samagotchi/bridge.rb +17 -10
  21. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  22. data/lib/samagotchi/bridge_client.rb +4 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/chi_invocation.rb +13 -16
  25. data/lib/samagotchi/config.rb +1 -1
  26. data/lib/samagotchi/context_status.rb +15 -2
  27. data/lib/samagotchi/engine.rb +52 -8
  28. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  29. data/lib/samagotchi/idle_recap.rb +1 -1
  30. data/lib/samagotchi/idle_scheduler.rb +34 -9
  31. data/lib/samagotchi/kernel_loop.rb +8 -1
  32. data/lib/samagotchi/last_turn.rb +5 -2
  33. data/lib/samagotchi/launch_mode.rb +4 -0
  34. data/lib/samagotchi/llm/chat_loop.rb +9 -1
  35. data/lib/samagotchi/owner_lock.rb +65 -5
  36. data/lib/samagotchi/participant_name.rb +21 -0
  37. data/lib/samagotchi/participants.rb +122 -0
  38. data/lib/samagotchi/queued_command.rb +9 -5
  39. data/lib/samagotchi/remote_attach_command.rb +119 -0
  40. data/lib/samagotchi/run_options.rb +14 -2
  41. data/lib/samagotchi/send_command.rb +1 -1
  42. data/lib/samagotchi/session.rb +8 -4
  43. data/lib/samagotchi/session_access/contract.rb +42 -4
  44. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  45. data/lib/samagotchi/session_access/local.rb +52 -1
  46. data/lib/samagotchi/session_access/remote.rb +112 -16
  47. data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
  48. data/lib/samagotchi/session_access.rb +85 -3
  49. data/lib/samagotchi/session_continue.rb +1 -1
  50. data/lib/samagotchi/session_inbox.rb +18 -9
  51. data/lib/samagotchi/session_manager.rb +21 -5
  52. data/lib/samagotchi/session_metrics.rb +7 -1
  53. data/lib/samagotchi/session_participants.rb +59 -0
  54. data/lib/samagotchi/sessions_command.rb +14 -11
  55. data/lib/samagotchi/share_command.rb +174 -0
  56. data/lib/samagotchi/steer.rb +83 -10
  57. data/lib/samagotchi/system_prompt.rb +21 -2
  58. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  59. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
  61. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  62. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  63. data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
  64. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  65. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  66. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  67. data/lib/samagotchi/terminal_ui.rb +4 -1
  68. data/lib/samagotchi/testing/waiting.rb +92 -9
  69. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  70. data/lib/samagotchi/turn_checkpoint.rb +5 -2
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +187 -74
  73. data/lib/samagotchi/web/grants.rb +303 -0
  74. data/lib/samagotchi/web/participant_access.rb +286 -0
  75. data/lib/samagotchi/web/principal.rb +29 -0
  76. data/lib/samagotchi/web/public/index.html +6 -5
  77. data/lib/samagotchi/web/public/working_set.js +6 -4
  78. data/lib/samagotchi/web/public/working_set_view.js +1 -1
  79. data/lib/samagotchi/web/server.rb +3 -1
  80. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  81. data/lib/samagotchi/worker.rb +44 -11
  82. data/lib/samagotchi/worker_inbound.rb +5 -2
  83. metadata +12 -1
@@ -147,6 +147,11 @@ module Samagotchi
147
147
  # write; the Engine's own (it hands it to a kernel it was given).
148
148
  attr_accessor :reminder_store
149
149
 
150
+ # @return [Float, nil] the last counted request's chars per token
151
+ # (ContextStatus#counted_chars_per_token), for the next turn's first
152
+ # estimate (no dip at a turn's start)
153
+ attr_accessor :counted_chars_per_token
154
+
150
155
  # @return [ModelProfile] the active prompt profile
151
156
  attr_reader :profile
152
157
 
@@ -309,7 +314,8 @@ module Samagotchi
309
314
 
310
315
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
311
316
  conversation = prepare_conversation(messages, keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since))
312
- context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
317
+ context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context,
318
+ counted_chars_per_token: @counted_chars_per_token)
313
319
  emit = ->(event) { emit_stream_event(on_stream_event, event) }
314
320
  @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
315
321
  Turn.new(
@@ -609,6 +615,7 @@ module Samagotchi
609
615
  response = generation.response.to_s
610
616
  turn.context.generation_done(generation.usage, prompt_chars: request.prompt.length, image_tokens: request.image_tokens,
611
617
  window: request.window)
618
+ @counted_chars_per_token = turn.context.counted_chars_per_token || @counted_chars_per_token
612
619
  emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
613
620
  thinking_chars: thinking_chars(response, generation.streamed_thinking),
614
621
  served_model: generation.served_model, requested_model: turn.model_name,
@@ -16,9 +16,12 @@ module Samagotchi
16
16
  # known. enqueued_id: the input's id when the turn ran one (a POST /turn
17
17
  # ack's, an input file's), so a wait for a queued message (`chi send
18
18
  # --queue --wait`, ReplyWait's turn:) tells its own turn's end from
19
- # another's. A field may be nil (unset; left out of the file).
19
+ # another's. author: who wrote the turn when it wasn't the owner ("p:<id>",
20
+ # a participant; origin stays the category), nil for the owner. A field
21
+ # may be nil (unset; left out of the file).
20
22
  LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
21
- :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id) do
23
+ :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id,
24
+ :author) do
22
25
  def initialize(**fields)
23
26
  super(**members.to_h { |m| [m, nil] }, **fields)
24
27
  end
@@ -19,6 +19,10 @@ module Samagotchi
19
19
  # A worker spawned or resumed for an attach whose Bridge never came (%s:
20
20
  # the session id).
21
21
  BRIDGE_LATE = "the worker for session %s did not start its Bridge in time"
22
+ # An attach (or a resume) that found the session's worker leaving, its
23
+ # Bridge closed and its recap on leave still being written: it waits for
24
+ # the worker to let go (OwnerLock.await_release), then starts a new one.
25
+ RECAP_WAIT = "(waiting for the session to finish its recap…)"
22
26
 
23
27
  module_function
24
28
 
@@ -63,6 +63,11 @@ module Samagotchi
63
63
  # The session every request is for; Engine sets it with the adapter.
64
64
  # Goes out as a Session-Id header (OpenAIChat#chat).
65
65
  attr_accessor :session_id
66
+ # The last counted request's chars per token
67
+ # (ContextStatus#counted_chars_per_token), carried to the next turn's
68
+ # tracker: its first estimate counts the tool schemas the chars leave
69
+ # out, so the ctx value doesn't dip at each turn's start.
70
+ attr_accessor :counted_chars_per_token
66
71
 
67
72
  # @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
68
73
  # @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
@@ -446,6 +451,7 @@ module Samagotchi
446
451
  message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
447
452
  message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
448
453
  ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
454
+ Steer::AUTHOR_KEYS.each { |key| message[key] = entry[key] if entry[key] }
449
455
  message[ToolResponse::LOADED] = entry[ToolResponse::LOADED] if entry[ToolResponse::LOADED].is_a?(Array)
450
456
  message[TOOL_CHANGE] = entry[TOOL_CHANGE] if entry[TOOL_CHANGE].is_a?(Hash)
451
457
  message
@@ -585,7 +591,8 @@ module Samagotchi
585
591
  @recovery_repeated = false
586
592
  # The names the last request declared deferred.
587
593
  @deferred = []
588
- @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
594
+ @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context,
595
+ counted_chars_per_token: loop.counted_chars_per_token)
589
596
  loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
590
597
  emit: method(:emit))
591
598
  end
@@ -976,6 +983,7 @@ module Samagotchi
976
983
  "completion_tokens" => usage.completion_tokens } })
977
984
  @context.generation_done(counts, prompt_chars: @request_chars.to_i, image_tokens: @request_image_tokens.to_i,
978
985
  window: window)
986
+ @loop.counted_chars_per_token = @context.counted_chars_per_token || @loop.counted_chars_per_token
979
987
  end
980
988
 
981
989
  # The model's answer at debug level, as the native loop dumps its
@@ -19,17 +19,37 @@ module Samagotchi
19
19
  DEFAULT_WAIT = 2.0
20
20
  RETRY_INTERVAL = 0.05
21
21
  OWNER_READ_ATTEMPTS = 10
22
+ # Seconds .await_release waits past a leaving worker's own budget: its
23
+ # last save, the task watch's absorb, its exit.
24
+ LEAVE_MARGIN = 5.0
25
+ # Seconds between .await_release's looks at the lock.
26
+ RELEASE_POLL = 0.1
22
27
 
23
28
  # Who holds the lock, as .owner reads it from the lock file. Its fields
24
29
  # may be nil for a moment right after acquisition.
25
- # kind: "worker" or "tui"; pid as written (an Integer).
26
- Owner = Data.define(:pid, :kind, :started_at) do
27
- def initialize(pid: nil, kind: nil, started_at: nil) = super
30
+ # kind: "worker" or "tui"; pid as written (an Integer). leaving_at (an
31
+ # ISO 8601 time) and leave_wait (seconds): set once a worker is leaving
32
+ # (#mark_leaving), its Bridge closed, the lock held for its last work
33
+ # (the recap on leave) for up to leave_wait.
34
+ Owner = Data.define(:pid, :kind, :started_at, :leaving_at, :leave_wait) do
35
+ def initialize(pid: nil, kind: nil, started_at: nil, leaving_at: nil, leave_wait: nil) = super
28
36
 
29
37
  # The interactive TUI's: it reads no input files and takes no notes.
30
38
  def tui? = kind == "tui"
31
39
 
32
40
  def worker? = kind == "worker"
41
+
42
+ # A worker that closed its Bridge and releases the lock once its
43
+ # last work is done.
44
+ def leaving? = worker? && !leaving_at.nil?
45
+
46
+ # Seconds of its leave_wait still to run at +now+ (0 when past).
47
+ def leave_left(now = Time.now)
48
+ started = Time.iso8601(leaving_at.to_s)
49
+ [leave_wait.to_f - (now - started), 0.0].max
50
+ rescue ArgumentError
51
+ leave_wait.to_f
52
+ end
33
53
  end
34
54
 
35
55
  # Take the lock, retrying for up to +wait+ seconds (another process's
@@ -71,12 +91,35 @@ module Samagotchi
71
91
 
72
92
  sleep(RETRY_INTERVAL / 5)
73
93
  end
74
- Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"])
94
+ Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"],
95
+ leaving_at: data["leaving_at"], leave_wait: data["leave_wait"])
75
96
  end
76
97
  rescue Errno::ENOENT
77
98
  nil
78
99
  end
79
100
 
101
+ # Wait for a leaving worker (Owner#leaving?) to release the lock: up to
102
+ # its leave_wait still to run plus LEAVE_MARGIN, looking every
103
+ # RELEASE_POLL seconds. An owner that isn't leaving (a live worker, a
104
+ # stuck one, a chi REPL) or no owner returns at once. A Ctrl-C (Interrupt)
105
+ # stops the wait as it would any sleep.
106
+ # @param on_wait [#call, nil] called once with the Owner before waiting
107
+ # @return [Symbol] :not_leaving, :released, or :timeout (still held)
108
+ def self.await_release(session_dir, on_wait: nil, margin: LEAVE_MARGIN, clock: method(:monotonic_now),
109
+ sleeper: method(:sleep), now: -> { Time.now })
110
+ holder = owner(session_dir)
111
+ return :not_leaving unless holder&.leaving?
112
+
113
+ on_wait&.call(holder)
114
+ deadline = clock.call + holder.leave_left(now.call) + margin
115
+ loop do
116
+ return :released unless owner(session_dir)
117
+ return :timeout if clock.call >= deadline
118
+
119
+ sleeper.call(RELEASE_POLL)
120
+ end
121
+ end
122
+
80
123
  def self.path(session_dir)
81
124
  File.join(session_dir, FILE)
82
125
  end
@@ -99,11 +142,28 @@ module Samagotchi
99
142
  def initialize(file, kind:)
100
143
  @file = file
101
144
  @kind = kind.to_s
145
+ @record = { "pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3) }
146
+ write_record
147
+ end
148
+
149
+ # The holder is leaving: its Bridge is closed and it releases the lock
150
+ # within +wait+ seconds (its recap on leave's timeout), which
151
+ # .await_release reads. .owner retries the moment the file is empty.
152
+ # @param wait [Numeric]
153
+ def mark_leaving(wait:)
154
+ return if @file.closed?
155
+
156
+ @record = @record.merge("leaving_at" => Time.now.iso8601(3), "leave_wait" => wait.to_f)
157
+ write_record
158
+ end
159
+
160
+ def write_record
102
161
  @file.truncate(0)
103
162
  @file.rewind
104
- @file.write(JSON.generate("pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3)))
163
+ @file.write(JSON.generate(@record))
105
164
  @file.flush
106
165
  end
166
+ private :write_record
107
167
 
108
168
  # Release the lock (process exit releases it too).
109
169
  def release
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A participant's name as chi shows it to anyone, the model included: one
5
+ # line of at most MAX characters with no brackets (nor Unicode line
6
+ # breaks, nor full-width square brackets), so a name can't close or forge
7
+ # the review-contribution header (Steer.review_header). The grants store
8
+ # cleans a name at issue (Web::Grants.clean_name); the header cleans it
9
+ # again, whatever a session file or a sidecar holds.
10
+ module ParticipantName
11
+ MAX = 64
12
+ UNSAFE = /[[:cntrl:]\p{Zl}\p{Zp}\[\][]【】〔〕]/
13
+
14
+ module_function
15
+
16
+ # @return [String] "" when nothing is left
17
+ def clean(name)
18
+ name.to_s.gsub(UNSAFE, " ").squeeze(" ").strip[0, MAX].to_s.strip
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "archive_store"
8
+ require_relative "session"
9
+ require_relative "web/grants"
10
+
11
+ module Samagotchi
12
+ # A session's participants: the people (or agents) its owner invited to
13
+ # review it (`chi share`, the owner's participants routes), each through a
14
+ # Web::Grants grant. Two files in the session dir, neither in the session
15
+ # JSON (chi web never saves that, and a worker would overwrite a field it
16
+ # added):
17
+ # - participants.json, the sidecar: who is or was invited, never a token
18
+ # or a digest. Written by whoever changes a grant (chi web, chi share);
19
+ # the worker reads it (Participants.read; P3's system-prompt line). A
20
+ # session nobody was invited to has none, so its files stay as they were.
21
+ # - participants.jsonl, the audit trail (0600, append-only): grants issued,
22
+ # revoked and expired, a participant's first use and each new peer,
23
+ # every route refused to them.
24
+ # Neither is copied to a continue, a fork or a delegate. See
25
+ # docs/internals/participants.md.
26
+ module Participants
27
+ FILE = "participants.json"
28
+ LOG = "participants.jsonl"
29
+
30
+ # One participant as the sidecar has them. +removed_at+ when the grant
31
+ # was revoked (nil while it works, or once it simply expired:
32
+ # +expires_at+ says when).
33
+ Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at) do
34
+ def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key.to_s]] })
35
+
36
+ # Still in the review at +now+.
37
+ def active?(now)
38
+ removed_at.nil? && Time.iso8601(expires_at.to_s) > now
39
+ rescue ArgumentError
40
+ false
41
+ end
42
+ end
43
+
44
+ module_function
45
+
46
+ # @return [Array<Entry>] the session's participants, [] without any
47
+ def read(session_id, state_dir: Session.default_state_dir)
48
+ file = File.join(Session.session_dir(session_id, state_dir: state_dir), FILE)
49
+ return [] unless File.exist?(file)
50
+
51
+ data = JSON.parse(File.read(file))
52
+ Array(data.is_a?(Hash) ? data["participants"] : nil).filter_map { |hash| Entry.from_h(hash) if hash.is_a?(Hash) }
53
+ rescue JSON::ParserError, SystemCallError
54
+ []
55
+ end
56
+
57
+ # The sidecar rewritten from +grants+ (that session's Web::Grants::Grant
58
+ # list). Nothing is written for a session without grants, nor for one
59
+ # whose dir is gone.
60
+ def write(session_id, grants, state_dir: Session.default_state_dir)
61
+ return if grants.empty?
62
+
63
+ dir = Session.session_dir(session_id, state_dir: state_dir)
64
+ return unless File.directory?(dir)
65
+
66
+ entries = grants.map do |grant|
67
+ { "id" => grant.id, "kind" => grant.kind, "name" => grant.name, "role" => grant.role,
68
+ "added_at" => grant.created_at, "expires_at" => grant.expires_at, "removed_at" => grant.revoked_at }
69
+ end
70
+ AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate({ "participants" => entries })}\n", perm: 0o600)
71
+ end
72
+
73
+ # One audit line: {"at", "event", …fields}. Nothing for a session whose
74
+ # dir is gone (deleted, pruned).
75
+ def log(session_id, event, state_dir: Session.default_state_dir, at: Time.now, **fields)
76
+ dir = Session.session_dir(session_id, state_dir: state_dir)
77
+ return unless File.directory?(dir)
78
+
79
+ line = JSON.generate({ "at" => at.utc.iso8601(3), "event" => event.to_s }.merge(fields.transform_keys(&:to_s)))
80
+ File.open(File.join(dir, LOG), File::WRONLY | File::APPEND | File::CREAT, 0o600) { |io| io.write("#{line}\n") }
81
+ end
82
+
83
+ # Invites a participant to +session_id+: the grant (+grants+, a
84
+ # Web::Grants), the sidecar and the audit line.
85
+ # @return [Web::Grants::Issued]
86
+ # @raise [Web::Grants::Invalid] an archived session, or bad values
87
+ # @raise [ArgumentError] no such session
88
+ def invite(grants, session_id:, name:, kind: "human", ttl: nil, state_dir: Session.default_state_dir)
89
+ raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
90
+ if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
91
+ raise Web::Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
92
+ end
93
+
94
+ issued = grants.issue(session_id: session_id, name: name, kind: kind, **{ ttl: ttl }.compact)
95
+ write(session_id, grants.list(session_id: session_id), state_dir: state_dir)
96
+ grant = issued.grant
97
+ log(session_id, :issued, state_dir: state_dir, participant: grant.id, name: grant.name, kind: grant.kind,
98
+ role: grant.role, expires_at: grant.expires_at)
99
+ issued
100
+ end
101
+
102
+ # Revokes grants (Web::Grants#revoke's arguments) and brings each
103
+ # affected session's sidecar and audit trail along.
104
+ # @return [Array<Web::Grants::Grant>] the grants it revoked
105
+ def revoke(grants, session_id: nil, id: nil, by: "owner", state_dir: Session.default_state_dir)
106
+ revoked = grants.revoke(session_id: session_id, id: id, by: by)
107
+ revoked.group_by(&:session_id).each do |sid, ended|
108
+ write(sid, grants.list(session_id: sid), state_dir: state_dir)
109
+ ended.each { |grant| log(sid, :revoked, state_dir: state_dir, participant: grant.id, by: by) }
110
+ end
111
+ revoked
112
+ end
113
+
114
+ # Ends the grants of sessions that were archived or deleted
115
+ # (SessionManager): +by+ "archive" or "delete". Nothing is written for a
116
+ # session nobody was invited to.
117
+ def end_sessions(session_ids, by:, state_dir: Session.default_state_dir)
118
+ grants = Web::Grants.new(Web::Grants.path(state_dir: state_dir))
119
+ session_ids.each { |sid| revoke(grants, session_id: sid, by: by, state_dir: state_dir) }
120
+ end
121
+ end
122
+ end
@@ -11,21 +11,25 @@ module Samagotchi
11
11
  # :queue, :refuse, :loop), +after_seq+ (the event count then, so a
12
12
  # turn's end knows it came before), +after_file+ (the input file it runs
13
13
  # after, a basename) and +enqueued_id+ (a file-delivered command's sender).
14
- QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file, :enqueued_id) do
14
+ # +author+: who sent it when it wasn't the owner ("p:<id>", as a turn's
15
+ # origin author), nil for the owner.
16
+ QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file, :enqueued_id,
17
+ :author) do
15
18
  def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil,
16
- enqueued_id: nil) = super
19
+ enqueued_id: nil, author: nil) = super
17
20
 
18
21
  # A new command with a fresh id.
19
- def self.mint(line, client_id: nil, card: false)
20
- new(command_id: SecureRandom.uuid, client_id: client_id, line: line, card: card ? true : false)
22
+ def self.mint(line, client_id: nil, card: false, author: nil)
23
+ new(command_id: SecureRandom.uuid, client_id: client_id, line: line, card: card ? true : false, author: author)
21
24
  end
22
25
 
23
26
  # The fields its command_queued and command_ran name: card only when
24
- # it is a card's.
27
+ # it is a card's, author only when it isn't the owner's.
25
28
  # @return [Hash]
26
29
  def event_fields
27
30
  fields = { command_id: command_id, client_id: client_id, line: line }
28
31
  fields[:card] = true if card
32
+ fields[:author] = author if author
29
33
  fields
30
34
  end
31
35
 
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_access"
5
+ require_relative "session_access/remote"
6
+ require_relative "launch_mode"
7
+ require_relative "model_profile"
8
+ require_relative "cli/exit"
9
+ require_relative "terminal_ui"
10
+ require_relative "terminal_ui/attach_launcher"
11
+ require_relative "terminal_ui/remote_connector"
12
+
13
+ module Samagotchi
14
+ # chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT]: the
15
+ # attached TUI on a session of another machine, through its chi web
16
+ # (bin/chi branches here after RunOptions.parse, before its own checks,
17
+ # which are this machine's). The run flags come parsed; the ones that
18
+ # run this machine's REPL or read its files were refused already
19
+ # (SessionAccess::Contract::RUN). The setup flags go as typed: that
20
+ # machine checks them against its own config and bundles.
21
+ class RemoteAttachCommand
22
+ # @param options [Hash] RunOptions.parse's
23
+ # @param access [SessionAccess::Remote]
24
+ def initialize(options, access:, stderr: $stderr, launcher: TerminalUI::AttachLauncher,
25
+ connector: TerminalUI::RemoteConnector)
26
+ @options = options
27
+ @access = access
28
+ @stderr = stderr
29
+ @launcher = launcher
30
+ @connector = connector
31
+ end
32
+
33
+ # @return [Integer] the exit status: AttachLauncher.exit_status's, 1 a
34
+ # launch that failed, 2 a usage error, 130 a Ctrl-C before the TUI
35
+ # ran (the wake, a wait for a leaving worker there)
36
+ def run
37
+ if (conflict = LaunchMode.attached_conflict(@options))
38
+ return failed(conflict)
39
+ end
40
+
41
+ existing = @options[:attach] || @options[:resume]
42
+ return usage(new_needs_dir) if !existing && !absolute_dir?
43
+
44
+ @access.require_feature!(SessionAccess::Remote::ATTACH_FEATURE)
45
+ id = existing ? @access.resolve(existing) : create
46
+ return id if id.is_a?(Integer)
47
+
48
+ ignored(id) if existing
49
+ launch(id, existing: existing)
50
+ rescue TerminalUI::AttachLauncher::Error, SessionAccess::RemoteFailed, SessionAccess::Refused,
51
+ SessionManager::OwnedByTUI, ModelProfile::MissingModel, ArgumentError => e
52
+ e.is_a?(SessionAccess::BadRequest) ? usage(e.message) : failed(e.message)
53
+ rescue Interrupt
54
+ CLI::Exit::INTERRUPTED
55
+ end
56
+
57
+ private
58
+
59
+ def launch(id, existing:)
60
+ model = @options[:model]
61
+ ended = @launcher.run(attach: (id if @options[:attach]), resume: (id if @options[:resume]), prompt: @options[:prompt],
62
+ model: (model if existing), no_interrupt: !!@options[:no_interrupt],
63
+ default_input: !@options[:no_default_input],
64
+ llm_context: (existing && @options[:llm_context]) || {},
65
+ thinking: (@options[:thinking] if existing),
66
+ connector: @connector.new(@access, session_id: id))
67
+ @launcher.exit_status(ended)
68
+ end
69
+
70
+ # A new session there, idle (its -p goes in once attached), as chi send
71
+ # --new makes one: the create's warnings printed as it prints them.
72
+ # @return [String, Integer] its id, or the exit status of a refusal
73
+ def create
74
+ settings = Array(@options[:sets]).to_h do |pair|
75
+ key, sep, value = pair.partition("=")
76
+ return usage("--set takes KEY=VALUE, not #{pair}") if sep.empty? || key.strip.empty?
77
+
78
+ [key.strip, value]
79
+ end
80
+ llm = @options[:llm_context] || {}
81
+ words = SessionAccess::SetupWords.new(
82
+ llm_context: (llm unless llm.empty?), thinking: @options[:thinking]&.to_s, toolkit: @options[:toolkit],
83
+ tools: Array(@options[:tools]), no_tools: Array(@options[:no_tools]),
84
+ settings: (settings unless settings.empty?), parent_answers: @options[:parent_answers]
85
+ )
86
+ created = @access.create(SessionAccess::NewSession.new(prompt: nil, idle: true, working_directory: @options[:dir],
87
+ model_name: @options[:model], setup: words,
88
+ client_id: @connector.client_id))
89
+ @stderr.puts("chi: warning: #{created.model_warning}") if created.model_warning
90
+ @stderr.puts("chi: warning: --parent-answers #{created.parent_answers_warning}") if created.parent_answers_warning
91
+ created.id
92
+ end
93
+
94
+ # An existing session keeps its own setup: the line says which flags
95
+ # went unused, as chi --attach here says it.
96
+ def ignored(id)
97
+ flags = [("--dir" if @options[:dir]), ("--toolkit" if @options[:toolkit]), ("--tool" if @options[:tools]),
98
+ ("--no-tool" if @options[:no_tools]), ("--set" if @options[:sets]),
99
+ ("--parent-answers" if @options[:parent_answers])].compact
100
+ TerminalUI::AttachLauncher.warn_memory_flags_ignored(id, [], [], @stderr, toolkit_flags: flags)
101
+ end
102
+
103
+ def absolute_dir? = @options[:dir].to_s.start_with?("/")
104
+
105
+ def new_needs_dir
106
+ "a new session on #{@access.name} needs --dir, that machine's absolute path (or --attach ID / --resume ID)"
107
+ end
108
+
109
+ def failed(message)
110
+ @stderr.puts("Error: #{message}")
111
+ CLI::Exit::FAILED
112
+ end
113
+
114
+ def usage(message)
115
+ @stderr.puts("Error: #{message}")
116
+ CLI::Exit::USAGE
117
+ end
118
+ end
119
+ end
@@ -14,7 +14,7 @@ module Samagotchi
14
14
  module RunOptions
15
15
  # The flags an underscored spelling is refused for (--recap_base_url),
16
16
  # with the config registry's.
17
- KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
17
+ KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
18
18
 
19
19
  module_function
20
20
 
@@ -107,6 +107,14 @@ module Samagotchi
107
107
  [options, cli_overrides]
108
108
  end
109
109
 
110
+ # The switches that take a value (--model NAME, -p TEXT), long and
111
+ # short: a word after one is its value, not a flag.
112
+ # @return [Array<String>]
113
+ def value_switches
114
+ parser_switches(build_parser({}, {})).select { |switch| switch.is_a?(OptionParser::Switch::RequiredArgument) }
115
+ .flat_map { |switch| switch.long + switch.short }.push("--no-tool").uniq
116
+ end
117
+
110
118
  # Every switch the parser takes, long and short, a --[no-]x as --x and
111
119
  # --no-x (--no-tool too, read before the parse), for what classifies the
112
120
  # run flags.
@@ -138,12 +146,14 @@ module Samagotchi
138
146
  chi context <add|push|ls|show|refresh|rm|mute|unmute> live external text attached to sessions
139
147
  chi send [-m TEXT] ID...|--new a message into sessions, as if typed there
140
148
  chi answer ID --question QID --option N answer a question a session waits on
149
+ chi share ID --name NAME | ls [ID] | revoke ID [P]|--all participants who review a session
141
150
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
142
151
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
143
152
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
144
153
  chi models [--format json] [TEXT] the models --model takes, from every host
145
154
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
146
- chi --remote NAME <send|answer|note|context|sessions> … those commands on that machine (or CHI_REMOTE=NAME)
155
+ chi --remote NAME <send|answer|note|context|sessions|share> … those commands on that machine (or CHI_REMOTE=NAME)
156
+ chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
147
157
  chi self version, paths, model and bundles
148
158
  chi update [--dry-run] update this chi, its bundles and the desktop helper
149
159
  (each subcommand takes --help)
@@ -152,6 +162,8 @@ module Samagotchi
152
162
  opts.on("-p", "--prompt PROMPT", "Run PROMPT as the first turn, then stay in the REPL") { |p| options[:prompt] = p }
153
163
  opts.on("--resume SESSION_ID", "Resume a previous session by its ID (or a unique prefix of it)") { |s| options[:resume] = s }
154
164
  opts.on("--attach SESSION_ID", "Attach to a session's worker, waking one if it has exited; ID or a unique prefix") { |s| options[:attach] = s }
165
+ opts.on("--dir DIR", "With --remote: a new session's folder on that machine, its absolute path (required there; " \
166
+ "here cd instead)") { |d| options[:dir] = d }
155
167
  opts.on("--[no-]shared", "Run the session in a background worker and attach to it, so `chi web` can share it (the default, config session.shared; with --resume: that session); --no-shared runs a plain in-process REPL") { |v| options[:shared] = v }
156
168
  opts.on("--model NAME", "Model for this run, not saved (overrides --resume's; config default.model) [env SAMAGOTCHI_DEFAULT_MODEL]") { |m| options[:model] = m; cli_overrides["default.model"] = m }
157
169
  opts.on("--profile NAME", Samagotchi::ModelProfile::NAMES,
@@ -646,7 +646,7 @@ module Samagotchi
646
646
  # its preview; the user can attach and send it again.
647
647
  unless result
648
648
  attach = @access.invocation.attach(session.id)
649
- @info.puts("#{session.id} failed: its worker did not start; the session is kept#{" (#{attach})" if attach}")
649
+ @info.puts("#{session.id} failed: its worker did not start; the session is kept (#{attach})")
650
650
  return false
651
651
  end
652
652
  unless result.accepted?
@@ -395,10 +395,12 @@ module Samagotchi
395
395
  # @param messages [Array<Hash>] a conversation to start from (a fork's
396
396
  # seed); [] by default
397
397
  # @param setup [SessionSetup] the session's own settings (none by default)
398
+ # @param now [Time] its created_at and updated_at (a spec's clock)
398
399
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
399
400
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
400
- scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil)
401
- now = Time.now.iso8601(3)
401
+ scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil,
402
+ now: Time.now)
403
+ now = now.iso8601(3)
402
404
  resolved_test = if test_run.nil?
403
405
  test_session_env?
404
406
  else
@@ -614,9 +616,11 @@ module Samagotchi
614
616
  # Never call it from a trap handler (Mutex#lock raises ThreadError
615
617
  # there; WorkerSignals saves on a thread of its own) or from inside a
616
618
  # save (the lock isn't re-entrant).
617
- def save(state_dir: self.class.default_state_dir)
619
+ # @param now [Time, nil] the updated_at to stamp (a spec's clock); nil,
620
+ # the time the lock is taken
621
+ def save(state_dir: self.class.default_state_dir, now: nil)
618
622
  @save_lock.synchronize do
619
- @updated_at = Time.now.iso8601(3)
623
+ @updated_at = (now || Time.now).iso8601(3)
620
624
  # Persist with current metadata version so new flag is written
621
625
  @metadata_version = METADATA_VERSION
622
626
  FileUtils.mkdir_p(state_dir)