samagotchi 0.141.0 → 0.142.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +12 -283
  6. data/docs/bundle-authoring.md +1 -0
  7. data/docs/cli.md +15 -7
  8. data/docs/configuration.md +57 -18
  9. data/docs/context.md +2 -2
  10. data/docs/guardrails.md +20 -11
  11. data/docs/hooks.md +9 -7
  12. data/docs/internals/attached-context.md +226 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bridge.md +380 -0
  15. data/docs/internals/bundles.md +1 -0
  16. data/docs/internals/context-telemetry.md +1 -1
  17. data/docs/internals/engine.md +24 -18
  18. data/docs/internals/participants.md +13 -12
  19. data/docs/internals/remote-sessions.md +1 -1
  20. data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
  21. data/docs/memory.md +31 -1
  22. data/docs/plugins.md +122 -1
  23. data/docs/sessions.md +50 -26
  24. data/docs/system-one.md +122 -0
  25. data/docs/testing.md +3 -1
  26. data/lib/samagotchi/bridge.rb +2 -0
  27. data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
  28. data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/config.rb +74 -14
  35. data/lib/samagotchi/context_candidate_choice.rb +8 -0
  36. data/lib/samagotchi/context_candidates_log.rb +25 -1
  37. data/lib/samagotchi/context_judge.rb +6 -2
  38. data/lib/samagotchi/engine.rb +68 -7
  39. data/lib/samagotchi/extension_load.rb +3 -2
  40. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  41. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  42. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  43. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  44. data/lib/samagotchi/guardrails_command.rb +3 -3
  45. data/lib/samagotchi/judgments_command.rb +155 -0
  46. data/lib/samagotchi/kernel_loop.rb +13 -1
  47. data/lib/samagotchi/llm/chat_loop.rb +74 -10
  48. data/lib/samagotchi/llm/errors.rb +7 -5
  49. data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
  50. data/lib/samagotchi/log_line.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  52. data/lib/samagotchi/plugin/api.rb +33 -2
  53. data/lib/samagotchi/plugin/context.rb +49 -1
  54. data/lib/samagotchi/plugin/judgments.rb +72 -0
  55. data/lib/samagotchi/plugin/loader.rb +3 -2
  56. data/lib/samagotchi/run_options.rb +1 -0
  57. data/lib/samagotchi/self_report.rb +36 -0
  58. data/lib/samagotchi/session_access/contract.rb +1 -1
  59. data/lib/samagotchi/session_commands.rb +3 -0
  60. data/lib/samagotchi/session_manager.rb +13 -2
  61. data/lib/samagotchi/session_retention.rb +10 -0
  62. data/lib/samagotchi/side_judgments.rb +12 -1
  63. data/lib/samagotchi/system_one/answer.rb +63 -11
  64. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  65. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  66. data/lib/samagotchi/system_one/client.rb +43 -17
  67. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  68. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  69. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  70. data/lib/samagotchi/system_one/judgments.rb +260 -0
  71. data/lib/samagotchi/system_one/log.rb +4 -2
  72. data/lib/samagotchi/system_one/recording.rb +117 -0
  73. data/lib/samagotchi/system_one/runs.rb +179 -0
  74. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  75. data/lib/samagotchi/system_one.rb +9 -0
  76. data/lib/samagotchi/testing/bundle.rb +9 -2
  77. data/lib/samagotchi/testing/host.rb +2 -1
  78. data/lib/samagotchi/testing/judgments.rb +185 -0
  79. data/lib/samagotchi/testing/recorder.rb +5 -0
  80. data/lib/samagotchi/testing/recording_api.rb +1 -1
  81. data/lib/samagotchi/tool_output_cut.rb +26 -6
  82. data/lib/samagotchi/tool_response.rb +1 -1
  83. data/lib/samagotchi/tool_runner.rb +63 -4
  84. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  85. data/lib/samagotchi/tools/memory.rb +28 -0
  86. data/lib/samagotchi/turn_note.rb +9 -0
  87. data/lib/samagotchi/version.rb +1 -1
  88. metadata +16 -2
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../log"
5
+ require_relative "../llm/errors"
6
+ require_relative "../side_judgments"
7
+ require_relative "judgment_settings"
8
+ require_relative "question"
9
+ require_relative "recording"
10
+ require_relative "runs"
11
+
12
+ module Samagotchi
13
+ module SystemOne
14
+ # Bundles' judgments (chi.judgment, ctx.judge, ctx.judge_later): what the
15
+ # user set for each, then the same Client and Recording core's uses ask
16
+ # through. It adds no power: a bundle's run is scoped to its name, and off
17
+ # until the user sets a mode. Core's uses stay outside (their threads,
18
+ # gates and contention are their own) and can't be set here (D2,
19
+ # JudgmentSettings).
20
+ class Judgments
21
+ # What chi.judgment declared. +questions+ is an Array of Question, or a
22
+ # callable given the ctx, returning what normalize_questions takes.
23
+ Declaration = Data.define(:name, :questions, :positive, :negative, :shared_host) do
24
+ # @raise [ArgumentError] anything the declaration can't be
25
+ def self.build(bundle:, name:, questions:, positive: nil, negative: nil, shared_host: :skip)
26
+ full = "#{bundle}.#{name}"
27
+ # The first word is the bundle: "a.b" + "x" and "a" + "b.x" must not meet.
28
+ raise ArgumentError, "judgment #{full.inspect}: a bundle with a dot in its name can't declare one" if bundle.to_s.include?(".")
29
+
30
+ unless Runs.declarable?(full)
31
+ raise ArgumentError, "judgment #{full.inspect}: a full name is dot-separated words of a-z, 0-9, _ and -, " \
32
+ "with no first word of #{Runs::RESERVED.join(", ")} and no #{Runs::RESERVED_SUFFIX} at its end"
33
+ end
34
+ unless SHARED_HOST.include?(shared_host)
35
+ raise ArgumentError, "judgment #{full}: shared_host: is #{SHARED_HOST.map(&:inspect).join(" or ")}, not #{shared_host.inspect}"
36
+ end
37
+
38
+ new(name: full, questions: questions.respond_to?(:call) ? questions : checked(full, questions),
39
+ positive: Array(positive).map(&:to_s).freeze, negative: Array(negative).map(&:to_s).freeze, shared_host: shared_host)
40
+ end
41
+
42
+ # The questions of an ask now.
43
+ # @return [Array<Question>]
44
+ def questions_for(context)
45
+ questions.respond_to?(:call) ? self.class.checked(name, questions.call(context)) : questions
46
+ end
47
+
48
+ def self.checked(full, questions)
49
+ list = SystemOne.normalize_questions(questions)
50
+ raise ArgumentError, "judgment #{full} has no questions" if list.empty?
51
+
52
+ list.freeze
53
+ end
54
+
55
+ # What each of its runs says of the label values, if anything.
56
+ def run_fields = { positive: positive, negative: negative }.reject { |_key, values| values.empty? }
57
+ end
58
+
59
+ SHARED_HOST = %i[ask skip].freeze
60
+
61
+ # What an ask came to. +reason+ says why there's no answer: :off,
62
+ # :no_host, :no_session, :failed, :cancelled, :shared_host or :busy; nil
63
+ # with answers. +mode+ is the judgment's ("off", "audit", "on"): in audit
64
+ # the bundle looks and doesn't act. +run_id+ is nil when no run is saved.
65
+ Result = Data.define(:answers, :run_id, :mode, :reason) do
66
+ def self.none(reason, mode: "off", run_id: nil) = new(answers: {}.freeze, run_id: run_id, mode: mode, reason: reason)
67
+
68
+ def no_judgment? = answers.empty?
69
+ def audit? = mode == "audit"
70
+
71
+ # An :noul question's probability of yes; nil with no answer, or for
72
+ # another type (its answer is #answers[id]).
73
+ # @param id [String, Symbol, nil] nil: the only question's
74
+ # @return [Float, nil]
75
+ def p(id = nil)
76
+ answer = id.nil? ? (answers.values.first if answers.size == 1) : answers[id.to_s]
77
+ answer&.noul
78
+ end
79
+ end
80
+
81
+ # One ask, made ready on the caller's thread.
82
+ Ask = Data.define(:client, :questions, :state, :run_id, :mode, :shared_host)
83
+
84
+ # The seams are what the Engine knows, read at each ask. They default to
85
+ # nothing, for a chi with no Engine (a bundle's install check): nothing
86
+ # is ever answered there.
87
+ # @param settings [#call] (name) → JudgmentSettings
88
+ # @param client_for [#call] (name, settings) → a Client, nil with no host
89
+ # @param runs [#call] → the Runs to save in
90
+ # @param where [#call] → Recording::Context, nil with no session
91
+ # @param side [SideJudgments]
92
+ # @param shared_host_busy [#call] (host) → whether a turn is running on it
93
+ # @param participant [#call] → whether a participant's turn runs: its
94
+ # text is never saved
95
+ def initialize(settings: JudgmentSettings.method(:of), client_for: ->(_name, _settings) {}, runs: -> { Runs.new }, where: -> {},
96
+ side: SideJudgments.new, shared_host_busy: ->(_host) { false }, participant: -> { false })
97
+ @settings = settings
98
+ @client_for = client_for
99
+ @runs = runs
100
+ @where = where
101
+ @side = side
102
+ @shared_host_busy = shared_host_busy
103
+ @participant = participant
104
+ @declarations = {}
105
+ @mutex = Mutex.new
106
+ end
107
+
108
+ # @param declaration [Declaration]
109
+ def declare(declaration)
110
+ @mutex.synchronize { @declarations[declaration.name] = declaration }
111
+ end
112
+
113
+ def declared?(name) = @mutex.synchronize { @declarations.key?(name.to_s) }
114
+
115
+ # Ask now, on this thread.
116
+ # @param context [Object] what a questions builder gets (the ctx)
117
+ # @return [Result]
118
+ # @raise [ArgumentError] +name+ isn't declared, or +state+ can't be one
119
+ def judge(name, state:, context: nil, cancel_controller: nil)
120
+ ask = prepare(name, state, context)
121
+ return ask if ask.is_a?(Result)
122
+
123
+ shared_host?(ask) ? skipped(ask, :shared_host) : asked(ask, cancel_controller)
124
+ end
125
+
126
+ # Ask on +name+'s side thread, one at a time; the block gets the Result
127
+ # on another thread, always once.
128
+ # @return [String, nil] the run's id, nil when no run will be saved
129
+ # @raise [ArgumentError] as #judge
130
+ def later(name, state:, context: nil, &block)
131
+ ask = prepare(name, state, context)
132
+ return tell(name, block, ask).run_id if ask.is_a?(Result)
133
+ return tell(name, block, skipped(ask, :shared_host)).run_id if shared_host?(ask)
134
+
135
+ started = @side.start(name, warn: warner(name, "judge_later_failed"),
136
+ finish: ->(result) { deliver(name, block, result || Result.none(:failed, mode: ask.mode, run_id: ask.run_id)) }) do
137
+ asked(ask, nil)
138
+ end
139
+ tell(name, block, skipped(ask, :busy)) unless started
140
+ ask.run_id
141
+ end
142
+
143
+ # A later line on a run (Runs#label, #acted); false when it isn't saved.
144
+ # Nothing is saved in a participant's turn, as an ask there saves no run.
145
+ def label(run_id, value, by:, strength: nil, question: nil)
146
+ return false if @participant.call
147
+
148
+ @runs.call.label(run_id, value, by: by, strength: strength, question: question)
149
+ end
150
+
151
+ def acted(run_id, what)
152
+ return false if @participant.call
153
+
154
+ @runs.call.acted(run_id, what)
155
+ end
156
+
157
+ # The system_one.judgments names no plugin declared, once the plugins
158
+ # have loaded: a typo there is a judgment that stays off. Core's names
159
+ # have their own warning (Config.validate_yaml_sections).
160
+ # @return [Array<String>]
161
+ def undeclared
162
+ known = @mutex.synchronize { @declarations.keys }
163
+ (JudgmentSettings.names - known).reject { |name| Config::RESERVED_JUDGMENTS.include?(name.split(".").first) }
164
+ end
165
+
166
+ private
167
+
168
+ # The Ask, or the Result that says why there is none.
169
+ def prepare(name, state, context)
170
+ declaration = @mutex.synchronize { @declarations[name.to_s] }
171
+ raise ArgumentError, "no judgment #{name} declared (chi.judgment)" unless declaration
172
+ unless state.is_a?(String) || state.is_a?(Hash) || state.is_a?(Array)
173
+ raise ArgumentError, "judgment #{name}: state: is a String, a Hash or an Array, not #{state.class}"
174
+ end
175
+
176
+ settings = @settings.call(declaration.name)
177
+ return Result.none(:off) if settings.off?
178
+
179
+ client = client_for(declaration, settings)
180
+ return Result.none(:no_host, mode: settings.mode) if client.nil?
181
+
182
+ where = where_now(declaration)
183
+ return Result.none(:no_session, mode: settings.mode) if where.nil?
184
+
185
+ ready(declaration, settings, client, where, state, context)
186
+ end
187
+
188
+ def where_now(declaration)
189
+ @where.call
190
+ rescue StandardError => e
191
+ warner(declaration.name, "judgment_session_unread").call(e)
192
+ nil
193
+ end
194
+
195
+ def ready(declaration, settings, client, where, state, context)
196
+ runs = @participant.call ? Runs.new : @runs.call
197
+ Ask.new(client: Recording.new(client, judgment: declaration.name, runs: runs, context: where, fields: declaration.run_fields),
198
+ questions: declaration.questions_for(context), state: copy_of(declaration, state),
199
+ run_id: runs.on? ? Runs.new_id(declaration.name) : nil, mode: settings.mode, shared_host: declaration.shared_host)
200
+ end
201
+
202
+ # A state nested too deep to copy (a cycle) is a bad state: a
203
+ # SystemStackError would go past a hook's rescue and end the turn.
204
+ def copy_of(declaration, state)
205
+ Runs.scrubbed(state)
206
+ rescue SystemStackError
207
+ raise ArgumentError, "judgment #{declaration.name}: state: is nested too deep"
208
+ end
209
+
210
+ def client_for(declaration, settings)
211
+ @client_for.call(declaration.name, settings)
212
+ rescue StandardError => e
213
+ warner(declaration.name, "judgment_client_failed").call(e)
214
+ nil
215
+ end
216
+
217
+ # Whether this ask would evict a running turn's cached prompt, and its
218
+ # bundle said to skip then. A check that fails counts as yes: no ask.
219
+ def shared_host?(ask)
220
+ ask.shared_host == :skip && @shared_host_busy.call(ask.client.host)
221
+ rescue StandardError => e
222
+ Samagotchi::Log.warn(:plugins, "judgment_shared_host_check_failed", error: e.class.name)
223
+ true
224
+ end
225
+
226
+ def skipped(ask, reason)
227
+ ask.client.skip(state: ask.state, questions: ask.questions, reason: reason.to_s, run_id: ask.run_id)
228
+ Result.none(reason, mode: ask.mode, run_id: ask.run_id)
229
+ end
230
+
231
+ def asked(ask, cancel_controller)
232
+ answers = ask.client.ask(state: ask.state, questions: ask.questions, cancel_controller: cancel_controller, run_id: ask.run_id)
233
+ return Result.none(:failed, mode: ask.mode, run_id: ask.run_id) if answers.nil?
234
+
235
+ Result.new(answers: answers.freeze, run_id: ask.run_id, mode: ask.mode, reason: nil)
236
+ rescue LLM::RequestCancelled
237
+ Result.none(:cancelled, mode: ask.mode, run_id: ask.run_id)
238
+ end
239
+
240
+ # The block gets +result+ on another thread, as an asked one does.
241
+ # @return [Result] +result+
242
+ def tell(name, block, result)
243
+ @side.aside(warn: warner(name, "judge_later_failed")) { deliver(name, block, result) } if block
244
+ result
245
+ end
246
+
247
+ def deliver(name, block, result)
248
+ block&.call(result)
249
+ rescue StandardError => e
250
+ warner(name, "judge_later_block_failed").call(e)
251
+ end
252
+
253
+ def warner(name, event)
254
+ lambda { |error|
255
+ Samagotchi::Log.warn(:plugins, event, judgment: name, error: "#{error.class}: #{error.message}"[0, 300])
256
+ }
257
+ end
258
+ end
259
+ end
260
+ end
@@ -48,10 +48,12 @@ module Samagotchi
48
48
  append(line)
49
49
  end
50
50
 
51
- # @param fields [Hash] the judgment's own fields (rule, tool, p, …)
51
+ # @param fields [Hash] the judgment's own fields (rule, tool, p, …); its
52
+ # :id if the caller has one (a saved run's), else a new one
52
53
  # @return [String, nil] the judgment's id, nil when nothing was written
53
54
  def judgment(fields)
54
- id = SecureRandom.uuid
55
+ fields = fields.dup
56
+ id = fields.delete(:id) || SecureRandom.uuid
55
57
  entry("judgment", { "id" => id }.merge(fields)) ? id : nil
56
58
  end
57
59
 
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../llm/errors"
4
+ require_relative "../log"
5
+ require_relative "question"
6
+ require_relative "runs"
7
+
8
+ module Samagotchi
9
+ module SystemOne
10
+ # A Client that saves each ask as a run (Runs): the same duck type, the
11
+ # same answers and the same raises. It sits where a use builds its
12
+ # client, so gates, threads and domain logs stay the use's own. A run
13
+ # that can't be saved is one warn and never changes the answer.
14
+ #
15
+ # client = Recording.wrap(Client.from_config(...), judgment: "approvals.allow", runs: runs, context: context)
16
+ # client&.ask(state:, questions:, cancel_controller:)
17
+ class Recording
18
+ # What a run says about its session, taken on the caller's thread:
19
+ # the ask itself may run on its own. +chain_link+ is the parent or the
20
+ # session this one continues (a chain is derived, SessionChain, and
21
+ # following it costs a listing), +session_model+ the session's model.
22
+ Context = Data.define(:session_id, :chain_link, :session_model, :test_run) do
23
+ def initialize(session_id: nil, chain_link: nil, session_model: nil, test_run: nil) = super
24
+
25
+ def to_h = super.transform_keys(&:to_s)
26
+
27
+ def self.of(session, session_model: nil)
28
+ new(session_id: session&.id, chain_link: session&.parent_id || session&.continues,
29
+ session_model: session_model, test_run: session&.test_run ? true : nil)
30
+ end
31
+ end
32
+
33
+ # The wrapped client, or nil for none: the caller's nil check stays.
34
+ # @param recorded_mode [String, nil] the use's mode as a run says it,
35
+ # where the client's isn't it (toolkit and context build theirs "on"
36
+ # in audit too); nil: the client's
37
+ def self.wrap(client, judgment:, runs:, context: Context.new, recorded_mode: nil)
38
+ client && new(client, judgment: judgment, runs: runs, context: context, recorded_mode: recorded_mode)
39
+ end
40
+
41
+ # @param fields [Hash] more for each of its runs (a bundle's judgment's
42
+ # positive: and negative: label values)
43
+ def initialize(client, judgment:, runs:, context: Context.new, recorded_mode: nil, fields: {})
44
+ @client = client
45
+ @judgment = judgment
46
+ @runs = runs
47
+ @context = context
48
+ @recorded_mode = recorded_mode
49
+ @fields = fields
50
+ end
51
+
52
+ def host = @client.host
53
+ def model = @client.model
54
+ def mode = @client.mode
55
+ def backend = @client.backend
56
+
57
+ # Client#ask, recorded. A cancelled ask or one that raises records its
58
+ # run, then raises as before.
59
+ # @param run_id [String, nil] the run's id when the caller needs it at
60
+ # once (it is passed in, not read back: one client serves several
61
+ # threads); nil: a new one
62
+ def ask(state:, questions:, cancel_controller: nil, run_id: nil)
63
+ facts = {}
64
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ answers = @client.ask(state: state, questions: questions, cancel_controller: cancel_controller,
66
+ observer: ->(**told) { facts.merge!(told) })
67
+ record(run_id, state, questions, answers, facts, started)
68
+ answers
69
+ rescue LLM::RequestCancelled
70
+ record(run_id, state, questions, nil, facts.merge(failure: "cancelled"), started)
71
+ raise
72
+ rescue StandardError => e
73
+ record(run_id, state, questions, nil, facts.merge(failure: e.class.name, failure_text: e.message), started)
74
+ raise
75
+ end
76
+
77
+ # An ask that wasn't made: its run, at once, asked: false, so a later
78
+ # replay can answer the state with no eviction today (docs/system-one.md).
79
+ # @param reason [String] why: the run's +skipped+
80
+ def skip(state:, questions:, reason:, run_id: nil)
81
+ save(questions) do
82
+ { id: run_id || Runs.new_id(@judgment), state: state, asked: false, skipped: reason, model: said(:model) }
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def record(run_id, state, questions, answers, facts, started)
89
+ save(questions) do
90
+ { id: run_id || Runs.new_id(@judgment), state: state, asked: true, model: facts[:model] || said(:model),
91
+ served_model: facts[:served_model], answers: facts[:raw] || answers&.transform_values(&:to_h), usage: facts[:usage],
92
+ failure: facts[:failure], failure_text: facts[:failure_text]&.to_s&.slice(0, 500),
93
+ latency_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round }
94
+ end
95
+ end
96
+
97
+ # One run: the block's fields, and what every run says.
98
+ def save(questions)
99
+ return unless @runs.on?
100
+
101
+ questions = SystemOne.normalize_questions(questions)
102
+ return if questions.empty? # Client#ask asked nothing.
103
+
104
+ @runs.record(judgment: @judgment, questions: questions, **@context.to_h.transform_keys(&:to_sym), **@fields,
105
+ origin: "live", mode: @recorded_mode || said(:mode), host: said(:host), **yield)
106
+ rescue StandardError, SystemStackError => e # a state nested too deep to copy
107
+ # Qualified: inside SystemOne, the bare Log is SystemOne::Log.
108
+ Samagotchi::Log.warn(:system_one, "system_one_run_failed",
109
+ echo: "[samagotchi:system_one] a run wasn't saved: #{e.class}: #{e.message}",
110
+ judgment: @judgment, error: e.class.name)
111
+ end
112
+
113
+ # What the client says about itself; a stand-in may say nothing.
114
+ def said(name) = @client.respond_to?(name) ? @client.public_send(name) : nil
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ require_relative "../config"
9
+ require_relative "../log"
10
+ require_relative "question"
11
+ require_relative "runs_store"
12
+
13
+ module Samagotchi
14
+ module SystemOne
15
+ # The saved asks: one run per System 1 ask (Recording), what was sent and
16
+ # answered, and later lines on it, for a later comparison or fine-tune
17
+ # (docs/system-one.md). A Runs without a store saves nothing. Local only.
18
+ #
19
+ # A run's id is "<judgment>/<uuid>": it carries its judgment, so a later
20
+ # line can find its way with the id alone. Later lines never hold up the
21
+ # caller: a failed one is one warn and a false.
22
+ class Runs
23
+ # What the specs read: lines in memory, by judgment.
24
+ class MemoryStore
25
+ attr_reader :lines, :versions
26
+
27
+ def initialize
28
+ @lines = Hash.new { |hash, judgment| hash[judgment] = [] }
29
+ @versions = Hash.new { |hash, judgment| hash[judgment] = [] }
30
+ @mutex = Mutex.new
31
+ end
32
+
33
+ def append(judgment, line)
34
+ @mutex.synchronize { @lines[judgment] << line }
35
+ true
36
+ end
37
+
38
+ def version(judgment, line)
39
+ @mutex.synchronize do
40
+ @versions[judgment] << line unless @versions[judgment].include?(line)
41
+ end
42
+ true
43
+ end
44
+ end
45
+
46
+ # Names core keeps (a first word, as a bundle's scope would be) and the
47
+ # suffix a later replay's runs use: a bundle's judgment takes none.
48
+ RESERVED = Config::RESERVED_JUDGMENTS
49
+ RESERVED_SUFFIX = ".replay"
50
+ # The total the sweep keeps (docs/system-one.md).
51
+ MAX_BYTES = 100 * 1024 * 1024
52
+
53
+ # Runs on the state dir +root+ (where the sessions folder sits), saving
54
+ # unless system_one.runs is off.
55
+ def self.in(root)
56
+ new(store: Config.get("system_one.runs").to_s == "off" ? nil : RunsStore.new(root))
57
+ end
58
+
59
+ # .in, from the sessions folder.
60
+ def self.for_sessions_dir(sessions_dir) = self.in(File.dirname(sessions_dir))
61
+
62
+ # The session sweep: runs past session.retention_days (0: forever), then
63
+ # the oldest over the total cap. Whatever system_one.runs says now.
64
+ # @return [Integer] the day files deleted
65
+ def self.sweep_at(root, days: Config.get("session.retention_days").to_i, max_bytes: MAX_BYTES)
66
+ RunsStore.new(root).sweep(days: days, max_bytes: max_bytes)
67
+ end
68
+
69
+ # Explicit `chi sessions delete`: that session's runs and their later
70
+ # lines go, whatever system_one.runs says now.
71
+ def self.purge_session(session_id, sessions_dir:)
72
+ RunsStore.new(File.dirname(sessions_dir)).purge_session(session_id)
73
+ end
74
+
75
+ # Whether a bundle may declare +name+ (safe as a folder, not core's).
76
+ def self.declarable?(name)
77
+ text = name.to_s
78
+ text.match?(RunsStore::NAME) && !RESERVED.include?(text.split(".").first) && !text.end_with?(RESERVED_SUFFIX)
79
+ end
80
+
81
+ # A new run's id.
82
+ def self.new_id(judgment) = "#{judgment}/#{SecureRandom.uuid}"
83
+
84
+ # The judgment a run id carries; nil for anything else (a judgments
85
+ # log's id from before runs, a name that isn't safe as a folder).
86
+ def self.judgment_of(run_id)
87
+ judgment, uuid = run_id.to_s.split("/", 2)
88
+ judgment if judgment.to_s.match?(RunsStore::NAME) && !uuid.to_s.empty?
89
+ end
90
+
91
+ # The digests a run groups by: the questions' wording and, apart, their
92
+ # criteria, which can vary per session (a toolkit's kits).
93
+ # @param questions [Array<Question>]
94
+ # @return [Hash{String => String}]
95
+ def self.digests(questions)
96
+ wording = questions.map { |question| [question.id, question.type.to_s, question.instructions] }
97
+ criteria = questions.map { |question| [question.id, question.criteria] }
98
+ { "instructions_digest" => digest(wording), "criteria_digest" => digest(criteria) }
99
+ end
100
+
101
+ def self.digest(value) = Digest::SHA256.hexdigest(JSON.generate(value))[0, 16]
102
+ private_class_method :digest
103
+
104
+ # A copy +value+ can be written as JSON from: bytes that aren't UTF-8
105
+ # (a command's output in a state) are replaced, in strings and keys.
106
+ def self.scrubbed(value)
107
+ case value
108
+ when String then value.dup.force_encoding(Encoding::UTF_8).scrub("�")
109
+ when Symbol then scrubbed(value.to_s)
110
+ when Hash then value.to_h { |key, item| [scrubbed(key.to_s), scrubbed(item)] }
111
+ when Array then value.map { |item| scrubbed(item) }
112
+ else value
113
+ end
114
+ end
115
+
116
+ # @param store [#append, #version, nil] nil: nothing is saved
117
+ def initialize(store: nil)
118
+ @store = store
119
+ end
120
+
121
+ def on? = !@store.nil?
122
+
123
+ # Save one ask, its questions once per digest pair. Raises what its
124
+ # store raises: Recording holds the one warn.
125
+ # @param id [String] the run's id
126
+ # @param judgment [String] its judgment's name
127
+ # @param questions [Array<Question>] as asked
128
+ # @param fields [Hash] the run's other fields; nil ones are left out
129
+ # @return [Boolean] whether it was saved
130
+ def record(id:, judgment:, questions:, **fields)
131
+ return false unless on?
132
+
133
+ digests = self.class.digests(questions)
134
+ wire = questions.to_h { |question| [question.id, question.to_wire] }
135
+ @store.version(judgment, digests.merge("questions" => self.class.scrubbed(wire)))
136
+ @store.append(judgment, { "kind" => "run", "id" => id, "judgment" => judgment, "at" => now }
137
+ .merge(digests).merge(self.class.scrubbed(fields.compact)))
138
+ end
139
+
140
+ # What the user (or +by+) said about a run.
141
+ # @param value [String] the use's own word
142
+ # @param by [String] who said it
143
+ # @param strength [String, nil] "strong" or "weak", where the use has
144
+ # the difference
145
+ # @param question [String, nil] the question it answers
146
+ # @return [Boolean] whether it was saved
147
+ def label(run_id, value, by:, strength: nil, question: nil)
148
+ later(run_id, "label", value: value, by: by, strength: strength, question: question)
149
+ end
150
+
151
+ # What the use did with the answer: never known when the run is written.
152
+ # @return [Boolean] whether it was saved
153
+ def acted(run_id, what) = later(run_id, "acted", what: what)
154
+
155
+ # What the gates said at ask time, which can't be recomputed later.
156
+ # @return [Boolean] whether it was saved
157
+ def gates(run_id, **fields) = later(run_id, "gates", **fields)
158
+
159
+ private
160
+
161
+ def later(run_id, kind, **fields)
162
+ return false unless on?
163
+
164
+ judgment = self.class.judgment_of(run_id)
165
+ return false if judgment.nil?
166
+
167
+ @store.append(judgment, { "kind" => kind, "run_id" => run_id.to_s, "at" => now }.merge(self.class.scrubbed(fields.compact)))
168
+ rescue StandardError, SystemStackError => e
169
+ # Qualified: inside SystemOne, the bare Log is SystemOne::Log.
170
+ Samagotchi::Log.warn(:system_one, "system_one_run_line_failed",
171
+ echo: "[samagotchi:system_one] a #{kind} line wasn't saved: #{e.class}: #{e.message}",
172
+ judgment: judgment, error: e.class.name)
173
+ false
174
+ end
175
+
176
+ def now = Time.now.utc.iso8601(3)
177
+ end
178
+ end
179
+ end