samagotchi 0.40.0 → 0.41.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 +54 -1
- data/README.md +1 -1
- data/bin/chi +19 -2
- data/docs/cli.md +25 -2
- data/docs/configuration.md +34 -3
- data/docs/guardrails.md +15 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +90 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +6 -7
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +91 -16
- data/lib/samagotchi/events.rb +8 -1
- data/lib/samagotchi/guardrails/model_size.rb +3 -4
- data/lib/samagotchi/guardrails/rules.rb +27 -17
- data/lib/samagotchi/guardrails/targets.rb +40 -1
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/llm/errors.rb +33 -1
- data/lib/samagotchi/llm_context_command.rb +151 -0
- data/lib/samagotchi/llm_context_override.rb +180 -0
- data/lib/samagotchi/llm_context_strategy.rb +63 -13
- data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
- data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
- data/lib/samagotchi/model_match.rb +54 -0
- data/lib/samagotchi/model_notes.rb +202 -0
- data/lib/samagotchi/parent_report.rb +12 -2
- data/lib/samagotchi/plugin/context.rb +3 -2
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +3 -17
- data/lib/samagotchi/self_report.rb +15 -0
- data/lib/samagotchi/send_command.rb +22 -2
- data/lib/samagotchi/session.rb +29 -4
- data/lib/samagotchi/session_commands.rb +21 -1
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_metrics.rb +26 -3
- data/lib/samagotchi/system_prompt.rb +66 -11
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
- data/lib/samagotchi/terminal_ui.rb +16 -6
- data/lib/samagotchi/tool_runner.rb +26 -1
- data/lib/samagotchi/tools/memory.rb +70 -3
- data/lib/samagotchi/turn_flow.rb +36 -8
- data/lib/samagotchi/turn_note.rb +40 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -1
- data/lib/samagotchi/web/public/app.js +40 -6
- data/lib/samagotchi/web/public/ctx.js +28 -5
- data/lib/samagotchi/web/public/format.js +5 -2
- data/lib/samagotchi/web/public/index.html +12 -0
- data/lib/samagotchi/web/public/live_turn.js +1 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/timing.js +5 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- data/lib/samagotchi/worker.rb +24 -18
- metadata +7 -1
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
- Installed gem files and bundled memories (`identity`, `memory_guide`,
|
|
78
78
|
`config_modification_protocol`, `self_map`, `delegated`) are managed: don't edit them
|
|
79
79
|
(local edits conflict on upgrade).
|
|
80
|
-
Put notes in my own memories.
|
|
81
|
-
|
|
80
|
+
Put notes in my own memories. Guidance for some models only is a model note
|
|
81
|
+
(`model_notes_<name>`, first line `models: <glob>`; memory_guide); a model-only
|
|
82
|
+
addition to one memory is an overlay on it (`current_model_only: true`), and it
|
|
83
|
+
needs that base entry to exist.
|
|
82
84
|
- Config edits: follow `config_modification_protocol`.
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -146,6 +146,9 @@ module Samagotchi
|
|
|
146
146
|
Entry.new(key: "context.status", yaml_path: %w[context status], type: :bool, default: true, expose: %i[env config cli]),
|
|
147
147
|
Entry.new(key: "context.window_tokens", yaml_path: %w[context window_tokens], type: :integer, default: nil, expose: %i[env config cli]),
|
|
148
148
|
Entry.new(key: "context.chars_per_token", yaml_path: %w[context chars_per_token], type: :float, default: 4.0, expose: %i[env config cli]),
|
|
149
|
+
# The tokens one scope's memory index (sent with every prompt) may hold before a write that crosses it gets a note
|
|
150
|
+
# (MemoryBundle::IndexSize); 0: no note. `memory:` is its own section, not the `memories:` preload list.
|
|
151
|
+
Entry.new(key: "memory.index_warn_tokens", yaml_path: %w[memory index_warn_tokens], type: :integer, default: 2500, expose: %i[env config cli]),
|
|
149
152
|
Entry.new(key: "context.status_thresholds", yaml_path: %w[context status_thresholds], type: :string, default: "20,40,60,80", expose: %i[env config cli]),
|
|
150
153
|
Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
|
|
151
154
|
# Attached context (ContextSources): how often a source's command runs when it names no --every (seconds; less
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -53,6 +53,7 @@ require_relative "steer_cut"
|
|
|
53
53
|
require_relative "engine_peers"
|
|
54
54
|
require_relative "bundle_needs"
|
|
55
55
|
require_relative "model_overlay"
|
|
56
|
+
require_relative "model_notes"
|
|
56
57
|
require_relative "served_model"
|
|
57
58
|
require_relative "image_store"
|
|
58
59
|
require_relative "vision_context"
|
|
@@ -256,7 +257,8 @@ module Samagotchi
|
|
|
256
257
|
Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
|
|
257
258
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
258
259
|
@prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
|
|
259
|
-
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
260
|
+
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
261
|
+
model_notes: -> { model_notes }, memories: memories,
|
|
260
262
|
muted_memory_names: @muted_memory_names)
|
|
261
263
|
@session = nil
|
|
262
264
|
@session_observer = SessionObserver.new
|
|
@@ -468,6 +470,14 @@ module Samagotchi
|
|
|
468
470
|
served: prompt_served_model(target), fallback_key: @fallback_model_key)
|
|
469
471
|
end
|
|
470
472
|
|
|
473
|
+
# The model notes for the effective model (ModelNotes): matched on its
|
|
474
|
+
# bare id and key, the session's mutes left out.
|
|
475
|
+
# @return [Array<ModelNotes::Note>]
|
|
476
|
+
def model_notes
|
|
477
|
+
ModelNotes.for(name: guardrail_model_name, key: @model_key, fallback_key: @fallback_model_key,
|
|
478
|
+
muted: @muted_memory_names)
|
|
479
|
+
end
|
|
480
|
+
|
|
471
481
|
# +name+'s resolved ref (ModelRef#ref).
|
|
472
482
|
def model_ref_for(name)
|
|
473
483
|
@host_registry.model_ref(name).ref
|
|
@@ -911,7 +921,7 @@ module Samagotchi
|
|
|
911
921
|
snapshot = @metrics.snapshot
|
|
912
922
|
target = @host_registry.resolve(@effective_model_name)
|
|
913
923
|
served, served_for = served_model_for(snapshot, target: target)
|
|
914
|
-
snapshot = snapshot.merge(served_model: served, served_model_for: served_for)
|
|
924
|
+
snapshot = snapshot.merge(served_model: served, served_model_for: served_for, llm_context: llm_context_summary)
|
|
915
925
|
unless snapshot.dig(:context, :window_tokens)
|
|
916
926
|
window = current_context_window(target)
|
|
917
927
|
if window
|
|
@@ -940,6 +950,39 @@ module Samagotchi
|
|
|
940
950
|
.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
|
|
941
951
|
end
|
|
942
952
|
|
|
953
|
+
# #turn_llm_context with where each value came from
|
|
954
|
+
# (LLMContextStrategy.explain), nil when it can't be resolved.
|
|
955
|
+
# @return [LLMContextStrategy::Explained, nil]
|
|
956
|
+
def llm_context_explained
|
|
957
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
958
|
+
LLMContextStrategy.explain(target, names: model_lookup_names(target), **llm_context_session_args)
|
|
959
|
+
rescue StandardError
|
|
960
|
+
nil
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# #llm_context_explained's summary (Explained#summary), nil when it
|
|
964
|
+
# can't be resolved: /stats, the snapshot, the web's info bar.
|
|
965
|
+
# @return [Hash, nil]
|
|
966
|
+
def llm_context_summary
|
|
967
|
+
llm_context_explained&.summary(llm_context_override)
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
# The session's own llm_context values, nil when it has none.
|
|
971
|
+
# @return [LLMContextOverride, nil]
|
|
972
|
+
def llm_context_override = @session&.llm_context
|
|
973
|
+
|
|
974
|
+
# Set the session's own llm_context values (an empty override unsets
|
|
975
|
+
# them); the next turn's start resolves them, never a running one (its
|
|
976
|
+
# TurnSettings are fixed). The caller saves the session.
|
|
977
|
+
# @raise [ArgumentError] with no session yet
|
|
978
|
+
def llm_context_override=(override)
|
|
979
|
+
raise ArgumentError, "no session yet" unless @session
|
|
980
|
+
|
|
981
|
+
@session.llm_context = override
|
|
982
|
+
# The status a later client reads counts against the new budget.
|
|
983
|
+
@last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
|
|
984
|
+
end
|
|
985
|
+
|
|
943
986
|
# The effective model is on a chat host (api: openai), whose loop uses
|
|
944
987
|
# no prompt profile.
|
|
945
988
|
def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
|
|
@@ -1010,6 +1053,7 @@ module Samagotchi
|
|
|
1010
1053
|
served_model: served_pair[0],
|
|
1011
1054
|
served_model_for: served_pair[1],
|
|
1012
1055
|
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
1056
|
+
llm_context: llm_context_summary,
|
|
1013
1057
|
recap_enabled: !@recap.nil?,
|
|
1014
1058
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
1015
1059
|
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
@@ -1158,7 +1202,9 @@ module Samagotchi
|
|
|
1158
1202
|
# cancel's reason and the hook that stopped it. Only what is known.
|
|
1159
1203
|
def stop_facts(event)
|
|
1160
1204
|
case event&.dig(:type)
|
|
1161
|
-
|
|
1205
|
+
# kept_steps: the turn's work stayed (a parent must not send the task again).
|
|
1206
|
+
when :turn_failed
|
|
1207
|
+
{ "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable], "kept_steps" => event[:kept_steps] }.compact
|
|
1162
1208
|
when :turn_canceled
|
|
1163
1209
|
{ "cancel_reason" => event[:cancellation_reason]&.to_s, "stopped_by" => event[:cancelled_by]&.to_s }.compact
|
|
1164
1210
|
else {}
|
|
@@ -1413,14 +1459,17 @@ module Samagotchi
|
|
|
1413
1459
|
target ||= @host_registry.resolve(@effective_model_name)
|
|
1414
1460
|
chat = target.entry.chat?
|
|
1415
1461
|
level = chat ? nil : thinking_level(target)
|
|
1416
|
-
@prompt_builder.build(chat: chat, thinking: level, layers: llm_context_layers(target))
|
|
1462
|
+
prompt = @prompt_builder.build(chat: chat, thinking: level, layers: llm_context_layers(target))
|
|
1463
|
+
# The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
|
|
1464
|
+
@metrics.memory_index = @prompt_builder.memory_index
|
|
1465
|
+
prompt
|
|
1417
1466
|
end
|
|
1418
1467
|
|
|
1419
1468
|
# The LLM context layers +target+'s turns run under: the native prompt
|
|
1420
1469
|
# declares the tools they add (forget_outputs under forget), so a
|
|
1421
1470
|
# model switch may change the tool list. [] under none.
|
|
1422
1471
|
def llm_context_layers(target)
|
|
1423
|
-
LLMContextStrategy.resolve(target, names: model_lookup_names(target)).active_layers
|
|
1472
|
+
LLMContextStrategy.resolve(target, names: model_lookup_names(target), **llm_context_session_args).active_layers
|
|
1424
1473
|
rescue StandardError
|
|
1425
1474
|
[]
|
|
1426
1475
|
end
|
|
@@ -1832,8 +1881,10 @@ module Samagotchi
|
|
|
1832
1881
|
# The turn's settings, set once here. The chat loop dispatches tools
|
|
1833
1882
|
# through the kernel without its #run: tag those dumps with this
|
|
1834
1883
|
# turn's model, not the last native one.
|
|
1884
|
+
explained = llm_context_explained
|
|
1835
1885
|
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
|
|
1836
|
-
llm_context:
|
|
1886
|
+
llm_context: explained&.resolved)
|
|
1887
|
+
log_llm_context(explained)
|
|
1837
1888
|
|
|
1838
1889
|
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
1839
1890
|
backend.complete(
|
|
@@ -1848,6 +1899,18 @@ module Samagotchi
|
|
|
1848
1899
|
end
|
|
1849
1900
|
private :generate
|
|
1850
1901
|
|
|
1902
|
+
# The turn's LLM context strategy and where it came from, in the turn
|
|
1903
|
+
# log: when it runs a layer, or the session set it (none too).
|
|
1904
|
+
def log_llm_context(explained)
|
|
1905
|
+
return unless explained
|
|
1906
|
+
return if explained.resolved.active_layers.empty? && explained.strategy.source != :session
|
|
1907
|
+
|
|
1908
|
+
summary = explained.summary
|
|
1909
|
+
Log.info(:turn, "llm_context", **summary.slice(:strategy, :strategy_source, :apply, :apply_source, :budget_tokens,
|
|
1910
|
+
:budget_source).compact)
|
|
1911
|
+
end
|
|
1912
|
+
private :log_llm_context
|
|
1913
|
+
|
|
1851
1914
|
def publish_used_memories(session, on_event)
|
|
1852
1915
|
# Persist deduped used memories onto the session for Web + reload.
|
|
1853
1916
|
begin
|
|
@@ -1948,20 +2011,30 @@ module Samagotchi
|
|
|
1948
2011
|
|
|
1949
2012
|
# A failure keeps the loop's partial conversation (else the turn's
|
|
1950
2013
|
# messages so far) with a failed note; nil when the turn never reached
|
|
1951
|
-
# the model. The model reads why on its next turn
|
|
1952
|
-
#
|
|
2014
|
+
# the model. The model reads why on its next turn. A turn that got to
|
|
2015
|
+
# tool results (LLM::FailedTurn.progress) says so in the note; on the
|
|
2016
|
+
# error (kept_steps) too unless it failed in a way that rolls back
|
|
2017
|
+
# (FailedTurn.keeps_work?: a context overflow). The UIs keep a marked
|
|
2018
|
+
# turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
|
|
2019
|
+
# note of their own; -p --non-interactive keeps what this keeps.
|
|
1953
2020
|
def failed_messages(turn, error)
|
|
1954
2021
|
kept = error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array) ? error.partial_conversation : turn.messages
|
|
1955
2022
|
return nil unless kept
|
|
1956
2023
|
|
|
2024
|
+
steps = LLM::FailedTurn.progress(turn.session.messages, kept)
|
|
2025
|
+
error.kept_steps = steps if steps && error.is_a?(LLM::FailedTurn) && LLM::FailedTurn.keeps_work?(error)
|
|
1957
2026
|
summary = error.respond_to?(:summary) ? error.summary : error.message
|
|
1958
|
-
TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue))
|
|
2027
|
+
TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue, steps: steps))
|
|
1959
2028
|
end
|
|
1960
2029
|
private :failed_messages
|
|
1961
2030
|
|
|
2031
|
+
# Called after #failed_messages, which marks the error's kept steps.
|
|
1962
2032
|
def failed_event(error, seconds)
|
|
1963
2033
|
failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
|
|
1964
2034
|
duration_ms: (seconds * 1000).round }
|
|
2035
|
+
# Its work stays: the UIs say so, as after a cancel.
|
|
2036
|
+
steps = LLM::FailedTurn.kept_steps(error)
|
|
2037
|
+
failed[:kept_steps] = steps if steps
|
|
1965
2038
|
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
1966
2039
|
if error.is_a?(LLM::ProviderError)
|
|
1967
2040
|
failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
|
|
@@ -2345,7 +2418,8 @@ module Samagotchi
|
|
|
2345
2418
|
model_name: -> { effective_model_ref },
|
|
2346
2419
|
model_key: -> { @model_key },
|
|
2347
2420
|
state_dir: -> { session_state_dir },
|
|
2348
|
-
scratch: -> { @scratch }
|
|
2421
|
+
scratch: -> { @scratch },
|
|
2422
|
+
llm_context: -> { @session&.llm_context }
|
|
2349
2423
|
)
|
|
2350
2424
|
end
|
|
2351
2425
|
|
|
@@ -2529,16 +2603,17 @@ module Samagotchi
|
|
|
2529
2603
|
nil
|
|
2530
2604
|
end
|
|
2531
2605
|
|
|
2532
|
-
# The
|
|
2533
|
-
#
|
|
2606
|
+
# The LLM context strategy the next turn runs under: the session's own
|
|
2607
|
+
# (Session#llm_context), else the effective model's (models:/hosts:
|
|
2608
|
+
# llm_context_strategy), else llm_context.strategy; read per turn as
|
|
2534
2609
|
# the window is.
|
|
2535
2610
|
def turn_llm_context
|
|
2536
|
-
|
|
2537
|
-
LLMContextStrategy.resolve(target, names: model_lookup_names(target))
|
|
2538
|
-
rescue StandardError
|
|
2539
|
-
nil
|
|
2611
|
+
llm_context_explained&.resolved
|
|
2540
2612
|
end
|
|
2541
2613
|
|
|
2614
|
+
def llm_context_session_args = @session&.llm_context&.resolve_args || {}
|
|
2615
|
+
private :llm_context_session_args
|
|
2616
|
+
|
|
2542
2617
|
# See #served_model. A report for another name (before a /model switch)
|
|
2543
2618
|
# doesn't count. Without a target, no probe. A remote chat host has no
|
|
2544
2619
|
# /props: nil until a turn.
|
data/lib/samagotchi/events.rb
CHANGED
|
@@ -9,7 +9,8 @@ module Samagotchi
|
|
|
9
9
|
# A turn is over: it completed, was canceled, or failed.
|
|
10
10
|
TURN_END = %i[turn_completed turn_canceled turn_failed].freeze
|
|
11
11
|
# Turn ends that leave the turn in the conversation: a failed turn's
|
|
12
|
-
# prompt goes back to the composer instead
|
|
12
|
+
# prompt goes back to the composer instead, unless its work stayed
|
|
13
|
+
# (#kept_turn?).
|
|
13
14
|
TURN_KEPT = %i[turn_completed turn_canceled].freeze
|
|
14
15
|
# The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
|
|
15
16
|
# joining UI replays as live events.
|
|
@@ -19,5 +20,11 @@ module Samagotchi
|
|
|
19
20
|
# turn, turn_events.js snapshotEvents).
|
|
20
21
|
STREAM_FRAMES = %i[snapshot reset stream_closed].freeze
|
|
21
22
|
SYNTHETIC = %i[merged_input].freeze
|
|
23
|
+
|
|
24
|
+
# Whether +event+ ends a turn that stays in the conversation: completed,
|
|
25
|
+
# canceled, or failed with its work kept (turn_failed's kept_steps).
|
|
26
|
+
def self.kept_turn?(event)
|
|
27
|
+
TURN_KEPT.include?(event[:type]) || (event[:type] == :turn_failed && !event[:kept_steps].nil?)
|
|
28
|
+
end
|
|
22
29
|
end
|
|
23
30
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../config"
|
|
4
|
+
require_relative "../model_match"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
module Guardrails
|
|
@@ -15,7 +16,6 @@ module Samagotchi
|
|
|
15
16
|
SETTING = "guardrails.small_models"
|
|
16
17
|
# The largest auto-small size, in billions of parameters.
|
|
17
18
|
MAX_SMALL = 32
|
|
18
|
-
GLOB_FLAGS = File::FNM_CASEFOLD | File::FNM_EXTGLOB
|
|
19
19
|
|
|
20
20
|
# A size starts at the name's start or after a separator, and ends at
|
|
21
21
|
# a separator or the name's end (so 4bit and v4.1 aren't sizes).
|
|
@@ -53,12 +53,11 @@ module Samagotchi
|
|
|
53
53
|
def small?(name, key, setting = self.setting)
|
|
54
54
|
return false if name.nil? || name.to_s.strip.empty?
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
entries.any? do |entry|
|
|
56
|
+
ModelMatch.parse(setting.nil? ? "auto" : setting.to_s).any? do |entry|
|
|
58
57
|
if entry.casecmp?("auto")
|
|
59
58
|
(size = billions(name)) ? size <= MAX_SMALL : false
|
|
60
59
|
else
|
|
61
|
-
|
|
60
|
+
ModelMatch.glob?(entry, name: name, key: key)
|
|
62
61
|
end
|
|
63
62
|
end
|
|
64
63
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "verdict"
|
|
4
4
|
require_relative "targets"
|
|
5
5
|
require_relative "model_size"
|
|
6
|
+
require_relative "../model_match"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
module Guardrails
|
|
@@ -20,8 +21,9 @@ module Samagotchi
|
|
|
20
21
|
# subcommand outside the session's repo (ShellGitDirs)
|
|
21
22
|
# models: "small" (Guardrails::ModelSize, guardrails.small_models),
|
|
22
23
|
# or a glob on the bare model name or the model key, or a
|
|
23
|
-
# list of them
|
|
24
|
-
#
|
|
24
|
+
# list of them ("|"-separated or a YAML list; ModelMatch):
|
|
25
|
+
# the rule only votes for a model that matches (no model
|
|
26
|
+
# matches none; missing = every model)
|
|
25
27
|
# touches: "chi_dirs": a command names a path in chi's config, hooks,
|
|
26
28
|
# approvals or bundles dir or a .git/hooks dir (resolved;
|
|
27
29
|
# a word that can't be resolved falls back to text)
|
|
@@ -30,7 +32,12 @@ module Samagotchi
|
|
|
30
32
|
# modes: "strict", "auto" or a list of them: the rule only votes
|
|
31
33
|
# in those guardrails.mode values (missing = every mode;
|
|
32
34
|
# a rule from config.yml without it votes in every mode)
|
|
33
|
-
# memory: "remove": a memory_write call with remove: true
|
|
35
|
+
# memory: "remove": a memory_write call with remove: true;
|
|
36
|
+
# "overlay": one that writes a model overlay
|
|
37
|
+
# (current_model_only: true); "prompt": a memory_write,
|
|
38
|
+
# write or edit of a memory file that reaches the system
|
|
39
|
+
# prompt (identity, model notes, their overlays;
|
|
40
|
+
# Targets#prompt_memory?)
|
|
34
41
|
# skip_read_only: true: a shell command that only reads
|
|
35
42
|
# (ReadOnlyShell) doesn't match
|
|
36
43
|
# verdict ask|deny, reason, scopes (for an ask; default all).
|
|
@@ -59,12 +66,20 @@ module Samagotchi
|
|
|
59
66
|
return false if git && !targets.git_outside_repo?
|
|
60
67
|
return false if touches && !targets.touches_chi?
|
|
61
68
|
return false if rm && !targets.rm_outside_tmp?
|
|
62
|
-
return false if memory && !targets
|
|
69
|
+
return false if memory && !memory_matches?(targets)
|
|
63
70
|
return false if skip_read_only && targets.read_only?
|
|
64
71
|
|
|
65
72
|
true
|
|
66
73
|
end
|
|
67
74
|
|
|
75
|
+
def memory_matches?(targets)
|
|
76
|
+
case memory
|
|
77
|
+
when "overlay" then targets.memory_overlay?
|
|
78
|
+
when "prompt" then targets.prompt_memory?
|
|
79
|
+
else targets.memory_remove?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
68
83
|
# Whether the rule votes in guardrails.mode +mode+.
|
|
69
84
|
def for_mode?(mode) = modes.nil? || modes.include?(mode.to_s)
|
|
70
85
|
|
|
@@ -76,15 +91,8 @@ module Samagotchi
|
|
|
76
91
|
# for every model). +small+ says whether the model is a small one.
|
|
77
92
|
def for_model?(name, key, small = -> { ModelSize.small?(name, key) })
|
|
78
93
|
return true unless models
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
models.any? do |entry|
|
|
82
|
-
if entry == "small"
|
|
83
|
-
small.call
|
|
84
|
-
else
|
|
85
|
-
[name, key].compact.any? { |candidate| File.fnmatch(entry, candidate, ModelSize::GLOB_FLAGS) }
|
|
86
|
-
end
|
|
87
|
-
end
|
|
94
|
+
|
|
95
|
+
ModelMatch.match?(models, name: name, key: key, small: small)
|
|
88
96
|
end
|
|
89
97
|
|
|
90
98
|
def path_matches?(targets)
|
|
@@ -190,10 +198,11 @@ module Samagotchi
|
|
|
190
198
|
value
|
|
191
199
|
end
|
|
192
200
|
|
|
193
|
-
|
|
201
|
+
MEMORY_KINDS = %w[remove overlay prompt].freeze
|
|
202
|
+
|
|
194
203
|
def self.memory_of(value, label)
|
|
195
204
|
return nil if value.nil?
|
|
196
|
-
raise ParseError, "#{label}: memory must be remove" unless value
|
|
205
|
+
raise ParseError, "#{label}: memory must be remove, overlay or prompt" unless MEMORY_KINDS.include?(value)
|
|
197
206
|
|
|
198
207
|
value
|
|
199
208
|
end
|
|
@@ -217,15 +226,16 @@ module Samagotchi
|
|
|
217
226
|
value
|
|
218
227
|
end
|
|
219
228
|
|
|
229
|
+
# ModelMatch's grammar: a string ("|"-separated) or a list of names.
|
|
220
230
|
def self.models_of(value, label)
|
|
221
231
|
return nil if value.nil?
|
|
222
232
|
|
|
223
233
|
names = Array(value)
|
|
224
|
-
unless !names.empty? && names.all? { |n| n.is_a?(String) && !n.strip.empty? }
|
|
234
|
+
unless !names.empty? && names.all? { |n| n.is_a?(String) && !n.strip.empty? } && !ModelMatch.parse(value).empty?
|
|
225
235
|
raise ParseError, "#{label}: models must be small, a glob or a list of them"
|
|
226
236
|
end
|
|
227
237
|
|
|
228
|
-
|
|
238
|
+
ModelMatch.parse(value)
|
|
229
239
|
end
|
|
230
240
|
|
|
231
241
|
def self.scopes_of(value, label)
|
|
@@ -10,6 +10,8 @@ require_relative "shell_git_dirs"
|
|
|
10
10
|
require_relative "read_only_shell"
|
|
11
11
|
require_relative "shell_paths"
|
|
12
12
|
require_relative "rm_targets"
|
|
13
|
+
require_relative "protected_paths"
|
|
14
|
+
require_relative "../memory_paths"
|
|
13
15
|
|
|
14
16
|
module Samagotchi
|
|
15
17
|
module Guardrails
|
|
@@ -65,6 +67,11 @@ module Samagotchi
|
|
|
65
67
|
attr_reader :memory_remove
|
|
66
68
|
alias memory_remove? memory_remove
|
|
67
69
|
|
|
70
|
+
# @return [Boolean] a memory_write call that writes a model overlay
|
|
71
|
+
# (current_model_only: true), for a rule's memory: overlay
|
|
72
|
+
attr_reader :memory_overlay
|
|
73
|
+
alias memory_overlay? memory_overlay
|
|
74
|
+
|
|
68
75
|
# @param call [Hash] the parsed tool call
|
|
69
76
|
# @param context [Context]
|
|
70
77
|
# @param model_key [String, nil] for a memory_write model overlay, and
|
|
@@ -104,8 +111,10 @@ module Samagotchi
|
|
|
104
111
|
label = given[:label]
|
|
105
112
|
end
|
|
106
113
|
memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
|
|
114
|
+
memory_overlay = tool == "memory_write" && !memory_remove && call[:current_model_only].to_s.strip.downcase == "true"
|
|
107
115
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
|
|
108
116
|
repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
|
|
117
|
+
memory_overlay: memory_overlay,
|
|
109
118
|
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
|
|
110
119
|
chi_dirs: -> { chi_dirs(context, base) })
|
|
111
120
|
end
|
|
@@ -209,10 +218,12 @@ module Samagotchi
|
|
|
209
218
|
|
|
210
219
|
# @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
|
|
211
220
|
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
|
|
212
|
-
model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false
|
|
221
|
+
model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false,
|
|
222
|
+
memory_overlay: false)
|
|
213
223
|
@acts_as = acts_as
|
|
214
224
|
@label = label
|
|
215
225
|
@memory_remove = memory_remove
|
|
226
|
+
@memory_overlay = memory_overlay
|
|
216
227
|
@repo = repo
|
|
217
228
|
@chi_dirs = chi_dirs
|
|
218
229
|
@session_root = session_root || repo_root || cwd
|
|
@@ -267,6 +278,34 @@ module Samagotchi
|
|
|
267
278
|
@read_only = !@command.nil? && ReadOnlyShell.read_only?(@command)
|
|
268
279
|
end
|
|
269
280
|
|
|
281
|
+
# A memory file that reaches the system prompt: the identity memory and
|
|
282
|
+
# the model notes (`model_notes_*`), with their overlays.
|
|
283
|
+
PROMPT_MEMORY = /\A(?:identity(?:\..+)?|model_notes_.+)\.md\z/
|
|
284
|
+
|
|
285
|
+
# Whether a path is a prompt-reaching memory file (.prompt_memory_path?),
|
|
286
|
+
# for a rule's memory: prompt.
|
|
287
|
+
def prompt_memory?
|
|
288
|
+
return @prompt_memory if defined?(@prompt_memory)
|
|
289
|
+
|
|
290
|
+
@prompt_memory = @paths.any? { |path| self.class.prompt_memory_path?(path) }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Whether +path+, resolved (symlinks, .., the case the disk has),
|
|
294
|
+
# lies right in a memories dir (the system scope or a project's) and
|
|
295
|
+
# its name, in any case, is a PROMPT_MEMORY one: on a case-insensitive
|
|
296
|
+
# disk MODEL_NOTES_x.md in Samagotchi/Memories is the same file chi
|
|
297
|
+
# loads.
|
|
298
|
+
def self.prompt_memory_path?(path)
|
|
299
|
+
real = ProtectedPaths.real(path)
|
|
300
|
+
return false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
|
|
301
|
+
|
|
302
|
+
dir = File.dirname(real)
|
|
303
|
+
dir.casecmp?(ProtectedPaths.real(MemoryPaths.system_dir)) ||
|
|
304
|
+
File.dirname(dir).casecmp?(ProtectedPaths.real(MemoryPaths.projects_dir))
|
|
305
|
+
rescue StandardError
|
|
306
|
+
false
|
|
307
|
+
end
|
|
308
|
+
|
|
270
309
|
# Whether the command names a path in chi's own dirs (ShellPaths:
|
|
271
310
|
# resolved paths; the text CHI_SHELL_TEXT for a word that can't be
|
|
272
311
|
# resolved); false without a command.
|
|
@@ -14,9 +14,12 @@ module Samagotchi
|
|
|
14
14
|
# Rules that ask about chi's own config and hooks: the core
|
|
15
15
|
# ProtectedPaths asks and the guardrails bundle's shell rule; and a
|
|
16
16
|
# command source for attached context (it runs later, ungated); chi
|
|
17
|
-
# broadcast, which is the user's
|
|
18
|
-
# (
|
|
19
|
-
|
|
17
|
+
# broadcast, which is the user's; the bundle's asks before a memory
|
|
18
|
+
# that reaches the system prompt, or a model overlay, is written (a
|
|
19
|
+
# parent model would be writing a model's prompt). Only the user may
|
|
20
|
+
# allow them (ParentApprovals).
|
|
21
|
+
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
|
|
22
|
+
model-overlay-write].freeze
|
|
20
23
|
SCOPES = %w[once session repo rule].freeze
|
|
21
24
|
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|
|
22
25
|
|
|
@@ -429,7 +429,12 @@ module Samagotchi
|
|
|
429
429
|
# streamed reply is not kept), so the caller can keep it, as a cancel's
|
|
430
430
|
# salvage does.
|
|
431
431
|
module FailedTurn
|
|
432
|
-
|
|
432
|
+
# +kept_steps+: set by the Engine when the turn's work stays
|
|
433
|
+
# (Engine#failed_messages): the tool results it got to; nil when it
|
|
434
|
+
# got to none, or failed in a way that rolls it back (#keeps_work?).
|
|
435
|
+
attr_accessor :partial_conversation, :kept_steps
|
|
436
|
+
|
|
437
|
+
TOOL_ROLES = %w[tool_response tool].freeze
|
|
433
438
|
|
|
434
439
|
# The innermost loop's conversation wins.
|
|
435
440
|
# @return [Exception] +error+
|
|
@@ -438,6 +443,33 @@ module Samagotchi
|
|
|
438
443
|
error.partial_conversation ||= conversation
|
|
439
444
|
error
|
|
440
445
|
end
|
|
446
|
+
|
|
447
|
+
# What a failed turn got to: +after+ (the conversation it leaves)
|
|
448
|
+
# against +before+ (the session's before it). A turn that added tool
|
|
449
|
+
# results did work worth keeping: its tool calls ran (files may have
|
|
450
|
+
# changed), and dropping them would leave the model blind to that. A
|
|
451
|
+
# model message alone isn't (a recovery nudge's, an unfinished
|
|
452
|
+
# call's). Counted by role, so notes that came meanwhile, the system
|
|
453
|
+
# head and reminders don't count.
|
|
454
|
+
# @return [Integer, nil] the tool results it added, or nil for none
|
|
455
|
+
def self.progress(before, after)
|
|
456
|
+
steps = count(after, TOOL_ROLES) - count(before, TOOL_ROLES)
|
|
457
|
+
steps.positive? ? steps : nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Whether a turn +error+ ended may keep its work. Not a context
|
|
461
|
+
# overflow: kept, the conversation would stay over the window, every
|
|
462
|
+
# later prompt failing the same way; it is rolled back, its prompt
|
|
463
|
+
# handed back, as before.
|
|
464
|
+
def self.keeps_work?(error) = !(error.respond_to?(:context_overflow?) && error.context_overflow?)
|
|
465
|
+
|
|
466
|
+
# The steps the session kept of the turn +error+ ended, or nil.
|
|
467
|
+
def self.kept_steps(error) = error.is_a?(self) && keeps_work?(error) ? error.kept_steps : nil
|
|
468
|
+
|
|
469
|
+
def self.count(messages, roles)
|
|
470
|
+
Array(messages).count { |m| roles.include?((m[:role] || m["role"]).to_s) }
|
|
471
|
+
end
|
|
472
|
+
private_class_method :count
|
|
441
473
|
end
|
|
442
474
|
end
|
|
443
475
|
end
|