samagotchi 0.135.0 → 0.136.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/configuration.md +10 -7
  5. data/docs/internals/prompt-caching.md +1 -1
  6. data/docs/internals/web-frontend.md +9 -8
  7. data/docs/internals/working-set.md +2 -2
  8. data/docs/sessions.md +3 -3
  9. data/docs/testing.md +11 -6
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/version.rb +1 -1
  12. data/lib/samagotchi/web/app.rb +4 -4
  13. data/lib/samagotchi/web/message_display.rb +16 -0
  14. data/lib/samagotchi/web/public/answer_fold.js +98 -29
  15. data/lib/samagotchi/web/public/app.js +17 -40
  16. data/lib/samagotchi/web/public/bubbles.js +27 -7
  17. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  18. data/lib/samagotchi/web/public/history_reveal.js +2 -2
  19. data/lib/samagotchi/web/public/index.html +67 -44
  20. data/lib/samagotchi/web/public/info_bar.js +11 -12
  21. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  22. data/lib/samagotchi/web/public/level_chip.js +33 -95
  23. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  24. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  25. data/lib/samagotchi/web/public/revise_stack.js +9 -3
  26. data/lib/samagotchi/web/public/session_fields.js +4 -4
  27. data/lib/samagotchi/web/public/session_state.js +7 -5
  28. data/lib/samagotchi/web/public/session_store.js +1 -1
  29. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  30. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  31. data/lib/samagotchi/web/public/shell_command.js +26 -0
  32. data/lib/samagotchi/web/public/start_settings.js +25 -88
  33. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  34. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  35. data/lib/samagotchi/web/public/timing.js +13 -3
  36. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  37. data/lib/samagotchi/web/public/turn_block.js +6 -1
  38. data/lib/samagotchi/web/public/turn_events.js +1 -1
  39. data/lib/samagotchi/web/public/turn_html.js +16 -14
  40. data/lib/samagotchi/web/session_create.rb +1 -1
  41. data/lib/samagotchi/web/session_show.rb +6 -3
  42. metadata +4 -1
@@ -0,0 +1,121 @@
1
+ // The settings panel's rows, drawn by the start page's ⚙ panel
2
+ // (start_settings.js) over a new session's choices and by the session bar's
3
+ // (settings_chip.js) over the open session's own: rows of segments
4
+ // (Thinking, Context), the Context row's Apply and budget disclosure, a
5
+ // line for a choice whose row is hidden, the review files' line. The caller
6
+ // passes the rows it draws (settingsPanelTemplate's +rows+) and applies the
7
+ // picks itself; the segments' data-act ("think:low", "ctx:stale",
8
+ // "ctx-more") is the same for every caller.
9
+
10
+ import { APPLIES, choiceWords, ownValues, strategyValue } from "./llm_context_chip.js";
11
+ import { DEFAULT, followSegment, segments } from "./level_chip.js";
12
+ import { reviewChipModel } from "./review_start.js";
13
+ import { CONFIG as THINKING } from "./thinking_chip.js";
14
+ import { html, nothing } from "./vendor/lit-html.js";
15
+
16
+ export { DEFAULT };
17
+
18
+ const STRATEGIES = [
19
+ { value: "none", label: "none" },
20
+ { value: "stale", label: "stale" },
21
+ { value: "stale,forget", label: "stale + forget", title: "stale + forget (experimental)" },
22
+ ];
23
+
24
+ // The Thinking row's segments (level_chip.js segments over thinking_chip.js'
25
+ // levels): the model's own level first ("default (high)"; "default" while
26
+ // the session has its own), then the levels;
27
+ // the pressed one is the choice. null without the model's thinking (the row
28
+ // is hidden).
29
+ export function thinkingSegments(summary, choice) {
30
+ return segments(THINKING, summary, choice);
31
+ }
32
+
33
+ // Whether the summary's +field+ (strategy, apply, budget) is the session's
34
+ // own: the value in force is then not the model's, so a default label
35
+ // doesn't name it.
36
+ const ownField = (summary, field) => summary?.[`${field}_source`] === "session";
37
+
38
+ // The Context row's segments over the strategies (a choice that isn't one
39
+ // of them, a newer chi's layer, is added); null without the model's
40
+ // llm_context (the row is hidden).
41
+ export function contextSegments(summary, choice) {
42
+ if (!summary?.strategy) return null;
43
+ const pressed = Array.isArray(choice?.strategy) ? strategyValue(choice.strategy) : DEFAULT;
44
+ const list = STRATEGIES.some((s) => s.value === pressed) || pressed === DEFAULT ? STRATEGIES
45
+ : [...STRATEGIES, { value: pressed, label: pressed }];
46
+ return [followSegment(summary.strategy, ownField(summary, "strategy")), ...list]
47
+ .map((s) => ({ ...s, pressed: s.value === pressed }));
48
+ }
49
+
50
+ // The context choice in a few words ("stale · budget 64000"), or null.
51
+ export function contextWords(choice) {
52
+ const words = choiceWords(choice);
53
+ if (!words) return null;
54
+ return [words.strategy, words.apply && `apply ${words.apply}`, words.budget && `budget ${words.budget}`]
55
+ .filter(Boolean).join(" · ");
56
+ }
57
+
58
+ export const segmentsTemplate = (name, label, segs, act) => html`<div class="sset-row"><span class="sset-label"
59
+ id=${`sset-${name}-label`}>${label}</span><div class="sset-seg" role="group" aria-labelledby=${`sset-${name}-label`}>${
60
+ segs.map((s) => html`<button type="button" data-act=${`${act}:${s.value}`} aria-pressed=${s.pressed ? "true" : "false"}
61
+ title=${s.title || nothing}>${s.label}</button>`)}</div></div>`;
62
+
63
+ const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
64
+
65
+ // The disclosure's fields: the apply rule and the budget, with their own
66
+ // Set (a typed budget needs a commit) and mergeChoice's error under them.
67
+ // Each default names the model's value, unless the session set its own
68
+ // (the summary's value is the session's then).
69
+ export function contextMoreTemplate(summary, choice, error) {
70
+ const own = ownValues({ own: choice });
71
+ const budget = summary.budget_tokens ? String(summary.budget_tokens) : "off";
72
+ const apply = ownField(summary, "apply") ? DEFAULT : `${DEFAULT} (${summary.apply})`;
73
+ const budgetDefault = ownField(summary, "budget") ? DEFAULT : `${DEFAULT} (${budget})`;
74
+ return html`<form class="sset-ctx-form"><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select
75
+ name="apply">${option(DEFAULT, apply, own.apply)}${APPLIES.map((a) => option(a, a, own.apply))}</select
76
+ ></span></label><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric"
77
+ autocomplete="off" placeholder=${`${budgetDefault}: 64000, 64k or off`} .value=${own.budget}></label
78
+ ><div class="ctx-pop-error" role="alert" ?hidden=${!error}>${error || ""}</div
79
+ ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
80
+ }
81
+
82
+ // A choice whose row is hidden (the picked model has no values for it).
83
+ export const sentAnywayTemplate = (text, act) => html`<div class="sset-anyway ctx-pop-meta">${text}
84
+ <button type="button" class="sset-link" data-act=${act}>Clear</button></div>`;
85
+
86
+ // One of the panel's rows, null drawing nothing:
87
+ // {name, label, segments, act}: a row of segments (segmentsTemplate);
88
+ // with +more+ ({open, summary, choice, error}) the Apply and budget
89
+ // disclosure under it, its fields while open (contextMoreTemplate)
90
+ // {anyway, act}: the line for a choice whose row is hidden, its Clear
91
+ // button's data-act (sentAnywayTemplate)
92
+ function rowTemplate(row) {
93
+ if (!row) return nothing;
94
+ if (row.anyway) return sentAnywayTemplate(row.anyway, row.act);
95
+ const { more } = row;
96
+ return html`${segmentsTemplate(row.name, row.label, row.segments, row.act)}${more ? html`<div class="sset-ctx-more"
97
+ ><button type="button" class="sset-link" data-act="ctx-more" aria-expanded=${more.open ? "true" : "false"}>Apply and budget ${
98
+ more.open ? "▾" : "▸"}</button></div>${more.open ? contextMoreTemplate(more.summary, more.choice, more.error) : nothing}`
99
+ : nothing}`;
100
+ }
101
+
102
+ // The review files' line: the files set (Edit, Remove), else Review files….
103
+ function reviewTemplate(review, ruled) {
104
+ const chip = review.length ? reviewChipModel(review) : null;
105
+ return html`${ruled ? html`<hr class="sset-rule">` : nothing}<div class="sset-review">${chip
106
+ ? html`<span class="sset-review-set" title=${chip.title}>Review: ${chip.text.replace(/^review: /, "")}</span
107
+ ><button type="button" class="sset-link" data-act="review">Edit</button><button type="button" class="sset-link"
108
+ data-act="review-clear">Remove</button>`
109
+ : html`<button type="button" class="sset-link" data-act="review" title=${reviewChipModel([]).title}>Review files…</button>`}</div>`;
110
+ }
111
+
112
+ // The panel: its heading (+kind+ beside it, or none) and close button, the
113
+ // +rows+ (rowTemplate's), the review files' line when +review+ is a list
114
+ // (null: no review line; a rule above it when a row of segments is drawn),
115
+ // and the +note+.
116
+ export function settingsPanelTemplate({ heading, kind = null, rows = [], review = null, note }) {
117
+ const segmented = rows.some((row) => row?.segments);
118
+ return html`<div class="ctx-pop-head"><strong>${heading}</strong>${kind ? html`<span class="ctx-pop-kind">${kind}</span>` : nothing
119
+ }<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
120
+ >${rows.map(rowTemplate)}${review ? reviewTemplate(review, segmented) : nothing}<div class="ctx-pop-meta">${note}</div>`;
121
+ }
@@ -0,0 +1,26 @@
1
+ // A `!cmd`'s saved user message: the worker appends "!(<command>)\n<output>"
2
+ // for the next turn (session_commands.rb, no turn id, no time). The history
3
+ // draws it as its command bubble (turn_html.js) and the reload's turns
4
+ // leave it out (timing.js turnGroups: a group of its own, no steps, no
5
+ // timing). Pure, no imports: timing.js and turn_html.js both read it, and
6
+ // turn_html.js imports timing.js. A module of its own: static JS is cached
7
+ // at unversioned URLs, and a new export on an old module would blank the
8
+ // page.
9
+
10
+ // Its command line and its output, or null when the content is any other
11
+ // message. The command sits on the first line inside `!(` `)`; the output
12
+ // is everything after that first newline. A message that merely starts
13
+ // with "!" or "!(" without the newline shape is a plain message.
14
+ export function shellCommandMessage(content) {
15
+ const text = String(content ?? "");
16
+ const match = text.match(/^!\((.*)\)\n([\s\S]*)$/);
17
+ if (!match) return null;
18
+ return { line: `!${match[1]}`, text: match[2] };
19
+ }
20
+
21
+ // A history item that is a `!cmd`'s record, not a prompt: a user message of
22
+ // that shape with no turn id (a prompt saves its turn's; session_chain.rb
23
+ // reads the record the same way).
24
+ export function isShellCommandItem(item) {
25
+ return item?.role === "user" && !item.turn_id && shellCommandMessage(item.content) !== null;
26
+ }
@@ -11,57 +11,21 @@
11
11
  // instead (settingsLabel()).
12
12
 
13
13
  import { createChipPopover, SHEET_TALL } from "./chip_popover.js";
14
- import { APPLIES, choiceWords, mergeChoice, ownValues, strategyValue } from "./llm_context_chip.js";
14
+ import { mergeChoice, ownValues } from "./llm_context_chip.js";
15
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";
16
+ import {
17
+ contextSegments, contextWords, DEFAULT, settingsPanelTemplate, thinkingSegments,
18
+ } from "./settings_panel.js";
19
+ import { render } from "./vendor/lit-html.js";
20
+
21
+ // The panel's rows (settings_panel.js), named here too: the start page and
22
+ // its specs read them from this module.
23
+ export { contextSegments, contextWords, DEFAULT, thinkingSegments };
20
24
  export const NOTE = "For this new session only.";
21
25
  export const PANEL_LABEL = "Session settings";
22
26
  const NO_THINKING = "the picked model has no thinking setting; it is sent anyway";
23
27
  const NO_LLM_CONTEXT = "the picked model has no LLM context values; it is sent anyway";
24
28
 
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
29
  // The row's override chips: what is set for the new session, each
66
30
  // {text, title} or null. A choice the picked model shows no row for (its
67
31
  // summary is null) stays, its title saying it is sent anyway.
@@ -92,53 +56,26 @@ export function settingsLabel(over) {
92
56
  return { set: set.length > 0, label: set.length ? `${PANEL_LABEL}: ${set.map((o) => o.text).join(", ")}` : PANEL_LABEL };
93
57
  }
94
58
 
95
- const segmentsTemplate = (name, label, segments, act) => html`<div class="sset-row"><span class="sset-label"
96
- id=${`sset-${name}-label`}>${label}</span><div class="sset-seg" role="group" aria-labelledby=${`sset-${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="sset-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="sset-anyway ctx-pop-meta">${text}
117
- <button type="button" class="sset-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}.
59
+ // The main view (settings_panel.js settingsPanelTemplate over the new
60
+ // session's choices). +state+: {thinking: {summary, choice}, llmContext:
61
+ // {summary, choice}, review: subjects or null (no review row), more: the
62
+ // disclosure open, error: the context Set's}.
122
63
  export function panelTemplate({ thinking, llmContext, review = null, more = false, error = null }) {
123
64
  const think = thinkingSegments(thinking.summary, thinking.choice);
124
65
  const ctx = contextSegments(llmContext.summary, llmContext.choice);
125
66
  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="sset-ctx-more"><button type="button" class="sset-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="sset-rule">` : nothing}<div class="sset-review">${reviewChip
136
- ? html`<span class="sset-review-set" title=${reviewChip.title}>Review: ${reviewChip.text.replace(/^review: /, "")}</span
137
- ><button type="button" class="sset-link" data-act="review">Edit</button><button type="button" class="sset-link"
138
- data-act="review-clear">Remove</button>`
139
- : html`<button type="button" class="sset-link" data-act="review" title=${reviewChipModel([]).title}>Review files…</button>`}</div>`
140
- : nothing
141
- }<div class="ctx-pop-meta">${NOTE}</div>`;
67
+ return settingsPanelTemplate({
68
+ heading: "New session",
69
+ rows: [
70
+ think ? { name: "think", label: "Thinking", segments: think, act: "think" } : null,
71
+ !think && thinking.choice ? { anyway: `Thinking ${thinking.choice}: ${NO_THINKING}.`, act: "think-clear" } : null,
72
+ ctx ? { name: "ctx", label: "Context", segments: ctx, act: "ctx",
73
+ more: { open: more, summary: llmContext.summary, choice: llmContext.choice, error } } : null,
74
+ ctxAnyway ? { anyway: `Context ${ctxAnyway}: ${NO_LLM_CONTEXT}.`, act: "ctx-clear" } : null,
75
+ ],
76
+ review,
77
+ note: NOTE,
78
+ });
142
79
  }
143
80
 
144
81
  // The panel on chip_popover.js' shell, a bottom sheet on a phone. +el+:
@@ -1,16 +1,9 @@
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 (SettingExplained#summary:
6
- // {level, source, own}; the worker's snapshot, a command_ran, or the
7
- // server's own reading of the session file). A config on level_chip.js.
8
- //
9
- // The start page's new session sets its level in the ⚙ panel instead
10
- // (start_settings.js, over LEVELS).
1
+ // Thinking as a level setting (level_chip.js): the levels the settings
2
+ // panels' Thinking row offers (settings_panel.js; the start page's ⚙ panel,
3
+ // the session bar's, settings_chip.js) and the /thinking line a session's
4
+ // pick runs in its worker.
11
5
 
12
6
  import * as level from "./level_chip.js";
13
- import { html, nothing } from "./vendor/lit-html.js";
14
7
 
15
8
  export const LEVELS = ["off", "low", "medium", "high"];
16
9
 
@@ -19,42 +12,11 @@ export const CONFIG = Object.freeze({
19
12
  prefix: "think",
20
13
  command: "/thinking",
21
14
  levels: LEVELS,
22
- anchor: "[data-think-chip]",
23
- formClass: "think-form",
24
- popoverClass: "think-popover",
25
- nowClass: "think-now",
26
- when: () => "From the next turn's start. On Gemma the whole prompt is read again; a hosted API reads the whole conversation again.",
15
+ cost: "A thinking change: on Gemma the whole prompt is read again; a hosted API reads the whole conversation again.",
27
16
  });
28
17
 
29
- export const { nowText } = level;
30
-
31
- // The chip: its text, tooltip and whether the session set its own; null
32
- // without a summary (an older worker). The default with nothing set
33
- // anywhere shows too ("think default"): the chip is where the level is set.
34
- export function chipModel(summary) {
35
- return level.chipModel(CONFIG, summary);
36
- }
37
-
38
- // The session bar's chip (a lit-html template; info_bar.js), nothing
39
- // without a summary.
40
- export function chipTemplate(summary) {
41
- const chip = chipModel(summary);
42
- if (!chip) return nothing;
43
- return html`<button type="button" class=${`model think-chip${chip.own ? " own" : ""}`} title=${chip.title} data-think-chip aria-haspopup="dialog">${chip.text}</button>`;
44
- }
45
-
46
- // The /thinking line for a select value, with --yes (level_chip.js
47
- // commandLine); null when it is what the form opened with.
18
+ // The /thinking line for a level, with --yes (level_chip.js commandLine);
19
+ // null when it is the session's (+initial+).
48
20
  export function commandLine(choice, initial = null) {
49
21
  return level.commandLine(CONFIG, choice, initial);
50
22
  }
51
-
52
- // The popover's form (level_chip.js formTemplate).
53
- export function formTemplate(summary, opts) {
54
- return level.formTemplate(CONFIG, summary, opts);
55
- }
56
-
57
- // The chip's popover (level_chip.js createLevelControl).
58
- export function createThinkingControl({ id = "thinkingPopover", ...opts }) {
59
- return level.createLevelControl({ cfg: CONFIG, id, ...opts });
60
- }
@@ -28,7 +28,7 @@ export function timeGapEl(text) {
28
28
 
29
29
  // The history items a group (timing.js turnGroups) holds, by index.
30
30
  function groupIndexes(group) {
31
- if (group.kind === "note" || group.kind === "steer") return [group.i];
31
+ if (group.kind === "note" || group.kind === "steer" || group.kind === "command") return [group.i];
32
32
  if (group.kind === "answer") return [group.answer];
33
33
  const steps = group.steps || [];
34
34
  return [
@@ -54,11 +54,14 @@ export function groupTimes(group, items) {
54
54
 
55
55
  // For each group, the divider text before it, or null. A group with no
56
56
  // known time draws none and hides the gap across it (no divider measured
57
- // over a stretch whose times are unknown).
57
+ // over a stretch whose times are unknown). A `!cmd`'s record is saved with
58
+ // no time: it draws none and the gap is measured across it (it ran in that
59
+ // stretch).
58
60
  export function groupGaps(groups, items, now = Date.now()) {
59
61
  let prevEnd = null;
60
62
  return groups.map((group) => {
61
63
  const { start, end } = groupTimes(group, items);
64
+ if (group.kind === "command" && start === null) return null;
62
65
  const text = prevEnd !== null && start !== null ? gapText(prevEnd, start, now) : null;
63
66
  prevEnd = end;
64
67
  return text;
@@ -1,3 +1,5 @@
1
+ import { isShellCommandItem } from "./shell_command.js";
2
+
1
3
  export function formatDuration(milliseconds) {
2
4
  const ms = Number(milliseconds);
3
5
  if (!Number.isFinite(ms) || ms < 0) return "";
@@ -148,8 +150,10 @@ export function turnRecordAt(timing, index) {
148
150
  }
149
151
 
150
152
  // A history item that starts a turn: a prompt, or the note a context wake
151
- // turn starts with (turn_start).
152
- const startsTurn = (item) => item?.role === "user" || (item?.role === "note" && item.turn_start === true);
153
+ // turn starts with (turn_start). A `!cmd`'s record (shell_command.js) is a
154
+ // user message but no prompt: no turn, no record, no timing.
155
+ const startsTurn = (item) =>
156
+ (item?.role === "user" && !isShellCommandItem(item)) || (item?.role === "note" && item.turn_start === true);
153
157
 
154
158
  // For each history item, the turn index its timing shows under, or null. A
155
159
  // turn with tool calls holds several assistant messages; only the last one
@@ -181,7 +185,9 @@ export function timedTurnIndexes(items) {
181
185
  // assistant message). Assistant messages before any prompt are answers on
182
186
  // their own. A turn that ended with no answer (the server's empty_answer
183
187
  // item) has no answer: its texts are steps, and `emptyAnswer` is that
184
- // item's index. The turn index counts prompts, as timedTurnIndexes does. The
188
+ // item's index. A `!cmd`'s record is a group of its own (`command`, its
189
+ // index): no steps, no record, no turn index. The turn index counts
190
+ // prompts, as timedTurnIndexes does. The
185
191
  // user's lines merged into the running turn (`merged`, the server's
186
192
  // Steer::INPUT_KIND) stay in it: the turn's `merged` list.
187
193
  export function turnGroups(items, timing) {
@@ -191,6 +197,10 @@ export function turnGroups(items, timing) {
191
197
  items.forEach((item, i) => {
192
198
  if (item?.role === "note" && !startsTurn(item)) {
193
199
  groups.push({ kind: "note", i });
200
+ } else if (isShellCommandItem(item)) {
201
+ // A `!cmd`'s record: its bubble alone, between the turns.
202
+ groups.push({ kind: "command", i });
203
+ turn = null;
194
204
  } else if (item?.role === "steer") {
195
205
  if (turn) turn.steers.push(i);
196
206
  else groups.push({ kind: "steer", i });
@@ -1,20 +1,34 @@
1
- // The session bar's tools chip: the session's toolkit when it isn't full
2
- // ("tools lean"), with the tools it leaves out and how to change them in
3
- // its tooltip. The summary is the session's toolkit ({name, label,
4
- // left_out, reachable, removed, fallback}: Toolkit::Explained#summary; the
5
- // worker's snapshot or a command_ran; the server's own reading of the
6
- // session file has no lists). Left out is reachable (find_tools and
7
- // call_tool reach them) and removed (only /toolkit add brings one back).
8
- // Changing it is /toolkit (no popover yet). A kit with a prompt text has
9
- // it on the summary too (prompt), and the tooltip ends with it.
1
+ // The session bar's 🧰 chip, after ⚙: the session's toolkit. "🧰" alone
2
+ // while the session is on config's default kit (toolkit.default, full
3
+ // unless set; GET /api/sessions/:id's session.toolkit_default), "🧰 lean"
4
+ // on another; the tooltip names the kit, the tools it leaves out and how to
5
+ // change them. No popover: /toolkit changes it. The summary is the
6
+ // session's toolkit ({name, label, left_out, reachable, removed, fallback}:
7
+ // Toolkit::Explained#summary, null on full; the worker's snapshot or a
8
+ // command_ran; the server's own reading of the session file has no lists).
9
+ // Left out is reachable (find_tools and call_tool reach them) and removed
10
+ // (only /toolkit add brings one back). A kit with a prompt text has it on
11
+ // the summary too (prompt), and the tooltip ends with it.
10
12
 
11
13
  import { html, nothing } from "./vendor/lit-html.js";
12
14
 
13
- // The chip's text and tooltip; null without a summary (full).
14
- export function chipModel(summary) {
15
- if (!summary) return null;
15
+ const FULL = "full";
16
+ const CHANGE = "/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.";
17
+
18
+ // The chip's text ("" while on the default kit: 🧰 alone), tooltip and
19
+ // whether it is the default. +summary+: the session's (null: full);
20
+ // +defaultKit+: config's toolkit.default (null: not known, full).
21
+ export function chipModel(summary, defaultKit = null) {
22
+ const fallbackKit = defaultKit || FULL;
23
+ if (!summary) {
24
+ const isDefault = fallbackKit === FULL;
25
+ const title = isDefault ? `Toolkit: ${FULL} (the default), every tool is declared. ${CHANGE}`
26
+ : `Toolkit: ${FULL}, every tool is declared (the default is ${fallbackKit}). ${CHANGE}`;
27
+ return { text: isDefault ? "" : FULL, title, isDefault };
28
+ }
16
29
  const label = summary.label || summary.name;
17
- const parts = [`Toolkit: ${label}, the tools this session declares.`];
30
+ const isDefault = !summary.fallback && label === fallbackKit;
31
+ const parts = [`Toolkit: ${label}${isDefault ? " (the default)" : ""}, the tools this session declares.`];
18
32
  if (summary.fallback) parts.push(`${summary.fallback}: every tool is declared (full) until it resolves.`);
19
33
  if (Array.isArray(summary.groups)) {
20
34
  parts.push(`Source groups: ${summary.groups.length ? summary.groups.join(", ") : "none"}.`);
@@ -27,15 +41,17 @@ export function chipModel(summary) {
27
41
  } else if (Array.isArray(summary.left_out)) {
28
42
  parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
29
43
  }
30
- parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
44
+ parts.push(CHANGE);
31
45
  if (summary.prompt) parts.push(`Prompt: ${summary.prompt}`);
32
- return { text: `tools ${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" ") };
46
+ return { text: isDefault ? "" : `${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" "), isDefault };
33
47
  }
34
48
 
35
- // The session bar's chip (a lit-html template; info_bar.js), nothing on
36
- // full.
37
- export function chipTemplate(summary) {
38
- const chip = chipModel(summary);
39
- if (!chip) return nothing;
40
- return html`<span class="model tools-chip" title=${chip.title}>${chip.text}</span>`;
49
+ // The session bar's chip (a lit-html template; info_bar.js): +toolkit+ is
50
+ // {summary, defaultKit}; nothing without it. 🧰 is drawn as the start
51
+ // page's toolkit icon (the glyph stays the text; index.html's CSS).
52
+ export function chipTemplate(toolkit) {
53
+ if (!toolkit) return nothing;
54
+ const chip = chipModel(toolkit.summary, toolkit.defaultKit);
55
+ return html`<span class=${`model tools-chip${chip.isDefault ? " default" : ""}`} title=${chip.title}><span
56
+ class="chip-icon tc-kit" aria-hidden="true">🧰</span>${chip.text ? ` ${chip.text}` : ""}</span>`;
41
57
  }
@@ -840,6 +840,8 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
840
840
  if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
841
841
  if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
842
842
  if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
843
+ // A `!cmd`'s record: its command bubble, no steps, no timing.
844
+ if (group.kind === "command") { parts.push(userTemplate(items[group.i])); continue; }
843
845
  turnOrdinal += 1;
844
846
  const prompt = items[group.user];
845
847
  // A context wake turn starts with its note.
@@ -881,8 +883,11 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
881
883
  // followed by the text that answered it when that isn't the answer: a
882
884
  // small model tells its user about a report before its next tool call,
883
885
  // and that step's text would stay folded in the block.
886
+ // A report's fold key (answer_fold.js), as its reply's: the turn's id
887
+ // and its place after the opening; none in a turn saved before turn ids.
888
+ const lineKey = (i) => (group.turnId && items[i]?.source === "delegate_report" ? `${group.turnId}:input:${i - group.user}` : null);
884
889
  parts.push((group.merged || []).map((i) =>
885
- html`${userTemplate(items[i], { state: "steered", step: items[i].step })}${reportReplyTemplate(i, group)}`));
890
+ html`${userTemplate(items[i], { state: "steered", step: items[i].step, lineKey: lineKey(i) })}${reportReplyTemplate(i, group)}`));
886
891
  // A turn that ended with no answer: chi's muted notice where the answer
887
892
  // would be, before the timing, as live.
888
893
  if (group.emptyAnswer != null) parts.push(html`${emptyAnswerTemplate(items[group.emptyAnswer])}${timingTemplate(group)}`);
@@ -328,7 +328,7 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
328
328
  case "pending_input_merged":
329
329
  if (!unmatchedMerge || !event.content) return [];
330
330
  if (unmatchedMerge === "report") {
331
- return [{ op: "add", enqueuedId: null, prompt: event.content, state: wakeStart ? null : "steered", label: clientLabel(REPORT_CLIENT) }];
331
+ return [{ op: "add", enqueuedId: null, prompt: event.content, state: wakeStart ? null : "steered", label: clientLabel(REPORT_CLIENT), source: "delegate_report" }];
332
332
  }
333
333
  return [{ op: "add", enqueuedId: null, prompt: event.content, state: "steered", label: null }];
334
334
  default:
@@ -214,18 +214,8 @@ export function stateBadgeTemplate(state, delivery = null) {
214
214
  return html`<span class="state-badge">${text}${remove}</span>`;
215
215
  }
216
216
 
217
- // A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
218
- // for the next turn): its command line and its output, or null when the
219
- // content is any other message. The command sits on the first line inside
220
- // `!(` `)`; the output is everything after that first newline. A message
221
- // that merely starts with "!" or "!(" without the newline shape is a plain
222
- // message.
223
- export function shellCommandMessage(content) {
224
- const text = String(content ?? "");
225
- const match = text.match(/^!\((.*)\)\n([\s\S]*)$/);
226
- if (!match) return null;
227
- return { line: `!${match[1]}`, text: match[2] };
228
- }
217
+ // A `!cmd`'s saved user message as its line and output (shell_command.js).
218
+ export { shellCommandMessage } from "./shell_command.js";
229
219
 
230
220
  // A `!cmd`'s history bubble: the same markup the live page draws for a
231
221
  // command (app.js's drawCommandBubble): the line, the output in a
@@ -316,12 +306,24 @@ export function steerRowTemplate({ source, text } = {}, { bubble = false } = {})
316
306
  // colour) in place of +label+. A turn's opening carries its Revise stamp
317
307
  // (+message.revises+: data-revises, revise_stack.js) and whether it was a
318
308
  // Verify turn (+message.verify+: data-verify, no Revise on its answer).
319
- export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null, delivery = null, author = null } = {}) {
309
+ // +message.source+ (a delegate report's "delegate_report") is its
310
+ // data-source; +lineKey+, a merged line's fold key (answer_fold.js:
311
+ // data-line-key, the reload's "<turn id>:input:<place>").
312
+ export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null, delivery = null, author = null, lineKey = null } = {}) {
320
313
  const named = BADGES[state] ? state : null;
321
314
  const from = author
322
315
  ? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name, author.mode)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name, author.mode)}</span>`
323
316
  : label ? html`<span class="origin-label">${label}</span>` : nothing;
324
- return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-participant-mode=${author?.mode || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-turn-id=${message?.turn_id || nothing} data-revises=${(message?.turn_id && message?.revises?.turn_id) || nothing} data-verify=${message?.turn_id && message?.verify ? "1" : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
317
+ return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-participant-mode=${author?.mode || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-turn-id=${message?.turn_id || nothing} data-revises=${(message?.turn_id && message?.revises?.turn_id) || nothing} data-verify=${message?.turn_id && message?.verify ? "1" : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing} data-source=${message?.source || nothing} data-line-key=${lineKey || nothing}>${from}${userMessageTemplate(message, thumbs)}${stateBadgeTemplate(named, delivery)}</div>`;
318
+ }
319
+
320
+ // A user line's text: plain (its quotes set apart), or the server's
321
+ // rendered markdown when the line has it (a delegate report's,
322
+ // message_display.rb; a live line has none until a reload). Its copy
323
+ // source stays the raw text either way (data-copy-source).
324
+ function userMessageTemplate(message, thumbs) {
325
+ if (typeof message?.html === "string") return html`<div class="user-message markdown" .innerHTML=${message.html}></div>`;
326
+ return html`<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>`;
325
327
  }
326
328
 
327
329
  // The notice of a turn that ended with no answer (the server's
@@ -69,7 +69,7 @@ module Samagotchi
69
69
  return error if error
70
70
 
71
71
  # llm_context: the session's own LLM context values before its first
72
- # turn (the start page's llm ctx chip; chi --llm-context), read
72
+ # turn (the start page's ⚙ panel; chi --llm-context), read
73
73
  # before anything spawns so a bad one spawns nothing.
74
74
  llm_context, error = request_llm_context(body["llm_context"])
75
75
  return error if error
@@ -13,6 +13,7 @@ require_relative "../session_commands"
13
13
  require_relative "../session_metrics"
14
14
  require_relative "../session_setting"
15
15
  require_relative "../system_one/log"
16
+ require_relative "../toolkit"
16
17
 
17
18
  module Samagotchi
18
19
  module Web
@@ -151,14 +152,16 @@ module Samagotchi
151
152
  else
152
153
  session_json = session_json.merge(saved_served_model(session, persisted))
153
154
  end
154
- # The per-session settings' chips (the info bar's llm ctx, think,
155
- # reasoning and tools): the worker's, else worked out here from the
155
+ # The per-session settings' chips (the info bar's ⚙ chip: llm ctx, think
156
+ # and reasoning; the tools chip): the worker's, else worked out here from the
156
157
  # session file (SessionSetting#snapshot_value, #saved_summary).
157
158
  SessionSetting::ALL.each do |setting|
158
159
  chip = setting.snapshot_value(snapshot) { setting.saved_summary(session, registry: host_registry) }
159
160
  session_json = session_json.merge(setting.key => chip)
160
161
  end
161
- session_json
162
+ # The 🧰 chip reads the session's toolkit against config's default:
163
+ # on it, the chip is 🧰 alone (a toolkit summary is nil on full only).
164
+ session_json.merge(toolkit_default: Toolkit.configured.name)
162
165
  end
163
166
 
164
167
  # ?cards=1: the page's re-read when a card arrives, for its rendered