samagotchi 0.100.0 → 0.101.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +10 -1
  5. data/docs/cli.md +55 -0
  6. data/docs/configuration.md +1 -0
  7. data/docs/hooks.md +2 -0
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/working-set.md +130 -0
  10. data/docs/sessions.md +1 -1
  11. data/docs/testing.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/work-cap/plugin.rb +26 -16
  15. data/lib/samagotchi/config.rb +3 -0
  16. data/lib/samagotchi/engine.rb +30 -0
  17. data/lib/samagotchi/hooks/loader.rb +17 -2
  18. data/lib/samagotchi/host_keys.rb +35 -18
  19. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  20. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  21. data/lib/samagotchi/tool_response.rb +10 -6
  22. data/lib/samagotchi/tool_runner.rb +25 -6
  23. data/lib/samagotchi/version.rb +1 -1
  24. data/lib/samagotchi/web/app.rb +49 -1
  25. data/lib/samagotchi/web/public/app.js +77 -11
  26. data/lib/samagotchi/web/public/chains.js +24 -0
  27. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  28. data/lib/samagotchi/web/public/data.js +6 -0
  29. data/lib/samagotchi/web/public/index.html +134 -2
  30. data/lib/samagotchi/web/public/info_bar.js +16 -4
  31. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  32. data/lib/samagotchi/web/public/markdown_place.js +47 -1
  33. data/lib/samagotchi/web/public/refs.js +10 -1
  34. data/lib/samagotchi/web/public/send_turn.js +6 -2
  35. data/lib/samagotchi/web/public/session_actions.js +10 -5
  36. data/lib/samagotchi/web/public/stage_model.js +6 -5
  37. data/lib/samagotchi/web/public/stage_view.js +34 -2
  38. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  39. data/lib/samagotchi/web/public/turn_view.js +37 -11
  40. data/lib/samagotchi/web/public/working_set.js +520 -0
  41. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  42. data/lib/samagotchi/web/public/working_set_view.js +426 -0
  43. data/lib/samagotchi/web/server.rb +6 -4
  44. data/lib/samagotchi/working_set.rb +200 -0
  45. metadata +7 -1
@@ -23,6 +23,8 @@ import { createInUseWatch } from "./stage_watch.js";
23
23
  import { formatDuration } from "./timing.js";
24
24
  import { easeScrollTo } from "./scroll.js";
25
25
  import { readStored, writeStored } from "./storage.js";
26
+ import { createWorkingSetRail } from "./working_set_rail.js";
27
+ import { stampRows } from "./working_set_view.js";
26
28
 
27
29
  const COLLAPSE_KEY = "chi_stage_collapsed";
28
30
  // The turn-end read (markdown, answer_display) settles within this, or the
@@ -54,10 +56,12 @@ function readCollapsed() {
54
56
  // +composerEl+ size the stage's cap.
55
57
  // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
56
58
  // on a running task_wait. +renderMarkdown+ and +linkRefs+: the asking
57
- // slot's markdown read and ref pass (stage_slots.js).
59
+ // slot's markdown read and ref pass (stage_slots.js). +workingSet+: the
60
+ // page's working set (working_set_view.js, web.working_set; null: off): its
61
+ // rail in the live slots, its footprint at the hand-off.
58
62
  export function createStageView({
59
63
  historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null,
60
- renderMarkdown, linkRefs,
64
+ renderMarkdown, linkRefs, workingSet = null,
61
65
  }) {
62
66
  // The dock becomes a clear column: the stage's card, then the composer's
63
67
  // (its rows moved into .dock-card, which wears the glass and the focus ring).
@@ -97,6 +101,16 @@ export function createStageView({
97
101
  asking.hidden = true;
98
102
  const now = node("div", "ts-now", live);
99
103
  const trail = node("div", "ts-trail", live);
104
+ // The working set's rail: out of the live slots' flow on their right (a
105
+ // row under the trail in a narrow stage), so it never sets their height.
106
+ const rail = workingSet
107
+ ? createWorkingSetRail({ live, stage, onOpen: (el) => workingSet.openTurn(el), onChange: () => scheduleRefresh() })
108
+ : null;
109
+ if (rail) {
110
+ workingSet.setSeen(rail.seenAt);
111
+ // The server's states change the lines (never the fold): a redraw.
112
+ workingSet.onServer(() => scheduleRefresh());
113
+ }
100
114
  const extras = node("div", "ts-extras", scroller);
101
115
  const answer = node("div", "ts-answer", scroller);
102
116
  const tail = node("div", "ts-tail", scroller);
@@ -324,6 +338,11 @@ export function createStageView({
324
338
  slotsView.drawNow(slots.tool, slots.quiet);
325
339
  slotsView.drawTrail(slots.trail, flash);
326
340
  slotsView.drawTicks(slots.ticks);
341
+ if (rail) {
342
+ const { items, ordinal } = workingSet.liveItems(turn, promptWords());
343
+ stampRows(blockEl, turn, ordinal);
344
+ rail.draw(items, { ctxPct: workingSet.ctxPct() });
345
+ }
327
346
  const calls = slots.ticks.length;
328
347
  // An ended turn says each thing once: its steps in the chip (the label
329
348
  // of what the cloud holds), its time in the status row's timing line
@@ -348,6 +367,13 @@ export function createStageView({
348
367
  tail.hidden = !tail.firstChild;
349
368
  }
350
369
 
370
+ // The prompt's first words (the working set's session tags say whose
371
+ // turn a step was).
372
+ function promptWords() {
373
+ const text = (promptSlot.querySelector(".user-message")?.textContent || "").replace(/\s+/g, " ").trim();
374
+ return text.length > 60 ? `${text.slice(0, 59)}\u2026` : text;
375
+ }
376
+
351
377
  // The next step: the live slots' waiting height is let go. In a turn
352
378
  // with the question room that height was the whole reply's: it goes as
353
379
  // far as the stage's scroll absorbs it (the scroll moves up by as much,
@@ -539,6 +565,7 @@ export function createStageView({
539
565
  function emptySlots() {
540
566
  for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot]) slot.replaceChildren();
541
567
  slotsView.reset();
568
+ rail?.reset();
542
569
  promptSlot.classList.remove("full");
543
570
  thinkingLine = null;
544
571
  flash = null;
@@ -584,6 +611,7 @@ export function createStageView({
584
611
  }
585
612
 
586
613
  function release() {
614
+ workingSet?.release();
587
615
  releaseTurn();
588
616
  clearStage();
589
617
  }
@@ -604,9 +632,13 @@ export function createStageView({
604
632
  const { blockEl } = view.current();
605
633
  view.chronological();
606
634
  const block = blockEl && blockEl.parentNode === cloudBody ? blockEl : null;
635
+ // The turn's set joins the page's turns (a plain one's too: the turns
636
+ // keep their places); its footprint goes under the block.
637
+ const footprint = workingSet ? workingSet.handOff(view.current().turn, promptWords()) : null;
607
638
  const pieces = handOffOrder({
608
639
  prompt: promptSlot.firstElementChild,
609
640
  block,
641
+ footprint: block ? footprint : null,
610
642
  extras: [...extras.children],
611
643
  answer: [...answer.children],
612
644
  timing: timingSlot.firstElementChild,
@@ -17,6 +17,8 @@
17
17
  // liveTurn, turnDom, shownTexts, questionCards, contextChips,
18
18
  // llmContextControl, thinkingControl,
19
19
  // reasoningHistoryControl the page's views
20
+ // workingSet the stage view's working set (web.working_set),
21
+ // null when off
20
22
  // sentIds, earlyRestores restore.js's bookkeeping
21
23
  // stream: {takeGuess(), restart()} the stream was opened from a
22
24
  // guess (takeGuess: whether, and forget it); restart:
@@ -51,7 +53,7 @@ export function createStreamHandlers(deps) {
51
53
  const {
52
54
  selected, state, clientId, capabilities, historyEl,
53
55
  liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl, reasoningHistoryControl,
54
- sentIds, earlyRestores, stream, composer, getSession,
56
+ workingSet = null, sentIds, earlyRestores, stream, composer, getSession,
55
57
  resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
56
58
  appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
57
59
  commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
@@ -307,6 +309,9 @@ export function createStreamHandlers(deps) {
307
309
  used_memories_updated: (data) => {
308
310
  if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
309
311
  },
312
+ // The server's working set (WorkingSet), at a request and at turn end;
313
+ // ignored with web.working_set off.
314
+ working_set_updated: (data) => { workingSet?.update({ items: data.items, at: data.at }); },
310
315
  context_status: (data) => {
311
316
  const current = state();
312
317
  // After /model (Engine#announce_switched_context!): the new model's
@@ -39,6 +39,7 @@ import * as model from "./turn_model.js";
39
39
  import { createHold } from "./hold.js";
40
40
  import { linkedLine } from "./notice_links.js";
41
41
  import { taskCommand, taskStopButton } from "./task_stop.js";
42
+ import { footprintTemplate, groupItems, stampHistory } from "./working_set_view.js";
42
43
 
43
44
  // ---- The templates: one step, its thinking, its label ----
44
45
 
@@ -71,13 +72,21 @@ function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking",
71
72
  // full text. None when there is nothing to say. A closed step's text is
72
73
  // set whole (.textContent): its refs (ref_scan.js) split it into spans
73
74
  // that a text part's update would leave behind. +at+: a reloaded step's
74
- // message index, for its refs (data-answer-at, as an answer's).
75
- function textTemplate(text, { live, sentences, placeholder, at = null }) {
75
+ // message index, for its refs (data-answer-at, as an answer's). A
76
+ // reloaded step's +rendered+ markdown (its message's html, sanitised on the
77
+ // server, as an answer's) shows in place of the text, which it keeps as
78
+ // its source (stepText); a live step's renders once it closed
79
+ // (markdown_place.js renderStepTexts).
80
+ function textTemplate(text, { live, sentences, placeholder, at = null, rendered = null }) {
76
81
  if (live && sentences?.length) {
77
82
  return html`<div class="gen-text streaming"><div class="narration">${sentences.map((s) => html`<div class="sentence">${s}</div>`)}</div></div>`;
78
83
  }
79
84
  if (live) return placeholder ? html`<div class="gen-text streaming">…</div>` : nothing;
80
- return text ? html`<div class="gen-text" data-answer-at=${at ?? nothing} .textContent=${text}></div>` : nothing;
85
+ if (!text) return nothing;
86
+ if (typeof rendered === "string" && rendered) {
87
+ return html`<div class="gen-text markdown md-compact" data-answer-at=${at ?? nothing} data-copy-source=${text} .innerHTML=${rendered}></div>`;
88
+ }
89
+ return html`<div class="gen-text" data-answer-at=${at ?? nothing} .textContent=${text}></div>`;
81
90
  }
82
91
 
83
92
  // One step, live or reloaded. +gen+: { text, thinking, tools, answer } (a
@@ -97,7 +106,7 @@ export function stepTemplate(gen, st = {}) {
97
106
  const failed = !live && model.failedCount(gen.tools) > 0;
98
107
  // A step left with its thinking only reads "thinking" once.
99
108
  const wrapped = live || !!text || gen.tools.length > 0;
100
- return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
109
+ return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at, rendered: gen.html })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
101
110
  }
102
111
 
103
112
  // A hook's line (hook_notice) as a step's row. data.line is a whole row of
@@ -807,10 +816,11 @@ function defaultPlaceAnswer(appendToHistory) {
807
816
 
808
817
  // The text of a reloaded block's step +iteration+ (1-based, as the snapshot's
809
818
  // cards count), "" when it has none: a reloaded question card's asking
810
- // reply.
819
+ // reply, as written (a rendered step keeps it as data-copy-source).
811
820
  export function stepText(block, iteration) {
812
821
  const step = block?.querySelectorAll(":scope > .gen")[Number(iteration) - 1];
813
- return step?.querySelector(":scope > .gen-text")?.textContent || "";
822
+ const text = step?.querySelector(":scope > .gen-text");
823
+ return text?.getAttribute("data-copy-source") ?? text?.textContent ?? "";
814
824
  }
815
825
 
816
826
  // A re-rendered failed turn's line (a .bubble.cancel, as live's
@@ -831,8 +841,11 @@ function failedLineTemplate(record) {
831
841
  // duration from the timing's tool records), then the answer and the turn's
832
842
  // timing line under it.
833
843
  // +thumbs(images)+ draws a prompt's or a tool row's image thumbs (a
834
- // template, images.js thumbsTemplate).
835
- export function turnHistoryNodes(items, timing, { thumbs }) {
844
+ // template, images.js thumbsTemplate). +workingSet+ (the stage view's
845
+ // web.working_set, never the turn view's): each block stamped with its
846
+ // turn's ordinal and its rows with theirs (working_set_view.js), and the
847
+ // turn's footprint line after it.
848
+ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false }) {
836
849
  const timingTemplate = (group) => {
837
850
  const record = group.record;
838
851
  // Numbered by its place in the turn records, as live (a failed turn
@@ -866,12 +879,16 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
866
879
  };
867
880
  const steerTemplate = (i, bubble) => steerRowTemplate({ source: items[i].source, text: items[i].content }, { bubble });
868
881
  const parts = [];
869
- // Each block's steps, in order: its outline once the nodes exist.
882
+ // Each block's steps, in order: its outline once the nodes exist; and
883
+ // its turn's place among the turns (the working set's stamp).
870
884
  const outlines = [];
885
+ const blockTurns = [];
886
+ let turnOrdinal = -1;
871
887
  for (const group of turnGroups(items, timing)) {
872
888
  if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
873
889
  if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
874
890
  if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
891
+ turnOrdinal += 1;
875
892
  const prompt = items[group.user];
876
893
  // A context wake turn starts with its note.
877
894
  parts.push(prompt.role === "note" ? noteBubbleTemplate(prompt) : userTemplate(prompt));
@@ -885,7 +902,12 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
885
902
  if (plainThinking) {
886
903
  parts.push(thinkingTemplate({ body: only.thinking, cls: "bubble thinking" }));
887
904
  } else if (group.steps.length) {
888
- const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
905
+ // A step's text with its message's rendered markdown (html: web.markdown on).
906
+ const gens = group.steps.map((step) => ({
907
+ text: step.i === null ? "" : String(items[step.i].content ?? ""),
908
+ html: step.i === null ? null : items[step.i].html ?? null,
909
+ thinking: step.thinking || "", tools: step.tools,
910
+ }));
889
911
  const steps = gens.map((gen, k) => stepTemplate(gen, {
890
912
  // The steers the step answered first, where the live rows went.
891
913
  steers: (group.steps[k].steers || []).map((i) => steerTemplate(i, false)),
@@ -895,6 +917,8 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
895
917
  }));
896
918
  parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
897
919
  outlines.push(gens);
920
+ blockTurns.push(turnOrdinal);
921
+ if (workingSet) parts.push(footprintTemplate(groupItems(group, turnOrdinal), turnOrdinal));
898
922
  }
899
923
  // Steers with no step to sit in, or in a turn that shows no block: on
900
924
  // their own after the prompt.
@@ -921,7 +945,9 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
921
945
  }
922
946
  const out = nodesOf(parts);
923
947
  // A finished turn's block as the live end leaves it: its outline, open.
924
- [...out.children].filter((el) => el.matches(".turn-work")).forEach((block, i) => outlineBlock(block, outlines[i] || []));
948
+ const blocks = [...out.children].filter((el) => el.matches(".turn-work"));
949
+ blocks.forEach((block, i) => outlineBlock(block, outlines[i] || []));
950
+ if (workingSet) stampHistory(blocks, blockTurns);
925
951
  // Each answer bubble and step text knows its message's refs
926
952
  // (answer_refs.js; the page pairs them after the render), the index
927
953
  // stays out of the DOM.