samagotchi 0.147.0 → 0.149.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +2 -2
- data/docs/architecture.md +1 -1
- data/docs/bundles.md +64 -17
- data/docs/cli.md +18 -0
- data/docs/configuration.md +7 -3
- data/docs/guardrails.md +27 -5
- data/docs/hooks.md +2 -1
- data/docs/internals/objectives.md +264 -0
- data/docs/internals/web-frontend.md +4 -1
- data/docs/plugins.md +4 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +44 -2
- data/docs/system-one.md +18 -2
- data/lib/samagotchi/answer_tail.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/guardrails/merge.yml +21 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +9 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +5 -3
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +44 -13
- data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
- data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +12 -1
- data/lib/samagotchi/context_note.rb +17 -5
- data/lib/samagotchi/engine.rb +113 -6
- data/lib/samagotchi/engine_peers.rb +11 -0
- data/lib/samagotchi/guardrail_wiring.rb +11 -6
- data/lib/samagotchi/guardrails/approvals.rb +3 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
- data/lib/samagotchi/guardrails/rules.rb +6 -1
- data/lib/samagotchi/guardrails/verdict.rb +21 -1
- data/lib/samagotchi/guardrails_command.rb +47 -3
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +6 -2
- data/lib/samagotchi/objectives.rb +395 -0
- data/lib/samagotchi/objectives_audit.rb +115 -0
- data/lib/samagotchi/objectives_check.rb +335 -0
- data/lib/samagotchi/objectives_command.rb +192 -0
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +21 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_chain.rb +91 -8
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_continue.rb +6 -2
- data/lib/samagotchi/session_inbox.rb +14 -6
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/steer.rb +24 -4
- data/lib/samagotchi/system_one/record.rb +17 -3
- data/lib/samagotchi/testing/boundary.rb +1 -0
- data/lib/samagotchi/testing/host.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +17 -0
- data/lib/samagotchi/tool_declarations.rb +34 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/objectives_tool.rb +75 -0
- data/lib/samagotchi/tools/peers.rb +3 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/message_display.rb +9 -0
- data/lib/samagotchi/web/participant_view.rb +1 -1
- data/lib/samagotchi/web/public/app.js +10 -0
- data/lib/samagotchi/web/public/cards_place.js +20 -4
- data/lib/samagotchi/web/public/commands.js +4 -1
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/objectives.css +80 -0
- data/lib/samagotchi/web/public/objectives_entry.js +22 -0
- data/lib/samagotchi/web/public/objectives_model.js +100 -0
- data/lib/samagotchi/web/public/objectives_moves.js +145 -0
- data/lib/samagotchi/web/public/objectives_strip.js +242 -0
- data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -1
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/session_show.rb +12 -1
- data/lib/samagotchi/worker.rb +3 -0
- data/lib/samagotchi/worker_inbound.rb +16 -8
- metadata +16 -1
|
@@ -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}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// An objectives note in the conversation, in place of the plain note bubble
|
|
2
|
+
// (turn_html.js noteBubbleTemplate): the list as pills, or, for a later edit, one
|
|
3
|
+
// line saying what the user changed. Read-only: the strip above the history is
|
|
4
|
+
// where the list is edited (objectives_strip.js). The raw note text stays the
|
|
5
|
+
// tooltip. A module of its own: static JS is cached at unversioned URLs.
|
|
6
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
7
|
+
import { DROPPED, entryLine } from "./objectives_model.js";
|
|
8
|
+
|
|
9
|
+
function pill(item) {
|
|
10
|
+
const klass = `obj-pill ${item.status}`;
|
|
11
|
+
return html`<span class=${klass} title=${item.done_when ? `done when: ${item.done_when}` : nothing}><span class="obj-id">${item.id}</span> ${item.status === "done" ? "✓ " : ""}${item.text}</span>`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function objectivesEntryTemplate(note, timeTitle = "") {
|
|
15
|
+
const line = entryLine(note.objectives);
|
|
16
|
+
const tip = [timeTitle, note.content].filter(Boolean).join("\n");
|
|
17
|
+
if (line) {
|
|
18
|
+
return html`<div class="bubble note objectives-entry change" title=${tip}><span class="obj-rule" aria-hidden="true"></span><span class="obj-change">${line}</span><span class="obj-rule" aria-hidden="true"></span></div>`;
|
|
19
|
+
}
|
|
20
|
+
const items = (note.objectives?.items || []).filter((i) => i.status !== DROPPED);
|
|
21
|
+
return html`<div class="bubble note objectives-entry" title=${tip}><span class="obj-entry-label">Objectives</span>${items.map(pill)}</div>`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The session's objectives as the page reads them (pure, no DOM): the fold the
|
|
2
|
+
// server sends (Objectives.view: {items: [{id, text, status, done_when?,
|
|
3
|
+
// move?, check?}], pending}) and what a list note's entry says (Objectives.entry:
|
|
4
|
+
// {items, changes?}). See docs/internals/objectives.md.
|
|
5
|
+
|
|
6
|
+
export const ACTIVE = "active";
|
|
7
|
+
export const DONE = "done";
|
|
8
|
+
export const DROPPED = "dropped";
|
|
9
|
+
|
|
10
|
+
const itemsOf = (list) => (Array.isArray(list?.items) ? list.items : []);
|
|
11
|
+
|
|
12
|
+
// The objectives the strip shows: active first, then done; a dropped one only
|
|
13
|
+
// in the edit list.
|
|
14
|
+
export function stripItems(list) {
|
|
15
|
+
const items = itemsOf(list);
|
|
16
|
+
return [...items.filter((i) => i.status === ACTIVE), ...items.filter((i) => i.status === DONE)];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Done of the ones still meant (a dropped one doesn't count).
|
|
20
|
+
export function progress(list) {
|
|
21
|
+
const kept = itemsOf(list).filter((i) => i.status !== DROPPED);
|
|
22
|
+
return { done: kept.filter((i) => i.status === DONE).length, total: kept.length };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The one objective a one-line strip names: the first still active, else the
|
|
26
|
+
// last one done.
|
|
27
|
+
export function headline(list) {
|
|
28
|
+
const items = stripItems(list);
|
|
29
|
+
return items.find((i) => i.status === ACTIVE) || items[items.length - 1] || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One change as words after "You": "marked O2 done", "reworded O3: old → new".
|
|
33
|
+
export function changeText(change) {
|
|
34
|
+
const { id, kind, text, was } = change || {};
|
|
35
|
+
switch (kind) {
|
|
36
|
+
case "added": return `added ${id} ${text}`;
|
|
37
|
+
case "reworded": return `reworded ${id}: ${was} → ${text}`;
|
|
38
|
+
case DONE: return `marked ${id} done`;
|
|
39
|
+
case DROPPED: return `dropped ${id} ${text}`;
|
|
40
|
+
case "reopened": return `reopened ${id}`;
|
|
41
|
+
default: return String(id ?? "");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// An entry's line: what the user did, or null when it has nothing to say (a
|
|
46
|
+
// first list, a list sent again), which then shows the list itself.
|
|
47
|
+
export function entryLine(entry) {
|
|
48
|
+
const changes = Array.isArray(entry?.changes) ? entry.changes : [];
|
|
49
|
+
return changes.length ? `You ${changes.map(changeText).join(" · ")}` : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The one route to an edit: the lines /objectives takes (ObjectivesCommand).
|
|
53
|
+
const oneLine = (text) => String(text ?? "").replace(/\s+/g, " ").trim();
|
|
54
|
+
export const markLine = (verb, id) => `/objectives ${verb} ${id}`;
|
|
55
|
+
export const addLine = (text) => (oneLine(text) ? `/objectives add ${oneLine(text)}` : null);
|
|
56
|
+
export const rewordLine = (id, text) => (oneLine(text) ? `/objectives edit ${id} ${oneLine(text)}` : null);
|
|
57
|
+
|
|
58
|
+
// An item's text as the edit box shows it: the form /objectives reads back.
|
|
59
|
+
export function editableText(item) {
|
|
60
|
+
return item?.done_when ? `${item.text} (done when: ${item.done_when})` : String(item?.text ?? "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// What the strip says about an edit waiting for the running turn's end. Idle,
|
|
64
|
+
// it lands in a moment and says nothing.
|
|
65
|
+
export function pendingText(list, running) {
|
|
66
|
+
return list?.pending && running ? "Applies after this turn" : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A check's line in the list: its verdict and reason, or that it runs. Information
|
|
70
|
+
// only (ObjectivesCheck). null: no check of the latest move.
|
|
71
|
+
export function checkText(check) {
|
|
72
|
+
if (!check) return null;
|
|
73
|
+
if (check.running) return "Checking…";
|
|
74
|
+
const word = check.verdict === "no_answer" ? "no answer" : check.verdict;
|
|
75
|
+
return `Check: ${word}${check.reason ? ` — ${check.reason}` : ""}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// When the soonest running check runs out, in ms; null with none. No event says so
|
|
79
|
+
// when its worker dies.
|
|
80
|
+
export function nextExpiry(list) {
|
|
81
|
+
const times = itemsOf(list).filter((i) => i.check?.running && i.check.until).map((i) => Date.parse(i.check.until)).filter(Number.isFinite);
|
|
82
|
+
return times.length ? Math.min(...times) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// +list+ without the running checks past their time.
|
|
86
|
+
export function expireChecks(list, now) {
|
|
87
|
+
if (!list) return list;
|
|
88
|
+
const gone = (i) => i.check?.running && Date.parse(i.check.until) <= now;
|
|
89
|
+
if (!itemsOf(list).some(gone)) return list;
|
|
90
|
+
return { ...list, items: list.items.map((i) => {
|
|
91
|
+
if (!gone(i)) return i;
|
|
92
|
+
const { check: _gone, ...rest } = i;
|
|
93
|
+
return rest;
|
|
94
|
+
}) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// What a tap on Not yet tells the agent: a normal message, as if typed.
|
|
98
|
+
export function notYetText(item) {
|
|
99
|
+
return `Not yet on ${item.id} (${item.text}).`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The model's moves on the objectives, as chips in the conversation. A tool row
|
|
2
|
+
// of the objectives tool shows its id as a chip (moveRowText, turn_html.js, live
|
|
3
|
+
// and reloaded alike), but a finished turn's steps collapse, so a finished turn
|
|
4
|
+
// also gets a line of its moves between its block and its answer (mockup G). The
|
|
5
|
+
// rows are the record: one observer on the history reads them back, as
|
|
6
|
+
// answer_fold.js decorates its answers, so every path that fills it is covered.
|
|
7
|
+
// Nothing is drawn for a turn with no move. A decision-card's "Concerns O2" gets
|
|
8
|
+
// the same chip. The newest long answer of a session with a list offers its
|
|
9
|
+
// short version where the bundle's /short is there (decision-card): the card
|
|
10
|
+
// lands under it, so the button sits there too. A module of its own: static JS is cached at unversioned URLs.
|
|
11
|
+
import { foldable } from "./answer_fold.js";
|
|
12
|
+
import { keepFollowing } from "./scroll.js";
|
|
13
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
14
|
+
|
|
15
|
+
const ROW_TITLE = /^(O\d+)\s+([^]*)$/;
|
|
16
|
+
const RECORDED = /Recorded a move on /;
|
|
17
|
+
const CONCERNS = /^Concerns (O\d+)\b/;
|
|
18
|
+
|
|
19
|
+
// A row's move: {id, note}, null for any other row, and for a call that recorded
|
|
20
|
+
// nothing (no list, a refusal: Tools::ObjectivesTool).
|
|
21
|
+
export function moveOfRow(row) {
|
|
22
|
+
if (row?.tool !== "objectives" || !RECORDED.test(String(row.output || ""))) return null;
|
|
23
|
+
const found = ROW_TITLE.exec(String(row.title || row.params || ""));
|
|
24
|
+
return found ? { id: found[1], note: found[2] } : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The row's title, its objective a chip, marked for the line above an answer
|
|
28
|
+
// when the call recorded a move.
|
|
29
|
+
export function moveRowText(row, shown) {
|
|
30
|
+
if (row?.tool !== "objectives") return shown;
|
|
31
|
+
const found = ROW_TITLE.exec(String(shown ?? ""));
|
|
32
|
+
if (!found) return shown;
|
|
33
|
+
const move = moveOfRow(row);
|
|
34
|
+
return html`<span class="obj-pill obj-mark" data-move-id=${move ? move.id : nothing} data-move-note=${move ? move.note : nothing}>${found[1]}</span> ${found[2]}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A finished turn block's moves, in order.
|
|
38
|
+
export function movesInBlock(block) {
|
|
39
|
+
return [...block.querySelectorAll("[data-move-id]")].map((el) => ({ id: el.dataset.moveId, note: el.dataset.moveNote || "" }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A card body's "Concerns O2 …" paragraph: the id a chip. Idempotent.
|
|
43
|
+
export function chipConcerns(body) {
|
|
44
|
+
for (const p of body.querySelectorAll("p")) {
|
|
45
|
+
const first = p.firstChild;
|
|
46
|
+
if (first?.nodeType !== 3) continue;
|
|
47
|
+
const found = CONCERNS.exec(first.textContent);
|
|
48
|
+
if (!found) continue;
|
|
49
|
+
const chip = body.ownerDocument.createElement("span");
|
|
50
|
+
chip.className = "obj-pill obj-mark";
|
|
51
|
+
chip.textContent = found[1];
|
|
52
|
+
first.textContent = first.textContent.slice(found[0].length);
|
|
53
|
+
p.insertBefore(chip, first);
|
|
54
|
+
p.classList.add("obj-concerns");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function lineFor(block) {
|
|
59
|
+
const next = block.nextElementSibling;
|
|
60
|
+
return next?.classList.contains("obj-moves") ? next : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// @param historyEl the history
|
|
64
|
+
// @param notYet (id) the tap on a move's Not yet (the newest turn's only: CSS)
|
|
65
|
+
// @param canShorten () whether the newest long answer offers its short version
|
|
66
|
+
// @param shorten () the tap on it
|
|
67
|
+
export function createObjectivesMoves({ historyEl, notYet = () => {}, canShorten = () => false, shorten = () => {} }) {
|
|
68
|
+
// Off once tapped; back when +ask+ answers false (a failure, already told).
|
|
69
|
+
const once = (e, ask) => {
|
|
70
|
+
const button = e.currentTarget;
|
|
71
|
+
button.disabled = true;
|
|
72
|
+
Promise.resolve(ask()).then((sent) => {
|
|
73
|
+
if (sent === false) button.disabled = false;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const drawLine = (line, moves) => render(html`${moves.map((m) => html`<div class="obj-move-line">
|
|
77
|
+
<span class="obj-pill obj-mark">${m.id}</span><span class="obj-move-note">${m.note}</span>
|
|
78
|
+
<button type="button" class="obj-act ghost obj-notyet" title=${`Tell the agent: not yet on ${m.id}`} @click=${(e) => once(e, () => notYet(m.id))}>Not yet</button>
|
|
79
|
+
</div>`)}`, line);
|
|
80
|
+
|
|
81
|
+
const markNewest = () => {
|
|
82
|
+
const lines = [...historyEl.querySelectorAll(":scope > .obj-moves")];
|
|
83
|
+
lines.forEach((line, i) => line.classList.toggle("newest", i === lines.length - 1));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// After an answer's own rows (its Revise row, its timing line: the bubble's
|
|
87
|
+
// next siblings, one of which CSS reads by adjacency).
|
|
88
|
+
function shortRowPlace(answer) {
|
|
89
|
+
let place = answer;
|
|
90
|
+
while (place.nextElementSibling?.matches(".revise-row, .turn-timing")) place = place.nextElementSibling;
|
|
91
|
+
return place;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function placeShort() {
|
|
95
|
+
const answers = canShorten() ? historyEl.querySelectorAll(":scope > .bubble.output:not(.streaming)") : [];
|
|
96
|
+
const answer = answers[answers.length - 1];
|
|
97
|
+
const long = answer && foldable(answer.dataset.copySource || answer.textContent);
|
|
98
|
+
let row = historyEl.querySelector(":scope > .obj-short");
|
|
99
|
+
if (!long) {
|
|
100
|
+
row?.remove();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!row) {
|
|
104
|
+
row = historyEl.ownerDocument.createElement("div");
|
|
105
|
+
row.className = "obj-short";
|
|
106
|
+
render(html`<button type="button" class="obj-act ghost" title="Ask for the short version; nothing enters the conversation"
|
|
107
|
+
@click=${(e) => once(e, shorten)}>Short version</button>`, row);
|
|
108
|
+
}
|
|
109
|
+
const place = shortRowPlace(answer);
|
|
110
|
+
if (place.nextElementSibling !== row) {
|
|
111
|
+
row.querySelector("button").disabled = false;
|
|
112
|
+
keepFollowing(historyEl, () => place.after(row));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decorate() {
|
|
117
|
+
for (const block of historyEl.querySelectorAll(":scope > .turn-work")) {
|
|
118
|
+
const moves = movesInBlock(block);
|
|
119
|
+
const line = lineFor(block);
|
|
120
|
+
if (!moves.length) {
|
|
121
|
+
line?.remove();
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const sig = JSON.stringify(moves);
|
|
125
|
+
if (line?.dataset.sig === sig) continue;
|
|
126
|
+
keepFollowing(historyEl, () => {
|
|
127
|
+
const el = line || historyEl.ownerDocument.createElement("div");
|
|
128
|
+
el.className = "obj-moves";
|
|
129
|
+
el.dataset.sig = sig;
|
|
130
|
+
drawLine(el, moves);
|
|
131
|
+
if (!line) block.after(el);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
markNewest();
|
|
135
|
+
placeShort();
|
|
136
|
+
for (const card of historyEl.querySelectorAll(".plugin-card .card-body")) {
|
|
137
|
+
if (card.closest(".plugin-card").querySelector(".card-source")?.textContent === "decision-card") chipConcerns(card);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The class toggle isn't a childList change, so this doesn't feed itself.
|
|
142
|
+
new MutationObserver(decorate).observe(historyEl, { childList: true, subtree: true });
|
|
143
|
+
decorate();
|
|
144
|
+
return { decorate };
|
|
145
|
+
}
|