samagotchi 0.54.0 → 0.56.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +29 -7
  6. data/docs/configuration.md +113 -8
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/context-telemetry.md +23 -5
  9. data/docs/internals/engine.md +54 -18
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/sessions.md +2 -2
  12. data/docs/testing.md +23 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/cache_confirm.rb +51 -0
  17. data/lib/samagotchi/client_id.rb +12 -0
  18. data/lib/samagotchi/config.rb +9 -3
  19. data/lib/samagotchi/engine.rb +102 -8
  20. data/lib/samagotchi/engine_peers.rb +1 -0
  21. data/lib/samagotchi/file_ref.rb +59 -0
  22. data/lib/samagotchi/host_config.rb +6 -3
  23. data/lib/samagotchi/host_registry.rb +4 -3
  24. data/lib/samagotchi/kernel_loop.rb +99 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  26. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  27. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  28. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  29. data/lib/samagotchi/model_settings.rb +5 -2
  30. data/lib/samagotchi/reasoning_history.rb +132 -0
  31. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  32. data/lib/samagotchi/self_report.rb +21 -0
  33. data/lib/samagotchi/session.rb +23 -2
  34. data/lib/samagotchi/session_commands.rb +55 -15
  35. data/lib/samagotchi/session_manager.rb +2 -1
  36. data/lib/samagotchi/session_setup.rb +7 -5
  37. data/lib/samagotchi/setting_explained.rb +20 -0
  38. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  40. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  41. data/lib/samagotchi/terminal_ui.rb +13 -6
  42. data/lib/samagotchi/thinking.rb +20 -25
  43. data/lib/samagotchi/thinking_command.rb +33 -11
  44. data/lib/samagotchi/tool_row_fields.rb +46 -0
  45. data/lib/samagotchi/tool_runner.rb +7 -11
  46. data/lib/samagotchi/tools/builtins.rb +6 -2
  47. data/lib/samagotchi/tools/peers.rb +4 -2
  48. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +48 -4
  51. data/lib/samagotchi/web/capabilities.rb +13 -9
  52. data/lib/samagotchi/web/editor.rb +38 -0
  53. data/lib/samagotchi/web/message_parts.rb +5 -10
  54. data/lib/samagotchi/web/public/activity.js +33 -18
  55. data/lib/samagotchi/web/public/app.js +256 -1133
  56. data/lib/samagotchi/web/public/commands.js +269 -0
  57. data/lib/samagotchi/web/public/composer.js +105 -0
  58. data/lib/samagotchi/web/public/diff_view.js +30 -4
  59. data/lib/samagotchi/web/public/index.html +11 -3
  60. data/lib/samagotchi/web/public/info_bar.js +80 -0
  61. data/lib/samagotchi/web/public/level_chip.js +174 -0
  62. data/lib/samagotchi/web/public/lists_view.js +725 -0
  63. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  64. data/lib/samagotchi/web/public/model_pick.js +1 -1
  65. data/lib/samagotchi/web/public/question_cards.js +2 -1
  66. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  67. data/lib/samagotchi/web/public/refs.js +134 -0
  68. data/lib/samagotchi/web/public/restore.js +81 -0
  69. data/lib/samagotchi/web/public/session_state.js +57 -0
  70. data/lib/samagotchi/web/public/stream_handlers.js +332 -0
  71. data/lib/samagotchi/web/public/thinking_chip.js +38 -144
  72. data/lib/samagotchi/web/public/turn_events.js +17 -258
  73. data/lib/samagotchi/web/public/turn_html.js +5 -1
  74. data/lib/samagotchi/web/server.rb +4 -4
  75. data/lib/samagotchi/worker.rb +17 -5
  76. metadata +18 -2
  77. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -1,6 +1,8 @@
1
1
  // Pure helpers for the Bridge's turn events (no DOM), so app.js's handlers
2
2
  // stay thin and the logic runs under `node --test`.
3
3
 
4
+ import { ACTIVITY_FIELDS, EVENT_FIELDS, pickFields } from "./activity.js";
5
+
4
6
  // The answer text of a :turn_completed event. `turn_summary.output` is the
5
7
  // contract; `result` is the KernelLoop result, which reaches the wire as its
6
8
  // string form (older servers, other clients' fixtures).
@@ -189,6 +191,11 @@ export function llmContextHover({ groups = [], freed_tokens: freed, tail_tokens:
189
191
  return lines.join("\n");
190
192
  }
191
193
 
194
+ // A ✂ row's notice data (turnDom.notice, placeTurnNotice).
195
+ export function llmContextNotice(data) {
196
+ return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
197
+ }
198
+
192
199
  // The one muted line for a turn that ended with no answer (turn_completed's
193
200
  // turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
194
201
  // it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
@@ -229,257 +236,6 @@ export function initWaitLine({ tasks } = {}) {
229
236
  return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
230
237
  }
231
238
 
232
- // Whether a composer line in an open session is one of its commands
233
- // (+commands+: the session GET's listing), as the terminals decide
234
- // (Commands::Registry#lookup): a "!" line with something after the "!", or
235
- // a listed name alone or followed by a space; a terminal's own (/stats,
236
- // /exit) too, which the page answers itself (webLocalReply). An unknown
237
- // "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
238
- export function sessionCommandLine(text, commands) {
239
- const line = String(text || "").trim();
240
- if (/^!\s*\S/.test(line)) return true;
241
- if (!line.startsWith("/")) return false;
242
- if (webLocalReply(line)) return true;
243
- return (Array.isArray(commands) ? commands : [])
244
- .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
245
- }
246
-
247
- // /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
248
- // for Tab and /help, never run as commands; TerminalUI::Formatting's
249
- // STEER_CHOICE: the word, then any whitespace and the text). During a
250
- // turn /cut TEXT is the text sent as a cut (POST /turn, delivery "cut"),
251
- // as chi send --cut does; /queue isn't in the web yet (the page says so,
252
- // as the attached TUI does). With no running turn both are the text as a
253
- // normal turn. The bare word gets the usage line.
254
- // @return {{send: string, delivery?: string} | {reply: string} | null}
255
- // null: not a delivery line
256
- export const STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)";
257
- export const QUEUE_UNAVAILABLE_WEB = "(/queue isn't available in the web yet; a plain message goes in at the running turn's next step, and /cut TEXT cuts in)";
258
- const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
259
-
260
- export function steerChoiceRoute(text, { running = false } = {}) {
261
- const match = STEER_CHOICE.exec(String(text || "").trim());
262
- if (!match) return null;
263
- const message = (match[2] || "").trim();
264
- if (!message) return { reply: STEER_CHOICE_USAGE };
265
- if (!running) return { send: message };
266
- if (match[1] === "queue") return { reply: QUEUE_UNAVAILABLE_WEB };
267
- return { send: message, delivery: "cut" };
268
- }
269
-
270
- // A line that is one word: a slash and a name, no spaces, no second slash
271
- // (Commands::Registry#unknown_command_word?). `/modle` is one; `/foo bar`
272
- // and `/usr/bin/env` are not (they are prompts).
273
- const UNKNOWN_COMMAND_WORD = /^\/[A-Za-z][A-Za-z0-9_-]*$/;
274
-
275
- // The hint for a command word no command answers (a typo like `/modle`),
276
- // or null when the line is not one: `Unknown command /modle. Did you mean
277
- // /model? /help lists the commands.` The "Did you mean" part needs a close
278
- // name among the session's commands and the page's own (webLocalReply).
279
- export function unknownCommandHint(text, commands) {
280
- const line = String(text || "").trim();
281
- if (!UNKNOWN_COMMAND_WORD.test(line)) return null;
282
- if (sessionCommandLine(line, commands)) return null;
283
- const names = (Array.isArray(commands) ? commands : [])
284
- .map((c) => (c && typeof c.name === "string" ? c.name : null))
285
- .filter(Boolean)
286
- .concat(Object.keys(WEB_LOCAL_REPLIES));
287
- const close = closestName(line, names);
288
- return `Unknown command ${line}. ${close ? `Did you mean ${close}? ` : ""}/help lists the commands.`;
289
- }
290
-
291
- // The closest name within an optimal string alignment distance of 2, or
292
- // null. Ties go to the first in the list.
293
- function closestName(line, names) {
294
- let best = null;
295
- let bestDistance = 3;
296
- for (const name of names) {
297
- const distance = osaDistance(line, name);
298
- if (distance < bestDistance) {
299
- best = name;
300
- bestDistance = distance;
301
- }
302
- }
303
- return best;
304
- }
305
-
306
- // Optimal string alignment distance: Levenshtein, with an adjacent
307
- // transposition counting as 1 (so `/modle` is 1 from `/model`, as Ruby's
308
- // DidYouMean reads it).
309
- function osaDistance(a, b) {
310
- const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
311
- for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
312
- for (let i = 1; i <= a.length; i += 1) {
313
- for (let j = 1; j <= b.length; j += 1) {
314
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
315
- rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
316
- if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
317
- rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
318
- }
319
- }
320
- }
321
- return rows[a.length][b.length];
322
- }
323
-
324
- // The terminal's own commands (a button in the web, or none yet): the
325
- // reply the page shows itself (they never go to the worker, which doesn't
326
- // know them), or null.
327
- const WEB_LOCAL_REPLIES = {
328
- "/archive": "/archive: use the archive button in the session bar",
329
- "/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
330
- "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
331
- "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
332
- "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
333
- "/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
334
- };
335
-
336
- export function webLocalReply(text) {
337
- const name = String(text || "").trim().split(/\s+/)[0].toLowerCase();
338
- return WEB_LOCAL_REPLIES[name] || null;
339
- }
340
-
341
- // The start page's first message when the page answers it itself
342
- // (webLocalReply: /stats, /exit …): shown there, with no session made just
343
- // to hold the reply. With images it is a message, as in a session.
344
- export function startPageReply(text, { images = 0 } = {}) {
345
- return images ? null : webLocalReply(text);
346
- }
347
-
348
- // The start page's reply to a first message that needs no session at all
349
- // (2.29): its own command's reply (startPageReply), or the hint for a
350
- // command word no command answers (a typo like /modle) — the hint the page
351
- // shows in a session too, as a command bubble. Decided before the session
352
- // is created, so a first /stats or /modle leaves none behind. A null here
353
- // means the message goes to a session (a prompt, or anything with images).
354
- export function startPageReplyOrHint(text, { commands = [], images = 0 } = {}) {
355
- if (images) return null;
356
- return startPageReply(text) || unknownCommandHint(text, commands);
357
- }
358
-
359
- // The continue card's answers, as the terminal's continue prompt reads them.
360
- export function continueLine(choice, reason = "") {
361
- const why = String(reason || "").trim();
362
- if (choice === "no" && why) return `/continue no, ${why}`;
363
- return `/continue ${choice}`;
364
- }
365
-
366
- // How to show a command_ran: `label` names another client (null for this
367
- // tab's own), `resync` says the conversation changed (!rollback, !cmd, a
368
- // continue answered no) and must be read again. An anytime command's
369
- // (`anytime`, D8) bubble is drawn at its command_queued, before the cards
370
- // it shows; its command_ran fills that bubble in (`commandId`). So is a
371
- // command queued for after the running turn (`queued`): its command_queued
372
- // (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
373
- // dropped (styled as failed).
374
- export const QUEUED_TEXT = "queued: runs after this turn";
375
-
376
- export function commandView(event, myId) {
377
- const own = isOwn(event.client_id, myId);
378
- const waiting = event.waits === "turn_end";
379
- return {
380
- label: own ? null : clientLabel(event.client_id),
381
- line: event.line || "",
382
- text: event.output || (waiting ? QUEUED_TEXT : ""),
383
- busy: event.status === "busy",
384
- failed: event.status === "error" || event.status === "dropped",
385
- resync: (event.changed || []).includes("messages"),
386
- modelName: event.model_name || null,
387
- anytime: event.anytime === true,
388
- queued: waiting || event.queued === true,
389
- waiting,
390
- commandId: event.command_id || null,
391
- // A card's action (Nudge…): no bubble unless it says something back.
392
- hidden: event.card === true && !event.output && event.status !== "error",
393
- };
394
- }
395
-
396
- // Whether a command_ran that re-read the conversation (`resync`) needs its
397
- // own bubble: the redrawn history ends with a command bubble for its line
398
- // (a `!cmd` appends its "!(<command>)\n<output>" message, which renders as
399
- // one), so a second bubble would show the output twice. +historyBubbles+:
400
- // the `.bubble.command` elements in document order; +line+: the event's
401
- // line. Only the last one counts: it is the redrawn history's end.
402
- export function historyHasCommandBubble(historyBubbles, line) {
403
- const last = historyBubbles[historyBubbles.length - 1];
404
- return !!last && last.querySelector(":scope > .command-line")?.textContent === line;
405
- }
406
-
407
- // What a prompt_restored (the worker rolled a failed turn back and handed its
408
- // prompt back) does in this tab: `refill` is the text for an empty composer,
409
- // only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
410
- // replay after a reload never refills stale text; `own`/`label` mark the
411
- // failed bubble shown again after the resync.
412
- export function restoreAction(event, { myId, sentIds, composerEmpty }) {
413
- const origin = event?.origin || {};
414
- const own = isOwn(origin.client_id, myId);
415
- const sent = own && !!origin.enqueued_id && sentIds.has(origin.enqueued_id);
416
- // The images go back as chips with the text.
417
- return withImages({
418
- refill: sent && composerEmpty ? event.prompt : null,
419
- own,
420
- label: own ? null : clientLabel(origin.client_id),
421
- }, sent && composerEmpty ? event.images : null);
422
- }
423
-
424
- // A turn that fails fast can have its prompt_restored come over the stream
425
- // before the /turn ack names its enqueued_id: `early` (a Map) keeps such a
426
- // restore of this tab's own prompt by that id, and the ack takes it back
427
- // (restoreOnAck), so the prompt goes into the composer the ack would clear.
428
- export function keepEarlyRestore(event, early, { myId, sentIds }) {
429
- const origin = event?.origin || {};
430
- const id = origin.enqueued_id;
431
- if (!isOwn(origin.client_id, myId) || !id || sentIds.has(id)) return false;
432
- early.set(id, event);
433
- return true;
434
- }
435
-
436
- // The ack's enqueued_id (already in sentIds) was restored early: what the
437
- // composer gets back (as restoreAction for an empty composer), else null.
438
- export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
439
- const event = enqueuedId && early.get(enqueuedId);
440
- if (!event) return null;
441
- early.delete(enqueuedId);
442
- return restoreAction(event, { myId, sentIds, composerEmpty: true });
443
- }
444
-
445
- // The /turn this kept entry was for failed: no ack comes, so nothing would
446
- // take it back. The whole map goes: this tab sends one turn at a time, so
447
- // every entry waits for an ack that is now never coming (an older one is
448
- // left over from a send that failed the same way).
449
- export function dropEarlyRestores(early) {
450
- const count = early.size;
451
- early.clear();
452
- return count;
453
- }
454
-
455
- // The composer's text with a restored prompt put back into it: the restored
456
- // text alone when nothing is typed, else the typed text with the restored
457
- // prompt under it (the user typed between the restore and the ack; their
458
- // text must not be replaced).
459
- export function restoreInto(refill, current) {
460
- const restored = String(refill ?? "");
461
- const typed = String(current ?? "");
462
- if (!restored) return typed;
463
- if (!typed.trim()) return restored;
464
- return `${typed.replace(/\n+$/, "")}\n${restored}`;
465
- }
466
-
467
- // What the composer holds once a /turn ack lands (4.13): the composer still
468
- // carries the text that was just sent, so that text goes (it is the history's
469
- // now) — but text typed since the send stays, and a prompt restored before
470
- // this ack (restoreOnAck's refill) is put back without replacing it. The
471
- // sent text is trimmed (turnText), so the composer's is too before they are
472
- // compared: a trailing newline or space must not keep the message; and an
473
- // image-only send (placeholders sent) leaves nothing worth keeping.
474
- // @param sent the text the turn was sent with
475
- // @param current the composer's text as the ack lands
476
- // @param refill the prompt an early restore handed back, if any
477
- export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
478
- const typed = String(current ?? "").trim();
479
- const kept = !typed || typed === String(sent ?? "").trim() ? "" : current;
480
- return restoreInto(refill, kept);
481
- }
482
-
483
239
  // What an event does to the prompt bubbles, as a list of ops for app.js:
484
240
  // add — a bubble for a prompt this tab hasn't shown (state queued /
485
241
  // started / steered, labelled by its sender)
@@ -580,10 +336,11 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
580
336
  closeText();
581
337
  const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
582
338
  if (part.label) call.label = part.label;
583
- const title = part.title ? { title: part.title } : {};
584
- const alias = part.called_as ? { called_as: part.called_as } : {};
585
- const view = part.view ? { view: part.view } : {};
586
- events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...alias, ...view });
339
+ // Its row fields, each in its place (activity.js ACTIVITY_FIELDS,
340
+ // EVENT_FIELDS), as the live events carry them.
341
+ const title = pickFields(part, ACTIVITY_FIELDS);
342
+ const fields = pickFields(part, EVENT_FIELDS);
343
+ events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...fields });
587
344
  if (part.status !== "running") {
588
345
  // The activity as the live event had it; action and duration_ms
589
346
  // only in snapshots from a worker that keeps them (the TUI's
@@ -591,11 +348,13 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
591
348
  const action = part.action ? { action: part.action } : {};
592
349
  const completed = {
593
350
  type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
594
- activity: { ...action, tool: part.tool, status: part.status, params: part.params, ...title, ...alias },
351
+ activity: { ...action, tool: part.tool, status: part.status, params: part.params, ...title },
595
352
  };
353
+ // A grep that found nothing: its row stays muted "no match".
354
+ if (part.no_match) completed.activity.no_match = true;
596
355
  if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
597
356
  if (part.diff) completed.diff = part.diff;
598
- if (part.view) completed.view = part.view;
357
+ Object.assign(completed, fields);
599
358
  events.push(withImages(completed, part.images));
600
359
  }
601
360
  break;
@@ -637,6 +396,6 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
637
396
 
638
397
  // +object+ with `images` when there are any (the shapes stay as they were
639
398
  // for a turn without).
640
- function withImages(object, images) {
399
+ export function withImages(object, images) {
641
400
  return images?.length ? { ...object, images } : object;
642
401
  }
@@ -7,6 +7,7 @@ import { commandBlockHtml, rowHover } from "./command_view.js";
7
7
  import { copySourceAttr } from "./copy.js";
8
8
  import { activityDiffHtml } from "./diff_view.js";
9
9
  import { escapeHtml, userBodyHtml } from "./format.js";
10
+ import { refAttrs } from "./refs.js";
10
11
  import { formatDuration } from "./timing.js";
11
12
  import { emptyAnswerLine } from "./turn_events.js";
12
13
 
@@ -81,7 +82,10 @@ export function toolRowHtml(row, { thumbs = () => "" } = {}) {
81
82
  export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
82
83
  const shown = row.title || row.params;
83
84
  const hover = rowHover(row) ? ` title="${escapeHtml(rowHover(row))}"` : "";
84
- const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
85
+ // A row with a ref (a file tool's file) carries it on its title, which
86
+ // opens it, and its diff lines open it at their line (refs.js).
87
+ const ref = shown ? refAttrs(row.ref) : "";
88
+ const params = shown ? `<span class="activity-params${ref ? " ref" : ""}"${hover}${ref}>${escapeHtml(shown)}</span>` : "";
85
89
  // The name the model called the tool by when it was an alias's, dim.
86
90
  const alias = row.called_as ? `<span class="activity-alias">(called as ${escapeHtml(row.called_as)})</span>` : "";
87
91
  const duration = formatDuration(row.duration_ms);
@@ -66,7 +66,7 @@ module Samagotchi
66
66
  # @return [Integer] the exit status
67
67
  def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
68
68
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
69
- new_token: false)
69
+ editor: Config::BY_KEY["web.editor"].default, new_token: false)
70
70
  rotate_token if new_token
71
71
  setting = host_setting(host)
72
72
  lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
@@ -95,7 +95,7 @@ module Samagotchi
95
95
  return 0
96
96
  end
97
97
  if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
98
- annotate_presets: annotate_presets, lan: lan)
98
+ annotate_presets: annotate_presets, editor: editor, lan: lan)
99
99
  0
100
100
  else
101
101
  1
@@ -119,7 +119,7 @@ module Samagotchi
119
119
  # @return [Boolean] false when the port was taken (said so on stderr)
120
120
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
121
121
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
122
- token_path: Token.path)
122
+ editor: Config::BY_KEY["web.editor"].default, token_path: Token.path)
123
123
  port = resolve_port(port)
124
124
  host = resolve_host(host)
125
125
  url ||= "http://#{url_host(host)}:#{port}/"
@@ -130,7 +130,7 @@ module Samagotchi
130
130
  lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
131
131
  end
132
132
  app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
133
- annotate_presets: annotate_presets, hub: hub, lan: lan_option)
133
+ annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option)
134
134
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
135
135
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
136
136
  # Subscribed before the first scan, which makes the first check.
@@ -693,7 +693,8 @@ module Samagotchi
693
693
  resolved = @continue_offer.resolved?(awaiting, result)
694
694
  @engine.synchronize_events do
695
695
  @continue_offer.announce_resolved(result, command) if resolved
696
- announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
696
+ announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
697
+ confirm: result.confirm)
697
698
  shown.each { |event| @engine.announce(event) }
698
699
  end
699
700
  save_or_log(:command) { save_session } unless Array(result.changed).empty?
@@ -706,9 +707,14 @@ module Samagotchi
706
707
  # the cards and notices it showed, held: they follow its command_ran,
707
708
  # as its output
708
709
  def run_command_line(command)
709
- @engine.holding_announcements { command_result(command) { @commands.run(command.line) } }
710
+ @engine.holding_announcements { command_result(command) { @commands.run(command.line, confirm: confirm?(command)) } }
710
711
  end
711
712
 
713
+ # Whether +command+ asks before a costly /thinking or /reasoning-history
714
+ # change (CacheConfirm): only a person at a web tab or a TUI sees the
715
+ # question; chi send, a delegate, a plugin and a card's action apply it.
716
+ def confirm?(command) = ClientId.interactive?(command.client_id) && !command.card
717
+
712
718
  # The block's Result for +command+; one that raised, or found no
713
719
  # command for the line, is an error Result saying so.
714
720
  # @return [SessionCommands::Result]
@@ -767,9 +773,9 @@ module Samagotchi
767
773
  # saved.
768
774
  def start_anytime_command(command)
769
775
  @engine.spawn_anytime do
770
- result = command_result(command) { @engine.running_anytime { @commands.run(command.line) } }
776
+ result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
771
777
  announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
772
- anytime: true)
778
+ anytime: true, confirm: result.confirm)
773
779
  rescue StandardError => e
774
780
  Log.warn(:worker, "anytime_command_failed", line: command.line, error: e.class.name, msg: e.message)
775
781
  end
@@ -777,7 +783,9 @@ module Samagotchi
777
783
 
778
784
  # @param anytime [Boolean] an anytime command's: its line was shown at
779
785
  # its command_queued already
780
- def announce_command(command, status:, output:, changed:, anytime: false)
786
+ # @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
787
+ # line that applies it (the TUI's prefill, the web's Apply button)
788
+ def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
781
789
  text = output.to_s
782
790
  event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
783
791
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
@@ -788,6 +796,10 @@ module Samagotchi
788
796
  event[:llm_context] = @engine.llm_context_summary if changed.intersect?(%i[llm_context model])
789
797
  # /thinking or /model: the level the next turn runs at (the web's chip).
790
798
  event[:thinking] = @engine.thinking_explained&.summary if changed.intersect?(%i[thinking model])
799
+ # /reasoning-history or /model: the level the next turn runs under
800
+ # (the web's chip); nil on a native host, which ignores it.
801
+ event[:reasoning_history] = @engine.reasoning_history_explained&.summary if changed.intersect?(%i[reasoning_history model])
802
+ event[:confirm] = confirm.to_h if confirm
791
803
  event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
792
804
  @engine.announce(event)
793
805
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.54.0
4
+ version: 0.56.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -212,6 +212,7 @@ files:
212
212
  - lib/samagotchi/bundles/system/manifest.yml
213
213
  - lib/samagotchi/bundles/system/memory_guide.md
214
214
  - lib/samagotchi/bundles/system/self_map.md
215
+ - lib/samagotchi/cache_confirm.rb
215
216
  - lib/samagotchi/cancellation_controller.rb
216
217
  - lib/samagotchi/child_move.rb
217
218
  - lib/samagotchi/child_reports.rb
@@ -260,6 +261,7 @@ files:
260
261
  - lib/samagotchi/engine_peers.rb
261
262
  - lib/samagotchi/events.rb
262
263
  - lib/samagotchi/extension_load.rb
264
+ - lib/samagotchi/file_ref.rb
263
265
  - lib/samagotchi/gem_update.rb
264
266
  - lib/samagotchi/generation_phase.rb
265
267
  - lib/samagotchi/git_head.rb
@@ -317,6 +319,7 @@ files:
317
319
  - lib/samagotchi/llm/native_tool_normalizer.rb
318
320
  - lib/samagotchi/llm/openai_chat.rb
319
321
  - lib/samagotchi/llm/prompt_cache.rb
322
+ - lib/samagotchi/llm/stream_drops.rb
320
323
  - lib/samagotchi/llm/turn_settings.rb
321
324
  - lib/samagotchi/llm/usage.rb
322
325
  - lib/samagotchi/llm/utf8_scrub.rb
@@ -393,6 +396,8 @@ files:
393
396
  - lib/samagotchi/prompt_warmup.rb
394
397
  - lib/samagotchi/question_desk.rb
395
398
  - lib/samagotchi/queued_command.rb
399
+ - lib/samagotchi/reasoning_history.rb
400
+ - lib/samagotchi/reasoning_history_command.rb
396
401
  - lib/samagotchi/recap_setup.rb
397
402
  - lib/samagotchi/recap_store.rb
398
403
  - lib/samagotchi/relay.rb
@@ -419,6 +424,7 @@ files:
419
424
  - lib/samagotchi/session_retention.rb
420
425
  - lib/samagotchi/session_setup.rb
421
426
  - lib/samagotchi/sessions_command.rb
427
+ - lib/samagotchi/setting_explained.rb
422
428
  - lib/samagotchi/steer.rb
423
429
  - lib/samagotchi/steer_cut.rb
424
430
  - lib/samagotchi/system_prompt.rb
@@ -457,6 +463,7 @@ files:
457
463
  - lib/samagotchi/tool_declarations.rb
458
464
  - lib/samagotchi/tool_ids.rb
459
465
  - lib/samagotchi/tool_response.rb
466
+ - lib/samagotchi/tool_row_fields.rb
460
467
  - lib/samagotchi/tool_runner.rb
461
468
  - lib/samagotchi/tool_view.rb
462
469
  - lib/samagotchi/tools/args.rb
@@ -506,6 +513,7 @@ files:
506
513
  - lib/samagotchi/waiting_steer.rb
507
514
  - lib/samagotchi/web/app.rb
508
515
  - lib/samagotchi/web/capabilities.rb
516
+ - lib/samagotchi/web/editor.rb
509
517
  - lib/samagotchi/web/info_probe.rb
510
518
  - lib/samagotchi/web/lan.rb
511
519
  - lib/samagotchi/web/markdown_renderer.rb
@@ -521,6 +529,8 @@ files:
521
529
  - lib/samagotchi/web/public/command_complete.js
522
530
  - lib/samagotchi/web/public/command_list.js
523
531
  - lib/samagotchi/web/public/command_view.js
532
+ - lib/samagotchi/web/public/commands.js
533
+ - lib/samagotchi/web/public/composer.js
524
534
  - lib/samagotchi/web/public/composer_size.js
525
535
  - lib/samagotchi/web/public/context_chips.js
526
536
  - lib/samagotchi/web/public/copy.js
@@ -532,8 +542,11 @@ files:
532
542
  - lib/samagotchi/web/public/hold.js
533
543
  - lib/samagotchi/web/public/images.js
534
544
  - lib/samagotchi/web/public/index.html
545
+ - lib/samagotchi/web/public/info_bar.js
535
546
  - lib/samagotchi/web/public/init_row.js
536
547
  - lib/samagotchi/web/public/kept_commands.js
548
+ - lib/samagotchi/web/public/level_chip.js
549
+ - lib/samagotchi/web/public/lists_view.js
537
550
  - lib/samagotchi/web/public/live_timing.js
538
551
  - lib/samagotchi/web/public/live_turn.js
539
552
  - lib/samagotchi/web/public/llm_context_chip.js
@@ -544,6 +557,9 @@ files:
544
557
  - lib/samagotchi/web/public/prompt_history.js
545
558
  - lib/samagotchi/web/public/question_card.js
546
559
  - lib/samagotchi/web/public/question_cards.js
560
+ - lib/samagotchi/web/public/reasoning_history_chip.js
561
+ - lib/samagotchi/web/public/refs.js
562
+ - lib/samagotchi/web/public/restore.js
547
563
  - lib/samagotchi/web/public/route.js
548
564
  - lib/samagotchi/web/public/scope.js
549
565
  - lib/samagotchi/web/public/scroll.js
@@ -552,9 +568,9 @@ files:
552
568
  - lib/samagotchi/web/public/session_title.js
553
569
  - lib/samagotchi/web/public/sessions_list.js
554
570
  - lib/samagotchi/web/public/shown_texts.js
555
- - lib/samagotchi/web/public/stable_html.js
556
571
  - lib/samagotchi/web/public/stage_model.js
557
572
  - lib/samagotchi/web/public/stage_view.js
573
+ - lib/samagotchi/web/public/stream_handlers.js
558
574
  - lib/samagotchi/web/public/strip.js
559
575
  - lib/samagotchi/web/public/tally.js
560
576
  - lib/samagotchi/web/public/task_stop.js
@@ -1,18 +0,0 @@
1
- // A block redrawn every tick (the info bar, once a second while a turn
2
- // runs) keeps its nodes: +html+ is written only when it changed, so a
3
- // shown tooltip stays up and a press on one of its buttons isn't lost to a
4
- // rewrite between mousedown and mouseup. +live+ ({selector: text}) names
5
- // the parts that change every tick (a running clock): +html+ carries them
6
- // empty and only their text is set, so a tick alone rewrites nothing else.
7
- const shown = new WeakMap();
8
-
9
- export function renderStable(el, html, live = {}) {
10
- if (shown.get(el) !== html) {
11
- el.innerHTML = html;
12
- shown.set(el, html);
13
- }
14
- for (const [selector, text] of Object.entries(live)) {
15
- const part = el.querySelector(selector);
16
- if (part && part.textContent !== text) part.textContent = text;
17
- }
18
- }