samagotchi 0.42.0 → 0.43.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.
@@ -6,6 +6,11 @@
6
6
  // the session's llm_context (LLMContextStrategy::Explained#summary: the
7
7
  // worker's snapshot, a command_ran, or the server's own reading of the
8
8
  // session file).
9
+ //
10
+ // The start page has one too (kind "new chat"): it shows the picked
11
+ // model's values (GET /api/models' llm_context) and its form keeps a
12
+ // choice for the next create only (POST /api/sessions' llm_context), never
13
+ // remembered: nothing is sent unless the user sets one.
9
14
 
10
15
  export const STRATEGIES = [
11
16
  { value: "none", label: "none" },
@@ -13,6 +18,8 @@ export const STRATEGIES = [
13
18
  { value: "stale,forget", label: "stale + forget (experimental)" },
14
19
  ];
15
20
  export const APPLIES = ["payoff", "next_request", "turn_end"];
21
+ export const SESSION = "session";
22
+ export const NEW_CHAT = "new chat";
16
23
 
17
24
  const DEFAULT = "default";
18
25
  const LAYER_ORDER = ["stale", "forget"];
@@ -33,15 +40,34 @@ export function ownValues(summary) {
33
40
  return { strategy, apply: own.apply || DEFAULT, budget };
34
41
  }
35
42
 
36
- // The chip: its text, tooltip and whether the session set its own; null
37
- // without a summary (an older worker).
38
- export function chipModel(summary) {
43
+ // The start page's choice for the new chat ({strategy: [layers], apply,
44
+ // budget_tokens}, the session file's shape; 0 is off) laid over the
45
+ // model's summary: what the chip shows, with the chosen fields' source
46
+ // "session" ("the new chat"). The model's own summary without a choice.
47
+ export function withChoice(summary, choice) {
48
+ if (!summary || !choice) return summary;
49
+ const merged = { ...summary, own: choice };
50
+ // (the budget's source and where are budget_source / budget_where)
51
+ const set = (field, key, value) => Object.assign(merged, { [key]: value, [`${field}_source`]: SESSION,
52
+ [`${field}_where`]: "the new chat" });
53
+ if (Array.isArray(choice.strategy)) set("strategy", "strategy", strategyValue(choice.strategy));
54
+ if (choice.apply) set("apply", "apply", choice.apply);
55
+ if (typeof choice.budget_tokens === "number") {
56
+ set("budget", "budget_tokens", choice.budget_tokens > 0 ? choice.budget_tokens : null);
57
+ }
58
+ return merged;
59
+ }
60
+
61
+ // The chip: its text, tooltip and whether the session (or the new chat)
62
+ // set its own; null without a summary (an older worker).
63
+ export function chipModel(summary, { kind = SESSION } = {}) {
39
64
  if (!summary || !summary.strategy) return null;
40
65
  const own = summary.strategy_source === "session" || !!summary.own;
41
66
  const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
67
+ const what = kind === NEW_CHAT ? "the new chat" : "this session";
42
68
  const title = `LLM context strategy: ${summary.strategy} (${summary.strategy_where}); apply ${summary.apply} ` +
43
- `(${summary.apply_where}); budget ${budget} (${summary.budget_where}). Click to change it for this session.`;
44
- return { text: `llm ctx ${summary.strategy}${own ? " · session" : ""}`, title, own };
69
+ `(${summary.apply_where}); budget ${budget} (${summary.budget_where}). Click to change it for ${what}.`;
70
+ return { text: `llm ctx ${summary.strategy}${own ? ` · ${kind}` : ""}`, title, own };
45
71
  }
46
72
 
47
73
  export function chipHtml(summary, escapeHtml) {
@@ -65,6 +91,54 @@ export function commandLine({ strategy = DEFAULT, apply = DEFAULT, budget = "" }
65
91
  return `/llm-context ${fields.map((field) => `${field} ${words[field]}`).join(" ")}`;
66
92
  }
67
93
 
94
+ // A budget as typed ("64000", "64k", "off", "0") in tokens, 0 for off;
95
+ // null when it isn't one or is out of chi's range (LLMContextOverride).
96
+ export function parseBudget(text) {
97
+ const word = String(text).trim().toLowerCase();
98
+ if (word === "off" || word === "0") return 0;
99
+ const match = /^(\d[\d_]*)(k)?$/.exec(word);
100
+ if (!match) return null;
101
+ const tokens = Number(match[1].replace(/_/g, "")) * (match[2] ? 1000 : 1);
102
+ if (tokens === 0) return 0; // "00", "0k": off, as chi reads them
103
+ return tokens >= 4000 && tokens <= 10_000_000 ? tokens : null;
104
+ }
105
+
106
+ // The new chat's choice after a Set: +choice+ with the fields the user
107
+ // changed (vs +initial+, the values the form opened with) applied, a field
108
+ // set back to default dropped; null when nothing is left. Throws on a
109
+ // budget that isn't one.
110
+ export function mergeChoice(choice, { strategy = DEFAULT, apply = DEFAULT, budget = "" } = {}, initial = null) {
111
+ const next = { ...(choice || {}) };
112
+ const words = { strategy, apply, budget: String(budget).trim() || DEFAULT };
113
+ const was = initial && { strategy: initial.strategy, apply: initial.apply, budget: String(initial.budget).trim() || DEFAULT };
114
+ const key = { strategy: "strategy", apply: "apply", budget: "budget_tokens" };
115
+ for (const field of Object.keys(words)) {
116
+ if (was && words[field] === was[field]) continue;
117
+ const word = words[field];
118
+ if (word === DEFAULT) { delete next[key[field]]; continue; }
119
+ if (field === "strategy") next.strategy = word === "none" ? [] : word.split(/[\s,|]+/).filter(Boolean);
120
+ else if (field === "apply") next.apply = word;
121
+ else {
122
+ const tokens = parseBudget(word);
123
+ if (tokens === null) throw new Error("A budget is a number of tokens from 4000 (4k) to 10000000, or off.");
124
+ next.budget_tokens = tokens;
125
+ }
126
+ }
127
+ return Object.keys(next).length ? next : null;
128
+ }
129
+
130
+ // The choice as POST /api/sessions' llm_context words it ({strategy:
131
+ // "stale,forget"|"none", apply, budget: "64000"|"off"}, the set fields);
132
+ // undefined without one, so the request has none.
133
+ export function choiceWords(choice) {
134
+ if (!choice) return undefined;
135
+ const words = {};
136
+ if (Array.isArray(choice.strategy)) words.strategy = strategyValue(choice.strategy);
137
+ if (choice.apply) words.apply = choice.apply;
138
+ if (typeof choice.budget_tokens === "number") words.budget = choice.budget_tokens > 0 ? String(choice.budget_tokens) : "off";
139
+ return Object.keys(words).length ? words : undefined;
140
+ }
141
+
68
142
  // The strategy list, with the session's own value when it isn't one of them
69
143
  // (forget alone, a newer chi's layer).
70
144
  export function strategyOptions(chosen) {
@@ -84,11 +158,16 @@ export function nowText(summary) {
84
158
  `budget ${budget} (${summary.budget_where})`;
85
159
  }
86
160
 
87
- export function formHtml(summary, escapeHtml, { running = false } = {}) {
88
- const own = ownValues(summary);
161
+ // For the new chat (kind NEW_CHAT), +summary+ is the model's and +choice+
162
+ // the values the form shows.
163
+ export function formHtml(summary, escapeHtml, { running = false, kind = SESSION, choice = null } = {}) {
164
+ const fresh = kind === NEW_CHAT;
165
+ const own = fresh ? ownValues({ own: choice }) : ownValues(summary);
89
166
  const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
167
+ const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
168
+ : "From the next turn's start. Turning forget on or off re-reads the whole prompt once.";
90
169
  return `<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong>` +
91
- `<span class="ctx-pop-kind">this session</span>` +
170
+ `<span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span>` +
92
171
  `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
93
172
  `<div class="ctx-pop-meta llmctx-now">${escapeHtml(nowText(summary))}</div>` +
94
173
  `<label class="llmctx-field"><span>Strategy</span><select name="strategy">` +
@@ -99,20 +178,44 @@ export function formHtml(summary, escapeHtml, { running = false } = {}) {
99
178
  APPLIES.map((a) => option(a, a, own.apply, escapeHtml)).join("") + `</select></label>` +
100
179
  `<label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off" ` +
101
180
  `placeholder="follow the model (64000, 64k or off)" value="${escapeHtml(own.budget)}"></label>` +
102
- `<div class="ctx-pop-meta">From the next turn's start. Turning forget on or off re-reads the whole prompt once.` +
103
- `${running ? " A turn is running: set it when it ends." : ""}</div>` +
181
+ `<div class="ctx-pop-error llmctx-error" hidden></div>` +
182
+ `<div class="ctx-pop-meta">${when}${running ? " A turn is running: set it when it ends." : ""}</div>` +
104
183
  `<div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button>` +
105
184
  `<button type="submit" class="llmctx-set"${running ? " disabled" : ""}>Set</button></div></form>`;
106
185
  }
107
186
 
187
+ const EDGE = 16;
188
+ const GAP = 8;
189
+
190
+ // Where the popover goes for the chip's +rect+: above it, or below when
191
+ // the room above is short of its height and below has more; capped to the
192
+ // room on that side (it scrolls), never past the window's edges.
193
+ // @return {{left, top?, bottom?, maxHeight?}}
194
+ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
195
+ const left = Math.max(EDGE, Math.min(rect.left, innerWidth - width - EDGE));
196
+ const above = rect.top - GAP - EDGE;
197
+ const below = innerHeight - rect.bottom - GAP - EDGE;
198
+ const down = height > above && below > above;
199
+ const room = Math.max(0, down ? below : above);
200
+ const place = down ? { left, top: rect.bottom + GAP } : { left, bottom: Math.max(EDGE, innerHeight - rect.top + GAP) };
201
+ return height > room ? { ...place, maxHeight: room } : place;
202
+ }
203
+
108
204
  // The chip's popover. el: where the chip is drawn (#infoText, redrawn every
109
205
  // tick: clicks are delegated); summary(): the open session's llm_context;
110
206
  // session(): the open session's id; running(): a turn runs; run(line):
111
207
  // sends the command.
208
+ // The start page's (kind NEW_CHAT): summary() is the picked model's,
209
+ // choice() the pending choice, and choose(next) takes a Set's (null for
210
+ // "follow the model") instead of a command running. id and anchor (the
211
+ // chip's selector) keep the two controls apart.
112
212
  export function createLlmContextControl({ el, escapeHtml, summary, session = () => null, running = () => false, run,
213
+ kind = SESSION, choice = () => null, choose = null,
214
+ id = "llmContextPopover", anchor: anchorSel = ".llmctx-chip",
113
215
  doc = document, win = window }) {
216
+ const fresh = kind === NEW_CHAT;
114
217
  const pop = doc.createElement("div");
115
- pop.id = "llmContextPopover";
218
+ pop.id = id;
116
219
  pop.className = "ctx-popover llmctx-popover";
117
220
  pop.setAttribute("role", "dialog");
118
221
  pop.hidden = true;
@@ -122,19 +225,23 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
122
225
  let openFor = null;
123
226
 
124
227
  function place(anchor) {
125
- const rect = anchor.getBoundingClientRect();
126
228
  const width = Math.min(360, win.innerWidth - 32);
127
229
  pop.style.width = `${width}px`;
128
- pop.style.left = `${Math.max(16, Math.min(rect.left, win.innerWidth - width - 16))}px`;
129
- pop.style.bottom = `${Math.max(16, win.innerHeight - rect.top + 8)}px`;
230
+ pop.style.maxHeight = "";
231
+ const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
232
+ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
233
+ pop.style.left = `${at.left}px`;
234
+ pop.style.top = at.top === undefined ? "" : `${at.top}px`;
235
+ pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
236
+ if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
130
237
  }
131
238
 
132
239
  function open(anchor) {
133
240
  const data = summary();
134
241
  if (!data) return;
135
- initial = ownValues(data);
242
+ initial = fresh ? ownValues({ own: choice() }) : ownValues(data);
136
243
  openFor = session();
137
- pop.innerHTML = formHtml(data, escapeHtml, { running: running() });
244
+ pop.innerHTML = formHtml(data, escapeHtml, { running: running(), kind, choice: choice() });
138
245
  pop.hidden = false;
139
246
  place(anchor);
140
247
  }
@@ -161,17 +268,39 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
161
268
  }
162
269
 
163
270
  el.addEventListener("click", (e) => {
164
- const anchor = e.target.closest(".llmctx-chip");
271
+ const anchor = e.target.closest(anchorSel);
165
272
  if (!anchor) return;
166
273
  e.stopPropagation();
167
274
  if (isOpen()) close();
168
275
  else open(anchor);
169
276
  });
277
+ // An edit makes an error the last Set showed stale.
278
+ const clearError = () => {
279
+ const error = pop.querySelector(".llmctx-error");
280
+ if (error && !error.hidden) { error.hidden = true; error.textContent = ""; }
281
+ };
282
+ pop.addEventListener("input", clearError);
283
+ pop.addEventListener("change", clearError);
170
284
  pop.addEventListener("submit", (e) => {
171
285
  e.preventDefault();
172
286
  const form = e.target;
173
- const line = commandLine({ strategy: form.elements.strategy.value, apply: form.elements.apply.value,
174
- budget: form.elements.budget.value }, initial);
287
+ const values = { strategy: form.elements.strategy.value, apply: form.elements.apply.value,
288
+ budget: form.elements.budget.value };
289
+ if (fresh) {
290
+ let next;
291
+ try {
292
+ next = mergeChoice(choice(), values, initial);
293
+ } catch (err) {
294
+ const error = pop.querySelector(".llmctx-error");
295
+ error.textContent = err.message;
296
+ error.hidden = false;
297
+ return;
298
+ }
299
+ close();
300
+ choose(next);
301
+ return;
302
+ }
303
+ const line = commandLine(values, initial);
175
304
  close();
176
305
  if (line) run(line);
177
306
  });
@@ -180,11 +309,12 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
180
309
  if (act === "close") close();
181
310
  else if (act === "reset") {
182
311
  close();
183
- run(commandLine());
312
+ if (fresh) choose(null);
313
+ else run(commandLine());
184
314
  }
185
315
  });
186
316
  doc.addEventListener("click", (e) => {
187
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(".llmctx-chip")) close();
317
+ if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
188
318
  });
189
319
  doc.addEventListener("keydown", (e) => {
190
320
  if (e.key === "Escape" && isOpen()) close();
@@ -19,7 +19,8 @@ export const RECENT_KEY = "chi_model_recent";
19
19
 
20
20
  // The payload's models as rows: {name, host, id, isDefaultHost}, and
21
21
  // sampling (the configured request parameters as /model words them) when
22
- // the model has some. The
22
+ // the model has some, and llm_context (the LLM context it starts under,
23
+ // LLMContextStrategy::Explained#summary) when the server sent one. The
23
24
  // default host's names are bare (name == id); the server's own default,
24
25
  // unshifted when no host lists it, has no host and goes with them, shown
25
26
  // by its name.
@@ -33,6 +34,7 @@ export function modelRows(payload) {
33
34
  const id = host ? String(m.id || name) : name;
34
35
  const row = { name, host, id, isDefaultHost: !host || name === id };
35
36
  if (m.sampling) row.sampling = String(m.sampling);
37
+ if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
36
38
  return row;
37
39
  });
38
40
  }
@@ -219,6 +219,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
219
219
  return true;
220
220
  },
221
221
  chosen: () => chosen,
222
+ // The picked model's row (its llm_context too); null before the list.
223
+ chosenRow: () => rows.find((r) => r.name === chosen) || null,
222
224
  close,
223
225
  isOpen,
224
226
  };
@@ -22,6 +22,8 @@ export function emptySessionState() {
22
22
  ctxPct: null,
23
23
  // Window the kernel resolved for the running generation (:generation_started).
24
24
  ctxWindow: null,
25
+ // Where it came from ("server", "config", … "default": chi's guess).
26
+ ctxWindowSource: null,
25
27
  status: "",
26
28
  model: "",
27
29
  // What the worker's server said it served for a model name: [served, asked].
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.42.0
4
+ version: 0.43.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov