samagotchi 0.148.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/bundles.md +38 -1
  5. data/docs/cli.md +17 -0
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +2 -1
  9. data/docs/internals/objectives.md +264 -0
  10. data/docs/internals/web-frontend.md +4 -1
  11. data/docs/plugins.md +4 -2
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +42 -0
  14. data/docs/system-one.md +17 -1
  15. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
  16. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  17. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
  18. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  19. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  20. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/config.rb +7 -1
  23. data/lib/samagotchi/context_note.rb +17 -5
  24. data/lib/samagotchi/engine.rb +113 -6
  25. data/lib/samagotchi/engine_peers.rb +11 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  27. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  28. data/lib/samagotchi/note_command.rb +1 -1
  29. data/lib/samagotchi/note_delivery.rb +6 -2
  30. data/lib/samagotchi/objectives.rb +395 -0
  31. data/lib/samagotchi/objectives_audit.rb +115 -0
  32. data/lib/samagotchi/objectives_check.rb +335 -0
  33. data/lib/samagotchi/objectives_command.rb +192 -0
  34. data/lib/samagotchi/plugin/api.rb +2 -1
  35. data/lib/samagotchi/plugin/context.rb +21 -1
  36. data/lib/samagotchi/session_commands.rb +15 -0
  37. data/lib/samagotchi/session_continue.rb +3 -0
  38. data/lib/samagotchi/session_inbox.rb +14 -6
  39. data/lib/samagotchi/session_manager.rb +1 -1
  40. data/lib/samagotchi/steer.rb +24 -4
  41. data/lib/samagotchi/testing/boundary.rb +1 -0
  42. data/lib/samagotchi/testing/host.rb +1 -0
  43. data/lib/samagotchi/tool_activity.rb +17 -0
  44. data/lib/samagotchi/tool_declarations.rb +34 -0
  45. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  46. data/lib/samagotchi/tools/builtins.rb +4 -1
  47. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  48. data/lib/samagotchi/tools/peers.rb +3 -0
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/message_display.rb +9 -0
  51. data/lib/samagotchi/web/participant_view.rb +1 -1
  52. data/lib/samagotchi/web/public/app.js +10 -0
  53. data/lib/samagotchi/web/public/cards_place.js +20 -4
  54. data/lib/samagotchi/web/public/commands.js +4 -1
  55. data/lib/samagotchi/web/public/index.html +2 -0
  56. data/lib/samagotchi/web/public/objectives.css +80 -0
  57. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  58. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  59. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  60. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  61. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  62. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  63. data/lib/samagotchi/web/public/turn_html.js +4 -1
  64. data/lib/samagotchi/web/session_show.rb +12 -1
  65. data/lib/samagotchi/worker.rb +3 -0
  66. data/lib/samagotchi/worker_inbound.rb +16 -8
  67. metadata +15 -1
@@ -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
@@ -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.148.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
@@ -7,6 +7,7 @@ require_relative "../context_note"
7
7
  require_relative "../image_store"
8
8
  require_relative "../message_parts"
9
9
  require_relative "../message_time"
10
+ require_relative "../objectives"
10
11
  require_relative "../output_formatter"
11
12
  require_relative "../recap_store"
12
13
  require_relative "../ref"
@@ -95,11 +96,19 @@ module Samagotchi
95
96
  list = Array(msgs)
96
97
  # The outputs' ✂ marks (LLM context edits), by id, for the parts.
97
98
  marks = parts ? MessageParts.edit_marks(list) : {}
99
+ # The list before each objectives note, for what its entry says changed.
100
+ objectives = nil
98
101
  list.each_with_index do |m, index|
99
102
  role = (m[:role] || m["role"]).to_s
100
103
  content = (m[:content] || m["content"]).to_s
101
104
  if Samagotchi::ContextNote.note?(m)
102
105
  note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m), **time_of(m) }
106
+ payload = Samagotchi::ContextNote.fetch(m, :objectives)
107
+ entry = payload && Samagotchi::Objectives.entry(payload, before: objectives)
108
+ if entry
109
+ note[:objectives] = entry
110
+ objectives = Samagotchi::Objectives.from_payload(payload)
111
+ end
103
112
  # A context wake turn's note starts that turn (Steer.turn_prompt?):
104
113
  # the page opens the turn with it and pairs it with its record.
105
114
  if Samagotchi::Steer.turn_prompt?(m)
@@ -82,7 +82,7 @@ module Samagotchi
82
82
  after_generation after_tool_call after_turn answer_display before_generation before_tool_call before_turn
83
83
  card command_output command_queued command_ran context_added context_status empty_answer_retry
84
84
  generation_cancelled generation_completed generation_progress generation_started guardrail_warning hook_notice
85
- llm_context_edited merged_input participants_changed pending_input_merged plugin_init_finished
85
+ llm_context_edited merged_input objectives_updated participants_changed pending_input_merged plugin_init_finished
86
86
  plugin_init_started plugin_init_wait prompt_restored question question_relay reasoning_history_refused
87
87
  recap_ready reminder_injected sampling_refused session_end session_start side_usage steer_cut thinking_dropped
88
88
  thinking_refused tool_dispatch_completed tool_dispatch_started used_memories_updated working_set_updated
@@ -54,6 +54,7 @@ import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
54
54
  import { createCardsPlace } from "./cards_place.js";
55
55
  import { createStageView } from "./stage_view.js";
56
56
  import { createWorkingSet, groupItems } from "./working_set_view.js";
57
+ import { createObjectives } from "./objectives_wiring.js";
57
58
  import { turnGroups } from "./timing.js";
58
59
  import { createGapClock, lastActivity } from "./time_gaps.js";
59
60
  import { createMarkdownPlace } from "./markdown_place.js";
@@ -421,6 +422,10 @@ const deliveryControl = createDeliveryControl({
421
422
  // This tab's id in the session's events: tells its own prompts from the
422
423
  // ones other clients (an attached TUI, another tab, reminders) sent.
423
424
  const clientId = newClientId();
425
+ const objectives = createObjectives({
426
+ el: $("#objectivesStrip"), historyEl, session: () => store.selected, running: () => liveTurn.running, holding: () => turnDom.holds(), commands: () => store.current.commands,
427
+ clientId, sendCommand, sendTurn, follow: () => followWokenWorker(), toast: (...args) => showToast(...args),
428
+ });
424
429
  // The question and continue cards in the history (question_cards.js).
425
430
  const questionCards = createQuestionCards({
426
431
  sessionId: () => store.selected,
@@ -1017,6 +1022,7 @@ function forgetSession({ keepVerify = false } = {}) {
1017
1022
  });
1018
1023
  commandList.close();
1019
1024
  resetTurnView();
1025
+ objectives.reset();
1020
1026
  seedInitTasks(initTasks, []);
1021
1027
  renderInitRow();
1022
1028
  setMarkdownWarning(null);
@@ -1178,6 +1184,7 @@ function renderSessionView(data, { kept = [] } = {}) {
1178
1184
  if (data.messages && data.messages.length) renderHistory(data.messages);
1179
1185
  else renderHistory(data.history || []);
1180
1186
  seedWorkingSet(data);
1187
+ objectives.seed(data.objectives);
1181
1188
  restoreCommandBubbles(historyEl, kept);
1182
1189
  // A failed turn's prompt went back to the user: shown after the
1183
1190
  // conversation, marked failed, as it was live (prompt_restored).
@@ -1761,6 +1768,8 @@ const streamHandlers = createStreamHandlers({
1761
1768
  // cards_place.js
1762
1769
  upsertCard, refreshCardBodies,
1763
1770
  syncModelPick: startPage.commandRan,
1771
+ objectivesUpdated: (data) => objectives.update(data),
1772
+ cardActionFailed: cards.actionFailed,
1764
1773
  });
1765
1774
 
1766
1775
  async function refreshTimingFromSession() {
@@ -2204,6 +2213,7 @@ store.on("settingsPanel", () => settingsControl.refresh());
2204
2213
  store.on("workingSetStrategy", () => workingSet?.strategyChanged());
2205
2214
  store.on("featureGates", () => featureGates.check(store.current));
2206
2215
  store.on("composer", updateComposerMode);
2216
+ store.on("composer", () => objectives.redraw());
2207
2217
  // The answers' Revise buttons follow liveTurn.running too (none on the
2208
2218
  // running turn's answer), which touches the composer.
2209
2219
  store.on("composer", () => reviseStacks.decorate());
@@ -48,6 +48,8 @@ export function turnAnswerEl(turnEls) {
48
48
  // @param api {getSession, sendCommand} (data.js)
49
49
  // @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries),
50
50
  // refreshCardBodies(), onCardAction(event)}
51
+ const HELD_UNTIL_ANSWERED = /^\/objectives --card /;
52
+
51
53
  export function createCardsPlace({
52
54
  historyEl, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
53
55
  followWokenWorker, api = { getSession, sendCommand },
@@ -162,7 +164,18 @@ export function createCardsPlace({
162
164
  }, 150);
163
165
  }
164
166
 
165
- // A card's action button (a click anywhere in the turn roots).
167
+ // Its command line's card is back in play: the worker refused the action.
168
+ function actionFailed(line) {
169
+ for (const button of findInTurnRoots(".plugin-card .card-action")) {
170
+ if (button.dataset.command === line) {
171
+ button.closest(".plugin-card").querySelectorAll(".card-action").forEach((b) => { b.disabled = false; });
172
+ }
173
+ }
174
+ }
175
+
176
+ // A card's action button (a click anywhere in the turn roots). A proposal
177
+ // card's buttons stay off until its folded card replaces them or the
178
+ // action fails (actionFailed): a second tap has nothing to do.
166
179
  function onCardAction(e) {
167
180
  const button = e.target.closest(".plugin-card .card-action");
168
181
  const id = selected();
@@ -170,7 +183,9 @@ export function createCardsPlace({
170
183
  const card = button.closest(".plugin-card");
171
184
  const err = card.querySelector(".card-error");
172
185
  err.classList.add("hidden");
173
- button.disabled = true;
186
+ const held = HELD_UNTIL_ANSWERED.test(button.dataset.command);
187
+ const off = held ? [...card.querySelectorAll(".card-action")] : [button];
188
+ off.forEach((b) => { b.disabled = true; });
174
189
  api.sendCommand(id, button.dataset.command, { clientId, card: true })
175
190
  .then(() => {
176
191
  // The command woke the worker: follow its stream if none is open
@@ -180,9 +195,10 @@ export function createCardsPlace({
180
195
  .catch((error) => {
181
196
  err.textContent = error.message;
182
197
  err.classList.remove("hidden");
198
+ off.forEach((b) => { b.disabled = false; });
183
199
  })
184
- .finally(() => { button.disabled = false; });
200
+ .finally(() => { if (!held) button.disabled = false; });
185
201
  }
186
202
 
187
- return { cardElement, upsertCard, placeSnapshotCards, refreshCardBodies, onCardAction };
203
+ return { cardElement, upsertCard, placeSnapshotCards, refreshCardBodies, onCardAction, actionFailed };
188
204
  }
@@ -163,6 +163,8 @@ export function continueLine(choice, reason = "") {
163
163
  const QUEUED_TEXT = "queued: runs after this turn";
164
164
  const APPLY_TEXT = "Apply (restarts the cache)";
165
165
 
166
+ const OBJECTIVES_LINE = /^\/objectives\b/;
167
+
166
168
  export function commandView(event, myId) {
167
169
  const own = isOwn(event.client_id, myId);
168
170
  const waiting = event.waits === "turn_end";
@@ -181,7 +183,8 @@ export function commandView(event, myId) {
181
183
  commandId: event.command_id || null,
182
184
  confirm: typeof event.confirm?.line === "string" ? event.confirm.line : null,
183
185
  // A card's action (Nudge…): no bubble unless it says something back.
184
- hidden: event.card === true && !event.output && event.status !== "error",
186
+ // A tap's /objectives edit: the strip and the entry are its answer.
187
+ hidden: event.card === true && event.status !== "error" && (!event.output || OBJECTIVES_LINE.test(event.line || "")),
185
188
  };
186
189
  }
187
190
 
@@ -14,6 +14,7 @@
14
14
  <link rel="stylesheet" href="/assets/annotate_bar.css">
15
15
  <link rel="stylesheet" href="/assets/composer.css">
16
16
  <link rel="stylesheet" href="/assets/forms.css">
17
+ <link rel="stylesheet" href="/assets/objectives.css">
17
18
  <style>
18
19
  /* Icon masks shared by the phone info bar and the all view's cards (the stroke only shapes the mask; the icon takes currentColor); the image icon is composer.css's. */
19
20
  :root{--icon-archive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E");--icon-unarchive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
@@ -1788,6 +1789,7 @@ a{color:var(--accent)}
1788
1789
  <div id="stage">
1789
1790
  <div id="markdownWarning" class="markdown-warning" hidden></div>
1790
1791
  <div id="markdownTip" class="markdown-tip" hidden><span class="markdown-tip-text"></span><button type="button" class="markdown-tip-close" aria-label="Dismiss tip" title="Dismiss tip">×</button></div>
1792
+ <div id="objectivesStrip" hidden></div>
1791
1793
  <div id="history"></div>
1792
1794
  <div id="empty"></div>
1793
1795
  <div id="notice" role="status" hidden></div>
@@ -0,0 +1,80 @@
1
+ /* The session's objectives (objectives_strip.js, objectives_entry.js). Colours are tokens.css's only. */
2
+ #objectivesStrip{flex:none;padding:6px max(16px,calc((100% - 960px)/2));border-bottom:1px solid var(--border);background:color-mix(in srgb,var(--bg-panel) 60%,transparent)}
3
+ #objectivesStrip[hidden]{display:none}
4
+ .obj-strip{display:flex;align-items:center;gap:8px;min-height:32px}
5
+ .obj-chips{display:flex;flex-wrap:wrap;gap:6px;min-width:0;flex:1}
6
+ .obj-chip{display:inline-flex;align-items:baseline;gap:6px;max-width:280px;min-width:0;padding:3px 10px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-bg);color:var(--text-2);font-size:13px;line-height:1.4}
7
+ .obj-chip.done{background:transparent;border-color:var(--border-strong);color:var(--text-muted)}
8
+ .obj-id{flex:none;font:700 12px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--accent)}
9
+ .obj-chip.done .obj-id{color:var(--text-muted)}
10
+ .obj-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
11
+ .obj-count{flex:none;color:var(--text-muted);font-size:12px;font-variant-numeric:tabular-nums}
12
+ /* An objectives note in the conversation: pills for a list, a line for an edit. */
13
+ .bubble.note.objectives-entry{display:flex;flex-wrap:wrap;align-items:center;gap:6px;background:transparent;border:0;padding:2px 0;white-space:normal}
14
+ .obj-entry-label{color:var(--text-muted);font-size:12px;margin-right:2px}
15
+ .obj-pill{display:inline-block;padding:2px 10px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-bg);color:var(--text-2);font-size:13px;line-height:1.45}
16
+ .obj-pill.done{background:transparent;border-color:var(--border-strong);color:var(--text-muted)}
17
+ .bubble.note.objectives-entry.change{flex-wrap:nowrap;gap:10px;color:var(--text-muted);font-size:13px}
18
+ .obj-rule{flex:1 1 16px;min-width:16px;height:1px;background:var(--border-strong)}
19
+ .obj-change{min-width:0;text-align:center;overflow-wrap:anywhere}
20
+ /* The strip's chips open the edit list. */
21
+ button.obj-chip{cursor:pointer;font:inherit;font-size:13px;line-height:1.4;text-align:left}
22
+ @media(hover:hover){button.obj-chip:hover:not(:disabled){filter:none;border-color:var(--accent)}}
23
+ .obj-edit{flex:none;background:transparent;color:var(--text-muted);padding:3px 8px;font-size:12px}
24
+ @media(hover:hover){.obj-edit:hover:not(:disabled){filter:none;color:var(--text-bright)}}
25
+ .obj-waiting{flex:none;color:var(--warn-text);font-size:12px}
26
+ /* The edit list (chip_popover.js's shell, .ctx-popover; a bottom sheet on a phone). */
27
+ .obj-panel{display:flex;flex-direction:column;gap:10px;overflow:auto}
28
+ .obj-rows{display:flex;flex-direction:column;gap:2px}
29
+ .obj-row{display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-top:1px solid var(--border)}
30
+ .obj-row:first-child{border-top:0}
31
+ .obj-row .obj-id{padding-top:3px}
32
+ .obj-row-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
33
+ .obj-row-text{display:block;width:100%;padding:2px 0;background:transparent;color:var(--text);text-align:left;font-size:14px;line-height:1.4;border-radius:4px;white-space:normal;overflow-wrap:anywhere}
34
+ @media(hover:hover){.obj-row-text:hover:not(:disabled){filter:none;text-decoration:underline dotted var(--text-muted)}}
35
+ .obj-row.done .obj-row-text{color:var(--text-muted)}
36
+ .obj-row.dropped .obj-row-text{color:var(--text-faint);text-decoration:line-through}
37
+ .obj-row.done .obj-id,.obj-row.dropped .obj-id{color:var(--text-muted)}
38
+ .obj-row-sub{display:flex;flex-direction:column;gap:1px;color:var(--text-muted);font-size:12px;overflow-wrap:anywhere}
39
+ .obj-row-acts{flex:none;display:flex;gap:4px}
40
+ .obj-check.refuted{color:var(--warn-text)}
41
+ .obj-check.confirmed{color:var(--text-2)}
42
+ .obj-act{padding:3px 10px;font-size:12px;border-radius:6px}
43
+ .obj-act.ghost{background:var(--bg-raised);color:var(--text-2)}
44
+ form.obj-row{align-items:center}
45
+ .obj-input{flex:1;min-width:0;padding:5px 8px;border-radius:6px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font-size:14px}
46
+ .obj-add{display:flex;gap:6px;align-items:center}
47
+ .obj-note{color:var(--text-muted);font-size:12px}
48
+ /* A phone: one line, the objective being worked on and how far along, and the whole list a tap away in a bottom sheet (chip_popover.js), never pushing the conversation down. */
49
+ @media(max-width:600px){
50
+ #objectivesStrip{padding:4px 12px}
51
+ .obj-strip{min-height:36px}
52
+ .obj-chips{flex-wrap:nowrap}
53
+ .obj-chip:not(.first){display:none}
54
+ .obj-chip.first{flex:1;max-width:none;min-height:36px;align-items:center;border-radius:10px}
55
+ .obj-edit{padding:8px;min-height:36px}
56
+ .obj-waiting{max-width:90px;font-size:11px;line-height:1.2}
57
+ .obj-pill{font-size:12.5px}
58
+ .bubble.note.objectives-entry.change{font-size:12.5px;gap:6px}
59
+ }
60
+ .obj-panel.sheet{font-size:15px;max-height:85dvh}
61
+ .obj-panel.sheet .obj-row{align-items:flex-start;flex-wrap:wrap;padding:8px 0}
62
+ .obj-panel.sheet form.obj-row{align-items:center;flex-wrap:nowrap}
63
+ /* The text keeps the row's width and the buttons get their own line: a phone reads a reason. */
64
+ .obj-panel.sheet .obj-row-main{flex:1 1 calc(100% - 40px)}
65
+ .obj-panel.sheet .obj-row-text{font-size:15px;min-height:32px}
66
+ .obj-panel.sheet .obj-row-acts{flex:1 1 100%;flex-wrap:wrap;justify-content:flex-end;gap:6px}
67
+ .obj-panel.sheet .obj-act{min-height:44px;min-width:56px;padding:0 8px;font-size:14px;border-radius:10px}
68
+ .obj-panel.sheet .obj-input{min-height:44px;font-size:16px;border-radius:10px}
69
+ .obj-panel.sheet .obj-note{font-size:12px}
70
+ @media (prefers-reduced-motion:reduce){.obj-chip,.obj-act{transition:none}}
71
+ /* A move: a turn's line above its answer, a tool row's chip, a decision card's "Concerns". */
72
+ .obj-pill.obj-mark{padding:0 7px;font:700 11.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--accent)}
73
+ .obj-moves{display:flex;flex-direction:column;gap:2px;margin-bottom:-4px;font-size:13px}
74
+ .obj-move-line{display:flex;align-items:baseline;gap:8px;color:var(--text-2);flex-wrap:wrap}
75
+ .obj-move-note{min-width:0;flex:1 1 200px;overflow-wrap:anywhere}
76
+ .obj-concerns .obj-pill{margin-right:4px}
77
+ .obj-notyet{flex:none}
78
+ .obj-moves:not(.newest) .obj-notyet{display:none}
79
+ @media (pointer:coarse){.obj-notyet{min-height:36px}}
80
+ .obj-short{display:flex;justify-content:flex-end;margin-top:-6px}