samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- metadata +20 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "yaml"
|
|
|
8
8
|
|
|
9
9
|
require_relative "config"
|
|
10
10
|
require_relative "context_note"
|
|
11
|
+
require_relative "steer"
|
|
11
12
|
require_relative "turn_note"
|
|
12
13
|
require_relative "model_profile"
|
|
13
14
|
require_relative "thought_stream_splitter"
|
|
@@ -15,14 +16,17 @@ require_relative "cancellation_controller"
|
|
|
15
16
|
require_relative "context_window"
|
|
16
17
|
require_relative "kernel_loop"
|
|
17
18
|
require_relative "tools/builtins"
|
|
19
|
+
require_relative "tools/task_runtime"
|
|
18
20
|
require_relative "session_commands"
|
|
19
21
|
require_relative "plugin/loader"
|
|
20
22
|
require_relative "log"
|
|
21
23
|
require_relative "log_subscriber"
|
|
24
|
+
require_relative "client"
|
|
22
25
|
require_relative "host_registry"
|
|
23
26
|
require_relative "llm/backend"
|
|
24
27
|
require_relative "llm/openai_chat"
|
|
25
28
|
require_relative "session"
|
|
29
|
+
require_relative "archive_store"
|
|
26
30
|
require_relative "session_observer"
|
|
27
31
|
require_relative "tool_declarations"
|
|
28
32
|
require_relative "session_metrics"
|
|
@@ -41,6 +45,8 @@ require_relative "served_model"
|
|
|
41
45
|
require_relative "image_store"
|
|
42
46
|
require_relative "vision_context"
|
|
43
47
|
require_relative "vision_support"
|
|
48
|
+
require_relative "sampling_settings"
|
|
49
|
+
require_relative "answer_display"
|
|
44
50
|
|
|
45
51
|
module Samagotchi
|
|
46
52
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -74,11 +80,16 @@ module Samagotchi
|
|
|
74
80
|
# @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
|
|
75
81
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
76
82
|
DEFAULT_SYSTEM_MEMORIES = %w[identity].freeze
|
|
83
|
+
# What memory_write answers in a scratch session.
|
|
84
|
+
SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
|
|
77
85
|
|
|
78
86
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
87
|
+
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
88
|
+
# delegate (a child would outlive it) nor plugin fork
|
|
79
89
|
def initialize(mode: :assist, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
|
|
80
|
-
plugins: true)
|
|
90
|
+
plugins: true, scratch: false)
|
|
81
91
|
@mode = mode.to_sym
|
|
92
|
+
@scratch = scratch
|
|
82
93
|
@chat_backend = nil
|
|
83
94
|
@chat_backend_mutex = Mutex.new
|
|
84
95
|
@default_model_name = ModelProfile.required_model_name(model_name)
|
|
@@ -113,6 +124,12 @@ module Samagotchi
|
|
|
113
124
|
# The tools this session offers (the prompts' declarations and the
|
|
114
125
|
# kernel's dispatch): the built-ins, per Engine.
|
|
115
126
|
@tools = Tools::Builtins.registry
|
|
127
|
+
if @scratch
|
|
128
|
+
# A scratch session's children would outlive it; its memories
|
|
129
|
+
# would too (write and edit into the memories: ScratchWrites).
|
|
130
|
+
[Tools::Delegate::NAME, Tools::DelegateResult::NAME].each { |name| @tools.unregister(name) }
|
|
131
|
+
@tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
|
|
132
|
+
end
|
|
116
133
|
# Likewise the slash commands its SessionCommands run.
|
|
117
134
|
@command_registry = SessionCommands.register_builtins(Commands::Registry.new)
|
|
118
135
|
# Installed bundles' plugins add commands, tools and hooks to these
|
|
@@ -220,6 +237,9 @@ module Samagotchi
|
|
|
220
237
|
@activity_seq = 0
|
|
221
238
|
@turn_running = false
|
|
222
239
|
@active_cancel_controller = nil
|
|
240
|
+
# Plugin steers ({text:, source:}) waiting for the running turn's next
|
|
241
|
+
# boundary (#steer); guarded by @activity_mutex.
|
|
242
|
+
@steers = []
|
|
223
243
|
# Reminder names the interactive REPL's IdleReminders callback marked due;
|
|
224
244
|
# the REPL polls them to decide when to run a synthetic reminder turn.
|
|
225
245
|
@due_reminder_names = []
|
|
@@ -302,6 +322,58 @@ module Samagotchi
|
|
|
302
322
|
@activity_mutex.synchronize { @turn_running }
|
|
303
323
|
end
|
|
304
324
|
|
|
325
|
+
# Put +text+ into the running turn, as a UI's steering does: it joins the
|
|
326
|
+
# conversation at the loop's next iteration boundary as its own user
|
|
327
|
+
# message (kind: "steer", source:). Callable from any thread; never
|
|
328
|
+
# blocks. With no turn running it does nothing (it never starts one).
|
|
329
|
+
# True means queued, not merged: at the after-answer boundary, and when
|
|
330
|
+
# the turn ends first, it is dropped (logged).
|
|
331
|
+
# @return [Boolean] whether it was queued
|
|
332
|
+
def steer(text, source:)
|
|
333
|
+
text = text.to_s.strip
|
|
334
|
+
return false if text.empty?
|
|
335
|
+
|
|
336
|
+
@activity_mutex.synchronize do
|
|
337
|
+
return false unless @turn_running
|
|
338
|
+
|
|
339
|
+
@steers << { text: text, source: source.to_s }
|
|
340
|
+
end
|
|
341
|
+
true
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# The drain a turn's loop gets: the caller's lines (a UI's steering; nil
|
|
345
|
+
# in a --non-interactive run) and the plugin steers. At the after-answer
|
|
346
|
+
# boundary the steers are dropped: the model answered, and a nudge would
|
|
347
|
+
# only restart the turn. The steer part fails on its own, never taking
|
|
348
|
+
# the user's lines with it.
|
|
349
|
+
def turn_drain(pending_input)
|
|
350
|
+
lambda do |at_answer: false|
|
|
351
|
+
lines = pending_input ? Array(pending_input.call) : []
|
|
352
|
+
lines + take_steers(at_answer)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
private :turn_drain
|
|
356
|
+
|
|
357
|
+
def take_steers(drop)
|
|
358
|
+
steers = @activity_mutex.synchronize do
|
|
359
|
+
taken = @steers
|
|
360
|
+
@steers = []
|
|
361
|
+
taken
|
|
362
|
+
end
|
|
363
|
+
return steers unless drop
|
|
364
|
+
|
|
365
|
+
log_dropped_steers(steers, "answered")
|
|
366
|
+
[]
|
|
367
|
+
rescue StandardError
|
|
368
|
+
[]
|
|
369
|
+
end
|
|
370
|
+
private :take_steers
|
|
371
|
+
|
|
372
|
+
def log_dropped_steers(steers, why)
|
|
373
|
+
steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
|
|
374
|
+
end
|
|
375
|
+
private :log_dropped_steers
|
|
376
|
+
|
|
305
377
|
# @return [Array<String>] reminder names queued for a synthetic REPL turn
|
|
306
378
|
def due_reminder_names
|
|
307
379
|
@activity_mutex.synchronize { @due_reminder_names.dup }
|
|
@@ -341,7 +413,7 @@ module Samagotchi
|
|
|
341
413
|
snapshot = @activity_mutex.synchronize { @session&.messages }
|
|
342
414
|
return "[]" if snapshot.nil?
|
|
343
415
|
|
|
344
|
-
JSON.generate(
|
|
416
|
+
JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
|
|
345
417
|
end
|
|
346
418
|
|
|
347
419
|
# Emit a :recap_ready event (additive slot) carrying the generated recap
|
|
@@ -892,9 +964,12 @@ module Samagotchi
|
|
|
892
964
|
target = @host_registry.resolve(@effective_model_name)
|
|
893
965
|
served, served_for = served_model_for(snapshot, target: target)
|
|
894
966
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for)
|
|
895
|
-
unless snapshot
|
|
967
|
+
unless snapshot.dig(:context, :window_tokens)
|
|
896
968
|
window = current_context_window(target)
|
|
897
|
-
|
|
969
|
+
if window
|
|
970
|
+
snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
|
|
971
|
+
window_source: window.source.to_s))
|
|
972
|
+
end
|
|
898
973
|
end
|
|
899
974
|
# The chat loop uses no prompt profile: drop one a native turn reported.
|
|
900
975
|
return snapshot.except(:profile, :profile_source) if target.entry.chat?
|
|
@@ -906,10 +981,28 @@ module Samagotchi
|
|
|
906
981
|
snapshot
|
|
907
982
|
end
|
|
908
983
|
|
|
984
|
+
# The status line's ctx from the saved context, for a worker that has run
|
|
985
|
+
# no turn yet (it woke after an idle exit): nil when either count is
|
|
986
|
+
# unknown or context.status is off.
|
|
987
|
+
def saved_context_status(context)
|
|
988
|
+
return nil unless context && @kernel.respond_to?(:context_display)
|
|
989
|
+
|
|
990
|
+
@kernel.context_display(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
|
|
991
|
+
end
|
|
992
|
+
|
|
909
993
|
# The effective model is on a chat host (api: openai), whose loop uses
|
|
910
994
|
# no prompt profile.
|
|
911
995
|
def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
|
|
912
996
|
|
|
997
|
+
# "temperature=0.6 (hosts.work)" for /model: the effective model's
|
|
998
|
+
# configured sampling, nil when none.
|
|
999
|
+
def sampling_summary
|
|
1000
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1001
|
+
SamplingSettings.summary(target, names: model_lookup_names(target))
|
|
1002
|
+
rescue StandardError
|
|
1003
|
+
nil
|
|
1004
|
+
end
|
|
1005
|
+
|
|
913
1006
|
# The model the server serves for the current model, and the name asked
|
|
914
1007
|
# for: what the last generation of that name reported, else llama.cpp's
|
|
915
1008
|
# model_alias (/props, one short cached probe; not with probe: false),
|
|
@@ -941,12 +1034,13 @@ module Samagotchi
|
|
|
941
1034
|
# :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
|
|
942
1035
|
def session_state_snapshot
|
|
943
1036
|
served_pair = served_model(probe: false)
|
|
1037
|
+
metrics = @metrics.snapshot
|
|
944
1038
|
{
|
|
945
1039
|
status: @session&.status,
|
|
946
1040
|
message_count: (@session&.messages || []).size,
|
|
947
1041
|
last_prompt: @session&.last_prompt,
|
|
948
1042
|
event_seq: @session_observer&.event_count,
|
|
949
|
-
metrics:
|
|
1043
|
+
metrics: metrics,
|
|
950
1044
|
pending_question: @question_mutex.synchronize { @pending_question&.dup },
|
|
951
1045
|
used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
|
|
952
1046
|
preloaded_memory_names: preloaded_memory_names,
|
|
@@ -955,7 +1049,7 @@ module Samagotchi
|
|
|
955
1049
|
model_name: @effective_model_name,
|
|
956
1050
|
served_model: served_pair[0],
|
|
957
1051
|
served_model_for: served_pair[1],
|
|
958
|
-
context_status: @last_context_status&.dup,
|
|
1052
|
+
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
959
1053
|
recap_enabled: !@recap.nil?,
|
|
960
1054
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
961
1055
|
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
@@ -1069,6 +1163,14 @@ module Samagotchi
|
|
|
1069
1163
|
# its own.
|
|
1070
1164
|
attr_writer :session_state_dir
|
|
1071
1165
|
|
|
1166
|
+
# The metrics load the session's saved records from its state dir, once.
|
|
1167
|
+
def bind_metrics(session)
|
|
1168
|
+
return unless session.respond_to?(:id) && session.id
|
|
1169
|
+
|
|
1170
|
+
@metrics.state_dir = session_state_dir
|
|
1171
|
+
@metrics.session_id = session.id
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1072
1174
|
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
1073
1175
|
|
|
1074
1176
|
# What a Bridge adds to a plugin's ctx.messages while a turn runs (the
|
|
@@ -1085,7 +1187,8 @@ module Samagotchi
|
|
|
1085
1187
|
# The gate's core checks, in order.
|
|
1086
1188
|
def guardrail_checks
|
|
1087
1189
|
rules = guardrail_rules
|
|
1088
|
-
[@guardrail_failures, rules.hook_asks, guardrail_protected_paths,
|
|
1190
|
+
[@guardrail_failures, rules.hook_asks, guardrail_protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
|
|
1191
|
+
rules].compact
|
|
1089
1192
|
end
|
|
1090
1193
|
|
|
1091
1194
|
# The YAML rules: config.yml's `guardrails:` section (rules, disable) and
|
|
@@ -1231,10 +1334,44 @@ module Samagotchi
|
|
|
1231
1334
|
# The conversation as a hook may read it: a frozen array of copied
|
|
1232
1335
|
# messages, so a hook cannot change what the turn sends or stores.
|
|
1233
1336
|
def hook_messages(messages)
|
|
1234
|
-
|
|
1337
|
+
AnswerDisplay.strip_all(messages).map(&:dup).freeze
|
|
1235
1338
|
end
|
|
1236
1339
|
private :hook_messages
|
|
1237
1340
|
|
|
1341
|
+
# How the turn ended, on the session (Session#last_turn): the caller's
|
|
1342
|
+
# save puts it in the file the session hub watches.
|
|
1343
|
+
def record_last_turn(session, outcome, seconds, origin)
|
|
1344
|
+
client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
|
|
1345
|
+
source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
|
|
1346
|
+
elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
|
|
1347
|
+
else "client"
|
|
1348
|
+
end
|
|
1349
|
+
session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
|
|
1350
|
+
"seconds" => seconds.round(1), "origin" => source }
|
|
1351
|
+
end
|
|
1352
|
+
private :record_last_turn
|
|
1353
|
+
|
|
1354
|
+
# Keep what the after_turn hooks presented as the answer's `display`
|
|
1355
|
+
# and tell the observers (:answer_display), after the turn_completed
|
|
1356
|
+
# they already had: the web re-reads the answer then. A turn_completed
|
|
1357
|
+
# with display_pending (after_turn hooks were about to run) always gets
|
|
1358
|
+
# one, `display: nil` when nothing was presented, so the web holds the
|
|
1359
|
+
# answer's pop until then and never swaps it after. Terminals are not
|
|
1360
|
+
# sinks of it; they printed the answer already. The session is saved by
|
|
1361
|
+
# the caller, as after every turn.
|
|
1362
|
+
def store_answer_display(session, display, pending: false)
|
|
1363
|
+
synchronize_events do
|
|
1364
|
+
messages = Array(session.messages)
|
|
1365
|
+
if display.changed? && messages.last.equal?(display.target)
|
|
1366
|
+
replace_session_messages(session, messages[0...-1] + [display.target.merge(AnswerDisplay::KEY => display.text)])
|
|
1367
|
+
@session_observer.notify({ type: :answer_display, display: display.text })
|
|
1368
|
+
elsif pending
|
|
1369
|
+
@session_observer.notify({ type: :answer_display, display: nil })
|
|
1370
|
+
end
|
|
1371
|
+
end
|
|
1372
|
+
end
|
|
1373
|
+
private :store_answer_display
|
|
1374
|
+
|
|
1238
1375
|
# ── The hook runtime (Hooks::Runtime) ─────────────────────────────────────
|
|
1239
1376
|
|
|
1240
1377
|
# The three things a hook can do beyond reading its event. Each gets the
|
|
@@ -1243,11 +1380,19 @@ module Samagotchi
|
|
|
1243
1380
|
Hooks::Runtime.new(
|
|
1244
1381
|
notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
|
|
1245
1382
|
ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
|
|
1246
|
-
stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) }
|
|
1383
|
+
stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
|
|
1384
|
+
steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) }
|
|
1247
1385
|
)
|
|
1248
1386
|
end
|
|
1249
1387
|
private :hook_runtime
|
|
1250
1388
|
|
|
1389
|
+
# What a steer is attributed to: the bundle of a bundle hook's label
|
|
1390
|
+
# ("<file> (bundle <name>)"), the label of any other.
|
|
1391
|
+
def hook_source(hook)
|
|
1392
|
+
hook.to_s[/\(bundle (.+)\)\z/, 1] || hook.to_s
|
|
1393
|
+
end
|
|
1394
|
+
private :hook_source
|
|
1395
|
+
|
|
1251
1396
|
# One line to the user (:hook_notice). During a turn it is a turn
|
|
1252
1397
|
# event: the turn's sink (the REPL) and the observers (bridge, log).
|
|
1253
1398
|
# Outside one (a plugin's command at the prompt) it is announced with
|
|
@@ -1379,7 +1524,7 @@ module Samagotchi
|
|
|
1379
1524
|
# Persist to session file for WEB stub + resume (generic for all UIs)
|
|
1380
1525
|
if @session
|
|
1381
1526
|
@session.pending_question = pending.dup
|
|
1382
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1527
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1383
1528
|
end
|
|
1384
1529
|
# Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
|
|
1385
1530
|
# stash this event (e.g. TerminalUI handle_question_event) will
|
|
@@ -1402,7 +1547,7 @@ module Samagotchi
|
|
|
1402
1547
|
@pending_question = nil
|
|
1403
1548
|
if @session
|
|
1404
1549
|
@session.pending_question = nil
|
|
1405
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1550
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1406
1551
|
end
|
|
1407
1552
|
emit_event(nil, { type: :question_answered, id: id, answer: ans })
|
|
1408
1553
|
return ans
|
|
@@ -1413,7 +1558,7 @@ module Samagotchi
|
|
|
1413
1558
|
@pending_question = nil
|
|
1414
1559
|
if @session
|
|
1415
1560
|
@session.pending_question = nil
|
|
1416
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1561
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1417
1562
|
end
|
|
1418
1563
|
emit_event(nil, { type: :question_answered, id: id, answer: sync_res })
|
|
1419
1564
|
return sync_res
|
|
@@ -1431,7 +1576,7 @@ module Samagotchi
|
|
|
1431
1576
|
@question_mutex.synchronize { @pending_question = nil }
|
|
1432
1577
|
if @session
|
|
1433
1578
|
@session.pending_question = nil
|
|
1434
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1579
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1435
1580
|
end
|
|
1436
1581
|
return { error: "no answer", detail: "handler failed to capture selection", id: id }
|
|
1437
1582
|
end
|
|
@@ -1453,7 +1598,7 @@ module Samagotchi
|
|
|
1453
1598
|
@pending_question = nil
|
|
1454
1599
|
if @session
|
|
1455
1600
|
@session.pending_question = nil
|
|
1456
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1601
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1457
1602
|
end
|
|
1458
1603
|
emit_event(nil, { type: :question_cancelled, id: id, reason: active_cancel_controller.reason.to_s })
|
|
1459
1604
|
return { error: "cancelled", reason: active_cancel_controller.reason.to_s, id: id }
|
|
@@ -1464,7 +1609,7 @@ module Samagotchi
|
|
|
1464
1609
|
@question_mutex.synchronize { @pending_question = nil }
|
|
1465
1610
|
if @session
|
|
1466
1611
|
@session.pending_question = nil
|
|
1467
|
-
begin; @session.save; rescue StandardError; nil; end
|
|
1612
|
+
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1468
1613
|
end
|
|
1469
1614
|
if answer
|
|
1470
1615
|
emit_event(nil, { type: :question_answered, id: id, answer: answer })
|
|
@@ -1512,6 +1657,9 @@ module Samagotchi
|
|
|
1512
1657
|
@question_answer = answer
|
|
1513
1658
|
@question_cv.broadcast
|
|
1514
1659
|
answer
|
|
1660
|
+
end.tap do
|
|
1661
|
+
# A human answered: the session is back in the lists (ArchiveStore).
|
|
1662
|
+
ArchiveStore.user_input(@session&.id, state_dir: session_state_dir)
|
|
1515
1663
|
end
|
|
1516
1664
|
end
|
|
1517
1665
|
|
|
@@ -1586,6 +1734,8 @@ module Samagotchi
|
|
|
1586
1734
|
@session = session
|
|
1587
1735
|
# One session per REPL/worker process: its records carry this sid.
|
|
1588
1736
|
Log.session_id = session.id if session.respond_to?(:id) && session.id
|
|
1737
|
+
# A woken worker's /stats and status line count the turns before it.
|
|
1738
|
+
bind_metrics(session)
|
|
1589
1739
|
sync_used_memories_from_session(session)
|
|
1590
1740
|
end
|
|
1591
1741
|
|
|
@@ -1616,7 +1766,7 @@ module Samagotchi
|
|
|
1616
1766
|
|
|
1617
1767
|
messages = @activity_mutex.synchronize { @session&.messages } || []
|
|
1618
1768
|
covered = state[:covered].to_i
|
|
1619
|
-
turns_since = Array(messages).drop(covered).count { |m|
|
|
1769
|
+
turns_since = Array(messages).drop(covered).count { |m| Steer.prompt?(m) }
|
|
1620
1770
|
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
1621
1771
|
end
|
|
1622
1772
|
|
|
@@ -1710,9 +1860,12 @@ module Samagotchi
|
|
|
1710
1860
|
# stay unchanged for callers that don't pass it.
|
|
1711
1861
|
with_origin = origin ? ->(event) { event.merge(origin: origin) } : ->(event) { event }
|
|
1712
1862
|
begin
|
|
1863
|
+
# A Stop cuts the /props probes this thread makes for the turn
|
|
1864
|
+
# (window, served model, vision) instead of waiting their timeout.
|
|
1865
|
+
probe_cancel_before = Client.swap_probe_cancel(effective_controller)
|
|
1713
1866
|
image_refs, image_error = turn_image_refs(session, continue ? [] : images)
|
|
1714
1867
|
# Emit turn_started event
|
|
1715
|
-
|
|
1868
|
+
bind_metrics(session)
|
|
1716
1869
|
turn_started = { type: :turn_started, session_id: session.id, prompt: prompt }
|
|
1717
1870
|
turn_started[:continue] = true if continue
|
|
1718
1871
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
@@ -1722,6 +1875,7 @@ module Samagotchi
|
|
|
1722
1875
|
# Before anything of the turn is kept or a reminder is used up.
|
|
1723
1876
|
vision = turn_vision(session)
|
|
1724
1877
|
@kernel.vision = vision if @kernel.respond_to?(:vision=)
|
|
1878
|
+
@kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
|
|
1725
1879
|
refuse_images!(vision) unless image_refs.empty?
|
|
1726
1880
|
announce_guardrail_failures(on_event)
|
|
1727
1881
|
# Plugins' slow setup that brings tools (an MCP server's first
|
|
@@ -1785,7 +1939,7 @@ module Samagotchi
|
|
|
1785
1939
|
cancel_controller: effective_controller,
|
|
1786
1940
|
model_name: bare_for_backend,
|
|
1787
1941
|
max_tool_output_chars: max_tool_output_chars,
|
|
1788
|
-
pending_input: pending_input
|
|
1942
|
+
pending_input: turn_drain(pending_input)
|
|
1789
1943
|
)
|
|
1790
1944
|
|
|
1791
1945
|
# Persist deduped used memories onto the session for Web + reload.
|
|
@@ -1818,24 +1972,31 @@ module Samagotchi
|
|
|
1818
1972
|
# placeholder text) is a turn the model should know ended that way.
|
|
1819
1973
|
empty = !canceled && !resumable &&
|
|
1820
1974
|
(response.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?))
|
|
1975
|
+
# after_turn hooks run below and may present the answer (the web
|
|
1976
|
+
# holds its pop until it knows).
|
|
1977
|
+
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1821
1978
|
synchronize_events do
|
|
1822
1979
|
if empty
|
|
1823
1980
|
# The placeholder is for the UIs (a new array: it must not leak
|
|
1824
1981
|
# into the result); the note is for the model, so it goes on the
|
|
1825
1982
|
# result's conversation too, which the REPL keeps as-is.
|
|
1983
|
+
# A retry's nudge at the tail goes: this note says it all.
|
|
1826
1984
|
note = TurnNote.empty
|
|
1827
|
-
|
|
1985
|
+
conversation&.replace(TurnNote.without_trailing(conversation))
|
|
1986
|
+
saved = TurnNote.without_trailing(conversation || session.messages)
|
|
1828
1987
|
saved << { role: "model", content: "[No response]" } if response.strip.empty?
|
|
1829
1988
|
replace_session_messages(session, saved + [note])
|
|
1830
1989
|
conversation << note if conversation
|
|
1831
1990
|
elsif canceled && conversation
|
|
1832
1991
|
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
|
|
1833
|
-
shown: TurnNote.interrupted_tail?(conversation)
|
|
1992
|
+
shown: TurnNote.interrupted_tail?(conversation),
|
|
1993
|
+
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
1834
1994
|
replace_session_messages(session, conversation)
|
|
1835
1995
|
elsif conversation
|
|
1836
1996
|
replace_session_messages(session, conversation)
|
|
1837
1997
|
end
|
|
1838
1998
|
session.status = Session::STATUS_IDLE
|
|
1999
|
+
record_last_turn(session, canceled ? "canceled" : "completed", turn_seconds.call, origin)
|
|
1839
2000
|
if canceled
|
|
1840
2001
|
emit_event(on_event, with_origin.call({
|
|
1841
2002
|
type: :turn_canceled,
|
|
@@ -1847,16 +2008,22 @@ module Samagotchi
|
|
|
1847
2008
|
emit_event(on_event, with_origin.call({
|
|
1848
2009
|
type: :turn_completed,
|
|
1849
2010
|
result: result,
|
|
1850
|
-
turn_summary: turn_summary(result)
|
|
2011
|
+
turn_summary: turn_summary(result),
|
|
2012
|
+
display_pending: display_pending
|
|
1851
2013
|
}))
|
|
1852
2014
|
end
|
|
1853
2015
|
end
|
|
1854
|
-
@metrics.persist
|
|
2016
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
1855
2017
|
|
|
1856
2018
|
# Fire :after_turn hook (runs even on cancel/success), with a read-only
|
|
1857
|
-
# copy of the conversation the turn stored
|
|
1858
|
-
|
|
1859
|
-
|
|
2019
|
+
# copy of the conversation the turn stored, and event[:present] for
|
|
2020
|
+
# a display version of the answer (AnswerDisplay).
|
|
2021
|
+
display = AnswerDisplay.new(session.messages)
|
|
2022
|
+
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
2023
|
+
messages: hook_messages(session.messages) }
|
|
2024
|
+
after_turn[:present] = display.presenter(after_turn)
|
|
2025
|
+
@hooks.fire(:after_turn, after_turn)
|
|
2026
|
+
store_answer_display(session, display, pending: display_pending)
|
|
1860
2027
|
|
|
1861
2028
|
# Fire :session_end after every turn (turn-level lifecycle)
|
|
1862
2029
|
@hooks.fire(:session_end, { type: :session_end, session_id: session.id })
|
|
@@ -1865,11 +2032,18 @@ module Samagotchi
|
|
|
1865
2032
|
rescue Interrupt
|
|
1866
2033
|
effective_controller.cancel!(:ctrl_c)
|
|
1867
2034
|
synchronize_events do
|
|
1868
|
-
|
|
2035
|
+
# Only the pre-turn messages survive here, so tasks this turn
|
|
2036
|
+
# started are missed (plan wait-stop §6).
|
|
2037
|
+
if messages
|
|
2038
|
+
note = TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call,
|
|
2039
|
+
running_tasks: Tools::TaskRuntime.running_created_in(messages))
|
|
2040
|
+
replace_session_messages(session, TurnNote.replace_trailing(messages, note))
|
|
2041
|
+
end
|
|
1869
2042
|
session.status = Session::STATUS_IDLE
|
|
2043
|
+
record_last_turn(session, "canceled", turn_seconds.call, origin)
|
|
1870
2044
|
emit_event(on_event, with_origin.call({ type: :turn_canceled, cancellation_reason: :ctrl_c }))
|
|
1871
2045
|
end
|
|
1872
|
-
@metrics.persist
|
|
2046
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
1873
2047
|
raise
|
|
1874
2048
|
rescue StandardError => e
|
|
1875
2049
|
# Keep what the turn got to (the prompt plus the loop's completed
|
|
@@ -1885,14 +2059,15 @@ module Samagotchi
|
|
|
1885
2059
|
end
|
|
1886
2060
|
replace_session_messages(session, kept) if kept
|
|
1887
2061
|
session.status = Session::STATUS_IDLE
|
|
1888
|
-
|
|
2062
|
+
record_last_turn(session, "failed", turn_seconds.call, origin)
|
|
2063
|
+
begin; session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1889
2064
|
failed = { type: :turn_failed, error_class: e.class.name, message: e.message }
|
|
1890
2065
|
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
1891
2066
|
if e.is_a?(LLM::ProviderError)
|
|
1892
2067
|
failed.merge!(error_kind: e.kind, retryable: e.retryable?, host: e.host, summary: e.summary)
|
|
1893
2068
|
end
|
|
1894
2069
|
emit_event(on_event, with_origin.call(failed))
|
|
1895
|
-
@metrics.persist
|
|
2070
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
1896
2071
|
raise
|
|
1897
2072
|
ensure
|
|
1898
2073
|
# A completed turn is activity: release the turn flag and advance the
|
|
@@ -1900,10 +2075,13 @@ module Samagotchi
|
|
|
1900
2075
|
# treats the just-finished turn as activity and re-arms its window.
|
|
1901
2076
|
# Always runs, even if an exception occurred.
|
|
1902
2077
|
set_turn_running(false)
|
|
1903
|
-
|
|
2078
|
+
Client.swap_probe_cancel(probe_cancel_before)
|
|
2079
|
+
left = @activity_mutex.synchronize do
|
|
1904
2080
|
@active_cancel_controller = nil
|
|
1905
2081
|
@turn_event_sink = nil
|
|
2082
|
+
@steers.tap { @steers = [] }
|
|
1906
2083
|
end
|
|
2084
|
+
log_dropped_steers(left, "turn_ended")
|
|
1907
2085
|
record_activity
|
|
1908
2086
|
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
1909
2087
|
clear_hooks
|
|
@@ -1928,6 +2106,15 @@ module Samagotchi
|
|
|
1928
2106
|
capability: -> { VisionSupport.for(target, profile: profile, adapter: vision_adapter(target)) })
|
|
1929
2107
|
end
|
|
1930
2108
|
|
|
2109
|
+
# The effective model's request parameters (hosts: and models:
|
|
2110
|
+
# sampling), read per turn: /model can change the model between turns.
|
|
2111
|
+
def turn_sampling
|
|
2112
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
2113
|
+
SamplingSettings.for(target, names: model_lookup_names(target))
|
|
2114
|
+
rescue StandardError
|
|
2115
|
+
SamplingSettings::EMPTY
|
|
2116
|
+
end
|
|
2117
|
+
|
|
1931
2118
|
def vision_adapter(target)
|
|
1932
2119
|
target.entry.chat? ? @host_registry.adapter_for(target.entry) : nil
|
|
1933
2120
|
rescue StandardError
|
|
@@ -2161,11 +2348,14 @@ module Samagotchi
|
|
|
2161
2348
|
(task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
|
|
2162
2349
|
},
|
|
2163
2350
|
card: ->(**card) { show_card(**card) },
|
|
2351
|
+
steer: ->(text, source) { steer(text, source: source) },
|
|
2352
|
+
stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
|
|
2164
2353
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
|
|
2165
2354
|
ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2166
2355
|
},
|
|
2167
2356
|
model_name: -> { @session&.model_name || @effective_model_name },
|
|
2168
|
-
state_dir: -> { session_state_dir }
|
|
2357
|
+
state_dir: -> { session_state_dir },
|
|
2358
|
+
scratch: -> { @scratch }
|
|
2169
2359
|
)
|
|
2170
2360
|
end
|
|
2171
2361
|
|
|
@@ -2175,7 +2365,7 @@ module Samagotchi
|
|
|
2175
2365
|
# with the event log held, so a turn is in exactly one of the two.
|
|
2176
2366
|
def plugin_messages
|
|
2177
2367
|
synchronize_events do
|
|
2178
|
-
messages = messages_checkpoint
|
|
2368
|
+
messages = AnswerDisplay.strip_all(messages_checkpoint)
|
|
2179
2369
|
first = messages.first
|
|
2180
2370
|
messages = messages.drop(1) if first && first[:role].to_s == "system" && first[:kind].to_s.empty?
|
|
2181
2371
|
running = turn_running? && @running_turn_messages ? Array(@running_turn_messages.call) : []
|
|
@@ -2462,10 +2652,14 @@ module Samagotchi
|
|
|
2462
2652
|
end
|
|
2463
2653
|
|
|
2464
2654
|
target = @host_registry.resolve(@effective_model_name)
|
|
2465
|
-
|
|
2655
|
+
ModelProfile.resolve(names: model_lookup_names(target), entry: target.entry, client: target.client, bare_model: target.bare_model)
|
|
2656
|
+
end
|
|
2657
|
+
|
|
2658
|
+
# The names a models: entry may be under: as typed (maybe an alias), the
|
|
2659
|
+
# part after a host prefix, alias-resolved, bare.
|
|
2660
|
+
def model_lookup_names(target)
|
|
2466
2661
|
typed = @model_lookup_names.first
|
|
2467
|
-
|
|
2468
|
-
ModelProfile.resolve(names: names.compact, entry: target.entry, client: target.client, bare_model: target.bare_model)
|
|
2662
|
+
(@model_lookup_names + [@host_registry.parse_qualified_model(typed).last, target.bare_model]).compact
|
|
2469
2663
|
end
|
|
2470
2664
|
|
|
2471
2665
|
# Everything that holds a profile follows the resolution: the kernel's
|
|
@@ -2661,6 +2855,8 @@ module Samagotchi
|
|
|
2661
2855
|
end
|
|
2662
2856
|
|
|
2663
2857
|
merged = Array(baseline).dup
|
|
2858
|
+
# For the warning when one can't be loaded: it names where it came from.
|
|
2859
|
+
@config_memories = merged.dup
|
|
2664
2860
|
Array(cli_memories).each do |raw|
|
|
2665
2861
|
raw.to_s.split(",").map(&:strip).reject(&:empty?).each do |name|
|
|
2666
2862
|
merged << name unless merged.include?(name)
|
|
@@ -2693,7 +2889,8 @@ module Samagotchi
|
|
|
2693
2889
|
scope, actual_name = split_memory_scope(name)
|
|
2694
2890
|
body = Tools::MemoryRead.call(actual_name, scope: scope)
|
|
2695
2891
|
if body.start_with?("Error:")
|
|
2696
|
-
|
|
2892
|
+
source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
|
|
2893
|
+
Log.warn(:memory, "preload_failed", echo: "Warning: #{source} could not be loaded (#{body})", memory: name)
|
|
2697
2894
|
next
|
|
2698
2895
|
end
|
|
2699
2896
|
# Record activated names so the UI can echo them in the sticky
|
|
@@ -36,6 +36,8 @@ module Samagotchi
|
|
|
36
36
|
label: label,
|
|
37
37
|
command: targets&.command,
|
|
38
38
|
paths: targets && !targets.paths.empty? ? targets.paths : nil,
|
|
39
|
+
# No command or path (an MCP tool): its arguments, as the question.
|
|
40
|
+
args: args_for_card(targets),
|
|
39
41
|
cwd: targets&.cwd,
|
|
40
42
|
repo_root: targets&.repo_root,
|
|
41
43
|
branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
|
|
@@ -47,6 +49,13 @@ module Samagotchi
|
|
|
47
49
|
}
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
def args_for_card(targets)
|
|
53
|
+
return nil if targets.nil? || targets.command || !targets.paths.empty?
|
|
54
|
+
|
|
55
|
+
text = Approval.args_text(targets.args, limit: ARGS_CHARS)
|
|
56
|
+
text.empty? ? nil : text
|
|
57
|
+
end
|
|
58
|
+
|
|
50
59
|
# Settle +verdict+ from open_question's result.
|
|
51
60
|
# @param answer [Hash, String] {selected_indices:, freeform:} or {error:}
|
|
52
61
|
# @param scopes [Array<String>] the offered scopes, in option order
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_paths"
|
|
4
|
+
require_relative "../memory_paths"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Guardrails
|
|
8
|
+
# A `chi scratch` session saves nothing: write and edit on a file under
|
|
9
|
+
# the memories folder (system and project memories, overlays, index.md)
|
|
10
|
+
# are denied. (memory_write itself refuses in a scratch session, with a
|
|
11
|
+
# shorter answer: Engine.) Reading them is fine. execute can still
|
|
12
|
+
# write there (as with ProtectedPaths).
|
|
13
|
+
class ScratchWrites
|
|
14
|
+
REASON = "scratch session: nothing is saved"
|
|
15
|
+
RULE = "scratch-session"
|
|
16
|
+
|
|
17
|
+
# @param memories_dir [#call] the memories folder (MemoryPaths.system_dir),
|
|
18
|
+
# read per call like the memory tools do
|
|
19
|
+
def initialize(memories_dir: -> { MemoryPaths.system_dir })
|
|
20
|
+
@memories_dir = memories_dir
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Vote on +verdict+.
|
|
24
|
+
def check(verdict)
|
|
25
|
+
return verdict unless writes_memory?(verdict)
|
|
26
|
+
|
|
27
|
+
verdict.deny!(REASON, rule: RULE, source: ProtectedPaths::SOURCE, decided_by: "core")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def writes_memory?(verdict)
|
|
33
|
+
targets = verdict.targets
|
|
34
|
+
return false unless targets && %w[write edit].include?(targets.tool)
|
|
35
|
+
|
|
36
|
+
targets.paths.any? { |path| ProtectedPaths.within?(ProtectedPaths.real(path), @memories_dir.call) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "guardrails/targets"
|
|
|
6
6
|
require_relative "guardrails/approval"
|
|
7
7
|
require_relative "guardrails/approvals"
|
|
8
8
|
require_relative "guardrails/protected_paths"
|
|
9
|
+
require_relative "guardrails/scratch_writes"
|
|
9
10
|
require_relative "guardrails/load_failures"
|
|
10
11
|
require_relative "guardrails/rules"
|
|
11
12
|
require_relative "guardrails/gate"
|