samagotchi 0.16.0 → 0.18.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -1
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # subcommands have their own flags).
11
11
  class BundleCommand
12
12
  USAGE = <<~TEXT
13
- Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build> [options]
13
+ Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash> [options]
14
14
 
15
15
  install <source> [--scope system|project] [--force]
16
16
  Install a memory bundle from a directory, zip, tar archive, or git URL.
@@ -36,7 +36,11 @@ module Samagotchi
36
36
  build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
37
37
  Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
38
38
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
39
- FILES... optional allowlist of *.md basenames to include (default: all).
39
+ FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
40
+
41
+ trash [--empty] [--dry-run] [--older-than DAYS]
42
+ List bundle trash (moved files from uninstalls/upgrades).
43
+ --empty deletes all trash folders. --older-than DAYS keeps only recent ones.
40
44
  TEXT
41
45
 
42
46
  # Each subcommand's --help (and only --help: -h and help are taken as
@@ -57,13 +61,14 @@ module Samagotchi
57
61
  build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
58
62
  Build local memories and installed hooks into a shareable bundle (dir or zip).
59
63
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
60
- FILES... optional allowlist of *.md basenames to include (default: all).
64
+ FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
61
65
 
62
66
  Examples:
63
67
  chi bundle build --scope system
64
68
  chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip
65
69
  chi bundle build --scope system --out ./my-bundle/ identity.md work.md
66
70
  TEXT
71
+ TRASH_HELP = "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
67
72
 
68
73
  # The subcommands' flags: --help only; a word not starting with -- is
69
74
  # a positional (install's source, -h included). --scope takes whatever
@@ -83,6 +88,11 @@ module Samagotchi
83
88
  BUILD_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/, dash_values: false) do |f|
84
89
  %w[--scope --name --version --description --out].each { |name| f.value name }
85
90
  end
91
+ TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
92
+ f.switch "--empty"
93
+ f.switch "--dry-run"
94
+ f.value "--older-than"
95
+ end
86
96
 
87
97
  # @param argv [Array<String>] the arguments after "bundle"
88
98
  def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
@@ -108,8 +118,9 @@ module Samagotchi
108
118
  when "diff" then diff(@argv[1..])
109
119
  when "list" then list
110
120
  when "build" then build(@argv[1..])
121
+ when "trash" then trash(@argv[1..])
111
122
  else
112
- @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
123
+ @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash"
113
124
  return CLI::Exit::USAGE
114
125
  end
115
126
  end
@@ -272,6 +283,7 @@ module Samagotchi
272
283
  begin
273
284
  uninstaller.run
274
285
  @stdout.puts "Uninstalled bundle '#{bundle_name}'"
286
+ puts_trashed(uninstaller.trashed_files, uninstaller.trash_dir)
275
287
  @stdout.puts "Removed: #{uninstaller.removed_files.join(', ')}" unless uninstaller.removed_files.empty?
276
288
  hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
277
289
  @stdout.puts "Hooks removed: #{hook_removed}" if hook_removed > 0
@@ -311,9 +323,13 @@ module Samagotchi
311
323
  if result.gone
312
324
  @stdout.puts "Uninstalled bundle '#{name}'"
313
325
  @stdout.puts "Removed: #{result.removed.join(", ")}" unless result.removed.empty?
326
+ result.trash.each_value { |files, dir| puts_trashed(files, dir) }
327
+ result.warnings.each { |w| @stdout.puts w }
314
328
  return 0
315
329
  end
316
330
  @stdout.puts "Removed from #{name}: #{result.removed.join(", ")}" unless result.removed.empty?
331
+ result.trash.each_value { |files, dir| puts_trashed(files, dir) }
332
+ result.warnings.each { |w| @stdout.puts w }
317
333
  result.blocked.each { |member, why| @stdout.puts "Kept #{member}: #{why}" }
318
334
  @stderr.puts "Uninstall failed: #{name} stays installed until #{result.blocked.keys.join(", ")} goes " \
319
335
  "(chi bundle uninstall #{name} --force)"
@@ -323,6 +339,11 @@ module Samagotchi
323
339
  1
324
340
  end
325
341
 
342
+ # Memory files an uninstall moved to the trash (MemoryBundle::Trash).
343
+ def puts_trashed(files, dir)
344
+ @stdout.puts "Moved to the trash: #{files.join(", ")} (#{dir})" if dir && !files.empty?
345
+ end
346
+
326
347
  def status(rest)
327
348
  name = rest.find { |a| !a.start_with?("--") }
328
349
  require "json"
@@ -557,6 +578,7 @@ module Samagotchi
557
578
  @stdout.puts "Built #{result[:files].size} file(s) to #{result[:out_path]}"
558
579
  @stdout.puts "Bundle: #{result[:name]} v#{result[:version]} scope=#{result[:scope]}"
559
580
  @stdout.puts "Files: #{result[:files].join(', ')}" unless result[:files].empty?
581
+ builder.warnings.each { |w| @stdout.puts w }
560
582
  unless result[:placeholder_warnings].empty?
561
583
  @stdout.puts "Placeholders:"
562
584
  result[:placeholder_warnings].each { |w| @stdout.puts " #{w}" }
@@ -568,6 +590,101 @@ module Samagotchi
568
590
  end
569
591
  end
570
592
 
593
+ def trash(rest)
594
+ parsed = parse_flags(TRASH_FLAGS, rest, "trash", help: TRASH_HELP)
595
+ return parsed if parsed.is_a?(Integer)
596
+
597
+ opts = parsed.options
598
+ empty = opts.fetch(:empty, false)
599
+ dry_run = opts.fetch(:dry_run, false)
600
+ older_than = opts[:older_than]
601
+
602
+ # --older-than and --dry-run without --empty are a usage error
603
+ if (older_than || dry_run) && !empty
604
+ @stderr.puts "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
605
+ return CLI::Exit::USAGE
606
+ end
607
+
608
+ # Validate older_than if given
609
+ if older_than
610
+ if older_than.to_s.match?(/\A\d+\z/) && older_than.to_i > 0
611
+ older_than = older_than.to_i
612
+ else
613
+ @stderr.puts "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
614
+ return CLI::Exit::USAGE
615
+ end
616
+ end
617
+
618
+ if empty
619
+ do_empty(older_than_days: older_than, dry_run: dry_run)
620
+ else
621
+ do_list
622
+ end
623
+ end
624
+
625
+ def do_list
626
+ entries = Samagotchi::MemoryBundle::Trash.entries
627
+ if entries.empty?
628
+ @stdout.puts "The bundle trash is empty."
629
+ return 0
630
+ end
631
+
632
+ entries.each do |e|
633
+ @stdout.puts " #{e.name} #{e.files} file#{'s' unless e.files == 1} #{format_bytes(e.bytes)} #{format_age(e.time)}"
634
+ end
635
+ @stdout.puts "\nEmpty it with: chi bundle trash --empty [--older-than DAYS]"
636
+ 0
637
+ end
638
+
639
+ def do_empty(older_than_days:, dry_run:)
640
+ entries = Samagotchi::MemoryBundle::Trash.empty!(
641
+ older_than_days: older_than_days,
642
+ dry_run: dry_run,
643
+ )
644
+ if entries.empty?
645
+ @stdout.puts "The bundle trash is empty."
646
+ return 0
647
+ end
648
+
649
+ total_files = entries.sum(&:files)
650
+ if dry_run
651
+ entries.each do |e|
652
+ @stdout.puts "Would delete: #{e.name} (#{e.files} file#{'s' unless e.files == 1}, #{format_bytes(e.bytes)})"
653
+ end
654
+ @stdout.puts "\n(dry-run: #{entries.size} folder#{'s' unless entries.size == 1}, #{total_files} file#{'s' unless total_files == 1})"
655
+ else
656
+ @stdout.puts "Deleted #{entries.size} folder#{'s' unless entries.size == 1} (#{total_files} file#{'s' unless total_files == 1}) from the trash."
657
+ end
658
+ 0
659
+ end
660
+
661
+ # Format bytes as human-readable (B, KB, MB with one decimal)
662
+ def format_bytes(bytes)
663
+ return "0 B" if bytes == 0
664
+ if bytes < 1024
665
+ "#{bytes} B"
666
+ elsif bytes < 1_048_576
667
+ "#{(bytes / 1024.0).round(1)} KB"
668
+ else
669
+ "#{(bytes / 1_048_576.0).round(1)} MB"
670
+ end
671
+ end
672
+
673
+ # Format age as "just now", "N minutes ago", "N hours ago", "N days ago"
674
+ def format_age(time)
675
+ now = Time.now
676
+ diff = now - time
677
+ if diff < 60
678
+ "just now"
679
+ elsif diff < 3600
680
+ "#{(diff / 60).to_i} minutes ago"
681
+ elsif diff < 86_400
682
+ "#{(diff / 3600).to_i} hours ago"
683
+ else
684
+ "#{(diff / 86_400).to_i} days ago"
685
+ end
686
+ end
687
+
571
688
  # +rest+ through +flags+, stopping at the first --help or error.
572
689
  #
573
690
  # @return [CLI::Flags::Result, Integer] the result, or the exit status
@@ -4,7 +4,8 @@
4
4
  # earlier or `git -C` variants can get past them.
5
5
  #
6
6
  # Fields: id, tool (a name, a list, or shell = execute + task_create),
7
- # command (Ruby regex), path (outside_repo or a glob), verdict (ask|deny),
7
+ # command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
8
+ # a shell call runs commit/add/reset/… in another checkout), verdict (ask|deny),
8
9
  # reason, scopes (for an ask: once, session, repo, rule; default all).
9
10
  # [^;&|\n]* keeps a match inside one command of a chain; the git rules match
10
11
  # the subcommand after optional global options (git -C dir push).
@@ -62,12 +63,26 @@ rules:
62
63
  verdict: ask
63
64
  reason: runs decoded text as a script
64
65
 
65
- # ── files outside the repo, and git hooks ──────────────────────────────
66
+ # ── work outside this session's repo, and git hooks ───────────────────
67
+ # outside_repo: not in the session's repo (its folder outside a repo);
68
+ # tmp dirs and memory files don't count.
66
69
  - id: write-outside-repo
67
70
  tool: [write, edit]
68
71
  path: outside_repo
69
72
  verdict: ask
70
- reason: writes outside the repository
73
+ reason: writes outside this session's repo
74
+ # cd/git -C into another checkout, then commit, add, reset, checkout,
75
+ # switch, rebase, merge, push, stash, rm, mv, cherry-pick, revert, pull,
76
+ # restore or am. Read-only git (status, log, diff) and tests there run.
77
+ # "This call in this repo" would never match again (the command holds
78
+ # the commit message), so it isn't offered: "rule in this repo" is the
79
+ # always-for-here answer.
80
+ - id: git-outside-repo
81
+ tool: shell
82
+ git: outside_repo
83
+ verdict: ask
84
+ scopes: [once, session, rule]
85
+ reason: runs git that changes another checkout, outside this session's repo
71
86
  - id: git-hooks-write
72
87
  tool: [write, edit]
73
88
  path: '**/.git/hooks/**'
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.3.0
3
+ version: 0.4.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, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes and git in another checkout outside the session's repo, git hooks, answering chi around chi answer), 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
+ # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
8
+ requires_chi: ">= 0.18.0"
9
9
  files:
10
10
  guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -60,7 +60,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
60
60
  - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
- - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
63
+ - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
64
64
 
65
65
  ```yaml
66
66
  guardrails:
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.16.0
3
+ version: 0.18.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:0410c2e62716e256a2a6aec73c7801def185cbae9c1db556efcedbbad22d908c
9
- config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
10
- memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
8
+ self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
9
+ config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
10
+ memory_guide.md: sha256:e535e31fcf29b4b32c270042851ecdd5f412ef2e3c56b0076963e3e1f1a08ef8
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -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` (and refreshed when `write`/`edit` change a memory's file).
19
+ - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the 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
 
@@ -85,13 +85,13 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
85
85
 
86
86
  | Command | Purpose |
87
87
  |---------|---------|
88
- | `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips. Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
88
+ | `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
89
89
  | `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
90
- | `uninstall <bundle> [--force]` | Removes bundle files (skips locally edited files unless `--force`) and `index.md` lines, deletes provenance dir. |
90
+ | `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
91
91
  | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
92
92
  | `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
93
93
  | `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=`. |
94
- | `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. |
94
+ | `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 and the memories other installed bundles own (each named in a `Left out` line) 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. |
95
95
 
96
96
  **Scope resolution for install/build:**
97
97
  - CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`Installer#resolve_target_dir`).
@@ -119,7 +119,7 @@ chi bundle build --scope system --out updated.zip
119
119
  ### Provenance internals (for debugging)
120
120
 
121
121
  - Each installed bundle is recorded at `<system dir>/.bundles/<name>/manifest.json` + `bases/<file>.md` snapshots (`Provenance`). Used only for upgrade `Merger` and `status`/`diff`. Build does **not** write provenance.
122
- - `index.md` is best-effort — failures are swallowed (`Installer#update_target_index`).
122
+ - A bundle records only the files it wrote. `index.md` updates aren't fatal: a failed one is a warning in the install/uninstall output.
123
123
 
124
124
  ## Best practices for the agent
125
125
 
@@ -3,6 +3,7 @@
3
3
  ## Start here
4
4
  - `chi self` (via `execute`) prints my version, **source dir**, config path, hooks dir,
5
5
  memory dirs, sessions dir, model/host and bundles. Use its source dir; don't hunt via `which`/`gem list`/`find /`.
6
+ Its `model` row is this session's model (live after `/model`), `model key` my overlay key.
6
7
  - My current session id is in the system prompt; resume with `chi --resume <id>`.
7
8
  - My debug log is the `log` line of `chi self` (default `$XDG_STATE_HOME/samagotchi/samagotchi.log`,
8
9
  next to the sessions dir; not `~/.local/state` when `XDG_STATE_HOME` is set). One record per line,
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
4
+
3
5
  module Samagotchi
4
6
  module Commands
5
7
  # The slash (and bang) commands a session knows, in lookup order: the
@@ -74,6 +76,32 @@ module Samagotchi
74
76
 
75
77
  def command?(line) = !lookup(line).nil?
76
78
 
79
+ # A line that is one word: a slash and a name, no spaces, no second
80
+ # slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
81
+ # are prompts).
82
+ UNKNOWN_COMMAND_WORD = /\A\/[A-Za-z][A-Za-z0-9_-]*\z/
83
+
84
+ # Whether +line+ is a command word no command answers: a typo like
85
+ # `/modle`, which a UI must not send to the model as a prompt.
86
+ def unknown_command_word?(line)
87
+ text = line.to_s.strip
88
+ return false unless UNKNOWN_COMMAND_WORD.match?(text)
89
+
90
+ lookup(text).nil? && lookup_local(text).nil?
91
+ end
92
+
93
+ # What a UI shows for an unknown command word, or nil when +line+ is
94
+ # not one: `Unknown command /modle. Did you mean /model? /help lists
95
+ # the commands.` The "Did you mean" part needs a close name among the
96
+ # entries (DidYouMean::SpellChecker).
97
+ def unknown_command_hint(line)
98
+ return nil unless unknown_command_word?(line)
99
+
100
+ text = line.to_s.strip
101
+ close = DidYouMean::SpellChecker.new(dictionary: @entries.map(&:name)).correct(text).first
102
+ "Unknown command #{text}. #{close ? "Did you mean #{close}? " : ""}/help lists the commands."
103
+ end
104
+
77
105
  # @return [Entry, nil] the first entry the UI runs itself (local) that
78
106
  # matches +line+, whatever its uis (a UI answers the others' too:
79
107
  # the REPL's /detach note)
@@ -142,6 +142,12 @@ module Samagotchi
142
142
  # Asks again in the same turn after an empty answer (EmptyAnswerRetry);
143
143
  # 0 = off, capped at 3. No CLI flag: workers get no CLI args.
144
144
  Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
145
+ # A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
146
+ # --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
147
+ Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
148
+ # Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
149
+ # config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
150
+ Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
145
151
  # What `chi update` touches; its --no-gem/--no-bundles/--no-desktop turn one off for a run.
146
152
  Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
147
153
  Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
@@ -161,7 +167,7 @@ module Samagotchi
161
167
  Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
162
168
  Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
163
169
  # Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
164
- Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 30, expose: %i[env config cli]),
170
+ Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
165
171
 
166
172
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
167
173
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Samagotchi
6
+ # A session worker's continue offer: what it announces when a turn runs out
7
+ # of iterations, when a prompt or a reminder drops the offer, when a
8
+ # /continue answers it, and the continue turn a yes runs. TurnFlow keeps
9
+ # the offer itself (its checkpoint and context); this is the worker's side
10
+ # of it, which every UI hears about.
11
+ #
12
+ # The offer is also a question (kind "continue", the step-limit
13
+ # question), standing on the Engine's question desk between turns, so
14
+ # everything built on a pending question sees it: chi send --wait (exit
15
+ # 3), chi answer, the lists' waiting, the web's badge and bell. Its
16
+ # answer becomes the matching /continue line on the worker's command
17
+ # queue, which runs it as a typed one (#run_command's path).
18
+ class ContinueOffer
19
+ KIND = "continue"
20
+ HEADER = "Step limit"
21
+ CONTINUE = "Continue"
22
+ STOP = "Stop"
23
+ PROMPT_PREVIEW = 200
24
+ # last_turn's outcome after a Stop: no turn ran, but the wait of
25
+ # whoever answered (chi answer) ends on it.
26
+ NOT_CONTINUED = "not_continued"
27
+
28
+ # @param engine [Engine] announces, records activity, holds the question
29
+ # @param turn_flow [TurnFlow] owns the offer
30
+ # @param run_turn [#call] (prompt, **turn_args, &block) the worker's
31
+ # run_engine_turn
32
+ # @param max_iterations [#call] (no_interrupt) → Integer
33
+ # @param queue_command [#call] (line, client_id:) queues a session
34
+ # command as a card's (Bridge#queue_command, card: true)
35
+ def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil)
36
+ @engine = engine
37
+ @turn_flow = turn_flow
38
+ @run_turn = run_turn
39
+ @max_iterations = max_iterations
40
+ @queue_command = queue_command
41
+ # A question was posted since the last close (it may have been
42
+ # answered or superseded meanwhile).
43
+ @asked = false
44
+ end
45
+
46
+ attr_writer :queue_command
47
+
48
+ def awaiting? = @turn_flow.awaiting_continue?
49
+
50
+ # TurnFlow keeps the checkpoint (a cancelled turn's, for !rollback) and
51
+ # the continue offer of a turn that ran out of iterations; an offer made
52
+ # (or kept, after a cancelled continue turn) is announced and asked.
53
+ def after_turn(result, continue: false, no_interrupt: false)
54
+ outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
55
+ return unless outcome == :continue_offered || outcome == :continue_cancelled
56
+
57
+ open
58
+ end
59
+
60
+ # A prompt (or a reminder turn) taken while a continue is offered
61
+ # replaces the answer (D2): the offer goes, the partial turn stays.
62
+ def drop(origin)
63
+ return unless awaiting?
64
+
65
+ close("dropped")
66
+ @engine.synchronize_events do
67
+ @turn_flow.drop_offer!
68
+ @engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
69
+ end
70
+ @engine.record_activity
71
+ end
72
+
73
+ # Whether a command's +result+ answered the offer that was pending
74
+ # (+awaiting+) as it started.
75
+ def resolved?(awaiting, result)
76
+ awaiting && result.decision && result.decision != :invalid ? true : false
77
+ end
78
+
79
+ # Called with the event log held, before the command's command_ran. A
80
+ # Stop runs no turn: last_turn says so (not_continued), so a wait on
81
+ # it ends (chi answer --option Stop).
82
+ def announce_resolved(result, command)
83
+ @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
84
+ return if result.resume
85
+
86
+ session = @engine.session
87
+ session.last_turn = { "outcome" => NOT_CONTINUED, "ended_at" => Time.now.iso8601(3) } if session
88
+ end
89
+
90
+ # After a command's command_ran and save: a /continue that answered the
91
+ # offer closes its question; a command that dropped the offer
92
+ # otherwise (!rollback) too.
93
+ # @param resolved [Boolean] it answered the offer (#resolved?)
94
+ def after_command(result, resolved:)
95
+ close(resolved ? "answered" : "dropped") unless awaiting?
96
+ return unless resolved
97
+
98
+ # An offer answered without a turn ("no") is activity: the recap
99
+ # written at the offer (it says the turn stopped) gets rewritten.
100
+ @engine.record_activity unless result.resume
101
+ end
102
+
103
+ # The continue offer was answered yes: resume the conversation without a
104
+ # user message, with the iteration limit the offer's turn had. A failure
105
+ # keeps the offer, as the REPL does ("continue prompt preserved"), and
106
+ # asks again.
107
+ def run_continue_turn(command)
108
+ offer = @turn_flow.offer
109
+ close("answered")
110
+ @turn_flow.before_continue_turn
111
+ @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
112
+ max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
113
+ if error
114
+ open(offer)
115
+ else
116
+ after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
117
+ end
118
+ end
119
+ end
120
+
121
+ # The question goes (the offer went, or was answered).
122
+ # @param reason [String] dropped, answered
123
+ def close(reason)
124
+ return unless @asked
125
+
126
+ @asked = false
127
+ @engine.withdraw_question(reason)
128
+ end
129
+
130
+ private
131
+
132
+ def open(offer = @turn_flow.offer)
133
+ @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
134
+ ask
135
+ end
136
+
137
+ # Post the step-limit question while the offer stands (again, after a
138
+ # question that superseded it closed).
139
+ def ask
140
+ return unless awaiting?
141
+
142
+ @asked = true
143
+ @engine.post_question(question_fields(@turn_flow.offer), on_answer: method(:answered),
144
+ on_superseded_close: method(:ask))
145
+ end
146
+
147
+ def question_fields(offer)
148
+ limit = limit_of_last_turn
149
+ context = offer[:context] || {}
150
+ text = "The turn ran out of iterations#{" (#{limit} steps)" if limit} before it answered. Continue it?"
151
+ prompt = context[:original_prompt].to_s.strip
152
+ unless prompt.empty?
153
+ prompt = "#{prompt[0, PROMPT_PREVIEW].rstrip}..." if prompt.length > PROMPT_PREVIEW
154
+ text += "\nPrompt: #{prompt}"
155
+ end
156
+ { kind: KIND, header: HEADER, question: text, options: [CONTINUE, STOP], multi_select: false,
157
+ allow_freeform: true, limit: limit, context: context }.compact
158
+ end
159
+
160
+ # The limit the offer's turn ran out at (Engine's last_turn marker).
161
+ def limit_of_last_turn
162
+ last = @engine.session&.last_turn
163
+ last.is_a?(Hash) && last["exhausted"] ? last["limit"] : nil
164
+ end
165
+
166
+ # The question's answer as the /continue line a typed answer would be:
167
+ # Continue → yes; Stop → no; a text (with Stop, or alone) → no, <text>.
168
+ def answered(answer, client_id:)
169
+ text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
170
+ line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
171
+ elsif text.empty? then "/continue no"
172
+ else "/continue no, #{text}"
173
+ end
174
+ @queue_command&.call(line, client_id: client_id)
175
+ end
176
+ end
177
+ end