hames 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6a98e47c0878e75c0da270c193f407260acc23f58614f81500c26095b837ebd9
4
+ data.tar.gz: 855c87d80768f619edd593bcfd98a176263a74fe1889aa70eb0b3b7ee021574e
5
+ SHA512:
6
+ metadata.gz: 7446507cced8c24120f7b76b0a8e8c3bf2ad75e286c0deed5e392058bcefcd04887e5ecc7391fd70d97b8a7e7fab12dacf0f67338f71a57a17b965b19477faaa
7
+ data.tar.gz: 69a3b325769844792ee3f71bfffe9e5a364b813722c3874b35c7333f7e4846f586271b4906fbbecb30277c170e28ebb3b3b26c69fccca37f5afbc9f2c623f6a3
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hames
4
+ # A Context is a repository of services and an event bus. Plugins mount into
5
+ # a context, claim service keys, register listeners, and install reversible
6
+ # effects. Forked contexts inherit parent services and listeners; their own
7
+ # registrations dispose independently (the per-agent scope primitive).
8
+ class Context
9
+ Listener = Data.define(:name, :block, :prepend, :owner)
10
+
11
+ attr_reader :parent
12
+
13
+ def initialize(parent: nil)
14
+ @parent = parent
15
+ @services = {}
16
+ @listeners = Hash.new { |h, k| h[k] = [] }
17
+ @effects = [] # frames: [owner, disposer] in registration order
18
+ @owner = nil # plugin id currently mounting (set by Loader)
19
+ end
20
+
21
+ # -- services -----------------------------------------------------------
22
+
23
+ def register_service(key, instance)
24
+ key = key.to_sym
25
+ raise ContractError, "service #{key} already registered" if @services.key?(key)
26
+
27
+ @services[key] = instance
28
+ effect { -> { @services.delete(key) } }
29
+ instance
30
+ end
31
+
32
+ def [](key)
33
+ key = key.to_sym
34
+ @services[key] || parent&.[](key) ||
35
+ raise(ServiceMissingError, "no service registered at ctx[:#{key}]")
36
+ end
37
+
38
+ def service?(key) = @services.key?(key.to_sym) || parent&.service?(key) || false
39
+
40
+ def method_missing(name, *args, &blk)
41
+ service?(name) && args.empty? && blk.nil? ? self[name] : super
42
+ end
43
+
44
+ def respond_to_missing?(name, include_private = false)
45
+ service?(name) || super
46
+ end
47
+
48
+ # -- effects (reversible registrations) ---------------------------------
49
+
50
+ # Runs the block now; the block must return a disposer callable (or nil).
51
+ # Disposal happens in reverse registration order, per owner, on unload.
52
+ def effect(&block)
53
+ disposer = block.call
54
+ @effects << [@owner, disposer] if disposer
55
+ disposer
56
+ end
57
+
58
+ def with_owner(owner)
59
+ prev, @owner = @owner, owner
60
+ yield
61
+ ensure
62
+ @owner = prev
63
+ end
64
+
65
+ # Dispose everything owned by `owner` (a plugin id), reverse order.
66
+ def dispose_owner!(owner)
67
+ kept = []
68
+ doomed = []
69
+ @effects.each { |fr| (fr[0] == owner ? doomed : kept) << fr }
70
+ @effects = kept
71
+ doomed.reverse_each { |(_o, d)| d.call }
72
+ end
73
+
74
+ # Dispose the whole context (child scopes call this when they end).
75
+ def dispose!
76
+ @effects.reverse_each { |(_o, d)| d.call }
77
+ @effects.clear
78
+ end
79
+
80
+ # -- events --------------------------------------------------------------
81
+
82
+ # Register a listener. Mode is validated against the event declaration.
83
+ # Returns a disposer and records it as an effect of the current owner.
84
+ def on(name, prepend: false, &block)
85
+ name = name.to_s
86
+ raise ContractError, "listener for undeclared event #{name}" unless Hames.declared?(name)
87
+
88
+ l = Listener.new(name:, block:, prepend:, owner: @owner)
89
+ bucket = @listeners[name]
90
+ prepend ? bucket.unshift(l) : bucket.push(l)
91
+ disposer = -> { bucket.delete(l) }
92
+ @effects << [@owner, disposer]
93
+ disposer
94
+ end
95
+
96
+ # Listeners visible to this context: parent chain first (registration
97
+ # order preserved within each context), respecting prepend within buckets.
98
+ def listeners_for(name)
99
+ own = @listeners[name.to_s]
100
+ parent ? parent.listeners_for(name) + own : own.dup
101
+ end
102
+
103
+ # emit: fire-and-forget, registration order, no return value.
104
+ def emit(name, *args)
105
+ Hames.assert_mode!(name, :emit)
106
+ listeners_for(name).each { |l| l.block.call(*args) }
107
+ nil
108
+ end
109
+
110
+ # waterfall: around-middleware. Each listener receives (*args, next_).
111
+ # Calling next_.(payload) delegates; returning without calling next_
112
+ # short-circuits. The innermost next_ returns its (possibly rewritten)
113
+ # payload — or calls the base block if one is given.
114
+ def waterfall(name, *args, &base)
115
+ Hames.assert_mode!(name, :waterfall)
116
+ chain = listeners_for(name)
117
+ invoke = lambda do |i, current_args|
118
+ if i >= chain.length
119
+ base ? base.call(*current_args) : current_args.first
120
+ else
121
+ next_ = ->(*rewritten) { invoke.call(i + 1, rewritten.empty? ? current_args : rewritten) }
122
+ chain[i].block.call(*current_args, next_)
123
+ end
124
+ end
125
+ invoke.call(0, args)
126
+ end
127
+
128
+ # parallel: all listeners observe the event; awaited as a group. Without
129
+ # a reactor this runs each in sequence but preserves the contract that
130
+ # dispatch completes only when every listener has. (Under terret's async
131
+ # runtime this maps onto an Async barrier.)
132
+ def parallel(name, *args)
133
+ Hames.assert_mode!(name, :parallel)
134
+ listeners_for(name).each { |l| l.block.call(*args) }
135
+ nil
136
+ end
137
+
138
+ # serial: ordered, awaited, single-decision. The first non-nil listener
139
+ # return value wins and stops dispatch.
140
+ def serial(name, *args)
141
+ Hames.assert_mode!(name, :serial)
142
+ listeners_for(name).each do |l|
143
+ result = l.block.call(*args)
144
+ return result unless result.nil?
145
+ end
146
+ nil
147
+ end
148
+
149
+ # -- fork ----------------------------------------------------------------
150
+
151
+ # Child scope: sees parent services and listeners; its own registrations
152
+ # dispose when the scope ends (or when its owner unloads).
153
+ def fork = Context.new(parent: self)
154
+ end
155
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hames
4
+ # Runtime event contracts. Every event is declared once with its dispatch
5
+ # mode (and optionally a durability flag and payload class). The bus refuses
6
+ # to dispatch undeclared events or to dispatch a declared event through the
7
+ # wrong mode. This is the Ruby replacement for Cordis's TypeScript
8
+ # declaration-merged event typing.
9
+ MODES = %i[emit waterfall parallel serial].freeze
10
+
11
+ EventDecl = Data.define(:name, :mode, :durable, :payload, :doc)
12
+
13
+ class << self
14
+ def events = (@events ||= {})
15
+
16
+ # Declare (or look up) an event contract.
17
+ #
18
+ # Hames.event "tools/pre_execute", mode: :waterfall, payload: Tools::Call
19
+ def event(name, mode: nil, durable: false, payload: nil, doc: nil)
20
+ name = name.to_s
21
+ return events.fetch(name) if mode.nil?
22
+
23
+ raise ArgumentError, "unknown dispatch mode #{mode.inspect}" unless MODES.include?(mode)
24
+ if (existing = events[name]) && existing.mode != mode
25
+ raise Hames::ContractError,
26
+ "event #{name} already declared with mode #{existing.mode}, got #{mode}"
27
+ end
28
+
29
+ events[name] = EventDecl.new(name:, mode:, durable:, payload:, doc:)
30
+ end
31
+
32
+ def declared?(name) = events.key?(name.to_s)
33
+
34
+ def assert_mode!(name, mode)
35
+ decl = events[name.to_s]
36
+ raise Hames::ContractError, "dispatch of undeclared event #{name}" unless decl
37
+ return if decl.mode == mode
38
+
39
+ raise Hames::ContractError,
40
+ "event #{name} is #{decl.mode}, dispatched as #{mode}"
41
+ end
42
+
43
+ # Generated documentation source: [ [name, mode, durable, doc], ... ]
44
+ def catalog
45
+ events.values.sort_by(&:name).map { |d| [d.name, d.mode, d.durable, d.doc] }
46
+ end
47
+
48
+ def reset_events! = events.clear # test hook
49
+ end
50
+
51
+ class ContractError < StandardError; end
52
+ class CycleError < StandardError; end
53
+ class ServiceMissingError < StandardError; end
54
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hames
4
+ # Base class for service plugins. A plugin may instead be any object that
5
+ # responds to apply(ctx) (the functional form), with optional #inject.
6
+ class Service
7
+ class << self
8
+ def service_key(key = nil)
9
+ @service_key = key.to_sym if key
10
+ @service_key
11
+ end
12
+
13
+ def inject(*keys)
14
+ @inject ||= []
15
+ @inject.concat(keys.map(&:to_sym)) unless keys.empty?
16
+ @inject
17
+ end
18
+ end
19
+
20
+ attr_reader :config
21
+
22
+ def initialize(config = {})
23
+ @config = config
24
+ end
25
+
26
+ def inject = self.class.inject
27
+
28
+ def apply(ctx)
29
+ ctx.register_service(self.class.service_key, self) if self.class.service_key
30
+ start(ctx)
31
+ end
32
+
33
+ def start(ctx); end
34
+ def stop(ctx); end
35
+ end
36
+
37
+ Row = Data.define(:id, :plugin, :config, :disabled) do
38
+ def self.build(h)
39
+ new(id: h.fetch(:id).to_s, plugin: h.fetch(:plugin),
40
+ config: h[:config] || {}, disabled: h[:disabled] || false)
41
+ end
42
+ end
43
+
44
+ # Mounts config rows into a context in dependency order derived from each
45
+ # plugin's inject list. Layers apply as ordered row lists; a later layer's
46
+ # row with an existing id replaces that row's config wholesale (never a
47
+ # deep merge); unknown ids append.
48
+ class Loader
49
+ attr_reader :ctx, :rows
50
+
51
+ def initialize(ctx = Context.new)
52
+ @ctx = ctx
53
+ @rows = {} # id => Row
54
+ @mounted = {} # id => plugin instance
55
+ end
56
+
57
+ def layer(row_hashes)
58
+ row_hashes.each do |h|
59
+ id = h.fetch(:id).to_s
60
+ if (existing = @rows[id])
61
+ # patch: wholesale config replacement; plugin class may also swap
62
+ @rows[id] = Hames::Row.new(
63
+ id: id,
64
+ plugin: h[:plugin] || existing.plugin,
65
+ config: h.key?(:config) ? (h[:config] || {}) : existing.config,
66
+ disabled: h.key?(:disabled) ? h[:disabled] : existing.disabled
67
+ )
68
+ else
69
+ @rows[id] = Row.build(h)
70
+ end
71
+ end
72
+ self
73
+ end
74
+
75
+ # Mount all enabled rows. Plugins whose injected services are not yet
76
+ # present wait; repeated passes mount whatever has become satisfiable.
77
+ # No progress with plugins still pending => cycle / missing provider.
78
+ def boot!
79
+ pending = @rows.values.reject(&:disabled).map { |r| [r, instantiate(r)] }
80
+ until pending.empty?
81
+ ready, pending = pending.partition { |(_r, pl)| satisfied?(pl) }
82
+ if ready.empty?
83
+ missing = pending.map { |(r, pl)| "#{r.id} (needs #{unmet(pl).join(', ')})" }
84
+ raise CycleError, "cannot mount: #{missing.join('; ')}"
85
+ end
86
+ ready.each { |(r, pl)| mount(r, pl) }
87
+ end
88
+ ctx
89
+ end
90
+
91
+ def unload!(id)
92
+ plugin = @mounted.delete(id) or raise ArgumentError, "no mounted plugin #{id}"
93
+ plugin.stop(ctx) if plugin.respond_to?(:stop)
94
+ ctx.dispose_owner!(id)
95
+ end
96
+
97
+ # Resolved tree, layer-agnostic view (for --dump-config).
98
+ def dump_config
99
+ @rows.values.map { |r| { id: r.id, plugin: r.plugin.to_s, config: r.config, disabled: r.disabled } }
100
+ end
101
+
102
+ private
103
+
104
+ def instantiate(row)
105
+ k = row.plugin
106
+ k.is_a?(Class) ? k.new(row.config) : k
107
+ end
108
+
109
+ def satisfied?(plugin)
110
+ unmet(plugin).empty?
111
+ end
112
+
113
+ def unmet(plugin)
114
+ needs = plugin.respond_to?(:inject) ? Array(plugin.inject) : []
115
+ needs.reject { |key| ctx.service?(key) }
116
+ end
117
+
118
+ def mount(row, plugin)
119
+ ctx.with_owner(row.id) { plugin.apply(ctx) }
120
+ @mounted[row.id] = plugin
121
+ end
122
+ end
123
+ end
data/lib/hames.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hames is Terret's plugin kernel: services in a context, typed events with
4
+ # four dispatch modes, reversible effects, and dependency-driven boot. It has
5
+ # no knowledge of LLMs and is reusable for any plugin-composed application.
6
+ module Hames
7
+ VERSION = "0.1.0"
8
+ end
9
+
10
+ require_relative "hames/events"
11
+ require_relative "hames/context"
12
+ require_relative "hames/loader"
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hames
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Obie Fernandez
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Hames is a small plugin kernel for Ruby: services resolved by key in
13
+ a context, typed events with four dispatch modes enforced at runtime, reversible
14
+ registrations, and dependency-driven boot. It has no knowledge of LLMs and is reusable
15
+ for any plugin-composed application. It is the kernel underneath the Terret agent
16
+ harness.'
17
+ email:
18
+ - obiefernandez@gmail.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - lib/hames.rb
24
+ - lib/hames/context.rb
25
+ - lib/hames/events.rb
26
+ - lib/hames/loader.rb
27
+ homepage: https://terret.org
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://terret.org
32
+ source_code_uri: https://github.com/terret-org/terret
33
+ bug_tracker_uri: https://github.com/terret-org/terret/issues
34
+ rubygems_mfa_required: 'true'
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '4.0'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 4.0.16
50
+ specification_version: 4
51
+ summary: 'Plugin kernel: services, typed events, reversible effects'
52
+ test_files: []