samagotchi 0.32.0 → 0.34.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 +75 -1
- data/README.md +4 -1
- data/docs/context.md +8 -1
- data/docs/guardrails.md +31 -1
- data/docs/hooks.md +7 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +79 -3
- data/docs/releasing.md +3 -2
- data/docs/sessions.md +9 -3
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bridge.rb +8 -3
- 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/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- 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 +4 -2
- data/lib/samagotchi/child_reports.rb +31 -10
- data/lib/samagotchi/children_status.rb +157 -0
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/context_absorber.rb +2 -1
- data/lib/samagotchi/context_fetch.rb +14 -45
- data/lib/samagotchi/context_sources.rb +20 -10
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +24 -108
- data/lib/samagotchi/engine_peers.rb +30 -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/parent_approvals.rb +0 -2
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/memory_bundle/builder.rb +35 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +47 -2
- data/lib/samagotchi/process_group.rb +157 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/steer.rb +27 -19
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +20 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +105 -16
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +19 -20
- data/lib/samagotchi/web/public/data.js +3 -0
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/turn_events.js +9 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/worker.rb +40 -13
- metadata +14 -1
|
@@ -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)
|
|
@@ -30,8 +30,6 @@ module Samagotchi
|
|
|
30
30
|
# rewrite would be "allow everything" from then on.
|
|
31
31
|
module ParentApprovals
|
|
32
32
|
KEY = "guardrails.parent_approvals"
|
|
33
|
-
# The client id chi answer posts its answers with.
|
|
34
|
-
CLIENT_ID = "cli:answer"
|
|
35
33
|
# Set by chi's execute and task_create for the commands they run: a
|
|
36
34
|
# chi started there answers as a parent (#parent_process?).
|
|
37
35
|
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
@@ -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
|
|
@@ -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"
|
|
@@ -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
|
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require_relative "manifest"
|
|
7
7
|
require_relative "placeholder"
|
|
8
|
+
require_relative "../context_providers"
|
|
8
9
|
require_relative "../memory_paths"
|
|
9
10
|
require_relative "../model_overlay"
|
|
10
11
|
|
|
@@ -152,6 +153,10 @@ module Samagotchi
|
|
|
152
153
|
requires_chi = prov_data && prov_data[:requires_chi]
|
|
153
154
|
needs = prov_data && prov_data[:needs]
|
|
154
155
|
|
|
156
|
+
# ── Scripts and context providers: the installed bundle's ──
|
|
157
|
+
scripts_to_copy = installed_scripts(prov, prov_data)
|
|
158
|
+
context_providers = prov_data ? ContextProviders.parse_list(prov_data[:context_providers]) : []
|
|
159
|
+
|
|
155
160
|
# Determine trust_level for the built bundle
|
|
156
161
|
build_trust_level = @trust_level
|
|
157
162
|
if (build_trust_level.nil? || build_trust_level.empty?) && prov_data && prov_data[:trust_level]
|
|
@@ -185,6 +190,11 @@ module Samagotchi
|
|
|
185
190
|
|
|
186
191
|
FileUtils.cp(plugin_src, File.join(staging, File.basename(plugin_src))) if plugin_src
|
|
187
192
|
|
|
193
|
+
unless scripts_to_copy.empty?
|
|
194
|
+
FileUtils.mkdir_p(File.join(staging, "scripts"))
|
|
195
|
+
scripts_to_copy.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
|
|
196
|
+
end
|
|
197
|
+
|
|
188
198
|
Manifest.write(
|
|
189
199
|
dir: staging,
|
|
190
200
|
name: resolved_name,
|
|
@@ -196,7 +206,10 @@ module Samagotchi
|
|
|
196
206
|
trust_level: build_trust_level,
|
|
197
207
|
plugin: plugin_src && { file: File.basename(plugin_src), sha256: Digest::SHA256.hexdigest(File.binread(plugin_src)) },
|
|
198
208
|
requires_chi: requires_chi,
|
|
199
|
-
needs: needs
|
|
209
|
+
needs: needs,
|
|
210
|
+
scripts: scripts_to_copy.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
211
|
+
context_providers: context_providers,
|
|
212
|
+
file_descriptions: file_descriptions(prov_data, files_map)
|
|
200
213
|
)
|
|
201
214
|
|
|
202
215
|
case format
|
|
@@ -255,6 +268,27 @@ module Samagotchi
|
|
|
255
268
|
|
|
256
269
|
private
|
|
257
270
|
|
|
271
|
+
# The installed bundle's scripts its record lists, file name => path
|
|
272
|
+
# in its scripts/ (a missing one is left out); {} without a record.
|
|
273
|
+
def installed_scripts(prov, prov_data)
|
|
274
|
+
return {} unless prov && prov_data && prov_data[:scripts].is_a?(Hash)
|
|
275
|
+
|
|
276
|
+
prov_data[:scripts].keys.map(&:to_s).sort.to_h { |file| [file, File.join(prov.scripts_dir, file)] }
|
|
277
|
+
.select { |_, path| File.file?(path) }
|
|
278
|
+
end
|
|
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
|
+
|
|
258
292
|
# Without an allowlist, a memory another installed bundle owns (its
|
|
259
293
|
# record lists it) isn't the user's to share: left out, one line
|
|
260
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)
|
|
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|
|
|
@@ -149,6 +158,8 @@ module Samagotchi
|
|
|
149
158
|
manifest["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
150
159
|
needs = parse_needs(needs)
|
|
151
160
|
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
161
|
+
manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
|
|
162
|
+
manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
152
163
|
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
153
164
|
end
|
|
154
165
|
|
|
@@ -203,7 +214,7 @@ module Samagotchi
|
|
|
203
214
|
raw.each_with_object({}) do |(k, v), acc|
|
|
204
215
|
next unless k.is_a?(String) && !k.empty?
|
|
205
216
|
|
|
206
|
-
str = v.to_s
|
|
217
|
+
str = (v.is_a?(Hash) ? v["sha256"] : v).to_s
|
|
207
218
|
acc[k] = if str.start_with?("sha256:")
|
|
208
219
|
str
|
|
209
220
|
else
|
|
@@ -212,6 +223,18 @@ module Samagotchi
|
|
|
212
223
|
end
|
|
213
224
|
end
|
|
214
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
|
+
|
|
215
238
|
# plugin: {file:, sha256:} → {file:, sha256:} (sha "" when not given),
|
|
216
239
|
# or nil; a file that isn't a plain .rb name is a validation error.
|
|
217
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?
|
|
@@ -47,8 +47,8 @@ module Samagotchi
|
|
|
47
47
|
# show (a String) or nil for nothing. A raise is shown as an error.
|
|
48
48
|
# @param name [String] "/name"; a name the session already has
|
|
49
49
|
# (built-in or another bundle's) is a load error
|
|
50
|
-
# @param anytime [Boolean]
|
|
51
|
-
#
|
|
50
|
+
# @param anytime [Boolean] runs at once on its own thread, beside a
|
|
51
|
+
# running turn, instead of being refused as busy (docs/plugins.md)
|
|
52
52
|
def command(name, description, anytime: false, &block)
|
|
53
53
|
raise ArgumentError, "command #{name.inspect} needs a block" unless block
|
|
54
54
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../client_id"
|
|
3
4
|
require_relative "../session"
|
|
4
5
|
require_relative "../bridge_client"
|
|
5
6
|
require_relative "../bridge/turn_accumulator"
|
|
6
7
|
require_relative "../tools/delegate"
|
|
8
|
+
require_relative "../children_status"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
# Loaded on first use: session_manager requires terminal_ui, which
|
|
@@ -15,7 +17,11 @@ module Samagotchi
|
|
|
15
17
|
# plugin. A fork is an ordinary chi session in its own worker, shown in
|
|
16
18
|
# every list as a child of this one (↳ parent).
|
|
17
19
|
class Sessions
|
|
18
|
-
#
|
|
20
|
+
# How long #stop waits for the child's worker to let go, as the web's
|
|
21
|
+
# stop does.
|
|
22
|
+
STOP_WAIT = 2
|
|
23
|
+
|
|
24
|
+
# A fork, send, read or stop that couldn't be done; the message says why.
|
|
19
25
|
class Error < StandardError; end
|
|
20
26
|
|
|
21
27
|
# @param host [Host] session_id, cwd, model_name and state_dir
|
|
@@ -62,7 +68,7 @@ module Samagotchi
|
|
|
62
68
|
|
|
63
69
|
state_dir = self.state_dir
|
|
64
70
|
sid = resolve(id, state_dir)
|
|
65
|
-
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id:
|
|
71
|
+
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir)
|
|
66
72
|
case delivered[:status]
|
|
67
73
|
when :accepted then sid
|
|
68
74
|
when :refused then raise Error, "session #{sid[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
|
|
@@ -95,10 +101,49 @@ module Samagotchi
|
|
|
95
101
|
running: running, messages: without_system_head(messages) }
|
|
96
102
|
end
|
|
97
103
|
|
|
104
|
+
# This session's children, newest first (ChildrenStatus.of): delegates
|
|
105
|
+
# and forks (delegate: false), archived ones only with +all+. It reads
|
|
106
|
+
# every session's file, so call it on demand, not in a loop.
|
|
107
|
+
# @return [Array<Hash>] frozen ChildrenStatus::Child fields (symbol
|
|
108
|
+
# keys); [] with no session yet
|
|
109
|
+
def children(all: false)
|
|
110
|
+
parent_id = @host.session_id.call or return []
|
|
111
|
+
|
|
112
|
+
ChildrenStatus.of(parent_id, state_dir: state_dir, include_archived: all).map { |child| child.to_h.freeze }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Stop one of this session's own children as `chi sessions stop` does
|
|
116
|
+
# (by session id; no pid is read here), waiting up to STOP_WAIT s for
|
|
117
|
+
# its worker to let go.
|
|
118
|
+
# @return [String] the child's id
|
|
119
|
+
# @raise [Error] not a child of this session, no such session, or a
|
|
120
|
+
# REPL owns it
|
|
121
|
+
def stop(id)
|
|
122
|
+
state_dir = self.state_dir
|
|
123
|
+
sid = resolve(id, state_dir)
|
|
124
|
+
parent_id = @host.session_id.call
|
|
125
|
+
unless parent_id && child_of?(sid, parent_id, state_dir)
|
|
126
|
+
raise Error, "session #{sid[0, 8]} is not a child of this session"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
SessionManager.stop_session(sid, state_dir: state_dir, wait: STOP_WAIT)
|
|
130
|
+
sid
|
|
131
|
+
rescue SessionManager::OwnedByTUI
|
|
132
|
+
raise Error, "session #{sid[0, 8]} is open in a chi REPL; stop it there"
|
|
133
|
+
end
|
|
134
|
+
|
|
98
135
|
private
|
|
99
136
|
|
|
100
137
|
def state_dir = @host.state_dir&.call || Session.default_state_dir
|
|
101
138
|
|
|
139
|
+
# A session file that won't load (corrupt) is an Error, not a crash
|
|
140
|
+
# of the command that called stop.
|
|
141
|
+
def child_of?(sid, parent_id, state_dir)
|
|
142
|
+
Session.load(sid, state_dir: state_dir).parent_id == parent_id
|
|
143
|
+
rescue ArgumentError => e
|
|
144
|
+
raise Error, e.message
|
|
145
|
+
end
|
|
146
|
+
|
|
102
147
|
def resolve(id, state_dir)
|
|
103
148
|
sid = Session.resolve_id(id.to_s.strip, state_dir: state_dir)
|
|
104
149
|
raise Error, "no session #{id}" unless sid && Session.exist?(sid, state_dir: state_dir)
|