samagotchi 0.101.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/bin/chi +13 -10
- data/docs/architecture.md +8 -6
- data/docs/cli.md +47 -7
- data/docs/configuration.md +25 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +86 -11
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +7 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/engine.rb +131 -12
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -13
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -2
- data/lib/samagotchi/web/public/stream_handlers.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +113 -21
- data/lib/samagotchi/web/public/working_set_view.js +136 -21
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +49 -11
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +5 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -5,9 +5,20 @@
|
|
|
5
5
|
// and the step tags' jump to a call. The rail is working_set_rail.js's; the
|
|
6
6
|
// rules are working_set.js's.
|
|
7
7
|
//
|
|
8
|
+
// Steering (P3a: drop / undrop): the list's rows and groups post
|
|
9
|
+
// `/working-set drop|undrop KEY…` lines (+sendLine+, the session command
|
|
10
|
+
// route). Between turns one runs at once: its command_ran re-reads the
|
|
11
|
+
// conversation and the re-emitted set strikes the item `dropped`. While a
|
|
12
|
+
// turn runs it is queued: its command_queued (waits: "turn_end"; any
|
|
13
|
+
// client's, and the snapshot's replay after a reload) makes it pending, a
|
|
14
|
+
// dashed-warn row with a pill and an undo (the inverse line, queued after
|
|
15
|
+
// it), until its command_ran. On a phone's sheet a tap on a row opens its
|
|
16
|
+
// action strip under it, one at a time.
|
|
17
|
+
//
|
|
8
18
|
// The server's projection (WorkingSet: a :working_set_updated event, a GET
|
|
9
19
|
// working_set when a session's set is seeded and when the expanded view
|
|
10
|
-
// opens) is one session-wide snapshot
|
|
20
|
+
// opens) is one session-wide snapshot: an event always taken, a GET's
|
|
21
|
+
// answer unless an event overtook it (working_set.js newerSnapshot); every form
|
|
11
22
|
// overlays its state and tokens on the rows' items by key (working_set.js
|
|
12
23
|
// overlay), so a state is the item's now, not then. A new snapshot redraws
|
|
13
24
|
// them all: the chip, the open popover, the footprints in place and the rail
|
|
@@ -21,8 +32,8 @@ import { html, nothing, render, svg } from "./vendor/lit-html.js";
|
|
|
21
32
|
import { createChipPopover } from "./chip_popover.js";
|
|
22
33
|
import { showRow } from "./refs.js";
|
|
23
34
|
import {
|
|
24
|
-
BUDGETS, fold, footprint, footprintText, GLYPHS, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot,
|
|
25
|
-
sessionItems, stepRange, stepTag, tokensText,
|
|
35
|
+
BUDGETS, dropRefusal, fold, footprint, footprintText, GLYPHS, INVERSE, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot,
|
|
36
|
+
opsText, overlay, parseSteerLine, pendingSteers, sessionItems, steerId, steerLine, stepRange, stepTag, tokensText,
|
|
26
37
|
} from "./working_set.js";
|
|
27
38
|
|
|
28
39
|
// The popover's files fold to this many lines (groups open in place, one at
|
|
@@ -83,7 +94,7 @@ export function footprintTemplate(items, ordinal) {
|
|
|
83
94
|
const sep = html` <span class="sep">·</span> `;
|
|
84
95
|
const parts = fp.labels.map((l, i) => html`${i ? sep : nothing}<span class=${`l ${l.kind}${l.state && l.state !== "live" ? ` ${l.state}` : ""}`} title=${labelTitle(l)}>${l.glyph ? html`<span class="k">${l.glyph}</span> ` : nothing}${labelText({ ...l, glyph: "" })}</span>${footTag(l)}`);
|
|
85
96
|
const text = footprintText(items, BUDGETS.footprint);
|
|
86
|
-
return html`<div class="ws-foot" role="button" tabindex="0" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}</div>`;
|
|
97
|
+
return html`<div class="ws-foot" role="button" tabindex="0" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}${fp.dropped ? html`${sep}<span class="f">${fp.dropped} dropped</span>` : nothing}</div>`;
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
// What a footprint shows that can change after it is drawn: its items'
|
|
@@ -122,7 +133,10 @@ function memberName(item, group) {
|
|
|
122
133
|
// +infoEl+: the session bar's line (the chip's popover is delegated there);
|
|
123
134
|
// +onChange()+: the chip's count or dot changed (app.js updateInfoBar).
|
|
124
135
|
// +getWorkingSet(id)+: the server's projection (data.js; null: none asked).
|
|
125
|
-
|
|
136
|
+
// +sendLine(line)+: posts a steer line as the session's command (null: no
|
|
137
|
+
// steering, the list reads only).
|
|
138
|
+
export function createWorkingSet({ historyEl, stageRoot = () => null, state, session = () => null, infoEl, onChange = () => {}, getWorkingSet = null,
|
|
139
|
+
sendLine = null, doc = document }) {
|
|
126
140
|
// The finished turns' sets ({items, prompt}), the running turn's (null:
|
|
127
141
|
// none), the rail's first-sight times (its fresh cue), the session count
|
|
128
142
|
// at the last open (the chip's dot).
|
|
@@ -135,9 +149,16 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
135
149
|
let view = { scope: "session", turn: null };
|
|
136
150
|
let openGroup = null;
|
|
137
151
|
// The server's snapshot ({items, at}) and the session it is of; what
|
|
138
|
-
// redraws the rail when it changes.
|
|
152
|
+
// redraws the rail when it changes. +events+ counts the events taken, so
|
|
153
|
+
// a GET knows whether one came while it was on its way.
|
|
139
154
|
let server = null;
|
|
140
155
|
let serverOf = null;
|
|
156
|
+
let events = 0;
|
|
157
|
+
// The steer lines queued for after the running turn, by command id, and
|
|
158
|
+
// the session they are of; the row whose action strip is open (a sheet).
|
|
159
|
+
const queued = new Map();
|
|
160
|
+
let queuedOf = null;
|
|
161
|
+
let stripOpen = null;
|
|
141
162
|
const listeners = new Set();
|
|
142
163
|
const lit = (items) => overlay(items, server);
|
|
143
164
|
|
|
@@ -175,7 +196,32 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
175
196
|
onClose: () => { openGroup = null; onChange(); },
|
|
176
197
|
});
|
|
177
198
|
|
|
178
|
-
|
|
199
|
+
const pending = () => pendingSteers([...queued.values()]);
|
|
200
|
+
|
|
201
|
+
// A steer button: +op+ on +items+ ("undo" sends the inverse of a pending op).
|
|
202
|
+
function actButton(op, items, { label = op, title = "", disabled = false } = {}) {
|
|
203
|
+
const ids = items.map(steerId).join(" ");
|
|
204
|
+
return html`<button type="button" class=${`ws-act-b ${op}`} data-ws="steer" data-op=${op} data-ids=${ids} title=${title} ?disabled=${disabled}>${label}</button>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// What a row offers: a pending op's pill and undo, an undrop, a drop (or
|
|
208
|
+
// why not); nothing for an item no op applies to.
|
|
209
|
+
function rowActions(item, pend) {
|
|
210
|
+
if (!sendLine) return { pill: nothing, buttons: nothing };
|
|
211
|
+
const op = pend.get(steerId(item));
|
|
212
|
+
if (op) {
|
|
213
|
+
const pill = html`<span class="ws-pend" title="applies at the next turn's start">${op === "drop" ? "drops next turn" : "back next turn"}</span>`;
|
|
214
|
+
return { pill, buttons: actButton(INVERSE[op], [item], { label: "undo", title: `take back the queued ${op}` }) };
|
|
215
|
+
}
|
|
216
|
+
if (item.state === "dropped") {
|
|
217
|
+
return { pill: nothing, buttons: actButton("undrop", [item], { title: "send it whole again from the next request (a cache break)" }) };
|
|
218
|
+
}
|
|
219
|
+
const why = dropRefusal(item);
|
|
220
|
+
if (why === null) return { pill: nothing, buttons: nothing };
|
|
221
|
+
return { pill: nothing, buttons: actButton("drop", [item], { title: why || "send a stub instead of its reads from the next turn's start", disabled: !!why }) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function rowTemplate(item, { session: inSession, group = null, now, pend = new Map() }) {
|
|
179
225
|
const fresh = isFresh(seenAt(item), now);
|
|
180
226
|
const tag = stepTag(item, { session: inSession });
|
|
181
227
|
const words = `${opsText(item)}${fresh ? " · just now" : ""}`;
|
|
@@ -201,7 +247,15 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
201
247
|
const edited = item.kind === "file" && item.ops.includes("edit");
|
|
202
248
|
const state = item.state && item.state !== "live" ? ` ${item.state}` : "";
|
|
203
249
|
const tokens = tokensText(item.tokens);
|
|
204
|
-
|
|
250
|
+
const id = steerId(item);
|
|
251
|
+
const { pill, buttons } = rowActions(item, pend);
|
|
252
|
+
const isPending = pill !== nothing;
|
|
253
|
+
const strip = stripOpen === id && buttons !== nothing;
|
|
254
|
+
const open = item.kind === "file" ? html`<button type="button" class="ws-act-b open ref" data-ref-kind="file" data-ref-path=${item.ref?.path || full}>open</button>` : nothing;
|
|
255
|
+
const row = html`<div class=${`ws-row ${item.kind}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}${isPending ? " pending" : ""}${strip ? " strip-open" : ""}`} data-ws-id=${id}><span class="k">${glyph}</span>${nameEl}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${pill}${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}${buttons !== nothing ? html`<span class="ws-act">${buttons}</span>` : nothing}</div>`;
|
|
256
|
+
// One template either way, so the tapped row stays the same node (a
|
|
257
|
+
// click outside the popover closes it).
|
|
258
|
+
return html`${row}${strip ? html`<div class="ws-strip">${buttons}${open}</div>` : nothing}`;
|
|
205
259
|
}
|
|
206
260
|
|
|
207
261
|
// A section's labels: leaves as rows, groups as an opener row and, open,
|
|
@@ -211,8 +265,11 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
211
265
|
if (label.type === "leaf") return rowTemplate(label.members[0], opts);
|
|
212
266
|
const open = openGroup === label.id;
|
|
213
267
|
const state = label.state && label.state !== "live" ? ` ${label.state}` : "";
|
|
214
|
-
const head = html`<button type="button" class=${`ws-row ws-group-head ${label.kind}${state}`} data-ws="group" data-id=${label.id} aria-expanded=${String(open)} title=${labelTitle(label)}><span class="k">${open ? "▾" : "▸"}</span><span class="t">${label.glyph ? `${label.glyph} ` : ""}${label.text}</span>${label.count ? html`<span class="n">· ${label.count}</span>` : nothing}${label.edited ? html`<span class="e">✎${label.edited}</span>` : nothing}${label.cut ? html`<span class="c" title=${`${label.cut} forgotten`}>✂${label.cut}</span>` : nothing}<span class="chev">${open ? "close" : "open"}</span></button>`;
|
|
215
|
-
|
|
268
|
+
const head = html`<button type="button" class=${`ws-row ws-group-head ${label.kind}${state}`} data-ws="group" data-id=${label.id} aria-expanded=${String(open)} title=${labelTitle(label)}><span class="k">${open ? "▾" : "▸"}</span><span class="t">${label.glyph ? `${label.glyph} ` : ""}${label.text}</span>${label.count ? html`<span class="n">· ${label.count}</span>` : nothing}${label.edited ? html`<span class="e">✎${label.edited}</span>` : nothing}${label.cut ? html`<span class="c" title=${`${label.cut} cut (forgotten or dropped)`}>✂${label.cut}</span>` : nothing}<span class="chev">${open ? "close" : "open"}</span></button>`;
|
|
269
|
+
// A group drops its members that can be, not pending already.
|
|
270
|
+
const droppable = sendLine ? label.members.filter((m) => dropRefusal(m) === "" && !opts.pend.has(steerId(m))) : [];
|
|
271
|
+
const act = droppable.length ? html`<span class="ws-act">${actButton("drop", droppable, { label: `drop ${droppable.length}`, title: "send stubs instead of their reads from the next turn's start" })}</span>` : nothing;
|
|
272
|
+
return html`<div class="ws-ghead">${head}${act}</div>${open ? label.members.map((m) => rowTemplate(m, { ...opts, group: label })) : nothing}`;
|
|
216
273
|
});
|
|
217
274
|
}
|
|
218
275
|
|
|
@@ -224,7 +281,8 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
224
281
|
const files = set.items.filter((i) => i.kind === "file");
|
|
225
282
|
const memories = set.items.filter((i) => i.kind === "memory");
|
|
226
283
|
const urls = set.items.filter((i) => i.kind === "url");
|
|
227
|
-
const
|
|
284
|
+
const pend = pending();
|
|
285
|
+
const opts = { session: inSession, now, pend };
|
|
228
286
|
const fileLabels = fold(files, POP_BUDGET).labels;
|
|
229
287
|
// Memories are rows of their own; past the budget one group that opens.
|
|
230
288
|
const memoryLabels = fold(memories, memories.length > POP_BUDGET ? 1 : Infinity).labels;
|
|
@@ -238,7 +296,8 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
238
296
|
${files.length ? html`<div class="ctx-pop-meta">files · ${files.length}</div>${labelsTemplate(fileLabels, opts)}` : nothing}
|
|
239
297
|
${memories.length || set.muted.length ? html`<div class="ctx-pop-meta">memories${memMeta ? ` · ${memMeta}` : ""}</div>${labelsTemplate(memoryLabels, opts)}${set.muted.map((m) => rowTemplate(m, opts))}` : nothing}
|
|
240
298
|
${urls.length ? html`<div class="ctx-pop-meta">urls · ${urls.length}</div>${labelsTemplate(fold(urls, POP_BUDGET).labels, opts)}` : nothing}
|
|
241
|
-
|
|
299
|
+
${pend.size ? html`<div class="ws-pending" role="status">${pend.size} change${pend.size === 1 ? "" : "s"} pending · apply at the next turn's start · <button type="button" class="ws-act-b" data-ws="undo-all">undo all</button></div>` : nothing}
|
|
300
|
+
<div class="ctx-pop-meta ws-hint">A file opens its ref; a step tag goes to the call.${sendLine ? " Drop sends stubs instead of an item's reads from the next turn." : ""} Esc closes.</div>`;
|
|
242
301
|
render(tpl, popover.pop);
|
|
243
302
|
}
|
|
244
303
|
|
|
@@ -267,10 +326,37 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
267
326
|
return [turn, step, row].every(Number.isInteger) ? { turn, step, row } : null;
|
|
268
327
|
};
|
|
269
328
|
|
|
329
|
+
// Posts +op+ for the ids (the items' steer words, from the set).
|
|
330
|
+
function steer(op, ids) {
|
|
331
|
+
if (!sendLine || !ids.length) return;
|
|
332
|
+
const all = [...sessionSet().items, ...turnItems(view.turn)];
|
|
333
|
+
const byId = new Map(all.map((it) => [steerId(it), it]));
|
|
334
|
+
const items = ids.map((id) => byId.get(id)).filter(Boolean);
|
|
335
|
+
if (items.length) Promise.resolve(sendLine(steerLine(op, items))).catch(() => {});
|
|
336
|
+
}
|
|
337
|
+
|
|
270
338
|
popover.pop.addEventListener("click", (e) => {
|
|
271
339
|
const act = e.target.closest("[data-ws]");
|
|
340
|
+
// A sheet's row: a tap outside its links and buttons opens its strip.
|
|
341
|
+
if (!act && popover.pop.classList.contains("sheet")) {
|
|
342
|
+
const row = e.target.closest(".ws-row[data-ws-id]");
|
|
343
|
+
if (row && !e.target.closest("a, button, .ref")) {
|
|
344
|
+
stripOpen = stripOpen === row.dataset.wsId ? null : row.dataset.wsId;
|
|
345
|
+
draw();
|
|
346
|
+
popover.replace();
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
272
350
|
if (!act) return;
|
|
273
|
-
if (act.dataset.ws === "
|
|
351
|
+
if (act.dataset.ws === "steer") {
|
|
352
|
+
steer(act.dataset.op, act.dataset.ids.split(" ").filter(Boolean));
|
|
353
|
+
} else if (act.dataset.ws === "undo-all") {
|
|
354
|
+
const pend = pending();
|
|
355
|
+
for (const op of ["drop", "undrop"]) {
|
|
356
|
+
const ids = [...pend].filter(([, o]) => o === op).map(([id]) => id);
|
|
357
|
+
steer(INVERSE[op], ids);
|
|
358
|
+
}
|
|
359
|
+
} else if (act.dataset.ws === "jump") {
|
|
274
360
|
const at = parseAt(act.dataset.at);
|
|
275
361
|
if (at) jump(at);
|
|
276
362
|
} else if (act.dataset.ws === "scope") {
|
|
@@ -332,9 +418,9 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
332
418
|
}
|
|
333
419
|
|
|
334
420
|
// A newer server snapshot redraws every form; an older one (a GET that
|
|
335
|
-
// answered after a newer event) is dropped.
|
|
336
|
-
function takeServer(snapshot) {
|
|
337
|
-
const next = newerSnapshot(server, snapshot);
|
|
421
|
+
// answered after a newer event) is dropped. +how+: newerSnapshot's.
|
|
422
|
+
function takeServer(snapshot, how) {
|
|
423
|
+
const next = newerSnapshot(server, snapshot, how);
|
|
338
424
|
if (next === server) return;
|
|
339
425
|
server = next;
|
|
340
426
|
serverOf = session();
|
|
@@ -348,9 +434,10 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
348
434
|
function fetchServer() {
|
|
349
435
|
const id = session();
|
|
350
436
|
if (!getWorkingSet || !id) return;
|
|
437
|
+
const asked = events;
|
|
351
438
|
Promise.resolve()
|
|
352
439
|
.then(() => getWorkingSet(id))
|
|
353
|
-
.then((snapshot) => { if (session() === id) takeServer(snapshot); })
|
|
440
|
+
.then((snapshot) => { if (session() === id) takeServer(snapshot, { overtaken: events !== asked }); })
|
|
354
441
|
.catch(() => {});
|
|
355
442
|
}
|
|
356
443
|
|
|
@@ -360,21 +447,49 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
360
447
|
seed(list) {
|
|
361
448
|
turns = list.slice();
|
|
362
449
|
live = null;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
450
|
+
// The snapshot's replay (after this) brings the queued lines again.
|
|
451
|
+
queued.clear();
|
|
452
|
+
queuedOf = session();
|
|
453
|
+
stripOpen = null;
|
|
454
|
+
// Another session's snapshot goes; the same session's (a resync, after
|
|
455
|
+
// a steer from the list) stays until the newer one comes, and so does
|
|
456
|
+
// the open list.
|
|
366
457
|
if (serverOf !== session()) {
|
|
458
|
+
if (popover.isOpen()) popover.close();
|
|
367
459
|
server = null;
|
|
368
460
|
serverOf = session();
|
|
461
|
+
} else if (popover.isOpen()) {
|
|
462
|
+
if (view.turn !== null && view.turn >= turns.length) view = { scope: "session", turn: turns.length - 1 };
|
|
463
|
+
popover.refresh();
|
|
369
464
|
}
|
|
370
465
|
openedCount = sessionSet().items.length;
|
|
371
466
|
refreshFootprints();
|
|
372
467
|
fetchServer();
|
|
373
468
|
},
|
|
374
469
|
// A :working_set_updated event's set ({items, at}).
|
|
375
|
-
update(snapshot) {
|
|
470
|
+
update(snapshot) {
|
|
471
|
+
events += 1;
|
|
472
|
+
takeServer(snapshot, { event: true });
|
|
473
|
+
},
|
|
376
474
|
// +fn()+ runs after each new server snapshot (the rail's redraw).
|
|
377
475
|
onServer(fn) { listeners.add(fn); },
|
|
476
|
+
// A command queued for after the running turn (its command_queued,
|
|
477
|
+
// waits "turn_end"): a steer line is pending until its command_ran.
|
|
478
|
+
commandQueued(event) {
|
|
479
|
+
if (queuedOf !== session()) {
|
|
480
|
+
queued.clear();
|
|
481
|
+
queuedOf = session();
|
|
482
|
+
}
|
|
483
|
+
if (event?.waits !== "turn_end" || !event.command_id || !parseSteerLine(event.line)) return;
|
|
484
|
+
queued.set(event.command_id, event.line);
|
|
485
|
+
changed();
|
|
486
|
+
},
|
|
487
|
+
// A command ran (or was dropped): its line is no longer pending.
|
|
488
|
+
commandRan(event) {
|
|
489
|
+
if (event?.command_id && queued.delete(event.command_id)) changed();
|
|
490
|
+
},
|
|
491
|
+
// The steer lines pending (for the specs and the rail).
|
|
492
|
+
pending: () => pending(),
|
|
378
493
|
// The running turn's items (its model +turn+), its ordinal the next
|
|
379
494
|
// turn's; the chip follows.
|
|
380
495
|
liveItems(turn, prompt = "") {
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -259,7 +259,7 @@ module Samagotchi
|
|
|
259
259
|
})
|
|
260
260
|
# A recap written while a continue offer waits says the turn stopped
|
|
261
261
|
# unfinished (before the idle jobs start).
|
|
262
|
-
@engine.
|
|
262
|
+
@engine.recap_awaiting_continue = -> { @turn_flow.awaiting_continue? }
|
|
263
263
|
# The seq of the last turn's end event: a command queued before it was
|
|
264
264
|
# queued while that turn ran.
|
|
265
265
|
@turn_end_seq = 0
|
|
@@ -943,14 +943,17 @@ module Samagotchi
|
|
|
943
943
|
# finishes) comes after its command_queued. The cards it shows go out at
|
|
944
944
|
# once (btw's "thinking…" card), after its command_queued, which the
|
|
945
945
|
# UIs draw its line at (Engine#running_anytime). Its handler reads
|
|
946
|
-
# copies (ctx.messages) and shows things through ctx only. One
|
|
947
|
-
# /title,
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
#
|
|
946
|
+
# copies (ctx.messages) and shows things through ctx only. One that
|
|
947
|
+
# changed the session (/title, /recap off: fields no turn writes) is
|
|
948
|
+
# saved here, as a queued one is (#run_command), through Session#save,
|
|
949
|
+
# which takes the session's lock and writes a running turn's checkpoint
|
|
950
|
+
# (as WorkerSignals' save off the turn thread does), so the turn's own
|
|
951
|
+
# saves keep it.
|
|
951
952
|
def start_anytime_command(command)
|
|
952
953
|
@engine.spawn_anytime do
|
|
953
954
|
result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
|
|
955
|
+
# Saved before its command_ran: a client that reads the file on the reply finds it.
|
|
956
|
+
save_or_log(:command) { save_session } unless Array(result.changed).empty?
|
|
954
957
|
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
|
|
955
958
|
anytime: true, confirm: result.confirm)
|
|
956
959
|
rescue StandardError => e
|
|
@@ -39,10 +39,12 @@ module Samagotchi
|
|
|
39
39
|
LIVE = "live"
|
|
40
40
|
STALE = "stale"
|
|
41
41
|
FORGOTTEN = "forgotten"
|
|
42
|
+
# Forgotten, every output of it by the user's drop (WorkingSetSteer).
|
|
43
|
+
DROPPED = "dropped"
|
|
42
44
|
|
|
43
45
|
# One thing the model touched: +kind+ file|memory|url; +key+ a file's
|
|
44
46
|
# absolute path (FileRef), a memory's name, a URL (whitespace
|
|
45
|
-
# collapsed); +state+ live|stale|forgotten; +tokens+ its content
|
|
47
|
+
# collapsed); +state+ live|stale|forgotten|dropped; +tokens+ its content
|
|
46
48
|
# outputs as sent; +ops+ read|edit for a file, read|write for a memory,
|
|
47
49
|
# read for a URL, in first-seen order; +count+ the runs that touched
|
|
48
50
|
# it; +first_id+ the first one's chi id (ToolIds).
|
|
@@ -85,17 +87,47 @@ module Samagotchi
|
|
|
85
87
|
def build(conversation, cwd:, strategy:, stat: File.method(:stat))
|
|
86
88
|
conversation = Array(conversation)
|
|
87
89
|
view = LLMContextView.new(strategy: strategy)
|
|
90
|
+
items = all_touches(conversation, cwd, view).group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
|
|
91
|
+
item(kind, key, group, conversation, view, stat)
|
|
92
|
+
end
|
|
93
|
+
Snapshot.new(items: items, at: conversation.size)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# One content output (read, memory_read, web_fetch) of an item: its
|
|
97
|
+
# chi +id+ (ToolIds), the +index+ of the tool_response entry holding
|
|
98
|
+
# it, whether the id is +derived+ (a legacy entry's: nothing can name
|
|
99
|
+
# it) and whether the model is still +sent+ it under the strategy.
|
|
100
|
+
Output = Data.define(:id, :index, :derived, :sent)
|
|
101
|
+
|
|
102
|
+
# The content outputs of the item +kind+/+key+ (#build's identity: a
|
|
103
|
+
# file's absolute path, a memory's name, a URL), in conversation
|
|
104
|
+
# order, by the same pairing #build uses, so what acts on an item's
|
|
105
|
+
# outputs and the projection never disagree. Edit-only items have
|
|
106
|
+
# none. A memory_read of several names is one output of each.
|
|
107
|
+
# @return [Array<Output>]
|
|
108
|
+
def outputs_of(conversation, kind:, key:, strategy:, cwd:)
|
|
109
|
+
conversation = Array(conversation)
|
|
110
|
+
view = LLMContextView.new(strategy: strategy)
|
|
111
|
+
all_touches(conversation, cwd, view).filter_map do |touch|
|
|
112
|
+
next unless touch.content && touch.kind == kind && touch.key == key
|
|
113
|
+
|
|
114
|
+
ref = touch.run.ref
|
|
115
|
+
Output.new(id: ref.id, index: touch.run.index, derived: ref.derived?,
|
|
116
|
+
sent: still_sent?(touch, conversation, view))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Every paired run's touches, with what +view+ sends of its output.
|
|
121
|
+
def all_touches(conversation, cwd, view)
|
|
88
122
|
stored = LLMContextStale.paired_runs(conversation)
|
|
89
|
-
|
|
123
|
+
# Under none with no drop the view sends the conversation as it is.
|
|
124
|
+
messages = view.messages(conversation)
|
|
125
|
+
sent = if messages.equal?(conversation)
|
|
90
126
|
{}
|
|
91
127
|
else
|
|
92
|
-
LLMContextStale.paired_runs(
|
|
128
|
+
LLMContextStale.paired_runs(messages).to_h { |run| [run.ref.id, run.text] }
|
|
93
129
|
end
|
|
94
|
-
|
|
95
|
-
items = touches.group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
|
|
96
|
-
item(kind, key, group, conversation, view, stat)
|
|
97
|
-
end
|
|
98
|
-
Snapshot.new(items: items, at: conversation.size)
|
|
130
|
+
stored.flat_map { |run| touches(run, sent.fetch(run.ref.id, run.text), cwd) }
|
|
99
131
|
end
|
|
100
132
|
|
|
101
133
|
# The items +run+ touched (none when it failed or was interrupted).
|
|
@@ -141,7 +173,7 @@ module Samagotchi
|
|
|
141
173
|
contents = group.select(&:content)
|
|
142
174
|
tokens = contents.sum { |touch| touch.sent.length / TokenUsage::CHARS_PER_TOKEN / touch.share }.ceil
|
|
143
175
|
state = if contents.any? && contents.none? { |touch| still_sent?(touch, conversation, view) }
|
|
144
|
-
FORGOTTEN
|
|
176
|
+
contents.all? { |touch| dropped?(touch, conversation) } ? DROPPED : FORGOTTEN
|
|
145
177
|
elsif kind == FILE && changed?(key, group.last.run, conversation, stat)
|
|
146
178
|
STALE
|
|
147
179
|
else
|
|
@@ -161,11 +193,17 @@ module Samagotchi
|
|
|
161
193
|
return true if sent == stored || sent == with_id(stored, touch.run.ref.id)
|
|
162
194
|
|
|
163
195
|
edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
|
|
164
|
-
return true unless edit
|
|
196
|
+
return true unless edit && view.sends?(edit)
|
|
165
197
|
|
|
166
198
|
edit.kind == :forget ? edit.keep? : edit.note.match?(BY_EDIT)
|
|
167
199
|
end
|
|
168
200
|
|
|
201
|
+
# Whether +touch+'s output holds the user's applied drop.
|
|
202
|
+
def dropped?(touch, conversation)
|
|
203
|
+
edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
|
|
204
|
+
!edit.nil? && edit.kind == LLMContextEdit::DROP && edit.applied?
|
|
205
|
+
end
|
|
206
|
+
|
|
169
207
|
def with_id(text, id)
|
|
170
208
|
match = text.match(ToolResponse::LEAD)
|
|
171
209
|
match ? "#{match[0]}[##{id}] #{match.post_match}" : nil
|
|
@@ -195,6 +233,6 @@ module Samagotchi
|
|
|
195
233
|
|
|
196
234
|
def field(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
197
235
|
|
|
198
|
-
private_class_method :touches, :failed?, :found_names, :item, :still_sent?, :with_id, :changed?, :stamp_of, :field
|
|
236
|
+
private_class_method :all_touches, :touches, :failed?, :found_names, :item, :still_sent?, :dropped?, :with_id, :changed?, :stamp_of, :field
|
|
199
237
|
end
|
|
200
238
|
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "working_set"
|
|
4
|
+
require_relative "working_set_steer"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# /working-set: with no arguments the session's working set (WorkingSet,
|
|
8
|
+
# as the web's list shows it: each item, what was done to it, its state
|
|
9
|
+
# and tokens, then the changes the next turn starts with); `drop KEY…`
|
|
10
|
+
# and `undrop KEY…` steer it (WorkingSetSteer). KEY: a path (absolute or
|
|
11
|
+
# the session's folder's), memory:NAME, a URL.
|
|
12
|
+
#
|
|
13
|
+
# An op runs between turns (mid_turn :queue: a line sent while a turn
|
|
14
|
+
# runs waits for its end), on a copy of the session's messages that
|
|
15
|
+
# replaces them, so the next turn's first request is the first with it.
|
|
16
|
+
class WorkingSetCommand
|
|
17
|
+
NAME = "/working-set"
|
|
18
|
+
USAGE = "usage: #{NAME} [drop KEY… | undrop KEY…] (KEY: a path, memory:NAME or a URL)".freeze
|
|
19
|
+
VERBS = [WorkingSetSteer::DROP, WorkingSetSteer::UNDROP].freeze
|
|
20
|
+
OP_WORDS = { WorkingSetSteer::DROP => "dropped", WorkingSetSteer::UNDROP => "undropped" }.freeze
|
|
21
|
+
|
|
22
|
+
# @param engine [Engine] #session, #working_set_strategy,
|
|
23
|
+
# #clone_messages, #replace_messages
|
|
24
|
+
# @param save [#call] saves the session after a change
|
|
25
|
+
def initialize(engine:, save:)
|
|
26
|
+
@engine = engine
|
|
27
|
+
@save = save
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param args [String] what follows /working-set
|
|
31
|
+
# @return [Array(String, Boolean, Symbol)] the reply, whether the
|
|
32
|
+
# conversation changed, and :ok or :error (every key refused)
|
|
33
|
+
# @raise [ArgumentError] a line that isn't one (its message and USAGE)
|
|
34
|
+
def run(args)
|
|
35
|
+
verb, *words = args.to_s.split
|
|
36
|
+
return [listing, false, :ok] if verb.nil?
|
|
37
|
+
raise ArgumentError, USAGE unless VERBS.include?(verb) && !words.empty?
|
|
38
|
+
|
|
39
|
+
session = @engine.session or raise ArgumentError, "no session yet"
|
|
40
|
+
cwd = session.working_directory
|
|
41
|
+
strategy = @engine.working_set_strategy
|
|
42
|
+
messages = @engine.clone_messages(session.messages)
|
|
43
|
+
lines = []
|
|
44
|
+
done = 0
|
|
45
|
+
words.each do |word|
|
|
46
|
+
target = WorkingSetSteer.target(word, cwd: cwd)
|
|
47
|
+
result = if verb == WorkingSetSteer::DROP
|
|
48
|
+
WorkingSetSteer.drop(messages, target, strategy: strategy, cwd: cwd)
|
|
49
|
+
else
|
|
50
|
+
sent = !WorkingSetSteer.pending?(messages, WorkingSetSteer::DROP, target)
|
|
51
|
+
WorkingSetSteer.undrop(messages, target, strategy: strategy, cwd: cwd, sent: sent)
|
|
52
|
+
end
|
|
53
|
+
messages = WorkingSetSteer.record!(messages, result)
|
|
54
|
+
lines << done_line(result)
|
|
55
|
+
done += 1
|
|
56
|
+
rescue WorkingSetSteer::Refused => e
|
|
57
|
+
lines << "#{verb} #{word}: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
if done.positive?
|
|
60
|
+
@engine.replace_messages(messages)
|
|
61
|
+
@save.call(session)
|
|
62
|
+
end
|
|
63
|
+
[lines.join("\n"), done.positive?, done.positive? ? :ok : :error]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The set under the strategy the next turn runs, and the pending ops.
|
|
67
|
+
def listing
|
|
68
|
+
session = @engine.session
|
|
69
|
+
return "no session yet" unless session
|
|
70
|
+
|
|
71
|
+
messages = Array(session.messages)
|
|
72
|
+
items = WorkingSet.build(messages, cwd: session.working_directory, strategy: @engine.working_set_strategy).items
|
|
73
|
+
return "working set: empty (the files, memories and URLs the turns read show here)\n#{USAGE}" if items.empty?
|
|
74
|
+
|
|
75
|
+
rows = items.map { |item| [name(item, session.working_directory), words(item)] }
|
|
76
|
+
width = rows.map { |row, _| row.length }.max
|
|
77
|
+
lines = ["working set (#{items.size} item#{"s" unless items.size == 1}):"]
|
|
78
|
+
lines.concat(rows.map { |row, text| " #{row.ljust(width)} #{text}" })
|
|
79
|
+
pending = WorkingSetSteer.pending_ops(messages)
|
|
80
|
+
unless pending.empty?
|
|
81
|
+
lines << "at the next turn's start: #{pending.map { |op| "#{OP_WORDS[op["op"]]} #{op["label"]}" }.join(", ")}"
|
|
82
|
+
end
|
|
83
|
+
lines << USAGE
|
|
84
|
+
lines.join("\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def name(item, cwd)
|
|
90
|
+
case item.kind
|
|
91
|
+
when WorkingSet::FILE then WorkingSetSteer.target(item.key, cwd: cwd).label
|
|
92
|
+
when WorkingSet::MEMORY then "#{WorkingSetSteer::MEMORY_PREFIX}#{item.key}"
|
|
93
|
+
else item.key
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def words(item)
|
|
98
|
+
parts = [item.ops.join(", ")]
|
|
99
|
+
parts << "×#{item.count}" if item.count > 1
|
|
100
|
+
parts << item.state unless item.state == WorkingSet::LIVE
|
|
101
|
+
parts << "~#{item.tokens} tokens" if item.tokens.positive?
|
|
102
|
+
parts.join(" · ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def done_line(done)
|
|
106
|
+
what = "#{OP_WORDS[done.op]} #{done.target.label} (#{done.ids.join(", ")})"
|
|
107
|
+
if done.op == WorkingSetSteer::DROP
|
|
108
|
+
frees = done.freed_chars.positive? ? "frees #{WorkingSetSteer.k(done.freed_chars)}" : "frees nothing, the stubs are as long"
|
|
109
|
+
"#{what}: the next turn's first request sends the stubs (#{frees}; the server reads the " \
|
|
110
|
+
"#{WorkingSetSteer.k(done.tail_chars)} after them again)"
|
|
111
|
+
elsif done.free
|
|
112
|
+
"#{what}: its drop was never sent; nothing changes"
|
|
113
|
+
else
|
|
114
|
+
"#{what}: the next request sends it whole again (the server reads the #{WorkingSetSteer.k(done.tail_chars)} " \
|
|
115
|
+
"from it on again)"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|