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,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "memory_paths"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Which project a folder belongs to, for scoping session lists (web and
|
|
7
|
+
# terminal alike): the project root memories use (MemoryPaths.project_root),
|
|
8
|
+
# so every worktree and subfolder of one repository is one project. A
|
|
9
|
+
# folder in no git repository has no project (nil): its scope is "all".
|
|
10
|
+
module ProjectScope
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param cache [Hash, nil] folder → root, reused across one listing
|
|
14
|
+
# @return [String, nil]
|
|
15
|
+
def root_for(dir, cache: nil)
|
|
16
|
+
dir = dir.to_s
|
|
17
|
+
return nil if dir.empty?
|
|
18
|
+
return cache[dir] if cache&.key?(dir)
|
|
19
|
+
|
|
20
|
+
root = MemoryPaths.in_repo?(dir) ? MemoryPaths.project_root(File.expand_path(dir)) : nil
|
|
21
|
+
cache[dir] = root if cache
|
|
22
|
+
root
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "model_profile"
|
|
4
|
+
require_relative "prompt_literal_guard"
|
|
5
|
+
require_relative "vision_context"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Formats a message list into a prompt string ready for the /completion endpoint.
|
|
9
|
+
#
|
|
10
|
+
# Supports multiple model profiles (Gemma 4, Qwen 3.6, etc.) with different
|
|
11
|
+
# token formats, role prefixing strategies, and tool response handling.
|
|
12
|
+
#
|
|
13
|
+
# Gemma 4:
|
|
14
|
+
# - Turn markers: <|turn>ROLE\n...<end_of_turn>\n
|
|
15
|
+
# - Tool response: standalone <|tool_response>...<tool_response|>
|
|
16
|
+
# - Generation cue: <|turn>model\n
|
|
17
|
+
#
|
|
18
|
+
# Qwen 3.6:
|
|
19
|
+
# - Role framing: <|im_start|>ROLE\n...<|im_end|>
|
|
20
|
+
# - Tool response: wrapped in user message with <tool_response>...</tool_response>
|
|
21
|
+
# - Generation cue: <|im_start|>assistant\n
|
|
22
|
+
module Prompt
|
|
23
|
+
# Tail system messages whose text isn't the harness's own.
|
|
24
|
+
ESCAPED_KINDS = %w[note turn_note context].freeze
|
|
25
|
+
|
|
26
|
+
# @param messages [Array<Hash>] each element has :role and :content.
|
|
27
|
+
# Recognised roles: "system", "user", "model", "tool_response".
|
|
28
|
+
# @param profile [ModelProfile] model token configuration
|
|
29
|
+
# @return [String] formatted prompt ready for the /completion endpoint.
|
|
30
|
+
def self.format(messages, profile: ModelProfile.default)
|
|
31
|
+
format_with_images(messages, profile: profile).first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The prompt plus the base64 images it carries, in marker order. Each
|
|
35
|
+
# image a message sends is a line after its text: "[image N: name] "
|
|
36
|
+
# and the profile's image template around ImagePlan::NATIVE_PLACEHOLDER
|
|
37
|
+
# (Client#complete swaps in the server's media marker); an image left
|
|
38
|
+
# out is its placeholder line. With no images the prompt is exactly
|
|
39
|
+
# what it was before images existed.
|
|
40
|
+
#
|
|
41
|
+
# @param vision [VisionContext, nil] the turn's images context
|
|
42
|
+
# @return [Array(String, Array<String>)]
|
|
43
|
+
def self.format_with_images(messages, profile: ModelProfile.default, vision: nil)
|
|
44
|
+
plan = ImagePlan.new(messages, vision, data: :base64)
|
|
45
|
+
images = []
|
|
46
|
+
suffixes = messages.each_with_index.map { |m, index| image_lines(plan.items(m, index), profile, images) }
|
|
47
|
+
text = if profile.uses_role_prefixes?
|
|
48
|
+
format_with_prefixes(messages, profile, suffixes)
|
|
49
|
+
else
|
|
50
|
+
format_with_turn_markers(messages, profile, suffixes)
|
|
51
|
+
end
|
|
52
|
+
[text, images]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def self.image_lines(items, profile, images)
|
|
58
|
+
return "" if items.empty?
|
|
59
|
+
|
|
60
|
+
lines = items.each_with_index.map do |item, index|
|
|
61
|
+
next item.placeholder unless item.sent?
|
|
62
|
+
next ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE) unless profile.image_template
|
|
63
|
+
|
|
64
|
+
images << item.data
|
|
65
|
+
"[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
|
|
66
|
+
"#{Kernel.format(profile.image_template, marker: ImagePlan::NATIVE_PLACEHOLDER)}"
|
|
67
|
+
end
|
|
68
|
+
"\n#{lines.join("\n")}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.format_with_turn_markers(messages, profile, suffixes)
|
|
72
|
+
# Gemma 4 style: <|turn>ROLE\n...<end_of_turn>\n
|
|
73
|
+
parts = messages.each_with_index.map do |m, index|
|
|
74
|
+
content = prompt_content_for(m, profile) + suffixes[index]
|
|
75
|
+
if m[:role] == "tool_response"
|
|
76
|
+
"#{profile.tool_response_open}\n#{content}#{profile.tool_response_close}\n"
|
|
77
|
+
else
|
|
78
|
+
"#{profile.turn_start}#{m[:role]}\n#{content}#{profile.turn_end}\n"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
parts << "#{profile.turn_start}model\n"
|
|
82
|
+
parts.join
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.format_with_prefixes(messages, profile, suffixes)
|
|
86
|
+
# Qwen 3.6 style: <|im_start|>ROLE\n...<|im_end|>
|
|
87
|
+
parts = []
|
|
88
|
+
|
|
89
|
+
messages.each_with_index do |m, index|
|
|
90
|
+
content = prompt_content_for(m, profile) + suffixes[index]
|
|
91
|
+
case m[:role]
|
|
92
|
+
when "system"
|
|
93
|
+
parts << "#{profile.system_prefix}#{content}<|im_end|>\n"
|
|
94
|
+
when "user"
|
|
95
|
+
parts << "#{profile.user_prefix}#{content}<|im_end|>\n"
|
|
96
|
+
when "model"
|
|
97
|
+
parts << "#{profile.model_prefix}#{content}<|im_end|>\n"
|
|
98
|
+
when "tool_response"
|
|
99
|
+
# Wrap tool response in user message for Qwen
|
|
100
|
+
parts << "#{profile.user_prefix}#{profile.tool_response_open}\n#{content}#{profile.tool_response_close}\n<|im_end|>\n"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Cue generation as assistant
|
|
105
|
+
parts << profile.assistant_prefix
|
|
106
|
+
parts.join
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.prompt_content_for(message, profile)
|
|
110
|
+
return message[:content].to_s if message[:preserve_literals]
|
|
111
|
+
|
|
112
|
+
# A context note is system-framed but its text is untrusted (Slack, a
|
|
113
|
+
# peer session), and a turn note carries an error's text: escaped like
|
|
114
|
+
# user content so it can't close its turn.
|
|
115
|
+
role = ESCAPED_KINDS.include?(message[:kind].to_s) ? "user" : message[:role]
|
|
116
|
+
PromptLiteralGuard.escape(message[:content], profile: profile, role: role)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Escapes literal control-token text when arbitrary source/output is embedded
|
|
5
|
+
# inside prompts, then restores those placeholders when model text is used.
|
|
6
|
+
module PromptLiteralGuard
|
|
7
|
+
GEMMA_TOKEN_PAIRS = [
|
|
8
|
+
["[[SAMAGOTCHI_LITERAL_TURN_START]]", "<|turn>"],
|
|
9
|
+
["[[SAMAGOTCHI_LITERAL_TURN_END]]", "<end_of_turn>"],
|
|
10
|
+
["[[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN]]", "<|tool_call>"],
|
|
11
|
+
["[[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE]]", "<tool_call|>"],
|
|
12
|
+
["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_OPEN]]", "<|tool_response>"],
|
|
13
|
+
["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "<tool_response|>"],
|
|
14
|
+
["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
|
|
15
|
+
["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
|
|
16
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", "<|channel>thought"],
|
|
17
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", "<channel|>"]
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
QWEN_TOKEN_PAIRS = [
|
|
21
|
+
["[[SAMAGOTCHI_LITERAL_IM_START]]", "<|im_start|>"],
|
|
22
|
+
["[[SAMAGOTCHI_LITERAL_IM_END]]", "<|im_end|>"],
|
|
23
|
+
["[[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN]]", "<tool_call>"],
|
|
24
|
+
["[[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE]]", "</tool_call>"],
|
|
25
|
+
["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_OPEN]]", "<tool_response>"],
|
|
26
|
+
["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "</tool_response>"],
|
|
27
|
+
["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<think>"],
|
|
28
|
+
["[[SAMAGOTCHI_LITERAL_THINK_CLOSE]]", "</think>"],
|
|
29
|
+
["[[SAMAGOTCHI_LITERAL_VISION_START]]", "<|vision_start|>"],
|
|
30
|
+
["[[SAMAGOTCHI_LITERAL_VISION_END]]", "<|vision_end|>"]
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def self.escape(text, profile:, role: nil)
|
|
34
|
+
value = text.to_s
|
|
35
|
+
return value if value.empty? || !escapable_role?(role)
|
|
36
|
+
|
|
37
|
+
token_pairs_for(profile).reduce(value) do |memo, (placeholder, token)|
|
|
38
|
+
memo.gsub(token, placeholder)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.restore(text, profile:)
|
|
43
|
+
value = text.to_s
|
|
44
|
+
return value if value.empty?
|
|
45
|
+
|
|
46
|
+
token_pairs_for(profile).reduce(value) do |memo, (placeholder, token)|
|
|
47
|
+
memo.gsub(placeholder, token)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.restore_call(call, profile:)
|
|
52
|
+
call.each_with_object({}) do |(key, value), restored|
|
|
53
|
+
restored[key] = value.is_a?(String) ? restore(value, profile: profile) : value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.token_pairs_for(profile)
|
|
58
|
+
case profile.name
|
|
59
|
+
when "qwen36"
|
|
60
|
+
QWEN_TOKEN_PAIRS
|
|
61
|
+
else
|
|
62
|
+
GEMMA_TOKEN_PAIRS
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.escapable_role?(role)
|
|
67
|
+
%w[user tool_response].include?(role.to_s)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require_relative "session"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# The idle recap saved with its session, in <session dir>/recap.json:
|
|
10
|
+
# {text, covered, covered_digest, model, created_at}
|
|
11
|
+
# A separate file, so it never races the session-file save at the end of a
|
|
12
|
+
# turn. Follows the Engine's current session (the REPL can switch).
|
|
13
|
+
class RecapStore
|
|
14
|
+
FILE = "recap.json"
|
|
15
|
+
# A session card's or the picker's recap line.
|
|
16
|
+
PREVIEW_CHARS = 140
|
|
17
|
+
# "The user was testing…" / "The user and the assistant were exploring…":
|
|
18
|
+
# the recap prompt opens with the user's task this way, which spent ~30
|
|
19
|
+
# of a card's visible characters on the same words every time. Only a
|
|
20
|
+
# subject plus an -ing verb ("was testing") or a regular past tense
|
|
21
|
+
# ("explored"); not one that names a wish or a request ("asked the
|
|
22
|
+
# assistant to", "wanted"), which reads wrong without its subject.
|
|
23
|
+
# Irregular past tenses ("ran", "made") keep their subject.
|
|
24
|
+
NOT_STRIPPED = %w[asked wanted requested needed hoped preferred expected seemed].freeze
|
|
25
|
+
SUBJECT_RE = /\AThe user(?: and (?:the )?assistant)? (?:(?:was|were|is|are) (?=[a-z]+ing\b)|(?=[a-z]+ed\b)(?!(?:#{NOT_STRIPPED.join("|")})\b))/
|
|
26
|
+
|
|
27
|
+
# @param session_id_lookup [#call] the current session's id, or nil
|
|
28
|
+
# @param state_dir_lookup [#call] the state dir holding the sessions
|
|
29
|
+
def initialize(session_id_lookup:, state_dir_lookup:)
|
|
30
|
+
@session_id_lookup = session_id_lookup
|
|
31
|
+
@state_dir_lookup = state_dir_lookup
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String, nil] the session the store reads and writes now
|
|
35
|
+
def key
|
|
36
|
+
@session_id_lookup.call
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Hash, nil] the saved recap (symbol keys), nil when none
|
|
40
|
+
def load
|
|
41
|
+
id = key
|
|
42
|
+
id && self.class.read(Session.session_dir(id, state_dir: @state_dir_lookup.call))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Write +state+ atomically. Skipped when the session file is gone (deleted,
|
|
46
|
+
# or discarded as empty), so this never recreates a removed session's dir.
|
|
47
|
+
def save(state)
|
|
48
|
+
id = key
|
|
49
|
+
return unless id
|
|
50
|
+
|
|
51
|
+
state_dir = @state_dir_lookup.call
|
|
52
|
+
return unless File.exist?(File.join(state_dir, "#{id}.json"))
|
|
53
|
+
|
|
54
|
+
dir = Session.session_dir(id, state_dir: state_dir)
|
|
55
|
+
FileUtils.mkdir_p(dir)
|
|
56
|
+
path = File.join(dir, FILE)
|
|
57
|
+
File.write("#{path}.tmp", JSON.generate(state))
|
|
58
|
+
File.rename("#{path}.tmp", path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Remove the saved recap (it no longer describes the session).
|
|
62
|
+
def delete
|
|
63
|
+
id = key
|
|
64
|
+
return unless id
|
|
65
|
+
|
|
66
|
+
FileUtils.rm_f(File.join(Session.session_dir(id, state_dir: @state_dir_lookup.call), FILE))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Hash, nil] the recap saved in +session_dir+, nil when there is
|
|
70
|
+
# none or it can't be read
|
|
71
|
+
def self.read(session_dir)
|
|
72
|
+
data = JSON.parse(File.read(File.join(session_dir, FILE)), symbolize_names: true)
|
|
73
|
+
return nil unless data.is_a?(Hash) && !data[:text].to_s.strip.empty?
|
|
74
|
+
|
|
75
|
+
data
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [String, nil] the first sentence of the recap saved in
|
|
81
|
+
# +session_dir+, on one line, without the subject phrase (SUBJECT_RE)
|
|
82
|
+
# and cut to PREVIEW_CHARS. The full recap keeps its sentence.
|
|
83
|
+
def self.preview(session_dir)
|
|
84
|
+
text = read(session_dir)&.dig(:text).to_s.gsub(/\s+/, " ").strip
|
|
85
|
+
return nil if text.empty?
|
|
86
|
+
|
|
87
|
+
first = text[/\A.*?[.!?](?=\s|\z)/] || text
|
|
88
|
+
first = first.sub(SUBJECT_RE, "").sub(/\A[a-z]/, &:upcase)
|
|
89
|
+
first.length > PREVIEW_CHARS ? "#{first[0, PREVIEW_CHARS - 1]}…" : first
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# Thread-safe store for periodic reminders registered by the agent.
|
|
8
|
+
#
|
|
9
|
+
# The agent registers reminders via the `register_reminder` tool during a
|
|
10
|
+
# turn. The `IdleReminders` detector thread polls this store to find due
|
|
11
|
+
# reminders and signals the Engine to auto-create a turn when one is due.
|
|
12
|
+
#
|
|
13
|
+
# The store is owned by the Engine and injected into KernelLoop so both
|
|
14
|
+
# loops (native and chat) use the same dispatch path for tool calls.
|
|
15
|
+
#
|
|
16
|
+
# Reminder lifecycle:
|
|
17
|
+
# 1. Agent calls register_reminder → store adds entry
|
|
18
|
+
# 2. IdleReminders detects due → sets @due_reminder_name on Engine
|
|
19
|
+
# 3. Engine#run_turn reads @due_reminder_name, injects [SYSTEM:] message
|
|
20
|
+
# 4. Agent acts on reminder → calls cancel_reminder → store removes entry
|
|
21
|
+
class ReminderStore
|
|
22
|
+
# @return [Hash{String => Hash}] id => {id:, name:, description:, interval_minutes:, next_fire_at:}
|
|
23
|
+
attr_reader :reminders
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@mutex = Monitor.new
|
|
27
|
+
@reminders = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Register a new reminder.
|
|
31
|
+
# @param call [Hash] the tool call params (from the model)
|
|
32
|
+
# @return [String] confirmation message
|
|
33
|
+
def register(call)
|
|
34
|
+
name = (call[:name] || call[:content]).to_s.strip
|
|
35
|
+
description = (call[:description] || "").to_s.strip
|
|
36
|
+
interval = (call[:interval_minutes] || 1).to_i
|
|
37
|
+
|
|
38
|
+
return "Error: name is required" if name.empty?
|
|
39
|
+
return "Error: description is required" if description.empty?
|
|
40
|
+
return "Error: interval_minutes must be >= 1" if interval < 1
|
|
41
|
+
return "Error: interval_minutes must be <= 1440" if interval > 1440
|
|
42
|
+
|
|
43
|
+
id = SecureRandom.hex(6)
|
|
44
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@reminders[name] = {
|
|
47
|
+
id: id,
|
|
48
|
+
name: name,
|
|
49
|
+
description: description,
|
|
50
|
+
interval_minutes: interval,
|
|
51
|
+
last_fire_at: now,
|
|
52
|
+
next_fire_at: now + (interval * 60)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
"Reminder '#{name}' registered (interval: #{interval}m, id: #{id})."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Cancel a reminder by name.
|
|
60
|
+
# @param name [String] the reminder name to cancel
|
|
61
|
+
# @return [String] result message
|
|
62
|
+
def cancel(name)
|
|
63
|
+
name = name.to_s.strip
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
if @reminders.delete(name)
|
|
66
|
+
"Reminder '#{name}' canceled."
|
|
67
|
+
else
|
|
68
|
+
"Error: no such reminder '#{name}'."
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] whether any reminder is registered
|
|
74
|
+
def any?
|
|
75
|
+
@mutex.synchronize { !@reminders.empty? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# List all active reminders.
|
|
79
|
+
# @return [String] formatted list
|
|
80
|
+
def list
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
return "No active reminders." if @reminders.empty?
|
|
83
|
+
|
|
84
|
+
lines = @reminders.map do |name, r|
|
|
85
|
+
" #{r[:name]} (interval: #{r[:interval_minutes]}m, id: #{r[:id]})"
|
|
86
|
+
end
|
|
87
|
+
"Active reminders:\n" + lines.join("\n")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get all due reminders whose interval has elapsed.
|
|
92
|
+
# Returns an Array of {name:, description:, interval_minutes:} hashes.
|
|
93
|
+
# Called by Engine#maybe_inject_reminders (synchronously, at run_turn start).
|
|
94
|
+
# Thread-safe. Returns a frozen copy so the caller can't mutate internal state.
|
|
95
|
+
# @return [Array<Hash>] array of due reminder hashes (may be empty)
|
|
96
|
+
def due_reminders
|
|
97
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@reminders.each_value.filter_map do |r|
|
|
100
|
+
next unless r[:next_fire_at] && now >= r[:next_fire_at]
|
|
101
|
+
{ name: r[:name], description: r[:description], interval_minutes: r[:interval_minutes] }
|
|
102
|
+
end.freeze
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get the name of the next due reminder (if any) whose interval has elapsed.
|
|
107
|
+
# Kept for backward compatibility with IdleReminders thread.
|
|
108
|
+
# @return [String, nil] the reminder name, or nil if none are due
|
|
109
|
+
def next_due_name
|
|
110
|
+
due = due_reminders
|
|
111
|
+
due.first&.fetch(:name)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Mark a reminder as fired — resets its next_fire_at to now + interval.
|
|
115
|
+
# Called by Engine after a due reminder has been delivered.
|
|
116
|
+
# @param name [String] the reminder name
|
|
117
|
+
def mark_fired(name)
|
|
118
|
+
name = name.to_s.strip
|
|
119
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
r = @reminders[name]
|
|
122
|
+
if r
|
|
123
|
+
r[:last_fire_at] = now
|
|
124
|
+
r[:next_fire_at] = now + (r[:interval_minutes] * 60)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Mark multiple reminders as fired — resets each next_fire_at to now + interval.
|
|
130
|
+
# Called by Engine after injecting multiple due reminders (batch operation).
|
|
131
|
+
# Thread-safe, single lock acquisition.
|
|
132
|
+
# @param names [Array<String>] reminder names to mark as fired
|
|
133
|
+
def mark_fired_batch(names)
|
|
134
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
names.each do |name|
|
|
137
|
+
name = name.to_s.strip
|
|
138
|
+
r = @reminders[name]
|
|
139
|
+
if r
|
|
140
|
+
r[:last_fire_at] = now
|
|
141
|
+
r[:next_fire_at] = now + (r[:interval_minutes] * 60)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get the description of a due reminder (for injection into the system prompt).
|
|
148
|
+
# Called by Engine during run_turn. Thread-safe.
|
|
149
|
+
# @param name [String] the reminder name
|
|
150
|
+
# @return [String, nil] the description, or nil if not found
|
|
151
|
+
def get_description(name)
|
|
152
|
+
name = name.to_s.strip
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
r = @reminders[name]
|
|
155
|
+
r[:description] if r
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Clear all reminders.
|
|
160
|
+
# Called when the session ends or the engine is reset.
|
|
161
|
+
def clear_all
|
|
162
|
+
@mutex.synchronize { @reminders.clear }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "context_window"
|
|
7
|
+
require_relative "session"
|
|
8
|
+
require_relative "log_path"
|
|
9
|
+
require_relative "model_profile"
|
|
10
|
+
require_relative "served_model"
|
|
11
|
+
require_relative "host_registry"
|
|
12
|
+
require_relative "tools/memory"
|
|
13
|
+
require_relative "hooks/loader"
|
|
14
|
+
require_relative "memory_bundle/provenance"
|
|
15
|
+
require_relative "memory_bundle/manifest"
|
|
16
|
+
require_relative "memory_bundle/system_bundle"
|
|
17
|
+
require_relative "desktop"
|
|
18
|
+
|
|
19
|
+
module Samagotchi
|
|
20
|
+
# `chi self`: where this chi lives and what it is configured to use.
|
|
21
|
+
#
|
|
22
|
+
# Read-only and nearly offline (one short /props GET for the served model,
|
|
23
|
+
# nothing else asks a model server), so the agent can run it via `execute`
|
|
24
|
+
# to orient itself — source dir to rg, config path, memory dirs, sessions,
|
|
25
|
+
# model/host, bundle versions — without guessing from $PATH.
|
|
26
|
+
module SelfReport
|
|
27
|
+
SOURCE_DIR = File.expand_path("../..", __dir__)
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def text(env: ENV)
|
|
32
|
+
rows = fields(env: env)
|
|
33
|
+
width = rows.map { |label, _| label.length }.max
|
|
34
|
+
rows.map { |label, value| "#{label.ljust(width)} #{value}" }.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fields(env: ENV)
|
|
38
|
+
model = model_name
|
|
39
|
+
[
|
|
40
|
+
["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
|
|
41
|
+
["source", "#{SOURCE_DIR} (#{install_kind})"],
|
|
42
|
+
["config", with_presence(ConfigFile.global_path(env: env))],
|
|
43
|
+
["hooks dir", hooks_dir(env)],
|
|
44
|
+
["memories", Tools::MemoryRead.memories_dir("system", env: env)],
|
|
45
|
+
["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
|
|
46
|
+
["sessions", Session.default_state_dir(env: env)],
|
|
47
|
+
["log", log_path(env)],
|
|
48
|
+
["model", model || "(not configured)"],
|
|
49
|
+
["host", model ? host_for(model, env) : "-"],
|
|
50
|
+
["api key", model ? api_key_for(model, env) : "-"],
|
|
51
|
+
["loop", model ? loop_for(model, env) : "-"],
|
|
52
|
+
["profile", model ? profile_for(model, env) : "-"],
|
|
53
|
+
["served model", model ? served_model_for(model, env) : "-"],
|
|
54
|
+
["context window", context_window(env)],
|
|
55
|
+
["bundles", bundles_summary],
|
|
56
|
+
["desktop", desktop_summary(env)]
|
|
57
|
+
]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# "git checkout" when running from a repo (bin/chi), "installed gem" when
|
|
61
|
+
# under a gem path; the installed gem's files are not meant to be edited.
|
|
62
|
+
def install_kind(dir = SOURCE_DIR)
|
|
63
|
+
return "git checkout" if File.exist?(File.join(dir, ".git"))
|
|
64
|
+
return "installed gem" if Gem.path.any? { |p| dir.start_with?(File.join(p, "gems") + File::SEPARATOR) }
|
|
65
|
+
|
|
66
|
+
"directory"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The debug log every chi process appends to (LogPath): the sessions
|
|
70
|
+
# dir's sibling by default, so a chi that wants to read its own trail
|
|
71
|
+
# doesn't guess ~/.local/state.
|
|
72
|
+
def log_path(env)
|
|
73
|
+
LogPath.resolve(env: env) || "(disabled: log.disable)"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def with_presence(path)
|
|
77
|
+
File.exist?(path) ? path : "#{path} (missing)"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def hooks_dir(env)
|
|
81
|
+
data = ConfigFile.read_yaml(path: ConfigFile.global_path(env: env))
|
|
82
|
+
configured = data.is_a?(Hash) && data["hooks"].is_a?(Hash) ? data["hooks"]["hooks_dir"] : nil
|
|
83
|
+
Hooks::Loader.expand_path(configured || Hooks::Loader.default_hooks_dir(env), env)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Offline, so only the fallback: the running server's n_ctx wins at runtime.
|
|
87
|
+
def context_window(env)
|
|
88
|
+
window = ContextWindow.configured(env: env)
|
|
89
|
+
"#{window.tokens} (#{window.source}; the server's n_ctx wins at runtime)"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def model_name
|
|
93
|
+
ModelProfile.required_model_name
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Routed without model discovery, so an unqualified name may land on
|
|
99
|
+
# another host at runtime after /models.
|
|
100
|
+
def loop_for(model, env)
|
|
101
|
+
entry, = HostRegistry.new(env: env).host_for_model(model)
|
|
102
|
+
return "-" unless entry
|
|
103
|
+
|
|
104
|
+
entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# chi self's one server call: a single-model llama.cpp answers any name
|
|
108
|
+
# with the model it loaded, and its /props names it (model_alias). One
|
|
109
|
+
# GET with the probe's short timeouts; a remote host isn't asked.
|
|
110
|
+
def served_model_for(model, env)
|
|
111
|
+
registry = HostRegistry.new(env: env)
|
|
112
|
+
entry, bare = registry.host_for_model(model)
|
|
113
|
+
return "-" unless entry
|
|
114
|
+
return "reported per turn (remote host)" if entry.remote?
|
|
115
|
+
|
|
116
|
+
client = registry.client_for(entry)
|
|
117
|
+
props = client.server_props(model: bare) if client.respond_to?(:server_props)
|
|
118
|
+
return "reported per turn (the server has no /props)" if props.nil?
|
|
119
|
+
|
|
120
|
+
served = ServedModel.from_props(props)
|
|
121
|
+
return "unknown (no answer from the server's /props)" unless served
|
|
122
|
+
|
|
123
|
+
ServedModel.differs?(bare, served) ? "#{served} (not #{bare}: the server serves its own model)" : served
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
"unknown (#{e.class})"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Offline, so no /props probe: where nothing is configured, a native
|
|
129
|
+
# llama.cpp host's chat template decides at runtime.
|
|
130
|
+
def profile_for(model, env)
|
|
131
|
+
registry = HostRegistry.new(env: env)
|
|
132
|
+
entry, bare = registry.host_for_model(model)
|
|
133
|
+
return "name-based (chat API: only strips thoughts)" if entry&.chat?
|
|
134
|
+
|
|
135
|
+
# As typed, the part after a host prefix (maybe an alias), alias-resolved, bare.
|
|
136
|
+
names = [model, registry.parse_qualified_model(model).last, ConfigFile.resolve_model_alias(model, env: env), bare]
|
|
137
|
+
override = Config.resolve_with_origin("model.profile", env: env, cli_overrides: Config.cli_overrides)
|
|
138
|
+
result = ModelProfile.resolve(names: names, entry: entry, client: nil, bare_model: bare, override: override,
|
|
139
|
+
models: ConfigFile.model_settings(env: env))
|
|
140
|
+
name = result.profile.name
|
|
141
|
+
case result.source
|
|
142
|
+
when :config then "#{name} (config #{result.detail})"
|
|
143
|
+
when :cli, :env then "#{name} (#{result.source})"
|
|
144
|
+
else
|
|
145
|
+
return "#{name} (#{result.source})" unless entry.nil? || entry.transport.nil? || entry.transport == :llama_cpp
|
|
146
|
+
|
|
147
|
+
given = result.source == :name ? "name says #{name}" : "default #{name}"
|
|
148
|
+
"from the server at runtime (#{given})"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def host_for(model, env)
|
|
153
|
+
entry, bare = HostRegistry.new(env: env).host_for_model(model)
|
|
154
|
+
return "(no hosts configured)" unless entry
|
|
155
|
+
|
|
156
|
+
suffix = bare && bare != model ? " as #{bare}" : ""
|
|
157
|
+
location = entry.url || "#{entry.host}:#{entry.port}"
|
|
158
|
+
"#{entry.name} #{location}#{suffix}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The variable the host's API key comes from and whether it is set; the
|
|
162
|
+
# key itself is never shown.
|
|
163
|
+
def api_key_for(model, env)
|
|
164
|
+
entry, = HostRegistry.new(env: env).host_for_model(model)
|
|
165
|
+
name = entry&.api_key_env
|
|
166
|
+
return "-" unless name
|
|
167
|
+
|
|
168
|
+
"#{name} (#{env[name].to_s.empty? ? "unset" : "set"})"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The Chi Helper app's version against this chi's (read from its
|
|
172
|
+
# Info.plist; no process or Services checks: chi desktop status has those).
|
|
173
|
+
def desktop_summary(env)
|
|
174
|
+
return "- (macOS only)" unless Desktop.supported?
|
|
175
|
+
|
|
176
|
+
version = Desktop::MacOS.new(env: env).app_version
|
|
177
|
+
return "not installed" unless version
|
|
178
|
+
|
|
179
|
+
version == VERSION ? "#{version} (matches)" : "#{version} (chi is #{VERSION}: chi desktop upgrade)"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
|
|
183
|
+
def bundles_summary
|
|
184
|
+
shipped = MemoryBundle::Manifest.read(dir: MemoryBundle::SystemBundle::GEM_BUNDLE_DIR).version rescue nil
|
|
185
|
+
installed = Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "manifest.json")].sort.map do |path|
|
|
186
|
+
name = File.basename(File.dirname(path))
|
|
187
|
+
version = (JSON.parse(File.read(path))["version"] rescue nil) || "?"
|
|
188
|
+
label = "#{name} #{version}"
|
|
189
|
+
label += " (shipped #{shipped})" if name == MemoryBundle::SystemBundle::BUNDLE_NAME
|
|
190
|
+
label
|
|
191
|
+
end
|
|
192
|
+
installed.empty? ? "(none installed; shipped system bundle #{shipped || "?"})" : installed.join(", ")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|