samagotchi 0.24.0 → 0.25.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 +54 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +4 -3
- data/docs/configuration.md +21 -13
- data/docs/hooks.md +1 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/prompt-caching.md +94 -0
- data/docs/plugins.md +16 -2
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +5 -2
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +23 -4
- data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/kernel_loop.rb +21 -3
- data/lib/samagotchi/llm/chat_loop.rb +17 -3
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/openai_chat.rb +5 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
- data/lib/samagotchi/thinking_tails.rb +103 -0
- data/lib/samagotchi/token_usage.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +37 -3
- data/lib/samagotchi/tool_declarations.rb +14 -0
- data/lib/samagotchi/tool_view.rb +40 -4
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/execute.rb +2 -1
- data/lib/samagotchi/tools/registry.rb +11 -4
- data/lib/samagotchi/tools/shell.rb +32 -0
- data/lib/samagotchi/tools/task_runtime.rb +3 -3
- data/lib/samagotchi/turn_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/command_view.js +71 -3
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +25 -0
- data/lib/samagotchi/web/public/stage_model.js +5 -1
- data/lib/samagotchi/web/public/stage_view.js +1 -1
- data/lib/samagotchi/web/public/turn_view.js +3 -3
- metadata +5 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "atomic_file"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# The thinking a session would otherwise lose: a SessionObserver
|
|
10
|
+
# subscriber (next to SessionMetrics) that keeps the tail of a generation
|
|
11
|
+
# cut mid-stream (a plugin's stop_generation, loop-guard's cut, a steer's
|
|
12
|
+
# cut), stopped with its turn by a plugin (stop_turn), or ended at the
|
|
13
|
+
# provider's output cap (finish_reason length). A cut generation's
|
|
14
|
+
# thinking is dropped from the conversation, and a looping one is what a
|
|
15
|
+
# fixture needs.
|
|
16
|
+
#
|
|
17
|
+
# The tails go to <session dir>/thinking_tails.jsonl, one JSON record a
|
|
18
|
+
# line, the newest MAX_RECORDS kept: next to the session's analytics.json,
|
|
19
|
+
# so an archived session (and a copied session dir) keeps them. Not in the
|
|
20
|
+
# session's messages (nothing there can reach the model, hooks or the
|
|
21
|
+
# web's reload by accident) and not in the turn records (the live snapshot
|
|
22
|
+
# carries those on every /state frame).
|
|
23
|
+
class ThinkingTails
|
|
24
|
+
FILE = "thinking_tails.jsonl"
|
|
25
|
+
# The last chars of a generation's thinking kept: a loop's cycle and
|
|
26
|
+
# some of its lead-in.
|
|
27
|
+
TAIL_CHARS = 20_000
|
|
28
|
+
MAX_RECORDS = 10
|
|
29
|
+
|
|
30
|
+
# @param session_dir [#call] the current session's directory, or nil
|
|
31
|
+
# (no session yet: nothing is written)
|
|
32
|
+
def initialize(session_dir:, clock: -> { Time.now })
|
|
33
|
+
@session_dir = session_dir
|
|
34
|
+
@clock = clock
|
|
35
|
+
@turn_id = nil
|
|
36
|
+
reset
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The saved records of +dir+, oldest first (empty without a file).
|
|
40
|
+
# @return [Array<Hash>] string-keyed
|
|
41
|
+
def self.read(dir)
|
|
42
|
+
File.readlines(File.join(dir, FILE), chomp: true).filter_map do |line|
|
|
43
|
+
JSON.parse(line)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
rescue SystemCallError
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(event)
|
|
52
|
+
case event[:type]
|
|
53
|
+
when :turn_started then @turn_id = event[:turn_id]
|
|
54
|
+
when :generation_started, :generation_retrying then reset
|
|
55
|
+
when :generation_chunk then add(event[:thinking].to_s)
|
|
56
|
+
when :generation_completed then completed(event)
|
|
57
|
+
when :generation_cancelled
|
|
58
|
+
# A plugin's stop_turn mid-generation; a user's Stop is not kept.
|
|
59
|
+
keep(event, finish_reason: "cancelled", stopped_by: "hook") if event[:reason].to_s == "hook"
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def reset
|
|
68
|
+
@tail = +""
|
|
69
|
+
@chars = 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add(thinking)
|
|
73
|
+
return if thinking.empty?
|
|
74
|
+
|
|
75
|
+
@chars += thinking.length
|
|
76
|
+
@tail << thinking
|
|
77
|
+
@tail = @tail[-TAIL_CHARS..] if @tail.length > 2 * TAIL_CHARS
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def completed(event)
|
|
81
|
+
stopped_by = event[:stopped_by]
|
|
82
|
+
return reset unless stopped_by || event[:finish_reason].to_s == "length"
|
|
83
|
+
|
|
84
|
+
keep(event, finish_reason: event[:finish_reason].to_s, stopped_by: stopped_by&.to_s, stop_reason: event[:stop_reason])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Writes the record and forgets the generation (the native loop's cut
|
|
88
|
+
# with no retry left reports :generation_cancelled after it too).
|
|
89
|
+
def keep(event, **fields)
|
|
90
|
+
return reset if @chars.zero?
|
|
91
|
+
|
|
92
|
+
record = { at: @clock.call.utc.iso8601(3), turn_id: @turn_id, iteration: event[:iteration], **fields,
|
|
93
|
+
thinking_chars: @chars, tail: @tail.length > TAIL_CHARS ? @tail[-TAIL_CHARS..] : @tail }.compact
|
|
94
|
+
reset
|
|
95
|
+
dir = @session_dir.call
|
|
96
|
+
return unless dir
|
|
97
|
+
|
|
98
|
+
FileUtils.mkdir_p(dir)
|
|
99
|
+
lines = self.class.read(dir).last(MAX_RECORDS - 1).map { |kept| JSON.generate(kept) }
|
|
100
|
+
AtomicFile.write(File.join(dir, FILE), (lines + [JSON.generate(record)]).join("\n") << "\n")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -12,15 +12,16 @@ module Samagotchi
|
|
|
12
12
|
# The last chunk can say more: llama.cpp adds a `timings` block with exact
|
|
13
13
|
# speeds and `cache_n` (its native `tokens_cached` counts the new tokens too,
|
|
14
14
|
# so it is not read); OpenRouter adds `usage.cost` (USD) and the
|
|
15
|
-
# `prompt_tokens_details.cached_tokens` / `
|
|
16
|
-
#
|
|
15
|
+
# `prompt_tokens_details.cached_tokens` / `cache_write_tokens` /
|
|
16
|
+
# `completion_tokens_details.reasoning_tokens` breakdowns (cache writes:
|
|
17
|
+
# Anthropic models, prompt caching's first request). Those fields are nil when a payload doesn't carry them.
|
|
17
18
|
#
|
|
18
19
|
# from_payload returns nil when no server token counts are present, so a
|
|
19
20
|
# caller can fall back to a chars/4 heuristic. The reported values are
|
|
20
21
|
# cumulative per request, so callers should take the running MAX across
|
|
21
22
|
# chunks (see SessionMetrics).
|
|
22
|
-
class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :
|
|
23
|
-
:predicted_per_second, :prompt_per_second, :predicted_ms, :source)
|
|
23
|
+
class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :cache_write_tokens,
|
|
24
|
+
:reasoning_tokens, :cost, :predicted_per_second, :prompt_per_second, :predicted_ms, :source)
|
|
24
25
|
CHARS_PER_TOKEN = 4.0
|
|
25
26
|
|
|
26
27
|
class << self
|
|
@@ -41,11 +42,12 @@ module Samagotchi
|
|
|
41
42
|
)
|
|
42
43
|
return nil unless prompt_tokens || completion_tokens
|
|
43
44
|
|
|
45
|
+
details = hash_at(usage, :prompt_tokens_details)
|
|
44
46
|
new(
|
|
45
47
|
prompt_tokens: prompt_tokens,
|
|
46
48
|
completion_tokens: completion_tokens,
|
|
47
|
-
cached_tokens: first_positive(*both(
|
|
48
|
-
|
|
49
|
+
cached_tokens: first_positive(*both(details, :cached_tokens), *both(timings, :cache_n)),
|
|
50
|
+
cache_write_tokens: first_positive(*both(details, :cache_write_tokens)),
|
|
49
51
|
reasoning_tokens: first_positive(*both(hash_at(usage, :completion_tokens_details), :reasoning_tokens)),
|
|
50
52
|
cost: number(*both(usage, :cost)),
|
|
51
53
|
predicted_per_second: positive_number(*both(timings, :predicted_per_second)),
|
|
@@ -55,6 +57,16 @@ module Samagotchi
|
|
|
55
57
|
)
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
# A request's prompt-cache counts as a :generation_completed event
|
|
61
|
+
# carries them (the log line too): the prompt, the part of it the
|
|
62
|
+
# server reused from its cache, and the part it wrote to it (nil when
|
|
63
|
+
# none: only some remote servers report writes).
|
|
64
|
+
# @return [Hash] {prompt_tokens:, cached_tokens:, cache_write_tokens:}
|
|
65
|
+
def cache_fields(prompt:, cached:, cache_write:)
|
|
66
|
+
{ prompt_tokens: prompt.to_i, cached_tokens: cached.to_i,
|
|
67
|
+
cache_write_tokens: cache_write.to_i.positive? ? cache_write.to_i : nil }
|
|
68
|
+
end
|
|
69
|
+
|
|
58
70
|
# Estimate token count from raw text length using the chars/4 heuristic.
|
|
59
71
|
# @param text [String]
|
|
60
72
|
# @return [Integer]
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "log"
|
|
5
|
+
require_relative "command_steps"
|
|
6
|
+
require_relative "tool_view"
|
|
5
7
|
require_relative "tools/execute"
|
|
6
8
|
require_relative "tools/read"
|
|
7
9
|
require_relative "tools/write"
|
|
@@ -42,15 +44,34 @@ module Samagotchi
|
|
|
42
44
|
}
|
|
43
45
|
title = tool_title(tool_name, call, cwd: cwd)
|
|
44
46
|
event[:title] = title if title
|
|
47
|
+
# The model's own words for a command, which the TUI's tool line
|
|
48
|
+
# shows in place of the cut params.
|
|
49
|
+
description = command_description(tool_name, call)
|
|
50
|
+
event[:description] = description if description
|
|
45
51
|
event
|
|
46
52
|
end
|
|
47
53
|
|
|
54
|
+
# An execute's description, cut as a row's title; nil for the rest.
|
|
55
|
+
def command_description(tool_name, call)
|
|
56
|
+
return nil unless call.is_a?(Hash) && ToolView::TOOLS.include?(tool_name)
|
|
57
|
+
|
|
58
|
+
ToolView.description_title(call)
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
# What the call did, for a web row's one line ("edit lib/a.rb", the
|
|
49
62
|
# command without its "cd … &&"): a file tool's path relative to +cwd+
|
|
50
|
-
# when it is under it, a command's
|
|
51
|
-
#
|
|
52
|
-
#
|
|
63
|
+
# when it is under it, a command's description (the model's few words),
|
|
64
|
+
# 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
|
|
67
|
+
# params). +params+ stays the full key=value line (the TUI, the
|
|
68
|
+
# guardrails).
|
|
53
69
|
def tool_title(tool_name, call, cwd: nil)
|
|
70
|
+
if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
|
|
71
|
+
title = command_description(tool_name, call) || steps_title(call[:content])
|
|
72
|
+
return title if title
|
|
73
|
+
end
|
|
74
|
+
|
|
54
75
|
text = case tool_name
|
|
55
76
|
when Tools::Read::NAME, Tools::MemoryRead::NAME then call[:content]
|
|
56
77
|
when Tools::Write::NAME, Tools::Edit::NAME, Tools::MemoryWrite::NAME then call[:path]
|
|
@@ -73,6 +94,19 @@ module Samagotchi
|
|
|
73
94
|
# eats the line.
|
|
74
95
|
LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
|
|
75
96
|
|
|
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)
|
|
100
|
+
steps = CommandSteps.parse(command)&.steps
|
|
101
|
+
return nil unless steps
|
|
102
|
+
|
|
103
|
+
first = steps.first.text.gsub(/\s+/, " ")
|
|
104
|
+
more = steps.size > 1 ? " +#{steps.size - 1}" : ""
|
|
105
|
+
room = TOOL_ACTIVITY_PREVIEW_LIMIT - more.length
|
|
106
|
+
first = "#{first[0, room - 1]}…" if first.length > room
|
|
107
|
+
"#{first}#{more}"
|
|
108
|
+
end
|
|
109
|
+
|
|
76
110
|
def command_title(command)
|
|
77
111
|
line = command.to_s.lines.map(&:strip).find { |l| !l.empty? }.to_s
|
|
78
112
|
line.sub(LEADING_CD, "")
|
|
@@ -26,6 +26,10 @@ module Samagotchi
|
|
|
26
26
|
type: "string",
|
|
27
27
|
description: "The shell command to run"
|
|
28
28
|
},
|
|
29
|
+
description: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Optional: what this command does, in a few words (what, not why)"
|
|
32
|
+
},
|
|
29
33
|
cwd: {
|
|
30
34
|
type: "string",
|
|
31
35
|
description: "Optional working directory to run in (defaults to the project root). Relative paths are resolved against the project root."
|
|
@@ -425,6 +429,16 @@ module Samagotchi
|
|
|
425
429
|
|
|
426
430
|
module_function
|
|
427
431
|
|
|
432
|
+
# +schema+ without execute's description property: what a session
|
|
433
|
+
# with execute.description off declares (the parameter isn't offered
|
|
434
|
+
# at all). Any other schema as given.
|
|
435
|
+
def without_command_description(schema)
|
|
436
|
+
return schema unless schema[:name] == "execute"
|
|
437
|
+
|
|
438
|
+
parameters = schema[:parameters]
|
|
439
|
+
schema.merge(parameters: parameters.merge(properties: parameters[:properties].except(:description)))
|
|
440
|
+
end
|
|
441
|
+
|
|
428
442
|
# Gemma 4 <|tool>declaration:NAME{…}<tool|> blocks for every tool, back
|
|
429
443
|
# to back, as Gemma 4's chat template declares tools.
|
|
430
444
|
# @param schemas [Array<Hash>] a Tools::Registry's schemas
|
data/lib/samagotchi/tool_view.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "command_steps"
|
|
3
4
|
require_relative "tools/execute"
|
|
4
5
|
require_relative "tools/task_create"
|
|
5
6
|
|
|
@@ -12,9 +13,14 @@ module Samagotchi
|
|
|
12
13
|
# 80-char cut stay as they are (the guardrails, "ran as:", the TUI).
|
|
13
14
|
#
|
|
14
15
|
# +truncated+: the command was longer than COMMAND_LIMIT and is cut to it;
|
|
15
|
-
# +chars+ is then its full length.
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# +chars+ is then its full length. +cd+ and +steps+: the command as
|
|
17
|
+
# CommandSteps reads it (its leading cd, its steps), both nil when it
|
|
18
|
+
# can't (the fallback: the UI shows the command itself) or the command
|
|
19
|
+
# is cut. +description+: what the model said the command does (execute's
|
|
20
|
+
# optional description), whitespace collapsed, a trailing period gone;
|
|
21
|
+
# nil when it gave none.
|
|
22
|
+
ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description) do
|
|
23
|
+
def to_h = super.merge(steps: steps&.map(&:to_h)).compact.reject { |_key, value| value == false }
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
class ToolView
|
|
@@ -23,6 +29,11 @@ module Samagotchi
|
|
|
23
29
|
|
|
24
30
|
TOOLS = [Tools::Execute::NAME, Tools::TaskCreate::NAME].freeze
|
|
25
31
|
|
|
32
|
+
# The longest description a view carries; a row's title shows the
|
|
33
|
+
# first TITLE_LIMIT characters of it.
|
|
34
|
+
DESCRIPTION_LIMIT = 300
|
|
35
|
+
TITLE_LIMIT = 60
|
|
36
|
+
|
|
26
37
|
# @return [ToolView, nil] nil for a tool without a view and for an
|
|
27
38
|
# empty command
|
|
28
39
|
def self.for(tool_name, call)
|
|
@@ -33,8 +44,33 @@ module Samagotchi
|
|
|
33
44
|
|
|
34
45
|
cwd = call[:cwd].to_s.strip
|
|
35
46
|
truncated = command.length > COMMAND_LIMIT
|
|
47
|
+
parsed = truncated ? nil : CommandSteps.parse(command)
|
|
36
48
|
new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
|
|
37
|
-
truncated: truncated, chars: truncated ? command.length : nil
|
|
49
|
+
truncated: truncated, chars: truncated ? command.length : nil, cd: parsed&.cd, steps: parsed&.steps,
|
|
50
|
+
description: description(call))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The call's description as a view shows it, nil for none.
|
|
54
|
+
def self.description(call)
|
|
55
|
+
text = call[:description]
|
|
56
|
+
return nil unless text.is_a?(String)
|
|
57
|
+
|
|
58
|
+
text = text.gsub(/\s+/, " ").strip.sub(/(?<!\.)\.\z/, "")
|
|
59
|
+
return nil if text.empty?
|
|
60
|
+
|
|
61
|
+
text.length > DESCRIPTION_LIMIT ? "#{text[0, DESCRIPTION_LIMIT - 1]}…" : text
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A row's title from the call's description: cut to TITLE_LIMIT, at a
|
|
65
|
+
# word when one ends in its second half. nil for none.
|
|
66
|
+
def self.description_title(call)
|
|
67
|
+
text = description(call)
|
|
68
|
+
return text if text.nil? || text.length <= TITLE_LIMIT
|
|
69
|
+
|
|
70
|
+
cut = text[0, TITLE_LIMIT - 1]
|
|
71
|
+
space = cut.rindex(" ")
|
|
72
|
+
cut = cut[0, space] if space && space >= TITLE_LIMIT / 2
|
|
73
|
+
"#{cut.sub(/[\s,;:.-]+\z/, "")}…"
|
|
38
74
|
end
|
|
39
75
|
end
|
|
40
76
|
end
|
|
@@ -142,11 +142,14 @@ module Samagotchi
|
|
|
142
142
|
-> { !!peers&.cancelled? }
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# @param command_description [Boolean] false: execute declares no
|
|
146
|
+
# description parameter (execute.description off)
|
|
145
147
|
# @return [Registry] a new registry with the built-ins, in
|
|
146
148
|
# TOOL_SCHEMAS order (an Engine's own, which bundles add to)
|
|
147
|
-
def registry
|
|
149
|
+
def registry(command_description: true)
|
|
148
150
|
by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
|
|
149
151
|
ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, registry|
|
|
152
|
+
schema = ToolDeclarations.without_command_description(schema) unless command_description
|
|
150
153
|
klass = by_name.fetch(schema[:name])
|
|
151
154
|
handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
|
|
152
155
|
registry.register(klass::NAME, schema: schema, handler: handler)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require_relative "output_guardrails"
|
|
5
|
+
require_relative "shell"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Tools
|
|
@@ -90,7 +91,7 @@ module Samagotchi
|
|
|
90
91
|
started = monotonic_time
|
|
91
92
|
deadline = started + timeout_sec
|
|
92
93
|
|
|
93
|
-
Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
94
|
+
Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
94
95
|
stdin.close
|
|
95
96
|
readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
|
|
96
97
|
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module Tools
|
|
5
5
|
# The tools a session offers the model, in declaration order: the
|
|
6
|
-
# built-ins (Tools::Builtins),
|
|
6
|
+
# built-ins (Tools::Builtins) as registered, then the ones bundles add,
|
|
7
|
+
# by bundle and name. That order doesn't depend on which plugin
|
|
8
|
+
# finished its init first or re-registered a changed tool, so the
|
|
9
|
+
# tool list (part of the prompt's cached prefix) is the same in every
|
|
10
|
+
# session with the same tools. The
|
|
7
11
|
# native prompts and the chat path's tools: are rendered from #schemas,
|
|
8
12
|
# and KernelLoop#dispatch runs a call through its entry's handler.
|
|
9
13
|
class Registry
|
|
@@ -43,13 +47,16 @@ module Samagotchi
|
|
|
43
47
|
def key?(name) = @entries.key?(name.to_s)
|
|
44
48
|
|
|
45
49
|
# @return [Array<String>] in declaration order
|
|
46
|
-
def names =
|
|
50
|
+
def names = entries.map(&:name)
|
|
47
51
|
|
|
48
52
|
# @return [Array<Entry>] in declaration order
|
|
49
|
-
def entries
|
|
53
|
+
def entries
|
|
54
|
+
core, added = @entries.values.partition(&:core?)
|
|
55
|
+
core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
|
|
56
|
+
end
|
|
50
57
|
|
|
51
58
|
# @return [Array<Hash>] the schemas, in declaration order
|
|
52
|
-
def schemas =
|
|
59
|
+
def schemas = entries.map(&:schema)
|
|
53
60
|
|
|
54
61
|
def freeze
|
|
55
62
|
@entries.freeze
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Tools
|
|
7
|
+
# The shell a model's (or the user's `!`) command runs in: execute,
|
|
8
|
+
# task_create and the shell bang. Non-login (a login shell re-sources
|
|
9
|
+
# profile files, which can rebuild PATH and shadow the inherited toolchain).
|
|
10
|
+
#
|
|
11
|
+
# macOS's /bin/sh is bash 3.2, which can't parse an apostrophe in a
|
|
12
|
+
# heredoc inside "$( )" -- the usual `git commit -m "$(cat <<'EOF' ...`.
|
|
13
|
+
# There, zsh in sh emulation runs instead (it reads no rc files in that
|
|
14
|
+
# mode), with the options that keep bash habits working: `{1..3}` brace
|
|
15
|
+
# expansion, unquoted `( )` in `[[ =~ ]]`, BASH_REMATCH, and echo
|
|
16
|
+
# expanding `\t` as macOS's sh does. Elsewhere (Linux) /bin/sh as before.
|
|
17
|
+
module Shell
|
|
18
|
+
ZSH = "/bin/zsh"
|
|
19
|
+
ZSH_SH_MODE = [ZSH, "--emulate", "sh", "+o", "ignore_braces", "+o", "sh_glob", "-o", "bash_rematch",
|
|
20
|
+
"+o", "bsd_echo"].freeze
|
|
21
|
+
SH = ["/bin/sh"].freeze
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] the argv that runs +command+
|
|
24
|
+
def self.argv(command) = [*program, "-c", command]
|
|
25
|
+
|
|
26
|
+
# @return [Array<String>] the shell and its options, without -c
|
|
27
|
+
def self.program(host_os: RbConfig::CONFIG["host_os"], zsh: File.executable?(ZSH))
|
|
28
|
+
host_os.to_s.include?("darwin") && zsh ? ZSH_SH_MODE : SH
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require "time"
|
|
8
8
|
require_relative "../atomic_file"
|
|
9
|
+
require_relative "shell"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Tools
|
|
@@ -49,11 +50,10 @@ module Samagotchi
|
|
|
49
50
|
output_io.sync = true
|
|
50
51
|
|
|
51
52
|
wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
|
|
52
|
-
# Non-login shell
|
|
53
|
-
# files, which can rebuild PATH and shadow the inherited toolchain.
|
|
53
|
+
# Non-login, the shell Execute uses too (Shell).
|
|
54
54
|
pid = Process.spawn(
|
|
55
55
|
spawn_env,
|
|
56
|
-
|
|
56
|
+
*Shell.argv(wrapped_command),
|
|
57
57
|
chdir: resolved_cwd,
|
|
58
58
|
out: output_io,
|
|
59
59
|
err: output_io,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "tool_view"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# A running count of one turn's tool calls, for the one-line tally a long,
|
|
5
7
|
# tool-heavy turn shows under its activity row:
|
|
@@ -32,10 +34,13 @@ module Samagotchi
|
|
|
32
34
|
|
|
33
35
|
# @param key [Object] one call's identity within the turn (the event's
|
|
34
36
|
# [iteration, call_index])
|
|
35
|
-
|
|
37
|
+
# @param description [String, nil] a command's description, which the
|
|
38
|
+
# last call shows in place of its params
|
|
39
|
+
def started(key:, tool:, params: nil, description: nil)
|
|
36
40
|
call = (@calls[key] ||= { tool: tool.to_s, params: "", status: "running" })
|
|
37
41
|
call[:tool] = tool.to_s unless tool.to_s.empty?
|
|
38
42
|
call[:params] = params.to_s unless params.nil?
|
|
43
|
+
call[:description] = description if description
|
|
39
44
|
call[:status] = "running"
|
|
40
45
|
call
|
|
41
46
|
end
|
|
@@ -43,9 +48,10 @@ module Samagotchi
|
|
|
43
48
|
# A completion with no start seen still counts as a call (as the web's
|
|
44
49
|
# activity rows do). Only "error" is a failure: a call a guardrail or an
|
|
45
50
|
# approval blocked still counts as a call, not as a failed one.
|
|
46
|
-
def completed(key:, tool:, status:, params: nil)
|
|
47
|
-
call = @calls[key] || started(key: key, tool: tool, params: params)
|
|
51
|
+
def completed(key:, tool:, status:, params: nil, description: nil)
|
|
52
|
+
call = @calls[key] || started(key: key, tool: tool, params: params, description: description)
|
|
48
53
|
call[:params] = params.to_s if params
|
|
54
|
+
call[:description] = description if description
|
|
49
55
|
call[:status] = status.to_s == "error" ? "error" : "ok"
|
|
50
56
|
call
|
|
51
57
|
end
|
|
@@ -58,7 +64,8 @@ module Samagotchi
|
|
|
58
64
|
next unless part[:kind].to_s == "tool"
|
|
59
65
|
|
|
60
66
|
key = [part[:iteration].to_i, part[:call_index].to_i]
|
|
61
|
-
|
|
67
|
+
view = part[:view].is_a?(Hash) ? part[:view].transform_keys(&:to_sym) : {}
|
|
68
|
+
started(key: key, tool: part[:tool], params: part[:params], description: ToolView.description_title(view))
|
|
62
69
|
completed(key: key, tool: part[:tool], status: part[:status]) unless part[:status].to_s == "running"
|
|
63
70
|
end
|
|
64
71
|
self
|
|
@@ -85,8 +92,12 @@ module Samagotchi
|
|
|
85
92
|
fields = [head] + top.map { |(tool, n), _| "#{tool} ×#{n}" }
|
|
86
93
|
if last
|
|
87
94
|
call = calls.last
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
shown = if call[:description]
|
|
96
|
+
"#{call[:tool]}: #{call[:description]}"
|
|
97
|
+
else
|
|
98
|
+
[call[:tool], call[:params].to_s.gsub(/\s+/, " ").strip].reject(&:empty?).join(" ")
|
|
99
|
+
end
|
|
100
|
+
fields << "last: #{shown}"
|
|
90
101
|
end
|
|
91
102
|
cut(fields.join(SEPARATOR), width)
|
|
92
103
|
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -17,12 +17,80 @@ export function rowHover(row) {
|
|
|
17
17
|
// given: execute's may be relative to the project root), the command as
|
|
18
18
|
// written (whitespace kept, its own scroll), a copy button, and a line
|
|
19
19
|
// saying how much a cut command left out. "" for a call without a view.
|
|
20
|
-
|
|
20
|
+
//
|
|
21
|
+
// A view with steps (CommandSteps) shows them in the command's place, its
|
|
22
|
+
// leading cd as one more "in" tag, and a "raw" toggle (a checkbox, CSS
|
|
23
|
+
// only, so a live and a reloaded row behave alike) that swaps the steps
|
|
24
|
+
// for the command as written. The copy button copies the raw command
|
|
25
|
+
// either way. A view without steps (the parser's fallback) is the raw
|
|
26
|
+
// block alone: nothing hides the command.
|
|
27
|
+
//
|
|
28
|
+
// +title+ is the row's: the model's description of the command, when it
|
|
29
|
+
// gave one, cut to fit. The block starts with the whole description when
|
|
30
|
+
// the title is cut, never in place of the command.
|
|
31
|
+
export function commandBlockHtml(view, title = null) {
|
|
21
32
|
const command = view?.command;
|
|
22
33
|
if (!command) return "";
|
|
23
|
-
const
|
|
34
|
+
const desc = view.description && view.description !== title
|
|
35
|
+
? `<div class="activity-command-desc">${escapeHtml(view.description)}</div>`
|
|
36
|
+
: "";
|
|
37
|
+
const steps = Array.isArray(view.steps) && view.steps.length ? view.steps : null;
|
|
38
|
+
const dirs = [view.cwd, steps && view.cd].filter(Boolean);
|
|
39
|
+
const where = dirs.length
|
|
40
|
+
? `in ${dirs.map((d) => `<span>${escapeHtml(d)}</span>`).join(" <span class=\"activity-command-sep\">›</span> ")}`
|
|
41
|
+
: "";
|
|
24
42
|
const cut = view.truncated
|
|
25
43
|
? `<div class="activity-command-cut">… (${command.length.toLocaleString("en-US")} of ${Number(view.chars || 0).toLocaleString("en-US")} chars)</div>`
|
|
26
44
|
: "";
|
|
27
|
-
|
|
45
|
+
const raw = `<pre><code>${escapeHtml(command)}</code></pre>`;
|
|
46
|
+
if (!steps) {
|
|
47
|
+
const cwd = where ? `<div class="activity-command-cwd">${where}</div>` : "";
|
|
48
|
+
return `<div class="activity-command code-wrap">${desc}${cwd}${raw}${copyButtonHtml("code")}${cut}</div>`;
|
|
49
|
+
}
|
|
50
|
+
const head = `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` +
|
|
51
|
+
`<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label></div>`;
|
|
52
|
+
return `<div class="activity-command code-wrap has-steps">${desc}${head}${stepsHtml(steps)}${raw}${copyButtonHtml("code")}${cut}</div>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// What an op between two steps reads as: an arrow for "then" (&&, ;, a
|
|
56
|
+
// new line, a pipe), "else" for ||, "&" for one put in the background.
|
|
57
|
+
const OP_GLYPHS = { "&&": "→", ";": "→", "\n": "→", "|": "→", "|&": "→", "||": "else", "&": "&" };
|
|
58
|
+
const OP_NAMES = { "\n": "new line", "&": "after starting the step before in the background" };
|
|
59
|
+
|
|
60
|
+
function opHtml(op) {
|
|
61
|
+
if (!op) return `<span class="step-op"></span>`;
|
|
62
|
+
const glyph = OP_GLYPHS[op] || op;
|
|
63
|
+
const name = OP_NAMES[op] || op;
|
|
64
|
+
return `<span class="step-op${op === "||" ? " else" : ""}" title="${escapeHtml(name)}">${escapeHtml(glyph)}</span>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A step's redirections to or from nowhere (2>&1, 2>/dev/null, </dev/null),
|
|
68
|
+
// dimmed: plumbing, not what the step does.
|
|
69
|
+
const PLUMBING = /(^|\s)(\d*>&\d+|&>\/dev\/null|\d*>>?\s*\/dev\/null|<\s*\/dev\/null)(?=\s|$)/g;
|
|
70
|
+
|
|
71
|
+
export function stepTextHtml(text) {
|
|
72
|
+
let out = "";
|
|
73
|
+
let at = 0;
|
|
74
|
+
for (const m of String(text).matchAll(PLUMBING)) {
|
|
75
|
+
const start = m.index + m[1].length;
|
|
76
|
+
out += `${escapeHtml(text.slice(at, start))}<span class="step-plumbing">${escapeHtml(m[2])}</span>`;
|
|
77
|
+
at = start + m[2].length;
|
|
78
|
+
}
|
|
79
|
+
return out + escapeHtml(String(text).slice(at));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The steps as a list: a label as a small heading above its step, the op
|
|
83
|
+
// that links a step to the one before, its text, its limit and heredoc
|
|
84
|
+
// as chips (beside the text, below it when the row is narrow).
|
|
85
|
+
export function stepsHtml(steps) {
|
|
86
|
+
const items = steps.map((step) => {
|
|
87
|
+
const label = step.label ? `<li class="step-label">${escapeHtml(step.label)}</li>` : "";
|
|
88
|
+
const limit = step.limit ? `<span class="step-chip" title="output limited">${escapeHtml(step.limit)}</span>` : "";
|
|
89
|
+
const heredoc = step.heredoc
|
|
90
|
+
? `<span class="step-chip heredoc" title="a heredoc: its text in the raw command">${escapeHtml(step.heredoc.tag)} · ` +
|
|
91
|
+
`${Number(step.heredoc.lines)} ${step.heredoc.lines === 1 ? "line" : "lines"}</span>`
|
|
92
|
+
: "";
|
|
93
|
+
return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredoc}</span></li>`;
|
|
94
|
+
});
|
|
95
|
+
return `<ol class="activity-steps">${items.join("")}</ol>`;
|
|
28
96
|
}
|
|
@@ -23,11 +23,21 @@ export function noteHtml(note) {
|
|
|
23
23
|
return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Who a steer names as its sender: a plugin's own label, chi's sender ids
|
|
27
|
+
// in words (the TUI's Formatting#steer_sender agrees;
|
|
28
|
+
// spec/shared/labels_matrix.json). "": no one named.
|
|
29
|
+
const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin" };
|
|
30
|
+
export function steerSender(source) {
|
|
31
|
+
const id = String(source ?? "");
|
|
32
|
+
return STEER_SENDERS[id] || id;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// A plugin's steer (a nudge put into the running turn): one line that
|
|
27
36
|
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
28
37
|
// own between bubbles (no step holds it), not a row of a step.
|
|
29
38
|
export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
|
|
30
|
-
const
|
|
39
|
+
const sender = steerSender(source);
|
|
40
|
+
const who = sender ? `${sender} nudged the model` : "nudged the model";
|
|
31
41
|
return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
|
|
32
42
|
`<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
|
|
33
43
|
}
|