samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
require_relative "../tool_declarations"
|
|
5
|
+
require_relative "execute"
|
|
6
|
+
require_relative "read"
|
|
7
|
+
require_relative "write"
|
|
8
|
+
require_relative "memory"
|
|
9
|
+
require_relative "edit"
|
|
10
|
+
require_relative "task_create"
|
|
11
|
+
require_relative "task_get"
|
|
12
|
+
require_relative "task_list"
|
|
13
|
+
require_relative "task_stop"
|
|
14
|
+
require_relative "task_wait"
|
|
15
|
+
require_relative "web_fetch"
|
|
16
|
+
require_relative "register_reminder"
|
|
17
|
+
require_relative "cancel_reminder"
|
|
18
|
+
require_relative "list_reminders"
|
|
19
|
+
require_relative "list_sessions"
|
|
20
|
+
require_relative "send_note"
|
|
21
|
+
require_relative "delegate"
|
|
22
|
+
require_relative "delegate_result"
|
|
23
|
+
require_relative "ask_user_question"
|
|
24
|
+
|
|
25
|
+
module Samagotchi
|
|
26
|
+
module Tools
|
|
27
|
+
# chi's own tools as Registry entries: each schema from
|
|
28
|
+
# ToolDeclarations::TOOL_SCHEMAS, with a handler (call, kctx) that runs
|
|
29
|
+
# its class. kctx (KernelLoop::ToolContext) gives what some need from the
|
|
30
|
+
# kernel: the reminder store, the peers, the model key, the muted memory
|
|
31
|
+
# read and the ask-user flow.
|
|
32
|
+
module Builtins
|
|
33
|
+
CLASSES = [
|
|
34
|
+
Execute,
|
|
35
|
+
Read,
|
|
36
|
+
Write,
|
|
37
|
+
MemoryRead,
|
|
38
|
+
MemoryWrite,
|
|
39
|
+
Edit,
|
|
40
|
+
TaskCreate,
|
|
41
|
+
TaskGet,
|
|
42
|
+
TaskList,
|
|
43
|
+
TaskStop,
|
|
44
|
+
TaskWait,
|
|
45
|
+
WebFetch,
|
|
46
|
+
RegisterReminder,
|
|
47
|
+
CancelReminder,
|
|
48
|
+
ListReminders,
|
|
49
|
+
ListSessions,
|
|
50
|
+
SendNote,
|
|
51
|
+
Delegate,
|
|
52
|
+
DelegateResult,
|
|
53
|
+
AskUserQuestion
|
|
54
|
+
].freeze
|
|
55
|
+
|
|
56
|
+
# The handlers that pass more than the call's content.
|
|
57
|
+
HANDLERS = {
|
|
58
|
+
MemoryRead::NAME => ->(call, kctx) { kctx.muted_memory_read(call) },
|
|
59
|
+
MemoryWrite::NAME => lambda do |call, kctx|
|
|
60
|
+
MemoryWrite.call(call[:content], path: call[:path], scope: call[:scope], description: call[:description],
|
|
61
|
+
current_model_only: truthy?(call[:current_model_only]),
|
|
62
|
+
model_key: kctx.model_key)
|
|
63
|
+
end,
|
|
64
|
+
Write::NAME => ->(call, _kctx) { Write.call(call[:content], path: call[:path]) },
|
|
65
|
+
Read::NAME => lambda do |call, _kctx|
|
|
66
|
+
Read.call(call[:content], start_line: call[:start_line], end_line: call[:end_line])
|
|
67
|
+
end,
|
|
68
|
+
Edit::NAME => lambda do |call, _kctx|
|
|
69
|
+
Edit.call(call[:content], path: call[:path], start_line: call[:start_line], end_line: call[:end_line])
|
|
70
|
+
end,
|
|
71
|
+
TaskCreate::NAME => ->(call, _kctx) { TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env]) },
|
|
72
|
+
TaskWait::NAME => lambda do |call, _kctx|
|
|
73
|
+
TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
|
|
74
|
+
done_pattern: call[:done_pattern])
|
|
75
|
+
end,
|
|
76
|
+
Execute::NAME => ->(call, _kctx) { Execute.call(call[:content], cwd: call[:cwd]) },
|
|
77
|
+
RegisterReminder::NAME => lambda do |call, kctx|
|
|
78
|
+
RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
|
|
79
|
+
interval_minutes: call[:interval_minutes])
|
|
80
|
+
end,
|
|
81
|
+
CancelReminder::NAME => ->(call, kctx) { CancelReminder.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
82
|
+
ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
83
|
+
ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
|
|
84
|
+
SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
|
|
85
|
+
Delegate::NAME => lambda do |call, kctx|
|
|
86
|
+
Delegate.call(call[:content], model: call[:model], session: call[:session], wait: call[:wait],
|
|
87
|
+
timeout: call[:timeout], peers: kctx.peers)
|
|
88
|
+
end,
|
|
89
|
+
DelegateResult::NAME => lambda do |call, kctx|
|
|
90
|
+
DelegateResult.call(call[:content], session: call[:session], timeout: call[:timeout], peers: kctx.peers)
|
|
91
|
+
end,
|
|
92
|
+
AskUserQuestion::NAME => ->(call, kctx) { kctx.ask_user_question(call) }
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
module_function
|
|
96
|
+
|
|
97
|
+
# @return [Registry] a new registry with the built-ins, in
|
|
98
|
+
# TOOL_SCHEMAS order (an Engine's own, which bundles add to)
|
|
99
|
+
def registry
|
|
100
|
+
by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
|
|
101
|
+
ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, registry|
|
|
102
|
+
klass = by_name.fetch(schema[:name])
|
|
103
|
+
handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
|
|
104
|
+
registry.register(klass::NAME, schema: schema, handler: handler)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Registry] the built-ins alone, frozen: a KernelLoop or a
|
|
109
|
+
# ChatLoop without an Engine's registry uses it
|
|
110
|
+
def default
|
|
111
|
+
@default ||= registry.freeze
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def truthy?(val)
|
|
115
|
+
return true if val == true
|
|
116
|
+
return false if val == false || val.nil?
|
|
117
|
+
|
|
118
|
+
val.to_s.strip.downcase == "true"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../reminder_store"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Tools
|
|
7
|
+
# Cancels a previously registered reminder by name.
|
|
8
|
+
class CancelReminder
|
|
9
|
+
NAME = "cancel_reminder"
|
|
10
|
+
|
|
11
|
+
def self.name = NAME
|
|
12
|
+
|
|
13
|
+
def self.call(content, reminder_store: nil)
|
|
14
|
+
return "Error: reminder_store not configured" unless reminder_store
|
|
15
|
+
|
|
16
|
+
name = content.to_s.strip
|
|
17
|
+
reminder_store.cancel(name)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../model_profile"
|
|
6
|
+
require_relative "peers"
|
|
7
|
+
require_relative "delegate_wait"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
11
|
+
# requires KernelLoop and so these tools (a require cycle otherwise).
|
|
12
|
+
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
13
|
+
|
|
14
|
+
module Tools
|
|
15
|
+
# Hand a task to a child session: an ordinary chi session in its own
|
|
16
|
+
# worker, started in this session's folder with the task as its first
|
|
17
|
+
# user message and the `delegated` system memory preloaded. It shows in
|
|
18
|
+
# every list as a child of this one and the user can attach to it. With
|
|
19
|
+
# session:, a follow-up to a child that exists. Only the child's final
|
|
20
|
+
# reply comes back (DelegateWait), never its trace.
|
|
21
|
+
class Delegate
|
|
22
|
+
NAME = "delegate"
|
|
23
|
+
CLIENT_PREFIX = "delegate"
|
|
24
|
+
MAX_CHILDREN_KEY = "session.max_children"
|
|
25
|
+
MAX_CHILDREN_DEFAULT = 4
|
|
26
|
+
# A child spawned this many seconds ago counts as running even before
|
|
27
|
+
# its worker holds the owner lock: two delegate calls in one model
|
|
28
|
+
# message come milliseconds apart, and the first child's worker is
|
|
29
|
+
# still starting when the second call counts.
|
|
30
|
+
STARTING_GRACE_SECONDS = 15
|
|
31
|
+
# The memory every child starts with, scoped so a project memory of
|
|
32
|
+
# the same name cannot shadow it.
|
|
33
|
+
CHILD_MEMORIES = ["system/delegated"].freeze
|
|
34
|
+
|
|
35
|
+
def self.name = NAME
|
|
36
|
+
|
|
37
|
+
# @param content [String] the task (the child's user message, verbatim)
|
|
38
|
+
# @param model [String, nil] the child's model (an alias works); the parent's by default
|
|
39
|
+
# @param session [String, nil] a child's id or prefix: send the task there instead
|
|
40
|
+
# @param wait [Boolean, String, nil] wait for the reply (default true)
|
|
41
|
+
# @param timeout [Integer, String, nil] seconds to wait (default DelegateWait::TIMEOUT_DEFAULT)
|
|
42
|
+
# @param peers [Peers, nil]
|
|
43
|
+
def self.call(content, model: nil, session: nil, wait: nil, timeout: nil, peers: nil)
|
|
44
|
+
return "Error: this session's id is not known here" unless peers&.session_id
|
|
45
|
+
|
|
46
|
+
task = content.to_s.strip
|
|
47
|
+
return "Error: give the task, the child's first message" if task.empty?
|
|
48
|
+
|
|
49
|
+
sd = peers.state_dir || Session.default_state_dir
|
|
50
|
+
parent = Session.load(peers.session_id, state_dir: sd)
|
|
51
|
+
wait = parse_wait(wait)
|
|
52
|
+
timeout = parse_timeout(timeout)
|
|
53
|
+
|
|
54
|
+
child_id = if session.to_s.strip.empty?
|
|
55
|
+
start_child(task, parent: parent, model: model, state_dir: sd)
|
|
56
|
+
else
|
|
57
|
+
follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
|
|
58
|
+
end
|
|
59
|
+
return child_id if child_id.start_with?("Error:")
|
|
60
|
+
return DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
|
|
61
|
+
|
|
62
|
+
started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
|
|
63
|
+
"session: #{child_id}\nstatus: running\n#{started}; delegate_result waits for its reply. " \
|
|
64
|
+
"It shows in chi sessions list and the web as a child of this session; the user can attach to it."
|
|
65
|
+
rescue ArgumentError => e
|
|
66
|
+
"Error: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [String] the new child's id, or an Error: line
|
|
70
|
+
def self.start_child(task, parent:, model:, state_dir:)
|
|
71
|
+
if parent.parent_id
|
|
72
|
+
return "Error: this session is a delegate of #{parent.parent_id}; delegated sessions don't delegate further"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
running = running_children(parent.id, state_dir: state_dir)
|
|
76
|
+
max = max_children
|
|
77
|
+
if running.size >= max
|
|
78
|
+
ids = running.map { |s| s[:short_id] }.join(", ")
|
|
79
|
+
return "Error: #{running.size} delegate#{"s" if running.size != 1} of this session #{running.size == 1 ? "is" : "are"} running " \
|
|
80
|
+
"(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. delegate_result waits for one; `chi sessions stop ID` stops one."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
|
|
84
|
+
model_name: child_model(model, parent), memories: CHILD_MEMORIES,
|
|
85
|
+
parent_id: parent.id, state_dir: state_dir)
|
|
86
|
+
child.id
|
|
87
|
+
end
|
|
88
|
+
private_class_method :start_child
|
|
89
|
+
|
|
90
|
+
# @return [String] the child's id, or an Error: line
|
|
91
|
+
def self.follow_up(task, session:, parent:, state_dir:)
|
|
92
|
+
id = Session.resolve_id(session, state_dir: state_dir)
|
|
93
|
+
child = begin
|
|
94
|
+
Session.load(id, state_dir: state_dir)
|
|
95
|
+
rescue ArgumentError
|
|
96
|
+
return "Error: no session #{session} (list_sessions shows them)"
|
|
97
|
+
end
|
|
98
|
+
unless child.parent_id == parent.id
|
|
99
|
+
return "Error: #{id[0, 8]} is not a delegate of this session; send_note reaches any session"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Only a reply after this delivery counts as the follow-up's.
|
|
103
|
+
DelegateWait.mark_seen(parent.id, id, state_dir: state_dir)
|
|
104
|
+
delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{CLIENT_PREFIX}:#{parent.id[0, 8]}",
|
|
105
|
+
state_dir: state_dir)
|
|
106
|
+
case delivered[:status]
|
|
107
|
+
when :accepted then id
|
|
108
|
+
when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
|
|
109
|
+
when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
|
|
110
|
+
else "Error: the message to session #{id[0, 8]} could not be written"
|
|
111
|
+
end
|
|
112
|
+
rescue SessionManager::OwnedByTUI
|
|
113
|
+
"Error: session #{id[0, 8]} is open in a chi REPL, which can't take a delegated message"
|
|
114
|
+
rescue Session::AmbiguousId => e
|
|
115
|
+
"Error: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
private_class_method :follow_up
|
|
118
|
+
|
|
119
|
+
# --model as the REPL reads it: an alias resolved, a host prefix kept.
|
|
120
|
+
# No existence check: an unknown model fails the child's first turn.
|
|
121
|
+
def self.child_model(model, parent)
|
|
122
|
+
return parent.model_name if model.to_s.strip.empty?
|
|
123
|
+
|
|
124
|
+
ModelProfile.required_model_name(ConfigFile.resolve_model_alias(model.to_s.strip))
|
|
125
|
+
end
|
|
126
|
+
private_class_method :child_model
|
|
127
|
+
|
|
128
|
+
# The children of +parent_id+ that count against session.max_children
|
|
129
|
+
# (a plugin's ctx.sessions.fork counts them too).
|
|
130
|
+
# @return [Array<Hash>] SessionManager.children_of rows
|
|
131
|
+
def self.running_children(parent_id, state_dir:)
|
|
132
|
+
SessionManager.children_of(parent_id, state_dir: state_dir).select { |s| running?(s) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# busy (a worker runs its turn), or running with a worker on its way.
|
|
136
|
+
def self.running?(summary)
|
|
137
|
+
return true if summary[:busy]
|
|
138
|
+
return false unless summary[:status] == Session::STATUS_RUNNING && !summary[:live]
|
|
139
|
+
|
|
140
|
+
Time.now - Time.iso8601(summary[:updated_at].to_s) < STARTING_GRACE_SECONDS
|
|
141
|
+
rescue ArgumentError
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
private_class_method :running?
|
|
145
|
+
|
|
146
|
+
def self.max_children
|
|
147
|
+
value = Integer(Config.get(MAX_CHILDREN_KEY), exception: false)
|
|
148
|
+
value&.positive? ? value : MAX_CHILDREN_DEFAULT
|
|
149
|
+
rescue StandardError
|
|
150
|
+
MAX_CHILDREN_DEFAULT
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# true unless told otherwise; text-based parsers hand strings over.
|
|
154
|
+
def self.parse_wait(value)
|
|
155
|
+
return true if value.nil? || value.to_s.strip.empty?
|
|
156
|
+
return value if value == true || value == false
|
|
157
|
+
|
|
158
|
+
!%w[false 0 no off].include?(value.to_s.strip.downcase)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.parse_timeout(value)
|
|
162
|
+
parsed = Integer(value.to_s.strip, exception: false)
|
|
163
|
+
parsed&.positive? ? parsed : DelegateWait::TIMEOUT_DEFAULT
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "peers"
|
|
5
|
+
require_relative "delegate"
|
|
6
|
+
require_relative "delegate_wait"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
10
|
+
# requires KernelLoop and so these tools (a require cycle otherwise).
|
|
11
|
+
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
12
|
+
|
|
13
|
+
module Tools
|
|
14
|
+
# Wait for a delegated session's next reply: the one named, or this
|
|
15
|
+
# session's newest running child.
|
|
16
|
+
class DelegateResult
|
|
17
|
+
NAME = "delegate_result"
|
|
18
|
+
|
|
19
|
+
def self.name = NAME
|
|
20
|
+
|
|
21
|
+
# @param session [String, nil] a child's id or prefix
|
|
22
|
+
# @param timeout [Integer, String, nil] seconds (default DelegateWait::TIMEOUT_DEFAULT)
|
|
23
|
+
# @param peers [Peers, nil]
|
|
24
|
+
def self.call(_content = nil, session: nil, timeout: nil, peers: nil)
|
|
25
|
+
return "Error: this session's id is not known here" unless peers&.session_id
|
|
26
|
+
|
|
27
|
+
sd = peers.state_dir || Session.default_state_dir
|
|
28
|
+
child_id = if session.to_s.strip.empty?
|
|
29
|
+
newest_running_child(peers.session_id, state_dir: sd) ||
|
|
30
|
+
(return "No running delegate; list_sessions shows finished ones.")
|
|
31
|
+
else
|
|
32
|
+
id = Session.resolve_id(session.to_s.strip, state_dir: sd)
|
|
33
|
+
child = Session.load(id, state_dir: sd)
|
|
34
|
+
unless child.parent_id == peers.session_id
|
|
35
|
+
return "Error: #{id[0, 8]} is not a delegate of this session (list_sessions marks them child)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
id
|
|
39
|
+
end
|
|
40
|
+
DelegateWait.call(child_id, peers: peers, timeout: Delegate.parse_timeout(timeout))
|
|
41
|
+
rescue Session::AmbiguousId => e
|
|
42
|
+
"Error: #{e.message}"
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
"Error: no session #{session.to_s.strip} (list_sessions shows them)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.newest_running_child(parent_id, state_dir:)
|
|
48
|
+
SessionManager.children_of(parent_id, state_dir: state_dir).find { |s| s[:busy] }&.fetch(:id)
|
|
49
|
+
end
|
|
50
|
+
private_class_method :newest_running_child
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "output_guardrails"
|
|
5
|
+
require_relative "peers"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
9
|
+
# requires KernelLoop and so these tools (a require cycle otherwise).
|
|
10
|
+
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
11
|
+
|
|
12
|
+
module Tools
|
|
13
|
+
# Waiting for a delegated session's next reply, shared by delegate and
|
|
14
|
+
# delegate_result. A reply is the child's output/<timestamp>.txt file
|
|
15
|
+
# (the worker writes one per turn that ended with visible text, just
|
|
16
|
+
# before its idle save), so "new" means a filename past the cursor: the
|
|
17
|
+
# newest one this parent was already given. The cursors live in this
|
|
18
|
+
# process (a worker runs one session), keyed by parent and child; a
|
|
19
|
+
# worker respawn loses them, which only repeats the newest reply once.
|
|
20
|
+
module DelegateWait
|
|
21
|
+
TIMEOUT_DEFAULT = 600
|
|
22
|
+
POLL_INTERVAL = 0.5
|
|
23
|
+
# Cut like an execute result: full up to TRUNCATE_AT bytes, else a
|
|
24
|
+
# head-and-tail preview of PREVIEW bytes.
|
|
25
|
+
TRUNCATE_AT_BYTES = OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES
|
|
26
|
+
PREVIEW_BYTES = OutputGuardrails::DEFAULT_PREVIEW_BYTES
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @return [Hash{Array(String, String) => String}] [parent id, child id] → newest reply filename given
|
|
31
|
+
def seen
|
|
32
|
+
@seen ||= {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param child_id [String] the child's full id
|
|
36
|
+
# @param peers [Peers] the parent (session_id, state_dir, cancelled?)
|
|
37
|
+
# @param timeout [Integer] seconds
|
|
38
|
+
# @param poll_interval [Float]
|
|
39
|
+
# @return [String] the tool result
|
|
40
|
+
def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
|
|
41
|
+
sd = peers.state_dir || Session.default_state_dir
|
|
42
|
+
key = [peers.session_id, child_id]
|
|
43
|
+
cursor = seen[key]
|
|
44
|
+
deadline = monotonic + timeout.to_i
|
|
45
|
+
seen_running = false
|
|
46
|
+
|
|
47
|
+
loop do
|
|
48
|
+
session = Session.load(child_id, state_dir: sd)
|
|
49
|
+
|
|
50
|
+
if (file = newest_reply(child_id, state_dir: sd)) && newer?(file, cursor)
|
|
51
|
+
seen[key] = file
|
|
52
|
+
return reply_result(child_id, File.read(File.join(reply_dir(child_id, state_dir: sd), file)))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
case session.status
|
|
56
|
+
when Session::STATUS_ERROR
|
|
57
|
+
return result(child_id, "error", "the child's worker failed: #{session.last_prompt.to_s.strip}; its session shows what happened")
|
|
58
|
+
when Session::STATUS_STOPPED
|
|
59
|
+
return result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if (pending = session.pending_question)
|
|
63
|
+
return result(child_id, "waiting_for_answer", waiting_text(child_id, pending))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if peers.respond_to?(:cancelled?) && peers.cancelled?
|
|
67
|
+
return result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if session.status == Session::STATUS_RUNNING
|
|
71
|
+
seen_running = true
|
|
72
|
+
elsif seen_running
|
|
73
|
+
# It ran and is idle again with no new reply: canceled, failed or
|
|
74
|
+
# empty. Before it was ever seen running, idle means a
|
|
75
|
+
# file-delivered follow-up its worker has not picked up yet.
|
|
76
|
+
return result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
return timeout_result(child_id, timeout) if monotonic > deadline
|
|
80
|
+
|
|
81
|
+
sleep(poll_interval)
|
|
82
|
+
end
|
|
83
|
+
rescue ArgumentError => e
|
|
84
|
+
"Error: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [String, nil] the newest reply filename (sortable timestamps)
|
|
88
|
+
def newest_reply(child_id, state_dir:)
|
|
89
|
+
dir = reply_dir(child_id, state_dir: state_dir)
|
|
90
|
+
return nil unless Dir.exist?(dir)
|
|
91
|
+
|
|
92
|
+
Dir.children(dir).select { |f| f.end_with?(".txt") }.max
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Point the cursor at the newest reply now, so only a later one counts
|
|
96
|
+
# (a follow-up sent to a child that already answered).
|
|
97
|
+
def mark_seen(parent_id, child_id, state_dir:)
|
|
98
|
+
seen[[parent_id, child_id]] = newest_reply(child_id, state_dir: state_dir)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reply_dir(child_id, state_dir:)
|
|
102
|
+
File.join(Session.session_dir(child_id, state_dir: state_dir), SessionManager::OUTPUT_DIR)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def newer?(file, cursor)
|
|
106
|
+
cursor.nil? || file > cursor
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reply_result(child_id, text)
|
|
110
|
+
"session: #{child_id}\nstatus: done\n---\n#{cut(text)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def result(child_id, status, text)
|
|
114
|
+
"session: #{child_id}\nstatus: #{status}\n#{text}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def timeout_result(child_id, timeout)
|
|
118
|
+
result(child_id, "running",
|
|
119
|
+
"no reply yet after #{timeout.to_i} s; the child keeps running. delegate_result #{child_id} waits again; " \
|
|
120
|
+
"chi --attach #{child_id} shows it.")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def waiting_text(child_id, pending)
|
|
124
|
+
what = pending[:kind].to_s == "approval" ? "an approval" : "a question"
|
|
125
|
+
"Child #{child_id} is waiting for an answer (#{what}); attach with chi --attach #{child_id} or answer it in the web. " \
|
|
126
|
+
"delegate_result #{child_id} waits again once it is answered."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def cut(text)
|
|
130
|
+
text = OutputGuardrails.safe_utf8(text)
|
|
131
|
+
return text if text.bytesize <= TRUNCATE_AT_BYTES
|
|
132
|
+
|
|
133
|
+
preview = OutputGuardrails.head_tail_from_string(content: text, preview_bytes: PREVIEW_BYTES)
|
|
134
|
+
[
|
|
135
|
+
"truncated=true",
|
|
136
|
+
"preview_strategy=head_tail",
|
|
137
|
+
"reply_bytes=#{text.bytesize}",
|
|
138
|
+
"returned_preview_bytes=#{preview[:returned_preview_bytes]}",
|
|
139
|
+
"omitted_bytes=#{preview[:omitted_bytes]}",
|
|
140
|
+
"[TRUNCATED_PREVIEW_HEAD]",
|
|
141
|
+
preview[:head],
|
|
142
|
+
"[... omitted #{preview[:omitted_bytes]} bytes ...]",
|
|
143
|
+
"[TRUNCATED_PREVIEW_TAIL]",
|
|
144
|
+
preview[:tail]
|
|
145
|
+
].join("\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def monotonic
|
|
149
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "output_guardrails"
|
|
4
|
+
require_relative "tool_path"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Tools
|
|
8
|
+
# Replaces an exact block of text in an existing file.
|
|
9
|
+
#
|
|
10
|
+
# XML syntax (used by the model):
|
|
11
|
+
# <tool name="edit" path="path/to/file">
|
|
12
|
+
# <old>exact text to replace</old>
|
|
13
|
+
# <new>replacement text</new>
|
|
14
|
+
# </tool>
|
|
15
|
+
#
|
|
16
|
+
# The <old> block must match exactly once in the file. The tool returns an
|
|
17
|
+
# error if the text is not found or appears more than once.
|
|
18
|
+
class Edit
|
|
19
|
+
NAME = "edit"
|
|
20
|
+
|
|
21
|
+
def self.name = NAME
|
|
22
|
+
|
|
23
|
+
def self.call(content, path:, start_line: nil, end_line: nil)
|
|
24
|
+
path = ToolPath.normalize(path)
|
|
25
|
+
|
|
26
|
+
if range_requested?(start_line, end_line)
|
|
27
|
+
return call_range_mode(content, path: path, start_line: start_line, end_line: end_line)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
old_text = extract_tag(content, "old")
|
|
31
|
+
new_text = extract_tag(content, "new")
|
|
32
|
+
|
|
33
|
+
return "Error: missing <old>...</old> block" if old_text.nil?
|
|
34
|
+
return "Error: missing <new>...</new> block" if new_text.nil?
|
|
35
|
+
return "Error: <old> block is empty" if old_text.empty?
|
|
36
|
+
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
37
|
+
|
|
38
|
+
source = File.read(path)
|
|
39
|
+
count = count_occurrences(source, old_text)
|
|
40
|
+
|
|
41
|
+
return "Error: old text not found in #{path}" if count == 0
|
|
42
|
+
return "Error: old text matches #{count} times in #{path}; make it unique" if count > 1
|
|
43
|
+
|
|
44
|
+
idx = source.index(old_text)
|
|
45
|
+
updated = source[0, idx] + new_text + source[idx + old_text.length..]
|
|
46
|
+
File.write(path, updated)
|
|
47
|
+
"Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"
|
|
48
|
+
rescue => e
|
|
49
|
+
"Error: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.call_range_mode(content, path:, start_line:, end_line:)
|
|
53
|
+
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
54
|
+
|
|
55
|
+
new_text = extract_tag(content, "new")
|
|
56
|
+
return "Error: missing <new>...</new> block" if new_text.nil?
|
|
57
|
+
|
|
58
|
+
start_num = parse_positive_line_number(start_line, "start_line")
|
|
59
|
+
return start_num if start_num.is_a?(String)
|
|
60
|
+
return "Error: start_line must be provided for range edits" if start_num.nil?
|
|
61
|
+
|
|
62
|
+
source = File.read(path)
|
|
63
|
+
lines = source.lines
|
|
64
|
+
total_lines = lines.length
|
|
65
|
+
|
|
66
|
+
# Option C: end_line is optional and means "to EOF" when omitted
|
|
67
|
+
# (unless SAMAGOTCHI_EDIT_END_OPTIONAL=false).
|
|
68
|
+
end_provided = !blank?(end_line)
|
|
69
|
+
end_num = end_provided ? parse_positive_line_number(end_line, "end_line") : nil
|
|
70
|
+
return end_num if end_num.is_a?(String)
|
|
71
|
+
|
|
72
|
+
# Option B: an end_line that overshoots EOF is clamped here and flagged
|
|
73
|
+
# in the result so the agent never mutates a different span silently.
|
|
74
|
+
# Set SAMAGOTCHI_EDIT_ALLOW_OOR_END=false to keep the hard error instead.
|
|
75
|
+
clamp_note = ""
|
|
76
|
+
end_value = nil
|
|
77
|
+
if end_provided
|
|
78
|
+
if end_num > total_lines
|
|
79
|
+
if OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_ALLOW_OOR_END", default: true)
|
|
80
|
+
clamp_note = " (end_line #{end_num} exceeds #{total_lines} lines; clamped to line #{total_lines})"
|
|
81
|
+
end_value = total_lines
|
|
82
|
+
else
|
|
83
|
+
return "Error: range out of bounds for #{path}: file has #{total_lines} lines"
|
|
84
|
+
end
|
|
85
|
+
else
|
|
86
|
+
end_value = end_num
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
unless OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_END_OPTIONAL", default: true)
|
|
90
|
+
return "Error: start_line and end_line must both be provided for range edits"
|
|
91
|
+
end
|
|
92
|
+
end_value = total_lines
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# start past EOF is genuinely unusable -> keep a hard error.
|
|
96
|
+
return "Error: start_line #{start_num} out of bounds for #{path}: file has #{total_lines} lines" if start_num > total_lines
|
|
97
|
+
return "Error: start_line must be <= end_line" if start_num > end_value
|
|
98
|
+
|
|
99
|
+
prefix = lines[0, start_num - 1].join
|
|
100
|
+
suffix = lines[end_value..]&.join.to_s
|
|
101
|
+
# Ensure new_text ends with a newline when a suffix follows so that the
|
|
102
|
+
# first suffix line isn't concatenated onto the last replacement line.
|
|
103
|
+
normalized = (!new_text.empty? && !suffix.empty? && !new_text.end_with?("\n")) ? new_text + "\n" : new_text
|
|
104
|
+
updated = prefix + normalized + suffix
|
|
105
|
+
File.write(path, updated)
|
|
106
|
+
|
|
107
|
+
replaced_lines = (end_value - start_num) + 1
|
|
108
|
+
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}"
|
|
110
|
+
rescue => e
|
|
111
|
+
"Error: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
private_class_method :call_range_mode
|
|
114
|
+
|
|
115
|
+
# Count non-overlapping literal occurrences of +needle+ in +haystack+.
|
|
116
|
+
def self.count_occurrences(haystack, needle)
|
|
117
|
+
count = 0
|
|
118
|
+
pos = 0
|
|
119
|
+
while (i = haystack.index(needle, pos))
|
|
120
|
+
count += 1
|
|
121
|
+
break if count > 1 # early exit — we only care whether it's 0, 1, or >1
|
|
122
|
+
pos = i + needle.length
|
|
123
|
+
end
|
|
124
|
+
count
|
|
125
|
+
end
|
|
126
|
+
private_class_method :count_occurrences
|
|
127
|
+
|
|
128
|
+
def self.extract_tag(content, tag)
|
|
129
|
+
m = content.match(/<#{tag}>(.*?)<\/#{tag}>/m)
|
|
130
|
+
m ? m[1] : nil
|
|
131
|
+
end
|
|
132
|
+
private_class_method :extract_tag
|
|
133
|
+
|
|
134
|
+
def self.range_requested?(start_line, end_line)
|
|
135
|
+
!blank?(start_line) || !blank?(end_line)
|
|
136
|
+
end
|
|
137
|
+
private_class_method :range_requested?
|
|
138
|
+
|
|
139
|
+
def self.parse_positive_line_number(value, key)
|
|
140
|
+
return nil if blank?(value)
|
|
141
|
+
|
|
142
|
+
integer = Integer(value.to_s.strip, exception: false)
|
|
143
|
+
return "Error: #{key} must be a positive integer" unless integer&.positive?
|
|
144
|
+
|
|
145
|
+
integer
|
|
146
|
+
end
|
|
147
|
+
private_class_method :parse_positive_line_number
|
|
148
|
+
|
|
149
|
+
def self.blank?(value)
|
|
150
|
+
value.nil? || value.to_s.strip.empty?
|
|
151
|
+
end
|
|
152
|
+
private_class_method :blank?
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|