pikuri-core 0.0.7 → 0.1.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/README.md +1 -1
- data/lib/pikuri/agent/chat_transport.rb +73 -93
- data/lib/pikuri/agent/configurator.rb +46 -106
- data/lib/pikuri/agent/context_window_detector.rb +44 -85
- data/lib/pikuri/agent/control/cancellable.rb +87 -66
- data/lib/pikuri/agent/control/interloper.rb +127 -105
- data/lib/pikuri/agent/control/step_limit.rb +25 -41
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +123 -188
- data/lib/pikuri/agent/extension.rb +118 -94
- data/lib/pikuri/agent/extension_context.rb +50 -77
- data/lib/pikuri/agent/history.rb +653 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
- data/lib/pikuri/agent/listener/terminal.rb +143 -117
- data/lib/pikuri/agent/listener/token_log.rb +101 -140
- data/lib/pikuri/agent/listener.rb +23 -43
- data/lib/pikuri/agent/listener_list.rb +26 -47
- data/lib/pikuri/agent/synthesizer.rb +45 -87
- data/lib/pikuri/agent.rb +816 -474
- data/lib/pikuri/bundler_env.rb +68 -0
- data/lib/pikuri/extractor/html.rb +63 -110
- data/lib/pikuri/extractor/passthrough.rb +20 -30
- data/lib/pikuri/extractor.rb +93 -154
- data/lib/pikuri/file_type.rb +63 -135
- data/lib/pikuri/finalizers.rb +32 -47
- data/lib/pikuri/paths.rb +104 -13
- data/lib/pikuri/ruby_llm_patches.rb +106 -0
- data/lib/pikuri/sanitizer.rb +45 -67
- data/lib/pikuri/subprocess.rb +75 -119
- data/lib/pikuri/testing.rb +296 -0
- data/lib/pikuri/tool/calculator.rb +56 -66
- data/lib/pikuri/tool/execute_context.rb +42 -0
- data/lib/pikuri/tool/fetch.rb +51 -77
- data/lib/pikuri/tool/parameters.rb +21 -29
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +52 -80
- data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
- data/lib/pikuri/tool/search/engines.rb +230 -97
- data/lib/pikuri/tool/search/exa.rb +56 -90
- data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
- data/lib/pikuri/tool/search/result.rb +10 -15
- data/lib/pikuri/tool/trifecta_legs.rb +217 -0
- data/lib/pikuri/tool/web_scrape.rb +38 -54
- data/lib/pikuri/tool/web_search.rb +100 -24
- data/lib/pikuri/tool.rb +140 -65
- data/lib/pikuri/trifecta/contribution.rb +43 -0
- data/lib/pikuri/trifecta/node.rb +47 -0
- data/lib/pikuri/trifecta/report.rb +230 -0
- data/lib/pikuri/trifecta.rb +127 -0
- data/lib/pikuri/url_cache.rb +33 -49
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +72 -88
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +14 -3
data/lib/pikuri/finalizers.rb
CHANGED
|
@@ -1,54 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
|
-
# Process-global teardown registry: one +at_exit+ for the whole
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# the same LIFO + per-handler-rescue + idempotent shape, one level
|
|
10
|
-
# up.
|
|
4
|
+
# Process-global teardown registry: one +at_exit+ for the whole process,
|
|
5
|
+
# with everything owning a resource that needs orderly shutdown (agents,
|
|
6
|
+
# {VectorDb::Server::Chroma}, background workers) registering here instead of
|
|
7
|
+
# growing its own +at_exit+. {Agent#on_close} promoted per-agent → per-process
|
|
8
|
+
# — the same LIFO + per-handler-rescue + idempotent shape.
|
|
11
9
|
#
|
|
12
10
|
# == Why one chokepoint
|
|
13
11
|
#
|
|
14
|
-
# Independent +at_exit+ hooks fire in
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# time) have closed gracefully, while the subprocess machinery they
|
|
21
|
-
# shell out to during close (e.g. {VectorDb::Server::Chroma#close}'s
|
|
22
|
-
# +docker stop+) is still live.
|
|
12
|
+
# Independent +at_exit+ hooks fire in file-load order — invisible and fragile.
|
|
13
|
+
# One registry makes the order explicit: the SIGTERM-the-strays backstop
|
|
14
|
+
# ({Subprocess.cleanup!}) registers at *load* time, so it sits at the bottom
|
|
15
|
+
# of the LIFO stack and runs *last* — after agents and servers (registered at
|
|
16
|
+
# *construction* time) close gracefully, while the subprocess machinery they
|
|
17
|
+
# shell out to during close (a +docker stop+) is still live.
|
|
23
18
|
#
|
|
24
19
|
# == Contract
|
|
25
20
|
#
|
|
26
|
-
# A registrant MUST respond to +#close+, and
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# +Finalizers.register { Pikuri::Subprocess.cleanup! }+).
|
|
21
|
+
# A registrant MUST respond to +#close+, idempotent and tolerant of running at
|
|
22
|
+
# exit (the host may have closed it earlier). Pass a block instead for
|
|
23
|
+
# teardown with no natural +#close+
|
|
24
|
+
# (+Finalizers.register { Pikuri::Subprocess.cleanup! }+).
|
|
31
25
|
#
|
|
32
26
|
# == Order: LIFO
|
|
33
27
|
#
|
|
34
|
-
# Last registered, first closed
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
# tears down first. Registration order is therefore dependency order;
|
|
38
|
-
# register the dependency before its dependents.
|
|
28
|
+
# Last registered, first closed. A registrant depending on an earlier one (a
|
|
29
|
+
# background indexer writing into {VectorDb::Server::Chroma}) registers later
|
|
30
|
+
# and tears down first — registration order is dependency order.
|
|
39
31
|
#
|
|
40
32
|
# == Errors are contained
|
|
41
33
|
#
|
|
42
|
-
# Each +#close+ runs
|
|
43
|
-
# {
|
|
44
|
-
# teardown can't strand the rest. {.run!} drains the registry, so a
|
|
45
|
-
# second call (an explicit one, then the +at_exit+) closes nothing.
|
|
34
|
+
# Each +#close+ runs in its own +rescue+ (a raise is logged, the sweep
|
|
35
|
+
# continues). {.run!} drains the registry, so a second call closes nothing.
|
|
46
36
|
module Finalizers
|
|
47
37
|
# @return [Logger] subsystem logger for contained teardown failures.
|
|
48
38
|
LOGGER = Pikuri.logger_for('Finalizers')
|
|
49
39
|
|
|
50
40
|
# Adapts a teardown block to the +#close+ protocol, so a block and a
|
|
51
|
-
# closeable
|
|
41
|
+
# closeable share one registry.
|
|
52
42
|
Closer = Struct.new(:block) do
|
|
53
43
|
# @return [void]
|
|
54
44
|
def close
|
|
@@ -60,17 +50,14 @@ module Pikuri
|
|
|
60
50
|
@mutex = Mutex.new
|
|
61
51
|
|
|
62
52
|
class << self
|
|
63
|
-
# Register a closeable (or a block) to
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# should drop out so it can be garbage-collected rather than
|
|
67
|
-
# pinned alive until the process dies.
|
|
53
|
+
# Register a closeable (or a block) to tear down at exit. Returns the
|
|
54
|
+
# handle so the caller can later {.unregister} it — a resource closed
|
|
55
|
+
# explicitly before exit should drop out to be GC'd, not pinned alive.
|
|
68
56
|
#
|
|
69
|
-
# @param closeable [#close, nil] resource to close
|
|
70
|
-
#
|
|
71
|
-
# @
|
|
72
|
-
#
|
|
73
|
-
# the {Closer} wrapping the block; pass it to {.unregister}
|
|
57
|
+
# @param closeable [#close, nil] resource to close; omit when passing a block
|
|
58
|
+
# @yield teardown for resources with no +#close+
|
|
59
|
+
# @return [#close] the registered handle (the object, or the {Closer}
|
|
60
|
+
# wrapping the block); pass to {.unregister}
|
|
74
61
|
# @raise [ArgumentError] if neither an object nor a block is given
|
|
75
62
|
def register(closeable = nil, &block)
|
|
76
63
|
unless closeable || block
|
|
@@ -82,8 +69,7 @@ module Pikuri
|
|
|
82
69
|
handle
|
|
83
70
|
end
|
|
84
71
|
|
|
85
|
-
# Drop a previously-registered handle. Idempotent
|
|
86
|
-
# something already gone (or never registered) is a no-op.
|
|
72
|
+
# Drop a previously-registered handle. Idempotent.
|
|
87
73
|
#
|
|
88
74
|
# @param handle [#close] the value returned by {.register}
|
|
89
75
|
# @return [void]
|
|
@@ -92,10 +78,9 @@ module Pikuri
|
|
|
92
78
|
nil
|
|
93
79
|
end
|
|
94
80
|
|
|
95
|
-
# Close every registrant in LIFO order, each guarded by its own
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
# no-op and keeps it safe against a concurrent caller.
|
|
81
|
+
# Close every registrant in LIFO order, each guarded by its own +rescue+.
|
|
82
|
+
# Wired to +at_exit+ below. Draining under the lock makes a repeat call a
|
|
83
|
+
# no-op and stays safe against a concurrent caller.
|
|
99
84
|
#
|
|
100
85
|
# @return [void]
|
|
101
86
|
def run!
|
data/lib/pikuri/paths.rb
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'pathname'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'fileutils'
|
|
4
6
|
|
|
5
7
|
module Pikuri
|
|
6
|
-
# Standardized on-disk locations for pikuri's local state
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# {
|
|
10
|
-
#
|
|
8
|
+
# Standardized on-disk locations for pikuri's local state, centralizing XDG
|
|
9
|
+
# resolution so every disk-caching component roots under one place instead of
|
|
10
|
+
# re-deriving it. The load-bearing distinction is {.cache} (shared across all
|
|
11
|
+
# pikuri processes, persistent) vs. {.new_temp} (private to this process,
|
|
12
|
+
# ephemeral) — reach for the wrong one and two concurrent processes clobber
|
|
13
|
+
# each other's scratch.
|
|
11
14
|
module Paths
|
|
12
|
-
#
|
|
13
|
-
#
|
|
15
|
+
# +temp-*+ umbrellas older than this are reaped by {.sweep_stale_temps!} at
|
|
16
|
+
# core load — generous enough not to disturb a long-lived process in another
|
|
17
|
+
# shell, tight enough that one killed last week doesn't leak forever.
|
|
14
18
|
#
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
19
|
+
# @return [Integer] seconds
|
|
20
|
+
TEMP_STALE_SECONDS = 7 * 24 * 60 * 60
|
|
21
|
+
|
|
22
|
+
# Pikuri's cache root: +$XDG_CACHE_HOME/pikuri+ or +~/.cache/pikuri+.
|
|
23
|
+
#
|
|
24
|
+
# Shared across ALL pikuri processes and PERSISTENT (survives exit) — the
|
|
25
|
+
# home for state meant to be reused: the URL cache, mem0 / Qdrant / Chroma
|
|
26
|
+
# server data. For scratch private to *this* process that should vanish when
|
|
27
|
+
# it exits, use {.new_temp}; reaching for +cache+ + a fixed subdir is how two
|
|
28
|
+
# concurrent processes end up overwriting each other's files.
|
|
29
|
+
#
|
|
30
|
+
# A method, not a constant: a constant would snapshot +XDG_CACHE_HOME+ at
|
|
31
|
+
# +require+ time, breaking env-stubbing in tests and a runtime change. Not
|
|
32
|
+
# created — callers +mkdir_p+ the subdir they need.
|
|
21
33
|
#
|
|
22
34
|
# @return [Pathname] the +<cache home>/pikuri+ directory
|
|
23
35
|
def self.cache
|
|
@@ -25,5 +37,84 @@ module Pikuri
|
|
|
25
37
|
home = File.expand_path('~/.cache') if home.nil? || home.empty?
|
|
26
38
|
Pathname.new(home).join('pikuri')
|
|
27
39
|
end
|
|
40
|
+
|
|
41
|
+
# A fresh scratch dir private to this process, reaped at exit via
|
|
42
|
+
# {Pikuri::Finalizers} (and stale-swept by {.sweep_stale_temps!} for strays a
|
|
43
|
+
# crash leaves behind). Mint one per concurrent-safe scratch need and hold
|
|
44
|
+
# the result:
|
|
45
|
+
#
|
|
46
|
+
# dir = Paths.new_temp # ~/.cache/pikuri/temp-XXXX/
|
|
47
|
+
# snap = Snapshot.new(source: db, dir: dir) # can't collide with another process
|
|
48
|
+
#
|
|
49
|
+
# Minted IN HOME under {.cache} — never +/tmp+ — for two concrete reasons: a
|
|
50
|
+
# +cp --reflink=auto+ copy shares extents only on the source's filesystem
|
|
51
|
+
# (the copied DB lives under +$HOME+), and the bubblewrap sandbox binds its
|
|
52
|
+
# scratch at +/tmp+ inside, so an umbrella already under +/tmp+ would bind on
|
|
53
|
+
# top of itself.
|
|
54
|
+
#
|
|
55
|
+
# A factory — a *new* dir each call (hence +new_+, not a memoized accessor
|
|
56
|
+
# like {.cache}); a caller memoizes if it wants one stable dir, but per
|
|
57
|
+
# *consumer*, not per process. Memoizing one at process scope and handing it
|
|
58
|
+
# to several agents puts two writers on one path — the collision this exists
|
|
59
|
+
# to prevent, moved inside the process.
|
|
60
|
+
#
|
|
61
|
+
# @return [Pathname] absolute, symlink-resolved path of the freshly-made dir
|
|
62
|
+
def self.new_temp
|
|
63
|
+
FileUtils.mkdir_p(cache)
|
|
64
|
+
path = Pathname.new(Dir.mktmpdir('temp-', cache)).realpath
|
|
65
|
+
Pikuri::Finalizers.register { FileUtils.remove_entry(path.to_s) if path.exist? }
|
|
66
|
+
path
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reap +temp-*+ umbrellas under {.cache} older than {TEMP_STALE_SECONDS}.
|
|
70
|
+
# Called once at core load (so every binary sweeps, regardless of which gems
|
|
71
|
+
# it wires) — the net for a process killed before its {Pikuri::Finalizers}
|
|
72
|
+
# reap could fire. Failures are swallowed (best-effort; the Finalizers
|
|
73
|
+
# removal is the load-bearing path), and recent dirs are left for concurrent
|
|
74
|
+
# processes.
|
|
75
|
+
#
|
|
76
|
+
# @return [void]
|
|
77
|
+
def self.sweep_stale_temps!
|
|
78
|
+
return unless File.directory?(cache)
|
|
79
|
+
|
|
80
|
+
cutoff = Time.now - TEMP_STALE_SECONDS
|
|
81
|
+
Dir.children(cache).each do |entry|
|
|
82
|
+
next unless entry.start_with?('temp-')
|
|
83
|
+
|
|
84
|
+
path = File.join(cache, entry)
|
|
85
|
+
next unless File.directory?(path)
|
|
86
|
+
next if File.mtime(path) > cutoff
|
|
87
|
+
|
|
88
|
+
FileUtils.remove_entry(path)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
# best-effort sweep; never block the host on dead state
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Pikuri's config root: +$XDG_CONFIG_HOME/pikuri+ or +~/.config/pikuri+.
|
|
95
|
+
# Home for user-owned, hand-edited config (the OS helper's +MACHINE.md+).
|
|
96
|
+
#
|
|
97
|
+
# A method for the same reason as {.cache}. Not created — config is
|
|
98
|
+
# user-owned, so callers +mkdir_p+ only when writing.
|
|
99
|
+
#
|
|
100
|
+
# @return [Pathname] the +<config home>/pikuri+ directory
|
|
101
|
+
def self.config
|
|
102
|
+
home = ENV['XDG_CONFIG_HOME']
|
|
103
|
+
home = File.expand_path('~/.config') if home.nil? || home.empty?
|
|
104
|
+
Pathname.new(home).join('pikuri')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Pikuri's state root: +$XDG_STATE_HOME/pikuri+ or +~/.local/state/pikuri+.
|
|
108
|
+
# Created automatically. For state that persists across restarts but isn't
|
|
109
|
+
# portable enough for XDG_DATA_HOME (logs, history, layout).
|
|
110
|
+
#
|
|
111
|
+
# @return [Pathname] the +<state home>/pikuri+ directory
|
|
112
|
+
def self.state
|
|
113
|
+
state = ENV['XDG_STATE_HOME']
|
|
114
|
+
state = File.expand_path('~/.local/state') if state.nil? || state.empty?
|
|
115
|
+
state = Pathname.new(state).join('pikuri')
|
|
116
|
+
FileUtils.mkdir_p(state.to_path)
|
|
117
|
+
state
|
|
118
|
+
end
|
|
28
119
|
end
|
|
29
120
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
# Local corrections to ruby_llm, applied at load time, carried until the fix
|
|
7
|
+
# ships upstream. Each patch pins us to a ruby_llm internal's private shape, so
|
|
8
|
+
# it's a liability: small, self-contained, tied to a tracked upstream issue so
|
|
9
|
+
# we know when to delete it. Reopening a third-party class is last resort —
|
|
10
|
+
# here only because the defect is on the live chat path and the release cadence
|
|
11
|
+
# isn't ours. Patching rather than dropping the dependency is the standing
|
|
12
|
+
# policy — `DECISIONS.md` +D_keep_ruby_llm+.
|
|
13
|
+
#
|
|
14
|
+
# == The streaming error-parse crash (ruby_llm #837)
|
|
15
|
+
#
|
|
16
|
+
# {https://github.com/crmne/ruby_llm/issues/837}
|
|
17
|
+
#
|
|
18
|
+
# On a *streaming* turn, a non-200 response whose body parses to a non-Hash
|
|
19
|
+
# JSON value crashes ruby_llm 1.16.0 with +TypeError: String does not have
|
|
20
|
+
# #dig+ at +providers/openai/streaming.rb:43+, instead of surfacing the
|
|
21
|
+
# endpoint's error. Two bodies trigger it, both because the method assumes a
|
|
22
|
+
# nested object to +.dig+:
|
|
23
|
+
#
|
|
24
|
+
# 1. A bare JSON string. The guard +return unless error_data['error']+ is
|
|
25
|
+
# meant as key-presence, but on a +String+ that's substring matching, so a
|
|
26
|
+
# body containing "error" slips through into +.dig+.
|
|
27
|
+
# 2. A +{"error": "<message>"}+ body where +error+ is a string value
|
|
28
|
+
# (xAI/Grok), not an object: the guard passes, then
|
|
29
|
+
# +error_data.dig('error', 'type')+ calls +String#dig+ — same +TypeError+.
|
|
30
|
+
#
|
|
31
|
+
# The non-streaming path already handles both (cases on Hash/Array/else,
|
|
32
|
+
# short-circuits a string +error+). This mirrors that: never +.dig+ a non-Hash,
|
|
33
|
+
# hand a non-typed error up as the message with a +nil+ status so ruby_llm's
|
|
34
|
+
# +build_stream_error_response+ falls back to the real HTTP status.
|
|
35
|
+
#
|
|
36
|
+
# Redefined whole (not +prepend+ + +super+) because it's a small self-contained
|
|
37
|
+
# +module_function+ with no state — faithful reproduction plus the guard is
|
|
38
|
+
# clearer than wrapping the buggy original. Re-running +module_function+
|
|
39
|
+
# replaces both the module method and the private copy the provider gets via
|
|
40
|
+
# +include+.
|
|
41
|
+
#
|
|
42
|
+
# == When to delete this file
|
|
43
|
+
#
|
|
44
|
+
# ruby_llm main already carries an equivalent fix, in a module renamed to
|
|
45
|
+
# +RubyLLM::Protocols::ChatCompletions::Streaming+ — so the next release past
|
|
46
|
+
# 1.16.0 obsoletes the patch *and* moves its target. The load-time guard below
|
|
47
|
+
# turns that into a boot failure naming this file; delete it and its spec then.
|
|
48
|
+
module RubyLlmPatches
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] +true+ once the patches below have been applied; a
|
|
52
|
+
# literal, kept honest by the load-time guard that raises if the target
|
|
53
|
+
# module has moved out from under it
|
|
54
|
+
def applied? = true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Reopening a module that has moved silently defines a fresh empty one: the
|
|
59
|
+
# patch would stop applying, the bug would return, and nothing — not even
|
|
60
|
+
# {Pikuri::RubyLlmPatches.applied?} — would notice. Fail at boot instead.
|
|
61
|
+
unless defined?(RubyLLM::Providers::OpenAI::Streaming) &&
|
|
62
|
+
RubyLLM::Providers::OpenAI::Streaming.respond_to?(:parse_streaming_error)
|
|
63
|
+
raise "RubyLLM::Providers::OpenAI::Streaming.parse_streaming_error is gone from ruby_llm " \
|
|
64
|
+
"#{RubyLLM::VERSION} — the streaming error-parse patch no longer applies. It is fixed " \
|
|
65
|
+
'upstream: delete pikuri-core/lib/pikuri/ruby_llm_patches.rb and its spec (ruby_llm #837).'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module RubyLLM
|
|
69
|
+
module Providers
|
|
70
|
+
class OpenAI
|
|
71
|
+
module Streaming
|
|
72
|
+
# Parse a streaming error body into a +[status, message]+ pair,
|
|
73
|
+
# tolerating a non-Hash parsed body. See
|
|
74
|
+
# {Pikuri::RubyLlmPatches} for why this override exists and when
|
|
75
|
+
# to remove it (ruby_llm #837).
|
|
76
|
+
#
|
|
77
|
+
# @param data [String] the JSON error body as received
|
|
78
|
+
# @return [Array(Integer, nil), String] a +[status, message]+
|
|
79
|
+
# pair; +status+ is +nil+ when the body carries no error type,
|
|
80
|
+
# deferring classification to the response's HTTP status
|
|
81
|
+
def parse_streaming_error(data)
|
|
82
|
+
error_data = JSON.parse(data)
|
|
83
|
+
# Non-Hash body (a bare JSON string/number/array): no structured
|
|
84
|
+
# error to read — surface it as the message, defer status.
|
|
85
|
+
return [nil, error_data.to_s] unless error_data.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
error = error_data['error']
|
|
88
|
+
return [nil, error_data.to_s] unless error
|
|
89
|
+
# A string error value (xAI/Grok's {"error": "..."}) has no type
|
|
90
|
+
# field — hand it up as-is, defer status to the HTTP response.
|
|
91
|
+
return [nil, error] if error.is_a?(String)
|
|
92
|
+
|
|
93
|
+
case error['type']
|
|
94
|
+
when 'server_error'
|
|
95
|
+
[500, error['message']]
|
|
96
|
+
when 'rate_limit_exceeded', 'insufficient_quota'
|
|
97
|
+
[429, error['message']]
|
|
98
|
+
else
|
|
99
|
+
[400, error['message']]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
module_function :parse_streaming_error
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/pikuri/sanitizer.rb
CHANGED
|
@@ -1,73 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
|
-
# Renders attacker-controlled text safe to display, and reports *why*
|
|
5
|
-
#
|
|
4
|
+
# Renders attacker-controlled text safe to display, and reports *why* it was
|
|
5
|
+
# unsafe.
|
|
6
6
|
#
|
|
7
|
-
# Every string an LLM composes is untrusted
|
|
8
|
-
# observation echoed
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# in plain sight, or a Cyrillic +а+ masquerading as a Latin +a+. The
|
|
16
|
-
# whole point of a confirmation prompt collapses if the bytes the user
|
|
17
|
-
# approves are not the bytes that execute.
|
|
7
|
+
# Every string an LLM composes is untrusted (a bash command, a tool
|
|
8
|
+
# observation echoed to the user, a confirmation-prompt description). A model
|
|
9
|
+
# — usually one being prompt-injected — can embed bytes a terminal *acts on*
|
|
10
|
+
# rather than prints: a CR that overwrites the line just read, an ESC that
|
|
11
|
+
# recolors/repositions, a backspace that erases, a bidi override that reorders
|
|
12
|
+
# text so it reads differently than it runs, a zero-width char, or a Cyrillic
|
|
13
|
+
# +а+ posing as Latin +a+. A confirmation prompt is worthless if the bytes the
|
|
14
|
+
# user approves aren't the bytes that execute.
|
|
18
15
|
#
|
|
19
|
-
# {.sanitize} is the one chrome-independent primitive every renderer
|
|
20
|
-
#
|
|
21
|
-
# both as a {Result}:
|
|
16
|
+
# {.sanitize} is the one chrome-independent primitive every renderer routes
|
|
17
|
+
# through, returning a {Result}:
|
|
22
18
|
#
|
|
23
|
-
# 1. *Neutralize* — make
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# the caller's, not ours.
|
|
32
|
-
# 2. *Warn* — return a {Warning} per category detected, each a semantic
|
|
33
|
-
# record (kind + offending tokens + a plain-English explanation).
|
|
34
|
-
# Presentation is the chrome's: a terminal renders these bold yellow,
|
|
35
|
-
# a web client a banner. The {Warning} carries no color or markup.
|
|
19
|
+
# 1. *Neutralize* — make dangerous bytes visible without changing structure:
|
|
20
|
+
# control bytes → +\xNN+, bidi/zero-width → +\u{NNNN}+, tab → +\t+;
|
|
21
|
+
# newlines preserved. *Faithful, not beautifying* — it never collapses
|
|
22
|
+
# whitespace or rewrites a tab, because the user must see exactly what they
|
|
23
|
+
# approve (a Makefile's leading tab stays a tab). A web chrome adds
|
|
24
|
+
# +html_escape+ on top; the HTML layer is the caller's.
|
|
25
|
+
# 2. *Warn* — one {Warning} per category detected (kind + offending tokens +
|
|
26
|
+
# plain-English explanation). Presentation is the chrome's.
|
|
36
27
|
#
|
|
37
28
|
# == Scope (deliberately closed)
|
|
38
29
|
#
|
|
39
|
-
# Detection
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
# a single word combining letters from Latin + Cyrillic + Greek, which
|
|
45
|
-
# is the signature of a homoglyph spoof and has near-zero false
|
|
46
|
-
# positives on real text (humans do not weld two alphabets inside one
|
|
47
|
-
# word; +café+ is all-Latin, +Москва+ all-Cyrillic, only +Pаypal+ mixes).
|
|
30
|
+
# Detection is complete on the *invisibility / cursor-control / reordering*
|
|
31
|
+
# classes, each a finite codepoint set: C0/C1 controls, DEL, bidi overrides,
|
|
32
|
+
# zero-width chars. Plus *mixed-script tokens* — one word welding Latin +
|
|
33
|
+
# Cyrillic + Greek, the homoglyph-spoof signature, near-zero false positives
|
|
34
|
+
# (+café+ all-Latin, +Москва+ all-Cyrillic, only +Pаypal+ mixes).
|
|
48
35
|
#
|
|
49
|
-
# Two confusable classes are
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# * *Single-symbol* confusables — the Greek question mark +;+ (U+037E)
|
|
56
|
-
# that looks like a semicolon, full-width forms, the division slash.
|
|
57
|
-
#
|
|
58
|
-
# "Solid" here means complete on the classes above, not exhaustive over
|
|
59
|
-
# all of Unicode.
|
|
36
|
+
# Two confusable classes are *out of scope* — detecting them needs Unicode
|
|
37
|
+
# confusables tables and heavy false positives on legitimate multilingual
|
|
38
|
+
# text: *whole-script* homoglyphs (an all-Cyrillic string that looks Latin,
|
|
39
|
+
# no mixing to detect) and *single-symbol* confusables (the Greek +;+ U+037E,
|
|
40
|
+
# full-width forms). "Solid" means complete on the classes above, not
|
|
41
|
+
# exhaustive over Unicode.
|
|
60
42
|
module Sanitizer
|
|
61
|
-
# One reason a piece of text was flagged,
|
|
62
|
-
# render however it surfaces warnings (bold yellow line, web banner).
|
|
43
|
+
# One reason a piece of text was flagged, for a chrome to render its own way.
|
|
63
44
|
#
|
|
64
|
-
# * +kind+ — a {Symbol}
|
|
65
|
-
# +:
|
|
66
|
-
# * +offenders+ — the distinct offending tokens,
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# * +explanation+ — a one-line
|
|
70
|
-
# what the bytes can do.
|
|
45
|
+
# * +kind+ — a {Symbol}: +:backspace+, +:control_bytes+, +:bidi+,
|
|
46
|
+
# +:zero_width+, or +:mixed_script+.
|
|
47
|
+
# * +offenders+ — the distinct offending tokens, first-seen order: escaped
|
|
48
|
+
# forms (+"\\x1b"+, +"\\u{202e}"+) for byte categories, raw tokens
|
|
49
|
+
# (+"Pаypal"+) for +:mixed_script+.
|
|
50
|
+
# * +explanation+ — a one-line chrome-agnostic summary.
|
|
71
51
|
Warning = Data.define(:kind, :offenders, :explanation)
|
|
72
52
|
|
|
73
53
|
# The output of {Sanitizer.sanitize}.
|
|
@@ -84,17 +64,15 @@ module Pikuri
|
|
|
84
64
|
# zero-width no-break space.
|
|
85
65
|
ZERO_WIDTH = [0x200b, 0x200c, 0x200d, 0xfeff].freeze
|
|
86
66
|
|
|
87
|
-
# Codepoints {.sanitize} rewrites: C0 controls including tab (U+0009)
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# must keep, so the C0 range is split around it.
|
|
67
|
+
# Codepoints {.sanitize} rewrites: C0 controls including tab (U+0009) but
|
|
68
|
+
# *excluding* newline (U+000A, passes through), C1 + DEL (U+007F–009F), the
|
|
69
|
+
# zero-width set, and the bidi overrides. Newline is the one control a
|
|
70
|
+
# faithful render keeps, so the C0 range splits around it.
|
|
92
71
|
SUSPECT = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200b-\u200d\u202a-\u202e\u2066-\u2069\ufeff]/
|
|
93
72
|
|
|
94
|
-
# The three Latin-confusable scripts whose mixing inside one token
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
# the "two distinct scripts" threshold.
|
|
73
|
+
# The three Latin-confusable scripts whose mixing inside one token signals a
|
|
74
|
+
# homoglyph spoof. Punctuation/digits/spaces are +Common+ and match none, so
|
|
75
|
+
# they never count toward the two-script threshold.
|
|
98
76
|
CONFUSABLE_SCRIPTS = { 'Latin' => /\p{Latin}/, 'Cyrillic' => /\p{Cyrillic}/, 'Greek' => /\p{Greek}/ }.freeze
|
|
99
77
|
|
|
100
78
|
# Neutralize +text+ for literal display and report what was flagged.
|