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
|
+
module Samagotchi
|
|
4
|
+
# Live thinking as one sentence at a time, for the terminal: the Ruby side
|
|
5
|
+
# of the web's sentences.js + thinking_ticker.js, with the same rules.
|
|
6
|
+
#
|
|
7
|
+
# Sentence boundary: [.!?…] optionally followed by closing quotes or
|
|
8
|
+
# brackets, then whitespace; a newline is also a boundary, and so is the end
|
|
9
|
+
# of the text (the ticker corrects itself on the next chunk). A bare list
|
|
10
|
+
# number ("1.", "118.") at the start of a line is a marker, not a sentence.
|
|
11
|
+
# Whitespace is collapsed. A trailing fragment longer than MAX_LEN (a
|
|
12
|
+
# code-like run with no punctuation) counts as the current sentence.
|
|
13
|
+
#
|
|
14
|
+
# The ticker changes its line no sooner than DWELL seconds after the last
|
|
15
|
+
# change, so a fast stream doesn't flicker: in between, the newest sentence
|
|
16
|
+
# waits and #tick shows it when the dwell is over.
|
|
17
|
+
class ThinkingTicker
|
|
18
|
+
MAX_LEN = 160
|
|
19
|
+
DWELL = 1.5
|
|
20
|
+
BOUNDARY = /([.!?…][)"'\]]*)([[:space:]]|\z)|\n/
|
|
21
|
+
LIST_MARKER = /\A\d{1,4}\.\z/
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] every complete sentence of +text+ (the end
|
|
24
|
+
# counts as a boundary), collapsed, blanks skipped
|
|
25
|
+
def self.sentences(text)
|
|
26
|
+
raw = text.to_s
|
|
27
|
+
found = []
|
|
28
|
+
start = 0
|
|
29
|
+
raw.to_enum(:scan, BOUNDARY).each do
|
|
30
|
+
m = Regexp.last_match
|
|
31
|
+
candidate = raw[start...(m.begin(0) + m[1].to_s.length)]
|
|
32
|
+
# A list number is no sentence: keep reading from the same start.
|
|
33
|
+
next if LIST_MARKER.match?(candidate.strip)
|
|
34
|
+
|
|
35
|
+
collapsed = collapse(candidate)
|
|
36
|
+
found << collapsed unless collapsed.empty?
|
|
37
|
+
start = m.end(0)
|
|
38
|
+
end
|
|
39
|
+
found
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The newest complete sentence of +text+, else the trailing fragment when
|
|
43
|
+
# longer than +max_len+, else "".
|
|
44
|
+
def self.current_sentence(text, max_len: MAX_LEN)
|
|
45
|
+
last = sentences(text).last
|
|
46
|
+
return last if last
|
|
47
|
+
|
|
48
|
+
fragment = collapse(text)
|
|
49
|
+
fragment.length > max_len ? fragment : ""
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.collapse(text)
|
|
53
|
+
text.to_s.gsub(/[[:space:]]+/, " ").strip
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
attr_reader :line
|
|
57
|
+
|
|
58
|
+
# @param clock [#call] seconds, monotonic
|
|
59
|
+
def initialize(clock:, dwell: DWELL)
|
|
60
|
+
@clock = clock
|
|
61
|
+
@dwell = dwell
|
|
62
|
+
reset
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Forget the line (a new generation).
|
|
66
|
+
def reset
|
|
67
|
+
@line = ""
|
|
68
|
+
@pending = nil
|
|
69
|
+
@changed_at = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Show the newest sentence of +text+ now, or keep it pending for the
|
|
73
|
+
# dwell. @return [Boolean] whether the line changed
|
|
74
|
+
def feed(text)
|
|
75
|
+
candidate = self.class.current_sentence(text)
|
|
76
|
+
return false if candidate.empty?
|
|
77
|
+
|
|
78
|
+
if candidate == @line
|
|
79
|
+
@pending = nil
|
|
80
|
+
false
|
|
81
|
+
elsif @line.empty? || due?
|
|
82
|
+
show(candidate)
|
|
83
|
+
else
|
|
84
|
+
@pending = candidate
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Show a pending sentence once the dwell is over.
|
|
90
|
+
# @return [Boolean] whether the line changed
|
|
91
|
+
def tick
|
|
92
|
+
return false unless @pending && due?
|
|
93
|
+
|
|
94
|
+
show(@pending)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def due?
|
|
100
|
+
@changed_at.nil? || @clock.call - @changed_at >= @dwell
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def show(candidate)
|
|
104
|
+
@line = candidate
|
|
105
|
+
@pending = nil
|
|
106
|
+
@changed_at = @clock.call
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "model_profile"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Incremental, profile-aware splitter for a raw model stream.
|
|
7
|
+
#
|
|
8
|
+
# It feeds raw streamed chunks (the `:generation_chunk` `content`, which is
|
|
9
|
+
# never mutated) and routes each byte into one of two additive lanes:
|
|
10
|
+
# * :thinking — content inside a thinking block
|
|
11
|
+
# * :text — visible prose (everything outside thinking/tool_call blocks)
|
|
12
|
+
#
|
|
13
|
+
# tool_call block bodies are dropped: the web activity panel renders those from
|
|
14
|
+
# the `:tool_call_started` / `:tool_call_completed` events, so duplicating them
|
|
15
|
+
# in the text bubble would be noise.
|
|
16
|
+
#
|
|
17
|
+
# The splitter is stateful across chunks: it carries partial open/close marker
|
|
18
|
+
# fragments at the end of each chunk so a marker split across a stream boundary
|
|
19
|
+
# is still recognized. `ThoughtStreamSplitter.for_profile` derives the block
|
|
20
|
+
# markers from the active ModelProfile, so the same splitter works for the
|
|
21
|
+
# Qwen literal-marker family and (tool_call only) the Gemma control-token family.
|
|
22
|
+
#
|
|
23
|
+
# For a profile whose `thought_close` is nil (Gemma 4), thinking blocks have no
|
|
24
|
+
# explicit close marker and are NOT split out — they remain in `:text` and the
|
|
25
|
+
# renderer's existing strip still hides them from the bubble. Qwen 3.6 (the
|
|
26
|
+
# default runtime profile) has explicit open/close markers for both families,
|
|
27
|
+
# so thinking is split cleanly.
|
|
28
|
+
class ThoughtStreamSplitter
|
|
29
|
+
# @return [Array<Hash>] each entry { open: String, close: String, lane: :thinking|:drop }
|
|
30
|
+
def self.for_profile(profile)
|
|
31
|
+
blocks = []
|
|
32
|
+
# Only register a thinking block when the profile has an explicit close
|
|
33
|
+
# marker (Qwen). A nil close (Gemma) means we can't bound the block, so we
|
|
34
|
+
# leave thinking in :text and let the renderer's strip handle it.
|
|
35
|
+
blocks << { open: profile.thought_open, close: profile.thought_close, lane: :thinking } if profile.thought_close
|
|
36
|
+
blocks << { open: profile.tool_call_open, close: profile.tool_call_close, lane: :drop }
|
|
37
|
+
new(blocks)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param blocks [Array<Hash>] as produced by #for_profile
|
|
41
|
+
def initialize(blocks)
|
|
42
|
+
@blocks = blocks
|
|
43
|
+
@carry = +""
|
|
44
|
+
@state = :normal
|
|
45
|
+
@active = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Feed one raw chunk. Returns the newly-routed deltas for this chunk only.
|
|
49
|
+
# @return [Hash{ text: String, thinking: String }]
|
|
50
|
+
def feed(chunk)
|
|
51
|
+
@carry << chunk.to_s
|
|
52
|
+
text = +""
|
|
53
|
+
thinking = +""
|
|
54
|
+
loop do
|
|
55
|
+
if @state == :normal
|
|
56
|
+
hit = earliest_open
|
|
57
|
+
if hit.nil?
|
|
58
|
+
flush_normal(text)
|
|
59
|
+
break
|
|
60
|
+
end
|
|
61
|
+
# Everything before the open marker is visible prose.
|
|
62
|
+
text << @carry[0...hit[:pos]]
|
|
63
|
+
@carry = @carry[hit[:pos]..]
|
|
64
|
+
@active = hit[:block]
|
|
65
|
+
@state = :in_block
|
|
66
|
+
# Drop the open marker itself; its body follows.
|
|
67
|
+
@carry = @carry[@active[:open].length..].to_s
|
|
68
|
+
else
|
|
69
|
+
close_pos = @carry.index(@active[:close])
|
|
70
|
+
if close_pos.nil?
|
|
71
|
+
flush_in_block(thinking)
|
|
72
|
+
break
|
|
73
|
+
end
|
|
74
|
+
body = @carry[0...close_pos]
|
|
75
|
+
route_body(body, thinking)
|
|
76
|
+
@carry = @carry[close_pos + @active[:close].length..].to_s
|
|
77
|
+
@active = nil
|
|
78
|
+
@state = :normal
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
{ text: text, thinking: thinking }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Flush any residual state at end of stream (e.g. an unterminated thinking
|
|
85
|
+
# block: its content is still thinking; a partial trailing marker is
|
|
86
|
+
# discarded since it can no longer complete).
|
|
87
|
+
# @return [Hash{ text: String, thinking: String }]
|
|
88
|
+
def finalize
|
|
89
|
+
text = +""
|
|
90
|
+
thinking = +""
|
|
91
|
+
if @state == :in_block
|
|
92
|
+
route_body(@carry, thinking)
|
|
93
|
+
else
|
|
94
|
+
text << @carry
|
|
95
|
+
end
|
|
96
|
+
@carry = +""
|
|
97
|
+
@active = nil
|
|
98
|
+
@state = :normal
|
|
99
|
+
{ text: text, thinking: thinking }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Normal state: the earliest complete open marker among @blocks, or nil.
|
|
105
|
+
def earliest_open
|
|
106
|
+
best = nil
|
|
107
|
+
@blocks.each do |block|
|
|
108
|
+
pos = @carry.index(block[:open])
|
|
109
|
+
next if pos.nil?
|
|
110
|
+
best = { pos: pos, block: block } if best.nil? || pos < best[:pos]
|
|
111
|
+
end
|
|
112
|
+
best
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Normal state: emit everything except a trailing fragment that could be the
|
|
116
|
+
# start of an open marker (retained for the next chunk to complete it).
|
|
117
|
+
def flush_normal(text)
|
|
118
|
+
partial = @blocks.map { |block| longest_suffix_prefix_of(@carry, block[:open]) }.max.to_i
|
|
119
|
+
text << @carry[0...(@carry.length - partial)]
|
|
120
|
+
@carry = @carry[@carry.length - partial..].to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# In-block state: emit the body seen so far to the active lane, retaining a
|
|
124
|
+
# trailing fragment that could be the start of the close marker.
|
|
125
|
+
def flush_in_block(thinking)
|
|
126
|
+
close = @active[:close]
|
|
127
|
+
partial = longest_suffix_prefix_of(@carry, close)
|
|
128
|
+
route_body(@carry[0...(@carry.length - partial)], thinking)
|
|
129
|
+
@carry = @carry[@carry.length - partial..].to_s
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Route a body fragment to the active block's lane (:thinking or :drop).
|
|
133
|
+
def route_body(body, thinking)
|
|
134
|
+
return if body.nil? || body.empty?
|
|
135
|
+
thinking << body if @active && @active[:lane] == :thinking
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Length of the longest suffix of +str+ that is a prefix of +marker+.
|
|
139
|
+
# Used to hold back a fragment that may complete into a marker next chunk.
|
|
140
|
+
def longest_suffix_prefix_of(str, marker)
|
|
141
|
+
mlen = marker.length
|
|
142
|
+
return 0 if mlen.zero? || str.empty?
|
|
143
|
+
[str.length, mlen].min.downto(1) do |n|
|
|
144
|
+
return n if str[-n..] == marker[0, n]
|
|
145
|
+
end
|
|
146
|
+
0
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# TokenUsage extracts real server-reported token counts from a model API
|
|
5
|
+
# stream payload, transport-agnostically.
|
|
6
|
+
#
|
|
7
|
+
# llama.cpp's /completion stream emits per-chunk `timings.prompt_n` /
|
|
8
|
+
# `timings.predicted_n` (cumulative) and a final top-level
|
|
9
|
+
# `tokens_evaluated` / `tokens_predicted`. mlx_lm.server (OpenAI-compatible)
|
|
10
|
+
# emits a `usage` object with `prompt_tokens` / `completion_tokens`.
|
|
11
|
+
#
|
|
12
|
+
# Returns nil when no server token data is present, so a caller can fall back
|
|
13
|
+
# to a chars/4 heuristic. The reported values are cumulative per request, so
|
|
14
|
+
# callers should take the running MAX across chunks (see SessionMetrics).
|
|
15
|
+
class TokenUsage
|
|
16
|
+
CHARS_PER_TOKEN = 4.0
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# @param payload [Hash, nil] the raw streamed chunk payload
|
|
20
|
+
# @return [Hash, nil] { prompt_tokens:, completion_tokens:, source: :server }
|
|
21
|
+
def from_payload(payload)
|
|
22
|
+
payload_hash = payload.is_a?(Hash) ? payload : {}
|
|
23
|
+
usage = payload_hash["usage"] || payload_hash[:usage]
|
|
24
|
+
usage = {} unless usage.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
prompt_tokens = first_positive(
|
|
27
|
+
usage["prompt_tokens"],
|
|
28
|
+
usage[:prompt_tokens],
|
|
29
|
+
payload_hash["prompt_tokens"],
|
|
30
|
+
payload_hash[:prompt_tokens],
|
|
31
|
+
payload_hash["prompt_n"],
|
|
32
|
+
payload_hash[:prompt_n],
|
|
33
|
+
payload_hash["tokens_evaluated"],
|
|
34
|
+
payload_hash[:tokens_evaluated],
|
|
35
|
+
payload_hash.dig("timings", "prompt_n"),
|
|
36
|
+
payload_hash.dig(:timings, :prompt_n)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
completion_tokens = first_positive(
|
|
40
|
+
usage["completion_tokens"],
|
|
41
|
+
usage[:completion_tokens],
|
|
42
|
+
payload_hash["completion_tokens"],
|
|
43
|
+
payload_hash[:completion_tokens],
|
|
44
|
+
payload_hash["predicted_n"],
|
|
45
|
+
payload_hash[:predicted_n],
|
|
46
|
+
payload_hash["tokens_predicted"],
|
|
47
|
+
payload_hash[:tokens_predicted],
|
|
48
|
+
payload_hash.dig("timings", "predicted_n"),
|
|
49
|
+
payload_hash.dig(:timings, :predicted_n)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return nil unless prompt_tokens || completion_tokens
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
prompt_tokens: prompt_tokens,
|
|
56
|
+
completion_tokens: completion_tokens,
|
|
57
|
+
source: :server
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Estimate token count from raw text length using the chars/4 heuristic.
|
|
62
|
+
# @param text [String]
|
|
63
|
+
# @return [Integer]
|
|
64
|
+
def estimate(text)
|
|
65
|
+
return 0 unless text.is_a?(String)
|
|
66
|
+
|
|
67
|
+
(text.length / CHARS_PER_TOKEN).ceil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Coerce each candidate to an integer (ContextUsage reads its own fields
|
|
71
|
+
# with this too), accepting integers, numeric strings,
|
|
72
|
+
# and floats (e.g. a JSON backend emitting 50.0). Truncates via to_i so a
|
|
73
|
+
# float never raises RangeError; non-numeric values are skipped.
|
|
74
|
+
def first_positive(*values)
|
|
75
|
+
values.each do |value|
|
|
76
|
+
next if value.nil?
|
|
77
|
+
|
|
78
|
+
n = Float(value).to_i
|
|
79
|
+
return n if n.positive?
|
|
80
|
+
rescue ArgumentError, TypeError
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "tools/execute"
|
|
6
|
+
require_relative "tools/read"
|
|
7
|
+
require_relative "tools/write"
|
|
8
|
+
require_relative "tools/memory"
|
|
9
|
+
require_relative "tools/edit"
|
|
10
|
+
require_relative "tools/task_create"
|
|
11
|
+
require_relative "tools/task_get"
|
|
12
|
+
require_relative "tools/task_list"
|
|
13
|
+
require_relative "tools/task_stop"
|
|
14
|
+
require_relative "tools/task_wait"
|
|
15
|
+
require_relative "tools/web_fetch"
|
|
16
|
+
require_relative "tools/ask_user_question"
|
|
17
|
+
require_relative "tools/list_sessions"
|
|
18
|
+
require_relative "tools/send_note"
|
|
19
|
+
require_relative "tools/delegate"
|
|
20
|
+
require_relative "tools/delegate_result"
|
|
21
|
+
|
|
22
|
+
module Samagotchi
|
|
23
|
+
# The one-line summary of a tool call that the UIs show ("reading file",
|
|
24
|
+
# path="…", ok/error/blocked). Both loops and KernelLoop#dispatch build it
|
|
25
|
+
# from here. The built-ins have their own words below; any other tool in
|
|
26
|
+
# the given Tools::Registry gets its entry's label and preview, or
|
|
27
|
+
# "calling tool" and its arguments as key=value.
|
|
28
|
+
module ToolActivity
|
|
29
|
+
TOOL_ACTIVITY_PREVIEW_LIMIT = 80
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param registry [Tools::Registry, nil] the session's tools
|
|
34
|
+
def tool_activity_event(tool_name, call, result, registry: nil)
|
|
35
|
+
{
|
|
36
|
+
action: tool_activity_action(tool_name, registry: registry),
|
|
37
|
+
tool: tool_name,
|
|
38
|
+
params: tool_activity_params(tool_name, call, registry: registry),
|
|
39
|
+
status: tool_activity_status(result, tool_name)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def tool_activity_action(tool_name, registry: nil)
|
|
44
|
+
case tool_name
|
|
45
|
+
when Tools::Execute::NAME then "running command"
|
|
46
|
+
when Tools::Read::NAME then "reading file"
|
|
47
|
+
when Tools::Write::NAME then "writing file"
|
|
48
|
+
when Tools::Edit::NAME then "editing file"
|
|
49
|
+
when Tools::MemoryRead::NAME then "reading memory"
|
|
50
|
+
when Tools::MemoryWrite::NAME then "saving memory"
|
|
51
|
+
when Tools::TaskCreate::NAME then "starting background task"
|
|
52
|
+
when Tools::TaskGet::NAME then "checking task"
|
|
53
|
+
when Tools::TaskList::NAME then "listing tasks"
|
|
54
|
+
when Tools::TaskStop::NAME then "stopping task"
|
|
55
|
+
when Tools::TaskWait::NAME then "waiting for task"
|
|
56
|
+
when Tools::WebFetch::NAME then "fetching URL"
|
|
57
|
+
when Tools::AskUserQuestion::NAME then "asking user"
|
|
58
|
+
when Tools::ListSessions::NAME then "listing sessions"
|
|
59
|
+
when Tools::SendNote::NAME then "sending a note"
|
|
60
|
+
when Tools::Delegate::NAME then "delegating"
|
|
61
|
+
when Tools::DelegateResult::NAME then "waiting for a delegate"
|
|
62
|
+
else registry_entry(registry, tool_name)&.label || "calling tool"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# "error" for an "Error:" result, and for an execute whose command
|
|
67
|
+
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
68
|
+
# signal), so the turn tally's "(N failed)" counts it.
|
|
69
|
+
def tool_activity_status(result, tool_name = nil)
|
|
70
|
+
text = result.to_s
|
|
71
|
+
return "error" if text.start_with?("Error:")
|
|
72
|
+
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
73
|
+
|
|
74
|
+
"ok"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def execute_failed?(text)
|
|
78
|
+
exit_line = text.lines.reverse_each.find { |line| line.start_with?("exit: ") }
|
|
79
|
+
return false unless exit_line
|
|
80
|
+
|
|
81
|
+
code = exit_line[/\Aexit: (\d*)/, 1]
|
|
82
|
+
code != "0"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tool_activity_params(tool_name, call, registry: nil)
|
|
86
|
+
case tool_name
|
|
87
|
+
when Tools::Execute::NAME
|
|
88
|
+
"command=#{preview_tool_param(call[:content])}"
|
|
89
|
+
when Tools::Read::NAME
|
|
90
|
+
parts = ["path=#{preview_tool_param(call[:content])}"]
|
|
91
|
+
range = format_line_range(call)
|
|
92
|
+
parts << "lines=#{range}" if range
|
|
93
|
+
parts.join(" ")
|
|
94
|
+
when Tools::Write::NAME
|
|
95
|
+
"path=#{preview_tool_param(call[:path])}"
|
|
96
|
+
when Tools::Edit::NAME
|
|
97
|
+
parts = ["path=#{preview_tool_param(call[:path])}"]
|
|
98
|
+
range = format_line_range(call)
|
|
99
|
+
parts << "lines=#{range}" if range
|
|
100
|
+
parts.join(" ")
|
|
101
|
+
when Tools::MemoryRead::NAME
|
|
102
|
+
parts = []
|
|
103
|
+
name = call[:content].to_s.strip
|
|
104
|
+
parts << "name=#{preview_tool_param(name)}" unless name.empty?
|
|
105
|
+
scope = call[:scope].to_s.strip
|
|
106
|
+
parts << "scope=#{preview_tool_param(scope)}" unless scope.empty?
|
|
107
|
+
parts.join(" ")
|
|
108
|
+
when Tools::MemoryWrite::NAME
|
|
109
|
+
parts = []
|
|
110
|
+
path = call[:path].to_s.strip
|
|
111
|
+
parts << "name=#{preview_tool_param(path)}" unless path.empty?
|
|
112
|
+
scope = call[:scope].to_s.strip
|
|
113
|
+
parts << "scope=#{preview_tool_param(scope)}" unless scope.empty?
|
|
114
|
+
desc = call[:description].to_s.strip
|
|
115
|
+
parts << "description=#{preview_tool_param(desc)}" unless desc.empty?
|
|
116
|
+
parts.join(" ")
|
|
117
|
+
when Tools::TaskCreate::NAME
|
|
118
|
+
parts = ["command=#{preview_tool_param(call[:content])}"]
|
|
119
|
+
cwd = call[:cwd].to_s.strip
|
|
120
|
+
parts << "cwd=#{preview_tool_param(cwd)}" unless cwd.empty?
|
|
121
|
+
env = call[:env].to_s.strip
|
|
122
|
+
parts << "env=#{preview_tool_param(env)}" unless env.empty?
|
|
123
|
+
parts.join(" ")
|
|
124
|
+
when Tools::TaskGet::NAME, Tools::TaskStop::NAME
|
|
125
|
+
"id=#{preview_tool_param(call[:content])}"
|
|
126
|
+
when Tools::TaskWait::NAME
|
|
127
|
+
parts = ["id=#{preview_tool_param(call[:content])}"]
|
|
128
|
+
timeout = call[:timeout].to_s.strip
|
|
129
|
+
tail_lines = call[:tail_lines].to_s.strip
|
|
130
|
+
done_pattern = call[:done_pattern].to_s.strip
|
|
131
|
+
parts << "timeout=#{preview_tool_param(timeout)}" unless timeout.empty?
|
|
132
|
+
parts << "tail_lines=#{preview_tool_param(tail_lines)}" unless tail_lines.empty?
|
|
133
|
+
parts << "done_pattern=#{preview_tool_param(done_pattern)}" unless done_pattern.empty?
|
|
134
|
+
parts.join(" ")
|
|
135
|
+
when Tools::TaskList::NAME
|
|
136
|
+
nil
|
|
137
|
+
when Tools::WebFetch::NAME
|
|
138
|
+
"url=#{preview_tool_param(call[:content])}"
|
|
139
|
+
when Tools::ListSessions::NAME
|
|
140
|
+
call[:cwd].to_s.strip.empty? ? nil : "cwd=#{preview_tool_param(call[:cwd])}"
|
|
141
|
+
when Tools::SendNote::NAME
|
|
142
|
+
"session=#{preview_tool_param(call[:session])} text=#{preview_tool_param(call[:content])}"
|
|
143
|
+
when Tools::Delegate::NAME
|
|
144
|
+
parts = []
|
|
145
|
+
parts << "session=#{preview_tool_param(call[:session])}" unless call[:session].to_s.strip.empty?
|
|
146
|
+
parts << "model=#{preview_tool_param(call[:model])}" unless call[:model].to_s.strip.empty?
|
|
147
|
+
parts << "task=#{preview_tool_param(call[:content])}"
|
|
148
|
+
parts << "wait=#{preview_tool_param(call[:wait])}" unless call[:wait].nil? || call[:wait].to_s.strip.empty?
|
|
149
|
+
parts.join(" ")
|
|
150
|
+
when Tools::DelegateResult::NAME
|
|
151
|
+
call[:session].to_s.strip.empty? ? nil : "session=#{preview_tool_param(call[:session])}"
|
|
152
|
+
when Tools::AskUserQuestion::NAME
|
|
153
|
+
parts = ["question=#{preview_tool_param(call[:question] || call[:content])}"]
|
|
154
|
+
opts = call[:options]
|
|
155
|
+
parts << "options=#{preview_tool_param(Array(opts).join(","))}" if opts && !Array(opts).empty?
|
|
156
|
+
parts.join(" ")
|
|
157
|
+
else
|
|
158
|
+
registry_params(registry_entry(registry, tool_name), call)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def registry_entry(registry, tool_name) = registry && !tool_name.nil? ? registry[tool_name] : nil
|
|
163
|
+
|
|
164
|
+
# A plugin tool's label ("chrome: screenshot"), what the UIs show for
|
|
165
|
+
# its raw name; nil for a built-in, an unknown tool or an empty label.
|
|
166
|
+
def plugin_label(tool_name, registry:)
|
|
167
|
+
entry = registry_entry(registry, tool_name)
|
|
168
|
+
label = entry && !entry.core? ? entry.label.to_s.strip : ""
|
|
169
|
+
label.empty? ? nil : label
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A registry tool's params: its preview, else each given argument as
|
|
173
|
+
# key="value" (the parsers' args:, which a registry-less reader such as
|
|
174
|
+
# the web's reload also has); nil for a tool the registry doesn't know
|
|
175
|
+
# that has no args:, and for a built-in without its own words above
|
|
176
|
+
# (the reminder tools).
|
|
177
|
+
def registry_params(entry, call)
|
|
178
|
+
return nil if entry&.core?
|
|
179
|
+
return nil if entry.nil? && !call[:args].is_a?(Hash)
|
|
180
|
+
if entry&.preview
|
|
181
|
+
begin
|
|
182
|
+
return entry.preview.call(call)
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
Log.warn(:plugins, "plugin_preview_failed", tool: entry.name, error: e.class.name,
|
|
185
|
+
msg: "#{entry.name} preview failed: #{e.message}")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
given = call[:args].is_a?(Hash) ? call[:args] : call.except(:name)
|
|
190
|
+
parts = given.filter_map do |key, value|
|
|
191
|
+
next if value.nil? || value.to_s.strip.empty?
|
|
192
|
+
|
|
193
|
+
"#{key}=#{preview_tool_param(value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value)}"
|
|
194
|
+
end
|
|
195
|
+
parts.empty? ? nil : parts.join(" ")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def format_line_range(call)
|
|
199
|
+
start_line = call[:start_line].to_s.strip
|
|
200
|
+
end_line = call[:end_line].to_s.strip
|
|
201
|
+
return nil if start_line.empty? && end_line.empty?
|
|
202
|
+
|
|
203
|
+
"#{start_line.empty? ? "?" : start_line}-#{end_line.empty? ? "?" : end_line}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def preview_tool_param(value)
|
|
207
|
+
text = value.to_s.gsub(/\s+/, " ").strip
|
|
208
|
+
return '""' if text.empty?
|
|
209
|
+
|
|
210
|
+
if text.length > TOOL_ACTIVITY_PREVIEW_LIMIT
|
|
211
|
+
text = "#{text[0, TOOL_ACTIVITY_PREVIEW_LIMIT - 1]}…"
|
|
212
|
+
end
|
|
213
|
+
text.inspect
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|