samagotchi 0.115.0 → 0.116.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/bin/chi +2 -0
  4. data/docs/cli.md +5 -1
  5. data/docs/configuration.md +7 -0
  6. data/docs/guardrails.md +188 -1
  7. data/docs/sessions.md +1 -1
  8. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
  9. data/lib/samagotchi/bridge.rb +26 -0
  10. data/lib/samagotchi/bridge_client.rb +7 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/config.rb +15 -0
  13. data/lib/samagotchi/engine.rb +6 -1
  14. data/lib/samagotchi/guardrail_wiring.rb +166 -2
  15. data/lib/samagotchi/guardrails/approval.rb +20 -0
  16. data/lib/samagotchi/guardrails/targets.rb +23 -2
  17. data/lib/samagotchi/guardrails/verdict.rb +23 -3
  18. data/lib/samagotchi/guardrails_command.rb +329 -0
  19. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
  20. data/lib/samagotchi/session_access/contract.rb +1 -1
  21. data/lib/samagotchi/session_commands.rb +79 -2
  22. data/lib/samagotchi/session_metrics.rb +7 -1
  23. data/lib/samagotchi/system_one/answer.rb +78 -0
  24. data/lib/samagotchi/system_one/approval_question.rb +113 -0
  25. data/lib/samagotchi/system_one/client.rb +89 -0
  26. data/lib/samagotchi/system_one/errors.rb +14 -0
  27. data/lib/samagotchi/system_one/fake_backend.rb +45 -0
  28. data/lib/samagotchi/system_one/http_backend.rb +129 -0
  29. data/lib/samagotchi/system_one/log.rb +133 -0
  30. data/lib/samagotchi/system_one/question.rb +86 -0
  31. data/lib/samagotchi/system_one/record.rb +83 -0
  32. data/lib/samagotchi/system_one/self_protect.rb +90 -0
  33. data/lib/samagotchi/system_one/similar.rb +69 -0
  34. data/lib/samagotchi/system_one.rb +25 -0
  35. data/lib/samagotchi/terminal_ui/attached_loop.rb +15 -4
  36. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
  37. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/app.rb +1 -0
  40. data/lib/samagotchi/web/public/activity.js +10 -0
  41. data/lib/samagotchi/web/public/app.js +3 -0
  42. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  43. data/lib/samagotchi/web/public/composer_fold.js +8 -8
  44. data/lib/samagotchi/web/public/composer_grip.js +65 -22
  45. data/lib/samagotchi/web/public/composer_size.js +30 -2
  46. data/lib/samagotchi/web/public/data.js +10 -0
  47. data/lib/samagotchi/web/public/favicon.svg +1 -1
  48. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  49. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  50. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  51. data/lib/samagotchi/web/public/index.html +5 -0
  52. data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
  53. data/lib/samagotchi/web/public/timing.js +9 -5
  54. data/lib/samagotchi/web/public/turn_events.js +1 -0
  55. data/lib/samagotchi/web/public/turn_html.js +13 -2
  56. data/lib/samagotchi/web/session_control.rb +22 -0
  57. data/lib/samagotchi/web/session_show.rb +35 -5
  58. metadata +15 -1
@@ -1,9 +1,9 @@
1
- import { GRIP_OPEN_PX, gripFloor, gripFolds } from "./composer_size.js";
1
+ import { GRIP_CLICK_FOLD_MS, GRIP_OPEN_PX, gripFloor, gripFolds, gripGesture } from "./composer_size.js";
2
2
  import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
3
3
  import { readStored, removeStored, writeStored } from "./storage.js";
4
4
 
5
- // The composer's grip and fold (used by app.js): the grip's drag, tap and keys, the
6
- // fold's state per visit and its page-wide listeners. app.js keeps the box's
5
+ // The composer's grip and fold (used by app.js): the grip's drag, click and keys,
6
+ // the fold's state per visit and its page-wide listeners. app.js keeps the box's
7
7
  // height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
8
8
  // fitPrompt reads; the floor is saved under GRIP_KEY.
9
9
  export const GRIP_KEY = "chi_composer_floor";
@@ -24,22 +24,44 @@ export function createComposerGrip({
24
24
  // past the fold threshold.
25
25
  const gripStartTitle = composeGrip.title;
26
26
  let gripWillFold = false;
27
+ // A click on the expanded grip folds GRIP_CLICK_FOLD_MS after it, so a
28
+ // second click in that window can still mean "reset the height" (a
29
+ // double-click). A drag or a key acts at once and takes the wait back.
30
+ let pendingFold = null;
27
31
  // This visit's state (a visit is one select() call): opened by the page
28
32
  // (a focus, a restored line), archived (decided once, from the visit's
29
33
  // first getSession answer).
30
34
  const newFoldVisit = () => ({ open: false, archived: false, decided: false });
31
35
  let foldVisit = newFoldVisit();
32
36
 
33
- // A drag down past the box folds the composer on release (a session only: the
34
- // start page's grip only resizes); from folded, a pull up opens it and the box
35
- // follows the pointer in the same gesture, a tap opens it and focuses the box.
36
- // No click listener: a click still fires after pointerdown's preventDefault
37
- // and would toggle twice.
37
+ function cancelPendingFold() {
38
+ if (!pendingFold) return;
39
+ clearTimeout(pendingFold);
40
+ pendingFold = null;
41
+ }
42
+
43
+ // The grip's custom height, forgotten only by a double-click.
44
+ function resetFloor() {
45
+ setFloor(null);
46
+ removeStored(GRIP_KEY);
47
+ fitPrompt();
48
+ }
49
+
50
+ // One release of the grip is one gesture, decided by composer_size.js's
51
+ // gripGesture: a drag down past the box folds the composer on release (a
52
+ // session only: the start page's grip only resizes); from folded, a pull up
53
+ // opens it and the box follows the pointer in the same gesture, a tap opens it
54
+ // and focuses the box; a click on the expanded grip folds it too, GRIP_CLICK_FOLD_MS
55
+ // after the release so a second click in that window can still reset the height.
56
+ // No click listener: a click still fires after pointerdown's preventDefault,
57
+ // so the release is the one that acts.
38
58
  composeGrip.addEventListener("pointerdown", (e) => {
39
59
  e.preventDefault();
40
60
  composeGrip.setPointerCapture(e.pointerId);
41
61
  composeGrip.classList.add("dragging");
42
62
  const tapY = e.clientY;
63
+ // The height the box had before this press: a drag that folds brings it back.
64
+ const floorBefore = floor();
43
65
  let startY = tapY;
44
66
  let startH = promptEl.offsetHeight;
45
67
  let opened = false;
@@ -76,17 +98,35 @@ export function createComposerGrip({
76
98
  };
77
99
  const up = (ev) => {
78
100
  const folds = end();
79
- if (folds) {
80
- // The next opening starts at the minimum (or the text's height).
81
- setFloor(null);
82
- removeStored(GRIP_KEY);
101
+ const gesture = gripGesture({
102
+ session: !!session(),
103
+ folded: isComposerFolded(),
104
+ dy: ev.clientY - tapY,
105
+ willFold: folds,
106
+ pending: !!pendingFold,
107
+ });
108
+ if (gesture === "fold") {
109
+ cancelPendingFold();
110
+ // The drag asked for a smaller box, but opening it brings back the
111
+ // height it had before the press: only a double-click forgets it.
112
+ setFloor(floorBefore);
113
+ writeStored(GRIP_KEY, String(floorBefore || ""));
83
114
  setComposerFolded(true);
84
- } else if (session() && isComposerFolded()) {
85
- if (Math.abs(ev.clientY - tapY) < GRIP_OPEN_PX) {
86
- setComposerFolded(false);
87
- promptEl.focus();
88
- }
89
- } else {
115
+ } else if (gesture === "open") {
116
+ cancelPendingFold();
117
+ setComposerFolded(false);
118
+ promptEl.focus();
119
+ } else if (gesture === "reset") {
120
+ resetFloor();
121
+ } else if (gesture === "hide") {
122
+ writeStored(GRIP_KEY, String(floor() || ""));
123
+ pendingFold = setTimeout(() => {
124
+ pendingFold = null;
125
+ setComposerFolded(true);
126
+ }, GRIP_CLICK_FOLD_MS);
127
+ } else if (gesture === "resize") {
128
+ // A drag that stops short of folding: keep the box at the height it asks for.
129
+ cancelPendingFold();
90
130
  writeStored(GRIP_KEY, String(floor() || ""));
91
131
  // A pull that opened the box leaves the focus in it, as a tap does.
92
132
  if (opened) promptEl.focus();
@@ -103,15 +143,16 @@ export function createComposerGrip({
103
143
  });
104
144
  composeGrip.addEventListener("dblclick", () => {
105
145
  if (session() && isComposerFolded()) return;
106
- setFloor(null);
107
- removeStored(GRIP_KEY);
108
- fitPrompt();
146
+ cancelPendingFold();
147
+ resetFloor();
109
148
  });
110
149
  // Enter or Space on the focused handle folds, or opens and focuses the box
111
- // (a session only; preventDefault: Space would scroll the page).
150
+ // (a session only; preventDefault: Space would scroll the page). At once:
151
+ // the keyboard has no double-click to wait for.
112
152
  composeGrip.addEventListener("keydown", (e) => {
113
153
  if (!session() || (e.key !== "Enter" && e.key !== " ")) return;
114
154
  e.preventDefault();
155
+ cancelPendingFold();
115
156
  if (isComposerFolded()) {
116
157
  setComposerFolded(false);
117
158
  promptEl.focus();
@@ -135,6 +176,8 @@ export function createComposerGrip({
135
176
  }
136
177
  function applyComposerFolded() {
137
178
  const folded = isComposerFolded();
179
+ // Anything else opened the composer: the click's wait for its fold is over.
180
+ if (!folded) cancelPendingFold();
138
181
  const was = doc.body.classList.contains("composer-folded");
139
182
  doc.body.classList.toggle("composer-folded", folded);
140
183
  // Its height was measured while hidden.
@@ -1,11 +1,16 @@
1
- // The composer's height. It grows with its text up to a cap; the grip on
2
- // its top edge sets a taller floor (the cap then is at least that floor).
1
+ // The composer's height and the gestures of the grip on its top edge. The
2
+ // height grows with the text up to a cap; the grip sets a taller floor (the
3
+ // cap then is at least that floor). A release of the grip is one gesture: a
4
+ // drag that resizes, a drag far enough down that folds the box away, or a
5
+ // click (which, expanded, folds too — after a delay, so a double-click can
6
+ // still reset the height).
3
7
  export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
4
8
  export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
5
9
  const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
6
10
  const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
7
11
  export const GRIP_FOLD_PX = 32; // a drag down this far past what the box shows folds the composer (on release)
8
12
  export const GRIP_OPEN_PX = 8; // a drag up this far from the folded handle opens it; less is a tap
13
+ export const GRIP_CLICK_FOLD_MS = 250; // how long a click on the expanded grip waits before folding, so a second click in that window can still mean a reset
9
14
 
10
15
  // contentPx: the text's height (scrollHeight plus borders); floorPx: the
11
16
  // grip's height, or null; minPx: the empty box's height.
@@ -28,3 +33,26 @@ export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
28
33
  export function gripFolds({ requestedPx, shownPx }) {
29
34
  return shownPx - requestedPx >= GRIP_FOLD_PX;
30
35
  }
36
+
37
+ // What a grip release asks for, decided from the press alone (the strip's
38
+ // grip's twin, strip.js's stripGripAction). +session+: is there a session (the
39
+ // start page's grip only resizes). +folded+: the composer is folded now.
40
+ // +dy+: the release y minus the press y, down positive. +willFold+: the drag is
41
+ // past GRIP_FOLD_PX below what the box shows. +pending+: an earlier click's fold
42
+ // is still waiting its GRIP_CLICK_FOLD_MS, so this release is the second click.
43
+ // fold – hide the box now
44
+ // open – a tap on the folded handle
45
+ // hide – the first click on the expanded grip: fold it after the delay
46
+ // reset – the second click in that delay: a double-click, so the height is reset
47
+ // resize – a drag that stops short: keep the box and save the floor it asks for
48
+ // none – a drag down on the folded handle (pulling up opens it while dragging)
49
+ export function gripGesture({ session, folded, dy, willFold, pending }) {
50
+ if (willFold) return "fold";
51
+ const tap = Math.abs(dy) < GRIP_OPEN_PX;
52
+ if (tap) {
53
+ if (folded) return "open";
54
+ if (!session) return "resize";
55
+ return pending ? "reset" : "hide";
56
+ }
57
+ return folded ? "none" : "resize";
58
+ }
@@ -180,6 +180,16 @@ export function dismissQuestion(id, questionId, opts = {}) {
180
180
  });
181
181
  }
182
182
 
183
+ // Flags a System 1 auto-allow in this session as one it should have asked
184
+ // about; the owner-only route returns 404 for an unknown judgment.
185
+ export function markSystemOneWrong(id, judgmentId, opts = {}) {
186
+ return api(`/api/sessions/${encodeURIComponent(id)}/guardrails/wrong`, {
187
+ method: "POST",
188
+ body: JSON.stringify({ id: judgmentId }),
189
+ ...opts,
190
+ });
191
+ }
192
+
183
193
  export function stopSession(id, opts = {}) {
184
194
  return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
185
195
  }
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7aa5ff"/><stop offset="1" stop-color="#8957e5"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1f6feb"/><stop offset="1" stop-color="#6e40c9"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
@@ -184,6 +184,11 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
184
184
  .ts-now button.task-stop{align-self:center}
185
185
  .activity-params{color:var(--text-muted);overflow-wrap:anywhere}
186
186
  .activity-alias{color:var(--text-faint);white-space:nowrap}
187
+ .activity-auto{color:var(--accent);font-size:11px;white-space:nowrap;cursor:help}
188
+ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;border:1px solid var(--border-strong);border-radius:6px;background:transparent;color:var(--text-muted);font-size:10px;line-height:16px;white-space:nowrap}
189
+ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:var(--text)}
190
+ .system-one-wrong.marked{border-color:var(--accent-border);color:var(--text-3)}
191
+ .activity-row.guardrail-wrong .activity-auto{color:var(--text-muted)}
187
192
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
188
193
  .activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
189
194
  .activity-output.tail{display:flex;flex-direction:column-reverse}
@@ -0,0 +1,32 @@
1
+ import { markSystemOneWrong } from "./data.js";
2
+
3
+ // The owner-only correction button on a System 1 auto-allowed tool row.
4
+ export function createSystemOneWrongAction({
5
+ sessionId,
6
+ mark = markSystemOneWrong,
7
+ alert = (text) => globalThis.alert(text),
8
+ }) {
9
+ return async (event) => {
10
+ const button = event.target.closest?.("button.system-one-wrong");
11
+ if (!button) return false;
12
+
13
+ event.preventDefault();
14
+ event.stopPropagation();
15
+ const id = sessionId();
16
+ if (!id || button.disabled) return false;
17
+
18
+ button.disabled = true;
19
+ try {
20
+ await mark(id, button.dataset.judgmentId);
21
+ button.textContent = "should have asked";
22
+ button.setAttribute("aria-pressed", "true");
23
+ button.classList.add("marked");
24
+ button.closest(".activity-row")?.classList.add("guardrail-wrong");
25
+ return true;
26
+ } catch (error) {
27
+ button.disabled = false;
28
+ alert(`Could not mark auto-allow: ${error.message}`);
29
+ return false;
30
+ }
31
+ };
32
+ }
@@ -274,18 +274,22 @@ function canceledSteps(g, record, tools) {
274
274
  // A call's row from its tool record alone (no saved parts): the record's
275
275
  // called_as and via too, so "(called as bash)" and "(via call_tool)" show
276
276
  // after a reload, as live, and
277
- // its no_match ("no match" on a grep that found nothing).
277
+ // its no_match ("no match" on a grep that found nothing) and System 1's
278
+ // auto-allow (guardrail: {rule, p}).
278
279
  function recordRows(tools, iteration) {
279
280
  return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
280
281
  key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
281
282
  ...(r.called_as ? { called_as: r.called_as } : {}),
282
283
  ...(r.via ? { via: r.via } : {}),
283
- ...noMatch(r),
284
+ ...recordFlags(r),
284
285
  }));
285
286
  }
286
287
 
287
- function noMatch(record) {
288
- return record?.no_match ? { no_match: true } : {};
288
+ function recordFlags(record) {
289
+ return {
290
+ ...(record?.no_match ? { no_match: true } : {}),
291
+ ...(record?.guardrail ? { guardrail: record.guardrail } : {}),
292
+ };
289
293
  }
290
294
 
291
295
  // A turn whose saved messages carry their parts (the server's ?parts=1):
@@ -308,7 +312,7 @@ function partsTurn(g, items, record, tools, stepsOnly = false) {
308
312
  const rows = Array.isArray(parts.tools)
309
313
  ? parts.tools.map((call, j) => {
310
314
  const r = takeRecord(pool, call.tool);
311
- return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...noMatch(r), ...call };
315
+ return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...recordFlags(r), ...call };
312
316
  })
313
317
  : [];
314
318
  const thinking = String(parts.thinking || "");
@@ -411,6 +411,7 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
411
411
  };
412
412
  // A grep that found nothing: its row stays muted "no match".
413
413
  if (part.no_match) completed.activity.no_match = true;
414
+ if (part.guardrail) completed.activity.guardrail = part.guardrail;
414
415
  if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
415
416
  if (part.diff) completed.diff = part.diff;
416
417
  Object.assign(completed, fields);
@@ -145,7 +145,8 @@ function tailClip(text) {
145
145
  // part the user toggled (the raw command, the diff, the read lines) stays.
146
146
  // +stop+: the live row's stop-task button (a node), after the title.
147
147
  export function toolRowTemplate(row, opts = {}) {
148
- return html`<div class="activity-row" data-key=${row.key}>${toolRowInnerTemplate(row, opts)}</div>`;
148
+ const wrong = row.guardrail?.should_have_asked ? " guardrail-wrong" : "";
149
+ return html`<div class=${`activity-row${wrong}`} data-key=${row.key}>${toolRowInnerTemplate(row, opts)}</div>`;
149
150
  }
150
151
 
151
152
  export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothing } = {}) {
@@ -163,13 +164,23 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
163
164
  // tool it was reached through (call_tool), dim.
164
165
  const alias = row.called_as ? html`<span class="activity-alias">(called as ${row.called_as})</span>`
165
166
  : row.via ? html`<span class="activity-alias">(via ${row.via})</span>` : nothing;
167
+ const auto = row.guardrail
168
+ ? html`<span class="activity-auto" title=${`rule ${row.guardrail.rule}, p=${Number(row.guardrail.p).toFixed(2)}, allowed once`}>auto-allowed · System 1</span>`
169
+ : nothing;
170
+ const wrong = row.guardrail?.judgment_id
171
+ ? row.guardrail.should_have_asked
172
+ ? html`<span class="system-one-wrong marked"
173
+ title="System 1 was told this call should have been asked about">should have asked</span>`
174
+ : html`<button type="button" class="system-one-wrong" data-judgment-id=${row.guardrail.judgment_id}
175
+ title="Tell System 1 this call should have been asked about">should have asked</button>`
176
+ : nothing;
166
177
  const duration = formatDuration(row.duration_ms);
167
178
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
168
179
  const output = full
169
180
  ? html`<div class=${tailMode(row.tool, full) ? "activity-output tail" : "activity-output"} title=${full}>${inlineOutput(row.tool, full)}</div>`
170
181
  : nothing;
171
182
  const status = statusParts(row.status, { noMatch: !!row.no_match });
172
- return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
183
+ return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${auto}${wrong}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
173
184
  }
174
185
 
175
186
  // A prompt bubble's states and their badges: queued (waiting for its
@@ -107,6 +107,28 @@ module Samagotchi
107
107
  end
108
108
  end
109
109
 
110
+ # The owner's correction for a System 1 auto-allow. The Bridge checks
111
+ # that the judgment belongs to this worker's session.
112
+ def handle_system_one_wrong(req, id)
113
+ body = parse_json(request_body(req))
114
+ return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
115
+
116
+ judgment_id = body["id"].to_s.strip
117
+ return error_response(400, "missing_fields", "id is required") if judgment_id.empty?
118
+
119
+ request = ->(client) { client.mark_system_one_wrong(judgment_id: judgment_id) }
120
+ relay(id, bridge_client(id), request, what: "the System 1 judgment was not labelled",
121
+ cant: "label System 1 judgments") do |reply|
122
+ case reply.status
123
+ when 200 then json_response(200, { status: "labelled", id: judgment_id })
124
+ when 404
125
+ error_response(404, "unknown_judgment", "unknown System 1 judgment") if reply.json&.dig("error") == "unknown_judgment"
126
+ when 409 then error_response(409, "already_labelled", "System 1 judgment already has a label")
127
+ when 400 then error_response(400, "invalid_judgment", reply_detail(reply, "invalid System 1 judgment"))
128
+ end
129
+ end
130
+ end
131
+
110
132
  # A session command typed in the composer (/model, /models, !rollback,
111
133
  # !cmd, /continue): the worker runs it (woken as for a turn) and every
112
134
  # UI gets its :command_ran. Needs the worker's Bridge.
@@ -8,9 +8,11 @@ require_relative "../answer_refs"
8
8
  require_relative "../answer_tail"
9
9
  require_relative "../output_formatter"
10
10
  require_relative "../question_asked"
11
+ require_relative "../guardrails/approvals"
11
12
  require_relative "../session_commands"
12
13
  require_relative "../session_metrics"
13
14
  require_relative "../session_setting"
15
+ require_relative "../system_one/log"
14
16
 
15
17
  module Samagotchi
16
18
  module Web
@@ -300,7 +302,7 @@ module Samagotchi
300
302
  persisted
301
303
  end
302
304
  turns = Array(source["turn_records"])
303
- timing_from(source, turns, Array(source["tool_records"]), live: live,
305
+ timing_from(source, turns, Array(source["tool_records"]), session_id: session_id, live: live,
304
306
  turn_count: (live && turn_count(live_metrics)) || turns.size)
305
307
  end
306
308
 
@@ -321,13 +323,13 @@ module Samagotchi
321
323
  # An older worker's metrics hold every record (before they held the
322
324
  # recent ones only, never more than LIVE_RECORDS_CAP turns).
323
325
  turns, tools = newest_turn_records(turns, tools, live_metrics["active_turn"]) if turns.size > SessionMetrics::LIVE_RECORDS_CAP
324
- return timing_from(live_metrics, turns, tools, live: true, turn_count: count)
326
+ return timing_from(live_metrics, turns, tools, session_id: session_id, live: true, turn_count: count)
325
327
  end
326
328
 
327
329
  persisted = read_analytics(session_id)
328
330
  all = Array(persisted["turn_records"])
329
331
  turns, tools = newest_turn_records(all, Array(persisted["tool_records"]), nil)
330
- timing_from(persisted, turns, tools, live: false, turn_count: all.size)
332
+ timing_from(persisted, turns, tools, session_id: session_id, live: false, turn_count: all.size)
331
333
  end
332
334
 
333
335
  # The finished turns a live worker counts: its metrics' turns (the
@@ -349,7 +351,7 @@ module Samagotchi
349
351
  [kept, tools.select { |r| r.is_a?(Hash) && ids.include?(r["turn_id"]) }]
350
352
  end
351
353
 
352
- def timing_from(source, turn_records, tool_records, live:, turn_count:)
354
+ def timing_from(source, turn_records, tool_records, session_id:, live:, turn_count:)
353
355
  started_at = source["started_at"]
354
356
  last_activity_at = source["last_activity_at"]
355
357
  {
@@ -357,7 +359,7 @@ module Samagotchi
357
359
  last_activity_at: last_activity_at,
358
360
  session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live),
359
361
  turn_records: turn_records,
360
- tool_records: tool_records,
362
+ tool_records: system_one_labelled_records(session_id, tool_records),
361
363
  turn_count: turn_count,
362
364
  active_turn: source["active_turn"],
363
365
  active_tools: Array(source["active_tools"]),
@@ -376,6 +378,34 @@ module Samagotchi
376
378
  }
377
379
  end
378
380
 
381
+ # A saved analytics record knows the judgment id, not the later label:
382
+ # the judgments log remains the source of truth across reloads.
383
+ def system_one_labelled_records(session_id, records)
384
+ ids = records.filter_map do |record|
385
+ record.dig("guardrail", "judgment_id") if record.is_a?(Hash) && record["guardrail"].is_a?(Hash)
386
+ end.to_h { |id| [id, true] }
387
+ return records if ids.empty?
388
+
389
+ log = SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(default_state_dir))
390
+ return records unless File.file?(log.path)
391
+
392
+ labelled = log.entries.each_with_object({}) do |entry, result|
393
+ next unless ids[entry["id"]] && entry["session_id"] == session_id && entry["decision"] == "auto" &&
394
+ entry["answer"] == "should_have_asked"
395
+
396
+ result[entry["id"]] = true
397
+ end
398
+ return records if labelled.empty?
399
+
400
+ records.map do |record|
401
+ guardrail = record.is_a?(Hash) && record["guardrail"]
402
+ judgment_id = guardrail.is_a?(Hash) && guardrail["judgment_id"]
403
+ next record unless labelled[judgment_id]
404
+
405
+ record.merge("guardrail" => guardrail.merge("should_have_asked" => true))
406
+ end
407
+ end
408
+
379
409
  def read_analytics(session_id)
380
410
  AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
381
411
  end
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.115.0
4
+ version: 0.116.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -362,6 +362,7 @@ files:
362
362
  - lib/samagotchi/guardrails/shell_paths.rb
363
363
  - lib/samagotchi/guardrails/targets.rb
364
364
  - lib/samagotchi/guardrails/verdict.rb
365
+ - lib/samagotchi/guardrails_command.rb
365
366
  - lib/samagotchi/hook_question.rb
366
367
  - lib/samagotchi/hook_routes.rb
367
368
  - lib/samagotchi/hooks.rb
@@ -576,6 +577,18 @@ files:
576
577
  - lib/samagotchi/steer.rb
577
578
  - lib/samagotchi/steer_cut.rb
578
579
  - lib/samagotchi/steer_cut_after.rb
580
+ - lib/samagotchi/system_one.rb
581
+ - lib/samagotchi/system_one/answer.rb
582
+ - lib/samagotchi/system_one/approval_question.rb
583
+ - lib/samagotchi/system_one/client.rb
584
+ - lib/samagotchi/system_one/errors.rb
585
+ - lib/samagotchi/system_one/fake_backend.rb
586
+ - lib/samagotchi/system_one/http_backend.rb
587
+ - lib/samagotchi/system_one/log.rb
588
+ - lib/samagotchi/system_one/question.rb
589
+ - lib/samagotchi/system_one/record.rb
590
+ - lib/samagotchi/system_one/self_protect.rb
591
+ - lib/samagotchi/system_one/similar.rb
579
592
  - lib/samagotchi/system_prompt.rb
580
593
  - lib/samagotchi/task_finish_note.rb
581
594
  - lib/samagotchi/task_watch.rb
@@ -816,6 +829,7 @@ files:
816
829
  - lib/samagotchi/web/public/strip.js
817
830
  - lib/samagotchi/web/public/strip_grip.js
818
831
  - lib/samagotchi/web/public/sw.js
832
+ - lib/samagotchi/web/public/system_one_wrong.js
819
833
  - lib/samagotchi/web/public/tally.js
820
834
  - lib/samagotchi/web/public/task_stop.js
821
835
  - lib/samagotchi/web/public/thinking_chip.js