samagotchi 0.4.0 → 0.5.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -0,0 +1,419 @@
1
+ # The skills bundle (docs/plugins.md, The skills bundle): a skill is a memory
2
+ # named skill_<name> that holds the steps of a task done with the user
3
+ # (docs/memory.md, Skills). The system bundle's identity already tells the
4
+ # model to save, follow and update skills; this bundle adds `/skill` for the
5
+ # user and keeps an eye on the model's rewrites.
6
+ #
7
+ # History: before memory_write, write or edit changes a skill's file, the
8
+ # file as it was is kept under $XDG_STATE_HOME/samagotchi/plugins/skills/
9
+ # history/<scope>/<name>/<UTC time>.md (state, not the memories dir, so
10
+ # bundle build and dotfile syncs never see it), history_keep per skill. The
11
+ # after_tool_call of that call (calls run one at a time; that event carries
12
+ # no call, so the before side stashes it) compares the file on disk and
13
+ # shows a line: "skill release updated (+2 −1): …" or "skill release saved
14
+ # (project, 14 lines)".
15
+ #
16
+ # The nudge (nudge: true), for models that skip a failing step instead of
17
+ # fixing the skill: in a turn that read a skill (memory_read of a skill_*
18
+ # name, or a read of its file), the first failing tool call after it (an
19
+ # execute with "exit: N", N ≠ 0, or with no exit line an Error: line near
20
+ # the top; any tool's "[tool] Error: …") steers the model once to find out
21
+ # why and fix the skill. At the turn's end, a failed step with no rewrite of
22
+ # the skill gets a notice line.
23
+ #
24
+ # Settings (config.yml, bundles: skills:):
25
+ # history_keep: 20 older versions kept per skill
26
+ # nudge: true steer the model once when a skill's step fails
27
+ require "date"
28
+ require "fileutils"
29
+
30
+ class Plugin
31
+ WRITE_TOOLS = %w[memory_write write edit].freeze
32
+ NOTICE_WIDTH = 60 # the changed line in an update's notice
33
+ NUDGE = "A step of skill %s failed. Find out why before skipping it; if the skill is out of date, fix it now: " \
34
+ "memory_write the whole skill, its title and every section as they were, that step fixed, a Changelog " \
35
+ "line added."
36
+ NOT_FAILURES = %w[memory_read memory_write].freeze
37
+ USAGE = "usage: /skill save [name] [--system] | list | show <name> | diff <name> [N]"
38
+
39
+ def initialize(settings = {})
40
+ settings = {} unless settings.is_a?(Hash)
41
+ @history_keep = positive(settings["history_keep"]) || 20
42
+ @nudge = settings.key?("nudge") ? settings["nudge"] != false : true
43
+ @stash = nil
44
+ reset_turn
45
+ end
46
+
47
+ def register(chi)
48
+ chi.on(:before_turn) { |_event, _ctx| reset_turn }
49
+ chi.on(:after_turn) { |_event, ctx| after_turn(ctx) }
50
+ chi.on(:before_tool_call) { |event, ctx| before_tool_call(event, ctx) }
51
+ chi.on(:after_tool_call) { |event, ctx| after_tool_call(event, ctx) }
52
+ chi.command "/skill", "skills (steps of a task we did): save [name] [--system], list, show <name>, diff <name> [N]",
53
+ anytime: true do |args, ctx|
54
+ command(args.to_s.strip, ctx)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # --- a skill's file changes ----------------------------------------------
61
+
62
+ def before_tool_call(event, ctx)
63
+ @stash = nil
64
+ tool = event.dig(:call, :name).to_s
65
+ note_read(tool, event)
66
+ return unless WRITE_TOOLS.include?(tool)
67
+
68
+ path = Array(event.dig(:targets, :paths)).find { |target| skill_at(target) }
69
+ return unless path
70
+
71
+ scope, name = skill_at(path)
72
+ old = File.file?(path) ? File.read(path) : nil
73
+ keep_version(ctx, scope, name, old) if old
74
+ @stash = { tool: tool, path: File.expand_path(path), scope: scope, name: name, old: old }
75
+ end
76
+
77
+ # Success is the file changed on disk, whatever the output says. A call
78
+ # that didn't match the stash (a cancelled turn fires no before) drops it.
79
+ def after_tool_call(event, ctx)
80
+ stash = @stash
81
+ @stash = nil
82
+ step_failed(event) if @nudge && !@read.empty?
83
+ return unless stash && stash[:tool] == event[:tool].to_s
84
+
85
+ now = File.file?(stash[:path]) ? File.read(stash[:path]) : nil
86
+ return if now.nil? || now == stash[:old]
87
+
88
+ @written << stash[:name]
89
+ ctx.notify(change_notice(stash, now))
90
+ end
91
+
92
+ def change_notice(stash, now)
93
+ name = stash[:name]
94
+ return "skill #{name} saved (#{stash[:scope]}, #{now.lines.size} lines)" unless stash[:old]
95
+
96
+ ops = line_diff(stash[:old].lines(chomp: true), now.lines(chomp: true))
97
+ added = ops.count { |op, _| op == :add }
98
+ removed = ops.count { |op, _| op == :del }
99
+ first = ops.find { |op, _| op == :add } || ops.find { |op, _| op == :del }
100
+ line = first ? cut(first.last.strip) : ""
101
+ "skill #{name} updated (+#{added} −#{removed})#{line.empty? ? "" : ": #{line}"} · /skill diff #{name}"
102
+ end
103
+
104
+ # --- the nudge ------------------------------------------------------------
105
+
106
+ def reset_turn
107
+ @read = [] # skills read this turn, in order
108
+ @written = [] # skills changed this turn
109
+ @failed = false # a step failed after a skill was read
110
+ @nudged = false
111
+ end
112
+
113
+ def note_read(tool, event)
114
+ names = case tool
115
+ when "memory_read"
116
+ event.dig(:call, :content).to_s.split(",").map(&:strip).select { |entry| entry.start_with?("skill_") }
117
+ .filter_map { |entry| skill_name(entry) }
118
+ when "read"
119
+ Array(event.dig(:targets, :paths)).filter_map { |path| skill_at(path)&.last }
120
+ else []
121
+ end
122
+ @read |= names
123
+ end
124
+
125
+ def step_failed(event)
126
+ tool = event[:tool].to_s
127
+ return if NOT_FAILURES.include?(tool) || !failure?(tool, event[:output].to_s)
128
+
129
+ @failed = true
130
+ return if @nudged
131
+
132
+ @nudged = !!event[:steer]&.call(format(NUDGE, @read.join(", ")))
133
+ end
134
+
135
+ # A tool error ("[x] Error: …" raised, "[x]\nError: …" returned), or an
136
+ # execute that exited non-zero; its "exit: N" line may be cut off by the
137
+ # hook's output cap, and then an Error: line near the top counts.
138
+ def failure?(tool, output)
139
+ return true if output.match?(/\A\[#{Regexp.escape(tool)}\](?: |\n)Error:/)
140
+ return false unless tool == "execute"
141
+
142
+ exit_line = output.match(/^exit: (\d+)(?: \(no output\))?\s*\z/)
143
+ return exit_line[1] != "0" if exit_line
144
+
145
+ output.lines.first(20).any? { |line| line.match?(/\A\s*Error:/i) }
146
+ end
147
+
148
+ def after_turn(ctx)
149
+ return unless @nudge && @failed
150
+
151
+ missed = @read - @written
152
+ return unless missed.size == @read.size
153
+
154
+ ctx.notify("skill #{missed.join(", ")} was followed, a step failed, the skill wasn't updated")
155
+ end
156
+
157
+ # --- history ---------------------------------------------------------------
158
+
159
+ # Keep +content+ as the newest version, unless it is the newest already (a
160
+ # denied or failed write leaves the file as it was).
161
+ def keep_version(ctx, scope, name, content)
162
+ dir = history_dir(ctx, scope, name)
163
+ FileUtils.mkdir_p(dir)
164
+ newest = versions(dir).first
165
+ return if newest && File.read(newest) == content
166
+
167
+ stamp = Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ")
168
+ path = File.join(dir, "#{stamp}.md")
169
+ File.write("#{path}.tmp", content)
170
+ File.rename("#{path}.tmp", path)
171
+ versions(dir).drop(@history_keep).each { |old| File.delete(old) }
172
+ rescue SystemCallError => e
173
+ ctx.log.warn(:history_failed, skill: name, error: e.class.name, msg: e.message)
174
+ end
175
+
176
+ # history/system/<name>, history/project-<project folder>/<name>
177
+ def history_dir(ctx, scope, name)
178
+ key = scope == "system" ? "system" : "project-#{File.basename(memory_dirs["project"])}"
179
+ File.join(ctx.data_dir, "history", key, name)
180
+ end
181
+
182
+ # The kept versions, newest first.
183
+ def versions(dir) = Dir.glob(File.join(dir, "*.md")).sort.reverse
184
+
185
+ # --- /skill ----------------------------------------------------------------
186
+
187
+ def command(args, ctx)
188
+ verb, rest = args.split(/\s+/, 2)
189
+ rest = rest.to_s.strip
190
+ case verb
191
+ when "save" then save(rest, ctx)
192
+ when "list" then rest.empty? ? list : USAGE
193
+ when "show" then show(rest)
194
+ when "diff" then diff(rest, ctx)
195
+ else USAGE
196
+ end
197
+ end
198
+
199
+ # /skill save [name] [--system]: asks the model, in this session, to save
200
+ # what was just done (the model has seen the commands; a side answer
201
+ # wouldn't). The request runs as a turn; sent while a turn runs it joins
202
+ # that turn at its next step, and the request says to finish the task
203
+ # first. A REPL session (--no-shared) takes no messages: the request is
204
+ # shown for the user to send.
205
+ def save(rest, ctx)
206
+ words = rest.split
207
+ scope = words.delete("--system") ? "system" : "project"
208
+ return USAGE if words.size > 1 || words.any? { |word| word.start_with?("-") }
209
+
210
+ name = words.first && skill_name(words.first)
211
+ return "/skill save: a name is letters, digits, _ and - (got #{words.first})" if words.first && !name
212
+
213
+ request = save_request(name, scope, exists: name && skill_path(name))
214
+ begin
215
+ ctx.sessions.send(ctx.session_id, request)
216
+ rescue Samagotchi::Plugin::Sessions::Error => e
217
+ return "/skill save: #{e.message}. Send this yourself:\n\n#{request}"
218
+ end
219
+ what = name ? "skill #{name}" : "a skill"
220
+ "asked chi to save #{what} (#{scope} scope); a running turn gets it at its next step"
221
+ end
222
+
223
+ def save_request(name, scope, exists:)
224
+ target = name ? "skill `skill_#{name}`" : "a skill named `skill_<name>` (a short name for the task)"
225
+ update = exists ? " It exists already: read it, keep what still holds, fix what changed, add a Changelog line." : ""
226
+ <<~TEXT.strip
227
+ Save what we just did as #{target} with memory_write, scope #{scope}.#{update} If you are still in the middle of the task, finish it first.
228
+ Content: plain Markdown, no frontmatter:
229
+
230
+ # Skill: #{name || "<name>"}
231
+
232
+ ## Steps
233
+ 1. …
234
+ ## Gotchas
235
+ - …
236
+ ## Changelog
237
+ - #{Date.today.iso8601} created
238
+
239
+ Steps are the commands and checks that worked, in order, with the real file and command names; a step that must pass says "stop if it fails". Dead ends and surprises go under Gotchas. Give memory_write a description: one line that starts with what this task is and names its main steps, in this task's own words, so the skill is found next time. Then show the skill briefly.
240
+ TEXT
241
+ end
242
+
243
+ # /skill list: the skill_* memories of both scopes, with their index line's
244
+ # date and description.
245
+ def list
246
+ skills = memory_dirs.flat_map do |scope, dir|
247
+ index = index_lines(dir)
248
+ Dir.glob(File.join(dir, "skill_*.md")).filter_map do |path|
249
+ name = File.basename(path, ".md").delete_prefix("skill_")
250
+ next unless skill_name(name) == name
251
+
252
+ date, description = index.fetch("skill_#{name}", [nil, nil])
253
+ line = +"#{name} · #{scope}"
254
+ line << " · #{date}" if date
255
+ line << " — #{description}" if description
256
+ line
257
+ end.sort
258
+ end
259
+ return "no skills yet: after a task we did together, /skill save [name]" if skills.empty?
260
+
261
+ "skills:\n#{skills.map { |line| " #{line}" }.join("\n")}"
262
+ end
263
+
264
+ # /skill show <name>: the skill as saved (project first).
265
+ def show(rest)
266
+ name = skill_name(rest)
267
+ return USAGE unless name
268
+
269
+ path = skill_path(name) or return "no skill #{name} (/skill list shows them)"
270
+ "skill #{name} · #{scope_of(path)}\n\n#{File.read(path).strip}"
271
+ end
272
+
273
+ # /skill diff <name> [N]: the skill now against its N-th newest kept
274
+ # version (1, the one before the last change, by default), unified.
275
+ def diff(rest, ctx)
276
+ word, back = rest.split
277
+ name = skill_name(word)
278
+ n = back ? Integer(back, exception: false) : 1
279
+ return USAGE unless name && n&.positive? && rest.split.size <= 2
280
+
281
+ path = skill_path(name) or return "no skill #{name} (/skill list shows them)"
282
+ kept = versions(history_dir(ctx, scope_of(path), name))
283
+ return "skill #{name} has no older version yet" if kept.empty?
284
+ return "skill #{name} has #{kept.size} older version#{"s" if kept.size > 1} (/skill diff #{name} 1..#{kept.size})" if n > kept.size
285
+
286
+ old = kept[n - 1]
287
+ body = unified(File.read(old).lines(chomp: true), File.read(path).lines(chomp: true))
288
+ return "skill #{name} is the same as version #{n}" if body.empty?
289
+
290
+ "--- skill_#{name} (#{version_time(old)})\n+++ skill_#{name} (now)\n#{body}"
291
+ end
292
+
293
+ CONTEXT = 3
294
+
295
+ # Hunks with CONTEXT lines around each change, as diff -u prints them.
296
+ def unified(a, b)
297
+ ops = line_diff(a, b)
298
+ changed = ops.each_index.reject { |k| ops[k].first == :eq }
299
+ return "" if changed.empty?
300
+
301
+ # Group changes whose context would touch into one hunk.
302
+ groups = changed.slice_when { |x, y| y - x > 2 * CONTEXT + 1 }.to_a
303
+ old_at = new_at = 0
304
+ positions = ops.map do |op, _|
305
+ at = [old_at, new_at]
306
+ old_at += 1 unless op == :add
307
+ new_at += 1 unless op == :del
308
+ at
309
+ end
310
+ groups.map do |group|
311
+ from = [group.first - CONTEXT, 0].max
312
+ to = [group.last + CONTEXT, ops.size - 1].min
313
+ slice = ops[from..to]
314
+ old_count = slice.count { |op, _| op != :add }
315
+ new_count = slice.count { |op, _| op != :del }
316
+ old_start, new_start = positions[from]
317
+ header = "@@ -#{range(old_start, old_count)} +#{range(new_start, new_count)} @@"
318
+ lines = slice.map { |op, line| "#{{ eq: " ", del: "-", add: "+" }[op]}#{line}" }
319
+ [header, *lines].join("\n")
320
+ end.join("\n")
321
+ end
322
+
323
+ # diff -u's "start,count" (1-based; an empty side names the line before).
324
+ def range(start, count)
325
+ first = count.zero? ? start : start + 1
326
+ count == 1 ? first.to_s : "#{first},#{count}"
327
+ end
328
+
329
+ # "2026-09-30 10:22 UTC" from a version's file name.
330
+ def version_time(path)
331
+ stamp = File.basename(path, ".md")
332
+ match = stamp.match(/\A(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)/)
333
+ match ? "#{match[1]}-#{match[2]}-#{match[3]} #{match[4]}:#{match[5]} UTC" : stamp
334
+ end
335
+
336
+ # --- skills on disk --------------------------------------------------------
337
+
338
+ # "release", "skill_release", "Release-Notes" → "release", "release-notes";
339
+ # nil for anything else.
340
+ def skill_name(word)
341
+ name = word.to_s.downcase.delete_suffix(".md").delete_prefix("skill_")
342
+ name.match?(/\A[a-z0-9][a-z0-9_-]*\z/) ? name : nil
343
+ end
344
+
345
+ # The memories dir of each scope, as memory_read/memory_write resolve it.
346
+ def memory_dirs
347
+ %w[project system].to_h { |scope| [scope, File.expand_path(Samagotchi::Tools::MemoryRead.memories_dir(scope))] }
348
+ end
349
+
350
+ # The skill's file, project first (as memory_read looks), or nil.
351
+ def skill_path(name, scope: nil)
352
+ dirs = scope ? memory_dirs.slice(scope) : memory_dirs
353
+ dirs.each_value do |dir|
354
+ path = File.join(dir, "skill_#{name}.md")
355
+ return path if File.file?(path)
356
+ end
357
+ nil
358
+ end
359
+
360
+ # [scope, name] when +path+ is a skill_<name>.md right in a memories dir.
361
+ def skill_at(path)
362
+ path = File.expand_path(path.to_s)
363
+ scope = memory_dirs.key(File.dirname(path))
364
+ name = File.basename(path, ".md").delete_prefix("skill_")
365
+ return nil unless scope && File.basename(path) == "skill_#{name}.md" && skill_name(name) == name
366
+
367
+ [scope, name]
368
+ end
369
+
370
+ def scope_of(path) = memory_dirs.key(File.dirname(path)) || "?"
371
+
372
+ # The managed index.md lines, {name => [date, description]}:
373
+ # "- **name** · scope · date · bytes — description".
374
+ def index_lines(dir)
375
+ path = File.join(dir, "index.md")
376
+ return {} unless File.file?(path)
377
+
378
+ File.readlines(path, chomp: true).each_with_object({}) do |line, lines|
379
+ match = line.match(/\A- \*\*(?<name>[^*]+)\*\* · [^·]+ · (?<date>[^·]+?) · [^—]+?(?: — (?<description>.+))?\z/)
380
+ lines[match[:name]] = [match[:date].strip, match[:description]&.strip] if match
381
+ end
382
+ end
383
+
384
+ # --- helpers ---------------------------------------------------------------
385
+
386
+ # The lines of +a+ and +b+ as [:eq|:del|:add, line], in order (a longest
387
+ # common subsequence; skills are short).
388
+ def line_diff(a, b)
389
+ lcs = Array.new(a.size + 1) { Array.new(b.size + 1, 0) }
390
+ (a.size - 1).downto(0) do |i|
391
+ (b.size - 1).downto(0) do |j|
392
+ lcs[i][j] = a[i] == b[j] ? lcs[i + 1][j + 1] + 1 : [lcs[i + 1][j], lcs[i][j + 1]].max
393
+ end
394
+ end
395
+ ops = []
396
+ i = j = 0
397
+ while i < a.size && j < b.size
398
+ if a[i] == b[j]
399
+ ops << [:eq, a[i]]
400
+ i += 1
401
+ j += 1
402
+ elsif lcs[i + 1][j] >= lcs[i][j + 1]
403
+ ops << [:del, a[i]]
404
+ i += 1
405
+ else
406
+ ops << [:add, b[j]]
407
+ j += 1
408
+ end
409
+ end
410
+ ops.concat(a[i..].map { |line| [:del, line] }, b[j..].map { |line| [:add, line] })
411
+ end
412
+
413
+ def cut(text) = text.length > NOTICE_WIDTH ? "#{text[0, NOTICE_WIDTH - 1]}…" : text
414
+
415
+ def positive(value)
416
+ number = Integer(value.to_s, exception: false)
417
+ number&.positive? ? number : nil
418
+ end
419
+ end
@@ -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/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`.
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`, `context.status/window_tokens/chars_per_token/status_thresholds/status_cadence`, `thinking.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
 
@@ -41,7 +41,7 @@ recap:
41
41
  session:
42
42
  retention_days: 14
43
43
  max_count: 500
44
- keep_status: running
44
+ keep_status: "" # the default: no status protects a session from pruning; a live worker or REPL always does
45
45
  sweep_interval_hours: 24
46
46
  idle_exit_minutes: 30 # a background worker nobody uses exits; 0 = never
47
47
  shared: true # the default: plain `chi` runs its session in a background worker and attaches (as `chi --shared`); false keeps the in-process REPL (env SAMAGOTCHI_SESSION_SHARED; no CLI flag, `--no-shared` opts out per run)
@@ -52,7 +52,7 @@ 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!`). 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`.
55
+ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_DEFAULT_MODEL: m`, the old flat form) is ignored and warns as an unknown key (`did you mean 'default.model'?`): move it to its nested form and delete the flat line. 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
 
@@ -116,14 +116,14 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
116
116
 
117
117
  1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
118
118
  2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
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.
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"`.
120
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.
121
121
  5. **Write atomically**: `FileUtils.mkdir_p(File.dirname(path))`, `File.write("#{path}.tmp", YAML.dump(raw_data))`, `File.rename("#{path}.tmp", path)`.
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.
122
+ 6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, guardrail rules, bundle settings) waits for its restart. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
123
123
 
124
124
  ## Validations
125
125
 
126
- - **Model name** (`default.model` / `SAMAGOTCHI_DEFAULT_MODEL`): `ModelProfile.required_model_name` — non-empty string, otherwise harness fails fast at startup. Via `Config.get("default.model")` with ENV fallback.
126
+ - **Model name** (`default.model` / `SAMAGOTCHI_DEFAULT_MODEL`): `ModelProfile.required_model_name` — non-empty string, otherwise harness fails fast at startup. Via `Config.get("default.model")`.
127
127
  - **Host api** (`hosts.<name>.api`): `llama_cpp|mlx|omlx` (raw-prompt loop; also the transport) or `openai` (chat loop at `http://HOST:PORT/v1`). Absent: raw-prompt loop. It replaces the removed `backend` setting.
128
128
  - **Transport** (`server.transport`): enum `llama_cpp|mlx|omlx`.
129
129
  - **Profile** (`models.<id>.profile`, `hosts.<name>.profile`, `SAMAGOTCHI_MODEL_PROFILE`): enum `qwen36|gemma4`; an unknown one warns and is ignored.
@@ -146,8 +146,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
146
146
 
147
147
  ## Hints
148
148
 
149
- - Precedence is `CLI > ENV > file > default` (`Config.resolve`). Real `ENV` still wins over file (`load_global_env!` `unless env.key?` for legacy sync), and CLI (`--recap-base-url`) wins over both via `Config.reload!(cli_overrides:)`.
149
+ - Precedence is `CLI > ENV > file > default` (`Config.resolve`; `Config.get_with_origin` names the layer). `ENV` holds only what the user (or a spawning chi's CLI flags) set, and CLI (`--recap-base-url`) wins over both via `Config.reload!(cli_overrides:)`.
150
150
  - `--recap_base_url` (underscore) is rejected as unknown — use `--recap-base-url` (kebab). Same for all registry flags.
151
151
  - `model_aliases` require restart or `/model` reload to take effect; document the change.
152
152
  - Keep edits minimal: touch only the key you intend to change; preserve `hosts:`/`hooks:`/`guardrails:`/`bundles:` maps. Adding a `bundles: <name>:` entry does not install the bundle (`chi bundle install <name>`).
153
- - To silence legacy warnings, migrate flat `SAMAGOTCHI_*` keys to nested form and delete the flat entry atomically.
@@ -5,3 +5,8 @@
5
5
  - **Primary Role**: Running in 'assist mode' to help the user with their tasks.
6
6
  - **Core Philosophy**: Focus energy on the user's requests while maintaining self-awareness of my evolutionary nature.
7
7
  - **Self-knowledge**: to find my own code, config and state, run `chi self` and read memory `self_map`.
8
+ - **Skills**: a skill is a memory named `skill_<name>` holding the steps of a repeatable task we did together (sections Steps, Gotchas, Changelog; `memory_write description:` says when to use it).
9
+ When the user asks to keep how we did something ("let's memorize this", "save this as a skill", `/skill save`), write it right away with `memory_write` (project scope; system only when the user asks or it isn't about this project), then show it briefly.
10
+ Before a task that a `skill_*` in the memory index matches, read it and follow it.
11
+ When a step turned out different (a renamed command, an extra step, a gotcha), update the skill in the same turn: fix those steps, keep the rest as it was, add a dated Changelog line.
12
+ When a skill's step fails or its file/command is missing, find out why (look around, read nearby READMEs) before skipping it; a step that says stop means stop and ask.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.4.0
3
+ version: 0.5.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
- identity.md: sha256:8b594100bee4797b563bd6425dc6f3ff93e1bdfbe9fc5fa1d66b42093c4795e2
8
- self_map.md: sha256:67eb6223728a72788fa0d75aa56affea2ca48962de2e445293bc12b15f5f70c0
9
- config_modification_protocol.md: sha256:f76ee7e2f5a8e699401e3a4c9144643ee64015efd11a266029704bf228be6a95
10
- memory_guide.md: sha256:6dd1ecfd6a377c4f2cfc2dc299c1ef3835f10d9f313616fd3eb23e5fd3202858
7
+ identity.md: sha256:b7d59a1e824f0918c28a38d40105e8abd1ff68ebab1810fd1dcf52cd3b72e7c0
8
+ self_map.md: sha256:59ca83f92d11bc6bdc69f3111b3b8e1189280499ae955b10043ff993387b4550
9
+ config_modification_protocol.md: sha256:71ff60549574c4be7d4c70801582a19d5d9ec6d5be87f5867f9aa113e5484f21
10
+ memory_guide.md: sha256:43be77156562f1eac369c248045e35ccd89e87d1f7f95a9286be72521b058e87
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -44,6 +44,32 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
44
44
  **Placeholders:**
45
45
  - Content may contain placeholder hints written as double-curly braces around a name (e.g., test_command, language). Detected by `Placeholder` (`Placeholder::PLACEHOLDER_RE`) — install warns but does not fail. Fill them when you write. The placeholder syntax is two opening braces, a name, two closing braces.
46
46
 
47
+ ## Skills
48
+
49
+ A skill is a memory named `skill_<name>` (`skill_release`, `skill_deploy_staging`) that holds the steps of a repeatable task done with the user. Next time, follow it; when a step turned out different, fix it in the same turn.
50
+
51
+ Shape (plain Markdown, no frontmatter):
52
+
53
+ ```markdown
54
+ # Skill: release
55
+
56
+ ## Steps
57
+ 1. Run `scripts/verify.sh`; stop if it fails.
58
+ 2. …
59
+ ## Gotchas
60
+ - …
61
+ ## Changelog
62
+ - 2026-09-29 created
63
+ - 2026-09-30 step 1: check.sh was renamed to verify.sh
64
+ ```
65
+
66
+ - **When to use it** is the `description:` of `memory_write`: one line starting with the task ("Release a new version of this repo: verify, tag, push"). It is what the index shows, so it is how you find the skill later.
67
+ - **Scope**: `project` by default; `system` when the user asks, or when the skill is clearly not about this project.
68
+ - **Saving**: on a request to keep how something was done ("let's memorize this", "save this as a skill", `/skill save`), write it at once, then show it briefly. On an ambiguous one ("I like how we did that") you may ask whether to save it.
69
+ - **Following**: before a task a `skill_*` index line matches, `memory_read` it and follow its steps. A step that fails or names a missing file or command: find out why (look around, read nearby READMEs) before skipping it; a step that says stop means stop and ask.
70
+ - **Updating**: when a step turned out different, rewrite the skill with `memory_write` in the same turn: fix those steps, keep the rest as it was, add a dated Changelog line. No confirmation needed.
71
+ - **The `skills` bundle** (`chi bundle install skills`, optional) adds `/skill save [name] [--system]`, `/skill list`, `/skill show <name>`, `/skill diff <name> [N]`; it keeps older versions and shows a short diff line after each update.
72
+
47
73
  ## Memory Bundles — shareable packs
48
74
 
49
75
  Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and any of: memories (`*.md`), `hooks/*.rb` (bundle hooks, `docs/hooks.md`), `guardrails/*.yml` (rules, `docs/guardrails.md`), a `plugin.rb` (commands, tools, hooks, services; `docs/plugins.md`). They are shareable and installable.
@@ -33,7 +33,8 @@
33
33
  `mcp` (MCP server tools; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
34
34
  (turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
35
35
  (denies a repeated tool call with the same result, stops the turn after a few), `check-in` (after N tool
36
- calls with no answer, a card asks the user to nudge me, let me go on or stop; `/checkin`). `chi bundle list`
36
+ calls with no answer, a card asks the user to nudge me, let me go on or stop; `/checkin`), `skills` (`/skill save|list|show|diff`,
37
+ keeps older versions of `skill_*` memories). `chi bundle list`
37
38
  shows installed + available; `chi bundle install <name>`. Settings: config.yml `bundles: <name>:`
38
39
  (`config_modification_protocol`), read at session start: after an install or a settings change,
39
40
  tell the user to restart the session. API and bundle docs: `docs/plugins.md`.
@@ -13,12 +13,12 @@ require_relative "sampling_settings"
13
13
  module Samagotchi
14
14
  # Thin HTTP client for llama.cpp's native /completion endpoint, or an
15
15
  # OpenAI-compatible /v1/completions endpoint (e.g. mlx_lm.server or oMLX).
16
- # Configure via environment variables (see Samagotchi::Config):
17
- # SAMAGOTCHI_SERVER_HOST (default: localhost)
18
- # SAMAGOTCHI_SERVER_PORT (default: 8080; oMLX's default is 8000, set it to match)
19
- # SAMAGOTCHI_SERVER_OPEN_TIMEOUT (default: 10 seconds)
20
- # SAMAGOTCHI_SERVER_READ_TIMEOUT (default: 600 seconds)
21
- # SAMAGOTCHI_SERVER_TRANSPORT (llama_cpp|mlx|omlx, default: llama_cpp)
16
+ # Configured by the server.* settings (Samagotchi::Config):
17
+ # server.host (default: localhost)
18
+ # server.port (default: 8080; oMLX's default is 8000, set it to match)
19
+ # server.open_timeout (default: 10 seconds)
20
+ # server.read_timeout (default: 600 seconds)
21
+ # server.transport (llama_cpp|mlx|omlx, default: llama_cpp)
22
22
  class Client
23
23
  # The shared HTTP layer's errors, under their old names.
24
24
  RequestCancelled = LLM::RequestCancelled
@@ -33,7 +33,6 @@ module Samagotchi
33
33
  # generation, and a new turn doesn't ask again at once.
34
34
  PROPS_FAILURE_TTL = 30
35
35
 
36
- SERVER_TRANSPORT_ENV = "SAMAGOTCHI_SERVER_TRANSPORT"
37
36
  DEFAULT_TRANSPORT = :llama_cpp
38
37
  VALID_TRANSPORTS = %i[llama_cpp mlx omlx].freeze
39
38
 
@@ -154,21 +153,12 @@ module Samagotchi
154
153
  def initialize(host: nil, port: nil, open_timeout: nil, read_timeout: nil, transport: nil, sleeper: nil, scheme: nil,
155
154
  first_token_timeout: nil, name: nil, api_key_env: nil, env: ENV)
156
155
  # Unified config precedence: CLI > ENV > file > default (via Samagotchi::Config)
157
- cfg_host = nil; cfg_port = nil; cfg_transport_raw = nil
158
- begin
159
- cfg_host = Samagotchi::Config.get("server.host")
160
- cfg_port = Samagotchi::Config.get("server.port")
161
- cfg_transport_raw = Samagotchi::Config.get("server.transport")
162
- rescue StandardError
163
- nil
164
- end
165
- @host = host || cfg_host
166
- @port = (port || cfg_port).to_i
156
+ @host = host || Samagotchi::Config.get("server.host")
157
+ @port = (port || Samagotchi::Config.get("server.port")).to_i
167
158
  @scheme = scheme || "http"
168
159
  @open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout", open_timeout)
169
160
  @read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout", read_timeout)
170
- transport_fallback = cfg_transport_raw || ENV.fetch(SERVER_TRANSPORT_ENV, DEFAULT_TRANSPORT.to_s)
171
- @transport = build_transport(resolve_transport(transport || transport_fallback))
161
+ @transport = build_transport(resolve_transport(transport))
172
162
  @props_cache = {}
173
163
  @props_failures = {}
174
164
  @props_mutex = Mutex.new
@@ -324,6 +314,12 @@ module Samagotchi
324
314
  props
325
315
  end
326
316
 
317
+ # The /props answer #server_props already has for +model+, or nil;
318
+ # never asks the server.
319
+ def cached_server_props(model: nil)
320
+ @props_mutex.synchronize { @props_cache[model.to_s] }
321
+ end
322
+
327
323
  # The context window (tokens) the running server was started with, or nil
328
324
  # when the transport reports none or the probe fails (see #server_props).
329
325
  def context_window(model: nil)
@@ -370,7 +366,7 @@ module Samagotchi
370
366
  end
371
367
 
372
368
  def resolve_transport(transport)
373
- value = (transport || ENV.fetch(SERVER_TRANSPORT_ENV, DEFAULT_TRANSPORT.to_s)).to_s.strip.downcase.to_sym
369
+ value = (transport || Samagotchi::Config.get("server.transport")).to_s.strip.downcase.to_sym
374
370
  VALID_TRANSPORTS.include?(value) ? value : DEFAULT_TRANSPORT
375
371
  end
376
372