samagotchi 0.33.0 → 0.35.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +6 -1
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/context.md +5 -0
  9. data/docs/guardrails.md +35 -3
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +17 -1
  12. data/docs/plugins.md +78 -3
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +15 -9
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  18. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  19. data/lib/samagotchi/broadcast/tags.rb +154 -0
  20. data/lib/samagotchi/broadcast_command.rb +228 -0
  21. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  22. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  23. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  31. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  32. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  35. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  36. data/lib/samagotchi/children_status.rb +143 -0
  37. data/lib/samagotchi/config.rb +6 -0
  38. data/lib/samagotchi/cut_policy.rb +58 -0
  39. data/lib/samagotchi/engine.rb +45 -108
  40. data/lib/samagotchi/engine_peers.rb +30 -0
  41. data/lib/samagotchi/git_head.rb +24 -0
  42. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  43. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  44. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  45. data/lib/samagotchi/guardrails/context.rb +32 -0
  46. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  47. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +11 -5
  50. data/lib/samagotchi/kernel_loop.rb +9 -22
  51. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  52. data/lib/samagotchi/log_subscriber.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/builder.rb +14 -1
  54. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  55. data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
  56. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  57. data/lib/samagotchi/memory_paths.rb +10 -0
  58. data/lib/samagotchi/note_command.rb +13 -13
  59. data/lib/samagotchi/note_delivery.rb +42 -0
  60. data/lib/samagotchi/plugin/api.rb +2 -2
  61. data/lib/samagotchi/plugin/context.rb +4 -4
  62. data/lib/samagotchi/plugin/sessions.rb +45 -1
  63. data/lib/samagotchi/process_group.rb +2 -5
  64. data/lib/samagotchi/session_manager.rb +4 -3
  65. data/lib/samagotchi/sessions_command.rb +3 -2
  66. data/lib/samagotchi/steer_cut.rb +125 -0
  67. data/lib/samagotchi/system_prompt.rb +21 -8
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  69. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  70. data/lib/samagotchi/terminal_ui.rb +14 -0
  71. data/lib/samagotchi/tool_declarations.rb +9 -5
  72. data/lib/samagotchi/tools/builtins.rb +2 -2
  73. data/lib/samagotchi/tools/delegate.rb +103 -14
  74. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  75. data/lib/samagotchi/tools/send_note.rb +5 -11
  76. data/lib/samagotchi/turn_note.rb +9 -1
  77. data/lib/samagotchi/version.rb +1 -1
  78. data/lib/samagotchi/web/app.rb +3 -0
  79. data/lib/samagotchi/web/capabilities.rb +19 -0
  80. data/lib/samagotchi/web/public/activity.js +26 -6
  81. data/lib/samagotchi/web/public/app.js +515 -288
  82. data/lib/samagotchi/web/public/card.js +12 -3
  83. data/lib/samagotchi/web/public/command_view.js +2 -3
  84. data/lib/samagotchi/web/public/families_view.js +171 -0
  85. data/lib/samagotchi/web/public/format.js +52 -0
  86. data/lib/samagotchi/web/public/index.html +94 -21
  87. data/lib/samagotchi/web/public/live_timing.js +154 -0
  88. data/lib/samagotchi/web/public/live_turn.js +44 -0
  89. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  90. data/lib/samagotchi/web/public/strip.js +5 -2
  91. data/lib/samagotchi/web/public/turn_html.js +107 -0
  92. data/lib/samagotchi/web/public/turn_model.js +5 -4
  93. data/lib/samagotchi/web/public/turn_view.js +43 -129
  94. data/lib/samagotchi/web/session_summary.rb +4 -0
  95. data/lib/samagotchi/worker.rb +173 -309
  96. data/lib/samagotchi/worker_inbound.rb +251 -0
  97. data/lib/samagotchi/worker_wakes.rb +120 -0
  98. metadata +24 -1
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "outside"
4
+ require_relative "protected_paths"
5
+ require_relative "shell_lex"
6
+ require_relative "shell_paths"
7
+ require_relative "shared_git"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # A delegate child asks before it changes anything outside its folder
12
+ # (+root+: the work tree top of its working_directory, never Dir.pwd,
13
+ # so a child whose worktree is gone still has one). A core check, on in
14
+ # every guardrails mode and with guardrails.enabled: false, for delegate
15
+ # sessions only (GuardrailWiring#checks), so ordinary sessions and a
16
+ # parent's own flows never get it. It asks, once or for the session
17
+ # (a repo or rule answer would hold in every worktree, for every child),
18
+ # when:
19
+ # write, edit: a path is outside root (Outside: tmp dirs and memory
20
+ # files never are);
21
+ # execute, task_create: mutating git runs in a dir outside root
22
+ # (ShellGitDirs), or a command that isn't read-only starts outside
23
+ # root (its cwd:, else Dir.pwd) or names a path in the repository's
24
+ # other checkouts (the main one and sibling worktrees) or its
25
+ # common git dir; or it runs git that changes what all checkouts
26
+ # share, from anywhere (SharedGit: config, update-ref, worktree add,
27
+ # stash clear, tags, another branch's delete).
28
+ # Paths elsewhere in a command (/usr/bin, ~/.gem) don't count, and
29
+ # read-only commands never ask. Text heuristics: sh -c bodies, scripts
30
+ # and $(…) aren't looked into.
31
+ class ChildBoundary
32
+ RULE = "child-boundary"
33
+ SOURCE = "core"
34
+ SCOPES = %w[once session].freeze
35
+ SHELL_TOOLS = %w[execute task_create].freeze
36
+
37
+ # @param root [#call] → String, nil: the child's folder (nil: no check)
38
+ # @param worktrees [#call] → Array<String>: every checkout of root's
39
+ # repository (GitInfo#worktrees); root's own is left out here
40
+ # @param common_dir [#call] → String, nil: root's common git dir (in
41
+ # a bare layout it is in no checkout)
42
+ # @param branch [#call] → String, nil: the branch checked out in root
43
+ def initialize(root:, worktrees: -> { [] }, common_dir: -> {}, branch: -> {})
44
+ @root = root
45
+ @worktrees = worktrees
46
+ @common_dir = common_dir
47
+ @branch = branch
48
+ end
49
+
50
+ # Vote on +verdict+ (its targets).
51
+ def check(verdict)
52
+ targets = verdict.targets
53
+ root = targets && @root.call
54
+ return verdict unless root && !root.to_s.empty?
55
+
56
+ crossing = crosses?(targets, root) or return verdict
57
+
58
+ what = crossing == :shared ? "git state every checkout of the repository shares, from its folder" : "something outside its folder"
59
+ verdict.ask!("a delegate child changes #{what} #{root}: ask the user",
60
+ scopes: SCOPES, rule: RULE, source: SOURCE, decided_by: "core")
61
+ end
62
+
63
+ private
64
+
65
+ def crosses?(targets, root)
66
+ case targets.tool
67
+ when "write", "edit" then targets.paths.any? { |path| Outside.outside?(path, root: root) }
68
+ when *SHELL_TOOLS then shell_crosses?(targets, root)
69
+ else false
70
+ end
71
+ end
72
+
73
+ def shell_crosses?(targets, root)
74
+ return true if targets.git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: root) }
75
+ return :shared if SharedGit.changes?(targets.command, own_branch: @branch.call)
76
+ return false if targets.read_only?
77
+ return true if targets.cwd && Outside.outside?(targets.cwd, root: root)
78
+
79
+ names_other_checkout?(targets.command, targets.cwd, root)
80
+ end
81
+
82
+ # Whether a word of +command+ resolves into another checkout of the
83
+ # repository (and not into root, which may sit inside the main one).
84
+ def names_other_checkout?(command, cwd, root)
85
+ real_root = ProtectedPaths.real(root)
86
+ others = [*@worktrees.call, @common_dir.call].compact.map { |dir| ProtectedPaths.real(dir) } - [real_root]
87
+ return false if others.empty?
88
+
89
+ walk = ShellPaths::Walk.new(cwd, Dir.home, ENV)
90
+ ShellLex.simple_commands(ShellLex.lex(command.to_s)).any? do |words|
91
+ walk.step(words).any? do |_word, path|
92
+ next false unless path
93
+
94
+ real = ProtectedPaths.real(path)
95
+ !ShellPaths.within?(real, real_root) && others.any? { |dir| ShellPaths.within?(real, dir) }
96
+ end
97
+ end
98
+ rescue ArgumentError, EncodingError
99
+ false
100
+ end
101
+ end
102
+ end
103
+ end
@@ -50,6 +50,38 @@ module Samagotchi
50
50
  def common_dir(dir) = info(dir)[:common_dir]
51
51
  def branch(dir) = info(dir)[:branch]
52
52
 
53
+ # Every checkout of +dir+'s repository that is still there: the main
54
+ # one (the folder holding a common .git dir that isn't bare) and each
55
+ # linked worktree (<common dir>/worktrees/*/gitdir names its .git
56
+ # file; a relative one is relative to that admin dir). Read from the
57
+ # files, no git process, on every call (not memoized: a worktree
58
+ # added mid-turn counts at once); [] outside a repo.
59
+ # @return [Array<String>] absolute folders
60
+ def worktrees(dir)
61
+ common = common_dir(dir) or return []
62
+ self.class.checkouts(common)
63
+ end
64
+
65
+ # @param common [String] an absolute common git dir
66
+ def self.checkouts(common)
67
+ main = File.basename(common) == ".git" && !bare?(common) ? [File.dirname(common)] : []
68
+ linked = Dir[File.join(common, "worktrees", "*", "gitdir")].sort.filter_map do |file|
69
+ gitdir = File.read(file).strip
70
+ next if gitdir.empty?
71
+
72
+ File.dirname(File.expand_path(gitdir, File.dirname(file)))
73
+ rescue SystemCallError
74
+ nil
75
+ end
76
+ (main + linked).select { |folder| File.directory?(folder) }.uniq
77
+ end
78
+
79
+ def self.bare?(common)
80
+ File.read(File.join(common, "config")).match?(/^\s*bare\s*=\s*true\s*$/i)
81
+ rescue SystemCallError
82
+ false
83
+ end
84
+
53
85
  private
54
86
 
55
87
  def info(dir)
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell_lex"
4
+ require_relative "shell_git_dirs"
5
+
6
+ module Samagotchi
7
+ module Guardrails
8
+ # Whether a shell command runs git that changes what every checkout of
9
+ # the repository shares (its common git dir), wherever it runs: from a
10
+ # worktree these reach the main checkout and the siblings too.
11
+ # config a write without --worktree (or --file/--blob, whose
12
+ # path the caller checks); --get, --list, `config get`
13
+ # and a lone key only read
14
+ # update-ref always
15
+ # worktree add, remove, move, prune, lock, unlock, repair
16
+ # stash drop, clear, pop (one stash list for all)
17
+ # tag creating or deleting one (not listing or verifying)
18
+ # branch delete, force or a forced move/copy, of a branch other
19
+ # than +own_branch+ (any, when that is unknown)
20
+ # Pure text, as ShellGitDirs: sh -c, aliases and $(…) get through.
21
+ module SharedGit
22
+ WORKTREE_CHANGES = %w[add remove move prune lock unlock repair].freeze
23
+ STASH_CHANGES = %w[drop clear pop].freeze
24
+ CONFIG_READS = %w[--get --get-all --get-regexp --get-urlmatch --list -l --get-color --get-colorbool].freeze
25
+ CONFIG_WRITES = %w[--unset --unset-all --add --replace-all --rename-section --remove-section -e --edit].freeze
26
+ CONFIG_OWN_FILE = %w[--worktree -f --file --blob].freeze
27
+ # config options that take the next word as their value.
28
+ CONFIG_VALUES = %w[--type --default --comment -f --file --blob].freeze
29
+ TAG_READS = %w[-l --list -n -v --verify --contains --no-contains --points-at --merged --no-merged].freeze
30
+ TAG_WRITES = %w[-d --delete -f --force -a --annotate -s --sign -u -m -F].freeze
31
+ BRANCH_DELETE = %w[-d -D --delete].freeze
32
+ BRANCH_MOVE = %w[-m --move -c --copy].freeze
33
+ BRANCH_FORCED_MOVE = %w[-M -C].freeze
34
+
35
+ module_function
36
+
37
+ # @param command [String]
38
+ # @param own_branch [String, nil] the branch checked out where the
39
+ # session works
40
+ def changes?(command, own_branch: nil)
41
+ ShellLex.simple_commands(ShellLex.lex(command.to_s)).any? do |words|
42
+ next false unless words.is_a?(Array)
43
+
44
+ sub, args = git_call(words)
45
+ sub && changes_shared?(sub, args, own_branch)
46
+ end
47
+ rescue ArgumentError, EncodingError
48
+ false
49
+ end
50
+
51
+ # [subcommand, its args] of a git simple command, or nil.
52
+ def git_call(words)
53
+ words = words.drop_while { |w| w.match?(/\A[A-Za-z_]\w*=/) || ShellGitDirs::PREFIXES.include?(w) }
54
+ verb = words.first
55
+ return nil unless verb == "git" || verb.to_s.end_with?("/git")
56
+
57
+ args = words.drop(1).grep_v(ShellLex::REDIRECT)
58
+ until args.empty?
59
+ arg = args.shift
60
+ if ShellGitDirs::VALUE_OPTIONS.include?(arg) then args.shift
61
+ elsif !arg.start_with?("-") then return [arg, args]
62
+ end
63
+ end
64
+ nil
65
+ end
66
+
67
+ def changes_shared?(sub, args, own_branch)
68
+ case sub
69
+ when "config" then config_writes?(args)
70
+ when "update-ref" then true
71
+ when "worktree" then WORKTREE_CHANGES.include?(args.first)
72
+ when "stash" then STASH_CHANGES.include?(args.first)
73
+ when "tag" then tag_changes?(args)
74
+ when "branch" then branch_changes?(args, own_branch)
75
+ else false
76
+ end
77
+ end
78
+
79
+ def config_writes?(args)
80
+ return false if args.any? { |a| CONFIG_OWN_FILE.include?(a) || a.start_with?("--file=", "--blob=") }
81
+ return false if %w[get list].include?(args.first)
82
+ return true if %w[set unset rename-section remove-section edit].include?(args.first)
83
+ return false if args.any? { |a| CONFIG_READS.include?(a) }
84
+ return true if args.any? { |a| CONFIG_WRITES.include?(a) }
85
+
86
+ operands(args, CONFIG_VALUES).size >= 2
87
+ end
88
+
89
+ def tag_changes?(args)
90
+ return true if args.any? { |a| TAG_WRITES.include?(a) }
91
+ return false if args.empty? || args.any? { |a| TAG_READS.include?(a) || a.start_with?("--list=", "--contains=", "--points-at=") }
92
+
93
+ !operands(args, []).empty?
94
+ end
95
+
96
+ # The branches a branch command deletes or overwrites (a plain -m/-c
97
+ # only moves its source; its new name doesn't exist yet).
98
+ def branch_changes?(args, own_branch)
99
+ names = operands(args, %w[-u --set-upstream-to -t --track])
100
+ targets = if args.any? { |a| BRANCH_DELETE.include?(a) } then names
101
+ elsif args.any? { |a| BRANCH_FORCED_MOVE.include?(a) } then names.size >= 2 ? names : [own_branch, *names]
102
+ elsif args.any? { |a| BRANCH_MOVE.include?(a) } then names.size >= 2 ? [names.first] : []
103
+ elsif args.any? { |a| %w[-f --force].include?(a) } then names.first(1)
104
+ else []
105
+ end
106
+ targets.any? { |name| own_branch.nil? || name != own_branch }
107
+ end
108
+
109
+ def operands(args, value_options)
110
+ rest = args.dup
111
+ found = []
112
+ until rest.empty?
113
+ arg = rest.shift
114
+ if value_options.include?(arg) then rest.shift
115
+ elsif arg == "--" then found.concat(rest)
116
+ break
117
+ elsif !arg.start_with?("-") then found << arg
118
+ end
119
+ end
120
+ found
121
+ end
122
+ end
123
+ end
124
+ end
@@ -13,9 +13,10 @@ module Samagotchi
13
13
  STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
14
14
  # Rules that ask about chi's own config and hooks: the core
15
15
  # ProtectedPaths asks and the guardrails bundle's shell rule; and a
16
- # command source for attached context (it runs later, ungated). Only
17
- # the user may allow them (ParentApprovals).
18
- PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd].freeze
16
+ # command source for attached context (it runs later, ungated); chi
17
+ # broadcast, which is the user's. Only the user may allow them
18
+ # (ParentApprovals).
19
+ PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast].freeze
19
20
  SCOPES = %w[once session repo rule].freeze
20
21
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
21
22
 
@@ -12,6 +12,7 @@ require_relative "guardrails/approval"
12
12
  require_relative "guardrails/approvals"
13
13
  require_relative "guardrails/protected_paths"
14
14
  require_relative "guardrails/scratch_writes"
15
+ require_relative "guardrails/child_boundary"
15
16
  require_relative "guardrails/load_failures"
16
17
  require_relative "guardrails/rules"
17
18
  require_relative "guardrails/gate"
@@ -83,8 +83,9 @@ module Samagotchi
83
83
 
84
84
  # What a hook can do beyond reading its event: the Engine's
85
85
  # callables, each given the hook's label. +notify+ takes
86
- # (text:, level:, hook:) and shows one line to the user; +ask_user+
87
- # takes (question:, options:, header:, allow_freeform:, hook:) and
86
+ # (text:, level:, hook:, and fallback_for: when the hook gave one: what
87
+ # the line stands in for, Engine#hook_notify) and shows one line to the
88
+ # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:) and
88
89
  # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
89
90
  # cancels the running turn (true when it did); +steer+ takes (text:,
90
91
  # hook:) and puts the text into the running turn (Engine#steer, true
@@ -239,7 +240,10 @@ module Samagotchi
239
240
  #
240
241
  # All procs get the same hash, so event[:hook] is set before each one;
241
242
  # the helpers are set once per fire and read event[:hook] when called:
242
- # event[:notify].call(text, level: :info) one line to the user
243
+ # event[:notify].call(text, level: :info, fallback_for: nil) one line
244
+ # to the user; fallback_for: :display marks a line that stands in
245
+ # for the answer's display (event[:present]), which a UI that
246
+ # renders the display's links leaves out (Engine#hook_notify)
243
247
  # event[:ask_user].call(question:, options:, header: nil, allow_freeform: false)
244
248
  # -> {selected:, freeform:, selected_indices:} or nil (no one to
245
249
  # ask, cancelled, bad options)
@@ -330,8 +334,10 @@ module Samagotchi
330
334
  def with_runtime(event)
331
335
  return unless event.is_a?(Hash)
332
336
 
333
- event[:notify] ||= lambda { |text, level: :info|
334
- @runtime&.notify&.call(text: text.to_s, level: level, hook: event[:hook])
337
+ event[:notify] ||= lambda { |text, level: :info, fallback_for: nil|
338
+ notice = { text: text.to_s, level: level, hook: event[:hook] }
339
+ notice[:fallback_for] = fallback_for unless fallback_for.nil?
340
+ @runtime&.notify&.call(**notice)
335
341
  nil
336
342
  }
337
343
  event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false|
@@ -13,6 +13,7 @@ require_relative "client"
13
13
  require_relative "llm/errors"
14
14
  require_relative "log"
15
15
  require_relative "empty_answer_retry"
16
+ require_relative "cut_policy"
16
17
  require_relative "thinking"
17
18
  require_relative "hooks"
18
19
  require_relative "pending_input_queue"
@@ -444,31 +445,17 @@ module Samagotchi
444
445
  messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
445
446
  end
446
447
 
447
- # A generation a plugin cut is an empty answer made early. Queued input
448
- # (a user's line, a plugin's steer) goes in first, with or without a
449
- # retry left, and spends no attempt: the model answers it. A steer's cut
450
- # (Engine#cut_for_steer) with nothing queued (its message was taken at
451
- # a boundary already) is asked again as is. Else it is asked again with
452
- # its own nudge while the retry budget lasts, else the turn ends as
453
- # cancelled (hook), with nothing salvaged and without the spent nudge.
454
- # A Stop that came right after the cut is a plain cancel. Returns :next
455
- # or the turn's result.
448
+ # A cut generation (CutPolicy): a Stop after it raises as any cancel,
449
+ # the turn going on is :next, the hook ending is the turn's result.
456
450
  def after_cut(turn, generation)
457
451
  cut = generation.cut
458
- raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller.cancelled?
452
+ outcome = CutPolicy.decide(cut: cut, cancel_controller: turn.cancel_controller, empty_retry: turn.empty_retry,
453
+ conversation: turn.conversation, iteration: turn.iteration, emit: turn.emit,
454
+ inject: -> { inject_pending_input!(turn) },
455
+ finish_reason: "stopped", thinking_chars: generation.streamed_thinking)
456
+ raise Client::RequestCancelled, turn.cancel_controller.reason if outcome.stopped?
457
+ return :next if outcome.again?
459
458
 
460
- # The row under the cut step, above the message it was cut for.
461
- emit(turn, type: :steer_cut, iteration: turn.iteration, source: cut[:source].to_s) if cut[:steer]
462
- return :next if inject_pending_input!(turn) || cut[:steer]
463
-
464
- if turn.empty_retry.left?
465
- turn.empty_retry.nudge!(turn.conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
466
- emit: turn.emit, iteration: turn.iteration, finish_reason: "stopped",
467
- thinking_chars: generation.streamed_thinking, stopped_by: cut[:by])
468
- return :next
469
- end
470
- turn.empty_retry.drop_nudge!(turn.conversation)
471
- turn.cancel_controller.cancel!(:hook, cut)
472
459
  emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook, stopped_by: cut[:by])
473
460
  cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
474
461
  end
@@ -10,6 +10,7 @@ require_relative "openai_chat"
10
10
  require_relative "native_tool_normalizer"
11
11
  require_relative "../kernel_loop"
12
12
  require_relative "../answer_display"
13
+ require_relative "../cut_policy"
13
14
  require_relative "../context_status"
14
15
  require_relative "../context_window"
15
16
  require_relative "../context_note"
@@ -396,30 +397,18 @@ module Samagotchi
396
397
  thinking_chars: response.reasoning.to_s.length)
397
398
  end
398
399
 
399
- # A generation a plugin cut (stop_generation) is an empty answer made
400
- # early. Queued input (a user's line, a plugin's steer) goes in
401
- # first, with or without a retry left, and spends no attempt. A
402
- # steer's cut (Engine#cut_for_steer) with nothing queued is asked
403
- # again as is. Else it is asked again with its own nudge while the
404
- # retry budget lasts, else the turn ends as cancelled (hook), with
405
- # nothing salvaged and without a spent nudge. A Stop that came right
406
- # after the cut is a plain cancel. Returns :retry, or the turn's
407
- # result.
400
+ # A cut generation (CutPolicy): a Stop after it ends the turn as
401
+ # any cancel, the turn going on is :retry, the hook ending is the
402
+ # turn's result.
408
403
  def after_cut(iteration, response)
409
- return canceled(iteration, @cancel_controller.reason) if @cancel_controller.cancelled?
404
+ outcome = CutPolicy.decide(cut: response.cut, cancel_controller: @cancel_controller, empty_retry: @empty_retry,
405
+ conversation: @conversation, iteration: iteration, emit: method(:emit),
406
+ inject: -> { inject_pending_input(iteration) },
407
+ finish_reason: response.finish_reason,
408
+ thinking_chars: response.reasoning.to_s.length)
409
+ return canceled(iteration, @cancel_controller.reason) if outcome.stopped?
410
+ return :retry if outcome.again?
410
411
 
411
- # The row under the cut step, above the message it was cut for.
412
- emit(type: :steer_cut, iteration: iteration, source: response.cut[:source].to_s) if response.cut[:steer]
413
- return :retry if inject_pending_input(iteration) || response.cut[:steer]
414
-
415
- if @empty_retry.left?
416
- @empty_retry.nudge!(@conversation, TurnNote.cut_retry(response.cut[:by], response.cut[:reason]),
417
- emit: method(:emit), iteration: iteration, finish_reason: response.finish_reason,
418
- thinking_chars: response.reasoning.to_s.length, stopped_by: response.cut[:by])
419
- return :retry
420
- end
421
- @empty_retry.drop_nudge!(@conversation)
422
- @cancel_controller.cancel!(:hook, response.cut)
423
412
  canceled(iteration, :hook)
424
413
  end
425
414
 
@@ -147,7 +147,7 @@ module Samagotchi
147
147
  # hook's, not the user's or the model's).
148
148
  def on_hook_notice(event)
149
149
  level = event[:level].to_s == "warn" ? :warn : :info
150
- log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
150
+ log(level, :hook_notice, hook: event[:hook], fallback_for: event[:fallback_for], msg: event[:text].to_s[0, 300])
151
151
  end
152
152
 
153
153
  # after_turn hooks presented the answer (AnswerDisplay): its size only
@@ -208,7 +208,8 @@ module Samagotchi
208
208
  requires_chi: requires_chi,
209
209
  needs: needs,
210
210
  scripts: scripts_to_copy.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
211
- context_providers: context_providers
211
+ context_providers: context_providers,
212
+ file_descriptions: file_descriptions(prov_data, files_map)
212
213
  )
213
214
 
214
215
  case format
@@ -276,6 +277,18 @@ module Samagotchi
276
277
  .select { |_, path| File.file?(path) }
277
278
  end
278
279
 
280
+ # The descriptions the installed bundle's record keeps for the files
281
+ # being built (its manifest's files: mappings), so a rebuild writes
282
+ # them back; {} without a record.
283
+ def file_descriptions(prov_data, files_map)
284
+ return {} unless prov_data && prov_data[:files].is_a?(Hash)
285
+
286
+ prov_data[:files].each_with_object({}) do |(key, entry), acc|
287
+ text = entry.is_a?(Hash) ? entry[:description].to_s : ""
288
+ acc[key.to_s] = text if files_map.key?(key.to_s) && !text.empty?
289
+ end
290
+ end
291
+
279
292
  # Without an allowlist, a memory another installed bundle owns (its
280
293
  # record lists it) isn't the user's to share: left out, one line
281
294
  # each. The bundle being built (same name) keeps its own.
@@ -92,6 +92,7 @@ module Samagotchi
92
92
  FileUtils.mkdir_p(target_dir)
93
93
 
94
94
  # Copy .md files and update index for each.
95
+ @file_descriptions = manifest&.file_descriptions || {}
95
96
  all_files_in_bundle = []
96
97
  provenance = Provenance.new(name: @name)
97
98
  existing_provenance = provenance.read
@@ -421,7 +422,8 @@ module Samagotchi
421
422
  needs: manifest.needs,
422
423
  conflicts: @conflicts.keys,
423
424
  scripts_files: scripts_for_provenance,
424
- context_providers: manifest.context_providers
425
+ context_providers: manifest.context_providers,
426
+ file_descriptions: manifest.file_descriptions
425
427
  )
426
428
  end
427
429
 
@@ -695,7 +697,9 @@ module Samagotchi
695
697
  # Index lines use the entry name (as memory_write does); drop the
696
698
  # legacy "name.md" line older installs wrote.
697
699
  IndexUpdater.remove_index(scope, file_key) unless entry_name == file_key
698
- IndexUpdater.update_index(scope, entry_name, byte_count, source: owned ? @name : nil)
700
+ # A manifest's description: for the file; nil keeps the line's.
701
+ description = owned ? @file_descriptions&.[](file_key) : nil
702
+ IndexUpdater.update_index(scope, entry_name, byte_count, description, source: owned ? @name : nil)
699
703
  rescue StandardError => e
700
704
  # Not fatal (the file is in place): a warning says so.
701
705
  @warnings << "index.md: line for #{entry_name} not updated (#{e.message})"
@@ -16,7 +16,9 @@ module Samagotchi
16
16
  # description: "…" # optional
17
17
  # files:
18
18
  # identity.md: sha256:abc123...
19
- # commit_preferences.md: sha256:def456...
19
+ # skill_x.md: # or a mapping: the description is the
20
+ # sha256: sha256:def456... # memory's index.md line
21
+ # description: "Use when …"
20
22
  # plugin: # optional — the bundle's plugin.rb (docs/plugins.md)
21
23
  # file: plugin.rb
22
24
  # sha256: sha256:0a1b...
@@ -50,7 +52,9 @@ module Samagotchi
50
52
  # A bundle name, as `chi bundle install <name>` takes a shipped one.
51
53
  BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
52
54
 
53
- attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
55
+ # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
56
+ # the description a mapping gave, for the files that have one.
57
+ attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
54
58
  :scripts, :context_providers
55
59
 
56
60
  def initialize(path:)
@@ -62,6 +66,7 @@ module Samagotchi
62
66
  @description = (raw["description"] || "").to_s
63
67
  @files = parse_files(raw["files"] || {})
64
68
  @hooks = parse_hooks(raw["hooks"] || {})
69
+ @file_descriptions = parse_file_descriptions(raw["files"])
65
70
  @trust_level = (raw["trust_level"] || "experimental").to_s
66
71
  @plugin = parse_plugin(raw["plugin"])
67
72
  @requires_chi = parse_requires_chi(raw["requires_chi"])
@@ -123,8 +128,9 @@ module Samagotchi
123
128
  end
124
129
 
125
130
  # Writes a fresh manifest.yml from computed checksums at `dir`.
131
+ # +file_descriptions+ (name → text) writes those files as mappings.
126
132
  def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
127
- plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [])
133
+ plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {})
128
134
  FileUtils.mkdir_p(dir)
129
135
  manifest = {
130
136
  "name" => name,
@@ -132,7 +138,10 @@ module Samagotchi
132
138
  "description" => description
133
139
  }
134
140
  manifest["scope"] = scope if scope
135
- manifest["files"] = files # { "file.md" => "sha256:abc..." }
141
+ manifest["files"] = files.to_h do |key, sha| # { "file.md" => "sha256:abc..." }
142
+ text = file_descriptions[key].to_s.strip
143
+ [key, text.empty? ? sha : { "sha256" => sha, "description" => text }]
144
+ end
136
145
  if hooks && !hooks.empty?
137
146
  # Normalize hooks to string-keyed with sha256 prefix preserved
138
147
  manifest["hooks"] = hooks.transform_keys(&:to_s).transform_values do |v|
@@ -205,7 +214,7 @@ module Samagotchi
205
214
  raw.each_with_object({}) do |(k, v), acc|
206
215
  next unless k.is_a?(String) && !k.empty?
207
216
 
208
- str = v.to_s
217
+ str = (v.is_a?(Hash) ? v["sha256"] : v).to_s
209
218
  acc[k] = if str.start_with?("sha256:")
210
219
  str
211
220
  else
@@ -214,6 +223,18 @@ module Samagotchi
214
223
  end
215
224
  end
216
225
 
226
+ # The descriptions of the files: entries given as a mapping.
227
+ def parse_file_descriptions(raw)
228
+ return {} unless raw.is_a?(Hash)
229
+
230
+ raw.each_with_object({}) do |(k, v), acc|
231
+ next unless k.is_a?(String) && v.is_a?(Hash)
232
+
233
+ text = v["description"].to_s.strip
234
+ acc[k] = text unless text.empty?
235
+ end
236
+ end
237
+
217
238
  # plugin: {file:, sha256:} → {file:, sha256:} (sha "" when not given),
218
239
  # or nil; a file that isn't a plain .rb name is a validation error.
219
240
  def parse_plugin(raw)
@@ -140,7 +140,7 @@ module Samagotchi
140
140
  # @param context_providers [Array<ContextProviders::Provider>]
141
141
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
142
142
  guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
143
- scripts_files: {}, context_providers: [])
143
+ scripts_files: {}, context_providers: [], file_descriptions: {})
144
144
  FileUtils.mkdir_p(@bundle_dir)
145
145
  bases_dir = File.join(@bundle_dir, "bases")
146
146
  FileUtils.mkdir_p(bases_dir)
@@ -158,6 +158,7 @@ module Samagotchi
158
158
  File.write(File.join(bases_dir, file_key), content)
159
159
  merged_entries[file_key] = { checksum: checksum }
160
160
  merged_entries[file_key][:conflict] = true if conflicts.include?(file_key.to_s)
161
+ merged_entries[file_key][:description] = file_descriptions[file_key.to_s] if file_descriptions[file_key.to_s]
161
162
  end
162
163
 
163
164
  # Prune stale base snapshots for files no longer in the bundle.
@@ -59,6 +59,16 @@ module Samagotchi
59
59
  dot_git && File.dirname(dot_git)
60
60
  end
61
61
 
62
+ # The git dir of the work tree holding +cwd+: .git itself in a main
63
+ # checkout, the worktree's own dir under .git/worktrees in a linked one
64
+ # (where HEAD is that worktree's); nil outside any repository.
65
+ def git_dir(cwd = Dir.pwd)
66
+ dot_git = find_dot_git(cwd)
67
+ return nil unless dot_git
68
+
69
+ File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
70
+ end
71
+
62
72
  # Is +cwd+ inside a git repository (a .git somewhere above it)?
63
73
  def in_repo?(cwd = Dir.pwd)
64
74
  !find_dot_git(cwd).nil?