aihype 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.
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'time'
5
+ require 'kramdown'
6
+ require_relative 'memory_file'
7
+ require_relative '../defaults'
8
+
9
+ module AIHype
10
+ class InvalidMemoryFileError < StandardError; end
11
+
12
+ class Memory
13
+ SEMVER_REGEX = /^\d+\.\d+\.\d+$/
14
+
15
+ def self.load(file_path)
16
+ content = File.read(file_path)
17
+ parse(content)
18
+ rescue Errno::ENOENT
19
+ nil
20
+ end
21
+
22
+ def self.parse(content)
23
+ parts = content.split(/^---$/)
24
+ raise InvalidMemoryFileError, 'Missing YAML frontmatter' if parts.length < 3
25
+
26
+ yaml_content = parts[1].strip
27
+ markdown_content = parts[2..].join('---').strip
28
+
29
+ frontmatter = YAML.safe_load(yaml_content, permitted_classes: [Time])
30
+ validate_frontmatter!(frontmatter)
31
+
32
+ default_rules, user_rules = extract_rules(markdown_content)
33
+
34
+ MemoryFile.new(
35
+ version: frontmatter['version'],
36
+ created: parse_datetime(frontmatter['created']),
37
+ updated: parse_datetime(frontmatter['updated']),
38
+ default_rules: default_rules,
39
+ user_rules: user_rules
40
+ )
41
+ end
42
+
43
+ def self.create_default
44
+ now = Time.now.utc
45
+ safety_rules = Defaults::SAFETY_RULES.map { |rule| "- #{rule}" }.join("\n")
46
+
47
+ <<~MARKDOWN
48
+ ---
49
+ version: #{Defaults::DEFAULT_VERSION}
50
+ created: #{now.iso8601}
51
+ updated: #{now.iso8601}
52
+ ---
53
+
54
+ # AIHype Blacklist Rules
55
+
56
+ ## Default Safety Rules
57
+ #{safety_rules}
58
+
59
+ ## User Rules
60
+ - #{Defaults::USER_RULES_PLACEHOLDER}
61
+ MARKDOWN
62
+ end
63
+
64
+ def self.save(file_path, memory_file)
65
+ FileUtils.mkdir_p(File.dirname(file_path))
66
+ File.write(file_path, memory_file.to_markdown)
67
+ end
68
+
69
+ def self.validate_frontmatter!(frontmatter)
70
+ raise InvalidMemoryFileError, 'Missing version field' unless frontmatter['version']
71
+
72
+ version = frontmatter['version'].to_s
73
+ raise InvalidMemoryFileError, 'Invalid semver version' unless version =~ SEMVER_REGEX
74
+
75
+ # Parse or validate datetime fields (they might already be Time objects from YAML)
76
+ parse_datetime(frontmatter['created'])
77
+ parse_datetime(frontmatter['updated'])
78
+ rescue ArgumentError => e
79
+ raise InvalidMemoryFileError, "Invalid datetime: #{e.message}"
80
+ end
81
+
82
+ def self.parse_datetime(value)
83
+ return value if value.is_a?(Time)
84
+ Time.parse(value)
85
+ end
86
+
87
+ def self.extract_rules(markdown_content)
88
+ raise InvalidMemoryFileError, 'Missing "Default Safety Rules" section' unless markdown_content.include?('## Default Safety Rules')
89
+
90
+ sections = markdown_content.split(/^## /)
91
+ default_section = sections.find { |s| s.start_with?('Default Safety Rules') }
92
+ user_section = sections.find { |s| s.start_with?('User Rules') }
93
+
94
+ default_rules = extract_list_items(default_section)
95
+ user_rules = extract_list_items(user_section)
96
+
97
+ validate_rules!(default_rules + user_rules)
98
+
99
+ min_rules = Defaults::MIN_SAFETY_RULES
100
+ raise InvalidMemoryFileError, "Default Safety Rules must contain at least #{min_rules} rules" if default_rules.length < min_rules
101
+
102
+ [default_rules, user_rules]
103
+ end
104
+
105
+ def self.extract_list_items(section)
106
+ return [] unless section
107
+
108
+ items = section.split("\n").select { |line| line.strip.start_with?('-') }.map do |line|
109
+ line.sub(/^-\s*/, '').strip
110
+ end
111
+
112
+ # Validate items before filtering placeholders
113
+ items.each do |item|
114
+ # Skip placeholder validation
115
+ next if item == Defaults::USER_RULES_PLACEHOLDER
116
+
117
+ raise InvalidMemoryFileError, 'Rule cannot be empty' if item.strip.empty?
118
+ end
119
+
120
+ # Filter out empty items and placeholders
121
+ items.reject { |item| item.empty? || item == Defaults::USER_RULES_PLACEHOLDER }
122
+ end
123
+
124
+ def self.validate_rules!(rules)
125
+ rules.each do |rule|
126
+ raise InvalidMemoryFileError, 'Rule cannot be empty' if rule.strip.empty?
127
+ raise InvalidMemoryFileError, 'Rule exceeds 500 characters' if rule.length > 500
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIHype
4
+ class MemoryFile
5
+ attr_reader :version, :created, :updated, :default_rules, :user_rules
6
+
7
+ def initialize(version:, created:, updated:, default_rules:, user_rules:)
8
+ @version = version
9
+ @created = created
10
+ @updated = updated
11
+ @default_rules = default_rules
12
+ @user_rules = user_rules
13
+ end
14
+
15
+ def all_rules
16
+ default_rules + user_rules
17
+ end
18
+
19
+ def to_markdown
20
+ <<~MARKDOWN
21
+ ---
22
+ version: #{version}
23
+ created: #{created.iso8601}
24
+ updated: #{updated.iso8601}
25
+ ---
26
+
27
+ # AIHype Blacklist Rules
28
+
29
+ ## Default Safety Rules
30
+ #{default_rules.map { |r| "- #{r}" }.join("\n")}
31
+
32
+ ## User Rules
33
+ #{user_rules.empty? ? '- (no custom rules)' : user_rules.map { |r| "- #{r}" }.join("\n")}
34
+ MARKDOWN
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require_relative 'env'
6
+
7
+ module AIHype
8
+ class ModelSelector
9
+ MODELS_API_URL = 'https://api.anthropic.com/v1/models'
10
+
11
+ # Model selection preferences for different use cases
12
+ PREFERENCES = {
13
+ fast: ->(display_name) { display_name.downcase.include?('haiku') },
14
+ cheap: ->(display_name) { display_name.downcase.include?('haiku') },
15
+ balanced: ->(display_name) { display_name.downcase.include?('sonnet') },
16
+ powerful: ->(display_name) { display_name.downcase.include?('opus') }
17
+ }.freeze
18
+
19
+ def initialize(api_key:, preference: :cheap)
20
+ @api_key = api_key
21
+ @preference = preference
22
+ @cache = nil
23
+ end
24
+
25
+ def select_model
26
+ # Fetch and cache models
27
+ models = fetch_models
28
+ return nil if models.empty?
29
+
30
+ # Apply preference filter
31
+ preference_filter = PREFERENCES[@preference]
32
+ preferred_models = models.select { |m| preference_filter.call(m['display_name']) }
33
+
34
+ # Return most recent preferred model, or most recent model if no preference match
35
+ (preferred_models.first || models.first)['id']
36
+ rescue StandardError => e
37
+ # Fallback to a known stable model if API fetch fails
38
+ warn "Warning: Failed to fetch models (#{e.message}), using fallback"
39
+ 'claude-3-5-haiku-20241022'
40
+ end
41
+
42
+ private
43
+
44
+ def fetch_models
45
+ return @cache if @cache
46
+
47
+ conn = Faraday.new(url: MODELS_API_URL) do |f|
48
+ f.response :json
49
+ f.adapter Faraday.default_adapter
50
+ f.options.timeout = 5
51
+ f.options.open_timeout = 5
52
+ end
53
+
54
+ response = conn.get do |req|
55
+ req.headers['x-api-key'] = @api_key
56
+ req.headers['anthropic-version'] = '2023-06-01'
57
+ end
58
+
59
+ return [] unless response.status == 200
60
+ return [] unless response.body['data']
61
+
62
+ @cache = response.body['data']
63
+ @cache
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+ require 'io/console'
5
+ require_relative 'core'
6
+ require_relative 'blacklist'
7
+ require_relative 'ai_matcher'
8
+ require_relative 'logger'
9
+
10
+ module AIHype
11
+ class PTYController
12
+ attr_reader :command, :args, :blacklist, :ai_matcher, :logger
13
+
14
+ PROMPT_TIMEOUT = 0.5
15
+ BUFFER_LINES = 20
16
+
17
+ def initialize(command:, args: [], config_path:, log_path:, verbose: false)
18
+ @command = command
19
+ @args = args
20
+ @config_path = config_path
21
+ @log_path = log_path
22
+ @verbose = verbose
23
+ @line_buffer = String.new
24
+ @recent_lines = []
25
+ end
26
+
27
+ def run
28
+ # Load components
29
+ @blacklist = Blacklist.from_file(@config_path)
30
+ @ai_matcher = AIMatcher.new
31
+ @logger = Logger.new(log_file_path: @log_path, verbose: @verbose)
32
+ @core = Core.new(blacklist: @blacklist, ai_matcher: @ai_matcher, logger: @logger)
33
+
34
+ @logger.log_startup(@blacklist.enabled_rules.length, @config_path)
35
+
36
+ # Spawn child process via PTY
37
+ PTY.spawn(@command, *@args) do |pty_out, pty_in, pid|
38
+ exit_code = process_pty_io(pty_out, pty_in, pid)
39
+ return exit_code
40
+ end
41
+
42
+ 0
43
+ rescue InvalidMemoryFileError, Errno::ENOENT => e
44
+ $stderr.puts "Error: Invalid or missing config file: #{e.message}"
45
+ 2
46
+ rescue StandardError => e
47
+ $stderr.puts "Error: #{e.message}"
48
+ $stderr.puts e.backtrace.join("\n") if @verbose
49
+ 1
50
+ end
51
+
52
+ private
53
+
54
+ def process_pty_io(pty_out, pty_in, pid)
55
+ # Set stdin to raw mode for transparent pass-through (only if truly interactive)
56
+ old_stdin_mode = nil
57
+ stdin_closed = false
58
+
59
+ if $stdin.tty? && !$stdin.closed?
60
+ begin
61
+ old_stdin_mode = $stdin.raw!
62
+ rescue => e
63
+ @logger.log_ai_failure(nil, "Failed to set raw mode: #{e.message}") if @verbose
64
+ end
65
+ end
66
+
67
+ loop do
68
+ # Monitor BOTH child output AND user input (unless stdin closed)
69
+ read_streams = [pty_out]
70
+ read_streams << $stdin unless stdin_closed || $stdin.closed?
71
+
72
+ ready = IO.select(read_streams, nil, nil, PROMPT_TIMEOUT)
73
+
74
+ if ready
75
+ # Handle user input (forward to child)
76
+ if !stdin_closed && ready[0].include?($stdin)
77
+ begin
78
+ user_input = $stdin.read_nonblock(4096)
79
+ @logger.log_user_input(user_input) if @verbose
80
+ pty_in.write(user_input)
81
+ pty_in.flush
82
+ rescue IO::WaitReadable
83
+ # No input available
84
+ rescue EOFError
85
+ # stdin closed - stop monitoring it
86
+ stdin_closed = true
87
+ end
88
+ end
89
+
90
+ # Handle child output (check for prompts)
91
+ if ready[0].include?(pty_out)
92
+ begin
93
+ chunk = pty_out.read_nonblock(4096)
94
+ $stdout.print chunk
95
+ $stdout.flush
96
+ @logger.log_child_output(chunk) if @verbose
97
+ @line_buffer << chunk
98
+ # Check for prompts immediately after receiving output
99
+ check_for_prompt(pty_in)
100
+ rescue IO::WaitReadable
101
+ check_for_prompt(pty_in)
102
+ rescue EOFError, Errno::EIO
103
+ break
104
+ end
105
+ end
106
+ else
107
+ # Timeout - check if we have a waiting prompt
108
+ check_for_prompt(pty_in)
109
+ end
110
+ end
111
+
112
+ Process.wait(pid)
113
+ $?.exitstatus || 0
114
+ rescue Errno::ECHILD
115
+ 0
116
+ ensure
117
+ # Restore terminal mode
118
+ $stdin.cooked! if old_stdin_mode
119
+ end
120
+
121
+ def check_for_prompt(pty_in)
122
+ return if @line_buffer.empty?
123
+
124
+ lines = @line_buffer.split("\n")
125
+ @recent_lines = (@recent_lines + lines).last(BUFFER_LINES)
126
+
127
+ # Check multi-line first
128
+ if @recent_lines.length > 1
129
+ combined = @recent_lines.last(10).join("\n")
130
+ if @core.approval_prompt?(combined)
131
+ handle_detected_prompt(combined, pty_in)
132
+ return
133
+ end
134
+ end
135
+
136
+ # Check last line
137
+ last_line = lines.last || ""
138
+ test_line = last_line.strip
139
+
140
+ if !test_line.empty? && @core.approval_prompt?(test_line)
141
+ unless possibly_incomplete_menu?(test_line)
142
+ handle_detected_prompt(test_line, pty_in)
143
+ return
144
+ end
145
+ end
146
+
147
+ @line_buffer = @line_buffer[-1000..-1] if @line_buffer.length > 10000
148
+ end
149
+
150
+ def possibly_incomplete_menu?(text)
151
+ text.match?(/do you want|would you like|should i/i) && !text.match?(/1\.\s*yes/i)
152
+ end
153
+
154
+ def handle_detected_prompt(prompt_text, pty_in)
155
+ result = @core.process_line(prompt_text)
156
+
157
+ if result[:is_prompt]
158
+ response = result[:response]
159
+
160
+ $stderr.puts "[DEBUG] Writing: #{response.inspect}" if @verbose
161
+
162
+ # Write response - handle keyboard sequences with delays
163
+ write_response_with_delays(pty_in, response)
164
+
165
+ # Clear buffers
166
+ @line_buffer.clear
167
+ @recent_lines.clear
168
+ end
169
+ end
170
+
171
+ def write_response_with_delays(pty_in, response)
172
+ # Wait briefly to ensure Ink framework is ready for input
173
+ sleep 0.3
174
+
175
+ # Write response
176
+ pty_in.write(response)
177
+ pty_in.flush
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+
5
+ module AIHype
6
+ class RateLimiter
7
+ attr_reader :requests_per_minute, :window_size
8
+
9
+ def initialize(requests_per_minute: 50, window_size: 60)
10
+ @requests_per_minute = requests_per_minute
11
+ @window_size = window_size # seconds
12
+ @requests = []
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # Wait if necessary to respect rate limits, then record request
17
+ def throttle
18
+ @mutex.synchronize do
19
+ now = Time.now
20
+
21
+ # Remove requests outside the current window
22
+ @requests.reject! { |time| now - time > @window_size }
23
+
24
+ # If at limit, wait until oldest request expires
25
+ if @requests.length >= @requests_per_minute
26
+ oldest_request = @requests.first
27
+ sleep_time = @window_size - (now - oldest_request) + 0.1 # Add small buffer
28
+ sleep(sleep_time) if sleep_time > 0
29
+
30
+ # Clean up again after sleeping
31
+ now = Time.now
32
+ @requests.reject! { |time| now - time > @window_size }
33
+ end
34
+
35
+ # Record this request
36
+ @requests << now
37
+ end
38
+ end
39
+
40
+ # Get current request count in window
41
+ def current_count
42
+ @mutex.synchronize do
43
+ now = Time.now
44
+ @requests.reject! { |time| now - time > @window_size }
45
+ @requests.length
46
+ end
47
+ end
48
+
49
+ # Reset the rate limiter
50
+ def reset
51
+ @mutex.synchronize do
52
+ @requests.clear
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIHype
4
+ VERSION = '0.1.0'
5
+ end
data/lib/aihype.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'aihype/version'
4
+ require_relative 'defaults'
5
+
6
+ # Configuration
7
+ require_relative 'aihype/env'
8
+
9
+ # Entity classes
10
+ require_relative 'aihype/blacklist_rule'
11
+ require_relative 'aihype/approval_prompt'
12
+ require_relative 'aihype/memory_file'
13
+ require_relative 'aihype/log_entry'
14
+
15
+ # Service modules
16
+ require_relative 'aihype/memory'
17
+ require_relative 'aihype/blacklist'
18
+ require_relative 'aihype/rate_limiter'
19
+ require_relative 'aihype/model_selector'
20
+ require_relative 'aihype/ai_matcher'
21
+ require_relative 'aihype/logger'
22
+ require_relative 'aihype/core'
23
+
24
+ # CLI interface
25
+ require_relative 'aihype/cli'
26
+
27
+ module AIHype
28
+ class Error < StandardError; end
29
+ end
data/lib/defaults.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIHype
4
+ module Defaults
5
+ # Default safety rules to prevent dangerous operations
6
+ # These rules are included in every new memory/aihype.md configuration
7
+ SAFETY_RULES = [
8
+ 'Never execute rm -rf / or similar destructive filesystem operations',
9
+ 'Never modify critical system files (/etc/passwd, /boot/, /etc/shadow)',
10
+ 'Never disable security features or firewalls',
11
+ 'Never brick the machine by corrupting bootloader or kernel'
12
+ ].freeze
13
+
14
+ # Placeholder text for user rules section
15
+ USER_RULES_PLACEHOLDER = '(add your custom rules here)'
16
+
17
+ # Default version for new configurations
18
+ DEFAULT_VERSION = '1.0.0'
19
+
20
+ # Minimum required safety rules
21
+ MIN_SAFETY_RULES = 3
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: aihype
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - AIHype Contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: kramdown
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.4'
41
+ description: Like "yes | ai" - wraps command-line tools via PTY, auto-answers interactive
42
+ prompts with AI-powered blacklist security
43
+ email:
44
+ - noreply@example.com
45
+ executables:
46
+ - aihype
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - README.md
51
+ - bin/aihype
52
+ - bin/mock-claude
53
+ - lib/aihype.rb
54
+ - lib/aihype/ai_matcher.rb
55
+ - lib/aihype/approval_prompt.rb
56
+ - lib/aihype/blacklist.rb
57
+ - lib/aihype/blacklist_rule.rb
58
+ - lib/aihype/cli.rb
59
+ - lib/aihype/core.rb
60
+ - lib/aihype/env.rb
61
+ - lib/aihype/log_entry.rb
62
+ - lib/aihype/logger.rb
63
+ - lib/aihype/memory.rb
64
+ - lib/aihype/memory_file.rb
65
+ - lib/aihype/model_selector.rb
66
+ - lib/aihype/pty_controller.rb
67
+ - lib/aihype/rate_limiter.rb
68
+ - lib/aihype/version.rb
69
+ - lib/defaults.rb
70
+ homepage: https://github.com/ahoward/aihype
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/ahoward/aihype
75
+ source_code_uri: https://github.com/ahoward/aihype
76
+ changelog_uri: https://github.com/ahoward/aihype/blob/main/CHANGELOG.md
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 3.0.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.5.22
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Auto-approve interactive prompts with blacklist protection
96
+ test_files: []