samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded through module_eval: it requires what it uses.
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
# An after_turn hook that links the source refs the model's answer
|
|
7
|
+
# mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
|
|
8
|
+
# web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
|
|
9
|
+
# through event[:present]: display only, the model's text stays as it was,
|
|
10
|
+
# and it survives a reload). Every UI also gets one line right after the
|
|
11
|
+
# turn, the terminals' only view of the links:
|
|
12
|
+
#
|
|
13
|
+
# sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 → https://myjira.com/browse/JIRA-10
|
|
14
|
+
#
|
|
15
|
+
# Sources are configured patterns (config.yml, `bundles: source-links:`),
|
|
16
|
+
# so JIRA is just the first entry; any source can be added:
|
|
17
|
+
#
|
|
18
|
+
# bundles:
|
|
19
|
+
# source-links:
|
|
20
|
+
# sources:
|
|
21
|
+
# - name: JIRA
|
|
22
|
+
# prefix: JIRA # simple form: \bJIRA-(\d+)\b
|
|
23
|
+
# base_url: https://myjira.com/browse/
|
|
24
|
+
# - name: GitHub
|
|
25
|
+
# pattern: '\bGH-(\d+)\b' # full form: a regex
|
|
26
|
+
# url: 'https://github.com/org/repo/issues/{match}'
|
|
27
|
+
# case_insensitive: false # optional, default false
|
|
28
|
+
# - name: Issues # `#12` → this project's repo,
|
|
29
|
+
# # `owner/repo#12` → that repo
|
|
30
|
+
# pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w))?#(?<num>\d+)\b'
|
|
31
|
+
# url: 'https://github.com/{repo}/issues/{num}'
|
|
32
|
+
# remote: origin # optional: the git remote {repo}/{host}
|
|
33
|
+
# # come from, default origin
|
|
34
|
+
# remote_host: github.com # optional: a host or a list; else a
|
|
35
|
+
# # remote on another host links nothing
|
|
36
|
+
# max: 10 # optional: refs per line, default 10
|
|
37
|
+
# note: false # optional: no sources line (the web
|
|
38
|
+
# # links stay), default true
|
|
39
|
+
#
|
|
40
|
+
# A `url:` template takes {match} (group 1, else the whole ref), {1}…{9}
|
|
41
|
+
# (numbered groups), {name} (named groups), and {repo}/{host}: the named
|
|
42
|
+
# group when it took part, else the project's (Dir.pwd's) git remote, asked
|
|
43
|
+
# of git once per worker. A ref with a placeholder that can't be filled is
|
|
44
|
+
# not linked; a `{word}` that is none of these stays text (warned at load).
|
|
45
|
+
#
|
|
46
|
+
# The note is not stored in the conversation: it is an event, replayed by a
|
|
47
|
+
# UI only while the session's worker lives (a reload keeps it; a stopped
|
|
48
|
+
# worker loses it). The links are stored as the answer's display. A ref in
|
|
49
|
+
# code (a `span` or a fenced block) or in a markdown link is not linked in
|
|
50
|
+
# the answer. The hook is inert until sources are configured.
|
|
51
|
+
class SourceLinks
|
|
52
|
+
# The answer is scanned only up to this many characters: the primary
|
|
53
|
+
# ReDoS guard, bounding the input a user-supplied regex can chew on.
|
|
54
|
+
MAX_SCAN = 20_000
|
|
55
|
+
# Refs per line before the "… +N more" tail.
|
|
56
|
+
DEFAULT_MAX = 10
|
|
57
|
+
# Per-regex timeout (Ruby 3.2+): a catastrophic pattern is abandoned
|
|
58
|
+
# instead of hanging the turn. No global Regexp.timeout is touched. The
|
|
59
|
+
# timeout is per match attempt, not per scan; MAX_SCAN bounds the total.
|
|
60
|
+
REGEX_TIMEOUT = 0.5
|
|
61
|
+
# A bare URL (scheme-anchored, so it can't start inside a markdown label),
|
|
62
|
+
# and a markdown link (label in group 1, target in group 2).
|
|
63
|
+
URL_SPAN = %r{[a-z][a-z0-9+.\-]*://\S+}i
|
|
64
|
+
MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
|
|
65
|
+
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
|
|
66
|
+
FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
|
|
67
|
+
# A `{word}` or `{N}` placeholder in a `url:` template.
|
|
68
|
+
PLACEHOLDER = /\{(\w+)\}/
|
|
69
|
+
# Placeholders every pattern has: {match} (group 1, else the whole ref),
|
|
70
|
+
# and {repo}/{host} (a named group, else the project's git remote).
|
|
71
|
+
BUILTIN_PLACEHOLDERS = %w[match repo host].freeze
|
|
72
|
+
# `git remote get-url` output: a URL with a scheme, `[user[:pw]@]host[:port]/path`.
|
|
73
|
+
REMOTE_URL = %r{\A(?:https?|ssh|git)://(?:[^/]*@)?(\[[^\]]*\]|[^/:@]+)(?::[^/]*)?(/.*)?\z}i
|
|
74
|
+
# scp-like `[user@]host:path`: a colon before any slash; a leading `/` or
|
|
75
|
+
# `.` is a local path.
|
|
76
|
+
REMOTE_SCP = %r{\A(?:[^@/:]+@)?([^/:.@][^/:@]*):(.*)\z}
|
|
77
|
+
|
|
78
|
+
# The {host:, repo:} a git remote URL names, or nil (a local path,
|
|
79
|
+
# `file://`, an empty path, or a path with an empty or dot segment).
|
|
80
|
+
def self.parse_remote_url(url)
|
|
81
|
+
url = url.to_s.strip
|
|
82
|
+
# Any other scheme (file://, a transport helper's) is not a web host.
|
|
83
|
+
match = url.include?("://") ? url.match(REMOTE_URL) : url.match(REMOTE_SCP)
|
|
84
|
+
return nil unless match
|
|
85
|
+
|
|
86
|
+
host = match[1]
|
|
87
|
+
repo = match[2].to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "").sub(/\.git\z/, "").sub(%r{/+\z}, "")
|
|
88
|
+
segments = repo.split("/", -1)
|
|
89
|
+
return nil if host.empty? || segments.empty? || segments.any? { |segment| ["", ".", ".."].include?(segment) }
|
|
90
|
+
|
|
91
|
+
{ host: host, repo: repo }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def initialize(settings = {})
|
|
95
|
+
settings = {} unless settings.is_a?(Hash)
|
|
96
|
+
@sources = compile_sources(settings["sources"])
|
|
97
|
+
max = settings["max"].to_i
|
|
98
|
+
@max = max.positive? ? max : DEFAULT_MAX
|
|
99
|
+
@note = settings["note"] != false
|
|
100
|
+
# [Dir.pwd, remote name] => {host:, repo:} or nil, for the worker's life.
|
|
101
|
+
@remotes = {}
|
|
102
|
+
@host_mismatch_logged = {}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def call(event)
|
|
106
|
+
return unless event.is_a?(Hash) && event[:type] == :after_turn
|
|
107
|
+
return unless event[:status].to_s == "completed"
|
|
108
|
+
return if @sources.empty?
|
|
109
|
+
|
|
110
|
+
text = last_model_text(event[:messages])
|
|
111
|
+
return if text.nil? || text.empty?
|
|
112
|
+
|
|
113
|
+
hits = occurrences(text[0, MAX_SCAN])
|
|
114
|
+
if @note
|
|
115
|
+
found = collect(hits)
|
|
116
|
+
event[:notify]&.call(line(found), level: :info) unless found.empty?
|
|
117
|
+
end
|
|
118
|
+
# The answer as shown: the model's text unless an earlier hook changed
|
|
119
|
+
# it (then its offsets differ, so it is scanned again).
|
|
120
|
+
event[:present]&.call { |shown| link(shown, shown == text ? hits : nil) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# The content of the last `role: "model"` message, or nil. Handles both
|
|
126
|
+
# string- and symbol-keyed messages. A turn that ended without a visible
|
|
127
|
+
# answer stores a `kind: turn_note` system message instead, so the role
|
|
128
|
+
# check alone is enough.
|
|
129
|
+
def last_model_text(messages)
|
|
130
|
+
Array(messages).reverse_each do |message|
|
|
131
|
+
next unless message.is_a?(Hash)
|
|
132
|
+
next unless (message.key?(:role) ? message[:role] : message["role"]).to_s == "model"
|
|
133
|
+
|
|
134
|
+
content = message.key?(:content) ? message[:content] : message["content"]
|
|
135
|
+
return content.to_s
|
|
136
|
+
end
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Every ref the note may name, as {start:, finish:, name:, ref:, url:,
|
|
141
|
+
# quiet:}, by offset (whatever order the sources are configured in). The
|
|
142
|
+
# skip rules below drop a ref that is already a link; +quiet+ marks one
|
|
143
|
+
# the note names but the answer does not link (in code, or in a markdown
|
|
144
|
+
# link's label: a link can't hold another). A source whose regex times out
|
|
145
|
+
# is skipped whole: its partial matches are discarded, the others still
|
|
146
|
+
# report.
|
|
147
|
+
def occurrences(text)
|
|
148
|
+
url_spans = bare_url_spans(text)
|
|
149
|
+
links = markdown_links(text)
|
|
150
|
+
code = code_spans(text)
|
|
151
|
+
labels = links.map { |link| link[:label] }
|
|
152
|
+
found = []
|
|
153
|
+
@sources.each do |source|
|
|
154
|
+
hits = []
|
|
155
|
+
begin
|
|
156
|
+
text.scan(source[:regex]) do
|
|
157
|
+
match = Regexp.last_match
|
|
158
|
+
start = match.begin(0)
|
|
159
|
+
finish = match.end(0)
|
|
160
|
+
next if inside_any?(url_spans, start, finish)
|
|
161
|
+
next if inside_markdown_link?(links, text, match)
|
|
162
|
+
next if url_adjacent?(text, match)
|
|
163
|
+
|
|
164
|
+
# An unresolved placeholder: no link, from this source.
|
|
165
|
+
url = source[:url].call(match[0], match)
|
|
166
|
+
next if url.nil?
|
|
167
|
+
|
|
168
|
+
quiet = inside_any?(code, start, finish) || inside_any?(labels, start, finish)
|
|
169
|
+
hits << { start: start, finish: finish, name: source[:name], ref: match[0], url: url, quiet: quiet }
|
|
170
|
+
end
|
|
171
|
+
rescue Regexp::TimeoutError
|
|
172
|
+
Samagotchi::Log.warn(:hooks, "source_links_timeout",
|
|
173
|
+
echo: "[samagotchi:hooks] source-links: #{source[:name]} timed out; skipped")
|
|
174
|
+
next
|
|
175
|
+
end
|
|
176
|
+
found.concat(hits)
|
|
177
|
+
end
|
|
178
|
+
# By offset; on a tie (two sources on one ref) the first configured wins.
|
|
179
|
+
found.each_with_index.sort_by { |hit, index| [hit[:start], index] }.map(&:first)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# [name, ref, url] for the note: first-occurrence order, deduped by the
|
|
183
|
+
# URL case-insensitively (`#12` and `o/r#12` may name one issue; with
|
|
184
|
+
# case_insensitive, `JIRA-1` and `jira-1` are one ticket).
|
|
185
|
+
def collect(hits)
|
|
186
|
+
seen = {}
|
|
187
|
+
hits.filter_map do |hit|
|
|
188
|
+
key = hit[:url].downcase
|
|
189
|
+
next if seen.key?(key)
|
|
190
|
+
|
|
191
|
+
seen[key] = true
|
|
192
|
+
[hit[:name], hit[:ref], hit[:url]]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# +text+ with each ref as a markdown link, every occurrence; the part
|
|
197
|
+
# beyond MAX_SCAN stays as it is. +hits+ are the scan of that text when
|
|
198
|
+
# the caller has it.
|
|
199
|
+
def link(text, hits = nil)
|
|
200
|
+
head = text[0, MAX_SCAN]
|
|
201
|
+
hits ||= occurrences(head)
|
|
202
|
+
out = +""
|
|
203
|
+
pos = 0
|
|
204
|
+
hits.each do |hit|
|
|
205
|
+
next if hit[:quiet] || hit[:start] < pos # two sources on one ref: the first wins
|
|
206
|
+
|
|
207
|
+
out << head[pos...hit[:start]] << "[#{hit[:ref].gsub(/[\[\]]/) { |c| "\\#{c}" }}](#{link_target(hit[:url])})"
|
|
208
|
+
pos = hit[:finish]
|
|
209
|
+
end
|
|
210
|
+
return text if pos.zero?
|
|
211
|
+
|
|
212
|
+
out << head[pos..] << text[MAX_SCAN..].to_s
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A URL as a markdown link target: whitespace and what would end it
|
|
216
|
+
# (parentheses, angle brackets) percent-encoded.
|
|
217
|
+
def link_target(url)
|
|
218
|
+
url.gsub(/[\s()<>]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The [start, end) ranges of the answer that are code: fenced blocks
|
|
222
|
+
# (``` or ~~~, to the closing fence or the end) and inline spans (a run
|
|
223
|
+
# of backticks to the next run of the same length). A line walk and a
|
|
224
|
+
# lookup per backtick run, no regex over the whole answer.
|
|
225
|
+
def code_spans(text)
|
|
226
|
+
fences = fenced_blocks(text)
|
|
227
|
+
runs = []
|
|
228
|
+
text.scan(/`+/) { runs << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
|
|
229
|
+
runs.reject! { |start, finish| inside_any?(fences, start, finish) }
|
|
230
|
+
by_length = Hash.new { |hash, key| hash[key] = [] }
|
|
231
|
+
runs.each_with_index { |(start, finish), index| by_length[finish - start] << index }
|
|
232
|
+
spans = []
|
|
233
|
+
index = 0
|
|
234
|
+
while index < runs.size
|
|
235
|
+
start, finish = runs[index]
|
|
236
|
+
same = by_length[finish - start]
|
|
237
|
+
closing = same.bsearch { |other| other > index }
|
|
238
|
+
if closing
|
|
239
|
+
spans << [start, runs[closing][1]]
|
|
240
|
+
index = closing + 1
|
|
241
|
+
else
|
|
242
|
+
index += 1
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
fences + spans
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def fenced_blocks(text)
|
|
249
|
+
blocks = []
|
|
250
|
+
open = nil
|
|
251
|
+
offset = 0
|
|
252
|
+
text.each_line do |line|
|
|
253
|
+
marker = line[FENCE_OPEN, 1]
|
|
254
|
+
if open.nil? && marker
|
|
255
|
+
open = [offset, marker]
|
|
256
|
+
elsif open && marker && marker[0] == open[1][0] && marker.length >= open[1].length && line.strip == marker
|
|
257
|
+
blocks << [open[0], offset + line.length]
|
|
258
|
+
open = nil
|
|
259
|
+
end
|
|
260
|
+
offset += line.length
|
|
261
|
+
end
|
|
262
|
+
blocks << [open[0], text.length] if open
|
|
263
|
+
blocks
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The [start, end) character ranges of the answer that are a bare URL
|
|
267
|
+
# (scheme-anchored, `[a-z][a-z0-9+.\-]*://\S+`). A ref inside one is not
|
|
268
|
+
# linked again. The span stops at the first `)` that no `(` inside the URL
|
|
269
|
+
# balances — so `(https://x.com/a)` ends before the `)`, while
|
|
270
|
+
# `…/Foo_(bar)` keeps it.
|
|
271
|
+
def bare_url_spans(text)
|
|
272
|
+
spans = []
|
|
273
|
+
text.scan(URL_SPAN) do
|
|
274
|
+
match = Regexp.last_match
|
|
275
|
+
finish = trim_url_end(match[0], match.begin(0), match.end(0))
|
|
276
|
+
spans << [match.begin(0), finish] if finish > match.begin(0)
|
|
277
|
+
end
|
|
278
|
+
spans
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The end offset of a URL match after trimming its tail: the span stops at
|
|
282
|
+
# the first `)` that no `(` inside the URL balances.
|
|
283
|
+
def trim_url_end(url, start, finish)
|
|
284
|
+
depth = 0
|
|
285
|
+
url.each_char.with_index do |char, index|
|
|
286
|
+
if char == "("
|
|
287
|
+
depth += 1
|
|
288
|
+
elsif char == ")"
|
|
289
|
+
if depth.zero?
|
|
290
|
+
finish = start + index
|
|
291
|
+
break
|
|
292
|
+
end
|
|
293
|
+
depth -= 1
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
finish
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# A markdown link's label and target ranges, with the target text. A ref in
|
|
300
|
+
# the target is a link destination (skip it); a ref in the label is skipped
|
|
301
|
+
# only when the target names that same ref — `[JIRA-123](https://x.com/JIRA-123)`
|
|
302
|
+
# is skipped, while `[fix for JIRA-123](https://github.com/o/r/pull/9)` still
|
|
303
|
+
# links the ticket.
|
|
304
|
+
def markdown_links(text)
|
|
305
|
+
links = []
|
|
306
|
+
text.scan(MARKDOWN_LINK) do
|
|
307
|
+
match = Regexp.last_match
|
|
308
|
+
links << { label: [match.begin(1), match.end(1)],
|
|
309
|
+
target: [match.begin(2), match.end(2)],
|
|
310
|
+
target_text: match[2].to_s }
|
|
311
|
+
end
|
|
312
|
+
links
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def inside_any?(spans, start, finish)
|
|
316
|
+
spans.any? { |span_start, span_end| start >= span_start && finish <= span_end }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def inside_markdown_link?(links, text, match)
|
|
320
|
+
start = match.begin(0)
|
|
321
|
+
finish = match.end(0)
|
|
322
|
+
links.any? do |link|
|
|
323
|
+
in_target = start >= link[:target][0] && finish <= link[:target][1]
|
|
324
|
+
in_label = start >= link[:label][0] && finish <= link[:label][1]
|
|
325
|
+
in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish]))
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# True when the link target names the ref as a whole token: the lookarounds
|
|
330
|
+
# reject a ref character (letter, digit or `-`) immediately before or after
|
|
331
|
+
# the ref. So `[JIRA-1](…/JIRA-12)` is NOT skipped (the target names
|
|
332
|
+
# JIRA-12), while `[JIRA-123](…/JIRA-123)` is. Note this is stricter than
|
|
333
|
+
# the bare-text scan's `\b`, which treats `-` as a boundary: `…/JIRA-1-foo`
|
|
334
|
+
# would match there but not here.
|
|
335
|
+
def target_names_ref?(target, ref)
|
|
336
|
+
escaped = Regexp.escape(ref)
|
|
337
|
+
target.match?(/(?<![A-Za-z0-9\-])#{escaped}(?![A-Za-z0-9\-])/)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# A ref glued to URL punctuation is part of a link too, even when the span
|
|
341
|
+
# scan misses it: `/browse/JIRA-1`, `?key=JIRA-1`, `JIRA-1/foo`. The
|
|
342
|
+
# character right before is `/`, `=` or `?`, or the one right after is `/`.
|
|
343
|
+
# `:` and `#` are NOT here: `Ticket:JIRA-5` and `#JIRA-123` are ordinary
|
|
344
|
+
# plain-text ways to write a ticket, and a real URL is caught by the span
|
|
345
|
+
# scan anyway.
|
|
346
|
+
def url_adjacent?(text, match)
|
|
347
|
+
start = match.begin(0)
|
|
348
|
+
before = start.positive? ? text[start - 1] : nil
|
|
349
|
+
after = text[match.end(0)]
|
|
350
|
+
["/", "=", "?"].include?(before) || after == "/"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def line(found)
|
|
354
|
+
shown = found.first(@max)
|
|
355
|
+
text = "sources: #{shown.map { |name, ref, url| "#{name} #{ref} → #{url}" }.join(', ')}"
|
|
356
|
+
extra = found.size - shown.size
|
|
357
|
+
text += ", … +#{extra} more" if extra.positive?
|
|
358
|
+
text
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def compile_sources(raw)
|
|
362
|
+
Array(raw).filter_map { |entry| compile_source(entry) }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# One configured source as {name:, regex:, url:}, or nil (with a warn) for
|
|
366
|
+
# an entry that is not a mapping, names neither a prefix nor a pattern, or
|
|
367
|
+
# whose pattern does not compile. Never raises into the turn.
|
|
368
|
+
def compile_source(entry)
|
|
369
|
+
unless entry.is_a?(Hash)
|
|
370
|
+
warn_invalid("a source entry must be a mapping, not #{entry.class}")
|
|
371
|
+
return nil
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
entry = entry.transform_keys(&:to_s)
|
|
375
|
+
name = entry["name"].to_s.strip
|
|
376
|
+
prefix = entry["prefix"].to_s.strip
|
|
377
|
+
pattern = entry["pattern"].to_s
|
|
378
|
+
flags = entry["case_insensitive"] ? Regexp::IGNORECASE : 0
|
|
379
|
+
|
|
380
|
+
if !prefix.empty?
|
|
381
|
+
name = prefix if name.empty?
|
|
382
|
+
regex = Regexp.new("\\b#{Regexp.escape(prefix)}-(\\d+)\\b", flags, timeout: REGEX_TIMEOUT)
|
|
383
|
+
base = entry["base_url"].to_s
|
|
384
|
+
{ name: name, regex: regex, url: ->(ref, _match) { "#{base}#{ref}" } }
|
|
385
|
+
elsif !pattern.empty?
|
|
386
|
+
name = "source" if name.empty?
|
|
387
|
+
regex = Regexp.new(pattern, flags, timeout: REGEX_TIMEOUT)
|
|
388
|
+
template = entry["url"].to_s
|
|
389
|
+
known = known_placeholders(name, template, regex)
|
|
390
|
+
remote = entry["remote"].to_s.strip
|
|
391
|
+
remote = "origin" if remote.empty?
|
|
392
|
+
hosts = Array(entry["remote_host"]).map { |host| host.to_s.strip.downcase }.reject(&:empty?)
|
|
393
|
+
source = { name: name, remote: remote, remote_hosts: hosts }
|
|
394
|
+
source.merge(regex: regex, url: ->(ref, match) { render_url(template, known, match, ref, source) })
|
|
395
|
+
else
|
|
396
|
+
warn_invalid("a source needs a prefix: or a pattern:")
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
rescue RegexpError => e
|
|
400
|
+
warn_invalid("its pattern does not compile: #{e.message}")
|
|
401
|
+
nil
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# The placeholders of +template+ this pattern can fill: {match}, {repo},
|
|
405
|
+
# {host}, its named groups and {1}…{N} for its N groups. Any other
|
|
406
|
+
# `{word}` stays as text, with one warning here, at compile time.
|
|
407
|
+
def known_placeholders(name, template, regex)
|
|
408
|
+
used = template.scan(PLACEHOLDER).flatten.uniq
|
|
409
|
+
groups = group_count(regex)
|
|
410
|
+
known, unknown = used.partition do |word|
|
|
411
|
+
if word.match?(/\A\d+\z/)
|
|
412
|
+
groups.nil? || (word.to_i.between?(1, groups))
|
|
413
|
+
else
|
|
414
|
+
BUILTIN_PLACEHOLDERS.include?(word) || regex.names.include?(word)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
unless unknown.empty?
|
|
418
|
+
list = unknown.map { |word| "{#{word}}" }.join(", ")
|
|
419
|
+
Samagotchi::Log.warn(:hooks, "source_links_unknown_placeholder",
|
|
420
|
+
echo: "[samagotchi:hooks] source-links: #{name}: #{list}: no such group in its pattern; " \
|
|
421
|
+
"left as text")
|
|
422
|
+
end
|
|
423
|
+
known
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# How many groups +regex+ captures (with named groups, only those), found
|
|
427
|
+
# by matching an always-empty alternative; nil when that fails.
|
|
428
|
+
def group_count(regex)
|
|
429
|
+
probe = Regexp.new("(?:#{regex.source}\n)|", regex.options, timeout: REGEX_TIMEOUT)
|
|
430
|
+
probe.match("").size - 1
|
|
431
|
+
rescue RegexpError, Regexp::TimeoutError
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The URL for one hit: +template+ with its +known+ placeholders filled, or
|
|
436
|
+
# nil when one can't be (a group that didn't take part, a {repo} with an
|
|
437
|
+
# empty or dot segment, no remote): we never build a URL with a hole.
|
|
438
|
+
def render_url(template, known, match, ref, source)
|
|
439
|
+
unresolved = false
|
|
440
|
+
url = template.gsub(PLACEHOLDER) do
|
|
441
|
+
word = Regexp.last_match(1)
|
|
442
|
+
next Regexp.last_match(0) unless known.include?(word)
|
|
443
|
+
|
|
444
|
+
value = placeholder_value(word, match, ref, source)
|
|
445
|
+
unresolved = true if value.nil?
|
|
446
|
+
value.to_s
|
|
447
|
+
end
|
|
448
|
+
unresolved ? nil : url
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# One placeholder's escaped value, or nil when it is unresolved.
|
|
452
|
+
def placeholder_value(word, match, ref, source)
|
|
453
|
+
case word
|
|
454
|
+
when "match" then escape_url(match[1] || ref)
|
|
455
|
+
when /\A\d+\z/ then match[word.to_i]&.then { |value| escape_url(value) }
|
|
456
|
+
when "repo", "host"
|
|
457
|
+
value = match.names.include?(word) ? match[word] : nil
|
|
458
|
+
value ||= remote_value(word, source)
|
|
459
|
+
word == "repo" ? escape_repo(value) : value&.then { |host| escape_url(host) }
|
|
460
|
+
else match[word]&.then { |value| escape_url(value) }
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# {repo} / {host} from the project's git remote (the source's `remote:`,
|
|
465
|
+
# default origin); nil when there is none, or when its host is not one of
|
|
466
|
+
# the source's `remote_host:` list.
|
|
467
|
+
def remote_value(word, source)
|
|
468
|
+
remote = project_remote(source[:remote])
|
|
469
|
+
return nil unless remote
|
|
470
|
+
|
|
471
|
+
hosts = source[:remote_hosts]
|
|
472
|
+
unless hosts.empty? || hosts.include?(remote[:host].downcase)
|
|
473
|
+
key = [source[:name], remote[:host]]
|
|
474
|
+
unless @host_mismatch_logged[key]
|
|
475
|
+
@host_mismatch_logged[key] = true
|
|
476
|
+
Samagotchi::Log.debug(:hooks, "source_links_remote_host_mismatch", source: source[:name],
|
|
477
|
+
host: remote[:host], remote_host: hosts.join(","))
|
|
478
|
+
end
|
|
479
|
+
return nil
|
|
480
|
+
end
|
|
481
|
+
remote[word.to_sym]
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# The project's (Dir.pwd's) remote +name+ as {host:, repo:}, or nil.
|
|
485
|
+
# Asked of git lazily (only a hit that needs it) and remembered, nil too,
|
|
486
|
+
# per [Dir.pwd, name]: git applies insteadOf rewrites and includes, and a
|
|
487
|
+
# worktree reports its main repo's remote.
|
|
488
|
+
def project_remote(name)
|
|
489
|
+
key = [Dir.pwd, name]
|
|
490
|
+
return @remotes[key] if @remotes.key?(key)
|
|
491
|
+
|
|
492
|
+
@remotes[key] = read_remote(key[0], name)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def read_remote(dir, name)
|
|
496
|
+
out, status = Open3.capture2({ "GIT_DIR" => nil, "GIT_WORK_TREE" => nil },
|
|
497
|
+
"git", "-C", dir, "remote", "get-url", name, err: File::NULL)
|
|
498
|
+
unless status.success?
|
|
499
|
+
Samagotchi::Log.debug(:hooks, "source_links_no_remote", remote: name, exit: status.exitstatus)
|
|
500
|
+
return nil
|
|
501
|
+
end
|
|
502
|
+
remote = self.class.parse_remote_url(out)
|
|
503
|
+
Samagotchi::Log.debug(:hooks, "source_links_remote", remote: name, host: remote&.dig(:host), repo: remote&.dig(:repo))
|
|
504
|
+
remote
|
|
505
|
+
rescue SystemCallError => e
|
|
506
|
+
Samagotchi::Log.debug(:hooks, "source_links_no_remote", remote: name, error: e.class.name)
|
|
507
|
+
nil
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# A repo path with each `/`-separated segment escaped and the `/` kept
|
|
511
|
+
# (GitLab's `group/sub/proj`); nil for an empty, `.` or `..` segment, which
|
|
512
|
+
# a browser would resolve out of the path.
|
|
513
|
+
def escape_repo(value)
|
|
514
|
+
return nil if value.nil?
|
|
515
|
+
|
|
516
|
+
segments = value.split("/", -1)
|
|
517
|
+
return nil if segments.empty? || segments.any? { |segment| ["", ".", ".."].include?(segment) }
|
|
518
|
+
|
|
519
|
+
segments.map { |segment| escape_url(segment) }.join("/")
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def warn_invalid(reason)
|
|
523
|
+
Samagotchi::Log.warn(:hooks, "source_links_invalid_source",
|
|
524
|
+
echo: "[samagotchi:hooks] source-links: skipping a source: #{reason}")
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# A capture group is free-form, so escape what goes into the URL path.
|
|
528
|
+
def escape_url(value)
|
|
529
|
+
value.to_s.gsub(%r{[^A-Za-z0-9\-._~]}) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
|
|
530
|
+
end
|
|
531
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: source-links
|
|
3
|
+
version: 0.3.0
|
|
4
|
+
scope: system
|
|
5
|
+
description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
files:
|
|
8
|
+
source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
|
|
9
|
+
hooks:
|
|
10
|
+
source_links.rb:
|
|
11
|
+
sha256: sha256:0ee86a932efc90d81777855c81dea16510710d7371fe8b6b146a5a16b29794c2
|
|
12
|
+
event: after_turn
|
|
13
|
+
on_error: log
|
|
14
|
+
priority: 90
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Source links
|
|
2
|
+
|
|
3
|
+
The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note, which every UI shows (the terminals only see this line). The line is **not part of the conversation**: it is an event, so it is not in the session file and you cannot refer back to it. A UI replays it while the session's worker lives (a page reload keeps it; a stopped worker loses it).
|
|
4
|
+
|
|
5
|
+
The refs come from the `bundles: source-links:` section of config.yml: each source is a `prefix:` + `base_url:` pair (simple) or a `pattern:` regex + `url:` template (full form), plus an optional `max:` for refs per line and `note: false` to drop the line (the web links stay). A `url:` template can take the project's own repo from its git remote, so a bare `#12` links to this project's issue and `owner/repo#12` to that repo's. With no sources configured the hook does nothing. A ref already inside a URL is not linked again, and a ref in code or in a markdown link is not linked in the answer.
|
|
@@ -18,7 +18,7 @@ Single registry `Samagotchi::Config` (`Config::ENTRIES` in `lib/samagotchi/confi
|
|
|
18
18
|
|
|
19
19
|
Sections forbid `_`/`-` (`SECTION_RE` `/\A[a-z0-9]+\z/`); leaves keep `snake_case` in YAML (`base_url`) and become kebab in CLI (`base-url`) via registry derivation — no generic string split, registry lookup avoids flat vs nested collision.
|
|
20
20
|
|
|
21
|
-
**Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/transport/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.shared/keep_empty/max_children` (env and config only), `log.file/disable`, `status.line/width_mode/max_width/fixed_width`, `context.status/window_tokens/chars_per_token/status_thresholds/status_cadence`, `thinking.ui/
|
|
21
|
+
**Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/transport/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.shared/keep_empty/max_children` (env and config only), `log.file/disable`, `status.line/width_mode/max_width/fixed_width`, `context.status/window_tokens/chars_per_token/status_thresholds/status_cadence`, `thinking.ui/render_interval/turn_preamble`, `default.n_predict`, `max_tool_output_chars`, `retry.max/base_delay/max_delay`, `read.*`, `execute.*`, `web.port/host`, `no_interrupt`, `no_default_input` etc. (`Config::ENTRIES`; `expose` says which of env/config/cli each takes). Precedence is `CLI > ENV > file > default`.
|
|
22
22
|
|
|
23
23
|
Example `config.yml` (new nested form, preferred):
|
|
24
24
|
|
|
@@ -52,13 +52,13 @@ log:
|
|
|
52
52
|
disable: false
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
Legacy flat keys (`SAMAGOTCHI_DEFAULT_MODEL`, `SAMAGOTCHI_N_PREDICT` etc. at top-level) are still read via fallback in `Config.lookup_yaml` but warn `Warning: config key 'SAMAGOTCHI_DEFAULT_MODEL' is legacy UPPER — use 'default.model'` (`ConfigFile.load_global_env!`). Migrate them to nested form and remove the flat entry. The old `LLAMA_HOST`/`LLAMA_PORT` aliases were removed; use `server.host`/`server.port` (nested) or `SAMAGOTCHI_SERVER_HOST`/`SAMAGOTCHI_SERVER_PORT`.
|
|
55
|
+
Legacy flat keys (`SAMAGOTCHI_DEFAULT_MODEL`, `SAMAGOTCHI_N_PREDICT` etc. at top-level) are still read via fallback in `Config.lookup_yaml` but warn `Warning: config key 'SAMAGOTCHI_DEFAULT_MODEL' is legacy UPPER — use 'default.model'` (`ConfigFile.load_global_env!`). When a file has both, the nested key wins and the warning names both. Migrate them to nested form and remove the flat entry. The old `LLAMA_HOST`/`LLAMA_PORT` aliases were removed; use `server.host`/`server.port` (nested) or `SAMAGOTCHI_SERVER_HOST`/`SAMAGOTCHI_SERVER_PORT`.
|
|
56
56
|
|
|
57
57
|
**Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
|
|
58
58
|
|
|
59
59
|
- `model_aliases:` map of alias → model id (`ConfigFile.resolve_model_alias`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid).
|
|
60
|
-
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
|
|
61
|
-
- `models:` map of model id or alias → `{profile}` (`ConfigFile.model_settings`). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
60
|
+
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
|
|
61
|
+
- `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
62
62
|
- `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
|
|
63
63
|
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; env `SAMAGOTCHI_GUARDRAILS_ENABLED`), `rules:` (list), `disable:` (list of rule ids, `id` or `bundle:id`, switching off a bundle's or config rule without editing it). A rule takes only `id`, `tool`, `command`, `path`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules load when a session starts: restart the worker/REPL after an edit. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
64
64
|
|
|
@@ -92,6 +92,10 @@ bundles:
|
|
|
92
92
|
deny_after: 2 # same call + same result N times in a turn -> deny the next
|
|
93
93
|
stop_after: 4 # stop the turn at this many denies
|
|
94
94
|
mode: deny # deny | notify (warn only); ignore_tools: [task_wait, ...]
|
|
95
|
+
check-in:
|
|
96
|
+
after: 50 # tool calls in one turn with no answer before the first check
|
|
97
|
+
every: 50 # then again every N more
|
|
98
|
+
mode: ask # ask (a card) | nudge (nudge the model by itself) | notify; message:, ignore_tools: [...]
|
|
95
99
|
mcp: # tools become mcp_<server>_<tool>; /mcp lists them
|
|
96
100
|
timeout: 60 # per call, seconds; startup_timeout: 10
|
|
97
101
|
servers:
|
|
@@ -113,7 +117,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
113
117
|
1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
|
|
114
118
|
2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
|
|
115
119
|
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"; raw_data.delete("SAMAGOTCHI_DEFAULT_MODEL")` to migrate legacy.
|
|
116
|
-
4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it
|
|
120
|
+
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_agent_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.
|
|
117
121
|
5. **Write atomically**: `FileUtils.mkdir_p(File.dirname(path))`, `File.write("#{path}.tmp", YAML.dump(raw_data))`, `File.rename("#{path}.tmp", path)`.
|
|
118
122
|
6. Update in-process state: `write_default_model!` sets `ENV["SAMAGOTCHI_DEFAULT_MODEL"]` and `Samagotchi::Config.reload!`; otherwise the harness picks it up on next `Config.get` (live resolve) or restart. CLI overrides (`--default-model`) win over file until process exit.
|
|
119
123
|
|
|
@@ -132,7 +136,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
132
136
|
- **Hosts**: each entry needs `host`, `port` 1-65535, `transport` and `api` optional (a raw `api` must match `transport`), name must match `/\A[a-z0-9][a-z0-9._-]*\z/i`.
|
|
133
137
|
- **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log`. Class name must match file basename snake→Pascal.
|
|
134
138
|
- **Scalars via registry**: `Config.coerce` validates `String/Numeric/true/false` per `type: :string/:integer/:float/:bool/:enum`; invalid values warn and fall back to entry `default`.
|
|
135
|
-
- **
|
|
139
|
+
- **Keys**: `validate_yaml_sections` flags unknown keys (with a suggestion), a registry section that isn't a mapping, and env/CLI-only keys (`model.profile`).
|
|
136
140
|
|
|
137
141
|
## Tools to use
|
|
138
142
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# Delegated session
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Another chi session (the parent) gave you this task. It reads only your last message of each turn; the user may be watching, or not.
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
- Don't write memories unless the task asks for it
|
|
8
|
-
- Don't
|
|
9
|
-
-
|
|
10
|
-
- If you are blocked, say so in the reply with the exact question; only ask_user_question when the task says the user is watching.
|
|
5
|
+
- Finish the task in this turn if you can. End with one reply the parent can act on without asking back: the result first, then evidence (paths, commands run and what they printed), then anything you could not verify or finish.
|
|
6
|
+
- The parent may send follow-up messages later; each one is a new turn in this same session, with what you did so far.
|
|
7
|
+
- Work in the directory you were started in. Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
|
|
8
|
+
- You can't delegate further. Don't start other chi sessions, and send notes only to the parent.
|
|
9
|
+
- If you are blocked, stop and say so in the reply with the exact question. Use ask_user_question only when the task says the user is watching.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.4.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:8b594100bee4797b563bd6425dc6f3ff93e1bdfbe9fc5fa1d66b42093c4795e2
|
|
8
|
-
self_map.md: sha256:
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
8
|
+
self_map.md: sha256:67eb6223728a72788fa0d75aa56affea2ca48962de2e445293bc12b15f5f70c0
|
|
9
|
+
config_modification_protocol.md: sha256:f76ee7e2f5a8e699401e3a4c9144643ee64015efd11a266029704bf228be6a95
|
|
10
10
|
memory_guide.md: sha256:6dd1ecfd6a377c4f2cfc2dc299c1ef3835f10d9f313616fd3eb23e5fd3202858
|
|
11
|
-
delegated.md: sha256:
|
|
11
|
+
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|