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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +97 -21
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +15 -1
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +22 -1
- data/docs/internals/participants.md +167 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/internals/working-set.md +4 -3
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +89 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
- data/lib/samagotchi/bridge.rb +17 -10
- data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +13 -16
- data/lib/samagotchi/config.rb +1 -1
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +52 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +9 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/run_options.rb +14 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +8 -4
- data/lib/samagotchi/session_access/contract.rb +42 -4
- data/lib/samagotchi/session_access/dispatch.rb +29 -10
- data/lib/samagotchi/session_access/local.rb +52 -1
- data/lib/samagotchi/session_access/remote.rb +112 -16
- data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
- data/lib/samagotchi/session_access.rb +85 -3
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +21 -5
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +14 -11
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/steer.rb +83 -10
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
- data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
- data/lib/samagotchi/terminal_ui.rb +4 -1
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +303 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/index.html +6 -5
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +1 -1
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +44 -11
- data/lib/samagotchi/worker_inbound.rb +5 -2
- 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,
|
data/lib/samagotchi/last_turn.rb
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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?
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|