samagotchi 0.100.1 → 0.102.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +14 -10
  4. data/docs/architecture.md +10 -2
  5. data/docs/cli.md +100 -5
  6. data/docs/configuration.md +26 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +205 -0
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +8 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +92 -53
  24. data/lib/samagotchi/engine.rb +153 -4
  25. data/lib/samagotchi/host_keys.rb +35 -18
  26. data/lib/samagotchi/idle_scheduler.rb +16 -1
  27. data/lib/samagotchi/llm_context_edit.rb +32 -10
  28. data/lib/samagotchi/llm_context_forget.rb +12 -3
  29. data/lib/samagotchi/llm_context_view.rb +19 -7
  30. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  31. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  32. data/lib/samagotchi/message_parts.rb +8 -3
  33. data/lib/samagotchi/plugin/context.rb +2 -2
  34. data/lib/samagotchi/prompt.rb +1 -1
  35. data/lib/samagotchi/recap_command.rb +68 -0
  36. data/lib/samagotchi/recap_setup.rb +27 -14
  37. data/lib/samagotchi/send_command.rb +14 -11
  38. data/lib/samagotchi/session.rb +19 -12
  39. data/lib/samagotchi/session_commands.rb +44 -2
  40. data/lib/samagotchi/session_config.rb +411 -0
  41. data/lib/samagotchi/session_setup.rb +9 -9
  42. data/lib/samagotchi/sessions_command.rb +6 -2
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  45. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  46. data/lib/samagotchi/terminal_ui.rb +8 -6
  47. data/lib/samagotchi/testing/bundle.rb +3 -2
  48. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  49. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  50. data/lib/samagotchi/tool_response.rb +10 -6
  51. data/lib/samagotchi/tool_runner.rb +25 -6
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +76 -11
  54. data/lib/samagotchi/web/public/app.js +35 -3
  55. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  56. data/lib/samagotchi/web/public/commands.js +8 -2
  57. data/lib/samagotchi/web/public/data.js +7 -0
  58. data/lib/samagotchi/web/public/index.html +145 -1
  59. data/lib/samagotchi/web/public/info_bar.js +16 -4
  60. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  61. data/lib/samagotchi/web/public/refs.js +10 -1
  62. data/lib/samagotchi/web/public/stage_model.js +6 -5
  63. data/lib/samagotchi/web/public/stage_view.js +34 -2
  64. data/lib/samagotchi/web/public/stream_handlers.js +10 -1
  65. data/lib/samagotchi/web/public/turn_html.js +5 -0
  66. data/lib/samagotchi/web/public/turn_view.js +16 -4
  67. data/lib/samagotchi/web/public/working_set.js +612 -0
  68. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  69. data/lib/samagotchi/web/public/working_set_view.js +541 -0
  70. data/lib/samagotchi/web/server.rb +6 -4
  71. data/lib/samagotchi/worker.rb +9 -6
  72. data/lib/samagotchi/working_set.rb +238 -0
  73. data/lib/samagotchi/working_set_command.rb +119 -0
  74. data/lib/samagotchi/working_set_steer.rb +250 -0
  75. metadata +11 -2
  76. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -0,0 +1,278 @@
1
+ // The working set's rail (web.working_set, the stage view): a column on the
2
+ // right of the stage's live slots (a row under the trail in a narrow stage)
3
+ // with the running turn's set folded into a few fixed 18 px lines
4
+ // (working_set.js fold). It never sets the live block's height: the rail is
5
+ // out of the flow (index.html .ts-ws), the row a fixed line. Lines are
6
+ // keyed by their label's id and patched only when what they show changes,
7
+ // so nothing restarts; a fold fades the absorbed lines out in place and the
8
+ // lines below slide up once (each line sits at its slot by a transform).
9
+ // Collapsed (kept per browser), it is its head line only, with the newest
10
+ // entry ticking through it.
11
+ import { BUDGETS, ariaLabel, fold, isFresh, labelTitle, nextSettle, shown } from "./working_set.js";
12
+ import { readStored, writeStored } from "./storage.js";
13
+ import { reducedMotion } from "./stage_frame.js";
14
+
15
+ export const COLLAPSE_KEY = "chi_working_set_collapsed";
16
+ // The stage width (px) under which the rail is a row; the phone's
17
+ // (index.html, under 600 px of window) shows neither.
18
+ export const RAIL_MIN = 760;
19
+ const LINE = 18;
20
+ const LEAVE_MS = 150;
21
+ const SVG = "http://www.w3.org/2000/svg";
22
+ const RING_R = 5.5;
23
+ const RING_LEN = 2 * Math.PI * RING_R;
24
+
25
+ function el(tag, cls, parent = null) {
26
+ const node = document.createElement(tag);
27
+ if (cls) node.className = cls;
28
+ if (parent) parent.appendChild(node);
29
+ return node;
30
+ }
31
+
32
+ // The context ring (14 px): the fill's share of the window, warn from 70 %,
33
+ // danger from 90 % (the :context_status buckets); hidden until a value.
34
+ export function ringBucket(pct) {
35
+ if (pct >= 90) return "danger";
36
+ if (pct >= 70) return "warn";
37
+ return "";
38
+ }
39
+
40
+ export function createRing(parent) {
41
+ const svg = document.createElementNS(SVG, "svg");
42
+ svg.setAttribute("class", "ws-ring");
43
+ svg.setAttribute("viewBox", "0 0 14 14");
44
+ svg.setAttribute("role", "img");
45
+ const title = document.createElementNS(SVG, "title");
46
+ svg.appendChild(title);
47
+ for (const cls of ["track", "fill"]) {
48
+ const c = document.createElementNS(SVG, "circle");
49
+ c.setAttribute("class", cls);
50
+ c.setAttribute("cx", "7");
51
+ c.setAttribute("cy", "7");
52
+ c.setAttribute("r", String(RING_R));
53
+ svg.appendChild(c);
54
+ }
55
+ svg.style.display = "none";
56
+ parent.appendChild(svg);
57
+ let shownPct = null;
58
+ return {
59
+ el: svg,
60
+ draw(pct) {
61
+ const value = Number.isFinite(pct) ? Math.max(0, Math.min(100, Math.round(pct))) : null;
62
+ if (value === shownPct) return;
63
+ shownPct = value;
64
+ svg.style.display = value === null ? "none" : "";
65
+ if (value === null) return;
66
+ svg.setAttribute("class", `ws-ring${ringBucket(value) ? ` ${ringBucket(value)}` : ""}`);
67
+ svg.querySelector(".fill").setAttribute("stroke-dasharray", `${(RING_LEN * value) / 100} ${RING_LEN}`);
68
+ title.textContent = `context ${value}% used`;
69
+ },
70
+ };
71
+ }
72
+
73
+ // +live+: the stage's live block (the rail goes last in it); +stage+: the
74
+ // stage (its width picks the form); +onOpen(railEl)+: the expanded view;
75
+ // +onChange()+: redraw (the stage's scheduleRefresh), for the fresh cue's
76
+ // end and a form change.
77
+ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
78
+ const rail = el("div", "ts-ws", live);
79
+ rail.setAttribute("role", "button");
80
+ rail.tabIndex = 0;
81
+ const head = el("div", "ws-head", rail);
82
+ const name = el("span", "ws-name", head);
83
+ const count = el("span", "ws-count", head);
84
+ const tick = el("span", "ws-tick", head);
85
+ const ring = createRing(head);
86
+ const foldBtn = el("button", "ws-fold", head);
87
+ foldBtn.type = "button";
88
+ const list = el("div", "ws-list", rail);
89
+
90
+ let collapsed = readStored(COLLAPSE_KEY) === "1";
91
+ let width = 0;
92
+ // The turn's fold so far (its groups stay), each line's node by label id
93
+ // and what it shows, the items' first sight (the fresh cue), and when the
94
+ // next cue ends.
95
+ let kept = [];
96
+ const nodes = new Map();
97
+ const seen = new Map();
98
+ let first = true;
99
+ let settleTimer = null;
100
+ let shownHead = null;
101
+
102
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(() => {
103
+ const next = stage.clientWidth;
104
+ if ((next >= RAIL_MIN) !== (width >= RAIL_MIN)) onChange?.();
105
+ width = next;
106
+ }) : null;
107
+ observer?.observe(stage);
108
+
109
+ function form() {
110
+ return (width || stage.clientWidth) >= RAIL_MIN ? "rail" : "row";
111
+ }
112
+
113
+ function applyCollapsed() {
114
+ rail.classList.toggle("collapsed", collapsed);
115
+ rail.setAttribute("aria-expanded", String(!collapsed));
116
+ foldBtn.textContent = collapsed ? "◂" : "–";
117
+ foldBtn.setAttribute("aria-label", collapsed ? "Show the working set" : "Fold the working set to one line");
118
+ foldBtn.title = foldBtn.getAttribute("aria-label");
119
+ }
120
+ applyCollapsed();
121
+
122
+ function setCollapsed(value) {
123
+ collapsed = value;
124
+ writeStored(COLLAPSE_KEY, value ? "1" : "0");
125
+ applyCollapsed();
126
+ shownHead = null;
127
+ onChange?.();
128
+ }
129
+
130
+ rail.addEventListener("click", (e) => {
131
+ if (e.target.closest(".ws-fold")) {
132
+ e.stopPropagation();
133
+ setCollapsed(!collapsed);
134
+ return;
135
+ }
136
+ if (collapsed) setCollapsed(false);
137
+ else onOpen?.(rail);
138
+ });
139
+ rail.addEventListener("keydown", (e) => {
140
+ if (e.target !== rail || (e.key !== "Enter" && e.key !== " ")) return;
141
+ e.preventDefault();
142
+ if (collapsed) setCollapsed(false);
143
+ else onOpen?.(rail);
144
+ });
145
+
146
+ // A line's node: its glyph, text, count and marks (✎ edited, a group's
147
+ // ✂ forgotten members).
148
+ function lineNode(label) {
149
+ const node = el("div", `ws-item ${label.kind}`);
150
+ el("span", "k", node);
151
+ el("span", "t", node);
152
+ el("span", "n", node);
153
+ el("span", "e", node);
154
+ el("span", "c", node);
155
+ return node;
156
+ }
157
+
158
+ // A state (the server's: stale faded, forgotten struck) is a class and a
159
+ // mark on the same line: it never refolds and never moves a line.
160
+ function fillLine(node, label, fresh) {
161
+ const state = label.state && label.state !== "live" ? ` ${label.state}` : "";
162
+ node.className = `ws-item ${label.kind}${label.type !== "leaf" ? " group" : ""}${fresh ? " fresh" : ""}${state}`;
163
+ const [k, t, n, e, c] = node.children;
164
+ k.textContent = label.glyph || "";
165
+ t.textContent = label.text;
166
+ n.textContent = label.count ? `· ${label.count}` : "";
167
+ e.textContent = label.edited ? (label.type === "leaf" ? "✎" : `✎${label.edited}`) : "";
168
+ c.textContent = label.type !== "leaf" && label.cut ? `✂${label.cut}` : "";
169
+ node.title = labelTitle(label);
170
+ }
171
+
172
+ // A line at its slot. A new line is placed before it is in the page (no
173
+ // transition); a kept one slides there (index.html .ws-item).
174
+ function place(node, i) {
175
+ node.style.transform = `translateY(${i * LINE}px)`;
176
+ // The row form lays the lines out in a flex row: by order, not by DOM.
177
+ node.style.order = String(i);
178
+ }
179
+
180
+ // The fresh cue's end redraws once it is due.
181
+ function scheduleSettle(now) {
182
+ clearTimeout(settleTimer);
183
+ const left = nextSettle(seen.values(), now);
184
+ if (left !== null) settleTimer = setTimeout(() => onChange?.(), left + 20);
185
+ }
186
+
187
+ // Draws +items+ (the running turn's, working_set.js itemsOfTurn); +ctxPct+
188
+ // the ring's value (null: no ring yet).
189
+ function draw(items, { ctxPct = null, now = Date.now() } = {}) {
190
+ // A turn joined mid-way (a reload, another tab): what is there already
191
+ // is not new.
192
+ for (const item of items) {
193
+ const id = `${item.kind}:${item.key}`;
194
+ if (!seen.has(id)) seen.set(id, first ? -Infinity : now);
195
+ }
196
+ first = false;
197
+ const budget = form() === "rail" ? BUDGETS.rail : BUDGETS.row;
198
+ const folded = fold(items, budget, { kept });
199
+ kept = folded.groups;
200
+ const view = shown(folded.labels, budget);
201
+ const freshOf = (label) => label.members.some((m) => isFresh(seen.get(`${m.kind}:${m.key}`), now));
202
+ const newest = items[items.length - 1];
203
+
204
+ // The head: its name, the count, the ring; collapsed, the newest entry.
205
+ name.textContent = collapsed ? "set" : "working set";
206
+ count.textContent = String(items.length);
207
+ const tickText = collapsed && newest ? `${newest.kind === "memory" ? "◆ " : newest.kind === "url" ? "↗ " : ""}${newest.label}` : "";
208
+ if (tick.textContent !== tickText) {
209
+ tick.textContent = tickText;
210
+ tick.classList.remove("fresh");
211
+ if (tickText && !reducedMotion()) {
212
+ void tick.offsetWidth;
213
+ tick.classList.add("fresh");
214
+ }
215
+ }
216
+ ring.draw(ctxPct);
217
+ const aria = ariaLabel(view.labels, view.more);
218
+ if (shownHead !== aria) {
219
+ shownHead = aria;
220
+ rail.setAttribute("aria-label", aria);
221
+ }
222
+
223
+ // The lines, by key: an unchanged one is left alone.
224
+ const animate = !reducedMotion();
225
+ const lines = view.labels.map((label) => ({ id: label.id, label, fresh: freshOf(label) }));
226
+ if (view.more) lines.push({ id: "more", more: view.more });
227
+ const ids = new Set(lines.map((l) => l.id));
228
+ for (const [id, entry] of nodes) {
229
+ if (ids.has(id)) continue;
230
+ nodes.delete(id);
231
+ const gone = entry.node;
232
+ if (!animate) { gone.remove(); continue; }
233
+ gone.classList.add("leave");
234
+ setTimeout(() => gone.remove(), LEAVE_MS);
235
+ }
236
+ lines.forEach((line, i) => {
237
+ let entry = nodes.get(line.id);
238
+ const key = JSON.stringify(line.more ? ["more", line.more] : [line.id, line.label.text, line.label.count || "", line.label.n, line.label.edited, line.label.cut || 0, line.label.state || "live", line.fresh]);
239
+ if (!entry) {
240
+ const node = line.more ? el("div", "ws-more") : lineNode(line.label);
241
+ entry = { node, key: null, slot: -1 };
242
+ nodes.set(line.id, entry);
243
+ place(node, i);
244
+ list.appendChild(node);
245
+ if (animate) node.classList.add("enter");
246
+ }
247
+ if (entry.key !== key) {
248
+ entry.key = key;
249
+ if (line.more) entry.node.textContent = `+ ${line.more} more`;
250
+ else fillLine(entry.node, line.label, line.fresh);
251
+ }
252
+ if (entry.slot !== i) {
253
+ place(entry.node, i);
254
+ entry.slot = i;
255
+ }
256
+ });
257
+ rail.classList.toggle("empty", !items.length);
258
+ scheduleSettle(now);
259
+ }
260
+
261
+ // A new turn (or none): no lines, no fold, nothing seen.
262
+ function reset() {
263
+ clearTimeout(settleTimer);
264
+ for (const { node } of nodes.values()) node.remove();
265
+ list.replaceChildren();
266
+ nodes.clear();
267
+ seen.clear();
268
+ kept = [];
269
+ first = true;
270
+ shownHead = null;
271
+ tick.textContent = "";
272
+ }
273
+
274
+ // For the expanded view: when an item was first seen (its fresh cue).
275
+ const seenAt = (item) => seen.get(`${item.kind}:${item.key}`);
276
+
277
+ return { el: rail, draw, reset, seenAt, form, isCollapsed: () => collapsed };
278
+ }