samagotchi 0.84.0 → 0.86.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 +80 -1
- data/docs/architecture.md +44 -6
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +54 -4
- data/docs/configuration.md +8 -5
- data/docs/guardrails.md +63 -35
- data/docs/hooks.md +20 -4
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +4 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +35 -24
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +76 -12
- data/lib/samagotchi/bridge_client.rb +14 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +5 -5
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +10 -3
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_report.rb +92 -16
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +22 -12
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +37 -24
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +22 -6
- data/lib/samagotchi/send_command.rb +34 -8
- data/lib/samagotchi/session_commands.rb +21 -7
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +15 -9
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +6 -1
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -10
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +4 -1
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -13,6 +13,13 @@ import { linkedLine } from "./notice_links.js";
|
|
|
13
13
|
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
14
14
|
import { drawCommandBubble } from "./commands.js";
|
|
15
15
|
|
|
16
|
+
export function removeQueuedBubble(bubble) {
|
|
17
|
+
if (!bubble) return;
|
|
18
|
+
const slot = bubble.parentElement?.classList.contains("ts-queued") ? bubble.parentElement : null;
|
|
19
|
+
bubble.remove();
|
|
20
|
+
if (slot && slot.childElementCount === 0) slot.remove();
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
// A prompt that started a turn: not a line steered into a running one.
|
|
17
24
|
export const TURN_PROMPT = ".bubble.user:not(.steered)";
|
|
18
25
|
|
|
@@ -205,6 +212,8 @@ export function createBubbles({
|
|
|
205
212
|
setBubbleState(bubble, null);
|
|
206
213
|
started = bubble;
|
|
207
214
|
}
|
|
215
|
+
} else if (op.op === "remove") {
|
|
216
|
+
removeQueuedBubble(bubbleById(op.enqueuedId));
|
|
208
217
|
} else if (op.op === "steer") {
|
|
209
218
|
const bubble = bubbleById(op.enqueuedId);
|
|
210
219
|
if (bubble) setBubbleState(bubble, "steered");
|
|
@@ -1,26 +1,15 @@
|
|
|
1
1
|
// Pure routing for one :generation_chunk event into the two render lanes.
|
|
2
2
|
//
|
|
3
|
-
// Every :generation_chunk the server sends carries
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// `content` as text.
|
|
10
|
-
//
|
|
11
|
-
// Presence is checked with `typeof === "string"` (not truthiness) so an empty
|
|
12
|
-
// `text` (e.g. a chunk that is entirely a tool_call body) still selects the new
|
|
13
|
-
// wire instead of accidentally falling back to raw `content`.
|
|
3
|
+
// Every :generation_chunk the server sends carries `text` (visible prose,
|
|
4
|
+
// thinking + tool_call blocks removed) and `thinking` (thinking-only): the
|
|
5
|
+
// native loop splits its stream per generation for every profile (Gemma's
|
|
6
|
+
// thought channel included), the chat loop gets reasoning apart from the
|
|
7
|
+
// answer. The raw stream text and the provider's payload stay in the worker
|
|
8
|
+
// (Bridge::ClientEvent); a missing or non-string field is an empty lane.
|
|
14
9
|
export function routeChunk(data) {
|
|
15
10
|
data = data || {};
|
|
16
|
-
if (typeof data.text === "string" || typeof data.thinking === "string") {
|
|
17
|
-
return {
|
|
18
|
-
text: typeof data.text === "string" ? data.text : "",
|
|
19
|
-
thinking: typeof data.thinking === "string" ? data.thinking : "",
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
11
|
return {
|
|
23
|
-
text: typeof data.
|
|
24
|
-
thinking: "",
|
|
12
|
+
text: typeof data.text === "string" ? data.text : "",
|
|
13
|
+
thinking: typeof data.thinking === "string" ? data.thinking : "",
|
|
25
14
|
};
|
|
26
15
|
}
|
|
@@ -110,6 +110,7 @@ const WEB_LOCAL_REPLIES = {
|
|
|
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
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",
|
|
113
|
+
"/unqueue": "/unqueue: a terminal's command; here the × on a message queued for after the turn removes it",
|
|
113
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",
|
|
114
115
|
};
|
|
115
116
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// Context-window usage (%) from a stream event
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Context-window usage (%) from a stream event: a :context_status's
|
|
2
|
+
// estimated_pct, or a :generation_chunk's `counts` (chi's own counts, which
|
|
3
|
+
// the worker's Bridge works out from the provider's payload; the payload
|
|
4
|
+
// itself never reaches a client). `windowTokens` is the window the kernel
|
|
5
|
+
// resolved for this generation (:generation_started carries it as
|
|
6
|
+
// context_window_tokens); a chunk's counts carry a window only when the server
|
|
7
|
+
// reported one (llama.cpp's /completion doesn't), and that one wins.
|
|
5
8
|
// `budgetTokens` is the session's LLM context budget (llm_context's
|
|
6
9
|
// budget_tokens): a percentage this file computes counts against it when it
|
|
7
10
|
// is smaller than the window, as the kernel's :context_status does
|
|
@@ -20,32 +23,15 @@ function countedTokens(windowTokens, budgetTokens = null) {
|
|
|
20
23
|
return budget && budget > 0 ? Math.min(budget, window) : window;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
// Tokens in context after this chunk: prompt + generated so far.
|
|
24
|
-
function usedTokens(p) {
|
|
25
|
-
if (!p || typeof p !== "object") return null;
|
|
26
|
-
if (num(p.n_past)) return p.n_past;
|
|
27
|
-
if (num(p.usage?.total_tokens)) return p.usage.total_tokens;
|
|
28
|
-
const prompt = num(p.usage?.prompt_tokens) ?? num(p.tokens_evaluated) ?? num(p.timings?.prompt_n);
|
|
29
|
-
const completion = num(p.usage?.completion_tokens) ?? num(p.tokens_predicted) ?? num(p.timings?.predicted_n);
|
|
30
|
-
if (prompt === null && completion === null) return null;
|
|
31
|
-
return (prompt ?? 0) + (completion ?? 0);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
26
|
export function extractCtxPct(data, windowTokens = null, budgetTokens = null) {
|
|
35
27
|
if (!data || typeof data !== "object") return null;
|
|
36
|
-
if (num(data.ctx_pct) !== null) return data.ctx_pct;
|
|
37
|
-
if (num(data.est_pct) !== null) return data.est_pct;
|
|
38
|
-
if (num(data.ctxPct) !== null) return data.ctxPct;
|
|
39
28
|
// The kernel's :context_status event: { usage: { estimated_pct, ... }, bucket }.
|
|
40
29
|
if (num(data.usage?.estimated_pct) !== null) return data.usage.estimated_pct;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
if (num(p.ctx_pct) !== null) return p.ctx_pct;
|
|
47
|
-
const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
|
|
48
|
-
const used = usedTokens(p);
|
|
30
|
+
// A chunk's counts: tokens in context after it (prompt + generated so far).
|
|
31
|
+
const counts = data.counts;
|
|
32
|
+
if (!counts || typeof counts !== "object") return null;
|
|
33
|
+
const used = num(counts.total_tokens);
|
|
34
|
+
const against = countedTokens(num(counts.context_window_tokens) || num(windowTokens), budgetTokens);
|
|
49
35
|
if (!against || used === null) return null;
|
|
50
36
|
return (used / against) * 100;
|
|
51
37
|
}
|
|
@@ -169,6 +169,17 @@ export function stopTask(id, taskId, opts = {}) {
|
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// Takes back a message queued for after the running turn (the × on its
|
|
173
|
+
// bubble): 200 {status, enqueued_id, prompt}; 409 already_started /
|
|
174
|
+
// not_queued, 404 unknown_enqueued_id, 503 no live worker.
|
|
175
|
+
export function dequeueTurn(id, enqueuedId, { clientId = null } = {}, opts = {}) {
|
|
176
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/turn/${encodeURIComponent(enqueuedId)}/dequeue`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: JSON.stringify(clientId ? { client_id: clientId } : {}),
|
|
179
|
+
...opts,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
// Hands the session to a new worker on the newest chi installed: 200
|
|
173
184
|
// {status, from_version, version}, or 409 with the reason in words (detail).
|
|
174
185
|
export function restartSession(id, opts = {}) {
|
|
@@ -120,6 +120,12 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
120
120
|
.bubble.user.queued .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
|
|
121
121
|
.bubble.user.steered .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
|
|
122
122
|
.bubble.user.failed .state-badge::before{background:var(--danger);box-shadow:0 0 0 1px color-mix(in srgb,var(--danger) 40%,transparent)}
|
|
123
|
+
/* The × on a message queued for after the turn (unqueue.js): only once the bubble has its enqueued_id; a 32px tap target around a small mark */
|
|
124
|
+
.bubble.user .state-badge button.unqueue{position:relative;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;margin:0 -3px 0 0;padding:0;border:0;border-radius:50%;background:transparent;color:var(--text-muted);font-size:14px;line-height:1;cursor:pointer}
|
|
125
|
+
.bubble.user .state-badge button.unqueue::before{content:"\00d7"}
|
|
126
|
+
.bubble.user .state-badge button.unqueue::after{content:"";position:absolute;inset:-8px}
|
|
127
|
+
.bubble.user .state-badge button.unqueue:hover:not(:disabled){background:var(--danger-bg-dim);color:var(--danger-text);filter:none}
|
|
128
|
+
.bubble.user:not([data-enqueued-id]) .state-badge button.unqueue{display:none}
|
|
123
129
|
.bubble.user .origin-label{font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}
|
|
124
130
|
.bubble.user .user-message{flex:1;min-width:0}
|
|
125
131
|
.bubble.user blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-3);margin:2px 0 6px}
|
|
@@ -847,6 +853,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
847
853
|
.turn-stage .turn-work.outline>.gen.folded{display:block}
|
|
848
854
|
.turn-stage .ts-cloud-body .gen:first-of-type{margin-top:0;border-top:0}
|
|
849
855
|
@media(max-width:600px){
|
|
856
|
+
.bubble.user.queued[data-delivery="queue"]{flex-wrap:wrap}
|
|
857
|
+
.bubble.user.queued[data-delivery="queue"] .origin-label,.bubble.user.queued[data-delivery="queue"] .state-badge{order:0}
|
|
858
|
+
.bubble.user.queued[data-delivery="queue"] .user-message{order:1;flex-basis:100%}
|
|
850
859
|
.ts-status{padding-left:10px}
|
|
851
860
|
.ts-scroll{padding:8px 10px 10px}
|
|
852
861
|
.ts-foot{padding:0 10px 10px}
|
|
@@ -410,6 +410,10 @@ export function createStageView({
|
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
function ensureQueuedSlot() {
|
|
414
|
+
if (!queuedSlot.isConnected) stage.appendChild(queuedSlot);
|
|
415
|
+
}
|
|
416
|
+
|
|
413
417
|
function emptySlots() {
|
|
414
418
|
for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot]) slot.replaceChildren();
|
|
415
419
|
slotsView.reset();
|
|
@@ -535,6 +539,7 @@ export function createStageView({
|
|
|
535
539
|
// The lines still queued behind this turn went into the history with
|
|
536
540
|
// the turn before (the hand-off at this turn_started, or an earlier
|
|
537
541
|
// one): back below this turn's card, as a reload places them.
|
|
542
|
+
ensureQueuedSlot();
|
|
538
543
|
queuedSlot.append(...waiting.filter((el) => !el.isConnected), ...historyEl.querySelectorAll(QUEUED_CHILDREN));
|
|
539
544
|
view.turnStarted(data || {});
|
|
540
545
|
show();
|
|
@@ -616,6 +621,7 @@ export function createStageView({
|
|
|
616
621
|
const where = placeFor({ live: holding, handingOff: false, kind: placeKind(el) });
|
|
617
622
|
if (where === "history") return false;
|
|
618
623
|
if (where === "queued") {
|
|
624
|
+
ensureQueuedSlot();
|
|
619
625
|
queuedSlot.appendChild(el);
|
|
620
626
|
scheduleRefresh();
|
|
621
627
|
return true;
|
|
@@ -61,7 +61,7 @@ export function createStreamHandlers(deps) {
|
|
|
61
61
|
return {
|
|
62
62
|
generation_chunk: (data) => {
|
|
63
63
|
// Route the chunk's bytes to the text bubble and the thinking block (see
|
|
64
|
-
// chunk_router.js).
|
|
64
|
+
// chunk_router.js).
|
|
65
65
|
const { text, thinking } = routeChunk(data);
|
|
66
66
|
liveTurn.timing.setNote("");
|
|
67
67
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null, now: data.replayed ? null : Date.now() });
|
|
@@ -116,6 +116,9 @@ export function createStreamHandlers(deps) {
|
|
|
116
116
|
showSteers(data.steers);
|
|
117
117
|
},
|
|
118
118
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
119
|
+
// A queued message taken back before it ran (the × here or in another
|
|
120
|
+
// tab, /unqueue in a terminal): its bubble goes, in every tab.
|
|
121
|
+
turn_dequeued: (data) => applyPromptOps(data),
|
|
119
122
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
120
123
|
if (stream.takeGuess()) {
|
|
121
124
|
// A replay of a turn the render drew: re-read and stream from there.
|
|
@@ -247,6 +247,8 @@ export function initWaitLine({ tasks } = {}) {
|
|
|
247
247
|
// tag — this tab's own local echo gets its enqueued_id
|
|
248
248
|
// start — the bubble's turn started (drops the "queued" badge)
|
|
249
249
|
// steer — the bubble was merged into the running turn
|
|
250
|
+
// remove — the queued message was taken back before it ran (turn_dequeued,
|
|
251
|
+
// whoever removed it): its bubble goes
|
|
250
252
|
// report — the merge brought a delegate child's report (origin
|
|
251
253
|
// child:<id8>, no bubble anywhere): its text gets a labelled one
|
|
252
254
|
// `known(enqueuedId)` says whether a bubble carries that id already;
|
|
@@ -276,6 +278,8 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
|
|
|
276
278
|
}
|
|
277
279
|
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
|
|
278
280
|
}
|
|
281
|
+
case "turn_dequeued":
|
|
282
|
+
return event.enqueued_id ? [{ op: "remove", enqueuedId: event.enqueued_id }] : [];
|
|
279
283
|
case "input_merged": {
|
|
280
284
|
const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
|
|
281
285
|
known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
|
|
@@ -184,9 +184,17 @@ export function badgeText(state, delivery = null) {
|
|
|
184
184
|
return (state === "queued" && QUEUED_BADGES[delivery]) || BADGES[state];
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// A message queued for after the turn (delivery queue) can be taken back
|
|
188
|
+
// while it waits: the badge carries its ×, shown once the bubble has its
|
|
189
|
+
// enqueued_id (style.css; an echo before its ack has none) and handled by
|
|
190
|
+
// unqueue.js. Not a next-step line: a step boundary may merge it any moment.
|
|
187
191
|
export function stateBadgeTemplate(state, delivery = null) {
|
|
188
192
|
const text = badgeText(state, delivery);
|
|
189
|
-
|
|
193
|
+
if (!text) return nothing;
|
|
194
|
+
const remove = state === "queued" && delivery === "queue"
|
|
195
|
+
? html`<button type="button" class="unqueue" title="Remove from queue" aria-label="Remove from queue"></button>`
|
|
196
|
+
: nothing;
|
|
197
|
+
return html`<span class="state-badge">${text}${remove}</span>`;
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
// A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The × on a message queued for after the running turn (its badge,
|
|
2
|
+
// turn_html.js stateBadgeTemplate): POST …/turn/:enqueued_id/dequeue
|
|
3
|
+
// (Bridge#handle_dequeue_turn) takes it back before it runs. The bubble
|
|
4
|
+
// goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
|
|
5
|
+
// here at the 200 already. Its text comes back (edit): into an empty
|
|
6
|
+
// composer, to change and send again; over a draft only in a toast (the
|
|
7
|
+
// draft stays). Its images don't come back. A refusal keeps it and says why
|
|
8
|
+
// in a toast. No confirm: nothing is lost. Pure but for the
|
|
9
|
+
// injected calls, so `node --test` covers it; app.js binds onClick to the
|
|
10
|
+
// turn roots.
|
|
11
|
+
import { dequeueTurn as postDequeue } from "./data.js";
|
|
12
|
+
import { removeQueuedBubble } from "./bubbles.js";
|
|
13
|
+
|
|
14
|
+
// Why a removal didn't happen, in words.
|
|
15
|
+
export function unqueueFailureText(error) {
|
|
16
|
+
if (error?.code === "already_started") return "Not removed: it already started";
|
|
17
|
+
if (error?.code === "not_queued") return "Not removed: it goes in at the running turn's next step";
|
|
18
|
+
if (error?.status === 404 && error?.code === "unknown_enqueued_id") return "Not removed: it is already gone";
|
|
19
|
+
return `Not removed: ${error?.message || "the worker did not answer"}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The removed text in a toast, clipped.
|
|
23
|
+
const SHOWN = 200;
|
|
24
|
+
function clipped(text) {
|
|
25
|
+
const line = String(text || "").replace(/\s+/g, " ").trim();
|
|
26
|
+
return line.length > SHOWN ? `${line.slice(0, SHOWN)}\u2026` : line;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// +sessionId+ is read at each click (the open session); +clientId+ is this
|
|
30
|
+
// tab's (the turn_dequeued names who removed it); +putBack(text)+ fills the
|
|
31
|
+
// composer when it is empty and says whether it did.
|
|
32
|
+
export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false }) {
|
|
33
|
+
// A click on a queued bubble's ×; any other click is left alone.
|
|
34
|
+
async function onClick(e) {
|
|
35
|
+
const button = e.target?.closest?.("button.unqueue");
|
|
36
|
+
if (!button || button.disabled) return null;
|
|
37
|
+
const bubble = button.closest(".bubble.user");
|
|
38
|
+
const enqueuedId = bubble?.dataset.enqueuedId;
|
|
39
|
+
const id = sessionId();
|
|
40
|
+
if (!enqueuedId || !id) return null;
|
|
41
|
+
e.stopPropagation?.();
|
|
42
|
+
button.disabled = true;
|
|
43
|
+
try {
|
|
44
|
+
const result = await dequeue(id, enqueuedId, { clientId });
|
|
45
|
+
removeQueuedBubble(bubble);
|
|
46
|
+
const text = String(result?.prompt || "");
|
|
47
|
+
if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
|
|
48
|
+
return result;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (button.isConnected) button.disabled = false;
|
|
51
|
+
toast(unqueueFailureText(error));
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { onClick };
|
|
56
|
+
}
|
|
@@ -120,6 +120,9 @@ module Samagotchi
|
|
|
120
120
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
121
121
|
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
122
122
|
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path)
|
|
123
|
+
config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
|
|
124
|
+
Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
|
|
125
|
+
end
|
|
123
126
|
port = resolve_port(port)
|
|
124
127
|
host = resolve_host(host)
|
|
125
128
|
url ||= "http://#{url_host(host)}:#{port}/"
|
|
@@ -166,6 +169,7 @@ module Samagotchi
|
|
|
166
169
|
warn "Error: can't listen on #{lan.ip}:#{port} (#{e.message}); did the address change? Run chi web again"
|
|
167
170
|
false
|
|
168
171
|
ensure
|
|
172
|
+
config_restart_unsubscribe&.call
|
|
169
173
|
hub&.stop
|
|
170
174
|
Samagotchi::Log.info(:web, "stop") if app
|
|
171
175
|
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -229,6 +229,9 @@ module Samagotchi
|
|
|
229
229
|
# The running turn, on disk at its iteration boundaries.
|
|
230
230
|
@checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
|
|
231
231
|
@engine = build_engine
|
|
232
|
+
@config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :worker) do |message|
|
|
233
|
+
@engine.announce(type: :hook_notice, hook: "config", text: message, level: :warn, between_turns: true)
|
|
234
|
+
end
|
|
232
235
|
settle_interrupted_turn
|
|
233
236
|
# Before the Bridge serves anything: a UI joining a resumed worker's
|
|
234
237
|
# stream gets the session's history and status in its snapshot, not
|
|
@@ -278,8 +281,9 @@ module Samagotchi
|
|
|
278
281
|
context_absorber: @context_absorber, wakes: @wakes,
|
|
279
282
|
awaiting_continue: -> { @turn_flow.awaiting_continue? },
|
|
280
283
|
stopped: -> { stopped_on_disk? },
|
|
281
|
-
queue_command: lambda { |line, client_id, after_file|
|
|
282
|
-
@bridge.queue_command(line, client_id: client_id, after_file: after_file
|
|
284
|
+
queue_command: lambda { |line, client_id, after_file, enqueued_id|
|
|
285
|
+
@bridge.queue_command(line, client_id: client_id, after_file: after_file,
|
|
286
|
+
enqueued_id: enqueued_id)
|
|
283
287
|
})
|
|
284
288
|
# /model's default is the config's, as in the REPL; the Engine started
|
|
285
289
|
# on the session's model. nil when there is no default.model.
|
|
@@ -414,6 +418,7 @@ module Samagotchi
|
|
|
414
418
|
# The context poller first: a command it runs goes (its process
|
|
415
419
|
# group) before the Engine does.
|
|
416
420
|
def teardown
|
|
421
|
+
@config_restart_unsubscribe&.call
|
|
417
422
|
@context_poller&.stop
|
|
418
423
|
drop_queued_commands
|
|
419
424
|
@engine&.shutdown
|
|
@@ -453,9 +458,10 @@ module Samagotchi
|
|
|
453
458
|
|
|
454
459
|
# A turn an earlier worker died in (TurnCheckpoint's mark in the file):
|
|
455
460
|
# ended now as canceled, with its note, and saved, before the Bridge
|
|
456
|
-
# serves the session (TurnCheckpoint.settle).
|
|
461
|
+
# serves the session (TurnCheckpoint.settle). Its queued id's wait
|
|
462
|
+
# (chi send --queue --wait) reads the end it records in the session dir.
|
|
457
463
|
def settle_interrupted_turn
|
|
458
|
-
mark = TurnCheckpoint.settle(@session)
|
|
464
|
+
mark = TurnCheckpoint.settle(@session, session_dir: @session_dir)
|
|
459
465
|
return unless mark
|
|
460
466
|
|
|
461
467
|
Log.info(:worker, "interrupted_turn_settled", turn_id: mark.id, iteration: mark.iteration, at: mark.at)
|
|
@@ -787,7 +793,9 @@ module Samagotchi
|
|
|
787
793
|
# the cards and notices it showed, held: they follow its command_ran,
|
|
788
794
|
# as its output
|
|
789
795
|
def run_command_line(command)
|
|
790
|
-
@engine.holding_announcements
|
|
796
|
+
@engine.holding_announcements do
|
|
797
|
+
command_result(command) { @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command)) }
|
|
798
|
+
end
|
|
791
799
|
end
|
|
792
800
|
|
|
793
801
|
# Whether +command+ asks before a costly /thinking or /reasoning-history
|
|
@@ -795,6 +803,11 @@ module Samagotchi
|
|
|
795
803
|
# question; chi send, a delegate, a plugin and a card's action apply it.
|
|
796
804
|
def confirm?(command) = ClientId.interactive?(command.client_id) && !command.card
|
|
797
805
|
|
|
806
|
+
# Whether +command+ is a parent agent's (chi answer's marker, which chi
|
|
807
|
+
# send and a parent's attach give a /continue yes too): held to
|
|
808
|
+
# guardrails.parent_answers (SessionCommands#continue_answer).
|
|
809
|
+
def as_parent?(command) = command.client_id.to_s == ClientId::CLI_ANSWER
|
|
810
|
+
|
|
798
811
|
# The block's Result for +command+; one that raised, or found no
|
|
799
812
|
# command for the line, is an error Result saying so.
|
|
800
813
|
# @return [SessionCommands::Result]
|
|
@@ -866,6 +879,7 @@ module Samagotchi
|
|
|
866
879
|
# @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
|
|
867
880
|
# line that applies it (the TUI's prefill, the web's Apply button)
|
|
868
881
|
def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
|
|
882
|
+
TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id)
|
|
869
883
|
text = output.to_s
|
|
870
884
|
event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
|
|
871
885
|
changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
|
|
@@ -70,9 +70,9 @@ module Samagotchi
|
|
|
70
70
|
# @param wakes [WorkerWakes] the wake budget a context wake asks
|
|
71
71
|
# @param awaiting_continue [#call] whether a continue offer waits
|
|
72
72
|
# @param stopped [#call] whether the session was stopped on disk
|
|
73
|
-
# @param queue_command [#call] (line, client_id, after_file)
|
|
74
|
-
# session command for the loop's next pass; after_file is the
|
|
75
|
-
# file it came in, or nil
|
|
73
|
+
# @param queue_command [#call] (line, client_id, after_file, enqueued_id)
|
|
74
|
+
# queues a session command for the loop's next pass; after_file is the
|
|
75
|
+
# input file it came in, or nil
|
|
76
76
|
def initialize(session:, state_dir:, session_dir:, engine:, context_absorber:, wakes:, awaiting_continue:,
|
|
77
77
|
stopped:, queue_command:)
|
|
78
78
|
@session = session
|
|
@@ -228,7 +228,7 @@ module Samagotchi
|
|
|
228
228
|
def queue_as_command(text, origin, after_file = nil)
|
|
229
229
|
return false unless @engine.command_registry.command?(text.to_s)
|
|
230
230
|
|
|
231
|
-
@queue_command.call(text, origin&.dig(:client_id), after_file)
|
|
231
|
+
@queue_command.call(text, origin&.dig(:client_id), after_file, origin&.dig(:enqueued_id))
|
|
232
232
|
true
|
|
233
233
|
end
|
|
234
234
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.86.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -124,6 +124,7 @@ files:
|
|
|
124
124
|
- bin/chi
|
|
125
125
|
- docs/architecture.md
|
|
126
126
|
- docs/broadcast.md
|
|
127
|
+
- docs/bundle-authoring.md
|
|
127
128
|
- docs/cli.md
|
|
128
129
|
- docs/configuration.md
|
|
129
130
|
- docs/context.md
|
|
@@ -162,6 +163,7 @@ files:
|
|
|
162
163
|
- lib/samagotchi/bridge.rb
|
|
163
164
|
- lib/samagotchi/bridge/bounded_queue.rb
|
|
164
165
|
- lib/samagotchi/bridge/card_store.rb
|
|
166
|
+
- lib/samagotchi/bridge/client_event.rb
|
|
165
167
|
- lib/samagotchi/bridge/event_id.rb
|
|
166
168
|
- lib/samagotchi/bridge/pending_card.rb
|
|
167
169
|
- lib/samagotchi/bridge/ring_buffer.rb
|
|
@@ -179,6 +181,9 @@ files:
|
|
|
179
181
|
- lib/samagotchi/broadcast/triage_model.rb
|
|
180
182
|
- lib/samagotchi/broadcast_command.rb
|
|
181
183
|
- lib/samagotchi/bundle_command.rb
|
|
184
|
+
- lib/samagotchi/bundle_command/check.rb
|
|
185
|
+
- lib/samagotchi/bundle_command/scaffold.rb
|
|
186
|
+
- lib/samagotchi/bundle_command/test_run.rb
|
|
182
187
|
- lib/samagotchi/bundle_needs.rb
|
|
183
188
|
- lib/samagotchi/bundle_session_settings.rb
|
|
184
189
|
- lib/samagotchi/bundles/agents-md/manifest.yml
|
|
@@ -218,6 +223,8 @@ files:
|
|
|
218
223
|
- lib/samagotchi/bundles/loop-guard/plugin.rb
|
|
219
224
|
- lib/samagotchi/bundles/mcp/manifest.yml
|
|
220
225
|
- lib/samagotchi/bundles/mcp/plugin.rb
|
|
226
|
+
- lib/samagotchi/bundles/ruby/manifest.yml
|
|
227
|
+
- lib/samagotchi/bundles/ruby/plugin.rb
|
|
221
228
|
- lib/samagotchi/bundles/skills/manifest.yml
|
|
222
229
|
- lib/samagotchi/bundles/skills/plugin.rb
|
|
223
230
|
- lib/samagotchi/bundles/slow-commands/manifest.yml
|
|
@@ -301,7 +308,7 @@ files:
|
|
|
301
308
|
- lib/samagotchi/guardrails/model_size.rb
|
|
302
309
|
- lib/samagotchi/guardrails/outside.rb
|
|
303
310
|
- lib/samagotchi/guardrails/parent_approvals.rb
|
|
304
|
-
- lib/samagotchi/guardrails/
|
|
311
|
+
- lib/samagotchi/guardrails/parent_policy.rb
|
|
305
312
|
- lib/samagotchi/guardrails/protected_paths.rb
|
|
306
313
|
- lib/samagotchi/guardrails/read_only_shell.rb
|
|
307
314
|
- lib/samagotchi/guardrails/rm_targets.rb
|
|
@@ -391,6 +398,7 @@ files:
|
|
|
391
398
|
- lib/samagotchi/memory_bundle/plugin_ref.rb
|
|
392
399
|
- lib/samagotchi/memory_bundle/profile.rb
|
|
393
400
|
- lib/samagotchi/memory_bundle/provenance.rb
|
|
401
|
+
- lib/samagotchi/memory_bundle/shas.rb
|
|
394
402
|
- lib/samagotchi/memory_bundle/shipped_update.rb
|
|
395
403
|
- lib/samagotchi/memory_bundle/source.rb
|
|
396
404
|
- lib/samagotchi/memory_bundle/status.rb
|
|
@@ -519,6 +527,7 @@ files:
|
|
|
519
527
|
- lib/samagotchi/testing/fake_http.rb
|
|
520
528
|
- lib/samagotchi/testing/helpers.rb
|
|
521
529
|
- lib/samagotchi/testing/host.rb
|
|
530
|
+
- lib/samagotchi/testing/install.rb
|
|
522
531
|
- lib/samagotchi/testing/minitest.rb
|
|
523
532
|
- lib/samagotchi/testing/recorder.rb
|
|
524
533
|
- lib/samagotchi/testing/recording_api.rb
|
|
@@ -691,6 +700,7 @@ files:
|
|
|
691
700
|
- lib/samagotchi/web/public/turn_html.js
|
|
692
701
|
- lib/samagotchi/web/public/turn_model.js
|
|
693
702
|
- lib/samagotchi/web/public/turn_view.js
|
|
703
|
+
- lib/samagotchi/web/public/unqueue.js
|
|
694
704
|
- lib/samagotchi/web/public/update_notice.js
|
|
695
705
|
- lib/samagotchi/web/public/vendor/directive-helpers.js
|
|
696
706
|
- lib/samagotchi/web/public/vendor/directive.js
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../config"
|
|
4
|
-
|
|
5
|
-
module Samagotchi
|
|
6
|
-
module Guardrails
|
|
7
|
-
# Whether a parent agent may answer Continue to a session's step-limit
|
|
8
|
-
# question (kind "continue", ContinueOffer): turn.parent_continue,
|
|
9
|
-
# config.yml only, default true. Continue grants no permission (every
|
|
10
|
-
# tool call still meets the guardrails), it spends time and tokens;
|
|
11
|
-
# false makes parents stop-only. Checked by `chi answer` before it posts
|
|
12
|
-
# and by the worker's question desk for an answer marked as chi
|
|
13
|
-
# answer's, the way ParentApprovals is.
|
|
14
|
-
module ParentContinue
|
|
15
|
-
KEY = "turn.parent_continue"
|
|
16
|
-
KIND = "continue"
|
|
17
|
-
CONTINUE = "Continue"
|
|
18
|
-
|
|
19
|
-
module_function
|
|
20
|
-
|
|
21
|
-
# @return [Boolean] this process's setting
|
|
22
|
-
def allowed?
|
|
23
|
-
Config.get(KEY) != false
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Why a parent may not give this answer, or nil.
|
|
27
|
-
# @param pending [PendingQuestion] the pending question
|
|
28
|
-
# @param selected [Array<String>] the selected labels
|
|
29
|
-
# @return [Symbol, nil] :stop_only
|
|
30
|
-
def refusal(pending, selected, allowed: allowed?)
|
|
31
|
-
return nil if allowed
|
|
32
|
-
return nil unless pending.kind.to_s == KIND
|
|
33
|
-
|
|
34
|
-
Array(selected).map(&:to_s).include?(CONTINUE) ? :stop_only : nil
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def message
|
|
38
|
-
"a parent agent may only stop this turn here (#{KEY}: false): answer Stop " \
|
|
39
|
-
"(--option Stop --text WHY), or tell your user it waits"
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|