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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -1
- data/README.md +4 -3
- data/bin/chi +5 -2
- data/docs/architecture.md +15 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +8 -4
- data/docs/guardrails.md +8 -0
- data/docs/memory.md +4 -0
- data/docs/sessions.md +6 -4
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +2 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/engine.rb +35 -11
- data/lib/samagotchi/guardrail_wiring.rb +64 -5
- data/lib/samagotchi/guardrails/approval.rb +10 -4
- data/lib/samagotchi/guardrails/approvals.rb +60 -12
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/memory_bundle/builder.rb +24 -1
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +33 -0
- data/lib/samagotchi/memory_bundle/status.rb +5 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +6 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/terminal_ui.rb +65 -16
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +97 -19
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +64 -11
- data/lib/samagotchi/web/public/app.js +102 -29
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +4 -2
- data/lib/samagotchi/web/public/index.html +11 -0
- data/lib/samagotchi/web/public/question_card.js +12 -0
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/turn_events.js +107 -3
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +5 -0
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- 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
|
|
160
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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:
|
|
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. |
|
data/lib/samagotchi/client.rb
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
354
|
-
# another -c, or a model switch may have loaded one with a
|
|
355
|
-
# window.
|
|
356
|
-
#
|
|
357
|
-
#
|
|
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}")
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
172
|
-
# would too (write and edit
|
|
173
|
-
|
|
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
|
-
#
|
|
1573
|
-
#
|
|
1574
|
-
#
|
|
1575
|
-
#
|
|
1576
|
-
#
|
|
1577
|
-
#
|
|
1578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
62
|
-
|
|
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" =>
|
|
70
|
-
when "repo" then entry.merge!("repo_root" =>
|
|
71
|
-
when "rule" then entry["repo_root"] =
|
|
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
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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
|
-
|
|
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 }
|