samagotchi 0.101.0 → 0.102.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/bin/chi +13 -10
  4. data/docs/architecture.md +8 -6
  5. data/docs/cli.md +47 -7
  6. data/docs/configuration.md +25 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +86 -11
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +7 -0
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +89 -53
  24. data/lib/samagotchi/engine.rb +131 -12
  25. data/lib/samagotchi/idle_scheduler.rb +16 -1
  26. data/lib/samagotchi/llm_context_edit.rb +32 -10
  27. data/lib/samagotchi/llm_context_forget.rb +12 -3
  28. data/lib/samagotchi/llm_context_view.rb +19 -7
  29. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  30. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  31. data/lib/samagotchi/message_parts.rb +8 -3
  32. data/lib/samagotchi/plugin/context.rb +2 -2
  33. data/lib/samagotchi/prompt.rb +1 -1
  34. data/lib/samagotchi/recap_command.rb +68 -0
  35. data/lib/samagotchi/recap_setup.rb +27 -14
  36. data/lib/samagotchi/send_command.rb +14 -11
  37. data/lib/samagotchi/session.rb +19 -12
  38. data/lib/samagotchi/session_commands.rb +44 -2
  39. data/lib/samagotchi/session_config.rb +411 -0
  40. data/lib/samagotchi/session_setup.rb +9 -9
  41. data/lib/samagotchi/sessions_command.rb +6 -2
  42. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  44. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  45. data/lib/samagotchi/terminal_ui.rb +8 -6
  46. data/lib/samagotchi/testing/bundle.rb +3 -2
  47. data/lib/samagotchi/version.rb +1 -1
  48. data/lib/samagotchi/web/app.rb +30 -13
  49. data/lib/samagotchi/web/public/app.js +2 -0
  50. data/lib/samagotchi/web/public/commands.js +8 -2
  51. data/lib/samagotchi/web/public/data.js +3 -2
  52. data/lib/samagotchi/web/public/index.html +20 -2
  53. data/lib/samagotchi/web/public/stream_handlers.js +4 -0
  54. data/lib/samagotchi/web/public/turn_html.js +5 -0
  55. data/lib/samagotchi/web/public/working_set.js +113 -21
  56. data/lib/samagotchi/web/public/working_set_view.js +136 -21
  57. data/lib/samagotchi/worker.rb +9 -6
  58. data/lib/samagotchi/working_set.rb +49 -11
  59. data/lib/samagotchi/working_set_command.rb +119 -0
  60. data/lib/samagotchi/working_set_steer.rb +250 -0
  61. metadata +5 -2
  62. data/lib/samagotchi/bundle_session_settings.rb +0 -251
data/docs/sessions.md CHANGED
@@ -151,6 +151,13 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
151
151
  nothing). The user's last message is still unanswered.]`, or `[SYSTEM: the previous turn's answer was refused by
152
152
  the provider (cyber). …]` when the provider refused it.
153
153
 
154
+ After you drop or undrop an item of the working set between turns (`/working-set drop KEY`, the web list's
155
+ `drop`; [cli.md](cli.md#the-working-set)), the next turn starts with one line about it (`kind: working_set`,
156
+ escaped like a context note; the UIs don't show it): `[WORKING SET: since your last turn the user dropped
157
+ lib/cart.rb from your context: you are sent a stub instead of its outputs; read it again only if the task needs
158
+ it.]`. Every op since the last turn joins that one line, and an undrop before the next turn takes its drop off it.
159
+ The drop itself is saved on the entries (`edits`, kind `drop`), so `--resume`, rollbacks and forks keep it.
160
+
154
161
  When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
155
162
  `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
156
163
  and range reads over full-file dumps]`, once per rise; see [context telemetry](internals/context-telemetry.md).
@@ -211,9 +218,9 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
211
218
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
212
219
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `removed` (a `--queue --wait` message taken back before it ran), `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` (`worker_stopped`: the turn waited for died with its worker and the next worker settled it) and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
213
220
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
214
- - `--set BUNDLE.KEY=VALUE` (with `--new`, repeatable) gives the new session a bundle setting of its own, as `chi --set` does (see *Bundle settings* below): `chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap. A bundle or key the installed bundles don't offer, or a value of another type than the manifest gives the key (`cost-cap.cap=five`), is a usage error (exit 2), as is `--set` without `--new`.
221
+ - `--set KEY=VALUE` (with `--new`, repeatable) gives the new session a setting of its own, as `chi --set` does (see *Session settings* below): a core key a session may set (`--set recap.enabled=false`) or a bundle's `BUNDLE.KEY` (`chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap). A key no session sets (`guardrails.mode`), a bundle or key the installed bundles don't offer, or a value of another type than the key takes (`recap.enabled=maybe`, `cost-cap.cap=five`) is a usage error (exit 2), as is `--set` without `--new`.
215
222
  - `--parent-answers LEVEL` (with `--new`) gives the new session its own `guardrails.parent_answers` (`hard`, `standard`, `soft`), tighten-only: config.yml's applies when it is stricter, with `chi send: warning: --parent-answers soft is looser than config.yml's …`. See *Parent answers* below.
216
- - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit, bundle settings and parent answers, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool`, `--set` and `--parent-answers` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
223
+ - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit, session settings and parent answers, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool`, `--set` and `--parent-answers` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
217
224
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
218
225
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
219
226
 
@@ -274,15 +281,25 @@ fork keep it, and a `delegate` child starts with its parent's as it is then (a l
274
281
  it). A value that isn't a level reads as `hard`. `/parent-answers` changes it; loosening it is yours alone (see
275
282
  [Guardrails](guardrails.md#answers-from-a-parent-agent)).
276
283
 
277
- ## Bundle settings
278
-
279
- A session file's `bundle_settings` (`{"cost-cap": {"cap": 3}}`, `{}` for none and for a session from before them)
280
- holds the bundle settings it was created with: `chi --set BUNDLE.KEY=VALUE`, `chi send --new --set …`, or `POST
281
- /api/sessions` `bundle_settings` (an object of the same shape, checked the same way; `400 invalid_bundle_settings`
282
- otherwise). They never change after: a respawned or woken worker reads the same, and a continue (`continue →`,
283
- `chi send --new --continues`) and a fork (`ctx.sessions.fork`) copy them, as they copy the toolkit. A delegate
284
- child starts without (a bundle has its own setting for children, such as cost-cap's `child:`). A bundle's plugin
285
- reads them in `ctx.settings`, over config.yml's `bundles:` ([Per-session settings](plugins.md#per-session-settings)).
284
+ ## Session settings
285
+
286
+ A session's own settings are what `chi --set`, `chi send --new --set …` or `POST /api/sessions` `settings` gave it
287
+ at creation. One changes live: `/recap on|off|default` (any UI, or `chi send -m "/recap off" ID`) sets or removes
288
+ `recap.enabled` and the worker saves it, so a respawn keeps it. The file keeps them in two fields:
289
+
290
+ - `config_settings` (`{"recap.enabled": false}`): the core keys a session may set (`recap.enabled`,
291
+ `recap.sentences`; see [Per-session settings](configuration.md#per-session-settings)), typed by their key. They go
292
+ first, over the CLI, the environment and config.yml. A value the file holds for a key that is no longer settable, or
293
+ of another type, is dropped when the file is read.
294
+ - `bundle_settings` (`{"cost-cap": {"cap": 3}}`): a bundle's settings, which its plugin reads in `ctx.settings`, over
295
+ config.yml's `bundles:` ([Per-session settings](plugins.md#per-session-settings)).
296
+
297
+ Both are `{}` for none and for a session from before them. `POST /api/sessions` takes them flat, as `--set` names
298
+ them (`"settings": {"recap.enabled": false, "cost-cap.cap": 3}`), checked the same way (`400 invalid_settings`
299
+ otherwise; `settings` beside `continues` is refused). A respawned or woken worker reads the same, and a continue
300
+ (`continue →`, `chi send --new --continues`) and a fork (`ctx.sessions.fork`) copy them, as they copy the toolkit. A
301
+ delegate child starts without (a bundle has its own setting for children, such as cost-cap's `child:`), so its recap
302
+ follows config.yml. `/stats` names them on a `settings:` line, and `chi sessions stats --format json` as `settings`.
286
303
 
287
304
  ## Delegating
288
305
 
data/docs/testing.md CHANGED
@@ -23,6 +23,13 @@ style pass: add a rule only for a bug class it would have caught. It runs in abo
23
23
  `rake release:check`; `rake lint` stays RuboCop alone (CI's rubocop job has no node). A name that is also a browser
24
24
  global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
25
25
 
26
+ CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
27
+ (`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
28
+ to it with `PW_TEST_CONNECT_WS_ENDPOINT`, and traces and the report stay on the runner. Nothing is installed with
29
+ apt. The tag is the installed `@playwright/test`'s version, read at run time, so bumping it in `package-lock.json`
30
+ moves the image too (`spec/ci_playwright_image_spec.rb` keeps a pinned tag out of the workflow). To run the suite
31
+ that way locally: `npx playwright run-server --port 3333`, then `PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3333/ npm run e2e`.
32
+
26
33
  No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
27
34
  clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
28
35
 
@@ -35,6 +35,7 @@ module Samagotchi
35
35
  @merged_origins = []
36
36
  @recap = nil
37
37
  @continue_offer = nil
38
+ @working_set = nil
38
39
  end
39
40
 
40
41
  def call(event)
@@ -192,6 +193,17 @@ module Samagotchi
192
193
  end
193
194
  private_class_method :with_images
194
195
 
196
+ # The running turn's working set: its last :working_set_updated
197
+ # ({items:, at:}, Engine#emit_working_set). The session's kept messages
198
+ # lag the running turn, so this is what a mid-turn read of the set
199
+ # answers (Web::App#handle_working_set).
200
+ # @return [Hash, nil] a copy; nil with no turn running, or before the
201
+ # turn emitted one (its set is then the session's: the Engine emits
202
+ # only a set that differs from the last one)
203
+ def working_set
204
+ @mutex.synchronize { @working_set && Marshal.load(Marshal.dump(@working_set)) }
205
+ end
206
+
195
207
  # @return [String, nil] the last idle recap, until a turn makes it stale
196
208
  def recap
197
209
  @mutex.synchronize { @recap }
@@ -249,6 +261,7 @@ module Samagotchi
249
261
  parts: [], pending_question: nil }
250
262
  @turn[:resumed] = true if event[:resumed]
251
263
  @tool_started_at = {}
264
+ @working_set = nil
252
265
  @turn[:images] = event[:images] if event[:images]
253
266
  @turn[:cut_after] = event[:cut_after] if event[:cut_after]
254
267
  when :input_merged
@@ -261,6 +274,7 @@ module Samagotchi
261
274
  if Events::TURN_END.include?(type)
262
275
  @turn = nil
263
276
  @merged_origins = []
277
+ @working_set = nil
264
278
  elsif event[:event_seq]
265
279
  @turn[:event_seq] = event[:event_seq]
266
280
  end
@@ -321,6 +335,8 @@ module Samagotchi
321
335
  Array(event[:steers]).each do |steer|
322
336
  parts << { kind: "steer", iteration: event[:iteration], source: steer[:source].to_s, text: steer[:text].to_s.dup }
323
337
  end
338
+ when :working_set_updated
339
+ @working_set = { items: event[:items], at: event[:at] }
324
340
  when :reminder_injected
325
341
  parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
326
342
  when :question_requested
@@ -246,7 +246,10 @@ module Samagotchi
246
246
  # with its own).
247
247
  # The commands queued for after the running turn (queued_commands,
248
248
  # TurnAccumulator#queued_commands) too.
249
- # @return [Hash] {messages:, current_turn:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
249
+ # working_set: the running turn's last :working_set_updated ({items:,
250
+ # at:}, TurnAccumulator#working_set), which the messages lag; nil
251
+ # between turns and before the turn emitted one.
252
+ # @return [Hash] {messages:, current_turn:, working_set:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
250
253
  # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:}
251
254
  def snapshot
252
255
  @engine.synchronize_events do
@@ -254,6 +257,7 @@ module Samagotchi
254
257
  {
255
258
  messages: @engine.messages_checkpoint,
256
259
  current_turn: current_turn_with_generation,
260
+ working_set: @accumulator.working_set,
257
261
  queued: @accumulator.queued,
258
262
  queued_commands: @accumulator.queued_commands,
259
263
  recap: @accumulator.recap,
@@ -571,10 +575,12 @@ module Samagotchi
571
575
 
572
576
  # /recap in an attached TUI: the saved recap, and a new one asked for at
573
577
  # once (it arrives as :recap_ready). Answers mid-turn too.
574
- # 200 {enabled:, saved:, request:, min_user_turns:}. Returns [headers, status, body].
578
+ # 200 {enabled:, saved:, request:, min_user_turns:}; off, session_off:
579
+ # whether the session's own setting (/recap off) turned it off.
580
+ # Returns [headers, status, body].
575
581
  def handle_recap(_session_id, _body = nil)
576
582
  recap = @engine.recap
577
- return [{}, 200, { enabled: false }] unless recap
583
+ return [{}, 200, { enabled: false, session_off: @engine.recap_override == false }] unless recap
578
584
 
579
585
  saved = @engine.saved_recap
580
586
  [{}, 200, { enabled: true, saved: saved, request: @engine.request_recap.to_s, min_user_turns: recap.min_user_turns }]
@@ -78,7 +78,7 @@ module Samagotchi
78
78
  check_requires_chi(manifest)
79
79
  check_trust(manifest)
80
80
  check_clash(manifest)
81
- check_install(manifest) unless missing || placeholders?
81
+ check_install(manifest) unless missing || placeholders? || manifest.session_settings_clash
82
82
  check_boundary(manifest)
83
83
  check_tests
84
84
  end
@@ -245,6 +245,10 @@ module Samagotchi
245
245
  # Before the sandbox: the user's installed bundles and chi's shipped ones.
246
246
  def check_clash(manifest)
247
247
  name = manifest.name
248
+ # A session setting --set would read as chi's own: the install refuses it.
249
+ if (clash = manifest.session_settings_clash)
250
+ add(:error, :clash, "manifest.yml", clash, line: manifest_line("session_settings"))
251
+ end
248
252
  shipped = MemoryBundle::Listing.shipped.map(&:name) + [MemoryBundle::SystemBundle::BUNDLE_NAME]
249
253
  if shipped.include?(name) && !shipped?
250
254
  add(:warn, :clash, "manifest.yml", "chi ships a bundle named #{name}; installing this one replaces chi's",
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "../../cli/exit"
4
4
  require_relative "../../cli/flags"
5
- require_relative "../../bundle_session_settings"
5
+ require_relative "../../session_config"
6
6
  require_relative "../../thinking"
7
7
  require_relative "sandbox"
8
8
  require_relative "run"
@@ -73,7 +73,7 @@ module Samagotchi
73
73
  render = Render.new(stdout: @stdout, stderr: @stderr)
74
74
  sandbox = nil
75
75
  begin
76
- sandbox = Sandbox.new(path, bundle_settings: settings.bundle, cost_cap: settings.cost_cap,
76
+ sandbox = Sandbox.new(path, settings: settings.bundle, cost_cap: settings.cost_cap,
77
77
  cost_cap_override: settings.cost_cap_override)
78
78
  sandbox.install
79
79
  result = Run.new(sandbox).run(prompt: options[:prompt], options: options, timeout: timeout)
@@ -133,7 +133,7 @@ module Samagotchi
133
133
  nil
134
134
  end
135
135
 
136
- # The --set KEY=VALUE pairs, typed as BundleSessionSettings.typed:
136
+ # The --set KEY=VALUE pairs, typed as SessionConfig.typed:
137
137
  # cost-cap.cap is the run's cap, the others the tried bundle's.
138
138
  # @return [Settings, nil] nil after saying what's wrong
139
139
  def parse_settings(pairs)
@@ -143,18 +143,18 @@ module Samagotchi
143
143
  pairs.each do |pair|
144
144
  key, sep, value = pair.partition("=")
145
145
  if key == COST_CAP_KEY && !sep.empty?
146
- cost_cap = BundleSessionSettings.typed(value)
146
+ cost_cap = SessionConfig.typed(value)
147
147
  override = true
148
148
  next if (cost_cap.is_a?(Numeric) && cost_cap >= 0) || cost_cap == false
149
149
 
150
150
  @stderr.puts "chi bundle try: --set #{COST_CAP_KEY} takes a non-negative number or off"
151
151
  return nil
152
152
  end
153
- unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
153
+ unless !sep.empty? && key.match?(SessionConfig::KEY)
154
154
  @stderr.puts "chi bundle try: --set takes KEY=VALUE, not #{pair}"
155
155
  return nil
156
156
  end
157
- bundle[key] = BundleSessionSettings.typed(value)
157
+ bundle[key] = SessionConfig.typed(value)
158
158
  end
159
159
  Settings.new(bundle: bundle, cost_cap: cost_cap, cost_cap_override: override)
160
160
  end
@@ -83,9 +83,9 @@ module Samagotchi
83
83
 
84
84
  attr_reader :root, :config_home, :state_home, :work_dir
85
85
 
86
- def initialize(path, bundle_settings: {}, cost_cap: 1, cost_cap_override: false)
86
+ def initialize(path, settings: {}, cost_cap: 1, cost_cap_override: false)
87
87
  @path = File.realpath(path)
88
- @bundle_settings = bundle_settings
88
+ @settings = settings
89
89
  @cost_cap = cost_cap
90
90
  @cost_cap_override = cost_cap_override
91
91
  read_manifest
@@ -153,7 +153,7 @@ module Samagotchi
153
153
  data["context"]&.delete("sources")
154
154
  data["bundles"] = { "cost-cap" => { "cap" => @cost_cap } }
155
155
  data["bundles"][@manifest.name] ||= {}
156
- data["bundles"][@manifest.name].merge!(@bundle_settings)
156
+ data["bundles"][@manifest.name].merge!(@settings)
157
157
  data["bundles"]["cost-cap"]["cap"] = @cost_cap if @cost_cap_override
158
158
  @layout.write_config(data)
159
159
  end
@@ -863,17 +863,17 @@ module Samagotchi
863
863
  Try::Command.new(stdout: @stdout, stderr: @stderr).run(parsed)
864
864
  end
865
865
 
866
- # --set KEY=VALUE values as typed (BundleSessionSettings.typed), or nil
866
+ # --set KEY=VALUE values as typed (SessionConfig.typed), or nil
867
867
  # after saying what's wrong.
868
868
  def check_settings(pairs, label:)
869
- require_relative "bundle_session_settings"
869
+ require_relative "session_config"
870
870
  pairs.each_with_object({}) do |pair, out|
871
871
  key, sep, value = pair.partition("=")
872
- unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
872
+ unless !sep.empty? && key.match?(SessionConfig::KEY)
873
873
  @stderr.puts "chi bundle #{label}: --set takes KEY=VALUE, not #{pair}"
874
874
  return nil
875
875
  end
876
- out[key] = BundleSessionSettings.typed(value)
876
+ out[key] = SessionConfig.typed(value)
877
877
  end
878
878
  end
879
879
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.101.0
3
+ version: 0.102.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:
@@ -96,6 +96,10 @@ module Samagotchi
96
96
  end
97
97
  private_class_method :listed_mid_turn
98
98
 
99
+ # A name is taken once, with one exception: core may give a name two
100
+ # entries, one the UIs run (local) and one the session runs, each with
101
+ # its own match (/recap shows in a UI, /recap off runs in the worker).
102
+ # #listing then has the name twice, /help and Tab once.
99
103
  # @param anytime [Boolean] mid_turn: :anytime (a bundle's command)
100
104
  # @param mid_turn [Symbol, #call, nil] see Entry; nil takes +anytime+
101
105
  # @param match [#call, nil] the default matches the name alone or the
@@ -104,7 +108,7 @@ module Samagotchi
104
108
  # @return [Entry]
105
109
  def register(name, description, id: nil, anytime: false, mid_turn: nil, local: false, uis: nil, match: nil,
106
110
  source: "core", annotations: [], &handler)
107
- raise ArgumentError, "command #{name} is already registered" if @entries.any? { |entry| entry.name == name }
111
+ raise ArgumentError, "command #{name} is already registered" unless name_free?(name, local: local, source: source)
108
112
 
109
113
  entry = Entry.new(id: id || name.delete_prefix("/").to_sym, name: name, description: description,
110
114
  mid_turn: mid_turn || (anytime ? :anytime : :refuse), local: local, uis: uis, match: match || default_match(name),
@@ -203,6 +207,13 @@ module Samagotchi
203
207
  @entries << entry
204
208
  end
205
209
 
210
+ def name_free?(name, local:, source:)
211
+ taken = @entries.select { |entry| entry.name == name }
212
+ return true if taken.empty?
213
+
214
+ source == "core" && taken.size == 1 && taken.first.source == "core" && !taken.first.local != !local
215
+ end
216
+
206
217
  def default_match(name)
207
218
  after = /\A#{Regexp.escape(name)}\s/
208
219
  ->(text) { text == name || after.match?(text) }
@@ -22,6 +22,10 @@ module Samagotchi
22
22
  autoload :ModelSetting, File.expand_path("model_settings", __dir__)
23
23
  # The per-model/host seconds a cut waits (like Thinking for the level).
24
24
  autoload :SteerCutAfter, File.expand_path("steer_cut_after", __dir__)
25
+ # A refused --set (Config.coerce! raises its Error), and recap.sentences'
26
+ # check (IdleRecap::RecapPrompt): both load on first use.
27
+ autoload :SessionConfig, File.expand_path("session_config", __dir__)
28
+ autoload :IdleRecap, File.expand_path("idle_recap", __dir__)
25
29
 
26
30
  # Unified configuration registry implementing the implicit convention:
27
31
  # ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
@@ -39,8 +43,12 @@ module Samagotchi
39
43
  module Config
40
44
  # invalid: what an enum's invalid value falls to, when not the default
41
45
  # (guardrails.parent_answers: the strictest).
46
+ # session: true for a key a session may set for itself (chi --set KEY=VALUE,
47
+ # SessionConfig; its value is read through Config.get(key, session:)),
48
+ # check: a lambda that takes a typed value and returns nil, or the
49
+ # "takes ..." phrase a refused --set names (recap.sentences' N or N-M).
42
50
  Entry = Struct.new(:key, :yaml_path, :type, :default, :expose, :enum_values, :yaml_aliases, :lifecycle, :invalid,
43
- keyword_init: true) do
51
+ :session, :check, keyword_init: true) do
44
52
  def env_key
45
53
  "SAMAGOTCHI_" + yaml_path.map { |p| p.upcase }.join("_")
46
54
  end
@@ -64,6 +72,9 @@ module Samagotchi
64
72
  def config_exposed?
65
73
  expose.include?(:config)
66
74
  end
75
+
76
+ # Whether a session may set the key for itself (chi --set).
77
+ def session? = session == true
67
78
  end
68
79
 
69
80
  # Section names: lower alnum only, no _ or -.
@@ -93,7 +104,9 @@ module Samagotchi
93
104
  # off on local ones. hosts.<name>.first_token_timeout wins.
94
105
  Entry.new(key: "server.first_token_timeout", yaml_path: %w[server first_token_timeout], type: :integer, default: nil, expose: %i[env config], lifecycle: :worker_start),
95
106
 
96
- Entry.new(key: "recap.enabled", yaml_path: %w[recap enabled], type: :bool, default: nil, expose: %i[env config], lifecycle: :worker_start),
107
+ # Both session-settable (chi --set recap.enabled=false): RecapSetup reads them through the session's layer.
108
+ Entry.new(key: "recap.enabled", yaml_path: %w[recap enabled], type: :bool, default: nil, expose: %i[env config], lifecycle: :worker_start,
109
+ session: true),
97
110
  Entry.new(key: "recap.model", yaml_path: %w[recap model], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
98
111
  Entry.new(key: "recap.base_url", yaml_path: %w[recap base_url], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
99
112
  Entry.new(key: "recap.host_ref", yaml_path: %w[recap host_ref], type: :string, default: nil, expose: %i[env config cli], yaml_aliases: %w[host], lifecycle: :worker_start),
@@ -101,7 +114,9 @@ module Samagotchi
101
114
  Entry.new(key: "recap.timeout", yaml_path: %w[recap timeout], type: :float, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
102
115
  Entry.new(key: "recap.min_user_turns", yaml_path: %w[recap min_user_turns], type: :integer, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
103
116
  # "N-M" or "N" sentences (1-10); unset = 2-4. Parsed by RecapPrompt.sentences_range.
104
- Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start),
117
+ Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli], lifecycle: :worker_start,
118
+ session: true,
119
+ check: ->(value) { "N or N-M (1-#{IdleRecap::RecapPrompt::MAX_SENTENCES})" unless IdleRecap::RecapPrompt.sentences_range(value) }),
105
120
 
106
121
  # chi broadcast (Broadcast::Recipients, Broadcast::Tags): a session that ended no turn in this many hours (and
107
122
  # no worker or REPL runs) gets no broadcast; a ticket id in the note and in a session's branch or prompts
@@ -308,7 +323,7 @@ module Samagotchi
308
323
 
309
324
  # Top-level maps and lists read by their own code, whose entry names and
310
325
  # contents are the user's: ConfigFile.model_aliases / preloaded_memories /
311
- # appended_prompt_files, Hooks::Loader, Engine#read_bundle_settings.
326
+ # appended_prompt_files, Hooks::Loader, ConfigFile.bundle_settings.
312
327
  FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
313
328
  # Maps of named entries and the keys an entry may hold
314
329
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
@@ -324,6 +339,10 @@ module Samagotchi
324
339
  # And toolkit's kits (Toolkit.kits).
325
340
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze, "toolkit" => %w[kits].freeze }.freeze
326
341
 
342
+ # What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
343
+ BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
344
+ "0" => false, "false" => false, "no" => false, "off" => false }.freeze
345
+
327
346
  # Fast lookup maps
328
347
  BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
329
348
 
@@ -342,58 +361,70 @@ module Samagotchi
342
361
  ENTRIES.select(&:cli_exposed?)
343
362
  end
344
363
 
345
- # Coercion helpers
346
- def coerce(entry, raw)
364
+ # The entries a session may set for itself (Entry#session?).
365
+ def session_entries
366
+ ENTRIES.select(&:session?)
367
+ end
368
+
369
+ # +raw+ as +entry+'s type, strictly: a value of another shape raises.
370
+ # :string as given (a YAML list joined with |), :integer, :float,
371
+ # :bool (true/yes/on/1, false/no/off/0/""), :enum in its canonical
372
+ # case; nil for nil, and for a blank value of any other type than
373
+ # :string and :bool. #coerce is its lenient twin (config.yml, env).
374
+ # @raise [SessionConfig::Error] "KEY takes WHAT, not RAW"
375
+ def coerce!(entry, raw)
347
376
  return nil if raw.nil?
377
+ # A YAML list for a "|"-separated setting (web.annotate_presets).
378
+ # As given, trailing spaces included ("Please ").
379
+ return raw.is_a?(Array) ? raw.join("|") : raw.to_s if entry.type == :string
348
380
 
349
- # For string, preserve as-is (including trailing spaces like "Please ")
350
- if entry.type == :string
351
- # A YAML list for a "|"-separated setting (web.annotate_presets).
352
- str = raw.is_a?(Array) ? raw.join("|") : raw.to_s
353
- return nil if str.empty? && entry.type != :string
381
+ str = raw.to_s.strip
382
+ return false if entry.type == :bool && str.empty?
383
+ return nil if str.empty?
384
+
385
+ value = case entry.type
386
+ when :integer then Integer(str, exception: false)
387
+ when :float then Float(str, exception: false)
388
+ when :bool then BOOL_WORDS[str.downcase]
389
+ when :enum then entry.enum_values.find { |v| v.downcase == str.downcase }
390
+ else str
391
+ end
392
+ raise SessionConfig::Error, "#{entry.key} takes #{takes(entry)}, not #{raw.to_s.inspect}" if value.nil?
393
+
394
+ value
395
+ end
354
396
 
355
- return str
397
+ # What +entry+'s type takes, as a refusal names it.
398
+ def takes(entry)
399
+ case entry.type
400
+ when :integer then "an integer"
401
+ when :float then "a number"
402
+ when :bool then "true or false"
403
+ when :enum then "#{entry.enum_values[0..-2].join(", ")} or #{entry.enum_values.last}"
404
+ else "text"
356
405
  end
357
- str = raw.to_s.strip
358
- return nil if str.empty? && entry.type != :string
406
+ end
359
407
 
408
+ # +raw+ as +entry+'s type (#coerce!), leniently: a value of another
409
+ # shape warns and falls back (the default; false for a bool; an
410
+ # enum's +invalid+ when it has one).
411
+ def coerce(entry, raw)
412
+ coerce!(entry, raw)
413
+ rescue SessionConfig::Error
360
414
  case entry.type
361
- when :string
362
- str
363
415
  when :integer
364
- Integer(str, exception: false).tap do |v|
365
- if v.nil?
366
- Log.warn(:config, "invalid_value", echo: "Warning: invalid integer for #{entry.key} (#{entry.env_key}): #{raw.inspect} — using default", key: entry.key)
367
- return entry.default
368
- end
369
- end
416
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid integer for #{entry.key} (#{entry.env_key}): #{raw.inspect} — using default", key: entry.key)
417
+ entry.default
370
418
  when :float
371
- val = Float(str, exception: false)
372
- if val.nil?
373
- Log.warn(:config, "invalid_value", echo: "Warning: invalid float for #{entry.key}: #{raw.inspect}", key: entry.key)
374
- return entry.default
375
- end
376
- val
419
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid float for #{entry.key}: #{raw.inspect}", key: entry.key)
420
+ entry.default
377
421
  when :bool
378
- case str.downcase
379
- when "1", "true", "yes", "on" then true
380
- when "0", "false", "no", "off", "" then false
381
- else
382
- Log.warn(:config, "invalid_value", echo: "Warning: invalid bool for #{entry.key}: #{raw.inspect} — treating as false", key: entry.key)
383
- false
384
- end
385
- when :enum
386
- lowered = str.downcase
387
- allowed = entry.enum_values.map(&:downcase)
388
- unless allowed.include?(lowered)
389
- using = entry.invalid ? "using #{entry.invalid}" : "using default"
390
- Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(", ")}) — #{using}", key: entry.key)
391
- return entry.invalid || entry.default
392
- end
393
- # return canonical casing from enum_values
394
- entry.enum_values.find { |v| v.downcase == lowered }
422
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid bool for #{entry.key}: #{raw.inspect} — treating as false", key: entry.key)
423
+ false
395
424
  else
396
- str
425
+ using = entry.invalid ? "using #{entry.invalid}" : "using default"
426
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(", ")}) — #{using}", key: entry.key)
427
+ entry.invalid || entry.default
397
428
  end
398
429
  end
399
430
 
@@ -406,10 +437,14 @@ module Samagotchi
406
437
  end
407
438
 
408
439
  # Same as #resolve, plus the layer the value came from:
409
- # :cli, :env, :file or :default.
410
- def resolve_with_origin(key, file_data: nil, env: ENV, cli_overrides: {})
440
+ # :session, :cli, :env, :file or :default. +session+ is a session's
441
+ # own values (SessionConfig::Settings#core, typed): a session-settable
442
+ # key's value there goes first, over the CLI (a worker gets its CLI
443
+ # as env, and the session's value was given for this session).
444
+ def resolve_with_origin(key, file_data: nil, env: ENV, cli_overrides: {}, session: nil)
411
445
  entry = find_by_key(key)
412
446
  raise ArgumentError, "unknown config key: #{key}" unless entry
447
+ return [session[entry.key], :session] if entry.session? && session&.key?(entry.key)
413
448
 
414
449
  # CLI wins
415
450
  if cli_overrides.key?(entry.key)
@@ -523,8 +558,9 @@ module Samagotchi
523
558
  end
524
559
  end
525
560
 
526
- def get(key)
527
- get_with_origin(key).first
561
+ # The live value of +key+; +session+ as #resolve_with_origin takes it.
562
+ def get(key, session: nil)
563
+ get_with_origin(key, session: session).first
528
564
  end
529
565
 
530
566
  # default.max_tokens: the most tokens one generation may produce, or
@@ -545,8 +581,8 @@ module Samagotchi
545
581
  end
546
582
 
547
583
  # [value, origin] for the live value #get returns; origin is one of
548
- # :cli, :env, :file, :default.
549
- def get_with_origin(key)
584
+ # :session, :cli, :env, :file, :default.
585
+ def get_with_origin(key, session: nil)
550
586
  entry = find_by_key(key)
551
587
  raise ArgumentError, "unknown config key: #{key}" unless entry
552
588
 
@@ -555,7 +591,7 @@ module Samagotchi
555
591
  # overrides captured via reload!
556
592
  path = Samagotchi::ConfigFile.global_path rescue nil
557
593
  file_data = Samagotchi::ConfigFile.read_yaml(path: path) if path
558
- resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
594
+ resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides, session: session)
559
595
  end
560
596
 
561
597
  # Problems with the keys of a parsed config.yml, one message per key the