samagotchi 0.73.0 → 0.75.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 +46 -1
- data/docs/desktop.md +2 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/background-tasks.md +2 -1
- data/docs/internals/bundles.md +2 -1
- data/docs/memory.md +14 -1
- data/docs/plugins.md +10 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +4 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +41 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -0
- data/lib/samagotchi/memory_bundle/installer.rb +18 -4
- data/lib/samagotchi/memory_bundle/memory_files.rb +47 -0
- data/lib/samagotchi/paths.rb +8 -6
- data/lib/samagotchi/session.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -8
- data/lib/samagotchi/session_manager.rb +7 -0
- data/lib/samagotchi/tool_output_cut.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +17 -4
- data/lib/samagotchi/tools/task_runtime.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/chains.js +42 -21
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/info_bar.js +4 -4
- data/lib/samagotchi/web/public/info_bar_model.js +16 -7
- data/lib/samagotchi/web/public/lists_view.js +2 -2
- data/lib/samagotchi/web/public/plain_line.js +55 -0
- data/lib/samagotchi/web/public/question_cards.js +4 -2
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/stage_model.js +3 -33
- data/lib/samagotchi/web/public/turn_html.js +3 -2
- data/lib/samagotchi/web/public/turn_model.js +7 -4
- metadata +3 -1
|
@@ -6,7 +6,7 @@ require_relative "recap_store"
|
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
# Session chains: a session that continues an earlier one (Session#continues),
|
|
9
|
-
# "the next
|
|
9
|
+
# "the next run of the same routine". The chain is derived, never stored:
|
|
10
10
|
# follow +continues+ back for the earlier links, forward (the session whose
|
|
11
11
|
# +continues+ names this one) for the later ones. A link is continued at
|
|
12
12
|
# most once (SessionManager.continue_session refuses a second), so the
|
|
@@ -69,7 +69,7 @@ module Samagotchi
|
|
|
69
69
|
# @param recap [Hash, nil] RecapStore.read's
|
|
70
70
|
# @return [String]
|
|
71
71
|
def note_text(previous, recap:)
|
|
72
|
-
head = "This session continues #{previous.id[0, 8]} (#{
|
|
72
|
+
head = "This session continues #{previous.id[0, 8]} (#{clock(previous.created_at) || previous.created_at}), the previous link of its chain."
|
|
73
73
|
text = recap && recap[:text].to_s.strip
|
|
74
74
|
return "#{head} It left no recap." if text.nil? || text.empty?
|
|
75
75
|
|
|
@@ -102,12 +102,6 @@ module Samagotchi
|
|
|
102
102
|
nil
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
def day(iso)
|
|
106
|
-
Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
|
|
107
|
-
rescue ArgumentError
|
|
108
|
-
iso.to_s
|
|
109
|
-
end
|
|
110
|
-
|
|
111
105
|
def clock(iso)
|
|
112
106
|
iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
113
107
|
rescue ArgumentError
|
|
@@ -25,6 +25,7 @@ require_relative "worker_sidecar"
|
|
|
25
25
|
require_relative "worker_signals"
|
|
26
26
|
require_relative "log"
|
|
27
27
|
require_relative "log_path"
|
|
28
|
+
require_relative "paths"
|
|
28
29
|
require_relative "model_profile"
|
|
29
30
|
require_relative "installed_gem"
|
|
30
31
|
require_relative "installed_versions"
|
|
@@ -213,6 +214,8 @@ module Samagotchi
|
|
|
213
214
|
# SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
|
|
214
215
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
215
216
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
217
|
+
# XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
|
|
218
|
+
# (Paths): the worker starts in the session's directory.
|
|
216
219
|
private_class_method def self.spawn_options(session)
|
|
217
220
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
218
221
|
# terminal must not reach them (the worker then leaves the process
|
|
@@ -239,6 +242,10 @@ module Samagotchi
|
|
|
239
242
|
else
|
|
240
243
|
child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
|
|
241
244
|
end
|
|
245
|
+
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
246
|
+
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
247
|
+
child_env["XDG_STATE_HOME"] = Paths.state_home unless ENV.fetch("XDG_STATE_HOME", "").to_s.strip.empty?
|
|
248
|
+
child_env["XDG_CONFIG_HOME"] = Paths.config_home unless ENV.fetch("XDG_CONFIG_HOME", "").to_s.strip.empty?
|
|
242
249
|
# The spawner's environment must not reach the worker's guardrails (a
|
|
243
250
|
# parent agent starting or waking a session): Process.spawn unsets a
|
|
244
251
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
@@ -64,7 +64,8 @@ module Samagotchi
|
|
|
64
64
|
# command has none: its output isn't all there is. A command a signal
|
|
65
65
|
# ended (a segfault, an OOM kill) has an exit line with no number
|
|
66
66
|
# ("exit: "): it ended, so its output is all there is. The JS twin is
|
|
67
|
-
# turn_html.js's EXIT_LINE.
|
|
67
|
+
# turn_html.js's EXIT_LINE; spec/fixtures/exit_lines.json holds the
|
|
68
|
+
# cases both must answer alike (both specs run it).
|
|
68
69
|
STDOUT_LABEL = "\nstdout:\n"
|
|
69
70
|
STDERR_LABEL = "\nstderr:\n"
|
|
70
71
|
EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?\z/
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "registry"
|
|
4
4
|
require_relative "../tool_declarations"
|
|
5
|
+
require_relative "../paths"
|
|
5
6
|
require_relative "execute"
|
|
6
7
|
require_relative "read"
|
|
7
8
|
require_relative "write"
|
|
@@ -127,6 +128,8 @@ module Samagotchi
|
|
|
127
128
|
# level (SAMAGOTCHI_SESSION_REASONING_HISTORY, the same way). A
|
|
128
129
|
# worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
|
|
129
130
|
# is unset: it was that run's, not the default of a chi run here.
|
|
131
|
+
# A set XDG_STATE_HOME / XDG_CONFIG_HOME goes in absolute (Paths), so
|
|
132
|
+
# a chi run in another cwd (chi send) reads this chi's dirs.
|
|
130
133
|
def parent_env(kctx, env: ENV)
|
|
131
134
|
peers = kctx.peers
|
|
132
135
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
@@ -136,7 +139,16 @@ module Samagotchi
|
|
|
136
139
|
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
137
140
|
SESSION_MODEL_ENV => model.empty? ? nil : model,
|
|
138
141
|
SESSION_THINKING_ENV => thinking,
|
|
139
|
-
SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env))
|
|
142
|
+
SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env)).merge(xdg_absolute(env))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The XDG dirs set in +env+, made absolute (nothing for an unset one).
|
|
146
|
+
def xdg_absolute(env = ENV)
|
|
147
|
+
set = ->(key) { !env.fetch(key, "").to_s.strip.empty? }
|
|
148
|
+
dirs = {}
|
|
149
|
+
dirs["XDG_STATE_HOME"] = Paths.state_home(env: env) if set.call("XDG_STATE_HOME")
|
|
150
|
+
dirs["XDG_CONFIG_HOME"] = Paths.config_home(env: env) if set.call("XDG_CONFIG_HOME")
|
|
151
|
+
dirs
|
|
140
152
|
end
|
|
141
153
|
|
|
142
154
|
# The unsets that keep a `chi --model X` worker's default from passing
|
|
@@ -151,11 +163,12 @@ module Samagotchi
|
|
|
151
163
|
SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
|
|
152
164
|
SESSION_REASONING_HISTORY_ENV = "SAMAGOTCHI_SESSION_REASONING_HISTORY"
|
|
153
165
|
|
|
154
|
-
# task_create's parent_env, less the
|
|
155
|
-
#
|
|
166
|
+
# task_create's parent_env, less what the model set in its env (a
|
|
167
|
+
# SAMAGOTCHI_DEFAULT_MODEL or an XDG dir it asked for stays; chi's own
|
|
168
|
+
# markers are reserved, the model's env can't set them).
|
|
156
169
|
def task_marker_env(kctx, env)
|
|
157
170
|
given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
|
|
158
|
-
parent_env(kctx).reject { |key,
|
|
171
|
+
parent_env(kctx).reject { |key, _| given.include?(key) }
|
|
159
172
|
end
|
|
160
173
|
|
|
161
174
|
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
@@ -99,6 +99,8 @@ module Samagotchi
|
|
|
99
99
|
# status. Only while this chi runs. A record a refresh finished in
|
|
100
100
|
# between (failed, no exit code) is corrected; a stopped task (its
|
|
101
101
|
# stop flag on disk) is left as task_stop records it.
|
|
102
|
+
# The thread is named EXIT_STATUS_THREAD (a spec joins them before it
|
|
103
|
+
# removes the tasks' folder).
|
|
102
104
|
# @return [Thread]
|
|
103
105
|
def record_exit_status(waiter, path, task_id:)
|
|
104
106
|
# Absolute now: safe against a later chdir.
|
|
@@ -106,6 +108,7 @@ module Samagotchi
|
|
|
106
108
|
path = File.expand_path(path)
|
|
107
109
|
Thread.new do
|
|
108
110
|
Thread.current.report_on_exception = false
|
|
111
|
+
Thread.current.name = EXIT_STATUS_THREAD
|
|
109
112
|
code = status_code(waiter.value)
|
|
110
113
|
next if code.nil? || record_at(record_path)&.stop_requested_by
|
|
111
114
|
|
|
@@ -124,6 +127,9 @@ module Samagotchi
|
|
|
124
127
|
status.exitstatus || (status.termsig && (128 + status.termsig))
|
|
125
128
|
end
|
|
126
129
|
|
|
130
|
+
# record_exit_status's threads' name.
|
|
131
|
+
EXIT_STATUS_THREAD = "task_exit_status"
|
|
132
|
+
|
|
127
133
|
# Reasons a refresh finishes a record with when it read no exit code.
|
|
128
134
|
MISSING_CODE_REASONS = ["process_ended_without_exit_code", NOT_CHIS_PROCESS].freeze
|
|
129
135
|
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Session chains on the page (docs/sessions.md, Session chains): a session
|
|
2
|
-
// that continues an earlier one (its summary's `continues`), "the next
|
|
2
|
+
// that continues an earlier one (its summary's `continues`), "the next run of
|
|
3
3
|
// the same routine". The chain is derived from the list, never stored: back
|
|
4
4
|
// through `continues`, forward through the session whose `continues` names
|
|
5
5
|
// this one. The lists fold a chain into its latest listed link's card (a
|
|
6
|
-
//
|
|
6
|
+
// `#N` chip, its earlier links in a popover); the session page links the
|
|
7
7
|
// previous and the next link. Pure: lists_view.js places the chip and rows
|
|
8
8
|
// (lit-html templates) and app.js the toast's markup, and they bind them.
|
|
9
9
|
import { normalize, previewForCard, relativeTime } from "./format.js";
|
|
@@ -33,7 +33,7 @@ export function isOpener(text) {
|
|
|
33
33
|
|
|
34
34
|
// A link's card title (format.js previewForCard), display only: a link
|
|
35
35
|
// whose title is the opener, as every link's first message is once sent,
|
|
36
|
-
// reads its chain's first link's title instead, so the
|
|
36
|
+
// reads its chain's first link's title instead, so the links of a chain
|
|
37
37
|
// keep its name. Its own title when the first link isn't in +byId+ (id →
|
|
38
38
|
// session; deleted, or a walk a missing link cut short) or is the opener too.
|
|
39
39
|
export function chainTitle(s, byId) {
|
|
@@ -112,13 +112,32 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
|
112
112
|
return { prev, next };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
export function chainDate(iso) {
|
|
115
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
116
|
+
const localDate = (iso) => {
|
|
118
117
|
const d = new Date(iso || "");
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// A link's day as the carry note writes its date: the local YYYY-MM-DD of
|
|
122
|
+
// its start; "" when it has none.
|
|
123
|
+
export function chainDate(iso) {
|
|
124
|
+
const d = localDate(iso);
|
|
125
|
+
return d ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A link's start as a local HH:MM; "" when it has none.
|
|
129
|
+
export function chainClock(iso) {
|
|
130
|
+
const d = localDate(iso);
|
|
131
|
+
return d ? `${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A link's start as a chain list shows it: its date (chainDate), with its
|
|
135
|
+
// HH:MM when one of +otherIsos+ (the chain's other links' starts) is on the
|
|
136
|
+
// same local day, so two links of one day tell apart. "" when it has none.
|
|
137
|
+
export function chainStamp(iso, otherIsos = []) {
|
|
138
|
+
const date = chainDate(iso);
|
|
139
|
+
if (!date) return "";
|
|
140
|
+
return otherIsos.some((o) => chainDate(o) === date) ? `${date} ${chainClock(iso)}` : date;
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
// The families (sessions_list.js families) with the chains folded in: a
|
|
@@ -129,7 +148,7 @@ export function chainDate(iso) {
|
|
|
129
148
|
// index). +all+: every session the page holds, archived ones too (the links
|
|
130
149
|
// the list leaves out). The same array when no chain is in it.
|
|
131
150
|
// @param fold false: every family stays (each link's card carries its
|
|
132
|
-
// chip, "
|
|
151
|
+
// chip, "#1 of 3"; folded false), for a search or the archived ones
|
|
133
152
|
export function foldChains(fams, all, { fold = true } = {}) {
|
|
134
153
|
if (!all.some((s) => s.continues)) return fams;
|
|
135
154
|
const nexts = nextsOf(all);
|
|
@@ -160,15 +179,15 @@ export function allViewChains(fams, all, { query = "", includeArchived = false }
|
|
|
160
179
|
}
|
|
161
180
|
|
|
162
181
|
// The links a family's chain list shows: the chain but its head, newest
|
|
163
|
-
// first, each {session,
|
|
182
|
+
// first, each {session, n} (n: its 1-based place in the chain).
|
|
164
183
|
function chainOthers(f) {
|
|
165
184
|
const links = f?.chain?.links || [];
|
|
166
|
-
return links.map((session, i) => ({ session,
|
|
185
|
+
return links.map((session, i) => ({ session, n: i + 1 }))
|
|
167
186
|
.filter(({ session }) => session.id !== f.head.id)
|
|
168
187
|
.reverse();
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
// The chip in a chain's card: "
|
|
190
|
+
// The chip in a chain's card: "#3" (the head's place; "#2 of 3" when
|
|
172
191
|
// later links aren't listed), with a live dot / "waiting" when one of the
|
|
173
192
|
// other links has a worker / waits on the user (typing in an archived link
|
|
174
193
|
// brings it back, but it stays folded here). nothing for a family in no
|
|
@@ -180,12 +199,12 @@ export function chainChipTemplate(f, { open = false } = {}) {
|
|
|
180
199
|
const others = chainOthers(f);
|
|
181
200
|
const live = others.filter(({ session: s }) => s.owner === "worker").length;
|
|
182
201
|
const waiting = others.filter(({ session: s }) => waitingOn(s)).length;
|
|
183
|
-
const
|
|
184
|
-
const text =
|
|
202
|
+
const n = at + 1;
|
|
203
|
+
const text = n === links.length ? `#${n}` : `#${n} of ${links.length}`;
|
|
185
204
|
const earlier = at;
|
|
186
205
|
const later = links.length - 1 - at;
|
|
187
206
|
const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
|
|
188
|
-
const title = `Session chain:
|
|
207
|
+
const title = `Session chain: link ${n} of ${links.length} (${parts.join(", ")}); open the list`;
|
|
189
208
|
const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
|
|
190
209
|
const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
|
|
191
210
|
return html`<button type="button" class=${`chain-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
|
|
@@ -193,24 +212,26 @@ export function chainChipTemplate(f, { open = false } = {}) {
|
|
|
193
212
|
}
|
|
194
213
|
|
|
195
214
|
// The chain's list (the chip's popover): one row per other link, newest
|
|
196
|
-
// first: status dot, "
|
|
215
|
+
// first: status dot, "#N", its date (with its HH:MM when another link of
|
|
216
|
+
// the chain, the head too, started the same day: chainStamp), its recap's first sentence (else
|
|
197
217
|
// its preview), the waiting / stopped badge, archived. A row's click opens
|
|
198
218
|
// that link (families_view.js's popover binds .family-row[data-id]).
|
|
199
219
|
// @param activeId the open session's id (its row is .active)
|
|
200
220
|
export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {}) {
|
|
201
221
|
const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
|
|
202
|
-
const
|
|
222
|
+
const links = f?.chain?.links || [];
|
|
223
|
+
const rows = chainOthers(f).map(({ session: s, n }) => {
|
|
203
224
|
const cls = ["family-row", "chain-row"];
|
|
204
225
|
if (activeId && s.id === activeId) cls.push("active");
|
|
205
226
|
if (s.archived) cls.push("archived");
|
|
206
227
|
const waiting = waitingBadge(s, { inFamily: true });
|
|
207
228
|
const stopped = waiting ? null : stoppedBadge(s);
|
|
208
|
-
const date =
|
|
229
|
+
const date = chainStamp(s.created_at, links.filter((l) => l !== s).map((l) => l.created_at));
|
|
209
230
|
const ago = relativeTime(s.updated_at || s.created_at || "", now);
|
|
210
231
|
const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
|
|
211
232
|
return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
|
|
212
|
-
title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day"
|
|
213
|
-
date ? html`<span class
|
|
233
|
+
title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">#${n}</span>${
|
|
234
|
+
date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
|
|
214
235
|
rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
|
|
215
236
|
});
|
|
216
237
|
return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
|
|
@@ -1033,6 +1033,8 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
|
|
|
1033
1033
|
.chain-chip .cc-waiting{font-family:system-ui,sans-serif;font-weight:600}
|
|
1034
1034
|
.chain-row .cr-day{font-family:ui-monospace,monospace;font-size:11px;color:var(--accent);white-space:nowrap}
|
|
1035
1035
|
.chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
1036
|
+
/* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
|
|
1037
|
+
.chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
|
|
1036
1038
|
/* The session page's links to the previous and the next link of its chain. */
|
|
1037
1039
|
#infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
|
|
1038
1040
|
#infoBar .meta a.chain-link:hover{background:var(--accent-bg)}
|
|
@@ -21,12 +21,12 @@ import { chipTemplate as toolkitChip } from "./toolkit_chip.js";
|
|
|
21
21
|
export const PREV = "←";
|
|
22
22
|
export const NEXT = "→";
|
|
23
23
|
|
|
24
|
-
// A chain's ← / → link (chains.js chainNeighbours): its date (else
|
|
25
|
-
// id), its
|
|
26
|
-
// archived}.
|
|
24
|
+
// A chain's ← / → link (chains.js chainNeighbours): its date or time (else
|
|
25
|
+
// its short id), its id, full start and preview in the tooltip. +link+:
|
|
26
|
+
// {id, date, stamp, preview, archived} (info_bar_model.js chainLinkView).
|
|
27
27
|
export function chainLinkTemplate(link, arrow, what) {
|
|
28
28
|
const short = String(link.id).slice(0, 8);
|
|
29
|
-
const title = `${what}: ${short}${link.preview ? ` · ${link.preview}` : ""}${link.archived ? " (archived)" : ""}`;
|
|
29
|
+
const title = `${what}: ${short}${link.stamp ? ` · ${link.stamp}` : ""}${link.preview ? ` · ${link.preview}` : ""}${link.archived ? " (archived)" : ""}`;
|
|
30
30
|
const text = arrow === PREV ? `${arrow} ${link.date || short}` : `${link.date || short} ${arrow}`;
|
|
31
31
|
return html`<a class="model chain-link" href=${sessionHash(link.id)} title=${title}>${text}</a>`;
|
|
32
32
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// its list and its in-flight actions. Pure: app.js's updateInfoBar applies
|
|
5
5
|
// it, every second while a turn runs (the session clock).
|
|
6
6
|
import { canStopSession, childrenSummary, delegatedBy, modelLabel, promptNotesChip } from "./format.js";
|
|
7
|
-
import { chainDate, chainNeighbours } from "./chains.js";
|
|
7
|
+
import { chainClock, chainDate, chainNeighbours } from "./chains.js";
|
|
8
8
|
import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
|
|
9
9
|
import { formatDuration, sessionDurationNow } from "./timing.js";
|
|
10
10
|
import { workerBadge } from "./update_notice.js";
|
|
@@ -21,12 +21,18 @@ export function workerBadgeFor(id, state, versions) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// A chain's link (chains.js chainNeighbours) as the info bar's ← / →
|
|
24
|
-
// shows it (info_bar.js chainLinkTemplate), or null.
|
|
25
|
-
//
|
|
26
|
-
|
|
24
|
+
// shows it (info_bar.js chainLinkTemplate), or null. Its date: its local
|
|
25
|
+
// HH:MM when it started on the open link's day (+selfIso+: the open
|
|
26
|
+
// session's created_at; the day is implied), else its YYYY-MM-DD; its
|
|
27
|
+
// stamp (the tooltip's) always both. +cardTitle+: the lists' title for a
|
|
28
|
+
// session (a chain's opener reads its first link's).
|
|
29
|
+
export function chainLinkView(link, cardTitle, selfIso = null) {
|
|
27
30
|
if (!link) return null;
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const iso = link.session?.created_at;
|
|
32
|
+
const day = chainDate(iso);
|
|
33
|
+
const clock = chainClock(iso);
|
|
34
|
+
return { id: link.id, date: day && day === chainDate(selfIso) ? clock : day, stamp: day ? `${day} ${clock}` : "",
|
|
35
|
+
preview: link.session ? cardTitle(link.session) : "", archived: !!link.session?.archived };
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
// The bar's buttons: continue (not on a link continued already, which has
|
|
@@ -66,6 +72,9 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
|
|
|
66
72
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
67
73
|
const sessionDuration = sessionDurationNow(state.timing, running);
|
|
68
74
|
// A delegated session names its parent, as a link to it.
|
|
75
|
+
// The open link's start: the payload's, else its list entry's (a fresh
|
|
76
|
+
// link may not be listed yet: then neither, and its links show dates).
|
|
77
|
+
const selfIso = state.createdAt || sessions.find((x) => x.id === id)?.created_at || null;
|
|
69
78
|
const parentChip = delegatedBy(state.parentId, sessions.find((x) => x.id === state.parentId) || null);
|
|
70
79
|
return {
|
|
71
80
|
id,
|
|
@@ -74,7 +83,7 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
|
|
|
74
83
|
model: modelLabel(state.model, ...(state.served || [])),
|
|
75
84
|
// A link of a session chain: ← the previous link, → the next one (each
|
|
76
85
|
// only while the list holds it).
|
|
77
|
-
chain: { prev: chainLinkView(chain.prev, cardTitle), next: chainLinkView(chain.next, cardTitle) },
|
|
86
|
+
chain: { prev: chainLinkView(chain.prev, cardTitle, selfIso), next: chainLinkView(chain.next, cardTitle, selfIso) },
|
|
78
87
|
parent: parentChip ? { id: state.parentId, title: parentChip.title } : null,
|
|
79
88
|
// A parent names its delegates; the link opens all sessions.
|
|
80
89
|
kids: childrenSummary({ id }, sessions),
|
|
@@ -129,7 +129,7 @@ export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_
|
|
|
129
129
|
// member does; with rows (all sessions) an open family lists its members
|
|
130
130
|
// inline, the strip's opens in a popover; matchIds: a search's matching
|
|
131
131
|
// members. A family in a session chain (chains.js foldChains) carries its
|
|
132
|
-
//
|
|
132
|
+
// `#N` chip, which opens the other links in a popover. entering: a card
|
|
133
133
|
// new to its list (enteringCards: it fades in); anim: its inline animation
|
|
134
134
|
// phases (cardAnimStyle).
|
|
135
135
|
// +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
|
|
@@ -342,7 +342,7 @@ export function createListsView(deps) {
|
|
|
342
342
|
doc.addEventListener("scroll", () => familyPop.reposition(), true);
|
|
343
343
|
|
|
344
344
|
// A chain's popover (chains.js): the other links of the chain a card's
|
|
345
|
-
//
|
|
345
|
+
// `#N` chip stands for, under the chip, in the strip or all sessions
|
|
346
346
|
// (the list it was opened from: chainFrom). A row opens that link.
|
|
347
347
|
const chainFamilies = new Map(); // head id → family, as the lists last drew it
|
|
348
348
|
let chainFrom = null; // the list element the open popover's chip is in
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Markdown out of the web UI's plain one-line places: the stage's live
|
|
2
|
+
// headline (stage_model.js), a closed step's title (turn_model.js
|
|
3
|
+
// genLabel), an answered question card's kept-reply line (question_cards.js).
|
|
4
|
+
// Pure, no DOM, so `node --test` covers it; the places that render markdown
|
|
5
|
+
// (step bodies, the card's open copy) keep the text as written.
|
|
6
|
+
|
|
7
|
+
// One line as plain text: the markdown a reply carries (headings, quotes,
|
|
8
|
+
// bullets and item numbers, table pipes, emphasis, code ticks, links) goes,
|
|
9
|
+
// and a line that is only syntax (a code fence, a table's separator row, a
|
|
10
|
+
// rule) is empty. A small pass, no renderer: a one-line
|
|
11
|
+
// preview only needs to read well, and an unclosed "**" mid-stream goes too.
|
|
12
|
+
export function plainLine(line) {
|
|
13
|
+
let s = String(line ?? "").trim();
|
|
14
|
+
if (/^(```|~~~)/.test(s)) return "";
|
|
15
|
+
if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
|
|
16
|
+
if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
|
|
17
|
+
s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^([-*+]|\d{1,9}[.)])\s+(\[[ xX]\]\s+)?/, "");
|
|
18
|
+
// A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
|
|
19
|
+
if (/^\||\|$/.test(s)) {
|
|
20
|
+
s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
|
|
21
|
+
}
|
|
22
|
+
// Code spans keep their text as is; an unclosed tick just goes.
|
|
23
|
+
const code = [];
|
|
24
|
+
s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
|
|
25
|
+
s = s
|
|
26
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
27
|
+
.replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
28
|
+
.replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
|
|
29
|
+
.replace(/`+/g, "")
|
|
30
|
+
.replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
|
|
31
|
+
.replace(/(\*\*|~~)/g, "")
|
|
32
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
|
|
33
|
+
.replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
|
|
34
|
+
.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
|
|
35
|
+
return s.replace(/\s+/g, " ").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A reply as one plain line (each of its lines plainLine's), for a preview
|
|
39
|
+
// that clamps it: blank and syntax-only lines go; the lines inside a code
|
|
40
|
+
// fence stay as written (code's "*" and "_" are not emphasis), the fences
|
|
41
|
+
// themselves go. A line that ends a sentence (or a clause: ":", ",") runs on
|
|
42
|
+
// with a space, one that doesn't (a heading, an item) with " · ", so
|
|
43
|
+
// "## Plan" and the line under it don't read as one sentence.
|
|
44
|
+
export function plainText(text) {
|
|
45
|
+
let out = "";
|
|
46
|
+
let fenced = false;
|
|
47
|
+
for (const raw of String(text ?? "").split("\n")) {
|
|
48
|
+
if (/^\s*(```|~~~)/.test(raw)) { fenced = !fenced; continue; }
|
|
49
|
+
const line = fenced ? raw.trim() : plainLine(raw);
|
|
50
|
+
if (!line) continue;
|
|
51
|
+
if (out) out += /[.!?…:;,]["')\]]*$/.test(out) ? " " : " · ";
|
|
52
|
+
out += line;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { sessionHash } from "./route.js";
|
|
23
23
|
import { clientLabel, isOwn } from "./turn_events.js";
|
|
24
24
|
import { continueLine } from "./commands.js";
|
|
25
|
+
import { plainText } from "./plain_line.js";
|
|
25
26
|
|
|
26
27
|
// @param sessionId () => the open session's id (answers go there)
|
|
27
28
|
// @param clientId this tab's id in the session's events
|
|
@@ -404,7 +405,8 @@ export function createQuestionCards({
|
|
|
404
405
|
// The reply that asked, kept with the card: what made the question
|
|
405
406
|
// answerable stays with it in the history. Muted under the "Q → A" line
|
|
406
407
|
// in two lines while the card is closed (a copy screen readers skip: not
|
|
407
|
-
// the toggle's label
|
|
408
|
+
// the toggle's label; plain text, plain_line.js: no "##" or "**" in a
|
|
409
|
+
// clamped line), and whole, selectable, at the top of the body
|
|
408
410
|
// when it is open (a drag in the summary would toggle the card).
|
|
409
411
|
const asked = saidOf.get(questionEl);
|
|
410
412
|
let full = null;
|
|
@@ -412,7 +414,7 @@ export function createQuestionCards({
|
|
|
412
414
|
const line = doc.createElement("div");
|
|
413
415
|
line.className = "question-said";
|
|
414
416
|
line.setAttribute("aria-hidden", "true");
|
|
415
|
-
line.textContent = asked;
|
|
417
|
+
line.textContent = plainText(asked);
|
|
416
418
|
summary.appendChild(line);
|
|
417
419
|
full = doc.createElement("div");
|
|
418
420
|
full.className = "question-said-full";
|
|
@@ -23,6 +23,9 @@ export function emptySessionState() {
|
|
|
23
23
|
parentId: null,
|
|
24
24
|
// The previous link of its chain (its `continues`), or null.
|
|
25
25
|
continues: null,
|
|
26
|
+
// Its start (the summary's created_at; the info bar's chain links are
|
|
27
|
+
// times on its day), or null.
|
|
28
|
+
createdAt: null,
|
|
26
29
|
ctxPct: null,
|
|
27
30
|
// Window the kernel resolved for the running generation (:generation_started).
|
|
28
31
|
ctxWindow: null,
|
|
@@ -107,6 +110,7 @@ export function sessionStateFrom(data) {
|
|
|
107
110
|
state.toolkit = s.toolkit || null;
|
|
108
111
|
state.parentId = s.parent_id || null;
|
|
109
112
|
state.continues = s.continues || null;
|
|
113
|
+
state.createdAt = s.created_at || null;
|
|
110
114
|
state.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
111
115
|
return state;
|
|
112
116
|
}
|
|
@@ -7,6 +7,7 @@ import { splitSentences } from "./sentences.js";
|
|
|
7
7
|
import { steerSender } from "./format.js";
|
|
8
8
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
9
9
|
import { askingText } from "./turn_model.js";
|
|
10
|
+
import { plainLine } from "./plain_line.js";
|
|
10
11
|
|
|
11
12
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
12
13
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
@@ -19,37 +20,6 @@ const QUIET_MS = 1500;
|
|
|
19
20
|
// A touch, wheel or scroll counts as use this long (phones have no hover).
|
|
20
21
|
const TOUCH_MS = 4000;
|
|
21
22
|
|
|
22
|
-
// One headline sentence as plain text: the markdown a streaming answer
|
|
23
|
-
// carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
|
|
24
|
-
// links) goes, and a line that is only syntax (a code fence, a table's
|
|
25
|
-
// separator row, a rule) is empty. A small pass, no renderer: a live line
|
|
26
|
-
// only needs to read well, and an unclosed "**" mid-stream goes too.
|
|
27
|
-
export function plainHeadline(line) {
|
|
28
|
-
let s = String(line ?? "").trim();
|
|
29
|
-
if (/^(```|~~~)/.test(s)) return "";
|
|
30
|
-
if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
|
|
31
|
-
if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
|
|
32
|
-
s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, "");
|
|
33
|
-
// A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
|
|
34
|
-
if (/^\||\|$/.test(s)) {
|
|
35
|
-
s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
|
|
36
|
-
}
|
|
37
|
-
// Code spans keep their text as is; an unclosed tick just goes.
|
|
38
|
-
const code = [];
|
|
39
|
-
s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
|
|
40
|
-
s = s
|
|
41
|
-
.replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
42
|
-
.replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
43
|
-
.replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
|
|
44
|
-
.replace(/`+/g, "")
|
|
45
|
-
.replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
|
|
46
|
-
.replace(/(\*\*|~~)/g, "")
|
|
47
|
-
.replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
|
|
48
|
-
.replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
|
|
49
|
-
.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
|
|
50
|
-
return s.replace(/\s+/g, " ").trim();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
23
|
// The step's newest narration sentence, as plain text: the end of a
|
|
54
24
|
// finished text is a boundary (its last line may have no period), a live
|
|
55
25
|
// one's not yet. A sentence that is only markdown syntax is skipped, and so
|
|
@@ -61,9 +31,9 @@ export function headlineOf(text, done) {
|
|
|
61
31
|
const plain = [];
|
|
62
32
|
for (const sentence of sentences) {
|
|
63
33
|
if (/^(```|~~~)/.test(sentence)) fenced = !fenced;
|
|
64
|
-
else if (!fenced &&
|
|
34
|
+
else if (!fenced && plainLine(sentence)) plain.push(plainLine(sentence));
|
|
65
35
|
}
|
|
66
|
-
const fragment = fenced ? "" :
|
|
36
|
+
const fragment = fenced ? "" : plainLine(rest);
|
|
67
37
|
if (done && fragment) return fragment;
|
|
68
38
|
if (plain.length) return plain[plain.length - 1];
|
|
69
39
|
return fragment.length > 160 ? fragment : "";
|
|
@@ -83,8 +83,9 @@ function llmEditTemplate(row) {
|
|
|
83
83
|
// A gate-corrected call's `ran as:` line shows on hover only.
|
|
84
84
|
const INLINE_CHARS = 300;
|
|
85
85
|
// The JS twin of ToolOutputCut::EXIT_LINE (tool_output_cut.rb): an exit line, then maybe a hint; a
|
|
86
|
-
// signal-killed command's has no number ("exit: ").
|
|
87
|
-
|
|
86
|
+
// signal-killed command's has no number ("exit: "). spec/fixtures/exit_lines.json holds the cases both
|
|
87
|
+
// must answer alike (exported for turn_html.test.js).
|
|
88
|
+
export const EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?$/;
|
|
88
89
|
// A bare success exit line; group 1 is the hint line after it, if any.
|
|
89
90
|
const SUCCESS_EXIT_LINE = /\nexit: 0(\n\([^\n]*\))?$/;
|
|
90
91
|
const EXECUTE_BLOCK = /(?:^|\n)(?:stdout|stderr):\n/;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { addCompleted, addStarted, newActivity, toolName } from "./activity.js";
|
|
27
27
|
import { tallyText } from "./tally.js";
|
|
28
28
|
import { clip, leadTrimmed } from "./format.js";
|
|
29
|
+
import { plainLine } from "./plain_line.js";
|
|
29
30
|
|
|
30
31
|
const LABEL_MAX = 80;
|
|
31
32
|
const LABEL_TOOLS = 3;
|
|
@@ -210,9 +211,11 @@ export function failedCount(rows) {
|
|
|
210
211
|
return rows.filter((r) => r.status === "error").length;
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
// The collapsed row of a gen: its narration's first line
|
|
214
|
-
//
|
|
215
|
-
//
|
|
214
|
+
// The collapsed row of a gen: its narration's first line as plain text
|
|
215
|
+
// (plain_line.js: "## Plan" reads "Plan"; a line that is only syntax, a rule
|
|
216
|
+
// or a fence, gives way to the next), else its first call's title ("edit
|
|
217
|
+
// lib/a.rb"), else the tools it worked with, else "thinking"; then its call
|
|
218
|
+
// count and how many failed.
|
|
216
219
|
export function genLabel(gen) {
|
|
217
220
|
const { head, calls } = genLabelParts(gen);
|
|
218
221
|
return calls ? `${head} · ${calls}` : head;
|
|
@@ -223,7 +226,7 @@ export function genLabel(gen) {
|
|
|
223
226
|
// starts with (its narration, its first call's title), so an open step's
|
|
224
227
|
// summary can say only its calls (turn_view.js labelHtml).
|
|
225
228
|
export function genLabelParts(gen) {
|
|
226
|
-
const line = String(gen.text || "").
|
|
229
|
+
const line = String(gen.text || "").split("\n").map(plainLine).find(Boolean) || "";
|
|
227
230
|
const calls = gen.tools.length ? toolCallsText(gen.tools) : "";
|
|
228
231
|
const first = gen.tools[0];
|
|
229
232
|
if (line) return { head: cut(line), calls, repeats: true };
|
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.75.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -380,6 +380,7 @@ files:
|
|
|
380
380
|
- lib/samagotchi/memory_bundle/installer.rb
|
|
381
381
|
- lib/samagotchi/memory_bundle/listing.rb
|
|
382
382
|
- lib/samagotchi/memory_bundle/manifest.rb
|
|
383
|
+
- lib/samagotchi/memory_bundle/memory_files.rb
|
|
383
384
|
- lib/samagotchi/memory_bundle/merger.rb
|
|
384
385
|
- lib/samagotchi/memory_bundle/placeholder.rb
|
|
385
386
|
- lib/samagotchi/memory_bundle/plugin_ref.rb
|
|
@@ -617,6 +618,7 @@ files:
|
|
|
617
618
|
- lib/samagotchi/web/public/notice_links.js
|
|
618
619
|
- lib/samagotchi/web/public/notifications.js
|
|
619
620
|
- lib/samagotchi/web/public/notify.js
|
|
621
|
+
- lib/samagotchi/web/public/plain_line.js
|
|
620
622
|
- lib/samagotchi/web/public/prompt_history.js
|
|
621
623
|
- lib/samagotchi/web/public/question_card.js
|
|
622
624
|
- lib/samagotchi/web/public/question_cards.js
|