samagotchi 0.147.0 → 0.149.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +2 -2
  4. data/docs/architecture.md +1 -1
  5. data/docs/bundles.md +64 -17
  6. data/docs/cli.md +18 -0
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +27 -5
  9. data/docs/hooks.md +2 -1
  10. data/docs/internals/objectives.md +264 -0
  11. data/docs/internals/web-frontend.md +4 -1
  12. data/docs/plugins.md +4 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +44 -2
  15. data/docs/system-one.md +18 -2
  16. data/lib/samagotchi/answer_tail.rb +1 -1
  17. data/lib/samagotchi/bundles/coordinator/guardrails/merge.yml +21 -0
  18. data/lib/samagotchi/bundles/coordinator/manifest.yml +9 -5
  19. data/lib/samagotchi/bundles/coordinator/plugin.rb +5 -3
  20. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +44 -13
  21. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  22. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  23. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +12 -1
  26. data/lib/samagotchi/context_note.rb +17 -5
  27. data/lib/samagotchi/engine.rb +113 -6
  28. data/lib/samagotchi/engine_peers.rb +11 -0
  29. data/lib/samagotchi/guardrail_wiring.rb +11 -6
  30. data/lib/samagotchi/guardrails/approvals.rb +3 -1
  31. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  32. data/lib/samagotchi/guardrails/rules.rb +6 -1
  33. data/lib/samagotchi/guardrails/verdict.rb +21 -1
  34. data/lib/samagotchi/guardrails_command.rb +47 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  36. data/lib/samagotchi/note_command.rb +1 -1
  37. data/lib/samagotchi/note_delivery.rb +6 -2
  38. data/lib/samagotchi/objectives.rb +395 -0
  39. data/lib/samagotchi/objectives_audit.rb +115 -0
  40. data/lib/samagotchi/objectives_check.rb +335 -0
  41. data/lib/samagotchi/objectives_command.rb +192 -0
  42. data/lib/samagotchi/plugin/api.rb +2 -1
  43. data/lib/samagotchi/plugin/context.rb +21 -1
  44. data/lib/samagotchi/run_options.rb +1 -0
  45. data/lib/samagotchi/send_command.rb +2 -1
  46. data/lib/samagotchi/session_chain.rb +91 -8
  47. data/lib/samagotchi/session_commands.rb +16 -1
  48. data/lib/samagotchi/session_continue.rb +6 -2
  49. data/lib/samagotchi/session_inbox.rb +14 -6
  50. data/lib/samagotchi/session_manager.rb +1 -1
  51. data/lib/samagotchi/steer.rb +24 -4
  52. data/lib/samagotchi/system_one/record.rb +17 -3
  53. data/lib/samagotchi/testing/boundary.rb +1 -0
  54. data/lib/samagotchi/testing/host.rb +1 -0
  55. data/lib/samagotchi/tool_activity.rb +17 -0
  56. data/lib/samagotchi/tool_declarations.rb +34 -0
  57. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  58. data/lib/samagotchi/tools/builtins.rb +4 -1
  59. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  60. data/lib/samagotchi/tools/peers.rb +3 -0
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/message_display.rb +9 -0
  63. data/lib/samagotchi/web/participant_view.rb +1 -1
  64. data/lib/samagotchi/web/public/app.js +10 -0
  65. data/lib/samagotchi/web/public/cards_place.js +20 -4
  66. data/lib/samagotchi/web/public/commands.js +4 -1
  67. data/lib/samagotchi/web/public/index.html +2 -0
  68. data/lib/samagotchi/web/public/objectives.css +80 -0
  69. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  70. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  71. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  72. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  73. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  74. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  75. data/lib/samagotchi/web/public/turn_html.js +4 -1
  76. data/lib/samagotchi/web/session_show.rb +12 -1
  77. data/lib/samagotchi/worker.rb +3 -0
  78. data/lib/samagotchi/worker_inbound.rb +16 -8
  79. metadata +16 -1
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ require_relative "atomic_file"
8
+ require_relative "context_note"
9
+ require_relative "llm_context_stale"
10
+ require_relative "log"
11
+ require_relative "message_time"
12
+ require_relative "objectives_check"
13
+ require_relative "session"
14
+ require_relative "session_chain"
15
+ require_relative "session_inbox"
16
+ require_relative "tools/objectives_tool"
17
+
18
+ module Samagotchi
19
+ # A session's objectives: what the user wants it to achieve, a list they
20
+ # type (/objectives, ObjectivesCommand). The list lives in the conversation
21
+ # as a note entry (ContextNote::OBJECTIVES_HEADER, its payload under the
22
+ # message's :objectives), each edit a whole new list that replaces the one
23
+ # before it. Pure but for the file reads named below, built from a
24
+ # conversation as WorkingSet is. The model's moves are its calls of the
25
+ # objectives tool (Tools::ObjectivesTool), read back from the same
26
+ # conversation: not entries. See docs/internals/objectives.md.
27
+ module Objectives
28
+ # Who a list's note is from (ContextNote's label).
29
+ SOURCE = "objectives"
30
+ ACTIVE = "active"
31
+ DONE = "done"
32
+ DROPPED = "dropped"
33
+ STATUSES = [ACTIVE, DONE, DROPPED].freeze
34
+ ID = /\AO(\d+)\z/
35
+ # What a list with nothing left to send says, so that it still replaces
36
+ # the one before it.
37
+ NONE_LEFT = "(no objectives now)"
38
+ # Cards a turn may leave: a model's proposals are for reading.
39
+ PROPOSALS_PER_TURN = 3
40
+ # Notes that say nothing new from outside: the list's own, and a continue's
41
+ # carry note, which holds the agent's last answer (#outside_note?).
42
+ NOT_OUTSIDE = [SOURCE, SessionChain::NOTE_SOURCE].freeze
43
+ # Beside the session's notes/: what #with_lock takes.
44
+ LOCK_FILE = "objectives.lock"
45
+ # Proposal cards answered, {card id => APPLIED | DISMISSED}, so that a repeat
46
+ # of a card's line can't apply again. Under #with_lock.
47
+ CARDS_FILE = "objectives_cards.json"
48
+ APPLIED = "applied"
49
+ DISMISSED = "dismissed"
50
+ # A text's own done-when: the last group, an earlier one stays text.
51
+ DONE_WHEN = /\A(.*)\(done when:\s*(.*?)\s*\)\z/i
52
+
53
+ # One objective: +id+ "O1", never reused; +status+ ACTIVE, DONE or
54
+ # DROPPED; +done_when+ an optional hint, never enforced.
55
+ Item = Data.define(:id, :text, :status, :done_when) do
56
+ def initialize(id:, text:, status: ACTIVE, done_when: nil)
57
+ super
58
+ end
59
+
60
+ # The line the frame and /objectives show.
61
+ def line
62
+ mark = status == ACTIVE ? "" : "[#{status}] "
63
+ "#{id} #{mark}#{text}#{" (done when: #{done_when})" if done_when}"
64
+ end
65
+
66
+ def to_payload = { "id" => id, "text" => text, "status" => status, "done_when" => done_when }.compact
67
+ end
68
+
69
+ # What the model last said moved an objective: information only, read
70
+ # from its tool call, and shown by /objectives.
71
+ # +ref+ names the call itself, words apart: its run's id and its model
72
+ # entry's time, so a later move in the same words is another move, and so is
73
+ # one after a rollback hands the same id out again.
74
+ Move = Data.define(:id, :note, :evidence, :ref) do
75
+ def initialize(id:, note:, evidence:, ref: nil) = super
76
+
77
+ def line = evidence.empty? ? note : "#{note} (evidence: #{evidence})"
78
+ end
79
+
80
+ # What the user did to one objective between two lists, for the entry's
81
+ # line: +kind+ added, done, dropped, reopened or reworded; +was+ a
82
+ # reworded one's old text. Display only, computed from the two lists.
83
+ Change = Data.define(:id, :kind, :text, :was) do
84
+ def to_h = { id: id, kind: kind, text: text, was: was }.compact
85
+ end
86
+
87
+ # A change the model proposes (the objectives tool), shown to the user as a
88
+ # card whose action is #line: the same edit they could type. +change+ add,
89
+ # reword, done or drop; +note+ why.
90
+ Proposal = Data.define(:change, :id, :text, :note, :was) do
91
+ # +was+: what a reword was proposed against (Objectives.fingerprint).
92
+ def initialize(change:, id:, text:, note:, was: nil)
93
+ super
94
+ end
95
+
96
+ # A proposal against +list+: a reword keeps the item's done-when unless its
97
+ # text brings one, and carries what it was proposed against.
98
+ def self.against(list, change:, id:, text:, note:)
99
+ item = change == "reword" ? list.find(id) : nil
100
+ return new(change: change, id: id, text: text, note: note) unless item
101
+
102
+ kept = item.done_when && !text.empty? && !text.match?(DONE_WHEN) ? "#{text} (done when: #{item.done_when})" : text
103
+ new(change: change, id: id, text: kept, note: note, was: Objectives.fingerprint(item))
104
+ end
105
+
106
+ # What's wrong with it against +list+, nil when it can apply.
107
+ def refusal(list)
108
+ return "change must be add, reword, done or drop" unless %w[add reword done drop].include?(change)
109
+ return "#{change} needs a text" if %w[add reword].include?(change) && text.empty?
110
+ return nil if change == "add"
111
+
112
+ list.find(id) ? nil : "no objective #{id} (this session's: #{list.items.map(&:id).join(", ")})"
113
+ end
114
+
115
+ # The /objectives line that applies it. An add's text can't split in two.
116
+ def line
117
+ case change
118
+ when "add" then "/objectives add #{text.gsub(/(?<!\S)\|(?!\S)/, "/")}"
119
+ when "reword" then "/objectives edit #{id} #{text}"
120
+ else "/objectives #{change} #{id}"
121
+ end
122
+ end
123
+
124
+ # The card's Apply: #line behind the prefix ObjectivesCommand reads.
125
+ def action_line(card)
126
+ line.sub("/objectives ", "/objectives --card #{card} #{"--was #{was} " if was}")
127
+ end
128
+
129
+ def title
130
+ case change
131
+ when "add" then "Add an objective?"
132
+ when "reword" then "Reword #{id}?"
133
+ when "done" then "Mark #{id} done?"
134
+ else "Drop #{id}?"
135
+ end
136
+ end
137
+
138
+ # The card's body: what, why, and the line for a terminal, which shows no button.
139
+ def body = [(text unless text.empty?), (note unless note.empty?), "`#{line}`"].compact.join("\n\n")
140
+ end
141
+
142
+ # The list: its items and the number the next id takes (it only grows,
143
+ # so a /objectives set doesn't reuse an id it dropped), and +moves+, the
144
+ # latest Move per id, which #build folds from the tool calls (never in
145
+ # the frame or the payload). Edits answer a new List.
146
+ List = Data.define(:items, :next_number, :moves) do
147
+ def initialize(items: [], next_number: 1, moves: {})
148
+ super
149
+ end
150
+
151
+ # Nothing was ever set.
152
+ def empty? = items.empty?
153
+
154
+ def find(id) = items.find { |item| item.id == id }
155
+
156
+ # @param text [String]
157
+ # @param done_when [String, nil]
158
+ # @return [List]
159
+ def add(text, done_when: nil)
160
+ with(items: items + [Item.new(id: "O#{next_number}", text: text, done_when: done_when)], next_number: next_number + 1)
161
+ end
162
+
163
+ # A new list in place of this one; the ids go on from this one's.
164
+ # @param entries [Array<Hash>] {text:, done_when:}
165
+ # @return [List]
166
+ def set(entries)
167
+ entries.reduce(with(items: [])) { |list, entry| list.add(entry[:text], done_when: entry[:done_when]) }
168
+ end
169
+
170
+ # @return [List]
171
+ def mark(id, status)
172
+ with(items: items.map { |item| item.id == id ? item.with(status: status) : item })
173
+ end
174
+
175
+ # @return [List]
176
+ def reword(id, text, done_when: nil)
177
+ with(items: items.map { |item| item.id == id ? item.with(text: text, done_when: done_when) : item })
178
+ end
179
+
180
+ # The note's text: what the model is shown (a dropped one isn't).
181
+ def text
182
+ lines = items.reject { |item| item.status == DROPPED }.map(&:line)
183
+ lines.empty? ? NONE_LEFT : lines.join("\n")
184
+ end
185
+
186
+ # What the note carries, in one key style: strings, as a save leaves them.
187
+ def to_payload = { "next" => next_number, "items" => items.map(&:to_payload) }
188
+
189
+ # What the page draws: the items, each with its last move and last check,
190
+ # and whether the newest edit is still a file waiting to go in.
191
+ # @param checks [Hash{String => Hash}] ObjectivesCheck.view_of's
192
+ def to_view(pending:, checks: {})
193
+ { items: items.map { |item| item_view(item, checks[item.id]) }, pending: pending }
194
+ end
195
+
196
+ private
197
+
198
+ def item_view(item, check)
199
+ move = moves[item.id]
200
+ view = item.to_payload
201
+ view = view.merge("move" => { "note" => move.note, "evidence" => move.evidence }) if move
202
+ check ? view.merge("check" => check) : view
203
+ end
204
+ end
205
+
206
+ module_function
207
+
208
+ # One edit's read and write as one step, across threads (each anytime command
209
+ # has its own) and processes: each note holds a whole list, so two edits that
210
+ # read the same list would lose one. The worker's absorb holds it too, so an
211
+ # edit reads neither between the note going into the conversation and its
212
+ # file going. An edit's events go under it: the order is always this lock,
213
+ # then the event log. Nothing takes this under the event log.
214
+ def with_lock(session_dir)
215
+ FileUtils.mkdir_p(session_dir)
216
+ File.open(File.join(session_dir, LOCK_FILE), File::RDWR | File::CREAT, 0o600) do |file|
217
+ file.flock(File::LOCK_EX)
218
+ yield
219
+ end
220
+ end
221
+
222
+ # An item's words, short: what a proposed reword was made against.
223
+ def fingerprint(item) = Digest::SHA256.hexdigest("#{item.text}\0#{item.done_when}")[0, 12]
224
+
225
+ # How a proposal card was answered, nil when it wasn't. Under #with_lock.
226
+ def card_state(session_dir, card)
227
+ JSON.parse(File.read(File.join(session_dir, CARDS_FILE)))[card]
228
+ rescue SystemCallError, JSON::ParserError, TypeError
229
+ nil
230
+ end
231
+
232
+ # Under #with_lock.
233
+ def record_card(session_dir, card, state)
234
+ path = File.join(session_dir, CARDS_FILE)
235
+ known = begin
236
+ JSON.parse(File.read(path))
237
+ rescue SystemCallError, JSON::ParserError
238
+ {}
239
+ end
240
+ AtomicFile.write(path, JSON.generate(known.merge(card => state)))
241
+ end
242
+
243
+ # The id a call names, as the list spells it.
244
+ def move_id(call) = call[:id].to_s.strip.upcase
245
+
246
+ # A call's text as one line.
247
+ def clean(text) = text.to_s.gsub(/[[:space:]]+/, " ").strip
248
+
249
+ # The list as +conversation+ leaves it, +pending+ over it: a pending file
250
+ # is newer than anything the conversation holds. Pure.
251
+ # @param conversation [Array<Hash>, nil] the stored entries
252
+ # @param pending [Hash, nil] #pending_payload's
253
+ # @param running [Array<Hash>, nil] the running turn's conversation, which
254
+ # +conversation+ (a session's messages) doesn't hold yet: its moves count
255
+ # @return [List]
256
+ def build(conversation, pending: nil, running: nil)
257
+ conversation = Array(conversation)
258
+ list = from_payload(pending) || conversation.reduce(List.new) do |so_far, message|
259
+ (ContextNote.note?(message) && from_payload(ContextNote.fetch(message, :objectives))) || so_far
260
+ end
261
+ list.empty? ? list : list.with(moves: moves_in(conversation).merge(moves_in(Array(running))))
262
+ end
263
+
264
+ # The latest Move per id among the conversation's objectives calls that
265
+ # recorded one (LLMContextStale.paired_runs, as the working set reads its
266
+ # runs: a call_tool's too, both hosts' forms). Tools::ObjectivesTool
267
+ # .recorded? is the check: a call with no list, a refused, a denied or an
268
+ # interrupted one recorded nothing.
269
+ # @return [Hash{String => Move}]
270
+ def moves_in(conversation)
271
+ LLMContextStale.paired_runs(conversation).each_with_object({}) do |run, moves|
272
+ next unless run.call[:name] == Tools::ObjectivesTool::NAME
273
+
274
+ id = move_id(run.call)
275
+ next unless Tools::ObjectivesTool.recorded?(run.text, id)
276
+
277
+ moves[id] = Move.new(id: id, note: clean(run.call[:note]), evidence: clean(run.call[:evidence]), ref: move_ref(conversation, run))
278
+ end
279
+ end
280
+
281
+ # Whether a note is news from outside the session's own words, for what reads
282
+ # a conversation as evidence or as incoming (ObjectivesCheck, ObjectivesAudit).
283
+ # @param source [String, nil] the note's source
284
+ # @param payload [Object] the note's :objectives
285
+ def outside_note?(source, payload) = payload.nil? && !NOT_OUTSIDE.include?(source)
286
+
287
+ def move_ref(conversation, run)
288
+ called = conversation[0...run.index].reverse_each.find { |entry| entry[:role].to_s == "model" }
289
+ "#{run.ref&.id}@#{called && called[MessageTime::KEY]}"
290
+ end
291
+ private_class_method :move_ref
292
+
293
+ # A session's list now, its unabsorbed newest file included.
294
+ # @return [List]
295
+ def current(session_id, conversation, state_dir:, running: nil)
296
+ build(conversation, pending: pending_payload(Session.session_dir(session_id, state_dir: state_dir)), running: running)
297
+ end
298
+
299
+ # The list as the page draws it (List#to_view), nil with no list: the page
300
+ # shows nothing then.
301
+ # @return [Hash, nil]
302
+ def view(session_id, conversation, state_dir:, running: nil)
303
+ dir = Session.session_dir(session_id, state_dir: state_dir)
304
+ pending = pending_payload(dir)
305
+ list = build(conversation, pending: pending, running: running)
306
+ list.empty? ? nil : list.to_view(pending: !pending.nil?, checks: ObjectivesCheck.view_of(dir, list))
307
+ end
308
+
309
+ # One note's list for the page's entry: its items and, when there was a
310
+ # list before it, what changed.
311
+ # @param payload [Object] the note's :objectives
312
+ # @param before [List, nil] the list before this note
313
+ # @return [Hash, nil] nil for what isn't a list
314
+ def entry(payload, before: nil)
315
+ list = from_payload(payload) or return nil
316
+
317
+ fields = { items: list.items.map(&:to_payload) }
318
+ fields[:changes] = changes(before, list).map(&:to_h) if before && !before.empty?
319
+ fields
320
+ end
321
+
322
+ # What the user did between two lists, in the new list's order.
323
+ # @return [Array<Change>]
324
+ def changes(before, after)
325
+ (before.items.map(&:id) | after.items.map(&:id)).filter_map do |id|
326
+ change_of(before.find(id), after.find(id))
327
+ end
328
+ end
329
+
330
+ # The newest objectives note waiting in +session_dir+'s notes/.
331
+ # @return [Hash, nil] its payload
332
+ def pending_payload(session_dir)
333
+ SessionInbox.find_new_note_files(session_dir).reverse_each do |file|
334
+ payload = SessionInbox.read_note(file)&.dig(:objectives)
335
+ return payload if from_payload(payload)
336
+ end
337
+ nil
338
+ end
339
+
340
+ # @param payload [Object] a note's :objectives; either key style reads
341
+ # @return [List, nil] nil for what isn't a list (+next+ not an Integer,
342
+ # +items+ not an Array): it can't wipe the fold or restart the ids
343
+ def from_payload(payload)
344
+ return nil unless payload.is_a?(Hash)
345
+
346
+ payload = payload.transform_keys(&:to_s)
347
+ return nil unless payload["next"].is_a?(Integer) && payload["items"].is_a?(Array)
348
+
349
+ items = payload["items"].filter_map { |raw| item_of(raw) }
350
+ highest = items.map { |item| item.id[ID, 1].to_i }.max || 0
351
+ List.new(items: items, next_number: [payload["next"], highest + 1].max)
352
+ end
353
+
354
+ # A continue's copy: +previous+'s list as a note file in the new link,
355
+ # read fresh, its unabsorbed newest file included. It runs inside the
356
+ # continue's rollback, and a link without the list is still a good link:
357
+ # a failure is logged. Not for a review link, whose next link's
358
+ # participants could differ (as SessionChain.carried_answer).
359
+ # @return [String, nil] the note file's path
360
+ def carry(previous, to:, state_dir:)
361
+ return nil if previous.review?
362
+
363
+ list = current(previous.id, Session.load(previous.id, state_dir: state_dir).messages, state_dir: state_dir)
364
+ return nil if list.empty?
365
+
366
+ SessionInbox.write_note(to, text: list.text, source: SOURCE, objectives: list.to_payload, state_dir: state_dir)
367
+ rescue StandardError => e
368
+ Log.warn(:worker, "continue_objectives_failed", sid: previous.id, error: e.class.name, msg: e.message)
369
+ nil
370
+ end
371
+
372
+ def change_of(was, now)
373
+ return Change.new(id: was.id, kind: DROPPED, text: was.text, was: nil) unless now
374
+ return Change.new(id: now.id, kind: "added", text: now.text, was: nil) unless was
375
+
376
+ if now.text != was.text || now.done_when != was.done_when
377
+ Change.new(id: now.id, kind: "reworded", text: now.text, was: was.text)
378
+ elsif now.status != was.status
379
+ Change.new(id: now.id, kind: now.status == ACTIVE ? "reopened" : now.status, text: now.text, was: nil)
380
+ end
381
+ end
382
+ private_class_method :change_of
383
+
384
+ def item_of(raw)
385
+ return nil unless raw.is_a?(Hash)
386
+
387
+ raw = raw.transform_keys(&:to_s)
388
+ return nil unless ID.match?(raw["id"].to_s) && !raw["text"].to_s.empty?
389
+
390
+ status = STATUSES.include?(raw["status"]) ? raw["status"] : ACTIVE
391
+ Item.new(id: raw["id"].to_s, text: raw["text"].to_s, status: status, done_when: raw["done_when"]&.to_s)
392
+ end
393
+ private_class_method :item_of
394
+ end
395
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "context_note"
5
+ require_relative "log"
6
+ require_relative "objectives"
7
+ require_relative "system_one/question"
8
+
9
+ module Samagotchi
10
+ # System 1 watching the objectives, in audit: one yes/no per active objective on
11
+ # what just came in (a user's message, a note) and what just went out (a finished
12
+ # answer), each saved as a run for `chi judgments`. Nothing acts on it: no card,
13
+ # no wake, no push, nothing in a prompt. A per-message judge didn't work as a
14
+ # gate on the pod (AUC .5 to .66), so this only collects: docs/system-one.md.
15
+ #
16
+ # Off unless objectives.system_one says audit, a list has an active objective and
17
+ # System 1 has a host. The asks run on SideJudgments' threads, one in flight per
18
+ # direction; what comes meanwhile is a saved skip, with no request.
19
+ class ObjectivesAudit
20
+ MODE = "audit"
21
+ INCOMING = "objectives.incoming"
22
+ OUTGOING = "objectives.outgoing"
23
+ QUESTION_ID = "affects"
24
+ # The most objectives one message asks about.
25
+ MAX_OBJECTIVES = 6
26
+ # What the state carries of a message: a message's head, an answer's tail.
27
+ MESSAGE_CHARS = 2000
28
+ # Narrow triggers: under these there is too little to affect anything.
29
+ INCOMING_MIN_CHARS = 40
30
+ OUTGOING_MIN_CHARS = 400
31
+ COMMAND_LINE = %r{\A\s*[/!]}
32
+ QUESTIONS = {
33
+ INCOMING => "A user works with a coding agent session toward the objective in the state. The state's message just " \
34
+ "came in, from the user or as a note. Could it change that objective: what it means, whether it still " \
35
+ "stands, how it is to be met, or whether it is met?",
36
+ OUTGOING => "A coding agent works toward the objective in the state. The state's message is the end of its finished " \
37
+ "answer. Does it report something that moves that objective on, blocks it, or changes whether it still stands?"
38
+ }.freeze
39
+
40
+ # Whether this use is on: config.yml, read at each ask.
41
+ def self.on? = Config.get("objectives.system_one").to_s.strip.downcase == MODE
42
+
43
+ # @param client_for [#call] (judgment, session) → a recording client, nil with no host or no saving
44
+ # @param side [SideJudgments]
45
+ # @param state_dir [#call] → the sessions folder
46
+ def initialize(client_for:, side:, state_dir:)
47
+ @client_for = client_for
48
+ @side = side
49
+ @state_dir = state_dir
50
+ end
51
+
52
+ # A user's message, at its turn's start: the caller says it is the owner's.
53
+ def incoming(session, text)
54
+ guarded do
55
+ next unless self.class.on? && text.to_s.strip.length >= INCOMING_MIN_CHARS && !text.match?(COMMAND_LINE)
56
+
57
+ ask(INCOMING, session, text.to_s.strip[0, MESSAGE_CHARS])
58
+ end
59
+ end
60
+
61
+ # A note that just went into the conversation.
62
+ # @param note [Hash] SessionInbox.read_note's
63
+ def note(session, note)
64
+ guarded do
65
+ next unless Objectives.outside_note?(note[:source], note[:objectives])
66
+
67
+ incoming(session, note[:text].to_s)
68
+ end
69
+ end
70
+
71
+ # A finished answer of the owner's turn: the caller says so.
72
+ def outgoing(session, text)
73
+ guarded do
74
+ next unless self.class.on? && text.to_s.strip.length >= OUTGOING_MIN_CHARS
75
+
76
+ ask(OUTGOING, session, text.to_s.strip[-MESSAGE_CHARS..] || text.to_s.strip)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def ask(judgment, session, message)
83
+ return if session.nil? || session.id.nil? || session.scratch || session.review?
84
+
85
+ items = Objectives.current(session.id, session.messages, state_dir: @state_dir.call).items
86
+ .select { |item| item.status == Objectives::ACTIVE }.first(MAX_OBJECTIVES)
87
+ return if items.empty?
88
+
89
+ client = @client_for.call(judgment, session) or return
90
+
91
+ states = items.map { |item| { "objective" => objective_text(item), "message" => message.scrub } }
92
+ questions = { QUESTION_ID => SystemOne::Question.new(id: QUESTION_ID, type: "noul", instructions: QUESTIONS.fetch(judgment)) }
93
+ kind = judgment.tr(".", "_").to_sym
94
+ return states.each { |state| client.skip(state: state, questions: questions, reason: "busy") } if @side.in_flight?(kind)
95
+
96
+ @side.start(kind, warn: method(:warned)) do
97
+ states.each { |state| client.ask(state: state, questions: questions) }
98
+ end
99
+ end
100
+
101
+ # What the objective says, with no id or status: a judgment's state stays
102
+ # comparable across sessions.
103
+ def objective_text(item) = item.done_when ? "#{item.text} (done when: #{item.done_when})" : item.text
104
+
105
+ def guarded
106
+ yield
107
+ rescue StandardError => e
108
+ warned(e)
109
+ end
110
+
111
+ def warned(error)
112
+ Log.warn(:system_one, "objectives_audit_failed", error: error.class.name, msg: error.message.to_s[0, 200])
113
+ end
114
+ end
115
+ end