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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +4 -1
  4. data/docs/context.md +8 -1
  5. data/docs/guardrails.md +31 -1
  6. data/docs/hooks.md +7 -4
  7. data/docs/memory.md +17 -1
  8. data/docs/plugins.md +79 -3
  9. data/docs/releasing.md +3 -2
  10. data/docs/sessions.md +9 -3
  11. data/lib/samagotchi/answer_command.rb +2 -1
  12. data/lib/samagotchi/archive_store.rb +1 -15
  13. data/lib/samagotchi/bridge.rb +8 -3
  14. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  15. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  16. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  20. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  23. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  24. data/lib/samagotchi/child_reports.rb +31 -10
  25. data/lib/samagotchi/children_status.rb +157 -0
  26. data/lib/samagotchi/client_id.rb +52 -0
  27. data/lib/samagotchi/context_absorber.rb +2 -1
  28. data/lib/samagotchi/context_fetch.rb +14 -45
  29. data/lib/samagotchi/context_sources.rb +20 -10
  30. data/lib/samagotchi/cut_policy.rb +58 -0
  31. data/lib/samagotchi/engine.rb +24 -108
  32. data/lib/samagotchi/engine_peers.rb +30 -0
  33. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  35. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  36. data/lib/samagotchi/guardrails/context.rb +32 -0
  37. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  38. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  39. data/lib/samagotchi/guardrails.rb +1 -0
  40. data/lib/samagotchi/kernel_loop.rb +9 -22
  41. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  42. data/lib/samagotchi/memory_bundle/builder.rb +35 -1
  43. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  44. data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  46. data/lib/samagotchi/memory_paths.rb +10 -0
  47. data/lib/samagotchi/plugin/api.rb +2 -2
  48. data/lib/samagotchi/plugin/sessions.rb +47 -2
  49. data/lib/samagotchi/process_group.rb +157 -0
  50. data/lib/samagotchi/prompt_warmup.rb +20 -1
  51. data/lib/samagotchi/question_desk.rb +2 -1
  52. data/lib/samagotchi/relay_verifier.rb +2 -4
  53. data/lib/samagotchi/reply_wait.rb +9 -4
  54. data/lib/samagotchi/send_command.rb +2 -1
  55. data/lib/samagotchi/session_manager.rb +2 -4
  56. data/lib/samagotchi/steer.rb +27 -19
  57. data/lib/samagotchi/steer_cut.rb +125 -0
  58. data/lib/samagotchi/system_prompt.rb +20 -8
  59. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  60. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  61. data/lib/samagotchi/terminal_ui.rb +2 -1
  62. data/lib/samagotchi/tool_declarations.rb +9 -5
  63. data/lib/samagotchi/tools/builtins.rb +2 -2
  64. data/lib/samagotchi/tools/delegate.rb +105 -16
  65. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  66. data/lib/samagotchi/tools/execute.rb +10 -81
  67. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  68. data/lib/samagotchi/turn_note.rb +9 -1
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +2 -1
  71. data/lib/samagotchi/web/public/app.js +19 -20
  72. data/lib/samagotchi/web/public/data.js +3 -0
  73. data/lib/samagotchi/web/public/question_card.js +3 -4
  74. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  75. data/lib/samagotchi/web/public/stable_html.js +18 -0
  76. data/lib/samagotchi/web/public/turn_events.js +9 -2
  77. data/lib/samagotchi/web/public/turn_view.js +1 -1
  78. data/lib/samagotchi/worker.rb +40 -13
  79. 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 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
 
@@ -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
- 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)
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] may run while a turn runs (from P2; today
51
- # it runs as other commands do)
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
- # A fork, send or read that couldn't be done; the message says why.
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: "plugin", state_dir: state_dir)
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)