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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. 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/preview_lines/render_interval`, `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`.
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 rejects top-level `_` (suggest `default.model`) and warns on legacy flat keys.
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
- - **Sections**: `validate_yaml_sections` rejects top-level keys containing `_` (suggest dotted) and section names containing `_`/`-`.
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
- A parent session delegated your task and reads only your final reply; the user may be watching or not.
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
- - Do the task; put everything the parent needs in your last message (findings, paths, commands run, what is unverified). Nothing else of yours reaches it.
6
- - Don't delegate further, don't start other chi sessions, don't send notes to sessions other than the parent.
7
- - Don't write memories unless the task asks for it; put what you learned in the reply instead.
8
- - Don't change config, install bundles or edit managed files.
9
- - Stay in the working directory you were started in.
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.2.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:d42481b930ef6e59deb1e5c989a9a171e834569b42be9a8d3dda6b2379b0a5ff
9
- config_modification_protocol.md: sha256:c67e5fc1611a09203e3ebb1af2c14cacc2878b42a9fb0eb3d190c3b2daddafc3
8
+ self_map.md: sha256:67eb6223728a72788fa0d75aa56affea2ca48962de2e445293bc12b15f5f70c0
9
+ config_modification_protocol.md: sha256:f76ee7e2f5a8e699401e3a4c9144643ee64015efd11a266029704bf228be6a95
10
10
  memory_guide.md: sha256:6dd1ecfd6a377c4f2cfc2dc299c1ef3835f10d9f313616fd3eb23e5fd3202858
11
- delegated.md: sha256:8f7965b6165c3526abb0cd39e44a8b73a8d6a26755049bdcd007baf0b0771087
11
+ delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561