samagotchi 0.12.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +20 -7
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +10 -6
  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 +11 -1
  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_command.rb +5 -3
  28. data/lib/samagotchi/engine.rb +40 -14
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  30. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  31. data/lib/samagotchi/host_registry.rb +0 -1
  32. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  33. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  34. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  35. data/lib/samagotchi/memory_paths.rb +8 -0
  36. data/lib/samagotchi/model_profile.rb +30 -16
  37. data/lib/samagotchi/parent_report.rb +213 -0
  38. data/lib/samagotchi/plugin/api.rb +15 -2
  39. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  40. data/lib/samagotchi/question_desk.rb +29 -2
  41. data/lib/samagotchi/recap_setup.rb +15 -5
  42. data/lib/samagotchi/reply_wait.rb +5 -1
  43. data/lib/samagotchi/send_command.rb +50 -75
  44. data/lib/samagotchi/session.rb +15 -0
  45. data/lib/samagotchi/session_manager.rb +34 -7
  46. data/lib/samagotchi/sessions_command.rb +13 -3
  47. data/lib/samagotchi/system_prompt.rb +10 -2
  48. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  49. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  50. data/lib/samagotchi/terminal_ui.rb +15 -1
  51. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  52. data/lib/samagotchi/tool_call_parser.rb +5 -6
  53. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  54. data/lib/samagotchi/tools/builtins.rb +14 -2
  55. data/lib/samagotchi/tools/delegate_wait.rb +16 -4
  56. data/lib/samagotchi/tools/execute.rb +8 -8
  57. data/lib/samagotchi/tools/task_create.rb +3 -2
  58. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +4 -3
  61. data/lib/samagotchi/web/message_parts.rb +2 -1
  62. data/lib/samagotchi/web/server.rb +2 -1
  63. data/lib/samagotchi/web/session_summary.rb +3 -11
  64. metadata +7 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "cli/flags"
4
+ require_relative "cli/exit"
4
5
  require_relative "memory_bundle"
5
6
 
6
7
  module Samagotchi
@@ -109,7 +110,7 @@ module Samagotchi
109
110
  when "build" then build(@argv[1..])
110
111
  else
111
112
  @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
112
- return 2
113
+ return CLI::Exit::USAGE
113
114
  end
114
115
  end
115
116
 
@@ -124,7 +125,7 @@ module Samagotchi
124
125
  force = opts.fetch(:force, false)
125
126
  if source.nil? || source.empty?
126
127
  @stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
127
- return 2
128
+ return CLI::Exit::USAGE
128
129
  end
129
130
  expanded_source = expand_source(source)
130
131
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
@@ -148,7 +149,7 @@ module Samagotchi
148
149
  agent = opts[:agent]
149
150
  if source.nil? || source.empty?
150
151
  @stderr.puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
151
- return 2
152
+ return CLI::Exit::USAGE
152
153
  end
153
154
  expanded_source = expand_source(source)
154
155
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
@@ -217,7 +218,11 @@ module Samagotchi
217
218
 
218
219
  # An upgrade that kept edited files: launch an agent to merge them
219
220
  # (--agent, or yes on a terminal), or say how to take the bundle's.
220
- # @return [Integer] 0 resolved, 2 kept the edits
221
+ # An upgrade that kept edited files exits 2, as a usage error does
222
+ # (scripts already read it so): not CLI::Exit::USAGE by meaning.
223
+ CONFLICTS_KEPT = 2
224
+
225
+ # @return [Integer] 0 resolved, CONFLICTS_KEPT kept the edits
221
226
  def upgrade_conflicts(installer, bundle_name, expanded_source, agent)
222
227
  @stdout.puts "\n#{installer.conflicts.size} conflict(s) need resolution."
223
228
  installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
@@ -227,13 +232,13 @@ module Samagotchi
227
232
  ans && ans.strip.downcase.start_with?("y")
228
233
  elsif agent.nil?
229
234
  @stdout.puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
230
- return 2
235
+ return CONFLICTS_KEPT
231
236
  else
232
237
  agent
233
238
  end
234
239
  unless launch
235
240
  @stdout.puts "Kept your edits in the file(s) above; the rest is upgraded. chi bundle diff #{bundle_name} FILE shows the base; re-run with --force to take the bundle's version."
236
- return 2
241
+ return CONFLICTS_KEPT
237
242
  end
238
243
 
239
244
  prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
@@ -258,7 +263,7 @@ module Samagotchi
258
263
  force = opts.fetch(:force, false)
259
264
  if bundle_name.nil? || bundle_name.empty?
260
265
  @stderr.puts "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
261
- return 2
266
+ return CLI::Exit::USAGE
262
267
  end
263
268
  data = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).read
264
269
  return uninstall_profile(bundle_name, force: force) if Samagotchi::MemoryBundle::Profile.installed_meta?(bundle_name, data)
@@ -355,6 +360,8 @@ module Samagotchi
355
360
  exists = File.exist?(hook_path) ? "ok" : "missing"
356
361
  @stdout.puts " #{hk}: event=#{event} on_error=#{on_error} priority=#{prio} [#{exists}]"
357
362
  end
363
+ @stdout.puts " requires_chi: #{st[:provenance][:requires_chi]}" if st[:hooks_requires_failure]
364
+ @stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
358
365
  end
359
366
  if (plugin = st[:plugin])
360
367
  note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
@@ -379,6 +386,7 @@ module Samagotchi
379
386
  hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
380
387
  plugin = st[:plugin]
381
388
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
389
+ mods += 1 if st[:hooks_requires_failure]
382
390
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
383
391
  includes = st[:provenance][:includes]
384
392
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
@@ -393,7 +401,7 @@ module Samagotchi
393
401
  bname = args[0]
394
402
  file_arg = args[1]
395
403
  if bname.nil? || bname.empty?
396
- @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return 2
404
+ @stderr.puts "Usage: chi bundle diff <bundle> [file]"; return CLI::Exit::USAGE
397
405
  end
398
406
  prov = Samagotchi::MemoryBundle::Provenance.new(name: bname)
399
407
  data = prov.read
@@ -534,7 +542,7 @@ module Samagotchi
534
542
  # Validate scope if given
535
543
  if scope && !%w[system project].include?(scope.to_s.strip.downcase)
536
544
  @stderr.puts "Invalid scope '#{scope}', expected system or project"
537
- return 2
545
+ return CLI::Exit::USAGE
538
546
  end
539
547
  begin
540
548
  builder = Samagotchi::MemoryBundle::Builder.new(
@@ -573,7 +581,7 @@ module Samagotchi
573
581
  end
574
582
  if parsed.error
575
583
  @stderr.puts "Unknown bundle #{sub} flag: #{parsed.error.arg}"
576
- return 2
584
+ return CLI::Exit::USAGE
577
585
  end
578
586
  parsed
579
587
  end
@@ -80,3 +80,22 @@ rules:
80
80
  command: '(\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles|\.git/hooks)'
81
81
  verdict: ask
82
82
  reason: the command names chi's config, hooks, guardrails or git hooks
83
+
84
+ # ── answering chi's own questions around chi answer ───────────────────
85
+ # A parent agent answers a child's approval with chi answer, which holds it
86
+ # to guardrails.parent_approvals. These shapes go around that: chi --attach
87
+ # or chi -p with stdin from a pipe, a here-string or a file (a chi after 0.12 treats
88
+ # those as a parent too, but an older child doesn't), and a POST to the
89
+ # Bridge's or the web's answer route.
90
+ - id: chi-answer-piped
91
+ tool: shell
92
+ command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<)'
93
+ verdict: ask
94
+ scopes: [once, session]
95
+ reason: answers chi's questions from a pipe or a file, around chi answer
96
+ - id: chi-answer-http
97
+ tool: shell
98
+ command: '\b(curl|wget|http|xh)\b[^;&|\n]*/answer\b'
99
+ verdict: ask
100
+ scopes: [once, session]
101
+ reason: posts an answer to a chi session's question directly, around chi answer
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
7
  # models: in small-models.yml (guardrails.small_models)
8
8
  requires_chi: ">= 0.8.1"
@@ -60,7 +60,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
60
60
  - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
- - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; env `SAMAGOTCHI_GUARDRAILS_ENABLED`), `rules:` (list), `disable:` (list of rule ids, `id` or `bundle:id`, switching off a bundle's or config rule without editing it). A rule takes only `id`, `tool`, `command`, `path`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules load when a session starts: restart the worker/REPL after an edit. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
63
+ - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
64
64
 
65
65
  ```yaml
66
66
  guardrails:
@@ -122,7 +122,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
122
122
  3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
123
123
  4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agent_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
124
124
  5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
125
- 6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, guardrail rules, bundle settings) waits for its restart. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
125
+ 6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
126
126
 
127
127
  ## Validations
128
128
 
@@ -136,8 +136,8 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
136
136
  - must not point to itself (case-insensitive)
137
137
  - keys are normalized to downcase on write — `Qwen` and `qwen` collide
138
138
  - **Alias target**: non-empty string (model id).
139
- - **Hosts**: each entry needs `host`, `port` 1-65535, `transport` and `api` optional (a raw `api` must match `transport`), name must match `/\A[a-z0-9][a-z0-9._-]*\z/i`.
140
- - **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log`. Class name must match file basename snake→Pascal.
139
+ - **Hosts**: each entry needs `host` (or `url:`, an http(s) URL with an optional path, replacing `host`/`port`; never both), `port` 1-65535 (default 8080), `enabled: false` skips the entry, `transport` and `api` optional (a raw `api` must match `transport`), name must match `/\A[a-z0-9][a-z0-9._-]*\z/i`.
140
+ - **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log`; `required: true` on a `before_tool_call` hook denies the call when the hook raises. Class name must match file basename snake→Pascal.
141
141
  - **Scalars via registry**: `Config.coerce` validates `String/Numeric/true/false` per `type: :string/:integer/:float/:bool/:enum`; invalid values warn and fall back to entry `default`.
142
142
  - **Keys**: `validate_yaml_sections` flags unknown keys (with a suggestion), a registry section that isn't a mapping, and env/CLI-only keys (`model.profile`).
143
143
 
@@ -151,5 +151,5 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
151
151
 
152
152
  - Precedence is `CLI > ENV > file > default` (`Config.resolve`; `Config.get_with_origin` names the layer). `ENV` holds only what the user (or a spawning chi's CLI flags) set, and CLI (`--recap-base-url`) wins over both via `Config.reload!(cli_overrides:)`.
153
153
  - `--recap_base_url` (underscore) is rejected as unknown — use `--recap-base-url` (kebab). Same for all registry flags.
154
- - `model_aliases` require restart or `/model` reload to take effect; document the change.
154
+ - `model_aliases` are read again at each model resolution: an edit applies to the next `--model`, `/model`, new session or delegate, no restart. A running session keeps the model it already resolved.
155
155
  - Keep edits minimal: touch only the key you intend to change; preserve `hosts:`/`hooks:`/`guardrails:`/`bundles:` maps. Adding a `bundles: <name>:` entry does not install the bundle (`chi bundle install <name>`).
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.12.0
3
+ version: 0.13.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
- config_modification_protocol.md: sha256:dddd083d6f419674e6e0cc39118ca9785583a557e4950af1a9b028e52e8a3e0c
9
+ config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
10
10
  memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "exit"
4
+
3
5
  module Samagotchi
4
6
  module CLI
5
7
  # The usage side of a subcommand: its error lines, usage errors (exit 2)
@@ -7,7 +9,6 @@ module Samagotchi
7
9
  # #command_name ("chi send"); its usage text is USAGE unless it
8
10
  # overrides #usage_text.
9
11
  module Command
10
- USAGE_EXIT = 2
11
12
  HELP_WORDS = %w[-h --help help].freeze
12
13
 
13
14
  private
@@ -30,7 +31,7 @@ module Samagotchi
30
31
  usage = usage_on_error
31
32
  error_line("#{command_name}: #{message}#{usage ? "" : " (see #{command_name} --help)"}")
32
33
  @stderr.puts(usage) if usage
33
- USAGE_EXIT
34
+ Exit::USAGE
34
35
  end
35
36
 
36
37
  # +argv+ through +flags+ (a Flags), help and usage errors done.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module CLI
5
+ # chi's exit statuses, one table for every subcommand (docs/cli.md,
6
+ # docs/sub-agent.md): a script or a parent agent branches on them.
7
+ module Exit
8
+ OK = 0
9
+ # Anything that went wrong at run time: a refusal, a failed turn, a
10
+ # worker gone, a file that isn't there.
11
+ FAILED = 1
12
+ # The command line itself: an unknown flag, a missing argument, an
13
+ # option the question doesn't offer.
14
+ USAGE = 2
15
+ # A question waits for an answer (chi send --wait, chi answer, chi -p).
16
+ QUESTION = 3
17
+ # Still running after --timeout.
18
+ RUNNING = 4
19
+ # Ctrl-C: what a shell gives a command it interrupted.
20
+ INTERRUPTED = 130
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../reply_wait"
5
+ require_relative "../parent_report"
6
+ require_relative "exit"
7
+
8
+ module Samagotchi
9
+ module CLI
10
+ # The parent-agent side shared by `chi send --wait` and `chi answer`:
11
+ # --format json's one object whatever the end, the session id resolved
12
+ # for it, and the wait for what comes next, reported the ParentReport
13
+ # way (Ctrl-C included). The including class (a CLI::Command) defines
14
+ # #parse (options, or an exit status) and #run_parsed(options), sets
15
+ # @stdout, @stderr and @state_dir, and may override the constants
16
+ # (specs stub them on the class).
17
+ module ParentWait
18
+ POLL_INTERVAL = ReplyWait::POLL_INTERVAL
19
+ # No live worker this long while waiting: it died before it could mark
20
+ # the session (a worker takes well under a second to start).
21
+ WORKER_GONE_AFTER = 5
22
+ FORMATS = %w[text json].freeze
23
+
24
+ # @return [Integer] the exit status
25
+ def run
26
+ options = parse
27
+ return options if options.is_a?(Integer)
28
+
29
+ @json = options[:format] == "json"
30
+ # With --format json stdout is one JSON object, whatever the end: a
31
+ # failure before the wait is reported from stderr's last line.
32
+ @stderr = ParentReport::LastLine.new(@stderr) if @json
33
+ status = run_parsed(options)
34
+ if @json && !@reported && status == 1
35
+ detail = @stderr.last.to_s.delete_prefix("#{command_name}: ")
36
+ @stdout.puts(ParentReport.error_line(detail, session_id: @session_id))
37
+ @stdout.flush
38
+ end
39
+ status
40
+ end
41
+
42
+ private
43
+
44
+ # The full id of +given+ (an id or a prefix), kept for the JSON
45
+ # error line; nil after the error line.
46
+ # @return [String, nil]
47
+ def resolve(given)
48
+ id = Session.resolve_id(given, state_dir: @state_dir)
49
+ Session.load(id, state_dir: @state_dir)
50
+ @session_id = id
51
+ rescue ArgumentError => e
52
+ error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
53
+ nil
54
+ end
55
+
56
+ # Wait for the session's next reply past +cursor+ (ReplyWait) and
57
+ # report it. Ctrl-C leaves the turn running (130).
58
+ # @param owner_grace [Numeric, nil] seconds with no live worker before
59
+ # it counts as gone; nil waits for whatever wakes one
60
+ # @return [Integer] the exit status
61
+ def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER)
62
+ result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
63
+ owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL)
64
+ result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
65
+ @reported = true
66
+ ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
67
+ json: @json, timeout: timeout)
68
+ rescue Interrupt
69
+ if @json
70
+ @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id))
71
+ @stdout.flush
72
+ @reported = true
73
+ end
74
+ error_line("#{command_name}: still running: chi --attach #{id}")
75
+ Exit::INTERRUPTED
76
+ rescue ArgumentError
77
+ error_line("#{command_name}: the session is gone (deleted while waiting)")
78
+ Exit::FAILED
79
+ end
80
+ end
81
+ end
82
+ end
@@ -104,10 +104,14 @@ module Samagotchi
104
104
  Entry.new(key: "image.max_bytes", yaml_path: %w[image max_bytes], type: :integer, default: 3_750_000, expose: %i[env config]),
105
105
  Entry.new(key: "image.max_per_request", yaml_path: %w[image max_per_request], type: :integer, default: 20, expose: %i[env config]),
106
106
 
107
- Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[env config]),
107
+ # The guardrails settings are config.yml only: a parent agent must not switch a child's guardrails off from its
108
+ # own environment (SAMAGOTCHI_GUARDRAILS_ENABLED=false). SessionManager.spawn_options unsets them for a worker too.
109
+ Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[config]),
108
110
  # Which models get the rules with `models: small` (Guardrails::ModelSize): auto (32B or less by the name's size, an MoE's
109
111
  # active size counting), globs on the bare model name or its key, "|"-separated (a YAML list works too), or "" ([]) for none.
110
- Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[env config]),
112
+ Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[config]),
113
+ # What `chi answer` lets a parent agent allow on an approval: off (deny only) or once ("Allow once", never a wider scope).
114
+ Entry.new(key: "guardrails.parent_approvals", yaml_path: %w[guardrails parent_approvals], type: :enum, default: "off", expose: %i[config], enum_values: %w[off once]),
111
115
 
112
116
  Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli]),
113
117
  Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli]),
@@ -156,6 +160,8 @@ module Samagotchi
156
160
  Entry.new(key: "execute.truncate_at_bytes", yaml_path: %w[execute truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
157
161
  Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
158
162
  Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
163
+ # Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
164
+ Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 30, expose: %i[env config cli]),
159
165
 
160
166
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
161
167
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
@@ -695,18 +701,7 @@ module Samagotchi
695
701
 
696
702
  def hosts_config(env: ENV, path: global_path(env: env))
697
703
  data = read_yaml(env: env, path: path)
698
- raw_hosts = data[HOSTS_KEY] if data.is_a?(Hash)
699
-
700
- # ENV override: SAMAGOTCHI_HOSTS_JSON (used to propagate to workers)
701
- env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
702
- unless env_json.empty?
703
- begin
704
- parsed_env = JSON.parse(env_json)
705
- raw_hosts = parsed_env if parsed_env.is_a?(Hash)
706
- rescue StandardError
707
- nil
708
- end
709
- end
704
+ raw_hosts = hosts_source(data, env)
710
705
 
711
706
  normalized = {}
712
707
  if raw_hosts.is_a?(Hash)
@@ -726,10 +721,8 @@ module Samagotchi
726
721
  port = raw_cfg["port"] || raw_cfg[:port]
727
722
  transport = raw_cfg["transport"] || raw_cfg[:transport]
728
723
  api = raw_cfg["api"] || raw_cfg[:api]
729
- enabled = raw_cfg.key?("enabled") ? raw_cfg["enabled"] : (raw_cfg.key?(:enabled) ? raw_cfg[:enabled] : true)
730
- if enabled == false || enabled.to_s.strip.downcase == "false"
731
- next
732
- end
724
+ next if host_disabled?(raw_cfg)
725
+
733
726
  url = (raw_cfg["url"] || raw_cfg[:url]).to_s.strip
734
727
  api_key_env = (raw_cfg["api_key_env"] || raw_cfg[:api_key_env]).to_s.strip
735
728
  # Kept as written; ModelProfile.resolve warns about an unknown one.
@@ -822,6 +815,42 @@ module Samagotchi
822
815
  {}
823
816
  end
824
817
 
818
+ # The names (lowercased) of the hosts entries with enabled: false, which
819
+ # hosts_config leaves out. Read from the same source: config.yml's
820
+ # hosts:, or SAMAGOTCHI_HOSTS_JSON in a worker.
821
+ # @return [Array<String>]
822
+ def disabled_host_names(env: ENV, path: global_path(env: env))
823
+ raw = hosts_source(read_yaml(env: env, path: path), env)
824
+ return [] unless raw.is_a?(Hash)
825
+
826
+ raw.filter_map { |name, cfg| name.to_s.strip.downcase if cfg.is_a?(Hash) && host_disabled?(cfg) }
827
+ rescue StandardError
828
+ []
829
+ end
830
+
831
+ # config.yml's hosts: as written, or the SAMAGOTCHI_HOSTS_JSON override
832
+ # a worker inherits from its parent.
833
+ def hosts_source(data, env)
834
+ raw = data[HOSTS_KEY] if data.is_a?(Hash)
835
+ env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
836
+ return raw if env_json.empty?
837
+
838
+ parsed = begin
839
+ JSON.parse(env_json)
840
+ rescue StandardError
841
+ nil
842
+ end
843
+ parsed.is_a?(Hash) ? parsed : raw
844
+ end
845
+ private_class_method :hosts_source
846
+
847
+ # enabled: false (or "false", any case) in a hosts entry.
848
+ def host_disabled?(cfg)
849
+ enabled = cfg.key?("enabled") ? cfg["enabled"] : (cfg.key?(:enabled) ? cfg[:enabled] : true)
850
+ enabled == false || enabled.to_s.strip.downcase == "false"
851
+ end
852
+ private_class_method :host_disabled?
853
+
825
854
  # Resolve the `recap:` section through the Config registry so scalar
826
855
  # recap settings share the single precedence path (CLI > ENV > file >
827
856
  # default). Returns false when explicitly disabled (file `recap: false`
@@ -908,6 +937,9 @@ module Samagotchi
908
937
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
909
938
  "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s).compact
910
939
  end
940
+ # Disabled hosts travel as just that, so a worker refuses "box:x"
941
+ # the way its parent does instead of sending it to the default host.
942
+ disabled_host_names(env: env, path: path).grep(HOST_NAME_RE).each { |name| simple[name] ||= { "enabled" => false } }
911
943
  JSON.generate(simple)
912
944
  rescue StandardError
913
945
  nil
@@ -6,7 +6,8 @@ require_relative "desktop"
6
6
 
7
7
  module Samagotchi
8
8
  # `chi desktop install|upgrade|uninstall|status`: the native helper that
9
- # sends selected text to live sessions as a context note (macOS for now).
9
+ # sends selected text (or images) to sessions, live, stopped or new, as a
10
+ # message or a context note (macOS for now).
10
11
  class DesktopCommand
11
12
  include CLI::Command
12
13
 
@@ -15,8 +16,9 @@ module Samagotchi
15
16
  install [--force] [--login]
16
17
  build "Chi Helper" into ~/Applications (needs the Command Line Tools)
17
18
  and start it: Services > "Send to chi", or ⌃⌥⌘N with the clipboard,
18
- sends text to a live session as a context note. --login: start it
19
- at login too (else it runs until you log out)
19
+ sends text or images to a session (live, stopped or new) as a
20
+ message (⏎) or a context note (⌘⏎). --login: start it at login
21
+ too (else it runs until you log out)
20
22
  upgrade rebuild it for this chi and restart it (chi update does this only
21
23
  when its sources changed)
22
24
  uninstall remove it, its settings and its launch file
@@ -223,8 +223,6 @@ module Samagotchi
223
223
  # memory_read guard reads the same list for every turn.
224
224
  @muted_memory_names = MutedMemories.normalize_list(muted_memories)
225
225
  @kernel.muted_memory_names = @muted_memory_names
226
- # Keep kernel client in sync with active host via setter
227
- @kernel_client_synced = false
228
226
  # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
229
227
  @kernel.question_handler = proc { |payload| request_question(payload) }
230
228
  # Every tool call asks this gate first. The kernel is never rebuilt, so
@@ -967,10 +965,6 @@ module Samagotchi
967
965
  @prompt_builder.preloaded_memory_names
968
966
  end
969
967
 
970
- def memory_muted?(name)
971
- MutedMemories.muted?(name, @muted_memory_names)
972
- end
973
-
974
968
  # ── Guardrails ─────────────────────────────────────────────────────────────
975
969
 
976
970
  # Who can answer an approval: :repl, :worker or :non_interactive (the
@@ -1245,8 +1239,8 @@ module Samagotchi
1245
1239
 
1246
1240
  # Answer the pending question (called from UI thread; QuestionDesk#answer).
1247
1241
  # @return [Hash] normalized answer
1248
- def answer_question(id:, selected:, freeform: nil)
1249
- @question_desk.answer(id: id, selected: selected, freeform: freeform)
1242
+ def answer_question(id:, selected:, freeform: nil, client_id: nil)
1243
+ @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id)
1250
1244
  end
1251
1245
 
1252
1246
  def set_question_sync_handler(&block)
@@ -1412,7 +1406,7 @@ module Samagotchi
1412
1406
  # the prompt is kept in the session and :turn_canceled is emitted, then the
1413
1407
  # Interrupt is re-raised so the caller still decides whether to exit. One
1414
1408
  # after the turn's end event (in its after_turn/session_end hooks) is
1415
- # only re-raised: the turn already ended. Any
1409
+ # only re-raised: the turn already ended; so is any error there. Any
1416
1410
  # other error (e.g. an LLM::ProviderError) emits :turn_failed and
1417
1411
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1418
1412
  # one-line summary:.
@@ -1424,7 +1418,8 @@ module Samagotchi
1424
1418
  # A Stop cuts the /props probes this thread makes for the turn
1425
1419
  # (window, served model, vision) instead of waiting their timeout.
1426
1420
  probe_cancel_before = Client.swap_probe_cancel(turn.controller)
1427
- prepare_turn(turn, continue ? [] : images)
1421
+ return stopped_before_model(turn) unless prepare_turn(turn, continue ? [] : images)
1422
+
1428
1423
  result = generate(turn, max_iterations: max_iterations, max_tool_output_chars: max_tool_output_chars,
1429
1424
  pending_input: pending_input)
1430
1425
  publish_used_memories(session, on_event)
@@ -1441,6 +1436,13 @@ module Samagotchi
1441
1436
  end
1442
1437
  raise
1443
1438
  rescue StandardError => e
1439
+ # After the end event (post-turn work) likewise: logged and
1440
+ # re-raised, no turn_failed over the turn that ended.
1441
+ if turn.ended
1442
+ Log.warn(:turn, "post_turn_error", error: "#{e.class}: #{e.message}")
1443
+ raise
1444
+ end
1445
+
1444
1446
  # Keep what the turn got to (the prompt plus the loop's completed
1445
1447
  # tool iterations) like a cancel does, and save it: a worker exits
1446
1448
  # after a failed turn. The REPL still rolls back to its checkpoint.
@@ -1482,7 +1484,9 @@ module Samagotchi
1482
1484
 
1483
1485
  # Everything before the model is asked: :turn_started, the kernel's
1484
1486
  # per-turn settings, the plugins' setup, the session_start/before_turn
1485
- # hooks, then the messages to send (#turn_messages).
1487
+ # hooks, then the messages to send (#turn_messages). False when a Stop
1488
+ # came before the hooks (the turn ends canceled there; before the
1489
+ # load-failure warnings when it came during the probes), else true.
1486
1490
  def prepare_turn(turn, images)
1487
1491
  session = turn.session
1488
1492
  image_refs, image_error = turn_image_refs(session, images)
@@ -1506,6 +1510,10 @@ module Samagotchi
1506
1510
  # Before anything of the turn is kept or a reminder is used up.
1507
1511
  turn.settings = turn_settings(session)
1508
1512
  refuse_images!(turn.settings.vision) unless image_refs.empty?
1513
+ # A Stop during the probes: the turn ends before the load-failure
1514
+ # warnings, which show once, so the next turn still shows them.
1515
+ return false if turn.controller.cancelled?
1516
+
1509
1517
  announce_guardrail_failures(turn.on_event)
1510
1518
  # Plugins' slow setup that brings tools (an MCP server's first
1511
1519
  # start): the turn waits for it here, before the system prompt
@@ -1515,6 +1523,9 @@ module Samagotchi
1515
1523
  await_init_tasks(turn.controller, turn.on_event)
1516
1524
  # Plugins' tool sets that changed since the last turn.
1517
1525
  apply_staged_tools!
1526
+ # A Stop during the wait above: the turn ends here,
1527
+ # before the hooks run or a reminder is used up.
1528
+ return false if turn.controller.cancelled?
1518
1529
 
1519
1530
  # Fire :session_start on the very first turn
1520
1531
  if @first_turn
@@ -1528,9 +1539,22 @@ module Samagotchi
1528
1539
  messages: hook_messages(session.messages) })
1529
1540
 
1530
1541
  turn_messages(turn, image_refs)
1542
+ true
1531
1543
  end
1532
1544
  private :prepare_turn
1533
1545
 
1546
+ # A turn stopped before the model was asked (#prepare_turn returned
1547
+ # false): canceled with nothing kept, like a Ctrl-C there; the result
1548
+ # has no conversation, so a UI's checkpoint stands.
1549
+ def stopped_before_model(turn)
1550
+ reason = turn.controller.reason
1551
+ end_turn(turn, "canceled") do |seconds|
1552
+ [nil, { type: :turn_canceled, cancellation_reason: reason, duration_ms: (seconds * 1000).round }]
1553
+ end
1554
+ LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
1555
+ end
1556
+ private :stopped_before_model
1557
+
1534
1558
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
1535
1559
  # sampling and thinking, the thinking resolved before the hooks run.
1536
1560
  # The model name is added in #generate, after the hooks that may switch
@@ -1640,7 +1664,8 @@ module Samagotchi
1640
1664
  end
1641
1665
  end
1642
1666
 
1643
- # Fire :after_turn hook (runs even on cancel/success), with a read-only
1667
+ # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
1668
+ # ends the turn in #run_turn without it), with a read-only
1644
1669
  # copy of the conversation the turn stored, and event[:present] for
1645
1670
  # a display version of the answer (AnswerDisplay).
1646
1671
  session = turn.session
@@ -1651,7 +1676,7 @@ module Samagotchi
1651
1676
  @hooks.fire(:after_turn, after_turn)
1652
1677
  store_answer_display(session, display, pending: display_pending)
1653
1678
 
1654
- # Fire :session_end after every turn (turn-level lifecycle)
1679
+ # Fire :session_end after the turn (turn-level lifecycle), as :after_turn
1655
1680
  @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
1656
1681
  end
1657
1682
  private :complete_turn
@@ -1973,7 +1998,8 @@ module Samagotchi
1973
1998
  end
1974
1999
  begin
1975
2000
  Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
1976
- failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {})
2001
+ failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
2002
+ requires_chi: data[:requires_chi])
1977
2003
  rescue Exception => e
1978
2004
  Log.error(:hooks, "bundle_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' failed to load hooks: #{e.class}: #{e.message}", bundle: bundle_name, error: e.class.name)
1979
2005
  end