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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tools/memory"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Model-specific memory overlays.
|
|
7
|
+
#
|
|
8
|
+
# A memory entry `<name>` may have a companion file `<name>.<modelkey>.md` in
|
|
9
|
+
# the same scope directory. When the entry is read under a matching model, the
|
|
10
|
+
# overlay body is appended automatically.
|
|
11
|
+
#
|
|
12
|
+
# Key invariants:
|
|
13
|
+
# - The base entry is the contract. An overlay only adds model-specific guidance
|
|
14
|
+
# and never contradicts the base protocol.
|
|
15
|
+
# - One key function is shared by both read and write paths.
|
|
16
|
+
# - Overlays are resolved only implicitly (via a read of the base); they are
|
|
17
|
+
# not first-class memories.
|
|
18
|
+
class ModelOverlay
|
|
19
|
+
# Derive the lowercase-dashed key from a bare model name.
|
|
20
|
+
#
|
|
21
|
+
# Transform: downcase → replace every non-alphanumeric run with `-` →
|
|
22
|
+
# squeeze consecutive `-` → trim leading/trailing `-`.
|
|
23
|
+
#
|
|
24
|
+
# Examples:
|
|
25
|
+
# "qwen3.6-35b-a3b" => "qwen3-6-35b-a3b"
|
|
26
|
+
# "gemma4o" => "gemma4o"
|
|
27
|
+
# nil / "" => nil
|
|
28
|
+
#
|
|
29
|
+
# This is THE key function — used by BOTH read and write paths.
|
|
30
|
+
def self.key_for(model_name)
|
|
31
|
+
return nil if model_name.nil? || model_name.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
key = model_name.to_s
|
|
34
|
+
.downcase
|
|
35
|
+
.gsub(/[^a-z0-9]+/, "-")
|
|
36
|
+
.gsub(/-+/, "-")
|
|
37
|
+
.sub(/\A-/, "")
|
|
38
|
+
.sub(/-\z/, "")
|
|
39
|
+
key.empty? ? nil : key
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Return the expected file path for an overlay in the given scope.
|
|
43
|
+
# Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
|
|
44
|
+
def self.overlay_path_for(name, key, scope)
|
|
45
|
+
return nil unless key && !key.to_s.strip.empty?
|
|
46
|
+
File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
|
|
50
|
+
# Useful for tooling, tests, and future index suppression.
|
|
51
|
+
OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9\-]+)\.md\z/
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log"
|
|
4
|
+
module Samagotchi
|
|
5
|
+
# Encapsulates all model-specific token formats and parsing behavior.
|
|
6
|
+
# Each profile maps to a model family; ModelProfile.resolve picks one
|
|
7
|
+
# (config, the server's chat template, the name, then qwen36).
|
|
8
|
+
#
|
|
9
|
+
# Current profiles:
|
|
10
|
+
# qwen36 — Qwen 3.6 chat template with function calling (the default)
|
|
11
|
+
# gemma4 — Gemma 4 format
|
|
12
|
+
class ModelProfile
|
|
13
|
+
MODEL_ENV = "SAMAGOTCHI_DEFAULT_MODEL"
|
|
14
|
+
# No model anywhere (--model, default.model in config.yml, the env): a
|
|
15
|
+
# first run before any config. An ArgumentError, as before.
|
|
16
|
+
MissingModel = Class.new(ArgumentError)
|
|
17
|
+
|
|
18
|
+
attr_reader :name, :turn_start, :turn_end,
|
|
19
|
+
:tool_call_open, :tool_call_close,
|
|
20
|
+
:tool_response_open, :tool_response_close,
|
|
21
|
+
:string_delim,
|
|
22
|
+
:thought_open, :thought_close,
|
|
23
|
+
:system_prefix, :user_prefix, :assistant_prefix,
|
|
24
|
+
:model_prefix,
|
|
25
|
+
:stop_sequences, :tool_decl_format,
|
|
26
|
+
:image_template
|
|
27
|
+
|
|
28
|
+
def initialize(config)
|
|
29
|
+
@name = config[:name]
|
|
30
|
+
@turn_start = config[:turn_start]
|
|
31
|
+
@turn_end = config[:turn_end]
|
|
32
|
+
@tool_call_open = config[:tool_call_open]
|
|
33
|
+
@tool_call_close = config[:tool_call_close]
|
|
34
|
+
@tool_response_open = config[:tool_response_open]
|
|
35
|
+
@tool_response_close = config[:tool_response_close]
|
|
36
|
+
@string_delim = config[:string_delim]
|
|
37
|
+
@thought_open = config[:thought_open]
|
|
38
|
+
@thought_close = config[:thought_close]
|
|
39
|
+
@system_prefix = config[:system_prefix]
|
|
40
|
+
@user_prefix = config[:user_prefix]
|
|
41
|
+
@assistant_prefix = config[:assistant_prefix]
|
|
42
|
+
@model_prefix = config[:model_prefix]
|
|
43
|
+
@stop_sequences = config[:stop_sequences]
|
|
44
|
+
@tool_decl_format = config[:tool_decl_format]
|
|
45
|
+
# How the chat template wraps an image's media marker ("%{marker}"),
|
|
46
|
+
# or nil when chi doesn't know it yet (native images are refused).
|
|
47
|
+
@image_template = config[:image_template]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The token the image template opens with, e.g. "<|vision_start|>": the
|
|
51
|
+
# server's chat template must use it too (VisionSupport).
|
|
52
|
+
def image_open_token
|
|
53
|
+
image_template&.split("%{marker}")&.first.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.gemma4
|
|
57
|
+
new(
|
|
58
|
+
name: "gemma4",
|
|
59
|
+
turn_start: "<|turn>",
|
|
60
|
+
turn_end: "<end_of_turn>",
|
|
61
|
+
tool_call_open: "<|tool_call>",
|
|
62
|
+
tool_call_close: "<tool_call|>",
|
|
63
|
+
tool_response_open: "<|tool_response>",
|
|
64
|
+
tool_response_close: "<tool_response|>",
|
|
65
|
+
string_delim: '<|"|>',
|
|
66
|
+
thought_open: "<|think|>",
|
|
67
|
+
thought_close: nil,
|
|
68
|
+
system_prefix: "",
|
|
69
|
+
user_prefix: "",
|
|
70
|
+
assistant_prefix: "",
|
|
71
|
+
model_prefix: "",
|
|
72
|
+
stop_sequences: ["<end_of_turn>", "<|tool_response>"],
|
|
73
|
+
tool_decl_format: :gemma4
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.qwen36
|
|
78
|
+
new(
|
|
79
|
+
name: "qwen36",
|
|
80
|
+
turn_start: "",
|
|
81
|
+
turn_end: "",
|
|
82
|
+
tool_call_open: "<tool_call>",
|
|
83
|
+
tool_call_close: "</tool_call>",
|
|
84
|
+
tool_response_open: "<tool_response>",
|
|
85
|
+
tool_response_close: "</tool_response>",
|
|
86
|
+
string_delim: nil,
|
|
87
|
+
thought_open: "<think>",
|
|
88
|
+
thought_close: "</think>",
|
|
89
|
+
system_prefix: "<|im_start|>system\n",
|
|
90
|
+
user_prefix: "<|im_start|>user\n",
|
|
91
|
+
assistant_prefix: "<|im_start|>assistant\n",
|
|
92
|
+
model_prefix: "<|im_start|>assistant\n",
|
|
93
|
+
stop_sequences: ["<|im_end|>"],
|
|
94
|
+
tool_decl_format: :qwen36,
|
|
95
|
+
image_template: "<|vision_start|>%{marker}<|vision_end|>"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.default
|
|
100
|
+
gemma4
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.normalize(value)
|
|
104
|
+
return value if value.is_a?(self)
|
|
105
|
+
|
|
106
|
+
case value.to_s.strip.downcase
|
|
107
|
+
when "qwen", "qwen3", "qwen36", "qwen3.6"
|
|
108
|
+
qwen36
|
|
109
|
+
when "gemma", "gemma4", "gemma4o"
|
|
110
|
+
gemma4
|
|
111
|
+
else
|
|
112
|
+
gemma4
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.required_model_name(model_name = nil, env: ENV)
|
|
117
|
+
value = model_name.to_s.strip
|
|
118
|
+
if value.empty?
|
|
119
|
+
begin
|
|
120
|
+
require_relative "config"
|
|
121
|
+
cfg = Samagotchi::Config.get("default.model") rescue nil
|
|
122
|
+
value = cfg.to_s.strip unless cfg.nil? || cfg.to_s.strip.empty?
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
value = env[MODEL_ENV].to_s.strip if value.empty?
|
|
128
|
+
raise MissingModel, missing_model_message if value.empty?
|
|
129
|
+
|
|
130
|
+
value
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# One line for the user: where to set the model.
|
|
134
|
+
def self.missing_model_message
|
|
135
|
+
path = begin
|
|
136
|
+
require_relative "config"
|
|
137
|
+
Samagotchi::ConfigFile.global_path
|
|
138
|
+
rescue StandardError, LoadError
|
|
139
|
+
"~/.config/samagotchi/config.yml"
|
|
140
|
+
end
|
|
141
|
+
"no model configured: set default.model in #{path} to the model id your server serves " \
|
|
142
|
+
"(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.from_model_name(model_name)
|
|
146
|
+
named(inferred_profile_name(model_name))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# qwen → qwen36, gemma → gemma4, anything else → DEFAULT_NAME: many
|
|
150
|
+
# models with other names are Qwen-based, and under the gemma4 profile a
|
|
151
|
+
# ChatML model never hits a stop sequence.
|
|
152
|
+
def self.inferred_profile_name(model_name)
|
|
153
|
+
normalized = model_name.to_s.strip.downcase
|
|
154
|
+
return "qwen36" if normalized.include?("qwen")
|
|
155
|
+
return "gemma4" if normalized.include?("gemma")
|
|
156
|
+
|
|
157
|
+
DEFAULT_NAME
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.from_env
|
|
161
|
+
from_model_name(required_model_name(nil, env: ENV))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# ── Resolution: which profile a model gets ───────────────────────────
|
|
165
|
+
|
|
166
|
+
NAMES = %w[qwen36 gemma4].freeze
|
|
167
|
+
|
|
168
|
+
# The last layer, when nothing else says.
|
|
169
|
+
DEFAULT_NAME = "qwen36"
|
|
170
|
+
|
|
171
|
+
# What the server's chat template must contain for each profile. The
|
|
172
|
+
# template tells the families apart where eos_token doesn't (Gemma 4's is
|
|
173
|
+
# <eos> or <turn|>, depending on who packed it). llama.cpp itself detects
|
|
174
|
+
# Gemma 4 by '<|tool_call>call:'.
|
|
175
|
+
FINGERPRINTS = {
|
|
176
|
+
"qwen36" => ["<|im_start|>", "<function="],
|
|
177
|
+
"gemma4" => ["<|turn>", "<|tool_call>"]
|
|
178
|
+
}.freeze
|
|
179
|
+
|
|
180
|
+
# A resolved profile and where it came from. source: :cli, :env,
|
|
181
|
+
# :config, :server, :name or :default; detail: the config key, the
|
|
182
|
+
# template evidence or the matched name. retry: the server probe failed
|
|
183
|
+
# (unreachable, or loading and answering 503), so the caller should
|
|
184
|
+
# resolve again before its next turn.
|
|
185
|
+
Resolution = Data.define(:profile, :source, :detail, :retry) do
|
|
186
|
+
def retry? = self.retry
|
|
187
|
+
|
|
188
|
+
# How /stats, /model and chi self name the source.
|
|
189
|
+
def label
|
|
190
|
+
case source
|
|
191
|
+
when :config then "config (#{detail})"
|
|
192
|
+
when :server then "server (chat_template)"
|
|
193
|
+
else source.to_s
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The profile called +value+, or nil (unlike .normalize, which always
|
|
199
|
+
# gives one).
|
|
200
|
+
def self.named(value)
|
|
201
|
+
name = value.to_s.strip.downcase
|
|
202
|
+
NAMES.include?(name) ? public_send(name) : nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# [profile name, evidence] from a llama.cpp /props body, or nil when its
|
|
206
|
+
# chat template says neither family. A ChatML template without Qwen's
|
|
207
|
+
# <function= calls (older Qwen3, Hermes, ...) still gets qwen36: its
|
|
208
|
+
# <|im_end|> stops generation, where gemma4's stop sequences never occur.
|
|
209
|
+
def self.fingerprint(props)
|
|
210
|
+
template = props.is_a?(Hash) ? props["chat_template"].to_s : ""
|
|
211
|
+
FINGERPRINTS.each do |name, markers|
|
|
212
|
+
return [name, markers.join(" + ")] if markers.all? { |marker| template.include?(marker) }
|
|
213
|
+
end
|
|
214
|
+
return ["qwen36", "ChatML"] if template.include?("<|im_start|>")
|
|
215
|
+
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Which profile a model gets, first match wins:
|
|
220
|
+
# 1. override: --profile / SAMAGOTCHI_MODEL_PROFILE ([value, origin];
|
|
221
|
+
# a nil value is not set, whatever its origin)
|
|
222
|
+
# 2. models: in the config file, under any of +names+
|
|
223
|
+
# 3. hosts.<name>.profile of +entry+
|
|
224
|
+
# 4. the server's chat template (native llama.cpp hosts only)
|
|
225
|
+
# 5. the name: qwen → qwen36, gemma → gemma4
|
|
226
|
+
# 6. DEFAULT_NAME
|
|
227
|
+
# An unknown configured value warns and is skipped.
|
|
228
|
+
#
|
|
229
|
+
# @param names [Array<String>] the model as typed, alias-resolved, bare
|
|
230
|
+
# @param entry [HostRegistry::HostEntry, nil] the model's host
|
|
231
|
+
# @param client [#server_props, nil] the host's client (nil: no probe)
|
|
232
|
+
# @param bare_model [String, nil] the name the probe asks the server about
|
|
233
|
+
# @return [Resolution]
|
|
234
|
+
def self.resolve(names:, entry:, client:, bare_model:, override: nil, models: nil)
|
|
235
|
+
require_relative "config"
|
|
236
|
+
override ||= Samagotchi::Config.get_with_origin("model.profile")
|
|
237
|
+
models ||= Samagotchi::ConfigFile.model_settings
|
|
238
|
+
names = Array(names).map { |n| n.to_s.strip }.reject(&:empty?).uniq
|
|
239
|
+
|
|
240
|
+
value, origin = override
|
|
241
|
+
if (profile = named(value))
|
|
242
|
+
return Resolution.new(profile: profile, source: origin == :env ? :env : :cli, detail: nil, retry: false)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
names.map(&:downcase).uniq.each do |key|
|
|
246
|
+
setting = models[key]
|
|
247
|
+
next unless setting && setting[:profile]
|
|
248
|
+
|
|
249
|
+
profile = configured(setting[:profile], "models: #{key}")
|
|
250
|
+
return Resolution.new(profile: profile, source: :config, detail: "models: #{key}", retry: false) if profile
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if entry&.profile && (profile = configured(entry.profile, "hosts.#{entry.name}"))
|
|
254
|
+
return Resolution.new(profile: profile, source: :config, detail: "hosts.#{entry.name}", retry: false)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
retry_later = false
|
|
258
|
+
if probe?(entry, client)
|
|
259
|
+
props = client.server_props(model: bare_model)
|
|
260
|
+
if props && !props.answered?
|
|
261
|
+
retry_later = true
|
|
262
|
+
elsif props && (found = fingerprint(props.body))
|
|
263
|
+
return Resolution.new(profile: named(found[0]), source: :server, detail: found[1], retry: false)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
names.each do |name|
|
|
268
|
+
lowered = name.downcase
|
|
269
|
+
family = if lowered.include?("qwen") then "qwen36"
|
|
270
|
+
elsif lowered.include?("gemma") then "gemma4"
|
|
271
|
+
end
|
|
272
|
+
return Resolution.new(profile: named(family), source: :name, detail: name, retry: retry_later) if family
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
Resolution.new(profile: named(DEFAULT_NAME), source: :default, detail: nil, retry: retry_later)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def self.configured(value, where)
|
|
279
|
+
profile = named(value)
|
|
280
|
+
Log.warn(:config, "unknown_profile", echo: "Warning: unknown profile #{value.to_s.inspect} in #{where} (allowed: #{NAMES.join(', ')}) — ignored", profile: value.to_s, where: where) unless profile
|
|
281
|
+
profile
|
|
282
|
+
end
|
|
283
|
+
private_class_method :configured
|
|
284
|
+
|
|
285
|
+
# Only a native llama.cpp host has /props with a chat template; a chat
|
|
286
|
+
# host (api: openai) barely uses a profile, and mlx/oMLX expose none.
|
|
287
|
+
def self.probe?(entry, client)
|
|
288
|
+
return false if client.nil? || !client.respond_to?(:server_props)
|
|
289
|
+
return false if entry&.chat?
|
|
290
|
+
|
|
291
|
+
!client.respond_to?(:transport) || client.transport.name == :llama_cpp
|
|
292
|
+
end
|
|
293
|
+
private_class_method :probe?
|
|
294
|
+
|
|
295
|
+
# ── Thought channel support ──────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
def thought_channel_open
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def uses_channel_thoughts?
|
|
302
|
+
false
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def uses_role_prefixes?
|
|
306
|
+
!system_prefix.empty? && !user_prefix.empty?
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tools/memory"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Memories hidden from one session (`--mute NAME`): name normalization and
|
|
7
|
+
# the index-text filter, shared by the Engine (the prompt's index, the
|
|
8
|
+
# identity auto-load, the preload list) and the KernelLoop (a memory_read
|
|
9
|
+
# guard). Nothing on disk changes: a muted memory keeps its file and its
|
|
10
|
+
# index line.
|
|
11
|
+
module MutedMemories
|
|
12
|
+
# A managed index line (IndexUpdater.managed_line), plus the legacy
|
|
13
|
+
# `- **name**: desc` and `- **name.md** ·` shapes still found in indexes.
|
|
14
|
+
INDEX_LINE = /\A- \*\*(.+?)\*\*[ \t]*(?:[·•].*|:.*)?\z/
|
|
15
|
+
|
|
16
|
+
# `project/gh-helper`, `gh-helper.md`, ` gh-helper ` → `gh-helper`.
|
|
17
|
+
# nil for a blank name.
|
|
18
|
+
def self.normalize(raw)
|
|
19
|
+
value = raw.to_s.strip
|
|
20
|
+
return nil if value.empty?
|
|
21
|
+
|
|
22
|
+
if value.include?("/")
|
|
23
|
+
scope, rest = value.split("/", 2)
|
|
24
|
+
value = rest if Tools::VALID_SCOPES.include?(scope)
|
|
25
|
+
end
|
|
26
|
+
base = File.basename(value, ".md").strip
|
|
27
|
+
base.empty? ? nil : base
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Names as given (comma lists allowed) → normalized, deduped.
|
|
31
|
+
def self.normalize_list(names)
|
|
32
|
+
Array(names).flat_map { |raw| raw.to_s.split(",") }.map { |n| normalize(n) }.compact.uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.muted?(name, muted)
|
|
36
|
+
return false if muted.nil? || muted.empty?
|
|
37
|
+
|
|
38
|
+
key = normalize(name)
|
|
39
|
+
!key.nil? && muted.include?(key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The index text without the lines that name a muted memory: a managed
|
|
43
|
+
# line (`- **name** · …`) or a bare name (the "no index yet" fallback).
|
|
44
|
+
# Every other byte stays.
|
|
45
|
+
def self.filter_index(text, muted)
|
|
46
|
+
return text if muted.nil? || muted.empty? || text.nil?
|
|
47
|
+
|
|
48
|
+
text.each_line.reject { |line| muted_index_line?(line, muted) }.join
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.muted_index_line?(line, muted)
|
|
52
|
+
muted?(index_line_name(line), muted)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The memory an index line names, normalized: a managed line's
|
|
56
|
+
# `**name**`, else the bare line (the "no index yet" list). nil for a
|
|
57
|
+
# line that names none (a heading, prose with spaces).
|
|
58
|
+
def self.index_line_name(line)
|
|
59
|
+
stripped = line.chomp
|
|
60
|
+
name = stripped.match(INDEX_LINE)&.[](1) || stripped
|
|
61
|
+
return nil if name.include?(" ")
|
|
62
|
+
|
|
63
|
+
normalize(name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "session_manager"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# `chi note`: push text into sessions as a context note (background the
|
|
8
|
+
# model sees on its next turn), never as a prompt; nothing wakes. For a
|
|
9
|
+
# script, e.g. an Automator action:
|
|
10
|
+
# pbpaste | chi note --source slack $(chi sessions list --live --scope=all --format tsv | cut -f1)
|
|
11
|
+
class NoteCommand
|
|
12
|
+
USAGE = <<~TEXT
|
|
13
|
+
Usage: chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all
|
|
14
|
+
Adds TEXT (or stdin) to each session as a context note: background
|
|
15
|
+
the model sees on its next turn, not a prompt. It starts no turn.
|
|
16
|
+
--source NAME where it came from, shown to the model (default: cli)
|
|
17
|
+
-m TEXT the note; without it, stdin is read
|
|
18
|
+
--all every session a worker runs now, in every project
|
|
19
|
+
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
20
|
+
TEXT
|
|
21
|
+
|
|
22
|
+
# @param argv [Array<String>] the arguments after "note"
|
|
23
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
|
|
24
|
+
@argv = argv.dup
|
|
25
|
+
@stdin = stdin
|
|
26
|
+
@stdout = stdout
|
|
27
|
+
@stderr = stderr
|
|
28
|
+
@state_dir = state_dir || Session.default_state_dir
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Integer] exit status: 0 all queued, 1 any refused or failed,
|
|
32
|
+
# 2 usage
|
|
33
|
+
def run
|
|
34
|
+
options = parse or return 2
|
|
35
|
+
return 0 if options[:help]
|
|
36
|
+
|
|
37
|
+
text = utf8(options[:text] || read_stdin)
|
|
38
|
+
unless text
|
|
39
|
+
usage_error("no note text: pass -m TEXT or pipe it in")
|
|
40
|
+
return 2
|
|
41
|
+
end
|
|
42
|
+
begin
|
|
43
|
+
text = SessionManager.checked_text(text)
|
|
44
|
+
rescue SessionManager::NoteRejected => e
|
|
45
|
+
@stderr.puts("chi note: #{e.message}")
|
|
46
|
+
return 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return deliver_to_live(text, options[:source]) if options[:all]
|
|
50
|
+
|
|
51
|
+
# One id at a time, so each line follows the order of the ids given.
|
|
52
|
+
seen = {}
|
|
53
|
+
results = options[:ids].uniq.map do |given|
|
|
54
|
+
id = resolve(given)
|
|
55
|
+
next false unless id
|
|
56
|
+
next true if seen[id]
|
|
57
|
+
|
|
58
|
+
seen[id] = true
|
|
59
|
+
deliver(id, text, options[:source])
|
|
60
|
+
end
|
|
61
|
+
results.all? ? 0 : 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def parse
|
|
67
|
+
options = { source: "cli", ids: [] }
|
|
68
|
+
until @argv.empty?
|
|
69
|
+
arg = @argv.shift
|
|
70
|
+
case arg
|
|
71
|
+
when "-h", "--help", "help"
|
|
72
|
+
@stdout.puts(USAGE)
|
|
73
|
+
return { help: true }
|
|
74
|
+
when "--all" then options[:all] = true
|
|
75
|
+
when "--source", "-m", "--message"
|
|
76
|
+
value = @argv.shift or return usage_error("#{arg} needs a value")
|
|
77
|
+
options[arg == "--source" ? :source : :text] = value
|
|
78
|
+
when /\A--source=(.*)\z/m then options[:source] = Regexp.last_match(1)
|
|
79
|
+
when /\A--message=(.*)\z/m then options[:text] = Regexp.last_match(1)
|
|
80
|
+
when /\A-/ then return usage_error("unknown option #{arg}")
|
|
81
|
+
else options[:ids] << arg
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
return usage_error("give session ids or --all") if options[:ids].empty? && !options[:all]
|
|
85
|
+
return usage_error("--all takes no ids") if options[:all] && options[:ids].any?
|
|
86
|
+
|
|
87
|
+
options
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Only a pipe or a file is read, as chi send does. A terminal means
|
|
91
|
+
# nobody piped a note in, and a socket a launcher or an agent's shell
|
|
92
|
+
# passes down may never close: waiting on either would hang a script.
|
|
93
|
+
def read_stdin
|
|
94
|
+
return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
|
|
95
|
+
if @stdin.respond_to?(:stat)
|
|
96
|
+
stat = @stdin.stat
|
|
97
|
+
return nil unless stat.pipe? || stat.file?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@stdin.read
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --all: every live session. @return [Integer] exit status
|
|
104
|
+
def deliver_to_live(text, source)
|
|
105
|
+
ids = SessionManager.session_summaries(live: true, include_tests: false, state_dir: @state_dir).map { |s| s[:id] }
|
|
106
|
+
if ids.empty?
|
|
107
|
+
error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
|
|
108
|
+
return 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
ids.map { |id| deliver(id, text, source) }.all? ? 0 : 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def resolve(given)
|
|
115
|
+
id = Session.resolve_id(given, state_dir: @state_dir)
|
|
116
|
+
Session.load(id, state_dir: @state_dir)
|
|
117
|
+
id
|
|
118
|
+
rescue ArgumentError => e
|
|
119
|
+
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
120
|
+
error_line("chi note: #{message}")
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Boolean] whether the note was queued
|
|
125
|
+
def deliver(id, text, source)
|
|
126
|
+
short = id[0, 8]
|
|
127
|
+
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
128
|
+
if owner && owner["kind"] == "tui"
|
|
129
|
+
@stdout.puts("#{short} refused: it is open in a chi REPL; notes need attached mode")
|
|
130
|
+
return false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
path = SessionManager.write_note(id, text: text, source: source, state_dir: @state_dir)
|
|
134
|
+
if owner
|
|
135
|
+
@stdout.puts("#{short} queued: its worker adds it within a few seconds")
|
|
136
|
+
else
|
|
137
|
+
queued = SessionManager.find_new_note_files(File.dirname(File.dirname(path))).size
|
|
138
|
+
@stdout.puts("#{short} waits for the session's next start (#{queued} #{queued == 1 ? "note" : "notes"} queued)")
|
|
139
|
+
end
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The note as UTF-8 whatever the locale says: with no LANG/LC_* (an app
|
|
144
|
+
# started from Finder, launchd) stdin reads as US-ASCII and ARGV as
|
|
145
|
+
# binary. Invalid bytes become U+FFFD rather than an error.
|
|
146
|
+
def utf8(text)
|
|
147
|
+
text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# stderr isn't buffered, stdout is when it's a pipe: flush the lines
|
|
151
|
+
# already printed, so the output keeps the order of the ids given.
|
|
152
|
+
def error_line(text)
|
|
153
|
+
@stdout.flush
|
|
154
|
+
@stderr.puts(text)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def usage_error(message)
|
|
158
|
+
error_line("chi note: #{message}")
|
|
159
|
+
@stderr.puts(USAGE)
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Samagotchi
|
|
3
|
+
# OutputFormatter strips model wire-format tokens from raw engine output at
|
|
4
|
+
# render time, so downstream renderers (web UI, terminal UI) never show the
|
|
5
|
+
# model's internal control/literal vocabulary.
|
|
6
|
+
#
|
|
7
|
+
# Two token families survive KernelLoop#run's strip_thought_blocks():
|
|
8
|
+
# * Gemma control tokens: <|turn>, <|tool_call>...<tool_call|>,
|
|
9
|
+
# <|tool_response>...<tool_response|>, <|think|>, <|channel>thought...,
|
|
10
|
+
# <channel|>, <|tool>declaration...<tool|> blocks, <|"|> delimiter.
|
|
11
|
+
# * Qwen prompt-literal blocks: [[SAMAGOTCHI_LITERAL_*]]
|
|
12
|
+
# (<tool_call>, </think>,
|
|
13
|
+
# …) which strip_thought_blocks does NOT remove.
|
|
14
|
+
#
|
|
15
|
+
# IMPORTANT: control-token matching is ENUMERATED by name, never a broad
|
|
16
|
+
# <[a-z_]{1,40}> wildcard. A wildcard eats arbitrary <word> content in
|
|
17
|
+
# tool/file output (e.g. a tool result containing "<div>" or "<resp>" would be
|
|
18
|
+
# silently mangled). If a new control token is added elsewhere, add its name to
|
|
19
|
+
# CONTROL_NAMES (and any new bare-literal form) here before it can appear in
|
|
20
|
+
# rendered output.
|
|
21
|
+
module OutputFormatter
|
|
22
|
+
# The complete vocabulary of known control-token names. Matched inside their
|
|
23
|
+
# wrapper forms; bare <name> content (no pipe) is deliberately preserved.
|
|
24
|
+
CONTROL_NAMES = %w[
|
|
25
|
+
turn
|
|
26
|
+
end_of_turn
|
|
27
|
+
tool_call
|
|
28
|
+
tool_response
|
|
29
|
+
tool
|
|
30
|
+
think
|
|
31
|
+
channel
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# Tokens are stripped individually by name (INDIVIDUAL_RE), which preserves the
|
|
35
|
+
# tool-call / declaration *body* content while removing only the markers. The
|
|
36
|
+
# body (e.g. `call:read{path:'x'}`) is real, readable output and must remain.
|
|
37
|
+
|
|
38
|
+
# Individual control tokens matched inside their wrapper forms. Each
|
|
39
|
+
# alternative requires at least one pipe (leading or trailing) so a bare
|
|
40
|
+
# <word> in the surrounding content is NOT stripped:
|
|
41
|
+
# <|name> / <name|> / <|name|>
|
|
42
|
+
INDIVIDUAL_RE = Regexp.new(
|
|
43
|
+
'<\\|(?:' + CONTROL_NAMES.join('|') + ')\\|?>' \
|
|
44
|
+
'|<(?:' + CONTROL_NAMES.join('|') + ')\\|>'
|
|
45
|
+
)
|
|
46
|
+
INDIVIDUAL_RE.freeze
|
|
47
|
+
|
|
48
|
+
# Angle-bracketed literal tokens stripped wholesale (including the thought
|
|
49
|
+
# channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
|
|
50
|
+
# are removed as a unit.
|
|
51
|
+
# Angle-bracketed literal tokens stripped wholesale (including the thought
|
|
52
|
+
# channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
|
|
53
|
+
# are removed as a unit. Pipes are escaped; angle brackets are literal.
|
|
54
|
+
LITERALS_RE = /<\|"\|>|<\|\w*channel>thought|<end_of_turn>/.freeze
|
|
55
|
+
|
|
56
|
+
# Qwen prompt-literal placeholders, e.g. </think>.
|
|
57
|
+
PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
|
|
58
|
+
|
|
59
|
+
# Qwen3.6 thinking blocks: <think>...</think> (including empty)
|
|
60
|
+
QWEN_THINK_RE = /<think>.*?<\/think>/m.freeze
|
|
61
|
+
# Qwen tool_call XML blocks that survive when a tool call is rendered as text
|
|
62
|
+
# e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
|
|
63
|
+
QWEN_TOOL_CALL_RE = /<tool_call>.*?<\/tool_call>/m.freeze
|
|
64
|
+
module_function
|
|
65
|
+
|
|
66
|
+
# Remove both control-token and prompt-literal token families from +text+.
|
|
67
|
+
# Known literals are stripped first (as whole units), then remaining tokens are
|
|
68
|
+
# stripped by name inside their wrappers (bodies preserved). Whitespace runs
|
|
69
|
+
# are collapsed and leading/trailing whitespace trimmed so removing a mid-sentence
|
|
70
|
+
# tag leaves no stray double-space or blank line. Internal newlines are preserved
|
|
71
|
+
# so a multi-line response stays readable. Blank / token-only input returns "".
|
|
72
|
+
def strip(text)
|
|
73
|
+
cleaned =
|
|
74
|
+
remove_tokens(text)
|
|
75
|
+
.gsub(/[ \t]+/, " ")
|
|
76
|
+
.gsub(/ *\n */, "\n")
|
|
77
|
+
.strip
|
|
78
|
+
cleaned.empty? ? '' : cleaned
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The same tokens as #strip, but the text keeps its layout (indentation,
|
|
82
|
+
# runs of spaces); only the blank lines removed blocks leave collapse.
|
|
83
|
+
# For showing a whole saved message (the attached TUI's join, the web history).
|
|
84
|
+
def strip_markup(text)
|
|
85
|
+
remove_tokens(text).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def remove_tokens(text)
|
|
89
|
+
text.to_s
|
|
90
|
+
.gsub(QWEN_THINK_RE, '')
|
|
91
|
+
.gsub(QWEN_TOOL_CALL_RE, '')
|
|
92
|
+
.gsub(LITERALS_RE, '')
|
|
93
|
+
.gsub(INDIVIDUAL_RE, '')
|
|
94
|
+
.gsub(PROMPT_LITERALS, '')
|
|
95
|
+
# orphaned closing tags that may remain after block removal
|
|
96
|
+
.gsub(/<\/?think>/, '')
|
|
97
|
+
.gsub(/<\/?tool_call>/, '')
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|