samagotchi 0.102.0 → 0.103.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 +40 -1
- data/bin/chi +23 -191
- data/docs/architecture.md +54 -0
- data/docs/cli.md +114 -7
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +26 -2
- data/docs/sessions.md +12 -5
- data/docs/sub-agent.md +8 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +24 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +12 -8
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +30 -5
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +360 -69
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- metadata +19 -1
|
@@ -197,9 +197,10 @@ export function unarchiveSession(id, opts = {}) {
|
|
|
197
197
|
return api(`/api/sessions/${encodeURIComponent(id)}/unarchive`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
// Deletes the session for good; the server
|
|
200
|
+
// Deletes the session for good; force=true has the server stop a live
|
|
201
|
+
// worker first (the page's confirm says so; without it, 409 worker_running).
|
|
201
202
|
export function deleteSession(id, opts = {}) {
|
|
202
|
-
return api(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", ...opts });
|
|
203
|
+
return api(`/api/sessions/${encodeURIComponent(id)}?force=true`, { method: "DELETE", ...opts });
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
// One EventSource listener per handler, named by the handler's key (the
|
|
@@ -914,6 +914,7 @@ body.working-set .ts-live{position:relative;padding-right:264px}
|
|
|
914
914
|
.ws-item.leave,.ws-more.leave{opacity:0;pointer-events:none}
|
|
915
915
|
/* Just entered (FLASH_MS): an accent dot for the glyph and a tint; it settles (the tint fades 800 ms). */
|
|
916
916
|
.ws-item.fresh{background:var(--accent-tint)}
|
|
917
|
+
.ws-item.pending{outline:1px dashed var(--warn-border);outline-offset:-1px;background:var(--warn-bg)}
|
|
917
918
|
.ws-item.fresh .k{font-size:0;display:flex;align-items:center;justify-content:center;height:var(--ws-line)}
|
|
918
919
|
.ws-item.fresh .k::before{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--accent-ring)}
|
|
919
920
|
.ws-more{padding-left:17px;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint);font-variant-numeric:tabular-nums}
|
|
@@ -973,7 +974,7 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
|
|
|
973
974
|
.ws-row a.t:hover{text-decoration:underline}
|
|
974
975
|
.ws-row .e{flex:none;color:var(--accent);font-weight:600}
|
|
975
976
|
.ws-row .n{flex:none;color:var(--text-faint)}
|
|
976
|
-
.ws-row .st{flex:
|
|
977
|
+
.ws-row .st{flex:0 4 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:auto;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
|
|
977
978
|
.ws-row .chev{flex:none;margin-left:auto;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
|
|
978
979
|
.ws-row .ws-step{flex:none}
|
|
979
980
|
.ws-row .tk,.ws-row .c{flex:none;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint);font-variant-numeric:tabular-nums}
|
|
@@ -981,7 +982,8 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
|
|
|
981
982
|
.ws-row.fresh{background:var(--accent-tint)}
|
|
982
983
|
.ws-row.fresh .k{color:var(--accent)}
|
|
983
984
|
.ws-row.muted{opacity:.6}
|
|
984
|
-
/* Steering (drop / undrop): a row's actions on hover and keyboard focus (always where there is no hover); a pending op's row dashed warn with its pill, the footer counting them
|
|
985
|
+
/* Steering (drop / undrop, pin / unpin): a row's actions on hover and keyboard focus (always where there is no hover); a pending op's row dashed warn with its pill, the footer counting them; a pinned row's 📌. */
|
|
986
|
+
.ws-pin{flex:none;font-size:10px;line-height:1}
|
|
985
987
|
.ws-act{flex:none;display:inline-flex;gap:4px;opacity:0}
|
|
986
988
|
.ws-row:hover .ws-act,.ws-row:focus-within .ws-act,.ws-ghead:hover .ws-act,.ws-ghead:focus-within .ws-act,.ws-row.pending .ws-act{opacity:1}
|
|
987
989
|
@media (hover:none){.ws-act{opacity:1}}
|
|
@@ -992,9 +994,14 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
|
|
|
992
994
|
.ws-ghead>.ws-group-head{flex:1;min-width:0}
|
|
993
995
|
.ws-row.pending{border:1px dashed var(--warn-border);background:var(--warn-bg)}
|
|
994
996
|
.ws-row.pending:hover{background:var(--warn-bg)}
|
|
997
|
+
/* A pending row: its pill and undo take the tokens' and the step tag's room, so the name stays readable. */
|
|
998
|
+
.ws-row.pending .tk,.ws-row.pending .ws-step{display:none}
|
|
995
999
|
.ws-pend{flex:none;padding:0 6px;border:1px solid var(--warn-border);border-radius:999px;background:var(--warn-bg);color:var(--warn-text);font-family:ui-sans-serif,system-ui,sans-serif;font-size:10.5px;line-height:15px;white-space:nowrap}
|
|
996
|
-
.ws-pending{margin-top:8px;padding:4px 8px;border:1px dashed var(--warn-border);border-radius:6px;background:var(--warn-bg);color:var(--warn-text);font-size:11.5px;line-height:20px}
|
|
997
|
-
.ws-pending .ws-act-b{color:var(--warn-text);border-color:var(--warn-border)}
|
|
1000
|
+
.ws-pending{display:flex;align-items:center;gap:6px;margin-top:8px;padding:4px 8px;border:1px dashed var(--warn-border);border-radius:6px;background:var(--warn-bg);color:var(--warn-text);font-size:11.5px;line-height:20px}
|
|
1001
|
+
.ws-pending .ws-act-b{flex:none;margin-left:auto;white-space:nowrap;color:var(--warn-text);border-color:var(--warn-border)}
|
|
1002
|
+
.ws-pending-t{min-width:0}
|
|
1003
|
+
/* A phone's sheet: one line, the rows' pills say when. */
|
|
1004
|
+
.ws-popover.sheet .ws-pending-when{display:none}
|
|
998
1005
|
.ws-popover.sheet .ws-row .ws-act{display:none}
|
|
999
1006
|
.ws-strip{display:flex;gap:8px;padding:2px 6px 6px 26px}
|
|
1000
1007
|
.ws-strip .ws-act-b{padding:0 14px;font-size:12.5px;line-height:28px;border-radius:6px}
|
|
@@ -1004,6 +1011,15 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
|
|
|
1004
1011
|
.ws-popover.sheet .ctx-pop-head>.ctx-pop-kind{order:3;flex:1 0 calc(100% - 30px)}
|
|
1005
1012
|
.ws-popover.sheet .ctx-pop-head>.ws-ring{order:4}
|
|
1006
1013
|
.ws-popover.sheet .ws-row{height:30px;line-height:30px}
|
|
1014
|
+
/* + add: the list's foot (a sheet's last row), one KEY typed; an added item the set doesn't hold yet is a pending row of its own. */
|
|
1015
|
+
.ws-add{display:flex;align-items:center;gap:6px;margin-top:8px}
|
|
1016
|
+
.ws-add-in{flex:1;min-width:0;height:26px;box-sizing:border-box;padding:0 8px;border:1px dashed var(--border-strong);border-radius:6px;background:transparent;color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
|
|
1017
|
+
.ws-add-in::placeholder{color:var(--text-faint)}
|
|
1018
|
+
.ws-add-in:focus{outline:none;border-style:solid;border-color:var(--accent)}
|
|
1019
|
+
.ws-add .ws-act-b{line-height:22px;padding:0 10px}
|
|
1020
|
+
.ws-row.added .k{color:var(--warn-text);font-weight:600}
|
|
1021
|
+
.ws-popover.sheet .ws-add-in{height:34px;font-size:16px}
|
|
1022
|
+
.ws-popover.sheet .ws-add .ws-act-b{line-height:32px;padding:0 14px;font-size:12.5px;border-radius:6px}
|
|
1007
1023
|
/* The session bar's chip, in the mem chip's place: a dot (the accent: changed since its list was last opened) and the count. */
|
|
1008
1024
|
#infoBar .meta .ws-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border:1px solid transparent;cursor:pointer;font-family:inherit;font-weight:400;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
1009
1025
|
#infoBar .meta .ws-chip:hover:not(:disabled){filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
@@ -183,7 +183,12 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
|
|
|
183
183
|
reveal,
|
|
184
184
|
// A new turn, or none: no height held yet.
|
|
185
185
|
resetHeld: () => { held = null; },
|
|
186
|
-
// The tallest content seen
|
|
187
|
-
|
|
186
|
+
// The tallest content seen and the height held for it are forgotten (a
|
|
187
|
+
// new turn, the cloud closed by hand): the frame refits to what shows now.
|
|
188
|
+
forgetContent: () => {
|
|
189
|
+
maxContent = 0;
|
|
190
|
+
held = null;
|
|
191
|
+
fitFrame();
|
|
192
|
+
},
|
|
188
193
|
};
|
|
189
194
|
}
|
|
@@ -341,7 +341,7 @@ export function createStageView({
|
|
|
341
341
|
if (rail) {
|
|
342
342
|
const { items, ordinal } = workingSet.liveItems(turn, promptWords());
|
|
343
343
|
stampRows(blockEl, turn, ordinal);
|
|
344
|
-
rail.draw(items, { ctxPct: workingSet.ctxPct() });
|
|
344
|
+
rail.draw(items, { ctxPct: workingSet.ctxPct(), pending: workingSet.pending() });
|
|
345
345
|
}
|
|
346
346
|
const calls = slots.ticks.length;
|
|
347
347
|
// An ended turn says each thing once: its steps in the chip (the label
|
|
@@ -437,7 +437,8 @@ export function createStageView({
|
|
|
437
437
|
function setCloud(open) {
|
|
438
438
|
cloudOpen = open;
|
|
439
439
|
cloudBody.hidden = !open || cloud.hidden;
|
|
440
|
-
// Closed by hand: its room isn't held
|
|
440
|
+
// Closed by hand: its room isn't held (the stage shrinks back to what
|
|
441
|
+
// shows).
|
|
441
442
|
if (!open) frame.forgetContent();
|
|
442
443
|
cloud.classList.toggle("open", open);
|
|
443
444
|
stage.classList.toggle("cloud-open", open);
|
|
@@ -244,6 +244,8 @@ export function createStreamHandlers(deps) {
|
|
|
244
244
|
state().llmContext = data.llm_context;
|
|
245
245
|
updateInfoBar();
|
|
246
246
|
llmContextControl.refresh();
|
|
247
|
+
// The working set's list offers pin only under forget.
|
|
248
|
+
workingSet?.strategyChanged();
|
|
247
249
|
}
|
|
248
250
|
// /thinking (or /model) changed the level the next turn runs at.
|
|
249
251
|
if (data.thinking) {
|
|
@@ -315,7 +317,7 @@ export function createStreamHandlers(deps) {
|
|
|
315
317
|
},
|
|
316
318
|
// The server's working set (WorkingSet), at a request and at turn end;
|
|
317
319
|
// ignored with web.working_set off.
|
|
318
|
-
working_set_updated: (data) => { workingSet?.update({ items: data.items, at: data.at }); },
|
|
320
|
+
working_set_updated: (data) => { workingSet?.update({ items: data.items, at: data.at, ...(data.adds ? { adds: data.adds } : {}) }); },
|
|
319
321
|
context_status: (data) => {
|
|
320
322
|
const current = state();
|
|
321
323
|
// After /model (Engine#announce_switched_context!): the new model's
|
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
// the set is append-only, which keeps the fold stable. state is "live" from
|
|
16
16
|
// the rows; the server's projection (GET working_set, :working_set_updated:
|
|
17
17
|
// WorkingSet) overlays its state (live, stale, forgotten, dropped) and
|
|
18
|
-
// tokens by key (overlay), never its at.
|
|
19
|
-
// `/working-set drop|undrop KEY…`
|
|
20
|
-
//
|
|
18
|
+
// tokens (and whether the user pinned it) by key (overlay), never its at.
|
|
19
|
+
// The user steers it with `/working-set drop|undrop|pin|unpin|add|unadd KEY…`
|
|
20
|
+
// lines (steerLine, addLine); one queued for after the running turn is
|
|
21
|
+
// pending (pendingSteers) until its command_ran, and an add stays pending
|
|
22
|
+
// until the next turn sends its note (the server's adds). Pins show only
|
|
23
|
+
// under a strategy with the forget layer (forgetOn).
|
|
21
24
|
|
|
22
25
|
export const FLASH_MS = 4000;
|
|
23
26
|
// Labels per form: the rail's four lines, the row's and the footprint's
|
|
@@ -217,9 +220,11 @@ function serverItem(index, item) {
|
|
|
217
220
|
return index.urls.find((it) => String(it.key).startsWith(head)) || null;
|
|
218
221
|
}
|
|
219
222
|
|
|
220
|
-
// +items+ with the server's state
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
+
// +items+ with the server's state, tokens, pinned and hasDrop (its
|
|
224
|
+
// has_drop: some output holds the user's drop) (+server+: a snapshot
|
|
225
|
+
// {items, at}, or null); an item it doesn't know stays live, without
|
|
226
|
+
// tokens, unpinned. New objects only where something changed; never
|
|
227
|
+
// touches at, ops or count.
|
|
223
228
|
export function overlay(items, server) {
|
|
224
229
|
if (!server) return items;
|
|
225
230
|
const index = serverIndex(server);
|
|
@@ -229,8 +234,14 @@ export function overlay(items, server) {
|
|
|
229
234
|
const tokens = found && Number.isFinite(found.tokens) ? found.tokens : undefined;
|
|
230
235
|
// A cut URL row's full key, the one a steer line names.
|
|
231
236
|
const serverKey = found && found.key !== item.key ? found.key : undefined;
|
|
232
|
-
|
|
237
|
+
const pinned = found?.pinned === true ? true : undefined;
|
|
238
|
+
const hasDrop = found?.has_drop === true ? true : undefined;
|
|
239
|
+
if (item.state === state && item.tokens === tokens && item.serverKey === serverKey && item.pinned === pinned && item.hasDrop === hasDrop) return item;
|
|
233
240
|
const next = { ...item, state };
|
|
241
|
+
if (pinned) next.pinned = true;
|
|
242
|
+
else delete next.pinned;
|
|
243
|
+
if (hasDrop) next.hasDrop = true;
|
|
244
|
+
else delete next.hasDrop;
|
|
234
245
|
if (tokens === undefined) delete next.tokens;
|
|
235
246
|
else next.tokens = tokens;
|
|
236
247
|
if (serverKey === undefined) delete next.serverKey;
|
|
@@ -544,26 +555,33 @@ export function nextSettle(seenTimes, now, ms = FLASH_MS) {
|
|
|
544
555
|
return next;
|
|
545
556
|
}
|
|
546
557
|
|
|
547
|
-
// ── steering (drop / undrop)
|
|
558
|
+
// ── steering (drop / undrop, pin / unpin) ─────────────────────────────────
|
|
548
559
|
|
|
549
560
|
export const STEER_COMMAND = "/working-set";
|
|
550
|
-
const STEER_LINE = /^\/working-set\s+(drop|undrop)\s+(\S.*)$/;
|
|
561
|
+
const STEER_LINE = /^\/working-set\s+(drop|undrop|pin|unpin|add|unadd)\s+(\S.*)$/;
|
|
551
562
|
const URL_WORD = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
552
|
-
export const INVERSE = Object.freeze({ drop: "undrop", undrop: "drop" });
|
|
563
|
+
export const INVERSE = Object.freeze({ drop: "undrop", undrop: "drop", pin: "unpin", unpin: "pin", add: "unadd", unadd: "add" });
|
|
564
|
+
export const STEER_OPS = Object.freeze(Object.keys(INVERSE));
|
|
553
565
|
|
|
554
566
|
// The word a steer line names +item+ by (WorkingSetSteer.target): a file's
|
|
555
|
-
//
|
|
556
|
-
|
|
567
|
+
// path, relative to +dir+ (the session's folder, which the worker resolves
|
|
568
|
+
// it against) when inside it, else absolute; memory:NAME; a URL (the
|
|
569
|
+
// server's full one for a cut row).
|
|
570
|
+
export function steerWord(item, dir = "") {
|
|
557
571
|
if (item.kind === "memory") return `memory:${item.key}`;
|
|
558
572
|
if (item.kind === "url") return item.serverKey || item.key;
|
|
559
|
-
|
|
573
|
+
const base = String(dir || "").replace(/\/+$/, "");
|
|
574
|
+
return base && item.key.startsWith(`${base}/`) ? item.key.slice(base.length + 1) : item.key;
|
|
560
575
|
}
|
|
561
576
|
|
|
562
|
-
// The id the page keys an item by ("file:/abs/a.rb"), from a steer word
|
|
563
|
-
|
|
577
|
+
// The id the page keys an item by ("file:/abs/a.rb"), from a steer word; a
|
|
578
|
+
// relative path is +dir+'s.
|
|
579
|
+
function wordId(word, dir = "") {
|
|
564
580
|
if (word.startsWith("memory:")) return `memory:${word.slice("memory:".length)}`;
|
|
565
581
|
if (URL_WORD.test(word)) return `url:${word}`;
|
|
566
|
-
|
|
582
|
+
const base = String(dir || "").replace(/\/+$/, "");
|
|
583
|
+
if (!base || word.startsWith("/")) return `file:${word}`;
|
|
584
|
+
return `file:${base}/${word.replace(/^(\.\/)+/, "")}`;
|
|
567
585
|
}
|
|
568
586
|
|
|
569
587
|
// An item's id as steer lines name it.
|
|
@@ -571,27 +589,62 @@ export function steerId(item) {
|
|
|
571
589
|
return wordId(steerWord(item));
|
|
572
590
|
}
|
|
573
591
|
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
592
|
+
// A line's words as the worker reads them (WorkingSetSteer.words):
|
|
593
|
+
// whitespace-separated, a quoted one ("…" or '…') keeps its spaces, a
|
|
594
|
+
// backslash escapes its quote or itself; an unclosed quote runs to the end.
|
|
595
|
+
const WORD = /\s*(?:"((?:[^"\\]|\\.)*)"?|'((?:[^'\\]|\\.)*)'?|(\S+))/ys;
|
|
596
|
+
export function steerWords(text) {
|
|
597
|
+
const words = [];
|
|
598
|
+
const source = String(text || "");
|
|
599
|
+
WORD.lastIndex = 0;
|
|
600
|
+
let m;
|
|
601
|
+
while (WORD.lastIndex < source.length && (m = WORD.exec(source))) {
|
|
602
|
+
const quoted = m[1] ?? m[2];
|
|
603
|
+
const word = quoted === undefined ? m[3] : quoted.replace(/\\(["'\\])/g, "$1");
|
|
604
|
+
if (word) words.push(word);
|
|
605
|
+
}
|
|
606
|
+
return words;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// +word+ as a line names it (WorkingSetSteer.quote): quoted when it has
|
|
610
|
+
// whitespace or starts with a quote, else as it is.
|
|
611
|
+
export function quoteWord(word) {
|
|
612
|
+
return /\s|^["']/.test(word) ? `"${word.replace(/["\\]/g, "\\$&")}"` : word;
|
|
577
613
|
}
|
|
578
614
|
|
|
579
|
-
//
|
|
580
|
-
|
|
615
|
+
// The line that does +op+ to +items+ ("/working-set drop lib/a.rb memory:x"),
|
|
616
|
+
// a file inside +dir+ (the session's folder) by its relative path, a key
|
|
617
|
+
// with spaces quoted.
|
|
618
|
+
export function steerLine(op, items, dir = "") {
|
|
619
|
+
return `${STEER_COMMAND} ${op} ${items.map((item) => quoteWord(steerWord(item, dir))).join(" ")}`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// A steer line's op and ids ({op, ids}), or null for any other line; a
|
|
623
|
+
// relative path is +dir+'s.
|
|
624
|
+
export function parseSteerLine(line, dir = "") {
|
|
581
625
|
const m = STEER_LINE.exec(String(line || "").trim());
|
|
582
626
|
if (!m) return null;
|
|
583
|
-
return { op: m[1], ids: m[2]
|
|
627
|
+
return { op: m[1], ids: steerWords(m[2]).map((word) => wordId(word, dir)) };
|
|
584
628
|
}
|
|
585
629
|
|
|
586
630
|
// The ops queued for after the running turn (+lines+, in queue order), per
|
|
587
|
-
// item id: "drop"
|
|
588
|
-
// nothing (the worker runs both, the second
|
|
589
|
-
|
|
590
|
-
|
|
631
|
+
// item id: its last op ("drop", "undrop", "pin", "unpin", "add"). An op
|
|
632
|
+
// followed by its inverse nets to nothing (the worker runs both, the second
|
|
633
|
+
// undoing the first); a pin after a drop is the pin (it takes the pending
|
|
634
|
+
// drop off), a drop after a pin is refused (the pin stays). +adds+: the ids
|
|
635
|
+
// the server's snapshot says are added already (made between turns, its
|
|
636
|
+
// adds), pending before any line; an unadd with no add pending is refused.
|
|
637
|
+
// A relative path is +dir+'s (the session's folder).
|
|
638
|
+
export function pendingSteers(lines, dir = "", adds = []) {
|
|
639
|
+
const pending = new Map(adds.map((id) => [id, "add"]));
|
|
591
640
|
for (const line of lines) {
|
|
592
|
-
const parsed = parseSteerLine(line);
|
|
641
|
+
const parsed = parseSteerLine(line, dir);
|
|
593
642
|
if (!parsed) continue;
|
|
594
643
|
for (const id of parsed.ids) {
|
|
644
|
+
// A drop after a pending pin: the worker refuses it (unpin it first).
|
|
645
|
+
if (parsed.op === "drop" && pending.get(id) === "pin") continue;
|
|
646
|
+
// An unadd of nothing added: the worker refuses it.
|
|
647
|
+
if (parsed.op === "unadd" && pending.get(id) !== "add") continue;
|
|
595
648
|
if (pending.get(id) === INVERSE[parsed.op]) pending.delete(id);
|
|
596
649
|
else pending.set(id, parsed.op);
|
|
597
650
|
}
|
|
@@ -601,12 +654,90 @@ export function pendingSteers(lines) {
|
|
|
601
654
|
|
|
602
655
|
// Why +item+ can't be dropped (a title for its disabled button), "" when
|
|
603
656
|
// it can, null when it has no drop at all (muted, dropped already, or the
|
|
604
|
-
// server doesn't know it yet: a running call).
|
|
657
|
+
// server doesn't know it yet: a running call). A pinned one asks for an
|
|
658
|
+
// unpin first (WorkingSetSteer::Refused's words).
|
|
605
659
|
export function dropRefusal(item) {
|
|
606
660
|
if (item.muted || item.state === "dropped") return null;
|
|
607
661
|
if (item.preloaded && !item.ops.includes("read")) return "preloaded into the system prompt: not dropped from here";
|
|
608
662
|
if (!Number.isFinite(item.tokens)) return null;
|
|
663
|
+
if (item.pinned) return "pinned by the user: unpin it first";
|
|
609
664
|
if (!item.ops.includes("read")) return "only edited or written: nothing of it to drop";
|
|
610
665
|
if (item.state === "forgotten") return "forgotten: nothing of it is sent";
|
|
611
666
|
return "";
|
|
612
667
|
}
|
|
668
|
+
|
|
669
|
+
// Whether +item+ offers pin or unpin: one the server knows (its tokens), an
|
|
670
|
+
// edit-only one too (its later reads are covered), not a muted or dropped
|
|
671
|
+
// one, nor one holding a drop (undrop it first), nor a memory only preloaded.
|
|
672
|
+
export function pinnable(item) {
|
|
673
|
+
if (item.muted || item.state === "dropped" || item.hasDrop) return false;
|
|
674
|
+
if (item.preloaded && !item.ops.includes("read")) return false;
|
|
675
|
+
return Number.isFinite(item.tokens);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Whether the strategy an llm ctx summary names ({strategy: "stale,forget"},
|
|
679
|
+
// session_state's llmContext) has the forget layer: pins mean something
|
|
680
|
+
// only there.
|
|
681
|
+
export function forgetOn(llmContext) {
|
|
682
|
+
return String(llmContext?.strategy || "").split(/[\s,|]+/).includes("forget");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Whether +item+ offers undrop: dropped, or holding the user's drop on some
|
|
686
|
+
// of its outputs (hasDrop: the rest forgotten by the model's layers, or
|
|
687
|
+
// still sent).
|
|
688
|
+
export function undroppable(item) {
|
|
689
|
+
return item.state === "dropped" || item.hasDrop === true;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ── + add ─────────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
// The line that adds what the user typed (one KEY: a path, memory:NAME or a
|
|
695
|
+
// URL; quoted when it has spaces), or null for nothing typed.
|
|
696
|
+
export function addLine(text) {
|
|
697
|
+
const word = String(text || "").trim();
|
|
698
|
+
return word ? `${STEER_COMMAND} add ${quoteWord(word)}` : null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The ids (kind:key) of a server snapshot's adds (WorkingSet::Add: the
|
|
702
|
+
// adds the next turn's note asks the model to read).
|
|
703
|
+
export function serverAddIds(server) {
|
|
704
|
+
return (server?.adds || []).filter((a) => a && a.kind && a.key).map((a) => `${a.kind}:${a.key}`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// An item for an id ("file:/abs/a.rb", "memory:x", "url:https://…") that
|
|
708
|
+
// the set doesn't hold yet: a pending add's row (added), a file by its path
|
|
709
|
+
// relative to +dir+ when inside it; +label+ the server's name when it has one.
|
|
710
|
+
export function addedItem(id, dir = "", label = "") {
|
|
711
|
+
const at = id.indexOf(":");
|
|
712
|
+
const kind = id.slice(0, at);
|
|
713
|
+
const key = id.slice(at + 1);
|
|
714
|
+
const item = { kind, key, ops: [], at: null, count: 0, state: "live", added: true };
|
|
715
|
+
if (kind === "file") {
|
|
716
|
+
const base = String(dir || "").replace(/\/+$/, "");
|
|
717
|
+
item.path = label || (base && key.startsWith(`${base}/`) ? key.slice(base.length + 1) : key);
|
|
718
|
+
item.label = item.path;
|
|
719
|
+
} else if (kind === "url") {
|
|
720
|
+
item.url = key;
|
|
721
|
+
item.label = urlLabel(key);
|
|
722
|
+
} else {
|
|
723
|
+
item.label = key;
|
|
724
|
+
}
|
|
725
|
+
return item;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The pending adds of items +items+ doesn't hold, as rows (addedItem), in
|
|
729
|
+
// the order they were added; the server's labels where it has them.
|
|
730
|
+
export function addedItems(pending, items, server = null, dir = "") {
|
|
731
|
+
const held = new Set(items.map(steerId));
|
|
732
|
+
const labels = new Map((server?.adds || []).map((a) => [`${a.kind}:${a.key}`, a.label]));
|
|
733
|
+
return [...pending].filter(([id, op]) => op === "add" && !held.has(id)).map(([id]) => addedItem(id, dir, labels.get(id) || ""));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Whether +item+ offers refresh (an add of an item the set holds: the model
|
|
737
|
+
// reads it again): stale, or forgotten without a drop of the user's (undrop
|
|
738
|
+
// brings that back); one the server knows, not muted nor only preloaded.
|
|
739
|
+
export function refreshable(item) {
|
|
740
|
+
if (item.muted || item.added || (item.preloaded && !item.ops.includes("read"))) return false;
|
|
741
|
+
if (!Number.isFinite(item.tokens)) return false;
|
|
742
|
+
return item.state === "stale" || (item.state === "forgotten" && item.hasDrop !== true);
|
|
743
|
+
}
|
|
@@ -7,8 +7,31 @@
|
|
|
7
7
|
// so nothing restarts; a fold fades the absorbed lines out in place and the
|
|
8
8
|
// lines below slide up once (each line sits at its slot by a transform).
|
|
9
9
|
// Collapsed (kept per browser), it is its head line only, with the newest
|
|
10
|
-
// entry ticking through it.
|
|
11
|
-
|
|
10
|
+
// entry ticking through it. Read-only: steering is the expanded view's; a
|
|
11
|
+
// line with a pending op (a /working-set line queued for the turn's end) is
|
|
12
|
+
// dashed, its title saying what applies next turn; an item added that the
|
|
13
|
+
// turn's set doesn't hold is a dashed `+ name · next turn` line of its own.
|
|
14
|
+
|
|
15
|
+
// A pending op's words on a line's title.
|
|
16
|
+
const PENDING_WORDS = { drop: "drops", undrop: "comes back", pin: "pinned", unpin: "unpinned", add: "read again" };
|
|
17
|
+
|
|
18
|
+
// The pending ops of +label+'s members (+pending+: working_set.js
|
|
19
|
+
// pendingSteers' Map), as "drops next turn" words; "" when none.
|
|
20
|
+
export function pendingText(label, pending) {
|
|
21
|
+
if (!pending?.size) return "";
|
|
22
|
+
const ops = [...new Set(label.members.map((m) => pending.get(steerId(m))).filter(Boolean))];
|
|
23
|
+
return ops.length ? `${ops.map((op) => PENDING_WORDS[op]).join(", ")} next turn` : "";
|
|
24
|
+
}
|
|
25
|
+
import { addedItems, BUDGETS, ariaLabel, fold, GLYPHS, isFresh, labelTitle, nextSettle, shown, steerId } from "./working_set.js";
|
|
26
|
+
|
|
27
|
+
// The pending adds +items+ doesn't hold as lines: `+ name · next turn`.
|
|
28
|
+
export function addLabels(items, pending) {
|
|
29
|
+
if (!pending?.size) return [];
|
|
30
|
+
return addedItems(pending, items).map((item) => ({
|
|
31
|
+
id: `add:${steerId(item)}`, type: "leaf", kind: item.kind, glyph: "+", text: `${GLYPHS[item.kind] ? `${GLYPHS[item.kind]} ` : ""}${item.kind === "file" ? item.path.split("/").pop() : item.label} · next turn`,
|
|
32
|
+
n: 1, edited: 0, cut: 0, state: "live", members: [], added: true,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
12
35
|
import { readStored, writeStored } from "./storage.js";
|
|
13
36
|
import { reducedMotion } from "./stage_frame.js";
|
|
14
37
|
|
|
@@ -157,16 +180,17 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
|
|
|
157
180
|
|
|
158
181
|
// A state (the server's: stale faded, forgotten struck) is a class and a
|
|
159
182
|
// mark on the same line: it never refolds and never moves a line.
|
|
160
|
-
function fillLine(node, label, fresh) {
|
|
183
|
+
function fillLine(node, label, fresh, pend) {
|
|
161
184
|
const state = label.state && label.state !== "live" ? ` ${label.state}` : "";
|
|
162
|
-
node.className = `ws-item ${label.kind}${label.type !== "leaf" ? " group" : ""}${fresh ? " fresh" : ""}${state}`;
|
|
185
|
+
node.className = `ws-item ${label.kind}${label.type !== "leaf" ? " group" : ""}${fresh ? " fresh" : ""}${state}${pend ? " pending" : ""}`;
|
|
163
186
|
const [k, t, n, e, c] = node.children;
|
|
164
187
|
k.textContent = label.glyph || "";
|
|
165
188
|
t.textContent = label.text;
|
|
166
189
|
n.textContent = label.count ? `· ${label.count}` : "";
|
|
167
190
|
e.textContent = label.edited ? (label.type === "leaf" ? "✎" : `✎${label.edited}`) : "";
|
|
168
191
|
c.textContent = label.type !== "leaf" && label.cut ? `✂${label.cut}` : "";
|
|
169
|
-
node.title =
|
|
192
|
+
if (label.added) node.title = "added: the model reads it at the next turn's start";
|
|
193
|
+
else node.title = pend ? `${labelTitle(label)}\n${pend}` : labelTitle(label);
|
|
170
194
|
}
|
|
171
195
|
|
|
172
196
|
// A line at its slot. A new line is placed before it is in the page (no
|
|
@@ -185,8 +209,9 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
|
|
|
185
209
|
}
|
|
186
210
|
|
|
187
211
|
// Draws +items+ (the running turn's, working_set.js itemsOfTurn); +ctxPct+
|
|
188
|
-
// the ring's value (null: no ring yet)
|
|
189
|
-
|
|
212
|
+
// the ring's value (null: no ring yet); +pending+ the queued steer ops
|
|
213
|
+
// (working_set.js pendingSteers).
|
|
214
|
+
function draw(items, { ctxPct = null, now = Date.now(), pending = null } = {}) {
|
|
190
215
|
// A turn joined mid-way (a reload, another tab): what is there already
|
|
191
216
|
// is not new.
|
|
192
217
|
for (const item of items) {
|
|
@@ -194,7 +219,8 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
|
|
|
194
219
|
if (!seen.has(id)) seen.set(id, first ? -Infinity : now);
|
|
195
220
|
}
|
|
196
221
|
first = false;
|
|
197
|
-
const
|
|
222
|
+
const adds = addLabels(items, pending);
|
|
223
|
+
const budget = Math.max(1, (form() === "rail" ? BUDGETS.rail : BUDGETS.row) - adds.length);
|
|
198
224
|
const folded = fold(items, budget, { kept });
|
|
199
225
|
kept = folded.groups;
|
|
200
226
|
const view = shown(folded.labels, budget);
|
|
@@ -222,8 +248,9 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
|
|
|
222
248
|
|
|
223
249
|
// The lines, by key: an unchanged one is left alone.
|
|
224
250
|
const animate = !reducedMotion();
|
|
225
|
-
const lines = view.labels.map((label) => ({ id: label.id, label, fresh: freshOf(label) }));
|
|
251
|
+
const lines = view.labels.map((label) => ({ id: label.id, label, fresh: freshOf(label), pend: pendingText(label, pending) }));
|
|
226
252
|
if (view.more) lines.push({ id: "more", more: view.more });
|
|
253
|
+
for (const label of adds) lines.push({ id: label.id, label, fresh: false, pend: "added next turn" });
|
|
227
254
|
const ids = new Set(lines.map((l) => l.id));
|
|
228
255
|
for (const [id, entry] of nodes) {
|
|
229
256
|
if (ids.has(id)) continue;
|
|
@@ -235,7 +262,7 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
|
|
|
235
262
|
}
|
|
236
263
|
lines.forEach((line, i) => {
|
|
237
264
|
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]);
|
|
265
|
+
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, line.pend]);
|
|
239
266
|
if (!entry) {
|
|
240
267
|
const node = line.more ? el("div", "ws-more") : lineNode(line.label);
|
|
241
268
|
entry = { node, key: null, slot: -1 };
|
|
@@ -247,14 +274,14 @@ export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
|
|
|
247
274
|
if (entry.key !== key) {
|
|
248
275
|
entry.key = key;
|
|
249
276
|
if (line.more) entry.node.textContent = `+ ${line.more} more`;
|
|
250
|
-
else fillLine(entry.node, line.label, line.fresh);
|
|
277
|
+
else fillLine(entry.node, line.label, line.fresh, line.pend);
|
|
251
278
|
}
|
|
252
279
|
if (entry.slot !== i) {
|
|
253
280
|
place(entry.node, i);
|
|
254
281
|
entry.slot = i;
|
|
255
282
|
}
|
|
256
283
|
});
|
|
257
|
-
rail.classList.toggle("empty", !items.length);
|
|
284
|
+
rail.classList.toggle("empty", !items.length && !adds.length);
|
|
258
285
|
scheduleSettle(now);
|
|
259
286
|
}
|
|
260
287
|
|