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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -1
- data/README.md +6 -1
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +5 -0
- data/docs/guardrails.md +35 -3
- data/docs/hooks.md +25 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +78 -3
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +15 -9
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
- data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/delegated.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/children_status.rb +143 -0
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +45 -108
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/git_head.rb +24 -0
- data/lib/samagotchi/guardrail_wiring.rb +27 -2
- data/lib/samagotchi/guardrails/approvals.rb +7 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
- data/lib/samagotchi/guardrails/context.rb +32 -0
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +14 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/plugin/sessions.rb +45 -1
- data/lib/samagotchi/process_group.rb +2 -5
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +21 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -0
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +103 -14
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/send_note.rb +5 -11
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +173 -309
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- 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)
|
|
17
|
-
# the user may allow them
|
|
18
|
-
|
|
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
|
|
87
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
448
|
-
#
|
|
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
|
-
|
|
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
|
|
400
|
-
#
|
|
401
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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?
|