samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -28,6 +28,13 @@ export function applySessionEvent(list, type, data = {}) {
28
28
  return list;
29
29
  }
30
30
 
31
+ // The sessions a list shows: archived ones only when asked (the all view's
32
+ // "include archived"). The page keeps them in its list either way: a parent
33
+ // chip and the open session still need them.
34
+ export function listedSessions(list, includeArchived = false) {
35
+ return includeArchived ? list : list.filter((s) => !s.archived);
36
+ }
37
+
31
38
  const updatedMs = (s) => {
32
39
  const ms = Date.parse(s.updated_at || "");
33
40
  return Number.isNaN(ms) ? 0 : ms;
@@ -92,9 +92,12 @@ export function turnGroups(items, timing) {
92
92
  items.forEach((item, i) => {
93
93
  if (item?.role === "note") {
94
94
  groups.push({ kind: "note", i });
95
+ } else if (item?.role === "steer") {
96
+ if (turn) turn.steers.push(i);
97
+ else groups.push({ kind: "steer", i });
95
98
  } else if (item?.role === "user") {
96
99
  turnIndex += 1;
97
- turn = { kind: "turn", turnIndex, user: i, texts: [] };
100
+ turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [] };
98
101
  groups.push(turn);
99
102
  } else if (item?.role === "assistant") {
100
103
  if (turn) turn.texts.push(i);
@@ -103,22 +106,44 @@ export function turnGroups(items, timing) {
103
106
  });
104
107
  return groups.map((g) => {
105
108
  if (g.kind !== "turn") return g;
106
- const record = turnRecordAt(timing, g.turnIndex);
107
- const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
108
- const stepsOnly = canceledSteps(g, record, tools);
109
- if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
110
- const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
111
- const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
112
- const steps = [];
113
- for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
114
- const iteration = k + 1;
115
- steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
116
- }
117
- const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
118
- return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
109
+ return withSteers(turnGroup(g, items, timing), g.steers, items);
119
110
  });
120
111
  }
121
112
 
113
+ // A turn's steers (a plugin's nudges) into its steps: each goes to the step
114
+ // that answered it (`step`: the server counts the model messages since the
115
+ // prompt, plus one). One whose step isn't there (the answer, which leaves
116
+ // the block) stays on the group (`steers`): a bubble before the answer, as
117
+ // live. A turn without steers keeps its shape.
118
+ function withSteers(group, steerIndexes, items) {
119
+ if (!steerIndexes.length) return group;
120
+ const steps = group.steps.map((step) => ({ ...step, steers: [] }));
121
+ const left = [];
122
+ for (const i of steerIndexes) {
123
+ const n = Number(items[i].step) || 1;
124
+ const step = steps.find((s) => s.iteration === n);
125
+ if (step) step.steers.push(i);
126
+ else left.push(i);
127
+ }
128
+ return { ...group, steps, steers: left };
129
+ }
130
+
131
+ function turnGroup(g, items, timing) {
132
+ const record = turnRecordAt(timing, g.turnIndex);
133
+ const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
134
+ const stepsOnly = canceledSteps(g, record, tools);
135
+ if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
136
+ const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
137
+ const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
138
+ const steps = [];
139
+ for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
140
+ const iteration = k + 1;
141
+ steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
142
+ }
143
+ const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
144
+ return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
145
+ }
146
+
122
147
  // A canceled turn that ran more than one step or any call has no answer:
123
148
  // its partial text stays in the block, as it does live (turn_view.js
124
149
  // turnEnded promotes only a completed turn or a lone step with no tools).
@@ -45,10 +45,36 @@ export function reminderText(event) {
45
45
  }
46
46
 
47
47
  // Who a hook's notice is from: the bundle's name for a bundle hook
48
- // ("known_names.rb (bundle known-names)"), else "hook".
48
+ // ("known_names.rb (bundle known-names)"), chi's own notice by its word
49
+ // (thinking), else "hook".
49
50
  export function hookNoticeLabel(hook) {
50
- const m = /\(bundle (.+)\)$/.exec(String(hook || ""));
51
- return m ? m[1] : "hook";
51
+ const name = String(hook || "");
52
+ const m = /\(bundle (.+)\)$/.exec(name);
53
+ if (m) return m[1];
54
+ // chi's own notices name themselves with a bare word (thinking).
55
+ return /^[a-z][a-z0-9-]*$/.test(name) ? name : "hook";
56
+ }
57
+
58
+ // The row a step gets when the loop asks again after an empty answer.
59
+ export function emptyRetryLine({ attempt, of } = {}) {
60
+ return `↻ empty answer, asking again (${attempt}/${of})`;
61
+ }
62
+
63
+ // The live turn's status while the provider is asked again after an error
64
+ // (generation_retrying): "↻ retrying (503) in 4 s, 2/5" (retry 2 of 5).
65
+ export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class } = {}) {
66
+ const why = status || String(error_class || "").split("::").pop();
67
+ const delay = Number(next_delay);
68
+ const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
69
+ const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
70
+ return `↻ retrying${why ? ` (${why})` : ""}${when}${count}`;
71
+ }
72
+
73
+ // The live turn's status while it waits for plugins' slow setup before its
74
+ // first request (plugin_init_wait).
75
+ export function initWaitLine({ tasks } = {}) {
76
+ const labels = (tasks || []).map((t) => [t?.bundle, t?.label].filter(Boolean).join(": ")).filter(Boolean);
77
+ return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
52
78
  }
53
79
 
54
80
  // A composer line for the worker's command route rather than a prompt.
@@ -56,6 +82,20 @@ export function isCommandLine(text) {
56
82
  return /^[/!]/.test(String(text || "").trim());
57
83
  }
58
84
 
85
+ // The terminal's own commands that have a button in the web: the reply
86
+ // the page shows itself (they never go to the worker, which doesn't know
87
+ // them), or null.
88
+ const WEB_LOCAL_REPLIES = {
89
+ "/archive": "/archive: use the archive button in the session bar",
90
+ "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
91
+ "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
92
+ };
93
+
94
+ export function webLocalReply(text) {
95
+ const name = String(text || "").trim().split(/\s+/)[0].toLowerCase();
96
+ return WEB_LOCAL_REPLIES[name] || null;
97
+ }
98
+
59
99
  // The continue card's answers, as the terminal's continue prompt reads them.
60
100
  export function continueLine(choice, reason = "") {
61
101
  const why = String(reason || "").trim();
@@ -80,6 +120,8 @@ export function commandView(event, myId) {
80
120
  modelName: event.model_name || null,
81
121
  anytime: event.anytime === true,
82
122
  commandId: event.command_id || null,
123
+ // A card's action (Nudge…): no bubble unless it says something back.
124
+ hidden: event.card === true && !event.output && event.status !== "error",
83
125
  };
84
126
  }
85
127
 
@@ -100,6 +142,27 @@ export function restoreAction(event, { myId, sentIds, composerEmpty }) {
100
142
  }, sent && composerEmpty ? event.images : null);
101
143
  }
102
144
 
145
+ // A turn that fails fast can have its prompt_restored come over the stream
146
+ // before the /turn ack names its enqueued_id: `early` (a Map) keeps such a
147
+ // restore of this tab's own prompt by that id, and the ack takes it back
148
+ // (restoreOnAck), so the prompt goes into the composer the ack would clear.
149
+ export function keepEarlyRestore(event, early, { myId, sentIds }) {
150
+ const origin = event?.origin || {};
151
+ const id = origin.enqueued_id;
152
+ if (!isOwn(origin.client_id, myId) || !id || sentIds.has(id)) return false;
153
+ early.set(id, event);
154
+ return true;
155
+ }
156
+
157
+ // The ack's enqueued_id (already in sentIds) was restored early: what the
158
+ // composer gets back (as restoreAction for an empty composer), else null.
159
+ export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
160
+ const event = enqueuedId && early.get(enqueuedId);
161
+ if (!event) return null;
162
+ early.delete(enqueuedId);
163
+ return restoreAction(event, { myId, sentIds, composerEmpty: true });
164
+ }
165
+
103
166
  // What an event does to the prompt bubbles, as a list of ops for app.js:
104
167
  // add — a bubble for a prompt this tab hasn't shown (state queued /
105
168
  // started / steered, labelled by its sender)
@@ -172,10 +235,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
172
235
  if (part.label) call.label = part.label;
173
236
  events.push({ type: "tool_call_started", ...call, params: part.params });
174
237
  if (part.status !== "running") {
175
- events.push(withImages({
238
+ const completed = {
176
239
  type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
177
240
  activity: { status: part.status, params: part.params },
178
- }, part.images));
241
+ };
242
+ if (part.diff) completed.diff = part.diff;
243
+ events.push(withImages(completed, part.images));
179
244
  }
180
245
  break;
181
246
  }
@@ -191,6 +256,11 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
191
256
  closeText();
192
257
  events.push({ type: "hook_notice", hook: part.hook, text: part.text, level: part.level });
193
258
  break;
259
+ // A plugin's steer: a steer-only merge, as live.
260
+ case "steer":
261
+ closeText();
262
+ events.push({ type: "pending_input_merged", count: 0, content: null, steers: [{ source: part.source, text: part.text }] });
263
+ break;
194
264
  default:
195
265
  break;
196
266
  }
@@ -13,7 +13,8 @@ import { toolName } from "./activity.js";
13
13
  import { leavesBlock } from "./card.js";
14
14
  import { activityStatusClass, activityStatusLabel, fillActivityRow } from "./chat_view.js";
15
15
  import { copySourceAttr } from "./copy.js";
16
- import { escapeHtml, messageBodyHtml, noteHtml, normalize } from "./format.js";
16
+ import { activityDiffHtml } from "./diff_view.js";
17
+ import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
17
18
  import { shouldFollowScroll } from "./scroll.js";
18
19
  import { hookNoticeLabel } from "./turn_events.js";
19
20
  import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
@@ -29,6 +30,8 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
29
30
  let blockToggled = false;
30
31
  // gen → { el, summary, thinking, text, activity, rows: Map(key → el), toggled }
31
32
  const genEls = new Map();
33
+ // Steer rows waiting for the step that answers them (steer()).
34
+ let pendingSteers = [];
32
35
  const dirty = new Set();
33
36
  let rafPending = false;
34
37
  // The promoted answer bubble while it is held at the box's height (the
@@ -69,6 +72,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
69
72
  const summary = document.createElement("summary");
70
73
  summary.textContent = `step ${turn.gens.length}`;
71
74
  el.appendChild(summary);
75
+ // A plugin's steers merged since the last step: this step answers them.
76
+ for (const row of pendingSteers) el.appendChild(row);
77
+ pendingSteers = [];
72
78
  const text = document.createElement("div");
73
79
  text.className = "gen-text streaming";
74
80
  // A step after the first shows "…" until its text comes (the first one
@@ -317,7 +323,8 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
317
323
 
318
324
  // A hook's line (hook_notice) as a row of the current step, above the row
319
325
  // of the call it judged; false with no running turn (app.js shows the
320
- // bubble then).
326
+ // bubble then). data.line is a whole row of chi's own (an empty-answer
327
+ // retry), shown as is.
321
328
  function notice(data) {
322
329
  const r = turn && model.notice(turn, data);
323
330
  if (!r) return false;
@@ -325,13 +332,34 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
325
332
  if (!r.gen.text) entry.text.textContent = "";
326
333
  const el = document.createElement("div");
327
334
  el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
328
- el.textContent = `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
335
+ el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
329
336
  const follow = isNearBottom();
330
337
  activityEl(r.gen).appendChild(el);
331
338
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
332
339
  return true;
333
340
  }
334
341
 
342
+ // A plugin's steer (pending_input_merged's steers) as the first row of
343
+ // the step that answers it: it joined the turn after the current step's
344
+ // tools, and the next generation (a new, open step) reads it; the
345
+ // current step collapses when that one starts. Held until then (a turn
346
+ // that ends first puts it in its last step). False with no running turn.
347
+ function steer(data) {
348
+ if (!turn || turn.ended) return false;
349
+ const holder = document.createElement("div");
350
+ holder.innerHTML = steerRowHtml(data);
351
+ pendingSteers.push(holder.firstChild);
352
+ return true;
353
+ }
354
+
355
+ // Held steer rows into the last step (the turn ends without another).
356
+ function flushSteers() {
357
+ const gen = turn.gens[turn.gens.length - 1];
358
+ const entry = gen && genEls.get(gen);
359
+ if (entry) for (const row of pendingSteers) entry.el.insertBefore(row, entry.thinkingWrap || entry.text);
360
+ pendingSteers = [];
361
+ }
362
+
335
363
  // A card shown during the turn (a plugin's, app.js draws it) as a row of
336
364
  // the current step, like a notice; false with no running turn (app.js
337
365
  // puts it between turns then).
@@ -376,8 +404,8 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
376
404
  // between turns.
377
405
  const leaving = !gen.thinking && gen.tools.length === 0;
378
406
  const plain = turn.gens.length === 1 && gen.tools.length === 0;
379
- const cards = leaving || plain ? [...entry.el.querySelectorAll(".plugin-card, .hook-notice")] : [];
380
- cards.forEach((el) => { if (el.classList.contains("hook-notice")) el.classList.add("bubble"); });
407
+ const cards = leaving || plain ? [...entry.el.querySelectorAll(".plugin-card, .hook-notice, .steer-row")] : [];
408
+ cards.forEach((el) => { if (!el.classList.contains("plugin-card")) el.classList.add("bubble"); });
381
409
  if (leaving) entry.el.remove();
382
410
  if (plain) {
383
411
  // A plain answer: no block, the bubble where it was (as the chat
@@ -425,6 +453,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
425
453
  // one shows at once.
426
454
  const box = promoting && kind === "completed" ? genEls.get(last)?.box : null;
427
455
  const heldHeight = box ? box.getBoundingClientRect().height : 0;
456
+ if (pendingSteers.length) flushSteers();
428
457
  apply(model.turnEnded(turn));
429
458
  if (promoting) promote(last, { heldHeight });
430
459
  const kept = [];
@@ -453,6 +482,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
453
482
  if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
454
483
  }
455
484
  genEls.clear();
485
+ pendingSteers = [];
456
486
  dirty.clear();
457
487
  rafPending = false;
458
488
  }
@@ -499,6 +529,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
499
529
  },
500
530
  turnEnded,
501
531
  notice,
532
+ steer,
502
533
  card,
503
534
  flush,
504
535
  // The rendered answer landed (or the re-read failed): let the held
@@ -532,7 +563,7 @@ function reloadRowHtml(r, thumbs) {
532
563
  : "";
533
564
  return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
534
565
  `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}${output}` +
535
- `${r.images?.length ? thumbs(r.images) : ""}</div>`;
566
+ `${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
536
567
  }
537
568
 
538
569
  // A reloaded history in the turn view (renderHistory's object path): per
@@ -554,28 +585,38 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
554
585
  const { body, renderedMarkdown } = messageBodyHtml(m);
555
586
  return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
556
587
  };
588
+ const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
557
589
  return turnGroups(items, timing).map((group) => {
558
590
  if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
591
+ if (group.kind === "steer") return steerHtml(group.i, true);
559
592
  if (group.kind === "answer") return answerHtml(items[group.answer], "");
560
593
  const prompt = items[group.user];
561
594
  const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}><div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
562
595
  const [only] = group.steps;
596
+ const noBlock = !group.steps.length ||
597
+ (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking);
563
598
  if (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking) {
564
599
  // A plain answer with thinking: the chat view's collapsed block, as live.
565
600
  parts.push(`<details class="bubble thinking"><summary>thinking</summary><div class="thinking-body">${escapeHtml(only.thinking)}</div></details>`);
566
601
  } else if (group.steps.length) {
567
602
  const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
568
- const rows = gens.map((gen) => {
603
+ const rows = gens.map((gen, k) => {
569
604
  // A thinking-only step: the body directly under the step's own
570
605
  // "thinking" summary (no wrap that would repeat it), as live.
571
606
  const body = gen.thinking ? `<div class="thinking-body">${escapeHtml(gen.thinking)}</div>` : "";
572
607
  const thinking = body && (gen.text || gen.tools.length) ? `<details class="thinking"><summary>thinking</summary>${body}</details>` : body;
573
608
  const text = gen.text ? `<div class="gen-text">${escapeHtml(gen.text)}</div>` : "";
609
+ // The steers the step answered first, where the live rows went.
610
+ const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
574
611
  const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
575
- return `<details class="gen"><summary>${escapeHtml(model.genLabel(gen))}</summary>${thinking}${text}${tools}</details>`;
612
+ return `<details class="gen"><summary>${escapeHtml(model.genLabel(gen))}</summary>${steers}${thinking}${text}${tools}</details>`;
576
613
  });
577
614
  parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
578
615
  }
616
+ // Steers with no step to sit in, or in a turn that shows no block: on
617
+ // their own after the prompt.
618
+ const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
619
+ parts.push(loose.map((i) => steerHtml(i, true)).join(""));
579
620
  // A turn with no answer (canceled, or running: no record yet) shows its
580
621
  // timing after its prompt and steps, where the live view leaves it.
581
622
  if (group.answer === null) parts.push(timingHtml(group));
@@ -9,6 +9,7 @@ require "webrick"
9
9
 
10
10
  require_relative "app"
11
11
  require_relative "session_hub"
12
+ require_relative "../config"
12
13
  require_relative "../project_scope"
13
14
  require_relative "../log"
14
15
  require_relative "../version"
@@ -51,7 +52,8 @@ module Samagotchi
51
52
  # open) its page for +dir+ and leave it be; else start one.
52
53
  # @param scope ["project", "all"] "all": the plain page, every session
53
54
  # @return [Integer] the exit status
54
- def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, turn_view: true)
55
+ def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, turn_view: true,
56
+ annotate_presets: Config::BY_KEY["web.annotate_presets"].default)
55
57
  host = resolve_host(host)
56
58
  port = resolve_port(port)
57
59
  url = scope_url(host, port, dir: dir, scope: scope)
@@ -65,7 +67,8 @@ module Samagotchi
65
67
  warn in_use_message(port)
66
68
  1
67
69
  else
68
- start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, turn_view: turn_view) ? 0 : 1
70
+ start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, turn_view: turn_view,
71
+ annotate_presets: annotate_presets) ? 0 : 1
69
72
  end
70
73
  rescue Interrupt
71
74
  # Ctrl-C: the server has stopped (start's ensure); one line, and the
@@ -78,13 +81,14 @@ module Samagotchi
78
81
  # from; built over the app's state dir unless given
79
82
  # @return [Boolean] false when the port was taken (said so on stderr)
80
83
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
81
- turn_view: true, hub: nil)
84
+ turn_view: true, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil)
82
85
  port = resolve_port(port)
83
86
  host = resolve_host(host)
84
87
  url ||= "http://#{url_host(host)}:#{port}/"
85
88
 
86
89
  hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
87
- app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, turn_view: turn_view, hub: hub)
90
+ app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, turn_view: turn_view,
91
+ annotate_presets: annotate_presets, hub: hub)
88
92
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
89
93
  hub.start
90
94
  # Said once the port is bound: a second chi web racing for it gets
@@ -244,7 +244,8 @@ module Samagotchi
244
244
  if stamp != entry.stamp
245
245
  session = Session.summary_from_file(path)
246
246
  # Corrupt, or missing a field: not a session, as Session.list has it.
247
- return drop(id) if session.nil?
247
+ # A `chi scratch` session is never shown.
248
+ return drop(id) if session.nil? || session.scratch
248
249
 
249
250
  # Looked up once per parse (a git call for a session saved before
250
251
  # the field), never per tick.
@@ -2,7 +2,10 @@
2
2
 
3
3
  require_relative "../session"
4
4
  require_relative "../recap_store"
5
+ require_relative "../archive_store"
6
+ require_relative "../session_metrics"
5
7
  require_relative "../bridge_client"
8
+ require_relative "../bridge/pending_card"
6
9
 
7
10
  module Samagotchi
8
11
  module Web
@@ -25,6 +28,7 @@ module Samagotchi
25
28
  # @return [Hash] symbol keys
26
29
  def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
27
30
  used = session.respond_to?(:used_memory_names) ? Array(session.used_memory_names) : []
31
+ up = bridge_up?(session_dir, owner)
28
32
  {
29
33
  id: session.id,
30
34
  status: status,
@@ -46,13 +50,32 @@ module Samagotchi
46
50
  owner: owner&.fetch("kind", nil),
47
51
  # The saved recap's first sentence, for the session card.
48
52
  recap: RecapStore.preview(session_dir),
53
+ # How full the context was after the last turn (%), or nil.
54
+ ctx_pct: SessionMetrics.saved_context_pct(session_dir)&.round(1),
55
+ # Hidden from the strip and the list unless "include archived".
56
+ archived: ArchiveStore.archived?(session_dir),
49
57
  project_root: session.project_root(cache: root_cache),
50
58
  # A worker is reachable: its Bridge sidecar is there and the
51
59
  # owner lock is held. A sidecar a dead worker left is not up.
52
- bridge_up: bridge_up?(session_dir, owner)
60
+ bridge_up: up,
61
+ # For the tab's notifications (notify.js): an open question, the
62
+ # running turn's open card with actions, and how the last turn
63
+ # ended.
64
+ pending_question: pending_question_for(session),
65
+ pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
66
+ last_turn: session.respond_to?(:last_turn) ? session.last_turn : nil
53
67
  }
54
68
  end
55
69
 
70
+ # The open question as {id:, kind:} ("approval" for a guardrail's,
71
+ # else "question"); nil when there is none. The text stays out.
72
+ def pending_question_for(session)
73
+ pending = session.respond_to?(:pending_question) ? session.pending_question : nil
74
+ return nil unless pending.is_a?(Hash) && pending[:id]
75
+
76
+ { id: pending[:id], kind: pending[:kind].to_s == "approval" ? "approval" : "question" }
77
+ end
78
+
56
79
  # status is turn state (idle/running). The live worker's snapshot is the
57
80
  # truth; on disk, a "running" with no live owner was left by a worker
58
81
  # that died mid-turn.
@@ -7,6 +7,7 @@ require_relative "turn_note"
7
7
  require_relative "context_note"
8
8
  require_relative "worker_idle_exit"
9
9
  require_relative "session_manager"
10
+ require_relative "archive_store"
10
11
  require_relative "log"
11
12
  require_relative "turn_flow"
12
13
  require_relative "session_commands"
@@ -286,6 +287,7 @@ module Samagotchi
286
287
  # list); the Engine resets it to idle when it ends.
287
288
  @session.status = Session::STATUS_RUNNING
288
289
  @session.save(state_dir: @state_dir)
290
+ user_input(origin&.dig(:client_id))
289
291
  drop_continue_offer(origin)
290
292
  @turn_flow.before_prompt_turn
291
293
  @merged_this_turn = []
@@ -316,6 +318,7 @@ module Samagotchi
316
318
  @engine.clear_due_reminder_names!
317
319
  return false unless @engine.reminders_due?
318
320
 
321
+ drop_continue_offer({ client_id: SessionManager::REMINDER_CLIENT_ID })
319
322
  @session.status = Session::STATUS_RUNNING
320
323
  @session.save(state_dir: @state_dir)
321
324
  begin
@@ -329,8 +332,8 @@ module Samagotchi
329
332
  ensure
330
333
  refuse_queued_commands
331
334
  end
332
- # A pending continue offer stays (the REPL rule); otherwise the
333
- # rollback window closes.
335
+ # The offer went before the turn (drop_continue_offer); the rollback
336
+ # window closes.
334
337
  @turn_flow.after_reminder_turn
335
338
  @session.save(state_dir: @state_dir) unless stopped_on_disk?
336
339
  true
@@ -381,6 +384,7 @@ module Samagotchi
381
384
  shown.each { |event| @engine.announce(event) }
382
385
  end
383
386
  @session.save(state_dir: @state_dir) unless Array(result.changed).empty? || stopped_on_disk?
387
+ user_input(command[:client_id]) if resolved
384
388
  # An offer answered without a turn ("no") is activity: the recap
385
389
  # written at the offer (it says the turn stopped) gets rewritten.
386
390
  @engine.record_activity if resolved && !result.resume
@@ -432,6 +436,7 @@ module Samagotchi
432
436
  status: status, output: text[0, COMMAND_OUTPUT_LIMIT], changed: changed.map(&:to_s),
433
437
  model_name: @engine.effective_model_name }
434
438
  event[:anytime] = true if anytime
439
+ event[:card] = true if command[:card]
435
440
  event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
436
441
  @engine.announce(event)
437
442
  end
@@ -472,8 +477,8 @@ module Samagotchi
472
477
  @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
473
478
  end
474
479
 
475
- # A prompt taken while a continue is offered replaces the answer (D2):
476
- # the offer goes, the partial turn stays.
480
+ # A prompt (or a reminder turn) taken while a continue is offered
481
+ # replaces the answer (D2): the offer goes, the partial turn stays.
477
482
  def drop_continue_offer(origin)
478
483
  return unless @turn_flow.awaiting_continue?
479
484
 
@@ -536,6 +541,7 @@ module Samagotchi
536
541
  FileUtils.rm_f(claimed_file)
537
542
  end
538
543
  end
544
+ merged.each { |_prompt, origin| user_input(origin&.dig(:client_id)) }
539
545
  unless merged.empty?
540
546
  @engine.announce(type: :input_merged, count: merged.size, origins: merged.filter_map(&:last))
541
547
  @merged_this_turn.concat(merged)
@@ -628,6 +634,12 @@ module Samagotchi
628
634
  Log.info(:worker, "idle_exit", idle_s: @idle_exit.idle_seconds.round)
629
635
  end
630
636
 
637
+ # A human's input (ArchiveStore.user_input?) un-archives the session;
638
+ # a delegate's, a plugin's or a reminder's doesn't.
639
+ def user_input(client_id)
640
+ ArchiveStore.user_input(@session_id, state_dir: @state_dir) if ArchiveStore.user_input?(client_id)
641
+ end
642
+
631
643
  def stopped_on_disk?
632
644
  SessionManager.stopped_on_disk?(@session_id, state_dir: @state_dir)
633
645
  end