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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/bin/chi +13 -10
- data/docs/architecture.md +8 -6
- data/docs/cli.md +47 -7
- data/docs/configuration.md +25 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +86 -11
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +7 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/engine.rb +131 -12
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -13
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -2
- data/lib/samagotchi/web/public/stream_handlers.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +113 -21
- data/lib/samagotchi/web/public/working_set_view.js +136 -21
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +49 -11
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +5 -2
- 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.
|
|
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
|