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
@@ -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 the carry note takes; a recap is
20
- # a few sentences, this only keeps a runaway one under the note's 16 KiB.
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, and that session's recap with its time, when it has one.
67
- # Never more than the recap: no transcript.
68
- # @param previous [Session]
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 msg[:turn_id].nil? && text.start_with?("!(")
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). +model+, when given, is the
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:}, or nil for a file that holds no usable note
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
- source: data["source"].to_s.empty? ? "cli" : data["source"].to_s,
145
- from_session: data["from_session"], from_cwd: data["from_cwd"], created_at: data["created_at"] }
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)
@@ -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 = quote_forged_headers(text) if participant_message?(message)
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 or a task'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
- def initialize(entries, rule:)
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.147.0"
4
+ VERSION = "0.149.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end