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
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "answer_tail"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "log"
|
|
7
|
+
require_relative "output_formatter"
|
|
4
8
|
require_relative "session"
|
|
9
|
+
require_relative "session_inbox"
|
|
10
|
+
require_relative "steer"
|
|
5
11
|
require_relative "recap_store"
|
|
6
12
|
|
|
7
13
|
module Samagotchi
|
|
@@ -16,9 +22,16 @@ module Samagotchi
|
|
|
16
22
|
LAST_PREFIX = "last:"
|
|
17
23
|
# Who the carry note is from (ContextNote's label: "note from session chain").
|
|
18
24
|
NOTE_SOURCE = "session chain"
|
|
19
|
-
# The most of the previous link's recap
|
|
20
|
-
#
|
|
25
|
+
# The most of the previous link's recap a note takes; a recap is a few
|
|
26
|
+
# sentences. Characters, not bytes: #note_text holds the carry note to
|
|
27
|
+
# SessionInbox::NOTE_MAX_BYTES itself.
|
|
21
28
|
RECAP_CHARS = 8000
|
|
29
|
+
# The most of the previous link's last answer the carry note takes. A
|
|
30
|
+
# wrap-up closes on what comes next, so a longer one keeps its end.
|
|
31
|
+
LAST_ANSWER_CHARS = 4000
|
|
32
|
+
LAST_ANSWER_HEAD = "\n\nIts last answer:\n"
|
|
33
|
+
# The "…" that marks a cut, in bytes.
|
|
34
|
+
CUT_MARK_BYTES = 3
|
|
22
35
|
|
|
23
36
|
module_function
|
|
24
37
|
|
|
@@ -63,14 +76,79 @@ module Samagotchi
|
|
|
63
76
|
end
|
|
64
77
|
|
|
65
78
|
# The context note a new link starts with: which session it continues,
|
|
66
|
-
# from when,
|
|
67
|
-
#
|
|
68
|
-
#
|
|
79
|
+
# from when, that session's recap with its time, when it has one, and
|
|
80
|
+
# its last answer (#last_answer; session.carry_last_answer). No other transcript. Never over
|
|
81
|
+
# SessionInbox::NOTE_MAX_BYTES, whatever the session holds: the inbox
|
|
82
|
+
# rejects a longer note, and a continue writes it after the archive. The
|
|
83
|
+
# answer gets what the rest leaves.
|
|
84
|
+
# @param previous [Session] the answer is read fresh from its file
|
|
69
85
|
# @param recap [Hash, nil] RecapStore.read's
|
|
70
86
|
# @return [String]
|
|
71
|
-
def note_text(previous, recap:)
|
|
87
|
+
def note_text(previous, recap:, state_dir:)
|
|
72
88
|
head = "This session continues #{previous.id[0, 8]} (#{clock(previous.created_at) || previous.created_at}), the previous link of its chain."
|
|
73
|
-
"#{head}#{recap_text(recap)}"
|
|
89
|
+
text = keep_start("#{head}#{recap_text(recap)}", SessionInbox::NOTE_MAX_BYTES)
|
|
90
|
+
room = SessionInbox::NOTE_MAX_BYTES - text.bytesize - LAST_ANSWER_HEAD.bytesize
|
|
91
|
+
answer = carried_answer(previous, room: room, state_dir: state_dir)
|
|
92
|
+
answer ? "#{text}#{LAST_ANSWER_HEAD}#{answer}" : text
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The last answer as the carry note takes it, in at most +room+ bytes;
|
|
96
|
+
# nil with session.carry_last_answer off, when there is none, when a
|
|
97
|
+
# review link has it (a continue copies review: but not the grants, so
|
|
98
|
+
# the next link's participants could differ) or when the session can't
|
|
99
|
+
# be read: this runs inside a continue's rollback, and a note without it
|
|
100
|
+
# is still a good note.
|
|
101
|
+
# @return [String, nil]
|
|
102
|
+
def carried_answer(previous, room:, state_dir:)
|
|
103
|
+
return nil if previous.review? || !Config.get("session.carry_last_answer")
|
|
104
|
+
|
|
105
|
+
text = last_answer(Session.load(previous.id, state_dir: state_dir).messages)
|
|
106
|
+
text && keep_end(text, room)
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
Log.warn(:worker, "continue_last_answer_failed", sid: previous.id, error: e.class.name, msg: e.message)
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The session's last word: the text of AnswerTail's newest bubble, with
|
|
113
|
+
# the wire-format markup stripped, unless that bubble is a step of a
|
|
114
|
+
# turn (it asked for tools) or old text a later turn never replaced
|
|
115
|
+
# (one that failed or was cut before it answered). A cut reply keeps
|
|
116
|
+
# its "[interrupted]": that is what happened.
|
|
117
|
+
# @param messages [Array<Hash>]
|
|
118
|
+
# @return [String, nil]
|
|
119
|
+
def last_answer(messages)
|
|
120
|
+
found = AnswerTail.find(messages)
|
|
121
|
+
return nil unless found && found[:content].is_a?(String) && Array(found[:tool_calls]).empty?
|
|
122
|
+
|
|
123
|
+
at = messages.rindex { |m| m.equal?(found) }
|
|
124
|
+
return nil if messages[(at + 1)..].any? { |m| m[:role].to_s == "tool_response" || later_turn?(m) }
|
|
125
|
+
|
|
126
|
+
OutputFormatter.strip_markup(found[:content].scrub)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A turn's opening, not a `!shell` line: a command the user ran after
|
|
130
|
+
# the answer leaves it the last word.
|
|
131
|
+
def later_turn?(message)
|
|
132
|
+
Steer.turn_prompt?(message) && !shell_line?(message)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# At most +max+ bytes of +text+'s start, cut on a character.
|
|
136
|
+
def keep_start(text, max)
|
|
137
|
+
return text if text.bytesize <= max
|
|
138
|
+
|
|
139
|
+
"#{text.byteslice(0, max - CUT_MARK_BYTES).scrub("")}…"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# +text+'s end: at most LAST_ANSWER_CHARS characters and +max+ bytes,
|
|
143
|
+
# with the "…" of a cut; nil when no character fits.
|
|
144
|
+
def keep_end(text, max)
|
|
145
|
+
return nil if max <= CUT_MARK_BYTES
|
|
146
|
+
|
|
147
|
+
text = "…#{text[(1 - LAST_ANSWER_CHARS)..]}" if text.length > LAST_ANSWER_CHARS
|
|
148
|
+
return text if text.bytesize <= max
|
|
149
|
+
|
|
150
|
+
kept = text.byteslice(-(max - CUT_MARK_BYTES), max - CUT_MARK_BYTES).scrub("")
|
|
151
|
+
kept.empty? ? nil : "…#{kept}"
|
|
74
152
|
end
|
|
75
153
|
|
|
76
154
|
# +recap+ as a note's tail: " Its recap, as of <time>:\n<text>" (cut
|
|
@@ -103,13 +181,18 @@ module Samagotchi
|
|
|
103
181
|
|
|
104
182
|
text = msg[:content]
|
|
105
183
|
next unless text.is_a?(String) && !text.strip.empty?
|
|
106
|
-
next if
|
|
184
|
+
next if shell_line?(msg)
|
|
107
185
|
|
|
108
186
|
return text.strip
|
|
109
187
|
end
|
|
110
188
|
nil
|
|
111
189
|
end
|
|
112
190
|
|
|
191
|
+
# A stored `!shell` line: "!(cmd)\n<output>", no turn_id.
|
|
192
|
+
def shell_line?(message)
|
|
193
|
+
message[:turn_id].nil? && message[:content].is_a?(String) && message[:content].start_with?("!(")
|
|
194
|
+
end
|
|
195
|
+
|
|
113
196
|
def clock(iso)
|
|
114
197
|
iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
115
198
|
rescue ArgumentError
|
|
@@ -27,6 +27,10 @@ require_relative "system_one/approval_label"
|
|
|
27
27
|
require_relative "system_one/record"
|
|
28
28
|
|
|
29
29
|
module Samagotchi
|
|
30
|
+
# Loaded on first use (a /objectives line): keeps the bundle test kit's
|
|
31
|
+
# load budget (spec/samagotchi/testing/sandbox_spec.rb).
|
|
32
|
+
autoload :ObjectivesCommand, File.expand_path("objectives_command", __dir__)
|
|
33
|
+
|
|
30
34
|
# The session commands a REPL and a session worker both run: /model,
|
|
31
35
|
# /models, /llm-context, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
|
|
32
36
|
# the Engine and its TurnFlow; the host prints the result's output and
|
|
@@ -50,6 +54,8 @@ module Samagotchi
|
|
|
50
54
|
REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
|
|
51
55
|
WORKING_SET_COMMAND = WorkingSetCommand::NAME
|
|
52
56
|
RECAP_COMMAND = RecapCommand::NAME
|
|
57
|
+
# A literal: ObjectivesCommand loads on first use (the autoload above).
|
|
58
|
+
OBJECTIVES_COMMAND = "/objectives"
|
|
53
59
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
54
60
|
# shows this many per host; /models <text> lists every match.
|
|
55
61
|
MODELS_PER_HOST = 20
|
|
@@ -134,6 +140,9 @@ module Samagotchi
|
|
|
134
140
|
# Only a name: renaming a running session doesn't wait for its turn.
|
|
135
141
|
registry.register(TITLE_COMMAND, "show or set this session's title (--clear: back to its first message)",
|
|
136
142
|
id: :title, anytime: true, match: ->(text) { text.match?(%r{\A/title(?:\s+.*)?\z}) }) { |text| title(text) }
|
|
143
|
+
# An edit only writes a note file (it goes in between turns): mid-turn too.
|
|
144
|
+
registry.register(OBJECTIVES_COMMAND, "show or edit what this session is to achieve (set A | B, add TEXT, edit ID TEXT, done ID…, drop ID…, undo ID…, check ID)",
|
|
145
|
+
id: :objectives, anytime: true) { |text| objectives(text) }
|
|
137
146
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
138
147
|
registry.register("/stats", "show the session's stats", local: true)
|
|
139
148
|
# /recap with words (on|off|default, or a slip: the usage) runs here:
|
|
@@ -534,7 +543,7 @@ module Samagotchi
|
|
|
534
543
|
rules.each do |rule|
|
|
535
544
|
summary = SystemOne::Record.new(entries, rule: rule).summary
|
|
536
545
|
counts = summary.reject { |key, _| key == :rule }.map { |key, value| "#{key}=#{value}" }.join(", ")
|
|
537
|
-
lines << " #{rule}: #{counts}"
|
|
546
|
+
lines << " #{rule}#{" (per repo)" if SystemOne::Record.per_repo?(rule)}: #{counts}"
|
|
538
547
|
end
|
|
539
548
|
end
|
|
540
549
|
end
|
|
@@ -703,6 +712,12 @@ module Samagotchi
|
|
|
703
712
|
reply(output, status: status, changed: changed ? [:title] : [])
|
|
704
713
|
end
|
|
705
714
|
|
|
715
|
+
# /objectives (ObjectivesCommand): nothing to save, an edit is a note file.
|
|
716
|
+
def objectives(text)
|
|
717
|
+
output, status = ObjectivesCommand.new(engine: @engine).run(text.delete_prefix(OBJECTIVES_COMMAND))
|
|
718
|
+
reply(output, status: status)
|
|
719
|
+
end
|
|
720
|
+
|
|
706
721
|
# /recap on|off|default (RecapCommand); :recap in changed when this
|
|
707
722
|
# session's own recap.enabled moved. It doesn't save: its host does on
|
|
708
723
|
# a changed result (Worker#run_command, the REPL).
|
|
@@ -5,6 +5,7 @@ require_relative "session"
|
|
|
5
5
|
require_relative "session_setup"
|
|
6
6
|
require_relative "session_inbox"
|
|
7
7
|
require_relative "session_chain"
|
|
8
|
+
require_relative "objectives"
|
|
8
9
|
require_relative "child_move"
|
|
9
10
|
require_relative "context_move"
|
|
10
11
|
require_relative "archive_store"
|
|
@@ -69,7 +70,8 @@ module Samagotchi
|
|
|
69
70
|
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
70
71
|
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
71
72
|
# starting with a context note from chi: the link and the previous
|
|
72
|
-
# link's recap (SessionChain.note_text)
|
|
73
|
+
# link's recap and last answer (SessionChain.note_text), read after the
|
|
74
|
+
# recap wait, so a turn that finished in it counts. +model+, when given, is the
|
|
73
75
|
# new link's model instead; an inherited one whose host can't run it any
|
|
74
76
|
# more (gone, disabled, an alias naming another host) falls back to the
|
|
75
77
|
# default with a warning (#model_for). The previous link is archived
|
|
@@ -208,11 +210,13 @@ module Samagotchi
|
|
|
208
210
|
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
209
211
|
# Its movable context too, with a note saying what moved and stayed.
|
|
210
212
|
moved_context = ContextMove.call(from: previous.id, to: link.id, mode: :continue, state_dir: state_dir)
|
|
213
|
+
# Its objectives last: the list reads as the latest word.
|
|
214
|
+
Objectives.carry(previous, to: link.id, state_dir: state_dir)
|
|
211
215
|
end
|
|
212
216
|
link = SessionManager.spawn_session(
|
|
213
217
|
prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
|
|
214
218
|
model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
|
|
215
|
-
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
219
|
+
continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
|
|
216
220
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
217
221
|
)
|
|
218
222
|
link.moved_children = move
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
|
+
require_relative "context_note"
|
|
8
9
|
require_relative "delivery"
|
|
9
10
|
require_relative "log"
|
|
10
11
|
require_relative "session"
|
|
@@ -83,11 +84,13 @@ module Samagotchi
|
|
|
83
84
|
# to the conversation between turns. It never starts a turn.
|
|
84
85
|
# @param source [String] where it came from ("cli", "slack", "session")
|
|
85
86
|
# @param from_session [String, nil] the sending session, for a peer's note
|
|
87
|
+
# @param objectives [Hash, nil] an objectives note's payload
|
|
88
|
+
# (Objectives::List#to_payload)
|
|
86
89
|
# @param now [Time] the time the file is named and stamped by (a spec's clock)
|
|
87
90
|
# @return [String] the note file's path
|
|
88
91
|
# @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
|
|
89
92
|
def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil,
|
|
90
|
-
now: Time.now)
|
|
93
|
+
objectives: nil, now: Time.now)
|
|
91
94
|
body = checked_text(text)
|
|
92
95
|
sd = state_dir || Session.default_state_dir
|
|
93
96
|
notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
|
|
@@ -98,7 +101,7 @@ module Samagotchi
|
|
|
98
101
|
name = "#{now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
|
|
99
102
|
path = File.join(notes_dir, name)
|
|
100
103
|
record = { "text" => body, "source" => source.to_s, "from_session" => from_session,
|
|
101
|
-
"from_cwd" => from_cwd, "created_at" => now.iso8601 }.compact
|
|
104
|
+
"from_cwd" => from_cwd, "created_at" => now.iso8601, "objectives" => objectives }.compact
|
|
102
105
|
AtomicFile.write(path, JSON.generate(record))
|
|
103
106
|
path
|
|
104
107
|
end
|
|
@@ -134,15 +137,20 @@ module Samagotchi
|
|
|
134
137
|
end
|
|
135
138
|
|
|
136
139
|
# @return [Hash, nil] {note_id:, text:, source:, from_session:, from_cwd:,
|
|
137
|
-
# created_at:},
|
|
140
|
+
# created_at:}, with an objectives note's objectives: and its header:
|
|
141
|
+
# (ContextNote.message takes them), or nil for a file that holds no
|
|
142
|
+
# usable note
|
|
138
143
|
def self.read_note(note_file)
|
|
139
144
|
data = JSON.parse(File.read(note_file))
|
|
140
145
|
text = data["text"].to_s.strip
|
|
141
146
|
return nil if text.empty?
|
|
142
147
|
|
|
143
|
-
{ note_id: File.basename(note_file).sub(/\.json(\.processing)?\z/, ""), text: text,
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
note = { note_id: File.basename(note_file).sub(/\.json(\.processing)?\z/, ""), text: text,
|
|
149
|
+
source: data["source"].to_s.empty? ? "cli" : data["source"].to_s,
|
|
150
|
+
from_session: data["from_session"], from_cwd: data["from_cwd"], created_at: data["created_at"] }
|
|
151
|
+
return note unless data["objectives"].is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
note.merge(objectives: data["objectives"], header: ContextNote::OBJECTIVES_HEADER)
|
|
146
154
|
rescue JSON::ParserError, SystemCallError, NoMethodError, TypeError
|
|
147
155
|
nil
|
|
148
156
|
end
|
|
@@ -71,7 +71,7 @@ module Samagotchi
|
|
|
71
71
|
# │ # enqueued_id, no_interrupt?, images?}
|
|
72
72
|
# ├── notes/ # context notes: background text the worker adds to
|
|
73
73
|
# │ └── <ts>-<rand>.json # the conversation between turns, never a turn
|
|
74
|
-
# │ # ({text, source, from_session?, from_cwd?, created_at})
|
|
74
|
+
# │ # ({text, source, from_session?, from_cwd?, created_at, objectives?})
|
|
75
75
|
# ├── output/ # agent writes responses here
|
|
76
76
|
# │ └── <timestamp>.txt # one file per agent response
|
|
77
77
|
# └── bridge.json # Bridge sidecar (how clients reach the worker)
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -242,18 +242,32 @@ module Samagotchi
|
|
|
242
242
|
# then +text+ (the message's content by default). Other messages: +text+.
|
|
243
243
|
def wire_text(message, text = (message[:content] || message["content"]).to_s)
|
|
244
244
|
header = header(message)
|
|
245
|
-
text =
|
|
245
|
+
text = if participant_message?(message) then quote_forged_headers(text)
|
|
246
|
+
elsif outside_text?(message) then quote_forged_objectives(text)
|
|
247
|
+
else text
|
|
248
|
+
end
|
|
246
249
|
header ? "#{header}\n#{text}" : text
|
|
247
250
|
end
|
|
248
251
|
|
|
252
|
+
# Text that came from outside the user's own typing: any entry with a
|
|
253
|
+
# kind (a note, a report, a steer) but the real objectives note, known by
|
|
254
|
+
# its payload key, never by its text.
|
|
255
|
+
def outside_text?(message)
|
|
256
|
+
!(message[:kind] || message["kind"]).to_s.empty? && (message[:objectives] || message["objectives"]).nil?
|
|
257
|
+
end
|
|
258
|
+
|
|
249
259
|
# A line of a participant's text that opens like one of chi's own
|
|
250
260
|
# headers or frames (another participant's review header, a steer's, an
|
|
251
|
-
# automatic input's, a delegate report's, a context note's
|
|
252
|
-
# frame), in any case, after any spaces (Unicode ones too); a line
|
|
261
|
+
# automatic input's, a delegate report's, a context note's, a task's or an
|
|
262
|
+
# objectives list's frame), in any case, after any spaces (Unicode ones too); a line
|
|
253
263
|
# starts after \n or \r.
|
|
254
264
|
FORGED_OPENINGS = ["Review contribution", "Steer", "Automatic input", "Delegate report", "CONTEXT NOTE",
|
|
255
|
-
"TASK FINISHED", "END NOTE"].freeze
|
|
265
|
+
"TASK FINISHED", "OBJECTIVES", "END NOTE"].freeze
|
|
256
266
|
FORGED_HEADER = /(\A|[\n\r])([\p{Space}&&[^\n\r]]*)\[(?=(?:#{FORGED_OPENINGS.map { |o| Regexp.escape(o) }.join("|")})\b)/i
|
|
267
|
+
# The same, for the objectives frame alone: the one frame that gives
|
|
268
|
+
# orders in the user's name, so any outside text is held to it
|
|
269
|
+
# (#outside_text?), whoever it came from.
|
|
270
|
+
FORGED_OBJECTIVES = /(\A|[\n\r])([\p{Space}&&[^\n\r]]*)\[(?=OBJECTIVES\b)/i
|
|
257
271
|
# What such a line is led by on the wire, so it reads as quoted text
|
|
258
272
|
# under the real header, not as a header of its own.
|
|
259
273
|
FORGED_MARK = "(quoted) "
|
|
@@ -265,6 +279,12 @@ module Samagotchi
|
|
|
265
279
|
text.to_s.gsub(FORGED_HEADER) { "#{::Regexp.last_match(1)}#{::Regexp.last_match(2)}#{FORGED_MARK}[" }
|
|
266
280
|
end
|
|
267
281
|
|
|
282
|
+
# +text+ with every FORGED_OBJECTIVES line led by FORGED_MARK; as
|
|
283
|
+
# #quote_forged_headers, on the wire only and deterministic.
|
|
284
|
+
def quote_forged_objectives(text)
|
|
285
|
+
text.to_s.gsub(FORGED_OBJECTIVES) { "#{::Regexp.last_match(1)}#{::Regexp.last_match(2)}#{FORGED_MARK}[" }
|
|
286
|
+
end
|
|
287
|
+
|
|
268
288
|
def message(text:, source:, author: nil, author_name: nil)
|
|
269
289
|
{ role: "user", kind: KIND, source: source.to_s, content: text.to_s, **author_fields(author, author_name) }
|
|
270
290
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../model_match"
|
|
5
|
+
|
|
3
6
|
module Samagotchi
|
|
4
7
|
module SystemOne
|
|
5
8
|
# One rule's record: what System 1 has judged for it and what the user
|
|
@@ -7,7 +10,9 @@ module Samagotchi
|
|
|
7
10
|
# joined with their last label), filtered to one rule. The gate before
|
|
8
11
|
# System 1 answers for the user: enough clean allows at the threshold,
|
|
9
12
|
# and no false allow among the rule's last 20 labelled asks
|
|
10
|
-
# (docs/guardrails.md, "System 1").
|
|
13
|
+
# (docs/guardrails.md, "System 1"). A rule in
|
|
14
|
+
# guardrails.system_one.repo_rules goes by repository: what the user allowed
|
|
15
|
+
# in one says nothing about another, so its record is built per repo.
|
|
11
16
|
#
|
|
12
17
|
# An ask with no label, no probability, or a `none` label counts for
|
|
13
18
|
# neither side: only a judgment the user actually answered teaches
|
|
@@ -21,13 +26,22 @@ module Samagotchi
|
|
|
21
26
|
# How many of the rule's labelled asks the false-allow check looks at.
|
|
22
27
|
WINDOW = 20
|
|
23
28
|
|
|
29
|
+
# Whether +rule+'s record goes by repository
|
|
30
|
+
# (guardrails.system_one.repo_rules, read like .rules).
|
|
31
|
+
def self.per_repo?(rule)
|
|
32
|
+
ModelMatch.parse(Config.get("guardrails.system_one.repo_rules")).include?(rule.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
24
35
|
attr_reader :rule, :entries
|
|
25
36
|
|
|
26
37
|
# @param entries [Array<Hash>] SystemOne::Log#entries, oldest first
|
|
27
38
|
# @param rule [String] the rule the record is for
|
|
28
|
-
|
|
39
|
+
# @param repo [String, nil] only this repository's asks (a judgment's
|
|
40
|
+
# repo: Guardrails::Targets#repo, its common git dir); nil: all of the
|
|
41
|
+
# rule's. A line with no repo is in no repository's record.
|
|
42
|
+
def initialize(entries, rule:, repo: nil)
|
|
29
43
|
@rule = rule.to_s
|
|
30
|
-
@entries = Array(entries).select { |entry| entry["rule"].to_s == @rule }
|
|
44
|
+
@entries = Array(entries).select { |entry| entry["rule"].to_s == @rule && (repo.nil? || entry["repo"] == repo) }
|
|
31
45
|
end
|
|
32
46
|
|
|
33
47
|
# @param threshold [Float] the probability an auto-allow needs
|
|
@@ -43,6 +43,7 @@ module Samagotchi
|
|
|
43
43
|
Allowed.new(name: "Samagotchi::Log.debug", file: "samagotchi/log"),
|
|
44
44
|
Allowed.new(name: "Samagotchi::Config.get", file: "samagotchi/config"),
|
|
45
45
|
Allowed.new(name: "Samagotchi::VERSION", file: "samagotchi/version"),
|
|
46
|
+
Allowed.new(name: "Samagotchi::CancellationController", file: "samagotchi/cancellation_controller"),
|
|
46
47
|
# The kit itself, for a bundle's own specs.
|
|
47
48
|
Allowed.new(name: "Samagotchi::Testing", file: "samagotchi/testing")
|
|
48
49
|
].freeze
|
|
@@ -39,6 +39,7 @@ module Samagotchi
|
|
|
39
39
|
notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
|
|
40
40
|
ask_user: ->(question) { recorder.ask_user(question) },
|
|
41
41
|
cancelled: -> { recorder[:cancelled] },
|
|
42
|
+
turn_running: -> { recorder[:running] },
|
|
42
43
|
card: ->(**card) { recorder.card(**card) },
|
|
43
44
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
|
|
44
45
|
recorder.ask_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
|
|
@@ -23,6 +23,7 @@ require_relative "tools/context_read"
|
|
|
23
23
|
require_relative "tools/delegate"
|
|
24
24
|
require_relative "tools/delegate_result"
|
|
25
25
|
require_relative "tools/forget_outputs"
|
|
26
|
+
require_relative "tools/objectives_tool"
|
|
26
27
|
|
|
27
28
|
module Samagotchi
|
|
28
29
|
# The one-line summary of a tool call that the UIs show ("reading file",
|
|
@@ -85,6 +86,15 @@ module Samagotchi
|
|
|
85
86
|
event
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
def objectives_proposal?(call) = call[:action].to_s.strip.downcase == Tools::ObjectivesTool::PROPOSE
|
|
90
|
+
|
|
91
|
+
# An objectives row's words: "O2 note · evidence", or "propose done O2 note".
|
|
92
|
+
def objectives_title(call)
|
|
93
|
+
return "#{Tools::ObjectivesTool::PROPOSE} #{call[:change]} #{call[:id]} #{call[:text]} · #{call[:note]}".squeeze(" ") if objectives_proposal?(call)
|
|
94
|
+
|
|
95
|
+
"#{call[:id]} #{call[:note]}#{" · #{call[:evidence]}" unless call[:evidence].to_s.strip.empty?}"
|
|
96
|
+
end
|
|
97
|
+
|
|
88
98
|
# An execute's description, cut as a row's title; nil for the rest.
|
|
89
99
|
def command_description(tool_name, call)
|
|
90
100
|
return nil unless call.is_a?(Hash) && ToolView::TOOLS.include?(tool_name)
|
|
@@ -112,6 +122,7 @@ module Samagotchi
|
|
|
112
122
|
when Tools::Read::NAME, Tools::MemoryRead::NAME then call[:content]
|
|
113
123
|
when Tools::Write::NAME, Tools::Edit::NAME, Tools::MemoryWrite::NAME then call[:path]
|
|
114
124
|
when Tools::Execute::NAME, Tools::TaskCreate::NAME then command_title(call[:content])
|
|
125
|
+
when Tools::ObjectivesTool::NAME then objectives_title(call)
|
|
115
126
|
end
|
|
116
127
|
text = text.to_s.strip
|
|
117
128
|
return nil if text.empty?
|
|
@@ -205,6 +216,7 @@ module Samagotchi
|
|
|
205
216
|
when Tools::Delegate::NAME then "delegating"
|
|
206
217
|
when Tools::DelegateResult::NAME then "waiting for a delegate"
|
|
207
218
|
when Tools::ForgetOutputs::NAME then forget_outputs_action(call)
|
|
219
|
+
when Tools::ObjectivesTool::NAME then objectives_proposal?(call) ? "proposing a change" : "recording a move"
|
|
208
220
|
else registry_entry(registry, tool_name)&.label || "calling tool"
|
|
209
221
|
end
|
|
210
222
|
end
|
|
@@ -430,6 +442,11 @@ module Samagotchi
|
|
|
430
442
|
parts << "restore=#{preview_tool_param(request.restore.join(","))}" if request.restore?
|
|
431
443
|
parts << "note=#{preview_tool_param(request.note)}" unless request.note.empty?
|
|
432
444
|
parts.empty? ? nil : parts.join(" ")
|
|
445
|
+
when Tools::ObjectivesTool::NAME
|
|
446
|
+
parts = ["id=#{preview_tool_param(call[:id])}", "note=#{preview_tool_param(call[:note])}"]
|
|
447
|
+
parts.unshift("change=#{preview_tool_param(call[:change])}") if objectives_proposal?(call)
|
|
448
|
+
parts << "evidence=#{preview_tool_param(call[:evidence])}" unless call[:evidence].to_s.strip.empty?
|
|
449
|
+
parts.join(" ")
|
|
433
450
|
when Tools::AskUserQuestion::NAME
|
|
434
451
|
parts = ["question=#{preview_tool_param(call[:question] || call[:content])}"]
|
|
435
452
|
opts = call[:options]
|
|
@@ -440,6 +440,40 @@ module Samagotchi
|
|
|
440
440
|
},
|
|
441
441
|
required: %w[question options]
|
|
442
442
|
}
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
name: "objectives",
|
|
446
|
+
description: "Record a move on one of the objectives in the OBJECTIVES note: in a line, what you did or found that moves it, so the user can follow. One when an objective really moved, not per step. Or propose a change to the list (action propose): the user sees a card and applies it or not. This tool doesn't edit the list, change a status or mark anything done, and it approves nothing; the user edits the list with /objectives. Asked to change it, propose it or say so, instead of saying you changed it.",
|
|
447
|
+
parameters: {
|
|
448
|
+
type: "object",
|
|
449
|
+
properties: {
|
|
450
|
+
action: {
|
|
451
|
+
type: "string",
|
|
452
|
+
description: "move or propose"
|
|
453
|
+
},
|
|
454
|
+
id: {
|
|
455
|
+
type: "string",
|
|
456
|
+
description: "The objective's id, e.g. O2 (not for a proposed add)"
|
|
457
|
+
},
|
|
458
|
+
note: {
|
|
459
|
+
type: "string",
|
|
460
|
+
description: "A move: what moved, in a line. A proposal: why, in a line"
|
|
461
|
+
},
|
|
462
|
+
evidence: {
|
|
463
|
+
type: "string",
|
|
464
|
+
description: "Optional, a move's: what shows it (a commit, a test count, a file)"
|
|
465
|
+
},
|
|
466
|
+
change: {
|
|
467
|
+
type: "string",
|
|
468
|
+
description: "A proposal's: add, reword, done or drop"
|
|
469
|
+
},
|
|
470
|
+
text: {
|
|
471
|
+
type: "string",
|
|
472
|
+
description: "A proposed add's or reword's new text"
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
required: %w[action note]
|
|
476
|
+
}
|
|
443
477
|
}
|
|
444
478
|
].freeze
|
|
445
479
|
|
|
@@ -53,7 +53,8 @@ module Samagotchi
|
|
|
53
53
|
"delegate" => { content: "task", fallback: :prefix_or_raw },
|
|
54
54
|
"ask_user_question" => { content: "question", also: %w[question], verbatim: %w[options], options: true,
|
|
55
55
|
fallback: :raw },
|
|
56
|
-
"forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] }
|
|
56
|
+
"forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] },
|
|
57
|
+
"objectives" => { content: "note", also: %w[note] }
|
|
57
58
|
}.freeze
|
|
58
59
|
|
|
59
60
|
# A name the model uses for a built-in, mapped to that built-in (a model
|
|
@@ -25,6 +25,7 @@ require_relative "delegate"
|
|
|
25
25
|
require_relative "delegate_result"
|
|
26
26
|
require_relative "ask_user_question"
|
|
27
27
|
require_relative "forget_outputs"
|
|
28
|
+
require_relative "objectives_tool"
|
|
28
29
|
require_relative "../guardrails/parent_approvals"
|
|
29
30
|
require_relative "../guardrails/pair_boundary"
|
|
30
31
|
require_relative "../model_profile"
|
|
@@ -58,7 +59,8 @@ module Samagotchi
|
|
|
58
59
|
ContextRead,
|
|
59
60
|
Delegate,
|
|
60
61
|
DelegateResult,
|
|
61
|
-
AskUserQuestion
|
|
62
|
+
AskUserQuestion,
|
|
63
|
+
ObjectivesTool
|
|
62
64
|
].freeze
|
|
63
65
|
# The built-ins an LLM context layer adds (offered only under it).
|
|
64
66
|
LAYER_CLASSES = [ForgetOutputs].freeze
|
|
@@ -115,6 +117,7 @@ module Samagotchi
|
|
|
115
117
|
DelegateResult.call(call[:content], session: call[:session], timeout: call[:timeout], peers: kctx.peers)
|
|
116
118
|
end,
|
|
117
119
|
AskUserQuestion::NAME => ->(call, kctx) { kctx.ask_user_question(call) },
|
|
120
|
+
ObjectivesTool::NAME => ->(call, kctx) { ObjectivesTool.call(call, peers: kctx.peers) },
|
|
118
121
|
ForgetOutputs::NAME => ->(call, kctx) { kctx.forget_outputs(call) }
|
|
119
122
|
}.freeze
|
|
120
123
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../tool_response"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Read at a call only: the builtins' file list loads this, Session not with it.
|
|
7
|
+
autoload :Objectives, File.expand_path("../objectives", __dir__)
|
|
8
|
+
|
|
9
|
+
module Tools
|
|
10
|
+
# The objectives tool's two actions. A move: what the model did or found for
|
|
11
|
+
# an objective. Information only: it never changes a status or the list
|
|
12
|
+
# (the user does, /objectives), and nothing is written here: the call in
|
|
13
|
+
# history is the record, which Objectives.build reads back. A proposal: a
|
|
14
|
+
# change to the list the user applies from a card with one tap, the card's
|
|
15
|
+
# action being the /objectives line; the model never changes the list by
|
|
16
|
+
# itself. See docs/internals/objectives.md.
|
|
17
|
+
class ObjectivesTool
|
|
18
|
+
NAME = "objectives"
|
|
19
|
+
MOVE = "move"
|
|
20
|
+
PROPOSE = "propose"
|
|
21
|
+
PROPOSED = "Proposed %s. The list is unchanged: the user sees a card and applies it or not."
|
|
22
|
+
RECORDED = "Recorded a move on %s."
|
|
23
|
+
EDITED_BY_USER = "The user edits the list with /objectives."
|
|
24
|
+
NONE = "There are no objectives here, so nothing was recorded. #{EDITED_BY_USER}".freeze
|
|
25
|
+
|
|
26
|
+
def self.name = NAME
|
|
27
|
+
|
|
28
|
+
# Whether +output+, a saved run, is this tool's answer that it recorded a
|
|
29
|
+
# move on +id+ (Objectives.moves_in). Positive: a run that recorded
|
|
30
|
+
# nothing (no list, a refusal, a denial, an interrupt) has no such answer.
|
|
31
|
+
def self.recorded?(output, id)
|
|
32
|
+
output.to_s.sub(ToolResponse::LEAD, "").start_with?(RECORDED % id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param call [Hash] the internal call (BuiltinCalls: action:, id:,
|
|
36
|
+
# note:, evidence:)
|
|
37
|
+
# @param peers [#objectives, nil] the session's list
|
|
38
|
+
# @return [String] the tool result
|
|
39
|
+
def self.call(call, peers:)
|
|
40
|
+
action = call[:action].to_s.strip.downcase
|
|
41
|
+
unless action.empty? || [MOVE, PROPOSE].include?(action)
|
|
42
|
+
return "Error: this tool records a move (action: move) or proposes a change (action: propose); it edits nothing. #{EDITED_BY_USER}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
list = peers&.objectives
|
|
46
|
+
return NONE if list.nil? || list.empty?
|
|
47
|
+
return propose(list, call, peers) if action == PROPOSE
|
|
48
|
+
|
|
49
|
+
id = Objectives.move_id(call)
|
|
50
|
+
refusal(list, id, call) || "#{RECORDED % id} The list is unchanged: this tool doesn't edit it. #{EDITED_BY_USER}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.propose(list, call, peers)
|
|
54
|
+
proposal = Objectives::Proposal.against(list, change: call[:change].to_s.strip.downcase, id: Objectives.move_id(call),
|
|
55
|
+
text: Objectives.clean(call[:text]), note: Objectives.clean(call[:note]))
|
|
56
|
+
refused = proposal.refusal(list)
|
|
57
|
+
return "Error: #{refused}. Nothing was proposed." if refused
|
|
58
|
+
|
|
59
|
+
return "Error: at most #{Objectives::PROPOSALS_PER_TURN} proposals a turn. Nothing more was proposed." unless peers.propose_objective(proposal)
|
|
60
|
+
|
|
61
|
+
format(PROPOSED, proposal.line.delete_prefix("/objectives "))
|
|
62
|
+
end
|
|
63
|
+
private_class_method :propose
|
|
64
|
+
|
|
65
|
+
def self.refusal(list, id, call)
|
|
66
|
+
item = list.find(id)
|
|
67
|
+
return "Error: no objective #{id} (this session's: #{list.items.map(&:id).join(", ")}). Nothing was recorded." unless item
|
|
68
|
+
return "Error: #{id} was dropped, so nothing was recorded." if item.status == Objectives::DROPPED
|
|
69
|
+
|
|
70
|
+
"Error: a move needs a note: what moved, in a line." if Objectives.clean(call[:note]).empty?
|
|
71
|
+
end
|
|
72
|
+
private_class_method :refusal
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -27,6 +27,9 @@ module Samagotchi
|
|
|
27
27
|
# A person's message waits for the turn's next boundary: never here
|
|
28
28
|
# (Engine::PeerView asks the running turn).
|
|
29
29
|
def input_waiting? = false
|
|
30
|
+
|
|
31
|
+
# The session's objectives: none here (Engine::PeerView reads them).
|
|
32
|
+
def objectives = nil
|
|
30
33
|
end
|
|
31
34
|
end
|
|
32
35
|
end
|