tsikol 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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/CONTRIBUTING.md +84 -0
  4. data/LICENSE +21 -0
  5. data/README.md +579 -0
  6. data/Rakefile +12 -0
  7. data/docs/README.md +69 -0
  8. data/docs/api/middleware.md +721 -0
  9. data/docs/api/prompt.md +858 -0
  10. data/docs/api/resource.md +651 -0
  11. data/docs/api/server.md +509 -0
  12. data/docs/api/test-helpers.md +591 -0
  13. data/docs/api/tool.md +527 -0
  14. data/docs/cookbook/authentication.md +651 -0
  15. data/docs/cookbook/caching.md +877 -0
  16. data/docs/cookbook/dynamic-tools.md +970 -0
  17. data/docs/cookbook/error-handling.md +887 -0
  18. data/docs/cookbook/logging.md +1044 -0
  19. data/docs/cookbook/rate-limiting.md +717 -0
  20. data/docs/examples/code-assistant.md +922 -0
  21. data/docs/examples/complete-server.md +726 -0
  22. data/docs/examples/database-manager.md +1198 -0
  23. data/docs/examples/devops-tools.md +1382 -0
  24. data/docs/examples/echo-server.md +501 -0
  25. data/docs/examples/weather-service.md +822 -0
  26. data/docs/guides/completion.md +472 -0
  27. data/docs/guides/getting-started.md +462 -0
  28. data/docs/guides/middleware.md +823 -0
  29. data/docs/guides/project-structure.md +434 -0
  30. data/docs/guides/prompts.md +920 -0
  31. data/docs/guides/resources.md +720 -0
  32. data/docs/guides/sampling.md +804 -0
  33. data/docs/guides/testing.md +863 -0
  34. data/docs/guides/tools.md +627 -0
  35. data/examples/README.md +92 -0
  36. data/examples/advanced_features.rb +129 -0
  37. data/examples/basic-migrated/app/prompts/weather_chat.rb +44 -0
  38. data/examples/basic-migrated/app/resources/weather_alerts.rb +18 -0
  39. data/examples/basic-migrated/app/tools/get_current_weather.rb +34 -0
  40. data/examples/basic-migrated/app/tools/get_forecast.rb +30 -0
  41. data/examples/basic-migrated/app/tools/get_weather_by_coords.rb +48 -0
  42. data/examples/basic-migrated/server.rb +25 -0
  43. data/examples/basic.rb +73 -0
  44. data/examples/full_featured.rb +175 -0
  45. data/examples/middleware_example.rb +112 -0
  46. data/examples/sampling_example.rb +104 -0
  47. data/examples/weather-service/app/prompts/weather/chat.rb +90 -0
  48. data/examples/weather-service/app/resources/weather/alerts.rb +59 -0
  49. data/examples/weather-service/app/tools/weather/get_current.rb +82 -0
  50. data/examples/weather-service/app/tools/weather/get_forecast.rb +90 -0
  51. data/examples/weather-service/server.rb +28 -0
  52. data/exe/tsikol +6 -0
  53. data/lib/tsikol/cli/templates/Gemfile.erb +10 -0
  54. data/lib/tsikol/cli/templates/README.md.erb +38 -0
  55. data/lib/tsikol/cli/templates/gitignore.erb +49 -0
  56. data/lib/tsikol/cli/templates/prompt.rb.erb +53 -0
  57. data/lib/tsikol/cli/templates/resource.rb.erb +29 -0
  58. data/lib/tsikol/cli/templates/server.rb.erb +24 -0
  59. data/lib/tsikol/cli/templates/tool.rb.erb +60 -0
  60. data/lib/tsikol/cli.rb +203 -0
  61. data/lib/tsikol/error_handler.rb +141 -0
  62. data/lib/tsikol/health.rb +198 -0
  63. data/lib/tsikol/http_transport.rb +72 -0
  64. data/lib/tsikol/lifecycle.rb +149 -0
  65. data/lib/tsikol/middleware.rb +168 -0
  66. data/lib/tsikol/prompt.rb +101 -0
  67. data/lib/tsikol/resource.rb +53 -0
  68. data/lib/tsikol/router.rb +190 -0
  69. data/lib/tsikol/server.rb +660 -0
  70. data/lib/tsikol/stdio_transport.rb +108 -0
  71. data/lib/tsikol/test_helpers.rb +261 -0
  72. data/lib/tsikol/tool.rb +111 -0
  73. data/lib/tsikol/version.rb +5 -0
  74. data/lib/tsikol.rb +72 -0
  75. metadata +219 -0
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ # Lifecycle management for servers
5
+ module Lifecycle
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def before_start(&block)
12
+ @before_start_hooks ||= []
13
+ @before_start_hooks << block if block_given?
14
+ @before_start_hooks
15
+ end
16
+
17
+ def after_start(&block)
18
+ @after_start_hooks ||= []
19
+ @after_start_hooks << block if block_given?
20
+ @after_start_hooks
21
+ end
22
+
23
+ def before_stop(&block)
24
+ @before_stop_hooks ||= []
25
+ @before_stop_hooks << block if block_given?
26
+ @before_stop_hooks
27
+ end
28
+
29
+ def after_stop(&block)
30
+ @after_stop_hooks ||= []
31
+ @after_stop_hooks << block if block_given?
32
+ @after_stop_hooks
33
+ end
34
+ end
35
+
36
+ # Instance methods
37
+ def run_before_start_hooks
38
+ log :debug, "Running before_start hooks"
39
+
40
+ self.class.before_start.each do |hook|
41
+ instance_eval(&hook)
42
+ end
43
+
44
+ @before_start_hooks&.each do |hook|
45
+ instance_eval(&hook)
46
+ end
47
+ end
48
+
49
+ def run_after_start_hooks
50
+ log :debug, "Running after_start hooks"
51
+
52
+ self.class.after_start.each do |hook|
53
+ instance_eval(&hook)
54
+ end
55
+
56
+ @after_start_hooks&.each do |hook|
57
+ instance_eval(&hook)
58
+ end
59
+ end
60
+
61
+ def run_before_stop_hooks
62
+ log :debug, "Running before_stop hooks"
63
+
64
+ self.class.before_stop.each do |hook|
65
+ instance_eval(&hook)
66
+ end
67
+
68
+ @before_stop_hooks&.each do |hook|
69
+ instance_eval(&hook)
70
+ end
71
+ end
72
+
73
+ def run_after_stop_hooks
74
+ log :debug, "Running after_stop hooks"
75
+
76
+ self.class.after_stop.each do |hook|
77
+ instance_eval(&hook)
78
+ end
79
+
80
+ @after_stop_hooks&.each do |hook|
81
+ instance_eval(&hook)
82
+ end
83
+ end
84
+
85
+ # DSL methods for instance-level hooks
86
+ def before_start(&block)
87
+ @before_start_hooks ||= []
88
+ @before_start_hooks << block
89
+ end
90
+
91
+ def after_start(&block)
92
+ @after_start_hooks ||= []
93
+ @after_start_hooks << block
94
+ end
95
+
96
+ def before_stop(&block)
97
+ @before_stop_hooks ||= []
98
+ @before_stop_hooks << block
99
+ end
100
+
101
+ def after_stop(&block)
102
+ @after_stop_hooks ||= []
103
+ @after_stop_hooks << block
104
+ end
105
+
106
+ # Tool-level hooks
107
+ def before_tool(name = nil, &block)
108
+ @before_tool_hooks ||= {}
109
+ if name
110
+ @before_tool_hooks[name] = block
111
+ else
112
+ @before_tool_hooks[:all] = block
113
+ end
114
+ end
115
+
116
+ def after_tool(name = nil, &block)
117
+ @after_tool_hooks ||= {}
118
+ if name
119
+ @after_tool_hooks[name] = block
120
+ else
121
+ @after_tool_hooks[:all] = block
122
+ end
123
+ end
124
+
125
+ def run_before_tool_hook(tool_name, params)
126
+ # Run specific hook
127
+ if @before_tool_hooks&.[](tool_name)
128
+ @before_tool_hooks[tool_name].call(params)
129
+ end
130
+
131
+ # Run general hook
132
+ if @before_tool_hooks&.[](:all)
133
+ @before_tool_hooks[:all].call(tool_name, params)
134
+ end
135
+ end
136
+
137
+ def run_after_tool_hook(tool_name, params, result)
138
+ # Run specific hook
139
+ if @after_tool_hooks&.[](tool_name)
140
+ @after_tool_hooks[tool_name].call(params, result)
141
+ end
142
+
143
+ # Run general hook
144
+ if @after_tool_hooks&.[](:all)
145
+ @after_tool_hooks[:all].call(tool_name, params, result)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ class Middleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ # Called before processing a request
10
+ def before_request(message)
11
+ # Default: pass through
12
+ message
13
+ end
14
+
15
+ # Called after processing a response
16
+ def after_response(response, original_message)
17
+ # Default: pass through
18
+ response
19
+ end
20
+
21
+ # Called on error
22
+ def on_error(error, message)
23
+ # Default: re-raise
24
+ raise error
25
+ end
26
+
27
+ def call(message)
28
+ # Pre-process request
29
+ processed_message = before_request(message)
30
+
31
+ # Call next middleware or handler
32
+ response = @app.call(processed_message)
33
+
34
+ # Post-process response
35
+ after_response(response, message)
36
+ rescue => e
37
+ on_error(e, message)
38
+ end
39
+ end
40
+
41
+ # Common middleware implementations
42
+
43
+ class LoggingMiddleware < Middleware
44
+ def initialize(app, logger: nil)
45
+ super(app)
46
+ @logger = logger
47
+ end
48
+
49
+ def before_request(message)
50
+ log :debug, "Request: #{message['method']}", data: { id: message['id'], params: message['params'] }
51
+ message
52
+ end
53
+
54
+ def after_response(response, original_message)
55
+ if response
56
+ status = response['error'] ? 'error' : 'success'
57
+ log :debug, "Response for #{original_message['method']} (#{status})",
58
+ data: { id: response['id'], error: response['error'] }
59
+ end
60
+ response
61
+ end
62
+
63
+ def on_error(error, message)
64
+ log :error, "Error processing #{message['method']}: #{error.message}",
65
+ data: { error: error.class.name, backtrace: error.backtrace.first(5) }
66
+ raise error
67
+ end
68
+
69
+ private
70
+
71
+ def log(level, message, data: nil)
72
+ if @logger && @logger.respond_to?(:log)
73
+ @logger.log(level, message, data: data)
74
+ else
75
+ puts "[#{level.upcase}] #{message} #{data ? "- #{data.inspect}" : ""}"
76
+ end
77
+ end
78
+ end
79
+
80
+ class RateLimitMiddleware < Middleware
81
+ def initialize(app, max_requests: 100, window: 60)
82
+ super(app)
83
+ @max_requests = max_requests
84
+ @window = window
85
+ @requests = {}
86
+ end
87
+
88
+ def before_request(message)
89
+ client_id = message["id"] # In real usage, identify client better
90
+ now = Time.now.to_i
91
+
92
+ # Clean old entries
93
+ @requests.delete_if { |_, time| now - time > @window }
94
+
95
+ # Check rate limit
96
+ client_requests = @requests.select { |id, _| id.to_s.start_with?(client_id.to_s[0..8]) }
97
+ if client_requests.size >= @max_requests
98
+ raise "Rate limit exceeded: #{@max_requests} requests per #{@window} seconds"
99
+ end
100
+
101
+ # Track request
102
+ @requests[message["id"]] = now
103
+
104
+ message
105
+ end
106
+ end
107
+
108
+ class AuthenticationMiddleware < Middleware
109
+ def initialize(app, &auth_block)
110
+ super(app)
111
+ @auth_block = auth_block
112
+ end
113
+
114
+ def before_request(message)
115
+ # Extract auth info (this is simplified - real auth would be more complex)
116
+ auth_info = message["metadata"] || {}
117
+
118
+ unless @auth_block.call(auth_info, message)
119
+ raise "Authentication failed"
120
+ end
121
+
122
+ message
123
+ end
124
+ end
125
+
126
+ class ValidationMiddleware < Middleware
127
+ def before_request(message)
128
+ # Validate JSON-RPC structure
129
+ unless message["jsonrpc"] == "2.0"
130
+ raise "Invalid JSON-RPC version"
131
+ end
132
+
133
+ unless message["method"]
134
+ raise "Missing method"
135
+ end
136
+
137
+ message
138
+ end
139
+ end
140
+
141
+ # Middleware stack manager
142
+ class MiddlewareStack
143
+ def initialize(base_handler)
144
+ @base_handler = base_handler
145
+ @middlewares = []
146
+ end
147
+
148
+ def use(middleware_class, *args, **kwargs, &block)
149
+ @middlewares << [middleware_class, args, kwargs, block]
150
+ self
151
+ end
152
+
153
+ def build
154
+ # Build middleware chain from inside out
155
+ @middlewares.reverse.reduce(@base_handler) do |app, (klass, args, kwargs, block)|
156
+ if block
157
+ klass.new(app, *args, **kwargs, &block)
158
+ else
159
+ klass.new(app, *args, **kwargs)
160
+ end
161
+ end
162
+ end
163
+
164
+ def call(message)
165
+ build.call(message)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ class Prompt
5
+ class << self
6
+ attr_reader :prompt_description, :arguments_config
7
+
8
+ def description(desc)
9
+ @prompt_description = desc
10
+ end
11
+
12
+ def argument(name, &block)
13
+ @arguments_config ||= {}
14
+ arg = ArgumentBuilder.new(name)
15
+ arg.instance_eval(&block) if block_given?
16
+ @arguments_config[name] = arg.build
17
+ end
18
+
19
+ def prompt_name
20
+ # Convert class name to prompt name
21
+ # Weather::Chat -> weather:chat
22
+ name.gsub('::', ':').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
23
+ end
24
+ end
25
+
26
+ def description
27
+ self.class.prompt_description || "Prompt: #{self.class.prompt_name}"
28
+ end
29
+
30
+ def arguments
31
+ self.class.arguments_config || {}
32
+ end
33
+
34
+ def generate(**args)
35
+ raise NotImplementedError, "Subclasses must implement generate method"
36
+ end
37
+
38
+ # Convert to MCP format
39
+ def to_mcp
40
+ {
41
+ name: self.class.prompt_name,
42
+ description: description,
43
+ arguments: build_arguments
44
+ }
45
+ end
46
+
47
+ # Get messages for MCP prompt
48
+ def get_messages(**args)
49
+ content = generate(**args)
50
+
51
+ [
52
+ {
53
+ role: "user",
54
+ content: {
55
+ type: "text",
56
+ text: content
57
+ }
58
+ }
59
+ ]
60
+ end
61
+
62
+ private
63
+
64
+ def build_arguments
65
+ arguments.map do |name, config|
66
+ {
67
+ name: name.to_s,
68
+ description: config[:description] || "Argument: #{name}",
69
+ required: config[:required] || false
70
+ }
71
+ end
72
+ end
73
+ end
74
+
75
+ class ArgumentBuilder
76
+ def initialize(name)
77
+ @name = name
78
+ @config = { name: name }
79
+ end
80
+
81
+ def required(val = true)
82
+ @config[:required] = val
83
+ end
84
+
85
+ def optional
86
+ @config[:required] = false
87
+ end
88
+
89
+ def description(desc)
90
+ @config[:description] = desc
91
+ end
92
+
93
+ def complete(&block)
94
+ @config[:completion] = block
95
+ end
96
+
97
+ def build
98
+ @config
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ class Resource
5
+ class << self
6
+ attr_reader :resource_description, :resource_uri, :resource_mime_type
7
+
8
+ def uri(u)
9
+ @resource_uri = u
10
+ end
11
+
12
+ def description(desc)
13
+ @resource_description = desc
14
+ end
15
+
16
+ def mime_type(type)
17
+ @resource_mime_type = type
18
+ end
19
+
20
+ def resource_name
21
+ # Convert class name to resource name
22
+ # Weather::Alerts -> weather/alerts
23
+ name.gsub('::', '/').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
24
+ end
25
+ end
26
+
27
+ def uri
28
+ self.class.resource_uri || self.class.resource_name
29
+ end
30
+
31
+ def description
32
+ self.class.resource_description || "Resource: #{uri}"
33
+ end
34
+
35
+ def mime_type
36
+ self.class.resource_mime_type || "text/plain"
37
+ end
38
+
39
+ def read
40
+ raise NotImplementedError, "Subclasses must implement read method"
41
+ end
42
+
43
+ # Convert to MCP format
44
+ def to_mcp
45
+ {
46
+ uri: uri,
47
+ name: uri,
48
+ description: description,
49
+ mimeType: mime_type
50
+ }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ class Router
5
+ attr_reader :tools, :resources, :prompts, :server
6
+
7
+ def initialize(server)
8
+ @server = server
9
+ @tools = {}
10
+ @resources = {}
11
+ @prompts = {}
12
+ end
13
+
14
+ # DSL methods for routes.rb
15
+ def tool(name_or_class, from: nil, &block)
16
+ if name_or_class.is_a?(Class)
17
+ # Direct class reference: tool GetCurrentWeather
18
+ tool_instance = name_or_class.new
19
+ @server.register_tool_instance(tool_instance)
20
+ elsif from
21
+ # Load from file with explicit path
22
+ load_tool_from_file(name_or_class, from)
23
+ elsif name_or_class.is_a?(String) && !block_given?
24
+ # Auto-infer from name: tool "get_current_weather"
25
+ inferred_path = name_or_class.gsub('-', '_')
26
+ load_tool_from_file(name_or_class, inferred_path)
27
+ elsif block_given?
28
+ # Inline definition (backward compatibility)
29
+ @server.tool(name_or_class, &block)
30
+ end
31
+ end
32
+
33
+ def resource(uri_or_class, from: nil, &block)
34
+ if uri_or_class.is_a?(Class)
35
+ # Direct class reference
36
+ resource_instance = uri_or_class.new
37
+ @server.register_resource_instance(resource_instance)
38
+ elsif from
39
+ # Load from file with explicit path
40
+ load_resource_from_file(uri_or_class, from)
41
+ elsif uri_or_class.is_a?(String) && !block_given?
42
+ # Auto-infer from URI: resource "weather/alerts" -> weather_alerts.rb
43
+ inferred_path = uri_or_class.gsub('/', '_').gsub('-', '_')
44
+ load_resource_from_file(uri_or_class, inferred_path)
45
+ elsif block_given?
46
+ # Inline definition
47
+ @server.resource(uri_or_class, &block)
48
+ end
49
+ end
50
+
51
+ def prompt(name_or_class, from: nil, &block)
52
+ if name_or_class.is_a?(Class)
53
+ # Direct class reference
54
+ prompt_instance = name_or_class.new
55
+ @server.register_prompt_instance(prompt_instance)
56
+ elsif from
57
+ # Load from file with explicit path
58
+ load_prompt_from_file(name_or_class, from)
59
+ elsif name_or_class.is_a?(String) && !block_given?
60
+ # Auto-infer from name: prompt "weather_chat"
61
+ inferred_path = name_or_class.gsub('-', '_')
62
+ load_prompt_from_file(name_or_class, inferred_path)
63
+ elsif block_given?
64
+ # Inline definition
65
+ @server.prompt(name_or_class, &block)
66
+ end
67
+ end
68
+
69
+ # Mount multiple items
70
+ def mount_tools(from:)
71
+ pattern = from.end_with?('/*') ? from : "#{from}/*"
72
+ base_path = "app/tools"
73
+
74
+ Dir.glob("#{base_path}/#{pattern}.rb").each do |file|
75
+ # Extract name from file path
76
+ name = file.sub("#{base_path}/", '').sub('.rb', '')
77
+ load_tool_from_file(nil, name)
78
+ end
79
+ end
80
+
81
+ def mount_resources(from:)
82
+ pattern = from.end_with?('/*') ? from : "#{from}/*"
83
+ base_path = "app/resources"
84
+
85
+ Dir.glob("#{base_path}/#{pattern}.rb").each do |file|
86
+ uri = file.sub("#{base_path}/", '').sub('.rb', '')
87
+ load_resource_from_file(nil, uri)
88
+ end
89
+ end
90
+
91
+ def mount_prompts(from:)
92
+ pattern = from.end_with?('/*') ? from : "#{from}/*"
93
+ base_path = "app/prompts"
94
+
95
+ Dir.glob("#{base_path}/#{pattern}.rb").each do |file|
96
+ name = file.sub("#{base_path}/", '').sub('.rb', '')
97
+ load_prompt_from_file(nil, name)
98
+ end
99
+ end
100
+
101
+ # Auto-discover all components
102
+ def auto_discover!
103
+ mount_tools(from: "**/*") if Dir.exist?("app/tools")
104
+ mount_resources(from: "**/*") if Dir.exist?("app/resources")
105
+ mount_prompts(from: "**/*") if Dir.exist?("app/prompts")
106
+ end
107
+
108
+ private
109
+
110
+ def load_tool_from_file(name, from)
111
+ file_path = "app/tools/#{from}.rb"
112
+
113
+ # Try to require the file
114
+ begin
115
+ require_relative "../../../#{file_path}"
116
+ rescue LoadError
117
+ # If relative path fails, try absolute
118
+ require File.expand_path(file_path)
119
+ end
120
+
121
+ # Convert file path to class name
122
+ # weather/get_current -> Weather::GetCurrent
123
+ class_name = from.split('/').map { |part|
124
+ part.split('_').map(&:capitalize).join
125
+ }.join('::')
126
+
127
+ # Try to get the class
128
+ klass = begin
129
+ Object.const_get(class_name)
130
+ rescue NameError
131
+ # If namespaced class not found, try without namespace
132
+ class_name.split('::').last.then { |n| Object.const_get(n) }
133
+ end
134
+
135
+ tool_instance = klass.new
136
+
137
+ # Register with server
138
+ @server.register_tool_instance(tool_instance, name: name)
139
+ end
140
+
141
+ def load_resource_from_file(uri, from)
142
+ file_path = "app/resources/#{from}.rb"
143
+
144
+ begin
145
+ require_relative "../../../#{file_path}"
146
+ rescue LoadError
147
+ require File.expand_path(file_path)
148
+ end
149
+
150
+ class_name = from.split('/').map { |part|
151
+ part.split('_').map(&:capitalize).join
152
+ }.join('::')
153
+
154
+ klass = begin
155
+ Object.const_get(class_name)
156
+ rescue NameError
157
+ class_name.split('::').last.then { |n| Object.const_get(n) }
158
+ end
159
+
160
+ resource_instance = klass.new
161
+
162
+ @server.register_resource_instance(resource_instance, uri: uri)
163
+ end
164
+
165
+ def load_prompt_from_file(name, from)
166
+ file_path = "app/prompts/#{from}.rb"
167
+
168
+ begin
169
+ require_relative "../../../#{file_path}"
170
+ rescue LoadError
171
+ require File.expand_path(file_path)
172
+ end
173
+
174
+ class_name = from.split('/').map { |part|
175
+ part.split('_').map(&:capitalize).join
176
+ }.join('::')
177
+
178
+ klass = begin
179
+ Object.const_get(class_name)
180
+ rescue NameError
181
+ class_name.split('::').last.then { |n| Object.const_get(n) }
182
+ end
183
+
184
+ prompt_instance = klass.new
185
+
186
+ @server.register_prompt_instance(prompt_instance, name: name)
187
+ end
188
+ end
189
+
190
+ end