samagotchi 0.16.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 +149 -1
- data/README.md +2 -0
- data/docs/architecture.md +38 -2
- data/docs/cli.md +37 -9
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +41 -0
- data/docs/plugins.md +5 -2
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +43 -4
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +86 -14
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +121 -4
- 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 +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- 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 +73 -19
- 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/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +136 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +46 -6
- 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/session_metrics.rb +137 -48
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +46 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +35 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- 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/peers.rb +3 -2
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +1 -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 +189 -43
- data/lib/samagotchi/web/public/app.js +157 -51
- 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 +12 -5
- 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/stage_view.js +13 -1
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +67 -12
- data/lib/samagotchi/web/public/turn_events.js +74 -3
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +58 -60
- data/lib/samagotchi/worker_sidecar.rb +7 -1
- metadata +10 -1
|
@@ -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
|
|
|
@@ -360,6 +360,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
360
360
|
// never above the session panel (the dock is absolute: a tall composer or
|
|
361
361
|
// a phone's keyboard would push the stage's top out) ────────────────────
|
|
362
362
|
|
|
363
|
+
// Scroll the stage so +slot+'s end shows (it is the scroller's child).
|
|
364
|
+
function revealSlot(slot) {
|
|
365
|
+
if (stage.hidden || slot.hidden) return;
|
|
366
|
+
const end = slot.offsetTop + slot.offsetHeight - scroller.clientHeight;
|
|
367
|
+
if (end > scroller.scrollTop) scroller.scrollTop = end;
|
|
368
|
+
}
|
|
369
|
+
|
|
363
370
|
function fitCap() {
|
|
364
371
|
if (stage.hidden) return;
|
|
365
372
|
const rest = dockEl.offsetHeight - stage.offsetHeight;
|
|
@@ -593,8 +600,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
593
600
|
const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
|
|
594
601
|
slot.appendChild(el);
|
|
595
602
|
slot.hidden = false;
|
|
596
|
-
|
|
603
|
+
const waits = el.matches?.(PENDING) || el.querySelector?.(ASKS);
|
|
604
|
+
if (stick || waits) slot.scrollTop = slot.scrollHeight;
|
|
597
605
|
scheduleRefresh();
|
|
606
|
+
// A card that waits for the user is brought into the stage's view: in
|
|
607
|
+
// a short window the prompt and headline fill it and the card sat
|
|
608
|
+
// below, out of sight (after the refresh, which hides the live slots).
|
|
609
|
+
if (waits) requestAnimationFrame(() => requestAnimationFrame(() => revealSlot(slot)));
|
|
598
610
|
return true;
|
|
599
611
|
},
|
|
600
612
|
// Where the live timing line goes (the status row), null: the history.
|
|
@@ -30,3 +30,14 @@ export function stripShowParts(sessions) {
|
|
|
30
30
|
waiting: waitingN ? `${waitingN} waiting` : "",
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
// A window this short hides the strip by itself: at ~500-700 px the stage
|
|
35
|
+
// is capped low and a card that waits there (a question, an approval) is
|
|
36
|
+
// cut off below the strip.
|
|
37
|
+
export const SHORT_WINDOW_QUERY = "(max-height: 700px)";
|
|
38
|
+
|
|
39
|
+
// Whether the strip is hidden: the user's own hide (saved), or a short
|
|
40
|
+
// window unless the user showed it (the pill) since it got short.
|
|
41
|
+
export function stripAutoHidden({ saved, short, shownWhileShort }) {
|
|
42
|
+
return !!saved || (!!short && !shownWhileShort);
|
|
43
|
+
}
|
|
@@ -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;
|
|
@@ -185,37 +226,51 @@ function recordRows(tools, iteration) {
|
|
|
185
226
|
}
|
|
186
227
|
|
|
187
228
|
// A turn whose saved messages carry their parts (the server's ?parts=1):
|
|
188
|
-
// every assistant message is one
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// it
|
|
229
|
+
// every assistant message is one step. Its calls take the turn's records by
|
|
230
|
+
// tool, in call order (each the next record of its tool, consumed with the
|
|
231
|
+
// ones it skipped), not by iteration: an empty retry spends an iteration
|
|
232
|
+
// that left no message (as the TUI's join, attached_loop.rb join_duration).
|
|
233
|
+
// The last message is the answer when it has text and made no call (and the
|
|
234
|
+
// turn is no canceled multi-step one); its thinking stays behind as a step,
|
|
235
|
+
// as it does live.
|
|
193
236
|
function partsTurn(g, items, record, tools, stepsOnly = false) {
|
|
194
237
|
const last = g.texts[g.texts.length - 1];
|
|
195
238
|
const lastItem = items[last];
|
|
196
239
|
const answer = !stepsOnly && String(lastItem.content ?? "") !== "" && !lastItem.parts?.tools?.length ? last : null;
|
|
240
|
+
const pool = [...tools].sort((a, b) =>
|
|
241
|
+
(Number(a.iteration) || 0) - (Number(b.iteration) || 0) || (Number(a.call_index) || 0) - (Number(b.call_index) || 0));
|
|
197
242
|
const steps = g.texts.map((i, k) => {
|
|
198
243
|
const iteration = k + 1;
|
|
199
244
|
const parts = items[i].parts || {};
|
|
200
|
-
const records = recordRows(tools, iteration);
|
|
201
245
|
const rows = Array.isArray(parts.tools)
|
|
202
246
|
? parts.tools.map((call, j) => {
|
|
203
|
-
const r =
|
|
247
|
+
const r = takeRecord(pool, call.tool);
|
|
204
248
|
return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...call };
|
|
205
249
|
})
|
|
206
|
-
:
|
|
250
|
+
: [];
|
|
207
251
|
const thinking = String(parts.thinking || "");
|
|
208
252
|
if (i === answer) return thinking ? { i: null, iteration, thinking, tools: [] } : null;
|
|
209
253
|
return { i, iteration, thinking, tools: rows };
|
|
210
254
|
}).filter(Boolean);
|
|
211
|
-
// Records
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
255
|
+
// Records no saved message took (a generation that saved nothing): a
|
|
256
|
+
// step per iteration of theirs, after the messages'.
|
|
257
|
+
let iteration = g.texts.length;
|
|
258
|
+
for (const n of [...new Set(pool.map((r) => Number(r.iteration) || 0))]) {
|
|
259
|
+
iteration += 1;
|
|
260
|
+
const rows = recordRows(pool, n).map((row) => ({ ...row, key: `${iteration}:${row.key.split(":")[1]}` }));
|
|
261
|
+
steps.push({ i: null, iteration, thinking: "", tools: rows });
|
|
215
262
|
}
|
|
216
263
|
return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
|
|
217
264
|
}
|
|
218
265
|
|
|
266
|
+
// The next record of +tool+ in +pool+ (call order), removed with the ones
|
|
267
|
+
// before it; undefined when there is none (the pool stays as it was).
|
|
268
|
+
function takeRecord(pool, tool) {
|
|
269
|
+
const at = pool.findIndex((r) => String(r.tool) === String(tool));
|
|
270
|
+
if (at < 0) return undefined;
|
|
271
|
+
return pool.splice(0, at + 1)[at];
|
|
272
|
+
}
|
|
273
|
+
|
|
219
274
|
// Append +el+ to the history, above +timingEl+ while it is a running turn's
|
|
220
275
|
// live timing line, so that line stays under the turn's activity, thinking
|
|
221
276
|
// and answer.
|
|
@@ -97,9 +97,73 @@ export function initWaitLine({ tasks } = {}) {
|
|
|
97
97
|
return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
// Whether a composer line in an open session is one of its commands
|
|
101
|
+
// (+commands+: the session GET's listing), as the terminals decide
|
|
102
|
+
// (Commands::Registry#lookup): a "!" line with something after the "!", or
|
|
103
|
+
// a listed name alone or followed by a space; a terminal's own (/stats,
|
|
104
|
+
// /exit) too, which the page answers itself (webLocalReply). An unknown
|
|
105
|
+
// "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
|
|
106
|
+
export function sessionCommandLine(text, commands) {
|
|
107
|
+
const line = String(text || "").trim();
|
|
108
|
+
if (/^!\s*\S/.test(line)) return true;
|
|
109
|
+
if (!line.startsWith("/")) return false;
|
|
110
|
+
if (webLocalReply(line)) return true;
|
|
111
|
+
return (Array.isArray(commands) ? commands : [])
|
|
112
|
+
.some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
|
|
113
|
+
}
|
|
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];
|
|
103
167
|
}
|
|
104
168
|
|
|
105
169
|
// The terminal's own commands (a button in the web, or none yet): the
|
|
@@ -119,6 +183,13 @@ export function webLocalReply(text) {
|
|
|
119
183
|
return WEB_LOCAL_REPLIES[name] || null;
|
|
120
184
|
}
|
|
121
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
|
+
|
|
122
193
|
// The continue card's answers, as the terminal's continue prompt reads them.
|
|
123
194
|
export function continueLine(choice, reason = "") {
|
|
124
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? }
|
|
@@ -173,7 +180,7 @@ module Samagotchi
|
|
|
173
180
|
absorb_notes
|
|
174
181
|
|
|
175
182
|
if (prompt = take_initial_prompt)
|
|
176
|
-
run_prompt(prompt, nil)
|
|
183
|
+
run_prompt(prompt, nil) unless initial_command(prompt)
|
|
177
184
|
next
|
|
178
185
|
end
|
|
179
186
|
|
|
@@ -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
|
|
@@ -269,6 +279,29 @@ module Samagotchi
|
|
|
269
279
|
prompt
|
|
270
280
|
end
|
|
271
281
|
|
|
282
|
+
# A session command sent as a message (chi send -m "/model x", a web
|
|
283
|
+
# page's first message) runs as the command, as the Bridge does for a
|
|
284
|
+
# POST /turn; an unknown /word stays a prompt. Queued: the loop's next
|
|
285
|
+
# pass runs it.
|
|
286
|
+
# @return [Boolean] whether +text+ was one
|
|
287
|
+
def queue_as_command(text, origin)
|
|
288
|
+
return false unless @engine.command_registry.command?(text.to_s)
|
|
289
|
+
|
|
290
|
+
@bridge.queue_command(text, client_id: origin&.dig(:client_id))
|
|
291
|
+
true
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The first prompt as a command: the session was saved as running for a
|
|
295
|
+
# turn that won't run.
|
|
296
|
+
# @return [Boolean] whether it was one
|
|
297
|
+
def initial_command(prompt)
|
|
298
|
+
return false unless queue_as_command(prompt, nil)
|
|
299
|
+
|
|
300
|
+
@session.status = Session::STATUS_IDLE
|
|
301
|
+
save_session
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
|
|
272
305
|
# Add the queued context notes to the conversation (between turns only,
|
|
273
306
|
# on this thread), save, then delete their files: a crash before the
|
|
274
307
|
# delete leaves them claimed, and Engine#add_context_note skips a note
|
|
@@ -293,7 +326,10 @@ module Samagotchi
|
|
|
293
326
|
|
|
294
327
|
begin
|
|
295
328
|
message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
|
|
296
|
-
|
|
329
|
+
return if message.to_s.strip.empty?
|
|
330
|
+
return if Array(images).empty? && queue_as_command(message, origin)
|
|
331
|
+
|
|
332
|
+
run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || [])
|
|
297
333
|
ensure
|
|
298
334
|
FileUtils.rm_f(claimed_file)
|
|
299
335
|
end
|
|
@@ -304,7 +340,7 @@ module Samagotchi
|
|
|
304
340
|
# @param images [Array<Hash>] the prompt's image refs ({file:, name:})
|
|
305
341
|
def run_prompt(prompt, origin, no_interrupt: false, images: [])
|
|
306
342
|
user_input(origin&.dig(:client_id))
|
|
307
|
-
|
|
343
|
+
@continue_offer.drop(origin)
|
|
308
344
|
@turn_flow.before_prompt_turn
|
|
309
345
|
@merged_this_turn = []
|
|
310
346
|
run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
|
|
@@ -313,7 +349,7 @@ module Samagotchi
|
|
|
313
349
|
# The Engine announced :turn_failed (with the error's one line).
|
|
314
350
|
restore_failed_turn([[prompt, origin, images], *@merged_this_turn], error: error)
|
|
315
351
|
else
|
|
316
|
-
after_turn(result, no_interrupt: no_interrupt)
|
|
352
|
+
@continue_offer.after_turn(result, no_interrupt: no_interrupt)
|
|
317
353
|
end
|
|
318
354
|
end
|
|
319
355
|
end
|
|
@@ -329,16 +365,16 @@ module Samagotchi
|
|
|
329
365
|
@engine.clear_due_reminder_names!
|
|
330
366
|
return false unless @engine.reminders_due?
|
|
331
367
|
|
|
332
|
-
|
|
368
|
+
@continue_offer.drop({ client_id: SessionManager::REMINDER_CLIENT_ID })
|
|
333
369
|
# A failure has no prompt to hand back (the Engine announced
|
|
334
370
|
# :turn_failed). The offer went before the turn
|
|
335
|
-
# (
|
|
371
|
+
# (ContinueOffer#drop); either way the rollback window closes.
|
|
336
372
|
run_engine_turn(nil, continue: true, origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
|
|
337
|
-
max_iterations:
|
|
373
|
+
max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
|
|
338
374
|
true
|
|
339
375
|
end
|
|
340
376
|
|
|
341
|
-
def max_iterations(no_interrupt) = no_interrupt
|
|
377
|
+
def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
|
|
342
378
|
|
|
343
379
|
# @return [Boolean] whether any command ran
|
|
344
380
|
def run_queued_commands
|
|
@@ -372,22 +408,18 @@ module Samagotchi
|
|
|
372
408
|
end
|
|
373
409
|
|
|
374
410
|
def run_command(command)
|
|
375
|
-
awaiting = @
|
|
411
|
+
awaiting = @continue_offer.awaiting?
|
|
376
412
|
result, shown = run_command_line(command)
|
|
377
|
-
resolved = awaiting
|
|
413
|
+
resolved = @continue_offer.resolved?(awaiting, result)
|
|
378
414
|
@engine.synchronize_events do
|
|
379
|
-
if resolved
|
|
380
|
-
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
|
|
381
|
-
end
|
|
415
|
+
@continue_offer.announce_resolved(result, command) if resolved
|
|
382
416
|
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed))
|
|
383
417
|
shown.each { |event| @engine.announce(event) }
|
|
384
418
|
end
|
|
385
419
|
save_session unless Array(result.changed).empty?
|
|
386
420
|
user_input(command[:client_id]) if resolved
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
@engine.record_activity if resolved && !result.resume
|
|
390
|
-
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
|
|
391
423
|
end
|
|
392
424
|
|
|
393
425
|
# @return [Array(SessionCommands::Result, Array<Hash>)] the result, and
|
|
@@ -440,22 +472,6 @@ module Samagotchi
|
|
|
440
472
|
@engine.announce(event)
|
|
441
473
|
end
|
|
442
474
|
|
|
443
|
-
# The continue offer was answered yes: resume the conversation without a
|
|
444
|
-
# user message, with the iteration limit the offer's turn had. A failure
|
|
445
|
-
# keeps the offer, as the REPL does ("continue prompt preserved").
|
|
446
|
-
def run_continue_turn(command)
|
|
447
|
-
offer = @turn_flow.offer
|
|
448
|
-
@turn_flow.before_continue_turn
|
|
449
|
-
run_engine_turn(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
|
|
450
|
-
max_iterations: max_iterations(offer[:no_interrupt])) do |result, error|
|
|
451
|
-
if error
|
|
452
|
-
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
453
|
-
else
|
|
454
|
-
after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
|
|
455
|
-
end
|
|
456
|
-
end
|
|
457
|
-
end
|
|
458
|
-
|
|
459
475
|
# One Engine turn, the same for a prompt, a reminder and a continue:
|
|
460
476
|
# the session shows as running to readers of the file (the web's
|
|
461
477
|
# session list; the Engine resets it to idle when it ends), commands
|
|
@@ -476,31 +492,13 @@ module Samagotchi
|
|
|
476
492
|
end
|
|
477
493
|
yield result, error
|
|
478
494
|
response = result&.output
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
# the continue offer of a turn that ran out of iterations, which every
|
|
485
|
-
# UI hears about.
|
|
486
|
-
def after_turn(result, continue: false, no_interrupt: false)
|
|
487
|
-
outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
|
|
488
|
-
return unless outcome == :continue_offered || outcome == :continue_cancelled
|
|
489
|
-
|
|
490
|
-
offer = @turn_flow.offer
|
|
491
|
-
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
492
|
-
end
|
|
493
|
-
|
|
494
|
-
# A prompt (or a reminder turn) taken while a continue is offered
|
|
495
|
-
# replaces the answer (D2): the offer goes, the partial turn stays.
|
|
496
|
-
def drop_continue_offer(origin)
|
|
497
|
-
return unless @turn_flow.awaiting_continue?
|
|
498
|
-
|
|
499
|
-
@engine.synchronize_events do
|
|
500
|
-
@turn_flow.drop_offer!
|
|
501
|
-
@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)
|
|
502
500
|
end
|
|
503
|
-
|
|
501
|
+
save_session
|
|
504
502
|
end
|
|
505
503
|
|
|
506
504
|
# Back to the conversation before the failed turn, as the REPL does (so
|
|
@@ -42,7 +42,13 @@ module Samagotchi
|
|
|
42
42
|
return nil unless sidecar&.port&.positive?
|
|
43
43
|
|
|
44
44
|
begin
|
|
45
|
-
Socket.tcp
|
|
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.
|
|
51
|
+
TCPSocket.new(host, sidecar.port, connect_timeout: timeout).close
|
|
46
52
|
sidecar
|
|
47
53
|
rescue StandardError
|
|
48
54
|
FileUtils.rm_f(path(session_dir)) if unlink
|