samagotchi 0.170.0 → 0.171.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -0,0 +1,319 @@
1
+ // The working set's reader (scene A of the working-set preview): what chi
2
+ // has of one item, as its timeline, newest first (ws_timeline.js cards,
3
+ // ws_cards.js templates): a file's reads as merged line slices over a strip
4
+ // of the whole file, its edits as diffs, a memory's reads with their
5
+ // overlay and, for a changed one, "Now · diff"; a slice chi no longer has
6
+ // greyed. It replaces the stage (#center: the history and the composer,
7
+ // kept under it, inert, so their scroll place stays) until "← Stage" or
8
+ // Esc; the session's set stays beside it with the open item marked, ↑↓
9
+ // step through it. On a phone it is full screen: ‹ Set back to the list's
10
+ // sheet, ‹ › the other items.
11
+ //
12
+ // Ways in: the popover peek's "Open the timeline" (ws_peek.js), a tool
13
+ // row's "timeline" (ws_way_in.js), the ref ▾ menu's "What chi has" on a
14
+ // file in the set (refs.js REF_ACTIONS.file, its env's +whatChiHas+). All
15
+ // the owner's: app.js makes this only for a viewer with working_set_items.
16
+ //
17
+ // The timeline comes from the item route (getWorkingSetItem); an answer for
18
+ // an item no longer open, or another session, is dropped (latest.js). While
19
+ // open, each new server snapshot asks again (the route builds from the
20
+ // session's messages, which lag a running turn's events). Leaving the
21
+ // session (another one, the start page) resets it: session_view.js
22
+ // forgetSession calls reset(), so #center is never left inert under it.
23
+
24
+ import { html, nothing, render } from "./vendor/lit-html.js";
25
+ import { api } from "./data.js";
26
+ import { createLatest } from "./latest.js";
27
+ import { diffTemplate } from "./diff_view.js";
28
+ import { createPeek } from "./ws_peek.js";
29
+ import { setTimelineWayIn } from "./ws_way_in.js";
30
+ import { cardTemplate } from "./ws_cards.js";
31
+ import { bandBox, countsText, eventTag, fileStrip, itemPath, linesText, timelineCards } from "./ws_timeline.js";
32
+ import { GLYPHS, leafText, opsText, steerId, steerLine, tokensText } from "./working_set.js";
33
+
34
+ // The item route's answer for session +id+ (+now+: a memory's text now).
35
+ export function getWorkingSetItem(id, kind, key, { now = false } = {}) {
36
+ return api(itemPath(id, kind, key, { now }));
37
+ }
38
+
39
+ const PHONE_MAX = 600;
40
+
41
+ // The head's pill: what is true of the item as a whole.
42
+ function itemPill(item, events) {
43
+ if (item.state === "stale") return { text: item.kind === "memory" ? "changed since chi read it" : "the file changed since chi saw it", warn: true };
44
+ if (item.state === "dropped") return { text: "dropped by you: chi no longer has it", warn: false };
45
+ if (item.state === "forgotten") return { text: "chi no longer has it", warn: false };
46
+ const edits = events.filter((e) => e.op === "edit" || e.op === "write").length;
47
+ return edits ? { text: `chi edited it ${edits}× · newest first`, warn: true } : null;
48
+ }
49
+
50
+ // What an item is called in the head: a file by its path in the session's
51
+ // folder, a memory by its name.
52
+ function itemName(item) {
53
+ if (item.kind === "file") return item.path || item.key;
54
+ if (item.kind === "url") return item.url || item.key;
55
+ return `${GLYPHS.memory} ${item.key}`;
56
+ }
57
+
58
+ // +centerEl+: what the reader covers (#center); +session()+, +state()+: the
59
+ // open session's id and fields (its folder: the undrop line); +getItem+: the
60
+ // item route (tests pass a stand-in); +sendLine(line)+: a steer line
61
+ // (undrop; null: none).
62
+ export function createWorkingSetPreview({ centerEl, session, state = () => ({}), getItem = getWorkingSetItem, sendLine = null,
63
+ doc = document, win = window }) {
64
+ let workingSet = null;
65
+ // The open item ({kind, key}), its answer, the memory's now, the tab.
66
+ let open = null;
67
+ let data = null;
68
+ let failed = false;
69
+ let tab = "has";
70
+ let openFor = null;
71
+ const latest = createLatest(() => `${session()}|${open ? `${open.kind}:${open.key}` : ""}`);
72
+ const peek = createPeek({ session, getItem: (id, kind, key) => getItem(id, kind, key), onChange: () => workingSet?.peekChanged() });
73
+
74
+ const el = doc.createElement("section");
75
+ el.className = "ws-reader";
76
+ el.id = "wsReader";
77
+ el.setAttribute("aria-label", "What chi has");
78
+ el.hidden = true;
79
+ centerEl?.appendChild(el);
80
+ const covered = () => [...(centerEl?.children || [])].filter((child) => child !== el);
81
+
82
+ const items = () => workingSet?.items() || [];
83
+ // The rows the set beside it draws (and ↑↓, ‹ › step over): not the muted.
84
+ const shown = () => items().filter((it) => !it.muted);
85
+ // Where focus was when it opened: it goes back there on close.
86
+ let returnTo = null;
87
+ const idOf = (it) => steerId(it);
88
+ // The open item as the set has it (its runs, state, tokens); a stand-in
89
+ // when the set doesn't hold it (yet).
90
+ const current = () => items().find((it) => it.kind === open?.kind && it.key === open?.key)
91
+ || (open ? { kind: open.kind, key: open.key, label: open.key, ops: [], state: "live", runs: [] } : null);
92
+
93
+ function load() {
94
+ const id = session();
95
+ if (!id || !open) return;
96
+ const fresh = latest.begin();
97
+ const { kind, key } = open;
98
+ Promise.resolve()
99
+ .then(() => getItem(id, kind, key, { now: kind === "memory" }))
100
+ .then((answer) => {
101
+ if (!fresh()) return;
102
+ data = answer;
103
+ failed = false;
104
+ draw();
105
+ })
106
+ .catch(() => {
107
+ if (!fresh()) return;
108
+ failed = true;
109
+ draw();
110
+ });
111
+ }
112
+
113
+ function stripTemplate(events) {
114
+ const strip = fileStrip(events);
115
+ if (!strip) return nothing;
116
+ const merged = strip.bands.filter((b) => !b.latest).sort((a, b) => a.first - b.first);
117
+ const ranges = merged.map((b) => `${b.first}–${b.last}`).join(", ");
118
+ const bands = strip.bands.map((band) => {
119
+ const box = bandBox(band, strip.length);
120
+ return html`<button type="button" class=${`wsr-band ${band.state}${band.latest ? " latest" : ""}`} data-wsr="band" data-first=${band.first} style=${`left:${box.left};width:${box.width}`} title=${`${linesText(band)}${band.state === "sent" ? "" : ` (${band.state})`}: go to its lines`}></button>`;
121
+ });
122
+ const chips = merged.map((band) => html`<button type="button" class=${`wsr-chip ${band.state}`} data-wsr="band" data-first=${band.first}>${band.first}–${band.last}</button>`);
123
+ return html`<div class="wsr-strip"><div class="wsr-strip-k"><span>1</span><span>read: ${ranges} of ${strip.length} lines known · tap a band to jump</span><span>${strip.length}</span></div><div class="wsr-bar" aria-label=${`read ${ranges} of ${strip.length} lines`}>${bands}</div><div class="wsr-chips">${chips}</div></div>`;
124
+ }
125
+
126
+ function memoryTemplate(item, events) {
127
+ const now = data?.now;
128
+ const changed = item.state === "stale" && now;
129
+ const lastRead = events.filter((e) => e.op === "memory_read").at(-1);
130
+ const memory = lastRead?.memory;
131
+ const banner = changed ? html`<div class="wsr-banner">Changed since chi's last read${lastRead ? ` (${eventTag(lastRead, item)})` : ""}: what chi has is not what the memory says now.</div>` : nothing;
132
+ const tabs = changed ? html`<div class="wsr-tabs" role="tablist"><button type="button" role="tab" aria-selected=${String(tab === "has")} class=${tab === "has" ? "on" : ""} data-wsr="tab" data-tab="has">What chi has</button><button type="button" role="tab" aria-selected=${String(tab === "now")} class=${tab === "now" ? "on" : ""} data-wsr="tab" data-tab="now">Now · diff</button></div>` : nothing;
133
+ const from = memory?.path ? html`<div class="wsr-from">${memory.scope} memory · from ${memory.path.replace(/[^/]*$/, "")}</div>` : nothing;
134
+ return { banner, tabs, from, nowBody: changed && tab === "now" ? (now.gone ? html`<div class="diff-note warn">the memory's file is gone</div>` : html`<article class="wsr-card now"><header class="wsr-card-h"><span class="wsr-card-t">what chi has → now</span></header>${diffTemplate(now.diff)}</article>`) : null };
135
+ }
136
+
137
+ function setTemplate(item) {
138
+ const rows = shown().map((it) => {
139
+ const on = it.kind === item.kind && it.key === item.key;
140
+ const state = it.state && it.state !== "live" ? ` ${it.state}` : "";
141
+ return html`<button type="button" class=${`ws-row wsr-item ${it.kind}${state}${on ? " on" : ""}`} data-wsr="item" data-ws-id=${idOf(it)} aria-current=${on ? "true" : "false"}><span class="k">${GLYPHS[it.kind] || ""}</span><span class="t">${leafText(it)}</span><span class="st">${opsText(it)}</span></button>`;
142
+ });
143
+ const files = items().filter((it) => it.kind === "file").length;
144
+ const memories = items().filter((it) => it.kind === "memory").length;
145
+ const pct = Number.isFinite(state().ctxPct) ? ` · ctx ${Math.round(state().ctxPct)} %` : "";
146
+ return html`<aside class="wsr-set" aria-label="Working set"><div class="wsr-set-h"><strong>working set</strong><span class="wsr-set-m">session</span></div><div class="wsr-set-n">${items().length} items${pct} · files · ${files} · memories · ${memories}</div><div class="wsr-set-rows">${rows}</div><div class="wsr-set-hint">↑↓ steps through the set · Esc back to the stage (same scroll place)</div></aside>`;
147
+ }
148
+
149
+ function draw() {
150
+ if (!open) return;
151
+ const item = current();
152
+ const events = data?.events || [];
153
+ const cards = timelineCards(events);
154
+ const tokens = tokensText(item.tokens);
155
+ const counts = countsText(events);
156
+ const pill = data ? itemPill(item, events) : null;
157
+ const memory = item.kind === "memory" ? memoryTemplate(item, events) : null;
158
+ const scope = item.kind === "memory" ? events.find((e) => e.memory)?.memory?.scope : null;
159
+ const meta = [scope ? `${scope} memory` : "", "timeline, newest first", counts, tokens ? `${tokens} tokens` : ""].filter(Boolean).join(" · ");
160
+ const phoneMeta = [scope ? `${scope} memory` : "", counts, tokens ? `${tokens} tokens` : ""].filter(Boolean).join(" · ");
161
+ const undrop = sendLine && (item.state === "dropped" || item.hasDrop) ? html`<button type="button" class="ws-act-b" data-wsr="undrop">undrop</button>` : nothing;
162
+ let body;
163
+ if (failed) body = html`<div class="wsr-note">Couldn't load what chi has of it.</div>`;
164
+ else if (!data) body = html`<div class="wsr-note">Loading…</div>`;
165
+ else if (!cards.length) body = html`<div class="wsr-note">Nothing of it in the conversation yet: a running turn's reads show at its next step.</div>`;
166
+ else if (memory?.nowBody) body = memory.nowBody;
167
+ else body = cards.map((card) => cardTemplate(card, { item, undrop }));
168
+ const list = shown();
169
+ const at = list.findIndex((it) => it.kind === item.kind && it.key === item.key);
170
+ const tpl = html`<div class="wsr-main">
171
+ <div class="wsr-head">
172
+ <button type="button" class="wsr-back" data-wsr="back"><span class="wsr-wide">← Stage</span><span class="wsr-narrow">‹ Set</span></button>
173
+ <span class="wsr-title"><span class="wsr-name" title=${item.key}>${itemName(item)}</span><span class="wsr-meta wsr-wide">${meta}</span><span class="wsr-meta wsr-narrow">${phoneMeta}</span></span>
174
+ ${pill ? html`<span class=${`wsr-head-pill${pill.warn ? " warn" : ""}`}>${pill.text}</span>` : nothing}
175
+ <span class="wsr-nav wsr-narrow"><button type="button" data-wsr="prev" aria-label="Previous item" ?disabled=${at <= 0}>‹</button><button type="button" data-wsr="next" aria-label="Next item" ?disabled=${at < 0 || at >= list.length - 1}>›</button></span>
176
+ </div>
177
+ ${memory ? memory.banner : nothing}${memory ? memory.tabs : nothing}
178
+ ${item.kind === "file" ? stripTemplate(events) : nothing}
179
+ <div class="wsr-body" tabindex="-1">${body}</div>
180
+ ${memory ? memory.from : nothing}
181
+ <div class="wsr-foot wsr-narrow">‹ › the next item · lines don't wrap, scroll sideways in the code</div>
182
+ </div>${setTemplate(item)}`;
183
+ render(tpl, el);
184
+ }
185
+
186
+ // Opens +target+ ({kind, key}): the stage stays under it, inert.
187
+ function openItem(target) {
188
+ if (!target?.kind || !target.key) return;
189
+ const again = open && open.kind === target.kind && open.key === target.key;
190
+ open = { kind: target.kind, key: target.key };
191
+ openFor = session();
192
+ if (!again) {
193
+ data = null;
194
+ failed = false;
195
+ tab = "has";
196
+ }
197
+ if (el.hidden) {
198
+ const active = doc.activeElement;
199
+ returnTo = active && active !== doc.body && !el.contains(active) ? active : null;
200
+ for (const child of covered()) child.inert = true;
201
+ el.hidden = false;
202
+ doc.body.classList.add("ws-reading");
203
+ }
204
+ draw();
205
+ el.querySelector(".wsr-body")?.scrollTo?.(0, 0);
206
+ el.querySelector(".wsr-back")?.focus({ preventScroll: true });
207
+ load();
208
+ }
209
+
210
+ // +refocus+: focus goes back where it was at the open (not for a jump,
211
+ // which scrolls to its row, nor a reset).
212
+ function close({ refocus = true } = {}) {
213
+ if (el.hidden) return;
214
+ open = null;
215
+ data = null;
216
+ latest.drop();
217
+ el.hidden = true;
218
+ for (const child of covered()) child.inert = false;
219
+ doc.body.classList.remove("ws-reading");
220
+ render(nothing, el);
221
+ const back = returnTo;
222
+ returnTo = null;
223
+ if (refocus && back?.isConnected && !back.closest?.("[inert]")) back.focus?.({ preventScroll: true });
224
+ }
225
+
226
+ // The item +step+ places from the open one in the set.
227
+ function stepItem(step) {
228
+ const list = shown();
229
+ const at = list.findIndex((it) => it.kind === open?.kind && it.key === open?.key);
230
+ const next = list[at + step];
231
+ if (next) openItem(next);
232
+ }
233
+
234
+ const phone = () => win.innerWidth < PHONE_MAX;
235
+
236
+ el.addEventListener("click", (e) => {
237
+ const act = e.target.closest("[data-wsr], [data-ws='jump']");
238
+ if (!act) return;
239
+ if (act.dataset.ws === "jump") {
240
+ const [turn, step, row] = String(act.dataset.at).split(".").map(Number);
241
+ close({ refocus: false });
242
+ workingSet?.jump({ turn, step, row });
243
+ return;
244
+ }
245
+ const what = act.dataset.wsr;
246
+ if (what === "back") {
247
+ close();
248
+ // After this click has passed the list's outside-click close.
249
+ if (phone()) setTimeout(() => workingSet?.openList(), 0);
250
+ } else if (what === "item") {
251
+ const found = items().find((it) => idOf(it) === act.dataset.wsId);
252
+ if (found) openItem(found);
253
+ } else if (what === "prev") stepItem(-1);
254
+ else if (what === "next") stepItem(1);
255
+ else if (what === "tab") {
256
+ tab = act.dataset.tab === "now" ? "now" : "has";
257
+ draw();
258
+ } else if (what === "band") {
259
+ const first = Number(act.dataset.first);
260
+ const card = [...el.querySelectorAll(".wsr-card.slice")].find((c) => c.querySelector(`.read-line[data-line="${first}"]`));
261
+ card?.scrollIntoView?.({ block: "start" });
262
+ } else if (what === "undrop" && sendLine) {
263
+ const item = current();
264
+ Promise.resolve(sendLine(steerLine("undrop", [item], state().dir))).catch(() => {});
265
+ }
266
+ });
267
+
268
+ doc.addEventListener("keydown", (e) => {
269
+ if (el.hidden || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
270
+ if (e.target?.closest?.("input, textarea, [contenteditable]")) return;
271
+ // A popover over the reader takes its own Esc.
272
+ if (doc.querySelector(".ctx-popover:not([hidden])")) return;
273
+ if (e.key === "Escape") {
274
+ e.preventDefault();
275
+ close();
276
+ } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
277
+ e.preventDefault();
278
+ stepItem(e.key === "ArrowDown" ? 1 : -1);
279
+ }
280
+ });
281
+
282
+ // A tool row's "timeline" (ws_way_in.js).
283
+ doc.addEventListener("click", (e) => {
284
+ const button = e.target.closest?.(".activity-ws[data-ws-kind]");
285
+ if (!button) return;
286
+ e.preventDefault();
287
+ openItem({ kind: button.dataset.wsKind, key: button.dataset.wsKey });
288
+ });
289
+
290
+ return {
291
+ peek,
292
+ // The working set's view (working_set_view.js), once made: the set,
293
+ // its jumps and its list; rows get their way in from now on.
294
+ attach(view) {
295
+ workingSet = view;
296
+ if (!view) return;
297
+ setTimelineWayIn(true);
298
+ // A new snapshot: asked again (a snapshot of another session: closed,
299
+ // though forgetSession's reset has done it already).
300
+ view.onServer(() => {
301
+ if (el.hidden) return;
302
+ if (session() !== openFor) close({ refocus: false });
303
+ else load();
304
+ });
305
+ },
306
+ open: openItem,
307
+ close,
308
+ isOpen: () => !el.hidden,
309
+ // The ref ▾ menu's "What chi has" (refs.js): a file ref whose path is
310
+ // an item of the set.
311
+ whatChiHas: {
312
+ has: (path) => items().some((it) => it.kind === "file" && it.key === path),
313
+ open: (path) => openItem({ kind: "file", key: path }),
314
+ },
315
+ // The session forgotten (another one, the start page: session_view.js
316
+ // forgetSession): closed, the stage no longer inert.
317
+ reset() { close({ refocus: false }); peek.close(); },
318
+ };
319
+ }
@@ -0,0 +1,200 @@
1
+ // What the model has of one working-set item, as the reader (ws_reader.js)
2
+ // and the popover's peek (ws_peek.js) show it: the pure part. No DOM, so
3
+ // `node --test` covers it. The server's timeline (GET …/working_set/item,
4
+ // WorkingSet.timeline_of: docs/internals/working-set.md, Timeline) is every
5
+ // run of the item in conversation order; here it becomes cards, newest
6
+ // first:
7
+ // - a slice: reads whose lines overlap or touch, merged (each line from the
8
+ // newest read holding it), its header listing the reads; reads merge only
9
+ // with reads in the same state (still sent, dropped, forgotten), so a
10
+ // slice the model no longer has is greyed whole, and only between the same
11
+ // edits or writes (an edit moves the lines after it);
12
+ // - a partial read (a head/tail preview, a cut output), shown as it is;
13
+ // - an edit or a write, its saved diff (diff_view.js);
14
+ // - a memory read (its text, its overlay apart), a memory write, a fetch.
15
+ // The file strip draws the merged ranges over the file's length (the
16
+ // largest last line any read knows: a whole read's is the file's).
17
+ //
18
+ // An event's place on the page (its step tag, "go to the call") is the
19
+ // item's row with its tool id (working_set.js runs); a legacy row has
20
+ // none, so the server's global step names it.
21
+
22
+ import { stepTag } from "./working_set.js";
23
+
24
+ // A slice longer than this shows its first lines and a "show all" fold.
25
+ export const SLICE_FOLD_LINES = 200;
26
+ // The peek's lines of a read or a text.
27
+ export const PEEK_LINES = 12;
28
+
29
+ const STATES = ["sent", "dropped", "forgotten"];
30
+
31
+ // An event's state: sent, dropped (the user's) or forgotten.
32
+ export function eventState(event) {
33
+ if (event?.dropped) return "dropped";
34
+ if (event && event.sent === false) return "forgotten";
35
+ return "sent";
36
+ }
37
+
38
+ // The path of the item route for session +id+ (+now+: a memory's text now).
39
+ export function itemPath(id, kind, key, { now = false } = {}) {
40
+ const query = new URLSearchParams({ kind, key });
41
+ if (now) query.set("now", "1");
42
+ return `/api/sessions/${encodeURIComponent(id)}/working_set/item?${query}`;
43
+ }
44
+
45
+ // A read's lines as numbered rows ([{n, text}]), its text split as the
46
+ // file's (a last newline ends a line), at most up to its last line.
47
+ export function readRows(event) {
48
+ const first = Number(event?.lines?.first) || 1;
49
+ const lines = String(event?.text ?? "").split("\n");
50
+ if (lines.at(-1) === "") lines.pop();
51
+ const last = Number(event?.lines?.last);
52
+ const count = Number.isFinite(last) && last >= first ? Math.min(lines.length, last - first + 1) : lines.length;
53
+ return lines.slice(0, count).map((text, i) => ({ n: first + i, text }));
54
+ }
55
+
56
+ const mergeable = (event) => event.op === "read" && !event.lines?.partial && Number.isFinite(event.lines?.first) && Number.isFinite(event.lines?.last);
57
+
58
+ // The reads of +events+ merged into slices: [{type: "slice", first, last,
59
+ // state, reads, rows, newest}], +newest+ the place of its newest read.
60
+ function slices(events) {
61
+ const out = [];
62
+ // An edit or a write starts a new version of the file: its lines move, so
63
+ // only reads between the same edits merge (line numbers are one file's).
64
+ let version = 0;
65
+ const versions = events.map((event) => {
66
+ if (event.op === "edit" || event.op === "write") version += 1;
67
+ return version;
68
+ });
69
+ for (let v = 0; v <= version; v += 1) for (const state of STATES) {
70
+ const reads = events.map((event, place) => ({ event, place }))
71
+ .filter(({ event, place }) => versions[place] === v && mergeable(event) && eventState(event) === state)
72
+ .sort((a, b) => a.event.lines.first - b.event.lines.first || a.place - b.place);
73
+ let open = null;
74
+ for (const read of reads) {
75
+ const { first, last } = read.event.lines;
76
+ if (open && first <= open.last + 1) {
77
+ open.last = Math.max(open.last, last);
78
+ open.reads.push(read);
79
+ } else {
80
+ open = { type: "slice", first, last, state, reads: [read] };
81
+ out.push(open);
82
+ }
83
+ }
84
+ }
85
+ return out.map((slice) => {
86
+ // Each line from the newest read that holds it.
87
+ const lines = new Map();
88
+ for (const { event } of [...slice.reads].sort((a, b) => a.place - b.place)) {
89
+ for (const row of readRows(event)) lines.set(row.n, row.text);
90
+ }
91
+ const rows = [...lines.keys()].sort((a, b) => a - b).map((n) => ({ n, text: lines.get(n) }));
92
+ const reads = slice.reads.sort((a, b) => b.place - a.place).map(({ event }) => event);
93
+ return { ...slice, reads, rows, newest: Math.max(...slice.reads.map((r) => r.place)) };
94
+ });
95
+ }
96
+
97
+ const TYPES = { edit: "edit", write: "edit", memory_read: "memory", memory_write: "written", fetch: "fetch" };
98
+
99
+ // The timeline's cards, newest first: [{type, newest, ...}] — slices
100
+ // (above), and {type: "partial" | "edit" | "memory" | "written" | "fetch",
101
+ // event, state}. The newest card is +latest+.
102
+ export function timelineCards(events = []) {
103
+ const list = Array.isArray(events) ? events : [];
104
+ const cards = slices(list);
105
+ list.forEach((event, place) => {
106
+ if (event.op === "read" && !mergeable(event)) cards.push({ type: "partial", event, state: eventState(event), newest: place });
107
+ else if (TYPES[event.op]) cards.push({ type: TYPES[event.op], event, state: eventState(event), newest: place });
108
+ });
109
+ cards.sort((a, b) => b.newest - a.newest);
110
+ if (cards.length) cards[0] = { ...cards[0], latest: true };
111
+ return cards;
112
+ }
113
+
114
+ // The file strip: the merged ranges over the file's length (+length+: the
115
+ // largest last line known), the newest read's range +latest+.
116
+ // @return {length, bands: [{first, last, state, latest?}]} or null without one
117
+ export function fileStrip(events = []) {
118
+ const reads = (events || []).filter(mergeable);
119
+ if (!reads.length) return null;
120
+ const length = Math.max(...reads.map((e) => e.lines.last));
121
+ const bands = slices(events).map(({ first, last, state }) => ({ first, last, state }));
122
+ const newest = reads.at(-1);
123
+ bands.push({ first: newest.lines.first, last: newest.lines.last, state: eventState(newest), latest: true });
124
+ return { length, bands };
125
+ }
126
+
127
+ // A band's place on the strip as percentages ({left, width}).
128
+ export function bandBox(band, length) {
129
+ const total = Math.max(1, Number(length) || 1);
130
+ const left = ((band.first - 1) / total) * 100;
131
+ const width = Math.max(0.5, ((band.last - band.first + 1) / total) * 100);
132
+ return { left: `${left.toFixed(2)}%`, width: `${Math.min(width, 100 - left).toFixed(2)}%` };
133
+ }
134
+
135
+ // "3 reads, 1 edit" (the peek's link and the reader's head).
136
+ export function countsText(events = []) {
137
+ const reads = events.filter((e) => e.op === "read" || e.op === "memory_read" || e.op === "fetch").length;
138
+ const edits = events.filter((e) => e.op === "edit" || e.op === "write" || e.op === "memory_write").length;
139
+ const word = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
140
+ return [reads ? word(reads, "read") : "", edits ? word(edits, "edit") : ""].filter(Boolean).join(", ");
141
+ }
142
+
143
+ // The place of +event+ on the page: its row's at, from the item's runs
144
+ // (working_set.js), or null (a legacy row, a row not drawn).
145
+ export function placeOf(event, item) {
146
+ const run = (item?.runs || []).find((r) => r.id === event?.tool_id);
147
+ return run ? run.at : null;
148
+ }
149
+
150
+ // An event's step tag: the row's "t3·s14", else the server's "step 14".
151
+ export function eventTag(event, item) {
152
+ const at = placeOf(event, item);
153
+ if (at) return stepTag({ at }, { session: true });
154
+ return Number.isFinite(event?.step) ? `step ${event.step}` : "";
155
+ }
156
+
157
+ // A slice's or a card's steps for its header ("t3·s13, t3·s162").
158
+ export function tagsText(events, item) {
159
+ return events.map((event) => eventTag(event, item)).filter(Boolean).join(", ");
160
+ }
161
+
162
+ // A read's lines in words: "lines 204–265", "line 3", "whole file".
163
+ export function linesText(lines) {
164
+ if (!lines) return "";
165
+ if (lines.partial) return lines.whole ? "a preview of the file" : `from line ${lines.first}, cut`;
166
+ return lines.first === lines.last ? `line ${lines.first}` : `lines ${lines.first}–${lines.last}`;
167
+ }
168
+
169
+ const NO_LONGER = { dropped: "dropped by you", forgotten: "forgotten" };
170
+
171
+ // Why chi no longer has a card's text, or "" while it does.
172
+ export function goneText(state) {
173
+ return NO_LONGER[state] || "";
174
+ }
175
+
176
+ // What the peek shows of an item: the latest edit's diff when it has one,
177
+ // else the newest read's (or text's) first lines; +after+ the newest event
178
+ // after the one shown (a read after the edit).
179
+ // @return {event, kind: "diff" | "lines" | "text", rows?, after} or null
180
+ export function peekOf(events = []) {
181
+ const list = Array.isArray(events) ? events : [];
182
+ if (!list.length) return null;
183
+ const editAt = list.findLastIndex((e) => (e.op === "edit" || e.op === "write") && e.diff);
184
+ if (editAt >= 0) {
185
+ const after = list.slice(editAt + 1).at(-1) || null;
186
+ return { event: list[editAt], kind: "diff", after };
187
+ }
188
+ // An edit without a diff (a no-op) shows nothing: the newest other run.
189
+ const event = list.findLast((e) => e.op !== "edit" && e.op !== "write") || list.at(-1);
190
+ if (event.op === "read") return { event, kind: "lines", rows: readRows(event).slice(0, PEEK_LINES), after: null };
191
+ const rows = String(event.text ?? "").split("\n").slice(0, PEEK_LINES).map((text, i) => ({ n: i + 1, text }));
192
+ return { event, kind: "text", rows, after: null };
193
+ }
194
+
195
+ // What an event is, in a header's words ("edit", "read 236–250", "memory read").
196
+ export function eventWords(event) {
197
+ if (!event) return "";
198
+ if (event.op === "read") return `read ${linesText(event.lines).replace(/^lines? /, "")}`.trim();
199
+ return { edit: "edit", write: "write", memory_read: "memory read", memory_write: "memory write", fetch: "fetch" }[event.op] || event.op;
200
+ }
@@ -0,0 +1,25 @@
1
+ // A tool row's way into the working set's reader (ws_reader.js): a small
2
+ // "timeline" button on an ok read, edit, write, memory_read or memory_write
3
+ // row (the item it touched: working_set.js rowEntries), which the reader's
4
+ // delegated click opens. Off until the page turns it on (app.js, for an
5
+ // owner with the working set): a participant's rows never get it (the
6
+ // null-control idiom: not drawn, not wired). turn_html.js draws it.
7
+
8
+ import { html, nothing } from "./vendor/lit-html.js";
9
+ import { rowEntries } from "./working_set.js";
10
+
11
+ const TOOLS = new Set(["read", "edit", "write", "memory_read", "memory_write"]);
12
+ let on = false;
13
+
14
+ // The page has a reader: rows drawn from now on carry the button.
15
+ export function setTimelineWayIn(value) {
16
+ on = !!value;
17
+ }
18
+
19
+ // The button for +row+ (activity.js's row model), or nothing.
20
+ export function wayInTemplate(row) {
21
+ if (!on || !TOOLS.has(row?.tool)) return nothing;
22
+ const entry = rowEntries(row)[0];
23
+ if (!entry) return nothing;
24
+ return html`<button type="button" class="activity-ws" data-ws-kind=${entry.kind} data-ws-key=${entry.key} title="What chi has of it: open its timeline">timeline</button>`;
25
+ }
@@ -272,45 +272,64 @@ module Samagotchi
272
272
  # POST /api/sessions/:id/images: the raw image as the body (a paste or
273
273
  # a drop), ?name= for its file name. Stored like any other image
274
274
  # (converted, downscaled); answers the ref the turn then names.
275
+ # A participant's upload is theirs (ParticipantImages).
275
276
  def handle_image_upload(req, id)
276
277
  session_dir = image_session_dir(id)
277
278
  return session_not_found(id) unless session_dir
279
+ return participant_image_upload(req, id, session_dir) unless principal(req).owner?
278
280
 
279
- length = req.content_length.to_i
280
- if length > MAX_IMAGE_UPLOAD_BYTES
281
- return error_response(413, "too_large", "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB")
282
- end
283
-
284
- bytes = request_body(req, MAX_IMAGE_UPLOAD_BYTES + 1).to_s.b
285
- if bytes.bytesize > MAX_IMAGE_UPLOAD_BYTES
286
- return error_response(413, "too_large", "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB")
287
- end
281
+ bytes = image_upload_bytes(req)
282
+ return error_response(413, "too_large", image_too_large_detail) unless bytes
288
283
 
289
- name = File.basename(req.params["name"].to_s)[0, 120]
290
- ref = ImageStore.ingest(session_dir, bytes: bytes, name: name.empty? ? "image" : name, source: "user")
284
+ ref = ImageStore.ingest(session_dir, bytes: bytes, name: upload_name(req), source: "user")
291
285
  json_response(201, ref)
292
286
  rescue ImageStore::Error => e
293
287
  error_response(422, "bad_image", e.message)
294
288
  end
295
289
 
290
+ # An upload's body, or nil when it is over MAX_IMAGE_UPLOAD_BYTES (by
291
+ # its Content-Length or what came).
292
+ def image_upload_bytes(req)
293
+ return nil if req.content_length.to_i > MAX_IMAGE_UPLOAD_BYTES
294
+
295
+ bytes = request_body(req, MAX_IMAGE_UPLOAD_BYTES + 1).to_s.b
296
+ bytes.bytesize > MAX_IMAGE_UPLOAD_BYTES ? nil : bytes
297
+ end
298
+
299
+ def image_too_large_detail = "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB"
300
+
301
+ # An upload's ?name=, a base name without control characters; "image"
302
+ # without one.
303
+ def upload_name(req)
304
+ name = File.basename(req.params["name"].to_s).gsub(/[[:cntrl:]]/, "")[0, 120]
305
+ name.empty? ? "image" : name
306
+ end
307
+
296
308
  # GET /api/sessions/:id/images/<hash>.<ext>: a stored image (only
297
- # raster types; never svg), for the page's thumbnails.
298
- def handle_image(_req, id, name)
309
+ # raster types; never svg), for the page's thumbnails. A participant
310
+ # reads only an image they uploaded (ParticipantImages), never
311
+ # cached; any other is the 404 an unknown file gets.
312
+ def handle_image(req, id, name)
299
313
  session_dir = image_session_dir(id)
300
314
  return not_found(path: "images/#{name}") unless session_dir && IMAGE_NAME_RE.match?(name.to_s)
301
315
 
316
+ participant = !principal(req).owner?
317
+ return not_found(path: "images/#{name}") if participant && !participant_owns_image?(req, session_dir, name)
318
+
302
319
  path = File.join(session_dir, ImageStore::DIR, name)
303
320
  return not_found(path: "images/#{name}") unless File.file?(path) && !File.symlink?(path)
304
321
 
305
322
  body = File.binread(path)
306
323
  [200, { "Content-Type" => IMAGE_TYPES.fetch(File.extname(name).delete(".")), "Content-Length" => body.bytesize.to_s,
307
- "X-Content-Type-Options" => "nosniff", "Cache-Control" => "private, max-age=86400" }, [body]]
324
+ "X-Content-Type-Options" => "nosniff", "Cache-Control" => participant ? "no-store" : "private, max-age=86400" },
325
+ [body]]
308
326
  end
309
327
 
310
328
  # The Bridge decides the reason (Bridge::CANCEL_REASONS; anything
311
329
  # else is manual) and the reply echoes its choice. No reason: user.
312
330
  def handle_cancel(req, id)
313
331
  @session_class.load(id, state_dir: default_state_dir)
332
+ return participant_cancel(req, id) unless principal(req).owner?
314
333
 
315
334
  parsed = parse_json(request_body(req))
316
335
  reason = (parsed["reason"] if parsed.is_a?(Hash)).to_s.strip
@@ -318,6 +337,12 @@ module Samagotchi
318
337
 
319
338
  turn_id = (parsed["turn_id"] if parsed.is_a?(Hash)).to_s.strip
320
339
  request = ->(client) { turn_id.empty? ? client.cancel(reason: reason) : client.cancel(reason: reason, turn_id: turn_id) }
340
+ relay_cancel(id, request)
341
+ end
342
+
343
+ # The cancel's relay to the worker and its answers (the owner's and a
344
+ # participant's, ParticipantAccess#participant_cancel).
345
+ def relay_cancel(id, request)
321
346
  relay(id, bridge_client(id), request, what: "the turn was not cancelled", cant: "cancel turns") do |reply|
322
347
  case reply.status
323
348
  when 202
@@ -300,7 +300,7 @@ module Samagotchi
300
300
  # default) is known not to take images; nil when it does or is
301
301
  # unknown (the worker decides when the turn runs).
302
302
  def vision_refused(name, typed)
303
- refusal = SendCommand.vision_refusal(name, SendCommand.vision_answer(name, typed: typed))
303
+ refusal = SendCommand.vision_refusal_for(name, typed: typed)
304
304
  refusal && error_response(422, "no_vision", refusal)
305
305
  end
306
306