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
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -670,13 +670,13 @@ module Samagotchi
|
|
|
670
670
|
thinking, error = request_thinking(body["thinking"])
|
|
671
671
|
return error if error
|
|
672
672
|
|
|
673
|
-
#
|
|
674
|
-
# {
|
|
675
|
-
#
|
|
673
|
+
# settings: the session's own settings, flat as chi --set names
|
|
674
|
+
# them ({"recap.enabled" => false, "cost-cap.cap" => 3}), checked
|
|
675
|
+
# the same way (SessionConfig.from_request).
|
|
676
676
|
begin
|
|
677
|
-
|
|
678
|
-
rescue
|
|
679
|
-
return error_response(400, "
|
|
677
|
+
settings = SessionConfig.from_request(body["settings"])
|
|
678
|
+
rescue SessionConfig::Error => e
|
|
679
|
+
return error_response(400, "invalid_settings", e.message)
|
|
680
680
|
end
|
|
681
681
|
|
|
682
682
|
folder = dir ? { working_directory: dir } : {}
|
|
@@ -689,7 +689,7 @@ module Samagotchi
|
|
|
689
689
|
# until that turn is saved.
|
|
690
690
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
691
691
|
folder[:preview] = preview unless preview.empty?
|
|
692
|
-
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking,
|
|
692
|
+
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings)
|
|
693
693
|
folder[:setup] = setup unless setup.empty?
|
|
694
694
|
begin
|
|
695
695
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
@@ -709,7 +709,7 @@ module Samagotchi
|
|
|
709
709
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
710
710
|
# link of that session's chain (SessionManager.continue_session), in its
|
|
711
711
|
# folder and with its own setup (SessionSetup: llm_context, thinking
|
|
712
|
-
# and reasoning_history levels,
|
|
712
|
+
# and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
|
|
713
713
|
# refused beside it (a create takes no reasoning_history). It runs on
|
|
714
714
|
# model (or model_name) when given, else on the previous link's: one
|
|
715
715
|
# whose host is gone falls back to the default, and the 201's
|
|
@@ -724,9 +724,9 @@ module Samagotchi
|
|
|
724
724
|
unless given.is_a?(String) && !given.strip.empty?
|
|
725
725
|
return error_response(400, "invalid_continues", "continues is a session id")
|
|
726
726
|
end
|
|
727
|
-
if (fields = %w[dir llm_context thinking
|
|
727
|
+
if (fields = %w[dir llm_context thinking settings].select { |key| body.key?(key) }).any?
|
|
728
728
|
return error_response(400, "invalid_continues",
|
|
729
|
-
"continues takes the previous session's folder, llm_context, thinking and
|
|
729
|
+
"continues takes the previous session's folder, llm_context, thinking and settings; " \
|
|
730
730
|
"leave out #{fields.join(", ")}")
|
|
731
731
|
end
|
|
732
732
|
|
|
@@ -1486,15 +1486,24 @@ module Samagotchi
|
|
|
1486
1486
|
# ── The working set (web.working_set) ───────────────────────────────
|
|
1487
1487
|
|
|
1488
1488
|
# GET /api/sessions/:id/working_set: the working set as the model has
|
|
1489
|
-
# it ({items:, at:}, WorkingSet), what the page overlays on its rows'
|
|
1489
|
+
# it ({items:, at:, running:}, WorkingSet), what the page overlays on its rows'
|
|
1490
1490
|
# set when it seeds a session's and when the expanded view opens (a
|
|
1491
1491
|
# file changed while the session sat idle). A live worker's snapshot
|
|
1492
1492
|
# messages are the truth, as #handle_show's, else the file's; the
|
|
1493
1493
|
# strategy is the session's llm ctx chip's, the folder its working
|
|
1494
|
-
# directory.
|
|
1494
|
+
# directory. running: a turn runs. The snapshot's messages lag it, so
|
|
1495
|
+
# the answer is then the turn's last :working_set_updated (the
|
|
1496
|
+
# snapshot's working_set, TurnAccumulator#working_set); before the
|
|
1497
|
+
# turn emitted one, or from an older worker without the field, it is
|
|
1498
|
+
# the session's messages, behind that turn's events, and the page
|
|
1499
|
+
# keeps theirs (working_set.js newerSnapshot).
|
|
1495
1500
|
def handle_working_set(_req, id)
|
|
1496
1501
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1497
1502
|
live = bridge_get_json(id, "snapshot")
|
|
1503
|
+
running = !live&.dig("snapshot", "current_turn").nil?
|
|
1504
|
+
turn_set = running && running_working_set(live["snapshot"]["working_set"])
|
|
1505
|
+
return json_response(200, turn_set.merge(running: true)) if turn_set
|
|
1506
|
+
|
|
1498
1507
|
messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
|
|
1499
1508
|
# A snapshot's entries have string keys (paired_runs reads symbols).
|
|
1500
1509
|
messages = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
|
|
@@ -1502,7 +1511,15 @@ module Samagotchi
|
|
|
1502
1511
|
SessionSetting::LLM_CONTEXT.saved_summary(session, registry: host_registry)
|
|
1503
1512
|
end
|
|
1504
1513
|
snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context))
|
|
1505
|
-
json_response(200, snapshot.to_h)
|
|
1514
|
+
json_response(200, snapshot.to_h.merge(running: running))
|
|
1515
|
+
end
|
|
1516
|
+
|
|
1517
|
+
# The running turn's set from a worker's snapshot ({"items", "at"}), as
|
|
1518
|
+
# {items:, at:}; nil when it has none (none emitted yet, an older worker).
|
|
1519
|
+
def running_working_set(set)
|
|
1520
|
+
return nil unless set.is_a?(Hash) && set["items"].is_a?(Array) && set["at"].is_a?(Integer)
|
|
1521
|
+
|
|
1522
|
+
{ items: set["items"], at: set["at"] }
|
|
1506
1523
|
end
|
|
1507
1524
|
|
|
1508
1525
|
# The strategy an llm ctx chip names ("none", "stale,forget"), as
|
|
@@ -236,6 +236,8 @@ const workingSet = view === "stage" && document.body.dataset.workingSet === "1"
|
|
|
236
236
|
? createWorkingSet({
|
|
237
237
|
historyEl, stageRoot: () => turnDom.root, state: () => current, session: () => selected, infoEl: infoTextEl,
|
|
238
238
|
onChange: () => updateInfoBar(), getWorkingSet,
|
|
239
|
+
// A drop or undrop from the list: the session's command, as typed.
|
|
240
|
+
sendLine: (line) => (selected ? sendCommand(selected, line, { clientId }) : null),
|
|
239
241
|
})
|
|
240
242
|
: null;
|
|
241
243
|
document.body.classList.toggle("working-set", !!workingSet);
|
|
@@ -109,13 +109,19 @@ const WEB_LOCAL_REPLIES = {
|
|
|
109
109
|
"/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
|
|
110
110
|
"/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
|
|
111
111
|
"/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
|
|
112
|
-
"/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
|
|
112
|
+
"/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now. /recap on|off|default sets this session's recap here too",
|
|
113
113
|
"/unqueue": "/unqueue: a terminal's command; here the × on a message queued for after the turn removes it",
|
|
114
114
|
"/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
|
|
115
115
|
};
|
|
116
116
|
|
|
117
|
+
// The ones whose words after the name are the worker's (/recap off): the
|
|
118
|
+
// page answers only the bare name.
|
|
119
|
+
const WORKER_WITH_WORDS = new Set(["/recap"]);
|
|
120
|
+
|
|
117
121
|
export function webLocalReply(text) {
|
|
118
|
-
const
|
|
122
|
+
const words = String(text || "").trim().split(/\s+/);
|
|
123
|
+
const name = words[0].toLowerCase();
|
|
124
|
+
if (WORKER_WITH_WORDS.has(name) && words.length > 1) return null;
|
|
119
125
|
return WEB_LOCAL_REPLIES[name] || null;
|
|
120
126
|
}
|
|
121
127
|
|
|
@@ -298,8 +298,9 @@ export function openEvents(handlers = {}, opts = {}) {
|
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
// A session's working set as the model has it (WorkingSet: {items, at
|
|
302
|
-
// what the stage view's working set overlays on its rows
|
|
301
|
+
// A session's working set as the model has it (WorkingSet: {items, at,
|
|
302
|
+
// running}), what the stage view's working set overlays on its rows
|
|
303
|
+
// (web.working_set).
|
|
303
304
|
export function getWorkingSet(id, opts = {}) {
|
|
304
305
|
return api(`/api/sessions/${encodeURIComponent(id)}/working_set`, opts);
|
|
305
306
|
}
|
|
@@ -905,9 +905,10 @@ body.working-set .ts-live{position:relative;padding-right:264px}
|
|
|
905
905
|
/* The server's states (WorkingSet): stale faded; forgotten struck, faint and faded (a group only when all its members are). The words are the popover's and the titles' (colour is never alone). */
|
|
906
906
|
.ws-item.stale{opacity:.55}
|
|
907
907
|
.ws-item.forgotten{opacity:.5}
|
|
908
|
-
.ws-item.forgotten
|
|
908
|
+
.ws-item.forgotten,.ws-item.dropped{opacity:.5}
|
|
909
|
+
.ws-item.forgotten .t,.ws-row.forgotten .t,.ws-foot .l.forgotten,.ws-item.dropped .t,.ws-row.dropped .t,.ws-foot .l.dropped{text-decoration:line-through;color:var(--text-faint)}
|
|
909
910
|
.ws-row.stale .k,.ws-row.stale .t,.ws-foot .l.stale{opacity:.55}
|
|
910
|
-
.ws-row.forgotten .k,.ws-row.forgotten .t,.ws-foot .l.forgotten{opacity:.5}
|
|
911
|
+
.ws-row.forgotten .k,.ws-row.forgotten .t,.ws-foot .l.forgotten,.ws-row.dropped .k,.ws-row.dropped .t,.ws-foot .l.dropped{opacity:.5}
|
|
911
912
|
.ws-item.enter{animation:ws-in .2s ease}
|
|
912
913
|
@keyframes ws-in{from{opacity:0}}
|
|
913
914
|
.ws-item.leave,.ws-more.leave{opacity:0;pointer-events:none}
|
|
@@ -980,6 +981,23 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
|
|
|
980
981
|
.ws-row.fresh{background:var(--accent-tint)}
|
|
981
982
|
.ws-row.fresh .k{color:var(--accent)}
|
|
982
983
|
.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
|
+
.ws-act{flex:none;display:inline-flex;gap:4px;opacity:0}
|
|
986
|
+
.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
|
+
@media (hover:none){.ws-act{opacity:1}}
|
|
988
|
+
.ws-act-b{flex:none;padding:0 6px;border:1px solid var(--border-strong);border-radius:4px;background:transparent;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;font-weight:400;line-height:16px;color:var(--text-muted);cursor:pointer}
|
|
989
|
+
.ws-act-b:hover:not(:disabled){filter:none;color:var(--accent);border-color:var(--accent);background:var(--accent-bg)}
|
|
990
|
+
.ws-act-b:disabled{opacity:.5;cursor:default}
|
|
991
|
+
.ws-ghead{display:flex;align-items:center;gap:4px}
|
|
992
|
+
.ws-ghead>.ws-group-head{flex:1;min-width:0}
|
|
993
|
+
.ws-row.pending{border:1px dashed var(--warn-border);background:var(--warn-bg)}
|
|
994
|
+
.ws-row.pending:hover{background:var(--warn-bg)}
|
|
995
|
+
.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)}
|
|
998
|
+
.ws-popover.sheet .ws-row .ws-act{display:none}
|
|
999
|
+
.ws-strip{display:flex;gap:8px;padding:2px 6px 6px 26px}
|
|
1000
|
+
.ws-strip .ws-act-b{padding:0 14px;font-size:12.5px;line-height:28px;border-radius:6px}
|
|
983
1001
|
.ws-popover.sheet{border-radius:14px}
|
|
984
1002
|
/* A phone's sheet: the name, the scope and × on the first line, the count and the ring under them. */
|
|
985
1003
|
.ws-popover.sheet .ctx-pop-close{order:2}
|
|
@@ -228,6 +228,8 @@ export function createStreamHandlers(deps) {
|
|
|
228
228
|
// the conversation (!rollback, !cmd, a continue answered no) re-reads it.
|
|
229
229
|
command_ran: async (data) => {
|
|
230
230
|
const id = selected();
|
|
231
|
+
// A queued /working-set line is no longer pending (the list's pill).
|
|
232
|
+
workingSet?.commandRan(data);
|
|
231
233
|
const view = commandView(data, clientId);
|
|
232
234
|
if (view.modelName && view.modelName !== state().model) {
|
|
233
235
|
state().model = view.modelName;
|
|
@@ -290,6 +292,8 @@ export function createStreamHandlers(deps) {
|
|
|
290
292
|
// turn: its bubble goes here, so the cards it shows come after it. One
|
|
291
293
|
// queued for after the turn (/model X mid-turn) shows waiting here.
|
|
292
294
|
command_queued: (data) => {
|
|
295
|
+
// A /working-set line queued for after the turn: pending in the list.
|
|
296
|
+
workingSet?.commandQueued(data);
|
|
293
297
|
// A card's action waits for its command_ran: shown only if it answers.
|
|
294
298
|
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
295
299
|
},
|
|
@@ -60,6 +60,11 @@ export function llmEditLine(edit) {
|
|
|
60
60
|
const tokens = Number(edit.tokens) > 0 ? ` · ~${compactTokens(Number(edit.tokens))}` : "";
|
|
61
61
|
return `✂ stubbed: ${edit.note || "stale"}${tokens}`;
|
|
62
62
|
}
|
|
63
|
+
// The user's drop (/working-set drop, the working set's list).
|
|
64
|
+
if (edit.kind === "drop") {
|
|
65
|
+
const tokens = Number(edit.tokens) > 0 ? ` · ~${compactTokens(Number(edit.tokens))}` : "";
|
|
66
|
+
return `✂ dropped by the user${tokens}`;
|
|
67
|
+
}
|
|
63
68
|
return "";
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
// touch unions its ops and bumps its count, never its at: within a turn
|
|
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
|
-
// WorkingSet) overlays its state (live, stale, forgotten) and
|
|
18
|
-
// (overlay), never its at.
|
|
17
|
+
// WorkingSet) overlays its state (live, stale, forgotten, dropped) and
|
|
18
|
+
// tokens by key (overlay), never its at. The user steers it with
|
|
19
|
+
// `/working-set drop|undrop KEY…` lines (steerLine); one queued for after the
|
|
20
|
+
// running turn is pending (pendingSteers) until its command_ran.
|
|
19
21
|
|
|
20
22
|
export const FLASH_MS = 4000;
|
|
21
23
|
// Labels per form: the rail's four lines, the row's and the footprint's
|
|
@@ -186,7 +188,7 @@ export function sessionItems({ turns = [], live = [], preloaded = [], muted = []
|
|
|
186
188
|
|
|
187
189
|
// ── the server's truth ────────────────────────────────────────────────────
|
|
188
190
|
|
|
189
|
-
export const STATES = Object.freeze(["live", "stale", "forgotten"]);
|
|
191
|
+
export const STATES = Object.freeze(["live", "stale", "forgotten", "dropped"]);
|
|
190
192
|
const ELLIPSIS = "\u2026";
|
|
191
193
|
|
|
192
194
|
// The server's items by "kind:key", and its URLs (a cut row key matches one
|
|
@@ -225,21 +227,31 @@ export function overlay(items, server) {
|
|
|
225
227
|
const found = serverItem(index, item);
|
|
226
228
|
const state = found ? found.state : "live";
|
|
227
229
|
const tokens = found && Number.isFinite(found.tokens) ? found.tokens : undefined;
|
|
228
|
-
|
|
230
|
+
// A cut URL row's full key, the one a steer line names.
|
|
231
|
+
const serverKey = found && found.key !== item.key ? found.key : undefined;
|
|
232
|
+
if (item.state === state && item.tokens === tokens && item.serverKey === serverKey) return item;
|
|
229
233
|
const next = { ...item, state };
|
|
230
234
|
if (tokens === undefined) delete next.tokens;
|
|
231
235
|
else next.tokens = tokens;
|
|
236
|
+
if (serverKey === undefined) delete next.serverKey;
|
|
237
|
+
else next.serverKey = serverKey;
|
|
232
238
|
return next;
|
|
233
239
|
});
|
|
234
240
|
}
|
|
235
241
|
|
|
236
|
-
// Which snapshot to keep
|
|
237
|
-
//
|
|
238
|
-
// that
|
|
239
|
-
|
|
242
|
+
// Which snapshot to keep, +next+ or +kept+. Events come in the order the
|
|
243
|
+
// server built them, so an event (+event+) always wins: a smaller at is a
|
|
244
|
+
// rollback that shortened the conversation, not an older set. A GET's
|
|
245
|
+
// answer loses to an event that came while it was on its way (+overtaken+),
|
|
246
|
+
// and, asked while a turn runs (its running: it is built from the session's
|
|
247
|
+
// messages, behind that turn's events), to a kept one from more of the
|
|
248
|
+
// conversation (its at).
|
|
249
|
+
export function newerSnapshot(kept, next, { event = false, overtaken = false } = {}) {
|
|
240
250
|
if (!next || !Array.isArray(next.items) || !Number.isFinite(next.at)) return kept;
|
|
241
|
-
if (!kept ||
|
|
242
|
-
return kept;
|
|
251
|
+
if (!kept || event) return next;
|
|
252
|
+
if (overtaken) return kept;
|
|
253
|
+
if (next.running && next.at < kept.at) return kept;
|
|
254
|
+
return next;
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
// Tokens as the popover shows them: "~840", "~1.2k", "~12k".
|
|
@@ -251,13 +263,17 @@ export function tokensText(tokens) {
|
|
|
251
263
|
}
|
|
252
264
|
|
|
253
265
|
const isForgotten = (item) => item.state === "forgotten";
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
266
|
+
// Nothing of it is sent: the model forgot it, or the user dropped it.
|
|
267
|
+
const isStruck = (item) => item.state === "forgotten" || item.state === "dropped";
|
|
268
|
+
const isFaded = (item) => item.state === "stale" || isStruck(item);
|
|
269
|
+
|
|
270
|
+
// A group's state: dropped when all its members are, forgotten when all are
|
|
271
|
+
// struck (forgotten or dropped), stale when all are stale or struck, else
|
|
272
|
+
// live (spec §3: struck only when all forgotten, faded when all stale or
|
|
273
|
+
// forgotten).
|
|
259
274
|
export function groupState(members) {
|
|
260
|
-
if (members.length && members.every(
|
|
275
|
+
if (members.length && members.every((m) => m.state === "dropped")) return "dropped";
|
|
276
|
+
if (members.length && members.every(isStruck)) return "forgotten";
|
|
261
277
|
if (members.length && members.every(isFaded)) return "stale";
|
|
262
278
|
return "live";
|
|
263
279
|
}
|
|
@@ -278,7 +294,7 @@ export function leafText(item) {
|
|
|
278
294
|
function leafLabel(item) {
|
|
279
295
|
return {
|
|
280
296
|
id: `${item.kind}:${item.key}`, type: "leaf", kind: item.kind, glyph: GLYPHS[item.kind] || "", text: leafText(item),
|
|
281
|
-
n: 1, edited: edited(item) ? 1 : 0, cut:
|
|
297
|
+
n: 1, edited: edited(item) ? 1 : 0, cut: isStruck(item) ? 1 : 0, state: item.state || "live", members: [item],
|
|
282
298
|
};
|
|
283
299
|
}
|
|
284
300
|
|
|
@@ -295,7 +311,7 @@ function groupLabel(spec, members) {
|
|
|
295
311
|
const edits = members.filter(edited).length;
|
|
296
312
|
// The state marks (spec §3): ✂n the forgotten members, the group's own
|
|
297
313
|
// state from all of them.
|
|
298
|
-
const marks = { cut: members.filter(
|
|
314
|
+
const marks = { cut: members.filter(isStruck).length, state: groupState(members) };
|
|
299
315
|
if (spec.type === "dir") {
|
|
300
316
|
return { id: `dir:${spec.dir}`, type: "dir", kind: "file", glyph: "", text: dirLabel(spec.dir), count: plural(n, "file"), n, edited: edits, ...marks, members, dir: spec.dir };
|
|
301
317
|
}
|
|
@@ -451,9 +467,16 @@ export function opsText(item) {
|
|
|
451
467
|
|
|
452
468
|
// A label as the screen reader says it ("web/public 9 files 2 edited").
|
|
453
469
|
function spoken(label) {
|
|
454
|
-
if (label.type === "dir") return `${label.text.replace(/^…\//, "").replace(/\/$/, "")} ${label.count}${label.edited ? ` ${label.edited} edited` : ""}${label
|
|
470
|
+
if (label.type === "dir") return `${label.text.replace(/^…\//, "").replace(/\/$/, "")} ${label.count}${label.edited ? ` ${label.edited} edited` : ""}${cutWords(label)}`;
|
|
455
471
|
if (label.type === "leaf") return `${label.kind === "memory" ? "memory " : label.kind === "url" ? "url " : ""}${label.text}${label.edited ? " edited" : ""}${label.state && label.state !== "live" ? ` ${label.state}` : ""}`;
|
|
456
|
-
return `${label.text}${label
|
|
472
|
+
return `${label.text}${cutWords(label)}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// A group's struck members in words: " 2 forgotten, 1 dropped".
|
|
476
|
+
function cutWords(label) {
|
|
477
|
+
const dropped = label.members.filter((m) => m.state === "dropped").length;
|
|
478
|
+
const forgotten = label.cut - dropped;
|
|
479
|
+
return [forgotten ? ` ${forgotten} forgotten` : "", dropped ? ` ${dropped} dropped` : ""].filter(Boolean).join(",");
|
|
457
480
|
}
|
|
458
481
|
|
|
459
482
|
// The rail's (or row's, footprint's) aria-label: the folded text spoken.
|
|
@@ -476,7 +499,8 @@ export function footprint(items, budget = BUDGETS.footprint) {
|
|
|
476
499
|
const kinds = new Set(rest.map((m) => m.kind));
|
|
477
500
|
tail = kinds.size === 1 ? `+${plural(rest.length, KIND_WORDS[[...kinds][0]])}` : `+${rest.length} more`;
|
|
478
501
|
}
|
|
479
|
-
return { labels: head, tail, edited: items.filter(edited).length, forgotten: items.filter(isForgotten).length
|
|
502
|
+
return { labels: head, tail, edited: items.filter(edited).length, forgotten: items.filter(isForgotten).length,
|
|
503
|
+
dropped: items.filter((i) => i.state === "dropped").length };
|
|
480
504
|
}
|
|
481
505
|
|
|
482
506
|
// A label's text as the footprint and the row draw it: its glyph, text,
|
|
@@ -498,6 +522,7 @@ export function footprintText(items, budget = BUDGETS.footprint) {
|
|
|
498
522
|
if (fp.tail) parts.push(fp.tail);
|
|
499
523
|
if (fp.edited) parts.push(`edited ${fp.edited}`);
|
|
500
524
|
if (fp.forgotten) parts.push(`${fp.forgotten} forgotten`);
|
|
525
|
+
if (fp.dropped) parts.push(`${fp.dropped} dropped`);
|
|
501
526
|
return `touched ${parts.join(" · ")}`;
|
|
502
527
|
}
|
|
503
528
|
|
|
@@ -518,3 +543,70 @@ export function nextSettle(seenTimes, now, ms = FLASH_MS) {
|
|
|
518
543
|
}
|
|
519
544
|
return next;
|
|
520
545
|
}
|
|
546
|
+
|
|
547
|
+
// ── steering (drop / undrop) ──────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
export const STEER_COMMAND = "/working-set";
|
|
550
|
+
const STEER_LINE = /^\/working-set\s+(drop|undrop)\s+(\S.*)$/;
|
|
551
|
+
const URL_WORD = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
552
|
+
export const INVERSE = Object.freeze({ drop: "undrop", undrop: "drop" });
|
|
553
|
+
|
|
554
|
+
// The word a steer line names +item+ by (WorkingSetSteer.target): a file's
|
|
555
|
+
// absolute path, memory:NAME, a URL (the server's full one for a cut row).
|
|
556
|
+
export function steerWord(item) {
|
|
557
|
+
if (item.kind === "memory") return `memory:${item.key}`;
|
|
558
|
+
if (item.kind === "url") return item.serverKey || item.key;
|
|
559
|
+
return item.key;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// The id the page keys an item by ("file:/abs/a.rb"), from a steer word.
|
|
563
|
+
function wordId(word) {
|
|
564
|
+
if (word.startsWith("memory:")) return `memory:${word.slice("memory:".length)}`;
|
|
565
|
+
if (URL_WORD.test(word)) return `url:${word}`;
|
|
566
|
+
return `file:${word}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// An item's id as steer lines name it.
|
|
570
|
+
export function steerId(item) {
|
|
571
|
+
return wordId(steerWord(item));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// The line that does +op+ to +items+ ("/working-set drop /abs/a.rb memory:x").
|
|
575
|
+
export function steerLine(op, items) {
|
|
576
|
+
return `${STEER_COMMAND} ${op} ${items.map(steerWord).join(" ")}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// A steer line's op and ids ({op, ids}), or null for any other line.
|
|
580
|
+
export function parseSteerLine(line) {
|
|
581
|
+
const m = STEER_LINE.exec(String(line || "").trim());
|
|
582
|
+
if (!m) return null;
|
|
583
|
+
return { op: m[1], ids: m[2].split(/\s+/).filter(Boolean).map(wordId) };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// The ops queued for after the running turn (+lines+, in queue order), per
|
|
587
|
+
// item id: "drop" or "undrop". An op followed by its inverse nets to
|
|
588
|
+
// nothing (the worker runs both, the second undoing the first).
|
|
589
|
+
export function pendingSteers(lines) {
|
|
590
|
+
const pending = new Map();
|
|
591
|
+
for (const line of lines) {
|
|
592
|
+
const parsed = parseSteerLine(line);
|
|
593
|
+
if (!parsed) continue;
|
|
594
|
+
for (const id of parsed.ids) {
|
|
595
|
+
if (pending.get(id) === INVERSE[parsed.op]) pending.delete(id);
|
|
596
|
+
else pending.set(id, parsed.op);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return pending;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Why +item+ can't be dropped (a title for its disabled button), "" when
|
|
603
|
+
// 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).
|
|
605
|
+
export function dropRefusal(item) {
|
|
606
|
+
if (item.muted || item.state === "dropped") return null;
|
|
607
|
+
if (item.preloaded && !item.ops.includes("read")) return "preloaded into the system prompt: not dropped from here";
|
|
608
|
+
if (!Number.isFinite(item.tokens)) return null;
|
|
609
|
+
if (!item.ops.includes("read")) return "only edited or written: nothing of it to drop";
|
|
610
|
+
if (item.state === "forgotten") return "forgotten: nothing of it is sent";
|
|
611
|
+
return "";
|
|
612
|
+
}
|