pikuri-core 0.0.4 → 0.0.5
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/lib/pikuri/agent/configurator.rb +9 -2
- data/lib/pikuri/agent/context_window_detector.rb +70 -10
- data/lib/pikuri/agent/control/interloper.rb +10 -2
- data/lib/pikuri/agent/event.rb +15 -0
- data/lib/pikuri/agent/extension.rb +37 -9
- data/lib/pikuri/agent/listener/terminal.rb +5 -1
- data/lib/pikuri/agent.rb +174 -73
- data/lib/pikuri/file_type.rb +220 -0
- data/lib/pikuri/finalizers.rb +118 -0
- data/lib/pikuri/paths.rb +29 -0
- data/lib/pikuri/subprocess.rb +36 -10
- data/lib/pikuri/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 914f6e02052e97773e630e32bc9689bf7edc0eee4e962419aa41a15ca4afa667
|
|
4
|
+
data.tar.gz: e9a68c3813a0bbcd292757ee2cb4dbce6df7abc890e0d4afa6cf51cae77a6247
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f0bdf7af9a4a85d3669b01f0929b92fdb2acbc9c7cb083611c4acaecc4d3b5b37a3e3d97a1fd060bec143016f4db00968707d149f127eb1dea5a7243dd51a73
|
|
7
|
+
data.tar.gz: cf92ad370fed6574f9cd7cc44fbd93e36c6bb03c6d56555267dc0a22a723ca9ce298231495b902570bfbbdde6b5e6b319860b2b625818eb2bdbb51d5dd2346e2
|
|
@@ -127,8 +127,15 @@ module Pikuri
|
|
|
127
127
|
# @param step_limit [Control::StepLimit, nil]
|
|
128
128
|
# @param cancellable [Control::Cancellable, nil]
|
|
129
129
|
# @param interloper [Control::Interloper, nil]
|
|
130
|
+
# @param on_close_sink [Array<Proc>, nil] array that {#on_close}
|
|
131
|
+
# appends to. {Agent#initialize} passes its own live
|
|
132
|
+
# +@on_close_handlers+ so a handler an extension arms via
|
|
133
|
+
# +c.on_close+ is reachable the instant it's registered — which
|
|
134
|
+
# is what lets the constructor close a half-built agent if a
|
|
135
|
+
# later extension's +configure+ raises. Defaults to a fresh
|
|
136
|
+
# array for standalone use (e.g. specs).
|
|
130
137
|
def initialize(transport:, system_prompt_base:, id:, streaming:,
|
|
131
|
-
step_limit:, cancellable:, interloper:)
|
|
138
|
+
step_limit:, cancellable:, interloper:, on_close_sink: nil)
|
|
132
139
|
@transport = transport
|
|
133
140
|
@system_prompt_base = system_prompt_base
|
|
134
141
|
@id = id
|
|
@@ -141,7 +148,7 @@ module Pikuri
|
|
|
141
148
|
@sub_agent_tools = []
|
|
142
149
|
@listeners = []
|
|
143
150
|
@system_prompt_additions = []
|
|
144
|
-
@on_close_handlers = []
|
|
151
|
+
@on_close_handlers = on_close_sink || []
|
|
145
152
|
@extensions = []
|
|
146
153
|
end
|
|
147
154
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'cgi'
|
|
5
6
|
|
|
6
7
|
module Pikuri
|
|
7
8
|
class Agent
|
|
@@ -31,6 +32,20 @@ module Pikuri
|
|
|
31
32
|
# (typically +bin/pikuri-chat+) derives the right URL from its configured
|
|
32
33
|
# base.
|
|
33
34
|
#
|
|
35
|
+
# == llama.cpp router mode
|
|
36
|
+
#
|
|
37
|
+
# A llama.cpp *router* (the multi-instance front that proxies to N
|
|
38
|
+
# on-demand model servers) answers a bare +/props+ with
|
|
39
|
+
# +{"role":"router", ..., "n_ctx":0}+ — there is no single loaded
|
|
40
|
+
# model at the router itself, so its top-level +n_ctx+ is +0+. The
|
|
41
|
+
# real per-model cap is one proxied hop away: +GET /props?model=<id>+
|
|
42
|
+
# routes the probe to that model's instance, whose +/props+ carries
|
|
43
|
+
# the launched +n_ctx+. So when the bare probe reports +role: router+
|
|
44
|
+
# and a +model_id+ is known, this re-probes with the model id before
|
|
45
|
+
# giving up. A plain single-model server is untouched: its bare
|
|
46
|
+
# +/props+ already carries a positive +n_ctx+, so the router branch
|
|
47
|
+
# never runs.
|
|
48
|
+
#
|
|
34
49
|
# == Failure handling
|
|
35
50
|
#
|
|
36
51
|
# The probe is best-effort. HTTP error, timeout, non-JSON body, or a
|
|
@@ -64,10 +79,15 @@ module Pikuri
|
|
|
64
79
|
# +RubyLLM::Chat#model.context_window+
|
|
65
80
|
# @param llama_probe_url [String, nil] full URL to llama.cpp +/props+;
|
|
66
81
|
# +nil+ or empty string skips the probe
|
|
67
|
-
|
|
82
|
+
# @param model_id [String, nil] the chat model id, used only to
|
|
83
|
+
# follow a llama.cpp router via +/props?model=<id>+ when the bare
|
|
84
|
+
# probe reports +role: router+. +nil+ or empty disables that
|
|
85
|
+
# second hop.
|
|
86
|
+
def initialize(override:, ruby_llm_reported:, llama_probe_url:, model_id: nil)
|
|
68
87
|
@override = override
|
|
69
88
|
@ruby_llm_reported = ruby_llm_reported
|
|
70
89
|
@llama_probe_url = llama_probe_url
|
|
90
|
+
@model_id = model_id
|
|
71
91
|
end
|
|
72
92
|
|
|
73
93
|
# @return [Integer, nil] resolved cap, or +nil+ if no source produced
|
|
@@ -83,25 +103,65 @@ module Pikuri
|
|
|
83
103
|
private
|
|
84
104
|
|
|
85
105
|
def probe_llama_cpp
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
).get(@llama_probe_url) do |req|
|
|
89
|
-
req.headers['Accept'] = 'application/json'
|
|
90
|
-
end
|
|
106
|
+
data = fetch_props(@llama_probe_url)
|
|
107
|
+
return nil if data.nil?
|
|
91
108
|
|
|
92
|
-
|
|
109
|
+
n_ctx = positive_n_ctx(data)
|
|
110
|
+
return n_ctx if n_ctx
|
|
93
111
|
|
|
94
|
-
|
|
95
|
-
n_ctx
|
|
96
|
-
return
|
|
112
|
+
# llama.cpp router: the bare /props carries no model, so its
|
|
113
|
+
# n_ctx is 0. Follow the router to the model's own instance.
|
|
114
|
+
return probe_router_model if data['role'] == 'router' && model_id_present?
|
|
97
115
|
|
|
98
116
|
warn_and_nil(
|
|
99
117
|
"no positive integer at default_generation_settings.n_ctx in #{@llama_probe_url} response"
|
|
100
118
|
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def probe_router_model
|
|
122
|
+
url = "#{@llama_probe_url}?model=#{CGI.escape(@model_id)}"
|
|
123
|
+
data = fetch_props(url)
|
|
124
|
+
return nil if data.nil?
|
|
125
|
+
|
|
126
|
+
n_ctx = positive_n_ctx(data)
|
|
127
|
+
return n_ctx if n_ctx
|
|
128
|
+
|
|
129
|
+
warn_and_nil(
|
|
130
|
+
"no positive integer at default_generation_settings.n_ctx in router probe #{url}"
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# GETs +url+ and parses its JSON body.
|
|
135
|
+
#
|
|
136
|
+
# @param url [String] a llama.cpp +/props+ URL
|
|
137
|
+
# @return [Hash, nil] the parsed body, or +nil+ (after one +warn+
|
|
138
|
+
# line) on non-200, timeout, transport error, or non-JSON body
|
|
139
|
+
def fetch_props(url)
|
|
140
|
+
response = Faraday.new(
|
|
141
|
+
request: { open_timeout: OPEN_TIMEOUT, timeout: READ_TIMEOUT }
|
|
142
|
+
).get(url) do |req|
|
|
143
|
+
req.headers['Accept'] = 'application/json'
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
return warn_and_nil("HTTP #{response.status} from #{url}") unless response.status == 200
|
|
147
|
+
|
|
148
|
+
JSON.parse(response.body)
|
|
101
149
|
rescue Faraday::Error, JSON::ParserError => e
|
|
102
150
|
warn_and_nil("#{e.class.name.split('::').last}: #{e.message}")
|
|
103
151
|
end
|
|
104
152
|
|
|
153
|
+
# @param data [Hash] a parsed +/props+ body
|
|
154
|
+
# @return [Integer, nil] the launched +n_ctx+ when present and
|
|
155
|
+
# positive, else +nil+
|
|
156
|
+
def positive_n_ctx(data)
|
|
157
|
+
n_ctx = data.dig('default_generation_settings', 'n_ctx')
|
|
158
|
+
n_ctx if n_ctx.is_a?(Integer) && n_ctx.positive?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def model_id_present?
|
|
162
|
+
!@model_id.nil? && !@model_id.empty?
|
|
163
|
+
end
|
|
164
|
+
|
|
105
165
|
def warn_and_nil(reason)
|
|
106
166
|
LOGGER.warn("llama.cpp /props probe failed: #{reason}")
|
|
107
167
|
nil
|
|
@@ -123,6 +123,14 @@ module Pikuri
|
|
|
123
123
|
@mutex.synchronize { !@items.empty? }
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# @return [Integer] number of pending injections; like
|
|
127
|
+
# {#pending?} and {#peek}, a snapshot observable from
|
|
128
|
+
# any thread — by the time the caller reads it the
|
|
129
|
+
# queue may already have drained
|
|
130
|
+
def size
|
|
131
|
+
@mutex.synchronize { @items.size }
|
|
132
|
+
end
|
|
133
|
+
|
|
126
134
|
# Atomically take and remove all pending items. Called by
|
|
127
135
|
# {Agent}'s +after_tool_result+ wiring; the +Agent+ then
|
|
128
136
|
# appends each item to the chat history and emits an
|
|
@@ -147,8 +155,8 @@ module Pikuri
|
|
|
147
155
|
# the pending-count so a debug print or banner can tell
|
|
148
156
|
# an idle interloper apart from one with queued items
|
|
149
157
|
def to_s
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
pending = size
|
|
159
|
+
pending.zero? ? 'Interloper' : "Interloper(#{pending} pending)"
|
|
152
160
|
end
|
|
153
161
|
end
|
|
154
162
|
end
|
data/lib/pikuri/agent/event.rb
CHANGED
|
@@ -45,6 +45,21 @@ module Pikuri
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# A system-role block an {Extension#on_user_message} hook
|
|
49
|
+
# injected into the chat log — recalled reference (memory
|
|
50
|
+
# context, retrieved snippets) tagged +role: :system+ so the
|
|
51
|
+
# model reads it as background, not new user input. Carries
|
|
52
|
+
# the injected text verbatim.
|
|
53
|
+
#
|
|
54
|
+
# Emitted by {Agent#dispatch_ext_on_user_message}, once per
|
|
55
|
+
# extension that returns a non-empty block, at the same site
|
|
56
|
+
# that grows the chat log — so the event stream stays a
|
|
57
|
+
# faithful mirror of what the model actually sees. Without it
|
|
58
|
+
# an injection is invisible: it never surfaces in the stream,
|
|
59
|
+
# only as a secondary echo in the assistant's later reasoning.
|
|
60
|
+
# {Listener::Terminal} renders it dim grey with a +⊕+ marker.
|
|
61
|
+
SystemInjected = Data.define(:content)
|
|
62
|
+
|
|
48
63
|
# Assistant reasoning ("thinking") block, extracted from the
|
|
49
64
|
# +thinking.text+ field on a +RubyLLM::Message+ with role
|
|
50
65
|
# +:assistant+. Emitted by {Agent}'s +after_message+ wiring;
|
|
@@ -5,17 +5,20 @@ module Pikuri
|
|
|
5
5
|
# The Extension protocol — how hosts bolt extra capabilities
|
|
6
6
|
# (system-prompt snippets, tools, lifecycle hooks) onto an
|
|
7
7
|
# {Agent}. Extensions are added via {Configurator#add_extension}
|
|
8
|
-
# inside the +Agent.new+ block; the Agent then drives
|
|
9
|
-
# on each — {#configure} during the block, {#bind} once the
|
|
10
|
-
#
|
|
8
|
+
# inside the +Agent.new+ block; the Agent then drives three hooks
|
|
9
|
+
# on each — {#configure} during the block, {#bind} once the agent
|
|
10
|
+
# is fully constructed, and {#on_user_message} on every user turn
|
|
11
|
+
# thereafter.
|
|
11
12
|
#
|
|
12
13
|
# Mix this module into an extension class to inherit empty
|
|
13
|
-
# default implementations of
|
|
14
|
-
# need. Extensions that don't +include+ this module still
|
|
15
|
-
# if they define
|
|
16
|
-
# Configurator call them by name
|
|
17
|
-
#
|
|
18
|
-
#
|
|
14
|
+
# default implementations of all three hooks; override the ones
|
|
15
|
+
# you need. Extensions that don't +include+ this module still
|
|
16
|
+
# work *if they define all three methods themselves* — the Agent
|
|
17
|
+
# and Configurator call them by name with no +respond_to?+ guard,
|
|
18
|
+
# so a missing one raises. The module exists to make the protocol
|
|
19
|
+
# *explicit* and to give "I want to implement just +configure+"
|
|
20
|
+
# extensions free no-op +bind+ / +on_user_message+ defaults (and
|
|
21
|
+
# any other combination).
|
|
19
22
|
#
|
|
20
23
|
# == Example
|
|
21
24
|
#
|
|
@@ -75,6 +78,31 @@ module Pikuri
|
|
|
75
78
|
# @param agent [Agent] the live agent, fully wired
|
|
76
79
|
# @return [void]
|
|
77
80
|
def bind(agent); end
|
|
81
|
+
|
|
82
|
+
# Optional per-turn hook fired by the {Agent} after a user-message
|
|
83
|
+
# is added to the chat. The
|
|
84
|
+
# default is a no-op returning +nil+; override and return {String}
|
|
85
|
+
# to emit a `:system` message with that text.
|
|
86
|
+
#
|
|
87
|
+
# == Append-only, never mutate
|
|
88
|
+
#
|
|
89
|
+
# The Agent only ever *appends* the returned block at the tail; it never
|
|
90
|
+
# rewrites or removes an earlier one. Mutating mid-log would bust the
|
|
91
|
+
# provider prefix cache for every message after the edit. Stale blocks
|
|
92
|
+
# ride the existing context-window machinery, not a per-turn rewrite.
|
|
93
|
+
#
|
|
94
|
+
# == Not inherited by sub-agents
|
|
95
|
+
#
|
|
96
|
+
# Like the rest of the extension surface, this fires on the parent agent
|
|
97
|
+
# only — sub-agents do not inherit extensions, so a persona's turns are
|
|
98
|
+
# never prefetched or recorded by the parent's memory.
|
|
99
|
+
#
|
|
100
|
+
# @param agent [Agent] the live agent whose turn this is
|
|
101
|
+
# @param content [String] the user message (initial or interloper) about
|
|
102
|
+
# to be sent to the model
|
|
103
|
+
# @return [String, nil] an optional block of text to be injected verbatim as
|
|
104
|
+
# a system-role message (after the user message), or +nil+ to inject nothing
|
|
105
|
+
def on_user_message(agent, content); end
|
|
78
106
|
end
|
|
79
107
|
end
|
|
80
108
|
end
|
|
@@ -9,7 +9,9 @@ module Pikuri
|
|
|
9
9
|
# Terminal renderer for the normalized event stream: dim grey
|
|
10
10
|
# reasoning, Markdown-rendered assistant content, cyan tool-
|
|
11
11
|
# call and tool-result lines, yellow fallback notice, red
|
|
12
|
-
# cancelled notice. {Event::
|
|
12
|
+
# cancelled notice. An {Event::SystemInjected} block (recalled
|
|
13
|
+
# memory / context an extension injected) renders dim grey
|
|
14
|
+
# with a +⊕+ marker. {Event::UserTurn} is intentionally silent
|
|
13
15
|
# (the terminal user just typed the message, so re-rendering
|
|
14
16
|
# it adds nothing); {Event::Tokens} and {Event::ContextCap}
|
|
15
17
|
# are silent too (their consumer is {TokenLog}).
|
|
@@ -123,6 +125,8 @@ module Pikuri
|
|
|
123
125
|
stream_fragment(Rainbow(content).color(85, 85, 85)) if @streaming
|
|
124
126
|
in Event::AssistantDelta(content:)
|
|
125
127
|
stream_fragment(content) if @streaming
|
|
128
|
+
in Event::SystemInjected(content:)
|
|
129
|
+
println(indent(Rainbow("⊕ #{content}").color(85, 85, 85)))
|
|
126
130
|
in Event::ToolCall(name:, arguments:)
|
|
127
131
|
args = arguments.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
|
|
128
132
|
println(indent(Rainbow("→ #{name}(#{args})").cyan))
|
data/lib/pikuri/agent.rb
CHANGED
|
@@ -94,8 +94,16 @@ module Pikuri
|
|
|
94
94
|
# queue is drained on every +after_tool_result+, each item
|
|
95
95
|
# appended as a +role: :user+ message and emitted as
|
|
96
96
|
# {Event::UserTurn} with +mid_loop: true+
|
|
97
|
+
# @param on_user_message [Proc, nil] when set, called with each
|
|
98
|
+
# drained interloper +content+ String *after* it is appended
|
|
99
|
+
# to the chat — the per-turn {Extension#on_user_message}
|
|
100
|
+
# dispatch (prefetch + recording). Threaded through here rather
|
|
101
|
+
# than fired inline so {Synthesizer.run}, which reuses this
|
|
102
|
+
# wiring without an interloper or memory, simply passes +nil+.
|
|
103
|
+
# Only consulted when +interloper+ is also set.
|
|
97
104
|
# @return [void]
|
|
98
|
-
def self.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil
|
|
105
|
+
def self.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil,
|
|
106
|
+
on_user_message: nil)
|
|
99
107
|
chat.after_message do |msg|
|
|
100
108
|
emit_after_message(msg, listeners)
|
|
101
109
|
end
|
|
@@ -106,7 +114,7 @@ module Pikuri
|
|
|
106
114
|
end
|
|
107
115
|
chat.after_tool_result do |result|
|
|
108
116
|
listeners.emit(Event::ToolResult.new(content: result))
|
|
109
|
-
drain_interloper(interloper, chat, listeners) if interloper
|
|
117
|
+
drain_interloper(interloper, chat, listeners, on_user_message) if interloper
|
|
110
118
|
end
|
|
111
119
|
end
|
|
112
120
|
|
|
@@ -216,18 +224,29 @@ module Pikuri
|
|
|
216
224
|
|
|
217
225
|
# Drain the interloper queue: for each pending item, append a
|
|
218
226
|
# +role: :user+ message to the chat history so the next
|
|
219
|
-
# round-trip sees it,
|
|
220
|
-
# +mid_loop: true+ to the listener stream so renderers see
|
|
221
|
-
# the
|
|
227
|
+
# round-trip sees it, emit an {Event::UserTurn} with
|
|
228
|
+
# +mid_loop: true+ to the listener stream so renderers see the
|
|
229
|
+
# injection, then run the per-turn {Extension#on_user_message}
|
|
230
|
+
# dispatch (so mid-loop injections are prefetched + recorded
|
|
231
|
+
# exactly like initial turns).
|
|
232
|
+
#
|
|
233
|
+
# The dispatch runs *after* the +:user+ append so any
|
|
234
|
+
# +<memory-context>+ it injects lands as a +:system+ message
|
|
235
|
+
# right behind the user turn it annotates — the same
|
|
236
|
+
# append-at-the-tail ordering {#run_loop} produces for initial
|
|
237
|
+
# turns.
|
|
222
238
|
#
|
|
223
239
|
# @param interloper [Control::Interloper]
|
|
224
240
|
# @param chat [RubyLLM::Chat]
|
|
225
241
|
# @param listeners [ListenerList]
|
|
242
|
+
# @param on_user_message [Proc, nil] per-content dispatch; +nil+
|
|
243
|
+
# skips it (e.g. an interloper with no memory extension wired)
|
|
226
244
|
# @return [void]
|
|
227
|
-
def self.drain_interloper(interloper, chat, listeners)
|
|
245
|
+
def self.drain_interloper(interloper, chat, listeners, on_user_message = nil)
|
|
228
246
|
interloper.drain!.each do |content|
|
|
229
247
|
chat.add_message(role: :user, content: content)
|
|
230
248
|
listeners.emit(Event::UserTurn.new(content: content, mid_loop: true))
|
|
249
|
+
on_user_message&.call(content)
|
|
231
250
|
end
|
|
232
251
|
end
|
|
233
252
|
private_class_method :drain_interloper
|
|
@@ -381,71 +400,31 @@ module Pikuri
|
|
|
381
400
|
@streaming = streaming
|
|
382
401
|
@synth_answer = nil
|
|
383
402
|
@on_close_handlers = []
|
|
384
|
-
|
|
385
|
-
#
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
403
|
+
# Stashed for {#run_configure}, which runs the failure-prone
|
|
404
|
+
# build phase below out of a separate method.
|
|
405
|
+
@block = block
|
|
406
|
+
@context_window = context_window
|
|
407
|
+
@llama_probe_url = llama_probe_url
|
|
408
|
+
|
|
409
|
+
# Register *before* the build phase so a mid-construction raise
|
|
410
|
+
# is still recoverable: extensions arm their cleanup via
|
|
411
|
+
# +c.on_close+ (which writes straight to +@on_close_handlers+,
|
|
412
|
+
# see {Configurator}), and the rescue below fires whatever was
|
|
413
|
+
# armed before the failure. On the happy path this registration
|
|
414
|
+
# is the at-exit backstop if the host forgets {#close}; an
|
|
415
|
+
# explicit {#close} unregisters, so the agent isn't pinned alive
|
|
416
|
+
# until process exit.
|
|
417
|
+
Pikuri::Finalizers.register(self)
|
|
418
|
+
|
|
419
|
+
begin
|
|
420
|
+
run_configure
|
|
421
|
+
rescue StandardError
|
|
422
|
+
# Half-built agent (e.g. an extension's +configure+ raised
|
|
423
|
+
# Cancelled mid-spawn). Fire the handlers armed so far, drop
|
|
424
|
+
# out of the registry, and re-raise — no partial state leaks.
|
|
425
|
+
close
|
|
426
|
+
raise
|
|
406
427
|
end
|
|
407
|
-
@on_close_handlers.concat(configurator.on_close_handlers)
|
|
408
|
-
@extensions = configurator.extensions.dup
|
|
409
|
-
|
|
410
|
-
@chat = RubyLLM.chat(**@transport.to_h)
|
|
411
|
-
@chat.with_instructions(@system_prompt)
|
|
412
|
-
@tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool) }
|
|
413
|
-
|
|
414
|
-
@context_window_cap = ContextWindowDetector.new(
|
|
415
|
-
override: context_window,
|
|
416
|
-
ruby_llm_reported: @chat.model.context_window,
|
|
417
|
-
llama_probe_url: llama_probe_url
|
|
418
|
-
).detect
|
|
419
|
-
|
|
420
|
-
self.class.wire_chat(
|
|
421
|
-
@chat,
|
|
422
|
-
listeners: @listeners,
|
|
423
|
-
step_limit: @step_limit,
|
|
424
|
-
cancellable: @cancellable,
|
|
425
|
-
interloper: @interloper
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
# One-shot context-window cap: lets every listener that
|
|
429
|
-
# cares (notably TokenLog) pick the value off the stream
|
|
430
|
-
# before any Tokens event arrives.
|
|
431
|
-
@listeners.emit(Event::ContextCap.new(cap: @context_window_cap))
|
|
432
|
-
|
|
433
|
-
# Bind sweep — each extension gets its chance to install
|
|
434
|
-
# per-agent state (dynamic tools via #internal_add_tool,
|
|
435
|
-
# per-agent close hooks via #on_close, etc.) now that the
|
|
436
|
-
# chat is fully wired. See IDEAS.md §"Extension protocol
|
|
437
|
-
# design" for what #configure vs #bind are each for.
|
|
438
|
-
@extensions.each { |ext| ext.bind(self) }
|
|
439
|
-
|
|
440
|
-
# Fallback cleanup: if the host forgets to call #close, the
|
|
441
|
-
# at_exit hook fires it on process exit. Idempotent, so an
|
|
442
|
-
# explicit close earlier makes this a no-op. The closure
|
|
443
|
-
# captures self, which keeps the agent reachable until
|
|
444
|
-
# process exit — fine for the handful of agents a typical
|
|
445
|
-
# host creates; if pikuri grows a long-running host that
|
|
446
|
-
# constructs many short-lived agents, switch to a single
|
|
447
|
-
# process-global registry that close-then-removes.
|
|
448
|
-
at_exit { close }
|
|
449
428
|
end
|
|
450
429
|
|
|
451
430
|
# @return [RubyLLM::Chat] underlying chat; the extension seam
|
|
@@ -601,13 +580,23 @@ module Pikuri
|
|
|
601
580
|
if user_message.nil? || user_message.to_s.strip.empty?
|
|
602
581
|
|
|
603
582
|
@synth_answer = nil
|
|
604
|
-
@listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
|
|
605
583
|
@step_limit&.reset!
|
|
606
584
|
@cancellable&.reset!
|
|
585
|
+
# Append the user turn, emit it, then run the memory dispatch — so
|
|
586
|
+
# any <memory-context> the dispatch injects lands as a :system
|
|
587
|
+
# message *after* the user turn it annotates (append-only at the
|
|
588
|
+
# tail; see {#dispatch_ext_on_user_message}). `ask` would bundle the
|
|
589
|
+
# user-message append with completion atomically, leaving no seam to
|
|
590
|
+
# inject between them, so the two halves run explicitly here:
|
|
591
|
+
# add_message + complete (the exact pair `ask` is sugar for). A raw
|
|
592
|
+
# String content matches the interloper drain path.
|
|
593
|
+
@chat.add_message(role: :user, content: user_message)
|
|
594
|
+
@listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
|
|
595
|
+
dispatch_ext_on_user_message(user_message)
|
|
607
596
|
if @streaming
|
|
608
|
-
@chat.
|
|
597
|
+
@chat.complete(&self.class.streaming_block(listeners: @listeners, cancellable: @cancellable))
|
|
609
598
|
else
|
|
610
|
-
@chat.
|
|
599
|
+
@chat.complete
|
|
611
600
|
end
|
|
612
601
|
nil
|
|
613
602
|
rescue Control::Cancellable::Cancelled
|
|
@@ -661,6 +650,10 @@ module Pikuri
|
|
|
661
650
|
return if @closed
|
|
662
651
|
|
|
663
652
|
@closed = true
|
|
653
|
+
# Drop out of the process-global registry first: a deliberate
|
|
654
|
+
# close means this agent no longer needs the at-exit fallback,
|
|
655
|
+
# and removing the reference lets it be garbage-collected.
|
|
656
|
+
Pikuri::Finalizers.unregister(self)
|
|
664
657
|
@on_close_handlers.reverse_each do |handler|
|
|
665
658
|
handler.call
|
|
666
659
|
rescue StandardError => e
|
|
@@ -719,5 +712,113 @@ module Pikuri
|
|
|
719
712
|
def to_s
|
|
720
713
|
"Agent(id=#{@id}, model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})"
|
|
721
714
|
end
|
|
715
|
+
|
|
716
|
+
private
|
|
717
|
+
|
|
718
|
+
# The failure-prone build phase, split out of {#initialize} so the
|
|
719
|
+
# constructor can wrap it in a rescue and self-heal. Funnels the
|
|
720
|
+
# +Agent.new+ block through a single {Configurator} — tools,
|
|
721
|
+
# listeners, system-prompt snippets, extensions, and +on_close+
|
|
722
|
+
# handlers — then wires the chat and runs the extension +bind+
|
|
723
|
+
# sweep. The Configurator's +on_close_sink:+ is +@on_close_handlers+
|
|
724
|
+
# itself, so a handler an extension arms via +c.on_close+ is live on
|
|
725
|
+
# the agent the instant it's registered — that's what lets the
|
|
726
|
+
# constructor's rescue close a half-built agent.
|
|
727
|
+
#
|
|
728
|
+
# @return [void]
|
|
729
|
+
def run_configure
|
|
730
|
+
configurator = Configurator.new(
|
|
731
|
+
transport: @transport,
|
|
732
|
+
system_prompt_base: @system_prompt,
|
|
733
|
+
id: @id,
|
|
734
|
+
streaming: @streaming,
|
|
735
|
+
step_limit: @step_limit,
|
|
736
|
+
cancellable: @cancellable,
|
|
737
|
+
interloper: @interloper,
|
|
738
|
+
on_close_sink: @on_close_handlers
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
@block&.call(configurator)
|
|
742
|
+
|
|
743
|
+
@tools = configurator.tools.dup
|
|
744
|
+
@sub_agent_tools = configurator.sub_agent_tools.dup
|
|
745
|
+
@listeners = ListenerList.new(configurator.listeners)
|
|
746
|
+
configurator.system_prompt_additions.each do |snippet|
|
|
747
|
+
@system_prompt = "#{@system_prompt}\n\n#{snippet}"
|
|
748
|
+
end
|
|
749
|
+
@extensions = configurator.extensions.dup
|
|
750
|
+
|
|
751
|
+
@chat = RubyLLM.chat(**@transport.to_h)
|
|
752
|
+
@chat.with_instructions(@system_prompt)
|
|
753
|
+
@tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool) }
|
|
754
|
+
|
|
755
|
+
@context_window_cap = ContextWindowDetector.new(
|
|
756
|
+
override: @context_window,
|
|
757
|
+
ruby_llm_reported: @chat.model.context_window,
|
|
758
|
+
llama_probe_url: @llama_probe_url,
|
|
759
|
+
model_id: @chat.model.id
|
|
760
|
+
).detect
|
|
761
|
+
|
|
762
|
+
self.class.wire_chat(
|
|
763
|
+
@chat,
|
|
764
|
+
listeners: @listeners,
|
|
765
|
+
step_limit: @step_limit,
|
|
766
|
+
cancellable: @cancellable,
|
|
767
|
+
interloper: @interloper,
|
|
768
|
+
on_user_message: method(:dispatch_ext_on_user_message)
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
# One-shot context-window cap: lets every listener that
|
|
772
|
+
# cares (notably TokenLog) pick the value off the stream
|
|
773
|
+
# before any Tokens event arrives.
|
|
774
|
+
@listeners.emit(Event::ContextCap.new(cap: @context_window_cap))
|
|
775
|
+
|
|
776
|
+
# Bind sweep — each extension gets its chance to install
|
|
777
|
+
# per-agent state (dynamic tools via #internal_add_tool,
|
|
778
|
+
# per-agent close hooks via #on_close, etc.) now that the
|
|
779
|
+
# chat is fully wired. See IDEAS.md §"Extension protocol
|
|
780
|
+
# design" for what #configure vs #bind are each for.
|
|
781
|
+
@extensions.each { |ext| ext.bind(self) }
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# Fire the per-turn {Extension#on_user_message} hook on every
|
|
785
|
+
# extension that defines it, appending any returned
|
|
786
|
+
# +<memory-context>+ block to the chat as a +role: :system+
|
|
787
|
+
# message right after the user turn it annotates (callers append
|
|
788
|
+
# the +:user+ message first; this runs last). The system role is
|
|
789
|
+
# load-bearing — it tags the block as recalled reference (not new
|
|
790
|
+
# input) and keeps it excludable from a later extraction pass.
|
|
791
|
+
# See {Extension#on_user_message}.
|
|
792
|
+
#
|
|
793
|
+
# Each injected block also emits an {Event::SystemInjected} at
|
|
794
|
+
# this site, so the listener stream mirrors the log growth (the
|
|
795
|
+
# Terminal renders it; otherwise an injection would be invisible
|
|
796
|
+
# except as a downstream echo in the assistant's reasoning).
|
|
797
|
+
#
|
|
798
|
+
# Private and the single place the chat log grows by a memory
|
|
799
|
+
# block — keeps "what mutates the log, when" one grep in this
|
|
800
|
+
# file. Fired from {#run_loop} (initial turn) and, via the
|
|
801
|
+
# +on_user_message:+ proc threaded into {.wire_chat}, from
|
|
802
|
+
# {.drain_interloper} (mid-loop interlopers). Called on every
|
|
803
|
+
# extension unconditionally — same as {Extension#configure} /
|
|
804
|
+
# {Extension#bind}: the hook is part of the protocol and the
|
|
805
|
+
# {Extension} module supplies a no-op default, so any extension
|
|
806
|
+
# that includes the module responds. An extension is "opted out"
|
|
807
|
+
# by leaving the default in place (it returns +nil+, injecting
|
|
808
|
+
# nothing), not by omitting the method.
|
|
809
|
+
#
|
|
810
|
+
# @param content [String] the incoming user message
|
|
811
|
+
# @return [void]
|
|
812
|
+
def dispatch_ext_on_user_message(content)
|
|
813
|
+
@extensions.each do |ext|
|
|
814
|
+
message = ext.on_user_message(self, content)
|
|
815
|
+
next unless message.is_a?(String) && !message.strip.empty?
|
|
816
|
+
|
|
817
|
+
block = message.strip
|
|
818
|
+
@chat.add_message(role: :system, content: block)
|
|
819
|
+
@listeners.emit(Event::SystemInjected.new(content: block))
|
|
820
|
+
end
|
|
821
|
+
nil
|
|
822
|
+
end
|
|
722
823
|
end
|
|
723
824
|
end
|
data/lib/pikuri/file_type.rb
CHANGED
|
@@ -21,6 +21,15 @@ module Pikuri
|
|
|
21
21
|
# The pure-extraction shape consumers like +Pikuri::VectorDb+'s
|
|
22
22
|
# indexer want (no LLM-tool concerns — no paging, no line
|
|
23
23
|
# numbering, no byte caps; just bytes-in-text-out).
|
|
24
|
+
# * {.read_as_text_paged} — the LLM-tool shape: the same
|
|
25
|
+
# extraction as {.read_as_text}, but lazily windowed to a
|
|
26
|
+
# line range with a byte cap, returning a {Page} value the
|
|
27
|
+
# caller renders. Shared by +Workspace::Read+ and
|
|
28
|
+
# +VectorDb::Tools::Read+ so the offset/limit/byte-cap windowing lives
|
|
29
|
+
# in one tested place; each tool keeps its own presentation
|
|
30
|
+
# (cat-n numbering, trailer wording, citation vs. path). Same
|
|
31
|
+
# refusal contract as {.read_as_text} (raises on image / binary
|
|
32
|
+
# / missing / malformed-PDF).
|
|
24
33
|
#
|
|
25
34
|
# {.detect_mime} and {.binary?} accept either a +String+ of bytes
|
|
26
35
|
# (sample taken by the caller) or a +Pathname+ — when given a path,
|
|
@@ -85,6 +94,58 @@ module Pikuri
|
|
|
85
94
|
# with this five-byte ASCII sequence per ISO 32000-1 §7.5.2.
|
|
86
95
|
PDF_MAGIC = '%PDF-'
|
|
87
96
|
|
|
97
|
+
# @return [Integer] default line-window size for
|
|
98
|
+
# {.read_as_text_paged} when the caller omits +limit+.
|
|
99
|
+
PAGE_DEFAULT_LIMIT = 2000
|
|
100
|
+
|
|
101
|
+
# @return [Integer] default hard byte cap on the content collected
|
|
102
|
+
# by a single {.read_as_text_paged} call. Bypassable by paging
|
|
103
|
+
# via +offset+. The rendered output is slightly larger (line
|
|
104
|
+
# numbering, trailer) — that's the caller's concern.
|
|
105
|
+
PAGE_MAX_BYTES = 50 * 1024
|
|
106
|
+
|
|
107
|
+
# @return [Integer] default per-line character cap;
|
|
108
|
+
# {.read_as_text_paged} truncates longer lines and appends
|
|
109
|
+
# {PAGE_LINE_TRUNCATION_MARKER}.
|
|
110
|
+
PAGE_MAX_LINE_LENGTH = 2000
|
|
111
|
+
|
|
112
|
+
# @return [String] suffix appended to a line truncated at
|
|
113
|
+
# {PAGE_MAX_LINE_LENGTH}.
|
|
114
|
+
PAGE_LINE_TRUNCATION_MARKER = "... (line truncated to #{PAGE_MAX_LINE_LENGTH} chars)"
|
|
115
|
+
|
|
116
|
+
# One windowed slice of a document, returned by
|
|
117
|
+
# {.read_as_text_paged}. The caller turns this into an
|
|
118
|
+
# observation; this struct carries everything a trailer needs
|
|
119
|
+
# without the caller re-reading the file.
|
|
120
|
+
#
|
|
121
|
+
# == Fields
|
|
122
|
+
#
|
|
123
|
+
# * +lines+ — +Array<String>+, the collected window. Already
|
|
124
|
+
# per-line truncated (with {PAGE_LINE_TRUNCATION_MARKER}); *not*
|
|
125
|
+
# line-numbered — numbering is presentation the caller adds. For
|
|
126
|
+
# a PDF the array includes +"--- Page N ---"+ marker lines (one
|
|
127
|
+
# per page that contributed text), which count toward +limit+ /
|
|
128
|
+
# the byte cap like any other line.
|
|
129
|
+
# * +start_line+ — the 1-indexed line number of +lines.first+
|
|
130
|
+
# (i.e. the +offset+ the caller asked for). +lines.last+ is at
|
|
131
|
+
# +start_line + lines.length - 1+.
|
|
132
|
+
# * +total_lines+ — total line count of the document when known,
|
|
133
|
+
# else +nil+. Known when extraction reached EOF (so the caller
|
|
134
|
+
# can print "of N"); +nil+ when the read stopped early — the
|
|
135
|
+
# byte cap fired, or a PDF filled the window before its last
|
|
136
|
+
# page (counting the rest would defeat the laziness).
|
|
137
|
+
# * +more+ — +true+ if content remains past this window (the
|
|
138
|
+
# caller should offer +offset = start_line + lines.length+).
|
|
139
|
+
# * +byte_capped+ — +true+ if {PAGE_MAX_BYTES} (not the line
|
|
140
|
+
# limit) was the stopping criterion.
|
|
141
|
+
# * +kind+ — +:text+ or +:pdf+; lets the caller word PDF-specific
|
|
142
|
+
# trailers and the empty-document message.
|
|
143
|
+
#
|
|
144
|
+
# An empty document yields +lines: []+, +total_lines: 0+; an
|
|
145
|
+
# +offset+ past EOF yields +lines: []+ with +total_lines+ set to
|
|
146
|
+
# the real (non-zero) count — the caller distinguishes the two.
|
|
147
|
+
Page = Data.define(:lines, :start_line, :total_lines, :more, :byte_capped, :kind)
|
|
148
|
+
|
|
88
149
|
# Recognise a file from its leading bytes. Returns the MIME type
|
|
89
150
|
# as a String for formats pikuri handles specially, or +nil+ for
|
|
90
151
|
# "unrecognised" — callers interpret +nil+ themselves (text,
|
|
@@ -208,6 +269,165 @@ module Pikuri
|
|
|
208
269
|
end
|
|
209
270
|
private_class_method :read_pdf_text
|
|
210
271
|
|
|
272
|
+
# Extract +path+ as text and return a windowed {Page}: the lines
|
|
273
|
+
# from +offset+ (1-indexed) up to +limit+ of them, stopping early
|
|
274
|
+
# if +max_bytes+ is reached, with over-long lines truncated at
|
|
275
|
+
# +max_line_length+. Lazy by design — a text file is streamed
|
|
276
|
+
# line-by-line and a PDF is parsed page-by-page only until the
|
|
277
|
+
# window fills, so reading the first page of a 500-page PDF parses
|
|
278
|
+
# a handful of pages, not all of them.
|
|
279
|
+
#
|
|
280
|
+
# Same routing and refusal contract as {.read_as_text}: PDFs are
|
|
281
|
+
# extracted (with +"--- Page N ---"+ marker lines, unlike
|
|
282
|
+
# {.read_as_text}'s marker-free join — paging is a display path,
|
|
283
|
+
# the marker-free form stays the indexing path); images, binaries,
|
|
284
|
+
# directories, missing files, and malformed PDFs all raise rather
|
|
285
|
+
# than returning a sentinel. The LLM-facing callers map those into
|
|
286
|
+
# +"Error: ..."+ observations themselves.
|
|
287
|
+
#
|
|
288
|
+
# @param path [Pathname] file to read.
|
|
289
|
+
# @param offset [Integer] 1-indexed first line to include. The
|
|
290
|
+
# caller is responsible for validating +offset >= 1+.
|
|
291
|
+
# @param limit [Integer] maximum lines to collect. Caller
|
|
292
|
+
# validates +limit >= 1+.
|
|
293
|
+
# @param max_bytes [Integer] hard byte cap on collected content.
|
|
294
|
+
# @param max_line_length [Integer] per-line truncation threshold.
|
|
295
|
+
# @return [Page] the windowed slice.
|
|
296
|
+
# @raise [ArgumentError] if +path+ isn't a +Pathname+, is a
|
|
297
|
+
# directory, an image, or binary.
|
|
298
|
+
# @raise [Errno::ENOENT] if +path+ doesn't exist.
|
|
299
|
+
# @raise [RuntimeError] on a malformed / unsupported PDF.
|
|
300
|
+
def read_as_text_paged(path, offset: 1, limit: PAGE_DEFAULT_LIMIT,
|
|
301
|
+
max_bytes: PAGE_MAX_BYTES, max_line_length: PAGE_MAX_LINE_LENGTH)
|
|
302
|
+
raise ArgumentError, "expected Pathname, got #{path.class}" unless path.is_a?(Pathname)
|
|
303
|
+
raise Errno::ENOENT, path.to_s unless path.exist?
|
|
304
|
+
raise ArgumentError, "#{path} is a directory" if path.directory?
|
|
305
|
+
|
|
306
|
+
mime = detect_mime(path)
|
|
307
|
+
if mime == 'application/pdf'
|
|
308
|
+
return paged_pdf(path, offset: offset, limit: limit,
|
|
309
|
+
max_bytes: max_bytes, max_line_length: max_line_length)
|
|
310
|
+
end
|
|
311
|
+
raise ArgumentError, "#{path} is an image (#{mime}); cannot extract as text" if mime&.start_with?('image/')
|
|
312
|
+
raise ArgumentError, "#{path} appears to be binary; cannot extract as text" if binary?(path)
|
|
313
|
+
|
|
314
|
+
paged_text(path, offset: offset, limit: limit,
|
|
315
|
+
max_bytes: max_bytes, max_line_length: max_line_length)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Stream a text file line-by-line into a {Page}. Keeps counting
|
|
319
|
+
# lines past the collection window so +total_lines+ can report the
|
|
320
|
+
# real total when the line limit (not the byte cap) stopped
|
|
321
|
+
# collection; on the byte cap it breaks and leaves +total_lines+
|
|
322
|
+
# +nil+ (the rest of the file is never read).
|
|
323
|
+
#
|
|
324
|
+
# @return [Page] +kind: :text+.
|
|
325
|
+
def paged_text(path, offset:, limit:, max_bytes:, max_line_length:)
|
|
326
|
+
start_index = offset - 1
|
|
327
|
+
collected = []
|
|
328
|
+
total_lines = 0
|
|
329
|
+
bytes = 0
|
|
330
|
+
byte_capped = false
|
|
331
|
+
more = false
|
|
332
|
+
|
|
333
|
+
path.each_line do |raw|
|
|
334
|
+
total_lines += 1
|
|
335
|
+
next if total_lines <= start_index
|
|
336
|
+
|
|
337
|
+
if collected.length >= limit
|
|
338
|
+
more = true
|
|
339
|
+
next
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
line = truncate_line(raw.chomp, max_line_length)
|
|
343
|
+
size = line.bytesize + 1 # +1 for the joining newline
|
|
344
|
+
if bytes + size > max_bytes
|
|
345
|
+
byte_capped = true
|
|
346
|
+
more = true
|
|
347
|
+
break
|
|
348
|
+
end
|
|
349
|
+
collected << line
|
|
350
|
+
bytes += size
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
Page.new(lines: collected, start_line: offset,
|
|
354
|
+
total_lines: byte_capped ? nil : total_lines,
|
|
355
|
+
more: more, byte_capped: byte_capped, kind: :text)
|
|
356
|
+
end
|
|
357
|
+
private_class_method :paged_text
|
|
358
|
+
|
|
359
|
+
# PDF counterpart to {paged_text}: walk +pdf-reader+'s lazy page
|
|
360
|
+
# iterator, emitting a +"--- Page N ---"+ header line then each
|
|
361
|
+
# line of the page's text, applying the same offset / limit /
|
|
362
|
+
# byte-cap contract. The +throw :done+ short-circuits both loops
|
|
363
|
+
# the moment the window fills, so parsing stops — which is why a
|
|
364
|
+
# PDF that stops early can't report +total_lines+ (it would have
|
|
365
|
+
# to parse every page to count).
|
|
366
|
+
#
|
|
367
|
+
# @return [Page] +kind: :pdf+.
|
|
368
|
+
# @raise [RuntimeError] on a malformed / unsupported PDF.
|
|
369
|
+
def paged_pdf(path, offset:, limit:, max_bytes:, max_line_length:)
|
|
370
|
+
start_index = offset - 1
|
|
371
|
+
collected = []
|
|
372
|
+
total_lines = 0
|
|
373
|
+
bytes = 0
|
|
374
|
+
byte_capped = false
|
|
375
|
+
more = false
|
|
376
|
+
|
|
377
|
+
catch(:done) do
|
|
378
|
+
path.open('rb') do |io|
|
|
379
|
+
reader = ::PDF::Reader.new(io)
|
|
380
|
+
reader.pages.each_with_index do |page, idx|
|
|
381
|
+
text = page.text.strip
|
|
382
|
+
next if text.empty?
|
|
383
|
+
|
|
384
|
+
["--- Page #{idx + 1} ---", *text.split("\n")].each do |raw|
|
|
385
|
+
total_lines += 1
|
|
386
|
+
next if total_lines <= start_index
|
|
387
|
+
|
|
388
|
+
if collected.length >= limit
|
|
389
|
+
more = true
|
|
390
|
+
throw :done
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
line = truncate_line(raw, max_line_length)
|
|
394
|
+
size = line.bytesize + 1
|
|
395
|
+
if bytes + size > max_bytes
|
|
396
|
+
byte_capped = true
|
|
397
|
+
more = true
|
|
398
|
+
throw :done
|
|
399
|
+
end
|
|
400
|
+
collected << line
|
|
401
|
+
bytes += size
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
Page.new(lines: collected, start_line: offset,
|
|
408
|
+
total_lines: more ? nil : total_lines,
|
|
409
|
+
more: more, byte_capped: byte_capped, kind: :pdf)
|
|
410
|
+
rescue ::PDF::Reader::MalformedPDFError,
|
|
411
|
+
::PDF::Reader::InvalidPageError,
|
|
412
|
+
::PDF::Reader::UnsupportedFeatureError => e
|
|
413
|
+
raise "Cannot extract PDF text from #{path}: " \
|
|
414
|
+
"#{e.class.name.split('::').last}: #{e.message}"
|
|
415
|
+
end
|
|
416
|
+
private_class_method :paged_pdf
|
|
417
|
+
|
|
418
|
+
# Truncate +line+ to +max_line_length+ chars, appending
|
|
419
|
+
# {PAGE_LINE_TRUNCATION_MARKER} when it overflows.
|
|
420
|
+
#
|
|
421
|
+
# @param line [String]
|
|
422
|
+
# @param max_line_length [Integer]
|
|
423
|
+
# @return [String]
|
|
424
|
+
def truncate_line(line, max_line_length)
|
|
425
|
+
return line if line.length <= max_line_length
|
|
426
|
+
|
|
427
|
+
line[0, max_line_length] + PAGE_LINE_TRUNCATION_MARKER
|
|
428
|
+
end
|
|
429
|
+
private_class_method :truncate_line
|
|
430
|
+
|
|
211
431
|
# Coerce an +input+ argument into a bytes String for the sniffs.
|
|
212
432
|
# +String+ inputs are returned as-is (caller already sampled);
|
|
213
433
|
# +Pathname+ inputs are opened in binary mode and up to
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
# Process-global teardown registry: one +at_exit+ for the whole
|
|
5
|
+
# process, with everything that owns a resource needing orderly
|
|
6
|
+
# shutdown (agents, {VectorDb::Server::Chroma}, future background
|
|
7
|
+
# workers) registering here instead of growing its own +at_exit+.
|
|
8
|
+
# It is {Agent#on_close} promoted from per-agent to per-process —
|
|
9
|
+
# the same LIFO + per-handler-rescue + idempotent shape, one level
|
|
10
|
+
# up.
|
|
11
|
+
#
|
|
12
|
+
# == Why one chokepoint
|
|
13
|
+
#
|
|
14
|
+
# Independent +at_exit+ hooks fire in an order decided by file load
|
|
15
|
+
# order, which is invisible and fragile. Routing every teardown
|
|
16
|
+
# through one registry makes the order explicit and controllable:
|
|
17
|
+
# the SIGTERM-the-strays backstop ({Subprocess.cleanup!}) registers
|
|
18
|
+
# at *load* time, so it sits at the bottom of the LIFO stack and runs
|
|
19
|
+
# *last* — after agents and servers (which register at *construction*
|
|
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.
|
|
23
|
+
#
|
|
24
|
+
# == Contract
|
|
25
|
+
#
|
|
26
|
+
# A registrant MUST respond to +#close+, and +#close+ MUST be
|
|
27
|
+
# idempotent and tolerant of running at process exit — the host may
|
|
28
|
+
# also have closed it explicitly earlier. Pass a block instead for
|
|
29
|
+
# teardown that has no natural +#close+ (e.g.
|
|
30
|
+
# +Finalizers.register { Pikuri::Subprocess.cleanup! }+).
|
|
31
|
+
#
|
|
32
|
+
# == Order: LIFO
|
|
33
|
+
#
|
|
34
|
+
# Last registered, first closed — Ruby +ensure+ semantics. A
|
|
35
|
+
# registrant that depends on an earlier one (a background indexer
|
|
36
|
+
# writing into {VectorDb::Server::Chroma}) is registered later and so
|
|
37
|
+
# tears down first. Registration order is therefore dependency order;
|
|
38
|
+
# register the dependency before its dependents.
|
|
39
|
+
#
|
|
40
|
+
# == Errors are contained
|
|
41
|
+
#
|
|
42
|
+
# Each +#close+ runs inside its own +rescue+: a raise is logged via
|
|
43
|
+
# {Pikuri.logger_for} and the sweep continues, so one botched
|
|
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.
|
|
46
|
+
module Finalizers
|
|
47
|
+
# @return [Logger] subsystem logger for contained teardown failures.
|
|
48
|
+
LOGGER = Pikuri.logger_for('Finalizers')
|
|
49
|
+
|
|
50
|
+
# Adapts a teardown block to the +#close+ protocol, so a block and a
|
|
51
|
+
# closeable object can share one registry.
|
|
52
|
+
Closer = Struct.new(:block) do
|
|
53
|
+
# @return [void]
|
|
54
|
+
def close
|
|
55
|
+
block.call
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@registered = []
|
|
60
|
+
@mutex = Mutex.new
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# Register a closeable (or a block) to be torn down at process
|
|
64
|
+
# exit. Returns the registered handle so the caller can later
|
|
65
|
+
# {.unregister} it — a resource closed explicitly before exit
|
|
66
|
+
# should drop out so it can be garbage-collected rather than
|
|
67
|
+
# pinned alive until the process dies.
|
|
68
|
+
#
|
|
69
|
+
# @param closeable [#close, nil] resource to close at exit; omit
|
|
70
|
+
# when passing a block
|
|
71
|
+
# @yield teardown to run at exit, for resources with no +#close+
|
|
72
|
+
# @return [#close] the registered handle — the object itself, or
|
|
73
|
+
# the {Closer} wrapping the block; pass it to {.unregister}
|
|
74
|
+
# @raise [ArgumentError] if neither an object nor a block is given
|
|
75
|
+
def register(closeable = nil, &block)
|
|
76
|
+
unless closeable || block
|
|
77
|
+
raise ArgumentError, 'Finalizers.register requires an object or a block'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
handle = closeable || Closer.new(block)
|
|
81
|
+
@mutex.synchronize { @registered << handle }
|
|
82
|
+
handle
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Drop a previously-registered handle. Idempotent — unregistering
|
|
86
|
+
# something already gone (or never registered) is a no-op.
|
|
87
|
+
#
|
|
88
|
+
# @param handle [#close] the value returned by {.register}
|
|
89
|
+
# @return [void]
|
|
90
|
+
def unregister(handle)
|
|
91
|
+
@mutex.synchronize { @registered.delete(handle) }
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Close every registrant in LIFO order, each guarded by its own
|
|
96
|
+
# +rescue+. Wired to +at_exit+ at the bottom of this file.
|
|
97
|
+
# Draining the registry under the lock makes a repeat call a
|
|
98
|
+
# no-op and keeps it safe against a concurrent caller.
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
def run!
|
|
102
|
+
handles = @mutex.synchronize do
|
|
103
|
+
taken = @registered.reverse
|
|
104
|
+
@registered.clear
|
|
105
|
+
taken
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
handles.each do |handle|
|
|
109
|
+
handle.close
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
LOGGER.warn("finalizer #{handle.class} raised #{e.class}: #{e.message}")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
at_exit { Pikuri::Finalizers.run! }
|
data/lib/pikuri/paths.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
# Standardized on-disk locations for pikuri's local state. Centralizes
|
|
7
|
+
# the XDG resolution so every component that caches to disk roots under
|
|
8
|
+
# one place instead of each re-deriving it — currently
|
|
9
|
+
# {Pikuri::VectorDb::Server::Chroma} (its corpus volume) and
|
|
10
|
+
# {Pikuri::Memory::Mem0Server} (its mem0 checkout + data volume).
|
|
11
|
+
module Paths
|
|
12
|
+
# Pikuri's cache root: +$XDG_CACHE_HOME/pikuri+ when +XDG_CACHE_HOME+
|
|
13
|
+
# is set and non-empty, else +~/.cache/pikuri+.
|
|
14
|
+
#
|
|
15
|
+
# A method, not a frozen constant, on purpose: a constant would
|
|
16
|
+
# snapshot +XDG_CACHE_HOME+ at +require+ time, which breaks
|
|
17
|
+
# env-stubbing in tests and ignores a runtime change in a long-lived
|
|
18
|
+
# process. The directory is *not* created — callers +mkdir_p+ the
|
|
19
|
+
# subdirectory they need (e.g. +Paths.cache.join('chroma')+,
|
|
20
|
+
# +Paths.cache.join('mem0')+).
|
|
21
|
+
#
|
|
22
|
+
# @return [Pathname] the +<cache home>/pikuri+ directory
|
|
23
|
+
def self.cache
|
|
24
|
+
home = ENV['XDG_CACHE_HOME']
|
|
25
|
+
home = File.expand_path('~/.cache') if home.nil? || home.empty?
|
|
26
|
+
Pathname.new(home).join('pikuri')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/pikuri/subprocess.rb
CHANGED
|
@@ -15,8 +15,10 @@ module Pikuri
|
|
|
15
15
|
# All subprocess spawning in +lib/+ goes through {.spawn}. Direct
|
|
16
16
|
# +Process.spawn+ / +Open3.*+ / +system+ / backticks anywhere in
|
|
17
17
|
# +lib/+ are bugs. The convention is grep-enforceable:
|
|
18
|
-
# +grep -
|
|
19
|
-
# only hit this file
|
|
18
|
+
# +grep -rnE 'Process\.spawn|Open3\.|\bsystem\(' lib/+ should
|
|
19
|
+
# only hit this file (plus the comment in
|
|
20
|
+
# +pikuri-mcp/lib/pikuri/mcp/servers.rb+ explaining the MCP
|
|
21
|
+
# exception).
|
|
20
22
|
#
|
|
21
23
|
# == Timeouts are the caller's job
|
|
22
24
|
#
|
|
@@ -47,10 +49,11 @@ module Pikuri
|
|
|
47
49
|
#
|
|
48
50
|
# == State is process-global
|
|
49
51
|
#
|
|
50
|
-
# One +@active+ Set
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
52
|
+
# One +@active+ Set for the whole process, swept once at exit via
|
|
53
|
+
# {Pikuri::Finalizers} (see the registration at the bottom of this
|
|
54
|
+
# file). A +Mutex+ guards register/prune/cleanup; v1 is
|
|
55
|
+
# single-threaded, so this is more for the exit-sweep/register race
|
|
56
|
+
# than for current callers.
|
|
54
57
|
#
|
|
55
58
|
# == Why +Pikuri::Subprocess+, not top-level
|
|
56
59
|
#
|
|
@@ -122,6 +125,23 @@ module Pikuri
|
|
|
122
125
|
self.class.send(:prune, @pgid)
|
|
123
126
|
end
|
|
124
127
|
|
|
128
|
+
# SIGTERM the whole process group without blocking for output —
|
|
129
|
+
# the stop button for a *daemon* child (one the caller never
|
|
130
|
+
# {#wait}s on, e.g. {Pikuri::Memory::Mem0Server}'s socat relay).
|
|
131
|
+
# Best-effort and idempotent: an already-dead group is a no-op.
|
|
132
|
+
# The group stays in the exit-sweep set until it actually dies,
|
|
133
|
+
# so a child that ignores SIGTERM is still re-signalled by
|
|
134
|
+
# {.cleanup!} at process exit.
|
|
135
|
+
#
|
|
136
|
+
# @return [void]
|
|
137
|
+
def terminate
|
|
138
|
+
Process.kill('-TERM', @pgid)
|
|
139
|
+
rescue Errno::ESRCH
|
|
140
|
+
# already gone
|
|
141
|
+
ensure
|
|
142
|
+
self.class.send(:prune, @pgid)
|
|
143
|
+
end
|
|
144
|
+
|
|
125
145
|
class << self
|
|
126
146
|
# Currently-tracked process groups, with dead ones pruned as a
|
|
127
147
|
# side effect. Useful for a future +/bg+ REPL command or a
|
|
@@ -135,9 +155,9 @@ module Pikuri
|
|
|
135
155
|
end
|
|
136
156
|
end
|
|
137
157
|
|
|
138
|
-
# SIGTERM every tracked process group.
|
|
139
|
-
# (production) and +after+ blocks
|
|
140
|
-
# ignores errors from already-dead groups.
|
|
158
|
+
# SIGTERM every tracked process group. Run at process exit via
|
|
159
|
+
# {Pikuri::Finalizers} (production) and from +after+ blocks
|
|
160
|
+
# (specs). Best-effort — ignores errors from already-dead groups.
|
|
141
161
|
#
|
|
142
162
|
# @return [void]
|
|
143
163
|
def cleanup!
|
|
@@ -170,4 +190,10 @@ module Pikuri
|
|
|
170
190
|
end
|
|
171
191
|
end
|
|
172
192
|
|
|
173
|
-
|
|
193
|
+
# Registered at load (boot) — before any agent or server registers at
|
|
194
|
+
# construction time — so in {Pikuri::Finalizers}' LIFO sweep this runs
|
|
195
|
+
# LAST: graceful +#close+ on agents and servers first, then SIGTERM
|
|
196
|
+
# whatever child groups are still alive. The reaper, not a peer. (Was a
|
|
197
|
+
# standalone +at_exit+; routed through Finalizers so the order relative
|
|
198
|
+
# to every other teardown is controlled, not left to file load order.)
|
|
199
|
+
Pikuri::Finalizers.register { Pikuri::Subprocess.cleanup! }
|
data/lib/pikuri/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pikuri-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Martin Vysny
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-06-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dentaku
|
|
@@ -200,6 +200,8 @@ files:
|
|
|
200
200
|
- lib/pikuri/agent/listener_list.rb
|
|
201
201
|
- lib/pikuri/agent/synthesizer.rb
|
|
202
202
|
- lib/pikuri/file_type.rb
|
|
203
|
+
- lib/pikuri/finalizers.rb
|
|
204
|
+
- lib/pikuri/paths.rb
|
|
203
205
|
- lib/pikuri/subprocess.rb
|
|
204
206
|
- lib/pikuri/tool.rb
|
|
205
207
|
- lib/pikuri/tool/calculator.rb
|