samagotchi 0.3.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 +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- 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 +112 -9
- 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/engine.rb +201 -104
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +26 -79
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -26
- 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 +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- 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 +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- metadata +12 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -46,7 +46,9 @@ require_relative "image_store"
|
|
|
46
46
|
require_relative "vision_context"
|
|
47
47
|
require_relative "vision_support"
|
|
48
48
|
require_relative "sampling_settings"
|
|
49
|
+
require_relative "thinking"
|
|
49
50
|
require_relative "answer_display"
|
|
51
|
+
require_relative "edit_preview"
|
|
50
52
|
|
|
51
53
|
module Samagotchi
|
|
52
54
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -92,9 +94,13 @@ module Samagotchi
|
|
|
92
94
|
@scratch = scratch
|
|
93
95
|
@chat_backend = nil
|
|
94
96
|
@chat_backend_mutex = Mutex.new
|
|
97
|
+
@host_registry = host_registry || HostRegistry.new
|
|
98
|
+
# A model given here (a worker's session model) is the one it runs,
|
|
99
|
+
# so its host is checked; the config default is only checked once it
|
|
100
|
+
# is used (the REPL checks the model it starts on, #switch_model!).
|
|
95
101
|
@default_model_name = ModelProfile.required_model_name(model_name)
|
|
102
|
+
ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
|
|
96
103
|
@effective_model_name = @default_model_name
|
|
97
|
-
@host_registry = host_registry || HostRegistry.new
|
|
98
104
|
# An injected client (specs) stands in for every host's client.
|
|
99
105
|
@host_registry.client_override = client if client
|
|
100
106
|
@client = @host_registry.resolve(@effective_model_name).client
|
|
@@ -116,6 +122,7 @@ module Samagotchi
|
|
|
116
122
|
# to load is announced, and a required guardrail's failure denies
|
|
117
123
|
# every tool call. Rules load now too, so their errors are announced.
|
|
118
124
|
@guardrail_failures = Guardrails::LoadFailures.new
|
|
125
|
+
@guardrail_rules_mutex = Mutex.new
|
|
119
126
|
# Plugins that failed to load: announced apart, as plugins (not
|
|
120
127
|
# guardrails: no tool call is denied for them).
|
|
121
128
|
@plugin_failures = Guardrails::LoadFailures.new
|
|
@@ -847,7 +854,7 @@ module Samagotchi
|
|
|
847
854
|
def switch_model!(model_name, persist_default: false)
|
|
848
855
|
# Resolve alias first (alias may point to qualified ref)
|
|
849
856
|
aliased = ConfigFile.resolve_model_alias(model_name)
|
|
850
|
-
resolved = ModelProfile.required_model_name(aliased)
|
|
857
|
+
resolved = ModelProfile.check_host!(ModelProfile.required_model_name(aliased), hosts: @host_registry.entries)
|
|
851
858
|
@effective_model_name = resolved
|
|
852
859
|
bare = bare_model_name(resolved)
|
|
853
860
|
@model_lookup_names = [model_name, aliased, resolved]
|
|
@@ -867,10 +874,6 @@ module Samagotchi
|
|
|
867
874
|
resolved
|
|
868
875
|
end
|
|
869
876
|
|
|
870
|
-
def reset_model!
|
|
871
|
-
switch_model!(@default_model_name)
|
|
872
|
-
end
|
|
873
|
-
|
|
874
877
|
# ── Hooks API ──────────────────────────────────────────────────────────────
|
|
875
878
|
|
|
876
879
|
# Register a hook callback for a named lifecycle event.
|
|
@@ -941,8 +944,6 @@ module Samagotchi
|
|
|
941
944
|
clear_due_reminder_names!
|
|
942
945
|
due
|
|
943
946
|
end
|
|
944
|
-
# Alias for backward compatibility.
|
|
945
|
-
alias maybe_inject_reminders collect_due_reminders
|
|
946
947
|
|
|
947
948
|
# @return [Boolean] whether any reminder is due now (the store's view,
|
|
948
949
|
# which #collect_due_reminders would inject), regardless of the REPL queue
|
|
@@ -1003,6 +1004,16 @@ module Samagotchi
|
|
|
1003
1004
|
nil
|
|
1004
1005
|
end
|
|
1005
1006
|
|
|
1007
|
+
# "off (models: qwen)" for /model: the effective model's thinking level
|
|
1008
|
+
# and where it came from, nil when none is set.
|
|
1009
|
+
def thinking_summary
|
|
1010
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1011
|
+
level, source = Thinking.resolve(target, names: model_lookup_names(target))
|
|
1012
|
+
source ? "#{level} (#{source})" : nil
|
|
1013
|
+
rescue StandardError
|
|
1014
|
+
nil
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1006
1017
|
# The model the server serves for the current model, and the name asked
|
|
1007
1018
|
# for: what the last generation of that name reported, else llama.cpp's
|
|
1008
1019
|
# model_alias (/props, one short cached probe; not with probe: false),
|
|
@@ -1128,6 +1139,9 @@ module Samagotchi
|
|
|
1128
1139
|
base.empty? ? nil : base
|
|
1129
1140
|
end
|
|
1130
1141
|
|
|
1142
|
+
# A memory read (memory_read, or read of a memories/*.md file) as it
|
|
1143
|
+
# starts: its names join used_memory_names.
|
|
1144
|
+
# @return [Array<String>, nil] the names this call read, nil for any other event
|
|
1131
1145
|
def capture_used_memory_from_event(event)
|
|
1132
1146
|
return unless event.is_a?(Hash) && event[:type] == :tool_call_started
|
|
1133
1147
|
|
|
@@ -1138,6 +1152,7 @@ module Samagotchi
|
|
|
1138
1152
|
return if names.empty?
|
|
1139
1153
|
|
|
1140
1154
|
add_used_memory_names(names)
|
|
1155
|
+
names
|
|
1141
1156
|
end
|
|
1142
1157
|
|
|
1143
1158
|
# ── Guardrails ─────────────────────────────────────────────────────────────
|
|
@@ -1193,28 +1208,54 @@ module Samagotchi
|
|
|
1193
1208
|
|
|
1194
1209
|
# The YAML rules: config.yml's `guardrails:` section (rules, disable) and
|
|
1195
1210
|
# installed bundles'. One that doesn't parse is a required load failure
|
|
1196
|
-
# (every call is denied).
|
|
1211
|
+
# (every call is denied). Read again when one of those files changed
|
|
1212
|
+
# (a stat of each per tool call), so a long-lived worker follows edits.
|
|
1197
1213
|
# @return [Guardrails::Rules]
|
|
1198
1214
|
def guardrail_rules
|
|
1199
|
-
@
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
|
|
1206
|
-
|
|
1207
|
-
rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
|
|
1208
|
-
disable = Guardrails::Rules.parse_disable(section && section["disable"])
|
|
1209
|
-
rescue Guardrails::Rules::ParseError => e
|
|
1210
|
-
Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
|
|
1211
|
-
@guardrail_failures.add("rules in config.yml", e.message, required: true)
|
|
1215
|
+
@guardrail_rules_mutex.synchronize do
|
|
1216
|
+
stamp = guardrail_rules_stamp
|
|
1217
|
+
if @guardrail_rules.nil? || stamp != @guardrail_rules_stamp
|
|
1218
|
+
@guardrail_failures.drop(:rules)
|
|
1219
|
+
@guardrail_rules = load_guardrail_rules
|
|
1220
|
+
@guardrail_rules_stamp = stamp
|
|
1212
1221
|
end
|
|
1213
|
-
|
|
1214
|
-
|
|
1222
|
+
@guardrail_rules
|
|
1223
|
+
end
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
# [path, mtime, size] of config.yml and every installed bundle's
|
|
1227
|
+
# manifest.json and guardrails/ file.
|
|
1228
|
+
def guardrail_rules_stamp
|
|
1229
|
+
require_relative "memory_bundle/provenance"
|
|
1230
|
+
paths = [Samagotchi::ConfigFile.global_path] +
|
|
1231
|
+
Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
|
|
1232
|
+
paths.compact.sort.map do |path|
|
|
1233
|
+
stat = File.stat(path)
|
|
1234
|
+
[path, stat.mtime.to_r, stat.size]
|
|
1235
|
+
rescue SystemCallError
|
|
1236
|
+
[path]
|
|
1215
1237
|
end
|
|
1216
1238
|
end
|
|
1217
1239
|
|
|
1240
|
+
def load_guardrail_rules
|
|
1241
|
+
section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
|
|
1242
|
+
section = section["guardrails"] if section.is_a?(Hash)
|
|
1243
|
+
rules = []
|
|
1244
|
+
disable = []
|
|
1245
|
+
begin
|
|
1246
|
+
raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
|
|
1247
|
+
|
|
1248
|
+
rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
|
|
1249
|
+
disable = Guardrails::Rules.parse_disable(section && section["disable"])
|
|
1250
|
+
rescue Guardrails::Rules::ParseError => e
|
|
1251
|
+
Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
|
|
1252
|
+
@guardrail_failures.add("rules in config.yml", e.message, required: true, group: :rules)
|
|
1253
|
+
end
|
|
1254
|
+
Guardrails::Rules.new(rules + bundle_guardrail_rules, disable: disable,
|
|
1255
|
+
enabled: Samagotchi::Config.get("guardrails.enabled") != false)
|
|
1256
|
+
end
|
|
1257
|
+
private :guardrail_rules_stamp, :load_guardrail_rules
|
|
1258
|
+
|
|
1218
1259
|
# Installed bundles' guardrails/*.yml, by bundle name then file name.
|
|
1219
1260
|
# A file that is missing, changed since install (sha256) or doesn't
|
|
1220
1261
|
# parse is a required load failure.
|
|
@@ -1224,7 +1265,7 @@ module Samagotchi
|
|
|
1224
1265
|
MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
|
|
1225
1266
|
if data[:error]
|
|
1226
1267
|
Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
|
|
1227
|
-
@guardrail_failures.add("rules (bundle #{bundle_name})", data[:error], required: true)
|
|
1268
|
+
@guardrail_failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
|
|
1228
1269
|
next
|
|
1229
1270
|
end
|
|
1230
1271
|
dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
|
|
@@ -1246,14 +1287,14 @@ module Samagotchi
|
|
|
1246
1287
|
rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
|
|
1247
1288
|
rescue Guardrails::Rules::ParseError, Psych::Exception => e
|
|
1248
1289
|
Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
|
|
1249
|
-
@guardrail_failures.add(what, e.message, required: true)
|
|
1290
|
+
@guardrail_failures.add(what, e.message, required: true, group: :rules)
|
|
1250
1291
|
end
|
|
1251
1292
|
end
|
|
1252
1293
|
end
|
|
1253
1294
|
rules
|
|
1254
1295
|
rescue StandardError => e
|
|
1255
1296
|
Log.error(:guardrails, "bundle_rules_failed", echo: "[samagotchi:guardrails] failed to read installed bundles' rules: #{e.class}: #{e.message}", error: e.class.name)
|
|
1256
|
-
@guardrail_failures.add("bundle rules", "#{e.class}: #{e.message}", required: true)
|
|
1297
|
+
@guardrail_failures.add("bundle rules", "#{e.class}: #{e.message}", required: true, group: :rules)
|
|
1257
1298
|
rules || []
|
|
1258
1299
|
end
|
|
1259
1300
|
|
|
@@ -1327,10 +1368,21 @@ module Samagotchi
|
|
|
1327
1368
|
|
|
1328
1369
|
# A plugin tool is asked about by its label, as its row shows it.
|
|
1329
1370
|
label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools)
|
|
1330
|
-
|
|
1371
|
+
# verdict.call is the call that will run (a hook may have replaced it).
|
|
1372
|
+
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
|
|
1331
1373
|
Guardrails::Approval.settle(verdict, open_question(payload), payload[:approval][:scopes])
|
|
1332
1374
|
end
|
|
1333
1375
|
|
|
1376
|
+
# The dry-run diff of an edit/write call for its approval; a preview
|
|
1377
|
+
# that fails only leaves the diff out, it never denies the call.
|
|
1378
|
+
def approval_preview(call)
|
|
1379
|
+
EditPreview.for(call)
|
|
1380
|
+
rescue StandardError => e
|
|
1381
|
+
Log.warn(:guardrails, "edit_preview_failed", error: "#{e.class}: #{e.message}")
|
|
1382
|
+
nil
|
|
1383
|
+
end
|
|
1384
|
+
private :approval_preview
|
|
1385
|
+
|
|
1334
1386
|
# The conversation as a hook may read it: a frozen array of copied
|
|
1335
1387
|
# messages, so a hook cannot change what the turn sends or stores.
|
|
1336
1388
|
def hook_messages(messages)
|
|
@@ -1466,45 +1518,24 @@ module Samagotchi
|
|
|
1466
1518
|
@question_mutex.synchronize { @pending_question&.dup }
|
|
1467
1519
|
end
|
|
1468
1520
|
|
|
1469
|
-
#
|
|
1470
|
-
#
|
|
1471
|
-
#
|
|
1472
|
-
#
|
|
1521
|
+
# Ask the model's question (ask_user_question): the kernel's
|
|
1522
|
+
# question_handler, called on the turn thread with the payload
|
|
1523
|
+
# Tools::AskUserQuestion.validate made. Opens it (#open_question) and
|
|
1524
|
+
# returns the answer as JSON for the tool result.
|
|
1473
1525
|
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
1474
1526
|
# @return [String] normalized answer JSON
|
|
1475
1527
|
def request_question(payload)
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
if options.empty? && payload[:options].is_a?(String)
|
|
1482
|
-
options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(payload[:options].to_s)
|
|
1483
|
-
end
|
|
1484
|
-
if question.empty? || options.empty?
|
|
1485
|
-
return JSON.generate({ error: "invalid question", detail: "question and 2-8 options required (got #{options.size})" })
|
|
1486
|
-
end
|
|
1487
|
-
# Dumb-model salvage: allow single option (don't hard error, just render what we have)
|
|
1488
|
-
if options.size == 1
|
|
1489
|
-
# keep as is
|
|
1490
|
-
elsif options.size < 2
|
|
1491
|
-
return JSON.generate({ error: "invalid question", detail: "question and 2-8 options required (got #{options.size})" })
|
|
1492
|
-
end
|
|
1493
|
-
if options.size > 8
|
|
1494
|
-
options = options.first(8)
|
|
1495
|
-
end
|
|
1496
|
-
|
|
1497
|
-
clean_header = strip_wire_tokens(payload[:header])
|
|
1498
|
-
result = open_question(
|
|
1499
|
-
question: question,
|
|
1500
|
-
options: options,
|
|
1501
|
-
header: clean_header.empty? ? nil : clean_header,
|
|
1502
|
-
multi_select: !!payload[:multi_select],
|
|
1503
|
-
allow_freeform: !!payload[:allow_freeform]
|
|
1504
|
-
)
|
|
1528
|
+
result = open_question(**payload.slice(:question, :options, :header),
|
|
1529
|
+
multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform])
|
|
1530
|
+
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
1531
|
+
# tool failure the model learns to avoid the tool from.
|
|
1532
|
+
result = { dismissed: true, id: result[:id], note: QUESTION_DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
|
|
1505
1533
|
result.is_a?(String) ? result : JSON.generate(result)
|
|
1506
1534
|
end
|
|
1507
1535
|
|
|
1536
|
+
QUESTION_DISMISSED_NOTE = "The user dismissed the question without answering. Go on with your best judgement, " \
|
|
1537
|
+
"or ask in your reply if you can't."
|
|
1538
|
+
|
|
1508
1539
|
# Open a question for the UIs and wait for its answer. Emits
|
|
1509
1540
|
# :question_requested, persists it to the session, and BLOCKS until
|
|
1510
1541
|
# answer_question / cancel_question wakes it (or the turn is cancelled).
|
|
@@ -1663,11 +1694,6 @@ module Samagotchi
|
|
|
1663
1694
|
end
|
|
1664
1695
|
end
|
|
1665
1696
|
|
|
1666
|
-
def strip_wire_tokens(text)
|
|
1667
|
-
text.to_s.gsub(/<\|[^|]*\|>/, "").gsub(/<\||\|>/, "").strip
|
|
1668
|
-
end
|
|
1669
|
-
private :strip_wire_tokens
|
|
1670
|
-
|
|
1671
1697
|
def set_question_sync_handler(&block)
|
|
1672
1698
|
@question_sync_handler = block
|
|
1673
1699
|
end
|
|
@@ -1703,10 +1729,15 @@ module Samagotchi
|
|
|
1703
1729
|
# prompt prefix, and the server's KV cache for it, stay stable.
|
|
1704
1730
|
# @param target [HostRegistry::ModelTarget, nil]
|
|
1705
1731
|
# @return [String]
|
|
1732
|
+
# The native prompt depends on the thinking level too (Gemma's token,
|
|
1733
|
+
# Qwen's turn preamble): a level change builds it again.
|
|
1706
1734
|
def system_prompt(target = nil)
|
|
1707
|
-
|
|
1735
|
+
target ||= @host_registry.resolve(@effective_model_name)
|
|
1736
|
+
chat = target.entry.chat?
|
|
1737
|
+
level = chat ? nil : thinking_level(target)
|
|
1708
1738
|
@system_prompts ||= {}
|
|
1709
|
-
@system_prompts[chat] ||= system_prompt_with_index(assist_system_prompt(chat: chat
|
|
1739
|
+
@system_prompts[[chat, level]] ||= system_prompt_with_index(assist_system_prompt(chat: chat, thinking: level),
|
|
1740
|
+
chat: chat, thinking: level)
|
|
1710
1741
|
end
|
|
1711
1742
|
|
|
1712
1743
|
# @return [Session] current session (Engine owns create/resume)
|
|
@@ -1876,6 +1907,10 @@ module Samagotchi
|
|
|
1876
1907
|
vision = turn_vision(session)
|
|
1877
1908
|
@kernel.vision = vision if @kernel.respond_to?(:vision=)
|
|
1878
1909
|
@kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
|
|
1910
|
+
thinking_target = @host_registry.resolve(@effective_model_name)
|
|
1911
|
+
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1912
|
+
@kernel.thinking = @turn_thinking.first if @kernel.respond_to?(:thinking=)
|
|
1913
|
+
announce_thinking_level(*@turn_thinking)
|
|
1879
1914
|
refuse_images!(vision) unless image_refs.empty?
|
|
1880
1915
|
announce_guardrail_failures(on_event)
|
|
1881
1916
|
# Plugins' slow setup that brings tools (an MCP server's first
|
|
@@ -2103,7 +2138,9 @@ module Samagotchi
|
|
|
2103
2138
|
def turn_vision(session)
|
|
2104
2139
|
target = @host_registry.resolve(@effective_model_name)
|
|
2105
2140
|
VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
|
|
2106
|
-
capability:
|
|
2141
|
+
capability: lambda {
|
|
2142
|
+
VisionSupport.for(target, profile: profile, adapter: vision_adapter(target), names: model_lookup_names(target))
|
|
2143
|
+
})
|
|
2107
2144
|
end
|
|
2108
2145
|
|
|
2109
2146
|
# The effective model's request parameters (hosts: and models:
|
|
@@ -2115,6 +2152,72 @@ module Samagotchi
|
|
|
2115
2152
|
SamplingSettings::EMPTY
|
|
2116
2153
|
end
|
|
2117
2154
|
|
|
2155
|
+
# The effective model's thinking level (Thinking.resolve), read each turn.
|
|
2156
|
+
def turn_thinking
|
|
2157
|
+
thinking_level(@host_registry.resolve(@effective_model_name))
|
|
2158
|
+
rescue StandardError
|
|
2159
|
+
Thinking::DEFAULT
|
|
2160
|
+
end
|
|
2161
|
+
|
|
2162
|
+
# An effort on a native host, which has no knob for it: said once per
|
|
2163
|
+
# session and host.
|
|
2164
|
+
def announce_thinking_level(level, target)
|
|
2165
|
+
return if target.entry.chat? || Thinking.native(level, profile).honoured
|
|
2166
|
+
|
|
2167
|
+
thinking_notice_once(:unsupported, target, :info,
|
|
2168
|
+
"#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
|
|
2169
|
+
"thinking stays as the model has it")
|
|
2170
|
+
rescue StandardError
|
|
2171
|
+
nil
|
|
2172
|
+
end
|
|
2173
|
+
|
|
2174
|
+
# Thinking off, and the model thought anyway: logged each time, said
|
|
2175
|
+
# once per session and host.
|
|
2176
|
+
def check_thinking_honoured(event)
|
|
2177
|
+
level, target = @turn_thinking
|
|
2178
|
+
chars = event[:thinking_chars].to_i
|
|
2179
|
+
return unless level == :off && chars.positive? && target
|
|
2180
|
+
|
|
2181
|
+
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
2182
|
+
thinking_notice_once(:not_honoured, target, :warn,
|
|
2183
|
+
"off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
|
|
2184
|
+
"(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
|
|
2185
|
+
"(see Thinking in docs/configuration.md)")
|
|
2186
|
+
rescue StandardError
|
|
2187
|
+
nil
|
|
2188
|
+
end
|
|
2189
|
+
|
|
2190
|
+
# The host refused the level's request fields (gpt-oss can't turn
|
|
2191
|
+
# thinking off) and the chat loop sent the request without them: said
|
|
2192
|
+
# once per session and host, standing in for the not-honoured notice.
|
|
2193
|
+
def thinking_refused(event)
|
|
2194
|
+
_level, target = @turn_thinking
|
|
2195
|
+
return unless target
|
|
2196
|
+
|
|
2197
|
+
Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
2198
|
+
detail: event[:detail])
|
|
2199
|
+
(@thinking_notices ||= Set.new) << [@session&.id, target.entry.name, :not_honoured]
|
|
2200
|
+
thinking_notice_once(:refused, target, :warn,
|
|
2201
|
+
"#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
|
|
2202
|
+
"sent without it, so thinking stays as the model has it")
|
|
2203
|
+
rescue StandardError
|
|
2204
|
+
nil
|
|
2205
|
+
end
|
|
2206
|
+
|
|
2207
|
+
def thinking_notice_once(kind, target, level, text)
|
|
2208
|
+
key = [@session&.id, target.entry.name, kind]
|
|
2209
|
+
return unless (@thinking_notices ||= Set.new).add?(key)
|
|
2210
|
+
|
|
2211
|
+
hook_notify(text, level, "thinking")
|
|
2212
|
+
end
|
|
2213
|
+
|
|
2214
|
+
# +target+'s thinking level (Thinking.resolve).
|
|
2215
|
+
def thinking_level(target)
|
|
2216
|
+
Thinking.resolve(target, names: model_lookup_names(target)).first
|
|
2217
|
+
rescue StandardError
|
|
2218
|
+
Thinking::DEFAULT
|
|
2219
|
+
end
|
|
2220
|
+
|
|
2118
2221
|
def vision_adapter(target)
|
|
2119
2222
|
target.entry.chat? ? @host_registry.adapter_for(target.entry) : nil
|
|
2120
2223
|
rescue StandardError
|
|
@@ -2174,29 +2277,6 @@ module Samagotchi
|
|
|
2174
2277
|
replace_session_messages(@session, clone_messages(checkpoint))
|
|
2175
2278
|
end
|
|
2176
2279
|
|
|
2177
|
-
# Backward-compatible: runs a prompt through the kernel loop without event forwarding.
|
|
2178
|
-
# @param session [Session]
|
|
2179
|
-
# @param prompt [String]
|
|
2180
|
-
# @return [String] model response text
|
|
2181
|
-
def process_prompt_through_kernel(session, prompt)
|
|
2182
|
-
result = run_turn(session, prompt)
|
|
2183
|
-
response = result.respond_to?(:text) ? result.text.to_s : result.to_s
|
|
2184
|
-
if response.strip.empty?
|
|
2185
|
-
session.messages << { role: "model", content: "[No response]" }
|
|
2186
|
-
"[No response]"
|
|
2187
|
-
else
|
|
2188
|
-
response
|
|
2189
|
-
end
|
|
2190
|
-
end
|
|
2191
|
-
|
|
2192
|
-
# Public entrypoint for background session workers.
|
|
2193
|
-
# @param session [Session]
|
|
2194
|
-
# @param prompt [String]
|
|
2195
|
-
# @return [String] model response
|
|
2196
|
-
def process_background_prompt(session:, prompt:)
|
|
2197
|
-
process_prompt_through_kernel(session, prompt)
|
|
2198
|
-
end
|
|
2199
|
-
|
|
2200
2280
|
# Clone a messages array (shallow dup of each element).
|
|
2201
2281
|
# @param messages [Array<Hash>]
|
|
2202
2282
|
# @return [Array<Hash>]
|
|
@@ -2237,6 +2317,12 @@ module Samagotchi
|
|
|
2237
2317
|
require_relative "memory_bundle/provenance"
|
|
2238
2318
|
settings = bundle_settings
|
|
2239
2319
|
MemoryBundle::Provenance.each_installed_holding_hooks do |bundle_name, data|
|
|
2320
|
+
if data[:error]
|
|
2321
|
+
Log.warn(:hooks, "bundle_manifest_invalid", echo: "[samagotchi:hooks] bundle '#{bundle_name}': #{data[:error]}; its hooks are not loaded",
|
|
2322
|
+
bundle: bundle_name)
|
|
2323
|
+
@guardrail_failures.add("hooks (bundle #{bundle_name})", data[:error], required: false)
|
|
2324
|
+
next
|
|
2325
|
+
end
|
|
2240
2326
|
bundle_dir = File.join(MemoryBundle::Provenance.bundles_dir, bundle_name)
|
|
2241
2327
|
hooks_dir = File.join(bundle_dir, "hooks")
|
|
2242
2328
|
if (data[:trust_level] || "experimental").to_s == "experimental"
|
|
@@ -2584,13 +2670,18 @@ module Samagotchi
|
|
|
2584
2670
|
event = event.merge(text: delta[:text], thinking: delta[:thinking]) if enrich == :always
|
|
2585
2671
|
end
|
|
2586
2672
|
end
|
|
2673
|
+
# The chat loop asked again without the thinking fields: a notice,
|
|
2674
|
+
# not an event of its own.
|
|
2675
|
+
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2676
|
+
|
|
2587
2677
|
emit_event(on_event, event)
|
|
2678
|
+
check_thinking_honoured(event) if event[:type] == :generation_completed
|
|
2588
2679
|
end
|
|
2589
2680
|
end
|
|
2590
2681
|
|
|
2591
2682
|
def emit_event(on_event, event)
|
|
2592
2683
|
# Capture used memories synchronously in the turn thread.
|
|
2593
|
-
begin
|
|
2684
|
+
read_names = begin
|
|
2594
2685
|
capture_used_memory_from_event(event)
|
|
2595
2686
|
rescue StandardError
|
|
2596
2687
|
nil
|
|
@@ -2609,6 +2700,11 @@ module Samagotchi
|
|
|
2609
2700
|
# Persistent subscribers: receive a copy with a locally-monotonic
|
|
2610
2701
|
# `event_seq`, fan out with per-subscriber error isolation.
|
|
2611
2702
|
@session_observer.notify(event)
|
|
2703
|
+
# Every memory read, after its tool_call_started: the whole list for
|
|
2704
|
+
# the UIs' memory line, and the names this call read (read_names).
|
|
2705
|
+
return unless read_names
|
|
2706
|
+
|
|
2707
|
+
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
|
|
2612
2708
|
end
|
|
2613
2709
|
|
|
2614
2710
|
# The window as the target's loop would see it (see ChatLoop#context_window:
|
|
@@ -2704,9 +2800,11 @@ module Samagotchi
|
|
|
2704
2800
|
|
|
2705
2801
|
# Only Qwen has an explicit thinking-close marker, so only Qwen can
|
|
2706
2802
|
# reliably have this preamble parsed back out of its thinking block.
|
|
2707
|
-
|
|
2803
|
+
# With thinking off there is no thinking to begin with it.
|
|
2804
|
+
def turn_preamble_instruction(thinking = nil)
|
|
2708
2805
|
return "" unless profile.name == "qwen36"
|
|
2709
2806
|
return "" if Samagotchi::Config.get("thinking.turn_preamble") == false
|
|
2807
|
+
return "" if (thinking || turn_thinking) == :off
|
|
2710
2808
|
|
|
2711
2809
|
"\nTurn preamble: as the very first line of your thinking, write \"TURN: \" followed by a short present-tense action phrase (max 8 words) describing what you are about to do, e.g. \"TURN: reading project config\". Then continue reasoning normally.\n"
|
|
2712
2810
|
end
|
|
@@ -2715,12 +2813,13 @@ module Samagotchi
|
|
|
2715
2813
|
|
|
2716
2814
|
# @param chat [Boolean] for the chat loop: no tool declarations, call
|
|
2717
2815
|
# syntax or turn preamble (its tools go as schemas with each request)
|
|
2718
|
-
|
|
2816
|
+
# @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
|
|
2817
|
+
def assist_system_prompt(chat: false, thinking: nil)
|
|
2719
2818
|
return chat_system_prompt if chat
|
|
2720
2819
|
|
|
2721
2820
|
declarations = tool_declarations
|
|
2722
2821
|
hint = tool_call_hint
|
|
2723
|
-
turn_preamble = turn_preamble_instruction
|
|
2822
|
+
turn_preamble = turn_preamble_instruction(thinking)
|
|
2724
2823
|
|
|
2725
2824
|
<<~SYS
|
|
2726
2825
|
You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. You have access to the following tools:
|
|
@@ -2800,15 +2899,12 @@ module Samagotchi
|
|
|
2800
2899
|
|
|
2801
2900
|
# @param chat [Boolean] no Gemma thinking token (the chat API's template
|
|
2802
2901
|
# decides about thinking)
|
|
2803
|
-
|
|
2902
|
+
# @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
|
|
2903
|
+
def system_prompt_with_index(base, chat: false, thinking: nil)
|
|
2804
2904
|
project_index = read_memory_index("project")
|
|
2805
2905
|
system_index = read_memory_index("system")
|
|
2806
2906
|
project_description = project_specific_description
|
|
2807
|
-
thinking_token =
|
|
2808
|
-
"<|think|>\n"
|
|
2809
|
-
else
|
|
2810
|
-
""
|
|
2811
|
-
end
|
|
2907
|
+
thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
|
|
2812
2908
|
memory_sections = [
|
|
2813
2909
|
"Project memories:\n#{project_index}",
|
|
2814
2910
|
"System memories:\n#{system_index}"
|
|
@@ -2913,8 +3009,9 @@ module Samagotchi
|
|
|
2913
3009
|
@activated_memory_names ||= []
|
|
2914
3010
|
end
|
|
2915
3011
|
|
|
2916
|
-
#
|
|
2917
|
-
|
|
3012
|
+
# The base prompt (specs, plugins' declarations) and the --memory names
|
|
3013
|
+
# the TerminalUI mirrors into its status line.
|
|
3014
|
+
public :assist_system_prompt, :activated_memory_names
|
|
2918
3015
|
|
|
2919
3016
|
def split_memory_scope(raw)
|
|
2920
3017
|
value = raw.to_s.strip
|
|
@@ -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
|