samagotchi 0.27.0 → 0.29.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 +65 -1
- data/docs/cli.md +6 -3
- data/docs/configuration.md +17 -3
- data/docs/internals/prompt-caching.md +38 -7
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundle_command.rb +4 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +8 -0
- data/lib/samagotchi/desktop/macos/App.swift +6 -4
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +30 -10
- data/lib/samagotchi/desktop/macos.rb +5 -1
- data/lib/samagotchi/engine.rb +5 -4
- data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/llm/openai_chat.rb +40 -4
- data/lib/samagotchi/llm/prompt_cache.rb +31 -5
- data/lib/samagotchi/memory_bundle/installer.rb +14 -0
- data/lib/samagotchi/session.rb +9 -0
- data/lib/samagotchi/session_commands.rb +6 -2
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +26 -13
- data/lib/samagotchi/terminal_ui/repl_input.rb +13 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/tool_activity.rb +32 -6
- data/lib/samagotchi/tools/builtins.rb +10 -7
- data/lib/samagotchi/tools/task_runtime.rb +21 -0
- data/lib/samagotchi/turn_note.rb +11 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_context.rb +17 -4
- data/lib/samagotchi/web/public/app.js +14 -4
- data/lib/samagotchi/web/public/index.html +37 -19
- data/lib/samagotchi/web/public/sessions_list.js +21 -4
- data/lib/samagotchi/web/public/stage_model.js +5 -2
- data/lib/samagotchi/web/public/task_stop.js +24 -1
- data/lib/samagotchi/web/public/turn_model.js +14 -5
- data/lib/samagotchi/web/public/turn_view.js +46 -10
- data/lib/samagotchi/worker.rb +9 -0
- metadata +1 -1
|
@@ -80,6 +80,17 @@ module Samagotchi
|
|
|
80
80
|
false
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# true when +url+ is OpenAI's own API (api.openai.com).
|
|
84
|
+
def self.openai?(url)
|
|
85
|
+
URI(url.to_s).host.to_s.downcase == "api.openai.com"
|
|
86
|
+
rescue URI::Error
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether cache.key asks for the session id as `prompt_cache_key`
|
|
91
|
+
# (session; off, the default, or anything else sends none).
|
|
92
|
+
def self.cache_key? = Samagotchi::Config.get("cache.key").to_s.strip.downcase == "session"
|
|
93
|
+
|
|
83
94
|
# Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
|
|
84
95
|
def self.parse_arguments(raw)
|
|
85
96
|
return raw if raw.is_a?(Hash)
|
|
@@ -141,10 +152,10 @@ module Samagotchi
|
|
|
141
152
|
# @return [ChatResponse]
|
|
142
153
|
def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
|
|
143
154
|
session_id: nil)
|
|
144
|
-
body = request_body(messages, tools, model, options)
|
|
155
|
+
body = request_body(messages, tools, model, options, session_id)
|
|
145
156
|
request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
|
|
146
157
|
log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body),
|
|
147
|
-
cache: ("
|
|
158
|
+
cache: cache_summary(body[:messages]), cache_by: ("session" if body.key?(:prompt_cache_key)) }
|
|
148
159
|
return chat_once(request, cancel_controller, log_fields) unless @stream
|
|
149
160
|
|
|
150
161
|
assembly = Assembly.new
|
|
@@ -236,7 +247,7 @@ module Samagotchi
|
|
|
236
247
|
[[open_timeout, timeout].min, timeout]
|
|
237
248
|
end
|
|
238
249
|
|
|
239
|
-
def request_body(messages, tools, model, options)
|
|
250
|
+
def request_body(messages, tools, model, options, session_id)
|
|
240
251
|
body = {
|
|
241
252
|
model: model,
|
|
242
253
|
messages: cache_breakpoints(Array(messages).map { |message| wire_message(message) }, model),
|
|
@@ -244,6 +255,7 @@ module Samagotchi
|
|
|
244
255
|
}
|
|
245
256
|
body[:stream_options] = { include_usage: true } if @stream
|
|
246
257
|
body[:max_tokens] = @default_max_tokens if @default_max_tokens
|
|
258
|
+
body[:prompt_cache_key] = session_id.to_s if prompt_cache_key?(session_id)
|
|
247
259
|
unless Array(tools).empty?
|
|
248
260
|
body[:tools] = tools
|
|
249
261
|
body[:tool_choice] = "auto"
|
|
@@ -264,10 +276,34 @@ module Samagotchi
|
|
|
264
276
|
PromptCache.mark(messages, model: model)
|
|
265
277
|
end
|
|
266
278
|
|
|
279
|
+
# The session id as `prompt_cache_key` (cache.key: session) goes only
|
|
280
|
+
# to the APIs that document it, OpenAI's and OpenRouter's (which also
|
|
281
|
+
# routes by it), for the session's own chat requests: a local server or
|
|
282
|
+
# another gateway may reject an unknown field.
|
|
283
|
+
def prompt_cache_key?(session_id)
|
|
284
|
+
return false if session_id.to_s.empty? || !@remote || @purpose != "chat"
|
|
285
|
+
return false unless self.class.openai?(@base_url) || self.class.openrouter?(@base_url)
|
|
286
|
+
|
|
287
|
+
self.class.cache_key?
|
|
288
|
+
rescue StandardError
|
|
289
|
+
false
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# The log line's cache= : on for breakpoints with Anthropic's default
|
|
293
|
+
# TTL, 1h for cache.ttl 1h, nil for none.
|
|
294
|
+
def cache_summary(messages)
|
|
295
|
+
return nil unless PromptCache.marked?(messages)
|
|
296
|
+
|
|
297
|
+
hour = Array(messages).any? do |message|
|
|
298
|
+
Array(message[:content]).any? { |part| part.is_a?(Hash) && part.dig(:cache_control, :ttl) == "1h" }
|
|
299
|
+
end
|
|
300
|
+
hour ? "1h" : "on"
|
|
301
|
+
end
|
|
302
|
+
|
|
267
303
|
# The body's fields beyond the conversation and the stream, for the
|
|
268
304
|
# log line: "temperature=0.6 presence_penalty=1.5", nil for none.
|
|
269
305
|
def sampling_summary(body)
|
|
270
|
-
SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
|
|
306
|
+
SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice, :prompt_cache_key))
|
|
271
307
|
end
|
|
272
308
|
|
|
273
309
|
# A String stays a String, an Array of parts an Array; both scrubbed
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../log"
|
|
5
|
+
|
|
3
6
|
module Samagotchi
|
|
4
7
|
module LLM
|
|
5
8
|
# Prompt-cache breakpoints for Claude models behind an OpenAI-compatible
|
|
@@ -17,11 +20,33 @@ module Samagotchi
|
|
|
17
20
|
# the stable part with the mark, the per-session tail (model, location,
|
|
18
21
|
# session) without, so a new session reads the stable part from the
|
|
19
22
|
# cache. `cache_split:` never goes on the wire (#without_split).
|
|
23
|
+
#
|
|
24
|
+
# cache.ttl: 1h marks the breakpoints with `ttl: "1h"` (#control), so a
|
|
25
|
+
# cache outlives a long reading gap; 5m, the default, sends Anthropic's
|
|
26
|
+
# default (no ttl).
|
|
20
27
|
module PromptCache
|
|
21
28
|
CONTROL = { type: "ephemeral" }.freeze
|
|
29
|
+
CONTROL_1H = { type: "ephemeral", ttl: "1h" }.freeze
|
|
30
|
+
TTLS = %w[5m 1h].freeze
|
|
22
31
|
|
|
23
32
|
module_function
|
|
24
33
|
|
|
34
|
+
# The cache_control a breakpoint carries under cache.ttl (an unknown
|
|
35
|
+
# value warns once and is 5m).
|
|
36
|
+
def control
|
|
37
|
+
value = Config.get("cache.ttl").to_s.strip.downcase
|
|
38
|
+
return CONTROL_1H if value == "1h"
|
|
39
|
+
|
|
40
|
+
unless value.empty? || value == "5m" || @warned_ttl
|
|
41
|
+
@warned_ttl = true
|
|
42
|
+
Log.warn(:config, "invalid_value", echo: "Warning: invalid value for cache.ttl: #{value.inspect} (allowed: 5m, 1h) — using 5m",
|
|
43
|
+
key: "cache.ttl")
|
|
44
|
+
end
|
|
45
|
+
CONTROL
|
|
46
|
+
rescue StandardError
|
|
47
|
+
CONTROL
|
|
48
|
+
end
|
|
49
|
+
|
|
25
50
|
# Whether +model+ (the bare id a host is asked for) names a Claude model.
|
|
26
51
|
def claude?(model) = model.to_s.match?(/claude/i)
|
|
27
52
|
|
|
@@ -34,8 +59,9 @@ module Samagotchi
|
|
|
34
59
|
return without_split(messages) unless claude?(model)
|
|
35
60
|
|
|
36
61
|
indexes = [system_index(messages), last_index(messages)].compact.uniq
|
|
62
|
+
breakpoint = control
|
|
37
63
|
messages.each_with_index.map do |message, index|
|
|
38
|
-
next marked(message.except(:cache_split), split: message[:cache_split]) if indexes.include?(index)
|
|
64
|
+
next marked(message.except(:cache_split), breakpoint, split: message[:cache_split]) if indexes.include?(index)
|
|
39
65
|
|
|
40
66
|
message.key?(:cache_split) ? message.except(:cache_split) : message
|
|
41
67
|
end
|
|
@@ -80,15 +106,15 @@ module Samagotchi
|
|
|
80
106
|
# A String becomes one text part (two at +split+, the mark on the
|
|
81
107
|
# first); an Array gets the mark on its last text part (the last part
|
|
82
108
|
# when none is text).
|
|
83
|
-
def marked(message, split: nil)
|
|
109
|
+
def marked(message, breakpoint, split: nil)
|
|
84
110
|
content = message[:content]
|
|
85
111
|
if content.is_a?(String)
|
|
86
112
|
if split.is_a?(Integer) && split.positive? && split < content.length
|
|
87
|
-
return message.merge(content: [{ type: "text", text: content[0, split], cache_control:
|
|
113
|
+
return message.merge(content: [{ type: "text", text: content[0, split], cache_control: breakpoint },
|
|
88
114
|
{ type: "text", text: content[split..] }])
|
|
89
115
|
end
|
|
90
116
|
|
|
91
|
-
return message.merge(content: [{ type: "text", text: content, cache_control:
|
|
117
|
+
return message.merge(content: [{ type: "text", text: content, cache_control: breakpoint }])
|
|
92
118
|
end
|
|
93
119
|
|
|
94
120
|
target = content.rindex { |part| part.is_a?(Hash) && (part[:type] || part["type"]).to_s == "text" } ||
|
|
@@ -96,7 +122,7 @@ module Samagotchi
|
|
|
96
122
|
part = content[target]
|
|
97
123
|
key = part.key?("type") ? "cache_control" : :cache_control
|
|
98
124
|
parts = content.dup
|
|
99
|
-
parts[target] = part.merge(key =>
|
|
125
|
+
parts[target] = part.merge(key => breakpoint)
|
|
100
126
|
message.merge(content: parts)
|
|
101
127
|
end
|
|
102
128
|
|
|
@@ -303,6 +303,7 @@ module Samagotchi
|
|
|
303
303
|
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
304
304
|
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
305
305
|
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
306
|
+
warn_rules_requires_chi(manifest, incoming_rules)
|
|
306
307
|
|
|
307
308
|
# Files the previous version shipped and this one doesn't.
|
|
308
309
|
if @upgrade && existing_provenance && existing_provenance[:files]
|
|
@@ -559,6 +560,19 @@ module Samagotchi
|
|
|
559
560
|
end
|
|
560
561
|
end
|
|
561
562
|
|
|
563
|
+
# GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
|
|
564
|
+
# requires_chi this chi doesn't meet, and a session that never loaded
|
|
565
|
+
# them records a required load failure (the gate refuses guarded calls
|
|
566
|
+
# until chi is updated): say so at install, as the hooks warning does.
|
|
567
|
+
def warn_rules_requires_chi(manifest, rule_files)
|
|
568
|
+
return if manifest.nil? || rule_files.empty?
|
|
569
|
+
|
|
570
|
+
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
571
|
+
@warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
|
|
572
|
+
"(chi update), guarded tool calls are refused"
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
562
576
|
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
563
577
|
# warning when its sha256 differs from the declared one or this chi
|
|
564
578
|
# doesn't meet requires_chi (the Engine then won't load it).
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -62,6 +62,15 @@ module Samagotchi
|
|
|
62
62
|
# "ended_at" => iso8601, "seconds" => Float, "origin" =>
|
|
63
63
|
# "client"|"reminder"|"delegate"}; nil before the first turn.
|
|
64
64
|
attr_accessor :last_turn
|
|
65
|
+
|
|
66
|
+
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
67
|
+
# left), e.g. "loop-guard"; nil when it ended otherwise. Both strings
|
|
68
|
+
# and symbols are read (the file's keys are strings).
|
|
69
|
+
def stopped_by
|
|
70
|
+
return nil unless @last_turn.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
(@last_turn["stopped_by"] || @last_turn[:stopped_by])&.to_s
|
|
73
|
+
end
|
|
65
74
|
# Archived (ArchiveStore): hidden from the lists. Set by .list (with
|
|
66
75
|
# include_archived); not saved in session.json.
|
|
67
76
|
attr_accessor :archived
|
|
@@ -5,6 +5,7 @@ require_relative "model_profile"
|
|
|
5
5
|
require_relative "served_model"
|
|
6
6
|
require_relative "turn_flow"
|
|
7
7
|
require_relative "tools/execute"
|
|
8
|
+
require_relative "tools/builtins"
|
|
8
9
|
require_relative "commands/registry"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
@@ -207,12 +208,15 @@ module Samagotchi
|
|
|
207
208
|
end
|
|
208
209
|
|
|
209
210
|
# The output goes into the conversation for the next turn. Not saved
|
|
210
|
-
# here (as in the REPL, the next turn saves it).
|
|
211
|
+
# here (as in the REPL, the next turn saves it). No parent-session
|
|
212
|
+
# marker: the user runs this, not the model. A worker's `chi --model X`
|
|
213
|
+
# default is unset, though: it was that run's, not the default of a chi
|
|
214
|
+
# run here (Tools::Builtins.cli_model_unset).
|
|
211
215
|
def shell(text)
|
|
212
216
|
command = text.delete_prefix(SHELL_BANG_PREFIX).strip
|
|
213
217
|
return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
|
|
214
218
|
|
|
215
|
-
output = Samagotchi::Tools::Execute.call(command)
|
|
219
|
+
output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset)
|
|
216
220
|
@engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
|
|
217
221
|
# Rolling back past this would silently drop the command output.
|
|
218
222
|
@turn_flow.note_conversation_changed
|
|
@@ -259,12 +259,13 @@ module Samagotchi
|
|
|
259
259
|
# @param include_archived [Boolean] archived sessions too
|
|
260
260
|
# @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
|
|
261
261
|
# updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
|
|
262
|
-
# parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:}; busy = live with
|
|
262
|
+
# parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:, stopped_by:}; busy = live with
|
|
263
263
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
264
264
|
# Session#project_root, waiting = the kind of question it waits on
|
|
265
265
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
266
266
|
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
267
|
-
# whose card it waits in too (the approval relay), its short id, or nil
|
|
267
|
+
# whose card it waits in too (the approval relay), its short id, or nil,
|
|
268
|
+
# stopped_by = the hook that stopped the last turn (Session#stopped_by)
|
|
268
269
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
269
270
|
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
270
271
|
sd = state_dir || Session.default_state_dir
|
|
@@ -284,7 +285,7 @@ module Samagotchi
|
|
|
284
285
|
owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
|
|
285
286
|
ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
|
|
286
287
|
parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
|
|
287
|
-
scratch: s.scratch, test_run: s.test_run }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
288
|
+
scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
|
|
288
289
|
end
|
|
289
290
|
(limit ? summaries.first(limit) : summaries.to_a)
|
|
290
291
|
end
|
|
@@ -112,6 +112,28 @@ module Samagotchi
|
|
|
112
112
|
options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? value.to_i : value] }
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# The flags a list row carries: a chi scratch session, a test run, an
|
|
116
|
+
# archived one, and the turn's stopper (see #stopped_flag).
|
|
117
|
+
def session_flag(scratch:, test_run:, archived:, stopped_by: nil)
|
|
118
|
+
flag = if scratch
|
|
119
|
+
" [scratch]"
|
|
120
|
+
else
|
|
121
|
+
(test_run ? " [test]" : "")
|
|
122
|
+
end
|
|
123
|
+
flag += " [archived]" if archived
|
|
124
|
+
flag + stopped_flag(stopped_by)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The marker for a turn a hook stopped: "[looped]" for loop-guard (the
|
|
128
|
+
# bundle's thinking watch, and the word `chi sessions stats` uses for its
|
|
129
|
+
# cuts), "[stopped by X]" for any other hook; "" otherwise. A cut with no
|
|
130
|
+
# retry left records the same stopped_by.
|
|
131
|
+
def stopped_flag(by)
|
|
132
|
+
return "" if by.to_s.empty?
|
|
133
|
+
|
|
134
|
+
by.to_s == "loop-guard" ? " [looped]" : " [stopped by #{by}]"
|
|
135
|
+
end
|
|
136
|
+
|
|
115
137
|
def list
|
|
116
138
|
format, scope, cwd = @opts.values_at(:format, :scope, :cwd)
|
|
117
139
|
unless format.nil? || %w[text json tsv].include?(format)
|
|
@@ -156,7 +178,7 @@ module Samagotchi
|
|
|
156
178
|
# id, for chi answer --question; relayed_to: the parent session (short
|
|
157
179
|
# id) whose card it also waits in (the approval relay), or nil
|
|
158
180
|
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
|
|
159
|
-
waiting_id relayed_to]
|
|
181
|
+
waiting_id relayed_to stopped_by]
|
|
160
182
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
161
183
|
when "tsv"
|
|
162
184
|
summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
|
|
@@ -176,12 +198,8 @@ module Samagotchi
|
|
|
176
198
|
end
|
|
177
199
|
# A delegated session points at its parent.
|
|
178
200
|
child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
|
|
179
|
-
flag =
|
|
180
|
-
|
|
181
|
-
else
|
|
182
|
-
(summary[:test_run] ? " [test]" : "")
|
|
183
|
-
end
|
|
184
|
-
flag += " [archived]" if summary[:archived]
|
|
201
|
+
flag = session_flag(scratch: summary[:scratch], test_run: summary[:test_run], archived: summary[:archived],
|
|
202
|
+
stopped_by: summary[:stopped_by])
|
|
185
203
|
ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
|
|
186
204
|
@stdout.puts "#{summary[:id]} #{state.ljust(8)} #{ctx.ljust(8)} #{summary[:updated_at]} #{desc}#{flag}#{child}"
|
|
187
205
|
end
|
|
@@ -209,12 +227,7 @@ module Samagotchi
|
|
|
209
227
|
end
|
|
210
228
|
# A delegated session points at its parent: ↳ <parent's short id>.
|
|
211
229
|
row = lambda do |s|
|
|
212
|
-
flag =
|
|
213
|
-
" [scratch]"
|
|
214
|
-
else
|
|
215
|
-
(s.test_run ? " [test]" : "")
|
|
216
|
-
end
|
|
217
|
-
flag += " [archived]" if s.archived
|
|
230
|
+
flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
|
|
218
231
|
child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
|
|
219
232
|
# How full the context was after the last turn: "ctx 12%", blank when unknown.
|
|
220
233
|
ctx = Samagotchi::SessionMetrics.context_label(
|
|
@@ -94,6 +94,19 @@ module Samagotchi
|
|
|
94
94
|
# @return [Boolean] whether it went in
|
|
95
95
|
def prefill(text) = open? && @reader.prefill(text)
|
|
96
96
|
|
|
97
|
+
# What the open prompt holds, nil when none is open: a line typed
|
|
98
|
+
# ahead there is the next turn's input.
|
|
99
|
+
def typed_text = @reader&.typed_text
|
|
100
|
+
|
|
101
|
+
# Whether the next turn's input is already here: text typed ahead at
|
|
102
|
+
# the open prompt, or a line a turn took as steering but never merged
|
|
103
|
+
# (it runs as the next turn).
|
|
104
|
+
def waiting_input?
|
|
105
|
+
@lock.synchronize { return true unless @front.empty? }
|
|
106
|
+
|
|
107
|
+
!typed_text.to_s.strip.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
97
110
|
# A question answered at the open prompt: the lines submitted from now
|
|
98
111
|
# on go to it, at the ? prompt. What was typed at the prompt is put aside
|
|
99
112
|
# and comes back once the question closes.
|
|
@@ -175,6 +175,11 @@ module Samagotchi
|
|
|
175
175
|
@engine.interface = @non_interactive ? :non_interactive : :repl
|
|
176
176
|
# A --non-interactive run exits after its turn: no turn-end warm-up.
|
|
177
177
|
@engine.next_turn_waiting = -> { true } if @non_interactive
|
|
178
|
+
# A warm-up never pays off while a line typed ahead at the open prompt
|
|
179
|
+
# (or a left-over steer) already forms the next turn: only prefill when
|
|
180
|
+
# the prompt is empty. The REPL-only check composes with the one above.
|
|
181
|
+
next_waiting = @engine.next_turn_waiting
|
|
182
|
+
@engine.next_turn_waiting = -> { @repl_input&.waiting_input? || next_waiting&.call }
|
|
178
183
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
179
184
|
# A recap written while a continue offer waits says the turn stopped
|
|
180
185
|
# unfinished. (Answering at the prompt is typing: activity already.)
|
|
@@ -12,6 +12,7 @@ require_relative "tools/edit"
|
|
|
12
12
|
require_relative "tools/task_create"
|
|
13
13
|
require_relative "tools/task_get"
|
|
14
14
|
require_relative "tools/task_list"
|
|
15
|
+
require_relative "tools/task_runtime"
|
|
15
16
|
require_relative "tools/task_stop"
|
|
16
17
|
require_relative "tools/task_wait"
|
|
17
18
|
require_relative "tools/web_fetch"
|
|
@@ -62,11 +63,13 @@ module Samagotchi
|
|
|
62
63
|
# command without its "cd … &&"): a file tool's path relative to +cwd+
|
|
63
64
|
# when it is under it, a command's description (the model's few words),
|
|
64
65
|
# else its first step and how many more (CommandSteps; its first line
|
|
65
|
-
# when it has no steps), a memory's name
|
|
66
|
-
# nil for the other tools (a plugin's row keeps its preview, the
|
|
66
|
+
# when it has no steps), a memory's name, a task tool's task command
|
|
67
|
+
# (task_title); nil for the other tools (a plugin's row keeps its preview, the
|
|
67
68
|
# params). +params+ stays the full key=value line (the TUI, the
|
|
68
69
|
# guardrails).
|
|
69
70
|
def tool_title(tool_name, call, cwd: nil)
|
|
71
|
+
return task_title(tool_name, call, cwd) if TASK_TOOLS.include?(tool_name)
|
|
72
|
+
|
|
70
73
|
if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
|
|
71
74
|
title = command_description(tool_name, call) || steps_title(call[:content])
|
|
72
75
|
return title if title
|
|
@@ -90,19 +93,42 @@ module Samagotchi
|
|
|
90
93
|
end
|
|
91
94
|
end
|
|
92
95
|
|
|
96
|
+
TASK_TOOLS = [Tools::TaskWait::NAME, Tools::TaskGet::NAME, Tools::TaskStop::NAME].freeze
|
|
97
|
+
|
|
98
|
+
# A task_wait / task_get / task_stop row's title: the task's command
|
|
99
|
+
# (its record under +cwd+, the process's directory when nil) as a
|
|
100
|
+
# command's title, and for a wait how long it waits ("npm test · up to
|
|
101
|
+
# 600s"). nil when there is no record: the row keeps its id.
|
|
102
|
+
def task_title(tool_name, call, cwd)
|
|
103
|
+
command = Tools::TaskRuntime.command_of(call[:content], root: cwd)
|
|
104
|
+
return nil unless command
|
|
105
|
+
|
|
106
|
+
suffix = ""
|
|
107
|
+
if tool_name == Tools::TaskWait::NAME
|
|
108
|
+
timeout = call[:timeout].to_s.strip
|
|
109
|
+
suffix = " · up to #{timeout.empty? ? Tools::TaskWait::TIMEOUT_DEFAULT : timeout.to_i}s"
|
|
110
|
+
end
|
|
111
|
+
room = TOOL_ACTIVITY_PREVIEW_LIMIT - suffix.length
|
|
112
|
+
text = steps_title(command, room) || command_title(command).gsub(/\s+/, " ")
|
|
113
|
+
return nil if text.empty?
|
|
114
|
+
|
|
115
|
+
text = "#{text[0, room - 1]}…" if text.length > room
|
|
116
|
+
"#{text}#{suffix}"
|
|
117
|
+
end
|
|
118
|
+
|
|
93
119
|
# A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
|
|
94
120
|
# eats the line.
|
|
95
121
|
LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
|
|
96
122
|
|
|
97
|
-
# "<first step> +N" (N the steps after it), cut to fit
|
|
98
|
-
# TOOL_ACTIVITY_PREVIEW_LIMIT; nil when the command has no steps.
|
|
99
|
-
def steps_title(command)
|
|
123
|
+
# "<first step> +N" (N the steps after it), cut to fit +limit+
|
|
124
|
+
# (TOOL_ACTIVITY_PREVIEW_LIMIT); nil when the command has no steps.
|
|
125
|
+
def steps_title(command, limit = TOOL_ACTIVITY_PREVIEW_LIMIT)
|
|
100
126
|
steps = CommandSteps.parse(command)&.steps
|
|
101
127
|
return nil unless steps
|
|
102
128
|
|
|
103
129
|
first = steps.first.text.gsub(/\s+/, " ")
|
|
104
130
|
more = steps.size > 1 ? " +#{steps.size - 1}" : ""
|
|
105
|
-
room =
|
|
131
|
+
room = limit - more.length
|
|
106
132
|
first = "#{first[0, room - 1]}…" if first.length > room
|
|
107
133
|
"#{first}#{more}"
|
|
108
134
|
end
|
|
@@ -117,13 +117,16 @@ module Samagotchi
|
|
|
117
117
|
peers = kctx.peers
|
|
118
118
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
119
119
|
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
120
|
+
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
121
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model }.merge(cli_model_unset(env))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The unsets that keep a `chi --model X` worker's default from passing
|
|
125
|
+
# for the default of a chi run here (nothing when it wasn't the CLI's).
|
|
126
|
+
def cli_model_unset(env = ENV)
|
|
127
|
+
return {} unless env[ModelProfile::MODEL_FROM_CLI_ENV]
|
|
128
|
+
|
|
129
|
+
{ ModelProfile::MODEL_ENV => nil, ModelProfile::MODEL_FROM_CLI_ENV => nil }
|
|
127
130
|
end
|
|
128
131
|
|
|
129
132
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
@@ -129,6 +129,27 @@ module Samagotchi
|
|
|
129
129
|
end.uniq
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
+
# A task id as task_create makes them: no path parts.
|
|
133
|
+
TASK_ID = /\A[A-Za-z0-9_-]+\z/
|
|
134
|
+
|
|
135
|
+
# The command of the task +task_id+ under +root+ (a session's working
|
|
136
|
+
# directory) as its record has it, for a UI row's title; nil for no
|
|
137
|
+
# such task or an unreadable record. Reads the record only (no
|
|
138
|
+
# refresh), so it is cheap and never fails.
|
|
139
|
+
# @return [String, nil]
|
|
140
|
+
def command_of(task_id, root: Dir.pwd)
|
|
141
|
+
id = task_id.to_s.strip
|
|
142
|
+
return nil unless id.match?(TASK_ID)
|
|
143
|
+
|
|
144
|
+
path = File.join(root || Dir.pwd, TASKS_DIR, id, METADATA_FILENAME)
|
|
145
|
+
return nil unless File.file?(path)
|
|
146
|
+
|
|
147
|
+
command = JSON.parse(File.read(path))["command"]
|
|
148
|
+
command.is_a?(String) && !command.strip.empty? ? command : nil
|
|
149
|
+
rescue StandardError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
132
153
|
def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
|
|
133
154
|
|
|
134
155
|
def get_record(task_id)
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -143,7 +143,7 @@ module Samagotchi
|
|
|
143
143
|
def cut_retry(by, reason)
|
|
144
144
|
why = reason.to_s.strip.empty? ? "" : ": #{one_line(reason)}"
|
|
145
145
|
retry_nudge(message("your last reply was cut off by #{by || "a plugin"}#{why}. Don't start the same reasoning " \
|
|
146
|
-
"again;
|
|
146
|
+
"again; continue the task with your next tool call, or answer if it is done."))
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
# A retry nudge is marked, so the spent one is found by the mark
|
|
@@ -188,6 +188,16 @@ module Samagotchi
|
|
|
188
188
|
list
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
+
# A copy of +messages+ without the note at its tail when that note is a
|
|
192
|
+
# retry nudge (anything else stays: an earlier turn's note is what says
|
|
193
|
+
# why that turn ended).
|
|
194
|
+
def without_trailing_nudge(messages)
|
|
195
|
+
list = Array(messages).dup
|
|
196
|
+
index = trailing_index(list)
|
|
197
|
+
list.delete_at(index) if index && retry_nudge?(list[index])
|
|
198
|
+
list
|
|
199
|
+
end
|
|
200
|
+
|
|
191
201
|
# The index of the note at the tail of +list+ (behind context notes at
|
|
192
202
|
# most), or nil.
|
|
193
203
|
def trailing_index(list)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -66,8 +66,10 @@ module Samagotchi
|
|
|
66
66
|
|
|
67
67
|
# Which images of a conversation one request sends. Every image that is
|
|
68
68
|
# not sent becomes a placeholder line: all of them when the model can't
|
|
69
|
-
# see images, the
|
|
70
|
-
#
|
|
69
|
+
# see images, the oldest ones past limits.max_per_request, and any whose
|
|
70
|
+
# file is gone. Past the limit the oldest images are dropped in batches
|
|
71
|
+
# (ImagePlan.dropped_count), so the cut stays put for a few new images
|
|
72
|
+
# and the history before it stays a cacheable prefix.
|
|
71
73
|
class ImagePlan
|
|
72
74
|
Item = Data.define(:ref, :data, :placeholder) do
|
|
73
75
|
def sent? = !data.nil?
|
|
@@ -92,7 +94,18 @@ module Samagotchi
|
|
|
92
94
|
Array(entry[:images]).each_index { |position| occurrences << [index, position] }
|
|
93
95
|
end
|
|
94
96
|
@limit = vision&.limits&.max_per_request || ImageStore::Limits.from_config.max_per_request
|
|
95
|
-
@sent = occurrences.
|
|
97
|
+
@sent = occurrences.drop(self.class.dropped_count(occurrences.size, @limit))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# How many of +total+ images (oldest first) a request leaves out with
|
|
101
|
+
# +limit+ per request: none up to the limit, then whole steps of
|
|
102
|
+
# limit/2, so it's a pure function of the count. With limit 20, 21..30
|
|
103
|
+
# images drop the oldest 10, 31..40 the oldest 20.
|
|
104
|
+
def self.dropped_count(total, limit)
|
|
105
|
+
return 0 if total <= limit
|
|
106
|
+
|
|
107
|
+
step = [limit / 2, 1].max
|
|
108
|
+
(((total - limit) + step - 1) / step) * step
|
|
96
109
|
end
|
|
97
110
|
|
|
98
111
|
# The Items for conversation[+index+] (none when it has no images).
|
|
@@ -120,7 +133,7 @@ module Samagotchi
|
|
|
120
133
|
def skip_reason(index, position)
|
|
121
134
|
return "no session images here" if @vision.nil?
|
|
122
135
|
return CANT_SEE unless @vision.sendable?
|
|
123
|
-
return "
|
|
136
|
+
return "an older image (chi sends up to the newest #{@limit})" unless @sent.include?([index, position])
|
|
124
137
|
|
|
125
138
|
nil
|
|
126
139
|
end
|
|
@@ -39,7 +39,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
|
39
39
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
40
40
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
41
41
|
import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
|
|
42
|
-
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
42
|
+
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
43
43
|
import { createNotifications } from "./notifications.js";
|
|
44
44
|
import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
|
|
45
45
|
import {
|
|
@@ -328,8 +328,12 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
328
328
|
const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
|
|
329
329
|
const waiting = waitingBadge(s);
|
|
330
330
|
const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
|
|
331
|
+
// A turn a hook stopped wears its own badge, unless the session waits on
|
|
332
|
+
// the user (the waiting badge wins: the stop is behind the need).
|
|
333
|
+
const stopped = waiting ? null : stoppedBadge(s);
|
|
334
|
+
const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
|
|
331
335
|
const dotWord = waiting ? waiting.title : s.status;
|
|
332
|
-
return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
|
|
336
|
+
return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
// A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
|
|
@@ -1483,6 +1487,10 @@ function clearTimingInterval() {
|
|
|
1483
1487
|
}
|
|
1484
1488
|
|
|
1485
1489
|
let liveTurnNumber = null;
|
|
1490
|
+
// The live line sits in the stage's status row, which already says the turn
|
|
1491
|
+
// runs ("running a tool"): it drops its own "running" there. It never moves
|
|
1492
|
+
// mid-turn (finishLiveTurnTiming rewrites it before any hand-off).
|
|
1493
|
+
let liveTurnTimingInStage = false;
|
|
1486
1494
|
|
|
1487
1495
|
function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
|
|
1488
1496
|
clearTimingInterval();
|
|
@@ -1495,7 +1503,9 @@ function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null
|
|
|
1495
1503
|
liveTurnTimingEl.className = "turn-timing live";
|
|
1496
1504
|
// The stage's status row while it holds the turn: the line moves into the
|
|
1497
1505
|
// history with it.
|
|
1498
|
-
|
|
1506
|
+
const stageHost = turnDom.timingHost?.() || null;
|
|
1507
|
+
liveTurnTimingInStage = !!stageHost;
|
|
1508
|
+
(stageHost || historyEl).appendChild(liveTurnTimingEl);
|
|
1499
1509
|
updateLiveTiming();
|
|
1500
1510
|
timingInterval = setInterval(updateLiveTiming, 1000);
|
|
1501
1511
|
}
|
|
@@ -1509,7 +1519,7 @@ function appendToHistory(el) {
|
|
|
1509
1519
|
function updateLiveTiming() {
|
|
1510
1520
|
const duration = elapsedSince(current.timing.activeTurn?.started_at);
|
|
1511
1521
|
if (liveTurnTimingEl && duration !== null) {
|
|
1512
|
-
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running:
|
|
1522
|
+
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: !liveTurnTimingInStage }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
|
|
1513
1523
|
}
|
|
1514
1524
|
updateInfoBar();
|
|
1515
1525
|
}
|