samagotchi 0.91.0 → 0.92.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 +58 -1
- data/README.md +5 -2
- data/bin/chi +4 -4
- data/docs/architecture.md +16 -12
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -5
- data/docs/configuration.md +3 -1
- data/docs/context.md +5 -3
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +21 -8
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge.rb +8 -9
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/engine.rb +22 -12
- data/lib/samagotchi/hooks/registry.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command.rb +3 -3
- data/lib/samagotchi/session_manager.rb +12 -20
- data/lib/samagotchi/task_watch.rb +64 -21
- data/lib/samagotchi/testing/bundle.rb +32 -19
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +24 -14
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +77 -185
- data/lib/samagotchi/worker_idle_exit.rb +20 -5
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +0 -3
- metadata +6 -1
|
@@ -91,16 +91,25 @@ module Samagotchi
|
|
|
91
91
|
end
|
|
92
92
|
private_class_method :settled_record
|
|
93
93
|
|
|
94
|
+
# A command's status (its command_ran's) as a settled outcome; any
|
|
95
|
+
# other status (busy, dropped) is a cancel for that reason.
|
|
96
|
+
COMMAND_OUTCOMES = { "ok" => "completed", "error" => "failed" }.freeze
|
|
97
|
+
# Its record's origin: no turn ran (ParentReport says "the command").
|
|
98
|
+
COMMAND_ORIGIN = "command"
|
|
99
|
+
|
|
94
100
|
# A file-delivered command ends its sender's wait without running a
|
|
95
|
-
# turn. Keep a terminal outcome under the same per-input settled path
|
|
96
|
-
|
|
101
|
+
# turn. Keep a terminal outcome under the same per-input settled path:
|
|
102
|
+
# +status+ is its command_ran's ("ok", "error", "busy", "dropped" at a
|
|
103
|
+
# teardown), so the wait reads completed, failed, or canceled with that
|
|
104
|
+
# reason.
|
|
105
|
+
def self.record_command_outcome(session_dir, enqueued_id, status:)
|
|
97
106
|
id = enqueued_id.to_s
|
|
98
107
|
return if id.empty? || !SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
99
108
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
outcome = COMMAND_OUTCOMES.fetch(status.to_s, "canceled")
|
|
110
|
+
record = LastTurn.new(outcome: outcome, cancel_reason: (status.to_s if outcome == "canceled"),
|
|
111
|
+
ended_at: Time.now.iso8601(3), origin: COMMAND_ORIGIN, enqueued_id: id)
|
|
112
|
+
write_settled(session_dir, id, record)
|
|
104
113
|
rescue SystemCallError, IOError => e
|
|
105
114
|
Log.warn(:worker, "command_outcome_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
|
|
106
115
|
end
|
|
@@ -109,14 +118,18 @@ module Samagotchi
|
|
|
109
118
|
id = mark.enqueued_id.to_s
|
|
110
119
|
return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
111
120
|
|
|
112
|
-
|
|
113
|
-
FileUtils.mkdir_p(dir)
|
|
114
|
-
record = settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds)
|
|
115
|
-
AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
|
|
121
|
+
write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds))
|
|
116
122
|
rescue SystemCallError => e
|
|
117
123
|
# The settle goes on; a wait for that turn runs to its timeout.
|
|
118
124
|
Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
|
|
119
125
|
end
|
|
126
|
+
|
|
127
|
+
def self.write_settled(session_dir, id, record)
|
|
128
|
+
dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
|
|
129
|
+
FileUtils.mkdir_p(dir)
|
|
130
|
+
AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
|
|
131
|
+
end
|
|
132
|
+
private_class_method :write_settled
|
|
120
133
|
private_class_method :record_settled
|
|
121
134
|
|
|
122
135
|
# @param session [Session] the worker's session (saved as a whole)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -35,10 +35,10 @@ import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
|
|
|
35
35
|
import { renderInfoBar } from "./info_bar.js";
|
|
36
36
|
import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
|
|
37
37
|
import { createShownTexts } from "./shown_texts.js";
|
|
38
|
-
import { MODEL_KEY } from "./model_pick.js";
|
|
38
|
+
import { MODEL_KEY, modelsFailedText } from "./model_pick.js";
|
|
39
39
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
40
40
|
import { createModelPicker } from "./model_picker.js";
|
|
41
|
-
import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
41
|
+
import { admitImages, chipName, chipsAfterSend, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
42
42
|
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
|
|
43
43
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
44
44
|
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
@@ -992,7 +992,10 @@ async function readTurnEnd() {
|
|
|
992
992
|
lists.render();
|
|
993
993
|
lists.paintActive();
|
|
994
994
|
}
|
|
995
|
-
} catch(_){
|
|
995
|
+
} catch (_) {
|
|
996
|
+
// A failed read (the server or worker gone a moment) leaves the answer
|
|
997
|
+
// as the stream drew it, plain text: the session's next load renders it.
|
|
998
|
+
}
|
|
996
999
|
}
|
|
997
1000
|
|
|
998
1001
|
function seedSeenFromDom() {
|
|
@@ -1452,8 +1455,9 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
|
1452
1455
|
if (restored?.refill && sentTo === selected) openForVisit();
|
|
1453
1456
|
promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
|
|
1454
1457
|
fitPrompt();
|
|
1455
|
-
|
|
1456
|
-
|
|
1458
|
+
// Only the sent chips go: images added since stay (they go up at their own send).
|
|
1459
|
+
const kept = chipsAfterSend(chips, sending);
|
|
1460
|
+
setChips(restored?.images?.length ? kept.concat(restoredChips(sentTo, restored.images)) : kept);
|
|
1457
1461
|
// Its turn_enqueued usually tagged the echo already; the ack covers a
|
|
1458
1462
|
// worker that wasn't live (no turn_enqueued then).
|
|
1459
1463
|
if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
|
|
@@ -1548,7 +1552,7 @@ async function handleCancel() {
|
|
|
1548
1552
|
$("#newBtn").addEventListener("click", () => {
|
|
1549
1553
|
if (allViewOpen()) leaveAllView("");
|
|
1550
1554
|
clearSelection();
|
|
1551
|
-
loadModels();
|
|
1555
|
+
loadModels({ asked: true });
|
|
1552
1556
|
});
|
|
1553
1557
|
|
|
1554
1558
|
// ── The start page's model picker ──────────────────────────────────────────
|
|
@@ -1641,11 +1645,19 @@ function restoreStartPick() {
|
|
|
1641
1645
|
renderNewChatLlmContext();
|
|
1642
1646
|
renderNewChatThinking();
|
|
1643
1647
|
}
|
|
1644
|
-
|
|
1648
|
+
// A reload cuts the fetch: its error is no news (modelsFailedText).
|
|
1649
|
+
let pageLeaving = false;
|
|
1650
|
+
addEventListener("beforeunload", () => { pageLeaving = true; });
|
|
1651
|
+
addEventListener("pagehide", () => { pageLeaving = true; });
|
|
1652
|
+
addEventListener("pageshow", () => { pageLeaving = false; }); // back from the bfcache
|
|
1653
|
+
// +asked+: + new session's load, for the start page it opens.
|
|
1654
|
+
async function loadModels({ asked = false } = {}) {
|
|
1645
1655
|
let payload;
|
|
1646
1656
|
try {
|
|
1647
1657
|
payload = await listModels();
|
|
1648
|
-
} catch (
|
|
1658
|
+
} catch (e) {
|
|
1659
|
+
const text = modelsFailedText(e, { startPage: !selected, asked, leaving: pageLeaving });
|
|
1660
|
+
if (text) showToast(text);
|
|
1649
1661
|
return;
|
|
1650
1662
|
}
|
|
1651
1663
|
const stored = readStored(MODEL_KEY);
|
|
@@ -1678,16 +1690,13 @@ function renderChips() {
|
|
|
1678
1690
|
drawFoldControls();
|
|
1679
1691
|
}
|
|
1680
1692
|
|
|
1693
|
+
// A chip that goes frees its preview; one kept in +list+ keeps it.
|
|
1681
1694
|
function setChips(list) {
|
|
1682
|
-
for (const c of chips) if (c.file && c.src) URL.revokeObjectURL(c.src);
|
|
1695
|
+
for (const c of chips) if (c.file && c.src && !list.includes(c)) URL.revokeObjectURL(c.src);
|
|
1683
1696
|
chips = list;
|
|
1684
1697
|
renderChips();
|
|
1685
1698
|
}
|
|
1686
1699
|
|
|
1687
|
-
function clearChips() {
|
|
1688
|
-
setChips([]);
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
1700
|
// A paste's, a drop's or the image button's files: the images the message
|
|
1692
1701
|
// may still take become chips (images.js admitImages); a toast says why the
|
|
1693
1702
|
// rest stayed out. Whether any went in.
|
|
@@ -1806,7 +1815,8 @@ composerEl.addEventListener("drop", (e) => {
|
|
|
1806
1815
|
const unqueue = createUnqueue({
|
|
1807
1816
|
sessionId: () => selected,
|
|
1808
1817
|
clientId,
|
|
1809
|
-
toast: (text) => showToast(text),
|
|
1818
|
+
toast: (text, action, options) => showToast(text, action, options),
|
|
1819
|
+
copy: (text) => copyText(text),
|
|
1810
1820
|
// Edit: the removed text back into an empty composer (never over a draft).
|
|
1811
1821
|
putBack: (text) => {
|
|
1812
1822
|
if (promptEl.value.trim() || chips.length) return false;
|
|
@@ -150,7 +150,10 @@ export function createCardsPlace({
|
|
|
150
150
|
const el = card.type === "card" && cardElement(card.id);
|
|
151
151
|
if (el) refreshCardBody(el, card);
|
|
152
152
|
}
|
|
153
|
-
} catch (_) {
|
|
153
|
+
} catch (_) {
|
|
154
|
+
// The live text stays (a failed re-read is only a lost render);
|
|
155
|
+
// the next card or load renders it.
|
|
156
|
+
}
|
|
154
157
|
}, 150);
|
|
155
158
|
}
|
|
156
159
|
|
|
@@ -94,6 +94,12 @@ export function chipsTemplate(chips) {
|
|
|
94
94
|
html`<span class="chip" data-index=${i}><img src=${c.src} alt=""><span class="chip-name">${c.name}</span><button type="button" class="chip-remove" data-index=${i} aria-label=${`remove ${c.name}`}>×</button></span>`);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// The chips left once a send is acked: those added while it was in flight
|
|
98
|
+
// (an upload can take seconds) stay for the next message.
|
|
99
|
+
export function chipsAfterSend(chips, sent) {
|
|
100
|
+
return (chips || []).filter((c) => !sent.includes(c));
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
// The {file, name} refs a turn names.
|
|
98
104
|
export function turnRefs(chips) {
|
|
99
105
|
return (chips || []).filter((c) => c.ref).map((c) => ({ file: c.ref.file, name: c.name }));
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
// The start page's model picker: pure helpers over GET /api/models' payload.
|
|
2
|
+
import { cantText } from "./format.js";
|
|
2
3
|
|
|
3
4
|
export const MODEL_KEY = "chi_model";
|
|
4
5
|
|
|
6
|
+
// A failed GET /api/models' toast: the picker stays hidden, so it says why
|
|
7
|
+
// and what a new session gets (+ new session asks again). "" (no toast) in
|
|
8
|
+
// a session, where no picker shows (+asked+: + new session's own load),
|
|
9
|
+
// and for a fetch aborted or cut by the page unloading.
|
|
10
|
+
export function modelsFailedText(error, { startPage = true, asked = false, leaving = false } = {}) {
|
|
11
|
+
if (leaving || error?.name === "AbortError" || !(startPage || asked)) return "";
|
|
12
|
+
return `${cantText("list the models", error)}. A new session starts on the default model.`;
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
// The name to preselect from the rows (modelRows): the remembered one when
|
|
6
16
|
// it is still offered (any case), else the server's default, both even
|
|
7
17
|
// when their host is down; else the first row whose host answered, else
|
|
@@ -36,19 +36,26 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
36
36
|
entry.el);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function item(text, action, sticky) {
|
|
40
|
+
const entry = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
|
|
41
|
+
entry.el.className = "toast-item";
|
|
42
|
+
draw(entry, text, action, sticky);
|
|
43
|
+
entry.key = entry.el.querySelector(".toast-message").textContent;
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
function show(text, action = null, { sticky = false } = {}) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
// Words are their own key; a template's key is its drawn text, so only
|
|
49
|
+
// a template is drawn before the look-up.
|
|
50
|
+
const candidate = typeof text === "string" ? null : item(text, action, sticky);
|
|
51
|
+
const key = candidate ? candidate.key : text;
|
|
44
52
|
|
|
45
|
-
const duplicate = entries.find((entry) => entry.key ===
|
|
53
|
+
const duplicate = entries.find((entry) => entry.key === key);
|
|
46
54
|
if (duplicate) {
|
|
47
55
|
if (duplicate.timer !== null) clearTimeout(duplicate.timer);
|
|
48
56
|
duplicate.timer = null;
|
|
49
57
|
duplicate.sticky = sticky || duplicate.sticky;
|
|
50
58
|
duplicate.action = action || duplicate.action;
|
|
51
|
-
duplicate.key = candidate.key;
|
|
52
59
|
draw(duplicate, text, duplicate.action, duplicate.sticky);
|
|
53
60
|
entries.push(...entries.splice(entries.indexOf(duplicate), 1));
|
|
54
61
|
el.appendChild(duplicate.el);
|
|
@@ -60,9 +67,10 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
60
67
|
if (entries.length === MAX_TOASTS) {
|
|
61
68
|
remove(entries.find((entry) => !entry.sticky) || entries[0], false);
|
|
62
69
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
const entry = candidate || item(text, action, sticky);
|
|
71
|
+
entries.push(entry);
|
|
72
|
+
el.appendChild(entry.el);
|
|
73
|
+
if (!sticky) entry.timer = setTimeout(() => remove(entry), TOAST_MS);
|
|
66
74
|
sync();
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
|
|
5
5
|
// here at the 200 already. Its text comes back (edit): into an empty
|
|
6
6
|
// composer, to change and send again; over a draft only in a toast (the
|
|
7
|
-
// draft stays)
|
|
8
|
-
//
|
|
7
|
+
// draft stays), and after a switch to another session meanwhile only in a
|
|
8
|
+
// sticky toast naming its session, with Copy. Its images don't come back. A refusal keeps it
|
|
9
|
+
// and says why in a toast. No confirm: nothing is lost. Pure but for the
|
|
9
10
|
// injected calls, so `node --test` covers it; app.js binds onClick to the
|
|
10
11
|
// turn roots.
|
|
11
12
|
import { dequeueTurn as postDequeue } from "./data.js";
|
|
@@ -28,8 +29,9 @@ function clipped(text) {
|
|
|
28
29
|
|
|
29
30
|
// +sessionId+ is read at each click (the open session); +clientId+ is this
|
|
30
31
|
// 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
|
-
|
|
32
|
+
// composer when it is empty and says whether it did; +copy(text)+ says
|
|
33
|
+
// whether the clipboard took it; +toast(text, action, options)+ as showToast.
|
|
34
|
+
export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false, copy = null }) {
|
|
33
35
|
// A click on a queued bubble's ×; any other click is left alone.
|
|
34
36
|
async function onClick(e) {
|
|
35
37
|
const button = e.target?.closest?.("button.unqueue");
|
|
@@ -44,6 +46,12 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
|
|
|
44
46
|
const result = await dequeue(id, enqueuedId, { clientId });
|
|
45
47
|
removeQueuedBubble(bubble);
|
|
46
48
|
const text = String(result?.prompt || "");
|
|
49
|
+
// Another session opened meanwhile: its composer isn't where this
|
|
50
|
+
// text goes, so only the toast has it.
|
|
51
|
+
if (text.trim() && sessionId() !== id) {
|
|
52
|
+
movedOn(id, text);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
47
55
|
if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
|
|
48
56
|
return result;
|
|
49
57
|
} catch (error) {
|
|
@@ -52,5 +60,13 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
|
|
|
52
60
|
return null;
|
|
53
61
|
}
|
|
54
62
|
}
|
|
63
|
+
// The removed text after a switch: in a sticky toast, its Copy takes the
|
|
64
|
+
// whole text (the toast clips it); a failed copy shows the toast again.
|
|
65
|
+
function movedOn(id, text) {
|
|
66
|
+
const line = `Removed from ${id.slice(0, 8)}: ${clipped(text)}`;
|
|
67
|
+
const show = () => toast(line, copy ? action : null, { sticky: true });
|
|
68
|
+
const action = { label: "Copy", run: async () => { if (await copy(text)) toast("Copied"); else show(); } };
|
|
69
|
+
show();
|
|
70
|
+
}
|
|
55
71
|
return { onClick };
|
|
56
72
|
}
|