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,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../served_model"
|
|
4
|
+
require_relative "../image_store"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# Line formatting shared by the REPL and the attached view: colour, the
|
|
9
|
+
# `tool>` line, elapsed durations. Pure apart from reading whether
|
|
10
|
+
# $stdout is a colour terminal.
|
|
11
|
+
module Formatting
|
|
12
|
+
def format_tool_activity_line(activity, duration_ms: nil)
|
|
13
|
+
params = activity[:params].to_s.strip
|
|
14
|
+
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
15
|
+
status = activity[:status].to_s
|
|
16
|
+
status_color = status == "ok" ? 32 : 31
|
|
17
|
+
elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
|
|
18
|
+
"#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color)}#{elapsed_suffix}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# "[image shot.png 1280×800 · ~1.3k tokens]", dim: a turn's image.
|
|
22
|
+
def format_image_line(ref)
|
|
23
|
+
paint("[image #{ImageRef.label(ImageStore.symbolize(ref))}]", 90)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# " → image 1280×800" after a tool line whose tool read an image.
|
|
27
|
+
def format_tool_image_suffix(images)
|
|
28
|
+
refs = Array(images).map { |ref| ImageStore.symbolize(ref) }
|
|
29
|
+
return "" if refs.empty?
|
|
30
|
+
|
|
31
|
+
" #{paint(refs.map { |ref| "→ image #{ref[:width]}×#{ref[:height]}" }.join(", "), 90)}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A card (Engine#show_card) as a framed block: the title and its
|
|
35
|
+
# source, the body as wrapped plain text (no terminal markdown), and
|
|
36
|
+
# one `→ <command>` line per action, the label after it when it says
|
|
37
|
+
# more. +updated+: a card shown again under its id.
|
|
38
|
+
# @param card [Hash] title:, source:, body:, level:, actions:
|
|
39
|
+
# @param width [Integer, nil] columns (#card_width by default)
|
|
40
|
+
def card_block(card, updated: false, width: nil)
|
|
41
|
+
width = [(width || card_width).to_i, 24].max
|
|
42
|
+
rail = paint("│", 90)
|
|
43
|
+
title = card[:title].to_s
|
|
44
|
+
title += " (updated)" if updated
|
|
45
|
+
source = card[:source].to_s
|
|
46
|
+
head = paint("┌ #{title}", card[:level].to_s == "warn" ? 33 : 1)
|
|
47
|
+
head += paint(" · #{source}", 90) unless source.empty?
|
|
48
|
+
lines = [head]
|
|
49
|
+
wrap_plain(strip_markdown(card[:body].to_s), width - 2).each { |line| lines << (line.empty? ? rail : "#{rail} #{line}") }
|
|
50
|
+
Array(card[:actions]).each do |action|
|
|
51
|
+
command = action[:command].to_s
|
|
52
|
+
label = action[:label].to_s
|
|
53
|
+
line = "#{rail} #{paint("→ #{command}", 36)}"
|
|
54
|
+
line += paint(" #{label}", 90) unless label.empty? || label == command
|
|
55
|
+
lines << line
|
|
56
|
+
end
|
|
57
|
+
lines << paint("└", 90)
|
|
58
|
+
lines.join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A card body's markdown made plain, cheaply (there is no terminal
|
|
62
|
+
# markdown): **x** and __x__ are x, backticks and code fences go, and
|
|
63
|
+
# so do headings' #s. Lists stay as they are.
|
|
64
|
+
def strip_markdown(text)
|
|
65
|
+
text.gsub(/^[ \t]*```.*(?:\n|\z)/, "")
|
|
66
|
+
.gsub(/^[ \t]{0,3}\#{1,6}[ \t]+/, "")
|
|
67
|
+
.gsub(/\*\*(.+?)\*\*/, '\1')
|
|
68
|
+
.gsub(/__(.+?)__/, '\1')
|
|
69
|
+
.delete("`")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The columns a card is wrapped to: the terminal's (a view with a
|
|
73
|
+
# surface asks it).
|
|
74
|
+
def card_width
|
|
75
|
+
surface = instance_variable_defined?(:@screen) ? @screen : instance_variable_get(:@surface)
|
|
76
|
+
surface.respond_to?(:columns) ? surface.columns : (IO.console&.winsize&.last || 80)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# +text+ wrapped at word boundaries to +width+ columns, its own line
|
|
80
|
+
# breaks kept (a blank line stays blank), a word longer than a line
|
|
81
|
+
# split. Trailing blank lines dropped.
|
|
82
|
+
def wrap_plain(text, width)
|
|
83
|
+
width = [width, 8].max
|
|
84
|
+
lines = text.gsub("\r\n", "\n").rstrip.split("\n", -1).flat_map do |raw|
|
|
85
|
+
raw = raw.rstrip
|
|
86
|
+
next [""] if raw.empty?
|
|
87
|
+
|
|
88
|
+
indent = raw[/\A */]
|
|
89
|
+
out = []
|
|
90
|
+
line = +""
|
|
91
|
+
raw.split(/ +/).reject(&:empty?).each do |word|
|
|
92
|
+
while word.length > width - indent.length
|
|
93
|
+
out << "#{indent}#{line}".rstrip unless line.empty?
|
|
94
|
+
line = +""
|
|
95
|
+
out << "#{indent}#{word[0, width - indent.length]}"
|
|
96
|
+
word = word[(width - indent.length)..]
|
|
97
|
+
end
|
|
98
|
+
if !line.empty? && indent.length + line.length + 1 + word.length > width
|
|
99
|
+
out << "#{indent}#{line}"
|
|
100
|
+
line = +""
|
|
101
|
+
end
|
|
102
|
+
line << (line.empty? ? word : " #{word}")
|
|
103
|
+
end
|
|
104
|
+
out << "#{indent}#{line}" unless line.empty?
|
|
105
|
+
out
|
|
106
|
+
end
|
|
107
|
+
text.strip.empty? ? [] : lines
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# The recap shown on return: dim, one "recap>" block, noting how many
|
|
113
|
+
# turns came since it was written.
|
|
114
|
+
def recap_block(text, turns_since: 0)
|
|
115
|
+
n = turns_since.to_i
|
|
116
|
+
note = if n == 1 then " (before the last turn)"
|
|
117
|
+
elsif n > 1 then " (before the last #{n} turns)"
|
|
118
|
+
else ""
|
|
119
|
+
end
|
|
120
|
+
text.to_s.strip.lines.map(&:chomp).each_with_index.map do |line, i|
|
|
121
|
+
paint(i.zero? ? "recap#{note}> #{line}" : line, 90)
|
|
122
|
+
end.join("\n")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
RECAP_OFF_TEXT = "recap is off (recap: false in config.yml, or SAMAGOTCHI_RECAP_ENABLED=false)"
|
|
126
|
+
|
|
127
|
+
# What /recap says (the REPL's words, attached mode's too): the saved
|
|
128
|
+
# recap, then what came of asking for a new one.
|
|
129
|
+
# @param saved [Hash, nil] {text:, turns_since:}
|
|
130
|
+
# @param request [Symbol, String, nil] IdleRecap#request_now's answer
|
|
131
|
+
def recap_command_text(enabled:, saved: nil, request: nil, min_user_turns: nil)
|
|
132
|
+
return RECAP_OFF_TEXT unless enabled
|
|
133
|
+
|
|
134
|
+
saved = saved&.transform_keys(&:to_sym)
|
|
135
|
+
lines = []
|
|
136
|
+
lines << recap_block(saved[:text], turns_since: saved[:turns_since]) if saved
|
|
137
|
+
lines << case request&.to_sym
|
|
138
|
+
when :started, :in_flight then "writing a recap…"
|
|
139
|
+
when :nothing_new then saved ? "(nothing new since this recap)" : "no recap yet: nothing to recap"
|
|
140
|
+
when :too_short then "no recap yet: it needs at least #{min_user_turns} user turns"
|
|
141
|
+
when :busy then saved ? nil : "no recap yet: a turn is running; one is written once the session is idle"
|
|
142
|
+
when :failed then "(could not ask for a recap)"
|
|
143
|
+
end
|
|
144
|
+
lines.compact.join("\n")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ── The status line (the REPL's, and attached mode's idle one) ────────
|
|
148
|
+
|
|
149
|
+
# SAMAGOTCHI_STATUS_LINE=off hides it.
|
|
150
|
+
def status_line_enabled?
|
|
151
|
+
value = ENV.fetch(STATUS_LINE_ENV, STATUS_LINE_ON).to_s.strip.downcase
|
|
152
|
+
!(value.empty? || value == STATUS_LINE_OFF || value == "0" || value == "false")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @return [Array<String>] the "status> a | b" row, cut to +width+
|
|
156
|
+
def status_rows(segments, width)
|
|
157
|
+
return [] if segments.empty? || width <= 0
|
|
158
|
+
|
|
159
|
+
row = cap_preview_text("status> #{segments.join(' | ')}", width)
|
|
160
|
+
[color_output? ? paint(row, 90) : row]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Longest asked-for name the status line keeps next to a served one.
|
|
164
|
+
STATUS_ASKED_MAX = 24
|
|
165
|
+
|
|
166
|
+
# +served+ / +served_for+: the model the server said it served for the
|
|
167
|
+
# name asked (see Engine#served_model); shown first when it's another
|
|
168
|
+
# model.
|
|
169
|
+
def status_model_text(model, default_model, served: nil, served_for: nil)
|
|
170
|
+
if ServedModel.differs?(served_for, served)
|
|
171
|
+
asked = model.to_s.length > STATUS_ASKED_MAX ? "#{model.to_s[0, STATUS_ASKED_MAX - 1]}…" : model
|
|
172
|
+
return "model=#{served} (served; asked #{asked})"
|
|
173
|
+
end
|
|
174
|
+
return "model=#{model}" if default_model.nil? || model == default_model
|
|
175
|
+
|
|
176
|
+
"model=#{model} (default: #{default_model})"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @param server [Hash, nil] the server's own numbers ({ctx_pct:, prompt_tokens:, ...})
|
|
180
|
+
# @param estimate [Hash, nil] the kernel's estimate ({est_pct:, bucket:})
|
|
181
|
+
def status_context_text(server: nil, estimate: nil)
|
|
182
|
+
return format_server_context_segment(server) if server.is_a?(Hash)
|
|
183
|
+
return "" unless estimate.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
pct = format("%.1f", estimate[:est_pct].to_f)
|
|
186
|
+
bucket = estimate[:bucket].to_s
|
|
187
|
+
return "ctx=#{pct}%" if bucket.empty?
|
|
188
|
+
|
|
189
|
+
"ctx=#{pct}% (#{bucket})"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def format_server_context_segment(status)
|
|
193
|
+
pct = status[:ctx_pct]
|
|
194
|
+
base = pct ? "ctx=#{format('%.1f', pct)}%" : "ctx=srv"
|
|
195
|
+
|
|
196
|
+
tokens = []
|
|
197
|
+
prompt_tokens = status[:prompt_tokens]
|
|
198
|
+
completion_tokens = status[:completion_tokens]
|
|
199
|
+
total_tokens = status[:total_tokens]
|
|
200
|
+
tokens << "p=#{prompt_tokens}" if prompt_tokens
|
|
201
|
+
tokens << "c=#{completion_tokens}" if completion_tokens
|
|
202
|
+
tokens << "t=#{total_tokens}" if total_tokens
|
|
203
|
+
|
|
204
|
+
return base if tokens.empty?
|
|
205
|
+
|
|
206
|
+
"#{base} (#{tokens.join(' ')})"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @param label [String] "mem" for the used memories, "muted" for the
|
|
210
|
+
# session's --mute list
|
|
211
|
+
def status_memory_text(names, limit, label: "mem")
|
|
212
|
+
names = Array(names)
|
|
213
|
+
return "" if names.empty?
|
|
214
|
+
|
|
215
|
+
visible = names.first(limit)
|
|
216
|
+
suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
|
|
217
|
+
"#{label}: #{visible.join(', ')}#{suffix}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def cap_preview_text(text, width)
|
|
221
|
+
return "" if width <= 0
|
|
222
|
+
|
|
223
|
+
value = text.to_s
|
|
224
|
+
value.length > width ? value[0, width] : value
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Render the analytics snapshot as a compact, user-facing report. Raw event
|
|
228
|
+
# logs (debug-only) are intentionally excluded; this surface is for the REPL.
|
|
229
|
+
def format_session_metrics(snapshot)
|
|
230
|
+
return "(no metrics yet)" unless snapshot.is_a?(Hash)
|
|
231
|
+
|
|
232
|
+
# A symbol locally; a string when the snapshot came over the Bridge.
|
|
233
|
+
token_src = snapshot[:token_source]&.to_s
|
|
234
|
+
token_src_label = case token_src
|
|
235
|
+
when "server" then "server-reported"
|
|
236
|
+
when "estimate" then "estimated (chars/4)"
|
|
237
|
+
else "n/a"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
lines = []
|
|
241
|
+
lines << "turns: #{snapshot[:turns]}"
|
|
242
|
+
lines << "tool calls: #{snapshot[:tool_calls_total]} (#{snapshot[:tool_errors]} errors)"
|
|
243
|
+
unless snapshot[:tool_calls_by_tool].to_a.empty?
|
|
244
|
+
by_tool = snapshot[:tool_calls_by_tool].sort_by { |_k, v| -v }
|
|
245
|
+
lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
246
|
+
end
|
|
247
|
+
lines << "iterations: #{snapshot[:iterations_total]}"
|
|
248
|
+
lines << "tokens in/out: #{snapshot[:tokens_in]}/#{snapshot[:tokens_out]} (total #{snapshot[:tokens_total]}, #{token_src_label})"
|
|
249
|
+
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
250
|
+
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
251
|
+
lines << "retries: #{snapshot[:retries]}"
|
|
252
|
+
if snapshot[:context_window_tokens]
|
|
253
|
+
lines << "context window: #{snapshot[:context_window_tokens]} tokens (#{snapshot[:context_window_source]})"
|
|
254
|
+
end
|
|
255
|
+
lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
|
|
256
|
+
if snapshot[:served_model]
|
|
257
|
+
asked = snapshot[:served_model_for]
|
|
258
|
+
note = ServedModel.differs?(asked, snapshot[:served_model]) ? " (asked for #{asked})" : ""
|
|
259
|
+
lines << "served model: #{snapshot[:served_model]}#{note}"
|
|
260
|
+
end
|
|
261
|
+
lines.join("\n")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def format_elapsed_duration(duration_ms)
|
|
265
|
+
duration_ms = duration_ms.to_f
|
|
266
|
+
return "" if duration_ms.negative?
|
|
267
|
+
return "#{duration_ms.round}ms" if duration_ms < 500
|
|
268
|
+
|
|
269
|
+
seconds = duration_ms / 1000
|
|
270
|
+
return "#{seconds.round(1)}s" if seconds < 10
|
|
271
|
+
return "#{seconds.round}s" if seconds < 60
|
|
272
|
+
|
|
273
|
+
total_seconds = seconds.round
|
|
274
|
+
"#{total_seconds / 60}m #{format('%02d', total_seconds % 60)}s"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def paint(text, code)
|
|
278
|
+
return text unless color_output?
|
|
279
|
+
|
|
280
|
+
"\e[#{code}m#{text}\e[0m"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def color_output?
|
|
284
|
+
return false unless $stdout.tty?
|
|
285
|
+
return false if ENV.key?("NO_COLOR")
|
|
286
|
+
|
|
287
|
+
ENV.fetch("TERM", "") != "dumb"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../image_store"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class TerminalUI
|
|
7
|
+
# `@path` in a typed prompt: each token that names an existing image
|
|
8
|
+
# file (by its bytes) becomes one of the turn's images. The prompt text
|
|
9
|
+
# stays as typed; a token that isn't an image (a source file, a missing
|
|
10
|
+
# path, an email address) is just text.
|
|
11
|
+
#
|
|
12
|
+
# @shot.png @~/Desktop/a.jpg @"my shot.png" @'my shot.png'
|
|
13
|
+
module ImageInput
|
|
14
|
+
# An @ at the start or after whitespace, then a quoted or bare path.
|
|
15
|
+
TOKEN_RE = /(?:\A|(?<=\s))@(?:"([^"]+)"|'([^']+)'|(\S+))/
|
|
16
|
+
|
|
17
|
+
# @return [Array<Hash>] {path:} per image, in order, each path once
|
|
18
|
+
def self.extract(text, cwd: Dir.pwd)
|
|
19
|
+
text.to_s.scan(TOKEN_RE).filter_map do |quoted_double, quoted_single, bare|
|
|
20
|
+
raw = quoted_double || quoted_single || bare.sub(/[,.;:!?)\]]+\z/, "")
|
|
21
|
+
path = expand(raw, cwd)
|
|
22
|
+
path if path && ImageStore.image_file?(path)
|
|
23
|
+
end.uniq.map { |path| { path: path } }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.expand(raw, cwd)
|
|
27
|
+
return nil if raw.empty?
|
|
28
|
+
|
|
29
|
+
File.expand_path(raw, cwd)
|
|
30
|
+
rescue ArgumentError # ~unknown_user
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
private_class_method :expand
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "reline"
|
|
6
|
+
|
|
7
|
+
require_relative "../memory_paths"
|
|
8
|
+
require_relative "../tools/memory"
|
|
9
|
+
require_relative "../session_commands"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
class TerminalUI
|
|
13
|
+
# Input the REPL and the attached TUI share: the persistent prompt
|
|
14
|
+
# history, Tab completion (/commands, @path in the cwd, #memory), the
|
|
15
|
+
# #memory shorthand rewrite, multiline reads at the main prompt, and a
|
|
16
|
+
# one-shot prefill of the next read (the default input, a prompt given
|
|
17
|
+
# back after a failed turn).
|
|
18
|
+
#
|
|
19
|
+
# Included for private use; it keeps state in @next_input_prefill and
|
|
20
|
+
# reads @no_default_input.
|
|
21
|
+
module InputSupport
|
|
22
|
+
PROMPT_HISTORY_ENV = "SAMAGOTCHI_HISTORY_FILE"
|
|
23
|
+
XDG_STATE_HOME_ENV = "XDG_STATE_HOME"
|
|
24
|
+
PROMPT_HISTORY_FILE = "history.json"
|
|
25
|
+
PROMPT_HISTORY_STATE_DIR = "samagotchi"
|
|
26
|
+
PROMPT_HISTORY_LIMIT = 20
|
|
27
|
+
DEFAULT_INPUT_ENV = "SAMAGOTCHI_DEFAULT_INPUT"
|
|
28
|
+
AT_PATH_COMPLETION_PREFIX = "@"
|
|
29
|
+
MEMORY_COMPLETION_PREFIX = "#"
|
|
30
|
+
AT_PATH_COMPLETION_MAX_CANDIDATES = 200
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# The /commands Tab offers: the session's registry, the REPL's own
|
|
35
|
+
# among them.
|
|
36
|
+
def slash_commands = command_registry.completions(:repl)
|
|
37
|
+
|
|
38
|
+
def command_registry = @commands&.registry || SessionCommands.builtin_registry
|
|
39
|
+
|
|
40
|
+
# Whether a new session's first read gets the default input
|
|
41
|
+
# (SAMAGOTCHI_DEFAULT_INPUT); --no-default-input says no.
|
|
42
|
+
def default_input_wanted? = !@no_default_input
|
|
43
|
+
|
|
44
|
+
# One read at the main prompt. In multiline mode Enter submits, while
|
|
45
|
+
# Meta+Enter/Alt+Enter inserts a newline on terminals that emit that
|
|
46
|
+
# distinct sequence (for example kitty); Tab completes; a queued prefill
|
|
47
|
+
# is typed in first.
|
|
48
|
+
# @return [String, nil] the line, nil on Ctrl-D
|
|
49
|
+
def read_prompt_line(prompt)
|
|
50
|
+
input = with_scoped_at_path_completion do
|
|
51
|
+
with_next_input_prefill do
|
|
52
|
+
Reline.readmultiline(prompt, true) { true }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
return nil if input.nil?
|
|
56
|
+
|
|
57
|
+
input.gsub(/\r\n?|\n\z/, "\n").strip
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def with_scoped_at_path_completion
|
|
61
|
+
previous_completion_proc = Reline.completion_proc
|
|
62
|
+
previous_autocompletion = Reline.autocompletion
|
|
63
|
+
Reline.autocompletion = true
|
|
64
|
+
Reline.completion_proc = method(:assist_path_completion_candidates).to_proc
|
|
65
|
+
yield
|
|
66
|
+
ensure
|
|
67
|
+
Reline.completion_proc = previous_completion_proc
|
|
68
|
+
Reline.autocompletion = previous_autocompletion
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def assist_path_completion_candidates(word)
|
|
72
|
+
token = word.to_s
|
|
73
|
+
return [] if token.empty?
|
|
74
|
+
|
|
75
|
+
if token.start_with?("/")
|
|
76
|
+
return build_slash_completion_candidates(token)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if token.start_with?(AT_PATH_COMPLETION_PREFIX)
|
|
80
|
+
path_fragment = token.delete_prefix(AT_PATH_COMPLETION_PREFIX)
|
|
81
|
+
return build_project_path_completion_candidates(path_fragment)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if token.start_with?(MEMORY_COMPLETION_PREFIX)
|
|
85
|
+
memory_fragment = token.delete_prefix(MEMORY_COMPLETION_PREFIX)
|
|
86
|
+
return build_memory_completion_candidates(memory_fragment)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_slash_completion_candidates(slash_fragment)
|
|
93
|
+
fragment = slash_fragment.to_s.strip
|
|
94
|
+
return [] unless fragment.start_with?("/")
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
buf = Reline.line_buffer.to_s
|
|
98
|
+
unless buf.empty?
|
|
99
|
+
return [] unless buf.lstrip.start_with?("/")
|
|
100
|
+
end
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
lowered = fragment.downcase
|
|
106
|
+
return slash_commands.dup if lowered == "/"
|
|
107
|
+
|
|
108
|
+
slash_commands.select { |cmd| cmd.start_with?(lowered) }
|
|
109
|
+
rescue StandardError
|
|
110
|
+
[]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_project_path_completion_candidates(path_fragment)
|
|
114
|
+
fragment = path_fragment.to_s.tr("\\", "/")
|
|
115
|
+
return [] if fragment.start_with?("/")
|
|
116
|
+
return [] if fragment.split("/").include?("..")
|
|
117
|
+
|
|
118
|
+
dir_part = ""
|
|
119
|
+
entry_prefix = fragment
|
|
120
|
+
|
|
121
|
+
if fragment.include?("/")
|
|
122
|
+
dir_part = fragment.sub(%r{[^/]*\z}, "")
|
|
123
|
+
entry_prefix = fragment.split("/").last.to_s
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
base_dir = dir_part.empty? ? Dir.pwd : File.expand_path(dir_part, Dir.pwd)
|
|
127
|
+
return [] unless path_within_cwd?(base_dir)
|
|
128
|
+
return [] unless File.directory?(base_dir)
|
|
129
|
+
|
|
130
|
+
entries = Dir.children(base_dir).sort
|
|
131
|
+
entries.reject! { |entry| entry.start_with?(".") } unless entry_prefix.start_with?(".")
|
|
132
|
+
matches = entries.select { |entry| entry.start_with?(entry_prefix) }
|
|
133
|
+
|
|
134
|
+
matches.first(AT_PATH_COMPLETION_MAX_CANDIDATES).map do |entry|
|
|
135
|
+
relative_path = "#{dir_part}#{entry}".tr("\\", "/")
|
|
136
|
+
absolute_path = File.join(base_dir, entry)
|
|
137
|
+
relative_path = "#{relative_path}/" if File.directory?(absolute_path)
|
|
138
|
+
"#{AT_PATH_COMPLETION_PREFIX}#{relative_path}"
|
|
139
|
+
end
|
|
140
|
+
rescue StandardError
|
|
141
|
+
[]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def path_within_cwd?(path)
|
|
145
|
+
expanded = File.expand_path(path)
|
|
146
|
+
cwd = Dir.pwd
|
|
147
|
+
expanded == cwd || expanded.start_with?("#{cwd}#{File::SEPARATOR}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_memory_completion_candidates(memory_fragment)
|
|
151
|
+
fragment = memory_fragment.to_s.strip.tr("\\", "/")
|
|
152
|
+
candidates = memory_completion_entries
|
|
153
|
+
return candidates.map { |entry| entry[:token] } if fragment.empty?
|
|
154
|
+
|
|
155
|
+
candidates.filter_map do |entry|
|
|
156
|
+
entry[:token] if entry[:token].delete_prefix(MEMORY_COMPLETION_PREFIX).start_with?(fragment)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def memory_completion_entries
|
|
161
|
+
grouped = Hash.new { |hash, key| hash[key] = [] }
|
|
162
|
+
|
|
163
|
+
each_memory_completion_entry do |scope, name|
|
|
164
|
+
grouped[name] << scope unless grouped[name].include?(scope)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
grouped.sort_by do |name, scopes|
|
|
168
|
+
[memory_scope_sort_key(scopes.min_by { |scope| memory_scope_sort_key(scope) }), name]
|
|
169
|
+
end.flat_map do |name, scopes|
|
|
170
|
+
scopes = scopes.sort_by { |scope| [memory_scope_sort_key(scope), scope] }
|
|
171
|
+
if scopes.length == 1
|
|
172
|
+
[{ token: "#{MEMORY_COMPLETION_PREFIX}#{name}", scope: scopes.first, name: name }]
|
|
173
|
+
else
|
|
174
|
+
scopes.map do |scope|
|
|
175
|
+
{ token: "#{MEMORY_COMPLETION_PREFIX}#{scope}/#{name}", scope: scope, name: name }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def memory_scope_sort_key(scope)
|
|
182
|
+
scope == "project" ? 0 : 1
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def each_memory_completion_entry
|
|
186
|
+
memory_completion_dirs.each do |scope, dir|
|
|
187
|
+
next unless File.directory?(dir)
|
|
188
|
+
|
|
189
|
+
Dir.glob(File.join(dir, "*.md")).sort.each do |path|
|
|
190
|
+
name = File.basename(path, ".md")
|
|
191
|
+
next if name.empty? || name == Tools::MEMORY_INDEX
|
|
192
|
+
|
|
193
|
+
yield scope, name
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
rescue StandardError
|
|
197
|
+
[]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def memory_completion_dirs
|
|
201
|
+
{
|
|
202
|
+
"project" => File.expand_path(MemoryPaths.project_dir, Dir.pwd),
|
|
203
|
+
"system" => File.expand_path(MemoryPaths.system_dir)
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def normalize_model_input(input)
|
|
208
|
+
input.to_s.gsub(/(^|[^\w\/])#((?:project|system)\/)?([a-zA-Z0-9][a-zA-Z0-9_-]*)/) do
|
|
209
|
+
prefix = Regexp.last_match(1)
|
|
210
|
+
scoped = Regexp.last_match(2).to_s
|
|
211
|
+
name = Regexp.last_match(3)
|
|
212
|
+
scope = scoped.delete_suffix("/")
|
|
213
|
+
normalized = if scope.empty?
|
|
214
|
+
"memory \"#{name}\""
|
|
215
|
+
else
|
|
216
|
+
"memory \"#{name}\" in #{scope} scope"
|
|
217
|
+
end
|
|
218
|
+
"#{prefix}#{normalized}"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def history_file_path
|
|
223
|
+
explicit = ENV[PROMPT_HISTORY_ENV].to_s.strip
|
|
224
|
+
return explicit unless explicit.empty?
|
|
225
|
+
|
|
226
|
+
File.join(xdg_state_home, PROMPT_HISTORY_STATE_DIR, PROMPT_HISTORY_FILE)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def xdg_state_home
|
|
230
|
+
configured = ENV[XDG_STATE_HOME_ENV].to_s.strip
|
|
231
|
+
return configured unless configured.empty?
|
|
232
|
+
|
|
233
|
+
File.join(Dir.home, ".local", "state")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def load_persistent_history
|
|
237
|
+
entries = load_history_entries_from_disk
|
|
238
|
+
entries.last(PROMPT_HISTORY_LIMIT).each { |entry| Reline::HISTORY << entry }
|
|
239
|
+
rescue StandardError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def persist_recent_history(input)
|
|
244
|
+
entries = normalize_history_entries(load_history_entries_from_disk)
|
|
245
|
+
entries << input
|
|
246
|
+
trimmed_entries = entries.last(PROMPT_HISTORY_LIMIT)
|
|
247
|
+
path = history_file_path
|
|
248
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
249
|
+
File.write(path, JSON.pretty_generate(trimmed_entries) + "\n")
|
|
250
|
+
rescue StandardError
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def load_history_entries_from_disk
|
|
255
|
+
path = history_file_path
|
|
256
|
+
return [] unless File.file?(path)
|
|
257
|
+
|
|
258
|
+
raw = File.read(path)
|
|
259
|
+
parsed = JSON.parse(raw)
|
|
260
|
+
normalize_history_entries(parsed)
|
|
261
|
+
rescue JSON::ParserError
|
|
262
|
+
normalize_history_entries(raw.to_s.lines.map(&:chomp))
|
|
263
|
+
rescue StandardError
|
|
264
|
+
[]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def normalize_history_entries(entries)
|
|
268
|
+
Array(entries).map { |entry| entry.to_s.gsub(/\r\n?/, "\n").strip }.reject(&:empty?)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The text as given ("Please " keeps its space); a blank one is none.
|
|
272
|
+
def queue_input_prefill(text)
|
|
273
|
+
return if text.to_s.strip.empty?
|
|
274
|
+
|
|
275
|
+
@next_input_prefill = text.to_s
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def queue_default_input
|
|
279
|
+
default = default_input_text
|
|
280
|
+
queue_input_prefill(default) if default
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @return [String, nil] the default input for a new session's first
|
|
284
|
+
# read, if it gets one
|
|
285
|
+
def default_input_text
|
|
286
|
+
return nil unless default_input_wanted?
|
|
287
|
+
|
|
288
|
+
default = ENV.fetch(DEFAULT_INPUT_ENV, nil)
|
|
289
|
+
return nil if default.nil? || default.strip.empty?
|
|
290
|
+
|
|
291
|
+
default
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def consume_input_prefill
|
|
295
|
+
value = @next_input_prefill
|
|
296
|
+
@next_input_prefill = nil
|
|
297
|
+
value
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def with_next_input_prefill
|
|
301
|
+
prefill = consume_input_prefill
|
|
302
|
+
return yield if prefill.nil? || prefill.empty?
|
|
303
|
+
|
|
304
|
+
previous_hook = Reline.pre_input_hook
|
|
305
|
+
inserted = false
|
|
306
|
+
Reline.pre_input_hook = proc do
|
|
307
|
+
unless inserted
|
|
308
|
+
Reline.insert_text(prefill)
|
|
309
|
+
inserted = true
|
|
310
|
+
end
|
|
311
|
+
previous_hook.call if previous_hook
|
|
312
|
+
end
|
|
313
|
+
begin
|
|
314
|
+
yield
|
|
315
|
+
ensure
|
|
316
|
+
# Restore only what we replaced: a method-level ensure also ran on the
|
|
317
|
+
# no-prefill early return and reset the hook to nil, dropping the
|
|
318
|
+
# Engine activity hook (with_activity_hook) after the first prompt.
|
|
319
|
+
Reline.pre_input_hook = previous_hook
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|