samagotchi 0.83.0 → 0.85.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 +31 -1
- data/bin/chi +27 -2
- data/docs/architecture.md +10 -1
- data/docs/cli.md +17 -1
- data/docs/configuration.md +2 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +3 -2
- data/docs/plugins.md +31 -5
- data/docs/sessions.md +15 -2
- data/docs/sub-agent.md +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +54 -1
- data/lib/samagotchi/bridge_client.rb +12 -0
- data/lib/samagotchi/bundle_session_settings.rb +212 -0
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +12 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +5 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +2 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_report.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +36 -2
- data/lib/samagotchi/reply_wait.rb +8 -1
- data/lib/samagotchi/send_command.rb +24 -7
- data/lib/samagotchi/session.rb +16 -1
- data/lib/samagotchi/session_commands.rb +7 -4
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/session_setup.rb +14 -6
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +89 -1
- data/lib/samagotchi/terminal_ui.rb +10 -2
- data/lib/samagotchi/testing/bundle.rb +31 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +41 -4
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- metadata +3 -1
|
@@ -0,0 +1,212 @@
|
|
|
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. The session keeps them (Session#bundle_settings,
|
|
13
|
+
# SessionSetup) and Plugin::Context#settings lays them over config.yml's
|
|
14
|
+
# bundles: <name>:.
|
|
15
|
+
module BundleSessionSettings
|
|
16
|
+
# A refused --set (or request field): the message says why.
|
|
17
|
+
class Error < ArgumentError; end
|
|
18
|
+
|
|
19
|
+
# A bundle name (MemoryBundle::Manifest::BUNDLE_NAME) and a setting key.
|
|
20
|
+
BUNDLE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
21
|
+
KEY = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
|
|
22
|
+
# The values a setting may take.
|
|
23
|
+
SCALARS = [String, Integer, Float, TrueClass, FalseClass].freeze
|
|
24
|
+
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
|
+
INTEGER = /\A-?(?:0|[1-9]\d*)\z/
|
|
27
|
+
DECIMAL = /\A-?(?:0|[1-9]\d*)\.\d+\z/
|
|
28
|
+
# The shipped bundles' sources (a stale record's hint reads them).
|
|
29
|
+
SHIPPED_DIR = File.expand_path("bundles", __dir__)
|
|
30
|
+
|
|
31
|
+
# The installed bundles a session may set settings of: +offered+ the
|
|
32
|
+
# keys of each bundle whose plugin loads ({name => keys}), +notes+ what
|
|
33
|
+
# 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)
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# --set's values as typed, in order: a later one of the same key wins.
|
|
40
|
+
# @param args [Array<String>] "BUNDLE.KEY=VALUE" each
|
|
41
|
+
# @return [Hash{String => Hash{String => Object}}] frozen
|
|
42
|
+
# @raise [Error] a value of another shape
|
|
43
|
+
def parse(args)
|
|
44
|
+
settings = {}
|
|
45
|
+
Array(args).each do |arg|
|
|
46
|
+
target, sep, raw = arg.to_s.partition("=")
|
|
47
|
+
bundle, *keys = target.split(".", -1)
|
|
48
|
+
if sep.empty? || keys.empty? || !BUNDLE.match?(bundle.to_s) || keys.any? { |key| !KEY.match?(key) }
|
|
49
|
+
raise Error, "--set takes BUNDLE.KEY=VALUE, not #{arg}"
|
|
50
|
+
end
|
|
51
|
+
raise Error, "--set #{arg}: one key under the bundle (BUNDLE.KEY), no nested path" if keys.size > 1
|
|
52
|
+
raise Error, "--set #{arg}: give a value" if raw.strip.empty?
|
|
53
|
+
|
|
54
|
+
(settings[bundle] ||= {})[keys.first] = typed(raw)
|
|
55
|
+
end
|
|
56
|
+
deep_freeze(settings)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# +settings+ when every bundle is installed and offers each key.
|
|
60
|
+
# @param bundles [Hash{String => Array<String>}] the installed bundles'
|
|
61
|
+
# offered keys (.offered)
|
|
62
|
+
# @param notes [Hash{String => String}] Availability#notes
|
|
63
|
+
# @param flag [String] what a refusal names ("--set", a request field)
|
|
64
|
+
# @return [Hash] +settings+
|
|
65
|
+
# @raise [Error]
|
|
66
|
+
def check!(settings, bundles: offered, notes: {}, flag: "--set")
|
|
67
|
+
settings.each do |bundle, values|
|
|
68
|
+
keys = bundles[bundle]
|
|
69
|
+
note = notes[bundle]
|
|
70
|
+
values.each_key do |key|
|
|
71
|
+
where = "#{flag} #{bundle}.#{key}"
|
|
72
|
+
raise Error, "#{where}: #{note || "no installed bundle #{bundle} (#{takers(bundles)})"}" if keys.nil?
|
|
73
|
+
raise Error, "#{where}: #{bundle} takes no session settings#{" #{note}" if note}" if keys.empty?
|
|
74
|
+
raise Error, "#{where}: #{bundle} takes #{keys.join(", ")}" unless keys.include?(key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
settings
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# --set's values parsed and checked (.parse, .check!) against the
|
|
81
|
+
# installed bundles (.availability).
|
|
82
|
+
def from_flags(args, available: availability)
|
|
83
|
+
check!(parse(args), bundles: available.offered, notes: available.notes)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 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.
|
|
90
|
+
def typed(value)
|
|
91
|
+
return value unless value.is_a?(String)
|
|
92
|
+
|
|
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
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# 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
|
|
104
|
+
# --set; an empty bundle entry is dropped.
|
|
105
|
+
# @param bundles [Hash, nil] the offered keys (.availability's by default)
|
|
106
|
+
# @return [Hash] frozen
|
|
107
|
+
# @raise [Error]
|
|
108
|
+
def from_request(raw, bundles: nil)
|
|
109
|
+
return {} if raw.nil?
|
|
110
|
+
raise Error, REQUEST_SHAPE unless raw.is_a?(Hash) && raw.all? { |_, values| scalar_map?(values) }
|
|
111
|
+
|
|
112
|
+
settings = normalize(raw.transform_values { |values| values.transform_values { |value| typed(value) } })
|
|
113
|
+
return settings if settings.empty?
|
|
114
|
+
|
|
115
|
+
available = bundles ? Availability.new(offered: bundles, notes: {}) : availability
|
|
116
|
+
check!(settings, bundles: available.offered, notes: available.notes, flag: "bundle_settings")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Each installed bundle's offered keys, by name (Availability#offered).
|
|
120
|
+
# @return [Hash{String => Array<String>}]
|
|
121
|
+
def offered(dir: nil) = availability(dir: dir).offered
|
|
122
|
+
|
|
123
|
+
# The installed bundles a session may set settings of: a record that
|
|
124
|
+
# doesn't read is left out, and so is one whose plugin doesn't load as
|
|
125
|
+
# chi's loader decides (Plugin::Loader#unloadable_reason: requires_chi,
|
|
126
|
+
# the file, its sha256), with a note saying why. A record with no
|
|
127
|
+
# session_settings whose shipped bundle lists some (installed before
|
|
128
|
+
# them) gets a note on refreshing it.
|
|
129
|
+
# @return [Availability]
|
|
130
|
+
def availability(dir: nil, shipped_dir: SHIPPED_DIR)
|
|
131
|
+
require_relative "memory_bundle/provenance"
|
|
132
|
+
require_relative "memory_bundle/manifest"
|
|
133
|
+
dir ||= MemoryBundle::Provenance.bundles_dir
|
|
134
|
+
offered = {}
|
|
135
|
+
notes = {}
|
|
136
|
+
MemoryBundle::Provenance.each_installed(dir: dir) do |name, bundle|
|
|
137
|
+
next if bundle.error?
|
|
138
|
+
|
|
139
|
+
if (reason = unloadable(dir, name, bundle))
|
|
140
|
+
notes[name] = "#{name} is installed but its plugin doesn't load: #{reason}"
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
offered[name] = bundle.session_settings
|
|
144
|
+
if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
|
|
145
|
+
notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
Availability.new(offered: offered, notes: notes)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A session file's value, read tolerantly: the bundles that map to
|
|
152
|
+
# settings, the scalar ones of those, string keys, frozen; {} for
|
|
153
|
+
# anything else.
|
|
154
|
+
# @return [Hash{String => Hash{String => Object}}]
|
|
155
|
+
def normalize(raw)
|
|
156
|
+
return {}.freeze unless raw.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
deep_freeze(raw.each_with_object({}) do |(bundle, values), acc|
|
|
159
|
+
next unless values.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
kept = values.each_with_object({}) do |(key, value), scalars|
|
|
162
|
+
scalars[key.to_s] = value if SCALARS.any? { |kind| value.is_a?(kind) }
|
|
163
|
+
end
|
|
164
|
+
acc[bundle.to_s] = kept unless kept.empty?
|
|
165
|
+
end)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Why +bundle+'s plugin doesn't load (as Plugin::Loader checks it), nil
|
|
169
|
+
# when it does or has none.
|
|
170
|
+
def unloadable(dir, name, bundle)
|
|
171
|
+
return nil unless bundle.plugin
|
|
172
|
+
|
|
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
|
+
expected = MemoryBundle::Provenance.recorded_sha(bundle.plugin.sha256)
|
|
180
|
+
return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
|
|
181
|
+
|
|
182
|
+
actual = MemoryBundle::Provenance.file_sha(file)
|
|
183
|
+
"its sha256 differs from the installed one (edited after install? reinstall the bundle)" unless actual == expected
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Whether chi ships a bundle +name+ whose manifest lists session_settings.
|
|
187
|
+
def shipped_settings?(shipped_dir, name)
|
|
188
|
+
path = File.join(shipped_dir, name, "manifest.yml")
|
|
189
|
+
return false unless File.file?(path)
|
|
190
|
+
|
|
191
|
+
raw = YAML.safe_load_file(path)
|
|
192
|
+
raw.is_a?(Hash) && raw["session_settings"].is_a?(Array) && !raw["session_settings"].empty?
|
|
193
|
+
rescue StandardError
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def scalar_map?(values)
|
|
198
|
+
values.is_a?(Hash) && values.values.all? { |value| SCALARS.any? { |kind| value.is_a?(kind) } }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def takers(bundles)
|
|
202
|
+
names = bundles.reject { |_, keys| keys.empty? }.keys.sort
|
|
203
|
+
names.empty? ? "no installed bundle takes --set" : "bundles that take --set: #{names.join(", ")}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def deep_freeze(settings)
|
|
207
|
+
settings.to_h { |bundle, values| [bundle.dup.freeze, values.to_h { |k, v| [k.dup.freeze, v.frozen? ? v : v.dup.freeze] }.freeze] }.freeze
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private_class_method :unloadable, :shipped_settings?, :scalar_map?, :takers, :deep_freeze
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cost-cap
|
|
3
|
-
version: 0.1.
|
|
3
|
+
version: 0.1.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:
|
|
9
|
+
sha256: sha256:2ea7f76af1ea1c1b0a9a8b0b4b11d6e85a4a816fae224ef5b220cfd20fb48f42
|
|
10
10
|
# ctx.cost is new in chi 0.83.0
|
|
11
11
|
requires_chi: ">= 0.83.0"
|
|
12
|
+
# chi --set cost-cap.cap=3: a session's own cap (ctx.settings["cap"])
|
|
13
|
+
session_settings: [cap]
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
#
|
|
11
11
|
# Settings (config.yml, bundles: cost-cap:), read at each use from
|
|
12
12
|
# ctx.settings, never at load:
|
|
13
|
+
# cap: 3.00 this session's own cap, set with chi --set cost-cap.cap=3
|
|
14
|
+
# (session_settings: in the manifest); 0/off: none
|
|
13
15
|
# default: 5.00 USD cap for a session with none set; absent/0/off: none
|
|
14
16
|
# child: 2.00 for delegate children (ctx.delegate?); absent: default
|
|
15
17
|
# warn_at: 0.8 the share of the cap that warns; 0/off: no warning
|
|
16
18
|
#
|
|
17
19
|
# State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
|
|
18
20
|
# written by /cap and by Continue; "cap": null (or false) is off for the
|
|
19
|
-
# session, over the config. Without a file the cap comes from the settings
|
|
21
|
+
# session, over the config. Without a file the cap comes from the settings:
|
|
22
|
+
# cap:, else child: for a delegate, else default:.
|
|
20
23
|
# The cap is resolved once per session id (ctx.delegate? parses the session
|
|
21
24
|
# file) and kept until /cap or Continue writes a new one.
|
|
22
25
|
require "json"
|
|
@@ -164,8 +167,9 @@ class Plugin
|
|
|
164
167
|
|
|
165
168
|
def breakdown(cost) = "#{money(cost.total)} (#{parts(cost)})"
|
|
166
169
|
|
|
167
|
-
# The session's cap and step now: the state file's, else
|
|
168
|
-
# delegate, else default:. Cached per
|
|
170
|
+
# The session's cap and step now: the state file's, else the session's
|
|
171
|
+
# own cap: (--set), else child: for a delegate, else default:. Cached per
|
|
172
|
+
# session id.
|
|
169
173
|
def resolve(ctx)
|
|
170
174
|
session = ctx.session_id
|
|
171
175
|
cached = @mutex.synchronize { @resolved }
|
|
@@ -189,9 +193,12 @@ class Plugin
|
|
|
189
193
|
|
|
190
194
|
def configured(ctx)
|
|
191
195
|
settings = ctx.settings
|
|
192
|
-
return { cap: nil, step: nil } unless
|
|
196
|
+
return { cap: nil, step: nil } unless %w[cap default child].any? { |key| settings.key?(key) }
|
|
193
197
|
|
|
194
|
-
key = settings.key?("
|
|
198
|
+
key = if settings.key?("cap") then "cap"
|
|
199
|
+
elsif settings.key?("child") && ctx.delegate? then "child"
|
|
200
|
+
else "default"
|
|
201
|
+
end
|
|
195
202
|
cap = self.class.amount(settings[key])
|
|
196
203
|
{ cap: cap, step: cap }
|
|
197
204
|
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -762,7 +762,7 @@ module Samagotchi
|
|
|
762
762
|
# Event types #announce accepts: facts about the session's input queue,
|
|
763
763
|
# a failed turn's prompt handed back and the continue offer, which live
|
|
764
764
|
# UIs need in the event log, emitted outside any turn's stream.
|
|
765
|
-
ANNOUNCEABLE_EVENTS = %i[turn_enqueued input_merged prompt_restored continue_offered continue_resolved
|
|
765
|
+
ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
|
|
766
766
|
command_queued command_ran context_added card hook_notice guardrail_warning
|
|
767
767
|
plugin_init_started plugin_init_finished].freeze
|
|
768
768
|
|
|
@@ -372,6 +372,11 @@ module Samagotchi
|
|
|
372
372
|
# Ends with its :generation_completed (and, unless cut, the
|
|
373
373
|
# after_generation hook).
|
|
374
374
|
def generate(turn, request)
|
|
375
|
+
# Before the generation starts: a hook that waits (a question) isn't
|
|
376
|
+
# the generation's time, and a Stop there leaves none started.
|
|
377
|
+
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
378
|
+
raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller&.cancelled?
|
|
379
|
+
|
|
375
380
|
# Each ask splits its own stream (run_generation rebuilds the
|
|
376
381
|
# splitter), so one that ended inside a thought or a tool call doesn't
|
|
377
382
|
# leave the next one "inside" it.
|
|
@@ -383,7 +388,6 @@ module Samagotchi
|
|
|
383
388
|
# streamed, for the log (a stuck thinking generation shows as
|
|
384
389
|
# thinking_chars=N content_length=…).
|
|
385
390
|
generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
|
|
386
|
-
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
387
391
|
buffer_mark = generation.buffer_mark = turn.buffer.length
|
|
388
392
|
# A cancel salvages only this generation's thinking: the earlier
|
|
389
393
|
# steps' is on their own messages already.
|
|
@@ -655,10 +655,14 @@ module Samagotchi
|
|
|
655
655
|
@loop.apply_llm_context!(@conversation, context: @context, emit: method(:emit))
|
|
656
656
|
window = @window = @loop.context_window(@model_name)
|
|
657
657
|
observe_context(iteration, window)
|
|
658
|
+
# Before the generation starts: a hook that waits (a question)
|
|
659
|
+
# isn't the generation's time, and a Stop there leaves none started.
|
|
660
|
+
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
661
|
+
return [@cancel_controller.reason, "", ""] if @cancel_controller&.cancelled?
|
|
662
|
+
|
|
658
663
|
retry_generation = @empty_retry.take_sampling!
|
|
659
664
|
emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
|
|
660
665
|
context_window_source: window&.source, price: @loop.price&.to_h)
|
|
661
|
-
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
662
666
|
streamed = +""
|
|
663
667
|
thought = +""
|
|
664
668
|
generation_controller = nil
|
|
@@ -16,6 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# One line each, with a few of their own fields (never text).
|
|
17
17
|
ANNOUNCED = {
|
|
18
18
|
turn_enqueued: %i[enqueued_id client_id],
|
|
19
|
+
turn_dequeued: %i[enqueued_id client_id],
|
|
19
20
|
command_queued: %i[command_id client_id],
|
|
20
21
|
command_ran: %i[command_id client_id status],
|
|
21
22
|
input_merged: %i[count],
|
|
@@ -202,7 +202,7 @@ module Samagotchi
|
|
|
202
202
|
parts.scripts.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
|
|
203
203
|
end
|
|
204
204
|
|
|
205
|
-
# manifest.yml for the staged bundle: requires_chi, needs and the
|
|
205
|
+
# manifest.yml for the staged bundle: requires_chi, needs, session_settings and the
|
|
206
206
|
# file descriptions come from the installed bundle's record, the
|
|
207
207
|
# trust_level from the CLI else the record.
|
|
208
208
|
def write_manifest(staging, name, version, files_map, parts)
|
|
@@ -223,7 +223,8 @@ module Samagotchi
|
|
|
223
223
|
needs: record&.needs,
|
|
224
224
|
scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
225
225
|
context_providers: parts.context_providers,
|
|
226
|
-
file_descriptions: file_descriptions(record, files_map)
|
|
226
|
+
file_descriptions: file_descriptions(record, files_map),
|
|
227
|
+
session_settings: record&.session_settings || []
|
|
227
228
|
)
|
|
228
229
|
end
|
|
229
230
|
|
|
@@ -20,13 +20,15 @@ module Samagotchi
|
|
|
20
20
|
# the lists as recorded (nil when absent): Manifest.parse_needs
|
|
21
21
|
# and ContextProviders.parse_list check them
|
|
22
22
|
# includes a profile's recorded members, nil for a plain bundle
|
|
23
|
+
# session_settings
|
|
24
|
+
# the keys a session may set (chi --set), [] when none
|
|
23
25
|
# error why manifest.json couldn't be read (the rest empty), else nil
|
|
24
26
|
class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
|
|
25
27
|
:requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
|
|
26
|
-
:includes, :context_providers, :error)
|
|
28
|
+
:includes, :context_providers, :session_settings, :error)
|
|
27
29
|
OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
|
|
28
30
|
requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
|
|
29
|
-
includes: nil, context_providers: nil, error: nil }.freeze
|
|
31
|
+
includes: nil, context_providers: nil, session_settings: [], error: nil }.freeze
|
|
30
32
|
|
|
31
33
|
def initialize(name:, **fields)
|
|
32
34
|
super(name: name, **OPTIONAL, **fields)
|
|
@@ -57,7 +59,8 @@ module Samagotchi
|
|
|
57
59
|
guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
|
|
58
60
|
scripts: mapping(h["scripts"]) { |meta| sha(meta) },
|
|
59
61
|
plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
|
|
60
|
-
includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil
|
|
62
|
+
includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
|
|
63
|
+
session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [])
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def self.string(value) = value&.to_s
|
|
@@ -420,7 +420,8 @@ module Samagotchi
|
|
|
420
420
|
conflicts: @conflicts.keys,
|
|
421
421
|
scripts_files: installed.script_files,
|
|
422
422
|
context_providers: @manifest.context_providers,
|
|
423
|
-
file_descriptions: @manifest.file_descriptions
|
|
423
|
+
file_descriptions: @manifest.file_descriptions,
|
|
424
|
+
session_settings: @manifest.session_settings
|
|
424
425
|
)
|
|
425
426
|
end
|
|
426
427
|
|
|
@@ -41,6 +41,9 @@ module Samagotchi
|
|
|
41
41
|
# cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
|
|
42
42
|
# why: GitHub PR # optional
|
|
43
43
|
# every_seconds: 300 # optional
|
|
44
|
+
# session_settings: [cap] # optional — the settings a session may set
|
|
45
|
+
# # (chi --set cost-cap.cap=3), read by the
|
|
46
|
+
# # plugin from ctx.settings at call time
|
|
44
47
|
# includes: [a, b] # optional — a profile (meta bundle): the
|
|
45
48
|
# # shipped bundles it installs; its dir holds
|
|
46
49
|
# # only manifest.yml (MemoryBundle::Profile)
|
|
@@ -60,7 +63,7 @@ module Samagotchi
|
|
|
60
63
|
# the description a mapping gave, for the files that have one; hooks:
|
|
61
64
|
# name → BundleHook.
|
|
62
65
|
attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
|
|
63
|
-
:scripts, :context_providers
|
|
66
|
+
:scripts, :context_providers, :session_settings
|
|
64
67
|
|
|
65
68
|
def initialize(path:)
|
|
66
69
|
@path = path
|
|
@@ -84,6 +87,7 @@ module Samagotchi
|
|
|
84
87
|
@scripts = parse_scripts(raw["scripts"])
|
|
85
88
|
@context_providers = parse_context_providers(raw["context_providers"])
|
|
86
89
|
@includes = parse_includes(raw["includes"])
|
|
90
|
+
@session_settings = self.class.parse_session_settings(raw["session_settings"])
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
# A profile: it installs the bundles in includes instead of files.
|
|
@@ -136,7 +140,8 @@ module Samagotchi
|
|
|
136
140
|
# Writes a fresh manifest.yml from computed checksums at `dir`.
|
|
137
141
|
# +file_descriptions+ (name → text) writes those files as mappings.
|
|
138
142
|
def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
|
|
139
|
-
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {}
|
|
143
|
+
plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
|
|
144
|
+
session_settings: [])
|
|
140
145
|
FileUtils.mkdir_p(dir)
|
|
141
146
|
manifest = {
|
|
142
147
|
"name" => name,
|
|
@@ -159,9 +164,26 @@ module Samagotchi
|
|
|
159
164
|
manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
|
|
160
165
|
manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
|
|
161
166
|
manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
167
|
+
manifest["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
162
168
|
File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
|
|
163
169
|
end
|
|
164
170
|
|
|
171
|
+
# A setting key a session may set (BundleSessionSettings::KEY).
|
|
172
|
+
SESSION_SETTING = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
|
|
173
|
+
|
|
174
|
+
# session_settings: → the keys, [] when absent; anything but a list of
|
|
175
|
+
# plain key names is a ValidationError.
|
|
176
|
+
def self.parse_session_settings(raw)
|
|
177
|
+
return [] if raw.nil?
|
|
178
|
+
|
|
179
|
+
keys = raw.is_a?(Array) ? raw : nil
|
|
180
|
+
unless keys&.all? { |key| (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(SESSION_SETTING) }
|
|
181
|
+
raise ValidationError, "session_settings: a list of setting names (#{raw.inspect})"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
keys.map(&:to_s).uniq
|
|
185
|
+
end
|
|
186
|
+
|
|
165
187
|
# needs: → [{command:, why:, hint:}] (why/hint a String or nil), [] when
|
|
166
188
|
# absent. Takes the manifest's string-keyed YAML or the symbol-keyed
|
|
167
189
|
# form provenance stores. Duplicates merge (the first one's why/hint
|
|
@@ -141,7 +141,7 @@ module Samagotchi
|
|
|
141
141
|
# @param context_providers [Array<ContextProviders::Provider>]
|
|
142
142
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
143
143
|
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
|
|
144
|
-
scripts_files: {}, context_providers: [], file_descriptions: {})
|
|
144
|
+
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [])
|
|
145
145
|
FileUtils.mkdir_p(@bundle_dir)
|
|
146
146
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
147
147
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -239,6 +239,7 @@ module Samagotchi
|
|
|
239
239
|
end
|
|
240
240
|
end
|
|
241
241
|
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
242
|
+
manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
242
243
|
|
|
243
244
|
write_manifest(manifest_data)
|
|
244
245
|
end
|
|
@@ -34,8 +34,9 @@ module Samagotchi
|
|
|
34
34
|
# @param result [ReplyWait::Result]
|
|
35
35
|
# @return [String] answered, question, failed, canceled, limit (the
|
|
36
36
|
# turn ran out of iterations, nobody asked), not_continued (a Stop
|
|
37
|
-
# answered the step-limit question),
|
|
38
|
-
#
|
|
37
|
+
# answered the step-limit question), removed (the queued message was
|
|
38
|
+
# taken back before it ran), no_answer, error, worker_gone, stopped
|
|
39
|
+
# or running
|
|
39
40
|
def status(result)
|
|
40
41
|
case result.status
|
|
41
42
|
when :done then "answered"
|
|
@@ -46,6 +47,7 @@ module Samagotchi
|
|
|
46
47
|
when "canceled" then "canceled"
|
|
47
48
|
when "exhausted" then "limit"
|
|
48
49
|
when "not_continued" then "not_continued"
|
|
50
|
+
when "removed" then "removed"
|
|
49
51
|
else "no_answer"
|
|
50
52
|
end
|
|
51
53
|
when :error then "error"
|
|
@@ -258,6 +260,7 @@ module Samagotchi
|
|
|
258
260
|
when "canceled" then "the turn was canceled"
|
|
259
261
|
when "completed" then "the turn ended with no visible answer"
|
|
260
262
|
when "not_continued" then "the turn was not continued (Stop); its work so far stays"
|
|
263
|
+
when "removed" then "the queued message was removed before it ran; no turn ran for it"
|
|
261
264
|
when "exhausted"
|
|
262
265
|
"the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered; " \
|
|
263
266
|
"chi send #{session_id} -m '/continue yes' continues it, a message drops it"
|
|
@@ -97,8 +97,25 @@ module Samagotchi
|
|
|
97
97
|
# @return [String] the bundle the plugin came with
|
|
98
98
|
attr_reader :bundle
|
|
99
99
|
|
|
100
|
-
#
|
|
101
|
-
|
|
100
|
+
# config.yml `bundles: <bundle>:` with the session's own values for
|
|
101
|
+
# the bundle laid over it (chi --set BUNDLE.KEY=VALUE, through the
|
|
102
|
+
# Host's setup: SessionSetup#bundle_settings), string keys, frozen.
|
|
103
|
+
# Plugin init and chi.ctx run before the session is bound: with no
|
|
104
|
+
# session id yet it is the config alone, and it is built again once
|
|
105
|
+
# per session id (the session's values never change after it starts).
|
|
106
|
+
# A plugin's initialize(settings) gets the config only: a value a
|
|
107
|
+
# session may set is read here, at call time.
|
|
108
|
+
# @return [Hash]
|
|
109
|
+
def settings
|
|
110
|
+
id = session_id
|
|
111
|
+
cached = @session_settings
|
|
112
|
+
return cached.last if cached && cached.first == id
|
|
113
|
+
|
|
114
|
+
values = session_values(id) if id
|
|
115
|
+
merged = id ? overlay(@settings, values || {}) : @settings
|
|
116
|
+
@session_settings = [id, merged].freeze unless id && values.nil?
|
|
117
|
+
merged
|
|
118
|
+
end
|
|
102
119
|
|
|
103
120
|
# @return [Logger]
|
|
104
121
|
attr_reader :log
|
|
@@ -419,6 +436,23 @@ module Samagotchi
|
|
|
419
436
|
nil
|
|
420
437
|
end
|
|
421
438
|
|
|
439
|
+
# The session's own values for this bundle ({} without any; nil when
|
|
440
|
+
# the Host couldn't tell them, so #settings can retry).
|
|
441
|
+
def session_values(id)
|
|
442
|
+
values = @host.setup&.call&.bundle_settings
|
|
443
|
+
values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
|
|
444
|
+
rescue StandardError => e
|
|
445
|
+
unless (@session_settings_unread ||= {}).key?(id)
|
|
446
|
+
@session_settings_unread[id] = true
|
|
447
|
+
@log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
|
|
448
|
+
end
|
|
449
|
+
nil
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def overlay(config, values)
|
|
453
|
+
values.empty? ? config : deep_freeze(config.merge(values))
|
|
454
|
+
end
|
|
455
|
+
|
|
422
456
|
def deep_freeze(value)
|
|
423
457
|
case value
|
|
424
458
|
when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
|
|
@@ -35,7 +35,9 @@ module Samagotchi
|
|
|
35
35
|
# reply (:no_reply): "failed", "canceled", "completed" (empty),
|
|
36
36
|
# "exhausted" (it ran out of iterations, with nobody asked whether to
|
|
37
37
|
# continue) or "not_continued" (a Stop answered the step-limit
|
|
38
|
-
# question; no turn ran), from the session's last_turn
|
|
38
|
+
# question; no turn ran), from the session's last_turn, or "removed"
|
|
39
|
+
# (turn:'s queued input was taken back before it ran, its tombstone:
|
|
40
|
+
# SessionInbox.removed?; no turn ran); nil when unknown. With
|
|
39
41
|
# "failed", text is the failure's summary when the turn note has one.
|
|
40
42
|
# @!attribute limit [Integer, nil] the iteration limit an "exhausted"
|
|
41
43
|
# turn ran out at
|
|
@@ -132,6 +134,11 @@ module Samagotchi
|
|
|
132
134
|
claimed = waiting.nil?
|
|
133
135
|
end
|
|
134
136
|
session = Session.load(id, state_dir: state_dir)
|
|
137
|
+
# Taken back before it ran (POST turn/dequeue): no turn will come.
|
|
138
|
+
if turn && claimed && !seen_own && SessionInbox.removed?(session_dir, turn)
|
|
139
|
+
return Result.new(status: :no_reply, outcome: "removed", session: session)
|
|
140
|
+
end
|
|
141
|
+
|
|
135
142
|
if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
|
|
136
143
|
seen_own = true
|
|
137
144
|
own_base = session.last_turn&.ended_at
|