samagotchi 0.26.0 → 0.28.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 +55 -1
- data/docs/architecture.md +6 -0
- data/docs/cli.md +6 -3
- data/docs/configuration.md +17 -3
- data/docs/hooks.md +1 -1
- data/docs/internals/prompt-caching.md +38 -7
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
- 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 +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +40 -4
- data/lib/samagotchi/llm/prompt_cache.rb +31 -5
- data/lib/samagotchi/log.rb +19 -3
- data/lib/samagotchi/session.rb +9 -0
- 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/thinking_tails.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +3 -2
- data/lib/samagotchi/tool_view.rb +14 -3
- data/lib/samagotchi/tools/task_get.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +49 -12
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +22 -10
- data/lib/samagotchi/turn_note.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_context.rb +17 -4
- data/lib/samagotchi/web/app.rb +25 -0
- data/lib/samagotchi/web/markdown_renderer.rb +21 -6
- data/lib/samagotchi/web/public/app.js +4 -0
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +19 -0
- data/lib/samagotchi/web/public/stage_model.js +8 -2
- data/lib/samagotchi/web/public/stage_view.js +14 -2
- data/lib/samagotchi/web/public/task_stop.js +124 -0
- data/lib/samagotchi/web/public/turn_view.js +24 -1
- metadata +2 -1
|
@@ -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
|
|
data/lib/samagotchi/log.rb
CHANGED
|
@@ -23,6 +23,8 @@ module Samagotchi
|
|
|
23
23
|
# (once, until Config.reload!). While that runs (Config can warn itself)
|
|
24
24
|
# records only echo. Nothing here raises into the caller: a record that
|
|
25
25
|
# can't be formatted is dropped, an unwritable file pauses (DebugLog).
|
|
26
|
+
# The one exception is strict mode (SAMAGOTCHI_LOG_STRICT=1, the spec
|
|
27
|
+
# suite): an unknown tag or a malformed event raises, whatever the level.
|
|
26
28
|
module Log
|
|
27
29
|
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
28
30
|
DEFAULT_LEVEL = :info
|
|
@@ -39,6 +41,7 @@ module Samagotchi
|
|
|
39
41
|
@state = nil
|
|
40
42
|
@resolving = false
|
|
41
43
|
@session_id = nil
|
|
44
|
+
@strict = ENV["SAMAGOTCHI_LOG_STRICT"] == "1"
|
|
42
45
|
|
|
43
46
|
class << self
|
|
44
47
|
# @param path [String, nil, :auto] the file; :auto → LogPath.resolve
|
|
@@ -72,6 +75,9 @@ module Samagotchi
|
|
|
72
75
|
# carry its first SID_LENGTH chars unless they pass their own sid:.
|
|
73
76
|
attr_accessor :session_id
|
|
74
77
|
|
|
78
|
+
# Raise on an unknown tag or a bad event instead of dropping the record.
|
|
79
|
+
attr_accessor :strict
|
|
80
|
+
|
|
75
81
|
def debug(tag, event, **kw) = log(:debug, tag, event, **kw)
|
|
76
82
|
def info(tag, event, **kw) = log(:info, tag, event, **kw)
|
|
77
83
|
def warn(tag, event, **kw) = log(:warn, tag, event, **kw)
|
|
@@ -96,7 +102,19 @@ module Samagotchi
|
|
|
96
102
|
resolved_state&.dig(:writer)&.path
|
|
97
103
|
end
|
|
98
104
|
|
|
99
|
-
def log(level, tag, event,
|
|
105
|
+
def log(level, tag, event, **kw)
|
|
106
|
+
check_names!(tag, event) if strict
|
|
107
|
+
write_record(level, tag, event, **kw)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def check_names!(tag, event)
|
|
113
|
+
raise ArgumentError, "unknown log tag #{tag}" unless LogLine::TAGS.include?(tag.to_s)
|
|
114
|
+
raise ArgumentError, "bad log event #{event}" unless event.to_s.match?(LogLine::SLUG)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def write_record(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
|
|
100
118
|
options = @options
|
|
101
119
|
echo_to_stderr(echo) if echo && options.fetch(:stderr, true)
|
|
102
120
|
state = resolved_state
|
|
@@ -113,8 +131,6 @@ module Samagotchi
|
|
|
113
131
|
nil
|
|
114
132
|
end
|
|
115
133
|
|
|
116
|
-
private
|
|
117
|
-
|
|
118
134
|
def build(level, tag, event, payload:, sid:, fields:)
|
|
119
135
|
tag = tag.to_s
|
|
120
136
|
event = event.to_s
|
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
|
|
@@ -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.)
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
class ThinkingTails
|
|
24
24
|
FILE = "thinking_tails.jsonl"
|
|
25
25
|
# The last chars of a generation's thinking kept: a loop's cycle and
|
|
26
|
-
# some of its lead-in.
|
|
26
|
+
# some of its lead-in. TurnNote cuts an empty step's thinking to it too.
|
|
27
27
|
TAIL_CHARS = 20_000
|
|
28
28
|
MAX_RECORDS = 10
|
|
29
29
|
|
|
@@ -150,12 +150,13 @@ module Samagotchi
|
|
|
150
150
|
# "error" for an "Error:" result, and for an execute whose command
|
|
151
151
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
152
152
|
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
153
|
-
# task_wait the user's Stop ended
|
|
153
|
+
# task_wait the user's Stop ended (the task itself runs on), and for one
|
|
154
|
+
# whose task was stopped (the stop-task button, or the model's task_stop).
|
|
154
155
|
def tool_activity_status(result, tool_name = nil)
|
|
155
156
|
text = result.to_s
|
|
156
157
|
return "error" if text.start_with?("Error:")
|
|
157
158
|
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
158
|
-
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
|
|
159
|
+
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
|
|
159
160
|
|
|
160
161
|
"ok"
|
|
161
162
|
end
|
data/lib/samagotchi/tool_view.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "command_steps"
|
|
4
4
|
require_relative "tools/execute"
|
|
5
5
|
require_relative "tools/task_create"
|
|
6
|
+
require_relative "tools/task_wait"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# What a richer UI shows of a tool call beyond its one-line params: the
|
|
@@ -18,8 +19,9 @@ module Samagotchi
|
|
|
18
19
|
# can't (the fallback: the UI shows the command itself) or the command
|
|
19
20
|
# is cut. +description+: what the model said the command does (execute's
|
|
20
21
|
# optional description), whitespace collapsed, a trailing period gone;
|
|
21
|
-
# nil when it gave none.
|
|
22
|
-
|
|
22
|
+
# nil when it gave none. +task_id+: the task a task_wait waits on (its
|
|
23
|
+
# only field), for the web's stop-task button.
|
|
24
|
+
ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description, :task_id) do
|
|
23
25
|
def to_h = super.merge(steps: steps&.map(&:to_h)).compact.reject { |_key, value| value == false }
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -37,6 +39,7 @@ module Samagotchi
|
|
|
37
39
|
# @return [ToolView, nil] nil for a tool without a view and for an
|
|
38
40
|
# empty command
|
|
39
41
|
def self.for(tool_name, call)
|
|
42
|
+
return task_wait(call) if tool_name == Tools::TaskWait::NAME && call.is_a?(Hash)
|
|
40
43
|
return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
|
|
41
44
|
|
|
42
45
|
command = call[:content].to_s
|
|
@@ -47,9 +50,17 @@ module Samagotchi
|
|
|
47
50
|
parsed = truncated ? nil : CommandSteps.parse(command)
|
|
48
51
|
new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
|
|
49
52
|
truncated: truncated, chars: truncated ? command.length : nil, cd: parsed&.cd, steps: parsed&.steps,
|
|
50
|
-
description: description(call))
|
|
53
|
+
description: description(call), task_id: nil)
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
def self.task_wait(call)
|
|
57
|
+
task_id = call[:content].to_s.strip
|
|
58
|
+
return nil if task_id.empty?
|
|
59
|
+
|
|
60
|
+
new(command: nil, cwd: nil, truncated: false, chars: nil, cd: nil, steps: nil, description: nil, task_id: task_id)
|
|
61
|
+
end
|
|
62
|
+
private_class_method :task_wait
|
|
63
|
+
|
|
53
64
|
# The call's description as a view shows it, nil for none.
|
|
54
65
|
def self.description(call)
|
|
55
66
|
text = call[:description]
|
|
@@ -30,8 +30,9 @@ module Samagotchi
|
|
|
30
30
|
"finished_at: #{record["finished_at"]}",
|
|
31
31
|
"exit_code: #{record["exit_code"]}",
|
|
32
32
|
"stop_reason: #{record["stop_reason"]}",
|
|
33
|
-
"output_path: #{record.fetch("output_path")}"
|
|
34
|
-
|
|
33
|
+
"output_path: #{record.fetch("output_path")}",
|
|
34
|
+
(TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
|
|
35
|
+
].compact.join("\n")
|
|
35
36
|
end
|
|
36
37
|
private_class_method :format_record
|
|
37
38
|
end
|
|
@@ -22,6 +22,9 @@ module Samagotchi
|
|
|
22
22
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
23
23
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
24
24
|
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
25
|
+
# task_wait and task_get add it to a task the user stopped (the web's
|
|
26
|
+
# stop-task button), so the model doesn't rerun the job.
|
|
27
|
+
USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
|
|
25
28
|
|
|
26
29
|
module_function
|
|
27
30
|
|
|
@@ -107,19 +110,27 @@ module Samagotchi
|
|
|
107
110
|
# A native tool_response joins its calls' results with "---".
|
|
108
111
|
# @return [Array<Hash>] {id:, command:}, oldest first
|
|
109
112
|
def running_created_in(messages)
|
|
110
|
-
|
|
113
|
+
created_ids_in(messages).filter_map do |id|
|
|
114
|
+
record, _error = get_record(id)
|
|
115
|
+
{ id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The ids of every task this conversation's task_create calls started,
|
|
120
|
+
# whatever their status now, oldest first.
|
|
121
|
+
# @return [Array<String>]
|
|
122
|
+
def created_ids_in(messages)
|
|
123
|
+
Array(messages).flat_map do |message|
|
|
111
124
|
next [] unless (message[:role] || message["role"]).to_s == "tool_response"
|
|
112
125
|
|
|
113
126
|
(message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
|
|
114
127
|
block[/\A\[task_create\]\ntask_id: (\S+)/, 1]
|
|
115
128
|
end
|
|
116
|
-
end
|
|
117
|
-
ids.uniq.filter_map do |id|
|
|
118
|
-
record, _error = get_record(id)
|
|
119
|
-
{ id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
|
|
120
|
-
end
|
|
129
|
+
end.uniq
|
|
121
130
|
end
|
|
122
131
|
|
|
132
|
+
def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
|
|
133
|
+
|
|
123
134
|
def get_record(task_id)
|
|
124
135
|
record = load_record(task_id)
|
|
125
136
|
return [nil, "Error: task not found: #{task_id}"] unless record
|
|
@@ -142,13 +153,19 @@ module Samagotchi
|
|
|
142
153
|
content.lines.last(line_count).join
|
|
143
154
|
end
|
|
144
155
|
|
|
145
|
-
|
|
156
|
+
# A requested stop ends as "stopped" whatever the exit code: the flag
|
|
157
|
+
# is on disk before the TERM, so a reader refreshing the record while
|
|
158
|
+
# the group dies records the stop, not a failure.
|
|
159
|
+
# @param by [String] "model" (task_stop) or "user" (the web's button)
|
|
160
|
+
def stop_task(task_id, by:)
|
|
146
161
|
record = load_record(task_id)
|
|
147
162
|
return [nil, "Error: task not found: #{task_id}"] unless record
|
|
148
163
|
|
|
149
164
|
refreshed = refresh_record(record)
|
|
150
165
|
return [refreshed, nil] unless refreshed["status"] == "running"
|
|
151
166
|
|
|
167
|
+
refreshed["stop_requested_by"] = by.to_s
|
|
168
|
+
write_record(refreshed)
|
|
152
169
|
pid = refreshed["pid"].to_i
|
|
153
170
|
|
|
154
171
|
begin
|
|
@@ -168,10 +185,7 @@ module Samagotchi
|
|
|
168
185
|
end
|
|
169
186
|
end
|
|
170
187
|
|
|
171
|
-
updated = refresh_record(refreshed)
|
|
172
|
-
updated["status"] = "stopped" if updated["status"] == "running"
|
|
173
|
-
updated["finished_at"] ||= timestamp
|
|
174
|
-
updated["stop_reason"] = "stopped_by_user"
|
|
188
|
+
updated = mark_stopped(refresh_record(refreshed))
|
|
175
189
|
write_record(updated)
|
|
176
190
|
|
|
177
191
|
[updated, nil]
|
|
@@ -294,9 +308,15 @@ module Samagotchi
|
|
|
294
308
|
end
|
|
295
309
|
|
|
296
310
|
def mark_finished_without_exit_code(record)
|
|
311
|
+
record = current_record(record)
|
|
312
|
+
return record unless record["status"] == "running"
|
|
313
|
+
|
|
297
314
|
exit_code = read_exit_code(record["exit_code_path"])
|
|
298
315
|
|
|
299
|
-
if
|
|
316
|
+
if record["stop_requested_by"]
|
|
317
|
+
record["exit_code"] = exit_code
|
|
318
|
+
mark_stopped(record)
|
|
319
|
+
elsif exit_code.nil?
|
|
300
320
|
record["status"] = "failed"
|
|
301
321
|
record["exit_code"] = nil
|
|
302
322
|
record["stop_reason"] ||= "process_ended_without_exit_code"
|
|
@@ -313,6 +333,23 @@ module Samagotchi
|
|
|
313
333
|
record
|
|
314
334
|
end
|
|
315
335
|
|
|
336
|
+
# The disk copy when it is newer than +record+: already finished, or
|
|
337
|
+
# flagged by a stop that started after +record+ was read.
|
|
338
|
+
def current_record(record)
|
|
339
|
+
disk = load_record(record.fetch("id"))
|
|
340
|
+
return record unless disk
|
|
341
|
+
return disk if disk["status"] != "running" || disk["stop_requested_by"]
|
|
342
|
+
|
|
343
|
+
record
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def mark_stopped(record)
|
|
347
|
+
record["status"] = "stopped"
|
|
348
|
+
record["stop_reason"] = "stopped_by_#{record["stop_requested_by"]}"
|
|
349
|
+
record["finished_at"] ||= timestamp
|
|
350
|
+
record
|
|
351
|
+
end
|
|
352
|
+
|
|
316
353
|
def read_exit_code(path)
|
|
317
354
|
return nil if path.to_s.empty? || !File.exist?(path)
|
|
318
355
|
|
|
@@ -37,10 +37,10 @@ module Samagotchi
|
|
|
37
37
|
return "Error: #{error}" if error
|
|
38
38
|
|
|
39
39
|
status = record.fetch("status")
|
|
40
|
-
return
|
|
40
|
+
return finished_response(record, tail_lines) unless status == "running"
|
|
41
41
|
|
|
42
42
|
if completion_pattern
|
|
43
|
-
output_tail =
|
|
43
|
+
output_tail = output_tail(record, tail_lines)
|
|
44
44
|
return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -53,10 +53,9 @@ module Samagotchi
|
|
|
53
53
|
|
|
54
54
|
# Timeout reached — do one final refresh to capture the latest status
|
|
55
55
|
refreshed, = TaskRuntime.get_record(task_id)
|
|
56
|
-
return
|
|
56
|
+
return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
format_response(refreshed, wait_result: "timeout", output_tail: output_tail)
|
|
58
|
+
format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines))
|
|
60
59
|
rescue StandardError => e
|
|
61
60
|
"Error: #{e.message}"
|
|
62
61
|
end
|
|
@@ -66,18 +65,31 @@ module Samagotchi
|
|
|
66
65
|
def self.cancelled_response(task_id, tail_lines, started)
|
|
67
66
|
record, error = TaskRuntime.get_record(task_id)
|
|
68
67
|
return "Error: #{error}" if error
|
|
69
|
-
return
|
|
68
|
+
return finished_response(record, tail_lines) unless record.fetch("status") == "running"
|
|
70
69
|
|
|
71
|
-
output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
|
|
72
70
|
note = [
|
|
73
71
|
"note: the user stopped the turn while waiting; the task is STILL RUNNING.",
|
|
74
72
|
" To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
|
|
75
73
|
"waited: #{(monotonic_time - started).round}s"
|
|
76
74
|
]
|
|
77
|
-
format_response(record, wait_result: "canceled", output_tail: output_tail, extra: note)
|
|
75
|
+
format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), extra: note)
|
|
78
76
|
end
|
|
79
77
|
private_class_method :cancelled_response
|
|
80
78
|
|
|
79
|
+
# A task the user stopped also says so, with the tail of its output:
|
|
80
|
+
# the model sees where the job was cut off.
|
|
81
|
+
def self.finished_response(record, tail_lines)
|
|
82
|
+
return format_response(record) unless TaskRuntime.stopped_by_user?(record)
|
|
83
|
+
|
|
84
|
+
format_response(record, output_tail: output_tail(record, tail_lines), extra: [TaskRuntime::USER_STOP_NOTE])
|
|
85
|
+
end
|
|
86
|
+
private_class_method :finished_response
|
|
87
|
+
|
|
88
|
+
def self.output_tail(record, tail_lines)
|
|
89
|
+
TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
|
|
90
|
+
end
|
|
91
|
+
private_class_method :output_tail
|
|
92
|
+
|
|
81
93
|
def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
|
|
82
94
|
lines = [
|
|
83
95
|
"task_id: #{record.fetch("id")}",
|
|
@@ -86,9 +98,9 @@ module Samagotchi
|
|
|
86
98
|
"stop_reason: #{record["stop_reason"]}",
|
|
87
99
|
"output_path: #{record.fetch("output_path")}"
|
|
88
100
|
]
|
|
89
|
-
return lines.join("\n") unless
|
|
101
|
+
return lines.join("\n") unless output_tail
|
|
90
102
|
|
|
91
|
-
lines << "wait_result: #{wait_result}"
|
|
103
|
+
lines << "wait_result: #{wait_result}" if wait_result
|
|
92
104
|
lines.concat(extra)
|
|
93
105
|
lines << "output_tail:"
|
|
94
106
|
lines << output_tail.rstrip unless output_tail.empty?
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "model_profile"
|
|
4
|
+
require_relative "thinking_tails"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# The one-line system note a turn leaves in the conversation when it ends
|
|
@@ -23,7 +24,8 @@ module Samagotchi
|
|
|
23
24
|
EMPTY_ANSWER = :empty_answer
|
|
24
25
|
# An empty step's thinking kept on the marker: its tail, so a model
|
|
25
26
|
# that thought for minutes doesn't put megabytes in the session file.
|
|
26
|
-
|
|
27
|
+
# The same cap as ThinkingTails' saved tails.
|
|
28
|
+
STEP_CHARS = ThinkingTails::TAIL_CHARS
|
|
27
29
|
# A step's native thinking starts with one of these; the cut keeps it,
|
|
28
30
|
# so the UIs still find the thinking (MessageParts).
|
|
29
31
|
THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
|
|
@@ -141,7 +143,7 @@ module Samagotchi
|
|
|
141
143
|
def cut_retry(by, reason)
|
|
142
144
|
why = reason.to_s.strip.empty? ? "" : ": #{one_line(reason)}"
|
|
143
145
|
retry_nudge(message("your last reply was cut off by #{by || "a plugin"}#{why}. Don't start the same reasoning " \
|
|
144
|
-
"again;
|
|
146
|
+
"again; continue the task with your next tool call, or answer if it is done."))
|
|
145
147
|
end
|
|
146
148
|
|
|
147
149
|
# A retry nudge is marked, so the spent one is found by the mark
|
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
|