samagotchi 0.91.0 → 0.92.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +5 -2
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +16 -12
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/hooks.md +3 -3
  11. data/docs/internals/background-tasks.md +2 -2
  12. data/docs/internals/bundles.md +4 -4
  13. data/docs/internals/engine.md +21 -8
  14. data/docs/internals/tool-guardrails.md +10 -2
  15. data/docs/memory.md +6 -0
  16. data/docs/plugins.md +30 -13
  17. data/docs/releasing.md +3 -0
  18. data/docs/sessions.md +3 -3
  19. data/lib/samagotchi/bridge.rb +8 -9
  20. data/lib/samagotchi/bundle_command/check.rb +43 -12
  21. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  22. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  23. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  24. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  25. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  27. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  28. data/lib/samagotchi/bundle_command.rb +11 -213
  29. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  30. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  32. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  33. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  34. data/lib/samagotchi/child_env.rb +50 -0
  35. data/lib/samagotchi/engine.rb +22 -12
  36. data/lib/samagotchi/hooks/registry.rb +2 -0
  37. data/lib/samagotchi/kernel_loop.rb +14 -3
  38. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  39. data/lib/samagotchi/llm/errors.rb +4 -4
  40. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  41. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  42. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  43. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  44. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  45. data/lib/samagotchi/parent_report.rb +12 -0
  46. data/lib/samagotchi/reply_wait.rb +6 -2
  47. data/lib/samagotchi/sandbox_layout.rb +66 -0
  48. data/lib/samagotchi/send_command.rb +3 -3
  49. data/lib/samagotchi/session_manager.rb +12 -20
  50. data/lib/samagotchi/task_watch.rb +64 -21
  51. data/lib/samagotchi/testing/bundle.rb +32 -19
  52. data/lib/samagotchi/testing/install.rb +26 -17
  53. data/lib/samagotchi/testing/sandbox.rb +45 -37
  54. data/lib/samagotchi/testing/waiting.rb +5 -7
  55. data/lib/samagotchi/tool_response.rb +28 -6
  56. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  57. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/public/app.js +24 -14
  60. data/lib/samagotchi/web/public/cards_place.js +4 -1
  61. data/lib/samagotchi/web/public/images.js +6 -0
  62. data/lib/samagotchi/web/public/model_pick.js +10 -0
  63. data/lib/samagotchi/web/public/toasts.js +17 -9
  64. data/lib/samagotchi/web/public/unqueue.js +20 -4
  65. data/lib/samagotchi/worker.rb +77 -185
  66. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  67. data/lib/samagotchi/worker_inbound.rb +5 -5
  68. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  69. data/lib/samagotchi/worker_wakes.rb +0 -3
  70. metadata +6 -1
@@ -90,46 +90,6 @@ module Samagotchi
90
90
  chi bundle build --scope system --out ./my-bundle/ identity.md work.md
91
91
  TEXT
92
92
  TRASH_HELP = "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
93
- NEW_HELP = <<~TEXT
94
- Usage: chi bundle new NAME [--dir DIR]
95
-
96
- Start a bundle in DIR/NAME (default ./NAME; refused when it exists): manifest.yml (shas filled,
97
- requires_chi this chi), plugin.rb (a /NAME command and an after_tool_call hook), test/NAME_test.rb
98
- (passes as written) and README.md. Then: chi bundle test, chi bundle check, chi bundle install.
99
- TEXT
100
- TEST_HELP = <<~TEXT
101
- Usage: chi bundle test [PATH] [-- ARGS]
102
-
103
- Run the tests of the bundle at PATH (default: .) on the Ruby and the lib chi runs on, outside any Bundler env:
104
- test/**/*_test.rb on Minitest (require "samagotchi/testing/minitest"), or else spec/ on RSpec with the kit
105
- (-r samagotchi/testing/rspec). ARGS go to the test runner (-- -n /name/, -- -e "a name").
106
- The tests run the bundle's code as written, with your HOME (the kit's sandbox is per test): run code you'd install.
107
- Exit status: the tests'; 1 no tests; 2 usage, or minitest / rspec doesn't load.
108
- TEXT
109
- CHECK_HELP = <<~TEXT
110
- Usage: chi bundle check [PATH] [--fix] [--set KEY=VALUE]
111
-
112
- Check the bundle at PATH (default: .) as an install would take it, one finding per line:
113
- error|warn|info CODE file[:line]: message (fix: hint)
114
- It installs and loads the bundle in a sandbox (temp HOME, config and state), not in yours, but its plugin and
115
- hooks run in this process as written: run it on code you'd install.
116
- --fix rewrite stale and placeholder sha256 lines in manifest.yml
117
- --set KEY=VALUE a setting for the load (config.yml bundles: <name>: KEY), repeatable
118
- Exit status: 0 no errors, 1 errors, 2 usage.
119
- TEXT
120
- TRY_HELP = <<~TEXT
121
- Usage: chi bundle try PATH -p TEXT [--model NAME] [--memory NAME] [--mute NAME] [--toolkit NAME]
122
- [--thinking LEVEL] [--set KEY=VALUE] [--json] [--keep] [--timeout SECONDS]
123
-
124
- Install PATH and run one non-interactive turn in an isolated temporary config, state and work directory.
125
- The user's hosts, keys and ordinary runtime settings are retained; their bundles, hooks, guardrails,
126
- memories, prompt additions, sessions and attached context sources are not. Shipped guardrails and a
127
- $1 cost cap are installed for the run. --set KEY=VALUE sets the tried bundle's config.yml bundles entry;
128
- --set cost-cap.cap=N overrides the cap (unlike chi --set BUNDLE.KEY=VALUE, these are config settings).
129
- --memory and --mute accept comma-separated names and repeat. --keep retains the sandbox and debug log.
130
- --json prints the experimental machine-readable trace.
131
- Exit status: child turn's status; 1 on install failure or timeout; 2 for usage; 130 on Ctrl-C.
132
- TEXT
133
93
 
134
94
  # The subcommands' flags: --help only; a word not starting with -- is
135
95
  # a positional (install's source, -h included). --scope takes whatever
@@ -153,27 +113,6 @@ module Samagotchi
153
113
  BUILD_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/, dash_values: false) do |f|
154
114
  %w[--scope --name --version --description --out].each { |name| f.value name }
155
115
  end
156
- NEW_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
157
- f.value "--dir"
158
- end
159
- TEST_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A-/)
160
- CHECK_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
161
- f.switch "--fix"
162
- f.value "--set", key: :set, repeat: true
163
- end
164
- TRY_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A-/) do |f|
165
- f.value "-p", "--prompt", key: :prompt
166
- f.value "--model"
167
- f.value "--memory", repeat: true
168
- f.value "--mute", repeat: true
169
- f.value "--toolkit"
170
- f.value "--thinking"
171
- f.value "--set", repeat: true
172
- f.switch "--json"
173
- f.switch "--keep"
174
- f.value "--timeout"
175
- f.refuse "--attach", "--shared", "chi bundle try can't use --attach or --shared"
176
- end
177
116
  TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
178
117
  f.switch "--empty"
179
118
  f.switch "--dry-run"
@@ -855,14 +794,14 @@ module Samagotchi
855
794
  end
856
795
 
857
796
  def new_bundle(rest)
858
- parsed = parse_flags(NEW_FLAGS, rest, "new", help: NEW_HELP)
797
+ require_relative "bundle_command/scaffold"
798
+ parsed = parse_flags(Scaffold::FLAGS, rest, "new", help: Scaffold::HELP)
859
799
  return parsed if parsed.is_a?(Integer)
860
800
 
861
801
  unless parsed.args.size == 1
862
- @stderr.puts NEW_HELP.lines.first
802
+ @stderr.puts Scaffold::HELP.lines.first
863
803
  return CLI::Exit::USAGE
864
804
  end
865
- require_relative "bundle_command/scaffold"
866
805
  scaffold = Scaffold.new(parsed.args.first, parent: parsed.options.fetch(:dir, "."))
867
806
  begin
868
807
  written = scaffold.write
@@ -885,20 +824,21 @@ module Samagotchi
885
824
  def test_bundle(rest)
886
825
  split = rest.index("--")
887
826
  own, args = split ? [rest[0...split], rest[(split + 1)..]] : [rest, []]
888
- parsed = parse_flags(TEST_FLAGS, own, "test", help: TEST_HELP)
827
+ require_relative "bundle_command/test_run"
828
+ parsed = parse_flags(TestRun::FLAGS, own, "test", help: TestRun::HELP)
889
829
  return parsed if parsed.is_a?(Integer)
890
830
 
891
831
  dir = parsed.args.first || "."
892
832
  unless parsed.args.size <= 1 && File.directory?(dir)
893
- @stderr.puts parsed.args.size > 1 ? TEST_HELP.lines.first : "chi bundle test: #{dir} isn't a folder"
833
+ @stderr.puts parsed.args.size > 1 ? TestRun::HELP.lines.first : "chi bundle test: #{dir} isn't a folder"
894
834
  return CLI::Exit::USAGE
895
835
  end
896
- require_relative "bundle_command/test_run"
897
836
  TestRun.new(dir, args: args, stdout: @stdout, stderr: @stderr).run
898
837
  end
899
838
 
900
839
  def check(rest)
901
- parsed = parse_flags(CHECK_FLAGS, rest, "check", help: CHECK_HELP)
840
+ require_relative "bundle_command/check"
841
+ parsed = parse_flags(Check::FLAGS, rest, "check", help: Check::HELP)
902
842
  return parsed if parsed.is_a?(Integer)
903
843
 
904
844
  settings = check_settings(parsed.options.fetch(:set, []), label: "check") or return CLI::Exit::USAGE
@@ -911,157 +851,15 @@ module Samagotchi
911
851
  @stderr.puts "chi bundle check: #{dir} isn't a folder"
912
852
  return CLI::Exit::USAGE
913
853
  end
914
- require_relative "bundle_command/check"
915
854
  Check.new(dir, fix: parsed.options.fetch(:fix, false), settings: settings, stdout: @stdout).run
916
855
  end
917
856
 
918
857
  def try_bundle(rest)
919
- parsed = parse_flags(TRY_FLAGS, rest, "try", help: TRY_HELP)
858
+ require_relative "bundle_command/try/command"
859
+ parsed = parse_flags(Try::Command::FLAGS, rest, "try", help: Try::Command::HELP)
920
860
  return parsed if parsed.is_a?(Integer)
921
861
 
922
- options = parsed.options
923
- if options[:prompt].nil? || parsed.args.size != 1
924
- @stderr.puts "Usage: chi bundle try PATH -p TEXT [options]"
925
- return CLI::Exit::USAGE
926
- end
927
- path = parsed.args.first
928
- unless File.directory?(path)
929
- @stderr.puts "chi bundle try: #{path} isn't a folder"
930
- return CLI::Exit::USAGE
931
- end
932
- if options.values_at(:model, :toolkit, :thinking, :memory, :mute).flatten.compact.any?(&:empty?)
933
- @stderr.puts "chi bundle try: model, toolkit, thinking, memory and mute values cannot be empty"
934
- return CLI::Exit::USAGE
935
- end
936
- require_relative "thinking"
937
- if options[:thinking] && !Thinking::LEVELS.map(&:to_s).include?(options[:thinking])
938
- @stderr.puts "chi bundle try: --thinking takes #{Thinking::LEVELS.join(", ")}, not #{options[:thinking]}"
939
- return CLI::Exit::USAGE
940
- end
941
-
942
- timeout = parse_try_timeout(options[:timeout])
943
- return CLI::Exit::USAGE unless timeout
944
-
945
- set_pairs = []
946
- cost_cap = 1
947
- cost_cap_override = false
948
- require_relative "bundle_session_settings"
949
- Array(options[:set]).each do |pair|
950
- key, sep, value = pair.partition("=")
951
- if sep.empty?
952
- @stderr.puts "chi bundle try: --set takes KEY=VALUE, not #{pair}"
953
- return CLI::Exit::USAGE
954
- end
955
- if key == "cost-cap.cap"
956
- cost_cap = BundleSessionSettings.typed(value)
957
- cost_cap_override = true
958
- unless (cost_cap.is_a?(Numeric) && cost_cap >= 0) || cost_cap == false
959
- @stderr.puts "chi bundle try: --set cost-cap.cap takes a non-negative number or off"
960
- return CLI::Exit::USAGE
961
- end
962
- else
963
- set_pairs << pair
964
- end
965
- end
966
- settings = check_settings(set_pairs, label: "try")
967
- return CLI::Exit::USAGE unless settings
968
-
969
- require_relative "bundle_command/try/sandbox"
970
- require_relative "bundle_command/try/run"
971
- require_relative "bundle_command/try/trace"
972
- sandbox = nil
973
- begin
974
- sandbox = Try::Sandbox.new(path, bundle_settings: settings, cost_cap: cost_cap,
975
- cost_cap_override: cost_cap_override)
976
- sandbox.install
977
- result = Try::Run.new(sandbox).run(prompt: options[:prompt], options: options, timeout: timeout)
978
- trace = Try::TraceReader.new(state_home: sandbox.state_home, session_id: result.session_id)
979
- .read(exit_code: result.exit_status, answer: result.stdout, stderr: result.stderr,
980
- sandbox: options[:keep] ? sandbox.root : nil, timed_out: result.timed_out)
981
- render_try_trace(trace, json: options[:json], child_stderr: result.stderr)
982
- @stderr.puts "chi bundle try: timed out after #{timeout} seconds" if result.timed_out
983
- result.exit_status
984
- rescue Try::Sandbox::Error => e
985
- @stderr.puts e.message
986
- e.status
987
- ensure
988
- report_try_sandbox(sandbox) if options[:keep] && sandbox
989
- sandbox&.cleanup unless options[:keep]
990
- end
991
- end
992
-
993
- def render_try_trace(trace, json:, child_stderr:)
994
- error_lines = child_stderr.lines.reject { |line| line.start_with?("Session: ") }
995
- @stderr.write(error_lines.join)
996
- if json
997
- render_trace_lines(trace, @stderr, include_answer: false)
998
- @stdout.puts JSON.generate(trace.to_h.transform_keys(&:to_s))
999
- else
1000
- render_trace_lines(trace, @stdout, include_answer: true)
1001
- end
1002
- end
1003
-
1004
- def report_try_sandbox(sandbox)
1005
- @stderr.puts "sandbox: #{sandbox.root}"
1006
- @stderr.puts "debug log: #{File.join(sandbox.state_home, "samagotchi", "samagotchi.log")}"
1007
- end
1008
-
1009
- def render_trace_lines(trace, output, include_answer:)
1010
- output.puts "tools:"
1011
- trace.tools.each do |tool|
1012
- output.puts format(" %d %-16s %-7s %dms %s", tool[:index], tool[:name], tool[:status],
1013
- tool[:duration_ms], JSON.generate(tool[:args].to_s[0, 60]))
1014
- end
1015
- trace.notices.each { |notice| output.puts "notices: [#{notice["hook"] || "hook"}] #{notice["text"]}" }
1016
- trace.cards.each do |card|
1017
- output.puts "card: #{[card["title"], card["body"]].compact.join(" — ")}"
1018
- end
1019
- trace.questions.each do |entry|
1020
- question = entry.dig("pending_question", "question").to_s
1021
- outcome = if entry["cancelled"] && entry["reason"] == "non_interactive"
1022
- "unanswerable (non_interactive)"
1023
- elsif entry["cancelled"]
1024
- "cancelled (#{entry["reason"]})"
1025
- elsif entry["answer"]
1026
- "answered"
1027
- else
1028
- "unanswered"
1029
- end
1030
- output.puts "questions: #{JSON.generate(question)} -> #{outcome}"
1031
- end
1032
- if trace.interactive_blocked
1033
- count = trace.denied.size + trace.questions.count { |question| question["cancelled"] && question["reason"] == "non_interactive" }
1034
- output.puts "warn: #{count} question(s)/call(s) couldn't be answered"
1035
- end
1036
- if include_answer
1037
- answer_lines = trace.answer.lines
1038
- output.puts "answer:"
1039
- answer_lines.first(10).each { |line| output.puts " #{line.chomp}" }
1040
- output.puts " … (truncated)" if answer_lines.size > 10
1041
- end
1042
- tokens = trace.tokens
1043
- prompt = tokens[:prompt] || "unknown"
1044
- completion = tokens[:completion] || "unknown"
1045
- cost = if trace.cost
1046
- "#{"~" if trace.cost_is_estimate}$#{format("%.4f", trace.cost)}"
1047
- else
1048
- "unknown"
1049
- end
1050
- output.puts "tokens: #{prompt} in / #{completion} out cost #{cost} exit #{trace.exit_code}" \
1051
- "#{" (#{trace.session_id})" if trace.session_id}"
1052
- end
1053
-
1054
- def parse_try_timeout(value)
1055
- return 300 unless value
1056
-
1057
- timeout = Float(value)
1058
- return timeout if timeout.positive? && timeout.finite?
1059
-
1060
- @stderr.puts "chi bundle try: --timeout must be a positive number"
1061
- nil
1062
- rescue ArgumentError
1063
- @stderr.puts "chi bundle try: --timeout must be a positive number"
1064
- nil
862
+ Try::Command.new(stdout: @stdout, stderr: @stderr).run(parsed)
1065
863
  end
1066
864
 
1067
865
  # --set KEY=VALUE values as typed (BundleSessionSettings.typed), or nil
@@ -9,9 +9,10 @@ module Samagotchi
9
9
  # value}}, string keys, scalar values (.typed). A bundle offers the keys a
10
10
  # session may set in its manifest's session_settings: (recorded at install,
11
11
  # InstalledBundle#session_settings); any other bundle or key is refused, so
12
- # a typo isn't saved. The session keeps them (Session#bundle_settings,
13
- # SessionSetup) and Plugin::Context#settings lays them over config.yml's
14
- # bundles: <name>:.
12
+ # a typo isn't saved, and so is a value of another type than the manifest
13
+ # gives the key (session_settings: {cap: number}). The session keeps them
14
+ # (Session#bundle_settings, SessionSetup) and Plugin::Context#settings
15
+ # lays them over config.yml's bundles: <name>:.
15
16
  module BundleSessionSettings
16
17
  # A refused --set (or request field): the message says why.
17
18
  class Error < ArgumentError; end
@@ -22,25 +23,44 @@ module Samagotchi
22
23
  # The values a setting may take.
23
24
  SCALARS = [String, Integer, Float, TrueClass, FalseClass].freeze
24
25
  REQUEST_SHAPE = "bundle_settings is an object of bundle => {key => scalar}"
25
- # What .typed reads as a number: no leading zero, no exponent, no _.
26
+ # What .typed reads as a number: no leading zero, no _; a decimal may
27
+ # leave out either side of its point (.5, 5.) and take an exponent
28
+ # (1e1, 2.5e-3), which YAML 1.1 leaves as text.
26
29
  INTEGER = /\A-?(?:0|[1-9]\d*)\z/
27
- DECIMAL = /\A-?(?:0|[1-9]\d*)\.\d+\z/
30
+ DECIMAL = /\A-?(?:(?:0|[1-9]\d*)(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\z/
31
+ # YAML 1.1's booleans (config.yml's), any case.
32
+ TRUE_WORDS = %w[true yes on].freeze
33
+ FALSE_WORDS = %w[false no off].freeze
28
34
  # The shipped bundles' sources (a stale record's hint reads them).
29
35
  SHIPPED_DIR = File.expand_path("bundles", __dir__)
30
36
 
31
37
  # The installed bundles a session may set settings of: +offered+ the
32
38
  # keys of each bundle whose plugin loads ({name => keys}), +notes+ what
33
39
  # a refusal adds for a bundle ({name => text}): why its plugin doesn't
34
- # load, or how to refresh a record from before session_settings.
35
- Availability = Data.define(:offered, :notes)
40
+ # load, or how to refresh a record from before session_settings;
41
+ # +types+ the types its manifest gives keys ({name => {key => type}}).
42
+ Availability = Data.define(:offered, :notes, :types) do
43
+ def initialize(offered:, notes:, types: {}) = super
44
+ end
45
+
46
+ # What each type (MemoryBundle::Manifest::SESSION_SETTING_TYPES) takes,
47
+ # as a value .typed gives it, and how a refusal names it. A number may
48
+ # be off (false), as a bundle's own settings are turned off.
49
+ TYPES = {
50
+ "number" => [->(value) { (value.is_a?(Numeric) && value.finite?) || value == false }, "a number (or off)"],
51
+ "boolean" => [->(value) { [true, false].include?(value) }, "true or false"],
52
+ "string" => [->(value) { value.is_a?(String) }, "text"]
53
+ }.freeze
36
54
 
37
55
  module_function
38
56
 
39
57
  # --set's values as typed, in order: a later one of the same key wins.
58
+ # A key +types+ gives as string keeps its text as given (.value_for).
40
59
  # @param args [Array<String>] "BUNDLE.KEY=VALUE" each
60
+ # @param types [Hash{String => Hash{String => String}}] Availability#types
41
61
  # @return [Hash{String => Hash{String => Object}}] frozen
42
62
  # @raise [Error] a value of another shape
43
- def parse(args)
63
+ def parse(args, types: {})
44
64
  settings = {}
45
65
  Array(args).each do |arg|
46
66
  target, sep, raw = arg.to_s.partition("=")
@@ -51,7 +71,7 @@ module Samagotchi
51
71
  raise Error, "--set #{arg}: one key under the bundle (BUNDLE.KEY), no nested path" if keys.size > 1
52
72
  raise Error, "--set #{arg}: give a value" if raw.strip.empty?
53
73
 
54
- (settings[bundle] ||= {})[keys.first] = typed(raw)
74
+ (settings[bundle] ||= {})[keys.first] = value_for(raw, types.dig(bundle, keys.first))
55
75
  end
56
76
  deep_freeze(settings)
57
77
  end
@@ -60,18 +80,23 @@ module Samagotchi
60
80
  # @param bundles [Hash{String => Array<String>}] the installed bundles'
61
81
  # offered keys (.offered)
62
82
  # @param notes [Hash{String => String}] Availability#notes
83
+ # @param types [Hash{String => Hash{String => String}}] Availability#types:
84
+ # a typed key's value must be of its type (TYPES)
63
85
  # @param flag [String] what a refusal names ("--set", a request field)
64
86
  # @return [Hash] +settings+
65
87
  # @raise [Error]
66
- def check!(settings, bundles: offered, notes: {}, flag: "--set")
88
+ def check!(settings, bundles: offered, notes: {}, types: {}, flag: "--set")
67
89
  settings.each do |bundle, values|
68
90
  keys = bundles[bundle]
69
91
  note = notes[bundle]
70
- values.each_key do |key|
92
+ values.each do |key, value|
71
93
  where = "#{flag} #{bundle}.#{key}"
72
94
  raise Error, "#{where}: #{note || "no installed bundle #{bundle} (#{takers(bundles)})"}" if keys.nil?
73
95
  raise Error, "#{where}: #{bundle} takes no session settings#{" #{note}" if note}" if keys.empty?
74
96
  raise Error, "#{where}: #{bundle} takes #{keys.join(", ")}" unless keys.include?(key)
97
+
98
+ takes, what = TYPES[types.dig(bundle, key)]
99
+ raise Error, "#{where}: #{bundle} takes #{what} for #{key}, not #{value.inspect}" if takes && !takes.call(value)
75
100
  end
76
101
  end
77
102
  settings
@@ -80,40 +105,59 @@ module Samagotchi
80
105
  # --set's values parsed and checked (.parse, .check!) against the
81
106
  # installed bundles (.availability).
82
107
  def from_flags(args, available: availability)
83
- check!(parse(args), bundles: available.offered, notes: available.notes)
108
+ check!(parse(args, types: available.types), bundles: available.offered, notes: available.notes, types: available.types)
84
109
  end
85
110
 
86
111
  # A value as --set (and a request's string) gives it: an integer
87
- # ("3", "-1"), a decimal ("2.5"), true, false or off (false); any other
88
- # string stays as given ("010", "0x10", "3:30", "No", "null"). A value
89
- # that isn't a String (a JSON number or boolean) is kept.
112
+ # ("3", "-1"), a decimal ("2.5", ".5", "1e1"), a boolean as config.yml's
113
+ # YAML reads one (true/yes/on, false/no/off, any case); any other string
114
+ # stays as given ("010", "0x10", "3:30", "y", "null"). A value that
115
+ # isn't a String (a JSON number or boolean) is kept.
90
116
  def typed(value)
91
117
  return value unless value.is_a?(String)
118
+ return Integer(value, 10) if INTEGER.match?(value)
92
119
 
93
- case value
94
- when INTEGER then Integer(value, 10)
95
- when DECIMAL then Float(value)
96
- when "true" then true
97
- when "false", "off" then false
98
- else value
120
+ if DECIMAL.match?(value)
121
+ number = Float(float_text(value))
122
+ # 1e400 is Infinity: no cap reaches it and JSON can't save it.
123
+ return number.finite? ? number : value
99
124
  end
125
+
126
+ word = value.downcase
127
+ return true if TRUE_WORDS.include?(word)
128
+ return false if FALSE_WORDS.include?(word)
129
+
130
+ value
100
131
  end
101
132
 
133
+ # A value for a key of +type+: a string key's text as given (--set
134
+ # b.name=yes stays "yes"), any other's .typed.
135
+ def value_for(value, type) = type == "string" ? value : typed(value)
136
+
137
+ # A decimal DECIMAL matches as Float reads it on every Ruby: a point
138
+ # with no digit after it ("5.", "5.e1") gets a 0, which Ruby 3.3's
139
+ # Float wants.
140
+ def float_text(value) = value.sub(/\.(?=e|\z)/i, ".0")
141
+
102
142
  # POST /api/sessions bundle_settings: nil for none, else an object of
103
- # objects of scalars (a string typed as --set's, .typed), checked like
143
+ # objects of scalars (a string typed as --set types it), checked like
104
144
  # --set; an empty bundle entry is dropped.
105
145
  # @param bundles [Hash, nil] the offered keys (.availability's by default)
146
+ # @param types [Hash] their types, with +bundles+ (Availability#types)
106
147
  # @return [Hash] frozen
107
148
  # @raise [Error]
108
- def from_request(raw, bundles: nil)
149
+ def from_request(raw, bundles: nil, types: {})
109
150
  return {} if raw.nil?
110
151
  raise Error, REQUEST_SHAPE unless raw.is_a?(Hash) && raw.all? { |_, values| scalar_map?(values) }
111
152
 
112
- settings = normalize(raw.transform_values { |values| values.transform_values { |value| typed(value) } })
113
- return settings if settings.empty?
153
+ given = normalize(raw)
154
+ return given if given.empty?
114
155
 
115
- available = bundles ? Availability.new(offered: bundles, notes: {}) : availability
116
- check!(settings, bundles: available.offered, notes: available.notes, flag: "bundle_settings")
156
+ available = bundles ? Availability.new(offered: bundles, notes: {}, types: types) : availability
157
+ settings = normalize(given.to_h do |bundle, values|
158
+ [bundle, values.to_h { |key, value| [key, value_for(value, available.types.dig(bundle, key))] }]
159
+ end)
160
+ check!(settings, bundles: available.offered, notes: available.notes, types: available.types, flag: "bundle_settings")
117
161
  end
118
162
 
119
163
  # Each installed bundle's offered keys, by name (Availability#offered).
@@ -133,6 +177,7 @@ module Samagotchi
133
177
  dir ||= MemoryBundle::Provenance.bundles_dir
134
178
  offered = {}
135
179
  notes = {}
180
+ types = {}
136
181
  MemoryBundle::Provenance.each_installed(dir: dir) do |name, bundle|
137
182
  next if bundle.error?
138
183
 
@@ -141,11 +186,12 @@ module Samagotchi
141
186
  next
142
187
  end
143
188
  offered[name] = bundle.session_settings
189
+ types[name] = bundle.session_setting_types unless bundle.session_setting_types.empty?
144
190
  if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
145
191
  notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
146
192
  end
147
193
  end
148
- Availability.new(offered: offered, notes: notes)
194
+ Availability.new(offered: offered, notes: notes, types: types)
149
195
  end
150
196
 
151
197
  # A session file's value, read tolerantly: the bundles that map to
@@ -165,24 +211,14 @@ module Samagotchi
165
211
  end)
166
212
  end
167
213
 
168
- # Why +bundle+'s plugin doesn't load (as Plugin::Loader checks it), nil
169
- # when it does or has none.
214
+ # Why +bundle+'s plugin doesn't load, as chi's loader decides it
215
+ # (Plugin::Loader.unloadable_reason), nil when it does or has none.
170
216
  def unloadable(dir, name, bundle)
171
217
  return nil unless bundle.plugin
172
218
 
173
- failure = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
174
- return failure if failure
175
-
176
- file = File.join(dir, name, "plugin", bundle.plugin.file.to_s)
177
- return "the file is missing" unless File.file?(file)
178
-
179
- return nil if MemoryBundle::Provenance.unchecked?(bundle.plugin.sha256)
180
-
181
- expected = MemoryBundle::Provenance.recorded_sha(bundle.plugin.sha256)
182
- return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
183
-
184
- actual = MemoryBundle::Provenance.file_sha(file)
185
- "its sha256 differs from the installed one (edited after install? reinstall the bundle)" unless actual == expected
219
+ require_relative "plugin/loader"
220
+ # +dir+, not Provenance#plugin_path's bundles_dir: a spec's records live elsewhere.
221
+ Plugin::Loader.unloadable_reason(File.join(dir, name, "plugin", bundle.plugin.file.to_s), bundle)
186
222
  end
187
223
 
188
224
  # Whether chi ships a bundle +name+ whose manifest lists session_settings.
@@ -191,8 +227,9 @@ module Samagotchi
191
227
  return false unless File.file?(path)
192
228
 
193
229
  raw = YAML.safe_load_file(path)
194
- raw.is_a?(Hash) && raw["session_settings"].is_a?(Array) && !raw["session_settings"].empty?
195
- rescue StandardError
230
+ listed = raw.is_a?(Hash) ? raw["session_settings"] : nil
231
+ (listed.is_a?(Array) || listed.is_a?(Hash)) && !listed.empty?
232
+ rescue Psych::Exception, SystemCallError
196
233
  false
197
234
  end
198
235
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: core
3
- version: 0.3.3
3
+ version: 0.3.4
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
7
7
  # Its members' highest floor (cost-cap's)
8
- requires_chi: ">= 0.86.0"
8
+ requires_chi: ">= 0.92.0"
9
9
  includes:
10
10
  - loop-guard
11
11
  - check-in
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: cost-cap
3
- version: 0.2.0
3
+ version: 0.2.1
4
4
  scope: system
5
5
  description: "A per-session $ cap: at the cap the turn pauses on Continue (another cap's worth) / Stop, a warning at 80% goes to the user only; /cap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:21308746c9cfa3e135909d74e1668ccfc659ea120b3fb41b1ee6a3ce386d8b45
9
+ sha256: sha256:28d632cd19117ef359acc6aa1c621081c14c018772b7fe1efd6284e346fea135
10
10
  # ctx.cost is new in chi 0.83.0; ask_user's effects: in the next chi
11
- requires_chi: ">= 0.86.0"
12
- # chi --set cost-cap.cap=3: a session's own cap (ctx.settings["cap"])
13
- session_settings: [cap]
11
+ requires_chi: ">= 0.92.0"
12
+ # session_settings: as a mapping of keys to types is new
13
+ # chi --set cost-cap.cap=3: a session's own cap (ctx.settings["cap"]), a number or off
14
+ session_settings: {cap: number}