samagotchi 0.16.0 → 0.17.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 +86 -1
- data/README.md +2 -0
- data/docs/architecture.md +13 -0
- data/docs/cli.md +4 -2
- data/docs/configuration.md +4 -4
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +3 -2
- data/docs/sub-agent.md +1 -0
- data/docs/testing.md +47 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +38 -7
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/engine.rb +31 -5
- 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 +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +37 -28
- data/lib/samagotchi/web/public/notify.js +7 -3
- 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 +26 -12
- data/lib/samagotchi/web/public/turn_events.js +13 -3
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +3 -1
|
@@ -105,12 +105,20 @@ module Samagotchi
|
|
|
105
105
|
# What execute and task_create export into the commands they run: a
|
|
106
106
|
# chi started there answers as a parent agent
|
|
107
107
|
# (Guardrails::ParentApprovals.parent_process?), even under a PTY
|
|
108
|
-
# wrapper. The session's id, "chi" without one.
|
|
108
|
+
# wrapper. The session's id, "chi" without one. And the model the
|
|
109
|
+
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
110
|
+
# /model), which chi self reports; unset when unknown, so a value
|
|
111
|
+
# inherited from an outer session never passes for this one.
|
|
109
112
|
def parent_env(kctx)
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
peers = kctx.peers
|
|
114
|
+
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
115
|
+
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
116
|
+
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
117
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model }
|
|
112
118
|
end
|
|
113
119
|
|
|
120
|
+
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
121
|
+
|
|
114
122
|
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
115
123
|
# (as DelegateWait does); a bare kernel has no peers: never cancelled.
|
|
116
124
|
def cancelled_proc(kctx)
|
|
@@ -55,6 +55,10 @@ module Samagotchi
|
|
|
55
55
|
fields = Relay.card(child, question, relay_id: relay_id, more: more)
|
|
56
56
|
answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
|
|
57
57
|
settle(answer, fields, client: client, desk: desk, relay_id: relay_id, qid: qid)
|
|
58
|
+
ensure
|
|
59
|
+
# A relay that failed before it settled closes, so the desk (which
|
|
60
|
+
# prunes only settled ones) keeps none open; a settled one stays.
|
|
61
|
+
desk&.close(relay_id) if relay_id
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
# Closes the card when the child's question is no longer pending (it
|
|
@@ -7,10 +7,11 @@ module Samagotchi
|
|
|
7
7
|
# folder, where sessions live, and whether the running turn was
|
|
8
8
|
# canceled (a waiting tool returns on it), and the question flow a
|
|
9
9
|
# delegate's approval is relayed through (relay: nil when this session
|
|
10
|
-
# can't host one)
|
|
10
|
+
# can't host one), and the model the session runs on (model_ref: the
|
|
11
|
+
# resolved ref, live after /model). Engine hands KernelLoop one that follows its current
|
|
11
12
|
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
12
13
|
# without an Engine.
|
|
13
|
-
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, keyword_init: true) do
|
|
14
|
+
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
|
|
14
15
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
15
16
|
def cancelled?
|
|
16
17
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
@@ -20,7 +20,7 @@ module Samagotchi
|
|
|
20
20
|
OUTPUT_TAIL_READ_BYTES = 16 * 1024
|
|
21
21
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
22
22
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
23
|
-
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION].freeze
|
|
23
|
+
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
24
24
|
|
|
25
25
|
module_function
|
|
26
26
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1059,7 +1059,8 @@ module Samagotchi
|
|
|
1059
1059
|
port && BridgeClient.new(session_id: session_id, port: port, host: DEFAULT_HOST)
|
|
1060
1060
|
end
|
|
1061
1061
|
|
|
1062
|
-
#
|
|
1062
|
+
# GET a read path of the session's live Bridge (+path+: "state",
|
|
1063
|
+
# "snapshot", "stats", ...).
|
|
1063
1064
|
# @return [Hash, nil] parsed JSON body, or nil when no live bridge / timeout.
|
|
1064
1065
|
def bridge_get_json(session_id, path)
|
|
1065
1066
|
bridge_client(session_id)&.get_json(path)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
listSessions,
|
|
3
|
-
createSession,
|
|
4
3
|
getSession,
|
|
5
4
|
sendTurn,
|
|
6
5
|
cancelTurn,
|
|
@@ -37,14 +36,14 @@ import { createPromptHistory } from "./prompt_history.js";
|
|
|
37
36
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
38
37
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
39
38
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
40
|
-
import { stripColumns, stripShowParts } from "./strip.js";
|
|
39
|
+
import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
|
|
41
40
|
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
42
41
|
import { createNotifications } from "./notifications.js";
|
|
43
42
|
import {
|
|
44
43
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
45
44
|
} from "./timing.js";
|
|
46
45
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
47
|
-
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine,
|
|
46
|
+
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
47
|
|
|
49
48
|
const $ = (s) => document.querySelector(s);
|
|
50
49
|
const topStripEl = $("#topStrip");
|
|
@@ -1732,37 +1731,19 @@ function addCreatedSession(created) {
|
|
|
1732
1731
|
render();
|
|
1733
1732
|
}
|
|
1734
1733
|
|
|
1735
|
-
// A first message: an idle session, then the turn
|
|
1736
|
-
// later one (handleSendTurn), so a failed first turn's prompt
|
|
1737
|
-
// into the composer too (sentIds).
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
const prompt = promptEl.value.trim();
|
|
1741
|
-
if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
|
|
1742
|
-
if (!prompt) return;
|
|
1743
|
-
actionBtn.disabled = true;
|
|
1744
|
-
try {
|
|
1745
|
-
const s = await createSession(prompt, { dir: scope.dir, model: chosenModel() });
|
|
1746
|
-
addCreatedSession(s);
|
|
1747
|
-
select(s.id);
|
|
1748
|
-
promptEl.value = "";
|
|
1749
|
-
fitPrompt();
|
|
1750
|
-
refreshHistory();
|
|
1751
|
-
} catch (e) {
|
|
1752
|
-
alert(e.message);
|
|
1753
|
-
} finally {
|
|
1754
|
-
actionBtn.disabled = false;
|
|
1755
|
-
}
|
|
1734
|
+
// A first message: an idle session, then the turn (or the command) into it
|
|
1735
|
+
// as for any later one (handleSendTurn), so a failed first turn's prompt
|
|
1736
|
+
// comes back into the composer too (sentIds).
|
|
1737
|
+
function handleCreate() {
|
|
1738
|
+
return handleSendTurn();
|
|
1756
1739
|
}
|
|
1757
1740
|
|
|
1758
1741
|
async function handleSendTurn() {
|
|
1759
|
-
if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
|
|
1760
1742
|
if (current.owner === "tui") return;
|
|
1761
1743
|
const prompt = turnText(promptEl.value, chips);
|
|
1762
1744
|
if (!prompt) return;
|
|
1763
1745
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1764
1746
|
const typed = promptEl.value.trim() !== "";
|
|
1765
|
-
if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
|
|
1766
1747
|
if (!selected) {
|
|
1767
1748
|
actionBtn.disabled = true;
|
|
1768
1749
|
try {
|
|
@@ -1776,6 +1757,9 @@ async function handleSendTurn() {
|
|
|
1776
1757
|
actionBtn.disabled = false;
|
|
1777
1758
|
}
|
|
1778
1759
|
}
|
|
1760
|
+
// A session command by the session's list (the new one's too: select
|
|
1761
|
+
// read it); an unknown /word is a prompt, as in a terminal.
|
|
1762
|
+
if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
|
|
1779
1763
|
const hint = historyEl.querySelector(".hint");
|
|
1780
1764
|
if (hint) hint.remove();
|
|
1781
1765
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
@@ -1795,6 +1779,15 @@ async function handleSendTurn() {
|
|
|
1795
1779
|
if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
|
|
1796
1780
|
}
|
|
1797
1781
|
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
|
|
1782
|
+
if (ack?.command_id) {
|
|
1783
|
+
// The worker knew it as a command (its list was newer than ours):
|
|
1784
|
+
// its command_ran shows it, not a prompt bubble.
|
|
1785
|
+
echo.remove();
|
|
1786
|
+
promptEl.value = "";
|
|
1787
|
+
fitPrompt();
|
|
1788
|
+
if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1798
1791
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1799
1792
|
refreshHistory();
|
|
1800
1793
|
// A turn that failed fast was restored before this ack: its prompt
|
|
@@ -2247,11 +2240,27 @@ function stripHiddenSaved() {
|
|
|
2247
2240
|
return false;
|
|
2248
2241
|
}
|
|
2249
2242
|
}
|
|
2243
|
+
// A short window hides the strip by itself, so the stage keeps room for a
|
|
2244
|
+
// card that waits (its question and choices); the sessions pill brings it
|
|
2245
|
+
// back until the window is tall again. Not saved: the saved choice is the
|
|
2246
|
+
// user's own hide ▴ / show.
|
|
2247
|
+
const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
|
|
2248
|
+
let stripShownWhileShort = false;
|
|
2249
|
+
function applyStripHidden() {
|
|
2250
|
+
document.body.classList.toggle("strip-hidden", stripAutoHidden({
|
|
2251
|
+
saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
|
|
2252
|
+
}));
|
|
2253
|
+
}
|
|
2250
2254
|
function setStripHidden(hidden) {
|
|
2251
2255
|
try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
|
|
2252
|
-
|
|
2256
|
+
stripShownWhileShort = !hidden && shortWindow.matches;
|
|
2257
|
+
applyStripHidden();
|
|
2253
2258
|
}
|
|
2254
|
-
|
|
2259
|
+
applyStripHidden();
|
|
2260
|
+
shortWindow.addEventListener("change", () => {
|
|
2261
|
+
if (!shortWindow.matches) stripShownWhileShort = false;
|
|
2262
|
+
applyStripHidden();
|
|
2263
|
+
});
|
|
2255
2264
|
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
2256
2265
|
|
|
2257
2266
|
$("#filter").addEventListener("input", renderAll);
|
|
@@ -31,9 +31,13 @@ export function waitingOn(summary) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// How long a delegated child's new approval waits before it notifies: its
|
|
34
|
-
// parent relays it
|
|
35
|
-
//
|
|
36
|
-
|
|
34
|
+
// parent relays it when it waits on its children, and then the parent's
|
|
35
|
+
// card notifies instead (one bell, not two). A child the parent isn't
|
|
36
|
+
// waiting on directly is seen by its look at the others (DelegateRelay
|
|
37
|
+
// OTHERS_EVERY, 2 s), and the relay reaches the page with the hub's next
|
|
38
|
+
// scan (SessionHub SCAN_INTERVAL, 1 s): up to ~3 s. Only these approvals
|
|
39
|
+
// wait; a child's approval nobody relays rings this late.
|
|
40
|
+
export const RELAY_HOLD_MS = 3500;
|
|
37
41
|
|
|
38
42
|
// What changed between two summaries of one session that needs the user.
|
|
39
43
|
// @param prev the summary held before (null for a session not seen yet)
|
|
@@ -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
|
+
}
|
|
@@ -185,37 +185,51 @@ function recordRows(tools, iteration) {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
// A turn whose saved messages carry their parts (the server's ?parts=1):
|
|
188
|
-
// every assistant message is one
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// it
|
|
188
|
+
// every assistant message is one step. Its calls take the turn's records by
|
|
189
|
+
// tool, in call order (each the next record of its tool, consumed with the
|
|
190
|
+
// ones it skipped), not by iteration: an empty retry spends an iteration
|
|
191
|
+
// that left no message (as the TUI's join, attached_loop.rb join_duration).
|
|
192
|
+
// The last message is the answer when it has text and made no call (and the
|
|
193
|
+
// turn is no canceled multi-step one); its thinking stays behind as a step,
|
|
194
|
+
// as it does live.
|
|
193
195
|
function partsTurn(g, items, record, tools, stepsOnly = false) {
|
|
194
196
|
const last = g.texts[g.texts.length - 1];
|
|
195
197
|
const lastItem = items[last];
|
|
196
198
|
const answer = !stepsOnly && String(lastItem.content ?? "") !== "" && !lastItem.parts?.tools?.length ? last : null;
|
|
199
|
+
const pool = [...tools].sort((a, b) =>
|
|
200
|
+
(Number(a.iteration) || 0) - (Number(b.iteration) || 0) || (Number(a.call_index) || 0) - (Number(b.call_index) || 0));
|
|
197
201
|
const steps = g.texts.map((i, k) => {
|
|
198
202
|
const iteration = k + 1;
|
|
199
203
|
const parts = items[i].parts || {};
|
|
200
|
-
const records = recordRows(tools, iteration);
|
|
201
204
|
const rows = Array.isArray(parts.tools)
|
|
202
205
|
? parts.tools.map((call, j) => {
|
|
203
|
-
const r =
|
|
206
|
+
const r = takeRecord(pool, call.tool);
|
|
204
207
|
return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...call };
|
|
205
208
|
})
|
|
206
|
-
:
|
|
209
|
+
: [];
|
|
207
210
|
const thinking = String(parts.thinking || "");
|
|
208
211
|
if (i === answer) return thinking ? { i: null, iteration, thinking, tools: [] } : null;
|
|
209
212
|
return { i, iteration, thinking, tools: rows };
|
|
210
213
|
}).filter(Boolean);
|
|
211
|
-
// Records
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
// Records no saved message took (a generation that saved nothing): a
|
|
215
|
+
// step per iteration of theirs, after the messages'.
|
|
216
|
+
let iteration = g.texts.length;
|
|
217
|
+
for (const n of [...new Set(pool.map((r) => Number(r.iteration) || 0))]) {
|
|
218
|
+
iteration += 1;
|
|
219
|
+
const rows = recordRows(pool, n).map((row) => ({ ...row, key: `${iteration}:${row.key.split(":")[1]}` }));
|
|
220
|
+
steps.push({ i: null, iteration, thinking: "", tools: rows });
|
|
215
221
|
}
|
|
216
222
|
return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
|
|
217
223
|
}
|
|
218
224
|
|
|
225
|
+
// The next record of +tool+ in +pool+ (call order), removed with the ones
|
|
226
|
+
// before it; undefined when there is none (the pool stays as it was).
|
|
227
|
+
function takeRecord(pool, tool) {
|
|
228
|
+
const at = pool.findIndex((r) => String(r.tool) === String(tool));
|
|
229
|
+
if (at < 0) return undefined;
|
|
230
|
+
return pool.splice(0, at + 1)[at];
|
|
231
|
+
}
|
|
232
|
+
|
|
219
233
|
// Append +el+ to the history, above +timingEl+ while it is a running turn's
|
|
220
234
|
// live timing line, so that line stays under the turn's activity, thinking
|
|
221
235
|
// and answer.
|
|
@@ -97,9 +97,19 @@ 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} `)));
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
// The terminal's own commands (a button in the web, or none yet): the
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -173,7 +173,7 @@ module Samagotchi
|
|
|
173
173
|
absorb_notes
|
|
174
174
|
|
|
175
175
|
if (prompt = take_initial_prompt)
|
|
176
|
-
run_prompt(prompt, nil)
|
|
176
|
+
run_prompt(prompt, nil) unless initial_command(prompt)
|
|
177
177
|
next
|
|
178
178
|
end
|
|
179
179
|
|
|
@@ -269,6 +269,29 @@ module Samagotchi
|
|
|
269
269
|
prompt
|
|
270
270
|
end
|
|
271
271
|
|
|
272
|
+
# A session command sent as a message (chi send -m "/model x", a web
|
|
273
|
+
# page's first message) runs as the command, as the Bridge does for a
|
|
274
|
+
# POST /turn; an unknown /word stays a prompt. Queued: the loop's next
|
|
275
|
+
# pass runs it.
|
|
276
|
+
# @return [Boolean] whether +text+ was one
|
|
277
|
+
def queue_as_command(text, origin)
|
|
278
|
+
return false unless @engine.command_registry.command?(text.to_s)
|
|
279
|
+
|
|
280
|
+
@bridge.queue_command(text, client_id: origin&.dig(:client_id))
|
|
281
|
+
true
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The first prompt as a command: the session was saved as running for a
|
|
285
|
+
# turn that won't run.
|
|
286
|
+
# @return [Boolean] whether it was one
|
|
287
|
+
def initial_command(prompt)
|
|
288
|
+
return false unless queue_as_command(prompt, nil)
|
|
289
|
+
|
|
290
|
+
@session.status = Session::STATUS_IDLE
|
|
291
|
+
save_session
|
|
292
|
+
true
|
|
293
|
+
end
|
|
294
|
+
|
|
272
295
|
# Add the queued context notes to the conversation (between turns only,
|
|
273
296
|
# on this thread), save, then delete their files: a crash before the
|
|
274
297
|
# delete leaves them claimed, and Engine#add_context_note skips a note
|
|
@@ -293,7 +316,10 @@ module Samagotchi
|
|
|
293
316
|
|
|
294
317
|
begin
|
|
295
318
|
message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
|
|
296
|
-
|
|
319
|
+
return if message.to_s.strip.empty?
|
|
320
|
+
return if Array(images).empty? && queue_as_command(message, origin)
|
|
321
|
+
|
|
322
|
+
run_prompt(message, origin, no_interrupt: !!no_interrupt, images: images || [])
|
|
297
323
|
ensure
|
|
298
324
|
FileUtils.rm_f(claimed_file)
|
|
299
325
|
end
|
|
@@ -42,7 +42,10 @@ 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 (Ruby 3.4, macOS 26) it hands
|
|
46
|
+
# back the socket of a refused connect (SO_ERROR set), so a dead
|
|
47
|
+
# worker's port read as live. TCPSocket raises ECONNREFUSED.
|
|
48
|
+
TCPSocket.new(host, sidecar.port, connect_timeout: timeout).close
|
|
46
49
|
sidecar
|
|
47
50
|
rescue StandardError
|
|
48
51
|
FileUtils.rm_f(path(session_dir)) if unlink
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- docs/releasing.md
|
|
138
138
|
- docs/sessions.md
|
|
139
139
|
- docs/sub-agent.md
|
|
140
|
+
- docs/testing.md
|
|
140
141
|
- lib/samagotchi.rb
|
|
141
142
|
- lib/samagotchi/answer_command.rb
|
|
142
143
|
- lib/samagotchi/answer_display.rb
|
|
@@ -280,6 +281,7 @@ files:
|
|
|
280
281
|
- lib/samagotchi/memory_bundle/source.rb
|
|
281
282
|
- lib/samagotchi/memory_bundle/status.rb
|
|
282
283
|
- lib/samagotchi/memory_bundle/system_bundle.rb
|
|
284
|
+
- lib/samagotchi/memory_bundle/trash.rb
|
|
283
285
|
- lib/samagotchi/memory_bundle/uninstaller.rb
|
|
284
286
|
- lib/samagotchi/memory_paths.rb
|
|
285
287
|
- lib/samagotchi/model_catalog.rb
|