samagotchi 0.147.0 → 0.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +2 -2
  4. data/docs/architecture.md +1 -1
  5. data/docs/bundles.md +64 -17
  6. data/docs/cli.md +18 -0
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +27 -5
  9. data/docs/hooks.md +2 -1
  10. data/docs/internals/objectives.md +264 -0
  11. data/docs/internals/web-frontend.md +4 -1
  12. data/docs/plugins.md +4 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +44 -2
  15. data/docs/system-one.md +18 -2
  16. data/lib/samagotchi/answer_tail.rb +1 -1
  17. data/lib/samagotchi/bundles/coordinator/guardrails/merge.yml +21 -0
  18. data/lib/samagotchi/bundles/coordinator/manifest.yml +9 -5
  19. data/lib/samagotchi/bundles/coordinator/plugin.rb +5 -3
  20. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +44 -13
  21. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  22. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  23. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +12 -1
  26. data/lib/samagotchi/context_note.rb +17 -5
  27. data/lib/samagotchi/engine.rb +113 -6
  28. data/lib/samagotchi/engine_peers.rb +11 -0
  29. data/lib/samagotchi/guardrail_wiring.rb +11 -6
  30. data/lib/samagotchi/guardrails/approvals.rb +3 -1
  31. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  32. data/lib/samagotchi/guardrails/rules.rb +6 -1
  33. data/lib/samagotchi/guardrails/verdict.rb +21 -1
  34. data/lib/samagotchi/guardrails_command.rb +47 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  36. data/lib/samagotchi/note_command.rb +1 -1
  37. data/lib/samagotchi/note_delivery.rb +6 -2
  38. data/lib/samagotchi/objectives.rb +395 -0
  39. data/lib/samagotchi/objectives_audit.rb +115 -0
  40. data/lib/samagotchi/objectives_check.rb +335 -0
  41. data/lib/samagotchi/objectives_command.rb +192 -0
  42. data/lib/samagotchi/plugin/api.rb +2 -1
  43. data/lib/samagotchi/plugin/context.rb +21 -1
  44. data/lib/samagotchi/run_options.rb +1 -0
  45. data/lib/samagotchi/send_command.rb +2 -1
  46. data/lib/samagotchi/session_chain.rb +91 -8
  47. data/lib/samagotchi/session_commands.rb +16 -1
  48. data/lib/samagotchi/session_continue.rb +6 -2
  49. data/lib/samagotchi/session_inbox.rb +14 -6
  50. data/lib/samagotchi/session_manager.rb +1 -1
  51. data/lib/samagotchi/steer.rb +24 -4
  52. data/lib/samagotchi/system_one/record.rb +17 -3
  53. data/lib/samagotchi/testing/boundary.rb +1 -0
  54. data/lib/samagotchi/testing/host.rb +1 -0
  55. data/lib/samagotchi/tool_activity.rb +17 -0
  56. data/lib/samagotchi/tool_declarations.rb +34 -0
  57. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  58. data/lib/samagotchi/tools/builtins.rb +4 -1
  59. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  60. data/lib/samagotchi/tools/peers.rb +3 -0
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/message_display.rb +9 -0
  63. data/lib/samagotchi/web/participant_view.rb +1 -1
  64. data/lib/samagotchi/web/public/app.js +10 -0
  65. data/lib/samagotchi/web/public/cards_place.js +20 -4
  66. data/lib/samagotchi/web/public/commands.js +4 -1
  67. data/lib/samagotchi/web/public/index.html +2 -0
  68. data/lib/samagotchi/web/public/objectives.css +80 -0
  69. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  70. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  71. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  72. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  73. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  74. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  75. data/lib/samagotchi/web/public/turn_html.js +4 -1
  76. data/lib/samagotchi/web/session_show.rb +12 -1
  77. data/lib/samagotchi/worker.rb +3 -0
  78. data/lib/samagotchi/worker_inbound.rb +16 -8
  79. metadata +16 -1
@@ -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))
@@ -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
@@ -9,6 +9,7 @@ require_relative "client_id"
9
9
  require_relative "config"
10
10
  require_relative "context_note"
11
11
  require_relative "log"
12
+ require_relative "objectives"
12
13
  require_relative "session"
13
14
  require_relative "session_inbox"
14
15
  require_relative "steer"
@@ -113,19 +114,26 @@ module Samagotchi
113
114
  # note the saved conversation already holds. A failed save keeps the
114
115
  # files too: the next pass claims them again, finds them in the
115
116
  # conversation and saves again. Not activity: a note alone neither
116
- # starts a turn nor keeps an idle worker up.
117
+ # starts a turn nor keeps an idle worker up. Of the objectives notes in
118
+ # a batch only the newest goes in: each is a whole list, so the older
119
+ # ones (edits made during a turn) would only be sent for nothing.
117
120
  def absorb_notes
118
121
  files = SessionInbox.find_new_note_files(@session_dir)
119
122
  return if files.empty? || @stopped.call
120
123
 
121
- claimed = files.filter_map { |file| SessionInbox.claim_note_file(file) }
122
- claimed.each do |file|
123
- note = SessionInbox.read_note(file)
124
- @engine.add_context_note(@session, note) if note
124
+ # Under the objectives' lock: an edit reads the conversation and then the
125
+ # pending file, so it must see this whole step or none of it.
126
+ Objectives.with_lock(@session_dir) do
127
+ claimed = files.filter_map { |file| SessionInbox.claim_note_file(file) }
128
+ notes = claimed.filter_map { |file| SessionInbox.read_note(file) }
129
+ newest_list = notes.reverse.find { |note| note[:objectives] }
130
+ notes.each do |note|
131
+ @engine.add_context_note(@session, note) if !note[:objectives] || note.equal?(newest_list)
132
+ end
133
+ next unless save(:notes)
134
+
135
+ claimed.each { |file| FileUtils.rm_f(file) }
125
136
  end
126
- return unless save(:notes)
127
-
128
- claimed.each { |file| FileUtils.rm_f(file) }
129
137
  end
130
138
 
131
139
  # The attached context's notes (ContextAbsorber), between turns. Not
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.147.0
4
+ version: 0.149.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -163,6 +163,7 @@ files:
163
163
  - docs/internals/llm-context-bench.md
164
164
  - docs/internals/llm-context-forget.md
165
165
  - docs/internals/llm-context-live.md
166
+ - docs/internals/objectives.md
166
167
  - docs/internals/participants.md
167
168
  - docs/internals/prompt-caching.md
168
169
  - docs/internals/remote-sessions.md
@@ -241,12 +242,15 @@ files:
241
242
  - lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md
242
243
  - lib/samagotchi/bundles/codex-peer/scripts/pair
243
244
  - lib/samagotchi/bundles/codex-peer/skill_codex_peer.md
245
+ - lib/samagotchi/bundles/coordinator/guardrails/merge.yml
244
246
  - lib/samagotchi/bundles/coordinator/manifest.yml
245
247
  - lib/samagotchi/bundles/coordinator/plugin.rb
246
248
  - lib/samagotchi/bundles/coordinator/skill_coordinator.md
247
249
  - lib/samagotchi/bundles/core/manifest.yml
248
250
  - lib/samagotchi/bundles/cost-cap/manifest.yml
249
251
  - lib/samagotchi/bundles/cost-cap/plugin.rb
252
+ - lib/samagotchi/bundles/decision-card/manifest.yml
253
+ - lib/samagotchi/bundles/decision-card/plugin.rb
250
254
  - lib/samagotchi/bundles/dev/manifest.yml
251
255
  - lib/samagotchi/bundles/github-pr/manifest.yml
252
256
  - lib/samagotchi/bundles/github-pr/plugin.rb
@@ -504,6 +508,10 @@ files:
504
508
  - lib/samagotchi/muted_memories.rb
505
509
  - lib/samagotchi/note_command.rb
506
510
  - lib/samagotchi/note_delivery.rb
511
+ - lib/samagotchi/objectives.rb
512
+ - lib/samagotchi/objectives_audit.rb
513
+ - lib/samagotchi/objectives_check.rb
514
+ - lib/samagotchi/objectives_command.rb
507
515
  - lib/samagotchi/output_formatter.rb
508
516
  - lib/samagotchi/owner_lock.rb
509
517
  - lib/samagotchi/owner_prompt.rb
@@ -742,6 +750,7 @@ files:
742
750
  - lib/samagotchi/tools/list_reminders.rb
743
751
  - lib/samagotchi/tools/list_sessions.rb
744
752
  - lib/samagotchi/tools/memory.rb
753
+ - lib/samagotchi/tools/objectives_tool.rb
745
754
  - lib/samagotchi/tools/output_guardrails.rb
746
755
  - lib/samagotchi/tools/peers.rb
747
756
  - lib/samagotchi/tools/read.rb
@@ -871,6 +880,12 @@ files:
871
880
  - lib/samagotchi/web/public/notice_links.js
872
881
  - lib/samagotchi/web/public/notifications.js
873
882
  - lib/samagotchi/web/public/notify.js
883
+ - lib/samagotchi/web/public/objectives.css
884
+ - lib/samagotchi/web/public/objectives_entry.js
885
+ - lib/samagotchi/web/public/objectives_model.js
886
+ - lib/samagotchi/web/public/objectives_moves.js
887
+ - lib/samagotchi/web/public/objectives_strip.js
888
+ - lib/samagotchi/web/public/objectives_wiring.js
874
889
  - lib/samagotchi/web/public/participant.html
875
890
  - lib/samagotchi/web/public/participant.js
876
891
  - lib/samagotchi/web/public/participant_annotate.js