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.
- checksums.yaml +7 -0
- data/README.md +452 -0
- data/bin/aihype +10 -0
- data/bin/mock-claude +77 -0
- data/lib/aihype/ai_matcher.rb +144 -0
- data/lib/aihype/approval_prompt.rb +63 -0
- data/lib/aihype/blacklist.rb +65 -0
- data/lib/aihype/blacklist_rule.rb +43 -0
- data/lib/aihype/cli.rb +170 -0
- data/lib/aihype/core.rb +130 -0
- data/lib/aihype/env.rb +79 -0
- data/lib/aihype/log_entry.rb +43 -0
- data/lib/aihype/logger.rb +101 -0
- data/lib/aihype/memory.rb +131 -0
- data/lib/aihype/memory_file.rb +37 -0
- data/lib/aihype/model_selector.rb +66 -0
- data/lib/aihype/pty_controller.rb +180 -0
- data/lib/aihype/rate_limiter.rb +56 -0
- data/lib/aihype/version.rb +5 -0
- data/lib/aihype.rb +29 -0
- data/lib/defaults.rb +23 -0
- metadata +96 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIHype
|
|
4
|
+
class ApprovalPrompt
|
|
5
|
+
attr_reader :id, :timestamp, :raw_text, :detected_patterns, :confidence, :matched_rule, :decision, :response_sent, :is_menu
|
|
6
|
+
|
|
7
|
+
def initialize(id:, raw_text:, detected_patterns: [], confidence: 0.0, is_menu: false)
|
|
8
|
+
@id = id
|
|
9
|
+
@timestamp = Time.now
|
|
10
|
+
@raw_text = raw_text
|
|
11
|
+
@detected_patterns = detected_patterns
|
|
12
|
+
@confidence = confidence
|
|
13
|
+
@matched_rule = nil
|
|
14
|
+
@decision = :pending
|
|
15
|
+
@response_sent = nil
|
|
16
|
+
@is_menu = is_menu
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def approve!(response = nil)
|
|
20
|
+
@decision = :approved
|
|
21
|
+
@response_sent = response || default_approve_response
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def deny!(rule_id, response = nil)
|
|
25
|
+
@decision = :denied
|
|
26
|
+
@matched_rule = rule_id
|
|
27
|
+
@response_sent = response || default_deny_response
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ai_unavailable_approve!(response = nil)
|
|
31
|
+
@decision = :ai_unavailable_approved
|
|
32
|
+
@response_sent = response || default_approve_response
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def approved?
|
|
36
|
+
@decision == :approved || @decision == :ai_unavailable_approved
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def denied?
|
|
40
|
+
@decision == :denied
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def default_approve_response
|
|
46
|
+
if @is_menu
|
|
47
|
+
# For interactive menus (like Claude CLI), select option 1 (Yes)
|
|
48
|
+
"1\n"
|
|
49
|
+
else
|
|
50
|
+
"yes\n"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_deny_response
|
|
55
|
+
if @is_menu
|
|
56
|
+
# For denials, just press enter on default
|
|
57
|
+
"\n"
|
|
58
|
+
else
|
|
59
|
+
"no\n"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'blacklist_rule'
|
|
4
|
+
require_relative 'memory'
|
|
5
|
+
|
|
6
|
+
module AIHype
|
|
7
|
+
class Blacklist
|
|
8
|
+
attr_reader :rules
|
|
9
|
+
|
|
10
|
+
def initialize(memory_file)
|
|
11
|
+
@rules = []
|
|
12
|
+
load_rules(memory_file)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_file(file_path)
|
|
16
|
+
memory_file = Memory.load(file_path)
|
|
17
|
+
raise InvalidMemoryFileError, "Memory file not found: #{file_path}" unless memory_file
|
|
18
|
+
|
|
19
|
+
new(memory_file)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def enabled_rules
|
|
23
|
+
@rules.select(&:enabled?)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rule_contents
|
|
27
|
+
enabled_rules.map(&:content)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find_by_content(content)
|
|
31
|
+
@rules.find { |r| r.content.downcase == content.downcase }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def add_rule(content, category: :user_defined)
|
|
35
|
+
existing = find_by_content(content)
|
|
36
|
+
return existing if existing
|
|
37
|
+
|
|
38
|
+
rule = BlacklistRule.new(content: content, category: category)
|
|
39
|
+
@rules << rule
|
|
40
|
+
rule
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def disable_rule(content)
|
|
44
|
+
rule = find_by_content(content)
|
|
45
|
+
rule&.disable
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enable_rule(content)
|
|
49
|
+
rule = find_by_content(content)
|
|
50
|
+
rule&.enable
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def load_rules(memory_file)
|
|
56
|
+
memory_file.default_rules.each do |content|
|
|
57
|
+
add_rule(content, category: :default_safety)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
memory_file.user_rules.each do |content|
|
|
61
|
+
add_rule(content, category: :user_defined)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module AIHype
|
|
6
|
+
class BlacklistRule
|
|
7
|
+
attr_reader :id, :content, :category, :created, :enabled
|
|
8
|
+
|
|
9
|
+
def initialize(content:, category: :user_defined, created: Time.now, enabled: true)
|
|
10
|
+
@content = content
|
|
11
|
+
@category = category
|
|
12
|
+
@created = created
|
|
13
|
+
@enabled = enabled
|
|
14
|
+
@id = generate_id
|
|
15
|
+
|
|
16
|
+
validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enabled?
|
|
20
|
+
@enabled
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def disable
|
|
24
|
+
@enabled = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enable
|
|
28
|
+
@enabled = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def generate_id
|
|
34
|
+
Digest::SHA256.hexdigest(@content.downcase)[0..11]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate!
|
|
38
|
+
raise ArgumentError, 'Content cannot be empty' if @content.nil? || @content.strip.empty?
|
|
39
|
+
raise ArgumentError, 'Content must be plain English (≤500 characters)' if @content.length > 500
|
|
40
|
+
raise ArgumentError, 'Content must be at least 3 characters' if @content.length < 3
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/aihype/cli.rb
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative 'version'
|
|
6
|
+
require_relative 'blacklist'
|
|
7
|
+
require_relative 'ai_matcher'
|
|
8
|
+
require_relative 'logger'
|
|
9
|
+
require_relative 'core'
|
|
10
|
+
require_relative 'memory'
|
|
11
|
+
require_relative 'pty_controller'
|
|
12
|
+
|
|
13
|
+
module AIHype
|
|
14
|
+
class CLI
|
|
15
|
+
DEFAULT_CONFIG_PATH = 'memory/aihype.md'
|
|
16
|
+
DEFAULT_LOG_PATH = 'memory/aihype.log'
|
|
17
|
+
|
|
18
|
+
attr_reader :config_path, :log_path, :verbose, :mode, :command, :command_args
|
|
19
|
+
|
|
20
|
+
def initialize(argv = ARGV)
|
|
21
|
+
@config_path = DEFAULT_CONFIG_PATH
|
|
22
|
+
@log_path = DEFAULT_LOG_PATH
|
|
23
|
+
@verbose = false
|
|
24
|
+
@mode = :pty
|
|
25
|
+
@config_path_specified = false
|
|
26
|
+
@command = nil
|
|
27
|
+
@command_args = []
|
|
28
|
+
parse_options(argv)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
case @mode
|
|
33
|
+
when :init
|
|
34
|
+
run_init
|
|
35
|
+
when :validate
|
|
36
|
+
run_validate
|
|
37
|
+
when :pty
|
|
38
|
+
run_pty
|
|
39
|
+
else
|
|
40
|
+
raise "Unknown mode: #{@mode}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def parse_options(argv)
|
|
47
|
+
parser = OptionParser.new do |opts|
|
|
48
|
+
opts.banner = "Usage: aihype [options] <command> [command_args...]"
|
|
49
|
+
opts.separator ""
|
|
50
|
+
opts.separator "Examples:"
|
|
51
|
+
opts.separator " aihype mock-claude config.yml"
|
|
52
|
+
opts.separator " aihype claude -p prompt.md"
|
|
53
|
+
opts.separator " aihype --config custom.md -- some-tool --with-args"
|
|
54
|
+
opts.separator ""
|
|
55
|
+
opts.separator "Commands:"
|
|
56
|
+
opts.separator " init Create default memory/aihype.md configuration"
|
|
57
|
+
opts.separator " validate Validate memory/aihype.md configuration"
|
|
58
|
+
opts.separator ""
|
|
59
|
+
opts.separator "Options:"
|
|
60
|
+
|
|
61
|
+
opts.on('-c', '--config PATH', 'Path to config file (default: memory/aihype.md)') do |path|
|
|
62
|
+
@config_path = path
|
|
63
|
+
@config_path_specified = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on('-l', '--log PATH', 'Path to log file (default: memory/aihype.log)') do |path|
|
|
67
|
+
@log_path = path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
opts.on('-v', '--verbose', 'Enable verbose logging') do
|
|
71
|
+
@verbose = true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
opts.on('-h', '--help', 'Show this help message') do
|
|
75
|
+
puts opts
|
|
76
|
+
exit 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
opts.on('--version', 'Show version') do
|
|
80
|
+
puts "aihype #{AIHype::VERSION}"
|
|
81
|
+
exit 0
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Use order! instead of parse! to stop at first non-option argument
|
|
86
|
+
# This allows passing flags to the child command (e.g., claude -p)
|
|
87
|
+
parser.order!(argv)
|
|
88
|
+
|
|
89
|
+
# Determine mode from remaining arguments
|
|
90
|
+
if argv.length > 0
|
|
91
|
+
command = argv[0]
|
|
92
|
+
case command
|
|
93
|
+
when 'init'
|
|
94
|
+
@mode = :init
|
|
95
|
+
when 'validate'
|
|
96
|
+
@mode = :validate
|
|
97
|
+
else
|
|
98
|
+
# PTY mode - spawn the command
|
|
99
|
+
@mode = :pty
|
|
100
|
+
@command = argv[0]
|
|
101
|
+
@command_args = argv[1..-1] || []
|
|
102
|
+
end
|
|
103
|
+
else
|
|
104
|
+
$stderr.puts "Error: No command specified"
|
|
105
|
+
$stderr.puts parser.help
|
|
106
|
+
exit 1
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_init
|
|
111
|
+
if File.exist?(@config_path)
|
|
112
|
+
$stderr.puts "Error: Configuration file already exists at #{@config_path}"
|
|
113
|
+
exit 1
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
content = Memory.create_default
|
|
117
|
+
FileUtils.mkdir_p(File.dirname(@config_path))
|
|
118
|
+
File.write(@config_path, content)
|
|
119
|
+
|
|
120
|
+
puts "Created #{@config_path}"
|
|
121
|
+
exit 0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def run_validate
|
|
125
|
+
unless File.exist?(@config_path)
|
|
126
|
+
$stderr.puts "Error: Configuration file not found at #{@config_path}"
|
|
127
|
+
exit 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
memory_file = Memory.load(@config_path)
|
|
132
|
+
blacklist = Blacklist.new(memory_file)
|
|
133
|
+
|
|
134
|
+
puts "Configuration is valid"
|
|
135
|
+
puts " Version: #{memory_file.version}"
|
|
136
|
+
puts " Default rules: #{memory_file.default_rules.length}"
|
|
137
|
+
puts " User rules: #{memory_file.user_rules.length}"
|
|
138
|
+
puts " Total enabled rules: #{blacklist.enabled_rules.length}"
|
|
139
|
+
exit 0
|
|
140
|
+
rescue InvalidMemoryFileError => e
|
|
141
|
+
$stderr.puts "Error: Invalid configuration file: #{e.message}"
|
|
142
|
+
exit 1
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
$stderr.puts "Error: #{e.message}"
|
|
145
|
+
exit 1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def run_pty
|
|
150
|
+
# Auto-create config if missing (only for default path)
|
|
151
|
+
unless File.exist?(@config_path) || @config_path_specified
|
|
152
|
+
content = Memory.create_default
|
|
153
|
+
FileUtils.mkdir_p(File.dirname(@config_path))
|
|
154
|
+
File.write(@config_path, content)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Spawn and control the command via PTY
|
|
158
|
+
controller = PTYController.new(
|
|
159
|
+
command: @command,
|
|
160
|
+
args: @command_args,
|
|
161
|
+
config_path: @config_path,
|
|
162
|
+
log_path: @log_path,
|
|
163
|
+
verbose: @verbose
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
exit_code = controller.run
|
|
167
|
+
exit exit_code
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/aihype/core.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'approval_prompt'
|
|
4
|
+
|
|
5
|
+
module AIHype
|
|
6
|
+
class Core
|
|
7
|
+
PROMPT_PATTERNS = [
|
|
8
|
+
/\b(do you want|would you like|should i|may i|can i|proceed with|continue with|confirm)\b.*\?/i,
|
|
9
|
+
/\(y\/n\)/i,
|
|
10
|
+
/\(yes\/no\)/i,
|
|
11
|
+
/\[y\/n\]/i,
|
|
12
|
+
/\[yes\/no\]/i,
|
|
13
|
+
/^\s*1\.\s*(yes|continue|proceed)/i, # Numbered menu with Yes option
|
|
14
|
+
/enter to confirm/i # Claude CLI style confirmation
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(blacklist:, ai_matcher:, logger:)
|
|
18
|
+
@blacklist = blacklist
|
|
19
|
+
@ai_matcher = ai_matcher
|
|
20
|
+
@logger = logger
|
|
21
|
+
@prompt_counter = 0
|
|
22
|
+
@multi_line_buffer = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def process_line(line)
|
|
26
|
+
if approval_prompt?(line)
|
|
27
|
+
handle_prompt(line)
|
|
28
|
+
else
|
|
29
|
+
{ output: line, is_prompt: false }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def process_buffer(buffer)
|
|
34
|
+
# Check if buffer contains a prompt
|
|
35
|
+
combined = buffer.join("\n")
|
|
36
|
+
if approval_prompt?(combined)
|
|
37
|
+
handle_prompt(combined)
|
|
38
|
+
else
|
|
39
|
+
{ output: buffer.last || "", is_prompt: false }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def approval_prompt?(line)
|
|
44
|
+
# Strip ANSI codes before checking
|
|
45
|
+
clean_line = strip_ansi(line)
|
|
46
|
+
PROMPT_PATTERNS.any? { |pattern| clean_line.match?(pattern) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def detect_patterns(line)
|
|
50
|
+
clean_line = strip_ansi(line)
|
|
51
|
+
PROMPT_PATTERNS.each_with_index.select { |pattern, _| clean_line.match?(pattern) }
|
|
52
|
+
.map { |_, idx| "pattern_#{idx}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def calculate_confidence(detected_patterns)
|
|
56
|
+
return 0.5 if detected_patterns.empty?
|
|
57
|
+
return 1.0 if detected_patterns.length > 1
|
|
58
|
+
|
|
59
|
+
0.75
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Detect if this is a numbered menu style prompt
|
|
63
|
+
def numbered_menu?(text)
|
|
64
|
+
clean_text = strip_ansi(text)
|
|
65
|
+
clean_text.match?(/^\s*[12]\.\s*(yes|no)/im) ||
|
|
66
|
+
clean_text.match?(/1\.\s*yes.*2\.\s*no/im)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Strip ANSI escape codes from text
|
|
70
|
+
def strip_ansi(text)
|
|
71
|
+
text.gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
|
|
72
|
+
.gsub(/\e\].*?\e\\/, '')
|
|
73
|
+
.gsub(/\e[>=]/, '')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def handle_prompt(line)
|
|
79
|
+
@prompt_counter += 1
|
|
80
|
+
detected_patterns = detect_patterns(line)
|
|
81
|
+
confidence = calculate_confidence(detected_patterns)
|
|
82
|
+
|
|
83
|
+
prompt = ApprovalPrompt.new(
|
|
84
|
+
id: "prompt_#{format('%03d', @prompt_counter)}",
|
|
85
|
+
raw_text: line.strip,
|
|
86
|
+
detected_patterns: detected_patterns,
|
|
87
|
+
confidence: confidence,
|
|
88
|
+
is_menu: numbered_menu?(line)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
evaluate_and_respond(prompt)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def evaluate_and_respond(prompt)
|
|
95
|
+
rules = @blacklist.rule_contents
|
|
96
|
+
|
|
97
|
+
if rules.empty?
|
|
98
|
+
prompt.approve!
|
|
99
|
+
@logger.log_approval(prompt)
|
|
100
|
+
return format_response(prompt)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
result = @ai_matcher.evaluate(prompt.raw_text, rules)
|
|
104
|
+
|
|
105
|
+
if result[:error]
|
|
106
|
+
prompt.ai_unavailable_approve!
|
|
107
|
+
@logger.log_ai_failure(prompt, result[:error])
|
|
108
|
+
elsif result[:matched]
|
|
109
|
+
prompt.deny!(result[:rule_id])
|
|
110
|
+
@logger.log_denial(prompt, result[:rule_id])
|
|
111
|
+
else
|
|
112
|
+
prompt.approve!
|
|
113
|
+
@logger.log_approval(prompt)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
format_response(prompt)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def format_response(prompt)
|
|
120
|
+
{
|
|
121
|
+
output: "#{prompt.response_sent}\n",
|
|
122
|
+
is_prompt: true,
|
|
123
|
+
decision: prompt.decision,
|
|
124
|
+
response: prompt.response_sent,
|
|
125
|
+
original_prompt: prompt.raw_text,
|
|
126
|
+
is_menu: prompt.is_menu
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/aihype/env.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIHype
|
|
4
|
+
module Env
|
|
5
|
+
# Anthropic API configuration
|
|
6
|
+
def self.anthropic_api_key
|
|
7
|
+
ENV['ANTHROPIC_API_KEY']
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Model selection
|
|
11
|
+
# Can be overridden to force a specific model
|
|
12
|
+
def self.model
|
|
13
|
+
ENV['AIHYPE_MODEL']
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Model selection preference: fast, cheap, balanced, powerful
|
|
17
|
+
def self.model_preference
|
|
18
|
+
pref = ENV['AIHYPE_MODEL_PREFERENCE']
|
|
19
|
+
return :cheap unless pref
|
|
20
|
+
|
|
21
|
+
pref.to_sym
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Timeout for API requests (in seconds)
|
|
25
|
+
def self.api_timeout
|
|
26
|
+
timeout = ENV['AIHYPE_API_TIMEOUT']
|
|
27
|
+
return 5 unless timeout # Increased to 5 seconds to account for rate limiting
|
|
28
|
+
|
|
29
|
+
timeout.to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Enable verbose logging
|
|
33
|
+
def self.verbose?
|
|
34
|
+
value = ENV['AIHYPE_VERBOSE']
|
|
35
|
+
['1', 'true', 'yes'].include?(value&.downcase)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Configuration file path
|
|
39
|
+
def self.config_path
|
|
40
|
+
ENV['AIHYPE_CONFIG'] || 'memory/aihype.md'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Log file path
|
|
44
|
+
def self.log_path
|
|
45
|
+
ENV['AIHYPE_LOG'] || 'memory/aihype.log'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Rate limiting - requests per minute
|
|
49
|
+
def self.rate_limit_rpm
|
|
50
|
+
rpm = ENV['AIHYPE_RATE_LIMIT_RPM']
|
|
51
|
+
return 50 unless rpm # Default to 50 requests per minute
|
|
52
|
+
|
|
53
|
+
rpm.to_i
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Rate limiting - window size in seconds
|
|
57
|
+
def self.rate_limit_window
|
|
58
|
+
window = ENV['AIHYPE_RATE_LIMIT_WINDOW']
|
|
59
|
+
return 60 unless window # Default to 60 seconds
|
|
60
|
+
|
|
61
|
+
window.to_i
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# All environment variables used by aihype
|
|
65
|
+
def self.all
|
|
66
|
+
{
|
|
67
|
+
anthropic_api_key: anthropic_api_key,
|
|
68
|
+
model: model,
|
|
69
|
+
model_preference: model_preference,
|
|
70
|
+
api_timeout: api_timeout,
|
|
71
|
+
verbose: verbose?,
|
|
72
|
+
config_path: config_path,
|
|
73
|
+
log_path: log_path,
|
|
74
|
+
rate_limit_rpm: rate_limit_rpm,
|
|
75
|
+
rate_limit_window: rate_limit_window
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module AIHype
|
|
6
|
+
class LogEntry
|
|
7
|
+
attr_reader :timestamp, :level, :event_type, :message, :metadata
|
|
8
|
+
|
|
9
|
+
VALID_LEVELS = %i[info warning error].freeze
|
|
10
|
+
VALID_EVENT_TYPES = %i[prompt_detected rule_matched ai_failure memory_loaded].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(level:, event_type:, message:, metadata: {})
|
|
13
|
+
@timestamp = Time.now
|
|
14
|
+
@level = level
|
|
15
|
+
@event_type = event_type
|
|
16
|
+
@message = message
|
|
17
|
+
@metadata = metadata
|
|
18
|
+
|
|
19
|
+
validate!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_json(*_args)
|
|
23
|
+
{
|
|
24
|
+
timestamp: timestamp.iso8601,
|
|
25
|
+
level: level.to_s.upcase,
|
|
26
|
+
event_type: event_type.to_s,
|
|
27
|
+
message: message,
|
|
28
|
+
metadata: metadata
|
|
29
|
+
}.to_json
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_stderr
|
|
33
|
+
"[AIHype] #{level.to_s.upcase}: #{message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate!
|
|
39
|
+
raise ArgumentError, "Invalid level: #{level}" unless VALID_LEVELS.include?(level)
|
|
40
|
+
raise ArgumentError, "Invalid event_type: #{event_type}" unless VALID_EVENT_TYPES.include?(event_type)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'log_entry'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module AIHype
|
|
7
|
+
class Logger
|
|
8
|
+
MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB
|
|
9
|
+
MAX_LOG_FILES = 5
|
|
10
|
+
|
|
11
|
+
def initialize(log_file_path: 'memory/aihype.log', verbose: false)
|
|
12
|
+
@log_file_path = log_file_path
|
|
13
|
+
@verbose = verbose
|
|
14
|
+
ensure_log_directory
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def log_startup(rules_count, config_path)
|
|
18
|
+
message = "Loaded #{rules_count} blacklist rules from #{config_path}"
|
|
19
|
+
entry = LogEntry.new(level: :info, event_type: :memory_loaded, message: message)
|
|
20
|
+
write(entry)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def log_approval(prompt)
|
|
24
|
+
message = "APPROVED: #{prompt.raw_text}"
|
|
25
|
+
metadata = { prompt_id: prompt.id, decision: prompt.decision }
|
|
26
|
+
entry = LogEntry.new(level: :info, event_type: :prompt_detected, message: message, metadata: metadata)
|
|
27
|
+
write(entry) if @verbose
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def log_denial(prompt, rule_content)
|
|
31
|
+
message = "DENIED: #{prompt.raw_text} (matched rule: #{rule_content})"
|
|
32
|
+
metadata = { prompt_id: prompt.id, rule: rule_content }
|
|
33
|
+
entry = LogEntry.new(level: :warning, event_type: :rule_matched, message: message, metadata: metadata)
|
|
34
|
+
write(entry)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def log_ai_failure(prompt, error)
|
|
38
|
+
message = "WARNING: AI unavailable (#{error}), approving all prompts"
|
|
39
|
+
metadata = { prompt_id: prompt.id, error: error }
|
|
40
|
+
entry = LogEntry.new(level: :warning, event_type: :ai_failure, message: message, metadata: metadata)
|
|
41
|
+
write(entry)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def log_user_input(input)
|
|
45
|
+
return unless @verbose
|
|
46
|
+
# Log raw user input for debugging bidirectional I/O
|
|
47
|
+
entry = LogEntry.new(level: :debug, event_type: :user_input, message: "User input: #{input.inspect}")
|
|
48
|
+
write(entry)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def log_child_output(output)
|
|
52
|
+
return unless @verbose
|
|
53
|
+
# Log raw child output for debugging bidirectional I/O
|
|
54
|
+
entry = LogEntry.new(level: :debug, event_type: :child_output, message: "Child output: #{output.inspect}")
|
|
55
|
+
write(entry)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def write(entry)
|
|
61
|
+
$stderr.puts entry.to_stderr
|
|
62
|
+
write_to_file(entry)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write_to_file(entry)
|
|
66
|
+
rotate_if_needed
|
|
67
|
+
File.open(@log_file_path, 'a') do |f|
|
|
68
|
+
f.puts entry.to_json
|
|
69
|
+
end
|
|
70
|
+
rescue Errno::ENOENT
|
|
71
|
+
ensure_log_directory
|
|
72
|
+
retry
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def ensure_log_directory
|
|
76
|
+
dir = File.dirname(@log_file_path)
|
|
77
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rotate_if_needed
|
|
81
|
+
return unless File.exist?(@log_file_path)
|
|
82
|
+
return unless File.size(@log_file_path) > MAX_LOG_SIZE
|
|
83
|
+
|
|
84
|
+
rotate_logs
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def rotate_logs
|
|
88
|
+
(MAX_LOG_FILES - 1).downto(1) do |i|
|
|
89
|
+
old_file = "#{@log_file_path}.#{i}"
|
|
90
|
+
new_file = "#{@log_file_path}.#{i + 1}"
|
|
91
|
+
File.rename(old_file, new_file) if File.exist?(old_file)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
File.rename(@log_file_path, "#{@log_file_path}.1") if File.exist?(@log_file_path)
|
|
95
|
+
|
|
96
|
+
# Remove oldest log if we exceed MAX_LOG_FILES
|
|
97
|
+
oldest = "#{@log_file_path}.#{MAX_LOG_FILES + 1}"
|
|
98
|
+
File.delete(oldest) if File.exist?(oldest)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|