samagotchi 0.59.0 → 0.61.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +11 -6
  6. data/docs/cli.md +9 -3
  7. data/docs/configuration.md +62 -12
  8. data/docs/hooks.md +82 -5
  9. data/docs/internals/engine.md +50 -13
  10. data/docs/internals/gemma4-contract.md +1 -0
  11. data/docs/internals/prompt-caching.md +6 -3
  12. data/docs/memory.md +1 -1
  13. data/docs/plugins.md +57 -4
  14. data/docs/sessions.md +7 -3
  15. data/docs/sub-agent.md +2 -2
  16. data/docs/testing.md +4 -2
  17. data/lib/samagotchi/answer_display.rb +4 -3
  18. data/lib/samagotchi/answer_refs.rb +210 -0
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/card_store.rb +6 -1
  21. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  22. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  24. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  26. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  28. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  29. data/lib/samagotchi/config.rb +33 -5
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +101 -23
  32. data/lib/samagotchi/hooks/registry.rb +3 -1
  33. data/lib/samagotchi/interrupted_turn.rb +39 -0
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +40 -12
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +16 -8
  38. data/lib/samagotchi/llm/model_result.rb +11 -2
  39. data/lib/samagotchi/llm/native_backend.rb +3 -2
  40. data/lib/samagotchi/log_subscriber.rb +8 -4
  41. data/lib/samagotchi/plugin/context.rb +16 -3
  42. data/lib/samagotchi/prompt_files.rb +127 -0
  43. data/lib/samagotchi/ref.rb +141 -0
  44. data/lib/samagotchi/reply_wait.rb +6 -2
  45. data/lib/samagotchi/session.rb +53 -5
  46. data/lib/samagotchi/session_manager.rb +64 -6
  47. data/lib/samagotchi/session_metrics.rb +8 -2
  48. data/lib/samagotchi/session_setup.rb +11 -5
  49. data/lib/samagotchi/system_prompt.rb +71 -15
  50. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  51. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  52. data/lib/samagotchi/terminal_ui.rb +21 -5
  53. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  54. data/lib/samagotchi/turn_flow.rb +22 -3
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +5 -0
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +15 -18
  59. data/lib/samagotchi/web/public/favicon.svg +1 -0
  60. data/lib/samagotchi/web/public/index.html +6 -3
  61. data/lib/samagotchi/web/public/ref_menu.js +9 -1
  62. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  63. data/lib/samagotchi/web/public/refs.js +129 -15
  64. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  65. data/lib/samagotchi/web/public/turn_view.js +17 -7
  66. data/lib/samagotchi/worker.rb +169 -21
  67. data/lib/samagotchi/worker_signals.rb +81 -0
  68. metadata +11 -1
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "log"
5
+ require_relative "ref"
6
+
7
+ module Samagotchi
8
+ # One ref on the answer: the text it stands for (+match+, what the page
9
+ # pairs it with) and the Ref itself. +to_h+ is the stored and sent shape,
10
+ # {match:, kind:, target:, label:, actions:}.
11
+ AnswerRef = Data.define(:match, :ref) do
12
+ # @raise [ArgumentError] an empty match, one over MAX_MATCH, or a ref
13
+ # that isn't a Ref
14
+ def initialize(match:, ref:)
15
+ match = match.to_s.strip
16
+ raise ArgumentError, "a ref needs a match (the text it stands for)" if match.empty?
17
+ raise ArgumentError, "a ref's match is at most #{AnswerRef::MAX_MATCH} characters" if match.length > AnswerRef::MAX_MATCH
18
+ raise ArgumentError, "not a Ref" unless ref.is_a?(Ref)
19
+
20
+ super
21
+ end
22
+
23
+ def to_h = { match: match, **ref.to_h }
24
+ end
25
+
26
+ class AnswerRef
27
+ MAX_MATCH = 500
28
+
29
+ # A hook's {match:, kind:, target:, label:, actions:}, or a stored
30
+ # entry (String or Symbol keys, nested ones too). A file ref's relative
31
+ # path is resolved against +cwd+, so the stored path is absolute; with
32
+ # no +cwd+ it is refused. An absolute path stays as given.
33
+ # @raise [ArgumentError] anything invalid
34
+ def self.from(value, cwd: nil)
35
+ raise ArgumentError, "a ref is a Hash {match:, kind:, target:, label:, actions:}" unless value.is_a?(Hash)
36
+
37
+ value = value.transform_keys(&:to_sym)
38
+ ref = Ref.new(kind: value[:kind], target: value[:target], label: value[:label], actions: value[:actions] || [])
39
+ new(match: value[:match], ref: absolute(ref, cwd))
40
+ end
41
+
42
+ def self.absolute(ref, cwd)
43
+ return ref unless ref.kind == Ref::FILE
44
+
45
+ path = ref.target[:path]
46
+ return ref if File.absolute_path?(path)
47
+ raise ArgumentError, "relative path, no session folder" if cwd.to_s.empty?
48
+
49
+ # absolute_path, not expand_path: a leading ~ is a name, never a home.
50
+ Ref.new(kind: ref.kind, target: ref.target.merge(path: File.absolute_path(path, cwd.to_s)), label: ref.label,
51
+ actions: ref.actions)
52
+ end
53
+ private_class_method :absolute
54
+ end
55
+
56
+ # The refs after_turn hooks attach to a turn's answer (event[:refs]),
57
+ # kept as `display_refs` on the answer's model message next to its
58
+ # `display` (AnswerDisplay). Display only, like `display`: the model
59
+ # never sees them (AnswerDisplay::DISPLAY_ONLY), and a UI pairs each with
60
+ # its +match+ in the shown answer (or drops it).
61
+ #
62
+ # The target is AnswerDisplay's: the stored conversation's last message
63
+ # when it is the model's. The list starts from the refs it already has
64
+ # (re-validated), so a later hook run never loses them.
65
+ class AnswerRefs
66
+ KEY = :display_refs
67
+ MAX_REFS = 200
68
+ # The list's JSON; a ref that would cross it is refused.
69
+ MAX_BYTES = 256 * 1024
70
+
71
+ # Stored refs (+raw+, as read from session.json or a snapshot), each
72
+ # rebuilt and checked again, the caps applied; what isn't valid any
73
+ # more is dropped. Never raises: a reload must show the conversation.
74
+ # A relative file path is dropped (there is no folder to resolve it).
75
+ # @return [Array<Hash>] the refs to send ({match:, kind:, target:, …})
76
+ def self.for_display(raw)
77
+ return [] if raw.nil?
78
+
79
+ kept = new([{ role: "model", KEY => raw }], cwd: nil).to_a
80
+ dropped = raw.is_a?(Array) ? raw.length - kept.length : 1
81
+ Log.debug(:web, "display_refs_dropped", count: dropped) if dropped.positive?
82
+ kept
83
+ rescue StandardError
84
+ []
85
+ end
86
+
87
+ # @return [Hash, nil] the message event[:refs] adds to
88
+ attr_reader :target
89
+
90
+ # @param messages [Array<Hash>] the conversation the turn stored
91
+ # @param cwd [String, nil] the session's folder, for relative file paths
92
+ def initialize(messages, cwd:)
93
+ last = Array(messages).last
94
+ @target = last if last.is_a?(Hash) && (last[:role] || last["role"]).to_s == "model"
95
+ @cwd = cwd
96
+ @refs = []
97
+ @seen = {}
98
+ @bytes = 2 # the list's []
99
+ @added = false
100
+ @closed = false
101
+ @rejections = Hash.new(0)
102
+ seed(@target) if @target
103
+ end
104
+
105
+ # True when a hook added a ref the answer didn't have.
106
+ def added? = @added
107
+
108
+ def count = @refs.length
109
+
110
+ # @return [Array<Hash>] the refs as stored ({match:, kind:, target:, …})
111
+ def to_a = @refs.dup
112
+
113
+ # The list is stored (Engine#store_answer_display): a later call (a
114
+ # plugin that kept event[:refs]) would be lost, so it returns nil.
115
+ def close! = @closed = true
116
+
117
+ def closed? = @closed
118
+
119
+ # event[:refs]: call it with the ref's keywords,
120
+ # +call(match:, kind:, target:, label: nil, actions: [])+.
121
+ # @param event [Hash] the after_turn event (for its :hook label)
122
+ # The hook's label is read at call time: one adder serves every hook of
123
+ # the fire, and the registry sets event[:hook] before each.
124
+ # @return [Proc] true when the ref is kept (or was already there), false
125
+ # when it is refused (logged, named by event[:hook]), nil when the
126
+ # turn has no answer or the list was already stored (#close!)
127
+ def adder(event)
128
+ lambda do |*args, **ref|
129
+ next nil unless @target
130
+ next late(event[:hook]) if @closed
131
+ next reject(event[:hook], "takes keywords (match:, kind:, target:, label:, actions:)") unless args.empty?
132
+
133
+ add(ref, event[:hook])
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ KEYS = %i[match kind target label actions].freeze
140
+ REASON_VALUES = /\(.*?\)|\[.*?\]|".*?"|\d+/
141
+ private_constant :KEYS, :REASON_VALUES
142
+
143
+ def seed(target)
144
+ raw = target[KEY] || target[KEY.to_s]
145
+ raw.each { |entry| keep(entry) } if raw.is_a?(Array)
146
+ end
147
+
148
+ def add(value, hook)
149
+ unknown = value.keys - KEYS
150
+ return reject(hook, "has unknown keys #{unknown.map(&:to_s).inspect}") unless unknown.empty?
151
+
152
+ entry = AnswerRef.from(value, cwd: @cwd).to_h
153
+ return true if @seen.key?(entry)
154
+ return over_cap(hook, "#{MAX_REFS} refs") if @refs.length >= MAX_REFS
155
+
156
+ size = JSON.generate(entry).bytesize + (@refs.empty? ? 0 : 1)
157
+ return over_cap(hook, "#{MAX_BYTES / 1024} KiB") if @bytes + size > MAX_BYTES
158
+
159
+ push(entry, size)
160
+ @added = true
161
+ rescue StandardError => e
162
+ reject(hook, e.message)
163
+ end
164
+
165
+ # A stored entry back in the list, checked again (a relative path is
166
+ # dropped: it was stored absolute). Duplicates and what is over the
167
+ # caps go quietly.
168
+ # @return [Boolean] kept
169
+ def keep(raw)
170
+ entry = AnswerRef.from(raw, cwd: nil).to_h
171
+ return false if @seen.key?(entry) || @refs.length >= MAX_REFS
172
+
173
+ size = JSON.generate(entry).bytesize + (@refs.empty? ? 0 : 1)
174
+ return false if @bytes + size > MAX_BYTES
175
+
176
+ push(entry, size)
177
+ true
178
+ rescue StandardError
179
+ false
180
+ end
181
+
182
+ def push(entry, size)
183
+ @refs << entry
184
+ @seen[entry] = true
185
+ @bytes += size
186
+ end
187
+
188
+ def over_cap(hook, cap) = reject(hook, "over the cap (#{cap})")
189
+
190
+ def late(hook)
191
+ Log.debug(:hooks, "refs_late", hook: hook.to_s)
192
+ nil
193
+ end
194
+
195
+ # Each reason is said once per hook per answer (a warn and its echo);
196
+ # the same again goes to debug with its count, so a hook looping over
197
+ # bad refs can't flood the terminal. The reason's values (a kind, a
198
+ # key list, a cap) don't make it a new one.
199
+ def reject(hook, why)
200
+ count = @rejections[[hook.to_s, why.gsub(REASON_VALUES, "")]] += 1
201
+ if count == 1
202
+ Log.warn(:hooks, "refs_rejected", echo: "[samagotchi:hooks] #{hook}: ref #{why}; dropped",
203
+ hook: hook.to_s, reason: why)
204
+ else
205
+ Log.debug(:hooks, "refs_rejected", hook: hook.to_s, reason: why, count: count)
206
+ end
207
+ false
208
+ end
209
+ end
210
+ end
@@ -41,7 +41,8 @@ module Samagotchi
41
41
  With no config.yml it writes one; an existing one gets a hosts:
42
42
  entry added (after a backup), and its other lines stay as they are.
43
43
  Then it installs the system bundle and the core bundles (loop-guard,
44
- check-in, guardrails), and on a terminal offers the dev bundles.
44
+ check-in, guardrails, slow-commands, agents-md), and on a terminal
45
+ offers the dev bundles.
45
46
  TEXT
46
47
 
47
48
  FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
@@ -45,7 +45,11 @@ module Samagotchi
45
45
  # its own), and a question still open when the worker went waits for no
46
46
  # one: neither is saved or seeded. Same caps as in memory. An entry from
47
47
  # an earlier worker lists with +earlier+: true (an attached TUI's resync
48
- # onto a new worker doesn't print those again).
48
+ # onto a new worker doesn't print those again). An earlier card shown
49
+ # during a turn (+in_turn+) comes back without its actions: they belong
50
+ # to a turn no worker runs any more (check-in's Nudge would steer the
51
+ # new worker's turn). A card shown between turns (github-pr's offer,
52
+ # btw, coordinator) keeps its buttons.
49
53
  class CardStore
50
54
  CAPACITY = 20
51
55
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
@@ -137,6 +141,7 @@ module Samagotchi
137
141
  next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
138
142
 
139
143
  entry = entry.merge(type: entry[:type].to_sym, earlier: true)
144
+ entry = entry.merge(actions: []) if entry[:type] == :card && entry[:in_turn] && entry.key?(:actions)
140
145
  entry unless entry[:type] == :guardrail_warning || open_question?(entry)
141
146
  end
142
147
  settle_during(after_turn: false)
@@ -118,6 +118,7 @@ module Samagotchi
118
118
  return [] unless turn
119
119
 
120
120
  started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
121
+ started[:resumed] = true if turn[:resumed]
121
122
  started[:started_at] = started_at if started_at
122
123
  events = [with_images(started, turn[:images])]
123
124
  text_iteration = nil # a text part is open for this iteration
@@ -238,6 +239,7 @@ module Samagotchi
238
239
  dequeue([event[:origin]])
239
240
  @turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
240
241
  parts: [], pending_question: nil }
242
+ @turn[:resumed] = true if event[:resumed]
241
243
  @tool_started_at = {}
242
244
  @turn[:images] = event[:images] if event[:images]
243
245
  @turn[:cut_after] = event[:cut_after] if event[:cut_after]
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: agents-md
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "Steers a nested AGENTS.md into the conversation the first time a tool touches a file under its directory"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:fba1b4b48fa99299aa4c5b56999f5083513f7eb22d85f3fd2a5c2dbd0d97c707
10
+ # ctx.prompt_files, read when the plugin loads (0.60.0)
11
+ requires_chi: ">= 0.60.0"
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The agents-md bundle (docs/plugins.md, The agents-md bundle): a nested
4
+ # AGENTS.md of the session's checkout is steered into the conversation the
5
+ # first time a tool touches a file under its directory.
6
+ #
7
+ # chi's system prompt holds only the AGENTS.md chain from the checkout top
8
+ # down to the session's cwd (SystemPrompt#prompt_files), so an AGENTS.md in
9
+ # a directory the session doesn't run in (pkg/inner/AGENTS.md while the
10
+ # session runs in pkg/) is never seen. Working on a nested file usually
11
+ # means its directory's rules apply, so the first ok `read`, `write` or
12
+ # `edit` (settings tools) of such a file steers the nearest AGENTS.md into
13
+ # the running turn, at the loop's next boundary, as its own user message:
14
+ #
15
+ # AGENTS.md for pkg/inner/ (applies to work under pkg/inner/):
16
+ # <content>
17
+ #
18
+ # It is history, not system prompt: the prompt cache is untouched, and a
19
+ # resume, a woken worker or a fork (which copies the messages) never
20
+ # re-injects — a before_turn pass over event[:messages] replaces the loaded
21
+ # set with the dirs whose steer is already in the conversation. A steer
22
+ # that was queued but dropped (the model answered first) is not in the
23
+ # messages, so it fires again on the next touch. It never stops generation
24
+ # or thinking; a plugin steer can't cut (SteerCut#cut_for_steer).
25
+ #
26
+ # Paths are compared by File.realpath, falling back to File.expand_path for
27
+ # a path that doesn't exist: the session's cwd can be /tmp/x on macOS while
28
+ # git and the prompt's files give /private/tmp/x. The touched file's own git
29
+ # root must equal the session's checkout (ctx.repo_root): a file in another
30
+ # repository, a nested repository or a submodule is ignored, and the walk up
31
+ # stops at that root. Directories whose AGENTS.md is already in the system
32
+ # prompt (ctx.prompt_files: the chain and appended files) or already steered
33
+ # are skipped.
34
+ #
35
+ # Settings (config.yml, bundles: agents-md:):
36
+ # tools: [read, write, edit] the calls that count as a touch
37
+ # max_chars: 16000 the content is cut at this many chars, the
38
+ # last line "[cut at N of M chars: read <path>
39
+ # for the rest]" naming the file by its absolute path
40
+ #
41
+ # skip_agents_md: true (top-level) disables the plugin, as it does the
42
+ # chain's loading.
43
+
44
+ require "samagotchi/config"
45
+ require "samagotchi/guardrails/context"
46
+
47
+ class Plugin
48
+ FILE_NAME = "AGENTS.md"
49
+ DEFAULT_TOOLS = %w[read write edit].freeze
50
+ DEFAULT_MAX_CHARS = 16_000
51
+ # The marker on the steer's first line (and what #rebuild matches):
52
+ # <rel dir>/ is relative to the checkout top, so a dir is unique.
53
+ MARKER = %r{\AAGENTS\.md for (.+)/ \(applies to work under \1/\):\n}
54
+
55
+ def initialize(settings = {})
56
+ tools = settings["tools"]
57
+ @tools = if tools.nil?
58
+ DEFAULT_TOOLS
59
+ elsif tools.is_a?(Array)
60
+ tools.map(&:to_s)
61
+ else
62
+ tools.to_s.split(/\s+/).reject(&:empty?)
63
+ end
64
+ chars = settings["max_chars"]
65
+ chars = chars.to_i if chars
66
+ @max_chars = chars.is_a?(Integer) && chars.positive? ? chars : DEFAULT_MAX_CHARS
67
+ @git = Samagotchi::Guardrails::GitInfo.new
68
+ # session_id => {rel dir => true}: the dirs steered this session.
69
+ @loaded = Hash.new { |hash, id| hash[id] = {} }
70
+ @lock = Mutex.new
71
+ end
72
+
73
+ def register(chi)
74
+ # Replaces the loaded set from the conversation: a dropped steer fires
75
+ # again on the next touch, a resume or a fork rebuilds from its messages.
76
+ chi.on(:before_turn) { |event, ctx| rebuild(event, ctx) }
77
+ chi.on(:after_tool_call) { |event, ctx| on_tool(event, ctx) }
78
+ end
79
+
80
+ # An ok touch of a file under a directory with a nested AGENTS.md steers
81
+ # each candidate (the file's directory up to, not including, the checkout
82
+ # top; outermost first) once per session.
83
+ def on_tool(event, ctx)
84
+ return if Samagotchi::Config.get("skip_agents_md") == true
85
+ return unless @tools.include?(event[:tool].to_s)
86
+ return unless event[:status].to_s == "ok"
87
+
88
+ call = event[:call] || {}
89
+ # The path a hook sees (docs/hooks.md): read on content:, write/edit on
90
+ # path:, expanded against the session's cwd.
91
+ raw = (event[:tool].to_s == "read" ? call[:content] : call[:path]).to_s.strip
92
+ return if raw.empty?
93
+
94
+ path = resolve(raw, base: ctx.cwd)
95
+ top = resolve(ctx.repo_root)
96
+ return if top.nil?
97
+
98
+ # Scope: the file's own git root must be the session's checkout (a
99
+ # nested repository, a submodule or another repository is ignored).
100
+ file_root = resolve(@git.root(File.dirname(path)))
101
+ return unless file_root && file_root == top
102
+
103
+ prompt_files = Set.new(ctx.prompt_files.map { |file| resolve(file) })
104
+ count = 0
105
+ candidate_dirs(path, top).reverse_each do |dir|
106
+ agents = File.join(dir, FILE_NAME)
107
+ next unless File.file?(agents)
108
+ next if prompt_files.include?(resolve(agents)) # in the system prompt already
109
+ next if loaded(ctx).key?(relative_dir(dir, top))
110
+
111
+ text = steer_text(dir, top, agents)
112
+ next unless text
113
+
114
+ # Queued: the dir is loaded; a steer dropped before it was injected
115
+ # (the model answered first) is not queued, so it fires again later.
116
+ count += 1 if ctx.steer(text) && (loaded(ctx)[relative_dir(dir, top)] = true)
117
+ end
118
+ ctx.log.info(:agents_md_steered, file: File.basename(path), n: count) if count.positive?
119
+ end
120
+
121
+ private
122
+
123
+ # The dirs between +path+'s directory and +top+, file's directory first
124
+ # (the caller reverses for outermost-first).
125
+ def candidate_dirs(path, top)
126
+ dirs = []
127
+ dir = File.dirname(path)
128
+ prefix = top.end_with?("/") ? top : "#{top}/"
129
+ while dir != top && dir.start_with?(prefix)
130
+ dirs << dir
131
+ parent = File.dirname(dir)
132
+ break if parent == dir
133
+
134
+ dir = parent
135
+ end
136
+ dirs
137
+ end
138
+
139
+ # +dir+ relative to +top+ ("." for +top+ itself), or nil when it isn't under it.
140
+ def relative_dir(dir, top)
141
+ prefix = top.end_with?("/") ? top : "#{top}/"
142
+ return "." if dir == top
143
+ return nil unless dir.start_with?(prefix)
144
+
145
+ dir.delete_prefix(prefix)
146
+ end
147
+
148
+ # The steer: the marker first line (what #rebuild matches), the content,
149
+ # cut at max_chars with a pointer at the end. Nil when there is nothing to say.
150
+ def steer_text(dir, top, agents)
151
+ content = File.read(agents, encoding: "UTF-8")
152
+ return nil if content.strip.empty?
153
+
154
+ rel_dir = relative_dir(dir, top)
155
+ header = "AGENTS.md for #{rel_dir}/ (applies to work under #{rel_dir}/):"
156
+ return "#{header}\n#{content}" if content.length <= @max_chars
157
+
158
+ body = content[0...@max_chars]
159
+ "#{header}\n#{body}\n[cut at #{@max_chars} of #{content.length} chars: read #{agents} for the rest]"
160
+ rescue SystemCallError, EncodingError
161
+ nil
162
+ end
163
+
164
+ # Replaces the loaded set with the dirs whose steer the conversation
165
+ # already carries (a user message, kind steer, source agents-md, the
166
+ # marker on its first line).
167
+ def rebuild(event, ctx)
168
+ id = event[:session_id] || ctx.session_id
169
+ found = {}
170
+ Array(event[:messages]).each do |message|
171
+ next unless message.is_a?(Hash)
172
+ next unless (message[:role] || message["role"]).to_s == "user"
173
+ next unless (message[:kind] || message["kind"]).to_s == "steer"
174
+ next unless (message[:source] || message["source"]).to_s == "agents-md"
175
+
176
+ if (match = MARKER.match((message[:content] || message["content"]).to_s))
177
+ found[match[1]] = true
178
+ end
179
+ end
180
+ @lock.synchronize { @loaded[id] = found }
181
+ end
182
+
183
+ # +path+ (relative ones against +base+) by File.realpath, falling back to
184
+ # File.expand_path when it doesn't exist; nil for an empty one.
185
+ def resolve(path, base: nil)
186
+ path = path.to_s
187
+ return nil if path.empty?
188
+
189
+ path = File.expand_path(path, base) if base && !File.absolute_path?(path)
190
+ File.realpath(path)
191
+ rescue SystemCallError
192
+ File.expand_path(path, base || Dir.pwd)
193
+ end
194
+
195
+ def loaded(ctx)
196
+ @lock.synchronize { @loaded[ctx.session_id] }
197
+ end
198
+ end
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.2.3
3
+ version: 0.2.4
4
4
  scope: system
5
5
  description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:821aea2821df12f278db9a94f8150244e67bf9453735d19a5abe9d94253eefa6
9
+ sha256: sha256:42fe075c664d256ecb8623b76a25d5c389e42c0534a41c56fe17b073d521e095
10
10
  requires_chi: ">= 0.3.0"
@@ -11,6 +11,10 @@
11
11
  # final answer is dropped: the card's Nudge then says so at the turn's end
12
12
  # (after_turn's messages have no steer with its text).
13
13
  #
14
+ # One nudge per check-in: a second Nudge (a repeat click, a stale card's
15
+ # button, a typed /checkin nudge) for the same check-in only says it was
16
+ # already nudged; before the turn's first check-in, one nudge in all.
17
+ #
14
18
  # The count is per turn: before_turn resets it (and closes a card left open
15
19
  # by a turn that failed or was interrupted, where after_turn doesn't fire);
16
20
  # steering merged mid-turn does not. The /checkin commands are anytime ones
@@ -76,6 +80,8 @@ class Plugin
76
80
  @card_id = nil # this turn's card, once shown
77
81
  @card_open = false
78
82
  @nudge = nil # the card's Nudge this turn: { text:, count: }
83
+ @checked_in = nil # the count at this turn's latest check-in
84
+ @nudged_for = nil # the check-in (its count, :none before one) nudged
79
85
  end
80
86
 
81
87
  def before_turn(ctx)
@@ -97,6 +103,7 @@ class Plugin
97
103
  next nil unless @enabled && @count >= @next_at
98
104
 
99
105
  @next_at = @count + @every
106
+ @checked_in = @count
100
107
  [@count, @mode]
101
108
  end
102
109
  check_in(ctx, *due) if due
@@ -107,7 +114,10 @@ class Plugin
107
114
  when "nudge"
108
115
  text = message(count)
109
116
  if ctx.steer(text)
110
- @mutex.synchronize { @nudge = { text: text, count: count } }
117
+ @mutex.synchronize do
118
+ @nudge = { text: text, count: count }
119
+ @nudged_for = count
120
+ end
111
121
  ctx.notify("nudged the model after #{count} tool calls")
112
122
  end
113
123
  when "notify"
@@ -232,10 +242,24 @@ class Plugin
232
242
  "check-in after #{n} tool calls, then every #{n}, for this session"
233
243
  end
234
244
 
245
+ # At most one nudge per check-in; the slot is taken before the steer (two
246
+ # clicks race on the anytime command's threads) and given back when no
247
+ # turn took it.
235
248
  def nudge(ctx)
236
- count = calls
249
+ count, key, earlier = @mutex.synchronize do
250
+ key = @checked_in || :none
251
+ next [nil, nil, @nudge&.dig(:count) || @count] if @nudged_for == key
252
+
253
+ @nudged_for = key
254
+ [@count, key, nil]
255
+ end
256
+ return "check-in: already nudged at #{earlier} tool calls" if earlier
257
+
237
258
  text = message(count)
238
- return "check-in: no turn running" unless ctx.steer(text)
259
+ unless ctx.steer(text)
260
+ @mutex.synchronize { @nudged_for = nil if @nudged_for == key }
261
+ return "check-in: no turn running"
262
+ end
239
263
 
240
264
  @mutex.synchronize { @nudge = { text: text, count: count } }
241
265
  close_card(ctx, "Nudged the model at #{count} tool calls.")
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: core
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
@@ -10,3 +10,4 @@ includes:
10
10
  - check-in
11
11
  - guardrails
12
12
  - slow-commands
13
+ - agents-md
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
121
121
  1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
122
122
  2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
123
123
  3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
124
- 4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
124
+ 4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:`/`append_system_prompt:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
125
125
  5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
126
126
  6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
127
127
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.59.0
3
+ version: 0.61.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:44a35f59baf44202972c9250989daa50eb978ab2a1967159e622c74ddd9c736c
9
+ config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
10
10
  memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -223,7 +223,7 @@ module Samagotchi
223
223
  Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
224
224
  # A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
225
225
  # --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
226
- Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
226
+ Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 300, expose: %i[env config]),
227
227
  # Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
228
228
  # config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
229
229
  Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
@@ -272,10 +272,10 @@ module Samagotchi
272
272
  Entry.new(key: "skip_agents_md", yaml_path: %w[skip_agents_md], type: :bool, default: false, expose: %i[env config])
273
273
  ].freeze
274
274
 
275
- # Top-level maps read by their own code, whose entry names and contents
276
- # are the user's: ConfigFile.model_aliases / preloaded_memories,
277
- # Hooks::Loader, Engine#read_bundle_settings.
278
- FREE_FORM_MAPS = %w[model_aliases hooks bundles memories].freeze
275
+ # Top-level maps and lists read by their own code, whose entry names and
276
+ # contents are the user's: ConfigFile.model_aliases / preloaded_memories /
277
+ # appended_prompt_files, Hooks::Loader, Engine#read_bundle_settings.
278
+ FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
279
279
  # Maps of named entries and the keys an entry may hold
280
280
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
281
281
  MAP_ENTRY_KEYS = {
@@ -987,6 +987,34 @@ module Samagotchi
987
987
  []
988
988
  end
989
989
 
990
+ # Resolve the `append_system_prompt:` list: files whose text goes into
991
+ # the system prompt after AGENTS.md (SystemPrompt). A YAML list of
992
+ # strings or a single string; ~ is expanded, a relative entry is kept as
993
+ # written (resolved against the cwd the prompt is built in). [] when
994
+ # the key is absent or malformed.
995
+ # @return [Array<String>]
996
+ def appended_prompt_files(env: ENV, path: global_path(env: env))
997
+ data = read_yaml(env: env, path: path)
998
+ raw = data["append_system_prompt"] if data.is_a?(Hash)
999
+ items = case raw
1000
+ when Array then raw
1001
+ when String then [raw]
1002
+ else []
1003
+ end
1004
+ items.select { |v| v.is_a?(String) }.map(&:strip).reject(&:empty?).filter_map { |v| expand_appended_entry(v) }
1005
+ rescue StandardError
1006
+ []
1007
+ end
1008
+
1009
+ # An append_system_prompt: entry with ~ expanded; nil (skipped, logged)
1010
+ # for a ~user that doesn't exist, so the other entries still load.
1011
+ def expand_appended_entry(entry)
1012
+ entry.start_with?("~") ? File.expand_path(entry) : entry
1013
+ rescue ArgumentError => e
1014
+ Samagotchi::Log.warn(:config, "appended_prompt_file_skipped", entry: entry, error: e.message) if defined?(Samagotchi::Log)
1015
+ nil
1016
+ end
1017
+
990
1018
  # Parse a model string that may be qualified as "host_alias:model"
991
1019
  # Returns [host_alias_or_nil, bare_model] (ModelRef.split)
992
1020
  def parse_host_qualified_model(raw, hosts: nil)