samagotchi 0.18.0 → 0.19.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -1
  3. data/README.md +4 -3
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +15 -2
  6. data/docs/cli.md +41 -9
  7. data/docs/configuration.md +8 -4
  8. data/docs/guardrails.md +8 -0
  9. data/docs/memory.md +4 -0
  10. data/docs/sessions.md +6 -4
  11. data/lib/samagotchi/bridge.rb +25 -9
  12. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  13. data/lib/samagotchi/bridge_client.rb +9 -5
  14. data/lib/samagotchi/bundle_command.rb +2 -1
  15. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  16. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  19. data/lib/samagotchi/client.rb +58 -10
  20. data/lib/samagotchi/engine.rb +35 -11
  21. data/lib/samagotchi/guardrail_wiring.rb +64 -5
  22. data/lib/samagotchi/guardrails/approval.rb +10 -4
  23. data/lib/samagotchi/guardrails/approvals.rb +60 -12
  24. data/lib/samagotchi/host_registry.rb +22 -4
  25. data/lib/samagotchi/idle_scheduler.rb +1 -1
  26. data/lib/samagotchi/installed_versions.rb +91 -0
  27. data/lib/samagotchi/live_versions.rb +5 -3
  28. data/lib/samagotchi/llm/http.rb +9 -3
  29. data/lib/samagotchi/memory_bundle/builder.rb +24 -1
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  31. data/lib/samagotchi/memory_bundle/installer.rb +33 -0
  32. data/lib/samagotchi/memory_bundle/status.rb +5 -1
  33. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  34. data/lib/samagotchi/model_overlay.rb +39 -0
  35. data/lib/samagotchi/model_profile.rb +6 -2
  36. data/lib/samagotchi/output_formatter.rb +9 -4
  37. data/lib/samagotchi/relay_desk.rb +9 -0
  38. data/lib/samagotchi/served_model.rb +14 -4
  39. data/lib/samagotchi/session_manager.rb +166 -11
  40. data/lib/samagotchi/session_metrics.rb +36 -13
  41. data/lib/samagotchi/sessions_command.rb +43 -4
  42. data/lib/samagotchi/system_prompt.rb +10 -1
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
  44. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  45. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  46. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  47. data/lib/samagotchi/terminal_ui.rb +65 -16
  48. data/lib/samagotchi/tool_declarations.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +21 -5
  50. data/lib/samagotchi/tools/execute.rb +97 -19
  51. data/lib/samagotchi/turn_note.rb +3 -2
  52. data/lib/samagotchi/update_command.rb +33 -12
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +64 -11
  55. data/lib/samagotchi/web/public/app.js +102 -29
  56. data/lib/samagotchi/web/public/data.js +6 -0
  57. data/lib/samagotchi/web/public/format.js +4 -2
  58. data/lib/samagotchi/web/public/index.html +11 -0
  59. data/lib/samagotchi/web/public/question_card.js +12 -0
  60. data/lib/samagotchi/web/public/question_cards.js +17 -11
  61. data/lib/samagotchi/web/public/scope.js +15 -6
  62. data/lib/samagotchi/web/public/session_state.js +4 -0
  63. data/lib/samagotchi/web/public/turn_events.js +107 -3
  64. data/lib/samagotchi/web/public/update_notice.js +87 -14
  65. data/lib/samagotchi/web/server.rb +28 -1
  66. data/lib/samagotchi/web/session_hub.rb +30 -3
  67. data/lib/samagotchi/web/session_summary.rb +5 -0
  68. data/lib/samagotchi/worker.rb +25 -8
  69. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  70. data/lib/samagotchi/worker_sidecar.rb +7 -5
  71. metadata +4 -1
@@ -156,14 +156,16 @@ module Samagotchi
156
156
  post("command", payload, read_body: true)
157
157
  end
158
158
 
159
- # POST /session/:id/exit: ask the worker to exit now. 200 = it will
160
- # (status "exiting"), 409 = something keeps it up (reason), 404 with
159
+ # POST /session/:id/exit: ask the worker to exit now (+restart+: hand
160
+ # the session to a new worker, a worker with the "restart" feature). 200
161
+ # = it will (status "exiting"/"restarting"), 409 = something keeps it up (reason), 404 with
161
162
  # error "not_found" = a worker older than the route.
162
163
  # @param client_id [String] the asking UI, whose own streams don't hold
163
164
  # @return [Response]
164
- def request_exit(client_id:, delete: false)
165
+ def request_exit(client_id:, delete: false, restart: false)
165
166
  body = { client_id: client_id }
166
167
  body[:delete] = true if delete
168
+ body[:restart] = true if restart
167
169
  post("exit", body, read_body: true)
168
170
  end
169
171
 
@@ -290,11 +292,13 @@ module Samagotchi
290
292
  # Follow the session's events on a reader thread (see EventStream).
291
293
  # @param snapshot [Boolean] join with a snapshot frame
292
294
  # @param client_id [String, nil] names the stream (see EventStream)
295
+ # @param rediscover [#call, nil] follows a new worker (see EventStream)
293
296
  # @yieldparam event [Hash] string-keyed event
294
297
  # @return [EventStream] started
295
- def follow(snapshot: true, client_id: nil, reconnect_delays: EventStream::DEFAULT_RECONNECT_DELAYS, &on_event)
298
+ def follow(snapshot: true, client_id: nil, reconnect_delays: EventStream::DEFAULT_RECONNECT_DELAYS, rediscover: nil,
299
+ &on_event)
296
300
  EventStream.new(self, snapshot: snapshot, client_id: client_id, reconnect_delays: reconnect_delays,
297
- &on_event).start
301
+ rediscover: rediscover, &on_event).start
298
302
  end
299
303
 
300
304
  # Open GET /session/:id/stream and read past the response headers.
@@ -360,8 +360,9 @@ module Samagotchi
360
360
  mods << "conflict (kept your edits over v#{st[:provenance][:version]}: chi bundle diff #{name} #{k})" if info[:conflict]
361
361
  mods << "modified" if info[:modified]
362
362
  mods << "missing" if info[:missing]
363
- mods << "no-index" unless info[:index_present]
363
+ mods << "no-index" unless info[:index_present] || info[:overlay]
364
364
  label = mods.empty? ? "ok" : mods.join(",")
365
+ label += " (model overlay)" if info[:overlay]
365
366
  @stdout.puts " #{k}: #{label}"
366
367
  end
367
368
  # Hooks
@@ -321,7 +321,8 @@ class SourceLinks
321
321
  links.any? do |link|
322
322
  in_target = start >= link[:target][0] && finish <= link[:target][1]
323
323
  in_label = start >= link[:label][0] && finish <= link[:label][1]
324
- in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish]))
324
+ in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish],
325
+ case_insensitive: match.regexp.casefold?))
325
326
  end
326
327
  end
327
328
 
@@ -330,10 +331,12 @@ class SourceLinks
330
331
  # the ref. So `[JIRA-1](…/JIRA-12)` is NOT skipped (the target names
331
332
  # JIRA-12), while `[JIRA-123](…/JIRA-123)` is. Note this is stricter than
332
333
  # the bare-text scan's `\b`, which treats `-` as a boundary: `…/JIRA-1-foo`
333
- # would match there but not here.
334
- def target_names_ref?(target, ref)
334
+ # would match there but not here. A case_insensitive source compares
335
+ # without case too: `[jira-1](…/JIRA-1)` is skipped.
336
+ def target_names_ref?(target, ref, case_insensitive: false)
335
337
  escaped = Regexp.escape(ref)
336
- target.match?(/(?<![A-Za-z0-9\-])#{escaped}(?![A-Za-z0-9\-])/)
338
+ flags = case_insensitive ? Regexp::IGNORECASE : 0
339
+ target.match?(Regexp.new("(?<![A-Za-z0-9\\-])#{escaped}(?![A-Za-z0-9\\-])", flags))
337
340
  end
338
341
 
339
342
  # A ref glued to URL punctuation is part of a link too, even when the span
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:574ea0c2b36f80dde410c4251753d70cbeea081d129f3f3547f41370e3990993
11
+ sha256: sha256:5a996b718b1db729305b893ed583b9e600e813570861d47e0b65a24fec36a35d
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.18.0
3
+ version: 0.19.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:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
9
9
  config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
10
- memory_guide.md: sha256:e535e31fcf29b4b32c270042851ecdd5f412ef2e3c56b0076963e3e1f1a08ef8
10
+ memory_guide.md: sha256:8819678a243590203fa439ad736bf1959af4b4677bc6d2d507bad92c46fb1ccc
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -88,7 +88,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
88
88
  | `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
89
89
  | `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
90
90
  | `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
91
- | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
91
+ | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot; a model overlay (`<name>.<key>.md`) has no index line and reads `ok (model overlay)`. |
92
92
  | `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
93
93
  | `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
94
94
  | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
@@ -31,12 +31,39 @@ module Samagotchi
31
31
  CONTEXT_WINDOW_PROBE_READ_TIMEOUT = 2
32
32
  # Seconds a probe that got no answer (refused, timed out) is remembered:
33
33
  # a down or hung server then costs one probe per window, not one per
34
- # generation, and a new turn doesn't ask again at once.
34
+ # generation, and a new turn doesn't ask again at once. A request to the
35
+ # host that succeeds drops the remembered failure (see
36
+ # #clear_props_failures!).
35
37
  PROPS_FAILURE_TTL = 30
36
38
 
37
39
  DEFAULT_TRANSPORT = :llama_cpp
38
40
  VALID_TRANSPORTS = %i[llama_cpp mlx omlx].freeze
39
41
 
42
+ # One /props cache per host per process. A host has more than one Client
43
+ # in a run (the registry's, a rebuilt HostRegistry's, the Engines of a
44
+ # TUI and a spec): with a cache each, every one of them asks /props for
45
+ # the same server. Keyed by the host's base URL (scheme://host:port), so
46
+ # two Clients of the same server share the answers their turns read.
47
+ # Entries live for the process: they are the same small hash per host
48
+ # (answers by model, failures by model with their timestamps).
49
+ @props_store = {}
50
+ @props_store_mutex = Mutex.new
51
+
52
+ class << self
53
+ # The shared props entry for a host's base URL:
54
+ # { answers: { model => ServerProps }, failures: { model => [props, at] },
55
+ # mutex: Mutex }. The caller may hold the entry's own mutex.
56
+ def props_entry(base_url)
57
+ @props_store_mutex.synchronize { @props_store[base_url] ||= { answers: {}, failures: {}, mutex: Mutex.new } }
58
+ end
59
+
60
+ # Forget every host's answers and failures. Specs call it per example
61
+ # (the store outlives one); nothing in a run does.
62
+ def reset_props_store!
63
+ @props_store_mutex.synchronize { @props_store.clear }
64
+ end
65
+ end
66
+
40
67
  # Wire-format strategy for one server transport. `Client` keeps the
41
68
  # transport-agnostic request/retry/stream loop; everything that differs
42
69
  # between llama.cpp's native API and the OpenAI-compatible servers
@@ -175,9 +202,13 @@ module Samagotchi
175
202
  @open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout", open_timeout)
176
203
  @read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout", read_timeout)
177
204
  @transport = build_transport(resolve_transport(transport))
178
- @props_cache = {}
179
- @props_failures = {}
180
- @props_mutex = Mutex.new
205
+ # This host's process-wide /props entry (see self.props_entry): its
206
+ # answers and failures are shared with every other Client of the same
207
+ # server.
208
+ @props_entry = self.class.props_entry("#{@scheme}://#{@host}:#{@port}")
209
+ @props_cache = @props_entry[:answers]
210
+ @props_failures = @props_entry[:failures]
211
+ @props_mutex = @props_entry[:mutex]
181
212
  @first_token_timeout = first_token_timeout
182
213
  @host_name = name
183
214
  @label = name.to_s.empty? ? @transport.label : name.to_s
@@ -270,6 +301,10 @@ module Samagotchi
270
301
  chunk[:finish_reason] = finish_reason if finish_reason
271
302
  on_chunk.call(**chunk)
272
303
  end
304
+ # The host answered: a /props probe that failed while it was down (or
305
+ # loading) is no longer true, so the next turn asks it again instead
306
+ # of reusing the failure for the rest of its PROPS_FAILURE_TTL.
307
+ clear_props_failures!
273
308
  result
274
309
  rescue LLM::BadRequest => e
275
310
  raise unless e.status == 404 && @transport.name == :llama_cpp
@@ -293,6 +328,7 @@ module Samagotchi
293
328
  def list_models
294
329
  uri = URI("#{@scheme}://#{@host}:#{@port}#{@transport.models_path}")
295
330
  response = @http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" })
331
+ clear_props_failures!
296
332
  parsed = JSON.parse(response.body.to_s)
297
333
  parsed.fetch("data", parsed)
298
334
  rescue LLM::ProviderError
@@ -309,7 +345,9 @@ module Samagotchi
309
345
  # Whatever the server answers (a non-200 too) is cached per model; a
310
346
  # network failure for PROPS_FAILURE_TTL seconds, then the next call asks
311
347
  # again. A probe cut by this thread's Client.probe_cancel answers
312
- # :cancelled, uncached, and the turn's next request ends it.
348
+ # :cancelled, uncached, and the turn's next request ends it. A completion
349
+ # or model list this client gets through clears the failures: a server
350
+ # that just answered is not the down or hung one the window remembered.
313
351
  def server_props(model: nil)
314
352
  path = @transport.props_path
315
353
  return nil unless path
@@ -350,11 +388,12 @@ module Samagotchi
350
388
  nil
351
389
  end
352
390
 
353
- # Forget cached /props answers: the server may have restarted with
354
- # another -c, or a model switch may have loaded one with a different
355
- # window. A recent failure stays until its PROPS_FAILURE_TTL ends: it
356
- # holds no stale window, and asking a hung server again at every turn's
357
- # start is what it saves.
391
+ # Forget cached /props answers for this host: the server may have
392
+ # restarted with another -c, or a model switch may have loaded one with a
393
+ # different window. The host's entry is shared with every Client of the
394
+ # same server, so the clear reaches them all. A recent failure stays
395
+ # until its PROPS_FAILURE_TTL ends: it holds no stale window, and asking
396
+ # a hung server again at every turn's start is what it saves.
358
397
  def invalidate_context_window!
359
398
  @props_mutex.synchronize { @props_cache.clear }
360
399
  end
@@ -363,6 +402,15 @@ module Samagotchi
363
402
 
364
403
  def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
365
404
 
405
+ # The host answered a request: whatever a probe failed with (refused,
406
+ # timed out, 503 while it loaded) is stale, so the next probe asks it
407
+ # again rather than serving the failure for the rest of its
408
+ # PROPS_FAILURE_TTL. A cached answer stays: it was read from the same
409
+ # running server.
410
+ def clear_props_failures!
411
+ @props_mutex.synchronize { @props_failures.clear }
412
+ end
413
+
366
414
  def probe_props(path, model)
367
415
  query = model.empty? ? "" : "?#{URI.encode_www_form(model: model)}"
368
416
  uri = URI("#{@scheme}://#{@host}:#{@port}#{path}#{query}")
@@ -142,7 +142,8 @@ module Samagotchi
142
142
  model_key: -> { @model_key },
143
143
  model_name: -> { guardrail_model_name },
144
144
  cancelled: -> { !!active_cancel_controller&.cancelled? },
145
- ask: ->(fields) { open_question(fields) }
145
+ ask: ->(fields) { open_question(fields) },
146
+ notify: ->(message) { guardrail_notify(message) }
146
147
  )
147
148
  @guardrail_failures = @guardrail_wiring.failures
148
149
  # Plugins that failed to load: announced apart, as plugins (not
@@ -168,9 +169,10 @@ module Samagotchi
168
169
  # kernel's dispatch): the built-ins, per Engine.
169
170
  @tools = Tools::Builtins.registry
170
171
  if @scratch
171
- # A scratch session's children would outlive it; its memories
172
- # would too (write and edit into the memories: ScratchWrites).
173
- [Tools::Delegate::NAME, Tools::DelegateResult::NAME].each { |name| @tools.unregister(name) }
172
+ # A scratch session's children would outlive it, and so would a
173
+ # note it left in a peer; its memories would too (write and edit
174
+ # into the memories: ScratchWrites).
175
+ [Tools::Delegate::NAME, Tools::DelegateResult::NAME, Tools::SendNote::NAME].each { |name| @tools.unregister(name) }
174
176
  @tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
175
177
  end
176
178
  # Likewise the slash commands its SessionCommands run.
@@ -613,6 +615,11 @@ module Samagotchi
613
615
  Thread.current[key] = outer
614
616
  end
615
617
 
618
+ # Whether an anytime command (/btw) still runs on its thread.
619
+ def anytime_running?
620
+ @lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
621
+ end
622
+
616
623
  # Start an anytime command on its own thread (D8), one #shutdown waits
617
624
  # for, so its command_ran is announced before the process leaves.
618
625
  # @return [Thread]
@@ -1194,6 +1201,16 @@ module Samagotchi
1194
1201
  end
1195
1202
  private :hook_notify
1196
1203
 
1204
+ # A guardrails line to the user after load (:guardrail_warning, as the
1205
+ # load failures show): a running turn's event, else announced.
1206
+ def guardrail_notify(message)
1207
+ event = { type: :guardrail_warning, message: message.to_s }
1208
+ in_turn, sink = @turn_state.in_turn_sink
1209
+ in_turn ? emit_event(sink, event) : announce_or_hold(event)
1210
+ nil
1211
+ end
1212
+ private :guardrail_notify
1213
+
1197
1214
  # A question through the question flow (REPL sync handler, attached TUI,
1198
1215
  # web), single-select, kind "hook". A --non-interactive run has no one
1199
1216
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
@@ -1569,13 +1586,13 @@ module Samagotchi
1569
1586
  emit_event(turn.on_event, turn.tag(turn_started))
1570
1587
  raise image_error if image_error
1571
1588
 
1572
- # Ask the server for its window again each turn (one short /props GET,
1573
- # cached across the turn's generations): a restart with another -c
1574
- # between turns raises no error that would drop the cache. The
1575
- # profile may probe too (a first turn, a retry). Both run after
1576
- # run_turn's probe-cancel swap, so a Stop cuts them, and a failure
1577
- # ends the turn as turn_failed.
1578
- @client.invalidate_context_window!
1589
+ # The window and the profile probes run after run_turn's probe-cancel
1590
+ # swap, so a Stop cuts them; a failure ends the turn as turn_failed.
1591
+ # The cache itself was dropped at the previous turn's end, so this
1592
+ # turn's first probe asks the server (a restart with another -c
1593
+ # between turns is noticed), and the answer the session's prompt build
1594
+ # read for its profile (a first turn) is what this turn starts from:
1595
+ # one GET /props per turn, not two.
1579
1596
  refresh_profile!
1580
1597
 
1581
1598
  # Before anything of the turn is kept or a reminder is used up.
@@ -1828,6 +1845,13 @@ module Samagotchi
1828
1845
  # treats the just-finished turn as activity and re-arms its window.
1829
1846
  left = @turn_state.finish!
1830
1847
  Client.swap_probe_cancel(probe_cancel_before)
1848
+ # The next turn's window and served model are asked again: the cache
1849
+ # (this host's, process-wide) is dropped here, at the turn's end,
1850
+ # rather than at its start, so what this turn read (the profile's own
1851
+ # probe included) is not asked for a second time within the turn. A
1852
+ # restart with another -c between turns is still noticed: the next
1853
+ # turn's first probe goes to the server.
1854
+ @client.invalidate_context_window!
1831
1855
  log_dropped_steers(left, "turn_ended")
1832
1856
  record_activity
1833
1857
  # Clear hooks so they remain turn-scoped and never leak into the next turn.
@@ -5,6 +5,8 @@ require "yaml"
5
5
  require_relative "config"
6
6
  require_relative "log"
7
7
  require_relative "guardrails"
8
+ require_relative "memory_bundle/manifest"
9
+ require_relative "version"
8
10
  require_relative "hooks"
9
11
  require_relative "edit_preview"
10
12
  require_relative "tool_activity"
@@ -32,7 +34,10 @@ module Samagotchi
32
34
  # @param model_name [#call] → String, nil: the effective model's bare name
33
35
  # @param cancelled [#call] → Boolean, whether the running turn is cancelled
34
36
  # @param ask [#call] (fields) → the answer; the Engine's question flow
35
- def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {})
37
+ # @param notify [#call] (message) → shows a guardrails line in every UI
38
+ # (a bundle's rules kept because it needs a newer chi)
39
+ def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
40
+ notify: ->(_message) {})
36
41
  @scratch = scratch
37
42
  @hooks_lookup = hooks
38
43
  @tools_lookup = tools
@@ -41,6 +46,11 @@ module Samagotchi
41
46
  @model_name_lookup = model_name
42
47
  @cancelled_lookup = cancelled
43
48
  @ask = ask
49
+ @notify = notify
50
+ # Bundle name → the rules last loaded from it whole, kept while the
51
+ # installed version needs a newer chi; [name, version] said so once.
52
+ @loaded_bundle_rules = {}
53
+ @kept_noticed = {}
44
54
  @failures = Guardrails::LoadFailures.new
45
55
  @rules_mutex = Mutex.new
46
56
  @git = Guardrails::GitInfo.new
@@ -60,10 +70,15 @@ module Samagotchi
60
70
  @interface = value
61
71
  end
62
72
 
73
+ # The approval scopes a scratch session offers: nothing outlives it.
74
+ SCRATCH_SCOPES = %w[once session].freeze
75
+
63
76
  # Where the approval store lives: beside Session's state dir
64
- # ($XDG_STATE_HOME/samagotchi/guardrails/).
77
+ # ($XDG_STATE_HOME/samagotchi/guardrails/). A scratch session reads
78
+ # it but keeps its own session approvals in memory.
65
79
  def state_dir=(state_dir)
66
80
  @approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
81
+ @approvals = Guardrails::Approvals::InMemory.new(@approvals) if @scratch
67
82
  @protected = nil
68
83
  end
69
84
 
@@ -115,7 +130,9 @@ module Samagotchi
115
130
 
116
131
  # Installed bundles' guardrails/*.yml, by bundle name then file name.
117
132
  # A file that is missing, changed since install (sha256) or doesn't
118
- # parse is a required load failure.
133
+ # parse is a required load failure. A bundle whose requires_chi this
134
+ # chi doesn't meet isn't read (its rules may use keys this chi doesn't
135
+ # know): the rules last loaded from it stay (#too_new_bundle_rules).
119
136
  def bundle_rules
120
137
  require_relative "memory_bundle/provenance"
121
138
  rules = []
@@ -125,7 +142,13 @@ module Samagotchi
125
142
  @failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
126
143
  next
127
144
  end
145
+ if (too_new = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION))
146
+ rules.concat(too_new_bundle_rules(bundle_name, data, too_new))
147
+ next
148
+ end
128
149
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
150
+ loaded = []
151
+ whole = true
129
152
  data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
130
153
  what = "rules #{basename} (bundle #{bundle_name})"
131
154
  path = File.join(dir, basename.to_s)
@@ -139,12 +162,15 @@ module Samagotchi
139
162
  doc = YAML.safe_load(File.read(path))
140
163
  raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
141
164
 
142
- rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
165
+ loaded.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
143
166
  rescue Guardrails::Rules::ParseError, Psych::Exception => e
144
167
  Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
145
168
  @failures.add(what, e.message, required: true, group: :rules)
169
+ whole = false
146
170
  end
147
171
  end
172
+ rules.concat(loaded)
173
+ @loaded_bundle_rules[bundle_name] = loaded if whole
148
174
  end
149
175
  rules
150
176
  rescue StandardError => e
@@ -186,12 +212,45 @@ module Samagotchi
186
212
  # A plugin tool is asked about by its label, as its row shows it.
187
213
  label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools_lookup.call)
188
214
  # verdict.call is the call that will run (a hook may have replaced it).
189
- payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
215
+ payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
216
+ only: (SCRATCH_SCOPES if @scratch))
190
217
  Guardrails::Approval.settle(verdict, @ask.call(payload), payload[:approval][:scopes])
191
218
  end
192
219
 
193
220
  private
194
221
 
222
+ # The rules of a bundle that needs a newer chi: the ones last loaded
223
+ # from it, with one notice per bundle version (restart the session to
224
+ # load the new ones). Never loaded (a chi older than the bundle it
225
+ # starts with): none, and a required load failure, as the hook loader
226
+ # does for a guardrail hook, so the calls aren't quietly unguarded.
227
+ def too_new_bundle_rules(bundle_name, data, reason)
228
+ kept = @loaded_bundle_rules[bundle_name]
229
+ unless kept
230
+ Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
231
+ bundle: bundle_name)
232
+ @failures.add("rules (bundle #{bundle_name})", "#{reason}; update chi (chi update) or uninstall the bundle",
233
+ required: true, group: :rules)
234
+ return []
235
+ end
236
+
237
+ key = [bundle_name, data[:version].to_s]
238
+ return kept if @kept_noticed[key]
239
+
240
+ @kept_noticed[key] = true
241
+ Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: data[:version].to_s,
242
+ requires_chi: data[:requires_chi].to_s)
243
+ @notify.call(kept_notice(bundle_name, data))
244
+ kept
245
+ end
246
+
247
+ def kept_notice(bundle_name, data)
248
+ id = @session_lookup.call&.id || "ID"
249
+ "bundle #{bundle_name} #{data[:version]} needs chi #{data[:requires_chi]} and this session runs chi #{Samagotchi::VERSION}: " \
250
+ "its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
251
+ "(chi sessions stop #{id}, then chi --resume #{id})"
252
+ end
253
+
195
254
  # [path, mtime, size] of config.yml and every installed bundle's
196
255
  # manifest.json and guardrails/ file.
197
256
  def rules_stamp
@@ -22,9 +22,11 @@ module Samagotchi
22
22
  # approval[:tool] stays the raw name
23
23
  # @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
24
24
  # write: the card shows its diff, the question text one change line
25
+ # @param only [Array<String>, nil] offer no scope beyond these (a
26
+ # scratch session: once, session); "once" when none is left
25
27
  # @return [Hash] open_question fields
26
- def payload(verdict, label: nil, preview: nil)
27
- scopes = offered_scopes(verdict)
28
+ def payload(verdict, label: nil, preview: nil, only: nil)
29
+ scopes = offered_scopes(verdict, only: only)
28
30
  targets = verdict.targets
29
31
  {
30
32
  question: question_text(verdict, label: label, preview: preview),
@@ -81,8 +83,12 @@ module Samagotchi
81
83
  end
82
84
 
83
85
  # The rule's scopes; "rule" only when a rule id names what to approve.
84
- def offered_scopes(verdict)
85
- verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
86
+ def offered_scopes(verdict, only: nil)
87
+ scopes = verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
88
+ return scopes unless only
89
+
90
+ scopes &= only
91
+ scopes.empty? ? ["once"] : scopes
86
92
  end
87
93
 
88
94
  def label(scope, verdict)
@@ -45,10 +45,25 @@ module Samagotchi
45
45
  # @param verdict [Verdict] an ask with its targets and context
46
46
  # @return [Hash, nil] the first entry that allows it
47
47
  def match(verdict)
48
- key = self.class.key_for(verdict)
49
- place = self.class.place_for(verdict)
48
+ self.class.find_in(read, verdict)
49
+ end
50
+
51
+ # Store the verdict's allowed scope (not "once").
52
+ # @return [Hash, nil] the entry
53
+ def add(verdict, scope)
54
+ entry = self.class.entry_for(verdict, scope) or return nil
55
+
56
+ update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
57
+ entry
58
+ end
59
+
60
+ # @param list [Array<Hash>] entries
61
+ # @return [Hash, nil] the first of +list+ that allows +verdict+
62
+ def self.find_in(list, verdict)
63
+ key = key_for(verdict)
64
+ place = place_for(verdict)
50
65
  session_id = verdict.context&.session_id
51
- read.find do |e|
66
+ list.find do |e|
52
67
  case e["scope"]
53
68
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
54
69
  when "repo" then e["repo_root"] == place && e["key"] == key
@@ -58,21 +73,18 @@ module Samagotchi
58
73
  end
59
74
  end
60
75
 
61
- # Store the verdict's allowed scope (not "once").
62
- # @return [Hash, nil] the entry
63
- def add(verdict, scope)
76
+ # @return [Hash, nil] the entry that stores +scope+ for +verdict+; nil for "once"
77
+ def self.entry_for(verdict, scope)
64
78
  return nil unless STORED_SCOPES.include?(scope)
65
79
 
66
80
  entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
67
81
  "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
68
82
  case scope
69
- when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => self.class.key_for(verdict))
70
- when "repo" then entry.merge!("repo_root" => self.class.place_for(verdict), "key" => self.class.key_for(verdict))
71
- when "rule" then entry["repo_root"] = self.class.place_for(verdict)
83
+ when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
84
+ when "repo" then entry.merge!("repo_root" => place_for(verdict), "key" => key_for(verdict))
85
+ when "rule" then entry["repo_root"] = place_for(verdict)
72
86
  end
73
- entry.compact!
74
- update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
75
- entry
87
+ entry.compact
76
88
  end
77
89
 
78
90
  # Remove entry +index+ (0-based, as #entries lists them).
@@ -167,6 +179,42 @@ module Samagotchi
167
179
  AtomicFile.write(@path, JSON.pretty_generate(list))
168
180
  end
169
181
  end
182
+
183
+ # A scratch session's approvals: the store's entries still apply, and
184
+ # its own session approvals are kept in memory, so none outlives it
185
+ # (it offers no wider scope; one that comes anyway is not kept).
186
+ class InMemory
187
+ def initialize(store)
188
+ @store = store
189
+ @own = []
190
+ @mutex = Mutex.new
191
+ end
192
+
193
+ def path = @store.path
194
+
195
+ # The store's entries, then this session's own.
196
+ def entries = @store.entries + @mutex.synchronize { @own.dup }
197
+
198
+ def match(verdict)
199
+ @mutex.synchronize { Approvals.find_in(@own, verdict) } || @store.match(verdict)
200
+ end
201
+
202
+ def add(verdict, scope)
203
+ return nil unless scope == "session"
204
+
205
+ entry = Approvals.entry_for(verdict, scope)
206
+ @mutex.synchronize { @own << entry unless @own.any? { |e| e.except("created_at") == entry.except("created_at") } }
207
+ entry
208
+ end
209
+
210
+ # Index as #entries lists them: the store's first, then its own.
211
+ def revoke(index)
212
+ stored = @store.entries.size
213
+ return @store.revoke(index) if index < stored
214
+
215
+ @mutex.synchronize { index - stored < @own.size ? @own.delete_at(index - stored) : nil }
216
+ end
217
+ end
170
218
  end
171
219
  end
172
220
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "ipaddr"
4
4
  require_relative "config"
5
+ require_relative "log"
5
6
  require_relative "model_ref"
6
7
  require_relative "client"
7
8
  require_relative "llm/openai_chat"
@@ -125,9 +126,17 @@ module Samagotchi
125
126
  LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
126
127
  ::1/128 fc00::/7 fe80::/10].map { |net| IPAddr.new(net) }.freeze
127
128
 
128
- # Whether an address is remote: https, or an http IP address outside
129
- # the local nets (LOCAL_NETS). An http host named by a name (localhost,
130
- # box, mac.local, gpu.lan) counts as local: chi doesn't look names up.
129
+ # Name suffixes that stay on a home or office network: mDNS, the
130
+ # special-use and customary private names, router defaults (fritz.box)
131
+ # and Tailscale's MagicDNS. Compared on whole labels.
132
+ LOCAL_NAME_SUFFIXES = %w[localhost local lan home home.arpa internal intranet localdomain private corp test
133
+ box ts.net].freeze
134
+
135
+ # Whether an address is remote: https, an http IP address outside the
136
+ # local nets (LOCAL_NETS), or an http name with a dot whose suffix isn't
137
+ # a local one (LOCAL_NAME_SUFFIXES): `gpu.example.com` is remote, while
138
+ # `box`, `mac.local`, `gpu.lan` and `pc.tail1234.ts.net` are local. chi
139
+ # doesn't look names up; hosts.<name>.remote overrides either way.
131
140
  def self.remote_address?(scheme, host)
132
141
  return true if scheme.to_s == "https"
133
142
 
@@ -135,9 +144,17 @@ module Samagotchi
135
144
  address = address.native if address.ipv4_mapped?
136
145
  LOCAL_NETS.none? { |net| net.family == address.family && net.include?(address) }
137
146
  rescue IPAddr::Error
138
- false
147
+ remote_name?(host)
139
148
  end
140
149
 
150
+ def self.remote_name?(host)
151
+ name = host.to_s.downcase.delete_suffix(".")
152
+ return false unless name.include?(".")
153
+
154
+ LOCAL_NAME_SUFFIXES.none? { |suffix| name == suffix || name.end_with?(".#{suffix}") }
155
+ end
156
+ private_class_method :remote_name?
157
+
141
158
  # server.first_token_timeout, or nil when unset or unreadable.
142
159
  def self.configured_first_token_timeout
143
160
  Config.get("server.first_token_timeout")
@@ -273,6 +290,7 @@ module Samagotchi
273
290
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
274
291
  @mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
275
292
  rescue StandardError => e
293
+ Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
276
294
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
277
295
  end
278
296
  results_mutex.synchronize { results[name] = data }