samagotchi 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "rubygems/package"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# The gem part of `chi update`: the newest samagotchi on rubygems, and a
|
|
12
|
+
# `gem install` of it through this Ruby's own gem command (the real
|
|
13
|
+
# interpreter's bindir, not a shim: the same under mise, rbenv and asdf).
|
|
14
|
+
#
|
|
15
|
+
# SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
|
|
16
|
+
# file the newest version and installs it instead.
|
|
17
|
+
class GemUpdate
|
|
18
|
+
Error = Class.new(StandardError)
|
|
19
|
+
|
|
20
|
+
LATEST_URL = URI("https://rubygems.org/api/v1/versions/samagotchi/latest.json")
|
|
21
|
+
# Not SAMAGOTCHI_UPDATE_GEM: that is the env form of config update.gem.
|
|
22
|
+
LOCAL_ENV = "SAMAGOTCHI_UPDATE_GEM_FILE"
|
|
23
|
+
TIMEOUT = 5
|
|
24
|
+
|
|
25
|
+
# source is what `gem install` takes: the gem's name, or a .gem file.
|
|
26
|
+
Latest = Struct.new(:version, :source, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# Runs a command without a shell; [output, success].
|
|
29
|
+
class Runner
|
|
30
|
+
def run(argv)
|
|
31
|
+
output, status = Open3.capture2e(*argv)
|
|
32
|
+
[output, status.success?]
|
|
33
|
+
rescue SystemCallError => e
|
|
34
|
+
[e.message, false]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param fetcher [#call, nil] → the latest.json body (specs)
|
|
39
|
+
def initialize(env: ENV, fetcher: nil, runner: Runner.new, gem_bin: File.join(RbConfig::CONFIG["bindir"], "gem"))
|
|
40
|
+
@env = env
|
|
41
|
+
@fetcher = fetcher || method(:fetch)
|
|
42
|
+
@runner = runner
|
|
43
|
+
@gem_bin = gem_bin
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Latest]
|
|
47
|
+
# @raise [Error] when it can't be found out (offline, a bad answer)
|
|
48
|
+
def latest
|
|
49
|
+
local = @env[LOCAL_ENV].to_s
|
|
50
|
+
return Latest.new(version: Gem::Package.new(local).spec.version.to_s, source: local) unless local.empty?
|
|
51
|
+
|
|
52
|
+
version = JSON.parse(@fetcher.call.to_s)["version"].to_s
|
|
53
|
+
raise Error, "rubygems.org answered without a version" if version.empty? || !Gem::Version.correct?(version)
|
|
54
|
+
|
|
55
|
+
Latest.new(version: version, source: "samagotchi")
|
|
56
|
+
rescue Error
|
|
57
|
+
raise
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
raise Error, "#{e.class}: #{e.message}".lines.first.strip
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def newer?(latest, current = VERSION)
|
|
63
|
+
Gem::Version.new(latest.version) > Gem::Version.new(current)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def install_argv(latest)
|
|
67
|
+
argv = [@gem_bin, "install", latest.source, "--no-document"]
|
|
68
|
+
latest.source == "samagotchi" ? argv + ["-v", latest.version] : argv
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Array(String, Boolean)] gem's output, success
|
|
72
|
+
def install(latest)
|
|
73
|
+
@runner.run(install_argv(latest))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def fetch
|
|
79
|
+
Net::HTTP.start(LATEST_URL.host, LATEST_URL.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
80
|
+
request = Net::HTTP::Get.new(LATEST_URL)
|
|
81
|
+
request["User-Agent"] = USER_AGENT
|
|
82
|
+
response = http.request(request)
|
|
83
|
+
raise Error, "rubygems.org answered #{response.code}" unless response.code == "200"
|
|
84
|
+
|
|
85
|
+
response.body
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -20,12 +20,14 @@ module Samagotchi
|
|
|
20
20
|
# @param label [String, nil] a plugin tool's label ("chrome:
|
|
21
21
|
# screenshot"): the question names the tool by it, as its row does;
|
|
22
22
|
# approval[:tool] stays the raw name
|
|
23
|
+
# @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
|
|
24
|
+
# write: the card shows its diff, the question text one change line
|
|
23
25
|
# @return [Hash] open_question fields
|
|
24
|
-
def payload(verdict, label: nil)
|
|
26
|
+
def payload(verdict, label: nil, preview: nil)
|
|
25
27
|
scopes = offered_scopes(verdict)
|
|
26
28
|
targets = verdict.targets
|
|
27
29
|
{
|
|
28
|
-
question: question_text(verdict, label: label),
|
|
30
|
+
question: question_text(verdict, label: label, preview: preview),
|
|
29
31
|
options: scopes.map { |scope| label(scope, verdict) } + [DENY],
|
|
30
32
|
header: HEADER,
|
|
31
33
|
multi_select: false,
|
|
@@ -36,17 +38,27 @@ module Samagotchi
|
|
|
36
38
|
label: label,
|
|
37
39
|
command: targets&.command,
|
|
38
40
|
paths: targets && !targets.paths.empty? ? targets.paths : nil,
|
|
41
|
+
# No command or path (an MCP tool): its arguments, as the question.
|
|
42
|
+
args: args_for_card(targets),
|
|
39
43
|
cwd: targets&.cwd,
|
|
40
44
|
repo_root: targets&.repo_root,
|
|
41
45
|
branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
|
|
42
46
|
rule: verdict.rule,
|
|
43
47
|
source: verdict.source,
|
|
44
48
|
reason: verdict.reason,
|
|
45
|
-
scopes: scopes
|
|
49
|
+
scopes: scopes,
|
|
50
|
+
preview: preview
|
|
46
51
|
}.compact
|
|
47
52
|
}
|
|
48
53
|
end
|
|
49
54
|
|
|
55
|
+
def args_for_card(targets)
|
|
56
|
+
return nil if targets.nil? || targets.command || !targets.paths.empty?
|
|
57
|
+
|
|
58
|
+
text = Approval.args_text(targets.args, limit: ARGS_CHARS)
|
|
59
|
+
text.empty? ? nil : text
|
|
60
|
+
end
|
|
61
|
+
|
|
50
62
|
# Settle +verdict+ from open_question's result.
|
|
51
63
|
# @param answer [Hash, String] {selected_indices:, freeform:} or {error:}
|
|
52
64
|
# @param scopes [Array<String>] the offered scopes, in option order
|
|
@@ -103,7 +115,8 @@ module Samagotchi
|
|
|
103
115
|
# execute: git push origin main
|
|
104
116
|
# in /path/to/repo (repo samagotchi, branch main)
|
|
105
117
|
# why: git push publishes commits (rule git-push, bundle guardrails)
|
|
106
|
-
|
|
118
|
+
# change: +3 −1 (edit/write, from the preview)
|
|
119
|
+
def question_text(verdict, label: nil, preview: nil)
|
|
107
120
|
targets = verdict.targets
|
|
108
121
|
tool = label || targets&.tool || verdict.call[:name].to_s
|
|
109
122
|
what = targets&.command || (targets && targets.paths.join(", "))
|
|
@@ -118,8 +131,26 @@ module Samagotchi
|
|
|
118
131
|
who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
|
|
119
132
|
reason = verdict.reason.to_s.strip
|
|
120
133
|
lines << " why: #{reason.empty? ? "(no reason given)" : reason} (#{who})"
|
|
134
|
+
change = change_text(preview)
|
|
135
|
+
lines << " change: #{change}" if change
|
|
121
136
|
lines.join("\n")
|
|
122
137
|
end
|
|
138
|
+
|
|
139
|
+
# One line for an EditPreview: "+3 −1", "new file, 12 lines",
|
|
140
|
+
# "would fail: …" or "not shown (binary file)". The diff itself stays
|
|
141
|
+
# out of the question text (size); the card and the TUI show it.
|
|
142
|
+
def change_text(preview)
|
|
143
|
+
return nil unless preview.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
get = ->(key) { preview[key] || preview[key.to_s] }
|
|
146
|
+
return "would fail: #{get.(:error)}" if get.(:error)
|
|
147
|
+
return "not shown (#{get.(:skipped)})" if get.(:skipped)
|
|
148
|
+
|
|
149
|
+
added = get.(:added).to_i
|
|
150
|
+
return "new file, #{added} #{added == 1 ? "line" : "lines"}" if get.(:new_file)
|
|
151
|
+
|
|
152
|
+
"+#{added} −#{get.(:removed).to_i}"
|
|
153
|
+
end
|
|
123
154
|
end
|
|
124
155
|
end
|
|
125
156
|
end
|
|
@@ -7,7 +7,7 @@ module Samagotchi
|
|
|
7
7
|
# guardrail that silently vanished is the failure this guards against);
|
|
8
8
|
# the rest are only announced.
|
|
9
9
|
class LoadFailures
|
|
10
|
-
Failure = Struct.new(:what, :reason, :required, keyword_init: true)
|
|
10
|
+
Failure = Struct.new(:what, :reason, :required, :group, keyword_init: true)
|
|
11
11
|
|
|
12
12
|
def initialize
|
|
13
13
|
@list = []
|
|
@@ -15,8 +15,14 @@ module Samagotchi
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# @param what [String] e.g. "hook guard.rb (config)"
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# @param group [Symbol, nil] what loads it again (#drop): :rules
|
|
19
|
+
def add(what, reason, required:, group: nil)
|
|
20
|
+
@mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required, group: group) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Forget a group's failures before it loads again.
|
|
24
|
+
def drop(group)
|
|
25
|
+
@mutex.synchronize { @list.reject! { |failure| failure.group == group } }
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def list = @mutex.synchronize { @list.dup }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_paths"
|
|
4
|
+
require_relative "../memory_paths"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Guardrails
|
|
8
|
+
# A `chi scratch` session saves nothing: write and edit on a file under
|
|
9
|
+
# the memories folder (system and project memories, overlays, index.md)
|
|
10
|
+
# are denied. (memory_write itself refuses in a scratch session, with a
|
|
11
|
+
# shorter answer: Engine.) Reading them is fine. execute can still
|
|
12
|
+
# write there (as with ProtectedPaths).
|
|
13
|
+
class ScratchWrites
|
|
14
|
+
REASON = "scratch session: nothing is saved"
|
|
15
|
+
RULE = "scratch-session"
|
|
16
|
+
|
|
17
|
+
# @param memories_dir [#call] the memories folder (MemoryPaths.system_dir),
|
|
18
|
+
# read per call like the memory tools do
|
|
19
|
+
def initialize(memories_dir: -> { MemoryPaths.system_dir })
|
|
20
|
+
@memories_dir = memories_dir
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Vote on +verdict+.
|
|
24
|
+
def check(verdict)
|
|
25
|
+
return verdict unless writes_memory?(verdict)
|
|
26
|
+
|
|
27
|
+
verdict.deny!(REASON, rule: RULE, source: ProtectedPaths::SOURCE, decided_by: "core")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def writes_memory?(verdict)
|
|
33
|
+
targets = verdict.targets
|
|
34
|
+
return false unless targets && %w[write edit].include?(targets.tool)
|
|
35
|
+
|
|
36
|
+
targets.paths.any? { |path| ProtectedPaths.within?(ProtectedPaths.real(path), @memories_dir.call) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "guardrails/targets"
|
|
|
6
6
|
require_relative "guardrails/approval"
|
|
7
7
|
require_relative "guardrails/approvals"
|
|
8
8
|
require_relative "guardrails/protected_paths"
|
|
9
|
+
require_relative "guardrails/scratch_writes"
|
|
9
10
|
require_relative "guardrails/load_failures"
|
|
10
11
|
require_relative "guardrails/rules"
|
|
11
12
|
require_relative "guardrails/gate"
|
|
@@ -15,14 +15,15 @@ module Samagotchi
|
|
|
15
15
|
takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# What a hook can do beyond reading its event: the Engine's
|
|
18
|
+
# What a hook can do beyond reading its event: the Engine's
|
|
19
19
|
# callables, each given the hook's label. +notify+ takes
|
|
20
20
|
# (text:, level:, hook:) and shows one line to the user; +ask_user+
|
|
21
21
|
# takes (question:, options:, header:, allow_freeform:, hook:) and
|
|
22
22
|
# returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
|
|
23
|
-
# cancels the running turn (true when it did)
|
|
24
|
-
#
|
|
25
|
-
|
|
23
|
+
# cancels the running turn (true when it did); +steer+ takes (text:,
|
|
24
|
+
# hook:) and puts the text into the running turn (Engine#steer, true
|
|
25
|
+
# when queued). A registry without one gives hooks no-op helpers.
|
|
26
|
+
Runtime = Struct.new(:notify, :ask_user, :stop_turn, :steer, keyword_init: true)
|
|
26
27
|
|
|
27
28
|
# A thread-safe registry for named hook callbacks.
|
|
28
29
|
#
|
|
@@ -172,6 +173,12 @@ module Samagotchi
|
|
|
172
173
|
# event[:stop_turn].call(reason) -> true when the turn was cancelled;
|
|
173
174
|
# in a before_tool_call event it also denies the call; from
|
|
174
175
|
# after_turn / session_end it does nothing (false)
|
|
176
|
+
# event[:steer].call(text) -> true when the text was queued for the
|
|
177
|
+
# running turn's next boundary (its own user message, source: the
|
|
178
|
+
# hook's bundle); from after_turn / session_end false
|
|
179
|
+
# and after_turn's fire site adds one more (AnswerDisplay):
|
|
180
|
+
# event[:present].call { |text| new_text } -> the answer's display
|
|
181
|
+
# text after the call (chained in hook order), nil without an answer
|
|
175
182
|
#
|
|
176
183
|
# @param name [Symbol] the hook name to fire
|
|
177
184
|
# @param event [Hash] the event payload (may be mutated by hooks)
|
|
@@ -191,6 +198,13 @@ module Samagotchi
|
|
|
191
198
|
end
|
|
192
199
|
end
|
|
193
200
|
|
|
201
|
+
# Whether a #fire of +name+ would call any hook.
|
|
202
|
+
# @param name [Symbol]
|
|
203
|
+
# @return [Boolean]
|
|
204
|
+
def any?(name)
|
|
205
|
+
!ordered_procs(name).empty?
|
|
206
|
+
end
|
|
207
|
+
|
|
194
208
|
# Like #fire, and yields the event after each hook (a raising one
|
|
195
209
|
# too), so the caller can fold what that hook did before the next one
|
|
196
210
|
# runs (the guardrail gate keeps a deny sticky this way).
|
|
@@ -235,7 +249,7 @@ module Samagotchi
|
|
|
235
249
|
end
|
|
236
250
|
end
|
|
237
251
|
|
|
238
|
-
# The
|
|
252
|
+
# The helpers, once per fire; a fire site's own keys stay.
|
|
239
253
|
def with_runtime(event)
|
|
240
254
|
return unless event.is_a?(Hash)
|
|
241
255
|
|
|
@@ -255,6 +269,11 @@ module Samagotchi
|
|
|
255
269
|
end
|
|
256
270
|
@runtime&.stop_turn&.call(reason: reason.to_s, hook: event[:hook]) ? true : false
|
|
257
271
|
}
|
|
272
|
+
event[:steer] ||= lambda { |text|
|
|
273
|
+
next false if TURN_OVER_EVENTS.include?(event[:type])
|
|
274
|
+
|
|
275
|
+
@runtime&.steer&.call(text: text.to_s, hook: event[:hook]) ? true : false
|
|
276
|
+
}
|
|
258
277
|
end
|
|
259
278
|
end
|
|
260
279
|
end
|
|
@@ -28,9 +28,11 @@ module Samagotchi
|
|
|
28
28
|
# come from it); api_key_env: the variable holding the host's API key;
|
|
29
29
|
# profile: the configured prompt profile name, if any;
|
|
30
30
|
# first_token_timeout: the configured first-token limit (see #first_token_limit);
|
|
31
|
-
# vision: the configured true/false (VisionSupport), nil when unset
|
|
31
|
+
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
32
|
+
# sampling: the configured request parameters (SamplingSettings), nil when unset;
|
|
33
|
+
# thinking: the configured level (Thinking), nil when unset.
|
|
32
34
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
33
|
-
:first_token_timeout, :vision, keyword_init: true) do
|
|
35
|
+
:first_token_timeout, :vision, :sampling, :thinking, keyword_init: true) do
|
|
34
36
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
35
37
|
# raw-prompt loop.
|
|
36
38
|
def chat? = api == :openai
|
|
@@ -86,9 +88,10 @@ module Samagotchi
|
|
|
86
88
|
entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
|
|
87
89
|
api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
|
|
88
90
|
profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
|
|
89
|
-
vision: cfg[:vision])
|
|
91
|
+
vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking])
|
|
90
92
|
entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
|
|
91
|
-
first_token_timeout: entry.first_token_limit, name: entry.name
|
|
93
|
+
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
94
|
+
api_key_env: entry.api_key_env, env: env)
|
|
92
95
|
@entries[entry.name] = entry
|
|
93
96
|
end
|
|
94
97
|
# Fallback single entry (should already be synthesized by hosts_config, but guard)
|
|
@@ -116,10 +119,6 @@ module Samagotchi
|
|
|
116
119
|
nil
|
|
117
120
|
end
|
|
118
121
|
|
|
119
|
-
def entry_names
|
|
120
|
-
@entries.keys
|
|
121
|
-
end
|
|
122
|
-
|
|
123
122
|
def default_entry
|
|
124
123
|
@entries["default"] || @entries.values.first
|
|
125
124
|
end
|
|
@@ -148,10 +147,8 @@ module Samagotchi
|
|
|
148
147
|
rescue StandardError
|
|
149
148
|
nil
|
|
150
149
|
end
|
|
151
|
-
|
|
152
|
-
return [
|
|
153
|
-
# Unknown prefix — treat as bare model on default host
|
|
154
|
-
return [default_entry, raw_model.to_s.strip]
|
|
150
|
+
# parse_qualified_model names a host only when it is one of ours
|
|
151
|
+
return [find_entry(host_ref), bare]
|
|
155
152
|
end
|
|
156
153
|
# Unqualified: also try alias resolution for discovery (small -> gemma-small or small -> small-box:gemma-small)
|
|
157
154
|
begin
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "llm/openai_chat"
|
|
5
|
+
require_relative "thinking"
|
|
6
|
+
require_relative "log"
|
|
5
7
|
|
|
6
8
|
module Samagotchi
|
|
7
9
|
# Standalone summarizer for the idle session-recap feature.
|
|
@@ -33,17 +35,6 @@ module Samagotchi
|
|
|
33
35
|
# Up to 10 sentences (recap.sentences) need ~350 tokens with thinking off.
|
|
34
36
|
MAX_TOKENS = 512
|
|
35
37
|
|
|
36
|
-
# Request fields that turn thinking off. A reasoning model otherwise
|
|
37
|
-
# spends the budget thinking and the recap stops mid-sentence.
|
|
38
|
-
# - chat_template_kwargs.enable_thinking: the chat template's switch
|
|
39
|
-
# (llama.cpp with Qwen/Gemma templates); templates without it ignore it.
|
|
40
|
-
# - reasoning_effort "none": the OpenAI-style knob, for servers that
|
|
41
|
-
# ignore the template switch (Splash thought until max_tokens).
|
|
42
|
-
THINKING_OFF = {
|
|
43
|
-
chat_template_kwargs: { enable_thinking: false },
|
|
44
|
-
reasoning_effort: "none"
|
|
45
|
-
}.freeze
|
|
46
|
-
|
|
47
38
|
# @param base_url [String] the OpenAI API base, e.g. http://host:8081/v1
|
|
48
39
|
# @param api_key_env [String, nil] the variable holding the host's key
|
|
49
40
|
# @param timeout [Numeric] HTTP request timeout. Kept to the recap's own
|
|
@@ -119,10 +110,17 @@ module Samagotchi
|
|
|
119
110
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
120
111
|
# or all of it, with "…".
|
|
121
112
|
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true)
|
|
122
|
-
response =
|
|
123
|
-
messages
|
|
124
|
-
|
|
125
|
-
|
|
113
|
+
response = begin
|
|
114
|
+
request(messages, max_tokens, cancel_controller)
|
|
115
|
+
rescue LLM::BadRequest => e
|
|
116
|
+
raise unless e.reasoning_refused? && !@thinking_refused
|
|
117
|
+
|
|
118
|
+
# The host won't turn thinking off (gpt-oss): ask again without the
|
|
119
|
+
# fields, and leave them out from now on.
|
|
120
|
+
@thinking_refused = true
|
|
121
|
+
Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
|
|
122
|
+
request(messages, max_tokens, cancel_controller)
|
|
123
|
+
end
|
|
126
124
|
content = response.text
|
|
127
125
|
reasoning = response.reasoning
|
|
128
126
|
raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
|
|
@@ -143,5 +141,16 @@ module Samagotchi
|
|
|
143
141
|
raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
|
|
144
142
|
end
|
|
145
143
|
|
|
144
|
+
def request(messages, max_tokens, cancel_controller)
|
|
145
|
+
@chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
|
|
146
|
+
options: { max_tokens: max_tokens, **thinking_fields })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A recap or side answer is short and tool-less: thinking off, whatever
|
|
150
|
+
# the model's level (a reasoning model otherwise spends the budget
|
|
151
|
+
# thinking and the recap stops mid-sentence). None once the host refused them.
|
|
152
|
+
def thinking_fields
|
|
153
|
+
@thinking_refused ? {} : Thinking.chat_fields(:off)
|
|
154
|
+
end
|
|
146
155
|
end
|
|
147
156
|
end
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "idle_client"
|
|
9
9
|
require_relative "output_formatter"
|
|
10
|
+
require_relative "steer"
|
|
10
11
|
require_relative "recap_store"
|
|
11
12
|
require_relative "log"
|
|
12
13
|
|
|
@@ -60,6 +61,9 @@ module Samagotchi
|
|
|
60
61
|
|
|
61
62
|
case message["role"]
|
|
62
63
|
when "user"
|
|
64
|
+
# A plugin's steer is a prod to the model, not what the user said.
|
|
65
|
+
next nil if Steer.steer?(message)
|
|
66
|
+
|
|
63
67
|
# An image is a line naming it (refs only, never its bytes).
|
|
64
68
|
[message["content"].to_s, *image_lines(message["images"])].reject(&:empty?).join("\n")
|
|
65
69
|
when "model", "assistant"
|
|
@@ -411,7 +415,7 @@ module Samagotchi
|
|
|
411
415
|
parsed = safe_parse(@engine.messages_json_for_recap)
|
|
412
416
|
return :too_short if parsed.nil?
|
|
413
417
|
|
|
414
|
-
user_turns = parsed.count { |message|
|
|
418
|
+
user_turns = parsed.count { |message| Steer.prompt?(message) }
|
|
415
419
|
if parsed.empty? || user_turns < @min_user_turns
|
|
416
420
|
drop_stale(parsed)
|
|
417
421
|
return :too_short
|
|
@@ -9,7 +9,7 @@ module Samagotchi
|
|
|
9
9
|
# (one background thread for the whole idle layer).
|
|
10
10
|
#
|
|
11
11
|
# Two delivery paths cooperate:
|
|
12
|
-
# 1. Pull-based: Engine#
|
|
12
|
+
# 1. Pull-based: Engine#collect_due_reminders
|
|
13
13
|
# reads ReminderStore#due_reminders at turn start → injects
|
|
14
14
|
# [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
|
|
15
15
|
# 2. Synthetic turns: when this job's tick finds due reminders while
|
|
@@ -43,7 +43,7 @@ module Samagotchi
|
|
|
43
43
|
@mutex.synchronize { @due_reminder_name }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
# Get all due reminders (from ReminderStore). Called by Engine#
|
|
46
|
+
# Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
|
|
47
47
|
# Thread-safe. Returns all due reminders, not just one.
|
|
48
48
|
# @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
|
|
49
49
|
def due_reminders
|
|
@@ -262,12 +262,7 @@ module Samagotchi
|
|
|
262
262
|
next message unless images.is_a?(Array) && !images.empty?
|
|
263
263
|
|
|
264
264
|
kept, gone = from ? images.partition { |ref| valid_ref?(from, ref) } : [[], images]
|
|
265
|
-
kept.each
|
|
266
|
-
file = (ref[:file] || ref["file"]).to_s
|
|
267
|
-
target = File.join(to.to_s, file)
|
|
268
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
269
|
-
FileUtils.cp(File.join(from.to_s, file), target) unless File.exist?(target)
|
|
270
|
-
end
|
|
265
|
+
kept.each { |ref| copy_file(ref, from: from, to: to) }
|
|
271
266
|
dropped += gone.size
|
|
272
267
|
message = message.reject { |key, _| key.to_s == "images" }
|
|
273
268
|
message[:images] = kept unless kept.empty?
|
|
@@ -280,6 +275,15 @@ module Samagotchi
|
|
|
280
275
|
[copied, dropped]
|
|
281
276
|
end
|
|
282
277
|
|
|
278
|
+
# One stored image's file from session dir +from+ into +to+ (kept when
|
|
279
|
+
# it is there already: the name is the content's hash).
|
|
280
|
+
def self.copy_file(ref, from:, to:)
|
|
281
|
+
file = (ref[:file] || ref["file"]).to_s
|
|
282
|
+
target = File.join(to.to_s, file)
|
|
283
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
284
|
+
FileUtils.cp(File.join(from.to_s, file), target) unless File.exist?(target)
|
|
285
|
+
end
|
|
286
|
+
|
|
283
287
|
# A ref for an image already stored in this session (a web upload),
|
|
284
288
|
# rebuilt from the file itself: only its name comes from the caller.
|
|
285
289
|
def self.ref_for(session_dir, file:, name: nil, source: "user")
|