samagotchi 0.121.1 → 0.123.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/bin/chi +4 -1
  4. data/docs/architecture.md +19 -7
  5. data/docs/cli.md +32 -1
  6. data/docs/configuration.md +55 -5
  7. data/docs/guardrails.md +119 -7
  8. data/docs/internals/engine.md +11 -2
  9. data/docs/internals/participants.md +76 -20
  10. data/docs/internals/prompt-caching.md +19 -7
  11. data/docs/internals/web-push.md +7 -3
  12. data/docs/memory.md +33 -0
  13. data/docs/plugins.md +3 -2
  14. data/docs/sessions.md +14 -4
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  16. data/lib/samagotchi/bridge.rb +8 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
  18. data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  21. data/lib/samagotchi/config.rb +11 -0
  22. data/lib/samagotchi/delivery.rb +7 -0
  23. data/lib/samagotchi/engine.rb +289 -12
  24. data/lib/samagotchi/event_fanout.rb +0 -1
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +46 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/log_line.rb +1 -1
  40. data/lib/samagotchi/memory_orphans.rb +11 -0
  41. data/lib/samagotchi/participant_mode.rb +85 -0
  42. data/lib/samagotchi/participants.rb +7 -5
  43. data/lib/samagotchi/plugin/api.rb +7 -4
  44. data/lib/samagotchi/process_group.rb +13 -0
  45. data/lib/samagotchi/review_apply.rb +10 -2
  46. data/lib/samagotchi/review_start.rb +42 -10
  47. data/lib/samagotchi/run_options.rb +1 -0
  48. data/lib/samagotchi/session.rb +14 -1
  49. data/lib/samagotchi/session_access/contract.rb +1 -1
  50. data/lib/samagotchi/session_access/local.rb +5 -2
  51. data/lib/samagotchi/session_access/remote.rb +32 -12
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
  53. data/lib/samagotchi/session_manager.rb +21 -7
  54. data/lib/samagotchi/session_participants.rb +18 -3
  55. data/lib/samagotchi/session_setup.rb +8 -4
  56. data/lib/samagotchi/sessions_command.rb +1 -1
  57. data/lib/samagotchi/share_command.rb +2 -2
  58. data/lib/samagotchi/steer.rb +13 -6
  59. data/lib/samagotchi/system_one/client.rb +16 -7
  60. data/lib/samagotchi/system_one/log.rb +25 -5
  61. data/lib/samagotchi/system_prompt.rb +20 -4
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  63. data/lib/samagotchi/terminal_ui.rb +2 -1
  64. data/lib/samagotchi/tool_runner.rb +11 -1
  65. data/lib/samagotchi/toolkit.rb +27 -8
  66. data/lib/samagotchi/toolkit_binding.rb +149 -7
  67. data/lib/samagotchi/toolkit_command.rb +15 -3
  68. data/lib/samagotchi/toolkit_report_command.rb +320 -0
  69. data/lib/samagotchi/toolkit_suggest.rb +377 -0
  70. data/lib/samagotchi/tools/builtins.rb +14 -1
  71. data/lib/samagotchi/tools/memory.rb +1 -1
  72. data/lib/samagotchi/update_command.rb +7 -6
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +12 -13
  75. data/lib/samagotchi/web/grants.rb +17 -5
  76. data/lib/samagotchi/web/message_display.rb +6 -1
  77. data/lib/samagotchi/web/participant_access.rb +4 -1
  78. data/lib/samagotchi/web/participant_review.rb +8 -4
  79. data/lib/samagotchi/web/participant_view.rb +12 -4
  80. data/lib/samagotchi/web/participants_admin.rb +10 -6
  81. data/lib/samagotchi/web/principal.rb +3 -4
  82. data/lib/samagotchi/web/public/app.js +34 -10
  83. data/lib/samagotchi/web/public/bubbles.js +3 -2
  84. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  85. data/lib/samagotchi/web/public/participant.html +1 -1
  86. data/lib/samagotchi/web/public/participant.js +12 -13
  87. data/lib/samagotchi/web/public/participant_model.js +20 -8
  88. data/lib/samagotchi/web/public/participant_names.js +29 -8
  89. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  90. data/lib/samagotchi/web/public/question_card.js +12 -2
  91. data/lib/samagotchi/web/public/question_cards.js +7 -0
  92. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  93. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  94. data/lib/samagotchi/web/public/turn_events.js +7 -5
  95. data/lib/samagotchi/web/public/turn_html.js +2 -2
  96. data/lib/samagotchi/web/public/turn_view.js +6 -4
  97. data/lib/samagotchi/web/public/update_notice.js +31 -2
  98. data/lib/samagotchi/web/push/notifier.rb +28 -1
  99. data/lib/samagotchi/web/server.rb +7 -3
  100. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  101. data/lib/samagotchi/web/session_control.rb +4 -2
  102. data/lib/samagotchi/web/session_hub.rb +24 -3
  103. data/lib/samagotchi/web/start_dir.rb +10 -5
  104. data/lib/samagotchi/web/update_check.rb +179 -0
  105. data/lib/samagotchi/worker.rb +60 -8
  106. data/lib/samagotchi/worker_link.rb +1 -0
  107. metadata +8 -1
@@ -14,8 +14,19 @@ module Samagotchi
14
14
  # a participant's turn; a review session's subjects (chi review, the
15
15
  # participant page's Plan) go through the same check.
16
16
  class ReviewPaths
17
- # Home folders that hold keys and tokens, never read in a review turn.
18
- HOME_SECRET_DIRS = %w[.ssh .gnupg .aws].freeze
17
+ # Home folders that hold keys and tokens, never read in a review turn
18
+ # (nor read or written in a pair turn).
19
+ # A pair turn neither reads nor writes these either: the shell's and
20
+ # git's config folders, the user's launch agents and ~/.local/bin (what
21
+ # runs at login or comes first on PATH).
22
+ HOME_SECRET_DIRS = %w[.ssh .gnupg .aws .config/gh .config/fish .config/git Library/LaunchAgents .local/bin].freeze
23
+ # Home files that start a shell, hold credentials or keep what was
24
+ # typed: a pair turn neither reads nor writes them (a review turn reads
25
+ # only inside the project anyway).
26
+ HOME_SECRET_FILES = %w[.zshrc .zshenv .zprofile .zlogin .bashrc .bash_profile .bash_login .profile .gitconfig
27
+ .netrc .npmrc .pypirc .claude.json .zsh_history .bash_history .history .python_history
28
+ .node_repl_history .psql_history .mysql_history .irb_history .lesshst
29
+ .local/share/fish/fish_history].freeze
19
30
  # Project files that hold secrets, by a path part's name: .env*,
20
31
  # netrc, keys and certificates, anything named credentials, package
21
32
  # registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
@@ -67,13 +78,34 @@ module Samagotchi
67
78
  nil
68
79
  end
69
80
 
70
- private
81
+ # SECRET when one of +paths+ is on the read denylist (as given or as
82
+ # it resolves), wherever it is; nil otherwise. A pair turn's check
83
+ # (PairBoundary): it reads outside the project too, never these.
84
+ # @param paths [Array<String>] absolute, or relative to the cwd
85
+ # @return [Symbol, nil]
86
+ def secret_refusal(paths)
87
+ paths.each do |path|
88
+ given = File.expand_path(path)
89
+ return SECRET if secret?(given) || secret?(ProtectedPaths.real(given))
90
+ end
91
+ nil
92
+ end
93
+
94
+ # The denylist's dirs and home files, absolute (as given and as they
95
+ # resolve): a pair turn's command check asks whether a word it can't
96
+ # resolve (a glob, a $var) may lead into one.
97
+ # @return [Array<String>]
98
+ def denied_roots
99
+ @denied_roots ||= begin
100
+ roots = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
101
+ roots += (HOME_SECRET_DIRS + HOME_SECRET_FILES).map { |name| File.join(@home, name) } unless @home.empty?
102
+ (roots + roots.map { |root| ProtectedPaths.real(root) }).uniq
103
+ end
104
+ end
71
105
 
106
+ # Whether +path+ (absolute) is on the read denylist.
72
107
  def secret?(path)
73
- dirs = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
74
- dirs += dirs.map { |dir| ProtectedPaths.real(dir) }
75
- dirs += HOME_SECRET_DIRS.map { |dir| File.join(@home, dir) } unless @home.empty?
76
- return true if dirs.any? { |dir| ShellPaths.within?(path, dir) }
108
+ return true if denied_roots.any? { |root| ShellPaths.within?(path, root) }
77
109
 
78
110
  parts = path.split(File::SEPARATOR)
79
111
  return true if parts.any? { |part| SECRET_FILE.match?(part) }
@@ -82,6 +114,8 @@ module Samagotchi
82
114
  git_config?(parts)
83
115
  end
84
116
 
117
+ private
118
+
85
119
  # A config file anywhere under a .git dir (.git/config,
86
120
  # .git/modules/<sub>/config, a worktree's config.worktree).
87
121
  def git_config?(parts)
@@ -87,8 +87,10 @@ module Samagotchi
87
87
  # as data (DataReaders) cut out, the <<TAG word and the terminator line
88
88
  # kept as written. This is what a rule's command: reads. A text the
89
89
  # lexer isn't sure of (unterminated) comes back as it was, and so does
90
- # one it can't read: the safe default.
91
- def self.code_text(text)
90
+ # one it can't read: the safe default. +keep_written+ false cuts every
91
+ # data body, a cat or tee writing it to a file too (a pair turn's check:
92
+ # text written to a file is data; where it goes is checked apart).
93
+ def self.code_text(text, keep_written: true)
92
94
  lexer = Lexer.new(text)
93
95
  tokens = lexer.tokens
94
96
  return text if lexer.unterminated?
@@ -104,7 +106,7 @@ module Samagotchi
104
106
 
105
107
  span = lexer.spans[i]
106
108
  cut = if bodies.include?(span)
107
- body_cut(text, span, words, redirected) if value == HEREDOC
109
+ body_cut(text, span, words, redirected, keep_written) if value == HEREDOC
108
110
  elsif value == SUBST
109
111
  substitution_cut(text, tokens, i, span, words, position)
110
112
  end
@@ -139,8 +141,8 @@ module Samagotchi
139
141
  # cat or tee writing a file may be run later (D1), and so may one
140
142
  # printing after an earlier exec sent the shell's output to a file
141
143
  # (+redirected+).
142
- def self.body_cut(text, span, words, redirected)
143
- return nil unless cut_body?(words, redirected)
144
+ def self.body_cut(text, span, words, redirected, keep_written = true)
145
+ return nil if keep_written && !cut_body?(words, redirected)
144
146
 
145
147
  [span, terminator_line(text[span])]
146
148
  end
@@ -41,13 +41,22 @@ module Samagotchi
41
41
  # @param tmp_roots [Array<String>] where a .git/hooks part doesn't count
42
42
  def touches?(command, dirs:, text:, cwd:, home: Dir.home, env: ENV, tmp_roots: [])
43
43
  roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
44
+ any_path?(command, text: text, cwd: cwd, home: home, env: env) { |path| names?(path, roots, tmp_roots) }
45
+ end
46
+
47
+ # Whether a word of +command+ names a path the block holds for (each
48
+ # word resolved as #touches? resolves it); a word that can't be
49
+ # resolved, a command with a $(…) or backticks, or one that doesn't
50
+ # lex, by the text match +text+ instead.
51
+ # @yieldparam path [String] an absolute path (not resolved for symlinks)
52
+ def any_path?(command, text:, cwd:, home: Dir.home, env: ENV)
44
53
  walk = Walk.new(cwd, home, env)
45
54
  tokens = ShellLex.lex(command.to_s)
46
55
  code = ShellLex.code_text(command.to_s)
47
56
  return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(text)
48
57
 
49
58
  ShellLex.simple_commands(tokens).any? do |words|
50
- walk.step(words).any? { |word, path| path ? names?(path, roots, tmp_roots) : word.match?(text) }
59
+ walk.step(words).any? { |word, path| path ? yield(path) : word.match?(text) }
51
60
  end
52
61
  rescue ArgumentError, EncodingError
53
62
  command.to_s.scrub.match?(text)
@@ -38,6 +38,13 @@ module Samagotchi
38
38
  # System 1's judgment that decided an ask, when it did: {id:, p:}.
39
39
  attr_accessor :judgment
40
40
 
41
+ # An ask no stored approval answers (Gate#settle_ask): the one a
42
+ # participant's pair turn gets before a write outside the project
43
+ # (PairBoundary), which the owner decides each time.
44
+ attr_writer :fresh_ask
45
+
46
+ def fresh_ask? = @fresh_ask == true
47
+
41
48
  def initialize(call:)
42
49
  @call = call
43
50
  @decision = :allow
@@ -14,6 +14,7 @@ require_relative "guardrails/protected_paths"
14
14
  require_relative "guardrails/scratch_writes"
15
15
  require_relative "guardrails/child_boundary"
16
16
  require_relative "guardrails/review_boundary"
17
+ require_relative "guardrails/pair_boundary"
17
18
  require_relative "guardrails/load_failures"
18
19
  require_relative "guardrails/rules"
19
20
  require_relative "guardrails/gate"
@@ -66,6 +66,7 @@ module Samagotchi
66
66
  parent_cannot_allow: "answered by a parent that could not allow",
67
67
  unanswered: "unanswered",
68
68
  no_approval: "no approval card",
69
+ participant: "asked by a participant's turn",
69
70
  no_question_id: "no question id"
70
71
  }.freeze
71
72
 
@@ -200,13 +201,17 @@ module Samagotchi
200
201
 
201
202
  # One card entry as an ask to judge: an Ask, a skip reason (:already_replayed,
202
203
  # :judged_live, :answered_by_parent, :parent_cannot_allow, :unanswered,
203
- # :no_approval, :no_question_id), or nil for an entry that is not a
204
+ # :no_approval, :participant, :no_question_id), or nil for an entry that is not a
204
205
  # question at all.
205
206
  def ask_of(entry, session_id, judged, sessions_dir)
206
207
  return nil unless entry.is_a?(Hash) && entry[:type] == "question"
207
208
 
208
209
  pending = entry[:pending_question]
209
210
  return :no_approval unless pending.is_a?(Hash) && pending[:approval].is_a?(Hash)
211
+ # A participant's turn's ask is never System 1's to judge, live or here
212
+ # (GuardrailWiring#system_one_judge): no record of it is the user's
213
+ # rule-level decision (it offered once only).
214
+ return :participant if pending[:approval][:author]
210
215
 
211
216
  question_id = pending[:id].to_s
212
217
  return :no_question_id if question_id.empty?
@@ -67,6 +67,10 @@ module Samagotchi
67
67
 
68
68
  def reminder_store = @kernel.reminder_store
69
69
  def peers = @kernel.peers
70
+
71
+ # The running participant turn's mode (KernelLoop#participant_mode:
72
+ # :pair, :review), nil for the owner's turns.
73
+ def participant_mode = @kernel.participant_mode&.call
70
74
  def model_key = @kernel.model_key
71
75
 
72
76
  # memory_read with the session's mutes applied: a blank name (the index)
@@ -179,6 +183,21 @@ module Samagotchi
179
183
  # Whether the current turn belongs to a participant, so pre-gate
180
184
  # refusals can defer to ReviewBoundary.
181
185
  attr_accessor :participant_turn
186
+ # The running participant turn's mode (a callable → :pair, :review or
187
+ # nil: GuardrailWiring#turn_mode): ToolRunner reads it. The Engine sets it.
188
+ attr_accessor :participant_mode
189
+ # Called before each generation, before the before_generation hook (a
190
+ # callable, nil: none): the Engine's check that may stop the turn (a
191
+ # pair turn past its participant's cap, Engine#guard_participant_spend).
192
+ attr_accessor :generation_guard
193
+
194
+ # The generation guard, if any; one that raises never breaks the turn.
195
+ def guard_generation
196
+ @generation_guard&.call
197
+ rescue StandardError => e
198
+ Log.warn(:turn, "generation_guard_failed", error: "#{e.class}: #{e.message}")
199
+ nil
200
+ end
182
201
 
183
202
  def participant_turn? = @participant_turn&.call == true
184
203
  # @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
@@ -426,6 +445,7 @@ module Samagotchi
426
445
  def generate(turn, request)
427
446
  # Before the generation starts: a hook that waits (a question) isn't
428
447
  # the generation's time, and a Stop there leaves none started.
448
+ guard_generation
429
449
  fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
430
450
  raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller&.cancelled?
431
451
 
@@ -389,6 +389,9 @@ module Samagotchi
389
389
  nil
390
390
  end
391
391
 
392
+ # The kernel's generation guard (KernelLoop#guard_generation).
393
+ def guard_generation = @kernel.respond_to?(:guard_generation) ? @kernel.guard_generation : nil
394
+
392
395
  # A hook failing must not break the turn (as in ToolRunner).
393
396
  def fire_hook(name, event)
394
397
  @kernel.hooks&.fire(name, event)
@@ -727,6 +730,7 @@ module Samagotchi
727
730
  observe_context(iteration, window)
728
731
  # Before the generation starts: a hook that waits (a question)
729
732
  # isn't the generation's time, and a Stop there leaves none started.
733
+ @loop.guard_generation
730
734
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
731
735
  return [@cancel_controller.reason, "", ""] if @cancel_controller&.cancelled?
732
736
 
@@ -16,7 +16,7 @@ module Samagotchi
16
16
  # (debug dumps) are indented by four spaces and belong to the record above.
17
17
  module LogLine
18
18
  LEVELS = %w[DEBUG INFO WARN ERROR].freeze
19
- TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast].freeze
19
+ TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast toolkit].freeze
20
20
  INDENT = " "
21
21
 
22
22
  Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
@@ -51,6 +51,17 @@ module Samagotchi
51
51
  found && text(found)
52
52
  end
53
53
 
54
+ # A model-facing hint: report the name collision without giving the model
55
+ # a command that could move another live clone's memories.
56
+ def model_message(env: ENV, cwd: Dir.pwd)
57
+ found = find(env: env, cwd: cwd)
58
+ return unless found
59
+
60
+ name = File.basename(found.computed).sub(/_\h{8}\z/, "")
61
+ "a folder with the same project name (#{name}) exists with another path's hash; ask the user whether its " \
62
+ "memories belong to this project before using them."
63
+ end
64
+
54
65
  def text(found)
55
66
  computed = found.computed
56
67
  lead = "no project memories in #{computed}"
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delivery"
4
+
5
+ module Samagotchi
6
+ # The rules for a participant's turn: the one place that decides them.
7
+ # - :review — a review session (its review: key, chi review): the turn
8
+ # only reads (Guardrails::ReviewBoundary);
9
+ # - :pair — a working session: the turn acts with the session's tools
10
+ # under the owner's guardrails, asks go to the owner
11
+ # (Guardrails::PairBoundary keeps secrets and owner-voiced tools out).
12
+ # No session (nil) is :review. A worker that doesn't name
13
+ # Delivery::PAIR_FEATURE runs every participant's turn as :review, so
14
+ # chi web and chi share ask .live, which reads the session's live worker,
15
+ # before they say what a participant's turn may do.
16
+ #
17
+ # .of is the session's: what a participant invited now gets. A turn's is
18
+ # .for_turn: :pair only for a participant whose grant was issued for it
19
+ # (Participants::Entry#mode / Grant#mode "pair", stamped at invite when
20
+ # the owner was shown that their turns act); a grant from before, or one
21
+ # issued while the session's worker predated pair turns, stays :review.
22
+ module ParticipantMode
23
+ REVIEW = :review
24
+ PAIR = :pair
25
+
26
+ module_function
27
+
28
+ # @param session [Session, nil]
29
+ # @param features [Array<String>, nil] the features of the worker that
30
+ # runs the turn (WorkerSidecar#features); nil: this chi's own (the
31
+ # worker itself, or the one a delivery will start)
32
+ # @return [Symbol] :review or :pair
33
+ def of(session, features: nil)
34
+ return REVIEW if session.nil? || !session.respond_to?(:review?) || session.review?
35
+ return REVIEW if features && !features.include?(Delivery::PAIR_FEATURE)
36
+
37
+ PAIR
38
+ end
39
+
40
+ # The mode a turn of the participant +entry+ (a Participants::Entry or a
41
+ # Web::Grants::Grant; nil: unknown) runs under in +session+.
42
+ # @return [Symbol]
43
+ def for_turn(session, entry, features: nil)
44
+ return REVIEW unless of(session, features: features) == PAIR
45
+ return REVIEW unless entry.respond_to?(:mode) && entry.mode.to_s == PAIR.to_s
46
+
47
+ PAIR
48
+ end
49
+
50
+ # .for_turn as chi web sees it, by the live worker's features (.live).
51
+ def live_for(session, entry, state_dir: nil)
52
+ for_turn(session, entry, features: live_features(session, state_dir))
53
+ end
54
+
55
+ # The mode a participant invited to +session+ now gets, as chi web and
56
+ # chi share see it: by the session's live worker's features (an older
57
+ # worker runs it as :review), else by this chi's (no live worker: the
58
+ # one a delivery starts is this chi's).
59
+ # @param state_dir [String, nil] the sessions' state dir (nil: the default)
60
+ # @return [Symbol]
61
+ def live(session, state_dir: nil)
62
+ of(session, features: live_features(session, state_dir))
63
+ end
64
+
65
+ # Whether +session+ is a working session whose live worker still runs a
66
+ # participant's turn as a review (it predates Delivery::PAIR_FEATURE).
67
+ def older_worker?(session, state_dir: nil)
68
+ of(session) == PAIR && live(session, state_dir: state_dir) == REVIEW
69
+ end
70
+
71
+ # The live worker's features, nil without a live worker (or when it
72
+ # can't be read).
73
+ def live_features(session, state_dir)
74
+ return nil if session.nil? || !session.respond_to?(:id) || session.id.to_s.empty?
75
+
76
+ require_relative "session"
77
+ require_relative "worker_link"
78
+ dir = Session.session_dir(session.id, state_dir: state_dir || Session.default_state_dir)
79
+ link = WorkerLink.resolve(dir, unlink: false)
80
+ link.live? ? link.features : nil
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ end
85
+ end
@@ -36,7 +36,9 @@ module Samagotchi
36
36
  # +expires_at+ says when). +cap+: their spending cap in dollars (the
37
37
  # worker's check before their queued turn starts), nil in a sidecar
38
38
  # from before caps.
39
- Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at, :cap) do
39
+ # +mode+: "pair" when the owner invited them to act (Grant#mode); nil
40
+ # for a review participant and for a sidecar from before it.
41
+ Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at, :cap, :mode) do
40
42
  def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key.to_s]] })
41
43
 
42
44
  # Still in the review at +now+.
@@ -72,7 +74,7 @@ module Samagotchi
72
74
  entries = grants.map do |grant|
73
75
  { "id" => grant.id, "kind" => grant.kind, "name" => grant.name, "role" => grant.role,
74
76
  "added_at" => grant.created_at, "expires_at" => grant.expires_at, "removed_at" => grant.revoked_at,
75
- "cap" => grant.spend_cap }
77
+ "cap" => grant.spend_cap, **(grant.mode ? { "mode" => grant.mode } : {}) }
76
78
  end
77
79
  AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate({ "participants" => entries })}\n", perm: 0o600)
78
80
  end
@@ -106,7 +108,7 @@ module Samagotchi
106
108
  # (Unbindable: an identity without such a public_url)
107
109
  # @raise [ArgumentError] no such session
108
110
  def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil, identity: nil, public_url: nil,
109
- state_dir: Session.default_state_dir)
111
+ mode: nil, state_dir: Session.default_state_dir)
110
112
  raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
111
113
 
112
114
  identity = Web::Grants.clean_identity(identity) unless identity.nil?
@@ -116,11 +118,11 @@ module Samagotchi
116
118
  end
117
119
 
118
120
  issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap, identity: identity,
119
- on_write: sidecar_writer(state_dir, [session_id]))
121
+ mode: mode, on_write: sidecar_writer(state_dir, [session_id]))
120
122
  grant = issued.grant
121
123
  log(session_id, :issued, state_dir: state_dir, participant: grant.id, name: grant.name, kind: grant.kind,
122
124
  role: grant.role, expires_at: grant.expires_at, cap: grant.spend_cap,
123
- **(grant.identity ? { identity: grant.identity } : {}))
125
+ **(grant.identity ? { identity: grant.identity } : {}), **(mode ? { mode: mode } : {}))
124
126
  issued
125
127
  end
126
128
 
@@ -312,17 +312,20 @@ module Samagotchi
312
312
  # a bundle has) with +add+ declared and +remove+ left out, and
313
313
  # +prompt+: text the sessions on it get in their system prompt (nil:
314
314
  # its base's, "" none; at most Toolkit::PROMPT_MAX characters);
315
- # +groups+: MCP source group names/globs (nil inherits its base).
315
+ # +groups+: MCP source group names/globs (nil inherits its base);
316
+ # +description+: optional classifier text, not included in prompts
317
+ # (at most Toolkit::DESCRIPTION_MAX characters).
316
318
  # A
317
319
  # name chi (full, lean, child) or another bundle has is a load error,
318
320
  # and so is a prompt that isn't text or is too long; a base that
319
321
  # doesn't resolve leaves the sessions on it with every tool
320
322
  # (Toolkit#resolve).
321
- def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
323
+ def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil, description: nil)
322
324
  name = name.to_s
323
325
  raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
324
326
 
325
- problem = Toolkit.prompt_error(prompt) || (Toolkit.groups_error(groups) unless groups == :all)
327
+ problem = Toolkit.prompt_error(prompt) || Toolkit.description_error(description) ||
328
+ (Toolkit.groups_error(groups) unless groups == :all)
326
329
  raise ArgumentError, "toolkit #{name}: #{problem}" if problem
327
330
  raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
328
331
  if Toolkit::BUILTIN_KITS.key?(name)
@@ -333,7 +336,7 @@ module Samagotchi
333
336
  raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
334
337
 
335
338
  @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
336
- groups: groups, source: @bundle)
339
+ groups: groups, source: @bundle, description: description)
337
340
  nil
338
341
  end
339
342
 
@@ -87,6 +87,19 @@ module Samagotchi
87
87
  nil
88
88
  end
89
89
 
90
+ # Whether +pid+ is stopped (SIGSTOP, Ctrl-Z; t: under a tracer): ps's
91
+ # state starts with T or t on macOS and Linux alike. Such a process
92
+ # takes no signal but KILL and answers no socket until it is continued.
93
+ # false when it isn't running or ps can't say.
94
+ def stopped?(pid)
95
+ return false unless signalable?(pid)
96
+
97
+ out, status = Open3.capture2("ps", "-o", "state=", "-p", pid.to_s, err: File::NULL)
98
+ status.success? && out.lstrip.start_with?("T", "t")
99
+ rescue SystemCallError, IOError
100
+ false
101
+ end
102
+
90
103
  # Field 22 (starttime); the command name (field 2) may hold spaces and
91
104
  # parentheses, so the fields are counted after its last ")".
92
105
  def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "participant_name"
4
4
  require_relative "steer"
5
+ require_relative "participant_mode"
5
6
  require_relative "output_formatter"
6
7
 
7
8
  module Samagotchi
@@ -76,7 +77,7 @@ module Samagotchi
76
77
 
77
78
  def latest(list)
78
79
  list.each_index.reverse_each do |at|
79
- next unless opening?(list[at]) && Steer.participant_author?(field(list[at], :author))
80
+ next unless opening?(list[at]) && review_turn?(list[at])
80
81
 
81
82
  proposal = proposal_at(list, at, check: false)
82
83
  return proposal if proposal
@@ -88,7 +89,7 @@ module Samagotchi
88
89
  def proposal_at(list, at, check: true)
89
90
  opening = list[at]
90
91
  author = field(opening, :author)
91
- if check && !Steer.participant_author?(author)
92
+ if check && !review_turn?(opening)
92
93
  raise Refused.new(format(NOT_REVIEW, short(field(opening, :turn_id))), code: :not_review_turn)
93
94
  end
94
95
 
@@ -99,6 +100,13 @@ module Samagotchi
99
100
  author_name: field(opening, :author_name).to_s, text: text)
100
101
  end
101
102
 
103
+ # Whether the turn +opening+ opens is a participant's review turn: a
104
+ # participant's, not stamped pair (a pair turn acted itself: there is
105
+ # nothing to apply). A message from before the stamp is a review's.
106
+ def review_turn?(opening)
107
+ Steer.participant_author?(field(opening, :author)) && field(opening, :participant_mode).to_s != ParticipantMode::PAIR.to_s
108
+ end
109
+
102
110
  # The turn's reply: its last model message with visible text, before
103
111
  # the next turn opens.
104
112
  def reply_text(list, at)
@@ -7,6 +7,7 @@ require_relative "session_chain"
7
7
  require_relative "recap_store"
8
8
  require_relative "guardrails/context"
9
9
  require_relative "guardrails/review_paths"
10
+ require_relative "participant_mode"
10
11
 
11
12
  module Samagotchi
12
13
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
@@ -75,6 +76,7 @@ module Samagotchi
75
76
  subjects = check_subjects(subjects, dir: dir)
76
77
  raise Refused, NO_SUBJECTS if subjects.empty?
77
78
 
79
+ setup = setup.with(toolkit_origin: "chosen")
78
80
  SessionManager.spawn_session(prompt: nil, preview: preview(subjects), working_directory: dir, model_name: model,
79
81
  setup: setup, review: SessionReview.new(subjects: subjects),
80
82
  note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir)
@@ -103,7 +105,8 @@ module Samagotchi
103
105
  recap = RecapStore.read(Session.session_dir(source.id, state_dir: sd))
104
106
  session = SessionManager.spawn_session(
105
107
  prompt: nil, preview: preview(subjects, source: source), working_directory: dir, model_name: choice.model,
106
- setup: SessionSetup.of(source), review: SessionReview.new(subjects: subjects, from: source.id),
108
+ setup: SessionSetup.of(source).with(toolkit_origin: "chosen"),
109
+ review: SessionReview.new(subjects: subjects, from: source.id),
107
110
  note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE, state_dir: sd
108
111
  )
109
112
  warnings = [choice.warning, session.model_warning].compact
@@ -138,20 +141,49 @@ module Samagotchi
138
141
  parts.join("\n\n")
139
142
  end
140
143
 
141
- # The warning for inviting a participant to +session+ when it isn't a
142
- # review session (D2: sharing a working session is allowed, with a
143
- # warning that names how many tool outputs it holds); nil for a review
144
- # session. chi share and the web's Invite show it as it is.
144
+ # What inviting a participant to +session+ now means: the grant's mode
145
+ # (Web::Grants::PAIR_MODE only when the owner is shown PAIR_WARNING: a
146
+ # working session whose live worker runs pair turns; nil otherwise, a
147
+ # review participant), the mode their turns get (:pair or :review) and
148
+ # the warning shown for it (nil for a review session). One read of the
149
+ # live worker for all three, so the stamp is what the warning said.
150
+ ShareTerms = Data.define(:grant_mode, :mode, :warning)
151
+
145
152
  # @param session [Session]
146
- # @return [String, nil]
147
- def share_warning(session)
148
- return nil if session.review?
153
+ # @param state_dir [String, nil] the sessions' state dir (nil: the default)
154
+ # @return [ShareTerms]
155
+ def share_terms(session, state_dir: nil)
156
+ return ShareTerms.new(grant_mode: nil, mode: ParticipantMode::REVIEW, warning: nil) if session.review?
149
157
 
150
158
  count = session.tool_output_count
151
- "#{session.id[0, 8]} is a working session, not a review session: a participant's review turn can quote what " \
152
- "its history holds (#{count} tool output#{"s" unless count == 1} so far). A review session starts from its recap instead."
159
+ outputs = "#{count} tool output#{"s" unless count == 1} so far"
160
+ pair = ParticipantMode.live(session, state_dir: state_dir) == ParticipantMode::PAIR
161
+ warning = format(pair ? PAIR_WARNING : OLDER_WORKER_WARNING, id: session.id[0, 8], outputs: outputs)
162
+ ShareTerms.new(grant_mode: (ParticipantMode::PAIR.to_s if pair), mode: pair ? ParticipantMode::PAIR : ParticipantMode::REVIEW,
163
+ warning: warning)
153
164
  end
154
165
 
166
+ # The warning for inviting a participant to +session+ when it isn't a
167
+ # review session (D2: sharing a working session is allowed, with a
168
+ # warning that says what a participant's turn does there and how many
169
+ # tool outputs its history holds); nil for a review session
170
+ # (#share_terms). chi share and the web's Invite show it as it is.
171
+ # @return [String, nil]
172
+ def share_warning(session, state_dir: nil) = share_terms(session, state_dir: state_dir).warning
173
+
174
+ # A working session's share warning (#share_warning): a participant's
175
+ # turns act there. chi share adds the command that starts a review
176
+ # session (chi review --from ID), as its invocation spells it.
177
+ PAIR_WARNING = "%<id>s is a working session: a participant's turns act in it with this session's tools " \
178
+ "(writes included; asks come to you) and read what its history holds (%<outputs>s). " \
179
+ "A review session only reads."
180
+ # The same while its live worker predates pair turns (Delivery::PAIR_FEATURE):
181
+ # a participant invited now gets review turns, whatever happens later.
182
+ OLDER_WORKER_WARNING = "%<id>s is a working session, but its worker predates pair turns: a participant invited " \
183
+ "now only reads, and can quote what its history holds (%<outputs>s). For turns that act " \
184
+ "in it, restart it (chi sessions restart %<id>s) and invite them again. A review session " \
185
+ "only reads."
186
+
155
187
  def relative(full, root)
156
188
  real_root = Guardrails::ProtectedPaths.real(root)
157
189
  real = Guardrails::ProtectedPaths.real(full)
@@ -166,6 +166,7 @@ module Samagotchi
166
166
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
167
167
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
168
168
  chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
169
+ chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
169
170
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
170
171
  chi models [--format json] [TEXT] the models --model takes, from every host
171
172
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
@@ -32,6 +32,7 @@ module Samagotchi
32
32
  STATUS_RUNNING = "running"
33
33
  STATUS_ERROR = "error"
34
34
  STATUS_STOPPED = "stopped"
35
+ TOOLKIT_ORIGINS = %w[chosen default inherited suggested].freeze
35
36
 
36
37
  # A stopped session's marker, <session dir>/stopped: a stop (chi
37
38
  # sessions stop, from another process) creates it instead of rewriting
@@ -222,6 +223,15 @@ module Samagotchi
222
223
  @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
223
224
  end
224
225
 
226
+ # Why this session is on its toolkit. Files from before the field read
227
+ # as chosen, so an old default session is never newly suggested.
228
+ attr_reader :toolkit_origin
229
+
230
+ def toolkit_origin=(value)
231
+ origin = value.to_s
232
+ @toolkit_origin = TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
233
+ end
234
+
225
235
  # The session's own settings (chi --set), a SessionConfig::Settings:
226
236
  # empty for none and for a session from before them. Its core part
227
237
  # ({key => value}: recap.enabled) is the file's config_settings, its
@@ -322,7 +332,7 @@ module Samagotchi
322
332
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
323
333
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
324
334
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
325
- toolkit: nil, settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
335
+ toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
326
336
  working_set_pins: [], review: nil)
327
337
  @id = id
328
338
  @metadata_version = metadata_version
@@ -355,6 +365,7 @@ module Samagotchi
355
365
  self.reasoning_history = reasoning_history
356
366
  self.appended_prompt_files = appended_prompt_files
357
367
  self.toolkit = toolkit
368
+ self.toolkit_origin = toolkit_origin
358
369
  self.settings = settings
359
370
  self.parent_answers = parent_answers
360
371
  self.working_set_pins = working_set_pins
@@ -464,6 +475,7 @@ module Samagotchi
464
475
  reasoning_history: setup.reasoning_history,
465
476
  appended_prompt_files: setup.appended_prompt_files,
466
477
  toolkit: setup.toolkit,
478
+ toolkit_origin: setup.toolkit_origin || "chosen",
467
479
  settings: setup.settings,
468
480
  parent_answers: setup.parent_answers,
469
481
  working_set_pins: setup.working_set_pins,
@@ -534,6 +546,7 @@ module Samagotchi
534
546
  "reasoning_history" => nil,
535
547
  "appended_prompt_files" => [],
536
548
  "toolkit" => nil,
549
+ "toolkit_origin" => "chosen",
537
550
  "bundle_settings" => {},
538
551
  "config_settings" => {},
539
552
  "interrupted_turn" => nil,