samagotchi 0.3.0 → 0.4.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 +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +201 -104
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +26 -79
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- metadata +12 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# How much a model thinks: one level per model, host or run, mapped to each
|
|
7
|
+
# backend's own switch. The one place that knows the mapping.
|
|
8
|
+
#
|
|
9
|
+
# off thinking off where the backend can do it
|
|
10
|
+
# low, medium, high the effort a chat host is asked for
|
|
11
|
+
# default chi sends nothing: the provider's or template's own default
|
|
12
|
+
#
|
|
13
|
+
# Order (#resolve): --thinking / SAMAGOTCHI_THINKING_LEVEL, then
|
|
14
|
+
# models.<key>.thinking, hosts.<name>.thinking, thinking.level in
|
|
15
|
+
# config.yml, then default.
|
|
16
|
+
module Thinking
|
|
17
|
+
LEVELS = %i[off low medium high default].freeze
|
|
18
|
+
EFFORTS = %i[low medium high].freeze
|
|
19
|
+
DEFAULT = :default
|
|
20
|
+
|
|
21
|
+
# Gemma 4 turns thinking on with this token at the start of the system prompt.
|
|
22
|
+
GEMMA_THINK_TOKEN = "<|think|>\n"
|
|
23
|
+
# An empty thought after the Qwen assistant cue: the model answers at once
|
|
24
|
+
# (what the server's own template does with enable_thinking false).
|
|
25
|
+
QWEN_EMPTY_THOUGHT = "<think>\n\n</think>\n\n"
|
|
26
|
+
|
|
27
|
+
# What a native prompt gets for a level: the text before the system
|
|
28
|
+
# prompt, the text after the assistant cue, and whether the level means
|
|
29
|
+
# anything there (low/medium/high have no native knob).
|
|
30
|
+
Native = Data.define(:system_token, :prefill, :honoured)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# A configured value as a level; nil when unset or not a level (that
|
|
35
|
+
# warns once, naming +where+). YAML reads an unquoted `off` as false,
|
|
36
|
+
# which is :off; true (`on`) isn't a level.
|
|
37
|
+
# @return [Symbol, nil]
|
|
38
|
+
def level(value, where)
|
|
39
|
+
return nil if value.nil?
|
|
40
|
+
return :off if value == false || value.to_s.strip.downcase == "false"
|
|
41
|
+
|
|
42
|
+
text = value.to_s.strip.downcase
|
|
43
|
+
return text.to_sym if LEVELS.include?(text.to_sym)
|
|
44
|
+
|
|
45
|
+
hint = value == true || %w[true on].include?(text) ? "; `on` isn't one, `default` leaves it to the model" : ""
|
|
46
|
+
ConfigFile.warn_once "Warning: #{where}: thinking must be one of #{LEVELS.join(', ')}#{hint}; ignored"
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The level for +target+ and where it came from ("--thinking",
|
|
51
|
+
# "models: qwen", "hosts.work", "thinking.level"; nil for the default).
|
|
52
|
+
# @param target [HostRegistry::ModelTarget]
|
|
53
|
+
# @param names [Array<String>, nil] the model's lookup names (Engine#model_lookup_names)
|
|
54
|
+
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
55
|
+
# @return [Array(Symbol, String|nil)]
|
|
56
|
+
def resolve(target, names: nil, models: nil)
|
|
57
|
+
global, origin = global_level
|
|
58
|
+
return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
|
|
59
|
+
|
|
60
|
+
models ||= begin
|
|
61
|
+
ConfigFile.model_settings
|
|
62
|
+
rescue StandardError
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
names ||= [target.model, target.bare_model]
|
|
66
|
+
key, value = ConfigFile.model_setting(names, :thinking, models: models)
|
|
67
|
+
return [value, "models: #{key}"] if key
|
|
68
|
+
|
|
69
|
+
host = target.entry.respond_to?(:thinking) ? target.entry.thinking : nil
|
|
70
|
+
return [host, "hosts.#{target.entry.name}"] if host
|
|
71
|
+
return [global, "thinking.level"] if global
|
|
72
|
+
|
|
73
|
+
[DEFAULT, nil]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# thinking.level as a level and its origin (:cli, :env, :file), or nil.
|
|
77
|
+
def global_level
|
|
78
|
+
raw, origin = Config.get_with_origin("thinking.level")
|
|
79
|
+
return nil if origin == :default
|
|
80
|
+
|
|
81
|
+
where = { cli: "--thinking", env: "SAMAGOTCHI_THINKING_LEVEL" }.fetch(origin, "thinking.level")
|
|
82
|
+
value = level(raw, where)
|
|
83
|
+
value ? [value, origin] : nil
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Request fields for an OpenAI-style chat host. off sends both switches
|
|
89
|
+
# (the template's enable_thinking and the OpenAI-style reasoning_effort
|
|
90
|
+
# "none"): llama.cpp, Splash and OpenRouter each honour one of them.
|
|
91
|
+
# An effort goes as reasoning_effort (llama.cpp ignores it). default
|
|
92
|
+
# sends nothing.
|
|
93
|
+
# @return [Hash] frozen
|
|
94
|
+
def chat_fields(level)
|
|
95
|
+
case level
|
|
96
|
+
when :off then { chat_template_kwargs: { enable_thinking: false }.freeze, reasoning_effort: "none" }.freeze
|
|
97
|
+
when *EFFORTS then { reasoning_effort: level.to_s }.freeze
|
|
98
|
+
else {}.freeze
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The native prompt's switch for +level+ under +profile+.
|
|
103
|
+
# @param profile [ModelProfile]
|
|
104
|
+
# @return [Native]
|
|
105
|
+
def native(level, profile)
|
|
106
|
+
off = level == :off
|
|
107
|
+
honoured = !EFFORTS.include?(level)
|
|
108
|
+
case profile&.name
|
|
109
|
+
when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: "", honoured: honoured)
|
|
110
|
+
when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
|
|
111
|
+
else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -4,6 +4,8 @@ require_relative "tool_activity"
|
|
|
4
4
|
require_relative "guardrails"
|
|
5
5
|
require_relative "vision_context"
|
|
6
6
|
require_relative "log"
|
|
7
|
+
require_relative "edit_preview"
|
|
8
|
+
require_relative "tools/tool_path"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
# The single per-call path both loops use: the tool_call_started and
|
|
@@ -17,6 +19,10 @@ module Samagotchi
|
|
|
17
19
|
# that returns many screenshots can't flood the context).
|
|
18
20
|
MAX_IMAGES_PER_RESULT = 4
|
|
19
21
|
|
|
22
|
+
# file_before for a call that isn't an edit/write, or won't run.
|
|
23
|
+
NOT_AN_EDIT = Object.new.freeze
|
|
24
|
+
private_constant :NOT_AN_EDIT
|
|
25
|
+
|
|
20
26
|
# @param kernel [KernelLoop] read lazily: Engine sets its hooks after
|
|
21
27
|
# the kernel is built.
|
|
22
28
|
def initialize(kernel)
|
|
@@ -29,7 +35,9 @@ module Samagotchi
|
|
|
29
35
|
# shown_params: the params line the live row showed, and shown_label:
|
|
30
36
|
# its label ("chrome: screenshot"), only for a tool that isn't built in
|
|
31
37
|
# (the loops save them with the result, so a reload without the plugin
|
|
32
|
-
# shows the same row)
|
|
38
|
+
# shows the same row), and diff: what an edit/write changed in its
|
|
39
|
+
# file (EditPreview.change; also on tool_call_completed, never in the
|
|
40
|
+
# model's output)
|
|
33
41
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
34
42
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
35
43
|
# The gate runs first, so tool_call_started shows the call that runs.
|
|
@@ -45,7 +53,9 @@ module Samagotchi
|
|
|
45
53
|
# The ask comes after tool_call_started: the UI shows the tool line,
|
|
46
54
|
# then the approval under it.
|
|
47
55
|
settle_ask(verdict) if verdict.ask?
|
|
56
|
+
before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
|
|
48
57
|
result = verdict.deny? ? denied(call, verdict) : dispatch(call)
|
|
58
|
+
diff = file_change(call, before)
|
|
49
59
|
result = approved(result, verdict) if verdict.allow? && verdict.decided_by
|
|
50
60
|
result, images = attach_images(call, result) if result[:images]
|
|
51
61
|
|
|
@@ -61,17 +71,40 @@ module Samagotchi
|
|
|
61
71
|
completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
62
72
|
tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
|
|
63
73
|
completed[:images] = images if images&.any?
|
|
74
|
+
completed[:diff] = diff if diff
|
|
64
75
|
emit(on_stream_event, completed)
|
|
65
76
|
|
|
66
77
|
run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
|
|
67
78
|
run[:images] = images if images&.any?
|
|
68
79
|
run[:shown_params] = params if params && plugin_tool?(call[:name])
|
|
69
80
|
run[:shown_label] = label if label
|
|
81
|
+
run[:diff] = diff if diff
|
|
70
82
|
run
|
|
71
83
|
end
|
|
72
84
|
|
|
73
85
|
private
|
|
74
86
|
|
|
87
|
+
# edit/write only: the file just before the call runs. The row diffs it
|
|
88
|
+
# with the file after, whatever the result says, so an edit that fails
|
|
89
|
+
# after writing still shows its change and one that wrote nothing shows
|
|
90
|
+
# none.
|
|
91
|
+
def file_before(call)
|
|
92
|
+
return NOT_AN_EDIT unless EditPreview.tool?(call[:name])
|
|
93
|
+
|
|
94
|
+
EditPreview.snapshot(Tools::ToolPath.normalize(call[:path]))
|
|
95
|
+
rescue StandardError
|
|
96
|
+
NOT_AN_EDIT
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def file_change(call, before)
|
|
100
|
+
return nil if before.equal?(NOT_AN_EDIT)
|
|
101
|
+
|
|
102
|
+
EditPreview.change(before, EditPreview.snapshot(Tools::ToolPath.normalize(call[:path])))
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Log.warn(:turn, "edit_diff_failed", tool: call[:name], error: "#{e.class}: #{e.message}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
75
108
|
# A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
|
|
76
109
|
# binary file). They become "?" here, before the output reaches the
|
|
77
110
|
# conversation, the events and the saved session: JSON.generate raises
|
|
@@ -19,30 +19,50 @@ module Samagotchi
|
|
|
19
19
|
|
|
20
20
|
def self.name = NAME
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
#
|
|
25
|
-
def self.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
QUESTION_REQUIRED_ERROR = "Error: ask_user_question requires 'question'"
|
|
23
|
+
|
|
24
|
+
# The tool result for a wrong number of options.
|
|
25
|
+
def self.options_count_error(count)
|
|
26
|
+
"Error: ask_user_question requires 2-8 options (got #{count}). Provide e.g. options=[\"Cats\",\"Dogs\"]"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The one check of a model's ask_user_question call (the kernel runs it
|
|
30
|
+
# before the question opens). Dumb-model tolerant: options may be a JSON
|
|
31
|
+
# string or bracket noise, a single option is salvaged, missing options
|
|
32
|
+
# are read from the call's content, booleans may be strings; wire control
|
|
33
|
+
# tokens are stripped from the question and header.
|
|
34
|
+
# @param call [Hash] the tool call (question:/content:, options:, header:,
|
|
35
|
+
# multi_select:, allow_freeform:)
|
|
36
|
+
# @return [Hash, String] the payload {question:, options:, header:,
|
|
37
|
+
# multi_select:, allow_freeform:} (no header key when empty), or the
|
|
38
|
+
# plain-text error the model gets as the tool result
|
|
39
|
+
def self.validate(call)
|
|
40
|
+
question = strip_wire_tokens(call[:question] || call[:content])
|
|
41
|
+
return QUESTION_REQUIRED_ERROR if question.empty?
|
|
42
|
+
|
|
43
|
+
options = normalize_options_lenient(call[:options])
|
|
44
|
+
options = normalize_options_lenient(call[:content].to_s) if options.empty? && call[:content]
|
|
45
|
+
return options_count_error(options.size) if options.empty? || options.size > 8
|
|
46
|
+
|
|
47
|
+
header = strip_wire_tokens(call[:header])
|
|
48
|
+
{
|
|
38
49
|
question: question,
|
|
39
|
-
options:
|
|
40
|
-
header: header,
|
|
41
|
-
multi_select:
|
|
42
|
-
allow_freeform:
|
|
50
|
+
options: options,
|
|
51
|
+
header: (header unless header.empty?),
|
|
52
|
+
multi_select: to_bool(call[:multi_select]),
|
|
53
|
+
allow_freeform: to_bool(call[:allow_freeform])
|
|
43
54
|
}.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Booleans the way models send them (Gemma passes "true"/"false").
|
|
58
|
+
def self.to_bool(value)
|
|
59
|
+
return value if value == true || value == false
|
|
44
60
|
|
|
45
|
-
|
|
61
|
+
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.strip_wire_tokens(text)
|
|
65
|
+
text.to_s.gsub(/<\|[^|]*\|>/, "").gsub(/<\||\|>/, "").strip
|
|
46
66
|
end
|
|
47
67
|
|
|
48
68
|
# Normalize options param: accept Array or JSON string; strip, reject empty.
|
|
@@ -135,18 +155,6 @@ module Samagotchi
|
|
|
135
155
|
|
|
136
156
|
arr.map { |v| sanitize_option(v) }.reject { |v| v.nil? || v.empty? }
|
|
137
157
|
end
|
|
138
|
-
|
|
139
|
-
def self.to_bool(v)
|
|
140
|
-
return nil if v.nil?
|
|
141
|
-
return v if v == true || v == false
|
|
142
|
-
|
|
143
|
-
s = v.to_s.strip.downcase
|
|
144
|
-
return true if %w[1 true yes on].include?(s)
|
|
145
|
-
return false if %w[0 false no off].include?(s)
|
|
146
|
-
|
|
147
|
-
nil
|
|
148
|
-
end
|
|
149
|
-
private_class_method :to_bool
|
|
150
158
|
end
|
|
151
159
|
end
|
|
152
160
|
end
|
|
@@ -22,9 +22,25 @@ module Samagotchi
|
|
|
22
22
|
|
|
23
23
|
def self.call(content, path:, start_line: nil, end_line: nil)
|
|
24
24
|
path = ToolPath.normalize(path)
|
|
25
|
+
result = apply(content, path: path, start_line: start_line, end_line: end_line)
|
|
26
|
+
return result if result.is_a?(String)
|
|
27
|
+
|
|
28
|
+
updated, message = result
|
|
29
|
+
File.write(path, updated)
|
|
30
|
+
message
|
|
31
|
+
rescue => e
|
|
32
|
+
"Error: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The edit without the write: [updated, message] or "Error: …", with
|
|
36
|
+
# exactly the strings call returns. +read+ reads the file, so a dry run
|
|
37
|
+
# (EditPreview) can reuse a copy it already has; each mode keeps its own
|
|
38
|
+
# check order (exact: tags first; range: file not found first).
|
|
39
|
+
def self.apply(content, path:, start_line: nil, end_line: nil, read: ->(p) { File.read(p) })
|
|
40
|
+
path = ToolPath.normalize(path)
|
|
25
41
|
|
|
26
42
|
if range_requested?(start_line, end_line)
|
|
27
|
-
return
|
|
43
|
+
return apply_range_mode(content, path: path, start_line: start_line, end_line: end_line, read: read)
|
|
28
44
|
end
|
|
29
45
|
|
|
30
46
|
old_text = extract_tag(content, "old")
|
|
@@ -35,7 +51,7 @@ module Samagotchi
|
|
|
35
51
|
return "Error: <old> block is empty" if old_text.empty?
|
|
36
52
|
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
37
53
|
|
|
38
|
-
source =
|
|
54
|
+
source = read.(path)
|
|
39
55
|
count = count_occurrences(source, old_text)
|
|
40
56
|
|
|
41
57
|
return "Error: old text not found in #{path}" if count == 0
|
|
@@ -43,13 +59,12 @@ module Samagotchi
|
|
|
43
59
|
|
|
44
60
|
idx = source.index(old_text)
|
|
45
61
|
updated = source[0, idx] + new_text + source[idx + old_text.length..]
|
|
46
|
-
|
|
47
|
-
"Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"
|
|
62
|
+
[updated, "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"]
|
|
48
63
|
rescue => e
|
|
49
64
|
"Error: #{e.message}"
|
|
50
65
|
end
|
|
51
66
|
|
|
52
|
-
def self.
|
|
67
|
+
def self.apply_range_mode(content, path:, start_line:, end_line:, read:)
|
|
53
68
|
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
54
69
|
|
|
55
70
|
new_text = extract_tag(content, "new")
|
|
@@ -59,7 +74,7 @@ module Samagotchi
|
|
|
59
74
|
return start_num if start_num.is_a?(String)
|
|
60
75
|
return "Error: start_line must be provided for range edits" if start_num.nil?
|
|
61
76
|
|
|
62
|
-
source =
|
|
77
|
+
source = read.(path)
|
|
63
78
|
lines = source.lines
|
|
64
79
|
total_lines = lines.length
|
|
65
80
|
|
|
@@ -102,15 +117,14 @@ module Samagotchi
|
|
|
102
117
|
# first suffix line isn't concatenated onto the last replacement line.
|
|
103
118
|
normalized = (!new_text.empty? && !suffix.empty? && !new_text.end_with?("\n")) ? new_text + "\n" : new_text
|
|
104
119
|
updated = prefix + normalized + suffix
|
|
105
|
-
File.write(path, updated)
|
|
106
120
|
|
|
107
121
|
replaced_lines = (end_value - start_num) + 1
|
|
108
122
|
new_line_count = new_text.lines.length
|
|
109
|
-
"Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"
|
|
123
|
+
[updated, "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"]
|
|
110
124
|
rescue => e
|
|
111
125
|
"Error: #{e.message}"
|
|
112
126
|
end
|
|
113
|
-
private_class_method :
|
|
127
|
+
private_class_method :apply_range_mode
|
|
114
128
|
|
|
115
129
|
# Count non-overlapping literal occurrences of +needle+ in +haystack+.
|
|
116
130
|
def self.count_occurrences(haystack, needle)
|
|
@@ -14,6 +14,10 @@ module Samagotchi
|
|
|
14
14
|
def self.name = NAME
|
|
15
15
|
|
|
16
16
|
def self.call(content, path:)
|
|
17
|
+
# A native call without content/text arrives as nil; check before
|
|
18
|
+
# touching the file, or File.write(path, nil) would empty it.
|
|
19
|
+
return "Error: missing content" unless content.is_a?(String)
|
|
20
|
+
|
|
17
21
|
path = ToolPath.normalize(path)
|
|
18
22
|
FileUtils.mkdir_p(File.dirname(path))
|
|
19
23
|
File.write(path, content)
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -143,9 +143,19 @@ module Samagotchi
|
|
|
143
143
|
@checkpoint = nil
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
# A reminder turn
|
|
146
|
+
# A reminder turn is about to run: a pending continue offer goes, as for
|
|
147
|
+
# a new prompt (the partial turn stays), so a later "no" can't roll the
|
|
148
|
+
# reminder's exchange back with it.
|
|
149
|
+
# @return [Boolean] whether an offer was pending
|
|
150
|
+
def before_reminder_turn
|
|
151
|
+
offered = awaiting_continue?
|
|
152
|
+
@offer = nil
|
|
153
|
+
offered
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A reminder turn ran: the rollback window closes.
|
|
147
157
|
def after_reminder_turn
|
|
148
|
-
@checkpoint = nil
|
|
158
|
+
@checkpoint = nil
|
|
149
159
|
end
|
|
150
160
|
|
|
151
161
|
private
|