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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// The session's objectives above the conversation: the fold's current state
|
|
2
|
+
// (Objectives.view), from the session read (seed) and live from the stream's
|
|
3
|
+
// objectives_updated (update). No list: the element stays hidden, nothing on
|
|
4
|
+
// the page. A tap opens the edit list (chip_popover.js: a bottom sheet on a
|
|
5
|
+
// phone): add, done, drop, undo and a new text, each a /objectives line on the
|
|
6
|
+
// command route, the one way to an edit, so its refusals are the command's. No
|
|
7
|
+
// message and no turn; the worker's next event redraws (every tab's), and a
|
|
8
|
+
// mid-turn edit shows as waiting until the turn's end. A module of its own:
|
|
9
|
+
// static JS is cached at unversioned URLs.
|
|
10
|
+
import { createChipPopover, SHEET_TALL } from "./chip_popover.js";
|
|
11
|
+
import { addLine, checkText, DONE, DROPPED, editableText, expireChecks, headline, markLine, nextExpiry, notYetText, pendingText, progress, rewordLine, stripItems } from "./objectives_model.js";
|
|
12
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
13
|
+
|
|
14
|
+
const EXPIRY_SLACK_MS = 50;
|
|
15
|
+
const EXPIRY_MAX_MS = 140_000;
|
|
16
|
+
|
|
17
|
+
// @param el the strip's element (index.html #objectivesStrip)
|
|
18
|
+
// @param session () the open session's id
|
|
19
|
+
// @param running () a turn runs
|
|
20
|
+
// @param sendLine (line) posts it as the session's command: a promise
|
|
21
|
+
// @param sendMessage (text) sends it as the user's message: a promise (Not yet)
|
|
22
|
+
// @param failed (error) a refused post, to tell the user
|
|
23
|
+
// @param onChange () the list came or went
|
|
24
|
+
export function createObjectivesStrip({ el, session = () => null, running = () => false, sendLine = async () => {},
|
|
25
|
+
sendMessage = async () => {}, failed = () => {}, onChange = () => {}, doc = document, win = window }) {
|
|
26
|
+
let list = null;
|
|
27
|
+
// The item whose text is being edited; while a post is out.
|
|
28
|
+
let editing = null;
|
|
29
|
+
let busy = false;
|
|
30
|
+
// The ids Not yet was sent for, since the list last changed or the panel closed.
|
|
31
|
+
const told = new Set();
|
|
32
|
+
let expiry = null;
|
|
33
|
+
|
|
34
|
+
// +first+: the one chip a phone's one-line strip keeps (objectives.css).
|
|
35
|
+
function chip(item, first) {
|
|
36
|
+
return html`<button type="button" class=${`obj-chip obj-open ${item.status}${first ? " first" : ""}`} data-id=${item.id} title=${item.text} aria-expanded="false"><span class="obj-id">${item.id}</span><span class="obj-text">${item.status === DONE ? "✓ " : ""}${item.text}</span></button>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function draw() {
|
|
40
|
+
el.hidden = !list;
|
|
41
|
+
if (!list) {
|
|
42
|
+
render(nothing, el);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const { done, total } = progress(list);
|
|
46
|
+
const waiting = pendingText(list, running());
|
|
47
|
+
render(html`<div class="obj-strip" role="group" aria-label="Objectives">
|
|
48
|
+
<div class="obj-chips">${stripItems(list).map((item) => chip(item, item === headline(list)))}</div>
|
|
49
|
+
${waiting ? html`<span class="obj-waiting" role="status">${waiting}</span>` : nothing}
|
|
50
|
+
<span class="obj-count">${done}/${total}</span>
|
|
51
|
+
<button type="button" class="obj-edit obj-open" aria-expanded="false">Edit</button>
|
|
52
|
+
</div>`, el);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function post(line) {
|
|
56
|
+
if (!line || busy) return;
|
|
57
|
+
busy = true;
|
|
58
|
+
drawPanel();
|
|
59
|
+
Promise.resolve(sendLine(line)).catch(failed).finally(() => {
|
|
60
|
+
busy = false;
|
|
61
|
+
drawPanel();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function subline(item) {
|
|
66
|
+
const parts = [];
|
|
67
|
+
if (item.done_when) parts.push(html`<span>done when: ${item.done_when}</span>`);
|
|
68
|
+
if (item.move) parts.push(html`<span>last move: ${item.move.note}${item.move.evidence ? ` (${item.move.evidence})` : ""}</span>`);
|
|
69
|
+
const check = checkText(item.check);
|
|
70
|
+
if (check) parts.push(html`<span class=${`obj-check ${item.check.verdict || "running"}`} title=${item.check.model ? `Read by ${item.check.model}. Information only.` : "Information only."}>${check}</span>`);
|
|
71
|
+
return parts.length ? html`<div class="obj-row-sub">${parts}</div>` : nothing;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function actions(item) {
|
|
75
|
+
const act = (name, label, title, off = false) => html`<button type="button" class="obj-act ghost" data-obj-act=${name} data-id=${item.id} title=${title} ?disabled=${busy || off}>${label}</button>`;
|
|
76
|
+
// A status button stays apart from it: a check never changes one.
|
|
77
|
+
const check = item.move && item.status !== DROPPED
|
|
78
|
+
? [act("check", "Check this", `One side request reads this move against the session's record. Information only, nothing enters the conversation`, !!item.check?.running)]
|
|
79
|
+
: [];
|
|
80
|
+
if (item.status === "active") {
|
|
81
|
+
const notYet = item.move ? [act("notyet", "Not yet", `Tell the agent: not yet on ${item.id}`, told.has(item.id))] : [];
|
|
82
|
+
return [...check, ...notYet, act("done", "Done", `Mark ${item.id} done`), act("drop", "Drop", `Drop ${item.id}`)];
|
|
83
|
+
}
|
|
84
|
+
return [...check, act("undo", "Undo", `Back to active: ${item.id}`)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function row(item) {
|
|
88
|
+
const klass = `obj-row ${item.status}`;
|
|
89
|
+
if (editing === item.id) {
|
|
90
|
+
return html`<form class=${klass} data-id=${item.id}>
|
|
91
|
+
<span class="obj-id">${item.id}</span>
|
|
92
|
+
<input class="obj-input" name="text" .value=${editableText(item)} aria-label=${`New text of ${item.id}`} autocomplete="off">
|
|
93
|
+
<button type="submit" class="obj-act" ?disabled=${busy}>Save</button>
|
|
94
|
+
<button type="button" class="obj-act ghost" data-obj-act="cancel">Cancel</button>
|
|
95
|
+
</form>`;
|
|
96
|
+
}
|
|
97
|
+
return html`<div class=${klass} data-row=${item.id}>
|
|
98
|
+
<span class="obj-id">${item.id}</span>
|
|
99
|
+
<div class="obj-row-main">
|
|
100
|
+
<button type="button" class="obj-row-text" data-obj-act="reword" data-id=${item.id} title=${`Edit the text of ${item.id}`}>${item.status === DONE ? "✓ " : ""}${item.text}</button>
|
|
101
|
+
${subline(item)}
|
|
102
|
+
</div>
|
|
103
|
+
<div class="obj-row-acts">${actions(item)}</div>
|
|
104
|
+
</div>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const panelTemplate = () => {
|
|
108
|
+
const waiting = pendingText(list, running());
|
|
109
|
+
return html`<div class="ctx-pop-head"><span class="ctx-pop-kind">Objectives</span><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
|
|
110
|
+
<div class="obj-rows">${list.items.map(row)}</div>
|
|
111
|
+
<form class="obj-add" data-add="1">
|
|
112
|
+
<input class="obj-input" name="text" placeholder="Add an objective" aria-label="Add an objective" autocomplete="off">
|
|
113
|
+
<button type="submit" class="obj-act" ?disabled=${busy}>Add</button>
|
|
114
|
+
</form>
|
|
115
|
+
<div class="obj-note" role="status">${waiting || "No message, no turn: the agent reads the change before its next turn."}</div>`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const shell = createChipPopover({
|
|
119
|
+
id: "objectivesPanel", className: "ctx-popover obj-panel", width: 560, el, anchor: ".obj-open", summary: () => list,
|
|
120
|
+
session, label: "Objectives", sheet: true, sheetShare: SHEET_TALL, doc, win,
|
|
121
|
+
show: () => render(panelTemplate(), shell.pop),
|
|
122
|
+
onOpen: () => {
|
|
123
|
+
const id = shell.opener?.()?.dataset?.id;
|
|
124
|
+
const target = id && shell.pop.querySelector(`[data-row="${id}"] .obj-row-text`);
|
|
125
|
+
(target || shell.pop.querySelector(".obj-add .obj-input"))?.focus();
|
|
126
|
+
},
|
|
127
|
+
onClose: () => {
|
|
128
|
+
editing = null;
|
|
129
|
+
told.clear();
|
|
130
|
+
},
|
|
131
|
+
onSubmit: (form) => {
|
|
132
|
+
const text = form.elements.text.value;
|
|
133
|
+
if (form.dataset.add) {
|
|
134
|
+
post(addLine(text));
|
|
135
|
+
form.reset();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const line = rewordLine(form.dataset.id, text);
|
|
139
|
+
editing = null;
|
|
140
|
+
// A blank text takes the edit back.
|
|
141
|
+
if (line) post(line);
|
|
142
|
+
else drawPanel();
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function drawPanel() {
|
|
147
|
+
if (shell.isOpen() && list) render(panelTemplate(), shell.pop);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
shell.pop.addEventListener("click", (e) => {
|
|
151
|
+
const button = e.target.closest("[data-obj-act]");
|
|
152
|
+
if (!button) return;
|
|
153
|
+
const { objAct: act, id } = button.dataset;
|
|
154
|
+
if (act === "reword") {
|
|
155
|
+
editing = id;
|
|
156
|
+
drawPanel();
|
|
157
|
+
const input = shell.pop.querySelector(`form[data-id="${id}"] .obj-input`);
|
|
158
|
+
input?.focus();
|
|
159
|
+
input?.select();
|
|
160
|
+
} else if (act === "cancel") {
|
|
161
|
+
editing = null;
|
|
162
|
+
drawPanel();
|
|
163
|
+
} else if (act === "notyet") {
|
|
164
|
+
notYet(id);
|
|
165
|
+
} else {
|
|
166
|
+
post(markLine(act, id));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
// Esc in the text being edited takes that edit back, not the panel.
|
|
170
|
+
shell.pop.addEventListener("keydown", (e) => {
|
|
171
|
+
if (e.key === "Escape" && editing && e.target.closest("form[data-id]")) {
|
|
172
|
+
e.stopPropagation();
|
|
173
|
+
editing = null;
|
|
174
|
+
drawPanel();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// One tap: a normal message the agent reads at its next step, or as the next
|
|
179
|
+
// turn. Any tab's echo is the stream's.
|
|
180
|
+
// @return {Promise<boolean>} false: it didn't go (a failure, already told)
|
|
181
|
+
async function notYet(id) {
|
|
182
|
+
const item = list?.items.find((i) => i.id === id);
|
|
183
|
+
if (!item || told.has(id)) return false;
|
|
184
|
+
told.add(id);
|
|
185
|
+
drawPanel();
|
|
186
|
+
try {
|
|
187
|
+
await sendMessage(notYetText(item));
|
|
188
|
+
return true;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
told.delete(id);
|
|
191
|
+
failed(e);
|
|
192
|
+
drawPanel();
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A running check whose worker died gets no event: let it go at its time. The
|
|
198
|
+
// cap keeps a page whose clock is off from holding it for long.
|
|
199
|
+
function armExpiry() {
|
|
200
|
+
win.clearTimeout(expiry);
|
|
201
|
+
const at = nextExpiry(list);
|
|
202
|
+
if (at === null) return;
|
|
203
|
+
expiry = win.setTimeout(() => {
|
|
204
|
+
list = expireChecks(list, Math.max(Date.now(), at));
|
|
205
|
+
draw();
|
|
206
|
+
drawPanel();
|
|
207
|
+
armExpiry();
|
|
208
|
+
}, Math.min(Math.max(at - Date.now(), 0) + EXPIRY_SLACK_MS, EXPIRY_MAX_MS));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The list without what a check adds: its start and end aren't a change to it.
|
|
212
|
+
const listSig = (view) => JSON.stringify(view?.items?.map(({ check: _check, ...item }) => item) ?? null);
|
|
213
|
+
|
|
214
|
+
const take = (view) => {
|
|
215
|
+
const next = view && Array.isArray(view.items) ? expireChecks(view, Date.now()) : null;
|
|
216
|
+
if (listSig(next) !== listSig(list)) told.clear();
|
|
217
|
+
list = next;
|
|
218
|
+
armExpiry();
|
|
219
|
+
draw();
|
|
220
|
+
shell.refresh();
|
|
221
|
+
onChange();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
// A session read's list; none clears it.
|
|
226
|
+
seed: take,
|
|
227
|
+
// The stream's objectives_updated.
|
|
228
|
+
update: take,
|
|
229
|
+
// No session open.
|
|
230
|
+
reset: () => {
|
|
231
|
+
if (shell.isOpen()) shell.close();
|
|
232
|
+
take(null);
|
|
233
|
+
},
|
|
234
|
+
notYet,
|
|
235
|
+
hasList: () => !!list,
|
|
236
|
+
// A turn started or ended: the waiting line's words.
|
|
237
|
+
redraw: () => {
|
|
238
|
+
draw();
|
|
239
|
+
drawPanel();
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The objectives' strip and moves, wired to the page's routes, so that app.js
|
|
2
|
+
// holds a few lines of them (its size cap). Every post follows the worker it woke
|
|
3
|
+
// only while the page still shows that session (latest.js), as cards_place.js does.
|
|
4
|
+
// A module of its own: static JS is cached at unversioned URLs.
|
|
5
|
+
import { stillOn } from "./latest.js";
|
|
6
|
+
import { cantText } from "./format.js";
|
|
7
|
+
import { createObjectivesMoves } from "./objectives_moves.js";
|
|
8
|
+
import { createObjectivesStrip } from "./objectives_strip.js";
|
|
9
|
+
|
|
10
|
+
// @param el the strip's element
|
|
11
|
+
// @param historyEl the history
|
|
12
|
+
// @param session () the open session's id
|
|
13
|
+
// @param running () a turn runs
|
|
14
|
+
// @param holding () the stage holds a finished turn (its answer, the newest, isn't in the history yet)
|
|
15
|
+
// @param commands () the session's command listing
|
|
16
|
+
// @param clientId this tab's
|
|
17
|
+
// @param sendCommand, sendTurn data.js's
|
|
18
|
+
// @param follow () follows a worker a post woke
|
|
19
|
+
// @param toast (text, action, options)
|
|
20
|
+
// @return {seed, update, reset, redraw, notYet}
|
|
21
|
+
export function createObjectives({ el, historyEl, session, running, holding = () => false, commands, clientId, sendCommand, sendTurn, follow, toast }) {
|
|
22
|
+
const posted = async (request) => {
|
|
23
|
+
const still = stillOn(session);
|
|
24
|
+
await request(session());
|
|
25
|
+
if (still()) follow();
|
|
26
|
+
};
|
|
27
|
+
const told = (what) => (e) => toast(cantText(what, e), null, { sticky: true });
|
|
28
|
+
|
|
29
|
+
const strip = createObjectivesStrip({
|
|
30
|
+
el, session, running,
|
|
31
|
+
// A tap's edit goes as a card's action does: no echo, the worker woken.
|
|
32
|
+
sendLine: (line) => posted((id) => sendCommand(id, line, { clientId, card: true })),
|
|
33
|
+
// Not yet: a tab of its own as far as the echo goes, so the stream draws it here too.
|
|
34
|
+
sendMessage: (text) => posted((id) => sendTurn(id, text, { clientId: `${clientId}:objectives`, history: false })),
|
|
35
|
+
failed: told("edit the objectives"),
|
|
36
|
+
onChange: () => moves.decorate(),
|
|
37
|
+
});
|
|
38
|
+
const moves = createObjectivesMoves({
|
|
39
|
+
historyEl, notYet: (id) => strip.notYet(id),
|
|
40
|
+
// Not during a turn: its answer isn't there yet, and /short refuses it. Nor
|
|
41
|
+
// with a turn still in the stage: the history's last answer isn't the newest,
|
|
42
|
+
// which is what /short reads. The hand-off fills the history, which redraws this.
|
|
43
|
+
canShorten: () => strip.hasList() && !running() && !holding() && (commands() || []).some((c) => c?.name === "/short"),
|
|
44
|
+
// false: a failure, already told: the button comes back.
|
|
45
|
+
shorten: () => posted((id) => sendCommand(id, "/short", { clientId, card: true })).then(() => true, (e) => {
|
|
46
|
+
told("ask for it")(e);
|
|
47
|
+
return false;
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
seed: strip.seed,
|
|
53
|
+
update: strip.update,
|
|
54
|
+
reset: strip.reset,
|
|
55
|
+
notYet: strip.notYet,
|
|
56
|
+
// A turn started or ended.
|
|
57
|
+
redraw: () => {
|
|
58
|
+
strip.redraw();
|
|
59
|
+
moves.decorate();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -326,6 +326,8 @@ export function createStreamHandlers(deps) {
|
|
|
326
326
|
// one's bubble was drawn at its command_queued: filled in here. One
|
|
327
327
|
// queued for after the turn ran after it: its bubble moves from among
|
|
328
328
|
// the turn's rows to after its answer and timing line.
|
|
329
|
+
// A card's action the worker refused: its buttons come back.
|
|
330
|
+
if (data.card && data.status === "error") deps.cardActionFailed?.(data.line);
|
|
329
331
|
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
330
332
|
if (view.hidden) queued?.remove();
|
|
331
333
|
else if (queued && view.queued && !view.anytime) {
|
|
@@ -360,10 +362,13 @@ export function createStreamHandlers(deps) {
|
|
|
360
362
|
context_added: (data) => {
|
|
361
363
|
const at = data.at || data.created_at || null;
|
|
362
364
|
gapBefore(at);
|
|
363
|
-
addNoteBubble({ label: data.label, content: data.text || "", at });
|
|
365
|
+
addNoteBubble({ label: data.label, content: data.text || "", at, objectives: data.objectives });
|
|
364
366
|
// An attached context source's note: its chip changed.
|
|
365
367
|
if (data.context_source) contextChips.refresh();
|
|
366
368
|
},
|
|
369
|
+
// The session's list changed (objectives_strip.js): an edit waiting, a
|
|
370
|
+
// list note in, a turn's moves.
|
|
371
|
+
objectives_updated: (data) => deps.objectivesUpdated?.(data),
|
|
367
372
|
// Someone joined, left or was revoked (the worker's ParticipantsWatch):
|
|
368
373
|
// the owner's chip and panel read the list again.
|
|
369
374
|
participants_changed: () => participantsControl?.reload(),
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { toolName } from "./activity.js";
|
|
7
7
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
8
8
|
import { commandBlockTemplate, rowHover } from "./command_view.js";
|
|
9
|
+
import { objectivesEntryTemplate } from "./objectives_entry.js";
|
|
10
|
+
import { moveRowText } from "./objectives_moves.js";
|
|
9
11
|
import { copySource } from "./copy.js";
|
|
10
12
|
import { activityDiffTemplate } from "./diff_view.js";
|
|
11
13
|
import { clip, fullTime, steerSender } from "./format.js";
|
|
@@ -158,7 +160,7 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
|
|
|
158
160
|
const ref = shown ? rowTitleRef(row) : null;
|
|
159
161
|
const attr = (value) => (value ? String(value) : nothing);
|
|
160
162
|
const params = shown
|
|
161
|
-
? html`<span class=${`activity-params${ref?.kind ? " ref" : ""}`} title=${hover || nothing} data-ref-kind=${attr(ref?.kind)} data-ref-path=${attr(ref?.kind && ref.path)} data-ref-line=${attr(ref?.kind && ref.line)} data-ref-end-line=${attr(ref?.kind && ref.end_line)}>${shown}</span>`
|
|
163
|
+
? html`<span class=${`activity-params${ref?.kind ? " ref" : ""}`} title=${hover || nothing} data-ref-kind=${attr(ref?.kind)} data-ref-path=${attr(ref?.kind && ref.path)} data-ref-line=${attr(ref?.kind && ref.line)} data-ref-end-line=${attr(ref?.kind && ref.end_line)}>${moveRowText(row, shown)}</span>`
|
|
162
164
|
: nothing;
|
|
163
165
|
// The name the model called the tool by when it was an alias's, or the
|
|
164
166
|
// tool it was reached through (call_tool), dim.
|
|
@@ -280,6 +282,7 @@ export function answerBubbleTemplate(message, { cls = "", at = null, turnId = nu
|
|
|
280
282
|
// in; the model reads it, nobody asked it anything): who sent it, then its
|
|
281
283
|
// text.
|
|
282
284
|
export function noteBubbleTemplate(note) {
|
|
285
|
+
if (note?.objectives) return objectivesEntryTemplate(note, timeTitle(note.at));
|
|
283
286
|
const from = note?.label ? `note from ${note.label}` : "note";
|
|
284
287
|
return html`<div class="bubble note" title=${timeTitle(note?.at)}><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
|
|
285
288
|
}
|
|
@@ -6,6 +6,7 @@ require "uri"
|
|
|
6
6
|
require_relative "../analytics_file"
|
|
7
7
|
require_relative "../answer_refs"
|
|
8
8
|
require_relative "../answer_tail"
|
|
9
|
+
require_relative "../objectives"
|
|
9
10
|
require_relative "../output_formatter"
|
|
10
11
|
require_relative "../question_asked"
|
|
11
12
|
require_relative "../guardrails/approvals"
|
|
@@ -91,10 +92,20 @@ module Samagotchi
|
|
|
91
92
|
# The stream cursor `<seq>-<epoch>`: a later worker resets it
|
|
92
93
|
# instead of taking the seq as its own. Only the snapshot has it.
|
|
93
94
|
last_event_id: turn_snapshot && turn_snapshot["event_id"],
|
|
94
|
-
timing: timing
|
|
95
|
+
timing: timing,
|
|
96
|
+
**objectives_field(id, raw_messages)
|
|
95
97
|
})
|
|
96
98
|
end
|
|
97
99
|
|
|
100
|
+
# The session's list (the page's strip), and no key with no list: a
|
|
101
|
+
# session without one reads as it always did. A snapshot's entries have
|
|
102
|
+
# string keys (paired_runs reads symbols).
|
|
103
|
+
def objectives_field(id, messages)
|
|
104
|
+
symbols = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
|
|
105
|
+
view = Samagotchi::Objectives.view(id, symbols, state_dir: default_state_dir)
|
|
106
|
+
view ? { objectives: view } : {}
|
|
107
|
+
end
|
|
108
|
+
|
|
98
109
|
# A list in the worker's turn +snapshot+ (queued prompts, commands,
|
|
99
110
|
# init tasks); [] without a worker.
|
|
100
111
|
def snapshot_list(snapshot, key) = Array(snapshot&.[](key))
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -1203,6 +1203,9 @@ module Samagotchi
|
|
|
1203
1203
|
save_or_log(:command) { save_session } unless Array(result.changed).empty?
|
|
1204
1204
|
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
|
|
1205
1205
|
anytime: true, confirm: result.confirm)
|
|
1206
|
+
# It may have left a note (/objectives): an idle loop takes it now, not
|
|
1207
|
+
# at the fallback tick. Harmless otherwise: one more pass.
|
|
1208
|
+
@waker.wake
|
|
1206
1209
|
rescue StandardError => e
|
|
1207
1210
|
Log.warn(:worker, "anytime_command_failed", line: command.line, error: e.class.name, msg: e.message)
|
|
1208
1211
|
end
|