samagotchi 0.11.0 → 0.13.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +15 -5
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -1
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../paths"
5
+ require_relative "../hooks/loader"
6
+ require_relative "approval"
7
+ require_relative "protected_paths"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # What a parent agent may allow on an approval (guardrails.parent_approvals,
12
+ # config.yml only): off, a deny only; once, "Allow once" too, never a
13
+ # wider scope. `chi answer` checks it before it posts; the worker checks
14
+ # it again for an answer marked as chi answer's (CLIENT_ID), with its own
15
+ # config. A convention for an honest but eager parent, not a security
16
+ # boundary: any local process can post to the Bridge, with or without
17
+ # the marker.
18
+ #
19
+ # An option allows when Approval.settle would allow it: its index is
20
+ # below the approval's scopes. The scope is read by index, never by
21
+ # label. When the scopes are missing or don't fit the options, it fails
22
+ # closed: only a last option labelled Deny denies.
23
+ #
24
+ # Whatever the setting, an approval for a call on chi's own config dir
25
+ # (config.yml, memories/.bundles' rules), the hooks dir or the approval
26
+ # store is the user's alone (:protected): "Allow once" on a config.yml
27
+ # rewrite would be "allow everything" from then on.
28
+ module ParentApprovals
29
+ KEY = "guardrails.parent_approvals"
30
+ # The client id chi answer posts its answers with.
31
+ CLIENT_ID = "cli:answer"
32
+ # Set by chi's execute and task_create for the commands they run: a
33
+ # chi started there answers as a parent (#parent_process?).
34
+ PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
35
+ # Environment variables that say an agent runs this process: Claude
36
+ # Code's, and chi's own. They survive a PTY wrapper (script, expect).
37
+ AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", PARENT_SESSION_ENV].freeze
38
+ # Rules that ask about chi's own config and hooks: the core
39
+ # ProtectedPaths asks and the guardrails bundle's shell rule.
40
+ PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
41
+ # A shell command naming chi's config, hooks or guardrails (the
42
+ # guardrails bundle's shell-touches-chi, less .git/hooks).
43
+ CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
44
+
45
+ module_function
46
+
47
+ # Whether an answer typed into this process is a parent agent's (so
48
+ # this setting applies to it): stdin isn't a terminal (a pipe, a file,
49
+ # /dev/null), or an agent marker is set. A person at chi --attach or
50
+ # the REPL has a terminal and no marker.
51
+ def parent_process?(env: ENV, stdin: $stdin)
52
+ return true if AGENT_MARKERS.any? { |key| !env.fetch(key, "").to_s.strip.empty? }
53
+
54
+ !(stdin.respond_to?(:tty?) && stdin.tty?)
55
+ end
56
+
57
+ # This process's setting: "once" or "off" (anything else is off).
58
+ def setting
59
+ Config.get(KEY).to_s == "once" ? "once" : "off"
60
+ end
61
+
62
+ # Why a parent may not give this answer, or nil.
63
+ # @param pending [Hash] the pending question (symbol or string keys)
64
+ # @param indices [Array<Integer, nil>] the selected options' places
65
+ # @param setting [String, nil] "off" or "once"
66
+ # @return [Symbol, nil] :protected (chi's own config: the user's
67
+ # alone), :off (no allow at all), :once_only, or nil
68
+ def refusal(pending, indices, setting:)
69
+ return nil unless approval?(pending)
70
+
71
+ scopes = scopes(pending)
72
+ allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
73
+ return nil if allows.empty?
74
+ return :protected if protected?(pending)
75
+ return :off unless setting.to_s == "once"
76
+ return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
77
+
78
+ :once_only
79
+ end
80
+
81
+ # Whether +pending+ asks about a call on chi's own config, hooks,
82
+ # guardrail rules or approvals: by its rule, its paths or its command
83
+ # (the dirs as this process's environment and config.yml name them).
84
+ def protected?(pending)
85
+ facts = fetch(pending, :approval)
86
+ return false unless facts.is_a?(Hash)
87
+ return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
88
+ return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
89
+
90
+ dirs = chi_dirs
91
+ paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
92
+ return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
93
+
94
+ command = fetch(facts, :command).to_s
95
+ return false if command.empty?
96
+
97
+ command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
98
+ end
99
+
100
+ # chi's config dir, the hooks dir and the approval store's dir.
101
+ def chi_dirs
102
+ hooks = begin Hooks::Loader.hooks_dir rescue nil end
103
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails")]
104
+ .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
105
+ end
106
+
107
+ # The refusal as a line for the parent: deny, and tell the user.
108
+ # Never how to allow it (the web, chi --attach): that is the user's.
109
+ # @param reason [Symbol] what #refusal returned
110
+ # @param typed [Boolean] the answer was typed at a ? prompt (the
111
+ # REPL), not given to chi answer
112
+ def message(reason, typed: false)
113
+ deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
114
+ case reason
115
+ when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
116
+ when :off then "allowing a tool call is up to the user: #{deny}"
117
+ else "only Allow once (#{KEY}: once) can be given here: #{deny}"
118
+ end
119
+ end
120
+
121
+ # An approval: its kind says so, or it carries approval facts.
122
+ def approval?(pending)
123
+ fetch(pending, :kind).to_s == Approval::KIND || !fetch(pending, :approval).nil?
124
+ end
125
+
126
+ # The offered scopes, in option order, or nil when they are missing,
127
+ # empty, not strings, or leave no option after them for Deny.
128
+ def scopes(pending)
129
+ facts = fetch(pending, :approval)
130
+ scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
131
+ return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
132
+ return nil unless scopes.size < options(pending).size
133
+
134
+ scopes
135
+ end
136
+
137
+ def deny?(pending, scopes, index)
138
+ return false unless index.is_a?(Integer) && index >= 0
139
+
140
+ options = options(pending)
141
+ return index >= scopes.size && index < options.size if scopes
142
+
143
+ index == options.size - 1 && options[index].to_s == Approval::DENY
144
+ end
145
+
146
+ def options(pending) = Array(fetch(pending, :options))
147
+
148
+ def fetch(hash, key)
149
+ return nil unless hash.is_a?(Hash)
150
+
151
+ hash.key?(key) ? hash[key] : hash[key.to_s]
152
+ end
153
+ end
154
+ end
155
+ end
@@ -4,6 +4,8 @@ require_relative "registry"
4
4
  require "digest"
5
5
  require_relative "../log"
6
6
  require_relative "../memory_bundle/provenance"
7
+ require_relative "../memory_bundle/manifest"
8
+ require_relative "../version"
7
9
 
8
10
  module Samagotchi
9
11
  # Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
@@ -28,7 +30,8 @@ module Samagotchi
28
30
  # fails to load, or its file's sha256 differs from the one recorded at
29
31
  # install, the failure is reported and the gate denies every tool call.
30
32
  # Any hook whose sha256 differs is not loaded (reinstall the bundle
31
- # after editing a hook by hand).
33
+ # after editing a hook by hand), nor any of a bundle whose requires_chi
34
+ # this chi doesn't meet.
32
35
  #
33
36
  # The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
34
37
  # - "fail_closed" (on :before_tool_call): a raising guardrail denies
@@ -48,10 +51,14 @@ module Samagotchi
48
51
  # @param settings [Hash] the bundle's section of config.yml
49
52
  # `bundles:` (string keys); a hook class whose initialize takes an
50
53
  # argument gets it
54
+ # @param requires_chi [String, nil] the bundle's requirement; when
55
+ # this chi doesn't meet it no hook loads (each is reported, as the
56
+ # plugin loader does)
51
57
  # @return [Integer] number of hooks successfully registered
52
- def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {})
58
+ def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, requires_chi: nil)
53
59
  return 0 unless metadata.is_a?(Hash)
54
60
 
61
+ too_old = MemoryBundle::Manifest.requires_chi_failure(requires_chi, Samagotchi::VERSION)
55
62
  loaded = 0
56
63
  metadata.each do |raw_basename, raw_meta|
57
64
  basename = raw_basename.to_s
@@ -72,7 +79,7 @@ module Samagotchi
72
79
  failures&.add(what, "the file is missing", required: true) if fail_closed
73
80
  next
74
81
  end
75
- if (mismatch = sha_mismatch(file, meta.(:sha256), required: fail_closed))
82
+ if (mismatch = too_old || sha_mismatch(file, meta.(:sha256), required: fail_closed))
76
83
  Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
77
84
  failures&.add(what, mismatch, required: fail_closed)
78
85
  next
@@ -21,7 +21,6 @@ module Samagotchi
21
21
  class HostRegistry
22
22
  CACHE_TTL_SECONDS = 60
23
23
  REMOTE_CACHE_TTL_SECONDS = 600
24
- LIST_TIMEOUT_SECONDS = 3
25
24
  # Seconds a remote host's stream may take to show something (a queued
26
25
  # free model on OpenRouter can send only keep-alives for minutes).
27
26
  REMOTE_FIRST_TOKEN_TIMEOUT = 120
@@ -229,7 +228,14 @@ module Samagotchi
229
228
  # Returns { host_name => { host:, port:, transport:, models: [LLM::ModelInfo], error: nil|String } }
230
229
  # Also populates the model index. Unless forced, a host's list is reused
231
230
  # for its TTL (60s; 10 minutes for a remote host).
232
- def list_all_models(force: true)
231
+ #
232
+ # With +wait+ (seconds), answers by one shared deadline: a host still
233
+ # listing then gets the error "no answer in S s" and its thread is left
234
+ # running (only for a short-lived process: `chi models`). Such a partial
235
+ # listing is not stored as the cache or the model index, so a later
236
+ # resolve doesn't miss the slow host's ids; finished hosts' lists are
237
+ # kept for their TTL as usual.
238
+ def list_all_models(force: true, wait: nil)
233
239
  results = {}
234
240
  results_mutex = Mutex.new
235
241
  threads = @entries.map do |name, entry|
@@ -247,7 +253,24 @@ module Samagotchi
247
253
  results_mutex.synchronize { results[name] = data }
248
254
  end
249
255
  end
250
- threads.each { |thread| thread.join if thread.is_a?(Thread) }
256
+ if wait
257
+ deadline = @clock.call + wait
258
+ threads.each { |thread| thread.join([deadline - @clock.call, 0].max) if thread.is_a?(Thread) }
259
+ partial = false
260
+ # a new hash: the threads keep writing into +results+ (their closure's)
261
+ snapshot = results_mutex.synchronize do
262
+ @entries.each_with_object({}) do |(name, entry), acc|
263
+ acc[name] = results.fetch(name) do
264
+ partial = true
265
+ { host: entry.host, port: entry.port, transport: entry.transport, models: [],
266
+ error: "no answer in #{wait == wait.to_i ? wait.to_i : wait} s" }
267
+ end
268
+ end
269
+ end
270
+ return snapshot if partial
271
+ else
272
+ threads.each { |thread| thread.join if thread.is_a?(Thread) }
273
+ end
251
274
 
252
275
  # Model index: model_id downcased -> the hosts listing it, in hosts:
253
276
  # order (not the order the threads answered in).
@@ -1,64 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
-
5
- require_relative "reminder_store"
3
+ require_relative "reminder_queue"
6
4
 
7
5
  module Samagotchi
8
6
  # Idle job for periodic reminders — polled by the shared IdleScheduler
9
7
  # (one background thread for the whole idle layer).
10
8
  #
11
9
  # Two delivery paths cooperate:
12
- # 1. Pull-based: Engine#collect_due_reminders
13
- # reads ReminderStore#due_reminders at turn start → injects
14
- # [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
10
+ # 1. Every turn injects what is due (ReminderQueue#inject!, from
11
+ # Engine#run_turn) as [SYSTEM: REMINDERS DUE] and marks it fired.
15
12
  # 2. Synthetic turns: when this job's tick finds due reminders while
16
- # idle, it latches @due_reminder_name and fires @auto_turn_callback
17
- # (the TUI/SessionManager queue a synthetic turn from the latch).
13
+ # idle, it fires @auto_turn_callback, which queues their names
14
+ # (Engine#note_due_reminders) for the worker's or the REPL's
15
+ # reminder turn. While names are queued the tick waits; whoever
16
+ # clears them (a turn's injection, the reminder turn's consumer)
17
+ # re-arms it. The queue is the only "already due" state.
18
18
  class IdleReminders
19
19
  DEFAULT_MIN_INACTIVITY_SECONDS = 60.0 # Minimum idle time before checking for reminders
20
20
 
21
21
  attr_reader :inactivity
22
22
 
23
23
  def initialize(engine:, inactivity: DEFAULT_MIN_INACTIVITY_SECONDS,
24
- reminder_store: nil,
24
+ queue: nil,
25
25
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
26
26
  callback: nil)
27
27
  raise ArgumentError, "IdleReminders requires an engine" unless engine
28
28
 
29
29
  @engine = engine
30
30
  @inactivity = inactivity
31
- @reminder_store = reminder_store || engine.instance_variable_get(:@reminder_store)
31
+ @queue = queue
32
32
  @clock = clock
33
33
  @auto_turn_callback = callback
34
-
35
- @mutex = Monitor.new
36
- @due_reminder_name = nil
37
- end
38
-
39
- # Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
40
- # Thread-safe. Returns all due reminders, not just one.
41
- # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
42
- def due_reminders
43
- @reminder_store&.due_reminders || []
44
- end
45
- # Get the names of all due reminders. Called by the background thread to
46
- # determine which reminders need a synthetic turn.
47
- # @return [Array<String>] reminder names that are due
48
- def due_reminder_names
49
- due_reminders.map { |r| r[:name] }
50
- end
51
-
52
- # Clear the pending due reminder (after it has been delivered).
53
- # Called by Engine after injecting the reminder into the system prompt.
54
- def clear_due
55
- @mutex.synchronize { @due_reminder_name = nil }
56
34
  end
57
35
 
58
36
  # One detector step, called by the shared IdleScheduler. Public so specs
59
37
  # can drive it deterministically.
60
38
  def tick
61
- return if @due_reminder_name # already due, waiting for engine to deliver
39
+ return if @queue&.pending? # already queued, waiting for a turn to deliver it
62
40
  return unless should_check?
63
41
 
64
42
  check_due_reminders
@@ -78,14 +56,13 @@ module Samagotchi
78
56
  end
79
57
 
80
58
  def check_due_reminders
81
- return unless @reminder_store
82
- due_names = @reminder_store.due_reminders.map { |r| r[:name] }
59
+ return unless @queue
60
+ due_names = @queue.due.map { |r| r[:name] }
83
61
  return if due_names.empty?
84
62
  # Signal the engine to create a synthetic turn via callback.
85
63
  # The callback is responsible for triggering a turn (e.g. SessionManager
86
64
  # writes a file, TerminalUI queues input). The engine's run_turn or
87
65
  # REPL injection point then picks up and delivers the reminders.
88
- @mutex.synchronize { @due_reminder_name = due_names.first }
89
66
  if @auto_turn_callback
90
67
  @auto_turn_callback.call(due_names)
91
68
  end