samagotchi 0.112.0 → 0.114.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 +48 -1
- data/bin/chi +8 -0
- data/docs/cli.md +24 -6
- data/docs/configuration.md +22 -10
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/internals/web-push.md +28 -4
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap_command.rb +70 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +24 -0
- data/lib/samagotchi/config_text_edit.rb +10 -3
- data/lib/samagotchi/gem_update.rb +5 -1
- data/lib/samagotchi/idle_recap.rb +38 -10
- data/lib/samagotchi/push_command.rb +188 -0
- data/lib/samagotchi/recap_store.rb +20 -3
- data/lib/samagotchi/recap_title.rb +78 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session_manager.rb +10 -3
- data/lib/samagotchi/sessions_command.rb +14 -11
- data/lib/samagotchi/title_command.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -3
- data/lib/samagotchi/web/markdown_renderer.rb +20 -0
- data/lib/samagotchi/web/public/app.js +6 -0
- data/lib/samagotchi/web/public/index.html +43 -4
- data/lib/samagotchi/web/public/markdown_tip.js +33 -0
- data/lib/samagotchi/web/public/notifications.js +22 -8
- data/lib/samagotchi/web/public/push_devices.js +187 -0
- data/lib/samagotchi/web/push/attention.rb +2 -1
- data/lib/samagotchi/web/push/store.rb +14 -0
- data/lib/samagotchi/web/push.rb +5 -0
- data/lib/samagotchi/web/push_routes.rb +17 -3
- data/lib/samagotchi/web/server.rb +46 -14
- data/lib/samagotchi/web/session_show.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +7 -2
- metadata +5 -1
|
@@ -11,6 +11,7 @@ require_relative "image_store"
|
|
|
11
11
|
require_relative "output_formatter"
|
|
12
12
|
require_relative "steer"
|
|
13
13
|
require_relative "recap_store"
|
|
14
|
+
require_relative "recap_title"
|
|
14
15
|
require_relative "log"
|
|
15
16
|
|
|
16
17
|
module Samagotchi
|
|
@@ -143,8 +144,17 @@ module Samagotchi
|
|
|
143
144
|
# user decisions) — tolerated. The "we" variant was 4/4 consistent.
|
|
144
145
|
# %<plain>s is the range, e.g. "2-4 plain sentences"; %<shape>s is SHAPE
|
|
145
146
|
# or ONE_SHAPE.
|
|
147
|
+
# Auto-title spike (eval 0004, 2026-10-25; local Qwen3.8-Flash-Next,
|
|
148
|
+
# the 4 recap-spike sessions, first recap + update, N=5): a first-line
|
|
149
|
+
# title parsed in 50/50, 2-6 words and no actor in all, no recap
|
|
150
|
+
# regression against "Reply with the recap only" (one reply echoed
|
|
151
|
+
# "Blank line follows title." before the recap: RecapTitle drops it).
|
|
152
|
+
# Asked last, it was there in 15/50 and 4 replies were the title alone.
|
|
153
|
+
# A quoted example title (probe p0) was copied as the title: none here.
|
|
154
|
+
TITLE = "Reply with a title line, a blank line, then the recap. The title line is \"#{RecapTitle::LABEL} \" " \
|
|
155
|
+
"followed by 2-6 words naming the task, no actors. The recap: "
|
|
146
156
|
SYSTEM = "You write short recaps of a chat between a user and an assistant, for the user " \
|
|
147
|
-
"coming back to it later.
|
|
157
|
+
"coming back to it later. #{TITLE}%<plain>s, no heading, " \
|
|
148
158
|
"no preamble, no notes about the task or the transcript. Do not mention what the " \
|
|
149
159
|
"transcript lacks or does not say; leave it out. Centre it on outcomes, not on " \
|
|
150
160
|
"the order of events. %<shape>s Keep project names " \
|
|
@@ -169,6 +179,10 @@ module Samagotchi
|
|
|
169
179
|
UPDATE = " You are given the earlier recap and the conversation since the earlier recap: " \
|
|
170
180
|
"write an updated recap of the whole session that also covers what happened since " \
|
|
171
181
|
"(do not just repeat the earlier recap)."
|
|
182
|
+
# With an earlier title (the user message's "Earlier title:" line): the
|
|
183
|
+
# spike kept it in 14/15 updates on the same task and changed it on 4/5
|
|
184
|
+
# where the focus moved, so a card doesn't rename at every idle window.
|
|
185
|
+
UPDATE_TITLE = " Keep the earlier title unless the focus moved."
|
|
172
186
|
# Holds the length once the recap covers more (S0: an updated recap ran
|
|
173
187
|
# to 6 sentences for 2-4 without it, within range+1 in 95%+ with it).
|
|
174
188
|
# The first-sentence line keeps the list preview on the task.
|
|
@@ -210,7 +224,9 @@ module Samagotchi
|
|
|
210
224
|
# transcript to summarize
|
|
211
225
|
# @param sentences [Array(Integer, Integer)] the range, from #sentences_range
|
|
212
226
|
# @param offer [Boolean] a continue offer is open: add OFFER_LINE
|
|
213
|
-
|
|
227
|
+
# @param previous_title [String, nil] the earlier recap's title (with +previous+)
|
|
228
|
+
def build(transcript, tool_count: nil, tool_names: [], previous: nil, previous_title: nil, sentences: DEFAULT_SENTENCES,
|
|
229
|
+
offer: false)
|
|
214
230
|
body = cap(transcript.to_s.strip)
|
|
215
231
|
return nil if body.empty?
|
|
216
232
|
|
|
@@ -227,8 +243,11 @@ module Samagotchi
|
|
|
227
243
|
one = sentences.last == 1
|
|
228
244
|
range = { plain: sentences_text(sentences, "plain "), sentences: sentences_text(sentences),
|
|
229
245
|
shape: one ? ONE_SHAPE : SHAPE, focus: one ? "it" : "the first sentence" }
|
|
230
|
-
|
|
246
|
+
earlier_title = previous && !previous_title.to_s.strip.empty? ? previous_title.to_s.strip : nil
|
|
247
|
+
update = "#{UPDATE}#{UPDATE_TITLE if earlier_title}#{format(UPDATE_LENGTH, range)}"
|
|
248
|
+
system = format(SYSTEM, range) + (previous ? update : "") + tools
|
|
231
249
|
user = +""
|
|
250
|
+
user << "Earlier title: #{earlier_title}\n\n" if earlier_title
|
|
232
251
|
user << "Earlier recap:\n#{previous}\n\n" if previous
|
|
233
252
|
user << "Transcript#{" since the earlier recap" if previous} (the system prompt and tool " \
|
|
234
253
|
"internals were removed; only user turns and assistant prose remain):\n---\n#{body}\n---\n"
|
|
@@ -269,8 +288,9 @@ module Samagotchi
|
|
|
269
288
|
# answer (the Worker's or the REPL's TurnFlow); read at each attempt
|
|
270
289
|
attr_writer :awaiting_continue
|
|
271
290
|
|
|
272
|
-
# @return [Hash, nil] the last recap written: {text:, covered:,
|
|
273
|
-
# covered_digest:, model:, created_at:}
|
|
291
|
+
# @return [Hash, nil] the last recap written: {text:, title:, covered:,
|
|
292
|
+
# covered_digest:, model:, created_at:} (title: the auto title, absent
|
|
293
|
+
# when no reply gave one), where covered counts the
|
|
274
294
|
# session messages it summarizes and covered_digest fingerprints the
|
|
275
295
|
# last of them. With a store, loaded from it for each new session.
|
|
276
296
|
def state
|
|
@@ -467,12 +487,13 @@ module Samagotchi
|
|
|
467
487
|
return :nothing_new if transcript.strip.empty?
|
|
468
488
|
|
|
469
489
|
prompt = RecapPrompt.build(transcript, tool_names: TranscriptFilter.tool_names(fresh), previous: previous&.dig(:text),
|
|
470
|
-
|
|
490
|
+
previous_title: previous&.dig(:title), sentences: @sentences, offer: offer_open?)
|
|
471
491
|
return :nothing_new if prompt.nil?
|
|
472
492
|
|
|
473
493
|
asked = target
|
|
474
494
|
@in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
|
|
475
|
-
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked.label
|
|
495
|
+
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked.label,
|
|
496
|
+
previous_title: previous&.dig(:title) }
|
|
476
497
|
:started
|
|
477
498
|
rescue StandardError
|
|
478
499
|
:failed
|
|
@@ -499,9 +520,16 @@ module Samagotchi
|
|
|
499
520
|
# An IdleClient::Summary names the model that answered; a plain String
|
|
500
521
|
# (a stubbed client) doesn't, and the asked label stands in.
|
|
501
522
|
served = recap.model if recap.respond_to?(:model)
|
|
502
|
-
text
|
|
503
|
-
|
|
504
|
-
|
|
523
|
+
# The title line off the recap (RecapTitle): the text is the recap
|
|
524
|
+
# alone. A reply without a title that passes keeps the earlier one, so
|
|
525
|
+
# a broken line doesn't blank the card; a title alone is no recap.
|
|
526
|
+
split = RecapTitle.split(recap.to_s)
|
|
527
|
+
text = split.body
|
|
528
|
+
return if text.empty?
|
|
529
|
+
|
|
530
|
+
title = split.title || job[:previous_title]
|
|
531
|
+
saved = { text: text, title: title, covered: job[:covered], covered_digest: job[:covered_digest],
|
|
532
|
+
model: served || job[:model], created_at: Time.now.utc.iso8601 }.compact
|
|
505
533
|
@mutex.synchronize { @state = saved }
|
|
506
534
|
save(saved)
|
|
507
535
|
@engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered], created_at: saved[:created_at])
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "cli/command"
|
|
6
|
+
require_relative "cli/exit"
|
|
7
|
+
require_relative "cli/flags"
|
|
8
|
+
require_relative "config"
|
|
9
|
+
require_relative "web/push"
|
|
10
|
+
require_relative "web/running"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# `chi web push`: this machine's Web Push devices (Web::Push::Store),
|
|
14
|
+
# without chi web running: list them, remove one, send a test push.
|
|
15
|
+
# Never prints an endpoint or keys: a device is its short id and chi's
|
|
16
|
+
# label. bin/chi branches here on `web push` before RunOptions (which
|
|
17
|
+
# would refuse the word); `chi --remote X web push` is refused before
|
|
18
|
+
# that (chi web runs on this machine only). See docs/cli.md.
|
|
19
|
+
class PushCommand
|
|
20
|
+
include CLI::Command
|
|
21
|
+
|
|
22
|
+
USAGE = <<~TEXT
|
|
23
|
+
Usage: chi web push <ls|rm|test> [options]
|
|
24
|
+
ls [--format json] the devices chi web pushes to: id, label, origin, added, last delivery,
|
|
25
|
+
last error, done turns; "stale" for another origin's (never sent to)
|
|
26
|
+
rm ID remove a device (it turns the bell on again to come back)
|
|
27
|
+
test [ID] a test push to ID, or to every device not stale (chi web need not run)
|
|
28
|
+
A device is added by the bell in the phone's Home Screen app (docs/configuration.md, Phone notifications).
|
|
29
|
+
TEXT
|
|
30
|
+
|
|
31
|
+
SUBCOMMANDS = %w[ls rm test].freeze
|
|
32
|
+
|
|
33
|
+
FLAGS = {
|
|
34
|
+
"ls" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--format" },
|
|
35
|
+
"rm" => CLI::Flags.new(help: HELP_WORDS),
|
|
36
|
+
"test" => CLI::Flags.new(help: HELP_WORDS)
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# @param store [Web::Push::Store, nil] this machine's by default
|
|
40
|
+
# @param origin [#call] → the public origin chi web runs on now (the
|
|
41
|
+
# running one's, else web.public_url's), nil when neither
|
|
42
|
+
# @param client [#call, nil] (sub) → a Client for its origin; one
|
|
43
|
+
# signed with the store's key by default
|
|
44
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, store: nil, origin: nil, client: nil)
|
|
45
|
+
@argv = argv.dup
|
|
46
|
+
@stdout = stdout
|
|
47
|
+
@stderr = stderr
|
|
48
|
+
@store = store || Web::Push::Store.new
|
|
49
|
+
@origin = origin || -> { PushCommand.current_origin }
|
|
50
|
+
@client = client
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The running chi web's https origin (a --tunnel's too), else
|
|
54
|
+
# web.public_url's; nil when neither.
|
|
55
|
+
def self.current_origin
|
|
56
|
+
Web::Running.live_public_url&.origin || Web::PublicUrl.from_config(url: Config.get("web.public_url"))&.origin
|
|
57
|
+
rescue Web::PublicUrl::Invalid
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Integer] 0 done, 1 refused (a store others can read, a
|
|
62
|
+
# corrupt one, an unknown device, a failed test), 2 usage
|
|
63
|
+
def run
|
|
64
|
+
@sub = @argv[0]
|
|
65
|
+
if @sub.nil? || HELP_WORDS.include?(@sub)
|
|
66
|
+
@stdout.puts(USAGE)
|
|
67
|
+
return 0
|
|
68
|
+
end
|
|
69
|
+
return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
|
|
70
|
+
|
|
71
|
+
parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
|
|
72
|
+
return parsed if parsed.is_a?(Integer)
|
|
73
|
+
|
|
74
|
+
send(:"run_#{@sub}", parsed.options, parsed.args)
|
|
75
|
+
rescue Web::Push::Store::Insecure, Web::Push::Store::Corrupt, Web::Push::Refused => e
|
|
76
|
+
error_line("#{command_name}: #{e.message}")
|
|
77
|
+
CLI::Exit::FAILED
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi web push #{@sub}" : "chi web push"
|
|
83
|
+
def usage_on_error = nil
|
|
84
|
+
|
|
85
|
+
def run_ls(options, args)
|
|
86
|
+
return usage_error("ls takes no arguments") unless args.empty?
|
|
87
|
+
|
|
88
|
+
format = options[:format]
|
|
89
|
+
return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
|
|
90
|
+
|
|
91
|
+
subs = devices
|
|
92
|
+
origin = @origin.call
|
|
93
|
+
if format == "json"
|
|
94
|
+
@stdout.puts(JSON.generate(subs.map { |sub| sub.public_h.merge(stale: stale?(sub, origin)) }))
|
|
95
|
+
elsif subs.empty?
|
|
96
|
+
@stdout.puts("no devices (turn the bell on in the phone's Home Screen app)")
|
|
97
|
+
else
|
|
98
|
+
print_table(subs, origin)
|
|
99
|
+
end
|
|
100
|
+
0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def run_rm(_options, args)
|
|
104
|
+
return usage_error("give one device ID (chi web push ls)") unless args.size == 1
|
|
105
|
+
|
|
106
|
+
devices
|
|
107
|
+
id = args.first
|
|
108
|
+
gone = Web::Push::Store.short_id?(id) && @store.remove_device(id)
|
|
109
|
+
return no_device(id) unless gone
|
|
110
|
+
|
|
111
|
+
@stdout.puts("#{gone.id} #{gone.label} removed")
|
|
112
|
+
0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def run_test(_options, args)
|
|
116
|
+
return usage_error("give at most one device ID (chi web push ls)") if args.size > 1
|
|
117
|
+
|
|
118
|
+
subs = devices
|
|
119
|
+
if args.empty?
|
|
120
|
+
origin = @origin.call
|
|
121
|
+
subs = subs.reject { |sub| stale?(sub, origin) }
|
|
122
|
+
if subs.empty?
|
|
123
|
+
error_line("#{command_name}: no device to push to (chi web push ls)")
|
|
124
|
+
return CLI::Exit::FAILED
|
|
125
|
+
end
|
|
126
|
+
else
|
|
127
|
+
subs = subs.select { |sub| sub.id == args.first }
|
|
128
|
+
return no_device(args.first) if subs.empty?
|
|
129
|
+
end
|
|
130
|
+
results = subs.map { |sub| test(sub) }
|
|
131
|
+
results.all? ? 0 : CLI::Exit::FAILED
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# One test push, its line printed; a :gone device is removed (as the
|
|
135
|
+
# route does). @return [Boolean] delivered
|
|
136
|
+
def test(sub)
|
|
137
|
+
result = client_for(sub).deliver(sub, Web::Push::TEST_PAYLOAD, ttl: Web::Push::TEST_TTL, urgency: "high")
|
|
138
|
+
words = "#{result.status} #{result.code}#{" (#{result.ms} ms)" if result.ms}"
|
|
139
|
+
words += ": #{result.error}" if result.error && !result.ok?
|
|
140
|
+
if result.status == :gone
|
|
141
|
+
@store.remove(sub.id)
|
|
142
|
+
words += ": removed (the push service no longer knows it)"
|
|
143
|
+
else
|
|
144
|
+
@store.mark(sub.id, ok: result.ok?, error: result.ok? ? nil : (result.error || result.code))
|
|
145
|
+
end
|
|
146
|
+
@stdout.puts("#{sub.id} #{sub.label} #{words}")
|
|
147
|
+
result.ok?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def client_for(sub)
|
|
151
|
+
return @client.call(sub) if @client
|
|
152
|
+
|
|
153
|
+
Web::Push::Client.new(key: Web::Push.load_key(@store), contact: Web::Push.contact(sub.origin))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The store's devices, after its permission check.
|
|
157
|
+
def devices
|
|
158
|
+
@store.check_permissions!
|
|
159
|
+
@store.subscriptions!
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def no_device(id)
|
|
163
|
+
error_line("#{command_name}: no device #{id[0, 16]} (chi web push ls)")
|
|
164
|
+
CLI::Exit::FAILED
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Stale: made on another origin than chi web's now; none is when that
|
|
168
|
+
# isn't known.
|
|
169
|
+
def stale?(sub, origin) = !origin.nil? && sub.origin != origin
|
|
170
|
+
|
|
171
|
+
def print_table(subs, origin)
|
|
172
|
+
rows = subs.map do |sub|
|
|
173
|
+
[sub.id, sub.label.to_s, sub.origin.to_s, when_text(sub.created_at), when_text(sub.last_ok_at) || "never",
|
|
174
|
+
sub.done? ? "on" : "off", [stale?(sub, origin) ? "stale" : nil, sub.last_error].compact.join(", ")]
|
|
175
|
+
end
|
|
176
|
+
rows.unshift(["ID", "LABEL", "ORIGIN", "ADDED", "LAST OK", "DONE", "NOTE"])
|
|
177
|
+
widths = rows.transpose.map { |column| column.map(&:length).max }
|
|
178
|
+
rows.each { |row| @stdout.puts(row.zip(widths).map { |cell, width| cell.ljust(width) }.join(" ").rstrip) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# An iso time as local "YYYY-MM-DD HH:MM"; nil for none.
|
|
182
|
+
def when_text(iso)
|
|
183
|
+
iso && Time.iso8601(iso).localtime.strftime("%Y-%m-%d %H:%M")
|
|
184
|
+
rescue ArgumentError
|
|
185
|
+
iso
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -8,7 +8,9 @@ require_relative "session"
|
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
# The idle recap saved with its session, in <session dir>/recap.json:
|
|
11
|
-
# {text, covered, covered_digest, model, created_at}
|
|
11
|
+
# {text, title, covered, covered_digest, model, created_at}
|
|
12
|
+
# (title: the auto title the recap reply led with, RecapTitle; absent in
|
|
13
|
+
# a recap saved before it, or when no reply gave one).
|
|
12
14
|
# A separate file, so it never races the session-file save at the end of a
|
|
13
15
|
# turn. Follows the Engine's current session (the REPL can switch).
|
|
14
16
|
class RecapStore
|
|
@@ -77,11 +79,26 @@ module Samagotchi
|
|
|
77
79
|
nil
|
|
78
80
|
end
|
|
79
81
|
|
|
82
|
+
# What a session card or list row reads from the saved recap.
|
|
83
|
+
# +preview+: #preview's sentence; +title+: the auto title, or nil.
|
|
84
|
+
Card = Data.define(:preview, :title)
|
|
85
|
+
|
|
86
|
+
# @return [Card] the saved recap's preview and auto title, from one
|
|
87
|
+
# read (both nil when there is no recap)
|
|
88
|
+
def self.card(session_dir)
|
|
89
|
+
data = read(session_dir)
|
|
90
|
+
title = data&.dig(:title).to_s.gsub(/\s+/, " ").strip
|
|
91
|
+
Card.new(preview: preview_of(data), title: title.empty? ? nil : title)
|
|
92
|
+
end
|
|
93
|
+
|
|
80
94
|
# @return [String, nil] the first sentence of the recap saved in
|
|
81
95
|
# +session_dir+, on one line, without the subject phrase (SUBJECT_RE)
|
|
82
96
|
# and cut to PREVIEW_CHARS. The full recap keeps its sentence.
|
|
83
|
-
def self.preview(session_dir)
|
|
84
|
-
|
|
97
|
+
def self.preview(session_dir) = preview_of(read(session_dir))
|
|
98
|
+
|
|
99
|
+
# #preview of a recap already read (RecapStore.read's hash, or nil).
|
|
100
|
+
def self.preview_of(data)
|
|
101
|
+
text = data&.dig(:text).to_s.gsub(/\s+/, " ").strip
|
|
85
102
|
return nil if text.empty?
|
|
86
103
|
|
|
87
104
|
first = text[/\A.*?[.!?](?=\s|\z)/] || text
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The auto title the recap reply leads with (IdleRecap::RecapPrompt::TITLE):
|
|
5
|
+
# "Title: <2-6 words>", a blank line, then the recap. #split takes the
|
|
6
|
+
# title line off, so the recap text the bubble, /recap, a continue's carry
|
|
7
|
+
# note and the next "Earlier recap:" read never holds it. A title line
|
|
8
|
+
# last is taken too (the spike's last-line arm put it there; a model may
|
|
9
|
+
# drift). A title that isn't 2-6 words or names an actor is no title, but
|
|
10
|
+
# its line still goes. Never raises.
|
|
11
|
+
module RecapTitle
|
|
12
|
+
LABEL = "Title:"
|
|
13
|
+
# As long as a card's title shows on a phone without wrapping twice.
|
|
14
|
+
MAX_CHARS = 60
|
|
15
|
+
WORDS = (2..6)
|
|
16
|
+
# "Title: Foo", "**Title:** Foo", "## Title: Foo" (a colon only: a
|
|
17
|
+
# recap may open "Title-casing the headings…").
|
|
18
|
+
LINE_RE = /\A[\s*_#>]*title[\s*_]*:[\s*_]*(?<title>.*)\z/i
|
|
19
|
+
# An instruction echoed between the title and the recap (the spike's
|
|
20
|
+
# "Blank line follows title."), or "(blank line)": dropped.
|
|
21
|
+
ECHO_RE = /\A[\s*_(\[]*(?:a |the )?blank line\b[^\n]{0,40}\z/i
|
|
22
|
+
# A "Recap:" label before the recap's first sentence: dropped, the
|
|
23
|
+
# sentence kept.
|
|
24
|
+
RECAP_LABEL_RE = /\A[\s*_#]*recap[\s*_]*:[\s*_]*/i
|
|
25
|
+
# The recap prompt's actors (RecapPrompt::SYSTEM: no "the user", no "the
|
|
26
|
+
# assistant", no "I", no "we"). A bare "User" or "Chi" stays: "User
|
|
27
|
+
# settings page", "Chi web push devices" name things.
|
|
28
|
+
ACTOR_RE = /\A(?:the\s+(?:user|assistant)(?:'s)?|(?:user|assistant)'s|i|we|you)(?=\s|\z)/i
|
|
29
|
+
QUOTES = "\"'`“”‘’«»"
|
|
30
|
+
|
|
31
|
+
# A reply split in two: +title+ nil when it has none (or none that
|
|
32
|
+
# passes), +body+ the recap without the title line.
|
|
33
|
+
Split = Data.define(:title, :body)
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# @param text [String] the recap model's reply
|
|
38
|
+
# @return [Split]
|
|
39
|
+
def split(text)
|
|
40
|
+
lines = text.to_s.strip.lines(chomp: true)
|
|
41
|
+
if lines.first && LINE_RE.match?(lines.first)
|
|
42
|
+
title = lines.shift[LINE_RE, :title]
|
|
43
|
+
lines = lead_cleaned(lines)
|
|
44
|
+
elsif lines.size > 1 && LINE_RE.match?(lines.last)
|
|
45
|
+
title = lines.pop[LINE_RE, :title]
|
|
46
|
+
else
|
|
47
|
+
return Split.new(title: nil, body: text.to_s.strip)
|
|
48
|
+
end
|
|
49
|
+
Split.new(title: clean(title), body: lines.join("\n").strip)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
Split.new(title: nil, body: text.to_s.strip)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The title as a card shows it: markup, quotes and a closing period
|
|
55
|
+
# gone, one line, cut at MAX_CHARS; nil when it isn't 2-6 words or
|
|
56
|
+
# starts with an actor.
|
|
57
|
+
def clean(raw)
|
|
58
|
+
title = raw.to_s.gsub(/[*_#]+/, " ").gsub(/\s+/, " ").strip
|
|
59
|
+
title = title.delete_prefix(title[0]).delete_suffix(title[-1]).strip while quoted?(title)
|
|
60
|
+
title = title.sub(/[.。]+\z/, "").strip
|
|
61
|
+
return nil unless WORDS.cover?(title.split.size)
|
|
62
|
+
return nil if ACTOR_RE.match?(title)
|
|
63
|
+
|
|
64
|
+
title.length > MAX_CHARS ? "#{title[0, MAX_CHARS - 1].rstrip}…" : title
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quoted?(title) = title.length > 1 && QUOTES.include?(title[0]) && QUOTES.include?(title[-1])
|
|
68
|
+
|
|
69
|
+
# Blank lines, echoed instructions and a "Recap:" label before the recap.
|
|
70
|
+
def lead_cleaned(lines)
|
|
71
|
+
lines = lines.drop_while { |line| line.strip.empty? || ECHO_RE.match?(line) }
|
|
72
|
+
return lines if lines.empty?
|
|
73
|
+
|
|
74
|
+
first = lines.first.sub(RECAP_LABEL_RE, "")
|
|
75
|
+
first.strip.empty? ? lead_cleaned(lines.drop(1)) : [first, *lines.drop(1)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -154,6 +154,7 @@ module Samagotchi
|
|
|
154
154
|
chi bootstrap [HOST[:PORT]|URL] first setup: find the model server, write config.yml
|
|
155
155
|
chi scratch [options] a one-time session in this terminal: nothing is kept
|
|
156
156
|
chi web [--port PORT] [--open] [--scope=all] the web UI
|
|
157
|
+
chi web push <ls|rm|test> the phones chi web pushes notifications to
|
|
157
158
|
chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean|stats>
|
|
158
159
|
chi note [--source NAME] [-m TEXT] ID...|--all background context for sessions
|
|
159
160
|
chi broadcast [-m TEXT] [--all] [--dry-run] | log | deliver a note for every session it may concern
|
|
@@ -308,12 +308,13 @@ module Samagotchi
|
|
|
308
308
|
# @param exclude [String, nil] a session id to leave out (the asker)
|
|
309
309
|
# @param include_archived [Boolean] archived sessions too
|
|
310
310
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
311
|
-
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
311
|
+
# updated_at:, status:, live:, busy:, owner:, recap:, auto_title:, parent_id:,
|
|
312
312
|
# parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
|
|
313
313
|
# waiting_id:, stopped_by:}; busy =
|
|
314
314
|
# live with a turn running, delegate = started by the delegate tool
|
|
315
315
|
# (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
|
|
316
|
-
# previous link of its chain, Session#continues), recap = the saved recap's first sentence,
|
|
316
|
+
# previous link of its chain, Session#continues), recap = the saved recap's first sentence, auto_title =
|
|
317
|
+
# the auto title its reply led with (RecapTitle) or nil, project =
|
|
317
318
|
# Session#project_root, waiting = the kind of question it waits on
|
|
318
319
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
319
320
|
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
@@ -339,7 +340,7 @@ module Samagotchi
|
|
|
339
340
|
|
|
340
341
|
{ id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), title: s.title, cwd: s.working_directory,
|
|
341
342
|
project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
|
|
342
|
-
owner: owner&.kind,
|
|
343
|
+
owner: owner&.kind, **recap_fields(Session.session_dir(s.id, state_dir: sd)),
|
|
343
344
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
|
|
344
345
|
budget_tokens: -> { budgets.call(s) })&.round(1),
|
|
345
346
|
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
|
|
@@ -478,6 +479,12 @@ module Samagotchi
|
|
|
478
479
|
cut(desc, SUMMARY_DESC_LIMIT)
|
|
479
480
|
end
|
|
480
481
|
|
|
482
|
+
# A session row's recap: and auto_title: (RecapStore.card, one read).
|
|
483
|
+
private_class_method def self.recap_fields(session_dir)
|
|
484
|
+
card = RecapStore.card(session_dir)
|
|
485
|
+
{ recap: card.preview, auto_title: card.title }
|
|
486
|
+
end
|
|
487
|
+
|
|
481
488
|
private_class_method def self.summary_preview(session)
|
|
482
489
|
cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
|
|
483
490
|
end
|
|
@@ -217,12 +217,13 @@ module Samagotchi
|
|
|
217
217
|
# scratch: a `chi scratch` session (deleted when its REPL ends, a
|
|
218
218
|
# leftover one at the next sweep)
|
|
219
219
|
# ctx_pct: how full the context was after the last turn, or nil
|
|
220
|
-
# title: the session's own name (/title; desc shows it too), or nil
|
|
220
|
+
# title: the session's own name (/title; desc shows it too), or nil;
|
|
221
|
+
# auto_title: the one its recap gave it (RecapTitle), or nil
|
|
221
222
|
# waiting: the kind of question it waits on (question, approval,
|
|
222
223
|
# hook: chi answer or the web answers it), or nil; waiting_id: its
|
|
223
224
|
# id, for chi answer --question; relayed_to: the parent session (short
|
|
224
225
|
# id) whose card it also waits in (the approval relay), or nil
|
|
225
|
-
keys = %i[id short_id desc title cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
|
|
226
|
+
keys = %i[id short_id desc title auto_title cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
|
|
226
227
|
waiting waiting_id relayed_to stopped_by]
|
|
227
228
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
228
229
|
when "tsv"
|
|
@@ -234,12 +235,13 @@ module Samagotchi
|
|
|
234
235
|
elsif summary[:busy] then "running"
|
|
235
236
|
else summary[:live] ? "live" : summary[:status].to_s
|
|
236
237
|
end
|
|
237
|
-
# The
|
|
238
|
-
# (text only: tsv and json keep desc for pickers);
|
|
239
|
-
# desc) says what the user named it.
|
|
238
|
+
# The recap's auto title, else its first sentence, says more than
|
|
239
|
+
# the last prompt (text only: tsv and json keep desc for pickers);
|
|
240
|
+
# a title (in desc) says what the user named it.
|
|
240
241
|
desc = summary[:desc]
|
|
241
|
-
|
|
242
|
-
|
|
242
|
+
named = summary[:auto_title] || summary[:recap]
|
|
243
|
+
if named && !summary[:title]
|
|
244
|
+
desc = [File.basename(summary[:cwd].to_s), named].reject(&:empty?).join(" · ")
|
|
243
245
|
desc = "#{desc[0, 59]}…" if desc.length > 60
|
|
244
246
|
end
|
|
245
247
|
# A delegated session points at its parent.
|
|
@@ -265,15 +267,16 @@ module Samagotchi
|
|
|
265
267
|
sort ||= "updated_at"
|
|
266
268
|
order ||= "desc"
|
|
267
269
|
sessions = @access.list(sort: sort, order: order, limit: limit, project_root: project, include_archived: include_archived)
|
|
268
|
-
# The session's title, else the
|
|
269
|
-
# last prompt (or the first message, before any
|
|
270
|
+
# The session's title, else the recap's auto title, else its first
|
|
271
|
+
# sentence, else the last prompt (or the first message, before any
|
|
272
|
+
# turn), cut as before.
|
|
270
273
|
# These rows read each session's folder here (its recap, saved
|
|
271
274
|
# context, owner): machine-local, the server's rows remotely (P2's
|
|
272
275
|
# list parity).
|
|
273
276
|
state_dir = Samagotchi::Session.default_state_dir
|
|
274
277
|
list_text = lambda do |s|
|
|
275
|
-
|
|
276
|
-
(
|
|
278
|
+
card = s.title ? nil : Samagotchi::RecapStore.card(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
279
|
+
(card&.title || card&.preview || Samagotchi::SessionManager.summary_text(s))[0, 60]
|
|
277
280
|
end
|
|
278
281
|
# How full the context was after the last turn: "ctx 12%", counted
|
|
279
282
|
# against the session's llm_context budget when one is set and smaller
|
|
@@ -48,10 +48,15 @@ module Samagotchi
|
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
|
-
# "title: Coordinator",
|
|
51
|
+
# "title: Coordinator", "title: none (shows the auto title: Parser tab
|
|
52
|
+
# handling)" (the recap's, RecapTitle), or "title: none (shows the
|
|
53
|
+
# first message: hey chi …)"
|
|
52
54
|
def listing(session)
|
|
53
55
|
return "title: #{session.title}" if session.title
|
|
54
56
|
|
|
57
|
+
auto = @engine.recap&.state&.dig(:title)
|
|
58
|
+
return "title: none (shows the auto title: #{auto})" if auto
|
|
59
|
+
|
|
55
60
|
preview = session.first_preview.to_s
|
|
56
61
|
"title: none (shows #{preview.empty? ? "the first message" : "the first message: #{preview}"})"
|
|
57
62
|
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -273,6 +273,7 @@ module Samagotchi
|
|
|
273
273
|
["DELETE", %r{\A/api/push/subscriptions\z}, :handle_push_unsubscribe, :owner],
|
|
274
274
|
# The id checked in the handler (participant_gate_spec fills ([^/]+) captures only).
|
|
275
275
|
["PATCH", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_prefs, :owner],
|
|
276
|
+
["DELETE", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_remove, :owner],
|
|
276
277
|
["POST", %r{\A/api/push/test\z}, :handle_push_test, :owner],
|
|
277
278
|
["POST", %r{\A/api/push/presence\z}, :handle_push_presence, :owner],
|
|
278
279
|
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
|
|
@@ -617,16 +618,58 @@ module Samagotchi
|
|
|
617
618
|
log_refused(refused_peer(req), req.path_info, reason)
|
|
618
619
|
return error_response(401, "unauthorized", unauthorized_detail(req, reason)) if req.path_info.start_with?("/api/")
|
|
619
620
|
|
|
620
|
-
body =
|
|
621
|
+
body = unauthorized_page(req)
|
|
621
622
|
[401, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" },
|
|
622
623
|
[body]]
|
|
623
624
|
end
|
|
624
625
|
|
|
626
|
+
# Which page a refused page request gets: the LAN page with no
|
|
627
|
+
# web.public_url, the proxy page for a request that came through it,
|
|
628
|
+
# and for a browser on this machine the one pointing at the public
|
|
629
|
+
# address (#direct_public?): there the paste box can't help.
|
|
630
|
+
def unauthorized_page(req)
|
|
631
|
+
return direct_public_page if direct_public?(req)
|
|
632
|
+
return PUBLIC_UNAUTHORIZED_PAGE if @public_url
|
|
633
|
+
|
|
634
|
+
UNAUTHORIZED_PAGE
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# A browser on this machine, with web.public_url set: the gate refuses
|
|
638
|
+
# it like every other request, and no trade can fix it here — loopback
|
|
639
|
+
# takes the token as Bearer only, so there is no cookie for it to keep
|
|
640
|
+
# (a cookie for 127.0.0.1 reaches every server on any local port). The
|
|
641
|
+
# page says where to open chi web instead. The origin is this machine's
|
|
642
|
+
# config, not a secret; it is escaped all the same, and no token or
|
|
643
|
+
# link code is in it.
|
|
644
|
+
def direct_public_page
|
|
645
|
+
origin = CGI.escapeHTML(@public_url.origin)
|
|
646
|
+
<<~HTML
|
|
647
|
+
<!doctype html>
|
|
648
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
649
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
650
|
+
<title>chi web: open it at its public address</title>
|
|
651
|
+
<style>
|
|
652
|
+
:root { color-scheme: light dark; }
|
|
653
|
+
body { font: 16px/1.5 -apple-system, system-ui, sans-serif; max-width: 30rem; margin: 3rem auto; padding: 0 1rem; }
|
|
654
|
+
a { color: inherit; }
|
|
655
|
+
</style></head>
|
|
656
|
+
<body>
|
|
657
|
+
<h1>chi web</h1>
|
|
658
|
+
<p>chi web is behind an access proxy, so every request needs its access token, this machine's too.
|
|
659
|
+
A browser here can't trade a token for a cookie on 127.0.0.1, so this page can't let you in: open
|
|
660
|
+
chi web at its public address instead.</p>
|
|
661
|
+
<p><a href="#{origin}/">#{origin}/</a></p>
|
|
662
|
+
<p>chi web prints a link to it in its terminal every time it starts.</p>
|
|
663
|
+
</body></html>
|
|
664
|
+
HTML
|
|
665
|
+
end
|
|
666
|
+
|
|
625
667
|
def unauthorized_detail(req, reason)
|
|
626
668
|
return "chi web behind the proxy: #{reason || "needs its access token"}" if proxied?(req)
|
|
627
|
-
return "chi web
|
|
669
|
+
return "chi web on the LAN needs its access token" unless @public_url
|
|
628
670
|
|
|
629
|
-
"chi web
|
|
671
|
+
detail = "chi web with web.public_url needs its access token, on this machine too"
|
|
672
|
+
direct_public?(req) ? "#{detail}; open its public address #{@public_url.origin}/ instead" : detail
|
|
630
673
|
end
|
|
631
674
|
|
|
632
675
|
# Who a refusal is logged as: the peer, or behind a proxy (every peer
|
|
@@ -7,6 +7,11 @@ module Samagotchi
|
|
|
7
7
|
module Web
|
|
8
8
|
class MarkdownRenderer
|
|
9
9
|
WARNING = "Markdown rendering is enabled, but the optional commonmarker gem is not installed. Install it with: gem install commonmarker"
|
|
10
|
+
# The session view's tip while rendering is off (#tip): how to turn it
|
|
11
|
+
# on, the gem first when it isn't installed. Each fits two lines on a
|
|
12
|
+
# phone (390px), hence the short lead and the gem one without config.yml.
|
|
13
|
+
TIP = "Markdown answers: restart chi web with --web-markdown (or web.markdown: true in config.yml)."
|
|
14
|
+
TIP_GEM = "Markdown answers: gem install commonmarker, then restart chi web with --web-markdown."
|
|
10
15
|
# NOTE: `span` is allowed because the syntax highlighter (commonmarker/syntect)
|
|
11
16
|
# emits classed <span>s; keeping it lets highlighting render.
|
|
12
17
|
ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
|
|
@@ -29,6 +34,15 @@ module Samagotchi
|
|
|
29
34
|
WARNING if enabled? && !load_bundled_commonmarker!
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
# The tip while rendering is off, nil when it is on. Whether the gem is
|
|
38
|
+
# installed is read from its gemspec, not by loading it: a server that
|
|
39
|
+
# won't render has no use for the native extension.
|
|
40
|
+
def tip
|
|
41
|
+
return nil if enabled?
|
|
42
|
+
|
|
43
|
+
commonmarker_installed? ? TIP : TIP_GEM
|
|
44
|
+
end
|
|
45
|
+
|
|
32
46
|
def render(markdown)
|
|
33
47
|
return nil unless available?
|
|
34
48
|
|
|
@@ -57,6 +71,12 @@ module Samagotchi
|
|
|
57
71
|
@commonmarker_available = false
|
|
58
72
|
end
|
|
59
73
|
|
|
74
|
+
def commonmarker_installed?
|
|
75
|
+
return @commonmarker_installed unless @commonmarker_installed.nil?
|
|
76
|
+
|
|
77
|
+
@commonmarker_installed = Gem::Specification.find_all_by_name("commonmarker").any?
|
|
78
|
+
end
|
|
79
|
+
|
|
60
80
|
def sanitize(html)
|
|
61
81
|
fragment = Nokogiri::HTML5.fragment(html)
|
|
62
82
|
fragment.traverse do |node|
|