samagotchi 0.6.0 → 0.8.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -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 +20 -0
  7. data/docs/desktop.md +49 -0
  8. data/docs/guardrails.md +2 -2
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/context-telemetry.md +3 -1
  11. data/docs/memory.md +48 -1
  12. data/docs/plugins.md +7 -5
  13. data/docs/releasing.md +5 -1
  14. data/docs/sessions.md +2 -2
  15. data/lib/samagotchi/archive_store.rb +3 -3
  16. data/lib/samagotchi/atomic_file.rb +34 -0
  17. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  18. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  19. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  20. data/lib/samagotchi/bootstrap_command.rb +78 -8
  21. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  22. data/lib/samagotchi/bridge.rb +56 -116
  23. data/lib/samagotchi/bridge_client.rb +7 -11
  24. data/lib/samagotchi/bundle_command.rb +81 -21
  25. data/lib/samagotchi/bundle_needs.rb +2 -11
  26. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  27. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  28. data/lib/samagotchi/bundles/system/identity.md +1 -0
  29. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  30. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  31. data/lib/samagotchi/client.rb +3 -23
  32. data/lib/samagotchi/config.rb +11 -11
  33. data/lib/samagotchi/context_note.rb +1 -1
  34. data/lib/samagotchi/context_status.rb +255 -0
  35. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  36. data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
  37. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  38. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  39. data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
  40. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  41. data/lib/samagotchi/desktop/macos.rb +25 -11
  42. data/lib/samagotchi/desktop_command.rb +1 -1
  43. data/lib/samagotchi/engine.rb +6 -7
  44. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  45. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  46. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  47. data/lib/samagotchi/hooks/loader.rb +2 -1
  48. data/lib/samagotchi/hooks/registry.rb +21 -0
  49. data/lib/samagotchi/image_store.rb +2 -3
  50. data/lib/samagotchi/kernel_loop.rb +21 -283
  51. data/lib/samagotchi/live_versions.rb +9 -13
  52. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  53. data/lib/samagotchi/llm/native_backend.rb +8 -8
  54. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  55. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  56. data/lib/samagotchi/log_path.rb +2 -3
  57. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  58. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  59. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  60. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  61. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  64. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  65. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  66. data/lib/samagotchi/memory_bundle.rb +1 -0
  67. data/lib/samagotchi/note_command.rb +5 -4
  68. data/lib/samagotchi/owner_lock.rb +0 -6
  69. data/lib/samagotchi/paths.rb +32 -0
  70. data/lib/samagotchi/plugin/api.rb +3 -6
  71. data/lib/samagotchi/plugin/context.rb +2 -3
  72. data/lib/samagotchi/plugin/loader.rb +1 -1
  73. data/lib/samagotchi/question_desk.rb +3 -2
  74. data/lib/samagotchi/recap_store.rb +2 -2
  75. data/lib/samagotchi/reply_wait.rb +2 -1
  76. data/lib/samagotchi/self_report.rb +2 -3
  77. data/lib/samagotchi/send_command.rb +3 -2
  78. data/lib/samagotchi/session.rb +41 -143
  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/input_support.rb +2 -9
  86. data/lib/samagotchi/terminal_ui.rb +10 -20
  87. data/lib/samagotchi/tools/memory.rb +3 -2
  88. data/lib/samagotchi/tools/send_note.rb +4 -3
  89. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  90. data/lib/samagotchi/turn_note.rb +13 -2
  91. data/lib/samagotchi/update_command.rb +10 -3
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +44 -76
  94. data/lib/samagotchi/web/info_probe.rb +33 -0
  95. data/lib/samagotchi/web/public/app.js +1 -1
  96. data/lib/samagotchi/web/public/index.html +1 -0
  97. data/lib/samagotchi/web/public/stage_model.js +22 -3
  98. data/lib/samagotchi/web/public/stage_view.js +25 -10
  99. data/lib/samagotchi/web/server.rb +8 -11
  100. data/lib/samagotchi/web/session_hub.rb +8 -7
  101. data/lib/samagotchi/web/session_summary.rb +4 -3
  102. data/lib/samagotchi/web/token.rb +2 -5
  103. data/lib/samagotchi/worker.rb +34 -20
  104. data/lib/samagotchi/worker_sidecar.rb +42 -0
  105. 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
@@ -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.6.0
3
+ version: 0.8.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: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]),
@@ -135,6 +137,11 @@ module Samagotchi
135
137
  Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
136
138
  Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
137
139
  Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config]),
140
+ # The desktop helper's kitty targets (Desktop::MacOS.kitty_settings): listen_on copied from kitty.conf (unset = none),
141
+ # the kitty binary, and the foreground programs listed, "|"-separated (a YAML list works too; "*" = every window).
142
+ Entry.new(key: "kitty.listen_on", yaml_path: %w[kitty listen_on], type: :string, default: nil, expose: %i[env config]),
143
+ Entry.new(key: "kitty.binary", yaml_path: %w[kitty binary], type: :string, default: "/Applications/kitty.app/Contents/MacOS/kitty", expose: %i[env config]),
144
+ Entry.new(key: "kitty.agents", yaml_path: %w[kitty agents], type: :string, default: "claude|codex|gemini|aider|opencode|cursor-agent|amp|goose", expose: %i[env config]),
138
145
 
139
146
  Entry.new(key: "read.truncate_at_bytes", yaml_path: %w[read truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
140
147
  Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
@@ -504,7 +511,6 @@ module Samagotchi
504
511
  # (hosts, model_aliases) and the recap section resolution. All of it
505
512
  # shares Config's precedence (CLI > ENV > file > default) for scalars.
506
513
  module ConfigFile
507
- XDG_CONFIG_HOME_ENV = "XDG_CONFIG_HOME"
508
514
  CONFIG_DIR = "samagotchi"
509
515
  CONFIG_FILE = "config.yml"
510
516
  MODEL_ALIASES_KEY = "model_aliases"
@@ -630,9 +636,7 @@ module Samagotchi
630
636
  end
631
637
 
632
638
  def config_dir(env: ENV)
633
- config_home = env.fetch(XDG_CONFIG_HOME_ENV, "").to_s.strip
634
- base_dir = config_home.empty? ? File.expand_path("~/.config") : config_home
635
- File.join(base_dir, CONFIG_DIR)
639
+ File.join(Paths.config_home(env: env), CONFIG_DIR)
636
640
  end
637
641
 
638
642
  def global_path(env: ENV)
@@ -911,9 +915,7 @@ module Samagotchi
911
915
  raw_data["default"] = { "model" => resolved }
912
916
  end
913
917
  FileUtils.mkdir_p(File.dirname(path))
914
- tmp = "#{path}.tmp"
915
- File.write(tmp, YAML.dump(raw_data))
916
- File.rename(tmp, path)
918
+ AtomicFile.write(path, YAML.dump(raw_data))
917
919
  clear_yaml_cache!(path)
918
920
  Samagotchi::Config.reload!(cli_overrides: Samagotchi::Config.cli_overrides)
919
921
  true
@@ -1038,9 +1040,7 @@ module Samagotchi
1038
1040
  normalized[lowered_key] = resolved_model
1039
1041
 
1040
1042
  FileUtils.mkdir_p(File.dirname(path))
1041
- tmp = "#{path}.tmp"
1042
- File.write(tmp, YAML.dump(raw_data))
1043
- File.rename(tmp, path)
1043
+ AtomicFile.write(path, YAML.dump(raw_data))
1044
1044
  clear_yaml_cache!(path)
1045
1045
  previous
1046
1046
  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,
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "context_usage"
5
+ require_relative "context_window"
6
+
7
+ module Samagotchi
8
+ # One turn's context tracking for KernelLoop#run: how full the window is
9
+ # before each request (the server's last prompt count plus an estimate for
10
+ # what the turn appended since, else a chars-per-token estimate), the
11
+ # bucket that puts it in, and what comes of that:
12
+ #
13
+ # - the status line's value (#display), every request and again from the
14
+ # server's final counts after each generation;
15
+ # - a :context_status stream event (#observe's return), on a bucket change
16
+ # or every context.status_cadence requests;
17
+ # - the model's own short line (#take_guidance), once per rise into a
18
+ # bucket whose guidance asks for a change (the second threshold up).
19
+ #
20
+ # The context.* settings are read once, when the tracker is built (one per
21
+ # run, so a changed setting counts from the next turn).
22
+ class ContextStatus
23
+ STATUS_PREFIX = "CONTEXT_STATUS"
24
+ # The model's own line about its context (a tail system message, kind
25
+ # LINE_KIND).
26
+ LINE_PREFIX = "[CONTEXT: "
27
+ LINE_KIND = "context"
28
+ GUIDANCE_FROM_RANK = 2
29
+
30
+ DEFAULT_CHARS_PER_TOKEN = 4.0
31
+ DEFAULT_THRESHOLDS = [20, 40, 60, 80].freeze
32
+
33
+ def self.enabled?
34
+ Config.get("context.status") != false
35
+ end
36
+
37
+ # Healthy to critical; the top configured bucket gets the last, the
38
+ # ones below it the ones before, and the first bucket (and the one
39
+ # under it) is always healthy.
40
+ GUIDANCE = [
41
+ "context healthy — proceed normally",
42
+ "context moderate — prefer targeted and range reads over full-file dumps",
43
+ "context elevated — be concise, prefer range reads, avoid re-reading large files",
44
+ "context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
45
+ ].freeze
46
+
47
+ # @param bucket [String] "under<N>" or "<N>plus"
48
+ # @param thresholds [Array<Integer>] the configured ones, sorted
49
+ def self.guidance(bucket, thresholds = DEFAULT_THRESHOLDS)
50
+ rank = bucket_rank(bucket, thresholds)
51
+ level = rank < GUIDANCE_FROM_RANK ? 0 : GUIDANCE.size - 1 - (thresholds.size - rank)
52
+ GUIDANCE[level.clamp(0, GUIDANCE.size - 1)]
53
+ end
54
+
55
+ # 0 for the bucket under the first threshold, then one per threshold.
56
+ def self.bucket_rank(bucket, thresholds)
57
+ return 0 if bucket.nil? || bucket.to_s.start_with?("under")
58
+
59
+ (thresholds.index(bucket.to_s.delete_suffix("plus").to_i) || -1) + 1
60
+ end
61
+
62
+ # The bucket of the last status line +conversation+ holds: the model's
63
+ # own line, or a legacy session's injected telemetry.
64
+ def self.last_bucket(conversation)
65
+ message = conversation.reverse.find do |entry|
66
+ content = entry[:content].to_s
67
+ entry[:role] == "system" && (content.start_with?(STATUS_PREFIX) || content.start_with?(LINE_PREFIX))
68
+ end
69
+ return nil unless message
70
+
71
+ match = message[:content].match(/\bbucket=([a-z0-9_]+)/)
72
+ match && match[1]
73
+ end
74
+
75
+ # @param conversation [Array<Hash>] the turn's conversation so far (its
76
+ # last status line's bucket is where rises count from)
77
+ def initialize(conversation: [])
78
+ @enabled = self.class.enabled?
79
+ chars_per_token = Config.get("context.chars_per_token").to_f
80
+ @chars_per_token = chars_per_token.positive? ? chars_per_token : DEFAULT_CHARS_PER_TOKEN
81
+ parsed = Config.get("context.status_thresholds").to_s.split(",").map { |value| value.strip.to_i }
82
+ .select { |value| value.between?(1, 99) }.uniq.sort
83
+ @thresholds = parsed.empty? ? DEFAULT_THRESHOLDS : parsed
84
+ @cadence = [Config.get("context.status_cadence").to_i, 0].max
85
+ @last_bucket = self.class.last_bucket(conversation)
86
+ @server_usage = nil
87
+ @counted = nil
88
+ @display = nil
89
+ @guidance = nil
90
+ end
91
+
92
+ # @return [Hash, nil] the status line's value ({est_pct:, bucket:});
93
+ # nil with context.status off or before the first request
94
+ attr_reader :display
95
+
96
+ def enabled? = @enabled
97
+
98
+ # Before a request: estimate +prompt+'s usage against +window+ (this
99
+ # request's ContextWindow::Resolved) and update the status line's
100
+ # value. A rise into a bucket that asks the model for a change leaves
101
+ # a line for #take_guidance.
102
+ # +image_tokens+: the images' estimate (their base64 is not in +prompt+).
103
+ #
104
+ # @return [Hash, nil] the :context_status event's fields (without
105
+ # :type/:iteration) when the emit gate fires, else nil
106
+ def observe(prompt, iteration_index:, window:, image_tokens: 0)
107
+ return nil unless @enabled
108
+
109
+ usage = estimate(prompt, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
110
+ bucket = bucket_for(usage[:estimated_pct])
111
+ # The status line's value, every iteration; the gate below decides
112
+ # only the event and the model's guidance line.
113
+ @display = { est_pct: usage[:estimated_pct], bucket: bucket }
114
+ emit = emit?(bucket: bucket, iteration_index: iteration_index)
115
+ previous_bucket = @last_bucket
116
+ @last_bucket = bucket
117
+ return nil unless emit
118
+
119
+ @guidance = guidance_message(usage: usage, bucket: bucket) if guidance_due?(previous_bucket, bucket)
120
+ { status: status_message(usage: usage, bucket: bucket), usage: usage, bucket: bucket, source: usage[:source] }
121
+ end
122
+
123
+ # @return [Hash, nil] the model's line from the last #observe (once)
124
+ def take_guidance
125
+ line = @guidance
126
+ @guidance = nil
127
+ line
128
+ end
129
+
130
+ # A stream payload's counts, kept for the next estimate.
131
+ # @return [Hash, nil] the payload's normalized counts, when it has any
132
+ def capture(payload)
133
+ normalized = ContextUsage.normalize(payload)
134
+ @server_usage = normalized if normalized
135
+ normalized
136
+ end
137
+
138
+ # After a generation: +usage+ is its own server counts (nil: none), for
139
+ # +prompt_chars+/+image_tokens+ as sent. The next estimate adds only
140
+ # what the turn appended since (answer, tool results), and the status
141
+ # line's value counts the answer. Without counts the pre-generation
142
+ # estimate stays.
143
+ def generation_done(usage, prompt_chars:, image_tokens:, window:)
144
+ return unless usage
145
+
146
+ @counted = { chars: prompt_chars, image_tokens: image_tokens }
147
+ value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
148
+ @display = value if value
149
+ end
150
+
151
+ # The status line's value ({est_pct:, bucket:}) for +used_tokens+ of
152
+ # +window_tokens+; nil without both, or with context.status off.
153
+ def display_for(used_tokens:, window_tokens:)
154
+ return nil unless @enabled
155
+ return nil unless ContextWindow.positive_integer?(used_tokens) && ContextWindow.positive_integer?(window_tokens)
156
+
157
+ pct = (used_tokens.to_f / window_tokens) * 100.0
158
+ { est_pct: pct, bucket: bucket_for(pct) }
159
+ end
160
+
161
+ # `window` is this request's ContextWindow::Resolved. A window the
162
+ # stream payload reports itself still wins.
163
+ # +counted+: the prompt the server's prompt_tokens counted ({chars:,
164
+ # image_tokens:}); what +prompt+ has on top of it (the answer, tool
165
+ # results since) is added as an estimate, so the value doesn't read low
166
+ # during a long tool loop. A prompt shorter than that one (trimmed) or
167
+ # none known: the server's count alone.
168
+ def estimate(prompt, window:, server_usage: nil, image_tokens: 0, counted: nil)
169
+ window_source = window.source
170
+ window_source = :server if server_usage && server_usage[:context_window_tokens]
171
+
172
+ if server_usage && server_usage[:prompt_tokens]
173
+ window_tokens = server_usage[:context_window_tokens] || window.tokens
174
+ used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt, image_tokens, counted)
175
+ source = "server"
176
+ else
177
+ window_tokens = window.tokens
178
+ used_tokens = (prompt.length / @chars_per_token).ceil + image_tokens
179
+ source = "estimate"
180
+ end
181
+
182
+ {
183
+ window_tokens: window_tokens,
184
+ window_source: window_source,
185
+ estimated_used_tokens: used_tokens,
186
+ estimated_remaining_tokens: [window_tokens - used_tokens, 0].max,
187
+ estimated_pct: (used_tokens.to_f / window_tokens) * 100.0,
188
+ source: source
189
+ }
190
+ end
191
+
192
+ private
193
+
194
+ # The estimate for what +prompt+ added since the +counted+ one.
195
+ def appended_tokens(prompt, image_tokens, counted)
196
+ return 0 unless counted
197
+
198
+ chars = prompt.length - counted[:chars]
199
+ return 0 unless chars.positive?
200
+
201
+ (chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
202
+ end
203
+
204
+ def bucket_for(estimated_pct)
205
+ bucket = "under#{@thresholds.first}"
206
+ @thresholds.each do |threshold|
207
+ bucket = "#{threshold}plus" if estimated_pct >= threshold
208
+ end
209
+ bucket
210
+ end
211
+
212
+ def bucket_rank(bucket) = self.class.bucket_rank(bucket, @thresholds)
213
+
214
+ # A rise (never a fall or a cadence tick) into a bucket whose guidance
215
+ # asks for a change. With no previous bucket (a first turn, a resumed
216
+ # session with no line yet), the first bucket counts as a rise from 0.
217
+ def guidance_due?(previous, bucket)
218
+ rank = bucket_rank(bucket)
219
+ rank >= GUIDANCE_FROM_RANK && rank > bucket_rank(previous)
220
+ end
221
+
222
+ def emit?(bucket:, iteration_index:)
223
+ bucket_changed = if @last_bucket.nil?
224
+ bucket != "under#{@thresholds.first}"
225
+ else
226
+ bucket != @last_bucket
227
+ end
228
+ cadence_due = @cadence.positive? && ((iteration_index + 1) % @cadence).zero?
229
+ bucket_changed || cadence_due
230
+ end
231
+
232
+ def guidance_message(usage:, bucket:)
233
+ how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
234
+ { role: "system", kind: LINE_KIND,
235
+ content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of the context window is in use " \
236
+ "(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds)}]" }
237
+ end
238
+
239
+ def status_message(usage:, bucket:)
240
+ format(
241
+ "%<prefix>s window_tokens=%<window>d window_src=%<window_src>s est_used_tokens=%<used>d est_remaining_tokens=%<remaining>d est_pct=%<pct>.1f bucket=%<bucket>s thresholds=%<thresholds>s src=%<src>s guidance=%<guidance>s",
242
+ prefix: STATUS_PREFIX,
243
+ window: usage[:window_tokens],
244
+ window_src: usage[:window_source] || "default",
245
+ used: usage[:estimated_used_tokens],
246
+ remaining: usage[:estimated_remaining_tokens],
247
+ pct: usage[:estimated_pct],
248
+ bucket: bucket,
249
+ thresholds: @thresholds.join(","),
250
+ src: usage[:source],
251
+ guidance: self.class.guidance(bucket, @thresholds)
252
+ )
253
+ end
254
+ end
255
+ end