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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f26e9b56204d1fbbaf64390e643a26c4183b3c21d45e3dcc43984c677a09f400
4
- data.tar.gz: e657171d9440ef19a53ae5ad9335c62ff34ea8db62c9753ce79352f699122f06
3
+ metadata.gz: 914f6e02052e97773e630e32bc9689bf7edc0eee4e962419aa41a15ca4afa667
4
+ data.tar.gz: e9a68c3813a0bbcd292757ee2cb4dbce6df7abc890e0d4afa6cf51cae77a6247
5
5
  SHA512:
6
- metadata.gz: 4f53aef4566f6218750c58b0cac4d1015d9ae2d2a1d464481ff4eabae3d42eb560559517a105ccd6fb2128e0b8e1b9393fdea257757dd6c85af0dc7a47683ed3
7
- data.tar.gz: ba4adbf32911a499111eaf26057622e94fa27511aaefb5f9fb705ac3471a20d860536d1717e6933ec3e2b55af152fbfb8b479f99e11e44ea9ad907dd1c666a66
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
- def initialize(override:, ruby_llm_reported:, llama_probe_url:)
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
- response = Faraday.new(
87
- request: { open_timeout: OPEN_TIMEOUT, timeout: READ_TIMEOUT }
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
- return warn_and_nil("HTTP #{response.status} from #{@llama_probe_url}") unless response.status == 200
109
+ n_ctx = positive_n_ctx(data)
110
+ return n_ctx if n_ctx
93
111
 
94
- data = JSON.parse(response.body)
95
- n_ctx = data.dig('default_generation_settings', 'n_ctx')
96
- return n_ctx if n_ctx.is_a?(Integer) && n_ctx.positive?
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
- size = @mutex.synchronize { @items.size }
151
- size.zero? ? 'Interloper' : "Interloper(#{size} pending)"
158
+ pending = size
159
+ pending.zero? ? 'Interloper' : "Interloper(#{pending} pending)"
152
160
  end
153
161
  end
154
162
  end
@@ -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 two hooks
9
- # on each — {#configure} during the block, {#bind} once the
10
- # agent is fully constructed.
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 both hooks; override the ones you
14
- # need. Extensions that don't +include+ this module still work
15
- # if they define both methods themselves (the Agent and
16
- # Configurator call them by name) the module exists to make
17
- # the protocol *explicit* and to give "I want to implement just
18
- # +configure+" extensions a free no-op +bind+ (and vice versa).
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::UserTurn} is intentionally silent
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, then emit an {Event::UserTurn} with
220
- # +mid_loop: true+ to the listener stream so renderers see
221
- # the injection.
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
- # Single Configurator funnel for everything the block adds —
386
- # tools, listeners, system-prompt snippets, extensions, and
387
- # on_close handlers. See {Configurator} for the per-method
388
- # contract.
389
- configurator = Configurator.new(
390
- transport: @transport,
391
- system_prompt_base: system_prompt,
392
- id: @id,
393
- streaming: @streaming,
394
- step_limit: @step_limit,
395
- cancellable: @cancellable,
396
- interloper: @interloper
397
- )
398
-
399
- block&.call(configurator)
400
-
401
- @tools = configurator.tools.dup
402
- @sub_agent_tools = configurator.sub_agent_tools.dup
403
- @listeners = ListenerList.new(configurator.listeners)
404
- configurator.system_prompt_additions.each do |snippet|
405
- @system_prompt = "#{@system_prompt}\n\n#{snippet}"
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.ask(user_message, &self.class.streaming_block(listeners: @listeners, cancellable: @cancellable))
597
+ @chat.complete(&self.class.streaming_block(listeners: @listeners, cancellable: @cancellable))
609
598
  else
610
- @chat.ask(user_message)
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
@@ -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! }
@@ -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
@@ -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 -rn 'Process\.spawn\|Open3\|system\|backtick' lib/+ should
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 and one +at_exit+ for the whole process. A
51
- # +Mutex+ guards register/prune/cleanup; v1 is single-threaded, so
52
- # this is more for the +at_exit+/register race than for current
53
- # callers.
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. Used by +at_exit+
139
- # (production) and +after+ blocks (specs). Best-effort —
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
- at_exit { Pikuri::Subprocess.cleanup! }
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! }
@@ -6,5 +6,5 @@ module Pikuri
6
6
  # additions to the public surface (+Pikuri::Tool+ / +Pikuri::Agent+ /
7
7
  # listeners / bundled tools), major for breaking changes to that
8
8
  # surface or to the +bin/pikuri-*+ CLIs.
9
- VERSION = '0.0.4'
9
+ VERSION = '0.0.5'
10
10
  end
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
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-05-29 00:00:00.000000000 Z
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