Commit b0bfc4a

mo khan <mo@mokhan.ca>
2026-08-27 07:20:17
refactor(gguf): extract Net::GGUF client (/simplify findings)
- Move the client class out of the plugin into lib/elelem/net/gguf.rb as Elelem::Net::GGUF, matching Net::Ollama/OpenAI/Claude; reduce plugins/gguf.rb to the standard Providers.register block. Fixes the reload defect (plugins are load()ed, so /reload re-executed the class and reassigned SHIM) and the path-coupling, and shelves the client where the siblings live. - Memoize the dlopen handle + Fiddle::Function bindings at class scope (like Net.http) rather than rebuilding them on every Client.new. - Drop the dead @close binding, fix the stale shim-path comment, and remove the no-op .clear lines (the locals already anchor the malloc'd buffers). Efficiency findings (per-call context/sampler/closure) are folded into the tool-calling work next, which rewrites those paths. Claude-Session: https://claude.ai/code/session_01UDKgb5gaG9Xmn3DViHRnJ7
Changed files (4)
lib/elelem/net/gguf.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "fiddle"
+
+module Elelem
+  module Net
+    # In-process GGUF client: loads a local model inside the elelem process via a
+    # thin C shim over llama.cpp (see ext/elelem_llama/elelem_llama.cpp), bound
+    # with stdlib Fiddle. No subprocess, no HTTP server, no third-party gem.
+    class GGUF
+      SHIM = File.expand_path("../native/libelelem_llama.so", __dir__)
+      V = Fiddle::TYPE_VOIDP
+      I = Fiddle::TYPE_INT
+
+      # dlopen + bindings are process-wide resources -- memoize once, like Net.http.
+      def self.functions
+        @functions ||= begin
+          lib = Fiddle.dlopen(SHIM)
+          {
+            open: Fiddle::Function.new(lib["el_open"], [V, I], V),
+            generate: Fiddle::Function.new(lib["el_generate"], [V, V, V, I, I, I, I, V], I)
+          }
+        end
+      end
+
+      def initialize(model_path:, n_ctx: 4096, n_threads: 16, max_tokens: 512)
+        @n_ctx = n_ctx
+        @n_threads = n_threads
+        @max_tokens = max_tokens
+        @model = self.class.functions[:open].call(model_path, 0) # 0 gpu layers = CPU (GPU later)
+        raise "gguf: failed to load model at #{model_path}" if @model.null?
+      end
+
+      # elelem provider contract: fetch(messages, tools=[]) { |event| } -> tool_calls
+      def fetch(messages, _tools = [], &block)
+        usable = messages.select { |m| m[:content] && !m[:content].to_s.empty? }
+        roles, keep_roles = str_array(usable.map { |m| role_of(m[:role]) })
+        contents, keep_contents = str_array(usable.map { |m| m[:content].to_s })
+
+        callback = Fiddle::Closure::BlockCaller.new(Fiddle::TYPE_VOID, [V]) do |cstr|
+          block&.call(type: "saying", text: Fiddle::Pointer.new(cstr).to_s)
+        end
+
+        fns = self.class.functions
+        fns[:generate].call(@model, roles, contents, usable.length,
+                            @n_ctx, @n_threads, @max_tokens, callback)
+        # keep_roles/keep_contents stay referenced through the native call above so
+        # GC can't free the malloc'd buffers mid-generation; they fall out of scope
+        # (and RUBY_FREE reclaims them) only now that the call has returned.
+        [] # tool-calling from the local model is deferred (phase 2)
+      end
+
+      private
+
+      def role_of(role)
+        r = role.to_s
+        %w[system user assistant].include?(r) ? r : "user"
+      end
+
+      # Build a C `const char**` from Ruby strings; returns [pointer_array, keep]
+      # where `keep` holds the per-string buffers so GC can't free them mid-call.
+      def str_array(strings)
+        cstrs = strings.map { |s| cstr(s) }
+        arr = Fiddle::Pointer.malloc([strings.size, 1].max * Fiddle::SIZEOF_VOIDP, Fiddle::RUBY_FREE)
+        cstrs.each_with_index do |ptr, i|
+          arr[i * Fiddle::SIZEOF_VOIDP, Fiddle::SIZEOF_VOIDP] = [ptr.to_i].pack("J")
+        end
+        [arr, cstrs]
+      end
+
+      def cstr(str)
+        bytes = "#{str}\0".b
+        ptr = Fiddle::Pointer.malloc(bytes.bytesize, Fiddle::RUBY_FREE)
+        ptr[0, bytes.bytesize] = bytes
+        ptr
+      end
+    end
+  end
+end
lib/elelem/plugins/gguf.rb
@@ -1,78 +1,8 @@
 # frozen_string_literal: true
 
-# In-process GGUF provider: loads a local model inside the elelem process via a
-# tiny C shim over llama.cpp (see ../gguf/elelem_llama.cpp), bound with stdlib
-# Fiddle. No subprocess, no HTTP server, no third-party gem.
-require "fiddle"
-
-module Elelem
-  module GGUF
-    SHIM = File.expand_path("../native/libelelem_llama.so", __dir__)
-    V = Fiddle::TYPE_VOIDP
-    I = Fiddle::TYPE_INT
-
-    class Client
-      def initialize(model_path:, n_ctx: 4096, n_threads: 16, max_tokens: 512)
-        @n_ctx = n_ctx
-        @n_threads = n_threads
-        @max_tokens = max_tokens
-
-        lib = Fiddle.dlopen(SHIM)
-        @open     = Fiddle::Function.new(lib["el_open"], [V, I], V)
-        @generate = Fiddle::Function.new(lib["el_generate"], [V, V, V, I, I, I, I, V], I)
-        @close    = Fiddle::Function.new(lib["el_close"], [V], Fiddle::TYPE_VOID)
-
-        @model = @open.call(model_path, 0) # n_gpu_layers 0 = CPU (GPU later)
-        raise "gguf: failed to load model at #{model_path}" if @model.null?
-      end
-
-      # elelem provider contract: fetch(messages, tools=[]) { |event| } -> tool_calls
-      def fetch(messages, _tools = [], &block)
-        usable = messages.select { |m| m[:content] && !m[:content].to_s.empty? }
-        roles, keep_roles = str_array(usable.map { |m| role_of(m[:role]) })
-        contents, keep_contents = str_array(usable.map { |m| m[:content].to_s })
-
-        callback = Fiddle::Closure::BlockCaller.new(Fiddle::TYPE_VOID, [V]) do |cstr|
-          block&.call(type: "saying", text: Fiddle::Pointer.new(cstr).to_s)
-        end
-
-        @generate.call(@model, roles, contents, usable.length,
-                       @n_ctx, @n_threads, @max_tokens, callback)
-        keep_roles.clear # hold the malloc'd strings alive until the call returns
-        keep_contents.clear
-        [] # tool-calling from the local model is deferred (phase 2)
-      end
-
-      private
-
-      def role_of(role)
-        r = role.to_s
-        %w[system user assistant].include?(r) ? r : "user"
-      end
-
-      # Build a C `const char**` from Ruby strings; returns [pointer_array, keep]
-      # where `keep` holds the per-string buffers so GC can't free them mid-call.
-      def str_array(strings)
-        cstrs = strings.map { |s| cstr(s) }
-        arr = Fiddle::Pointer.malloc([strings.size, 1].max * Fiddle::SIZEOF_VOIDP, Fiddle::RUBY_FREE)
-        cstrs.each_with_index do |ptr, i|
-          arr[i * Fiddle::SIZEOF_VOIDP, Fiddle::SIZEOF_VOIDP] = [ptr.to_i].pack("J")
-        end
-        [arr, cstrs]
-      end
-
-      def cstr(str)
-        bytes = "#{str}\0".b
-        ptr = Fiddle::Pointer.malloc(bytes.bytesize, Fiddle::RUBY_FREE)
-        ptr[0, bytes.bytesize] = bytes
-        ptr
-      end
-    end
-  end
-end
-
+# In-process GGUF provider (see Elelem::Net::GGUF in lib/elelem/net/gguf.rb).
 Elelem::Providers.register(:gguf) do
-  Elelem::GGUF::Client.new(
+  Elelem::Net::GGUF.new(
     model_path: ENV.fetch("GGUF_MODEL", File.expand_path("~/models/Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf")),
     n_ctx: Integer(ENV.fetch("GGUF_N_CTX", "4096")),
     n_threads: Integer(ENV.fetch("GGUF_THREADS", "16")),
lib/elelem/net.rb
@@ -3,6 +3,7 @@
 require_relative "net/ollama"
 require_relative "net/openai"
 require_relative "net/claude"
+require_relative "net/gguf"
 
 module Elelem
   module Net
elelem.gemspec
@@ -35,6 +35,7 @@ Gem::Specification.new do |spec|
     "lib/elelem/mcp/token_storage.rb",
     "lib/elelem/net.rb",
     "lib/elelem/net/claude.rb",
+    "lib/elelem/net/gguf.rb",
     "lib/elelem/net/ollama.rb",
     "lib/elelem/net/openai.rb",
     "lib/elelem/permissions.json",