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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -1
- data/docs/architecture.md +1 -1
- data/docs/bundles.md +38 -1
- data/docs/cli.md +17 -0
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +2 -1
- 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 +42 -0
- data/docs/system-one.md +17 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
- 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 +7 -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/guardrails/pair_boundary.rb +3 -2
- 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/session_commands.rb +15 -0
- data/lib/samagotchi/session_continue.rb +3 -0
- 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/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 +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:},
|
|
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
|
|
@@ -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
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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}
|