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,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The single-owner lock of a session: whichever process runs the session's
|
|
9
|
+
# Engine (a SessionManager worker or the in-process TUI) holds an exclusive
|
|
10
|
+
# flock on `<session_dir>/owner.lock` for its lifetime. A second would-be
|
|
11
|
+
# owner backs off, so one session never gets two Engines.
|
|
12
|
+
#
|
|
13
|
+
# flock locks belong to the open file description: the kernel releases them
|
|
14
|
+
# when the owner exits (however it dies), Ruby opens files close-on-exec so a
|
|
15
|
+
# spawned grandchild never inherits one, and probing from another descriptor
|
|
16
|
+
# (even in the owner's own process) never releases the owner's lock.
|
|
17
|
+
class OwnerLock
|
|
18
|
+
FILE = "owner.lock"
|
|
19
|
+
DEFAULT_WAIT = 2.0
|
|
20
|
+
RETRY_INTERVAL = 0.05
|
|
21
|
+
OWNER_READ_ATTEMPTS = 10
|
|
22
|
+
|
|
23
|
+
# Take the lock, retrying for up to +wait+ seconds (another process's
|
|
24
|
+
# #owner probe holds it for a moment). On success the owner's pid and kind
|
|
25
|
+
# are written into the lock file for #owner to report.
|
|
26
|
+
# @param session_dir [String]
|
|
27
|
+
# @param kind [String] "worker" or "tui"
|
|
28
|
+
# @return [OwnerLock, nil] nil when another owner holds it
|
|
29
|
+
def self.acquire(session_dir, kind:, wait: DEFAULT_WAIT)
|
|
30
|
+
FileUtils.mkdir_p(session_dir)
|
|
31
|
+
file = File.open(path(session_dir), File::RDWR | File::CREAT, 0o644)
|
|
32
|
+
deadline = monotonic_now + wait.to_f
|
|
33
|
+
until file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
34
|
+
if monotonic_now >= deadline
|
|
35
|
+
file.close
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
sleep(RETRY_INTERVAL)
|
|
39
|
+
end
|
|
40
|
+
new(file, kind: kind)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The current owner, read without disturbing it.
|
|
44
|
+
# @param session_dir [String]
|
|
45
|
+
# @return [Hash, nil] {"pid", "kind", "started_at"} while held (values may
|
|
46
|
+
# be missing for a moment right after acquisition), nil when free
|
|
47
|
+
def self.owner(session_dir)
|
|
48
|
+
File.open(path(session_dir), File::RDONLY) do |file|
|
|
49
|
+
if file.flock(File::LOCK_SH | File::LOCK_NB)
|
|
50
|
+
file.flock(File::LOCK_UN)
|
|
51
|
+
return nil
|
|
52
|
+
end
|
|
53
|
+
# A new owner truncates, then writes: retry briefly rather than
|
|
54
|
+
# report an owner of unknown kind.
|
|
55
|
+
data = {}
|
|
56
|
+
OWNER_READ_ATTEMPTS.times do
|
|
57
|
+
data = parse(File.read(file.path))
|
|
58
|
+
break unless data.empty?
|
|
59
|
+
|
|
60
|
+
sleep(RETRY_INTERVAL / 5)
|
|
61
|
+
end
|
|
62
|
+
data
|
|
63
|
+
end
|
|
64
|
+
rescue Errno::ENOENT
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] whether this session has ever had a lock-taking owner
|
|
69
|
+
# (workers from before the lock only left a pid file)
|
|
70
|
+
def self.lock_file?(session_dir)
|
|
71
|
+
File.exist?(path(session_dir))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.path(session_dir)
|
|
75
|
+
File.join(session_dir, FILE)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.parse(raw)
|
|
79
|
+
data = JSON.parse(raw.to_s)
|
|
80
|
+
data.is_a?(Hash) ? data : {}
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
{}
|
|
83
|
+
end
|
|
84
|
+
private_class_method :parse
|
|
85
|
+
|
|
86
|
+
def self.monotonic_now
|
|
87
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
88
|
+
end
|
|
89
|
+
private_class_method :monotonic_now
|
|
90
|
+
|
|
91
|
+
attr_reader :kind
|
|
92
|
+
|
|
93
|
+
def initialize(file, kind:)
|
|
94
|
+
@file = file
|
|
95
|
+
@kind = kind.to_s
|
|
96
|
+
@file.truncate(0)
|
|
97
|
+
@file.rewind
|
|
98
|
+
@file.write(JSON.generate("pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3)))
|
|
99
|
+
@file.flush
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Release the lock (process exit releases it too).
|
|
103
|
+
def release
|
|
104
|
+
return if @file.closed?
|
|
105
|
+
|
|
106
|
+
@file.flock(File::LOCK_UN)
|
|
107
|
+
@file.close
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Thread-safe FIFO of user steering messages submitted while a turn is
|
|
5
|
+
# running. UIs (TUI, web, background workers) are push-only producers;
|
|
6
|
+
# KernelLoop drains the queue at iteration boundaries and injects the
|
|
7
|
+
# messages into the conversation. True mid-stream injection is impossible
|
|
8
|
+
# with llama.cpp's /completion API, so draining only happens between full
|
|
9
|
+
# LLM responses / tool dispatches.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# queue = PendingInputQueue.new
|
|
13
|
+
# queue.push("please also check the specs")
|
|
14
|
+
# kernel.run(messages, pending_input: queue.method(:drain))
|
|
15
|
+
class PendingInputQueue
|
|
16
|
+
def initialize
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@messages = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Append a message to the queue. Nil/empty strings are ignored.
|
|
22
|
+
def push(text)
|
|
23
|
+
text = text.to_s
|
|
24
|
+
return if text.empty?
|
|
25
|
+
|
|
26
|
+
@mutex.synchronize { @messages << text }
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Remove and return all queued messages in FIFO order. Non-blocking.
|
|
31
|
+
# @return [Array<String>]
|
|
32
|
+
def drain
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
drained = @messages
|
|
35
|
+
@messages = []
|
|
36
|
+
drained
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def empty?
|
|
41
|
+
@mutex.synchronize { @messages.empty? }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def size
|
|
45
|
+
@mutex.synchronize { @messages.size }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../log"
|
|
4
|
+
require_relative "../tools/args"
|
|
5
|
+
require_relative "service"
|
|
6
|
+
require_relative "tool_result"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Plugin
|
|
10
|
+
# What a plugin's #register(chi) gets (docs/plugins.md). Each call
|
|
11
|
+
# stages a registration and checks it; Loader commits them all once
|
|
12
|
+
# #register returns, so a plugin that raises halfway adds nothing.
|
|
13
|
+
class Api
|
|
14
|
+
COMMAND_NAME = %r{\A/[a-z][a-z0-9_-]{0,31}\z}
|
|
15
|
+
TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
|
|
16
|
+
PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
|
|
17
|
+
|
|
18
|
+
# @param bundle [String]
|
|
19
|
+
# @param label [String] "<file> (bundle <name>)": what hooks and
|
|
20
|
+
# failures are named by
|
|
21
|
+
# @param registries [Registries]
|
|
22
|
+
# @param context [Context, nil] what handlers get as ctx
|
|
23
|
+
def initialize(bundle:, label:, registries:, context: nil)
|
|
24
|
+
@bundle = bundle
|
|
25
|
+
@label = label
|
|
26
|
+
@registries = registries
|
|
27
|
+
@context = context
|
|
28
|
+
@hooks = []
|
|
29
|
+
@commands = []
|
|
30
|
+
@tools = []
|
|
31
|
+
@services = []
|
|
32
|
+
@inits = []
|
|
33
|
+
@committed = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The Context the plugin's handlers get, for #register itself: its
|
|
37
|
+
# settings, log, data_dir, and ctx.notify / ctx.card, which, shown
|
|
38
|
+
# while chi starts, wait for the first turn (beside the plugins' load
|
|
39
|
+
# warnings).
|
|
40
|
+
# @return [Context, nil]
|
|
41
|
+
def ctx = @context
|
|
42
|
+
|
|
43
|
+
# A slash command the session runs: the block gets the text after the
|
|
44
|
+
# name (stripped, "" for none) and the Context, and returns what to
|
|
45
|
+
# show (a String) or nil for nothing. A raise is shown as an error.
|
|
46
|
+
# @param name [String] "/name"; a name the session already has
|
|
47
|
+
# (built-in or another bundle's) is a load error
|
|
48
|
+
# @param anytime [Boolean] may run while a turn runs (from P2; today
|
|
49
|
+
# it runs as other commands do)
|
|
50
|
+
def command(name, description, anytime: false, &block)
|
|
51
|
+
raise ArgumentError, "command #{name.inspect} needs a block" unless block
|
|
52
|
+
name = name.to_s
|
|
53
|
+
raise ArgumentError, "command name #{name.inspect} must look like /name (a-z, 0-9, _ and -)" unless name.match?(COMMAND_NAME)
|
|
54
|
+
if (taken = @registries.commands.entries.find { |entry| entry.name == name })
|
|
55
|
+
raise ArgumentError, "command #{name} is already registered (#{taken.source})"
|
|
56
|
+
end
|
|
57
|
+
raise ArgumentError, "command #{name} is registered twice" if @commands.any? { |cmd| cmd[:name] == name }
|
|
58
|
+
|
|
59
|
+
@commands << { name: name, description: description.to_s, anytime: anytime ? true : false, block: block }
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A tool the model can call: the block gets the call's arguments (a
|
|
64
|
+
# frozen Hash, string keys, typed by the schema: see Tools::Args) and
|
|
65
|
+
# the Context, and returns the result text ("Error: …" marks a
|
|
66
|
+
# failure). A raise is the model's "Error: <message>".
|
|
67
|
+
# @param name [String] a-z, 0-9 and _; a name the session already has
|
|
68
|
+
# is a load error
|
|
69
|
+
# @param params [Hash] name => a JSON Schema property ({type:,
|
|
70
|
+
# description:, enum:, items:, properties:, …}) plus required: true
|
|
71
|
+
# @param schema [Hash, nil] instead of params: the parameters as one
|
|
72
|
+
# JSON Schema object ({type: "object", properties:, required: […]}),
|
|
73
|
+
# an MCP server's inputSchema for example
|
|
74
|
+
# @param label [String, nil] the activity line's action
|
|
75
|
+
# @param preview [#call, nil] args → the activity line's params
|
|
76
|
+
# @param targets [#call, nil] args → what guardrail rules match: a Hash
|
|
77
|
+
# with paths: (absolute or relative to the cwd), command: (a shell
|
|
78
|
+
# command) and cwd:, each optional
|
|
79
|
+
def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
|
|
80
|
+
spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
|
|
81
|
+
targets: targets, &block)
|
|
82
|
+
if (taken = @registries.tools[spec[:name]])
|
|
83
|
+
raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
|
|
84
|
+
end
|
|
85
|
+
raise ArgumentError, "tool #{spec[:name]} is registered twice" if @tools.any? { |tool| tool[:name] == spec[:name] }
|
|
86
|
+
|
|
87
|
+
@tools << spec
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The plugin's whole tool set, after #register (a plugin whose tools
|
|
92
|
+
# are known only later: an MCP server that listed them). The block
|
|
93
|
+
# gets a set whose #tool takes #tool's arguments; what it declares
|
|
94
|
+
# replaces the plugin's tools from the next turn on: its tools not in
|
|
95
|
+
# the set go, new or changed ones are registered, and the system
|
|
96
|
+
# prompts are built again if anything changed. Safe from any thread:
|
|
97
|
+
# the set is staged, and the turn thread applies it before the
|
|
98
|
+
# turn's first model request. A name another bundle (or chi) has is
|
|
99
|
+
# left out, with a notice.
|
|
100
|
+
# @raise [ArgumentError] a bad tool (as #tool), or called in #register
|
|
101
|
+
def replace_tools
|
|
102
|
+
raise ArgumentError, "replace_tools needs a block" unless block_given?
|
|
103
|
+
raise ArgumentError, "replace_tools is for after register (use chi.tool there)" unless @committed
|
|
104
|
+
|
|
105
|
+
set = ToolSet.new
|
|
106
|
+
yield set
|
|
107
|
+
if (stage = @registries.stage_tools)
|
|
108
|
+
stage.call(@bundle, set.specs, @context)
|
|
109
|
+
elsif Api.apply_tools(@registries.tools, @bundle, set.specs, @context)[:changed]
|
|
110
|
+
tools_changed!
|
|
111
|
+
end
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# What #replace_tools' block declares tools on.
|
|
116
|
+
class ToolSet
|
|
117
|
+
# @return [Array<Hash>]
|
|
118
|
+
attr_reader :specs
|
|
119
|
+
|
|
120
|
+
def initialize
|
|
121
|
+
@specs = []
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# As Api#tool.
|
|
125
|
+
def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
|
|
126
|
+
spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
|
|
127
|
+
targets: targets, &block)
|
|
128
|
+
raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
|
|
129
|
+
|
|
130
|
+
@specs << spec
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Run the block on a hook event (docs/hooks.md: :before_turn,
|
|
136
|
+
# :after_turn, :before_tool_call, …), like a bundle's hooks/*.rb:
|
|
137
|
+
# the block gets the event hash, with event[:notify] and the other
|
|
138
|
+
# helpers, and the Context (a block may take the event alone). A
|
|
139
|
+
# block that raises is logged (not shown) and skipped.
|
|
140
|
+
# @param priority [Integer] lower runs first among bundle hooks
|
|
141
|
+
def on(event, priority: 100, &block)
|
|
142
|
+
raise ArgumentError, "on(#{event.inspect}) needs a block" unless block
|
|
143
|
+
raise ArgumentError, "on: the event must be a Symbol or String" unless event.is_a?(Symbol) || event.is_a?(String)
|
|
144
|
+
|
|
145
|
+
@hooks << { event: event.to_sym, priority: Integer(priority), block: block }
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A long-lived thing the plugin keeps (a server process): the block
|
|
150
|
+
# starts it and returns what #value gives; in it, svc.on_stop { }
|
|
151
|
+
# says how to stop it. It starts on first svc.value, or now with
|
|
152
|
+
# eager: true (a raise then fails the plugin's load, unless the
|
|
153
|
+
# plugin rescues it). The Engine stops its services when it shuts
|
|
154
|
+
# down (the REPL or the session's worker exits), newest first.
|
|
155
|
+
# @param name [String, Symbol] unique in the plugin
|
|
156
|
+
# @return [Service]
|
|
157
|
+
def service(name, eager: false, &block)
|
|
158
|
+
raise ArgumentError, "service #{name.inspect} needs a block" unless block
|
|
159
|
+
name = "#{@bundle}:#{name}"
|
|
160
|
+
raise ArgumentError, "service #{name} is registered twice" if @services.any? { |svc| svc.name == name }
|
|
161
|
+
raise ArgumentError, "this chi has no services (plugins: false)" unless @registries.services
|
|
162
|
+
|
|
163
|
+
service = @registries.services.add(Service.new(name, &block))
|
|
164
|
+
@services << service
|
|
165
|
+
service.start if eager
|
|
166
|
+
service
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Slow setup (downloading a model, indexing a repo, logging in, an
|
|
170
|
+
# MCP server's first start) that must not hold chi's start: the block
|
|
171
|
+
# runs on its own thread once the session's UI can show it, not in
|
|
172
|
+
# #register. It gets the Context (ctx.cancelled? says chi is shutting
|
|
173
|
+
# down) and returns a short summary ("3 tools") or raises (the UIs
|
|
174
|
+
# show a warn card). Every UI shows it running (the label) and done.
|
|
175
|
+
# @param label [String] what it does, shown while it runs
|
|
176
|
+
# @param provides_tools [Boolean] it registers tools (chi.replace_tools):
|
|
177
|
+
# a turn sent meanwhile waits for it before its first model request,
|
|
178
|
+
# up to +timeout+; a Ctrl-C ends the wait
|
|
179
|
+
# @param quiet [Boolean] shown only if it fails (a background refresh)
|
|
180
|
+
# @param timeout [Numeric, nil] seconds a turn waits for it (default 60)
|
|
181
|
+
# @param failed [String, nil] the warn card's short title if it raises
|
|
182
|
+
# ("chrome didn't start"; default "setup failed", the label then
|
|
183
|
+
# leads the card's body)
|
|
184
|
+
def init(label, provides_tools: false, quiet: false, timeout: nil, failed: nil, &block)
|
|
185
|
+
raise ArgumentError, "init needs a block" unless block
|
|
186
|
+
raise ArgumentError, "init needs a label" if label.to_s.strip.empty?
|
|
187
|
+
raise ArgumentError, "this chi runs no init tasks (plugins: false)" unless @registries.init
|
|
188
|
+
|
|
189
|
+
@inits << { label: label.to_s.strip, provides_tools: provides_tools ? true : false, quiet: quiet ? true : false,
|
|
190
|
+
timeout: timeout && Float(timeout), failed: failed.to_s.strip.empty? ? nil : failed.to_s.strip,
|
|
191
|
+
block: block }
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Stop the services the plugin started: its load failed after all.
|
|
196
|
+
def abort!
|
|
197
|
+
@services.reverse_each(&:stop)
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Say the session's tools changed after #register (a plugin that
|
|
202
|
+
# registers tools late): the system prompts are built again for the
|
|
203
|
+
# next turn, so the model sees the new set. That costs the server its
|
|
204
|
+
# cached prompt prefix once, so call it only when the set changed.
|
|
205
|
+
def tools_changed!
|
|
206
|
+
@registries.tools_changed&.call
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Register what was staged. Called by Loader after #register.
|
|
211
|
+
def commit!
|
|
212
|
+
context = @context
|
|
213
|
+
@commands.each do |cmd|
|
|
214
|
+
block = cmd[:block]
|
|
215
|
+
@registries.commands.register(cmd[:name], cmd[:description], anytime: cmd[:anytime], source: @bundle) do |args|
|
|
216
|
+
block.call(args, context)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
@tools.each { |tool| @registries.tools.register(tool[:name], source: @bundle, **Api.entry_fields(tool, context)) }
|
|
220
|
+
@hooks.each do |hook|
|
|
221
|
+
bundle = @bundle
|
|
222
|
+
label = @label
|
|
223
|
+
block = hook[:block]
|
|
224
|
+
context = @context
|
|
225
|
+
@registries.hooks.register_bundle(bundle, hook[:event], hook_name: label.split(" ").first,
|
|
226
|
+
priority: hook[:priority]) do |event|
|
|
227
|
+
block.call(event, context)
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
# Logged only: a turn's live region is on screen.
|
|
230
|
+
Log.warn(:plugins, "plugin_hook_failed", bundle: bundle, event: hook[:event].to_s, error: e.class.name,
|
|
231
|
+
msg: "#{label} #{hook[:event]} hook failed: #{e.message}")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
@inits.each do |init|
|
|
235
|
+
block = init[:block]
|
|
236
|
+
@registries.init.call(@bundle, init[:label], @label, provides_tools: init[:provides_tools], quiet: init[:quiet],
|
|
237
|
+
timeout: init[:timeout], failed: init[:failed]) do
|
|
238
|
+
block.call(context)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
@committed = true
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @return [Hash] how many of each it registered (for the log)
|
|
245
|
+
def counts = { commands: @commands.size, tools: @tools.size, hooks: @hooks.size, services: @services.size,
|
|
246
|
+
inits: @inits.size }
|
|
247
|
+
|
|
248
|
+
# A checked tool declaration (#tool's arguments).
|
|
249
|
+
# @return [Hash] {name:, schema:, label:, preview:, targets:, block:}
|
|
250
|
+
def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
|
|
251
|
+
raise ArgumentError, "tool #{name.inspect} needs a block" unless block
|
|
252
|
+
name = name.to_s
|
|
253
|
+
raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
|
|
254
|
+
raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
|
|
255
|
+
raise ArgumentError, "tool #{name}: targets must respond to #call" if targets && !targets.respond_to?(:call)
|
|
256
|
+
|
|
257
|
+
parameters = schema ? schema_parameters(name, schema) : params_schema(name, params)
|
|
258
|
+
{ name: name, schema: { name: name, description: description.to_s, parameters: parameters },
|
|
259
|
+
label: label&.to_s, preview: preview, targets: targets, block: block }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Make +bundle+'s tools in +registry+ the +specs+: its tools not in
|
|
263
|
+
# them go; a new one, or one whose schema or label changed, is
|
|
264
|
+
# registered (a changed one again, at the end); an unchanged one is
|
|
265
|
+
# kept as it is. A name another source has is left out. Called on
|
|
266
|
+
# the turn thread (Engine#apply_staged_tools!).
|
|
267
|
+
# @return [Hash] {changed: Boolean, skipped: [String] (why)}
|
|
268
|
+
def self.apply_tools(registry, bundle, specs, context)
|
|
269
|
+
wanted = specs.to_h { |spec| [spec[:name], spec] }
|
|
270
|
+
changed = false
|
|
271
|
+
registry.entries.each do |entry|
|
|
272
|
+
next unless entry.source == bundle
|
|
273
|
+
next if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
|
|
274
|
+
|
|
275
|
+
registry.unregister(entry.name)
|
|
276
|
+
changed = true
|
|
277
|
+
end
|
|
278
|
+
skipped = []
|
|
279
|
+
specs.each do |spec|
|
|
280
|
+
if (taken = registry[spec[:name]])
|
|
281
|
+
skipped << "tool #{spec[:name]} is already registered (#{taken.source})" unless taken.source == bundle
|
|
282
|
+
next
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
registry.register(spec[:name], source: bundle, **entry_fields(spec, context))
|
|
286
|
+
changed = true
|
|
287
|
+
end
|
|
288
|
+
{ changed: changed, skipped: skipped }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Registry#register's keywords for a tool declaration: its block
|
|
292
|
+
# wrapped as a handler (typed args, the Context), preview, targets.
|
|
293
|
+
def self.entry_fields(spec, context)
|
|
294
|
+
block = spec[:block]
|
|
295
|
+
preview = spec[:preview]
|
|
296
|
+
targets = spec[:targets]
|
|
297
|
+
parameters = spec[:schema][:parameters]
|
|
298
|
+
{
|
|
299
|
+
schema: spec[:schema], label: spec[:label],
|
|
300
|
+
handler: lambda { |call, _kctx|
|
|
301
|
+
result = block.call(Api.args_of(call, parameters), context)
|
|
302
|
+
# A String (a ToolResult too, with its images) as it is.
|
|
303
|
+
result.is_a?(String) ? result : result.to_s
|
|
304
|
+
},
|
|
305
|
+
preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
|
|
306
|
+
targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# A tool call's arguments as a plugin sees them: the parsers' args:
|
|
311
|
+
# (a call built without one, in a spec say: the call without its
|
|
312
|
+
# name), string keys, typed by +parameters+, frozen.
|
|
313
|
+
def self.args_of(call, parameters = nil)
|
|
314
|
+
given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| key == :name || key == :args }
|
|
315
|
+
Tools::Args.coerce(given, parameters).freeze
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# A JSON Schema with symbol keys (property names included), as
|
|
319
|
+
# ToolDeclarations::TOOL_SCHEMAS has them.
|
|
320
|
+
def self.symbolize(value)
|
|
321
|
+
case value
|
|
322
|
+
when Hash then value.to_h { |key, item| [key.to_sym, symbolize(item)] }
|
|
323
|
+
when Array then value.map { |item| symbolize(item) }
|
|
324
|
+
else value
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The parameters object from params: (name => property spec).
|
|
329
|
+
def self.params_schema(name, params)
|
|
330
|
+
raise ArgumentError, "tool #{name}: params must be a Hash" unless params.is_a?(Hash)
|
|
331
|
+
|
|
332
|
+
required = []
|
|
333
|
+
properties = params.each_with_object({}) do |(param, spec), acc|
|
|
334
|
+
param = param.to_s
|
|
335
|
+
raise ArgumentError, "tool #{name}: parameter name #{param.inspect} is not a plain word" unless param.match?(PARAM_NAME)
|
|
336
|
+
raise ArgumentError, "tool #{name}: parameter #{param} must be a Hash {type:, description:}" unless spec.is_a?(Hash)
|
|
337
|
+
|
|
338
|
+
spec = Api.symbolize(spec)
|
|
339
|
+
required << param if spec.delete(:required) == true
|
|
340
|
+
acc[param.to_sym] = { type: (spec.delete(:type) || "string").to_s, description: spec.delete(:description).to_s }.merge(spec)
|
|
341
|
+
end
|
|
342
|
+
{ type: "object", properties: properties, required: required }
|
|
343
|
+
end
|
|
344
|
+
private_class_method :params_schema
|
|
345
|
+
|
|
346
|
+
# The parameters object from schema: (a JSON Schema object).
|
|
347
|
+
def self.schema_parameters(name, schema)
|
|
348
|
+
raise ArgumentError, "tool #{name}: schema must be a Hash" unless schema.is_a?(Hash)
|
|
349
|
+
|
|
350
|
+
schema = Api.symbolize(schema)
|
|
351
|
+
raise ArgumentError, "tool #{name}: schema must be {type: \"object\", properties: {…}}" unless schema.fetch(:type, "object").to_s == "object"
|
|
352
|
+
|
|
353
|
+
properties = schema[:properties] || {}
|
|
354
|
+
raise ArgumentError, "tool #{name}: schema properties must be a Hash" unless properties.is_a?(Hash)
|
|
355
|
+
|
|
356
|
+
{ type: "object", properties: properties, required: Array(schema[:required]).map(&:to_s) }
|
|
357
|
+
.merge(schema.except(:type, :properties, :required))
|
|
358
|
+
end
|
|
359
|
+
private_class_method :schema_parameters
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|