samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -28,6 +28,13 @@ export function applySessionEvent(list, type, data = {}) {
|
|
|
28
28
|
return list;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The sessions a list shows: archived ones only when asked (the all view's
|
|
32
|
+
// "include archived"). The page keeps them in its list either way: a parent
|
|
33
|
+
// chip and the open session still need them.
|
|
34
|
+
export function listedSessions(list, includeArchived = false) {
|
|
35
|
+
return includeArchived ? list : list.filter((s) => !s.archived);
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
const updatedMs = (s) => {
|
|
32
39
|
const ms = Date.parse(s.updated_at || "");
|
|
33
40
|
return Number.isNaN(ms) ? 0 : ms;
|
|
@@ -92,9 +92,12 @@ export function turnGroups(items, timing) {
|
|
|
92
92
|
items.forEach((item, i) => {
|
|
93
93
|
if (item?.role === "note") {
|
|
94
94
|
groups.push({ kind: "note", i });
|
|
95
|
+
} else if (item?.role === "steer") {
|
|
96
|
+
if (turn) turn.steers.push(i);
|
|
97
|
+
else groups.push({ kind: "steer", i });
|
|
95
98
|
} else if (item?.role === "user") {
|
|
96
99
|
turnIndex += 1;
|
|
97
|
-
turn = { kind: "turn", turnIndex, user: i, texts: [] };
|
|
100
|
+
turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [] };
|
|
98
101
|
groups.push(turn);
|
|
99
102
|
} else if (item?.role === "assistant") {
|
|
100
103
|
if (turn) turn.texts.push(i);
|
|
@@ -103,22 +106,44 @@ export function turnGroups(items, timing) {
|
|
|
103
106
|
});
|
|
104
107
|
return groups.map((g) => {
|
|
105
108
|
if (g.kind !== "turn") return g;
|
|
106
|
-
|
|
107
|
-
const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
|
|
108
|
-
const stepsOnly = canceledSteps(g, record, tools);
|
|
109
|
-
if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
|
|
110
|
-
const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
|
|
111
|
-
const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
|
|
112
|
-
const steps = [];
|
|
113
|
-
for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
|
|
114
|
-
const iteration = k + 1;
|
|
115
|
-
steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
|
|
116
|
-
}
|
|
117
|
-
const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
|
|
118
|
-
return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
|
|
109
|
+
return withSteers(turnGroup(g, items, timing), g.steers, items);
|
|
119
110
|
});
|
|
120
111
|
}
|
|
121
112
|
|
|
113
|
+
// A turn's steers (a plugin's nudges) into its steps: each goes to the step
|
|
114
|
+
// that answered it (`step`: the server counts the model messages since the
|
|
115
|
+
// prompt, plus one). One whose step isn't there (the answer, which leaves
|
|
116
|
+
// the block) stays on the group (`steers`): a bubble before the answer, as
|
|
117
|
+
// live. A turn without steers keeps its shape.
|
|
118
|
+
function withSteers(group, steerIndexes, items) {
|
|
119
|
+
if (!steerIndexes.length) return group;
|
|
120
|
+
const steps = group.steps.map((step) => ({ ...step, steers: [] }));
|
|
121
|
+
const left = [];
|
|
122
|
+
for (const i of steerIndexes) {
|
|
123
|
+
const n = Number(items[i].step) || 1;
|
|
124
|
+
const step = steps.find((s) => s.iteration === n);
|
|
125
|
+
if (step) step.steers.push(i);
|
|
126
|
+
else left.push(i);
|
|
127
|
+
}
|
|
128
|
+
return { ...group, steps, steers: left };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function turnGroup(g, items, timing) {
|
|
132
|
+
const record = turnRecordAt(timing, g.turnIndex);
|
|
133
|
+
const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
|
|
134
|
+
const stepsOnly = canceledSteps(g, record, tools);
|
|
135
|
+
if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
|
|
136
|
+
const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
|
|
137
|
+
const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
|
|
138
|
+
const steps = [];
|
|
139
|
+
for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
|
|
140
|
+
const iteration = k + 1;
|
|
141
|
+
steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
|
|
142
|
+
}
|
|
143
|
+
const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
|
|
144
|
+
return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
// A canceled turn that ran more than one step or any call has no answer:
|
|
123
148
|
// its partial text stays in the block, as it does live (turn_view.js
|
|
124
149
|
// turnEnded promotes only a completed turn or a lone step with no tools).
|
|
@@ -45,10 +45,36 @@ export function reminderText(event) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Who a hook's notice is from: the bundle's name for a bundle hook
|
|
48
|
-
// ("known_names.rb (bundle known-names)"),
|
|
48
|
+
// ("known_names.rb (bundle known-names)"), chi's own notice by its word
|
|
49
|
+
// (thinking), else "hook".
|
|
49
50
|
export function hookNoticeLabel(hook) {
|
|
50
|
-
const
|
|
51
|
-
|
|
51
|
+
const name = String(hook || "");
|
|
52
|
+
const m = /\(bundle (.+)\)$/.exec(name);
|
|
53
|
+
if (m) return m[1];
|
|
54
|
+
// chi's own notices name themselves with a bare word (thinking).
|
|
55
|
+
return /^[a-z][a-z0-9-]*$/.test(name) ? name : "hook";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The row a step gets when the loop asks again after an empty answer.
|
|
59
|
+
export function emptyRetryLine({ attempt, of } = {}) {
|
|
60
|
+
return `↻ empty answer, asking again (${attempt}/${of})`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The live turn's status while the provider is asked again after an error
|
|
64
|
+
// (generation_retrying): "↻ retrying (503) in 4 s, 2/5" (retry 2 of 5).
|
|
65
|
+
export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class } = {}) {
|
|
66
|
+
const why = status || String(error_class || "").split("::").pop();
|
|
67
|
+
const delay = Number(next_delay);
|
|
68
|
+
const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
|
|
69
|
+
const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
|
|
70
|
+
return `↻ retrying${why ? ` (${why})` : ""}${when}${count}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The live turn's status while it waits for plugins' slow setup before its
|
|
74
|
+
// first request (plugin_init_wait).
|
|
75
|
+
export function initWaitLine({ tasks } = {}) {
|
|
76
|
+
const labels = (tasks || []).map((t) => [t?.bundle, t?.label].filter(Boolean).join(": ")).filter(Boolean);
|
|
77
|
+
return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
80
|
// A composer line for the worker's command route rather than a prompt.
|
|
@@ -56,6 +82,20 @@ export function isCommandLine(text) {
|
|
|
56
82
|
return /^[/!]/.test(String(text || "").trim());
|
|
57
83
|
}
|
|
58
84
|
|
|
85
|
+
// The terminal's own commands that have a button in the web: the reply
|
|
86
|
+
// the page shows itself (they never go to the worker, which doesn't know
|
|
87
|
+
// them), or null.
|
|
88
|
+
const WEB_LOCAL_REPLIES = {
|
|
89
|
+
"/archive": "/archive: use the archive button in the session bar",
|
|
90
|
+
"/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
|
|
91
|
+
"/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export function webLocalReply(text) {
|
|
95
|
+
const name = String(text || "").trim().split(/\s+/)[0].toLowerCase();
|
|
96
|
+
return WEB_LOCAL_REPLIES[name] || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
59
99
|
// The continue card's answers, as the terminal's continue prompt reads them.
|
|
60
100
|
export function continueLine(choice, reason = "") {
|
|
61
101
|
const why = String(reason || "").trim();
|
|
@@ -80,6 +120,8 @@ export function commandView(event, myId) {
|
|
|
80
120
|
modelName: event.model_name || null,
|
|
81
121
|
anytime: event.anytime === true,
|
|
82
122
|
commandId: event.command_id || null,
|
|
123
|
+
// A card's action (Nudge…): no bubble unless it says something back.
|
|
124
|
+
hidden: event.card === true && !event.output && event.status !== "error",
|
|
83
125
|
};
|
|
84
126
|
}
|
|
85
127
|
|
|
@@ -100,6 +142,27 @@ export function restoreAction(event, { myId, sentIds, composerEmpty }) {
|
|
|
100
142
|
}, sent && composerEmpty ? event.images : null);
|
|
101
143
|
}
|
|
102
144
|
|
|
145
|
+
// A turn that fails fast can have its prompt_restored come over the stream
|
|
146
|
+
// before the /turn ack names its enqueued_id: `early` (a Map) keeps such a
|
|
147
|
+
// restore of this tab's own prompt by that id, and the ack takes it back
|
|
148
|
+
// (restoreOnAck), so the prompt goes into the composer the ack would clear.
|
|
149
|
+
export function keepEarlyRestore(event, early, { myId, sentIds }) {
|
|
150
|
+
const origin = event?.origin || {};
|
|
151
|
+
const id = origin.enqueued_id;
|
|
152
|
+
if (!isOwn(origin.client_id, myId) || !id || sentIds.has(id)) return false;
|
|
153
|
+
early.set(id, event);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The ack's enqueued_id (already in sentIds) was restored early: what the
|
|
158
|
+
// composer gets back (as restoreAction for an empty composer), else null.
|
|
159
|
+
export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
|
|
160
|
+
const event = enqueuedId && early.get(enqueuedId);
|
|
161
|
+
if (!event) return null;
|
|
162
|
+
early.delete(enqueuedId);
|
|
163
|
+
return restoreAction(event, { myId, sentIds, composerEmpty: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
103
166
|
// What an event does to the prompt bubbles, as a list of ops for app.js:
|
|
104
167
|
// add — a bubble for a prompt this tab hasn't shown (state queued /
|
|
105
168
|
// started / steered, labelled by its sender)
|
|
@@ -172,10 +235,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
172
235
|
if (part.label) call.label = part.label;
|
|
173
236
|
events.push({ type: "tool_call_started", ...call, params: part.params });
|
|
174
237
|
if (part.status !== "running") {
|
|
175
|
-
|
|
238
|
+
const completed = {
|
|
176
239
|
type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
|
|
177
240
|
activity: { status: part.status, params: part.params },
|
|
178
|
-
}
|
|
241
|
+
};
|
|
242
|
+
if (part.diff) completed.diff = part.diff;
|
|
243
|
+
events.push(withImages(completed, part.images));
|
|
179
244
|
}
|
|
180
245
|
break;
|
|
181
246
|
}
|
|
@@ -191,6 +256,11 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
191
256
|
closeText();
|
|
192
257
|
events.push({ type: "hook_notice", hook: part.hook, text: part.text, level: part.level });
|
|
193
258
|
break;
|
|
259
|
+
// A plugin's steer: a steer-only merge, as live.
|
|
260
|
+
case "steer":
|
|
261
|
+
closeText();
|
|
262
|
+
events.push({ type: "pending_input_merged", count: 0, content: null, steers: [{ source: part.source, text: part.text }] });
|
|
263
|
+
break;
|
|
194
264
|
default:
|
|
195
265
|
break;
|
|
196
266
|
}
|
|
@@ -13,7 +13,8 @@ import { toolName } from "./activity.js";
|
|
|
13
13
|
import { leavesBlock } from "./card.js";
|
|
14
14
|
import { activityStatusClass, activityStatusLabel, fillActivityRow } from "./chat_view.js";
|
|
15
15
|
import { copySourceAttr } from "./copy.js";
|
|
16
|
-
import {
|
|
16
|
+
import { activityDiffHtml } from "./diff_view.js";
|
|
17
|
+
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
17
18
|
import { shouldFollowScroll } from "./scroll.js";
|
|
18
19
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
19
20
|
import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
|
|
@@ -29,6 +30,8 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
29
30
|
let blockToggled = false;
|
|
30
31
|
// gen → { el, summary, thinking, text, activity, rows: Map(key → el), toggled }
|
|
31
32
|
const genEls = new Map();
|
|
33
|
+
// Steer rows waiting for the step that answers them (steer()).
|
|
34
|
+
let pendingSteers = [];
|
|
32
35
|
const dirty = new Set();
|
|
33
36
|
let rafPending = false;
|
|
34
37
|
// The promoted answer bubble while it is held at the box's height (the
|
|
@@ -69,6 +72,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
69
72
|
const summary = document.createElement("summary");
|
|
70
73
|
summary.textContent = `step ${turn.gens.length}`;
|
|
71
74
|
el.appendChild(summary);
|
|
75
|
+
// A plugin's steers merged since the last step: this step answers them.
|
|
76
|
+
for (const row of pendingSteers) el.appendChild(row);
|
|
77
|
+
pendingSteers = [];
|
|
72
78
|
const text = document.createElement("div");
|
|
73
79
|
text.className = "gen-text streaming";
|
|
74
80
|
// A step after the first shows "…" until its text comes (the first one
|
|
@@ -317,7 +323,8 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
317
323
|
|
|
318
324
|
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
319
325
|
// of the call it judged; false with no running turn (app.js shows the
|
|
320
|
-
// bubble then).
|
|
326
|
+
// bubble then). data.line is a whole row of chi's own (an empty-answer
|
|
327
|
+
// retry), shown as is.
|
|
321
328
|
function notice(data) {
|
|
322
329
|
const r = turn && model.notice(turn, data);
|
|
323
330
|
if (!r) return false;
|
|
@@ -325,13 +332,34 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
325
332
|
if (!r.gen.text) entry.text.textContent = "";
|
|
326
333
|
const el = document.createElement("div");
|
|
327
334
|
el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
328
|
-
el.textContent = `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
335
|
+
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
329
336
|
const follow = isNearBottom();
|
|
330
337
|
activityEl(r.gen).appendChild(el);
|
|
331
338
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
332
339
|
return true;
|
|
333
340
|
}
|
|
334
341
|
|
|
342
|
+
// A plugin's steer (pending_input_merged's steers) as the first row of
|
|
343
|
+
// the step that answers it: it joined the turn after the current step's
|
|
344
|
+
// tools, and the next generation (a new, open step) reads it; the
|
|
345
|
+
// current step collapses when that one starts. Held until then (a turn
|
|
346
|
+
// that ends first puts it in its last step). False with no running turn.
|
|
347
|
+
function steer(data) {
|
|
348
|
+
if (!turn || turn.ended) return false;
|
|
349
|
+
const holder = document.createElement("div");
|
|
350
|
+
holder.innerHTML = steerRowHtml(data);
|
|
351
|
+
pendingSteers.push(holder.firstChild);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Held steer rows into the last step (the turn ends without another).
|
|
356
|
+
function flushSteers() {
|
|
357
|
+
const gen = turn.gens[turn.gens.length - 1];
|
|
358
|
+
const entry = gen && genEls.get(gen);
|
|
359
|
+
if (entry) for (const row of pendingSteers) entry.el.insertBefore(row, entry.thinkingWrap || entry.text);
|
|
360
|
+
pendingSteers = [];
|
|
361
|
+
}
|
|
362
|
+
|
|
335
363
|
// A card shown during the turn (a plugin's, app.js draws it) as a row of
|
|
336
364
|
// the current step, like a notice; false with no running turn (app.js
|
|
337
365
|
// puts it between turns then).
|
|
@@ -376,8 +404,8 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
376
404
|
// between turns.
|
|
377
405
|
const leaving = !gen.thinking && gen.tools.length === 0;
|
|
378
406
|
const plain = turn.gens.length === 1 && gen.tools.length === 0;
|
|
379
|
-
const cards = leaving || plain ? [...entry.el.querySelectorAll(".plugin-card, .hook-notice")] : [];
|
|
380
|
-
cards.forEach((el) => { if (el.classList.contains("
|
|
407
|
+
const cards = leaving || plain ? [...entry.el.querySelectorAll(".plugin-card, .hook-notice, .steer-row")] : [];
|
|
408
|
+
cards.forEach((el) => { if (!el.classList.contains("plugin-card")) el.classList.add("bubble"); });
|
|
381
409
|
if (leaving) entry.el.remove();
|
|
382
410
|
if (plain) {
|
|
383
411
|
// A plain answer: no block, the bubble where it was (as the chat
|
|
@@ -425,6 +453,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
425
453
|
// one shows at once.
|
|
426
454
|
const box = promoting && kind === "completed" ? genEls.get(last)?.box : null;
|
|
427
455
|
const heldHeight = box ? box.getBoundingClientRect().height : 0;
|
|
456
|
+
if (pendingSteers.length) flushSteers();
|
|
428
457
|
apply(model.turnEnded(turn));
|
|
429
458
|
if (promoting) promote(last, { heldHeight });
|
|
430
459
|
const kept = [];
|
|
@@ -453,6 +482,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
453
482
|
if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
|
|
454
483
|
}
|
|
455
484
|
genEls.clear();
|
|
485
|
+
pendingSteers = [];
|
|
456
486
|
dirty.clear();
|
|
457
487
|
rafPending = false;
|
|
458
488
|
}
|
|
@@ -499,6 +529,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
499
529
|
},
|
|
500
530
|
turnEnded,
|
|
501
531
|
notice,
|
|
532
|
+
steer,
|
|
502
533
|
card,
|
|
503
534
|
flush,
|
|
504
535
|
// The rendered answer landed (or the re-read failed): let the held
|
|
@@ -532,7 +563,7 @@ function reloadRowHtml(r, thumbs) {
|
|
|
532
563
|
: "";
|
|
533
564
|
return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
|
|
534
565
|
`<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}${output}` +
|
|
535
|
-
`${r.images?.length ? thumbs(r.images) : ""}</div>`;
|
|
566
|
+
`${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
|
|
536
567
|
}
|
|
537
568
|
|
|
538
569
|
// A reloaded history in the turn view (renderHistory's object path): per
|
|
@@ -554,28 +585,38 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
554
585
|
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
555
586
|
return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
|
|
556
587
|
};
|
|
588
|
+
const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
|
|
557
589
|
return turnGroups(items, timing).map((group) => {
|
|
558
590
|
if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
|
|
591
|
+
if (group.kind === "steer") return steerHtml(group.i, true);
|
|
559
592
|
if (group.kind === "answer") return answerHtml(items[group.answer], "");
|
|
560
593
|
const prompt = items[group.user];
|
|
561
594
|
const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}><div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
|
|
562
595
|
const [only] = group.steps;
|
|
596
|
+
const noBlock = !group.steps.length ||
|
|
597
|
+
(group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking);
|
|
563
598
|
if (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking) {
|
|
564
599
|
// A plain answer with thinking: the chat view's collapsed block, as live.
|
|
565
600
|
parts.push(`<details class="bubble thinking"><summary>thinking</summary><div class="thinking-body">${escapeHtml(only.thinking)}</div></details>`);
|
|
566
601
|
} else if (group.steps.length) {
|
|
567
602
|
const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
|
|
568
|
-
const rows = gens.map((gen) => {
|
|
603
|
+
const rows = gens.map((gen, k) => {
|
|
569
604
|
// A thinking-only step: the body directly under the step's own
|
|
570
605
|
// "thinking" summary (no wrap that would repeat it), as live.
|
|
571
606
|
const body = gen.thinking ? `<div class="thinking-body">${escapeHtml(gen.thinking)}</div>` : "";
|
|
572
607
|
const thinking = body && (gen.text || gen.tools.length) ? `<details class="thinking"><summary>thinking</summary>${body}</details>` : body;
|
|
573
608
|
const text = gen.text ? `<div class="gen-text">${escapeHtml(gen.text)}</div>` : "";
|
|
609
|
+
// The steers the step answered first, where the live rows went.
|
|
610
|
+
const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
|
|
574
611
|
const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => reloadRowHtml(r, thumbs)).join("")}</div>` : "";
|
|
575
|
-
return `<details class="gen"><summary>${escapeHtml(model.genLabel(gen))}</summary>${thinking}${text}${tools}</details>`;
|
|
612
|
+
return `<details class="gen"><summary>${escapeHtml(model.genLabel(gen))}</summary>${steers}${thinking}${text}${tools}</details>`;
|
|
576
613
|
});
|
|
577
614
|
parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
|
|
578
615
|
}
|
|
616
|
+
// Steers with no step to sit in, or in a turn that shows no block: on
|
|
617
|
+
// their own after the prompt.
|
|
618
|
+
const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
|
|
619
|
+
parts.push(loose.map((i) => steerHtml(i, true)).join(""));
|
|
579
620
|
// A turn with no answer (canceled, or running: no record yet) shows its
|
|
580
621
|
// timing after its prompt and steps, where the live view leaves it.
|
|
581
622
|
if (group.answer === null) parts.push(timingHtml(group));
|
|
@@ -9,6 +9,7 @@ require "webrick"
|
|
|
9
9
|
|
|
10
10
|
require_relative "app"
|
|
11
11
|
require_relative "session_hub"
|
|
12
|
+
require_relative "../config"
|
|
12
13
|
require_relative "../project_scope"
|
|
13
14
|
require_relative "../log"
|
|
14
15
|
require_relative "../version"
|
|
@@ -51,7 +52,8 @@ module Samagotchi
|
|
|
51
52
|
# open) its page for +dir+ and leave it be; else start one.
|
|
52
53
|
# @param scope ["project", "all"] "all": the plain page, every session
|
|
53
54
|
# @return [Integer] the exit status
|
|
54
|
-
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, turn_view: true
|
|
55
|
+
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, turn_view: true,
|
|
56
|
+
annotate_presets: Config::BY_KEY["web.annotate_presets"].default)
|
|
55
57
|
host = resolve_host(host)
|
|
56
58
|
port = resolve_port(port)
|
|
57
59
|
url = scope_url(host, port, dir: dir, scope: scope)
|
|
@@ -65,7 +67,8 @@ module Samagotchi
|
|
|
65
67
|
warn in_use_message(port)
|
|
66
68
|
1
|
|
67
69
|
else
|
|
68
|
-
start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, turn_view: turn_view
|
|
70
|
+
start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, turn_view: turn_view,
|
|
71
|
+
annotate_presets: annotate_presets) ? 0 : 1
|
|
69
72
|
end
|
|
70
73
|
rescue Interrupt
|
|
71
74
|
# Ctrl-C: the server has stopped (start's ensure); one line, and the
|
|
@@ -78,13 +81,14 @@ module Samagotchi
|
|
|
78
81
|
# from; built over the app's state dir unless given
|
|
79
82
|
# @return [Boolean] false when the port was taken (said so on stderr)
|
|
80
83
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
81
|
-
turn_view: true, hub: nil)
|
|
84
|
+
turn_view: true, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil)
|
|
82
85
|
port = resolve_port(port)
|
|
83
86
|
host = resolve_host(host)
|
|
84
87
|
url ||= "http://#{url_host(host)}:#{port}/"
|
|
85
88
|
|
|
86
89
|
hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
|
|
87
|
-
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, turn_view: turn_view,
|
|
90
|
+
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, turn_view: turn_view,
|
|
91
|
+
annotate_presets: annotate_presets, hub: hub)
|
|
88
92
|
Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
|
|
89
93
|
hub.start
|
|
90
94
|
# Said once the port is bound: a second chi web racing for it gets
|
|
@@ -244,7 +244,8 @@ module Samagotchi
|
|
|
244
244
|
if stamp != entry.stamp
|
|
245
245
|
session = Session.summary_from_file(path)
|
|
246
246
|
# Corrupt, or missing a field: not a session, as Session.list has it.
|
|
247
|
-
|
|
247
|
+
# A `chi scratch` session is never shown.
|
|
248
|
+
return drop(id) if session.nil? || session.scratch
|
|
248
249
|
|
|
249
250
|
# Looked up once per parse (a git call for a session saved before
|
|
250
251
|
# the field), never per tick.
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../session"
|
|
4
4
|
require_relative "../recap_store"
|
|
5
|
+
require_relative "../archive_store"
|
|
6
|
+
require_relative "../session_metrics"
|
|
5
7
|
require_relative "../bridge_client"
|
|
8
|
+
require_relative "../bridge/pending_card"
|
|
6
9
|
|
|
7
10
|
module Samagotchi
|
|
8
11
|
module Web
|
|
@@ -25,6 +28,7 @@ module Samagotchi
|
|
|
25
28
|
# @return [Hash] symbol keys
|
|
26
29
|
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
|
|
27
30
|
used = session.respond_to?(:used_memory_names) ? Array(session.used_memory_names) : []
|
|
31
|
+
up = bridge_up?(session_dir, owner)
|
|
28
32
|
{
|
|
29
33
|
id: session.id,
|
|
30
34
|
status: status,
|
|
@@ -46,13 +50,32 @@ module Samagotchi
|
|
|
46
50
|
owner: owner&.fetch("kind", nil),
|
|
47
51
|
# The saved recap's first sentence, for the session card.
|
|
48
52
|
recap: RecapStore.preview(session_dir),
|
|
53
|
+
# How full the context was after the last turn (%), or nil.
|
|
54
|
+
ctx_pct: SessionMetrics.saved_context_pct(session_dir)&.round(1),
|
|
55
|
+
# Hidden from the strip and the list unless "include archived".
|
|
56
|
+
archived: ArchiveStore.archived?(session_dir),
|
|
49
57
|
project_root: session.project_root(cache: root_cache),
|
|
50
58
|
# A worker is reachable: its Bridge sidecar is there and the
|
|
51
59
|
# owner lock is held. A sidecar a dead worker left is not up.
|
|
52
|
-
bridge_up:
|
|
60
|
+
bridge_up: up,
|
|
61
|
+
# For the tab's notifications (notify.js): an open question, the
|
|
62
|
+
# running turn's open card with actions, and how the last turn
|
|
63
|
+
# ended.
|
|
64
|
+
pending_question: pending_question_for(session),
|
|
65
|
+
pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
|
|
66
|
+
last_turn: session.respond_to?(:last_turn) ? session.last_turn : nil
|
|
53
67
|
}
|
|
54
68
|
end
|
|
55
69
|
|
|
70
|
+
# The open question as {id:, kind:} ("approval" for a guardrail's,
|
|
71
|
+
# else "question"); nil when there is none. The text stays out.
|
|
72
|
+
def pending_question_for(session)
|
|
73
|
+
pending = session.respond_to?(:pending_question) ? session.pending_question : nil
|
|
74
|
+
return nil unless pending.is_a?(Hash) && pending[:id]
|
|
75
|
+
|
|
76
|
+
{ id: pending[:id], kind: pending[:kind].to_s == "approval" ? "approval" : "question" }
|
|
77
|
+
end
|
|
78
|
+
|
|
56
79
|
# status is turn state (idle/running). The live worker's snapshot is the
|
|
57
80
|
# truth; on disk, a "running" with no live owner was left by a worker
|
|
58
81
|
# that died mid-turn.
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "turn_note"
|
|
|
7
7
|
require_relative "context_note"
|
|
8
8
|
require_relative "worker_idle_exit"
|
|
9
9
|
require_relative "session_manager"
|
|
10
|
+
require_relative "archive_store"
|
|
10
11
|
require_relative "log"
|
|
11
12
|
require_relative "turn_flow"
|
|
12
13
|
require_relative "session_commands"
|
|
@@ -286,6 +287,7 @@ module Samagotchi
|
|
|
286
287
|
# list); the Engine resets it to idle when it ends.
|
|
287
288
|
@session.status = Session::STATUS_RUNNING
|
|
288
289
|
@session.save(state_dir: @state_dir)
|
|
290
|
+
user_input(origin&.dig(:client_id))
|
|
289
291
|
drop_continue_offer(origin)
|
|
290
292
|
@turn_flow.before_prompt_turn
|
|
291
293
|
@merged_this_turn = []
|
|
@@ -316,6 +318,7 @@ module Samagotchi
|
|
|
316
318
|
@engine.clear_due_reminder_names!
|
|
317
319
|
return false unless @engine.reminders_due?
|
|
318
320
|
|
|
321
|
+
drop_continue_offer({ client_id: SessionManager::REMINDER_CLIENT_ID })
|
|
319
322
|
@session.status = Session::STATUS_RUNNING
|
|
320
323
|
@session.save(state_dir: @state_dir)
|
|
321
324
|
begin
|
|
@@ -329,8 +332,8 @@ module Samagotchi
|
|
|
329
332
|
ensure
|
|
330
333
|
refuse_queued_commands
|
|
331
334
|
end
|
|
332
|
-
#
|
|
333
|
-
#
|
|
335
|
+
# The offer went before the turn (drop_continue_offer); the rollback
|
|
336
|
+
# window closes.
|
|
334
337
|
@turn_flow.after_reminder_turn
|
|
335
338
|
@session.save(state_dir: @state_dir) unless stopped_on_disk?
|
|
336
339
|
true
|
|
@@ -381,6 +384,7 @@ module Samagotchi
|
|
|
381
384
|
shown.each { |event| @engine.announce(event) }
|
|
382
385
|
end
|
|
383
386
|
@session.save(state_dir: @state_dir) unless Array(result.changed).empty? || stopped_on_disk?
|
|
387
|
+
user_input(command[:client_id]) if resolved
|
|
384
388
|
# An offer answered without a turn ("no") is activity: the recap
|
|
385
389
|
# written at the offer (it says the turn stopped) gets rewritten.
|
|
386
390
|
@engine.record_activity if resolved && !result.resume
|
|
@@ -432,6 +436,7 @@ module Samagotchi
|
|
|
432
436
|
status: status, output: text[0, COMMAND_OUTPUT_LIMIT], changed: changed.map(&:to_s),
|
|
433
437
|
model_name: @engine.effective_model_name }
|
|
434
438
|
event[:anytime] = true if anytime
|
|
439
|
+
event[:card] = true if command[:card]
|
|
435
440
|
event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
|
|
436
441
|
@engine.announce(event)
|
|
437
442
|
end
|
|
@@ -472,8 +477,8 @@ module Samagotchi
|
|
|
472
477
|
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
473
478
|
end
|
|
474
479
|
|
|
475
|
-
# A prompt taken while a continue is offered
|
|
476
|
-
# the offer goes, the partial turn stays.
|
|
480
|
+
# A prompt (or a reminder turn) taken while a continue is offered
|
|
481
|
+
# replaces the answer (D2): the offer goes, the partial turn stays.
|
|
477
482
|
def drop_continue_offer(origin)
|
|
478
483
|
return unless @turn_flow.awaiting_continue?
|
|
479
484
|
|
|
@@ -536,6 +541,7 @@ module Samagotchi
|
|
|
536
541
|
FileUtils.rm_f(claimed_file)
|
|
537
542
|
end
|
|
538
543
|
end
|
|
544
|
+
merged.each { |_prompt, origin| user_input(origin&.dig(:client_id)) }
|
|
539
545
|
unless merged.empty?
|
|
540
546
|
@engine.announce(type: :input_merged, count: merged.size, origins: merged.filter_map(&:last))
|
|
541
547
|
@merged_this_turn.concat(merged)
|
|
@@ -628,6 +634,12 @@ module Samagotchi
|
|
|
628
634
|
Log.info(:worker, "idle_exit", idle_s: @idle_exit.idle_seconds.round)
|
|
629
635
|
end
|
|
630
636
|
|
|
637
|
+
# A human's input (ArchiveStore.user_input?) un-archives the session;
|
|
638
|
+
# a delegate's, a plugin's or a reminder's doesn't.
|
|
639
|
+
def user_input(client_id)
|
|
640
|
+
ArchiveStore.user_input(@session_id, state_dir: @state_dir) if ArchiveStore.user_input?(client_id)
|
|
641
|
+
end
|
|
642
|
+
|
|
631
643
|
def stopped_on_disk?
|
|
632
644
|
SessionManager.stopped_on_disk?(@session_id, state_dir: @state_dir)
|
|
633
645
|
end
|