samagotchi 0.8.1 → 0.10.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +9 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +47 -17
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +29 -29
  12. data/lib/samagotchi/bridge/card_store.rb +4 -4
  13. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  14. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  16. data/lib/samagotchi/bridge.rb +59 -112
  17. data/lib/samagotchi/bridge_client.rb +11 -28
  18. data/lib/samagotchi/bundle_command.rb +158 -212
  19. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  21. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  22. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  25. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  27. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  32. data/lib/samagotchi/bundles/system/identity.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
  35. data/lib/samagotchi/cli/command.rb +51 -0
  36. data/lib/samagotchi/cli/flags.rb +116 -0
  37. data/lib/samagotchi/config.rb +70 -55
  38. data/lib/samagotchi/config_text_edit.rb +107 -0
  39. data/lib/samagotchi/desktop_command.rb +24 -18
  40. data/lib/samagotchi/edit_preview.rb +2 -2
  41. data/lib/samagotchi/engine.rb +20 -75
  42. data/lib/samagotchi/events.rb +23 -0
  43. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  44. data/lib/samagotchi/guardrails/targets.rb +7 -1
  45. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  46. data/lib/samagotchi/hooks/loader.rb +23 -33
  47. data/lib/samagotchi/hooks/registry.rb +41 -0
  48. data/lib/samagotchi/hooks.rb +0 -3
  49. data/lib/samagotchi/host_registry.rb +15 -0
  50. data/lib/samagotchi/idle_reminders.rb +0 -7
  51. data/lib/samagotchi/image_store.rb +22 -0
  52. data/lib/samagotchi/kernel_loop.rb +12 -16
  53. data/lib/samagotchi/live_versions.rb +2 -13
  54. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  55. data/lib/samagotchi/log_subscriber.rb +5 -1
  56. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  57. data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  59. data/lib/samagotchi/memory_bundle/installer.rb +25 -30
  60. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  61. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  63. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  64. data/lib/samagotchi/memory_paths.rb +10 -0
  65. data/lib/samagotchi/note_command.rb +23 -37
  66. data/lib/samagotchi/owner_lock.rb +14 -3
  67. data/lib/samagotchi/plugin/api.rb +11 -17
  68. data/lib/samagotchi/plugin/context.rb +70 -15
  69. data/lib/samagotchi/plugin/loader.rb +2 -3
  70. data/lib/samagotchi/sampling_settings.rb +3 -5
  71. data/lib/samagotchi/self_report.rb +6 -9
  72. data/lib/samagotchi/send_command.rb +33 -52
  73. data/lib/samagotchi/session.rb +88 -92
  74. data/lib/samagotchi/session_archive_command.rb +8 -14
  75. data/lib/samagotchi/session_commands.rb +3 -21
  76. data/lib/samagotchi/session_delete_command.rb +15 -29
  77. data/lib/samagotchi/session_manager.rb +29 -15
  78. data/lib/samagotchi/session_metrics.rb +2 -1
  79. data/lib/samagotchi/sessions_command.rb +3 -2
  80. data/lib/samagotchi/system_prompt.rb +2 -1
  81. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  83. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  85. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  86. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  87. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  88. data/lib/samagotchi/terminal_ui.rb +156 -234
  89. data/lib/samagotchi/thinking.rb +2 -3
  90. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  91. data/lib/samagotchi/tool_call_parser.rb +56 -292
  92. data/lib/samagotchi/tool_declarations.rb +1 -1
  93. data/lib/samagotchi/tool_runner.rb +8 -0
  94. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  95. data/lib/samagotchi/tools/builtins.rb +2 -1
  96. data/lib/samagotchi/tools/edit.rb +21 -29
  97. data/lib/samagotchi/tools/memory.rb +2 -13
  98. data/lib/samagotchi/tools/send_note.rb +1 -1
  99. data/lib/samagotchi/update_command.rb +32 -22
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/vision_support.rb +4 -5
  102. data/lib/samagotchi/web/app.rb +89 -151
  103. data/lib/samagotchi/web/public/activity.js +1 -11
  104. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  105. data/lib/samagotchi/web/public/app.js +158 -785
  106. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  107. data/lib/samagotchi/web/public/command_list.js +99 -0
  108. data/lib/samagotchi/web/public/data.js +22 -66
  109. data/lib/samagotchi/web/public/format.js +0 -23
  110. data/lib/samagotchi/web/public/index.html +0 -2
  111. data/lib/samagotchi/web/public/model_pick.js +0 -7
  112. data/lib/samagotchi/web/public/notifications.js +169 -0
  113. data/lib/samagotchi/web/public/question_cards.js +352 -0
  114. data/lib/samagotchi/web/public/session_state.js +34 -0
  115. data/lib/samagotchi/web/public/timing.js +1 -1
  116. data/lib/samagotchi/web/public/turn_events.js +11 -2
  117. data/lib/samagotchi/web/public/turn_model.js +0 -14
  118. data/lib/samagotchi/web/session_hub.rb +6 -4
  119. data/lib/samagotchi/web/session_summary.rb +5 -3
  120. data/lib/samagotchi/worker.rb +59 -53
  121. data/lib/samagotchi/worker_sidecar.rb +27 -1
  122. metadata +13 -1
@@ -187,13 +187,13 @@ end
187
187
  class Plugin
188
188
  DEFAULT_IGNORE = %w[task_wait task_get delegate_result list_sessions list_reminders].freeze
189
189
  # Built-in tools carry flat fields; plugin and unknown tools carry args:.
190
- KEY_FIELDS = %i[content path start_line end_line cwd env scope].freeze
190
+ # An edit's text is its old_text/new_text.
191
+ KEY_FIELDS = %i[content path start_line end_line cwd env scope old_text new_text].freeze
191
192
  SHORT_CHARS = 60
192
193
  THOUGHT_CHARS = 80
193
194
  SOURCE = "bundle loop-guard"
194
195
 
195
196
  def initialize(settings = {})
196
- settings = {} unless settings.is_a?(Hash)
197
197
  @deny_after = positive(settings["deny_after"]) || 2
198
198
  @stop_after = positive(settings["stop_after"]) || 4
199
199
  @ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
@@ -234,16 +234,16 @@ class Plugin
234
234
  ctx.notify("thinking repeats itself (#{what})", level: :warn)
235
235
  when "retry"
236
236
  @thinking_loops += 1
237
- return stop_thinking(event, ctx, found, what) if @thinking_loops > 1
237
+ return stop_thinking(ctx, found, what) if @thinking_loops > 1
238
238
 
239
- ctx.notify("thinking repeats itself (#{what}): cut", level: :warn) if event[:stop_generation]&.call("its thinking kept repeating itself")
239
+ ctx.notify("thinking repeats itself (#{what}): cut", level: :warn) if ctx.stop_generation("its thinking kept repeating itself")
240
240
  else
241
- stop_thinking(event, ctx, found, what)
241
+ stop_thinking(ctx, found, what)
242
242
  end
243
243
  end
244
244
 
245
- def stop_thinking(event, ctx, found, what)
246
- return unless event[:stop_turn]&.call("the model's thinking kept repeating itself (#{what})")
245
+ def stop_thinking(ctx, found, what)
246
+ return unless ctx.stop_turn("the model's thinking kept repeating itself (#{what})")
247
247
 
248
248
  lines = found.sentences.first(3).map { |sentence| "- \"#{cut(sentence, THOUGHT_CHARS)}\"" }
249
249
  ctx.card(title: "loop-guard stopped the turn",
@@ -288,7 +288,7 @@ class Plugin
288
288
  verdict.deny!("repeated call", source: SOURCE, advice: advice(key, times))
289
289
  warn(ctx, key, "repeated, denied")
290
290
  @denials += 1
291
- stop(event, ctx) if @denials == @stop_after
291
+ stop(ctx) if @denials == @stop_after
292
292
  end
293
293
 
294
294
  # A call's result, paired with the before_tool_call that keyed it. A call
@@ -308,8 +308,8 @@ class Plugin
308
308
  @previews[key] = preview(output)
309
309
  end
310
310
 
311
- def stop(event, ctx)
312
- event[:stop_turn]&.call("the model kept repeating the same calls")
311
+ def stop(ctx)
312
+ ctx.stop_turn("the model kept repeating the same calls")
313
313
  repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
314
314
  lines = repeated.map do |key, n|
315
315
  "- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:f7bfcaae51863c0dc2b057b0d30f79b7bed7935891e817fa7373f787e5c4ca5e
11
- requires_chi: ">= 0.1.37"
10
+ sha256: sha256:61b195a5621ddf57ba69a505aff06d620d6459e836d736205343a251b0fecb71
11
+ requires_chi: ">= 0.8.0"
@@ -421,10 +421,8 @@ class Plugin
421
421
 
422
422
  # Written aside and renamed: workers share the dir.
423
423
  def save_cache(server, ctx)
424
- path = cache_path(server, ctx)
425
- tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
426
- File.write(tmp, JSON.generate({ digest: server.digest, saved_at: Time.now.utc.iso8601, tools: server.listed }))
427
- File.rename(tmp, path)
424
+ Samagotchi::AtomicFile.write(cache_path(server, ctx),
425
+ JSON.generate({ digest: server.digest, saved_at: Time.now.utc.iso8601, tools: server.listed }))
428
426
  rescue SystemCallError => e
429
427
  ctx.log.warn("mcp_cache_not_written", server: server.name, msg: e.message)
430
428
  end
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: skills
3
- version: 0.1.0
3
+ version: 0.1.3
4
4
  scope: system
5
5
  description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:76758d41e75db107e0a6f7ce35b9eac4944052746af16aeba30f38210cc436dc
10
- requires_chi: ">= 0.4.0"
9
+ sha256: sha256:4f32b3457ada3392009e4a32b690e577ef0bb05d46c6cfed9ae02640119620d8
10
+ requires_chi: ">= 0.9.0"
@@ -13,13 +13,20 @@
13
13
  # shows a line: "skill release updated (+2 −1): …" or "skill release saved
14
14
  # (project, 14 lines)".
15
15
  #
16
+ # A skill read in this turn is also watched for changes made some other way
17
+ # (an execute running sed, a script): after each other tool call, and at the
18
+ # turn's end, its file is compared with the content last seen (at the read,
19
+ # or after the last write above); a change keeps that content as a version,
20
+ # shows the same line and counts as the skill updated.
21
+ #
16
22
  # The nudge (nudge: true), for models that skip a failing step instead of
17
23
  # fixing the skill: in a turn that read a skill (memory_read of a skill_*
18
24
  # name, or a read of its file), the first failing tool call after it (an
19
25
  # execute with "exit: N", N ≠ 0, or with no exit line an Error: line near
20
26
  # 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.
27
+ # why and fix the skill, unless a skill read was changed already (by any
28
+ # tool, see above). At the turn's end, a failed step with no change to a
29
+ # skill read gets a notice line.
23
30
  #
24
31
  # Settings (config.yml, bundles: skills:):
25
32
  # history_keep: 20 older versions kept per skill
@@ -31,13 +38,11 @@ class Plugin
31
38
  WRITE_TOOLS = %w[memory_write write edit].freeze
32
39
  NOTICE_WIDTH = 60 # the changed line in an update's notice
33
40
  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."
41
+ "edit the step that changed in its file (or memory_write the whole skill) and add a Changelog line."
36
42
  NOT_FAILURES = %w[memory_read memory_write].freeze
37
43
  USAGE = "usage: /skill save [name] [--system] | list | show <name> | diff <name> [N]"
38
44
 
39
45
  def initialize(settings = {})
40
- settings = {} unless settings.is_a?(Hash)
41
46
  @history_keep = positive(settings["history_keep"]) || 20
42
47
  @nudge = settings.key?("nudge") ? settings["nudge"] != false : true
43
48
  @stash = nil
@@ -79,16 +84,38 @@ class Plugin
79
84
  def after_tool_call(event, ctx)
80
85
  stash = @stash
81
86
  @stash = nil
82
- step_failed(event) if @nudge && !@read.empty?
83
- return unless stash && stash[:tool] == event[:tool].to_s
87
+ stash = nil unless stash && stash[:tool] == event[:tool].to_s
88
+ changed_elsewhere(ctx) unless stash
89
+ step_failed(event, ctx) if @nudge && !@read.empty?
90
+ return unless stash
84
91
 
85
92
  now = File.file?(stash[:path]) ? File.read(stash[:path]) : nil
86
93
  return if now.nil? || now == stash[:old]
87
94
 
88
- @written << stash[:name]
95
+ @written |= [stash[:name]]
96
+ @seen[stash[:name]] = { path: stash[:path], scope: stash[:scope], content: now }
89
97
  ctx.notify(change_notice(stash, now))
90
98
  end
91
99
 
100
+ # A skill read this turn whose file differs from the content last seen was
101
+ # changed by something other than the write tools (an execute's sed, say):
102
+ # keep what was there as a version, show the change, count the skill as
103
+ # updated (any change counts, the user's own edit too). A file gone since
104
+ # is left alone.
105
+ def changed_elsewhere(ctx)
106
+ @seen.each do |name, seen|
107
+ now = File.file?(seen[:path]) ? File.read(seen[:path]) : nil
108
+ next if now.nil? || now == seen[:content]
109
+
110
+ keep_version(ctx, seen[:scope], name, seen[:content]) if seen[:content]
111
+ ctx.notify(change_notice({ name: name, scope: seen[:scope], old: seen[:content] }, now))
112
+ seen[:content] = now
113
+ @written |= [name]
114
+ end
115
+ rescue SystemCallError => e
116
+ ctx.log.warn(:watch_failed, error: e.class.name, msg: e.message)
117
+ end
118
+
92
119
  def change_notice(stash, now)
93
120
  name = stash[:name]
94
121
  return "skill #{name} saved (#{stash[:scope]}, #{now.lines.size} lines)" unless stash[:old]
@@ -105,31 +132,52 @@ class Plugin
105
132
 
106
133
  def reset_turn
107
134
  @read = [] # skills read this turn, in order
135
+ @seen = {} # of those, name => {path:, scope:, content:} last seen
108
136
  @written = [] # skills changed this turn
109
137
  @failed = false # a step failed after a skill was read
110
138
  @nudged = false
111
139
  end
112
140
 
113
141
  def note_read(tool, event)
114
- names = case tool
142
+ found = case tool
115
143
  when "memory_read"
144
+ scope = event.dig(:call, :scope).to_s.strip
145
+ scope = nil unless memory_dirs.key?(scope)
146
+ # A skill not written yet is watched where memory_write would
147
+ # put it by default: the read scope, else project.
116
148
  event.dig(:call, :content).to_s.split(",").map(&:strip).select { |entry| entry.start_with?("skill_") }
117
- .filter_map { |entry| skill_name(entry) }
149
+ .filter_map do |entry|
150
+ name = skill_name(entry) or next
151
+ unwritten = File.join(memory_dirs[scope || "project"], "skill_#{name}.md")
152
+ [name, skill_path(name, scope: scope) || unwritten]
153
+ end
118
154
  when "read"
119
- Array(event.dig(:targets, :paths)).filter_map { |path| skill_at(path)&.last }
155
+ Array(event.dig(:targets, :paths)).filter_map { |path| (at = skill_at(path)) && [at.last, path] }
120
156
  else []
121
157
  end
122
- @read |= names
158
+ found.each { |name, path| watch(name, path) }
159
+ @read |= found.map(&:first)
160
+ end
161
+
162
+ # Remember a read skill's content (nil: not written yet) the first time in
163
+ # a turn: a later read mustn't hide a change made in between.
164
+ def watch(name, path)
165
+ return if @seen.key?(name) || path.nil?
166
+
167
+ content = File.file?(path) ? File.read(path) : nil
168
+ @seen[name] = { path: File.expand_path(path), scope: skill_at(path)&.first, content: content }
169
+ rescue SystemCallError
170
+ nil
123
171
  end
124
172
 
125
- def step_failed(event)
173
+ def step_failed(event, ctx)
126
174
  tool = event[:tool].to_s
127
175
  return if NOT_FAILURES.include?(tool) || !failure?(tool, event[:output].to_s)
128
176
 
129
177
  @failed = true
130
- return if @nudged
178
+ return if @nudged || @read.any? { |name| @written.include?(name) }
131
179
 
132
- @nudged = !!event[:steer]&.call(format(NUDGE, @read.join(", ")))
180
+ @nudged = ctx.steer(format(NUDGE, @read.join(", ")))
133
181
  end
134
182
 
135
183
  # A tool error ("[x] Error: …" raised, "[x]\nError: …" returned), or an
@@ -146,6 +194,7 @@ class Plugin
146
194
  end
147
195
 
148
196
  def after_turn(ctx)
197
+ changed_elsewhere(ctx)
149
198
  return unless @nudge && @failed
150
199
 
151
200
  missed = @read - @written
@@ -166,8 +215,7 @@ class Plugin
166
215
 
167
216
  stamp = Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ")
168
217
  path = File.join(dir, "#{stamp}.md")
169
- File.write("#{path}.tmp", content)
170
- File.rename("#{path}.tmp", path)
218
+ Samagotchi::AtomicFile.write(path, content)
171
219
  versions(dir).drop(@history_keep).each { |old| File.delete(old) }
172
220
  rescue SystemCallError => e
173
221
  ctx.log.warn(:history_failed, skill: name, error: e.class.name, msg: e.message)
@@ -92,7 +92,6 @@ class SourceLinks
92
92
  end
93
93
 
94
94
  def initialize(settings = {})
95
- settings = {} unless settings.is_a?(Hash)
96
95
  @sources = compile_sources(settings["sources"])
97
96
  max = settings["max"].to_i
98
97
  @max = max.positive? ? max : DEFAULT_MAX
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.0
3
+ version: 0.3.1
4
4
  scope: system
5
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
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:0ee86a932efc90d81777855c81dea16510710d7371fe8b6b146a5a16b29794c2
11
+ sha256: sha256:574ea0c2b36f80dde410c4251753d70cbeea081d129f3f3547f41370e3990993
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -113,7 +113,7 @@ bundles:
113
113
 
114
114
  A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cover every MCP tool; see `docs/guardrails.md`. The mcp bundle saves each server's tool list in `$XDG_STATE_HOME/samagotchi/plugins/mcp/tools-<server>.json` (keyed by a digest of command/env/cwd): a changed server config is picked up by the next session start, which shows "Starting MCP server x (config changed, …)".
115
115
 
116
- **Preservation rule**: `ConfigFile.write_default_model!` and `ConfigFile.write_model_alias!` both load raw YAML (including nested sections and maps), mutate one key (`raw_data["default"]["model"] = ...` for new form), write atomically via `tmp`+`rename`. Never overwrite the file with only scalar keys — that would clobber `hooks:` / `model_aliases:` / `hosts:` / `recap:` / `guardrails:` / `bundles:`.
116
+ **Preservation rule**: `ConfigFile.write_default_model!` and `ConfigFile.write_model_alias!` (`/model --default`, `/model --alias`) change one key on its own line of the text (`ConfigTextEdit`: the user's comments and layout stay; a new key goes at the end of its section, a missing section at the end of the file), check that the result parses to the old data plus that key (else they dump the whole file from it), and write through `AtomicFile` (a unique temp file renamed over config.yml, through a symlink, keeping its mode). Never overwrite the file with only scalar keys — that would clobber `hooks:` / `model_aliases:` / `hosts:` / `recap:` / `guardrails:` / `bundles:`.
117
117
 
118
118
  ## Workflow for any config edit
119
119
 
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
121
121
  2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
122
122
  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"`.
123
123
  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.
124
- 5. **Write atomically**: `FileUtils.mkdir_p(File.dirname(path))`, `File.write("#{path}.tmp", YAML.dump(raw_data))`, `File.rename("#{path}.tmp", path)`.
124
+ 5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
125
125
  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`).
126
126
 
127
127
  ## Validations
@@ -8,6 +8,6 @@
8
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
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
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.
11
+ When a step turned out different (a renamed command, an extra step, a gotcha), update the skill in the same turn: `edit` those steps in its file (memory_write only to rewrite it all), keep the rest as it was, add a dated Changelog line.
12
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.
13
13
  While following a skill, when anything looks unexpected (a check fails, output differs from what a step says, a warning the skill doesn't mention): stop, don't improvise a fix, tell the user what you saw and ask how to go on. Read each command's whole output, including warnings, before the next step.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.8.1
3
+ version: 0.10.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:d6f40d5e44096537d337c4ffda86823446f8b59eb8b8cc47014e97ec16f019c9
7
+ identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
- config_modification_protocol.md: sha256:24e95727488daa80ac3008e020d8be098c7a1882153b7a61aba35450f72b86b7
10
- memory_guide.md: sha256:50c076fb6bda7ed010877ff664bb2e9e904418fe08b058ebaa911d4e82e9a683
9
+ config_modification_protocol.md: sha256:d8bdf062d5afce7430ead7f49439c1d1ed197a6261dc69a7c884f4b814266b12
10
+ memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -6,7 +6,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
6
6
 
7
7
  - Plain Markdown files (`*.md`) stored outside the repo so they persist.
8
8
  - Loaded at startup into your system prompt as **Project memories** and **System memories** indexes (blank-name `memory_read`).
9
- - You manage them via tools, not shell file ops. On write, `index.md` is auto-maintained — do not edit it manually.
9
+ - You manage them via tools (`memory_write`, or `edit` on a memory's file for a small change), not shell file ops. `index.md` is auto-maintained on every write — do not edit it manually.
10
10
 
11
11
  ## Scopes — where files live
12
12
 
@@ -16,7 +16,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
16
16
  | `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
17
17
 
18
18
  - The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the resolved root and folder. Outside a repository the root is the working directory. A separate clone is a different project.
19
- - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write`.
19
+ - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
20
20
 
21
21
  ## Tools you have
22
22
 
@@ -34,7 +34,8 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
34
34
  - `name` is the entry name **without** `.md` (the tool adds it). Use `name`, **not** `path` — `path` belongs to the file tools and is ignored here. `name: "index"` is a verbatim write to `index.md` (no auto-index update) — rarely needed.
35
35
  - `scope` is **required** — never omit. Prefer `project` for repo conventions, `system` for user preferences.
36
36
  - `description` is appended to the managed `index.md` line (`— description`). Replaces previous description if given; otherwise preserves existing one.
37
- - On success returns `Memory 'name' saved to <scope> scope (N bytes). Index updated: …` — confirm `bytes` and `scope`.
37
+ - For a small change to an existing memory (a step, a line), `edit` its file instead of rewriting it all: the path is in `memory_write`'s result and the scope dirs are in the prompt. Its index line is refreshed either way.
38
+ - On success returns `Memory 'name' saved to <scope> scope (N bytes). File written: <path> Index line refreshed automatically.` — confirm `bytes` and `scope`; the index needs nothing from you.
38
39
 
39
40
  **When to write:**
40
41
  - After learning a durable preference (e.g. commit style, test command, coding guideline) that the user confirmed or you observed repeatedly — ask before overwriting existing entries where appropriate.
@@ -67,7 +68,7 @@ Shape (plain Markdown, no frontmatter):
67
68
  - **Scope**: `project` by default; `system` when the user asks, or when the skill is clearly not about this project.
68
69
  - **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
70
  - **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. When anything looks unexpected (a check fails, output differs from what a step says, a warning the skill doesn't mention): stop, don't improvise a fix, tell the user what you saw and ask. Read each command's whole output, including warnings, before the next step.
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
+ - **Updating**: when a step turned out different, update the skill in the same turn: `edit` those steps in its file (`<scope dir>/skill_<name>.md`), or rewrite it with `memory_write`; keep the rest as it was, add a dated Changelog line. No confirmation needed.
71
72
  - **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
73
 
73
74
  ## Memory Bundles — shareable packs
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module CLI
5
+ # The usage side of a subcommand: its error lines, usage errors (exit 2)
6
+ # and help. The including class sets @stdout and @stderr and defines
7
+ # #command_name ("chi send"); its usage text is USAGE unless it
8
+ # overrides #usage_text.
9
+ module Command
10
+ USAGE_EXIT = 2
11
+ HELP_WORDS = %w[-h --help help].freeze
12
+
13
+ private
14
+
15
+ def usage_text = self.class::USAGE
16
+
17
+ # What follows a usage error's line: the usage, or nil for a short
18
+ # "(see chi … --help)" at the end of the line instead.
19
+ def usage_on_error = usage_text
20
+
21
+ # stderr isn't buffered, stdout is when it's a pipe: flush the lines
22
+ # already printed, so the output keeps the order of the ids given.
23
+ def error_line(text)
24
+ @stdout.flush
25
+ @stderr.puts(text)
26
+ end
27
+
28
+ # @return [Integer] the usage exit status
29
+ def usage_error(message)
30
+ usage = usage_on_error
31
+ error_line("#{command_name}: #{message}#{usage ? "" : " (see #{command_name} --help)"}")
32
+ @stderr.puts(usage) if usage
33
+ USAGE_EXIT
34
+ end
35
+
36
+ # +argv+ through +flags+ (a Flags), help and usage errors done.
37
+ # @return [Flags::Result, Integer] the result, or the exit status
38
+ # after the help (0) or a usage error (2)
39
+ def parse_flags(flags, argv, defaults = {})
40
+ parsed = flags.parse(argv, defaults)
41
+ if parsed.help
42
+ @stdout.puts(usage_text)
43
+ return 0
44
+ end
45
+ return usage_error(parsed.error.message) if parsed.error
46
+
47
+ parsed
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module CLI
5
+ # A subcommand's flags as a table (the subcommands bin/chi dispatches
6
+ # before OptionParser): each flag's names, whether it takes a value
7
+ # ("--flag V", or "--flag=V" for a long name) and whether it repeats.
8
+ # #parse walks argv once, in order, and stops at the first help word or
9
+ # error; what isn't a flag is a positional argument.
10
+ #
11
+ # FLAGS = CLI::Flags.new(help: %w[-h --help help]) do |f|
12
+ # f.value "-m", "--message"
13
+ # f.value "--image", key: :images, repeat: true
14
+ # f.switch "--new"
15
+ # end
16
+ # FLAGS.parse(argv, images: []) # => Result
17
+ class Flags
18
+ Flag = Struct.new(:key, :value, :repeat, :set, :refusal, keyword_init: true)
19
+
20
+ # kind :unknown (an undeclared flag, a switch given =V, or a
21
+ # positional where none are taken), :missing (a value flag with no
22
+ # value after it) or :refused (a flag declared with #refuse); +arg+ is
23
+ # the argument as given.
24
+ Error = Data.define(:kind, :arg, :refusal) do
25
+ def message
26
+ case kind
27
+ when :unknown then "unknown option #{arg}"
28
+ when :missing then "#{arg} needs a value"
29
+ else refusal
30
+ end
31
+ end
32
+ end
33
+
34
+ # +options+ by key, +args+ the positionals in order; +help+ true when
35
+ # a help word stopped the parse; +error+ nil or an Error.
36
+ Result = Data.define(:options, :args, :help, :error)
37
+
38
+ # @param help [Array<String>] words that stop the parse with help
39
+ # (not when they are a flag's value)
40
+ # @param flag_pattern [Regexp] what an undeclared flag looks like (an
41
+ # unknown option); other arguments are positionals
42
+ # @param dash_values [Boolean] false: a value flag followed by a
43
+ # "--…" argument has no value
44
+ # @param args [Boolean] false: a positional is an unknown option
45
+ def initialize(help: [], flag_pattern: /\A-/, dash_values: true, args: true)
46
+ @help = help
47
+ @flag_pattern = flag_pattern
48
+ @dash_values = dash_values
49
+ @args = args
50
+ @flags = {}
51
+ yield self if block_given?
52
+ end
53
+
54
+ # A flag without a value: options[key] = +set+. The key defaults to
55
+ # the last name without its dashes, snake_cased.
56
+ def switch(*names, key: nil, set: true)
57
+ add(names, Flag.new(key: key, value: false, set: set))
58
+ end
59
+
60
+ # A flag with a value; +repeat+: options[key] collects them in an array.
61
+ def value(*names, key: nil, repeat: false)
62
+ add(names, Flag.new(key: key, value: true, repeat: repeat))
63
+ end
64
+
65
+ # A flag that is an error, with +message+ (a flag that doesn't exist
66
+ # on purpose).
67
+ def refuse(*names, message)
68
+ add(names, Flag.new(refusal: message))
69
+ end
70
+
71
+ # @param defaults [Hash] the options before any flag (not changed)
72
+ # @return [Result]
73
+ def parse(argv, defaults = {})
74
+ options = defaults.dup
75
+ args = []
76
+ rest = argv.dup
77
+ until rest.empty?
78
+ arg = rest.shift
79
+ return Result.new(options: options, args: args, help: true, error: nil) if @help.include?(arg)
80
+
81
+ name, inline = arg.start_with?("--") && arg.include?("=") ? arg.split("=", 2) : [arg, nil]
82
+ flag = @flags[name]
83
+ flag = nil if inline && !flag&.value
84
+ unless flag
85
+ return failure(options, args, :unknown, arg) if !@args || arg.match?(@flag_pattern)
86
+
87
+ args << arg
88
+ next
89
+ end
90
+ return failure(options, args, :refused, arg, flag.refusal) if flag.refusal
91
+ next options[flag.key] = flag.set unless flag.value
92
+
93
+ unless inline
94
+ return failure(options, args, :missing, arg) if rest.empty? || (!@dash_values && rest.first.start_with?("--"))
95
+
96
+ inline = rest.shift
97
+ end
98
+ options[flag.key] = flag.repeat ? [*options[flag.key], inline] : inline
99
+ end
100
+ Result.new(options: options, args: args, help: false, error: nil)
101
+ end
102
+
103
+ private
104
+
105
+ def add(names, flag)
106
+ flag.key ||= names.last.sub(/\A-+/, "").tr("-", "_").to_sym unless flag.refusal
107
+ names.each { |name| @flags[name] = flag }
108
+ self
109
+ end
110
+
111
+ def failure(options, args, kind, arg, refusal = nil)
112
+ Result.new(options: options, args: args, help: false, error: Error.new(kind: kind, arg: arg, refusal: refusal))
113
+ end
114
+ end
115
+ end
116
+ end