samagotchi 0.101.0 → 0.102.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/bin/chi +13 -10
  4. data/docs/architecture.md +8 -6
  5. data/docs/cli.md +47 -7
  6. data/docs/configuration.md +25 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +86 -11
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +7 -0
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +89 -53
  24. data/lib/samagotchi/engine.rb +131 -12
  25. data/lib/samagotchi/idle_scheduler.rb +16 -1
  26. data/lib/samagotchi/llm_context_edit.rb +32 -10
  27. data/lib/samagotchi/llm_context_forget.rb +12 -3
  28. data/lib/samagotchi/llm_context_view.rb +19 -7
  29. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  30. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  31. data/lib/samagotchi/message_parts.rb +8 -3
  32. data/lib/samagotchi/plugin/context.rb +2 -2
  33. data/lib/samagotchi/prompt.rb +1 -1
  34. data/lib/samagotchi/recap_command.rb +68 -0
  35. data/lib/samagotchi/recap_setup.rb +27 -14
  36. data/lib/samagotchi/send_command.rb +14 -11
  37. data/lib/samagotchi/session.rb +19 -12
  38. data/lib/samagotchi/session_commands.rb +44 -2
  39. data/lib/samagotchi/session_config.rb +411 -0
  40. data/lib/samagotchi/session_setup.rb +9 -9
  41. data/lib/samagotchi/sessions_command.rb +6 -2
  42. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  44. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  45. data/lib/samagotchi/terminal_ui.rb +8 -6
  46. data/lib/samagotchi/testing/bundle.rb +3 -2
  47. data/lib/samagotchi/version.rb +1 -1
  48. data/lib/samagotchi/web/app.rb +30 -13
  49. data/lib/samagotchi/web/public/app.js +2 -0
  50. data/lib/samagotchi/web/public/commands.js +8 -2
  51. data/lib/samagotchi/web/public/data.js +3 -2
  52. data/lib/samagotchi/web/public/index.html +20 -2
  53. data/lib/samagotchi/web/public/stream_handlers.js +4 -0
  54. data/lib/samagotchi/web/public/turn_html.js +5 -0
  55. data/lib/samagotchi/web/public/working_set.js +113 -21
  56. data/lib/samagotchi/web/public/working_set_view.js +136 -21
  57. data/lib/samagotchi/worker.rb +9 -6
  58. data/lib/samagotchi/working_set.rb +49 -11
  59. data/lib/samagotchi/working_set_command.rb +119 -0
  60. data/lib/samagotchi/working_set_steer.rb +250 -0
  61. metadata +5 -2
  62. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "llm_context_edit"
5
+ require_relative "llm_context_view"
6
+ require_relative "llm_context_apply"
7
+ require_relative "token_usage"
8
+ require_relative "working_set"
9
+
10
+ module Samagotchi
11
+ # The user steering the working set (/working-set, the web's popover):
12
+ # drop an item (every content output of it still sent goes out of the
13
+ # prompt as a stub) and undrop it. Pure, on a conversation the caller
14
+ # owns (Engine#clone_messages' copies: an op stores new edits Hashes,
15
+ # LLMContextEdit.store) and runs between turns, so the next turn's first
16
+ # request is the first with the change (prompt caching: one break where
17
+ # the new prompt is fresh anyway).
18
+ #
19
+ # A drop is the user's layer (LLMContextEdit kind :drop, by "user"),
20
+ # saved applied: the view sends it whatever the strategy, none too
21
+ # (LLMContextView#sends?). One edit per id: a drop on an output that
22
+ # holds an edit already (a forget sent whole under none, a staged one, a
23
+ # stale stub) keeps that edit's saved Hash in its +was+, and an undrop
24
+ # puts it back. The outputs are WorkingSet.outputs_of's, the projection's
25
+ # own pairing, so the set and the op never disagree.
26
+ #
27
+ # Every op since the last turn is listed in one tail system message
28
+ # (kind "working_set", #record!), so the model learns what changed and
29
+ # doesn't read a dropped file again by reflex. An inverse op before the
30
+ # next turn cancels its op there (a drop then an undrop: nothing sent,
31
+ # nothing noted).
32
+ module WorkingSetSteer
33
+ BY = "user"
34
+ NOTE_KIND = "working_set"
35
+ # The tail note's ops: [{"op", "kind", "key", "label"}], until the turn
36
+ # that sends it (the chat loop's kept entries leave the key out).
37
+ OPS = :working_set_ops
38
+ NOTE_OPEN = "[WORKING SET: "
39
+ DROP = "drop"
40
+ UNDROP = "undrop"
41
+ INVERSE = { DROP => UNDROP, UNDROP => DROP }.freeze
42
+ MEMORY_PREFIX = "memory:"
43
+ URL = %r{\A[a-z][a-z0-9+.-]*://}i
44
+
45
+ # Why an op can't be done: the one place each refusal's words live
46
+ # (/working-set and forget_outputs add context only).
47
+ class Refused < StandardError
48
+ MESSAGES = {
49
+ dropped: "dropped by the user",
50
+ not_in_set: "not in the working set",
51
+ only_edited: "nothing to drop: only edited or written (no read of it is sent)",
52
+ derived: "nothing to drop: an older session's output that chi can't name",
53
+ already_dropped: "already dropped",
54
+ not_sent: "nothing to drop: none of it is sent now (forgotten or stubbed)",
55
+ not_dropped: "not dropped"
56
+ }.freeze
57
+
58
+ # @return [Symbol] a key of MESSAGES
59
+ attr_reader :reason
60
+
61
+ def initialize(reason)
62
+ @reason = reason
63
+ super(MESSAGES.fetch(reason))
64
+ end
65
+
66
+ # The words for +reason+, for a caller that only reports it.
67
+ def self.text(reason) = MESSAGES.fetch(reason)
68
+ end
69
+
70
+ # What a KEY names: an item's identity (WorkingSet's kind and key) and
71
+ # how replies name it (a file relative to the session's folder).
72
+ Target = Data.define(:kind, :key, :label)
73
+
74
+ # What an op did: the ids it changed, and the chars it frees (a drop)
75
+ # and the tail the server reads again from its earliest entry on.
76
+ # +free+: an undrop that took back a drop never sent (nothing changes).
77
+ Done = Data.define(:target, :op, :ids, :freed_chars, :tail_chars, :free)
78
+
79
+ module_function
80
+
81
+ # +word+ (a path, absolute or the session's folder's; memory:NAME; a
82
+ # URL) as a Target.
83
+ def target(word, cwd:)
84
+ word = word.to_s.strip
85
+ if word.start_with?(MEMORY_PREFIX)
86
+ name = word.delete_prefix(MEMORY_PREFIX).strip
87
+ Target.new(kind: WorkingSet::MEMORY, key: name, label: "memory #{name}")
88
+ elsif word.match?(URL)
89
+ url = word.gsub(/\s+/, " ")
90
+ Target.new(kind: WorkingSet::URL, key: url, label: url)
91
+ else
92
+ path = File.expand_path(word, cwd.to_s.empty? ? Dir.pwd : cwd)
93
+ Target.new(kind: WorkingSet::FILE, key: path, label: relative(path, cwd))
94
+ end
95
+ end
96
+
97
+ # A path as replies show it: below +cwd+ relative, else as it is.
98
+ def relative(path, cwd)
99
+ base = "#{cwd.to_s.chomp("/")}/"
100
+ !cwd.to_s.empty? && path.start_with?(base) ? path.delete_prefix(base) : path
101
+ end
102
+
103
+ # Drops +target+'s content outputs that are still sent under
104
+ # +strategy+ (the next turn's: LLMContextView's form), saving an applied
105
+ # :drop on each.
106
+ # @return [Done]
107
+ # @raise [Refused]
108
+ def drop(conversation, target, strategy:, cwd:, now: Time.now.utc.iso8601(3))
109
+ outputs = WorkingSet.outputs_of(conversation, kind: target.kind, key: target.key, strategy: strategy, cwd: cwd)
110
+ raise Refused, (known?(conversation, target, strategy, cwd) ? :only_edited : :not_in_set) if outputs.empty?
111
+
112
+ named = outputs.reject(&:derived)
113
+ raise Refused, :derived if named.empty?
114
+ raise Refused, :already_dropped if named.all? { |output| drop_of(conversation, output) }
115
+
116
+ sent = named.select(&:sent)
117
+ raise Refused, :not_sent if sent.empty?
118
+
119
+ edits = sent.map do |output|
120
+ was = LLMContextEdit.on(conversation[output.index])[output.id]
121
+ [output.index, LLMContextEdit.new(id: output.id, kind: LLMContextEdit::DROP, note: "", by: BY, staged_at: now,
122
+ applied_at: now, was: was&.to_h)]
123
+ end
124
+ freed, tail = LLMContextApply.weigh(conversation, layers: layers(strategy), edits: edits)
125
+ edits.each { |index, edit| LLMContextEdit.store(conversation[index], edit) }
126
+ Done.new(target: target, op: DROP, ids: edits.map { |_, edit| edit.id }, freed_chars: freed, tail_chars: tail,
127
+ free: false)
128
+ end
129
+
130
+ # Takes +target+'s drops off, putting back what each output held before
131
+ # it (+was+). +sent+: whether the drop reached a request (else the
132
+ # undrop is free).
133
+ # @return [Done]
134
+ # @raise [Refused]
135
+ def undrop(conversation, target, strategy:, cwd:, sent: true)
136
+ outputs = WorkingSet.outputs_of(conversation, kind: target.kind, key: target.key, strategy: strategy, cwd: cwd)
137
+ dropped = outputs.filter_map { |output| (edit = drop_of(conversation, output)) && [output, edit] }
138
+ if dropped.empty?
139
+ raise Refused, (outputs.empty? && !known?(conversation, target, strategy, cwd) ? :not_in_set : :not_dropped)
140
+ end
141
+
142
+ dropped.each do |output, edit|
143
+ entry = conversation[output.index]
144
+ LLMContextEdit.remove(entry, output.id)
145
+ before = edit.was && LLMContextEdit.from_h(output.id, edit.was)
146
+ LLMContextEdit.store(entry, before) if before
147
+ end
148
+ first = dropped.map { |output, _| output.index }.min
149
+ view = LLMContextView.new(strategy: strategy)
150
+ tail = sent ? LLMContextView.chars(view.messages(conversation)[first..]) : 0
151
+ Done.new(target: target, op: UNDROP, ids: dropped.map { |output, _| output.id }, freed_chars: 0, tail_chars: tail,
152
+ free: !sent)
153
+ end
154
+
155
+ # +output+'s drop (an LLMContextEdit), or nil.
156
+ def drop_of(conversation, output)
157
+ edit = LLMContextEdit.on(conversation[output.index])[output.id]
158
+ edit if edit&.kind == LLMContextEdit::DROP
159
+ end
160
+
161
+ # Whether the set holds +target+ at all (an edit-only file does).
162
+ def known?(conversation, target, strategy, cwd)
163
+ WorkingSet.build(conversation, cwd: cwd, strategy: strategy).items.any? do |item|
164
+ item.kind == target.kind && item.key == target.key
165
+ end
166
+ end
167
+
168
+ def layers(strategy) = LLMContextView.new(strategy: strategy).layers
169
+
170
+ # ── the tail note ───────────────────────────────────────────────────
171
+
172
+ # The index of the note the next turn will send (one after the last
173
+ # turn's entries: only system messages follow it), or nil.
174
+ def pending_note_index(messages)
175
+ (messages.size - 1).downto(0) do |index|
176
+ message = messages[index]
177
+ return nil unless message[:role].to_s == "system"
178
+ return index if message[:kind].to_s == NOTE_KIND
179
+ end
180
+ nil
181
+ end
182
+
183
+ # The ops the pending note lists ([{"op", "kind", "key", "label"}]).
184
+ def pending_ops(messages)
185
+ index = pending_note_index(messages)
186
+ index ? Array(messages[index][OPS]).select { |op| op.is_a?(Hash) } : []
187
+ end
188
+
189
+ # Whether +target+'s +op+ is pending (made since the last turn).
190
+ def pending?(messages, op, target)
191
+ pending_ops(messages).any? { |entry| same?(entry, op, target) }
192
+ end
193
+
194
+ # +messages+ with +done+'s op in the pending note: its inverse there
195
+ # cancels both (the note goes when nothing is left), else it is added
196
+ # (the note appended when there is none). A new Array; the note is a new
197
+ # Hash.
198
+ # @return [Array<Hash>]
199
+ def record!(messages, done)
200
+ index = pending_note_index(messages)
201
+ ops = index ? pending_ops(messages) : []
202
+ inverse = ops.index { |entry| same?(entry, INVERSE.fetch(done.op), done.target) }
203
+ if inverse
204
+ ops = ops.reject.with_index { |_, i| i == inverse }
205
+ else
206
+ ops = ops.reject { |entry| same?(entry, done.op, done.target) }
207
+ ops += [{ "op" => done.op, "kind" => done.target.kind, "key" => done.target.key, "label" => done.target.label }]
208
+ end
209
+ messages = messages.dup
210
+ if ops.empty?
211
+ messages.delete_at(index) if index
212
+ elsif index
213
+ messages[index] = note(ops)
214
+ else
215
+ messages << note(ops)
216
+ end
217
+ messages
218
+ end
219
+
220
+ def same?(entry, op, target)
221
+ entry["op"] == op && entry["kind"] == target.kind && entry["key"] == target.key
222
+ end
223
+
224
+ # The note listing +ops+: a tail system message, escaped like a context
225
+ # note (Prompt::ESCAPED_KINDS: it carries paths and URLs).
226
+ def note(ops)
227
+ drops = ops.select { |entry| entry["op"] == DROP }.map { |entry| entry["label"] }
228
+ undrops = ops.select { |entry| entry["op"] == UNDROP }.map { |entry| entry["label"] }
229
+ parts = []
230
+ unless drops.empty?
231
+ parts << "the user dropped #{drops.join(", ")} from your context: you are sent a stub instead of " \
232
+ "#{drops.size == 1 ? "its outputs" : "their outputs"}; read #{drops.size == 1 ? "it" : "them"} again " \
233
+ "only if the task needs it"
234
+ end
235
+ unless undrops.empty?
236
+ parts << "the user brought back #{undrops.join(", ")}: #{undrops.size == 1 ? "its outputs are" : "their outputs are"} " \
237
+ "sent whole again"
238
+ end
239
+ { role: "system", kind: NOTE_KIND, content: "#{NOTE_OPEN}since your last turn #{parts.join("; ")}.]", OPS => ops }
240
+ end
241
+
242
+ # "12.4k tokens", chars/4.
243
+ def k(chars)
244
+ tokens = (chars / TokenUsage::CHARS_PER_TOKEN).ceil
245
+ tokens >= 1000 ? format("%.1fk tokens", tokens / 1000.0) : "#{tokens} tokens"
246
+ end
247
+
248
+ private_class_method :relative, :drop_of, :known?, :layers, :same?
249
+ end
250
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.101.0
4
+ version: 0.102.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -211,7 +211,6 @@ files:
211
211
  - lib/samagotchi/bundle_command/try/sandbox.rb
212
212
  - lib/samagotchi/bundle_command/try/trace.rb
213
213
  - lib/samagotchi/bundle_needs.rb
214
- - lib/samagotchi/bundle_session_settings.rb
215
214
  - lib/samagotchi/bundles/agents-md/manifest.yml
216
215
  - lib/samagotchi/bundles/agents-md/plugin.rb
217
216
  - lib/samagotchi/bundles/btw/manifest.yml
@@ -493,6 +492,7 @@ files:
493
492
  - lib/samagotchi/queued_command.rb
494
493
  - lib/samagotchi/reasoning_history.rb
495
494
  - lib/samagotchi/reasoning_history_command.rb
495
+ - lib/samagotchi/recap_command.rb
496
496
  - lib/samagotchi/recap_setup.rb
497
497
  - lib/samagotchi/recap_store.rb
498
498
  - lib/samagotchi/redacted_inspect.rb
@@ -517,6 +517,7 @@ files:
517
517
  - lib/samagotchi/session_archive_command.rb
518
518
  - lib/samagotchi/session_chain.rb
519
519
  - lib/samagotchi/session_commands.rb
520
+ - lib/samagotchi/session_config.rb
520
521
  - lib/samagotchi/session_continue.rb
521
522
  - lib/samagotchi/session_delete_command.rb
522
523
  - lib/samagotchi/session_inbox.rb
@@ -777,6 +778,8 @@ files:
777
778
  - lib/samagotchi/worker_task_wakes.rb
778
779
  - lib/samagotchi/worker_wakes.rb
779
780
  - lib/samagotchi/working_set.rb
781
+ - lib/samagotchi/working_set_command.rb
782
+ - lib/samagotchi/working_set_steer.rb
780
783
  - lib/samagotchi/yaml_lines.rb
781
784
  homepage: https://github.com/dm1try/samagotchi
782
785
  licenses:
@@ -1,251 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require_relative "version"
5
-
6
- module Samagotchi
7
- # A session's own bundle settings (chi --set BUNDLE.KEY=VALUE, chi send
8
- # --new --set, POST /api/sessions bundle_settings): {bundle => {key =>
9
- # value}}, string keys, scalar values (.typed). A bundle offers the keys a
10
- # session may set in its manifest's session_settings: (recorded at install,
11
- # InstalledBundle#session_settings); any other bundle or key is refused, so
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>:.
16
- module BundleSessionSettings
17
- # A refused --set (or request field): the message says why.
18
- class Error < ArgumentError; end
19
-
20
- # A bundle name (MemoryBundle::Manifest::BUNDLE_NAME) and a setting key.
21
- BUNDLE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
22
- KEY = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
23
- # The values a setting may take.
24
- SCALARS = [String, Integer, Float, TrueClass, FalseClass].freeze
25
- REQUEST_SHAPE = "bundle_settings is an object of bundle => {key => scalar}"
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.
29
- INTEGER = /\A-?(?:0|[1-9]\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
34
- # The shipped bundles' sources (a stale record's hint reads them).
35
- SHIPPED_DIR = File.expand_path("bundles", __dir__)
36
-
37
- # The installed bundles a session may set settings of: +offered+ the
38
- # keys of each bundle whose plugin loads ({name => keys}), +notes+ what
39
- # a refusal adds for a bundle ({name => text}): why its plugin doesn't
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
54
-
55
- module_function
56
-
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).
59
- # @param args [Array<String>] "BUNDLE.KEY=VALUE" each
60
- # @param types [Hash{String => Hash{String => String}}] Availability#types
61
- # @return [Hash{String => Hash{String => Object}}] frozen
62
- # @raise [Error] a value of another shape
63
- def parse(args, types: {})
64
- settings = {}
65
- Array(args).each do |arg|
66
- target, sep, raw = arg.to_s.partition("=")
67
- bundle, *keys = target.split(".", -1)
68
- if sep.empty? || keys.empty? || !BUNDLE.match?(bundle.to_s) || keys.any? { |key| !KEY.match?(key) }
69
- raise Error, "--set takes BUNDLE.KEY=VALUE, not #{arg}"
70
- end
71
- raise Error, "--set #{arg}: one key under the bundle (BUNDLE.KEY), no nested path" if keys.size > 1
72
- raise Error, "--set #{arg}: give a value" if raw.strip.empty?
73
-
74
- (settings[bundle] ||= {})[keys.first] = value_for(raw, types.dig(bundle, keys.first))
75
- end
76
- deep_freeze(settings)
77
- end
78
-
79
- # +settings+ when every bundle is installed and offers each key.
80
- # @param bundles [Hash{String => Array<String>}] the installed bundles'
81
- # offered keys (.offered)
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)
85
- # @param flag [String] what a refusal names ("--set", a request field)
86
- # @return [Hash] +settings+
87
- # @raise [Error]
88
- def check!(settings, bundles: offered, notes: {}, types: {}, flag: "--set")
89
- settings.each do |bundle, values|
90
- keys = bundles[bundle]
91
- note = notes[bundle]
92
- values.each do |key, value|
93
- where = "#{flag} #{bundle}.#{key}"
94
- raise Error, "#{where}: #{note || "no installed bundle #{bundle} (#{takers(bundles)})"}" if keys.nil?
95
- raise Error, "#{where}: #{bundle} takes no session settings#{" #{note}" if note}" if keys.empty?
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)
100
- end
101
- end
102
- settings
103
- end
104
-
105
- # --set's values parsed and checked (.parse, .check!) against the
106
- # installed bundles (.availability).
107
- def from_flags(args, available: availability)
108
- check!(parse(args, types: available.types), bundles: available.offered, notes: available.notes, types: available.types)
109
- end
110
-
111
- # A value as --set (and a request's string) gives it: an integer
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.
116
- def typed(value)
117
- return value unless value.is_a?(String)
118
- return Integer(value, 10) if INTEGER.match?(value)
119
-
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
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
131
- end
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
-
142
- # POST /api/sessions bundle_settings: nil for none, else an object of
143
- # objects of scalars (a string typed as --set types it), checked like
144
- # --set; an empty bundle entry is dropped.
145
- # @param bundles [Hash, nil] the offered keys (.availability's by default)
146
- # @param types [Hash] their types, with +bundles+ (Availability#types)
147
- # @return [Hash] frozen
148
- # @raise [Error]
149
- def from_request(raw, bundles: nil, types: {})
150
- return {} if raw.nil?
151
- raise Error, REQUEST_SHAPE unless raw.is_a?(Hash) && raw.all? { |_, values| scalar_map?(values) }
152
-
153
- given = normalize(raw)
154
- return given if given.empty?
155
-
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")
161
- end
162
-
163
- # Each installed bundle's offered keys, by name (Availability#offered).
164
- # @return [Hash{String => Array<String>}]
165
- def offered(dir: nil) = availability(dir: dir).offered
166
-
167
- # The installed bundles a session may set settings of: a record that
168
- # doesn't read is left out, and so is one whose plugin doesn't load as
169
- # chi's loader decides (Plugin::Loader#unloadable_reason: requires_chi,
170
- # the file, its sha256), with a note saying why. A record with no
171
- # session_settings whose shipped bundle lists some (installed before
172
- # them) gets a note on refreshing it.
173
- # @return [Availability]
174
- def availability(dir: nil, shipped_dir: SHIPPED_DIR)
175
- require_relative "memory_bundle/provenance"
176
- require_relative "memory_bundle/manifest"
177
- dir ||= MemoryBundle::Provenance.bundles_dir
178
- offered = {}
179
- notes = {}
180
- types = {}
181
- MemoryBundle::Provenance.each_installed(dir: dir) do |name, bundle|
182
- next if bundle.error?
183
-
184
- if (reason = unloadable(dir, name, bundle))
185
- notes[name] = "#{name} is installed but its plugin doesn't load: #{reason}"
186
- next
187
- end
188
- offered[name] = bundle.session_settings
189
- types[name] = bundle.session_setting_types unless bundle.session_setting_types.empty?
190
- if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
191
- notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
192
- end
193
- end
194
- Availability.new(offered: offered, notes: notes, types: types)
195
- end
196
-
197
- # A session file's value, read tolerantly: the bundles that map to
198
- # settings, the scalar ones of those, string keys, frozen; {} for
199
- # anything else.
200
- # @return [Hash{String => Hash{String => Object}}]
201
- def normalize(raw)
202
- return {}.freeze unless raw.is_a?(Hash)
203
-
204
- deep_freeze(raw.each_with_object({}) do |(bundle, values), acc|
205
- next unless values.is_a?(Hash)
206
-
207
- kept = values.each_with_object({}) do |(key, value), scalars|
208
- scalars[key.to_s] = value if SCALARS.any? { |kind| value.is_a?(kind) }
209
- end
210
- acc[bundle.to_s] = kept unless kept.empty?
211
- end)
212
- end
213
-
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.
216
- def unloadable(dir, name, bundle)
217
- return nil unless bundle.plugin
218
-
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)
222
- end
223
-
224
- # Whether chi ships a bundle +name+ whose manifest lists session_settings.
225
- def shipped_settings?(shipped_dir, name)
226
- path = File.join(shipped_dir, name, "manifest.yml")
227
- return false unless File.file?(path)
228
-
229
- raw = YAML.safe_load_file(path)
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
233
- false
234
- end
235
-
236
- def scalar_map?(values)
237
- values.is_a?(Hash) && values.values.all? { |value| SCALARS.any? { |kind| value.is_a?(kind) } }
238
- end
239
-
240
- def takers(bundles)
241
- names = bundles.reject { |_, keys| keys.empty? }.keys.sort
242
- names.empty? ? "no installed bundle takes --set" : "bundles that take --set: #{names.join(", ")}"
243
- end
244
-
245
- def deep_freeze(settings)
246
- settings.to_h { |bundle, values| [bundle.dup.freeze, values.to_h { |k, v| [k.dup.freeze, v.frozen? ? v : v.dup.freeze] }.freeze] }.freeze
247
- end
248
-
249
- private_class_method :unloadable, :shipped_settings?, :scalar_map?, :takers, :deep_freeze
250
- end
251
- end