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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +11 -6
- data/docs/cli.md +9 -3
- data/docs/configuration.md +62 -12
- data/docs/hooks.md +82 -5
- data/docs/internals/engine.md +50 -13
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +6 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +57 -4
- data/docs/sessions.md +7 -3
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +33 -5
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +101 -23
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +16 -8
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +8 -4
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +141 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/session.rb +53 -5
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/system_prompt.rb +71 -15
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/terminal_ui.rb +21 -5
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -0
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +15 -18
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +6 -3
- data/lib/samagotchi/web/public/ref_menu.js +9 -1
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +129 -15
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/turn_view.js +17 -7
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_signals.rb +81 -0
- 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
|
|
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
|
+
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:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.")
|
|
@@ -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.
|
|
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:
|
|
9
|
+
config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
|
|
10
10
|
memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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)
|