samagotchi 0.143.0 → 0.145.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 +71 -1
- data/docs/bundle-authoring.md +1 -1
- data/docs/cli.md +21 -4
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +6 -3
- data/docs/hooks.md +5 -1
- data/docs/internals/bridge.md +2 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/web-frontend.md +59 -2
- data/docs/plugins.md +4 -2
- data/docs/releasing.md +3 -2
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bridge/sse_writer.rb +11 -1
- data/lib/samagotchi/bundle_command/check.rb +13 -2
- data/lib/samagotchi/bundle_settings_check.rb +34 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +4 -4
- data/lib/samagotchi/bundles/check-in/plugin.rb +13 -6
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +4 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +4 -2
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/work-cap/plugin.rb +17 -8
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/engine.rb +17 -7
- data/lib/samagotchi/kernel_loop.rb +7 -0
- data/lib/samagotchi/live_versions.rb +28 -2
- data/lib/samagotchi/llm/chat_loop.rb +5 -0
- data/lib/samagotchi/llm/utf8_scrub.rb +5 -3
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +3 -2
- data/lib/samagotchi/plugin/peer.rb +1 -1
- data/lib/samagotchi/plugin_session_state.rb +6 -3
- data/lib/samagotchi/self_report.rb +15 -13
- data/lib/samagotchi/session_access/local.rb +7 -10
- data/lib/samagotchi/session_commands.rb +2 -1
- data/lib/samagotchi/system_one/answer.rb +8 -5
- data/lib/samagotchi/system_one/client.rb +17 -0
- data/lib/samagotchi/system_one/http_backend.rb +23 -10
- data/lib/samagotchi/testing/boundary.rb +76 -0
- data/lib/samagotchi/testing/env_not_shown.rb +17 -0
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/update_command.rb +8 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/annotate_bar.js +1 -1
- data/lib/samagotchi/web/public/app.js +15 -3
- data/lib/samagotchi/web/public/chip_popover.js +14 -4
- data/lib/samagotchi/web/public/composer_fold.js +5 -2
- data/lib/samagotchi/web/public/composer_grip.js +45 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/index.html +79 -40
- data/lib/samagotchi/web/public/info_bar_model.js +6 -4
- data/lib/samagotchi/web/public/more_sheet.js +212 -0
- data/lib/samagotchi/web/public/question_cards.js +26 -3
- data/lib/samagotchi/web/public/ref_scan.js +4 -2
- data/lib/samagotchi/web/public/stage_frame.js +58 -13
- data/lib/samagotchi/web/public/stage_model.js +2 -1
- data/lib/samagotchi/web/public/stage_view.js +52 -20
- data/lib/samagotchi/web/public/turn_block.js +43 -9
- data/lib/samagotchi/worker.rb +21 -4
- metadata +3 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: github-pr
|
|
3
|
-
version: 0.6.
|
|
3
|
+
version: 0.6.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: Proposes the branch's open GitHub pull request to a session, attached once you mention it (#N, the branch or its URL), and attaches any PR URL with chi context add or the web's "+ URL"; the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can attach the PR at once instead, or do nothing
|
|
6
6
|
trust_level: reviewed
|
|
@@ -11,7 +11,7 @@ needs:
|
|
|
11
11
|
hint: brew install gh, then gh auth login
|
|
12
12
|
plugin:
|
|
13
13
|
file: plugin.rb
|
|
14
|
-
sha256: sha256:
|
|
14
|
+
sha256: sha256:114e999c49d4d427b7570e3f4bec8db5565ee0dd1a99f2f5ec3000748a1aec3a
|
|
15
15
|
scripts:
|
|
16
16
|
pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
|
|
17
17
|
context_providers:
|
|
@@ -501,8 +501,10 @@ class LineLinker
|
|
|
501
501
|
QUICK = /\S:\d/
|
|
502
502
|
URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
|
|
503
503
|
MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/
|
|
504
|
-
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
|
|
505
|
-
|
|
504
|
+
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~). A
|
|
505
|
+
# backtick fence's info string carries no backtick: "```foo``` first" is
|
|
506
|
+
# inline code, and as a fence it would stay open over the rest of the text.
|
|
507
|
+
FENCE_OPEN = /\A {0,3}(`{3,}(?![^\n]*`)|~{3,})/
|
|
506
508
|
|
|
507
509
|
# The content of the last model message, or nil (string or symbol keys).
|
|
508
510
|
def self.last_model_text(messages)
|
|
@@ -86,8 +86,10 @@ class SourceLinks
|
|
|
86
86
|
URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
|
|
87
87
|
TRAILING_URL_PUNCTUATION = /[.,;:!?'"]+\z/
|
|
88
88
|
MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
|
|
89
|
-
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
|
|
90
|
-
|
|
89
|
+
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~). A
|
|
90
|
+
# backtick fence's info string carries no backtick: "```foo``` first" is
|
|
91
|
+
# inline code, and as a fence it would stay open over the rest of the text.
|
|
92
|
+
FENCE_OPEN = /\A {0,3}(`{3,}(?![^\n]*`)|~{3,})/
|
|
91
93
|
# A `{word}` or `{N}` placeholder in a `url:` template.
|
|
92
94
|
PLACEHOLDER = /\{(\w+)\}/
|
|
93
95
|
# Placeholders every pattern has: {match} (group 1, else the whole ref),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.6.
|
|
3
|
+
version: 0.6.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, refs in a waiting question's reply, and a one-line note after the turn (note false turns it off); sources are configured patterns
|
|
6
6
|
trust_level: reviewed
|
|
@@ -9,7 +9,7 @@ files:
|
|
|
9
9
|
source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
|
|
10
10
|
hooks:
|
|
11
11
|
source_links.rb:
|
|
12
|
-
sha256: sha256:
|
|
12
|
+
sha256: sha256:7e3fafb843f1a9006b1a42f4ec647b41a358d814515cd0be73b60eb08b898992
|
|
13
13
|
events: [after_turn, question_asked]
|
|
14
14
|
on_error: log
|
|
15
15
|
priority: 90
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: work-cap
|
|
3
|
-
version: 0.1.
|
|
3
|
+
version: 0.1.5
|
|
4
4
|
scope: system
|
|
5
5
|
description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:31cf59a518a7fe3f51efc72f3bb213a0f053d6577aa81a7f47eaac12c5caa07a
|
|
10
10
|
# ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
|
|
11
|
-
# ctx.session_dir is new in
|
|
12
|
-
requires_chi: ">= 0.
|
|
11
|
+
# ctx.session_dir is new in 0.143.0
|
|
12
|
+
requires_chi: ">= 0.144.0"
|
|
13
13
|
# chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off
|
|
14
14
|
session_settings: {tool_calls: number, minutes: number}
|
|
15
15
|
# config.yml bundles: work-cap: (another key warns; an older chi ignores this)
|
|
@@ -142,7 +142,7 @@ class Plugin
|
|
|
142
142
|
cap = meter_cap[:cap]
|
|
143
143
|
next unless used[meter] >= share * cap && first_warning?(meter, [resolved[:session], cap])
|
|
144
144
|
|
|
145
|
-
ctx.notify("#{count(used[meter])} of #{
|
|
145
|
+
ctx.notify("#{count(used[meter])} of #{counted(cap, meter)} (#{(used[meter] / cap * 100).floor}%)",
|
|
146
146
|
level: :warn)
|
|
147
147
|
end
|
|
148
148
|
end
|
|
@@ -196,7 +196,7 @@ class Plugin
|
|
|
196
196
|
save_meter(ctx, meter, "cap" => cap, "step" => reached[meter][:step] || reached[meter][:cap])
|
|
197
197
|
end
|
|
198
198
|
ctx.notify("#{raised.size == 1 ? "cap" : "caps"} raised to " \
|
|
199
|
-
"#{raised.map { |meter, cap|
|
|
199
|
+
"#{raised.map { |meter, cap| counted(cap, meter) }.join(" and ")}")
|
|
200
200
|
else
|
|
201
201
|
stop(ctx, reached, usage, used)
|
|
202
202
|
end
|
|
@@ -204,21 +204,21 @@ class Plugin
|
|
|
204
204
|
|
|
205
205
|
# "412 tool calls of 400", and "61 active minutes of 60" for the other.
|
|
206
206
|
def reached_text(reached, used)
|
|
207
|
-
reached.map { |meter, meter_cap| "#{
|
|
207
|
+
reached.map { |meter, meter_cap| "#{counted(used[meter], meter)} of #{count(meter_cap[:cap])}" }.join(", ")
|
|
208
208
|
end
|
|
209
209
|
|
|
210
210
|
# "it to 800", or "them to 800 tool calls and 120 active minutes".
|
|
211
211
|
def raised_text(raised)
|
|
212
212
|
return "it to #{count(raised.values.first)}" if raised.size == 1
|
|
213
213
|
|
|
214
|
-
"them to #{raised.map { |meter, cap|
|
|
214
|
+
"them to #{raised.map { |meter, cap| counted(cap, meter) }.join(" and ")}"
|
|
215
215
|
end
|
|
216
216
|
|
|
217
217
|
# The session's totals a reader decides on, the reached meters left out
|
|
218
218
|
# (already named): turns, tool calls, active minutes and the spend.
|
|
219
219
|
def totals(ctx, usage, used, skip = [])
|
|
220
220
|
parts = ["#{usage.turns} #{usage.turns == 1 ? "turn" : "turns"}"]
|
|
221
|
-
parts <<
|
|
221
|
+
parts << counted(used["tool_calls"], "tool_calls") unless skip.include?("tool_calls")
|
|
222
222
|
parts << "#{active_text(used["minutes"])} active" unless skip.include?("minutes")
|
|
223
223
|
cost = ctx.cost
|
|
224
224
|
parts << format("$%.2f", cost.total) if cost.known? && cost.total.positive?
|
|
@@ -268,7 +268,7 @@ class Plugin
|
|
|
268
268
|
return USAGE unless value
|
|
269
269
|
|
|
270
270
|
save_meter(ctx, meter, "cap" => value, "step" => value)
|
|
271
|
-
"work-cap:
|
|
271
|
+
"work-cap: cap set to #{counted(value, meter)} (#{count(used(ctx, ctx.usage)[meter])} so far)"
|
|
272
272
|
rescue StandardError => e
|
|
273
273
|
ctx.log.warn(:workcap_command_failed, error: "#{e.class}: #{e.message}")
|
|
274
274
|
"work-cap: #{e.message}"
|
|
@@ -281,12 +281,18 @@ class Plugin
|
|
|
281
281
|
return "work-cap: no cap (#{totals(ctx, usage, used)})" if meters.values.none? { |meter_cap| meter_cap[:cap] }
|
|
282
282
|
|
|
283
283
|
capped = meters.select { |_meter, meter_cap| meter_cap[:cap] }
|
|
284
|
-
caps = capped.map { |meter, meter_cap| "#{count(used[meter])} of #{
|
|
284
|
+
caps = capped.map { |meter, meter_cap| "#{count(used[meter])} of #{counted(meter_cap[:cap], meter)}" }
|
|
285
285
|
"work-cap: #{caps.join(", ")} (#{totals(ctx, usage, used, capped.keys)})"
|
|
286
286
|
end
|
|
287
287
|
|
|
288
288
|
def unit(meter) = meter == "tool_calls" ? "tool calls" : "active minutes"
|
|
289
289
|
|
|
290
|
+
# "412 tool calls", "1 tool call".
|
|
291
|
+
def counted(value, meter)
|
|
292
|
+
number = count(value)
|
|
293
|
+
"#{number} #{number == "1" ? unit(meter).chomp("s") : unit(meter)}"
|
|
294
|
+
end
|
|
295
|
+
|
|
290
296
|
# The session's caps now: per meter the state file's, else the session's
|
|
291
297
|
# own value (--set), else child:'s for a delegate, else default:'s.
|
|
292
298
|
# Cached per session id and settings (a config change resolves again).
|
|
@@ -310,7 +316,7 @@ class Plugin
|
|
|
310
316
|
def state(ctx, session)
|
|
311
317
|
return [{}, nil] unless session
|
|
312
318
|
|
|
313
|
-
path =
|
|
319
|
+
path = saved_file(ctx)
|
|
314
320
|
saved = JSON.parse(File.read(path))
|
|
315
321
|
raise JSON::ParserError, "not a JSON object" unless saved.is_a?(Hash)
|
|
316
322
|
|
|
@@ -412,6 +418,9 @@ class Plugin
|
|
|
412
418
|
|
|
413
419
|
def state_file(ctx) = File.join(ctx.session_dir, "state.json")
|
|
414
420
|
|
|
421
|
+
# For a read: a session with nothing saved keeps no folder.
|
|
422
|
+
def saved_file(ctx) = File.join(ctx.session_dir(create: false), "state.json")
|
|
423
|
+
|
|
415
424
|
def count(value) = self.class.count(value)
|
|
416
425
|
|
|
417
426
|
def first_warning?(meter, key)
|
|
@@ -299,7 +299,7 @@ module Samagotchi
|
|
|
299
299
|
|
|
300
300
|
def bucket_rank(bucket) = self.class.bucket_rank(bucket, @thresholds)
|
|
301
301
|
|
|
302
|
-
# A rise (never a fall
|
|
302
|
+
# A rise (never a fall) into a bucket whose guidance
|
|
303
303
|
# asks for a change. With no previous bucket (a first turn, a resumed
|
|
304
304
|
# session with no line yet), the first bucket counts as a rise from 0.
|
|
305
305
|
def guidance_due?(previous, bucket)
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1288,11 +1288,21 @@ module Samagotchi
|
|
|
1288
1288
|
# Hooks are turn-scoped: they are automatically cleared after each
|
|
1289
1289
|
# `run_turn` call so that a single turn's hooks do not leak into the next.
|
|
1290
1290
|
#
|
|
1291
|
+
# A hook that raises tells the user once, as a bundle's does
|
|
1292
|
+
# (HookRoutes#plugin_hook_failed); the registry logs each failure and goes on.
|
|
1293
|
+
#
|
|
1291
1294
|
# @param name [Symbol] one of the hook names (see Hooks module)
|
|
1292
1295
|
# @param block [Proc] receives an event hash (may mutate in place)
|
|
1293
1296
|
# @return [void]
|
|
1294
|
-
def register_hook(name, &)
|
|
1295
|
-
@hooks.register(name
|
|
1297
|
+
def register_hook(name, &block)
|
|
1298
|
+
return @hooks.register(name) unless block
|
|
1299
|
+
|
|
1300
|
+
@hooks.register(name) do |event|
|
|
1301
|
+
yield(event)
|
|
1302
|
+
rescue StandardError => e
|
|
1303
|
+
@hook_routes.plugin_hook_failed("#{Hooks::Registry::TURN_HOOK_LABEL} (#{name})", "#{e.class}: #{e.message}")
|
|
1304
|
+
raise
|
|
1305
|
+
end
|
|
1296
1306
|
end
|
|
1297
1307
|
|
|
1298
1308
|
# Unregister a previously registered hook.
|
|
@@ -2945,12 +2955,12 @@ module Samagotchi
|
|
|
2945
2955
|
end
|
|
2946
2956
|
|
|
2947
2957
|
# config.yml bundles: names and keys that do nothing (BundleSettingsCheck),
|
|
2948
|
-
# held like the judgments' line, for the same reason.
|
|
2958
|
+
# held like the judgments' line, for the same reason. Logged by every
|
|
2959
|
+
# Engine; shown by the first to find them (BundleSettingsCheck.unshown).
|
|
2949
2960
|
def warn_bundle_settings
|
|
2950
|
-
BundleSettingsCheck.warnings(@extension_load.bundle_settings)
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
end
|
|
2961
|
+
lines = BundleSettingsCheck.warnings(@extension_load.bundle_settings)
|
|
2962
|
+
lines.each { |line| Log.warn(:config, "bundle_setting_unknown", msg: line) }
|
|
2963
|
+
BundleSettingsCheck.unshown(lines).each { |line| hook_notify(line, :warn, "config") }
|
|
2954
2964
|
end
|
|
2955
2965
|
private :warn_bundle_settings
|
|
2956
2966
|
|
|
@@ -734,6 +734,13 @@ module Samagotchi
|
|
|
734
734
|
response = request.prefill + generation.response.to_s
|
|
735
735
|
answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
|
|
736
736
|
empty = strip_thought_blocks(response.to_s).strip.empty?
|
|
737
|
+
# A Stop that lands on an empty reply is no empty answer: the empty
|
|
738
|
+
# generation goes, as it does below, and the turn ends cancelled.
|
|
739
|
+
if empty && turn.cancel_controller&.cancelled?
|
|
740
|
+
turn.empty_steps << turn.conversation.pop
|
|
741
|
+
raise Client::RequestCancelled, turn.cancel_controller.reason
|
|
742
|
+
end
|
|
743
|
+
|
|
737
744
|
retry_empty = empty && turn.empty_retry.retry_empty?(iteration: turn.iteration,
|
|
738
745
|
cancelled: turn.cancel_controller&.cancelled?,
|
|
739
746
|
finish_reason: generation.finish_reason,
|
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "config"
|
|
3
4
|
require_relative "session"
|
|
4
5
|
require_relative "version"
|
|
5
6
|
require_relative "worker_link"
|
|
6
7
|
require_relative "web/info_probe"
|
|
8
|
+
require_relative "web/running"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
# Which chi versions the running processes run, for `chi update`: session
|
|
10
12
|
# workers (their bridge.json sidecar names the version since chi update exists; an
|
|
11
|
-
# older one names none) and a `chi web` on its port (/api/info)
|
|
12
|
-
#
|
|
13
|
+
# older one names none) and a `chi web` on its port (/api/info): also
|
|
14
|
+
# chi self's and chi share's lookup of that chi web. Read-only: a dead
|
|
15
|
+
# worker's sidecar is left for the next client to clean up.
|
|
13
16
|
module LiveVersions
|
|
14
17
|
WEB_TIMEOUT = 0.5
|
|
18
|
+
# chi web's port when config names none: Web::Server::DEFAULT_PORT,
|
|
19
|
+
# without loading chi web.
|
|
20
|
+
DEFAULT_WEB_PORT = 4567
|
|
15
21
|
|
|
16
22
|
# version is nil for a sidecar written before sidecars carried one;
|
|
17
23
|
# restart: the worker takes `chi sessions restart` (its "restart" feature).
|
|
18
24
|
Worker = Struct.new(:session_id, :version, :restart, keyword_init: true)
|
|
25
|
+
# The port a chi web answers on and its /api/info.
|
|
26
|
+
WebAnswer = Data.define(:port, :info)
|
|
19
27
|
|
|
20
28
|
module_function
|
|
21
29
|
|
|
@@ -41,5 +49,23 @@ module Samagotchi
|
|
|
41
49
|
info = Web::InfoProbe.call(host, port, timeout: timeout)
|
|
42
50
|
info.is_a?(Hash) ? info : nil
|
|
43
51
|
end
|
|
52
|
+
|
|
53
|
+
# Config's web.port, or the default for none that is a port.
|
|
54
|
+
def configured_web_port
|
|
55
|
+
port = Config.get("web.port").to_s.to_i
|
|
56
|
+
port.positive? ? port : DEFAULT_WEB_PORT
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The chi web on this machine: on the port the running one recorded (a
|
|
60
|
+
# --port it was started with is not config's), else on +configured+.
|
|
61
|
+
# @param path [String] its record (Web::Running.path)
|
|
62
|
+
# @return [WebAnswer, nil] nil with none
|
|
63
|
+
def web_answering(host, configured, path:, timeout: WEB_TIMEOUT)
|
|
64
|
+
[Web::Running.port(path: path), configured].compact.uniq.each do |port|
|
|
65
|
+
info = web_info(host, port, timeout: timeout)
|
|
66
|
+
return WebAnswer.new(port: port, info: info) if info
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
44
70
|
end
|
|
45
71
|
end
|
|
@@ -677,6 +677,11 @@ module Samagotchi
|
|
|
677
677
|
"could not read, twice in a row; nothing was run", host: host)
|
|
678
678
|
end
|
|
679
679
|
end
|
|
680
|
+
# A Stop that lands on an empty reply is no empty answer either:
|
|
681
|
+
# nothing more is asked because the turn is stopping. (A refusal
|
|
682
|
+
# the Stop landed on stays what the provider said.)
|
|
683
|
+
return canceled(iteration, @cancel_controller.reason) if empty && !stopped && @cancel_controller&.cancelled?
|
|
684
|
+
|
|
680
685
|
# Kept before a merge too: the model answers the merged line
|
|
681
686
|
# knowing what it just said.
|
|
682
687
|
@conversation << with_thinking({ role: "model", content: last_text }, response) unless empty || stray
|
|
@@ -8,8 +8,9 @@ module Samagotchi
|
|
|
8
8
|
# `JSON#to_json` raises `JSON::GeneratorError` on such input, which would
|
|
9
9
|
# abort the whole turn, so a request body is scrubbed first: only
|
|
10
10
|
# offending bytes are replaced with "?", every valid UTF-8 string passes
|
|
11
|
-
# through untouched.
|
|
12
|
-
#
|
|
11
|
+
# through untouched. A binary string is read as UTF-8 first (JSON raises
|
|
12
|
+
# on its high bytes too); other encodings are left alone because
|
|
13
|
+
# `#to_json` converts them without raising.
|
|
13
14
|
module Utf8Scrub
|
|
14
15
|
module_function
|
|
15
16
|
|
|
@@ -18,7 +19,8 @@ module Samagotchi
|
|
|
18
19
|
def call(obj)
|
|
19
20
|
case obj
|
|
20
21
|
when String
|
|
21
|
-
obj.encoding == Encoding::
|
|
22
|
+
text = obj.encoding == Encoding::BINARY ? obj.dup.force_encoding(Encoding::UTF_8) : obj
|
|
23
|
+
text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
|
|
22
24
|
when Array
|
|
23
25
|
obj.map { |element| call(element) }
|
|
24
26
|
when Hash
|
|
@@ -54,8 +54,8 @@ module Samagotchi
|
|
|
54
54
|
"ctx.context.decline" => "0.43.0", "ctx.context.declined(url:)" => "0.43.0", "ctx.context.attach(force:)" => "0.43.0",
|
|
55
55
|
"ctx.ask_user(effects:)" => "0.86.0", "chi.toolkit(description:)" => "0.122.0", "ctx.review" => "0.140.0",
|
|
56
56
|
"ctx.context.propose" => "0.140.0", "ctx.context.candidates" => "0.140.0", "ctx.context.withdraw" => "0.140.0",
|
|
57
|
-
"ctx.continues" => "0.141.0", "ctx.context.left_behind" => "0.141.0", "ctx.context.attach(movable:)" => "0.141.0"
|
|
58
|
-
|
|
57
|
+
"ctx.continues" => "0.141.0", "ctx.context.left_behind" => "0.141.0", "ctx.context.attach(movable:)" => "0.141.0",
|
|
58
|
+
"ctx.session_dir" => "0.143.0", "ctx.session_dir(create:)" => "0.144.0"
|
|
59
59
|
}.freeze
|
|
60
60
|
|
|
61
61
|
# @param bundle [String]
|
|
@@ -304,10 +304,11 @@ module Samagotchi
|
|
|
304
304
|
# This session's own folder under #data_dir, created on use and read
|
|
305
305
|
# now (a continue moves the id on). chi removes it with the session:
|
|
306
306
|
# the layout is core's (PluginSessionState).
|
|
307
|
+
# @param create [Boolean] false for a read: the path, with no folder made
|
|
307
308
|
# @return [String, nil] nil with no session yet
|
|
308
|
-
def session_dir
|
|
309
|
+
def session_dir(create: true)
|
|
309
310
|
id = session_id
|
|
310
|
-
id && PluginSessionState.dir(data_dir, id)
|
|
311
|
+
id && PluginSessionState.dir(data_dir, id, create: create)
|
|
311
312
|
end
|
|
312
313
|
|
|
313
314
|
# The conversation so far, a frozen copy, without the system prompt.
|
|
@@ -313,7 +313,7 @@ module Samagotchi
|
|
|
313
313
|
end
|
|
314
314
|
|
|
315
315
|
def last_send(ctx)
|
|
316
|
-
path = File.join(ctx.session_dir, LAST_SEND)
|
|
316
|
+
path = File.join(ctx.session_dir(create: false), LAST_SEND)
|
|
317
317
|
File.file?(path) ? JSON.parse(File.read(path)) : nil
|
|
318
318
|
rescue JSON::ParserError, SystemCallError
|
|
319
319
|
nil
|
|
@@ -21,15 +21,18 @@ module Samagotchi
|
|
|
21
21
|
# A session's folder under a bundle's +data_dir+, created. A
|
|
22
22
|
# sessions/<id>.json there moves in as STATE_FILE, so a running session
|
|
23
23
|
# keeps it when its bundle moves to ctx.session_dir.
|
|
24
|
+
# @param create [Boolean] false for a read: only names the folder, so a
|
|
25
|
+
# session with nothing saved leaves none (the older file still moves in)
|
|
24
26
|
# @return [String, nil] nil for an id that isn't a plain one (it could
|
|
25
27
|
# name a path outside the dir)
|
|
26
|
-
def dir(data_dir, session_id)
|
|
28
|
+
def dir(data_dir, session_id, create: true)
|
|
27
29
|
id = session_id.to_s
|
|
28
30
|
return nil unless Session.valid_id?(id)
|
|
29
31
|
|
|
30
32
|
path = File.join(data_dir, "sessions", id)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
older = "#{path}.json"
|
|
34
|
+
FileUtils.mkdir_p(path) if create || File.file?(older)
|
|
35
|
+
adopt(older, File.join(path, STATE_FILE))
|
|
33
36
|
path
|
|
34
37
|
end
|
|
35
38
|
|
|
@@ -25,6 +25,7 @@ require_relative "memory_bundle/entry_size"
|
|
|
25
25
|
require_relative "desktop"
|
|
26
26
|
require_relative "live_versions"
|
|
27
27
|
require_relative "web/lan"
|
|
28
|
+
require_relative "web/running"
|
|
28
29
|
require_relative "chi_home"
|
|
29
30
|
require_relative "home_notice"
|
|
30
31
|
require_relative "project_env"
|
|
@@ -33,8 +34,8 @@ module Samagotchi
|
|
|
33
34
|
# `chi self`: where this chi lives and what it is configured to use.
|
|
34
35
|
#
|
|
35
36
|
# Read-only and nearly offline (one short /props GET for the served model,
|
|
36
|
-
# nothing else asks a model server;
|
|
37
|
-
# chi web
|
|
37
|
+
# nothing else asks a model server; /api/info GETs for this machine's
|
|
38
|
+
# chi web), so the agent can run it via `execute`
|
|
38
39
|
# to orient itself — source dir to rg, config path, memory dirs, sessions,
|
|
39
40
|
# model/host, bundle versions — without guessing from $PATH.
|
|
40
41
|
module SelfReport
|
|
@@ -77,22 +78,23 @@ module Samagotchi
|
|
|
77
78
|
["context window", context_window(env, model)],
|
|
78
79
|
["bundles", bundles_summary],
|
|
79
80
|
["desktop", desktop_summary(env)],
|
|
80
|
-
["chi web", web_summary]
|
|
81
|
+
["chi web", web_summary(env)]
|
|
81
82
|
]
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
WEB_TIMEOUT = 0.3
|
|
85
86
|
|
|
86
|
-
# Whether a chi web answers on this machine
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return "
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
# Whether a chi web answers on this machine (LiveVersions.web_answering:
|
|
88
|
+
# the port it recorded, else config's), and whether a phone can reach it
|
|
89
|
+
# (LAN mode: /api/info's lan).
|
|
90
|
+
def web_summary(env = ENV)
|
|
91
|
+
configured = LiveVersions.configured_web_port
|
|
92
|
+
answer = LiveVersions.web_answering(Web::Lan.local_host(Config.get("web.host")), configured,
|
|
93
|
+
path: Web::Running.path(env: env), timeout: WEB_TIMEOUT)
|
|
94
|
+
return "not running on port #{configured}" unless answer
|
|
95
|
+
return "LAN on #{answer.info["lan"]}:#{answer.port} (and 127.0.0.1)" if answer.info["lan"]
|
|
96
|
+
|
|
97
|
+
"on 127.0.0.1:#{answer.port} (this machine only)"
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
# "git checkout" when running from a repo (bin/chi), "installed gem" when
|
|
@@ -22,6 +22,7 @@ require_relative "../review_start"
|
|
|
22
22
|
require_relative "../launch_mode"
|
|
23
23
|
require_relative "../participants"
|
|
24
24
|
require_relative "../config"
|
|
25
|
+
require_relative "../live_versions"
|
|
25
26
|
require_relative "../web/grants"
|
|
26
27
|
require_relative "../web/info_probe"
|
|
27
28
|
require_relative "../web/public_url"
|
|
@@ -453,8 +454,6 @@ module Samagotchi
|
|
|
453
454
|
|
|
454
455
|
def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
|
|
455
456
|
|
|
456
|
-
# Web::Server::DEFAULT_PORT, without loading chi web.
|
|
457
|
-
WEB_PORT = 4567
|
|
458
457
|
LOCAL_PROBE_TIMEOUT = 1.0
|
|
459
458
|
|
|
460
459
|
# Where this machine's chi web answers (GET /api/info): its LAN ip
|
|
@@ -491,14 +490,12 @@ module Samagotchi
|
|
|
491
490
|
end
|
|
492
491
|
|
|
493
492
|
def local_web
|
|
494
|
-
configured =
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
end
|
|
501
|
-
LocalWeb.new(ip: nil, port: configured)
|
|
493
|
+
configured = LiveVersions.configured_web_port
|
|
494
|
+
answer = LiveVersions.web_answering("127.0.0.1", configured, path: Web::Running.path(state_dir: state_dir!),
|
|
495
|
+
timeout: LOCAL_PROBE_TIMEOUT)
|
|
496
|
+
return LocalWeb.new(ip: nil, port: configured) unless answer
|
|
497
|
+
|
|
498
|
+
LocalWeb.new(ip: own_address(answer.info["lan"]), port: answer.port)
|
|
502
499
|
end
|
|
503
500
|
|
|
504
501
|
def dir = @state_dir ? { state_dir: @state_dir } : {}
|
|
@@ -512,7 +512,8 @@ module Samagotchi
|
|
|
512
512
|
mode = Config.get("guardrails.system_one.mode").to_s
|
|
513
513
|
return if mode == "off" && entries.empty?
|
|
514
514
|
|
|
515
|
-
|
|
515
|
+
no_judgment = SystemOne::Client.no_judgment_reason(host_registry: @engine.host_registry, mode: mode)
|
|
516
|
+
lines << (no_judgment ? "System 1 (#{mode}, but nothing is judged: #{no_judgment}):" : "System 1 (#{mode}):")
|
|
516
517
|
auto_allows = system_one_auto_allows(entries)
|
|
517
518
|
lines << " auto-allows (this session, newest first; /guardrails wrong N):"
|
|
518
519
|
if auto_allows.empty?
|
|
@@ -20,7 +20,9 @@ module Samagotchi
|
|
|
20
20
|
# not usable: no type, a type the contract doesn't have, a noul outside
|
|
21
21
|
# 0..1, a score that isn't a number or past the last level, … A nil means
|
|
22
22
|
# "no judgment for this question", never a guess.
|
|
23
|
-
|
|
23
|
+
# @param levels [Integer, nil] how many levels the question declared: a
|
|
24
|
+
# bare score, with no legend or probabilities, is checked against it
|
|
25
|
+
def self.from_wire(id, raw, levels: nil)
|
|
24
26
|
return nil unless raw.is_a?(Hash)
|
|
25
27
|
|
|
26
28
|
type = raw["type"].to_s.strip.downcase.to_sym
|
|
@@ -46,7 +48,7 @@ module Samagotchi
|
|
|
46
48
|
legend = levels(raw["legend"])
|
|
47
49
|
return nil if bad_field(raw, "legend", legend)
|
|
48
50
|
|
|
49
|
-
score = level_score(raw["score"], legend, odds)
|
|
51
|
+
score = level_score(raw["score"], legend, odds, levels)
|
|
50
52
|
score.nil? ? nil : new(**answer, score: score, level: top_level(legend, odds))
|
|
51
53
|
end
|
|
52
54
|
end
|
|
@@ -100,14 +102,15 @@ module Samagotchi
|
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
# The score as sent: the weighted level index, a whole number kept whole.
|
|
103
|
-
# Not a number, or past the last level the response names
|
|
104
|
-
|
|
105
|
+
# Not a number, or past the last level the response names (or else the
|
|
106
|
+
# question declared), is no answer.
|
|
107
|
+
private_class_method def self.level_score(value, legend, odds, levels)
|
|
105
108
|
return nil unless value.is_a?(Numeric)
|
|
106
109
|
|
|
107
110
|
score = value.to_f
|
|
108
111
|
return nil if score.nan? || score.infinite? || score.negative?
|
|
109
112
|
|
|
110
|
-
count = legend&.size || (odds.is_a?(Array) ? odds.size : odds&.size)
|
|
113
|
+
count = legend&.size || (odds.is_a?(Array) ? odds.size : odds&.size) || levels
|
|
111
114
|
# A hair past the last level is the probabilities' own rounding, not a
|
|
112
115
|
# level out of range.
|
|
113
116
|
return nil if count && score > count - 1 + 1e-9
|
|
@@ -60,6 +60,23 @@ module Samagotchi
|
|
|
60
60
|
mode: mode, host: host, model: model.empty? ? nil : model, tag: tag)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Why an ask under +mode+ would get no judgment whatever the host does,
|
|
64
|
+
# from the settings alone (no request); nil when one would be asked for,
|
|
65
|
+
# and under off, which promises none (`/guardrails`).
|
|
66
|
+
# @return [String, nil]
|
|
67
|
+
def self.no_judgment_reason(host_registry:, mode:)
|
|
68
|
+
return nil if mode.to_s.strip.downcase == "off"
|
|
69
|
+
return "guardrails.system_one still sets a key that moved to system_one" if moved_keys_left?
|
|
70
|
+
|
|
71
|
+
client = from_config(host_registry: host_registry, mode: mode)
|
|
72
|
+
return "system_one.host is not set" unless client
|
|
73
|
+
|
|
74
|
+
client.backend.target
|
|
75
|
+
nil
|
|
76
|
+
rescue UnsupportedHost => e
|
|
77
|
+
e.message
|
|
78
|
+
end
|
|
79
|
+
|
|
63
80
|
# system_one.timeout_ms, else the default: the whole
|
|
64
81
|
# request's limit, and how long a turn waits for an answer (toolkit
|
|
65
82
|
# suggestion's on mode).
|
|
@@ -47,15 +47,7 @@ module Samagotchi
|
|
|
47
47
|
# @raise [BackendError] the request failed, the status isn't 200, or
|
|
48
48
|
# the body isn't a usable response
|
|
49
49
|
def ask(state:, questions:, cancel_controller: nil, observer: nil)
|
|
50
|
-
entry =
|
|
51
|
-
raise UnsupportedHost, "host #{@host.inspect} is not in hosts:" unless entry
|
|
52
|
-
unless entry.api&.to_sym == :openai
|
|
53
|
-
raise UnsupportedHost, "host #{@host} has api: #{entry.api || "llama_cpp"}; System One speaks the OpenAI-compatible API"
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
model = @model || host_model(entry)
|
|
57
|
-
raise UnsupportedHost, "host #{@host} names no model for System One" if model.nil? || model.empty?
|
|
58
|
-
|
|
50
|
+
entry, model = target
|
|
59
51
|
observer&.call(model: model)
|
|
60
52
|
url = "#{entry.openai_base_url.to_s.chomp("/")}/systemone"
|
|
61
53
|
questions = SystemOne.normalize_questions(questions)
|
|
@@ -72,7 +64,28 @@ module Samagotchi
|
|
|
72
64
|
data = reply_of(response.body)
|
|
73
65
|
answers = data["answers"]
|
|
74
66
|
observer&.call(served_model: data["model"], raw: answers, usage: data["usage"])
|
|
75
|
-
questions.to_h
|
|
67
|
+
questions.to_h do |question|
|
|
68
|
+
levels = question.criteria.size if question.type == :score
|
|
69
|
+
[question.id, Answer.from_wire(question.id, answers[question.id], levels: levels)]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The hosts: entry and model an ask would go to, without asking: all
|
|
74
|
+
# read from the settings, no request.
|
|
75
|
+
# @return [Array(HostRegistry::Entry, String)]
|
|
76
|
+
# @raise [UnsupportedHost] the host is unknown, not an openai host, or
|
|
77
|
+
# names no model
|
|
78
|
+
def target
|
|
79
|
+
entry = @host_registry.find_entry(@host)
|
|
80
|
+
raise UnsupportedHost, "host #{@host.inspect} is not in hosts:" unless entry
|
|
81
|
+
unless entry.api&.to_sym == :openai
|
|
82
|
+
raise UnsupportedHost, "host #{@host} has api: #{entry.api || "llama_cpp"}; System One speaks the OpenAI-compatible API"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
model = @model || host_model(entry)
|
|
86
|
+
raise UnsupportedHost, "host #{@host} names no model for System One" if model.nil? || model.empty?
|
|
87
|
+
|
|
88
|
+
[entry, model]
|
|
76
89
|
end
|
|
77
90
|
|
|
78
91
|
private
|