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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/desktop.md +2 -1
  4. data/docs/guardrails.md +2 -1
  5. data/docs/internals/background-tasks.md +2 -1
  6. data/docs/internals/bundles.md +2 -1
  7. data/docs/memory.md +14 -1
  8. data/docs/plugins.md +10 -2
  9. data/docs/releasing.md +1 -1
  10. data/docs/sessions.md +4 -4
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +41 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/desktop/macos.rb +5 -0
  15. data/lib/samagotchi/memory_bundle/installer.rb +18 -4
  16. data/lib/samagotchi/memory_bundle/memory_files.rb +47 -0
  17. data/lib/samagotchi/paths.rb +8 -6
  18. data/lib/samagotchi/session.rb +1 -1
  19. data/lib/samagotchi/session_chain.rb +2 -8
  20. data/lib/samagotchi/session_manager.rb +7 -0
  21. data/lib/samagotchi/tool_output_cut.rb +2 -1
  22. data/lib/samagotchi/tools/builtins.rb +17 -4
  23. data/lib/samagotchi/tools/task_runtime.rb +6 -0
  24. data/lib/samagotchi/version.rb +1 -1
  25. data/lib/samagotchi/web/public/chains.js +42 -21
  26. data/lib/samagotchi/web/public/index.html +2 -0
  27. data/lib/samagotchi/web/public/info_bar.js +4 -4
  28. data/lib/samagotchi/web/public/info_bar_model.js +16 -7
  29. data/lib/samagotchi/web/public/lists_view.js +2 -2
  30. data/lib/samagotchi/web/public/plain_line.js +55 -0
  31. data/lib/samagotchi/web/public/question_cards.js +4 -2
  32. data/lib/samagotchi/web/public/session_state.js +4 -0
  33. data/lib/samagotchi/web/public/stage_model.js +3 -33
  34. data/lib/samagotchi/web/public/turn_html.js +3 -2
  35. data/lib/samagotchi/web/public/turn_model.js +7 -4
  36. 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 day of the same routine". The chain is derived, never stored:
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]} (#{day(previous.created_at)}), the previous link of its chain."
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 unsets of a variable the model
155
- # set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
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, value| value.nil? && given.include?(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
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.73.0"
4
+ VERSION = "0.75.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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 day of
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
- // `day N` chip, its earlier links in a popover); the session page links the
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 days of a chain
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
- // A link's day as the carry note writes it: the local YYYY-MM-DD of its
116
- // start; "" when it has none.
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
- if (Number.isNaN(d.getTime())) return "";
120
- const pad = (n) => String(n).padStart(2, "0");
121
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
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, "day 1 of 3"; folded false), for a search or the archived ones
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, day} (day: its 1-based place in the chain).
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, day: i + 1 }))
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: "day 3" (the head's place; "day 2 of 3" when
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 day = at + 1;
184
- const text = day === links.length ? `day ${day}` : `day ${day} of ${links.length}`;
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: day ${day} of ${links.length} (${parts.join(", ")}); open the list`;
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, "day N", its date, its recap's first sentence (else
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 rows = chainOthers(f).map(({ session: s, day }) => {
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 = chainDate(s.created_at);
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">day ${day}</span>${
213
- date ? html`<span class="cr-date">${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
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 its short
25
- // id), its preview and id in the tooltip. +link+: {id, date, preview,
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. +cardTitle+: the
25
- // lists' title for a session (a chain's opener reads its first link's).
26
- export function chainLinkView(link, cardTitle) {
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
- return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? cardTitle(link.session) : "",
29
- archived: !!link.session?.archived };
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
- // `day N` chip, which opens the other links in a popover. entering: a card
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
- // `day N` chip stands for, under the chip, in the strip or all sessions
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), and whole, selectable, at the top of the body
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 && plainHeadline(sentence)) plain.push(plainHeadline(sentence));
34
+ else if (!fenced && plainLine(sentence)) plain.push(plainLine(sentence));
65
35
  }
66
- const fragment = fenced ? "" : plainHeadline(rest);
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
- const EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?$/;
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, else its first
214
- // call's title ("edit lib/a.rb"), else the tools it worked with, else
215
- // "thinking"; then its call count and how many failed.
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 || "").trim().split("\n")[0].trim();
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.73.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