samagotchi 0.94.0 → 0.96.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/README.md +7 -7
  4. data/bin/chi +6 -2
  5. data/docs/architecture.md +4 -5
  6. data/docs/cli.md +11 -3
  7. data/docs/configuration.md +49 -86
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/context-telemetry.md +1 -1
  10. data/docs/internals/engine.md +7 -4
  11. data/docs/plugins.md +4 -3
  12. data/docs/sessions.md +8 -2
  13. data/docs/testing.md +6 -9
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -5
  15. data/lib/samagotchi/bundle_command/try/sandbox.rb +8 -2
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +6 -7
  17. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/children_status.rb +1 -1
  20. data/lib/samagotchi/client.rb +59 -196
  21. data/lib/samagotchi/config.rb +74 -15
  22. data/lib/samagotchi/engine.rb +7 -7
  23. data/lib/samagotchi/host_config.rb +20 -27
  24. data/lib/samagotchi/host_registry.rb +8 -8
  25. data/lib/samagotchi/kernel_loop.rb +46 -8
  26. data/lib/samagotchi/model_profile.rb +2 -3
  27. data/lib/samagotchi/plugin/sessions.rb +4 -3
  28. data/lib/samagotchi/self_report.rb +0 -2
  29. data/lib/samagotchi/send_command.rb +1 -1
  30. data/lib/samagotchi/session.rb +13 -1
  31. data/lib/samagotchi/session_commands.rb +13 -0
  32. data/lib/samagotchi/session_continue.rb +8 -8
  33. data/lib/samagotchi/session_delete_command.rb +1 -1
  34. data/lib/samagotchi/session_manager.rb +13 -7
  35. data/lib/samagotchi/sessions_command.rb +8 -6
  36. data/lib/samagotchi/system_prompt.rb +7 -21
  37. data/lib/samagotchi/terminal_ui/thinking_line.rb +2 -4
  38. data/lib/samagotchi/thinking.rb +35 -2
  39. data/lib/samagotchi/thinking_notices.rb +11 -4
  40. data/lib/samagotchi/title_command.rb +61 -0
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/vision_support.rb +0 -3
  43. data/lib/samagotchi/web/app.rb +4 -4
  44. data/lib/samagotchi/web/public/app.js +12 -0
  45. data/lib/samagotchi/web/public/chains.js +21 -8
  46. data/lib/samagotchi/web/public/format.js +4 -3
  47. data/lib/samagotchi/web/public/index.html +7 -0
  48. data/lib/samagotchi/web/public/info_bar_model.js +2 -0
  49. data/lib/samagotchi/web/public/session_rename.js +86 -0
  50. data/lib/samagotchi/web/public/session_state.js +4 -2
  51. data/lib/samagotchi/web/session_summary.rb +3 -0
  52. data/lib/samagotchi/worker.rb +5 -2
  53. metadata +3 -1
@@ -83,7 +83,7 @@ module Samagotchi
83
83
  # follows the chain to its new end instead.
84
84
  # @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
85
85
  # @param prompt [String, nil] its first message (nil: it starts idle)
86
- # @param title [String, nil] what the lists show before the first turn;
86
+ # @param preview [String, nil] what the lists show before the first turn;
87
87
  # by default the prompt, else the previous link's preview
88
88
  # @return [Session] the new link (#model_warning as spawn_session's,
89
89
  # #moved_children: the delegates it took over, #opener:
@@ -92,7 +92,7 @@ module Samagotchi
92
92
  # @raise [Refused] continued already, delegates open that can't
93
93
  # move, or its folder is gone
94
94
  # @raise [ArchiveRefused, OwnedByTUI] as archive_session
95
- def self.run(id_or_ref, prompt: nil, title: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
95
+ def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
96
96
  sd = state_dir || Session.default_state_dir
97
97
  TRIES.times do |try|
98
98
  id = SessionChain.resolve(id_or_ref, state_dir: sd)
@@ -100,7 +100,7 @@ module Samagotchi
100
100
  follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < TRIES - 1
101
101
  next :moved if follow && SessionChain.next_of(id, state_dir: sd)
102
102
 
103
- run_locked(id, prompt: prompt, title: title, model: model, state_dir: sd, recap_wait: recap_wait,
103
+ run_locked(id, prompt: prompt, preview: preview, model: model, state_dir: sd, recap_wait: recap_wait,
104
104
  archive_wait: archive_wait)
105
105
  end
106
106
  return result unless result == :moved
@@ -116,7 +116,7 @@ module Samagotchi
116
116
  end
117
117
  end
118
118
 
119
- private_class_method def self.run_locked(id, prompt:, title:, model:, state_dir:, recap_wait:, archive_wait:)
119
+ private_class_method def self.run_locked(id, prompt:, preview:, model:, state_dir:, recap_wait:, archive_wait:)
120
120
  previous = Session.load(id, state_dir: state_dir)
121
121
  checked = check!(previous, state_dir, model: model)
122
122
  plan = checked.plan
@@ -132,7 +132,7 @@ module Samagotchi
132
132
  archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
133
133
  archived = archive[:archived] - already
134
134
  begin
135
- spawn_continuation(previous, prompt: prompt, title: title, choice: checked.choice, state_dir: state_dir,
135
+ spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
136
136
  move: plan.move)
137
137
  rescue StandardError
138
138
  # Nothing started: the delegates go back, what this archived goes
@@ -194,16 +194,16 @@ module Samagotchi
194
194
 
195
195
  # The new link, holding off any worker but its own (ChildMove.starting?)
196
196
  # while +move+ goes to it.
197
- private_class_method def self.spawn_continuation(previous, prompt:, title:, choice:, state_dir:, move:)
197
+ private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:)
198
198
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
199
199
  start = prompt.to_s.strip.empty? ? nil : prompt
200
- title = previous.first_preview if title.to_s.strip.empty? && start.nil?
200
+ preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
201
201
  before_spawn = lambda do |link|
202
202
  ChildMove.mark_starting(link.id, state_dir: state_dir)
203
203
  ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
204
204
  end
205
205
  link = SessionManager.spawn_session(
206
- prompt: start, title: title, working_directory: previous.working_directory,
206
+ prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
207
207
  model_name: choice.model, setup: SessionSetup.of(previous),
208
208
  continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
209
209
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
@@ -80,7 +80,7 @@ module Samagotchi
80
80
  end
81
81
 
82
82
  def preview_of(id)
83
- text = Session.load(id, state_dir: @state_dir).first_preview.to_s.gsub(/\s+/, " ").strip
83
+ text = Session.load(id, state_dir: @state_dir).display_title.gsub(/\s+/, " ").strip
84
84
  return nil if text.empty?
85
85
 
86
86
  text.length > PREVIEW_LIMIT ? "#{text[0, PREVIEW_LIMIT - 1]}…" : text
@@ -138,8 +138,11 @@ module Samagotchi
138
138
  # @param messages [Array<Hash>] a conversation to start from (a plugin's
139
139
  # ctx.sessions.fork); its image refs are copied from +images_from+
140
140
  # @param images_from [String, nil] the session dir the seed's images are in
141
- # @param title [String, nil] what the lists show before the first turn
141
+ # @param preview [String, nil] what the lists show before the first turn
142
142
  # (the prompt's preview by default, else the seed's first user message)
143
+ # @param title [String, nil] the session's own name (Session#title; a
144
+ # continue's link inherits its previous link's), saved before the
145
+ # worker starts: the worker owns the file from then on
143
146
  # @param setup [SessionSetup] the session's own settings (chi
144
147
  # --llm-context; a fork and a continue copy their session's; a delegate
145
148
  # child the toolkit its delegate call named); without a toolkit the
@@ -156,7 +159,7 @@ module Samagotchi
156
159
  # and #model_warning: an id its host's saved list doesn't have
157
160
  def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
158
161
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
159
- title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
162
+ preview: nil, title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
160
163
  note_source: "chi", before_spawn: nil)
161
164
  sd = state_dir || Session.default_state_dir
162
165
  # The resolved ref is stored (a resumed session keeps its model when an
@@ -189,7 +192,8 @@ module Samagotchi
189
192
  session.last_prompt = prompt
190
193
  # The worker takes last_prompt and clears it, and messages are saved at
191
194
  # the turn's end: until then this is the only preview a list has.
192
- session.first_preview = Session.preview_of(title.to_s.strip.empty? ? prompt : title)
195
+ session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
196
+ session.title = title
193
197
  session_dir = Session.session_dir(session.id, state_dir: sd)
194
198
  unless session.messages.empty?
195
199
  session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
@@ -321,7 +325,7 @@ module Samagotchi
321
325
  owned = !!owner&.worker?
322
326
  next if live && !owned
323
327
 
324
- { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
328
+ { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), title: s.title, cwd: s.working_directory,
325
329
  project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
326
330
  owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
327
331
  ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
@@ -464,10 +468,12 @@ module Samagotchi
464
468
  cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
465
469
  end
466
470
 
467
- # A session as one line of text for the lists: its last prompt, else the
468
- # first message it was started with (a session command like `/model x`,
469
- # or a first turn not saved yet).
471
+ # A session as one line of text for the lists: its title (/title), else
472
+ # its last prompt, else the first message it was started with (a session
473
+ # command like `/model x`, or a first turn not saved yet).
470
474
  def self.summary_text(session)
475
+ return one_line(session.title) unless session.title.to_s.strip.empty?
476
+
471
477
  one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
472
478
  end
473
479
 
@@ -198,11 +198,12 @@ module Samagotchi
198
198
  # scratch: a `chi scratch` session (deleted when its REPL ends, a
199
199
  # leftover one at the next sweep)
200
200
  # ctx_pct: how full the context was after the last turn, or nil
201
+ # title: the session's own name (/title; desc shows it too), or nil
201
202
  # waiting: the kind of question it waits on (question, approval,
202
203
  # hook: chi answer or the web answers it), or nil; waiting_id: its
203
204
  # id, for chi answer --question; relayed_to: the parent session (short
204
205
  # id) whose card it also waits in (the approval relay), or nil
205
- keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
206
+ keys = %i[id short_id desc title cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
206
207
  waiting waiting_id relayed_to stopped_by]
207
208
  @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
208
209
  when "tsv"
@@ -215,9 +216,10 @@ module Samagotchi
215
216
  else summary[:live] ? "live" : summary[:status].to_s
216
217
  end
217
218
  # The saved recap's first sentence says more than the last prompt
218
- # (text only: tsv and json keep desc for pickers).
219
+ # (text only: tsv and json keep desc for pickers); a title (in
220
+ # desc) says what the user named it.
219
221
  desc = summary[:desc]
220
- if summary[:recap]
222
+ if summary[:recap] && !summary[:title]
221
223
  desc = [File.basename(summary[:cwd].to_s), summary[:recap]].reject(&:empty?).join(" · ")
222
224
  desc = "#{desc[0, 59]}…" if desc.length > 60
223
225
  end
@@ -245,11 +247,11 @@ module Samagotchi
245
247
  order ||= "desc"
246
248
  sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
247
249
  include_archived: include_archived)
248
- # The saved recap's first sentence, else the last prompt (or the first
249
- # message, before any turn), cut as before.
250
+ # The session's title, else the saved recap's first sentence, else the
251
+ # last prompt (or the first message, before any turn), cut as before.
250
252
  state_dir = Samagotchi::Session.default_state_dir
251
253
  list_text = lambda do |s|
252
- recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
254
+ recap = s.title ? nil : Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
253
255
  (recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
254
256
  end
255
257
  # How full the context was after the last turn: "ctx 12%", counted
@@ -96,7 +96,7 @@ module Samagotchi
96
96
  @section_notes = nil
97
97
  @section_files = []
98
98
  @built[key] = system_prompt_with_index(
99
- assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
99
+ assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
100
100
  )
101
101
  @index_sizes[key] = @measured_index
102
102
  @notes_loaded[key] = @section_notes || []
@@ -151,9 +151,8 @@ module Samagotchi
151
151
 
152
152
  # The base prompt (specs, plugins' declarations), with Gemma's tool
153
153
  # declarations at its end as #build has them.
154
- def base(chat: false, thinking: nil, layers: [])
155
- with_tail_declarations(assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat,
156
- layers: layers)
154
+ def base(chat: false, layers: [])
155
+ with_tail_declarations(assist_system_prompt(chat: chat, layers: layers), chat: chat, layers: layers)
157
156
  end
158
157
 
159
158
  # Names activated via preloaded --memory entries during prompt
@@ -195,27 +194,14 @@ module Samagotchi
195
194
  end
196
195
  end
197
196
 
198
- # Only Qwen has an explicit thinking-close marker, so only Qwen can
199
- # reliably have this preamble parsed back out of its thinking block.
200
- # With thinking off there is no thinking to begin with it.
201
- def turn_preamble_instruction(thinking = nil)
202
- return "" unless profile.name == "qwen36"
203
- return "" if Samagotchi::Config.get("thinking.turn_preamble") == false
204
- return "" if (thinking || turn_thinking) == :off
205
-
206
- "\nTurn preamble: as the very first line of your thinking, write \"TURN: \" followed by a short present-tense action phrase (max 8 words) describing what you are about to do, e.g. \"TURN: reading project config\". Then continue reasoning normally.\n"
207
- end
208
-
209
197
  # ── System prompts ─────────────────────────────────────────────────────────
210
198
 
211
- # @param chat [Boolean] for the chat loop: no tool declarations, call
212
- # syntax or turn preamble (its tools go as schemas with each request)
213
- # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
214
- def assist_system_prompt(chat: false, thinking: nil, layers: [])
199
+ # @param chat [Boolean] for the chat loop: no tool declarations or call
200
+ # syntax (its tools go as schemas with each request)
201
+ def assist_system_prompt(chat: false, layers: [])
215
202
  return chat_system_prompt if chat
216
203
 
217
204
  hint = tool_call_hint
218
- turn_preamble = turn_preamble_instruction(thinking)
219
205
  # Gemma 4's template declares the tools at the end of the system turn
220
206
  # (#system_prompt_with_index puts them there).
221
207
  tools = if gemma_tail_declarations?
@@ -228,7 +214,7 @@ module Samagotchi
228
214
  You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. #{tools}
229
215
  #{hint}
230
216
  You may make multiple tool calls. After seeing tool results, continue reasoning or answer the user.
231
- #{turn_preamble}
217
+
232
218
  #{ToolDeclarations::SMALL_CONTEXT_PROTOCOL}
233
219
 
234
220
  #{assist_guidance}
@@ -20,8 +20,6 @@ module Samagotchi
20
20
 
21
21
  # Enough text to find the newest sentence in; scanning grows with it.
22
22
  BUFFER_LIMIT = 2000
23
- # The Qwen turn preamble's first line ("TURN: reading the config").
24
- TURN_PREFIX = /\ATURN:\s*/i
25
23
  # Where a raw stream's thinking ends: a boundary, so its last words
26
24
  # don't run into the answer's first.
27
25
  THOUGHT_CLOSE = %r{<channel\|>|</think>}
@@ -65,8 +63,8 @@ module Samagotchi
65
63
  # @return [Symbol, nil] :thinking or :writing
66
64
  attr_reader :label
67
65
 
68
- # The sentence as a row shows it: no TURN: prefix, markdown plain.
69
- def sentence = strip_markdown(@ticker.line.sub(TURN_PREFIX, ""))
66
+ # The sentence as a row shows it: markdown plain.
67
+ def sentence = strip_markdown(@ticker.line)
70
68
 
71
69
  # #sentence cut to +room+ columns, with an ellipsis when cut.
72
70
  def fit(room)
@@ -27,6 +27,14 @@ module Samagotchi
27
27
  # An empty thought after the Qwen assistant cue: the model answers at once
28
28
  # (what the server's own template does with enable_thinking false).
29
29
  QWEN_EMPTY_THOUGHT = "<think>\n\n</think>\n\n"
30
+ # An open thought after the Qwen assistant cue, as a Qwen3.5+ template
31
+ # writes it with thinking on: without it Qwen3.6 writes an empty thought
32
+ # at once and never thinks (#opens_thought?).
33
+ QWEN_OPEN_THOUGHT = "<think>\n"
34
+ # A Jinja string literal that ends with the open thought: '<think>\n'
35
+ # (a Qwen3.5+ generation prompt). Qwen3's writes only the empty one,
36
+ # '<think>\n\n</think>\n\n', with thinking off.
37
+ OPEN_THOUGHT_LITERAL = /<think>(?:\\n|\n)['"]/
30
38
  # Gemma 4's empty thought channel after the model cue, as its chat
31
39
  # template writes it with enable_thinking false (only where a model turn
32
40
  # starts: Prompt.prefill_for).
@@ -168,15 +176,40 @@ module Samagotchi
168
176
  caps.is_a?(Hash) && caps["supports_reasoning_effort"] == false
169
177
  end
170
178
 
179
+ # Whether a llama.cpp /props answer (+props+, Client::ServerProps)
180
+ # carries a chat template whose generation prompt opens a thought
181
+ # (Qwen3.5 and later: `assistant\n` then '<think>\n' unless
182
+ # enable_thinking is false). Read from the template's last
183
+ # add_generation_prompt branch on: the message loop before it writes
184
+ # earlier turns' thoughts. false when there is no template (a router's
185
+ # own /props, an unloaded model, a failed probe).
186
+ def opens_thought?(props)
187
+ return false unless props.respond_to?(:answered?) && props.answered? && props.body.is_a?(Hash)
188
+
189
+ template = props.body["chat_template"]
190
+ return false unless template.is_a?(String)
191
+
192
+ cue = template.rindex("add_generation_prompt")
193
+ !cue.nil? && OPEN_THOUGHT_LITERAL.match?(template[cue..])
194
+ end
195
+
171
196
  # The native prompt's switch for +level+ under +profile+.
172
197
  # @param profile [ModelProfile]
198
+ # @param opens_thought [Boolean] the server's template opens a thought
199
+ # after the cue (#opens_thought?): qwen36 then gets the open thought
200
+ # at every level but off, as the template has it
173
201
  # @return [Native]
174
- def native(level, profile)
202
+ def native(level, profile, opens_thought: false)
175
203
  off = level == :off
176
204
  honoured = !EFFORTS.include?(level)
177
205
  case profile&.name
178
206
  when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: off ? GEMMA_EMPTY_THOUGHT : "", honoured: honoured)
179
- when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
207
+ when "qwen36"
208
+ prefill = if off then QWEN_EMPTY_THOUGHT
209
+ elsif opens_thought then QWEN_OPEN_THOUGHT
210
+ else ""
211
+ end
212
+ Native.new(system_token: "", prefill: prefill, honoured: honoured)
180
213
  else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
181
214
  end
182
215
  end
@@ -102,16 +102,23 @@ module Samagotchi
102
102
  private
103
103
 
104
104
  # An effort on a native host, which has no knob for it: said once per
105
- # session and host.
105
+ # session and host. A qwen36 whose server template opens a thought
106
+ # (Thinking.opens_thought?, from the /props answer the profile probe
107
+ # cached) thinks at every effort, the same.
106
108
  def announce_level(level, target)
107
109
  return if target.entry.chat?
108
110
 
109
111
  profile = @profile.call
110
112
  return if Thinking.native(level, profile).honoured
111
113
 
112
- notice_once(:unsupported, target, :info,
113
- "#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
114
- "thinking stays as the model has it")
114
+ text = if profile&.name == "qwen36" && Thinking.opens_thought?(target.client&.cached_server_props(model: target.bare_model))
115
+ "#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
116
+ "it thinks, with no effort control (low, medium and high think the same)"
117
+ else
118
+ "#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
119
+ "thinking stays as the model has it"
120
+ end
121
+ notice_once(:unsupported, target, :info, text)
115
122
  rescue StandardError => e
116
123
  failed(:announce_level, e)
117
124
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # /title, the session's own name (Session#title): with no argument, the
5
+ # title and what shows without one; `/title TEXT` names the session
6
+ # (whitespace collapsed, cut at 80 characters as a preview is), saved at
7
+ # once; `/title --clear` drops it, and the lists show the first message
8
+ # again. Anyone who may send the session a command may name it (a parent
9
+ # agent its delegates too). It runs mid-turn as well (anytime): the save
10
+ # takes the session's lock and writes a running turn's checkpoint, as a
11
+ # turn's own saves do.
12
+ class TitleCommand
13
+ NAME = "/title"
14
+ CLEAR = "--clear"
15
+ USAGE = "usage: #{NAME} [TEXT|#{CLEAR}]".freeze
16
+ # As long as a preview (Session.preview_of) may be.
17
+ LIMIT = 80
18
+
19
+ # @param engine [Engine] #session
20
+ # @param save [#call] saves the session after a change
21
+ def initialize(engine:, save:)
22
+ @engine = engine
23
+ @save = save
24
+ end
25
+
26
+ # @param args [String] what follows /title
27
+ # @return [Array(String, Boolean, Symbol)] the reply, whether the
28
+ # title changed, and :ok or :error
29
+ def run(args)
30
+ session = @engine.session or return ["no session yet", false, :error]
31
+ text = args.to_s.gsub(/\s+/, " ").strip
32
+ return [listing(session), false, :ok] if text.empty?
33
+ # "--clear and more" is a slip, not a title.
34
+ return [USAGE, false, :error] if text.start_with?("#{CLEAR} ")
35
+
36
+ title = if text == CLEAR then nil
37
+ elsif text.length > LIMIT then "#{text[0, LIMIT]}…"
38
+ else text
39
+ end
40
+ return ["#{listing(session)}\n(no change)", false, :ok] if title == session.title
41
+
42
+ session.title = title
43
+ # The command itself as the session's first line (chi send --new
44
+ # "/title X", a web first message) became its preview: it goes, and
45
+ # the save derives the preview from the first user message again.
46
+ session.first_preview = "" if session.first_preview.to_s.start_with?(NAME)
47
+ @save.call(session)
48
+ [listing(session), true, :ok]
49
+ end
50
+
51
+ private
52
+
53
+ # "title: Coordinator", or "title: none (shows the first message: hey chi …)"
54
+ def listing(session)
55
+ return "title: #{session.title}" if session.title
56
+
57
+ preview = session.first_preview.to_s
58
+ "title: none (shows #{preview.empty? ? "the first message" : "the first message: #{preview}"})"
59
+ end
60
+ end
61
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.94.0"
4
+ VERSION = "0.96.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -61,9 +61,6 @@ module Samagotchi
61
61
 
62
62
  def self.native(target, _entry, profile, trust_config:)
63
63
  client = target.client
64
- transport = client.transport.name
65
- return no("#{transport} hosts take no images (only llama.cpp does)") unless transport == :llama_cpp
66
-
67
64
  props = client.server_props(model: target.bare_model)
68
65
  return no("can't reach /props for the media marker") unless props&.answered?
69
66
 
@@ -681,7 +681,7 @@ module Samagotchi
681
681
  # next; the list and the notifications name the session by it
682
682
  # until that turn is saved.
683
683
  preview = idle ? body["preview"].to_s.strip : ""
684
- folder[:title] = preview unless preview.empty?
684
+ folder[:preview] = preview unless preview.empty?
685
685
  setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, bundle_settings: bundle_settings)
686
686
  folder[:setup] = setup unless setup.empty?
687
687
  begin
@@ -725,7 +725,7 @@ module Samagotchi
725
725
 
726
726
  preview = idle ? body["preview"].to_s.strip : ""
727
727
  model = (body["model"] || body["model_name"]).to_s.strip
728
- session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, title: preview.empty? ? nil : preview,
728
+ session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, preview: preview.empty? ? nil : preview,
729
729
  model: model.empty? ? nil : model)
730
730
  return error if error
731
731
 
@@ -742,8 +742,8 @@ module Samagotchi
742
742
  # SessionManager.continue_session, its refusals as responses: [session,
743
743
  # nil] or [nil, the response]. Only its own errors: what fails after
744
744
  # the new link started is not a refusal of it.
745
- def continue_from(given, prompt:, title:, model:)
746
- [@manager.continue_session(given, prompt: prompt, title: title, model: model, state_dir: @state_dir), nil]
745
+ def continue_from(given, prompt:, preview:, model:)
746
+ [@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
747
747
  rescue SessionContinue::Refused => e
748
748
  extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
749
749
  [nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
@@ -85,6 +85,7 @@ import { createToasts } from "./toasts.js";
85
85
  import { createUnqueue } from "./unqueue.js";
86
86
  import { createStartPage } from "./start_page.js";
87
87
  import { createSessionActions } from "./session_actions.js";
88
+ import { createRename } from "./session_rename.js";
88
89
  import { createSendTurn } from "./send_turn.js";
89
90
 
90
91
  const $ = (s) => document.querySelector(s);
@@ -495,6 +496,14 @@ const sessionActions = createSessionActions({
495
496
  closeLiveStream: () => streamControl?.close(),
496
497
  workerGone,
497
498
  });
499
+ // ✎: /title through the chips' path (session_rename.js).
500
+ const rename = createRename({
501
+ button: $("#infoRenameBtn"),
502
+ session: () => selected,
503
+ titleOf: (id) => lists.sessionsById().get(id)?.title || null,
504
+ run: (line) => runSessionCommand(line),
505
+ });
506
+
498
507
  const { confirmDelete, leaveGoneSession, continueSelected, archiveOne, setArchived } = sessionActions;
499
508
 
500
509
  // All sessions: its own view at #/sessions, so the browser's back button
@@ -1046,6 +1055,9 @@ function updateInfoBar() {
1046
1055
  const deleteBtn = document.getElementById("infoDeleteBtn");
1047
1056
  const archiveBtn = document.getElementById("infoArchiveBtn");
1048
1057
  const continueBtn = document.getElementById("infoContinueBtn");
1058
+ const renameBtn = document.getElementById("infoRenameBtn");
1059
+ if (renameBtn) renameBtn.style.display = buttons.rename.shown ? "" : "none";
1060
+ rename.follow(buttons.rename.shown ? selected : null);
1049
1061
  if (continueBtn) {
1050
1062
  continueBtn.style.display = buttons.continue.shown ? "" : "none";
1051
1063
  continueBtn.disabled = buttons.continue.disabled;
@@ -36,9 +36,11 @@ export function isOpener(text) {
36
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
+ // A link with a title of its own (/title; a new link inherits it) reads it,
40
+ // and a titled first link names its chain.
39
41
  export function chainTitle(s, byId) {
40
42
  const own = previewForCard(s);
41
- if (!s?.continues || !isOpener(s.first_preview || s.last_prompt)) return own;
43
+ if (s?.title || !s?.continues || !isOpener(s.first_preview || s.last_prompt)) return own;
42
44
  const seen = new Set([s.id]);
43
45
  let at = s;
44
46
  while (at.continues) {
@@ -47,7 +49,7 @@ export function chainTitle(s, byId) {
47
49
  seen.add(prev.id);
48
50
  at = prev;
49
51
  }
50
- return isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
52
+ return !at.title && isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
51
53
  }
52
54
 
53
55
  // The open session's title (app.js updateSessionTitle: the top bar and the
@@ -55,9 +57,10 @@ export function chainTitle(s, byId) {
55
57
  // (+shown+, its summary, has continues) reads +cardTitle+(shown) whatever
56
58
  // its first message: a web link inherits its chain's title, and its first
57
59
  // message is an opener (the constant or the user's own). Else +first+,
58
- // unless it is the opener. +shown+ null: not listed yet, +first+.
60
+ // unless it is the opener. A titled session (/title) reads its card's too.
61
+ // +shown+ null: not listed yet, +first+.
59
62
  export function openTitle({ shown, first, cardTitle }) {
60
- if (shown && (shown.continues || isOpener(first))) return cardTitle(shown);
63
+ if (shown && (shown.title || shown.continues || isOpener(first))) return cardTitle(shown);
61
64
  return first;
62
65
  }
63
66
 
@@ -213,13 +216,23 @@ export function chainChipTemplate(f, { open = false } = {}) {
213
216
 
214
217
  // The chain's list (the chip's popover): one row per other link, newest
215
218
  // 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
217
- // its preview), the waiting / stopped badge, archived. A row's click opens
219
+ // the chain, the head too, started the same day: chainStamp), its own title (/title) when it
220
+ // differs from the head's (a link inherits its chain's: the card says it), else its recap's
221
+ // first sentence, else its preview, the waiting / stopped badge, archived. A row's click opens
218
222
  // that link (families_view.js's popover binds .family-row[data-id]).
219
223
  // @param activeId the open session's id (its row is .active)
224
+ // A chain row's words: a name of its own (not the one it shares with the
225
+ // head), else what tells the link apart: its recap, else its preview (an
226
+ // opener's read as the chain's, chainTitle, with its own title left out).
227
+ function rowTitle(s, headTitle, byId) {
228
+ if (s.title && s.title !== headTitle) return s.title;
229
+ return s.recap || chainTitle({ ...s, title: null }, byId);
230
+ }
231
+
220
232
  export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {}) {
221
- const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
222
233
  const links = f?.chain?.links || [];
234
+ const byId = new Map(links.map((s) => [s.id, s]));
235
+ const headTitle = (byId.get(f?.head?.id) || f?.head)?.title || null;
223
236
  const rows = chainOthers(f).map(({ session: s, n }) => {
224
237
  const cls = ["family-row", "chain-row"];
225
238
  if (activeId && s.id === activeId) cls.push("active");
@@ -231,7 +244,7 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
231
244
  const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
232
245
  return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
233
246
  title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-n">#${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>${
247
+ date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
235
248
  rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
236
249
  });
237
250
  return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
@@ -58,8 +58,9 @@ export function firstPreview(text) {
58
58
  }
59
59
 
60
60
  export function previewForCard(session) {
61
- // Unified card preview: first message (80) else previewOf(last_prompt)
62
- const raw = session.first_preview || session.last_prompt || "";
61
+ // Unified card preview: its title (/title), else its first message (80),
62
+ // else its last prompt.
63
+ const raw = session.title || session.first_preview || session.last_prompt || "";
63
64
  return firstPreview(raw) || "\u2014";
64
65
  }
65
66
 
@@ -69,7 +70,7 @@ const DELETE_PREVIEW_CHARS = 40;
69
70
  // list's owner can be stale (a worker idle-exits), so a worker is only
70
71
  // "stopped if it's still running", never promised.
71
72
  export function deleteConfirmText(session) {
72
- const n = normalize(session.first_preview || session.last_prompt || "");
73
+ const n = normalize(session.title || session.first_preview || session.last_prompt || "");
73
74
  const preview = clip(n, DELETE_PREVIEW_CHARS + 1);
74
75
  const shortId = session.short_id || String(session.id).slice(0, 8);
75
76
  const lines = [
@@ -887,6 +887,10 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
887
887
  #infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
888
888
  #infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}
889
889
  #infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
890
+ #infoBar #infoRenameBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
891
+ #infoBar #infoRenameBtn:hover{color:var(--text);background:var(--bg-hover)}
892
+ /* ✎'s input (session_rename.js), in the button's place. */
893
+ #infoBar .rename-input{flex:0 1 18em;min-width:6em;font:inherit;font-size:12px;padding:3px 8px;border-radius:8px;border:1px solid var(--accent-border);background:var(--bg-raised);color:var(--text);outline:none}
890
894
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
891
895
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
892
896
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
@@ -976,6 +980,8 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
976
980
  #infoBar>button{font-size:0;line-height:0;padding:5px 7px}
977
981
  #infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
978
982
  #infoBar #infoArchiveBtn{--icon:var(--icon-archive)}
983
+ #infoBar #infoRenameBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.5 2.5l3 3L5.5 13.5H2.5v-3z'/%3E%3C/svg%3E")}
984
+ #infoBar .rename-input{flex:1 1 8em;font-size:16px}
979
985
  #infoBar #infoArchiveBtn.archived{--icon:var(--icon-unarchive)}
980
986
  #infoBar #infoContinueBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8h9M8.5 4.5 12 8l-3.5 3.5'/%3E%3C/svg%3E")}
981
987
  #infoBar #infoCancelBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='4' width='8' height='8' rx='1.5'/%3E%3C/svg%3E")}
@@ -1300,6 +1306,7 @@ a{color:var(--accent)}
1300
1306
  <div class="info" id="infoText"></div>
1301
1307
  <div id="contextChips" aria-label="Attached context" hidden></div>
1302
1308
  <button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1309
+ <button class="ghost" id="infoRenameBtn" title="Rename this session" aria-label="Rename this session" style="display:none">✎</button>
1303
1310
  <button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
1304
1311
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1305
1312
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
@@ -42,6 +42,8 @@ export function infoButtons({ id, state, chain, archived, continuing, streaming
42
42
  const busy = !!id && continuing;
43
43
  return {
44
44
  continue: { shown: !!id && !chain.next && state.owner !== "tui", disabled: busy, text: busy ? "continuing…" : "continue →" },
45
+ // ✎: /title from the web (a plain terminal's session takes no commands).
46
+ rename: { shown: !!id && state.owner !== "tui" },
45
47
  delete: { shown: !!id },
46
48
  archive: {
47
49
  shown: !!id,