samagotchi 0.104.0 → 0.106.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 +70 -1
- data/docs/architecture.md +6 -3
- data/docs/cli.md +62 -19
- data/docs/configuration.md +10 -6
- data/docs/guardrails.md +69 -0
- data/docs/hooks.md +9 -0
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/engine.md +37 -1
- data/docs/internals/participants.md +229 -21
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/internals/working-set.md +12 -5
- data/docs/plugins.md +2 -0
- data/docs/testing.md +23 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -4
- data/lib/samagotchi/bridge.rb +45 -8
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +21 -9
- data/lib/samagotchi/config.rb +17 -2
- data/lib/samagotchi/continue_offer.rb +13 -3
- data/lib/samagotchi/delivery.rb +10 -0
- data/lib/samagotchi/engine.rb +98 -8
- data/lib/samagotchi/guardrail_wiring.rb +13 -3
- data/lib/samagotchi/guardrails/review_boundary.rb +148 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hook_routes.rb +5 -2
- data/lib/samagotchi/hooks/registry.rb +12 -3
- data/lib/samagotchi/idle_recap.rb +9 -0
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/parent_report.rb +17 -5
- data/lib/samagotchi/participant_messages.rb +16 -0
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +29 -6
- data/lib/samagotchi/participants_watch.rb +90 -0
- data/lib/samagotchi/plugin/context.rb +7 -1
- data/lib/samagotchi/plugin/side_question.rb +9 -1
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/reply_wait/poll.rb +18 -3
- data/lib/samagotchi/run_options.rb +1 -1
- data/lib/samagotchi/send_command.rb +41 -3
- data/lib/samagotchi/session_access/contract.rb +2 -0
- data/lib/samagotchi/session_access/local.rb +31 -9
- data/lib/samagotchi/session_access/remote.rb +19 -2
- data/lib/samagotchi/session_access/web_bridge_client.rb +4 -2
- data/lib/samagotchi/session_access.rb +6 -3
- data/lib/samagotchi/session_commands.rb +6 -0
- data/lib/samagotchi/session_inbox.rb +31 -5
- data/lib/samagotchi/session_manager.rb +43 -3
- data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
- data/lib/samagotchi/session_metrics.rb +94 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +10 -7
- data/lib/samagotchi/share_command.rb +61 -22
- data/lib/samagotchi/side_usage.rb +4 -2
- data/lib/samagotchi/steer.rb +120 -11
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +94 -22
- data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
- data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
- data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/testing/host.rb +4 -2
- data/lib/samagotchi/testing/recorder.rb +3 -1
- data/lib/samagotchi/toolkit.rb +21 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +45 -20
- data/lib/samagotchi/web/grants.rb +89 -8
- data/lib/samagotchi/web/participant_access.rb +206 -35
- data/lib/samagotchi/web/participant_review.rb +165 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +157 -0
- data/lib/samagotchi/web/participant_view.rb +415 -0
- data/lib/samagotchi/web/public/app.js +15 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/data.js +20 -0
- data/lib/samagotchi/web/public/index.html +75 -30
- data/lib/samagotchi/web/public/info_bar.js +12 -5
- data/lib/samagotchi/web/public/info_bar_model.js +8 -5
- data/lib/samagotchi/web/public/participant.html +119 -0
- data/lib/samagotchi/web/public/participant.js +300 -0
- data/lib/samagotchi/web/public/participant_model.js +160 -0
- data/lib/samagotchi/web/public/participant_names.js +21 -0
- data/lib/samagotchi/web/public/participants_panel.js +311 -0
- data/lib/samagotchi/web/public/stream_handlers.js +8 -1
- data/lib/samagotchi/web/public/tokens.css +12 -0
- data/lib/samagotchi/web/public/turn_events.js +16 -5
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +4 -4
- data/lib/samagotchi/web/qr.rb +14 -0
- data/lib/samagotchi/web/running.rb +52 -0
- data/lib/samagotchi/web/server.rb +13 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -0
- data/lib/samagotchi/worker.rb +125 -11
- data/lib/samagotchi/worker_inbound.rb +12 -1
- metadata +17 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_paths"
|
|
4
|
+
require_relative "shell_paths"
|
|
5
|
+
require_relative "../paths"
|
|
6
|
+
require_relative "../toolkit"
|
|
7
|
+
require_relative "../steer"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Guardrails
|
|
11
|
+
# A participant's turn may only read (D3, D4: it proposes, the owner
|
|
12
|
+
# applies). A core check, on in every guardrails mode and with
|
|
13
|
+
# guardrails.enabled: false, active only while the running turn's author
|
|
14
|
+
# is a participant (GuardrailWiring#checks); the owner's turns never get
|
|
15
|
+
# it. It denies, never asks (nobody on the participant's side may
|
|
16
|
+
# approve, and the owner's approval would be the participant's text
|
|
17
|
+
# acting with the owner's power), every call outside the review kit
|
|
18
|
+
# (Toolkit::REVIEW_TOOLS). Not forget_outputs: it would replace the
|
|
19
|
+
# owner's earlier tool outputs with a note of the participant's, which
|
|
20
|
+
# later owner turns read as their own output, with no review header.
|
|
21
|
+
# read: only under the session's project root, and never a file of
|
|
22
|
+
# the read denylist (chi's state and config dirs, home secret dirs,
|
|
23
|
+
# project secret files), checked on the path as given and as it
|
|
24
|
+
# resolves (a symlink in the project to ~/.ssh is ~/.ssh);
|
|
25
|
+
# memory_read: the project scope only, named (no fallback to system);
|
|
26
|
+
# anything else, call_tool's real target included: a call_tool of a
|
|
27
|
+
# built-in reaches the gate as that built-in (ToolRunner#unwrapped),
|
|
28
|
+
# so what is left of call_tool is a plugin or MCP tool, denied in v1.
|
|
29
|
+
# The declared tools stay the session's (no prompt rebuild per author),
|
|
30
|
+
# so a participant turn's model may still try a write: it is denied
|
|
31
|
+
# here, with the header's "This turn may only read" to keep it rare.
|
|
32
|
+
class ReviewBoundary
|
|
33
|
+
RULE = "review-boundary"
|
|
34
|
+
SOURCE = "core"
|
|
35
|
+
ALLOWED = Toolkit::REVIEW_TOOLS
|
|
36
|
+
PROJECT_SCOPE = "project"
|
|
37
|
+
|
|
38
|
+
# Home folders that hold keys and tokens, never read in a review turn.
|
|
39
|
+
HOME_SECRET_DIRS = %w[.ssh .gnupg .aws].freeze
|
|
40
|
+
# Project files that hold secrets, by a path part's name: .env*,
|
|
41
|
+
# netrc, keys and certificates, anything named credentials, package
|
|
42
|
+
# registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
|
|
43
|
+
# secrets file; and a git config (.git/config, a submodule's
|
|
44
|
+
# .git/modules/x/config), checked apart.
|
|
45
|
+
SECRET_FILE = /\A(?:\.env.*|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
|
|
46
|
+
\.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
|
|
47
|
+
# The .docker/config.json kind: a dot dir's config holding tokens.
|
|
48
|
+
SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
|
|
49
|
+
GIT_DIR = ".git"
|
|
50
|
+
|
|
51
|
+
ADVICE = "This is a participant's review turn: answer from what you can read and propose any change as " \
|
|
52
|
+
"text or a diff for the owner to apply."
|
|
53
|
+
NOT_IN_KIT = "a participant's review turn may only read (read, memory_read): %s is not allowed"
|
|
54
|
+
OUTSIDE = "a participant's review turn reads only inside the session's project"
|
|
55
|
+
SECRET = "a participant's review turn can't read secrets or chi's own files"
|
|
56
|
+
HOME_ROOT = "a participant's review turn reads only inside a project, and this session's folder is the " \
|
|
57
|
+
"home folder or above it"
|
|
58
|
+
MEMORY_SCOPE = "a participant's review turn reads project memories only: call memory_read with scope: project"
|
|
59
|
+
|
|
60
|
+
# @param author [#call] → String, nil: the running turn's author
|
|
61
|
+
# (GuardrailWiring#turn_author); the check is off unless it is a
|
|
62
|
+
# participant's (Steer.participant_author?)
|
|
63
|
+
# @param root [#call] → String, nil: the session's project root (the
|
|
64
|
+
# work tree top of its folder); nil denies every read
|
|
65
|
+
# @param denied_dirs [#call] → Array<String>: chi's own dirs (state,
|
|
66
|
+
# config: sessions, grants, the web token, memories and .bundles,
|
|
67
|
+
# config.yml, hooks)
|
|
68
|
+
# @param home [#call] → String, nil
|
|
69
|
+
def initialize(author:, root:, denied_dirs: -> { ReviewBoundary.chi_dirs }, home: -> { Dir.home })
|
|
70
|
+
@author = author
|
|
71
|
+
@root = root
|
|
72
|
+
@denied_dirs = denied_dirs
|
|
73
|
+
@home = home
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# chi's state dir and config dir, as Paths has them now.
|
|
77
|
+
def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
|
|
78
|
+
|
|
79
|
+
# Vote on +verdict+ (its call and targets).
|
|
80
|
+
def check(verdict)
|
|
81
|
+
return verdict unless Steer.participant_author?(@author.call)
|
|
82
|
+
|
|
83
|
+
reason = refusal(verdict)
|
|
84
|
+
return verdict unless reason
|
|
85
|
+
|
|
86
|
+
verdict.deny!(reason, rule: RULE, source: SOURCE, decided_by: "core", advice: ADVICE)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def refusal(verdict)
|
|
92
|
+
call = verdict.call || {}
|
|
93
|
+
tool = call[:name].to_s
|
|
94
|
+
return format(NOT_IN_KIT, tool_label(tool, verdict.targets)) unless ALLOWED.include?(tool)
|
|
95
|
+
|
|
96
|
+
case tool
|
|
97
|
+
when "read" then read_refusal(verdict.targets&.paths || [])
|
|
98
|
+
when "memory_read" then MEMORY_SCOPE unless call[:scope].to_s.strip.downcase == PROJECT_SCOPE
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A plugin or MCP tool behind call_tool names what it acts as.
|
|
103
|
+
def tool_label(tool, targets)
|
|
104
|
+
acts_as = targets&.acts_as
|
|
105
|
+
acts_as && acts_as != tool ? "#{tool} (#{acts_as})" : tool
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_refusal(paths)
|
|
109
|
+
return OUTSIDE if paths.empty?
|
|
110
|
+
|
|
111
|
+
root = @root.call.to_s
|
|
112
|
+
return OUTSIDE if root.empty?
|
|
113
|
+
|
|
114
|
+
real_root = ProtectedPaths.real(root)
|
|
115
|
+
home = @home.call.to_s
|
|
116
|
+
return HOME_ROOT if !home.empty? && ShellPaths.within?(ProtectedPaths.real(home), real_root)
|
|
117
|
+
|
|
118
|
+
paths.each do |path|
|
|
119
|
+
given = File.expand_path(path)
|
|
120
|
+
real = ProtectedPaths.real(given)
|
|
121
|
+
return OUTSIDE unless ShellPaths.within?(real, real_root)
|
|
122
|
+
return SECRET if secret?(given, home) || secret?(real, home)
|
|
123
|
+
end
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def secret?(path, home)
|
|
128
|
+
dirs = @denied_dirs.call.compact.map { |dir| File.expand_path(dir) }
|
|
129
|
+
dirs += dirs.map { |dir| ProtectedPaths.real(dir) }
|
|
130
|
+
dirs += HOME_SECRET_DIRS.map { |dir| File.join(home, dir) } unless home.empty?
|
|
131
|
+
return true if dirs.any? { |dir| ShellPaths.within?(path, dir) }
|
|
132
|
+
|
|
133
|
+
parts = path.split(File::SEPARATOR)
|
|
134
|
+
return true if parts.any? { |part| SECRET_FILE.match?(part) }
|
|
135
|
+
return true if parts.each_cons(2).any? { |dir, file| SECRET_DIR_FILES[dir.downcase] == file.downcase }
|
|
136
|
+
|
|
137
|
+
git_config?(parts)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A config file anywhere under a .git dir (.git/config,
|
|
141
|
+
# .git/modules/<sub>/config, a worktree's config.worktree).
|
|
142
|
+
def git_config?(parts)
|
|
143
|
+
git = parts.index { |part| part.casecmp?(GIT_DIR) } or return false
|
|
144
|
+
parts[(git + 1)..].any? { |part| part.downcase.start_with?("config") }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -13,6 +13,7 @@ require_relative "guardrails/approvals"
|
|
|
13
13
|
require_relative "guardrails/protected_paths"
|
|
14
14
|
require_relative "guardrails/scratch_writes"
|
|
15
15
|
require_relative "guardrails/child_boundary"
|
|
16
|
+
require_relative "guardrails/review_boundary"
|
|
16
17
|
require_relative "guardrails/load_failures"
|
|
17
18
|
require_relative "guardrails/rules"
|
|
18
19
|
require_relative "guardrails/gate"
|
|
@@ -22,11 +22,13 @@ module Samagotchi
|
|
|
22
22
|
# @param question_desk [#call] → QuestionDesk
|
|
23
23
|
# @param cancel_controller [#call] → the running turn's CancellationController, nil
|
|
24
24
|
# @param steer [#call] (text, source) → steers the running turn
|
|
25
|
-
|
|
25
|
+
# @param turn_author [#call] → the running turn's participant author, nil
|
|
26
|
+
def initialize(event_routes:, question_desk:, cancel_controller:, steer:, turn_author: -> {})
|
|
26
27
|
@event_routes = event_routes
|
|
27
28
|
@question_desk = question_desk
|
|
28
29
|
@cancel_controller = cancel_controller
|
|
29
30
|
@steer = steer
|
|
31
|
+
@turn_author = turn_author
|
|
30
32
|
@unknown_fallbacks = Set.new
|
|
31
33
|
end
|
|
32
34
|
|
|
@@ -40,7 +42,8 @@ module Samagotchi
|
|
|
40
42
|
ask_user: ->(question) { ask_user(question) },
|
|
41
43
|
stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
|
|
42
44
|
steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
|
|
43
|
-
stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
|
|
45
|
+
stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) },
|
|
46
|
+
turn_author: @turn_author
|
|
44
47
|
)
|
|
45
48
|
end
|
|
46
49
|
|
|
@@ -119,9 +119,12 @@ module Samagotchi
|
|
|
119
119
|
# cancels the running turn (true when it did); +steer+ takes (text:,
|
|
120
120
|
# hook:) and puts the text into the running turn (Engine#steer, true
|
|
121
121
|
# when queued); +stop_generation+ takes (reason:, hook:) and cuts the
|
|
122
|
-
# streaming generation while the turn goes on (true when it did)
|
|
123
|
-
#
|
|
124
|
-
|
|
122
|
+
# streaming generation while the turn goes on (true when it did);
|
|
123
|
+
# +turn_author+ returns the running turn's author when a participant
|
|
124
|
+
# wrote it ("p:<id>"), nil for the owner's (every event of such a turn
|
|
125
|
+
# carries it as author:). A registry without one gives hooks no-op
|
|
126
|
+
# helpers.
|
|
127
|
+
Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, :stop_generation, :turn_author, keyword_init: true)
|
|
125
128
|
|
|
126
129
|
# A thread-safe registry for named hook callbacks.
|
|
127
130
|
#
|
|
@@ -386,6 +389,12 @@ module Samagotchi
|
|
|
386
389
|
def with_runtime(event)
|
|
387
390
|
return unless event.is_a?(Hash)
|
|
388
391
|
|
|
392
|
+
# A participant's turn: its events say whose it is (the owner's
|
|
393
|
+
# carry no author:, as before).
|
|
394
|
+
if !event.key?(:author) && (author = @runtime&.turn_author&.call)
|
|
395
|
+
event[:author] = author
|
|
396
|
+
end
|
|
397
|
+
|
|
389
398
|
event[:notify] ||= lambda { |text, level: :info, fallback_for: nil|
|
|
390
399
|
notice = { text: text.to_s, level: level, hook: event[:hook] }
|
|
391
400
|
notice[:fallback_for] = fallback_for unless fallback_for.nil?
|
|
@@ -55,6 +55,8 @@ module Samagotchi
|
|
|
55
55
|
TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
|
|
56
56
|
TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
|
|
57
57
|
DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
|
|
58
|
+
# A participant's message (chi share): a reviewer's, not the user's.
|
|
59
|
+
PARTICIPANT_LINE = "(a review contribution from %s, a participant; not the user's words)"
|
|
58
60
|
|
|
59
61
|
module_function
|
|
60
62
|
|
|
@@ -75,6 +77,8 @@ module Samagotchi
|
|
|
75
77
|
# A delegate child's report (ChildReports) is chi's news, not
|
|
76
78
|
# what the user said.
|
|
77
79
|
content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
|
|
80
|
+
# A participant's is theirs, by the name it was saved with.
|
|
81
|
+
content = "#{participant_line(message)}\n#{content}" if Steer.participant_message?(message)
|
|
78
82
|
images = ImageRef.placeholder_only?(content) ? [] : image_lines(message["images"])
|
|
79
83
|
[content, *images].reject(&:empty?).join("\n")
|
|
80
84
|
when "model", "assistant"
|
|
@@ -97,6 +101,11 @@ module Samagotchi
|
|
|
97
101
|
end
|
|
98
102
|
end
|
|
99
103
|
|
|
104
|
+
def participant_line(message)
|
|
105
|
+
name = ParticipantName.clean(message["author_name"])
|
|
106
|
+
format(PARTICIPANT_LINE, name.empty? ? "a participant" : name)
|
|
107
|
+
end
|
|
108
|
+
|
|
100
109
|
def image_lines(images)
|
|
101
110
|
Array(images).filter_map { |ref| "[image #{ref["name"] || File.basename(ref["file"].to_s)}]" if ref.is_a?(Hash) }
|
|
102
111
|
end
|
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
|
|
@@ -451,6 +451,7 @@ module Samagotchi
|
|
|
451
451
|
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
452
452
|
message[AnswerRefs::KEY] = entry[AnswerRefs::KEY] if entry[AnswerRefs::KEY]
|
|
453
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] }
|
|
454
455
|
message[ToolResponse::LOADED] = entry[ToolResponse::LOADED] if entry[ToolResponse::LOADED].is_a?(Array)
|
|
455
456
|
message[TOOL_CHANGE] = entry[TOOL_CHANGE] if entry[TOOL_CHANGE].is_a?(Hash)
|
|
456
457
|
message
|
|
@@ -33,14 +33,20 @@ module Samagotchi
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# A participant's turn waiting on the session's owner (a question or
|
|
37
|
+
# approval it raised, the owner's step-limit Continue offer it is held
|
|
38
|
+
# for): what chi web's participant wait says, and how it reads here.
|
|
39
|
+
WAITING_FOR_OWNER = "waiting for the session's owner"
|
|
40
|
+
|
|
36
41
|
module_function
|
|
37
42
|
|
|
38
43
|
# @param result [ReplyWait::Result]
|
|
39
44
|
# @return [String] answered, question, failed, canceled, limit (the
|
|
40
45
|
# turn ran out of iterations, nobody asked), not_continued (a Stop
|
|
41
46
|
# answered the step-limit question), removed (the queued message was
|
|
42
|
-
# taken back before it ran), no_answer, error, worker_gone, stopped
|
|
43
|
-
#
|
|
47
|
+
# taken back before it ran), no_answer, error, worker_gone, stopped,
|
|
48
|
+
# waiting_for_owner (a participant's turn waits on the owner: chi
|
|
49
|
+
# web's participant wait, through --remote) or running
|
|
44
50
|
def status(result)
|
|
45
51
|
case result.status
|
|
46
52
|
when :done then "answered"
|
|
@@ -57,6 +63,7 @@ module Samagotchi
|
|
|
57
63
|
when :error then "error"
|
|
58
64
|
when :worker_gone then "worker_gone"
|
|
59
65
|
when :stopped then "stopped"
|
|
66
|
+
when :waiting_for_owner then "waiting_for_owner"
|
|
60
67
|
# ReplyWait's own :canceled is the caller giving up (its cancelled:),
|
|
61
68
|
# and :timeout the deadline: the turn goes on either way.
|
|
62
69
|
else "running"
|
|
@@ -64,12 +71,13 @@ module Samagotchi
|
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
# @return [Integer] 0 answered (or not continued: the Stop asked for
|
|
67
|
-
# was carried out), 3 a question waits,
|
|
74
|
+
# was carried out), 3 a question waits (or, for a participant, the
|
|
75
|
+
# owner: waiting_for_owner), 4 still running, 1 anything
|
|
68
76
|
# else
|
|
69
77
|
def exit_status(result)
|
|
70
78
|
case status(result)
|
|
71
79
|
when "answered", "not_continued" then CLI::Exit::OK
|
|
72
|
-
when "question" then CLI::Exit::QUESTION
|
|
80
|
+
when "question", "waiting_for_owner" then CLI::Exit::QUESTION
|
|
73
81
|
when "running" then CLI::Exit::RUNNING
|
|
74
82
|
else CLI::Exit::FAILED
|
|
75
83
|
end
|
|
@@ -304,8 +312,12 @@ module Samagotchi
|
|
|
304
312
|
when :error then "the worker failed: #{result.text}; #{shows} shows what happened"
|
|
305
313
|
when :worker_gone then "the worker is gone; #{shows} shows what happened"
|
|
306
314
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
315
|
+
when :waiting_for_owner
|
|
316
|
+
turn = turn_id || result.turn_id
|
|
317
|
+
turn.to_s.empty? ? WAITING_FOR_OWNER : "#{WAITING_FOR_OWNER}: #{via.command("send --wait --turn #{turn} #{session_id}")}"
|
|
307
318
|
else
|
|
308
|
-
|
|
319
|
+
turn = turn_id || result.turn_id
|
|
320
|
+
wait_again = via.command("send --wait --turn #{turn} #{session_id}") unless turn.to_s.empty?
|
|
309
321
|
hint = wait_again || shows
|
|
310
322
|
timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
|
|
311
323
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The words a participant is refused with, one per condition, wherever
|
|
5
|
+
# it is met: chi web's routes, the Bridge, the worker, and the
|
|
6
|
+
# SessionAccess errors a chi --remote client raises for the same codes.
|
|
7
|
+
# No requires, so any file can use it.
|
|
8
|
+
module ParticipantMessages
|
|
9
|
+
# 403 not_permitted: a route, a command or a shell line only the owner may use.
|
|
10
|
+
NOT_PERMITTED = "a participant can't do that: only the session's owner can"
|
|
11
|
+
# 429 too_many_turns: their running turn and its queue are full.
|
|
12
|
+
TOO_MANY_TURNS = "this participant has too many turns waiting: wait for one to finish"
|
|
13
|
+
# 429 cap_reached: their grant spent its cap.
|
|
14
|
+
CAP_REACHED = "this participant's spending cap is reached: ask the session's owner to raise it"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -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
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "atomic_file"
|
|
7
|
+
require_relative "participant_messages"
|
|
7
8
|
require_relative "archive_store"
|
|
8
9
|
require_relative "session"
|
|
9
10
|
require_relative "web/grants"
|
|
@@ -26,11 +27,16 @@ module Samagotchi
|
|
|
26
27
|
module Participants
|
|
27
28
|
FILE = "participants.json"
|
|
28
29
|
LOG = "participants.jsonl"
|
|
30
|
+
# What a participant is told when they try what only the owner may do:
|
|
31
|
+
# a route (chi web's 403 not_permitted), a command or a shell line.
|
|
32
|
+
NOT_PERMITTED = ParticipantMessages::NOT_PERMITTED
|
|
29
33
|
|
|
30
34
|
# One participant as the sidecar has them. +removed_at+ when the grant
|
|
31
35
|
# was revoked (nil while it works, or once it simply expired:
|
|
32
|
-
# +expires_at+ says when).
|
|
33
|
-
|
|
36
|
+
# +expires_at+ says when). +cap+: their spending cap in dollars (the
|
|
37
|
+
# worker's check before their queued turn starts), nil in a sidecar
|
|
38
|
+
# from before caps.
|
|
39
|
+
Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at, :cap) do
|
|
34
40
|
def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key.to_s]] })
|
|
35
41
|
|
|
36
42
|
# Still in the review at +now+.
|
|
@@ -65,7 +71,8 @@ module Samagotchi
|
|
|
65
71
|
|
|
66
72
|
entries = grants.map do |grant|
|
|
67
73
|
{ "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
|
|
74
|
+
"added_at" => grant.created_at, "expires_at" => grant.expires_at, "removed_at" => grant.revoked_at,
|
|
75
|
+
"cap" => grant.spend_cap }
|
|
69
76
|
end
|
|
70
77
|
AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate({ "participants" => entries })}\n", perm: 0o600)
|
|
71
78
|
end
|
|
@@ -85,20 +92,36 @@ module Samagotchi
|
|
|
85
92
|
# @return [Web::Grants::Issued]
|
|
86
93
|
# @raise [Web::Grants::Invalid] an archived session, or bad values
|
|
87
94
|
# @raise [ArgumentError] no such session
|
|
88
|
-
def invite(grants, session_id:, name:, kind: "human", ttl: nil, state_dir: Session.default_state_dir)
|
|
95
|
+
def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil, state_dir: Session.default_state_dir)
|
|
89
96
|
raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
|
|
90
97
|
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
|
|
91
98
|
raise Web::Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
|
|
92
99
|
end
|
|
93
100
|
|
|
94
|
-
issued = grants.issue(session_id: session_id, name: name, kind: kind,
|
|
101
|
+
issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap)
|
|
95
102
|
write(session_id, grants.list(session_id: session_id), state_dir: state_dir)
|
|
96
103
|
grant = issued.grant
|
|
97
104
|
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)
|
|
105
|
+
role: grant.role, expires_at: grant.expires_at, cap: grant.spend_cap)
|
|
99
106
|
issued
|
|
100
107
|
end
|
|
101
108
|
|
|
109
|
+
# Sets participant +id+'s cap on +session_id+ (chi share cap): the
|
|
110
|
+
# grant, the sidecar (the worker's check) and the audit line.
|
|
111
|
+
# @return [Web::Grants::Grant, nil] nil when +id+ isn't an active grant there
|
|
112
|
+
# @raise [Web::Grants::Invalid] a cap out of range
|
|
113
|
+
def set_cap(grants, session_id:, id:, cap:, state_dir: Session.default_state_dir)
|
|
114
|
+
grant = grants.set_cap(id, cap, session_id: session_id) or return nil
|
|
115
|
+
|
|
116
|
+
write(session_id, grants.list(session_id: session_id), state_dir: state_dir)
|
|
117
|
+
log(session_id, :cap, state_dir: state_dir, participant: grant.id, cap: grant.spend_cap)
|
|
118
|
+
grant
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The refusal for a cap change of +id+, no active participant of
|
|
122
|
+
# +session_id+.
|
|
123
|
+
def no_participant(id, session_id) = "no active participant #{id} in #{session_id.to_s[0, 8]} (chi share ls lists them)"
|
|
124
|
+
|
|
102
125
|
# Revokes grants (Web::Grants#revoke's arguments) and brings each
|
|
103
126
|
# affected session's sidecar and audit trail along.
|
|
104
127
|
# @return [Array<Web::Grants::Grant>] the grants it revoked
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Loaded only when a sidecar is read: it brings Web::Grants (and Rack).
|
|
5
|
+
autoload :Participants, File.expand_path("participants", __dir__)
|
|
6
|
+
|
|
7
|
+
# Who is in a session's review now, and who came or went since the last
|
|
8
|
+
# look: participants.json (Participants.read) read again when its mtime
|
|
9
|
+
# moves, each entry's expiry measured at every look. The worker asks
|
|
10
|
+
# #changes on its idle tick and announces :participants_changed; the
|
|
11
|
+
# Bridge's join snapshot asks #active (any thread). A session nobody was
|
|
12
|
+
# invited to has no sidecar: one stat per look. Joined means invited (an
|
|
13
|
+
# active grant the last look didn't have), left means revoked or expired:
|
|
14
|
+
# participants have no presence of their own yet. See
|
|
15
|
+
# docs/internals/participants.md (The owner's terminal).
|
|
16
|
+
class ParticipantsWatch
|
|
17
|
+
# Participants::FILE, without loading Participants for a stat.
|
|
18
|
+
FILE = "participants.json"
|
|
19
|
+
|
|
20
|
+
# What changed: the active participants now, the ones that joined, and
|
|
21
|
+
# the ones that left (each with +reason+ "revoked" or "expired").
|
|
22
|
+
Change = Data.define(:participants, :joined, :left) do
|
|
23
|
+
def to_event = { type: :participants_changed, participants: participants, joined: joined, left: left }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param session_id [#call] → the session's id (nil before it has one)
|
|
27
|
+
# @param state_dir [#call] → the sessions' state dir
|
|
28
|
+
# @param clock [#call] → Time
|
|
29
|
+
def initialize(session_id:, state_dir:, clock: -> { Time.now })
|
|
30
|
+
@session_id = session_id
|
|
31
|
+
@state_dir = state_dir
|
|
32
|
+
@clock = clock
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
@stamp = nil
|
|
35
|
+
@entries = []
|
|
36
|
+
@known = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The participants in the review now: [{id:, name:, kind:}], [] for none.
|
|
40
|
+
# @return [Array<Hash>]
|
|
41
|
+
def active
|
|
42
|
+
@mutex.synchronize { active_rows(read_entries) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The change since the last call, nil when none. The first call only
|
|
46
|
+
# takes the state as it is (a worker's start announces nobody).
|
|
47
|
+
# @return [Change, nil]
|
|
48
|
+
def changes
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
entries = read_entries
|
|
51
|
+
rows = active_rows(entries)
|
|
52
|
+
known = @known
|
|
53
|
+
@known = rows.to_h { |row| [row[:id], row] }
|
|
54
|
+
next nil if known.nil?
|
|
55
|
+
|
|
56
|
+
joined = rows.reject { |row| known.key?(row[:id]) }
|
|
57
|
+
left = known.values.reject { |row| @known.key?(row[:id]) }.map do |row|
|
|
58
|
+
entry = entries.find { |e| e.id == row[:id] }
|
|
59
|
+
row.merge(reason: entry&.removed_at ? "revoked" : "expired")
|
|
60
|
+
end
|
|
61
|
+
next nil if joined.empty? && left.empty?
|
|
62
|
+
|
|
63
|
+
Change.new(participants: rows, joined: joined, left: left)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def active_rows(entries)
|
|
70
|
+
now = @clock.call
|
|
71
|
+
entries.select { |entry| entry.active?(now) }.map { |entry| { id: entry.id, name: entry.name, kind: entry.kind } }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The sidecar's entries, read again only when its mtime moved.
|
|
75
|
+
def read_entries
|
|
76
|
+
id = @session_id.call
|
|
77
|
+
return @entries = [] if id.to_s.empty?
|
|
78
|
+
|
|
79
|
+
file = File.join(Session.session_dir(id, state_dir: @state_dir.call), FILE)
|
|
80
|
+
stamp = [id, File.mtime(file)]
|
|
81
|
+
return @entries if stamp == @stamp
|
|
82
|
+
|
|
83
|
+
@stamp = stamp
|
|
84
|
+
@entries = Participants.read(id, state_dir: @state_dir.call)
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
@stamp = nil
|
|
87
|
+
@entries = []
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -61,7 +61,7 @@ module Samagotchi
|
|
|
61
61
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
62
62
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
63
63
|
:steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
|
|
64
|
-
:cancel_controller, :cost, :usage, keyword_init: true)
|
|
64
|
+
:cancel_controller, :cost, :usage, :turn_author, keyword_init: true)
|
|
65
65
|
|
|
66
66
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
67
67
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -175,6 +175,12 @@ module Samagotchi
|
|
|
175
175
|
# with no session
|
|
176
176
|
def usage = @host.usage&.call || SessionMetrics::Usage.zero
|
|
177
177
|
|
|
178
|
+
# @return [String, nil] who wrote the running turn when it isn't the
|
|
179
|
+
# session's owner: a participant's "p:<id>" (chi share); nil for the
|
|
180
|
+
# owner's turns and between turns. A handler runs with the owner's
|
|
181
|
+
# power whatever the author (docs/hooks.md).
|
|
182
|
+
def turn_author = @host.turn_author&.call
|
|
183
|
+
|
|
178
184
|
# @return [String, nil] the name of the session's toolkit ("full" for
|
|
179
185
|
# one from before toolkits; nil with no session)
|
|
180
186
|
def toolkit = @host.toolkit&.call
|
|
@@ -42,13 +42,21 @@ module Samagotchi
|
|
|
42
42
|
line = IdleRecap::TranscriptFilter.build([message])
|
|
43
43
|
next if line.strip.empty?
|
|
44
44
|
|
|
45
|
-
"#{message
|
|
45
|
+
"#{speaker(message)}: #{line}"
|
|
46
46
|
end.join("\n\n")
|
|
47
47
|
return text if text.length <= MAX_TRANSCRIPT_CHARS
|
|
48
48
|
|
|
49
49
|
"[earlier conversation left out]\n\n#{text[-MAX_TRANSCRIPT_CHARS..]}"
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Who a paragraph is: a participant's message is a Participant's (the
|
|
53
|
+
# filter's line names them), any other user message the User's.
|
|
54
|
+
def speaker(message)
|
|
55
|
+
return "Assistant" unless message["role"] == "user"
|
|
56
|
+
|
|
57
|
+
Steer.participant_message?(message) ? "Participant" : "User"
|
|
58
|
+
end
|
|
59
|
+
|
|
52
60
|
# String keys, as TranscriptFilter reads a saved session.
|
|
53
61
|
def stringified(messages)
|
|
54
62
|
JSON.parse(JSON.generate(Array(messages)))
|
|
@@ -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
|
|