samagotchi 0.17.0 → 0.18.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 +64 -1
- data/docs/architecture.md +25 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +42 -14
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +11 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +5 -2
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- metadata +8 -1
|
@@ -23,17 +23,11 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// opts.dir: the folder the chat starts in (else the server's cwd).
|
|
27
|
-
// dir: the folder the chat starts in; model: the model it starts on (as
|
|
28
|
-
// GET /api/models spells it); either left out means the server's default.
|
|
29
|
-
export function createSession(prompt, { dir, model, ...opts } = {}) {
|
|
30
|
-
const body = { prompt, ...(dir ? { dir } : {}), ...(model ? { model } : {}) };
|
|
31
|
-
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
26
|
// A session with no first turn: the page's first message is sent into it
|
|
35
|
-
// as a turn (with its images, once they are uploaded).
|
|
36
|
-
//
|
|
27
|
+
// as a turn (with its images, once they are uploaded). dir: the folder the
|
|
28
|
+
// chat starts in; model: the model it starts on (as GET /api/models spells
|
|
29
|
+
// it); either left out means the server's default. preview: that message,
|
|
30
|
+
// which names the session until its turn is saved.
|
|
37
31
|
export function createIdleSession({ dir, model, preview, ...opts } = {}) {
|
|
38
32
|
const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
|
|
39
33
|
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
@@ -61,11 +55,16 @@ export function uploadImage(id, file, name, opts = {}) {
|
|
|
61
55
|
}
|
|
62
56
|
|
|
63
57
|
// opts.tail: the lighter answer for the end of a turn (the session, its
|
|
64
|
-
// timing and the last answer only), not the whole history
|
|
58
|
+
// timing and the last answer only), not the whole history; with
|
|
59
|
+
// opts.recent its timing holds the newest turn's records and turn_count
|
|
60
|
+
// (mergeTiming), and opts.turnId asks for that turn's answer.
|
|
61
|
+
// opts.timing: the whole timing alone (when the merge came up short).
|
|
65
62
|
// opts.parts: each saved message with what it did (thinking, tool calls
|
|
66
63
|
// and their output), for the turn view's reload.
|
|
67
|
-
export function getSession(id, { tail, parts, cards, ...opts } = {}) {
|
|
68
|
-
|
|
64
|
+
export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...opts } = {}) {
|
|
65
|
+
let query = cards ? "?cards=1" : tail ? "?tail=1" : timing ? "?timing=1" : parts ? "?parts=1" : "";
|
|
66
|
+
if (tail && recent) query += "&recent=1";
|
|
67
|
+
if (tail && turnId) query += `&turn_id=${encodeURIComponent(turnId)}`;
|
|
69
68
|
return api(`/api/sessions/${id}${query}`, opts);
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -224,6 +224,9 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
224
224
|
.continue-context{font-size:12px;color:var(--text-3);margin-bottom:8px;white-space:pre-wrap}
|
|
225
225
|
.continue-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
226
226
|
.continue-reason{flex:1;min-width:140px;background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:8px;color:var(--text);padding:6px 8px}
|
|
227
|
+
/* The step-limit question (kind continue): the continue card's buttons inside a question card; the picked one stays marked once answered. */
|
|
228
|
+
.bubble.question.step-limit .continue-actions{margin-top:2px}
|
|
229
|
+
.bubble.question.step-limit .question-choice.selected{outline:2px solid var(--ok);outline-offset:1px;opacity:1}
|
|
227
230
|
.bubble.question.submitting{opacity:.7}
|
|
228
231
|
.bubble.question.answered{border-left-color:var(--ok)}
|
|
229
232
|
.bubble.question.cancelled{border-left-color:var(--text-muted);opacity:.75}
|
|
@@ -666,9 +669,11 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
666
669
|
.card.archived{opacity:.6}
|
|
667
670
|
.card.archived:hover{opacity:.85}
|
|
668
671
|
.card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
|
|
669
|
-
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
|
|
672
|
+
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
|
|
670
673
|
#toast[hidden]{display:none}
|
|
671
674
|
#toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
|
|
675
|
+
#toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
|
|
676
|
+
#toast .toast-close:hover{color:var(--text)}
|
|
672
677
|
#allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
|
|
673
678
|
.card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
|
|
674
679
|
.card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
|
|
@@ -15,13 +15,15 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
15
15
|
// A finished turn shorter than this was watched: no "done".
|
|
16
16
|
export const DONE_MIN_SECONDS = 10;
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
19
|
+
// and asks whether to go on) has its own reason: "out of steps".
|
|
20
|
+
const questionReason = (q) => (q.kind === "approval" ? "approval" : q.kind === "continue" ? "continue" : "question");
|
|
19
21
|
|
|
20
22
|
// What a session waits on the user for right now, from one summary: an open
|
|
21
23
|
// question or approval, or a running turn's card with actions (check-in's
|
|
22
24
|
// Nudge / Keep going / Stop asks as a question does). The session cards
|
|
23
25
|
// mark and lift these; attentionFor notifies when one opens.
|
|
24
|
-
// @return null | {reason: "question"|"approval"|"card", id}
|
|
26
|
+
// @return null | {reason: "question"|"approval"|"continue"|"card", id}
|
|
25
27
|
export function waitingOn(summary) {
|
|
26
28
|
const q = summary?.pending_question;
|
|
27
29
|
if (q && q.id) return { reason: questionReason(q), id: q.id };
|
|
@@ -137,6 +139,7 @@ export function attentionText(session, attention) {
|
|
|
137
139
|
const body = {
|
|
138
140
|
question: "needs an answer",
|
|
139
141
|
approval: "needs approval",
|
|
142
|
+
continue: "hit its step limit",
|
|
140
143
|
card: "needs you",
|
|
141
144
|
failed: "turn failed",
|
|
142
145
|
done: `done in ${seconds} s`
|
|
@@ -8,6 +8,23 @@ export function isApproval(pq) {
|
|
|
8
8
|
return !!pq && pq.kind === "approval";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// The step-limit question (kind "continue"): a turn ran out of iterations.
|
|
12
|
+
// Its card has a button per option (Continue, Stop) and a reason that goes
|
|
13
|
+
// with Stop; it can't be dismissed.
|
|
14
|
+
export function isContinue(pq) {
|
|
15
|
+
return !!pq && pq.kind === "continue";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const CONTINUE_REASON_PLACEHOLDER = "Stop, because… (the model reads it)";
|
|
19
|
+
|
|
20
|
+
// Why a step-limit question closed unanswered (its question_cancelled reason).
|
|
21
|
+
const CONTINUE_CLOSED = {
|
|
22
|
+
dropped: "Dropped: a new prompt came",
|
|
23
|
+
answered: "Answered with /continue",
|
|
24
|
+
superseded: "Set aside for another question",
|
|
25
|
+
replaced: "Asked again",
|
|
26
|
+
};
|
|
27
|
+
|
|
11
28
|
// A delegate's approval relayed to this session's user (the approval
|
|
12
29
|
// relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
|
|
13
30
|
// task, or null.
|
|
@@ -79,6 +96,7 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
|
|
|
79
96
|
const approval = isApproval(pq);
|
|
80
97
|
const relay = relayView(pq);
|
|
81
98
|
if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
|
|
99
|
+
if (isContinue(pq)) return continueResultText({ answer, cancelled, reason });
|
|
82
100
|
if (cancelled) {
|
|
83
101
|
const label = approval ? "Denied" : "Cancelled";
|
|
84
102
|
return reason ? `${label} (${reason})` : label;
|
|
@@ -97,6 +115,14 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
|
|
|
97
115
|
return parts.length ? `Answered: ${parts.join(" · ")}` : "Answered";
|
|
98
116
|
}
|
|
99
117
|
|
|
118
|
+
function continueResultText({ answer, cancelled, reason }) {
|
|
119
|
+
if (cancelled) return CONTINUE_CLOSED[reason] || (reason ? `Closed (${reason})` : "Closed");
|
|
120
|
+
const sel = answer && Array.isArray(answer.selected) ? answer.selected : [];
|
|
121
|
+
const freeform = answer && answer.freeform ? String(answer.freeform) : "";
|
|
122
|
+
if (sel[0] === "Continue") return "Continued";
|
|
123
|
+
return freeform ? `Stopped: ${freeform}` : "Stopped";
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
// Shorten a line of text for the card's summary (the collapsed one-liner).
|
|
101
127
|
export function truncate(text, n = 80) {
|
|
102
128
|
const s = String(text ?? "");
|
|
@@ -115,7 +141,8 @@ export function summaryText(pq, { answer = null, cancelled = false, reason = ""
|
|
|
115
141
|
const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
|
|
116
142
|
return truncate(relay ? `${relay.who}: ${text}` : text, 120);
|
|
117
143
|
}
|
|
118
|
-
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?`
|
|
144
|
+
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?`
|
|
145
|
+
: isContinue(pq) ? "Step limit" : "Question";
|
|
119
146
|
return truncate(pq.header || label);
|
|
120
147
|
}
|
|
121
148
|
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
16
|
import { diffHtml } from "./diff_view.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction, relayedToView, resultText,
|
|
19
|
+
summaryText,
|
|
20
|
+
} from "./question_card.js";
|
|
18
21
|
import { sessionHash } from "./route.js";
|
|
19
22
|
import { clientLabel, continueLine, isOwn } from "./turn_events.js";
|
|
20
23
|
|
|
@@ -50,8 +53,11 @@ export function createQuestionCards({
|
|
|
50
53
|
removeHintIfEmpty();
|
|
51
54
|
|
|
52
55
|
const approval = approvalView(pq);
|
|
56
|
+
const stepLimit = isContinue(pq);
|
|
57
|
+
// The step-limit question stands for the old continue card.
|
|
58
|
+
if (stepLimit) dropContinueCard();
|
|
53
59
|
const card = doc.createElement("details");
|
|
54
|
-
card.className = approval ? "bubble question approval" : "bubble question";
|
|
60
|
+
card.className = approval ? "bubble question approval" : stepLimit ? "bubble question step-limit" : "bubble question";
|
|
55
61
|
card.dataset.qid = pq.id;
|
|
56
62
|
// Open while pending: the question must be actionable.
|
|
57
63
|
card.open = true;
|
|
@@ -77,7 +83,9 @@ export function createQuestionCards({
|
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
const options = Array.isArray(pq.options) ? pq.options : [];
|
|
80
|
-
if (
|
|
86
|
+
if (stepLimit) {
|
|
87
|
+
renderStepLimitActions(card, pq, options);
|
|
88
|
+
} else if (options.length) {
|
|
81
89
|
const list = doc.createElement("div");
|
|
82
90
|
list.className = "question-options";
|
|
83
91
|
const inputType = pq.multi_select ? "checkbox" : "radio";
|
|
@@ -98,6 +106,14 @@ export function createQuestionCards({
|
|
|
98
106
|
card.appendChild(list);
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
if (stepLimit) {
|
|
110
|
+
const follow = isNearBottom();
|
|
111
|
+
place(card);
|
|
112
|
+
questionEl = card;
|
|
113
|
+
if (follow) scrollToEnd();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
101
117
|
let freeformInput = null;
|
|
102
118
|
if (pq.allow_freeform) {
|
|
103
119
|
freeformInput = doc.createElement("input");
|
|
@@ -168,6 +184,49 @@ export function createQuestionCards({
|
|
|
168
184
|
if (follow) scrollToEnd();
|
|
169
185
|
}
|
|
170
186
|
|
|
187
|
+
// The step-limit question's actions: a button per option (Continue,
|
|
188
|
+
// Stop), the reason (it goes with Stop) and no dismiss: the turn waits
|
|
189
|
+
// on one or the other. A Continue with a reason in the box is refused
|
|
190
|
+
// here, as the worker would.
|
|
191
|
+
function renderStepLimitActions(card, pq, options) {
|
|
192
|
+
const reason = doc.createElement("input");
|
|
193
|
+
reason.type = "text";
|
|
194
|
+
reason.className = "question-freeform continue-reason";
|
|
195
|
+
reason.placeholder = CONTINUE_REASON_PLACEHOLDER;
|
|
196
|
+
const err = doc.createElement("div");
|
|
197
|
+
err.className = "question-error hidden";
|
|
198
|
+
const row = doc.createElement("div");
|
|
199
|
+
row.className = "continue-actions";
|
|
200
|
+
const send = (label) => {
|
|
201
|
+
const freeform = label === "Continue" ? "" : reason.value.trim();
|
|
202
|
+
if (label === "Continue" && reason.value.trim()) {
|
|
203
|
+
err.textContent = "Continue takes no reason: clear it, or Stop with it.";
|
|
204
|
+
err.classList.remove("hidden");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
err.classList.add("hidden");
|
|
208
|
+
card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
|
|
209
|
+
card.classList.add("submitting");
|
|
210
|
+
api.sendAnswer(sessionId(), { id: pq.id, selected: [label], ...(freeform ? { freeform } : {}) }).catch((e) => {
|
|
211
|
+
card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
|
|
212
|
+
card.classList.remove("submitting");
|
|
213
|
+
err.textContent = /503|not_live/.test(e.message) ? "Session is not running — restart it to answer." : e.message;
|
|
214
|
+
err.classList.remove("hidden");
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
options.forEach((label, i) => {
|
|
218
|
+
const button = doc.createElement("button");
|
|
219
|
+
button.className = i === 0 ? "question-choice continue-yes" : "question-choice continue-no ghost";
|
|
220
|
+
button.dataset.value = String(label);
|
|
221
|
+
button.textContent = String(label);
|
|
222
|
+
button.addEventListener("click", () => send(String(label)));
|
|
223
|
+
row.appendChild(button);
|
|
224
|
+
});
|
|
225
|
+
if (pq.allow_freeform) row.appendChild(reason);
|
|
226
|
+
card.appendChild(row);
|
|
227
|
+
card.appendChild(err);
|
|
228
|
+
}
|
|
229
|
+
|
|
171
230
|
// The tool, the command (or paths, or args) as code, where and why; no
|
|
172
231
|
// code box when there is nothing to show.
|
|
173
232
|
function renderApprovalDetails(view) {
|
|
@@ -282,6 +341,9 @@ export function createQuestionCards({
|
|
|
282
341
|
|
|
283
342
|
function markQuestionSelection(sel) {
|
|
284
343
|
if (!questionEl || !sel.length) return;
|
|
344
|
+
questionEl.querySelectorAll(".question-choice").forEach((button) => {
|
|
345
|
+
if (sel.includes(button.dataset.value)) button.classList.add("selected");
|
|
346
|
+
});
|
|
285
347
|
questionEl.querySelectorAll(".question-option input").forEach((input) => {
|
|
286
348
|
if (sel.includes(input.value)) {
|
|
287
349
|
input.checked = true;
|
|
@@ -317,8 +379,17 @@ export function createQuestionCards({
|
|
|
317
379
|
dropped: "Dropped: a new prompt came",
|
|
318
380
|
};
|
|
319
381
|
|
|
382
|
+
// The old card goes: the step-limit question asks the same (a worker that
|
|
383
|
+
// posts it announces continue_offered too, for older pages).
|
|
384
|
+
function dropContinueCard() {
|
|
385
|
+
continueEl?.remove();
|
|
386
|
+
continueEl = null;
|
|
387
|
+
}
|
|
388
|
+
|
|
320
389
|
function renderContinueCard(offer) {
|
|
321
390
|
if (continueEl?.isConnected) return;
|
|
391
|
+
// A step-limit question is up: it is the card (an older worker has none).
|
|
392
|
+
if (isContinue(pending)) return;
|
|
322
393
|
removeHintIfEmpty();
|
|
323
394
|
const card = doc.createElement("div");
|
|
324
395
|
card.className = "bubble continue";
|
|
@@ -70,6 +70,7 @@ export function withChildrenAfterParents(list) {
|
|
|
70
70
|
const WAITING_BADGES = {
|
|
71
71
|
question: { text: "question", title: "Waiting for your answer" },
|
|
72
72
|
approval: { text: "approval", title: "Waiting for your approval" },
|
|
73
|
+
continue: { text: "out of steps", title: "The turn hit its step limit: continue or stop it" },
|
|
73
74
|
card: { text: "needs you", title: "A card in the running turn waits for you" },
|
|
74
75
|
};
|
|
75
76
|
|
|
@@ -55,6 +55,47 @@ export function normalizeTiming(data = {}) {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// A turn-end re-read's timing (+data+, the server's ?tail=1&recent=1: the
|
|
59
|
+
// newest turn's records and turn_count) merged into the page's (+timing+,
|
|
60
|
+
// normalizeTiming's shape). Records by id: a known one is replaced in
|
|
61
|
+
// place; an unknown turn goes in started_at order (ties and unparsable
|
|
62
|
+
// dates keep arrival order), an unknown tool record at the end (they come
|
|
63
|
+
// grouped by turn, in call order). startedAt, sessionDurationMs and
|
|
64
|
+
// activeTurn are the reply's. `short`: the reply's turn_count says the page
|
|
65
|
+
// misses a turn (it then reads the whole timing). A reply without
|
|
66
|
+
// turn_count (an older server: the whole timing) replaces, as before; more
|
|
67
|
+
// records than the count (a worker died before saving a turn the page saw)
|
|
68
|
+
// are kept.
|
|
69
|
+
// @return {{timing: object, short: boolean}}
|
|
70
|
+
export function mergeTiming(timing, data = {}) {
|
|
71
|
+
const fresh = normalizeTiming(data || {});
|
|
72
|
+
const count = data?.turn_count;
|
|
73
|
+
if (typeof count !== "number" || !Number.isFinite(count)) return { timing: fresh, short: false };
|
|
74
|
+
const turnRecords = (timing?.turnRecords || []).slice();
|
|
75
|
+
fresh.turnRecords.forEach((record) => {
|
|
76
|
+
if (replaceById(turnRecords, record)) return;
|
|
77
|
+
const started = Date.parse(record.started_at);
|
|
78
|
+
const at = Number.isFinite(started)
|
|
79
|
+
? turnRecords.findIndex((r) => Date.parse(r?.started_at) > started)
|
|
80
|
+
: -1;
|
|
81
|
+
if (at < 0) turnRecords.push(record);
|
|
82
|
+
else turnRecords.splice(at, 0, record);
|
|
83
|
+
});
|
|
84
|
+
const toolRecords = (timing?.toolRecords || []).slice();
|
|
85
|
+
fresh.toolRecords.forEach((record) => {
|
|
86
|
+
if (!replaceById(toolRecords, record)) toolRecords.push(record);
|
|
87
|
+
});
|
|
88
|
+
return { timing: { ...fresh, turnRecords, toolRecords }, short: turnRecords.length < count };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function replaceById(records, record) {
|
|
92
|
+
if (record?.id == null) return false;
|
|
93
|
+
const at = records.findIndex((r) => r?.id === record.id);
|
|
94
|
+
if (at < 0) return false;
|
|
95
|
+
records[at] = record;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
58
99
|
export function turnRecordAt(timing, index) {
|
|
59
100
|
const records = timing?.turnRecords;
|
|
60
101
|
return Array.isArray(records) ? records[index] || null : null;
|
|
@@ -112,6 +112,60 @@ export function sessionCommandLine(text, commands) {
|
|
|
112
112
|
.some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// A line that is one word: a slash and a name, no spaces, no second slash
|
|
116
|
+
// (Commands::Registry#unknown_command_word?). `/modle` is one; `/foo bar`
|
|
117
|
+
// and `/usr/bin/env` are not (they are prompts).
|
|
118
|
+
const UNKNOWN_COMMAND_WORD = /^\/[A-Za-z][A-Za-z0-9_-]*$/;
|
|
119
|
+
|
|
120
|
+
// The hint for a command word no command answers (a typo like `/modle`),
|
|
121
|
+
// or null when the line is not one: `Unknown command /modle. Did you mean
|
|
122
|
+
// /model? /help lists the commands.` The "Did you mean" part needs a close
|
|
123
|
+
// name among the session's commands and the page's own (webLocalReply).
|
|
124
|
+
export function unknownCommandHint(text, commands) {
|
|
125
|
+
const line = String(text || "").trim();
|
|
126
|
+
if (!UNKNOWN_COMMAND_WORD.test(line)) return null;
|
|
127
|
+
if (sessionCommandLine(line, commands)) return null;
|
|
128
|
+
const names = (Array.isArray(commands) ? commands : [])
|
|
129
|
+
.map((c) => (c && typeof c.name === "string" ? c.name : null))
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.concat(Object.keys(WEB_LOCAL_REPLIES));
|
|
132
|
+
const close = closestName(line, names);
|
|
133
|
+
return `Unknown command ${line}. ${close ? `Did you mean ${close}? ` : ""}/help lists the commands.`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The closest name within an optimal string alignment distance of 2, or
|
|
137
|
+
// null. Ties go to the first in the list.
|
|
138
|
+
function closestName(line, names) {
|
|
139
|
+
let best = null;
|
|
140
|
+
let bestDistance = 3;
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
const distance = osaDistance(line, name);
|
|
143
|
+
if (distance < bestDistance) {
|
|
144
|
+
best = name;
|
|
145
|
+
bestDistance = distance;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return best;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Optimal string alignment distance: Levenshtein, with an adjacent
|
|
152
|
+
// transposition counting as 1 (so `/modle` is 1 from `/model`, as Ruby's
|
|
153
|
+
// DidYouMean reads it).
|
|
154
|
+
function osaDistance(a, b) {
|
|
155
|
+
const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
156
|
+
for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
|
|
157
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
158
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
159
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
160
|
+
rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
|
|
161
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
162
|
+
rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return rows[a.length][b.length];
|
|
167
|
+
}
|
|
168
|
+
|
|
115
169
|
// The terminal's own commands (a button in the web, or none yet): the
|
|
116
170
|
// reply the page shows itself (they never go to the worker, which doesn't
|
|
117
171
|
// know them), or null.
|
|
@@ -129,6 +183,13 @@ export function webLocalReply(text) {
|
|
|
129
183
|
return WEB_LOCAL_REPLIES[name] || null;
|
|
130
184
|
}
|
|
131
185
|
|
|
186
|
+
// The start page's first message when the page answers it itself
|
|
187
|
+
// (webLocalReply: /stats, /exit …): shown there, with no session made just
|
|
188
|
+
// to hold the reply. With images it is a message, as in a session.
|
|
189
|
+
export function startPageReply(text, { images = 0 } = {}) {
|
|
190
|
+
return images ? null : webLocalReply(text);
|
|
191
|
+
}
|
|
192
|
+
|
|
132
193
|
// The continue card's answers, as the terminal's continue prompt reads them.
|
|
133
194
|
export function continueLine(choice, reason = "") {
|
|
134
195
|
const why = String(reason || "").trim();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// An open tab keeps its JS across a `chi web` restart (its event streams
|
|
2
|
+
// just reconnect), so after an upgrade old page code talks to a new server.
|
|
3
|
+
// The page knows the version that served it (body data-version); each
|
|
4
|
+
// GET /api/events snapshot names the one serving now.
|
|
5
|
+
|
|
6
|
+
// The toast's text when the chi web serving now is another version than
|
|
7
|
+
// the one that served this page, else null: also when either side doesn't
|
|
8
|
+
// say (an older chi web, a page from before data-version), and when the
|
|
9
|
+
// toast was already shown for this version (+shown+: the reconnects'
|
|
10
|
+
// snapshots carry it again).
|
|
11
|
+
export function updateNotice(loaded, served, shown = null) {
|
|
12
|
+
const known = (v) => typeof v === "string" && v !== "";
|
|
13
|
+
if (!known(loaded) || !known(served) || loaded === served || served === shown) return null;
|
|
14
|
+
return `chi was updated to ${served}`;
|
|
15
|
+
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -12,6 +12,8 @@ require_relative "session_manager"
|
|
|
12
12
|
require_relative "archive_store"
|
|
13
13
|
require_relative "log"
|
|
14
14
|
require_relative "turn_flow"
|
|
15
|
+
require_relative "continue_offer"
|
|
16
|
+
require_relative "iteration_limit"
|
|
15
17
|
require_relative "session_commands"
|
|
16
18
|
require_relative "model_profile"
|
|
17
19
|
|
|
@@ -44,8 +46,6 @@ module Samagotchi
|
|
|
44
46
|
# How much of a command's output goes into its :command_ran.
|
|
45
47
|
COMMAND_OUTPUT_LIMIT = 4096
|
|
46
48
|
BUSY_OUTPUT = "busy: wait for the turn to end"
|
|
47
|
-
DEFAULT_MAX_ITERATIONS = 100
|
|
48
|
-
NO_INTERRUPT_MAX_ITERATIONS = 1000 # as Engine::NO_INTERRUPT_MAX_ITERATIONS
|
|
49
49
|
|
|
50
50
|
# Wakes the worker loop. Whoever queues work writes it first and wakes
|
|
51
51
|
# after, and #wait drains every wake before the loop looks for work: a
|
|
@@ -108,6 +108,13 @@ module Samagotchi
|
|
|
108
108
|
# an empty session until the first turn.
|
|
109
109
|
@engine.session = @session
|
|
110
110
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
111
|
+
# Its question's answer is queued as the matching /continue (the
|
|
112
|
+
# Bridge exists by the time anyone answers).
|
|
113
|
+
@continue_offer = ContinueOffer.new(engine: @engine, turn_flow: @turn_flow, run_turn: method(:run_engine_turn),
|
|
114
|
+
max_iterations: method(:max_iterations),
|
|
115
|
+
queue_command: lambda { |line, client_id:|
|
|
116
|
+
@bridge.queue_command(line, client_id: client_id, card: true)
|
|
117
|
+
})
|
|
111
118
|
# A recap written while a continue offer waits says the turn stopped
|
|
112
119
|
# unfinished (before the idle jobs start).
|
|
113
120
|
@engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
|
|
@@ -203,7 +210,10 @@ module Samagotchi
|
|
|
203
210
|
ensure
|
|
204
211
|
# The anytime commands finish and the plugins' services stop (a
|
|
205
212
|
# server process), whatever the way out; the Bridge last, so a
|
|
206
|
-
# command's command_ran still reaches its UI on a crash.
|
|
213
|
+
# command's command_ran still reaches its UI on a crash. A
|
|
214
|
+
# step-limit question stays in the file (a save here would write
|
|
215
|
+
# over a stop's status): with no live worker the lists don't read
|
|
216
|
+
# it as waiting, and the next worker drops it (drop_dead_question).
|
|
207
217
|
@engine&.shutdown
|
|
208
218
|
@bridge&.stop
|
|
209
219
|
end
|
|
@@ -330,7 +340,7 @@ module Samagotchi
|
|
|
330
340
|
# @param images [Array<Hash>] the prompt's image refs ({file:, name:})
|
|
331
341
|
def run_prompt(prompt, origin, no_interrupt: false, images: [])
|
|
332
342
|
user_input(origin&.dig(:client_id))
|
|
333
|
-
|
|
343
|
+
@continue_offer.drop(origin)
|
|
334
344
|
@turn_flow.before_prompt_turn
|
|
335
345
|
@merged_this_turn = []
|
|
336
346
|
run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
|
|
@@ -339,7 +349,7 @@ module Samagotchi
|
|
|
339
349
|
# The Engine announced :turn_failed (with the error's one line).
|
|
340
350
|
restore_failed_turn([[prompt, origin, images], *@merged_this_turn], error: error)
|
|
341
351
|
else
|
|
342
|
-
after_turn(result, no_interrupt: no_interrupt)
|
|
352
|
+
@continue_offer.after_turn(result, no_interrupt: no_interrupt)
|
|
343
353
|
end
|
|
344
354
|
end
|
|
345
355
|
end
|
|
@@ -355,16 +365,16 @@ module Samagotchi
|
|
|
355
365
|
@engine.clear_due_reminder_names!
|
|
356
366
|
return false unless @engine.reminders_due?
|
|
357
367
|
|
|
358
|
-
|
|
368
|
+
@continue_offer.drop({ client_id: SessionManager::REMINDER_CLIENT_ID })
|
|
359
369
|
# A failure has no prompt to hand back (the Engine announced
|
|
360
370
|
# :turn_failed). The offer went before the turn
|
|
361
|
-
# (
|
|
371
|
+
# (ContinueOffer#drop); either way the rollback window closes.
|
|
362
372
|
run_engine_turn(nil, continue: true, origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
|
|
363
|
-
max_iterations:
|
|
373
|
+
max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
|
|
364
374
|
true
|
|
365
375
|
end
|
|
366
376
|
|
|
367
|
-
def max_iterations(no_interrupt) = no_interrupt
|
|
377
|
+
def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
|
|
368
378
|
|
|
369
379
|
# @return [Boolean] whether any command ran
|
|
370
380
|
def run_queued_commands
|
|
@@ -398,22 +408,18 @@ module Samagotchi
|
|
|
398
408
|
end
|
|
399
409
|
|
|
400
410
|
def run_command(command)
|
|
401
|
-
awaiting = @
|
|
411
|
+
awaiting = @continue_offer.awaiting?
|
|
402
412
|
result, shown = run_command_line(command)
|
|
403
|
-
resolved = awaiting
|
|
413
|
+
resolved = @continue_offer.resolved?(awaiting, result)
|
|
404
414
|
@engine.synchronize_events do
|
|
405
|
-
if resolved
|
|
406
|
-
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
|
|
407
|
-
end
|
|
415
|
+
@continue_offer.announce_resolved(result, command) if resolved
|
|
408
416
|
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
|
|
409
417
|
shown.each { |event| @engine.announce(event) }
|
|
410
418
|
end
|
|
411
419
|
save_session unless Array(result.changed).empty?
|
|
412
420
|
user_input(command[:client_id]) if resolved
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
@engine.record_activity if resolved && !result.resume
|
|
416
|
-
run_continue_turn(command) if result.resume
|
|
421
|
+
@continue_offer.after_command(result, resolved: resolved)
|
|
422
|
+
@continue_offer.run_continue_turn(command) if result.resume
|
|
417
423
|
end
|
|
418
424
|
|
|
419
425
|
# @return [Array(SessionCommands::Result, Array<Hash>)] the result, and
|
|
@@ -466,22 +472,6 @@ module Samagotchi
|
|
|
466
472
|
@engine.announce(event)
|
|
467
473
|
end
|
|
468
474
|
|
|
469
|
-
# The continue offer was answered yes: resume the conversation without a
|
|
470
|
-
# user message, with the iteration limit the offer's turn had. A failure
|
|
471
|
-
# keeps the offer, as the REPL does ("continue prompt preserved").
|
|
472
|
-
def run_continue_turn(command)
|
|
473
|
-
offer = @turn_flow.offer
|
|
474
|
-
@turn_flow.before_continue_turn
|
|
475
|
-
run_engine_turn(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
|
|
476
|
-
max_iterations: max_iterations(offer[:no_interrupt])) do |result, error|
|
|
477
|
-
if error
|
|
478
|
-
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
479
|
-
else
|
|
480
|
-
after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
|
-
end
|
|
484
|
-
|
|
485
475
|
# One Engine turn, the same for a prompt, a reminder and a continue:
|
|
486
476
|
# the session shows as running to readers of the file (the web's
|
|
487
477
|
# session list; the Engine resets it to idle when it ends), commands
|
|
@@ -502,31 +492,13 @@ module Samagotchi
|
|
|
502
492
|
end
|
|
503
493
|
yield result, error
|
|
504
494
|
response = result&.output
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
# the continue offer of a turn that ran out of iterations, which every
|
|
511
|
-
# UI hears about.
|
|
512
|
-
def after_turn(result, continue: false, no_interrupt: false)
|
|
513
|
-
outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
|
|
514
|
-
return unless outcome == :continue_offered || outcome == :continue_cancelled
|
|
515
|
-
|
|
516
|
-
offer = @turn_flow.offer
|
|
517
|
-
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
# A prompt (or a reminder turn) taken while a continue is offered
|
|
521
|
-
# replaces the answer (D2): the offer goes, the partial turn stays.
|
|
522
|
-
def drop_continue_offer(origin)
|
|
523
|
-
return unless @turn_flow.awaiting_continue?
|
|
524
|
-
|
|
525
|
-
@engine.synchronize_events do
|
|
526
|
-
@turn_flow.drop_offer!
|
|
527
|
-
@engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
|
|
495
|
+
# A turn that ran out of iterations and asks whether to continue left
|
|
496
|
+
# no reply: its text is what came before its last tool calls, and a
|
|
497
|
+
# wait (chi send --wait) gets the question instead.
|
|
498
|
+
unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
|
|
499
|
+
SessionInbox.write_output(@session_dir, response)
|
|
528
500
|
end
|
|
529
|
-
|
|
501
|
+
save_session
|
|
530
502
|
end
|
|
531
503
|
|
|
532
504
|
# Back to the conversation before the failed turn, as the REPL does (so
|
|
@@ -42,9 +42,12 @@ module Samagotchi
|
|
|
42
42
|
return nil unless sidecar&.port&.positive?
|
|
43
43
|
|
|
44
44
|
begin
|
|
45
|
-
# Not Socket.tcp: with connect_timeout
|
|
46
|
-
#
|
|
47
|
-
# worker's port read as
|
|
45
|
+
# Not Socket.tcp: with connect_timeout on macOS 26 it hands back the
|
|
46
|
+
# socket of a refused connect (the kernel's second connect() says
|
|
47
|
+
# EISCONN, SO_ERROR is unchecked), so a dead worker's port read as
|
|
48
|
+
# live. Ruby bug #22223, fixed in 3.4.11 / 4.0.7, not in 3.3.
|
|
49
|
+
# TCPSocket checks SO_ERROR and raises ECONNREFUSED on every Ruby,
|
|
50
|
+
# so this stays even once 3.3 is dropped.
|
|
48
51
|
TCPSocket.new(host, sidecar.port, connect_timeout: timeout).close
|
|
49
52
|
sidecar
|
|
50
53
|
rescue StandardError
|