samagotchi 0.7.0 → 0.8.1

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/configuration.md +1 -0
  7. data/docs/guardrails.md +44 -4
  8. data/docs/hooks.md +2 -2
  9. data/docs/internals/context-telemetry.md +3 -1
  10. data/docs/memory.md +48 -1
  11. data/docs/plugins.md +7 -5
  12. data/docs/releasing.md +6 -2
  13. data/docs/sessions.md +2 -2
  14. data/lib/samagotchi/archive_store.rb +3 -3
  15. data/lib/samagotchi/atomic_file.rb +34 -0
  16. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  17. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  18. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  19. data/lib/samagotchi/bootstrap_command.rb +78 -8
  20. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  21. data/lib/samagotchi/bridge.rb +56 -116
  22. data/lib/samagotchi/bridge_client.rb +7 -11
  23. data/lib/samagotchi/bundle_command.rb +81 -21
  24. data/lib/samagotchi/bundle_needs.rb +2 -11
  25. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  27. data/lib/samagotchi/bundles/guardrails/guardrails/small-models.yml +27 -0
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -2
  29. data/lib/samagotchi/bundles/system/identity.md +1 -0
  30. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  32. data/lib/samagotchi/client.rb +3 -23
  33. data/lib/samagotchi/config.rb +9 -11
  34. data/lib/samagotchi/context_note.rb +1 -1
  35. data/lib/samagotchi/context_status.rb +255 -0
  36. data/lib/samagotchi/desktop/macos.rb +2 -3
  37. data/lib/samagotchi/engine.rb +19 -7
  38. data/lib/samagotchi/guardrail_wiring.rb +6 -6
  39. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  40. data/lib/samagotchi/guardrails/gate.rb +7 -4
  41. data/lib/samagotchi/guardrails/model_size.rb +87 -0
  42. data/lib/samagotchi/guardrails/rules.rb +35 -3
  43. data/lib/samagotchi/guardrails/targets.rb +22 -4
  44. data/lib/samagotchi/guardrails.rb +1 -0
  45. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  46. data/lib/samagotchi/hooks/loader.rb +2 -1
  47. data/lib/samagotchi/hooks/registry.rb +21 -0
  48. data/lib/samagotchi/image_store.rb +2 -3
  49. data/lib/samagotchi/kernel_loop.rb +21 -283
  50. data/lib/samagotchi/live_versions.rb +9 -13
  51. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  52. data/lib/samagotchi/llm/native_backend.rb +8 -8
  53. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  54. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  55. data/lib/samagotchi/log_path.rb +2 -3
  56. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  57. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  58. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  59. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  60. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  61. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  62. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  63. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  64. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  65. data/lib/samagotchi/memory_bundle.rb +1 -0
  66. data/lib/samagotchi/note_command.rb +5 -4
  67. data/lib/samagotchi/owner_lock.rb +0 -6
  68. data/lib/samagotchi/paths.rb +32 -0
  69. data/lib/samagotchi/plugin/api.rb +3 -6
  70. data/lib/samagotchi/plugin/context.rb +2 -3
  71. data/lib/samagotchi/plugin/loader.rb +1 -1
  72. data/lib/samagotchi/question_desk.rb +3 -2
  73. data/lib/samagotchi/recap_store.rb +2 -2
  74. data/lib/samagotchi/reply_wait.rb +2 -1
  75. data/lib/samagotchi/self_report.rb +2 -3
  76. data/lib/samagotchi/send_command.rb +3 -2
  77. data/lib/samagotchi/session.rb +41 -143
  78. data/lib/samagotchi/session_commands.rb +6 -1
  79. data/lib/samagotchi/session_inbox.rb +175 -0
  80. data/lib/samagotchi/session_manager.rb +62 -363
  81. data/lib/samagotchi/session_metrics.rb +2 -3
  82. data/lib/samagotchi/session_retention.rb +259 -0
  83. data/lib/samagotchi/sessions_command.rb +3 -2
  84. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  85. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  86. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  87. data/lib/samagotchi/terminal_ui.rb +10 -20
  88. data/lib/samagotchi/tool_runner.rb +12 -3
  89. data/lib/samagotchi/tools/memory.rb +3 -2
  90. data/lib/samagotchi/tools/send_note.rb +4 -3
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  92. data/lib/samagotchi/turn_note.rb +13 -2
  93. data/lib/samagotchi/update_command.rb +9 -2
  94. data/lib/samagotchi/version.rb +1 -1
  95. data/lib/samagotchi/web/app.rb +44 -76
  96. data/lib/samagotchi/web/info_probe.rb +33 -0
  97. data/lib/samagotchi/web/public/app.js +1 -1
  98. data/lib/samagotchi/web/public/index.html +1 -0
  99. data/lib/samagotchi/web/public/stage_model.js +22 -3
  100. data/lib/samagotchi/web/public/stage_view.js +25 -10
  101. data/lib/samagotchi/web/server.rb +8 -11
  102. data/lib/samagotchi/web/session_hub.rb +8 -7
  103. data/lib/samagotchi/web/session_summary.rb +4 -3
  104. data/lib/samagotchi/web/token.rb +2 -5
  105. data/lib/samagotchi/worker.rb +34 -20
  106. data/lib/samagotchi/worker_sidecar.rb +42 -0
  107. metadata +15 -1
@@ -94,6 +94,9 @@ module Samagotchi
94
94
  return 1
95
95
  end
96
96
  expanded_source = expand_source(source)
97
+ if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
98
+ return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
99
+ end
97
100
  bundle_name = begin
98
101
  bundle_name_for(expanded_source)
99
102
  rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
@@ -139,6 +142,9 @@ module Samagotchi
139
142
  return 1
140
143
  end
141
144
  expanded_source = expand_source(source)
145
+ if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
146
+ return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
147
+ end
142
148
  bundle_name = begin
143
149
  bundle_name_for(expanded_source)
144
150
  rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
@@ -238,6 +244,9 @@ module Samagotchi
238
244
  # Propagate overrides
239
245
  Samagotchi::MemoryBundle::Uninstaller.system_dir_override = Samagotchi::MemoryBundle::Installer.system_dir_override
240
246
  Samagotchi::MemoryBundle::Uninstaller.project_dir_base_override = Samagotchi::MemoryBundle::Installer.project_dir_base_override
247
+ data = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).read
248
+ return uninstall_profile(bundle_name, force: force) if Samagotchi::MemoryBundle::Profile.installed_meta?(bundle_name, data)
249
+
241
250
  uninstaller = Samagotchi::MemoryBundle::Uninstaller.new(name: bundle_name, scope: scope, force: force)
242
251
  begin
243
252
  uninstaller.run
@@ -252,6 +261,47 @@ module Samagotchi
252
261
  end
253
262
  end
254
263
 
264
+ # install/upgrade of a shipped profile: its new members (Profile).
265
+ def install_profile(dir, scope:, force:, dry_run:, word:)
266
+ name = File.basename(dir)
267
+ if scope.to_s.strip.downcase == "project"
268
+ @stderr.puts "#{word} failed: #{name} is a profile: its bundles install system-wide (drop --scope project)"
269
+ return 1
270
+ end
271
+ result = Samagotchi::MemoryBundle::Profile.install(dir, dry_run: dry_run, force: force)
272
+ @stdout.puts "#{result.name} v#{result.version} (profile)"
273
+ lines = []
274
+ lines << "#{dry_run ? "would install" : "installed"}: #{result.installed.join(", ")}" unless result.installed.empty?
275
+ lines << "already installed: #{result.already.join(", ")}" unless result.already.empty?
276
+ result.skipped.each { |member, why| lines << "skipped #{member}: #{why}" }
277
+ result.failed.each { |member, why| lines << "failed #{member}: #{why}" }
278
+ lines << "nothing new to install" if lines.empty?
279
+ lines.each { |line| @stdout.puts " #{line}" }
280
+ @stdout.puts "(dry-run: no changes written)" if dry_run
281
+ result.failed? ? 1 : 0
282
+ rescue Samagotchi::MemoryBundle::Manifest::ValidationError => e
283
+ @stderr.puts "#{word} failed: #{e.message}"
284
+ 1
285
+ end
286
+
287
+ # uninstall of a profile: its recorded members, then itself (Profile).
288
+ def uninstall_profile(name, force:)
289
+ result = Samagotchi::MemoryBundle::Profile.uninstall(name, force: force)
290
+ if result.gone
291
+ @stdout.puts "Uninstalled bundle '#{name}'"
292
+ @stdout.puts "Removed: #{result.removed.join(", ")}" unless result.removed.empty?
293
+ return 0
294
+ end
295
+ @stdout.puts "Removed from #{name}: #{result.removed.join(", ")}" unless result.removed.empty?
296
+ result.blocked.each { |member, why| @stdout.puts "Kept #{member}: #{why}" }
297
+ @stderr.puts "Uninstall failed: #{name} stays installed until #{result.blocked.keys.join(", ")} goes " \
298
+ "(chi bundle uninstall #{name} --force)"
299
+ 1
300
+ rescue Samagotchi::MemoryBundle::Uninstaller::UninstallError => e
301
+ @stderr.puts "Uninstall failed: #{e.message}"
302
+ 1
303
+ end
304
+
255
305
  def status(rest)
256
306
  name = rest.find { |a| !a.start_with?("--") }
257
307
  require "json"
@@ -297,26 +347,26 @@ module Samagotchi
297
347
  @stdout.puts " not loaded: #{plugin[:requires_failure]}" if plugin[:requires_failure]
298
348
  end
299
349
  st[:needs].each { |need| @stdout.puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
300
- else
301
- bundles_dir = Samagotchi::MemoryBundle::Provenance.bundles_dir
302
- unless File.directory?(bundles_dir)
303
- @stdout.puts "No installed bundles."; return 0
350
+ (st[:provenance][:includes] || []).each do |member|
351
+ state = Samagotchi::MemoryBundle::Provenance.new(name: member.to_s).installed? ? "installed" : "not installed: chi bundle install #{member}"
352
+ @stdout.puts " includes #{member} [#{state}]"
304
353
  end
305
- entries = Dir.entries(bundles_dir).reject { |e| e.start_with?(".") }
306
- if entries.empty?
307
- @stdout.puts "No installed bundles."
308
- else
309
- entries.sort.each do |bname|
310
- st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
311
- next unless st
312
- mods = st[:files].values.count { |v| v[:modified] || v[:missing] }
313
- hooks_count = (st[:provenance][:hooks] || {}).size
314
- hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
315
- plugin = st[:plugin]
316
- mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
317
- plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
318
- @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info} issues=#{mods}"
319
- end
354
+ else
355
+ bundles = Samagotchi::MemoryBundle::Provenance.each_installed.to_a
356
+ @stdout.puts "No installed bundles." if bundles.empty?
357
+ bundles.each do |bname, data|
358
+ next @stdout.puts(" #{bname} (manifest.json unreadable)") if data[:error]
359
+
360
+ st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
361
+ mods = st[:files].values.count { |v| v[:modified] || v[:missing] }
362
+ hooks_count = (st[:provenance][:hooks] || {}).size
363
+ hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
364
+ plugin = st[:plugin]
365
+ mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
366
+ plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
367
+ includes = st[:provenance][:includes]
368
+ includes_info = includes ? " includes=#{includes.join(",")}" : ""
369
+ @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
320
370
  end
321
371
  end
322
372
  return 0
@@ -432,7 +482,8 @@ module Samagotchi
432
482
  @stdout.puts "Installed:"
433
483
  installed.each do |b|
434
484
  next @stdout.puts(" #{b.name.ljust(name_w)} (#{b.error})") if b.error
435
- line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"} files=#{b.files} installed=#{b.installed_at || "?"}"
485
+ line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"} " \
486
+ "#{b.includes ? profile_members(b.includes, installed) : "files=#{b.files}"} installed=#{b.installed_at || "?"}"
436
487
  line += " (shipped v#{b.upgrade.version}: chi bundle upgrade #{b.upgrade.source})" if b.upgrade
437
488
  @stdout.puts line
438
489
  end
@@ -442,12 +493,21 @@ module Samagotchi
442
493
  @stdout.puts ""
443
494
  @stdout.puts "Available (shipped with chi, install with: chi bundle install <name>):"
444
495
  available.each do |s|
445
- @stdout.puts " #{s.source.ljust(name_w)} #{"v#{s.version}".ljust(ver_w)} #{s.description}".rstrip
496
+ members = s.includes.empty? ? "" : " (#{s.includes.join(", ")})"
497
+ @stdout.puts " #{s.source.ljust(name_w)} #{"v#{s.version}".ljust(ver_w)} #{s.description}#{members}".rstrip
446
498
  end
447
499
  end
448
500
  return 0
449
501
  end
450
502
 
503
+ # An installed profile's list cell: its recorded members still
504
+ # installed, and the ones left out (uninstalled by the user).
505
+ def profile_members(members, installed)
506
+ names = installed.map(&:name)
507
+ here, gone = members.partition { |m| names.include?(m) }
508
+ "includes=#{here.join(",")}#{gone.empty? ? "" : " left out=#{gone.join(",")}"}"
509
+ end
510
+
451
511
  def build(rest)
452
512
  scope = nil
453
513
  name = nil
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module Samagotchi
6
4
  autoload :MutedMemories, File.expand_path("muted_memories", __dir__)
7
5
  autoload :Log, File.expand_path("log", __dir__)
@@ -67,15 +65,8 @@ module Samagotchi
67
65
  # entry name → marker, for the installed bundles of +scope+ with a
68
66
  # missing need. A manifest.json or needs: that doesn't parse is skipped.
69
67
  def self.entry_markers(scope, path:, bundles_dir:)
70
- return {} unless Dir.exist?(bundles_dir)
71
-
72
- Dir[File.join(bundles_dir, "*", "manifest.json")].sort.each_with_object({}) do |mjson, acc|
73
- data = begin
74
- JSON.parse(File.read(mjson), symbolize_names: true)
75
- rescue JSON::ParserError, SystemCallError
76
- next
77
- end
78
- next unless data.is_a?(Hash) && data[:scope].to_s == scope.to_s
68
+ MemoryBundle::Provenance.each_installed(dir: bundles_dir).each_with_object({}) do |(_name, data), acc|
69
+ next if data[:error] || data[:scope].to_s != scope.to_s
79
70
  next unless data[:needs].is_a?(Array) && !data[:needs].empty?
80
71
 
81
72
  needs = begin
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: core
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "Recommended safety set"
6
+ trust_level: reviewed
7
+ requires_chi: ">= 0.8.0"
8
+ includes:
9
+ - loop-guard
10
+ - check-in
11
+ - guardrails
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: dev
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "Developer tools"
6
+ trust_level: reviewed
7
+ requires_chi: ">= 0.8.0"
8
+ includes:
9
+ - known-names
10
+ - mcp
11
+ - btw
12
+ - skills
13
+ - source-links
@@ -0,0 +1,27 @@
1
+ # Guardrail rules only small models get (models: small): asks that a strong
2
+ # model would find noise. Which models are small is guardrails.small_models:
3
+ # auto (the default) is 32B or less by the size in the model's name, an
4
+ # MoE's active size counting (35B-A3B is small); a list of globs; or [] for
5
+ # none. `/guardrails` says whether the current model is small.
6
+ #
7
+ # These asks offer only once and session: an approval for the repo or the
8
+ # whole rule would silence them for good, on any model.
9
+ # Same fields and git prefix as rules.yml.
10
+ rules:
11
+ # ── losing uncommitted work ────────────────────────────────────────────
12
+ # git checkout -- <path>, git checkout <rev> -- <path>, git checkout .,
13
+ # and git restore of the worktree (not git restore --staged alone).
14
+ - id: git-discard-worktree
15
+ tool: shell
16
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+(?:checkout\b[^;&|\n]*\s(?:--\s+\S|\.(?:\s|$|/))|restore\b(?![^;&|\n]*\s--staged\b)(?=[^;&|\n]*\s[^-\s])|restore\b(?=[^;&|\n]*\s(?:--worktree|-W)\b))'
17
+ models: small
18
+ verdict: ask
19
+ reason: git checkout -- / git restore discards uncommitted changes
20
+ scopes: [once, session]
21
+ - id: git-stash-drop
22
+ tool: shell
23
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+stash\s+(drop|clear)\b'
24
+ models: small
25
+ verdict: ask
26
+ reason: git stash drop/clear deletes stashed work
27
+ scopes: [once, session]
@@ -1,8 +1,10 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.1.1
3
+ version: 0.2.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks) and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
+ # models: in small-models.yml (guardrails.small_models)
8
+ requires_chi: ">= 0.8.1"
7
9
  files:
8
10
  guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -10,3 +10,4 @@
10
10
  Before a task that a `skill_*` in the memory index matches, read it and follow it.
11
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
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
+ 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.7.0
3
+ version: 0.8.1
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:b7d59a1e824f0918c28a38d40105e8abd1ff68ebab1810fd1dcf52cd3b72e7c0
7
+ identity.md: sha256:d6f40d5e44096537d337c4ffda86823446f8b59eb8b8cc47014e97ec16f019c9
8
8
  self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
9
  config_modification_protocol.md: sha256:24e95727488daa80ac3008e020d8be098c7a1882153b7a61aba35450f72b86b7
10
- memory_guide.md: sha256:43be77156562f1eac369c248045e35ccd89e87d1f7f95a9286be72521b058e87
10
+ memory_guide.md: sha256:50c076fb6bda7ed010877ff664bb2e9e904418fe08b058ebaa911d4e82e9a683
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -66,7 +66,7 @@ Shape (plain Markdown, no frontmatter):
66
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
67
  - **Scope**: `project` by default; `system` when the user asks, or when the skill is clearly not about this project.
68
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.
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. 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
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
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
72
 
@@ -78,6 +78,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
78
78
  - Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
79
79
  - Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
80
80
  - A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
81
+ - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
81
82
 
82
83
  ### CLI — `chi bundle`
83
84
 
@@ -88,7 +89,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
88
89
  | `uninstall <bundle> [--force]` | Removes bundle files (skips locally edited files unless `--force`) and `index.md` lines, deletes provenance dir. |
89
90
  | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
90
91
  | `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
91
- | `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one). |
92
+ | `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
92
93
  | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
93
94
 
94
95
  **Scope resolution for install/build:**
@@ -6,6 +6,7 @@ require "uri"
6
6
  require_relative "config"
7
7
  require_relative "cancellation_controller"
8
8
  require_relative "llm/http"
9
+ require_relative "llm/utf8_scrub"
9
10
  require_relative "vision_context"
10
11
  require_relative "vision_support"
11
12
  require_relative "sampling_settings"
@@ -203,7 +204,7 @@ module Samagotchi
203
204
  images: [], sampling: {})
204
205
  images = Array(images)
205
206
  request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling }
206
- return stream_completion(scrub_utf8(prompt), request, on_chunk, cancel_controller, on_retry) if images.empty?
207
+ return stream_completion(LLM::Utf8Scrub.call(prompt), request, on_chunk, cancel_controller, on_retry) if images.empty?
207
208
 
208
209
  # The media marker is random per server process: a restart between the
209
210
  # /props read and the request makes the prompt fail to tokenize, so
@@ -211,7 +212,7 @@ module Samagotchi
211
212
  attempts = 0
212
213
  begin
213
214
  attempts += 1
214
- payload_prompt = { prompt_string: scrub_utf8(prompt.gsub(ImagePlan::NATIVE_PLACEHOLDER, media_marker!(model))),
215
+ payload_prompt = { prompt_string: LLM::Utf8Scrub.call(prompt.gsub(ImagePlan::NATIVE_PLACEHOLDER, media_marker!(model))),
215
216
  multimodal_data: images }
216
217
  stream_completion(payload_prompt, request, on_chunk, cancel_controller, on_retry)
217
218
  rescue LLM::BadRequest => e
@@ -403,27 +404,6 @@ module Samagotchi
403
404
  (sampling || {}).reject { |key, value| value.nil? || ConfigFile::SAMPLING_RESERVED_KEYS.include?(key.to_s) }
404
405
  end
405
406
 
406
- # Conversation content (system prompt + tool responses + model output) can
407
- # contain invalid UTF-8 — e.g. a shell/file op writes a garbled multibyte
408
- # sequence (a truncated em-dash, a stray replacement byte). `JSON#to_json`
409
- # raises `JSON::GeneratorError` on such input, which would abort the whole
410
- # turn. Scrub the payload first: only offending bytes are replaced with "?",
411
- # every valid UTF-8 string passes through untouched. Non-UTF-8 encodings are
412
- # left alone because `#to_json` already handles them without raising.
413
- def scrub_utf8(obj)
414
- case obj
415
- when String
416
- str = obj.to_s
417
- str.encoding == Encoding::UTF_8 && !str.valid_encoding? ? str.scrub("?") : str
418
- when Array
419
- obj.map { |element| scrub_utf8(element) }
420
- when Hash
421
- obj.each_with_object({}) { |(key, value), memo| memo[scrub_utf8(key)] = scrub_utf8(value) }
422
- else
423
- obj
424
- end
425
- end
426
-
427
407
  # Resolve a user-facing SAMAGOTCHI_DEFAULT_MODEL selector to the exact id oMLX
428
408
  # expects in the request body (an id from its `/v1/models` list).
429
409
  #
@@ -5,7 +5,9 @@ require "json"
5
5
  require "fileutils"
6
6
  require "uri"
7
7
  require "set"
8
+ require_relative "atomic_file"
8
9
  require_relative "log"
10
+ require_relative "paths"
9
11
 
10
12
  module Samagotchi
11
13
  # Parses the thinking: levels of host and model entries (it needs Config).
@@ -88,7 +90,7 @@ module Samagotchi
88
90
  Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli]),
89
91
  # Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
90
92
  Entry.new(key: "session.shared", yaml_path: %w[session shared], type: :bool, default: true, expose: %i[env config]),
91
- # false: a session nothing happened in is deleted when it is left (SessionManager.empty_session?).
93
+ # false: a session nothing happened in is deleted when it is left (SessionManager.discardable?).
92
94
  Entry.new(key: "session.keep_empty", yaml_path: %w[session keep_empty], type: :bool, default: false, expose: %i[env config]),
93
95
  # The most sessions one session may have delegated and still running (the delegate tool); a guard against a runaway model.
94
96
  Entry.new(key: "session.max_children", yaml_path: %w[session max_children], type: :integer, default: 4, expose: %i[env config]),
@@ -101,6 +103,9 @@ module Samagotchi
101
103
  Entry.new(key: "image.max_per_request", yaml_path: %w[image max_per_request], type: :integer, default: 20, expose: %i[env config]),
102
104
 
103
105
  Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[env config]),
106
+ # Which models get the rules with `models: small` (Guardrails::ModelSize): auto (32B or less by the name's size, an MoE's
107
+ # active size counting), globs on the bare model name or its key, "|"-separated (a YAML list works too), or "" ([]) for none.
108
+ Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[env config]),
104
109
 
105
110
  Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli]),
106
111
  Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli]),
@@ -509,7 +514,6 @@ module Samagotchi
509
514
  # (hosts, model_aliases) and the recap section resolution. All of it
510
515
  # shares Config's precedence (CLI > ENV > file > default) for scalars.
511
516
  module ConfigFile
512
- XDG_CONFIG_HOME_ENV = "XDG_CONFIG_HOME"
513
517
  CONFIG_DIR = "samagotchi"
514
518
  CONFIG_FILE = "config.yml"
515
519
  MODEL_ALIASES_KEY = "model_aliases"
@@ -635,9 +639,7 @@ module Samagotchi
635
639
  end
636
640
 
637
641
  def config_dir(env: ENV)
638
- config_home = env.fetch(XDG_CONFIG_HOME_ENV, "").to_s.strip
639
- base_dir = config_home.empty? ? File.expand_path("~/.config") : config_home
640
- File.join(base_dir, CONFIG_DIR)
642
+ File.join(Paths.config_home(env: env), CONFIG_DIR)
641
643
  end
642
644
 
643
645
  def global_path(env: ENV)
@@ -916,9 +918,7 @@ module Samagotchi
916
918
  raw_data["default"] = { "model" => resolved }
917
919
  end
918
920
  FileUtils.mkdir_p(File.dirname(path))
919
- tmp = "#{path}.tmp"
920
- File.write(tmp, YAML.dump(raw_data))
921
- File.rename(tmp, path)
921
+ AtomicFile.write(path, YAML.dump(raw_data))
922
922
  clear_yaml_cache!(path)
923
923
  Samagotchi::Config.reload!(cli_overrides: Samagotchi::Config.cli_overrides)
924
924
  true
@@ -1043,9 +1043,7 @@ module Samagotchi
1043
1043
  normalized[lowered_key] = resolved_model
1044
1044
 
1045
1045
  FileUtils.mkdir_p(File.dirname(path))
1046
- tmp = "#{path}.tmp"
1047
- File.write(tmp, YAML.dump(raw_data))
1048
- File.rename(tmp, path)
1046
+ AtomicFile.write(path, YAML.dump(raw_data))
1049
1047
  clear_yaml_cache!(path)
1050
1048
  previous
1051
1049
  end
@@ -15,7 +15,7 @@ module Samagotchi
15
15
 
16
16
  module_function
17
17
 
18
- # Takes SessionManager.read_note's keys.
18
+ # Takes SessionInbox.read_note's keys.
19
19
  # @return [Hash] the conversation message
20
20
  def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, **)
21
21
  { role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,