tama-rb 0.1.2

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.
data/lib/tama/http.rb ADDED
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "faraday"
5
+ require "faraday/retry"
6
+ require "json"
7
+ require "uri"
8
+
9
+ module Tama
10
+ class HTTP
11
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
12
+
13
+ attr_reader :base_uri, :connection
14
+
15
+ def initialize(base_url:, headers:, timeout:, retries:, connection: nil)
16
+ @base_uri = parse_base_url(base_url)
17
+ @headers = Faraday::Utils::Headers.new(headers)
18
+ @timeout = validate_timeout(timeout)
19
+ @retries = validate_retries(retries)
20
+ unless connection.nil? || connection.respond_to?(:run_request)
21
+ raise Error::ConfigurationError, "connection must be a Faraday-compatible connection"
22
+ end
23
+
24
+ @connection = connection || build_connection
25
+ end
26
+
27
+ def namespace!
28
+ segments = base_uri.path.split("/").reject(&:empty?)
29
+ raise Error::ConfigurationError, "Base URL must end with an operation namespace" if segments.empty?
30
+
31
+ segments.last
32
+ end
33
+
34
+ def validate_namespace!(*allowed)
35
+ namespace = namespace!
36
+ return if allowed.include?(namespace)
37
+
38
+ raise Error::InvalidNamespaceError,
39
+ "Invalid client namespace. Expected one of #{allowed.inspect}, got #{namespace.inspect}"
40
+ end
41
+
42
+ def request(method, path, body: nil, query: nil, headers: {}, timeout: nil, &on_data)
43
+ connection.run_request(method, absolute_url(path), body, merged_headers(headers)) do |request|
44
+ request.options.timeout = validate_timeout(timeout || @timeout)
45
+ request.options.on_data = on_data if on_data
46
+ query&.each_pair { |key, value| request.params[key] = value }
47
+ end
48
+ rescue Error
49
+ raise
50
+ rescue Faraday::Error => e
51
+ raise Error::TransportError.new(e.message, cause: e)
52
+ end
53
+
54
+ def parse_data(response, model, list: false)
55
+ check_status!(response)
56
+ document = parse_json(response.body)
57
+ raise Error::ParseError, "Response JSON must be an object with a data key" unless document.is_a?(Hash) && document.key?("data")
58
+
59
+ data = document["data"]
60
+ if list
61
+ raise Error::ParseError, "Response data must be an array" unless data.is_a?(Array)
62
+
63
+ data.map { |item| model.parse(item) }
64
+ else
65
+ model.parse(data)
66
+ end
67
+ end
68
+
69
+ def parse_body(response, model)
70
+ check_status!(response)
71
+ model.parse(parse_json(response.body))
72
+ end
73
+
74
+ def check_status!(response)
75
+ status = response.status.to_i
76
+ return response if status.between?(200, 299)
77
+
78
+ body = parse_error_body(response.body)
79
+ raise Error::NotFoundError.new(body:) if status == 404
80
+ if status == 422
81
+ errors = (body.is_a?(Hash) && body.key?("errors")) ? body["errors"] : body
82
+ raise Error::ValidationError.new("API validation failed", errors:, status:)
83
+ end
84
+
85
+ raise Error::HTTPError.new(status:, body:)
86
+ end
87
+
88
+ def self.segment(value, name)
89
+ unless value.is_a?(String) && !value.empty?
90
+ raise Error::ValidationError.new("#{name} must be a non-empty string", errors: {name => "must be a non-empty string"})
91
+ end
92
+
93
+ CGI.escape(value).gsub("+", "%20")
94
+ end
95
+
96
+ private
97
+
98
+ def parse_base_url(value)
99
+ raise Error::ConfigurationError, "base_url must be a string" unless value.is_a?(String) && !value.empty?
100
+
101
+ uri = URI.parse(value)
102
+ unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty?
103
+ raise Error::ConfigurationError, "base_url must be an absolute HTTP(S) URL"
104
+ end
105
+ unless uri.port.between?(1, 65_535)
106
+ raise Error::ConfigurationError, "base_url port must be between 1 and 65535"
107
+ end
108
+
109
+ uri.path = "/" if uri.path.empty?
110
+ uri
111
+ rescue URI::InvalidURIError => e
112
+ raise Error::ConfigurationError, "Invalid base_url: #{e.message}"
113
+ end
114
+
115
+ def validate_timeout(value)
116
+ return value if value.is_a?(Numeric) && value.positive?
117
+
118
+ raise Error::ConfigurationError, "timeout must be a positive number"
119
+ end
120
+
121
+ def validate_retries(value)
122
+ return value if value.is_a?(Integer) && value >= 0
123
+
124
+ raise Error::ConfigurationError, "retries must be a non-negative integer"
125
+ end
126
+
127
+ def build_connection
128
+ Faraday.new do |faraday|
129
+ faraday.request :json
130
+ faraday.request :retry, max: @retries, retry_statuses: RETRY_STATUSES
131
+ faraday.adapter Faraday.default_adapter
132
+ end
133
+ end
134
+
135
+ def absolute_url(path)
136
+ uri = base_uri.dup
137
+ base_path = uri.path.sub(%r{/+\z}, "")
138
+ endpoint = path.sub(%r{\A/+}, "")
139
+ uri.path = "#{base_path}/#{endpoint}"
140
+ uri.query = nil
141
+ uri.fragment = nil
142
+ uri.to_s
143
+ end
144
+
145
+ def merged_headers(headers)
146
+ @headers.merge(headers).tap do |merged|
147
+ merged["Accept"] ||= "application/json"
148
+ end
149
+ end
150
+
151
+ def parse_json(body)
152
+ return body if body.is_a?(Hash) || body.is_a?(Array)
153
+ raise Error::ParseError, "Response body must contain JSON" unless body.is_a?(String) && !body.empty?
154
+
155
+ JSON.parse(body)
156
+ rescue JSON::ParserError => e
157
+ raise Error::ParseError, "Malformed JSON response: #{e.message}"
158
+ end
159
+
160
+ def parse_error_body(body)
161
+ return body unless body.is_a?(String)
162
+ return body if body.empty?
163
+
164
+ JSON.parse(body)
165
+ rescue JSON::ParserError
166
+ body
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Memory
5
+ Entity = Model.define(
6
+ :id, :class_id, :current_state, :identifier,
7
+ required: %i[class_id current_state identifier],
8
+ types: {id: String, class_id: String, current_state: String, identifier: String}
9
+ )
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Memory
5
+ EntityParams = Data.define(:identifier, :record, :validate_record) do
6
+ def initialize(identifier:, record:, validate_record: true)
7
+ unless identifier.is_a?(String) && !identifier.empty?
8
+ raise Error::ValidationError.new("identifier is required", errors: {identifier: "must be a non-empty string"})
9
+ end
10
+ unless record.is_a?(Hash)
11
+ raise Error::ValidationError.new("record is required", errors: {record: "must be a hash"})
12
+ end
13
+ unless [true, false].include?(validate_record)
14
+ raise Error::ValidationError.new("validate_record must be boolean", errors: {validate_record: "must be boolean"})
15
+ end
16
+
17
+ super
18
+ end
19
+
20
+ def self.parse(value)
21
+ data = Params.hash(value, "entity")
22
+ new(
23
+ identifier: data["identifier"],
24
+ record: data["record"],
25
+ validate_record: data.fetch("validate_record", true)
26
+ )
27
+ end
28
+
29
+ def to_h
30
+ {"identifier" => identifier, "record" => record, "validate_record" => validate_record}
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Memory
5
+ class Service
6
+ def initialize(http)
7
+ @http = http
8
+ end
9
+
10
+ def create_entity(klass, attributes = nil, headers: {}, timeout: nil, **attribute_keywords)
11
+ @http.validate_namespace!("ingest")
12
+ class_id = klass.is_a?(Neural::Klass) ? klass.id : klass
13
+ attributes ||= attribute_keywords
14
+ params = attributes.is_a?(EntityParams) ? attributes : EntityParams.parse(attributes)
15
+ path = "memory/classes/#{HTTP.segment(class_id, "class_id")}/entities"
16
+ response = @http.request(:post, path, body: {"entity" => params.to_h}, headers:, timeout:)
17
+ @http.parse_data(response, Entity)
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/tama/model.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tama
6
+ module Model
7
+ module_function
8
+
9
+ def define(*fields, required: [], types: {}, defaults: {}, transforms: {})
10
+ Class.new(Data.define(*fields)) do
11
+ define_singleton_method(:fields) { fields }
12
+
13
+ define_method(:initialize) do |**attributes|
14
+ unknown = attributes.keys - fields
15
+ unless unknown.empty?
16
+ raise Tama::Error::ValidationError.new("Unknown attributes: #{unknown.join(", ")}", errors: {attributes: unknown})
17
+ end
18
+
19
+ values = fields.to_h { |field| [field, attributes.fetch(field, defaults.fetch(field, nil))] }
20
+ Tama::Model.validate!(values, required:, types:, name: self.class.name)
21
+ super(**values)
22
+ end
23
+
24
+ define_singleton_method(:parse) do |input|
25
+ data = Tama::Model.hash(input)
26
+ values = fields.to_h do |field|
27
+ value = data.key?(field.to_s) ? data[field.to_s] : data[field]
28
+ value = transforms.fetch(field).call(value) if !value.nil? && transforms.key?(field)
29
+ [field, value.nil? ? defaults.fetch(field, nil) : value]
30
+ end
31
+
32
+ new(**values)
33
+ rescue Tama::Error::ParseError
34
+ raise
35
+ rescue Tama::Error => e
36
+ raise Tama::Error::ParseError, "Invalid #{name || "model"}: #{e.message}"
37
+ rescue => e
38
+ raise Tama::Error::ParseError, "Invalid #{name || "model"}: #{e.message}"
39
+ end
40
+ end
41
+ end
42
+
43
+ def hash(input)
44
+ input = JSON.parse(input) if input.is_a?(String)
45
+ raise Tama::Error::ParseError, "Expected a JSON object" unless input.is_a?(Hash)
46
+
47
+ input
48
+ rescue JSON::ParserError => e
49
+ raise Tama::Error::ParseError, "Malformed JSON: #{e.message}"
50
+ end
51
+
52
+ def validate!(values, required:, types:, name:)
53
+ missing = required.select { |field| blank?(values[field]) }
54
+ unless missing.empty?
55
+ raise Tama::Error::ValidationError.new("Missing required fields: #{missing.join(", ")}", errors: {required: missing})
56
+ end
57
+
58
+ types.each do |field, expected|
59
+ value = values[field]
60
+ next if value.nil? || valid_type?(value, expected)
61
+
62
+ raise Tama::Error::ValidationError.new(
63
+ "#{field} has an invalid type for #{name || "model"}",
64
+ errors: {field => "must be #{type_name(expected)}"}
65
+ )
66
+ end
67
+ end
68
+
69
+ def blank?(value)
70
+ value.nil? || (value.is_a?(String) && value.empty?)
71
+ end
72
+
73
+ def valid_type?(value, expected)
74
+ Array(expected).any? { |type| value.is_a?(type) }
75
+ end
76
+
77
+ def type_name(expected)
78
+ Array(expected).join(" or ")
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Neural
5
+ Klass = Model.define(
6
+ :id, :space_id, :provision_state, :schema, :name, :description,
7
+ required: %i[provision_state name],
8
+ types: {
9
+ id: String, space_id: String, provision_state: String, schema: Hash,
10
+ name: String, description: String
11
+ }
12
+ )
13
+ end
14
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Neural
5
+ Operation = Model.define(
6
+ :id, :current_state, :class_id, :node_ids,
7
+ required: %i[current_state class_id],
8
+ types: {id: String, current_state: String, class_id: String, node_ids: Array},
9
+ transforms: {
10
+ node_ids: lambda do |value|
11
+ unless value.is_a?(Array) && value.all? { |node_id| node_id.is_a?(String) }
12
+ raise Error::ValidationError, "node_ids must be an array of strings"
13
+ end
14
+
15
+ value
16
+ end
17
+ }
18
+ )
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Neural
5
+ OperationParams = Data.define(:chain_ids, :node_type) do
6
+ def initialize(chain_ids:, node_type: nil)
7
+ unless chain_ids.is_a?(Array) && !chain_ids.empty? && chain_ids.all? { |id| id.is_a?(String) && !id.empty? }
8
+ raise Error::ValidationError.new("chain_ids must contain at least one chain ID", errors: {chain_ids: "must contain non-empty strings"})
9
+ end
10
+ unless node_type.nil? || node_type.is_a?(String)
11
+ raise Error::ValidationError.new("node_type must be a string", errors: {node_type: "must be a string"})
12
+ end
13
+
14
+ super
15
+ end
16
+
17
+ def self.parse(value)
18
+ data = Params.hash(value, "operation")
19
+ new(chain_ids: data["chain_ids"], node_type: data["node_type"])
20
+ end
21
+
22
+ def to_h
23
+ {"chain_ids" => chain_ids}.tap { |body| body["node_type"] = node_type unless node_type.nil? }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Neural
5
+ class Service
6
+ def initialize(http)
7
+ @http = http
8
+ end
9
+
10
+ def get_space(slug, headers: {}, timeout: nil)
11
+ @http.validate_namespace!("provision")
12
+ path = "neural/spaces/#{HTTP.segment(slug, "slug")}"
13
+ @http.parse_data(@http.request(:get, path, headers:, timeout:), Space)
14
+ end
15
+
16
+ def get_class(space, name, headers: {}, timeout: nil)
17
+ @http.validate_namespace!("provision")
18
+ space_id = space.is_a?(Space) ? space.id : space
19
+ path = "neural/spaces/#{HTTP.segment(space_id, "space_id")}/classes/#{HTTP.segment(name, "name")}"
20
+ @http.parse_data(@http.request(:get, path, headers:, timeout:), Klass)
21
+ end
22
+
23
+ def create_class_operation(klass, attributes = nil, headers: {}, timeout: nil, **attribute_keywords)
24
+ @http.validate_namespace!("provision")
25
+ class_id = klass.is_a?(Klass) ? klass.id : klass
26
+ attributes ||= attribute_keywords
27
+ params = attributes.is_a?(OperationParams) ? attributes : OperationParams.parse(attributes)
28
+ path = "neural/classes/#{HTTP.segment(class_id, "class_id")}/operations"
29
+ response = @http.request(:post, path, body: {"operation" => params.to_h}, headers:, timeout:)
30
+ @http.parse_data(response, Operation)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Neural
5
+ Space = Model.define(
6
+ :id, :name, :slug, :type, :provision_state,
7
+ required: %i[name type provision_state],
8
+ types: {id: String, name: String, slug: String, type: String, provision_state: String}
9
+ )
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Params
5
+ module_function
6
+
7
+ def hash(value, name)
8
+ value = value.to_h if value.respond_to?(:to_h) && !value.is_a?(Hash)
9
+ raise Error::ValidationError.new("#{name} must be a hash", errors: {name => "must be a hash"}) unless value.is_a?(Hash)
10
+
11
+ value.transform_keys(&:to_s)
12
+ end
13
+
14
+ def require_string!(data, key)
15
+ value = data[key]
16
+ return value if value.is_a?(String) && !value.empty?
17
+
18
+ raise Error::ValidationError.new("#{key} is required", errors: {key => "must be a non-empty string"})
19
+ end
20
+
21
+ def optional_string!(data, key)
22
+ value = data[key]
23
+ return if value.nil?
24
+ return value if value.is_a?(String)
25
+
26
+ raise Error::ValidationError.new("#{key} is invalid", errors: {key => "must be a string"})
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Perception
5
+ Chain = Model.define(
6
+ :id, :space_id, :name, :slug, :provision_state,
7
+ required: %i[name provision_state],
8
+ types: {id: String, space_id: String, name: String, slug: String, provision_state: String}
9
+ )
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Perception
5
+ Concept = Model.define(
6
+ :id, :relation, :content, :generator,
7
+ required: %i[id relation content],
8
+ types: {id: String, relation: String, content: Hash, generator: Generator},
9
+ transforms: {generator: ->(value) { Generator.parse(value) }}
10
+ )
11
+
12
+ class << Concept
13
+ alias_method :parse_one, :parse
14
+
15
+ def parse(input)
16
+ return input.map { |item| parse_one(item) } if input.is_a?(Array)
17
+
18
+ parse_one(input)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Perception
5
+ Generator = Model.define(
6
+ :type, :reference, :parameters,
7
+ required: %i[type reference parameters],
8
+ types: {type: Symbol, reference: String, parameters: Hash},
9
+ transforms: {
10
+ type: lambda do |value|
11
+ type = value.to_sym
12
+ raise Error::ValidationError, "generator type must be model or module" unless %i[model module].include?(type)
13
+
14
+ type
15
+ end
16
+ }
17
+ )
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ module Perception
5
+ class Service
6
+ def initialize(http)
7
+ @http = http
8
+ end
9
+
10
+ def get_chain(space, slug, headers: {}, timeout: nil)
11
+ @http.validate_namespace!("provision")
12
+ space_id = space.is_a?(Neural::Space) ? space.id : space
13
+ path = "perception/spaces/#{HTTP.segment(space_id, "space_id")}/chains/#{HTTP.segment(slug, "slug")}"
14
+ @http.parse_data(@http.request(:get, path, headers:, timeout:), Chain)
15
+ end
16
+
17
+ def list_concepts(entity_id, query: {}, headers: {}, timeout: nil)
18
+ @http.validate_namespace!("perception")
19
+ unless query.respond_to?(:each_pair)
20
+ raise Error::ValidationError.new("query must be a hash", errors: {query: "must be a hash"})
21
+ end
22
+
23
+ path = "entities/#{HTTP.segment(entity_id, "entity_id")}/concepts"
24
+ response = @http.request(:get, path, query:, headers:, timeout:)
25
+ @http.parse_data(response, Concept, list: true)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tama
6
+ class SSEParser
7
+ def initialize(callback)
8
+ @callback = callback
9
+ @buffer = +""
10
+ @done = false
11
+ end
12
+
13
+ def feed(chunk)
14
+ return if @done
15
+ raise Error::ParseError, "SSE chunk must be a string" unless chunk.is_a?(String)
16
+
17
+ @buffer << chunk
18
+ consume_complete_events
19
+ self
20
+ end
21
+
22
+ def finish
23
+ process_event(@buffer) unless @done || @buffer.empty?
24
+ @buffer.clear
25
+ self
26
+ end
27
+
28
+ private
29
+
30
+ def consume_complete_events
31
+ while (match = @buffer.match(/\r\n\r\n|\n\n|\r\r/))
32
+ event = @buffer.slice!(0, match.begin(0))
33
+ @buffer.slice!(0, match[0].bytesize)
34
+ process_event(event)
35
+ break if @done
36
+ end
37
+ end
38
+
39
+ def process_event(event)
40
+ data = event.split(/\r\n|\n|\r/).filter_map do |line|
41
+ next unless line.start_with?("data:")
42
+
43
+ value = line.delete_prefix("data:")
44
+ value = value.delete_prefix(" ")
45
+ value
46
+ end.join("\n")
47
+
48
+ return if data.empty?
49
+
50
+ if data == "[DONE]"
51
+ @done = true
52
+ return
53
+ end
54
+
55
+ @callback.call(JSON.parse(data))
56
+ rescue JSON::ParserError => e
57
+ raise Error::ParseError, "Malformed SSE data: #{e.message}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tama
4
+ VERSION = "0.1.2"
5
+ end
data/lib/tama.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tama/version"
4
+ require_relative "tama/error"
5
+ require_relative "tama/model"
6
+ require_relative "tama/params"
7
+ require_relative "tama/neural/space"
8
+ require_relative "tama/neural/klass"
9
+ require_relative "tama/neural/operation"
10
+ require_relative "tama/neural/operation_params"
11
+ require_relative "tama/memory/entity"
12
+ require_relative "tama/memory/entity_params"
13
+ require_relative "tama/perception/chain"
14
+ require_relative "tama/perception/generator"
15
+ require_relative "tama/perception/concept"
16
+ require_relative "tama/agentic/author"
17
+ require_relative "tama/agentic/thread"
18
+ require_relative "tama/agentic/message_params"
19
+ require_relative "tama/agentic/message"
20
+ require_relative "tama/broadcast"
21
+ require_relative "tama/sse_parser"
22
+ require_relative "tama/http"
23
+ require_relative "tama/neural/service"
24
+ require_relative "tama/memory/service"
25
+ require_relative "tama/perception/service"
26
+ require_relative "tama/agentic/service"
27
+ require_relative "tama/client"