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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/bin/chi +13 -10
- data/docs/architecture.md +8 -6
- data/docs/cli.md +47 -7
- data/docs/configuration.md +25 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +86 -11
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +7 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/engine.rb +131 -12
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -13
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -2
- data/lib/samagotchi/web/public/stream_handlers.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +113 -21
- data/lib/samagotchi/web/public/working_set_view.js +136 -21
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +49 -11
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +5 -2
- 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
|
|
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,
|
|
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
|
-
##
|
|
278
|
-
|
|
279
|
-
A session
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
-
#
|
|
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:}
|
|
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 "../../
|
|
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,
|
|
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
|
|
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 =
|
|
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?(
|
|
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] =
|
|
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,
|
|
86
|
+
def initialize(path, settings: {}, cost_cap: 1, cost_cap_override: false)
|
|
87
87
|
@path = File.realpath(path)
|
|
88
|
-
@
|
|
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!(@
|
|
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 (
|
|
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 "
|
|
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?(
|
|
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] =
|
|
876
|
+
out[key] = SessionConfig.typed(value)
|
|
877
877
|
end
|
|
878
878
|
end
|
|
879
879
|
|
|
@@ -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"
|
|
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) }
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
#
|
|
346
|
-
def
|
|
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
|
-
|
|
350
|
-
if entry.type == :
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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
|