samagotchi 0.134.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +43 -38
  6. data/docs/configuration.md +11 -9
  7. data/docs/guardrails.md +53 -9
  8. data/docs/internals/engine.md +6 -3
  9. data/docs/internals/participants.md +1 -1
  10. data/docs/internals/prompt-caching.md +1 -1
  11. data/docs/internals/web-frontend.md +263 -0
  12. data/docs/internals/working-set.md +2 -2
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +36 -6
  15. data/lib/samagotchi/bridge.rb +11 -6
  16. data/lib/samagotchi/bridge_client.rb +8 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +0 -2
  19. data/lib/samagotchi/engine.rb +69 -12
  20. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  21. data/lib/samagotchi/guardrails/approval.rb +41 -5
  22. data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
  23. data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
  24. data/lib/samagotchi/guardrails/pair_command.rb +387 -78
  25. data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
  27. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  28. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  29. data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
  30. data/lib/samagotchi/guardrails_command.rb +13 -10
  31. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  32. data/lib/samagotchi/message_parts.rb +1 -1
  33. data/lib/samagotchi/revise_anchor.rb +33 -0
  34. data/lib/samagotchi/session_inbox.rb +20 -12
  35. data/lib/samagotchi/session_manager.rb +13 -11
  36. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  37. data/lib/samagotchi/system_one/self_protect.rb +13 -15
  38. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  39. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  40. data/lib/samagotchi/tools/execute.rb +6 -6
  41. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  42. data/lib/samagotchi/tools/read.rb +5 -5
  43. data/lib/samagotchi/turn_anchors.rb +49 -0
  44. data/lib/samagotchi/verify_anchor.rb +5 -5
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +6 -18
  47. data/lib/samagotchi/web/message_display.rb +33 -1
  48. data/lib/samagotchi/web/participant_access.rb +2 -2
  49. data/lib/samagotchi/web/participant_view.rb +8 -0
  50. data/lib/samagotchi/web/public/activity.js +1 -1
  51. data/lib/samagotchi/web/public/annotations.js +1 -1
  52. data/lib/samagotchi/web/public/answer_fold.js +100 -31
  53. data/lib/samagotchi/web/public/app.js +451 -367
  54. data/lib/samagotchi/web/public/bubbles.js +30 -10
  55. data/lib/samagotchi/web/public/cards_place.js +4 -2
  56. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  57. data/lib/samagotchi/web/public/composer.css +8 -0
  58. data/lib/samagotchi/web/public/context_chips.js +9 -6
  59. data/lib/samagotchi/web/public/data.js +4 -2
  60. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  61. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  62. data/lib/samagotchi/web/public/history_reveal.js +10 -6
  63. data/lib/samagotchi/web/public/index.html +105 -53
  64. data/lib/samagotchi/web/public/info_bar.js +11 -12
  65. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  66. data/lib/samagotchi/web/public/latest.js +46 -0
  67. data/lib/samagotchi/web/public/level_chip.js +33 -95
  68. data/lib/samagotchi/web/public/lists_view.js +15 -13
  69. data/lib/samagotchi/web/public/live_timing.js +2 -0
  70. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  71. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  72. data/lib/samagotchi/web/public/method_api.js +14 -0
  73. data/lib/samagotchi/web/public/notifications.js +4 -11
  74. data/lib/samagotchi/web/public/participant.js +11 -6
  75. data/lib/samagotchi/web/public/participant_names.js +1 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  77. data/lib/samagotchi/web/public/project_picker.js +18 -22
  78. data/lib/samagotchi/web/public/question_cards.js +2 -2
  79. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  80. data/lib/samagotchi/web/public/restore.js +2 -0
  81. data/lib/samagotchi/web/public/revise_stack.js +378 -0
  82. data/lib/samagotchi/web/public/scope.js +12 -20
  83. data/lib/samagotchi/web/public/send_turn.js +29 -16
  84. data/lib/samagotchi/web/public/session_actions.js +15 -14
  85. data/lib/samagotchi/web/public/session_fields.js +152 -0
  86. data/lib/samagotchi/web/public/session_payload.js +35 -0
  87. data/lib/samagotchi/web/public/session_state.js +9 -5
  88. data/lib/samagotchi/web/public/session_store.js +191 -0
  89. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  90. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  91. data/lib/samagotchi/web/public/shell_command.js +26 -0
  92. data/lib/samagotchi/web/public/stage_model.js +4 -4
  93. data/lib/samagotchi/web/public/stage_view.js +11 -16
  94. data/lib/samagotchi/web/public/start_settings.js +25 -88
  95. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  96. data/lib/samagotchi/web/public/task_stop.js +1 -1
  97. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  98. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  99. data/lib/samagotchi/web/public/timing.js +15 -5
  100. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  101. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
  102. data/lib/samagotchi/web/public/turn_events.js +3 -3
  103. data/lib/samagotchi/web/public/turn_html.js +20 -16
  104. data/lib/samagotchi/web/public/turn_model.js +6 -6
  105. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  106. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  107. data/lib/samagotchi/web/server.rb +4 -4
  108. data/lib/samagotchi/web/session_control.rb +2 -2
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +6 -3
  111. data/lib/samagotchi/worker.rb +9 -9
  112. data/lib/samagotchi/worker_inbound.rb +6 -5
  113. metadata +15 -3
  114. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -19,7 +19,7 @@
19
19
  // refresh() fetch the list (no events stream)
20
20
  // select(id) open a session
21
21
  // confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
22
- // updateInfoBar(), updateSessionTitle(), showToast(text, action)
22
+ // updateSessionTitle(), showToast(text, action)
23
23
  // allViewOpen(), openAllView(), leaveAllView(hash)
24
24
  // renameShown(owner) whether a card's title has its ✎ (session_title.js)
25
25
  // onRename() the active card's ✎ clicked (session_rename.js open)
@@ -231,7 +231,7 @@ function draw(el, template) {
231
231
  export function createListsView(deps) {
232
232
  const {
233
233
  doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
234
- confirmDelete, setArchived, archiveOne, updateInfoBar, updateSessionTitle, showToast,
234
+ confirmDelete, setArchived, archiveOne, updateSessionTitle, showToast,
235
235
  allViewOpen, openAllView, leaveAllView, renameShown = () => false, onRename = () => {}, renaming = () => false,
236
236
  } = deps;
237
237
  const $ = (s) => doc.querySelector(s);
@@ -302,18 +302,25 @@ export function createListsView(deps) {
302
302
  // is the cards drawn, which select mode picks from.
303
303
  const drawnOrder = new Map(); // list element → card ids in the order last drawn
304
304
  const heldLists = new Set(); // lists drawn in a held order
305
+ // Lists whose last draw was hidden (the strip under the all view, which
306
+ // the store's list write draws as the view opens): that order was never
307
+ // seen, so a hover that stuck on the strip doesn't hold it afterwards.
308
+ const drawnHidden = new Set();
305
309
  const canHover = win.matchMedia?.("(hover: hover)").matches ?? false;
306
310
  function listOrder(el, next, idOf = (s) => s.id) {
307
311
  // The strip's popover holds it too (the pointer leaves the strip for it,
308
312
  // and a touch screen has no hover at all), and so does a rename typed
309
313
  // over its active card (the card doesn't jump away under the input).
310
- const holding = el.getClientRects().length > 0
314
+ const shown = el.getClientRects().length > 0;
315
+ const holding = shown
311
316
  && ((canHover && el.matches(":hover")) || (el === topStripEl && (familyPop.isOpen() || renaming())) || chainPopIn(el));
312
- const held = holding ? drawnOrder.get(el) : null;
317
+ const held = holding && !drawnHidden.has(el) ? drawnOrder.get(el) : null;
313
318
  const ordered = held ? heldOrder(held, next, idOf) : next;
314
319
  if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
315
320
  else heldLists.delete(el);
316
321
  drawnOrder.set(el, ordered.map(idOf));
322
+ if (shown) drawnHidden.delete(el);
323
+ else drawnHidden.add(el);
317
324
  return ordered;
318
325
  }
319
326
 
@@ -498,7 +505,7 @@ export function createListsView(deps) {
498
505
  noteChainFamilies(cards);
499
506
  const refused = scopeError();
500
507
  if (refused) {
501
- draw(topStripEl, html`<div class="hint scope-error">${refused} · <a href=${allScopeHref("", null, win.location.search)}>show every session</a></div>`);
508
+ draw(topStripEl, html`<div class="hint scope-error">${refused} · <a href=${allScopeHref("")}>show every session</a></div>`);
502
509
  lastDrawn.delete(topStripEl);
503
510
  familyPop.close();
504
511
  chainPop.close();
@@ -727,14 +734,9 @@ export function createListsView(deps) {
727
734
  selecting.running = { done: 0, total: ids.length, archive };
728
735
  updateSelectBar();
729
736
  try {
730
- const outcome = await runBatch(ids, async (id) => {
731
- const answer = await archiveOne(id, archive);
732
- if (answer.ok) {
733
- render();
734
- updateInfoBar();
735
- }
736
- return answer;
737
- }, (done) => {
737
+ // An archived one flips in the store's list (archiveOne): the lists
738
+ // and the bar redraw from there.
739
+ const outcome = await runBatch(ids, (id) => archiveOne(id, archive), (done) => {
738
740
  selecting.running.done = done;
739
741
  updateSelectBar();
740
742
  });
@@ -105,6 +105,8 @@ export function createLiveTiming({
105
105
 
106
106
  start(startedAt = new Date(now()).toISOString(), id = null) {
107
107
  stopTicker();
108
+ // In place, on purpose: the running turn's own clock in the session
109
+ // store's timing (session_fields.js, D8).
108
110
  timing().activeTurn = id ? { id, started_at: startedAt } : { started_at: startedAt };
109
111
  turnId = id || null;
110
112
  // The turns ended so far, so this one comes next.
@@ -1,28 +1,18 @@
1
- // The session bar's LLM context chip: the strategy the next turn runs under
2
- // ("llm ctx stale,forget"; "· session" when the session set its own), and a
3
- // popover that sets the session's own strategy, apply rule and budget by
4
- // running /llm-context in its worker (the command's reply shows in the
5
- // conversation, like a typed one). The values and where each came from are
6
- // the session's llm_context (LLMContextStrategy::Explained#summary: the
1
+ // The session's LLM context (LLMContextStrategy::Explained#summary: the
7
2
  // worker's snapshot, a command_ran, or the server's own reading of the
8
- // session file).
3
+ // session file) as the settings panels read and change it: the session's own
4
+ // values (ownValues), the /llm-context line a session bar's pick runs in its
5
+ // worker (commandLine; settings_chip.js), a typed budget (parseBudget).
9
6
  //
10
- // The start page's new session sets its strategy in the ⚙ panel instead
7
+ // The start page's new session sets its strategy in the ⚙ panel
11
8
  // (start_settings.js): mergeChoice and choiceWords are its, a choice in the
12
9
  // session file's shape sent as POST /api/sessions' llm_context words.
13
10
 
14
- import { createChipPopover } from "./chip_popover.js";
15
- import { html, nothing, render } from "./vendor/lit-html.js";
16
-
17
- const STRATEGIES = [
18
- { value: "none", label: "none" },
19
- { value: "stale", label: "stale" },
20
- { value: "stale,forget", label: "stale + forget (experimental)" },
21
- ];
22
11
  export const APPLIES = ["payoff", "next_request", "turn_end"];
23
- export const SESSION = "session";
24
12
 
25
13
  const DEFAULT = "default";
14
+ // What a budget parseBudget refuses gets, under the fields.
15
+ export const BUDGET_ERROR = "A budget is a number of tokens from 4000 (4k) to 10000000, or off.";
26
16
  const LAYER_ORDER = ["stale", "forget"];
27
17
 
28
18
  // Layers as one value, in chi's order (stale before forget); unknown ones after.
@@ -41,31 +31,12 @@ export function ownValues(summary) {
41
31
  return { strategy, apply: own.apply || DEFAULT, budget };
42
32
  }
43
33
 
44
- // The chip: its text, tooltip and whether the session set its own; null
45
- // without a summary (an older worker).
46
- export function chipModel(summary) {
47
- if (!summary || !summary.strategy) return null;
48
- const own = summary.strategy_source === "session" || !!summary.own;
49
- const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
50
- const title = `LLM context strategy: ${summary.strategy} (${summary.strategy_where}); apply ${summary.apply} ` +
51
- `(${summary.apply_where}); budget ${budget} (${summary.budget_where}). Click to change it for this session.`;
52
- return { text: `llm ctx ${summary.strategy}${own ? ` · ${SESSION}` : ""}`, title, own };
53
- }
54
-
55
- // The session bar's chip (a lit-html template; info_bar.js), nothing
56
- // without a summary.
57
- export function chipTemplate(summary) {
58
- const chip = chipModel(summary);
59
- if (!chip) return nothing;
60
- return html`<button type="button" class=${`model llmctx-chip${chip.own ? " own" : ""}`} title=${chip.title} aria-haspopup="dialog">${chip.text}</button>`;
61
- }
62
-
63
- // The /llm-context line for the form's values. With +initial+ (the values
64
- // the form opened with) only the fields the user changed go, so a value the
65
- // form can't show is never unset by a Set; null when nothing changed.
34
+ // The /llm-context line for the panel's values. With +initial+ (the
35
+ // session's own values, ownValues) only the fields that differ go, so a
36
+ // value the tap didn't touch is never unset; null when nothing changed.
66
37
  // Without it every field goes, "default" for one left to the model, and all
67
- // of them default is a reset. With --yes: the form states what a change
68
- // costs, so a Set is the choice (no confirm step), as level_chip.js's.
38
+ // of them default is a reset. With --yes: the panel states what a change
39
+ // costs, so a pick is the choice (no confirm step), as level_chip.js's.
69
40
  export function commandLine({ strategy = DEFAULT, apply = DEFAULT, budget = "" } = {}, initial = null) {
70
41
  const words = { strategy, apply, budget: String(budget).trim() || DEFAULT };
71
42
  const was = initial && { strategy: initial.strategy, apply: initial.apply, budget: String(initial.budget).trim() || DEFAULT };
@@ -104,7 +75,7 @@ export function mergeChoice(choice, { strategy = DEFAULT, apply = DEFAULT, budge
104
75
  else if (field === "apply") next.apply = word;
105
76
  else {
106
77
  const tokens = parseBudget(word);
107
- if (tokens === null) throw new Error("A budget is a number of tokens from 4000 (4k) to 10000000, or off.");
78
+ if (tokens === null) throw new Error(BUDGET_ERROR);
108
79
  next.budget_tokens = tokens;
109
80
  }
110
81
  }
@@ -122,105 +93,3 @@ export function choiceWords(choice) {
122
93
  if (typeof choice.budget_tokens === "number") words.budget = choice.budget_tokens > 0 ? String(choice.budget_tokens) : "off";
123
94
  return Object.keys(words).length ? words : undefined;
124
95
  }
125
-
126
- // The strategy list, with the session's own value when it isn't one of them
127
- // (forget alone, a newer chi's layer).
128
- function strategyOptions(chosen) {
129
- if (chosen === DEFAULT || STRATEGIES.some((s) => s.value === chosen)) return STRATEGIES;
130
- return [...STRATEGIES, { value: chosen, label: chosen }];
131
- }
132
-
133
- const option = (value, label) => html`<option value=${value}>${label}</option>`;
134
-
135
- // The popover's form: the values now (each with its source), and the
136
- // session's own to change.
137
- export function nowText(summary) {
138
- const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
139
- return `Now: ${summary.strategy} (${summary.strategy_where}) · apply ${summary.apply} (${summary.apply_where}) · ` +
140
- `budget ${budget} (${summary.budget_where})`;
141
- }
142
-
143
- // The popover's form (a lit-html template). +now+ is the summary its "Now:"
144
- // line reads (refresh passes a newer one, the rest stays as the form opened,
145
- // so lit leaves what the user typed alone). The selects take their value as a property (.value), set after their
146
- // options: +unset+ draws the options alone, the first pass renderForm makes.
147
- export function formTemplate(summary, { running = false, now = summary, unset = false } = {}) {
148
- const own = ownValues(summary);
149
- const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
150
- const when = "From the next turn's start. Turning forget on or off re-reads the whole prompt once.";
151
- const chosen = (value) => (unset ? nothing : value);
152
- return html`<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong
153
- ><span class="ctx-pop-kind">this session</span
154
- ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
155
- ><div class="ctx-pop-meta llmctx-now">${nowText(now)}</div
156
- ><label class="llmctx-field"><span>Strategy</span><span class="select-wrap"><select name="strategy" .value=${chosen(own.strategy)}>${
157
- option(DEFAULT, follow("strategy"))}${strategyOptions(own.strategy).map((s) => option(s.value, s.label))}</select></span></label
158
- ><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select name="apply" .value=${chosen(own.apply)}>${
159
- option(DEFAULT, follow("apply"))}${APPLIES.map((a) => option(a, a))}</select></span></label
160
- ><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off"
161
- placeholder="follow the model (64000, 64k or off)" .value=${own.budget}></label
162
- ><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
163
- ><div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button
164
- ><button type="submit" class="llmctx-set">Set</button></div></form>`;
165
- }
166
-
167
- // Draws the form into +target+ (formTemplate's options). A first draw (no
168
- // form there yet) makes two passes, the options and then the selects'
169
- // values: lit sets an element's own bindings before its children, so a
170
- // select's .value would land before its options exist. A later draw is
171
- // one pass, and lit writes only what changed since the last (the "Now:"
172
- // line), never a value the user changed.
173
- export function renderForm(target, summary, opts = {}) {
174
- if (!target.firstElementChild) render(formTemplate(summary, { ...opts, unset: true }), target);
175
- render(formTemplate(summary, opts), target);
176
- }
177
-
178
- // The chip's popover (chip_popover.js' shell). el: where the chip is drawn
179
- // (#infoText, info_bar.js: clicks are delegated); summary(): the open
180
- // session's llm_context; session(): the open session's id; running(): a
181
- // turn runs; run(line): sends the command. id and anchor: the popover's id
182
- // and the chip's selector.
183
- export function createLlmContextControl({ el, summary, session = () => null, running = () => false, run,
184
- id = "llmContextPopover", anchor = ".llmctx-chip",
185
- doc = document, win = window }) {
186
- // The values the form opened with.
187
- let initial = null;
188
- // What the open form was drawn from: the summary, the form's options and
189
- // the summary its "Now:" line reads.
190
- let shown = null;
191
- const draw = (changes = {}) => {
192
- shown = { ...shown, ...changes };
193
- renderForm(shell.pop, shown.data, { ...shown.opts, now: shown.now });
194
- };
195
-
196
- // A refresh (a newer summary): an open form keeps what the user typed;
197
- // only its "Now:" line follows (the same template again: lit writes that
198
- // line alone).
199
- function show(data, opening) {
200
- if (!opening) return draw({ now: data });
201
- initial = ownValues(data);
202
- shown = { data, opts: { running: running() } };
203
- draw({ now: data });
204
- }
205
-
206
- function submit(form) {
207
- const values = { strategy: form.elements.strategy.value, apply: form.elements.apply.value,
208
- budget: form.elements.budget.value };
209
- const line = commandLine(values, initial);
210
- shell.close();
211
- if (line) run(line);
212
- }
213
-
214
- function act(name) {
215
- if (name !== "reset") return;
216
- shell.close();
217
- run(commandLine());
218
- }
219
-
220
- const shell = createChipPopover({
221
- id, className: "ctx-popover llmctx-popover", width: 360, el, anchor, summary, session, show,
222
- onClose: () => { initial = null; shown = null; }, onSubmit: submit, onAct: act, doc, win,
223
- });
224
-
225
- return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
226
- }
@@ -8,7 +8,7 @@
8
8
  // steps' texts.
9
9
  const TEXTS = ".bubble.output, .gen:not(.live) > .gen-text";
10
10
  // The closed steps' texts still shown raw: a live turn's (a reload's come
11
- // rendered, turn_view.js textTemplate).
11
+ // rendered, turn_block.js textTemplate).
12
12
  const RAW_STEPS = ".gen:not(.live) > .gen-text:not(.markdown)";
13
13
 
14
14
  export function createMarkdownPlace({
@@ -0,0 +1,14 @@
1
+ // chi web's JSON as notifications.js and push_devices.js call it:
2
+ // (method, path, body). Over the page's api (data.js), so a signed-out
3
+ // proxy answer (a 401, a redirect, HTML) reports auth_lost as every other
4
+ // read does, and a refusal throws api()'s Error (the server's words, .status,
5
+ // .code).
6
+
7
+ import { api as pageApi } from "./data.js";
8
+
9
+ // @param body the JSON body (undefined: none)
10
+ // @param opts api()'s fetch options (specs: fetchImpl); api: another client
11
+ // @return a Promise of the answer's JSON ({} for an empty one)
12
+ export function methodApi(method, path, body, { api = pageApi, ...opts } = {}) {
13
+ return api(path, { ...opts, method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
14
+ }
@@ -36,6 +36,7 @@ import {
36
36
  isWaitingTag, openPushKeys, pushSupported, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe,
37
37
  } from "./push.js";
38
38
  import { createPushDevices } from "./push_devices.js";
39
+ import { methodApi } from "./method_api.js";
39
40
 
40
41
  export const NOTIFY_KEY = "chi_notify";
41
42
  // The device id chi web gave this app's subscription (POST subscribe's
@@ -64,17 +65,9 @@ const PUSH_TITLES = {
64
65
  denied: "Notifications are blocked for this app in the phone's settings",
65
66
  };
66
67
 
67
- // fetch's JSON, or an Error with chi web's detail.
68
- async function defaultApi(method, path, body) {
69
- const res = await fetch(path, {
70
- method,
71
- headers: body === undefined ? {} : { "Content-Type": "application/json" },
72
- body: body === undefined ? undefined : JSON.stringify(body),
73
- });
74
- const data = res.status === 204 ? null : await res.json().catch(() => null);
75
- if (!res.ok) throw new Error(data?.detail || `HTTP ${res.status}`);
76
- return data;
77
- }
68
+ // chi web's JSON, or api()'s Error; a signed-out answer reports auth_lost
69
+ // (method_api.js).
70
+ const defaultApi = (method, path, body) => methodApi(method, path, body);
78
71
 
79
72
  // How a presence report goes: a beacon when the page is leaving (it
80
73
  // outlives the page; same origin, so the cookie goes), else the api's
@@ -171,6 +171,16 @@ function scheduleReload() {
171
171
  reloadTimer = setTimeout(load, 120);
172
172
  }
173
173
 
174
+ // The types the participant stream sends: exactly Web::ParticipantView::EVENTS
175
+ // (spec/web/participant_view_spec.rb reads this list), so a type the server
176
+ // starts sending isn't dropped here unseen.
177
+ const STREAM_EVENTS = [
178
+ "generation_chunk", "generation_retrying", "tool_call_started", "tool_call_completed",
179
+ "turn_started", "turn_completed", "turn_canceled", "turn_failed", "turn_enqueued", "turn_dequeued", "input_merged",
180
+ "question_requested", "question_answered", "question_cancelled", "continue_offered", "continue_resolved",
181
+ "snapshot", "reset", "verdict_recorded",
182
+ ];
183
+
174
184
  // The stream from the review data's cursor; a closed or refused stream is
175
185
  // opened again from fresh data (a worker may have started or restarted).
176
186
  function connect() {
@@ -203,12 +213,7 @@ function connect() {
203
213
  renderLive();
204
214
  }
205
215
  };
206
- for (const type of ["generation_chunk", "generation_retrying", "tool_call_started", "tool_call_completed",
207
- "turn_started", "turn_completed", "turn_canceled", "turn_failed", "turn_enqueued", "turn_dequeued", "input_merged",
208
- "question_requested", "question_answered", "question_cancelled", "continue_offered", "continue_resolved",
209
- "snapshot", "reset", "verdict_recorded"]) {
210
- source.addEventListener(type, onEvent);
211
- }
216
+ for (const type of STREAM_EVENTS) source.addEventListener(type, onEvent);
212
217
  source.onerror = () => {
213
218
  if (source.readyState !== EventSource.CLOSED) return;
214
219
  scheduleRetry(retryDelay(failures));
@@ -1,6 +1,6 @@
1
1
  // The open session's participants by author ("p:<id>" → name), for the
2
2
  // author badges on their prompts (turn_events.js promptOps, a reload's
3
- // turn_view.js): the participants panel sets it when it reads the list. A
3
+ // turn_block.js): the participants panel sets it when it reads the list. A
4
4
  // prompt's own author_name (a saved message, turn_started) comes first; a
5
5
  // badge drawn before the list came, with neither, says UNNAMED until
6
6
  // nameUnnamedBadges names it.
@@ -18,6 +18,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
18
18
  import { createChipPopover } from "./chip_popover.js";
19
19
  import { authorColor, participantAuthor } from "./participant_model.js";
20
20
  import { setParticipantNames } from "./participant_names.js";
21
+ import { stillOn } from "./latest.js";
21
22
 
22
23
  // Seen this recently: here now (the chip's dot). The gate writes
23
24
  // last_used_at at most once a minute, so two minutes.
@@ -298,9 +299,10 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
298
299
  onChange();
299
300
  return;
300
301
  }
302
+ const still = stillOn(session, id);
301
303
  try {
302
304
  const body = await api.list(id);
303
- if (id !== session()) return;
305
+ if (!still()) return;
304
306
  grants = Array.isArray(body.participants) ? body.participants : [];
305
307
  working = typeof body.warning === "string" ? body.warning : null;
306
308
  inviteMode = typeof body.mode === "string" ? body.mode : null;
@@ -309,7 +311,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
309
311
  loadedFor = id;
310
312
  if (setParticipantNames(grants)) onNames();
311
313
  } catch {
312
- if (id !== session()) return;
314
+ if (!still()) return;
313
315
  // Another session's list and warning are not this one's.
314
316
  if (loadedFor !== id) {
315
317
  grants = [];
@@ -1,24 +1,20 @@
1
1
  // The project picker opened by the top-bar scope chip. The server supplies
2
- // the project roots and their current/default marks; links carry the view
3
- // without carrying a session hash into another project's list.
2
+ // the project roots and their current/default marks; links never carry a
3
+ // session hash into another project's list.
4
4
 
5
5
  import { html, nothing, render } from "./vendor/lit-html.js";
6
6
  import { allScopeHref, projectScopeHref } from "./scope.js";
7
7
  import { createChipPopover } from "./chip_popover.js";
8
-
9
- function homeHref(search) {
10
- const view = new URLSearchParams(search || "").get("view");
11
- return view ? `/?view=${encodeURIComponent(view)}` : "/";
12
- }
8
+ import { createLatest } from "./latest.js";
13
9
 
14
10
  function sessionCount(count) {
15
11
  const n = Number(count) || 0;
16
12
  return `${n} session${n === 1 ? "" : "s"}`;
17
13
  }
18
14
 
19
- function projectRow(row, search) {
15
+ function projectRow(row) {
20
16
  return html`<a class=${`project-row${row.default ? " default" : ""}${row.current ? " current" : ""}`}
21
- href=${row.default ? homeHref(search) : projectScopeHref(row.dir, "", search)}
17
+ href=${row.default ? "/" : projectScopeHref(row.dir, "")}
22
18
  aria-current=${row.current ? "page" : nothing}>
23
19
  <span class="project-main"><span class="project-name">${row.name}</span
24
20
  >${row.default ? html`<span class="project-default">default</span>` : nothing
@@ -30,14 +26,14 @@ function projectRow(row, search) {
30
26
 
31
27
  // +rows+ is the API's ordered list. When there is no project scope, the
32
28
  // all-projects row is current; a bad ?dir= has no current project to mark.
33
- export function pickerTemplate({ rows = [], loading = false, error = "", search = "", currentDir = null,
29
+ export function pickerTemplate({ rows = [], loading = false, error = "", currentDir = null,
34
30
  allCurrent = false } = {}) {
35
- const allHref = allScopeHref("", currentDir, search);
31
+ const allHref = allScopeHref("", currentDir);
36
32
  return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
37
33
  data-act="close" aria-label="Close">×</button></div>
38
34
  ${loading ? html`<div class="project-note" role="status">Loading projects…</div>` : nothing}
39
35
  ${error ? html`<div class="project-note project-error" role="alert">${error}</div>` : nothing}
40
- <nav class="project-list" aria-label="Projects">${rows.map((row) => projectRow(row, search))}
36
+ <nav class="project-list" aria-label="Projects">${rows.map((row) => projectRow(row))}
41
37
  <a id="projectAll" class=${`project-row project-all${allCurrent ? " current" : ""}`}
42
38
  href=${allHref} aria-current=${allCurrent ? "page" : nothing}>
43
39
  <span class="project-main"><span class="project-name">All projects</span
@@ -47,20 +43,20 @@ export function pickerTemplate({ rows = [], loading = false, error = "", search
47
43
  </nav>`;
48
44
  }
49
45
 
50
- // +scopeError+ returns the page's current folder error, if any. +where+
51
- // supplies the live query string so links retain ?view= without a hash.
52
- export function createProjectPicker({ el, scope, scopeError = () => null, api, where = () => ({ search: "" }),
46
+ // +scopeError+ returns the page's current folder error, if any.
47
+ export function createProjectPicker({ el, scope, scopeError = () => null, api,
53
48
  doc = globalThis.document, win = globalThis.window }) {
54
49
  let rows = [];
55
50
  let loading = false;
56
51
  let error = "";
57
- let loads = 0;
52
+ // Only the newest read is drawn; a close drops the one out (latest.js).
53
+ const latest = createLatest();
58
54
 
59
55
  const currentDir = () => scopeError() ? null : scope.dir || null;
60
56
  const paint = (focus = false) => {
61
57
  const failedScope = !!scopeError();
62
58
  render(pickerTemplate({
63
- rows, loading, error, search: where().search, currentDir: currentDir(),
59
+ rows, loading, error, currentDir: currentDir(),
64
60
  allCurrent: !scope.dir && !failedScope,
65
61
  }), shell.pop);
66
62
  if (focus) {
@@ -71,7 +67,7 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
71
67
  };
72
68
 
73
69
  async function load(focus = false) {
74
- const turn = ++loads;
70
+ const fresh = latest.begin();
75
71
  loading = true;
76
72
  error = "";
77
73
  if (shell.isOpen()) {
@@ -83,14 +79,14 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
83
79
  const dir = currentDir();
84
80
  const answer = await api(`/api/projects${dir ? `?dir=${encodeURIComponent(dir)}` : ""}`);
85
81
  if (!Array.isArray(answer)) throw new Error("the server returned an invalid project list");
86
- if (turn !== loads) return;
82
+ if (!fresh()) return;
87
83
  rows = answer;
88
84
  } catch (e) {
89
- if (turn !== loads) return;
85
+ if (!fresh()) return;
90
86
  error = `Can't read projects: ${e?.message || e}`;
91
87
  rows = [];
92
88
  }
93
- if (turn !== loads) return;
89
+ if (!fresh()) return;
94
90
  loading = false;
95
91
  if (shell.isOpen()) {
96
92
  paint(focus);
@@ -108,7 +104,7 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
108
104
  paint();
109
105
  void load(true);
110
106
  },
111
- onClose: () => { loads += 1; },
107
+ onClose: () => { latest.drop(); },
112
108
  });
113
109
 
114
110
  return { pop: shell.pop, isOpen: shell.isOpen, load };
@@ -91,7 +91,7 @@ export function createQuestionCards({
91
91
  summary.textContent = summaryText(pq);
92
92
  card.appendChild(summary);
93
93
  // A hand toggle: the resolve's auto-collapse then leaves it as the user
94
- // left it (rememberToggle-style, as turn_view.js does).
94
+ // left it (rememberToggle-style, as turn_block.js does).
95
95
  summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
96
96
 
97
97
  // Relayed to the parent: say where else it waits (QuestionDesk#annotate).
@@ -632,7 +632,7 @@ export function createQuestionCards({
632
632
  pendingQuestion: () => pending,
633
633
  renderContinue: renderContinueCard,
634
634
  resolveContinue: resolveContinueCard,
635
- // The turn view was reset: a later offer draws a new card.
635
+ // The running turn's DOM was reset: a later offer draws a new card.
636
636
  forgetContinue() { continueEl = null; },
637
637
  };
638
638
  }
@@ -1,13 +1,10 @@
1
- // The session bar's reasoning history chip: which of its own reasoning the
2
- // session's model gets back from the next turn on ("reasoning all";
3
- // "· session" when the session set its own), and a popover with a level
4
- // select that runs /reasoning-history in the session's worker. The summary
5
- // is the session's reasoning_history (SettingExplained#summary; null on a
6
- // native host, which ignores it, or from an older worker: no chip then).
7
- // A config on level_chip.js.
1
+ // Reasoning history as a level setting (level_chip.js): which of its own
2
+ // reasoning the session's model gets back from the next turn on. The session
3
+ // bar's ⚙ panel draws its row on a chat host (settings_chip.js; the summary
4
+ // is null on a native host, which ignores it, or from an older worker: no
5
+ // row then) and runs /reasoning-history in the session's worker.
8
6
 
9
7
  import * as level from "./level_chip.js";
10
- import { html, nothing } from "./vendor/lit-html.js";
11
8
 
12
9
  export const LEVELS = ["drop", "turn", "all"];
13
10
 
@@ -16,34 +13,11 @@ export const CONFIG = Object.freeze({
16
13
  prefix: "reasoning",
17
14
  command: "/reasoning-history",
18
15
  levels: LEVELS,
19
- anchor: "[data-rh-chip]",
20
- formClass: "rh-form",
21
- popoverClass: "rh-popover",
22
- nowClass: "rh-now",
23
- when: () => "From the next turn's start. A change to or from all makes the server read the conversation again.",
16
+ cost: "A reasoning history change to or from all makes the server read the conversation again.",
24
17
  });
25
18
 
26
- export function chipModel(summary) {
27
- return level.chipModel(CONFIG, summary);
28
- }
29
-
30
- // The session bar's chip (a lit-html template; info_bar.js), nothing
31
- // without a summary.
32
- export function chipTemplate(summary) {
33
- const chip = chipModel(summary);
34
- if (!chip) return nothing;
35
- return html`<button type="button" class=${`model rh-chip${chip.own ? " own" : ""}`} title=${chip.title} data-rh-chip aria-haspopup="dialog">${chip.text}</button>`;
36
- }
37
-
19
+ // The /reasoning-history line for a level, with --yes; null when it is the
20
+ // session's (+initial+).
38
21
  export function commandLine(choice, initial = null) {
39
22
  return level.commandLine(CONFIG, choice, initial);
40
23
  }
41
-
42
- export function formTemplate(summary, opts) {
43
- return level.formTemplate(CONFIG, summary, opts);
44
- }
45
-
46
- // The chip's popover in the session bar (level_chip.js createLevelControl).
47
- export function createReasoningHistoryControl({ id = "reasoningHistoryPopover", ...opts }) {
48
- return level.createLevelControl({ cfg: CONFIG, id, ...opts });
49
- }
@@ -21,6 +21,8 @@ export function restoreAction(event, { myId, sentIds, composerEmpty }) {
21
21
  own,
22
22
  label: own ? null : clientLabel(origin.client_id),
23
23
  ...(back && event.verify ? { verify: event.verify } : {}),
24
+ // A Revise turn's draft ({turn_id}): its composer chip comes back.
25
+ ...(back && event.revises?.turn_id ? { revises: { turn_id: event.revises.turn_id } } : {}),
24
26
  }, back ? event.images : null);
25
27
  }
26
28