samagotchi 0.100.0 → 0.100.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10cafc8e1deb147528195213bd69b946f428918a8c344c57022b2c905cd2f4fb
4
- data.tar.gz: 6b6764b06a4effc61ac2edd1d2221d7afd232b2a8ce3470f0b14280d3ded432d
3
+ metadata.gz: 47dbac4f107af7d9c7d9d571b907a1e9e777326732614ed34986f2dce954168a
4
+ data.tar.gz: 4335f033596dc75f3db80b754a347e36862d0fd07f1d72bafcc7a5c50ed539ff
5
5
  SHA512:
6
- metadata.gz: a4da078e7bb452a96fbc8d38f9565946fa12a3e3b8185e35f26032fcc2a3ae76249ec5ad7712dd12b74fec0f52db5fac7c03890f5074fca747985944b50f8e26
7
- data.tar.gz: 3cc980b577869624c31a8c577b69612198569875d5c3b1e2f9070a4aebed2b5d4e6d4d29d44e25e56e2144b2346f4017352dca2e7a01b2898bc6fa5046b4459e
6
+ metadata.gz: a2454b93c9271b14ba0349c2991f6b0312dfdee76fd6a90005feb38726025dad71b6b4f1701bcbb2622c3db786d86fe2e58636668aa2f7d43d0ac289cae36e5a
7
+ data.tar.gz: 8fef8d270b58719f286b57c840f1a5fc3a53de10fec01fdcc08ea3de1315de17d267a834759b05ae09c05ca80b8f27009329eb38ff0aaf9a7b6ab59585b3e1d3
data/CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.100.1] - 2026-10-22
12
+
13
+ ### Fixed
14
+
15
+ - The web's `continue →` no longer takes a draft left over from another session (or the start page) as the new
16
+ link's first message: the opener goes in, and the toast's **Restore draft** brings the draft back. A draft written
17
+ in the continued session still stays.
18
+ - work-cap 0.1.1: a changed `bundles: work-cap:` setting reaches a running session (the caps are resolved again when the
19
+ settings change; the state file still wins per meter), and an empty key (`tool_calls:` with no value) falls through to
20
+ `child:` / `default:` instead of reading as "off"; `off`, `false` and `0` stay an explicit no cap.
21
+ - Web: a closed step's text in the history renders as markdown (with `web.markdown` on) and keeps its source refs,
22
+ live and after a reload, as the answer does.
23
+ - A hook entry in config.yml's `hooks:` that isn't a mapping, or has no `path:`, warns naming it
24
+ (`hooks.before_turn[0]`) instead of vanishing; the event's other entries still load.
25
+
11
26
  ## [0.100.0] - 2026-10-22
12
27
 
13
28
  ### Added
@@ -3792,7 +3807,8 @@ and long-lived sessions.
3792
3807
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3793
3808
  hotkey panel that send selected text or the clipboard to your sessions.
3794
3809
 
3795
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.100.0...HEAD
3810
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.100.1...HEAD
3811
+ [0.100.1]: https://github.com/dm1try/samagotchi/compare/v0.100.0...v0.100.1
3796
3812
  [0.100.0]: https://github.com/dm1try/samagotchi/compare/v0.99.0...v0.100.0
3797
3813
  [0.99.0]: https://github.com/dm1try/samagotchi/compare/v0.98.0...v0.99.0
3798
3814
  [0.98.0]: https://github.com/dm1try/samagotchi/compare/v0.97.0...v0.98.0
data/docs/architecture.md CHANGED
@@ -431,7 +431,10 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
431
431
  `drawAsking(text, refs)`) pairs them on its raw draw and again after its markdown swap; the card pairs its
432
432
  question text and its kept reply (`question_cards.js`); the step's closed `.gen-text` takes them
433
433
  (`turn_view.js`; on a reload its `data-answer-at`, the message's `display_refs`) and `linkTextRefs` pairs it at
434
- the turn's end and after a reload. A file ref outside the
434
+ the turn's end and after a reload. A closed step's text is markdown too with `web.markdown` on: a reload's
435
+ from its message's `html` (`turn_view.js` `textTemplate`), a live turn's from `POST /api/render` on its step's
436
+ first open after the turn's end (`markdown_place.js` `renderStepTexts`, which pairs its refs again after the
437
+ swap); the text as written stays on it as `data-copy-source` (`stepText`, a reloaded card's kept reply). A file ref outside the
435
438
  session's folder is dropped, as a reload drops it: the folder `/tail` names, else (an older worker) the hub's
436
439
  session list entry's, else (refs to check, no hub entry) the session file's; with no folder known at all, every
437
440
  file ref is dropped rather than shown unchecked.
data/docs/hooks.md CHANGED
@@ -28,6 +28,8 @@ hooks:
28
28
 
29
29
  Each event key holds a list of entries. A `hooks:` that isn't a mapping, or an event key that isn't a list
30
30
  (`before_turn: guard.rb`), warns at each start and loads nothing for it; an empty key loads nothing, silently.
31
+ An entry that isn't a mapping (`- guard.rb`) or has no `path:` warns the same way, naming it
32
+ (`hooks.before_turn[0]`, counted from 0), and is skipped alone: the key's other entries load.
31
33
 
32
34
  ## Plugin Format
33
35
 
data/docs/sessions.md CHANGED
@@ -237,7 +237,7 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
237
237
  - **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
238
238
  - **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
239
239
  - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
240
- - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
240
+ - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
241
241
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
242
242
  - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
243
243
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.100.0
3
+ version: 0.100.1
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: work-cap
3
- version: 0.1.0
3
+ version: 0.1.1
4
4
  scope: system
5
5
  description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:d4de4b835d1657223517f3d6a5d8a9b089b7c8fad08ed077f72748369323bd65
9
+ sha256: sha256:f4ce7ea7f5801490a29e232c41c7bca2007c140d21f1a78bbfb60e6b6fd4f64b
10
10
  # ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
11
11
  requires_chi: ">= 0.100.0"
12
12
  # chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off
@@ -12,9 +12,10 @@
12
12
  # prompt text. cost-cap (the $ cap) is its sibling; the wordings follow it.
13
13
  #
14
14
  # Settings (config.yml, bundles: work-cap:), from ctx.settings, never at
15
- # load: the caps are resolved once per session (see State below), so a
16
- # config change reaches a running session through /workcap or a new
17
- # session; warn_at and ignore_tools are read at each check.
15
+ # load: the caps are resolved once per session and settings (see State
16
+ # below), again when ctx.settings changes, so a config change reaches a
17
+ # running session (the state file still wins per meter); warn_at and
18
+ # ignore_tools are read at each check.
18
19
  # tool_calls: 400 this session's own tool-call cap (session_settings:,
19
20
  # chi --set work-cap.tool_calls=400); 0/off: none
20
21
  # minutes: 60 this session's own cap on active minutes, same rules
@@ -26,8 +27,10 @@
26
27
  # tools whose calls and time aren't counted (waiting:
27
28
  # delegate blocks on its child by default)
28
29
  # Each meter resolves on its own: its own value, else child:'s for a
29
- # delegate, else default:'s. A value that doesn't read (tool_calls: many)
30
- # warns the user once and the next one is used, never a silent "no cap".
30
+ # delegate, else default:'s. An empty key (YAML nil) is unset and falls
31
+ # through; off, false and 0 are an explicit "no cap". A value that doesn't
32
+ # read (tool_calls: many) warns the user once and the next one is used,
33
+ # never a silent "no cap".
31
34
  #
32
35
  # What counts: ctx.usage's tool calls and active time (generations plus
33
36
  # finished tool calls, never a question's wait), less ignore_tools'.
@@ -36,8 +39,8 @@
36
39
  # "step": 400}, "minutes": {"cap": null}}, written by /workcap and by
37
40
  # Continue; a meter's "cap": null is off for the session, over the config;
38
41
  # a meter missing from the file comes from the settings. Resolved once per
39
- # session id (ctx.delegate? parses the session file) and kept until
40
- # /workcap or Continue writes a new one.
42
+ # session id and settings (ctx.delegate? parses the session file) and kept
43
+ # until /workcap or Continue writes a new file, or the settings change.
41
44
  require "json"
42
45
  require "fileutils"
43
46
 
@@ -57,7 +60,7 @@ class Plugin
57
60
 
58
61
  def initialize(_settings)
59
62
  @mutex = Mutex.new
60
- @resolved = nil # {session:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
63
+ @resolved = nil # {session:, settings:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
61
64
  @warned = {} # meter => [session id, cap] the share warning was given for
62
65
  @unread_warned = nil # the session id the "isn't a number" notice was given for
63
66
  end
@@ -101,7 +104,8 @@ class Plugin
101
104
  def self.float_text(text) = text.sub(/\.(?=e|\z)/i, ".0")
102
105
 
103
106
  # Whether +value+ reads as a cap or as none: a number (text too), off,
104
- # false or nil; not "many", true or a list.
107
+ # false or nil (nil is unset, not none: #first_readable skips it); not
108
+ # "many", true or a list.
105
109
  def self.readable?(value, meter)
106
110
  case value
107
111
  when false, nil then true
@@ -269,16 +273,17 @@ class Plugin
269
273
 
270
274
  # The session's caps now: per meter the state file's, else the session's
271
275
  # own value (--set), else child:'s for a delegate, else default:'s.
272
- # Cached per session id.
276
+ # Cached per session id and settings (a config change resolves again).
273
277
  def resolve(ctx)
274
278
  session = ctx.session_id
279
+ settings = ctx.settings
275
280
  cached = @mutex.synchronize { @resolved }
276
- return cached if cached && cached[:session] == session
281
+ return cached if cached && cached[:session] == session && cached[:settings] == settings
277
282
 
278
283
  saved = state(ctx, session)
279
284
  configured = configured(ctx)
280
285
  meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
281
- resolved = { session: session, meters: meters, unreadable: configured[:unreadable] }
286
+ resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable] }
282
287
  @mutex.synchronize { @resolved = resolved }
283
288
  end
284
289
 
@@ -301,9 +306,9 @@ class Plugin
301
306
  end
302
307
 
303
308
  # Per meter the first of its own value, child:'s (a delegate's) and
304
- # default:'s that is set and reads (.readable?); the ones set that don't
305
- # read (and a child:/default: that isn't a mapping) are kept for the
306
- # notice (#unreadable).
309
+ # default:'s that is set (an empty key, nil, isn't) and reads
310
+ # (.readable?); the ones set that don't read (and a child:/default: that
311
+ # isn't a mapping) are kept for the notice (#unreadable).
307
312
  def configured(ctx)
308
313
  settings = ctx.settings
309
314
  unreadable = {}
@@ -327,6 +332,8 @@ class Plugin
327
332
  next unless values.key?(meter)
328
333
 
329
334
  value = values[meter]
335
+ next if value.nil?
336
+
330
337
  unless self.class.readable?(value, meter)
331
338
  unreadable[name] = value
332
339
  next
@@ -360,12 +367,15 @@ class Plugin
360
367
  {}
361
368
  end
362
369
  saved = {} unless saved.is_a?(Hash)
363
- saved[meter] = data
370
+ saved[meter] = data.transform_values { |value| whole(value) }
364
371
  Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
365
372
  # Resolved again at the next use, from the file now holding the meter.
366
373
  @mutex.synchronize { @resolved = nil }
367
374
  end
368
375
 
376
+ # 800.0 as 800 (a whole cap is saved as an Integer), 2.5 as it is.
377
+ def whole(value) = value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
378
+
369
379
  def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
370
380
 
371
381
  def count(value) = self.class.count(value)
@@ -110,8 +110,8 @@ module Samagotchi
110
110
  next
111
111
  end
112
112
 
113
- configs.each do |cfg|
114
- next unless cfg.is_a?(Hash) && cfg["path"]
113
+ configs.each_with_index do |cfg, index|
114
+ next unless valid_entry?(event_type, cfg, index)
115
115
 
116
116
  definitions << {
117
117
  event_type: event_type.to_s,
@@ -125,6 +125,21 @@ module Samagotchi
125
125
  definitions
126
126
  end
127
127
 
128
+ # A hook entry is a mapping with a path:; anything else warns, naming
129
+ # the entry (hooks.EVENT[N], from 0), and is skipped alone.
130
+ def self.valid_entry?(event_type, cfg, index)
131
+ where = "hooks.#{event_type}[#{index}]"
132
+ unless cfg.is_a?(Hash)
133
+ Log.warn(:hooks, "hooks_entry_invalid", echo: "[samagotchi:hooks] config.yml #{where}: must be a mapping with a path:; ignored", event: event_type.to_s, index: index)
134
+ return false
135
+ end
136
+ return true if cfg["path"].is_a?(String) && !cfg["path"].strip.empty?
137
+
138
+ Log.warn(:hooks, "hooks_entry_no_path", echo: "[samagotchi:hooks] config.yml #{where}: has no path:; ignored", event: event_type.to_s, index: index)
139
+ false
140
+ end
141
+ private_class_method :valid_entry?
142
+
128
143
  # Load a plugin from the hooks directory.
129
144
  # Returns an instance of the plugin class. Instances are cached per
130
145
  # [path, settings] across Engines: two entries with different
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.100.0"
4
+ VERSION = "0.100.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -38,7 +38,7 @@ import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
38
38
  import { createCardsPlace } from "./cards_place.js";
39
39
  import { createStageView } from "./stage_view.js";
40
40
  import { createMarkdownPlace } from "./markdown_place.js";
41
- import { openerText, openTitle } from "./chains.js";
41
+ import { openerFill, openTitle, restoredComposer } from "./chains.js";
42
42
  import { createTaskStopper } from "./task_stop.js";
43
43
  import { appendQuote } from "./annotations.js";
44
44
  import { parsePresets } from "./annotate_presets.js";
@@ -67,7 +67,7 @@ import {
67
67
  appendAboveLiveTiming, mergeTiming, normalizeTiming,
68
68
  } from "./timing.js";
69
69
  import { createLiveTurn } from "./live_turn.js";
70
- import { applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
70
+ import { answerRefsOf, applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
71
71
  import { installRefClicks } from "./refs.js";
72
72
  import { linkRefs, touchedFiles } from "./ref_scan.js";
73
73
  import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
@@ -174,6 +174,15 @@ const infoCancelBtn = $("#infoCancelBtn");
174
174
  const composeHintEl = $("#composeHint");
175
175
 
176
176
  let selected = null;
177
+ // The session the composer's text was last written in ("" the start page,
178
+ // undefined unknown): the composer is one for every session, and continue →
179
+ // keeps only a draft of its own (fillOpener).
180
+ let draftFrom;
181
+ // Record that the composer's text was written now, in session +id+ (default
182
+ // the open one). Every write of text into the composer comes through here.
183
+ function noteDraft(id) {
184
+ draftFrom = id ?? selected ?? "";
185
+ }
177
186
  let streamControl = null;
178
187
  // The session list's stream (GET /api/events); null while the page falls
179
188
  // back to fetching (a chi web without a hub).
@@ -213,6 +222,7 @@ const markdownPlace = createMarkdownPlace({
213
222
  normalize,
214
223
  pairAnswerRefs,
215
224
  setAnswerRefs,
225
+ answerRefsOf,
216
226
  });
217
227
  const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
218
228
  const turnDom = view === "stage"
@@ -500,6 +510,7 @@ const sessionActions = createSessionActions({
500
510
  toast: showToast,
501
511
  addCreatedSession,
502
512
  fillOpener,
513
+ restoreDraft,
503
514
  closeLiveStream: () => streamControl?.close(),
504
515
  workerGone,
505
516
  });
@@ -759,6 +770,7 @@ function clearNotice() {
759
770
  noticeEl.textContent = "";
760
771
  }
761
772
  promptEl.addEventListener("input", clearNotice);
773
+ promptEl.addEventListener("input", () => noteDraft());
762
774
 
763
775
  async function select(id) {
764
776
  clearNotice();
@@ -1277,6 +1289,7 @@ const streamHandlers = createStreamHandlers({
1277
1289
  // A failed turn's line back: seen, so the composer opens (this visit).
1278
1290
  openForVisit();
1279
1291
  promptEl.value = text;
1292
+ noteDraft();
1280
1293
  fitPrompt();
1281
1294
  },
1282
1295
  setChips,
@@ -1372,6 +1385,7 @@ const sendTurnControl = createSendTurn({
1372
1385
  clear: clearComposer,
1373
1386
  openForVisit: () => openForVisit(),
1374
1387
  focus: () => focusPrompt(),
1388
+ noteDraft,
1375
1389
  },
1376
1390
  historyEl,
1377
1391
  turnDom,
@@ -1525,6 +1539,7 @@ const unqueue = createUnqueue({
1525
1539
  if (promptEl.value.trim() || chips.length) return false;
1526
1540
  openForVisit();
1527
1541
  promptEl.value = text;
1542
+ noteDraft();
1528
1543
  fitPrompt();
1529
1544
  promptEl.focus();
1530
1545
  return true;
@@ -1571,7 +1586,10 @@ const commandList = createCommandList({
1571
1586
  list: $("#commandList"),
1572
1587
  prompt: promptEl,
1573
1588
  commands: () => (selected && current.owner !== "tui" ? current.commands : []),
1574
- afterPick: () => fitPrompt(),
1589
+ afterPick: () => {
1590
+ noteDraft();
1591
+ fitPrompt();
1592
+ },
1575
1593
  });
1576
1594
 
1577
1595
  // ↑/↓: the prompt history shared with the TUI (prompt_history.js), from a
@@ -1588,6 +1606,7 @@ function historyKey(e) {
1588
1606
  if (!recalled) return false;
1589
1607
  e.preventDefault();
1590
1608
  promptEl.value = recalled.value;
1609
+ noteDraft();
1591
1610
  promptEl.setSelectionRange(recalled.caret, recalled.caret);
1592
1611
  fitPrompt();
1593
1612
  return true;
@@ -1620,15 +1639,31 @@ $("#infoContinueBtn").addEventListener("click", () => continueSelected());
1620
1639
  $("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiveSelected());
1621
1640
 
1622
1641
  // The new link's composer: the opener (openerText: the previous link's own,
1623
- // else the constant), unless a draft is there (it stays). +created+: the
1624
- // continue's 201.
1625
- function fillOpener(created) {
1626
- if (promptEl.value.trim() || chips.length) return;
1642
+ // else the constant). A draft written in this session (or the new link, or
1643
+ // of unknown origin) stays instead; one carried over from another session or
1644
+ // the start page gives way to the opener and is returned, for the toast's
1645
+ // Restore draft (chains.js openerFill). +created+: the continue's 201;
1646
+ // +continuedId+: the session continued.
1647
+ // @return the displaced draft's text, or null
1648
+ function fillOpener(created, continuedId) {
1649
+ const { value, displaced } = openerFill({
1650
+ text: promptEl.value, hasChips: chips.length > 0, draftFrom, continuedId, linkId: created.id, created,
1651
+ });
1652
+ if (value === null) return null;
1627
1653
  openForVisit();
1628
- promptEl.value = openerText(created);
1654
+ promptEl.value = value;
1655
+ noteDraft(created.id);
1629
1656
  fitPrompt();
1630
1657
  focusPrompt();
1631
1658
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
1659
+ return displaced;
1660
+ }
1661
+
1662
+ // Restore draft (the continue toast's): +draft+ back into the composer,
1663
+ // over the untouched +opener+ or an empty one, else after what is there
1664
+ // (chains.js restoredComposer); in whatever session is open now.
1665
+ function restoreDraft(draft, opener) {
1666
+ fillComposer(restoredComposer({ current: promptEl.value, opener, draft }));
1632
1667
  }
1633
1668
 
1634
1669
  const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
@@ -1642,6 +1677,7 @@ function showToast(text, action = null, options = {}) { toasts.show(text, action
1642
1677
  function fillComposer(text) {
1643
1678
  openForVisit();
1644
1679
  promptEl.value = text;
1680
+ noteDraft();
1645
1681
  fitPrompt();
1646
1682
  focusPrompt();
1647
1683
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
@@ -25,6 +25,30 @@ export function openerText(created) {
25
25
  return typeof own === "string" && own.trim() ? own : CONTINUE_OPENER;
26
26
  }
27
27
 
28
+ // What continue → does with the composer as the new link opens (app.js
29
+ // fillOpener): +text+ / +hasChips+ the composer now; +draftFrom+ the session
30
+ // the text was last written in ("" the start page, undefined unknown);
31
+ // +continuedId+ the session continued, +linkId+ the new link. An empty
32
+ // composer gets the opener. A draft written in either session, or of unknown
33
+ // origin, stays (+value+ null). One carried over from another session or the
34
+ // start page gives way to the opener and comes back as +displaced+ (the
35
+ // toast's Restore draft). Its image chips stay either way: only text is
36
+ // replaced.
37
+ export function openerFill({ text, hasChips, draftFrom, continuedId, linkId, created }) {
38
+ const hasText = !!text?.trim();
39
+ if (!hasText && !hasChips) return { value: openerText(created), displaced: null };
40
+ if (draftFrom === undefined || draftFrom === continuedId || draftFrom === linkId) return { value: null, displaced: null };
41
+ return { value: openerText(created), displaced: hasText ? text : null };
42
+ }
43
+
44
+ // The composer after Restore draft: +draft+ (a displaced draft) replaces
45
+ // +current+ when that is empty or still exactly +opener+; else it goes after
46
+ // it, past a blank line. Never loses text typed since.
47
+ export function restoredComposer({ current, opener, draft }) {
48
+ if (!current.trim() || current === opener) return draft;
49
+ return `${current.replace(/\s+$/, "")}\n\n${draft}`;
50
+ }
51
+
28
52
  // Whether +text+ (a title or a first message) is the opener and nothing
29
53
  // else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
30
54
  export function isOpener(text) {
@@ -345,7 +345,13 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
345
345
  --md-strong-color:var(--text-bright);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
346
346
  --md-pre-size:12.5px;--md-cell-size:13px
347
347
  }
348
- .md-compact:is(.ts-asking,.question-said-full){white-space:normal}
348
+ .gen-text.md-compact{
349
+ --md-heading-size:14.5px;--md-heading-color:var(--text);
350
+ --md-subheading-size:14px;--md-subheading-color:var(--text-2);
351
+ --md-strong-color:var(--text);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
352
+ --md-pre-size:12.5px;--md-cell-size:13px
353
+ }
354
+ .md-compact:is(.ts-asking,.question-said-full,.gen-text){white-space:normal}
349
355
  .md-compact :is(h1,h2,h3,h4,h5,h6){font-size:var(--md-heading-size);font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--md-heading-color)}
350
356
  .md-compact :is(h4,h5,h6){font-size:var(--md-subheading-size);color:var(--md-subheading-color)}
351
357
  .md-compact :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
@@ -7,11 +7,17 @@
7
7
  // The texts whose refs linkTextRefs links: the answers and the closed
8
8
  // steps' texts.
9
9
  const TEXTS = ".bubble.output, .gen:not(.live) > .gen-text";
10
+ // The closed steps' texts still shown raw: a live turn's (a reload's come
11
+ // rendered, turn_view.js textTemplate).
12
+ const RAW_STEPS = ".gen:not(.live) > .gen-text:not(.markdown)";
10
13
 
11
14
  export function createMarkdownPlace({
12
15
  capabilities, renderText, roots, touchedFiles, currentDir, linkRefs, applyFinalAnswer, findInTurnRoots, normalize,
13
- pairAnswerRefs, setAnswerRefs = () => {},
16
+ pairAnswerRefs, setAnswerRefs = () => {}, answerRefsOf = () => null,
14
17
  }) {
18
+ // The raw step texts already waiting for their step's open.
19
+ const waiting = new WeakSet();
20
+
15
21
  // A text the page already shows as its markdown HTML (POST /api/render,
16
22
  // sanitised there), or null: markdown off here (no request), or a failed
17
23
  // read (the raw text stays).
@@ -55,6 +61,7 @@ export function createMarkdownPlace({
55
61
  // turn's end and after a reload's render. A ref already made stays as it
56
62
  // is.
57
63
  function linkTextRefs() {
64
+ renderStepTexts();
58
65
  const pageRoots = roots();
59
66
  const touched = touchedFiles(pageRoots);
60
67
  for (const root of pageRoots) {
@@ -66,5 +73,44 @@ export function createMarkdownPlace({
66
73
  }
67
74
  }
68
75
 
76
+ // The closed steps' raw texts as markdown, as the answers' (markdown on
77
+ // only): each from its step's first open (a turn's end would ask once per
78
+ // step, folded or not), as a resolved question card's kept reply. The
79
+ // text stays as its source (stepText); its refs, a question's, pair again
80
+ // on the swap, then the `path:line` ones (linkRefsIn). A null render
81
+ // keeps the text, and the next open asks again.
82
+ function renderStepTexts() {
83
+ if (capabilities().display !== true) return;
84
+ for (const root of roots()) {
85
+ for (const el of root.querySelectorAll(RAW_STEPS)) {
86
+ const step = el.parentElement;
87
+ if (waiting.has(el) || !step?.addEventListener) continue;
88
+ waiting.add(el);
89
+ renderOnOpen(step, el, el.textContent);
90
+ }
91
+ }
92
+ }
93
+
94
+ function renderOnOpen(step, el, text) {
95
+ let state = "raw";
96
+ const draw = () => {
97
+ if (!step.open || state !== "raw") return;
98
+ state = "asking";
99
+ renderMarkdown(text).then((html) => {
100
+ if (!html || el.textContent !== text) { state = html ? "done" : "raw"; return; }
101
+ state = "done";
102
+ step.removeEventListener("toggle", draw);
103
+ const refs = answerRefsOf(el);
104
+ el.setAttribute("data-copy-source", text);
105
+ // Sanitised on the server (POST /api/render).
106
+ el.innerHTML = html;
107
+ el.classList.add("markdown", "md-compact");
108
+ linkRefsIn(el, refs);
109
+ });
110
+ };
111
+ step.addEventListener("toggle", draw);
112
+ draw();
113
+ }
114
+
69
115
  return { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs };
70
116
  }
@@ -19,7 +19,8 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
19
19
  // (start_page.js: pendingPick(), chosenModel(), newChoices(),
20
20
  // clearChoices()); +takeDelivery()+ the delivery line's armed choice;
21
21
  // +composer+: {el (the text box), button (Send/Start), chips(),
22
- // setChips(list), fit(), clear(), openForVisit(), focus()}; +historyEl+,
22
+ // setChips(list), fit(), clear(), openForVisit(), focus(), noteDraft(id) (the
23
+ // text was written in session id)}; +historyEl+,
23
24
  // +turnDom+; +makeUserBubble(prompt, opts)+, +bubbleById(id)+,
24
25
  // +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
25
26
  // +showNotice(text)+, +updateInfoBar()+, +updateComposerMode()+,
@@ -128,7 +129,10 @@ export function createSendTurn({
128
129
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
129
130
  // A restored line must be seen: the composer opens for this visit.
130
131
  if (restored?.refill && sentTo === session()) composer.openForVisit();
131
- promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
132
+ const before = promptEl.value;
133
+ promptEl.value = composerAfterAck({ sent: line, current: before, refill: restored?.refill || "" });
134
+ // A line back from sentTo is a draft of that session, wherever the page is now.
135
+ if (promptEl.value.trim() && promptEl.value !== before) composer.noteDraft(sentTo);
132
136
  composer.fit();
133
137
  // Only the sent chips go: images added since stay (they go up at their own send).
134
138
  const kept = chipsAfterSend(composer.chips(), sending);
@@ -1,4 +1,4 @@
1
- import { continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
1
+ import { continueRefusal, continuedText, openChildrenTemplate, openerText } from "./chains.js";
2
2
  import { cantText, deleteConfirmText, goneSessionNotice } from "./format.js";
3
3
  import { sessionHash } from "./route.js";
4
4
  import { inTree } from "./sessions_list.js";
@@ -14,12 +14,14 @@ import { inTree } from "./sessions_list.js";
14
14
  // +refresh()+ fetches the list); +lists+ (lists_view.js: render);
15
15
  // +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
16
16
  // +toast(text, action, options)+, +addCreatedSession(created)+,
17
- // +fillOpener(created)+ (the new link's composer), +closeLiveStream()+ (the live
17
+ // +fillOpener(created, continuedId)+ (the new link's composer; returns a draft
18
+ // from another session it displaced, or null), +restoreDraft(draft, opener)+
19
+ // (that draft back, never over newer text), +closeLiveStream()+ (the live
18
20
  // stream's close, the stream left as it is) and +workerGone(id)+ the page's.
19
21
  // +alert+ / +confirm+: the browser's.
20
22
  export function createSessionActions({
21
23
  selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
22
- select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, closeLiveStream, workerGone,
24
+ select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
23
25
  alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
24
26
  }) {
25
27
  // The session this tab is stopping: its stream may close before the stop
@@ -105,8 +107,11 @@ export function createSessionActions({
105
107
  return;
106
108
  }
107
109
  await select(next);
108
- if (selected() === next) fillOpener(created);
109
- toast(continuedText(id, created), null, { sticky });
110
+ // A draft carried over from elsewhere gave way to the opener: the
111
+ // toast brings it back, and stays until closed so it can't be lost.
112
+ const displaced = selected() === next ? fillOpener(created, id) : null;
113
+ const restore = displaced ? { label: "Restore draft", run: () => restoreDraft(displaced, openerText(created)) } : null;
114
+ toast(continuedText(id, created), restore, { sticky: sticky || !!displaced });
110
115
  } catch (e) {
111
116
  const refusal = continueRefusal(e, sessions());
112
117
  if (refusal.kind === "open") {
@@ -71,13 +71,21 @@ function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking",
71
71
  // full text. None when there is nothing to say. A closed step's text is
72
72
  // set whole (.textContent): its refs (ref_scan.js) split it into spans
73
73
  // that a text part's update would leave behind. +at+: a reloaded step's
74
- // message index, for its refs (data-answer-at, as an answer's).
75
- function textTemplate(text, { live, sentences, placeholder, at = null }) {
74
+ // message index, for its refs (data-answer-at, as an answer's). A
75
+ // reloaded step's +rendered+ markdown (its message's html, sanitised on the
76
+ // server, as an answer's) shows in place of the text, which it keeps as
77
+ // its source (stepText); a live step's renders once it closed
78
+ // (markdown_place.js renderStepTexts).
79
+ function textTemplate(text, { live, sentences, placeholder, at = null, rendered = null }) {
76
80
  if (live && sentences?.length) {
77
81
  return html`<div class="gen-text streaming"><div class="narration">${sentences.map((s) => html`<div class="sentence">${s}</div>`)}</div></div>`;
78
82
  }
79
83
  if (live) return placeholder ? html`<div class="gen-text streaming">…</div>` : nothing;
80
- return text ? html`<div class="gen-text" data-answer-at=${at ?? nothing} .textContent=${text}></div>` : nothing;
84
+ if (!text) return nothing;
85
+ if (typeof rendered === "string" && rendered) {
86
+ return html`<div class="gen-text markdown md-compact" data-answer-at=${at ?? nothing} data-copy-source=${text} .innerHTML=${rendered}></div>`;
87
+ }
88
+ return html`<div class="gen-text" data-answer-at=${at ?? nothing} .textContent=${text}></div>`;
81
89
  }
82
90
 
83
91
  // One step, live or reloaded. +gen+: { text, thinking, tools, answer } (a
@@ -97,7 +105,7 @@ export function stepTemplate(gen, st = {}) {
97
105
  const failed = !live && model.failedCount(gen.tools) > 0;
98
106
  // A step left with its thinking only reads "thinking" once.
99
107
  const wrapped = live || !!text || gen.tools.length > 0;
100
- return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
108
+ return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at, rendered: gen.html })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
101
109
  }
102
110
 
103
111
  // A hook's line (hook_notice) as a step's row. data.line is a whole row of
@@ -807,10 +815,11 @@ function defaultPlaceAnswer(appendToHistory) {
807
815
 
808
816
  // The text of a reloaded block's step +iteration+ (1-based, as the snapshot's
809
817
  // cards count), "" when it has none: a reloaded question card's asking
810
- // reply.
818
+ // reply, as written (a rendered step keeps it as data-copy-source).
811
819
  export function stepText(block, iteration) {
812
820
  const step = block?.querySelectorAll(":scope > .gen")[Number(iteration) - 1];
813
- return step?.querySelector(":scope > .gen-text")?.textContent || "";
821
+ const text = step?.querySelector(":scope > .gen-text");
822
+ return text?.getAttribute("data-copy-source") ?? text?.textContent ?? "";
814
823
  }
815
824
 
816
825
  // A re-rendered failed turn's line (a .bubble.cancel, as live's
@@ -885,7 +894,12 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
885
894
  if (plainThinking) {
886
895
  parts.push(thinkingTemplate({ body: only.thinking, cls: "bubble thinking" }));
887
896
  } else if (group.steps.length) {
888
- const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
897
+ // A step's text with its message's rendered markdown (html: web.markdown on).
898
+ const gens = group.steps.map((step) => ({
899
+ text: step.i === null ? "" : String(items[step.i].content ?? ""),
900
+ html: step.i === null ? null : items[step.i].html ?? null,
901
+ thinking: step.thinking || "", tools: step.tools,
902
+ }));
889
903
  const steps = gens.map((gen, k) => stepTemplate(gen, {
890
904
  // The steers the step answered first, where the live rows went.
891
905
  steers: (group.steps[k].steers || []).map((i) => steerTemplate(i, false)),
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.100.0
4
+ version: 0.100.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov