samagotchi 0.131.0 → 0.133.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/bin/chi +19 -1
  4. data/docs/architecture.md +59 -1
  5. data/docs/cli.md +29 -8
  6. data/docs/configuration.md +115 -16
  7. data/docs/desktop.md +16 -6
  8. data/docs/guardrails.md +10 -4
  9. data/docs/internals/anthropic.md +3 -2
  10. data/docs/internals/engine.md +4 -4
  11. data/docs/internals/prompt-caching.md +26 -6
  12. data/docs/memory.md +16 -2
  13. data/docs/plugins.md +68 -1
  14. data/docs/sessions.md +60 -6
  15. data/docs/testing.md +1 -1
  16. data/lib/samagotchi/bootstrap_command.rb +35 -6
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
  19. data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
  20. data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
  21. data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/cache_confirm.rb +18 -0
  24. data/lib/samagotchi/chi_home.rb +311 -0
  25. data/lib/samagotchi/chi_shim.rb +77 -0
  26. data/lib/samagotchi/child_env.rb +5 -3
  27. data/lib/samagotchi/config.rb +2 -0
  28. data/lib/samagotchi/context_fetch.rb +4 -2
  29. data/lib/samagotchi/desktop/macos.rb +100 -18
  30. data/lib/samagotchi/engine.rb +35 -4
  31. data/lib/samagotchi/engine_snapshots.rb +5 -0
  32. data/lib/samagotchi/gem_update.rb +19 -10
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
  35. data/lib/samagotchi/guardrails/pair_command.rb +57 -1
  36. data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
  37. data/lib/samagotchi/guardrails/review_paths.rb +7 -4
  38. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  39. data/lib/samagotchi/home_command.rb +145 -0
  40. data/lib/samagotchi/home_doctor.rb +177 -0
  41. data/lib/samagotchi/home_notice.rb +80 -0
  42. data/lib/samagotchi/installed_versions.rb +5 -3
  43. data/lib/samagotchi/llm_context_command.rb +53 -7
  44. data/lib/samagotchi/llm_context_override.rb +3 -2
  45. data/lib/samagotchi/plugin/api.rb +45 -1
  46. data/lib/samagotchi/plugin/context.rb +34 -1
  47. data/lib/samagotchi/plugin/loader.rb +5 -2
  48. data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
  49. data/lib/samagotchi/project_env.rb +360 -0
  50. data/lib/samagotchi/run_options.rb +1 -0
  51. data/lib/samagotchi/self_report.rb +33 -0
  52. data/lib/samagotchi/session_access/contract.rb +1 -1
  53. data/lib/samagotchi/session_commands.rb +68 -19
  54. data/lib/samagotchi/session_manager.rb +54 -1
  55. data/lib/samagotchi/session_model_settings.rb +5 -2
  56. data/lib/samagotchi/system_prompt.rb +34 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  59. data/lib/samagotchi/testing/boundary.rb +3 -1
  60. data/lib/samagotchi/testing/bundle.rb +22 -3
  61. data/lib/samagotchi/testing/host.rb +22 -1
  62. data/lib/samagotchi/testing/recorder.rb +6 -1
  63. data/lib/samagotchi/testing/recording_api.rb +1 -1
  64. data/lib/samagotchi/tools/builtins.rb +13 -2
  65. data/lib/samagotchi/update_command.rb +65 -7
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +61 -1
  68. data/lib/samagotchi/web/public/app.js +4 -1
  69. data/lib/samagotchi/web/public/chip_popover.js +5 -2
  70. data/lib/samagotchi/web/public/composer.css +11 -1
  71. data/lib/samagotchi/web/public/data.js +8 -4
  72. data/lib/samagotchi/web/public/index.html +52 -10
  73. data/lib/samagotchi/web/public/level_chip.js +22 -38
  74. data/lib/samagotchi/web/public/llm_context_chip.js +30 -73
  75. data/lib/samagotchi/web/public/model_pick.js +26 -0
  76. data/lib/samagotchi/web/public/model_picker.js +3 -1
  77. data/lib/samagotchi/web/public/review_start.js +13 -57
  78. data/lib/samagotchi/web/public/send_turn.js +3 -2
  79. data/lib/samagotchi/web/public/start_page.js +131 -99
  80. data/lib/samagotchi/web/public/start_settings.js +298 -0
  81. data/lib/samagotchi/web/public/start_toolkit.js +157 -0
  82. data/lib/samagotchi/web/public/thinking_chip.js +9 -22
  83. data/lib/samagotchi/web/server.rb +27 -8
  84. data/lib/samagotchi/web/update_check.rb +3 -2
  85. metadata +11 -1
@@ -1,12 +1,10 @@
1
- import {
2
- chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_SESSION, withChoice,
3
- } from "./llm_context_chip.js";
4
- import { chipModel as thinkingChipModel, createThinkingControl, withChoice as withThinkingChoice } from "./thinking_chip.js";
1
+ import { choiceWords } from "./llm_context_chip.js";
5
2
  import { MODEL_KEY, modelsFailedText } from "./model_pick.js";
6
3
  import { createModelPicker } from "./model_picker.js";
7
4
  import { chainNeighbours, freshLink } from "./chains.js";
8
5
  import { readStored } from "./storage.js";
9
- import { createReviewStartControl, reviewChipModel } from "./review_start.js";
6
+ import { createStartSettings, overrides, settingsLabel } from "./start_settings.js";
7
+ import { createStartToolkit, toolkitLabel, toolkitOverride } from "./start_toolkit.js";
10
8
 
11
9
  // ── The start page's model picker ──────────────────────────────────────────
12
10
  // The hosts' models from GET /api/models, the choice this browser made last
@@ -19,22 +17,29 @@ import { createReviewStartControl, reviewChipModel } from "./review_start.js";
19
17
  // default in charge. The search list itself is model_picker.js'; a pick
20
18
  // sends the focus to the message box.
21
19
  //
22
- // Beside it, the start page's LLM context and thinking chips: the picked
23
- // model's values, and a choice for the next new session only (sent with its
24
- // create, then dropped; never remembered: a reload or opening a session
25
- // drops it too, a failed create keeps it).
20
+ // Beside it, the start page's ⚙ panel (start_settings.js): thinking, LLM
21
+ // context and review files, each a choice for the next new session only
22
+ // (sent with its create, then dropped; never remembered: a reload or opening
23
+ // a session drops it too, a failed create keeps it). What is set shows as
24
+ // override chips on the row, each opening the panel (a dot on ⚙ on a phone).
25
+ // And its 🧰 list (start_toolkit.js): the new session's toolkit, the same
26
+ // kind of choice (Auto, nothing sent, by default), with its own override
27
+ // chip and dot; hidden without GET /api/models' toolkits.
26
28
  export const START_PICK_TITLE = "The model the new session starts on";
27
29
 
28
30
  // Used by app.js. Elements: +pickEl+ (the picker's button), +panelEl+,
29
31
  // +searchEl+, +listEl+, +composerEl+, +centerEl+ (model_picker.js'),
30
- // +llmContextEl+ / +thinkingEl+ (the two chips).
32
+ // +settingsEl+ (⚙), +thinkingEl+ / +llmContextEl+ / +reviewEl+ (the override
33
+ // chips; no +reviewEl+: no review files), +toolkitBtnEl+ (🧰) and
34
+ // +toolkitEl+ (its override chip; both optional).
31
35
  // Hooks: +selected()+ the open session's id or null; +state()+ the open
32
36
  // session's fields (session_state.js: model, continues…); +running()+ a turn
33
37
  // runs; +sessions()+ the list; +run(line)+ a session command from a control
34
38
  // (resolves true once the worker took it); +focusPrompt()+; +toast(text)+;
35
39
  // +listModels()+ GET /api/models (data.js). +win+: the page's unload events.
36
40
  export function createStartPage({
37
- pickEl, panelEl, searchEl, listEl, composerEl, centerEl, llmContextEl, thinkingEl, reviewEl,
41
+ pickEl, panelEl, searchEl, listEl, composerEl, centerEl, settingsEl, llmContextEl, thinkingEl, reviewEl = null,
42
+ toolkitBtnEl = null, toolkitEl = null,
38
43
  selected, state, running, sessions, run, focusPrompt, toast, listModels,
39
44
  doc = globalThis.document, win = globalThis.window,
40
45
  }) {
@@ -43,6 +48,10 @@ export function createStartPage({
43
48
  let newChatThinking = null;
44
49
  // The files that make the new session a review session ([]: an ordinary one).
45
50
  let newChatReview = [];
51
+ // The kit the new session starts on (null: Auto, nothing sent), and the
52
+ // models payload's toolkits (null: no 🧰).
53
+ let newChatToolkit = null;
54
+ let toolkits = null;
46
55
  let startPickTitle = START_PICK_TITLE;
47
56
  // The fresh link the picker shows (null: the start page, or a session it
48
57
  // is hidden in), and a link pick's /model still in flight (a send waits).
@@ -57,65 +66,79 @@ export function createStartPage({
57
66
  let pageLeaving = false;
58
67
 
59
68
  const newChatLlmSummary = () => (selected() || pickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
60
- const llmContextNewControl = createLlmContextControl({
61
- el: llmContextEl,
62
- summary: newChatLlmSummary,
63
- kind: NEW_SESSION,
64
- choice: () => newChatLlmContext,
65
- choose: (next) => {
66
- newChatLlmContext = next;
67
- renderNewChatLlmContext();
68
- focusPrompt();
69
- },
70
- id: "llmContextNewPopover",
71
- anchor: "#llmContextNew",
72
- });
73
69
  const newChatThinkingSummary = () => (selected() || pickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
74
- const thinkingNewControl = createThinkingControl({
75
- el: thinkingEl,
76
- summary: newChatThinkingSummary,
77
- kind: NEW_SESSION,
78
- choice: () => newChatThinking,
79
- choose: (next) => {
80
- newChatThinking = next;
81
- renderNewChatThinking();
82
- focusPrompt();
70
+ // A pick in the panel keeps the focus there (it stays open; the prompt's
71
+ // focus would raise a phone's keyboard behind the sheet).
72
+ const settings = createStartSettings({
73
+ el: settingsEl,
74
+ button: settingsEl,
75
+ openers: [settingsEl, thinkingEl, llmContextEl, reviewEl].filter(Boolean).map((e) => `#${e.id}`).join(", "),
76
+ thinking: {
77
+ summary: newChatThinkingSummary,
78
+ choice: () => newChatThinking,
79
+ choose: (next) => { newChatThinking = next; renderOverrides(); },
83
80
  },
84
- id: "thinkingNewPopover",
85
- anchor: "#thinkingNew",
86
- });
87
- const reviewNewControl = reviewEl ? createReviewStartControl({
88
- el: reviewEl,
89
- choice: () => newChatReview,
90
- choose: (next) => {
91
- newChatReview = next;
92
- renderNewChatReview();
93
- focusPrompt();
81
+ llmContext: {
82
+ summary: newChatLlmSummary,
83
+ choice: () => newChatLlmContext,
84
+ choose: (next) => { newChatLlmContext = next; renderOverrides(); },
94
85
  },
86
+ review: reviewEl ? {
87
+ choice: () => newChatReview,
88
+ choose: (next) => { newChatReview = next; renderOverrides(); },
89
+ } : null,
90
+ doc,
91
+ win,
92
+ });
93
+ thinkingEl.addEventListener("click", () => settings.toggle("main"));
94
+ llmContextEl.addEventListener("click", () => settings.toggle("main"));
95
+ reviewEl?.addEventListener("click", () => settings.toggle("review"));
96
+ const toolkitList = toolkitBtnEl ? createStartToolkit({
97
+ el: toolkitBtnEl,
98
+ button: toolkitBtnEl,
99
+ openers: [toolkitBtnEl, toolkitEl].filter(Boolean).map((e) => `#${e.id}`).join(", "),
100
+ toolkits: () => toolkits,
101
+ choice: () => newChatToolkit,
102
+ choose: (next) => { newChatToolkit = next; renderOverrides(); },
103
+ review: () => newChatReview.length > 0,
104
+ doc,
105
+ win,
95
106
  }) : null;
96
- function renderNewChatReview() {
97
- if (!reviewEl) return;
98
- const chip = reviewChipModel(newChatReview);
99
- reviewEl.textContent = chip.text;
100
- reviewEl.title = chip.title;
101
- reviewEl.classList.toggle("own", chip.own);
102
- }
103
- function renderNewChatThinking() {
104
- const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_SESSION });
105
- thinkingEl.hidden = !chip;
106
- if (!chip) return thinkingNewControl.close();
107
- thinkingEl.textContent = chip.text;
108
- thinkingEl.title = chip.title;
109
- thinkingEl.classList.toggle("own", chip.own);
110
- }
111
- // Hidden without the model's values (the list not in, an older server).
112
- function renderNewChatLlmContext() {
113
- const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_SESSION });
114
- llmContextEl.hidden = !chip;
115
- if (!chip) return llmContextNewControl.close();
116
- llmContextEl.textContent = chip.text;
117
- llmContextEl.title = chip.title;
118
- llmContextEl.classList.toggle("own", chip.own);
107
+ toolkitEl?.addEventListener("click", () => toolkitList?.toggle());
108
+ // The override chips (only what is set) and ⚙: its dot and label say
109
+ // what is set; hidden with nothing to show (no model values, no review).
110
+ function renderOverrides() {
111
+ const thinkingSummary = newChatThinkingSummary();
112
+ const llmContextSummary = newChatLlmSummary();
113
+ const over = overrides({ thinking: newChatThinking, llmContext: newChatLlmContext, review: newChatReview,
114
+ thinkingSummary, llmContextSummary });
115
+ const paint = (chipEl, chip) => {
116
+ if (!chipEl) return;
117
+ chipEl.hidden = !chip;
118
+ chipEl.textContent = chip?.text || "";
119
+ chipEl.title = chip?.title || "";
120
+ };
121
+ paint(thinkingEl, over.thinking);
122
+ paint(llmContextEl, over.llmContext);
123
+ paint(reviewEl, over.review);
124
+ const label = settingsLabel(over);
125
+ settingsEl.hidden = !(thinkingSummary || llmContextSummary || reviewEl || label.set);
126
+ settingsEl.classList.toggle("set", label.set);
127
+ settingsEl.title = label.label;
128
+ settingsEl.setAttribute("aria-label", label.label);
129
+ if (settingsEl.hidden) settings.close();
130
+ else settings.refresh();
131
+ if (!toolkitBtnEl) return;
132
+ // 🧰 needs the list; a kit picked before it went (a failed reload of
133
+ // the models) still shows as its chip, and is still sent.
134
+ const kit = toolkitLabel(newChatToolkit);
135
+ paint(toolkitEl, toolkitOverride(newChatToolkit));
136
+ toolkitBtnEl.hidden = !toolkits;
137
+ toolkitBtnEl.classList.toggle("set", kit.set);
138
+ toolkitBtnEl.title = kit.label;
139
+ toolkitBtnEl.setAttribute("aria-label", kit.label);
140
+ if (toolkitBtnEl.hidden) toolkitList.close();
141
+ else toolkitList.refresh();
119
142
  }
120
143
 
121
144
  const modelPicker = createModelPicker({
@@ -129,8 +152,8 @@ export function createStartPage({
129
152
  onPick: (name) => {
130
153
  if (selected()) pickForLink(name);
131
154
  else {
132
- renderNewChatLlmContext();
133
- renderNewChatThinking();
155
+ renderOverrides();
156
+ syncStartPickLabel();
134
157
  }
135
158
  focusPrompt();
136
159
  },
@@ -154,6 +177,12 @@ export function createStartPage({
154
177
  pendingPickId = id;
155
178
  pickRanEarly = false;
156
179
  }
180
+ function syncStartPickLabel() {
181
+ const fullName = modelPicker.chosen() || "";
182
+ pickEl.title = fullName ? `${startPickTitle} · ${fullName}` : startPickTitle;
183
+ pickEl.setAttribute("aria-label", fullName
184
+ ? `Model for the new session: ${fullName}` : "Model for the new session");
185
+ }
157
186
  // The button's name, tooltip and label for what the page shows: a fresh
158
187
  // link's model, else the start page's (whose choice stays as it is).
159
188
  // Toggles the fresh-link class (the CSS shows the button in a session
@@ -179,8 +208,12 @@ export function createStartPage({
179
208
  pickEl.title = "The model this link starts on";
180
209
  pickEl.setAttribute("aria-label", "Model this link starts on");
181
210
  } else {
182
- pickEl.title = startPickTitle;
183
- pickEl.setAttribute("aria-label", "Model for the new session");
211
+ if (open) {
212
+ pickEl.title = startPickTitle;
213
+ pickEl.setAttribute("aria-label", "Model for the new session");
214
+ } else {
215
+ syncStartPickLabel();
216
+ }
184
217
  }
185
218
  }
186
219
  // A command_ran with a model (stream_handlers.js): while a pick is in
@@ -192,8 +225,8 @@ export function createStartPage({
192
225
  // Leaving a session: the start page's remembered model again (no fetch).
193
226
  function restoreStartPick() {
194
227
  modelPicker.restore(readStored(MODEL_KEY));
195
- renderNewChatLlmContext();
196
- renderNewChatThinking();
228
+ syncStartPickLabel();
229
+ renderOverrides();
197
230
  }
198
231
  win.addEventListener("beforeunload", () => { pageLeaving = true; });
199
232
  win.addEventListener("pagehide", () => { pageLeaving = true; });
@@ -206,53 +239,44 @@ export function createStartPage({
206
239
  } catch (e) {
207
240
  const text = modelsFailedText(e, { startPage: !selected(), asked, leaving: pageLeaving });
208
241
  if (text) toast(text);
242
+ // ⚙ without the model's rows (its review files only), no 🧰.
243
+ toolkits = null;
244
+ renderOverrides();
209
245
  return;
210
246
  }
247
+ toolkits = payload?.toolkits?.kits?.length ? payload.toolkits : null;
211
248
  const stored = readStored(MODEL_KEY);
212
- if (!modelPicker.setModels(payload, stored)) return;
249
+ if (!modelPicker.setModels(payload, stored)) return renderOverrides();
213
250
  startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
214
251
  pickEl.hidden = false;
215
252
  // A fresh link open meanwhile shows its own model again, not the stored one.
216
253
  syncModelPick();
217
- renderNewChatLlmContext();
218
- renderNewChatThinking();
254
+ renderOverrides();
219
255
  }
220
256
  // The picker's choice for a create request; nothing (the server's default)
221
257
  // while the list is not in.
222
258
  function chosenModel() {
223
259
  return pickEl.hidden ? undefined : modelPicker.chosen() || undefined;
224
260
  }
225
- // The create request's own choices (the chips').
261
+ // The create request's own choices (the ⚙ panel's).
226
262
  function newChoices() {
227
263
  return { llm_context: choiceWords(newChatLlmContext), thinking: newChatThinking || undefined,
228
- review: newChatReview.length ? { subjects: newChatReview } : undefined };
264
+ review: newChatReview.length ? { subjects: newChatReview } : undefined,
265
+ toolkit: newChatToolkit || undefined };
229
266
  }
230
267
  // The choices were for that new session only: a create took them.
231
268
  function clearChoices() {
232
269
  newChatLlmContext = null;
233
- renderNewChatLlmContext();
234
270
  newChatThinking = null;
235
- renderNewChatThinking();
236
271
  newChatReview = [];
237
- renderNewChatReview();
272
+ newChatToolkit = null;
273
+ renderOverrides();
238
274
  }
239
275
  // A session opened: the start page's choices were for a new session, they go.
240
276
  function dropChoices() {
241
- llmContextNewControl.close();
242
- if (newChatLlmContext) {
243
- newChatLlmContext = null;
244
- renderNewChatLlmContext();
245
- }
246
- thinkingNewControl.close();
247
- if (newChatThinking) {
248
- newChatThinking = null;
249
- renderNewChatThinking();
250
- }
251
- reviewNewControl?.close();
252
- if (newChatReview.length) {
253
- newChatReview = [];
254
- renderNewChatReview();
255
- }
277
+ settings.close();
278
+ toolkitList?.close();
279
+ clearChoices();
256
280
  }
257
281
 
258
282
  return {
@@ -261,11 +285,19 @@ export function createStartPage({
261
285
  pendingPick: () => pendingPick,
262
286
  // The start page's review files ([]: the new session isn't a review).
263
287
  reviewSubjects: () => newChatReview,
264
- // Start refused them (400 invalid_review): said in the review form, opened
265
- // again. @return whether it was said there
288
+ // Start refused them (400 invalid_review): said in the ⚙ panel's review
289
+ // view, opened again. @return whether it was said there
266
290
  reviewRefused: (message) => {
267
- if (!reviewNewControl || !newChatReview.length) return false;
268
- reviewNewControl.refuse(message);
291
+ if (!reviewEl || !newChatReview.length) return false;
292
+ settings.refuse(message);
293
+ return true;
294
+ },
295
+ // Start refused the picked kit (400 toolkit_refused): said in the 🧰
296
+ // list, opened again. Without a pick it is config.yml's default that
297
+ // is broken: @return false (the caller's alert).
298
+ toolkitRefused: (message) => {
299
+ if (!toolkitList || !newChatToolkit || !toolkits) return false;
300
+ toolkitList.refuse(message);
269
301
  return true;
270
302
  },
271
303
  };
@@ -0,0 +1,298 @@
1
+ // The start page's ⚙ panel: the new session's rarely used settings in one
2
+ // place (thinking, LLM context, review files), each a choice for the next
3
+ // create only (start_page.js keeps them; never remembered). A pick applies
4
+ // at once and the panel stays open; a typed budget has its own Set. Rows
5
+ // the picked model has no values for are hidden, but a choice already made
6
+ // is still sent: the panel says so and offers to clear it. The review files
7
+ // are a view of their own (‹ back to the settings).
8
+ //
9
+ // Beside ⚙ on the row, override chips name what is set (overrides(): "think
10
+ // high", "ctx stale", "review: README.md"); on a phone ⚙ carries a dot
11
+ // instead (settingsLabel()).
12
+
13
+ import { createChipPopover } from "./chip_popover.js";
14
+ import { APPLIES, choiceWords, mergeChoice, ownValues, strategyValue } from "./llm_context_chip.js";
15
+ import { readSubjects, reviewChipModel, reviewFormTemplate } from "./review_start.js";
16
+ import { LEVELS } from "./thinking_chip.js";
17
+ import { html, nothing, render } from "./vendor/lit-html.js";
18
+
19
+ export const DEFAULT = "default";
20
+ export const NOTE = "For this new session only.";
21
+ export const PANEL_LABEL = "Session settings";
22
+ const NO_THINKING = "the picked model has no thinking setting; it is sent anyway";
23
+ const NO_LLM_CONTEXT = "the picked model has no LLM context values; it is sent anyway";
24
+
25
+ const STRATEGIES = [
26
+ { value: "none", label: "none" },
27
+ { value: "stale", label: "stale" },
28
+ { value: "stale,forget", label: "stale + forget", title: "stale + forget (experimental)" },
29
+ ];
30
+
31
+ // The first segment: follow the model, its value in brackets ("default
32
+ // (high)"; a model with no level of its own, "default" alone).
33
+ const followSegment = (value) => ({ value: DEFAULT, label: value === DEFAULT ? DEFAULT : `${DEFAULT} (${value})`,
34
+ title: `Follow the model (${value})` });
35
+
36
+ // The Thinking row's segments: the model's own level first ("default
37
+ // (high)"), then the levels; the pressed one is the choice. null without
38
+ // the model's thinking (the row is hidden).
39
+ export function thinkingSegments(summary, choice) {
40
+ if (!summary?.level) return null;
41
+ const pressed = choice || DEFAULT;
42
+ return [followSegment(summary.level),
43
+ ...LEVELS.map((level) => ({ value: level, label: level }))].map((s) => ({ ...s, pressed: s.value === pressed }));
44
+ }
45
+
46
+ // The Context row's segments over the strategies (a choice that isn't one
47
+ // of them, a newer chi's layer, is added); null without the model's
48
+ // llm_context (the row is hidden).
49
+ export function contextSegments(summary, choice) {
50
+ if (!summary?.strategy) return null;
51
+ const pressed = Array.isArray(choice?.strategy) ? strategyValue(choice.strategy) : DEFAULT;
52
+ const list = STRATEGIES.some((s) => s.value === pressed) || pressed === DEFAULT ? STRATEGIES
53
+ : [...STRATEGIES, { value: pressed, label: pressed }];
54
+ return [followSegment(summary.strategy), ...list].map((s) => ({ ...s, pressed: s.value === pressed }));
55
+ }
56
+
57
+ // The context choice in a few words ("stale · budget 64000"), or null.
58
+ export function contextWords(choice) {
59
+ const words = choiceWords(choice);
60
+ if (!words) return null;
61
+ return [words.strategy, words.apply && `apply ${words.apply}`, words.budget && `budget ${words.budget}`]
62
+ .filter(Boolean).join(" · ");
63
+ }
64
+
65
+ // The row's override chips: what is set for the new session, each
66
+ // {text, title} or null. A choice the picked model shows no row for (its
67
+ // summary is null) stays, its title saying it is sent anyway.
68
+ export function overrides({ thinking = null, llmContext = null, review = [], thinkingSummary = null,
69
+ llmContextSummary = null } = {}) {
70
+ const ctx = contextWords(llmContext);
71
+ return {
72
+ thinking: thinking ? {
73
+ text: `think ${thinking}`,
74
+ title: thinkingSummary
75
+ ? `Thinking: ${thinking} for the new session (the model's: ${thinkingSummary.level}). Click to change it.`
76
+ : `Thinking: ${thinking} for the new session: ${NO_THINKING}. Click to change it.`,
77
+ } : null,
78
+ llmContext: ctx ? {
79
+ text: `ctx ${ctx}`,
80
+ title: llmContextSummary
81
+ ? `LLM context: ${ctx} for the new session (the model's: ${llmContextSummary.strategy}). Click to change it.`
82
+ : `LLM context: ${ctx} for the new session: ${NO_LLM_CONTEXT}. Click to change it.`,
83
+ } : null,
84
+ review: review?.length ? reviewChipModel(review) : null,
85
+ };
86
+ }
87
+
88
+ // ⚙'s state: whether anything is set (its dot on a phone) and its tooltip
89
+ // and accessible name, which list what is.
90
+ export function settingsLabel(over) {
91
+ const set = [over.thinking, over.llmContext, over.review].filter(Boolean);
92
+ return { set: set.length > 0, label: set.length ? `${PANEL_LABEL}: ${set.map((o) => o.text).join(", ")}` : PANEL_LABEL };
93
+ }
94
+
95
+ const segmentsTemplate = (name, label, segments, act) => html`<div class="ss-row"><span class="ss-label"
96
+ id=${`ss-${name}-label`}>${label}</span><div class="ss-seg" role="group" aria-labelledby=${`ss-${name}-label`}>${
97
+ segments.map((s) => html`<button type="button" data-act=${`${act}:${s.value}`} aria-pressed=${s.pressed ? "true" : "false"}
98
+ title=${s.title || nothing}>${s.label}</button>`)}</div></div>`;
99
+
100
+ const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
101
+
102
+ // The disclosure's fields: the apply rule and the budget, with their own
103
+ // Set (a typed budget needs a commit) and mergeChoice's error under them.
104
+ function contextMoreTemplate(summary, choice, error) {
105
+ const own = ownValues({ own: choice });
106
+ const budget = summary.budget_tokens ? String(summary.budget_tokens) : "off";
107
+ return html`<form class="ss-ctx-form"><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select
108
+ name="apply">${option(DEFAULT, `${DEFAULT} (${summary.apply})`, own.apply)}${APPLIES.map((a) => option(a, a, own.apply))}</select
109
+ ></span></label><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric"
110
+ autocomplete="off" placeholder=${`${DEFAULT} (${budget}): 64000, 64k or off`} .value=${own.budget}></label
111
+ ><div class="ctx-pop-error" role="alert" ?hidden=${!error}>${error || ""}</div
112
+ ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
113
+ }
114
+
115
+ // A choice whose row is hidden (the picked model has no values for it).
116
+ const sentAnywayTemplate = (text, act) => html`<div class="ss-anyway ctx-pop-meta">${text}
117
+ <button type="button" class="ss-link" data-act=${act}>Clear</button></div>`;
118
+
119
+ // The main view. +state+: {thinking: {summary, choice}, llmContext: {summary,
120
+ // choice}, review: subjects or null (no review row), more: the disclosure
121
+ // open, error: the context Set's}.
122
+ export function panelTemplate({ thinking, llmContext, review = null, more = false, error = null }) {
123
+ const think = thinkingSegments(thinking.summary, thinking.choice);
124
+ const ctx = contextSegments(llmContext.summary, llmContext.choice);
125
+ const ctxAnyway = !ctx && contextWords(llmContext.choice);
126
+ const reviewChip = review?.length ? reviewChipModel(review) : null;
127
+ return html`<div class="ctx-pop-head"><strong>New session</strong
128
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
129
+ >${think ? segmentsTemplate("think", "Thinking", think, "think") : nothing
130
+ }${!think && thinking.choice ? sentAnywayTemplate(`Thinking ${thinking.choice}: ${NO_THINKING}.`, "think-clear") : nothing
131
+ }${ctx ? html`${segmentsTemplate("ctx", "Context", ctx, "ctx")}<div class="ss-more"><button type="button" class="ss-link"
132
+ data-act="ctx-more" aria-expanded=${more ? "true" : "false"}>Apply and budget ${more ? "▾" : "▸"}</button></div
133
+ >${more ? contextMoreTemplate(llmContext.summary, llmContext.choice, error) : nothing}` : nothing
134
+ }${ctxAnyway ? sentAnywayTemplate(`Context ${ctxAnyway}: ${NO_LLM_CONTEXT}.`, "ctx-clear") : nothing
135
+ }${review ? html`${think || ctx ? html`<hr class="ss-rule">` : nothing}<div class="ss-review">${reviewChip
136
+ ? html`<span class="ss-review-set" title=${reviewChip.title}>Review: ${reviewChip.text.replace(/^review: /, "")}</span
137
+ ><button type="button" class="ss-link" data-act="review">Edit</button><button type="button" class="ss-link"
138
+ data-act="review-clear">Remove</button>`
139
+ : html`<button type="button" class="ss-link" data-act="review" title=${reviewChipModel([]).title}>Review files…</button>`}</div>`
140
+ : nothing
141
+ }<div class="ctx-pop-meta">${NOTE}</div>`;
142
+ }
143
+
144
+ // The panel on chip_popover.js' shell, a bottom sheet on a phone. +el+:
145
+ // where ⚙ is (its clicks are delegated); +button+: ⚙ (the panel opens at it,
146
+ // and focus goes back to it whichever opener was clicked); +openers+: the
147
+ // selector of every element that opens it (⚙ and the override chips: a click
148
+ // there is not a click outside). +thinking+ / +llmContext+: {summary(),
149
+ // choice(), choose(next)}; +review+: {choice(), choose(subjects)}, or null
150
+ // (no review row).
151
+ // @return {open(view), toggle(view), close, refresh, refuse(message), isOpen}
152
+ export function createStartSettings({ el, button, openers = `#${button.id}`, thinking, llmContext, review = null,
153
+ doc = document, win = window }) {
154
+ // "main" or "review"; the disclosure; the context Set's error; the review
155
+ // form's error and text as typed.
156
+ let view = "main";
157
+ let more = false;
158
+ let error = null;
159
+ let reviewError = null;
160
+ let reviewText = null;
161
+
162
+ function draw() {
163
+ if (view === "review") {
164
+ render(reviewFormTemplate(review.choice(), { error: reviewError, text: reviewText, back: true }), shell.pop);
165
+ } else {
166
+ render(panelTemplate({
167
+ thinking: { summary: thinking.summary(), choice: thinking.choice() },
168
+ llmContext: { summary: llmContext.summary(), choice: llmContext.choice() },
169
+ review: review ? review.choice() : null, more, error,
170
+ }), shell.pop);
171
+ }
172
+ shell.replace();
173
+ }
174
+
175
+ // Focus inside on open (the popover sits at the end of <body>, so Tab
176
+ // from ⚙ wouldn't reach it): the pressed segment of the first row, else
177
+ // the first button; the review view's text field.
178
+ function focusInside() {
179
+ const pop = shell.pop;
180
+ const at = view === "review" ? pop.querySelector("textarea")
181
+ : pop.querySelector(".ss-seg [aria-pressed=true]") || pop.querySelector(".ss-link, .ss-seg button");
182
+ at?.focus();
183
+ }
184
+
185
+ function switchTo(next) {
186
+ view = next;
187
+ draw();
188
+ if (next === "review") focusInside();
189
+ else shell.pop.querySelector("[data-act=review]")?.focus();
190
+ }
191
+
192
+ function pickContext(changes) {
193
+ const initial = ownValues({ own: llmContext.choice() });
194
+ let next;
195
+ try {
196
+ next = mergeChoice(llmContext.choice(), { ...initial, ...changes }, initial);
197
+ } catch (err) {
198
+ error = err.message;
199
+ return draw();
200
+ }
201
+ error = null;
202
+ llmContext.choose(next);
203
+ draw();
204
+ }
205
+
206
+ function act(name) {
207
+ const [what, value] = name.split(/:(.*)/s);
208
+ if (what === "think") thinking.choose(value === DEFAULT ? null : value);
209
+ else if (what === "think-clear") thinking.choose(null);
210
+ else if (what === "ctx") return pickContext({ strategy: value });
211
+ else if (what === "ctx-clear") llmContext.choose(null);
212
+ else if (what === "ctx-more") more = !more;
213
+ else if (what === "review") return switchTo("review");
214
+ else if (what === "back") {
215
+ reviewError = null;
216
+ reviewText = null;
217
+ return switchTo("main");
218
+ } else if (what === "review-clear" || what === "clear") {
219
+ review.choose([]);
220
+ reviewError = null;
221
+ reviewText = null;
222
+ if (view === "review") return switchTo("main");
223
+ } else return;
224
+ draw();
225
+ }
226
+
227
+ function submit(form) {
228
+ if (form.classList.contains("ss-ctx-form")) {
229
+ return pickContext({ apply: form.elements.apply.value, budget: form.elements.budget.value });
230
+ }
231
+ const typed = form.elements.subjects?.value || "";
232
+ const read = readSubjects(typed);
233
+ if (read.error) {
234
+ reviewError = read.error;
235
+ reviewText = typed;
236
+ return draw();
237
+ }
238
+ review.choose(read.subjects);
239
+ reviewError = null;
240
+ reviewText = null;
241
+ switchTo("main");
242
+ }
243
+
244
+ const shell = createChipPopover({
245
+ id: "startSettingsPopover", className: "ctx-popover start-settings", width: 440, el, anchor: `#${button.id}`, openers,
246
+ summary: () => ({}), sheet: true, label: PANEL_LABEL, doc, win,
247
+ show: (_data, opening) => {
248
+ if (opening) {
249
+ const choice = llmContext.choice();
250
+ more = !!(choice?.apply || typeof choice?.budget_tokens === "number");
251
+ }
252
+ draw();
253
+ },
254
+ onOpen: focusInside,
255
+ onClose: () => {
256
+ view = "main";
257
+ error = null;
258
+ reviewError = null;
259
+ reviewText = null;
260
+ },
261
+ onSubmit: submit,
262
+ onAct: act,
263
+ });
264
+ // An edit makes the context Set's error stale.
265
+ shell.pop.addEventListener("input", (e) => {
266
+ if (error && e.target.closest(".ss-ctx-form")) {
267
+ error = null;
268
+ draw();
269
+ }
270
+ });
271
+
272
+ function open(at = "main") {
273
+ view = review ? at : "main";
274
+ if (shell.isOpen()) {
275
+ draw();
276
+ focusInside();
277
+ } else {
278
+ shell.open(button);
279
+ }
280
+ }
281
+
282
+ return {
283
+ open,
284
+ // An override chip's click: opens the panel (on +at+'s view), or closes it.
285
+ toggle: (at = "main") => (shell.isOpen() ? shell.close() : open(at)),
286
+ close: () => shell.close(),
287
+ isOpen: () => shell.isOpen(),
288
+ // The picked model changed (its default labels, which rows show).
289
+ refresh: () => { if (shell.isOpen()) draw(); },
290
+ // Start refused the review files (400 invalid_review): said in the
291
+ // review view, opened at the files chosen.
292
+ refuse: (message) => {
293
+ reviewError = message;
294
+ reviewText = null;
295
+ open("review");
296
+ },
297
+ };
298
+ }