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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -1
- data/README.md +7 -7
- data/bin/chi +6 -2
- data/docs/architecture.md +4 -5
- data/docs/cli.md +11 -3
- data/docs/configuration.md +49 -86
- data/docs/hooks.md +1 -1
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +7 -4
- data/docs/plugins.md +4 -3
- data/docs/sessions.md +8 -2
- data/docs/testing.md +6 -9
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -5
- data/lib/samagotchi/bundle_command/try/sandbox.rb +8 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/client.rb +59 -196
- data/lib/samagotchi/config.rb +74 -15
- data/lib/samagotchi/engine.rb +7 -7
- data/lib/samagotchi/host_config.rb +20 -27
- data/lib/samagotchi/host_registry.rb +8 -8
- data/lib/samagotchi/kernel_loop.rb +46 -8
- data/lib/samagotchi/model_profile.rb +2 -3
- data/lib/samagotchi/plugin/sessions.rb +4 -3
- data/lib/samagotchi/self_report.rb +0 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +13 -1
- data/lib/samagotchi/session_commands.rb +13 -0
- data/lib/samagotchi/session_continue.rb +8 -8
- data/lib/samagotchi/session_delete_command.rb +1 -1
- data/lib/samagotchi/session_manager.rb +13 -7
- data/lib/samagotchi/sessions_command.rb +8 -6
- data/lib/samagotchi/system_prompt.rb +7 -21
- data/lib/samagotchi/terminal_ui/thinking_line.rb +2 -4
- data/lib/samagotchi/thinking.rb +35 -2
- data/lib/samagotchi/thinking_notices.rb +11 -4
- data/lib/samagotchi/title_command.rb +61 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +0 -3
- data/lib/samagotchi/web/app.rb +4 -4
- data/lib/samagotchi/web/public/app.js +12 -0
- data/lib/samagotchi/web/public/chains.js +21 -8
- data/lib/samagotchi/web/public/format.js +4 -3
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/info_bar_model.js +2 -0
- data/lib/samagotchi/web/public/session_rename.js +86 -0
- data/lib/samagotchi/web/public/session_state.js +4 -2
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +5 -2
- 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
|
|
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,
|
|
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,
|
|
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:,
|
|
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,
|
|
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:,
|
|
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
|
-
|
|
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).
|
|
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
|
|
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(
|
|
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
|
|
468
|
-
# first message it was started with (a session
|
|
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
|
|
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,
|
|
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,
|
|
155
|
-
with_tail_declarations(assist_system_prompt(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
|
|
212
|
-
# syntax
|
|
213
|
-
|
|
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
|
-
|
|
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:
|
|
69
|
-
def sentence = strip_markdown(@ticker.line
|
|
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)
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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
|
|
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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[:
|
|
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,
|
|
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:,
|
|
746
|
-
[@manager.continue_session(given, prompt: prompt,
|
|
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.
|
|
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
|
|
217
|
-
// its
|
|
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">${
|
|
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:
|
|
62
|
-
|
|
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,
|