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 +7 -0
- data/lib/hames/context.rb +155 -0
- data/lib/hames/events.rb +54 -0
- data/lib/hames/loader.rb +123 -0
- data/lib/hames.rb +12 -0
- metadata +52 -0
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
|
data/lib/hames/events.rb
ADDED
|
@@ -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
|
data/lib/hames/loader.rb
ADDED
|
@@ -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: []
|