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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +25 -2
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +90 -1
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bridge/card_store.rb +6 -7
  12. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  13. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  17. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  18. data/lib/samagotchi/config.rb +3 -0
  19. data/lib/samagotchi/engine.rb +91 -16
  20. data/lib/samagotchi/events.rb +8 -1
  21. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  22. data/lib/samagotchi/guardrails/rules.rb +27 -17
  23. data/lib/samagotchi/guardrails/targets.rb +40 -1
  24. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  25. data/lib/samagotchi/llm/errors.rb +33 -1
  26. data/lib/samagotchi/llm_context_command.rb +151 -0
  27. data/lib/samagotchi/llm_context_override.rb +180 -0
  28. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  29. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  31. data/lib/samagotchi/model_match.rb +54 -0
  32. data/lib/samagotchi/model_notes.rb +202 -0
  33. data/lib/samagotchi/parent_report.rb +12 -2
  34. data/lib/samagotchi/plugin/context.rb +3 -2
  35. data/lib/samagotchi/plugin/sessions.rb +3 -1
  36. data/lib/samagotchi/reply_wait.rb +3 -17
  37. data/lib/samagotchi/self_report.rb +15 -0
  38. data/lib/samagotchi/send_command.rb +22 -2
  39. data/lib/samagotchi/session.rb +29 -4
  40. data/lib/samagotchi/session_commands.rb +21 -1
  41. data/lib/samagotchi/session_manager.rb +5 -2
  42. data/lib/samagotchi/session_metrics.rb +26 -3
  43. data/lib/samagotchi/system_prompt.rb +66 -11
  44. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
  46. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  47. data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
  48. data/lib/samagotchi/terminal_ui.rb +16 -6
  49. data/lib/samagotchi/tool_runner.rb +26 -1
  50. data/lib/samagotchi/tools/memory.rb +70 -3
  51. data/lib/samagotchi/turn_flow.rb +36 -8
  52. data/lib/samagotchi/turn_note.rb +40 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +20 -1
  55. data/lib/samagotchi/web/public/app.js +40 -6
  56. data/lib/samagotchi/web/public/ctx.js +28 -5
  57. data/lib/samagotchi/web/public/format.js +5 -2
  58. data/lib/samagotchi/web/public/index.html +12 -0
  59. data/lib/samagotchi/web/public/live_turn.js +1 -0
  60. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  61. data/lib/samagotchi/web/public/session_state.js +3 -0
  62. data/lib/samagotchi/web/public/timing.js +5 -1
  63. data/lib/samagotchi/web/session_summary.rb +2 -0
  64. data/lib/samagotchi/worker.rb +24 -18
  65. 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. A model-only note is an overlay on an existing
81
- entry (`current_model_only: true`), and it needs that base entry to exist.
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`.
@@ -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
@@ -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 }, memories: memories,
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
- when :turn_failed then { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable] }.compact
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: turn_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 (a UI that rolls the
1952
- # turn back leaves its own note, TurnFlow#prompt_turn_failed).
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 effective model's LLM context strategy (models:/hosts:
2533
- # llm_context_strategy, else llm_context.strategy), read per turn as
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
- target = @host_registry.resolve(@effective_model_name)
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.
@@ -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
- entries = (setting.nil? ? "auto" : setting.to_s).split("|").map(&:strip).reject(&:empty?)
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
- [name, key].compact.any? { |candidate| File.fnmatch(entry, candidate.to_s, GLOB_FLAGS) }
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: the rule only votes for a model that matches
24
- # (no model matches none; missing = every model)
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.memory_remove?
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
- return false if name.nil? || name.empty?
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
- # Only "remove" for now.
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 == "remove"
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
- names.map(&:strip)
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. Only the user may allow them
18
- # (ParentApprovals).
19
- PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast].freeze
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
- attr_accessor :partial_conversation
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