samagotchi 0.51.0 → 0.52.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +5 -1
  11. data/docs/testing.md +6 -1
  12. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  13. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  14. data/lib/samagotchi/bridge.rb +10 -13
  15. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/children_status.rb +1 -1
  19. data/lib/samagotchi/commands/registry.rb +2 -1
  20. data/lib/samagotchi/config.rb +50 -182
  21. data/lib/samagotchi/config_text_edit.rb +8 -32
  22. data/lib/samagotchi/context_sources.rb +0 -7
  23. data/lib/samagotchi/context_window.rb +2 -2
  24. data/lib/samagotchi/continue_offer.rb +5 -4
  25. data/lib/samagotchi/cut_policy.rb +0 -1
  26. data/lib/samagotchi/delivery.rb +23 -0
  27. data/lib/samagotchi/engine.rb +48 -17
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/host_config.rb +183 -0
  30. data/lib/samagotchi/host_registry.rb +5 -10
  31. data/lib/samagotchi/last_turn.rb +48 -0
  32. data/lib/samagotchi/live_versions.rb +0 -6
  33. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  34. data/lib/samagotchi/llm_context_override.rb +3 -22
  35. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  36. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  37. data/lib/samagotchi/model_profile.rb +4 -4
  38. data/lib/samagotchi/model_settings.rb +46 -0
  39. data/lib/samagotchi/plugin/context.rb +3 -3
  40. data/lib/samagotchi/plugin/sessions.rb +2 -2
  41. data/lib/samagotchi/queued_command.rb +35 -0
  42. data/lib/samagotchi/reminder_store.rb +0 -27
  43. data/lib/samagotchi/reply_wait.rb +11 -13
  44. data/lib/samagotchi/sampling_settings.rb +4 -4
  45. data/lib/samagotchi/self_report.rb +8 -5
  46. data/lib/samagotchi/send_command.rb +37 -19
  47. data/lib/samagotchi/session.rb +45 -24
  48. data/lib/samagotchi/session_commands.rb +36 -7
  49. data/lib/samagotchi/session_inbox.rb +18 -9
  50. data/lib/samagotchi/session_manager.rb +8 -7
  51. data/lib/samagotchi/session_setup.rb +30 -0
  52. data/lib/samagotchi/steer_cut.rb +9 -2
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  55. data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
  56. data/lib/samagotchi/terminal_ui.rb +8 -3
  57. data/lib/samagotchi/thinking.rb +67 -7
  58. data/lib/samagotchi/thinking_command.rb +72 -0
  59. data/lib/samagotchi/tools/builtins.rb +7 -2
  60. data/lib/samagotchi/tools/peers.rb +3 -2
  61. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/vision_support.rb +4 -3
  64. data/lib/samagotchi/web/app.rb +54 -6
  65. data/lib/samagotchi/web/public/app.js +133 -20
  66. data/lib/samagotchi/web/public/data.js +8 -4
  67. data/lib/samagotchi/web/public/format.js +8 -4
  68. data/lib/samagotchi/web/public/index.html +8 -5
  69. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  70. data/lib/samagotchi/web/public/model_pick.js +3 -1
  71. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  72. data/lib/samagotchi/web/public/timing.js +15 -0
  73. data/lib/samagotchi/web/public/turn_events.js +23 -0
  74. data/lib/samagotchi/web/public/turn_view.js +328 -310
  75. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  76. data/lib/samagotchi/web/session_summary.rb +1 -1
  77. data/lib/samagotchi/worker.rb +43 -42
  78. data/lib/samagotchi/worker_inbound.rb +5 -5
  79. data/lib/samagotchi/worker_wakes.rb +0 -2
  80. data/lib/samagotchi/yaml_lines.rb +72 -0
  81. metadata +11 -1
@@ -0,0 +1,169 @@
1
+ // The session bar's thinking chip: the level the next turn runs at ("think
2
+ // low"; "· session" when the session set its own), and a popover with a
3
+ // level select that runs /thinking in the session's worker (the command's
4
+ // reply shows in the conversation, like a typed one). The level and where
5
+ // it came from are the session's thinking (Thinking::Explained#summary:
6
+ // {level, source, own}; the worker's snapshot, a command_ran, or the
7
+ // server's own reading of the session file).
8
+ //
9
+ // The start page has one too (kind "new chat"): it shows the picked
10
+ // model's level (GET /api/models' thinking) and keeps a choice for the
11
+ // next create only (POST /api/sessions' thinking), never remembered:
12
+ // nothing is sent unless the user sets one.
13
+
14
+ import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
15
+
16
+ export const LEVELS = ["off", "low", "medium", "high"];
17
+ const DEFAULT = "default";
18
+
19
+ // The new chat's choice (a level, or null) laid over the model's summary:
20
+ // what the chip shows. The model's own summary without a choice.
21
+ export function withChoice(summary, choice) {
22
+ if (!summary || !choice) return summary;
23
+ return { ...summary, level: choice, source: SESSION, own: choice };
24
+ }
25
+
26
+ // The chip: its text, tooltip and whether the session (or the new chat)
27
+ // set its own; null without a summary (an older worker). The default with
28
+ // nothing set anywhere shows too ("think default"): the chip is where the
29
+ // level is set.
30
+ export function chipModel(summary, { kind = SESSION } = {}) {
31
+ if (!summary || !summary.level) return null;
32
+ const own = !!summary.own;
33
+ const what = kind === NEW_CHAT ? "the new chat" : "this session";
34
+ const from = own ? what : summary.source || "the model's own";
35
+ const title = `Thinking: ${summary.level} (${from}). Click to change it for ${what}.`;
36
+ return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
37
+ }
38
+
39
+ export function chipHtml(summary, escapeHtml) {
40
+ const chip = chipModel(summary);
41
+ if (!chip) return "";
42
+ return `<button type="button" class="model think-chip${chip.own ? " own" : ""}" title="${escapeHtml(chip.title)}" ` +
43
+ `data-think-chip aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
44
+ }
45
+
46
+ // The /thinking line for a select value ("default" unsets the session's
47
+ // own); null when it is what the form opened with.
48
+ export function commandLine(level, initial = null) {
49
+ if (initial !== null && level === initial) return null;
50
+ return `/thinking ${level}`;
51
+ }
52
+
53
+ function option(value, label, chosen, escapeHtml) {
54
+ return `<option value="${escapeHtml(value)}"${value === chosen ? " selected" : ""}>${escapeHtml(label)}</option>`;
55
+ }
56
+
57
+ // "Now: low (session)", "Now: default (the model's own)".
58
+ export function nowText(summary) {
59
+ return `Now: ${summary.level} (${summary.source || "the model's own"})`;
60
+ }
61
+
62
+ // The popover's form. For the new chat (kind NEW_CHAT), +summary+ is the
63
+ // model's and +choice+ the level the form shows.
64
+ export function formHtml(summary, escapeHtml, { running = false, kind = SESSION, choice = null } = {}) {
65
+ const fresh = kind === NEW_CHAT;
66
+ const chosen = (fresh ? choice : summary.own) || DEFAULT;
67
+ const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
68
+ const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
69
+ : "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
70
+ return `<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong>` +
71
+ `<span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span>` +
72
+ `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
73
+ `<div class="ctx-pop-meta think-now">${escapeHtml(nowText(summary))}</div>` +
74
+ `<label class="llmctx-field"><span>Level</span><select name="level">` +
75
+ option(DEFAULT, follow, chosen, escapeHtml) +
76
+ LEVELS.map((level) => option(level, level, chosen, escapeHtml)).join("") + `</select></label>` +
77
+ `<div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div>` +
78
+ `<div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
79
+ }
80
+
81
+ // The chip's popover, as createLlmContextControl's: el is where the chip
82
+ // is drawn (clicks are delegated: #infoText is redrawn every tick);
83
+ // summary(): the open session's thinking; session(): its id; running():
84
+ // a turn runs; run(line): sends the command. The start page's (kind
85
+ // NEW_CHAT): summary() is the picked model's, choice() the pending level
86
+ // and choose(next) takes a Set's (null for "follow the model"). id and
87
+ // anchor (the chip's selector) keep it apart from the llm ctx control on
88
+ // the same element.
89
+ export function createThinkingControl({ el, escapeHtml, summary, session = () => null, running = () => false, run,
90
+ kind = SESSION, choice = () => null, choose = null,
91
+ id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
92
+ doc = document, win = window }) {
93
+ const fresh = kind === NEW_CHAT;
94
+ const pop = doc.createElement("div");
95
+ pop.id = id;
96
+ pop.className = "ctx-popover think-popover";
97
+ pop.setAttribute("role", "dialog");
98
+ pop.hidden = true;
99
+ doc.body.appendChild(pop);
100
+ let initial = null;
101
+ let openFor = null;
102
+
103
+ function place(anchor) {
104
+ const width = Math.min(320, win.innerWidth - 32);
105
+ pop.style.width = `${width}px`;
106
+ pop.style.maxHeight = "";
107
+ const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
108
+ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
109
+ pop.style.left = `${at.left}px`;
110
+ pop.style.top = at.top === undefined ? "" : `${at.top}px`;
111
+ pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
112
+ if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
113
+ }
114
+
115
+ function open(anchor) {
116
+ const data = summary();
117
+ if (!data) return;
118
+ initial = (fresh ? choice() : data.own) || DEFAULT;
119
+ openFor = session();
120
+ pop.innerHTML = formHtml(data, escapeHtml, { running: running(), kind, choice: choice() });
121
+ pop.hidden = false;
122
+ place(anchor);
123
+ }
124
+
125
+ function close() {
126
+ pop.hidden = true;
127
+ initial = null;
128
+ openFor = null;
129
+ }
130
+
131
+ function isOpen() { return !pop.hidden; }
132
+
133
+ // A new summary: an open form keeps the user's pick; only "Now:" follows.
134
+ function refresh() {
135
+ if (!isOpen()) return;
136
+ const data = summary();
137
+ if (!data || session() !== openFor) return close();
138
+ const now = pop.querySelector(".think-now");
139
+ if (now) now.textContent = nowText(data);
140
+ }
141
+
142
+ el.addEventListener("click", (e) => {
143
+ const anchor = e.target.closest(anchorSel);
144
+ if (!anchor) return;
145
+ e.stopPropagation();
146
+ if (isOpen()) close();
147
+ else open(anchor);
148
+ });
149
+ pop.addEventListener("submit", (e) => {
150
+ e.preventDefault();
151
+ const level = e.target.elements.level.value;
152
+ const was = initial;
153
+ close();
154
+ if (fresh) return choose(level === DEFAULT ? null : level);
155
+ const line = commandLine(level, was);
156
+ if (line) run(line);
157
+ });
158
+ pop.addEventListener("click", (e) => {
159
+ if (e.target.closest("[data-act]")?.dataset.act === "close") close();
160
+ });
161
+ doc.addEventListener("click", (e) => {
162
+ if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
163
+ });
164
+ doc.addEventListener("keydown", (e) => {
165
+ if (e.key === "Escape" && isOpen()) close();
166
+ });
167
+
168
+ return { close, refresh, get open() { return isOpen(); } };
169
+ }
@@ -63,6 +63,18 @@ export function elapsedSince(startedAt, now = Date.now()) {
63
63
  return Math.max(0, current - started);
64
64
  }
65
65
 
66
+ // The info bar's session clock: while a turn runs it counts from the
67
+ // session's start (+timing.startedAt+), or, on a session's first turn (its
68
+ // snapshot came before any turn, so no start), from the running turn's;
69
+ // otherwise the server's session_duration_ms (null when unknown).
70
+ export function sessionDurationNow(timing, running, now = Date.now()) {
71
+ if (running) {
72
+ const since = elapsedSince(timing?.startedAt ?? timing?.activeTurn?.started_at, now);
73
+ if (since !== null) return since;
74
+ }
75
+ return timing?.sessionDurationMs ?? null;
76
+ }
77
+
66
78
  export function normalizeTiming(data = {}) {
67
79
  return {
68
80
  startedAt: typeof data.started_at === "string" ? data.started_at : null,
@@ -251,9 +263,12 @@ function canceledSteps(g, record, tools) {
251
263
  return record?.status === "canceled" && (g.texts.length > 1 || tools.length > 0);
252
264
  }
253
265
 
266
+ // A call's row from its tool record alone (no saved parts): the record's
267
+ // called_as too, so "(called as bash)" shows after a reload, as live.
254
268
  function recordRows(tools, iteration) {
255
269
  return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
256
270
  key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
271
+ ...(r.called_as ? { called_as: r.called_as } : {}),
257
272
  }));
258
273
  }
259
274
 
@@ -244,6 +244,29 @@ export function sessionCommandLine(text, commands) {
244
244
  .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
245
245
  }
246
246
 
247
+ // /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
248
+ // for Tab and /help, never run as commands; TerminalUI::Formatting's
249
+ // STEER_CHOICE: the word, then any whitespace and the text). During a
250
+ // turn /cut TEXT is the text sent as a cut (POST /turn, delivery "cut"),
251
+ // as chi send --cut does; /queue isn't in the web yet (the page says so,
252
+ // as the attached TUI does). With no running turn both are the text as a
253
+ // normal turn. The bare word gets the usage line.
254
+ // @return {{send: string, delivery?: string} | {reply: string} | null}
255
+ // null: not a delivery line
256
+ export const STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)";
257
+ export const QUEUE_UNAVAILABLE_WEB = "(/queue isn't available in the web yet; a plain message goes in at the running turn's next step, and /cut TEXT cuts in)";
258
+ const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
259
+
260
+ export function steerChoiceRoute(text, { running = false } = {}) {
261
+ const match = STEER_CHOICE.exec(String(text || "").trim());
262
+ if (!match) return null;
263
+ const message = (match[2] || "").trim();
264
+ if (!message) return { reply: STEER_CHOICE_USAGE };
265
+ if (!running) return { send: message };
266
+ if (match[1] === "queue") return { reply: QUEUE_UNAVAILABLE_WEB };
267
+ return { send: message, delivery: "cut" };
268
+ }
269
+
247
270
  // A line that is one word: a slash and a name, no spaces, no second slash
248
271
  // (Commands::Registry#unknown_command_word?). `/modle` is one; `/foo bar`
249
272
  // and `/usr/bin/env` are not (they are prompts).