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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +2 -2
- data/docs/architecture.md +1 -1
- data/docs/bundles.md +64 -17
- data/docs/cli.md +18 -0
- data/docs/configuration.md +7 -3
- data/docs/guardrails.md +27 -5
- data/docs/hooks.md +2 -1
- data/docs/internals/objectives.md +264 -0
- data/docs/internals/web-frontend.md +4 -1
- data/docs/plugins.md +4 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +44 -2
- data/docs/system-one.md +18 -2
- data/lib/samagotchi/answer_tail.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/guardrails/merge.yml +21 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +9 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +5 -3
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +44 -13
- data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
- data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +12 -1
- data/lib/samagotchi/context_note.rb +17 -5
- data/lib/samagotchi/engine.rb +113 -6
- data/lib/samagotchi/engine_peers.rb +11 -0
- data/lib/samagotchi/guardrail_wiring.rb +11 -6
- data/lib/samagotchi/guardrails/approvals.rb +3 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
- data/lib/samagotchi/guardrails/rules.rb +6 -1
- data/lib/samagotchi/guardrails/verdict.rb +21 -1
- data/lib/samagotchi/guardrails_command.rb +47 -3
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +6 -2
- data/lib/samagotchi/objectives.rb +395 -0
- data/lib/samagotchi/objectives_audit.rb +115 -0
- data/lib/samagotchi/objectives_check.rb +335 -0
- data/lib/samagotchi/objectives_command.rb +192 -0
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +21 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_chain.rb +91 -8
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_continue.rb +6 -2
- data/lib/samagotchi/session_inbox.rb +14 -6
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/steer.rb +24 -4
- data/lib/samagotchi/system_one/record.rb +17 -3
- data/lib/samagotchi/testing/boundary.rb +1 -0
- data/lib/samagotchi/testing/host.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +17 -0
- data/lib/samagotchi/tool_declarations.rb +34 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/objectives_tool.rb +75 -0
- data/lib/samagotchi/tools/peers.rb +3 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/message_display.rb +9 -0
- data/lib/samagotchi/web/participant_view.rb +1 -1
- data/lib/samagotchi/web/public/app.js +10 -0
- data/lib/samagotchi/web/public/cards_place.js +20 -4
- data/lib/samagotchi/web/public/commands.js +4 -1
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/objectives.css +80 -0
- data/lib/samagotchi/web/public/objectives_entry.js +22 -0
- data/lib/samagotchi/web/public/objectives_model.js +100 -0
- data/lib/samagotchi/web/public/objectives_moves.js +145 -0
- data/lib/samagotchi/web/public/objectives_strip.js +242 -0
- data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -1
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/session_show.rb +12 -1
- data/lib/samagotchi/worker.rb +3 -0
- data/lib/samagotchi/worker_inbound.rb +16 -8
- 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
|