pwn 0.5.562 → 0.5.587

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/pwn/config.rb CHANGED
@@ -32,11 +32,20 @@ module PWN
32
32
  introspection: false,
33
33
  grok: {
34
34
  base_uri: 'optional - Base URI for Grok - Use private base OR defaults to https://api.x.ai/v1',
35
- key: 'required - OpenAI API Key',
35
+ key: 'required - xAI Grok API Key',
36
36
  model: 'optional - Grok model to use',
37
- system_role_content: 'You are an ethically hacking OpenAI agent.',
38
- temp: 'optional - OpenAI temperature',
39
- max_prompt_length: 256_000
37
+ system_role_content: 'You are an ethically hacking xAI Grok agent.',
38
+ temp: 'optional - Grok temperature',
39
+ max_prompt_length: 256_000,
40
+ # OAuth support for xAI SuperGrok subscriptions (in addition to API key)
41
+ # Populate via pwn-vault command (values stored encrypted in ~/.pwn/pwn.yaml)
42
+ oauth: {
43
+ client_id: 'optional - xAI SuperGrok OAuth Client ID (for subscriptions without API key)',
44
+ client_secret: 'optional - xAI SuperGrok OAuth Client Secret',
45
+ access_token: 'optional - xAI SuperGrok OAuth Access Token (preferred for SuperGrok subs; used as Bearer)',
46
+ refresh_token: 'optional - xAI SuperGrok OAuth Refresh Token',
47
+ token_uri: 'optional - OAuth token endpoint (defaults handled in PWN::AI::Grok if needed)'
48
+ }
40
49
  },
41
50
  openai: {
42
51
  base_uri: 'optional - Base URI for OpenAI - Use private base OR defaults to https://api.openai.com/v1',
@@ -53,6 +62,14 @@ module PWN
53
62
  system_role_content: 'You are an ethically hacking Ollama agent.',
54
63
  temp: 'optional - Ollama temperature',
55
64
  max_prompt_length: 32_000
65
+ },
66
+ anthropic: {
67
+ base_uri: 'optional - Base URI for Anthropic - Use private base OR defaults to https://api.anthropic.com/v1',
68
+ key: 'required - Anthropic API Key',
69
+ model: 'optional - Anthropic model to use (e.g. claude-3-5-sonnet-20240620)',
70
+ system_role_content: 'You are an ethically hacking Anthropic agent.',
71
+ temp: 'optional - Anthropic temperature',
72
+ max_prompt_length: 200_000
56
73
  }
57
74
  },
58
75
  plugins: {
@@ -125,15 +142,33 @@ module PWN
125
142
  }
126
143
  },
127
144
  shodan: { api_key: 'SHODAN API Key' }
145
+ },
146
+ memory: {
147
+ enabled: true,
148
+ provider: 'file' # file | sqlite (future)
149
+ },
150
+ sessions: {
151
+ enabled: true,
152
+ provider: 'jsonl'
153
+ },
154
+ cron: {
155
+ enabled: true,
156
+ provider: 'yaml'
128
157
  }
129
158
  }
130
159
 
131
160
  # Remove beginning colon from key names
161
+
132
162
  yaml_env = YAML.dump(env).gsub(/^(\s*):/, '\1')
133
163
  File.write(pwn_env_path, yaml_env)
134
164
  # Change file permission to 600
135
165
  File.chmod(0o600, pwn_env_path)
136
166
 
167
+ # Ensure skills dir for pwn-ai agent (in parent of pwn_env_path)
168
+ pwn_env_root = File.dirname(pwn_env_path)
169
+ pwn_skills_path = File.join(pwn_env_root, 'skills')
170
+ FileUtils.mkdir_p(pwn_skills_path)
171
+
137
172
  env[:driver_opts] = {
138
173
  pwn_env_path: pwn_env_path,
139
174
  pwn_dec_path: pwn_dec_path
@@ -145,7 +180,16 @@ module PWN
145
180
  )
146
181
 
147
182
  Pry.config.refresh_pwn_env = false if defined?(Pry)
183
+ env[:pwn_skills_path] = pwn_skills_path
184
+ PWN::Config.load_skills(pwn_skills_path: pwn_skills_path)
185
+
186
+ # Hermes-equivalent memory/sessions/cron paths (pwn-ai agent)
187
+ env[:pwn_memory_path] = PWN::Memory::MEMORY_FILE if defined?(PWN::Memory)
188
+ env[:pwn_sessions_path] = PWN::Sessions.sessions_dir if defined?(PWN::Sessions)
189
+ env[:pwn_cron_path] = PWN::Cron.cron_dir if defined?(PWN::Cron)
190
+
148
191
  PWN.send(:remove_const, :Env) if PWN.const_defined?(:Env)
192
+
149
193
  PWN.const_set(:Env, env.freeze)
150
194
  rescue StandardError => e
151
195
  raise e
@@ -195,9 +239,13 @@ module PWN
195
239
 
196
240
  public_class_method def self.refresh_env(opts = {})
197
241
  pwn_env_root = "#{Dir.home}/.pwn"
242
+ pwn_env_path = opts[:pwn_env_path] ||= "#{pwn_env_root}/pwn.yaml"
243
+ pwn_env_root = File.dirname(pwn_env_path)
198
244
  FileUtils.mkdir_p(pwn_env_root)
199
245
 
200
- pwn_env_path = opts[:pwn_env_path] ||= "#{pwn_env_root}/pwn.yaml"
246
+ pwn_skills_path = File.join(pwn_env_root, 'skills')
247
+ FileUtils.mkdir_p(pwn_skills_path)
248
+
201
249
  return default_env(pwn_env_path: pwn_env_path) unless File.exist?(pwn_env_path)
202
250
 
203
251
  is_encrypted = PWN::Plugins::Vault.file_encrypted?(file: pwn_env_path)
@@ -226,9 +274,14 @@ module PWN
226
274
  raise "ERROR: Unsupported AI Engine: #{engine} in #{pwn_env_path}. Supported AI Engines:\n#{valid_ai_engines.inspect}" unless valid_ai_engines.include?(engine)
227
275
 
228
276
  key = env[:ai][engine][:key]
229
- if key.nil?
277
+ oauth_access = nil
278
+ if engine == :grok
279
+ oauth = env[:ai][engine][:oauth] ||= {}
280
+ oauth_access = oauth[:access_token] if oauth[:access_token] && !oauth[:access_token].to_s.match?(/optional/i) && !oauth[:access_token].to_s.empty?
281
+ end
282
+ if key.nil? && oauth_access.nil?
230
283
  key = PWN::Plugins::AuthenticationHelper.mask_password(
231
- prompt: "#{engine} API Key"
284
+ prompt: "#{engine} API Key (or configure oauth:access_token in pwn-vault for xAI SuperGrok subscriptions)"
232
285
  )
233
286
  env[:ai][engine][:key] = key
234
287
  end
@@ -256,11 +309,23 @@ module PWN
256
309
  pwn_dec_path: pwn_dec_path
257
310
  }
258
311
 
312
+ # Make pwn-ai (Hermes agent TUI equiv) aware of skills folder in pwn_env parent (before freeze)
313
+ env[:pwn_skills_path] = pwn_skills_path if defined?(pwn_skills_path)
314
+ PWN::Config.load_skills(pwn_skills_path: pwn_skills_path) if defined?(pwn_skills_path)
315
+
316
+ # Hermes-equivalent: memory, sessions, cron for pwn-ai agent (before freeze)
317
+ env[:pwn_memory_path] = PWN::Memory::MEMORY_FILE if defined?(PWN::Memory)
318
+ PWN::Memory.load if defined?(PWN::Memory)
319
+ env[:pwn_sessions_path] = PWN::Sessions.sessions_dir if defined?(PWN::Sessions)
320
+ env[:pwn_cron_path] = PWN::Cron.cron_dir if defined?(PWN::Cron)
321
+
259
322
  # Assign the refreshed env to PWN::Env
323
+
260
324
  PWN.send(:remove_const, :Env) if PWN.const_defined?(:Env)
261
325
  PWN.const_set(:Env, env.freeze)
262
326
 
263
327
  # Redact sensitive artifacts from PWN::Env and store in PWN::EnvRedacted
328
+
264
329
  env_redacted = redact_sensitive_artifacts(config: env)
265
330
  PWN.send(:remove_const, :EnvRedacted) if PWN.const_defined?(:EnvRedacted)
266
331
  PWN.const_set(:EnvRedacted, env_redacted.freeze)
@@ -272,6 +337,79 @@ module PWN
272
337
  raise e
273
338
  end
274
339
 
340
+ # Supported Method Parameters::
341
+ # pwn_skills_path = PWN::Config.pwn_skills_path(
342
+ # pwn_env_path: 'optional - Path to pwn.yaml file. Defaults to ~/.pwn/pwn.yaml'
343
+ # )
344
+ public_class_method def self.pwn_skills_path(opts = {})
345
+ pwn_env_path = opts[:pwn_env_path] ||= "#{Dir.home}/.pwn/pwn.yaml"
346
+ File.join(File.dirname(pwn_env_path), 'skills')
347
+ end
348
+
349
+ # Supported Method Parameters::
350
+ # skills = PWN::Config.load_skills(
351
+ # pwn_skills_path: 'optional - Path to skills folder. Defaults to ~/.pwn/skills'
352
+ # )
353
+ #
354
+ # Loads instruction-based skills (.md, .txt, .skill, .yaml) and executable Ruby skills (.rb)
355
+ # into PWN::Skills constant (hash of basename => {type, path, content, loaded?}).
356
+ # The pwn-ai command (REPL driver) loads and is aware of this folder to expand
357
+ # autonomous agent capabilities (equivalent to Hermes agent TUI skills for task execution).
358
+ public_class_method def self.load_skills(opts = {})
359
+ pwn_skills_path = opts[:pwn_skills_path] || PWN::Env[:pwn_skills_path] || pwn_skills_path
360
+ FileUtils.mkdir_p(pwn_skills_path) if pwn_skills_path && !Dir.exist?(pwn_skills_path.to_s)
361
+
362
+ skills = {}
363
+ return skills unless pwn_skills_path && Dir.exist?(pwn_skills_path.to_s)
364
+
365
+ Dir.glob(File.join(pwn_skills_path, '*.{rb,md,txt,skill,yml,yaml}')).each do |skill_file|
366
+ basename = File.basename(skill_file, '.*').to_sym
367
+ content = File.read(skill_file)
368
+ ext = File.extname(skill_file).downcase
369
+
370
+ if ext == '.rb'
371
+ begin
372
+ require skill_file
373
+ skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: true }
374
+ rescue StandardError => e
375
+ skills[basename] = { type: :ruby, path: skill_file, content: content, loaded: false, error: e.message }
376
+ end
377
+ else
378
+ skills[basename] = { type: :instruction, path: skill_file, content: content }
379
+ end
380
+ end
381
+
382
+ PWN.send(:remove_const, :Skills) if PWN.const_defined?(:Skills)
383
+ PWN.const_set(:Skills, skills.freeze)
384
+ skills
385
+ rescue StandardError => e
386
+ raise e
387
+ end
388
+
389
+ # Supported Method Parameters::
390
+ # path = PWN::Config.pwn_memory_path
391
+ public_class_method def self.pwn_memory_path
392
+ defined?(PWN::Memory) ? PWN::Memory::MEMORY_FILE : File.join(Dir.home, '.pwn', 'memory.json')
393
+ end
394
+
395
+ # Supported Method Parameters::
396
+ # PWN::Config.load_memory
397
+ public_class_method def self.load_memory
398
+ defined?(PWN::Memory) ? PWN::Memory.load : {}
399
+ end
400
+
401
+ # Supported Method Parameters::
402
+ # path = PWN::Config.pwn_sessions_path
403
+ public_class_method def self.pwn_sessions_path
404
+ defined?(PWN::Sessions) ? PWN::Sessions.sessions_dir : File.join(Dir.home, '.pwn', 'sessions')
405
+ end
406
+
407
+ # Supported Method Parameters::
408
+ # path = PWN::Config.pwn_cron_path
409
+ public_class_method def self.pwn_cron_path
410
+ defined?(PWN::Cron) ? PWN::Cron.cron_dir : File.join(Dir.home, '.pwn', 'cron')
411
+ end
412
+
275
413
  # Author(s):: 0day Inc. <support@0dayinc.com>
276
414
 
277
415
  public_class_method def self.authors
data/lib/pwn/cron.rb ADDED
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require 'securerandom'
7
+
8
+ module PWN
9
+ # PWN::Cron provides cron / scheduled task management for the pwn-ai agent
10
+ # (equivalent to Hermes cron jobs + scheduler).
11
+ # Jobs are defined in ~/.pwn/cron/jobs.yml and can be triggered by system
12
+ # cron, manual run, or from within pwn-ai agent loops.
13
+ #
14
+ # Each job can contain a prompt (for pwn-ai), a ruby script snippet, or
15
+ # reference to external script. Delivery can be 'log' (default), 'email', etc.
16
+ # (email would require additional plugins).
17
+ module Cron
18
+ CRON_DIR = File.join(Dir.home, '.pwn', 'cron')
19
+ JOBS_FILE = File.join(CRON_DIR, 'jobs.yml')
20
+
21
+ # Supported Method Parameters::
22
+ # dir = PWN::Cron.cron_dir
23
+ public_class_method def self.cron_dir
24
+ FileUtils.mkdir_p(CRON_DIR)
25
+ CRON_DIR
26
+ end
27
+
28
+ # Supported Method Parameters::
29
+ # jobs = PWN::Cron.list
30
+ public_class_method def self.list
31
+ load_jobs
32
+ end
33
+
34
+ # Supported Method Parameters::
35
+ # job = PWN::Cron.create(
36
+ # name: 'optional',
37
+ # schedule: 'required e.g. "0 * * * *" or "30m" or "every 2h"',
38
+ # prompt: 'optional - pwn-ai prompt to run',
39
+ # ruby: 'optional - ruby snippet to eval',
40
+ # script: 'optional - path to external script',
41
+ # delivery: 'log|stdout (default log)',
42
+ # enabled: true
43
+ # )
44
+ public_class_method def self.create(opts = {})
45
+ jobs = load_jobs
46
+ id = SecureRandom.hex(6)
47
+ name = opts[:name] || "job-#{id}"
48
+ job = {
49
+ id: id,
50
+ name: name,
51
+ schedule: opts[:schedule] || '0 * * * *',
52
+ prompt: opts[:prompt],
53
+ ruby: opts[:ruby],
54
+ script: opts[:script],
55
+ delivery: opts[:delivery] || 'log',
56
+ enabled: opts.fetch(:enabled, true),
57
+ created_at: Time.now.utc.iso8601,
58
+ last_run: nil,
59
+ last_status: nil
60
+ }
61
+ jobs[id] = job
62
+ save_jobs(jobs)
63
+
64
+ # Optionally install a crontab entry (user must have permission)
65
+ install_crontab_entry(job) if opts[:install_crontab]
66
+
67
+ job
68
+ end
69
+
70
+ # Supported Method Parameters::
71
+ # PWN::Cron.run(id: 'required or name')
72
+ # Executes the job (for pwn-ai prompt it will use current active AI engine
73
+ # via PWN::AI::* but without full REPL hook unless in pwn-ai).
74
+ public_class_method def self.run(opts = {})
75
+ id = opts[:id]
76
+ jobs = load_jobs
77
+ job = jobs[id] || jobs.values.find { |j| j[:name] == id || j[:id] == id }
78
+ raise "Job #{id} not found" unless job
79
+
80
+ start = Time.now
81
+ result = nil
82
+ status = 'success'
83
+
84
+ begin
85
+ if job[:prompt]
86
+ engine = begin
87
+ PWN::Env[:ai][:active].to_s.downcase.to_sym
88
+ rescue StandardError
89
+ :grok
90
+ end
91
+ case engine
92
+ when :grok
93
+ result = PWN::AI::Grok.chat(request: job[:prompt], spinner: false)
94
+ when :ollama
95
+ result = PWN::AI::Ollama.chat(request: job[:prompt], spinner: false)
96
+ when :openai
97
+ result = PWN::AI::OpenAI.chat(request: job[:prompt], spinner: false)
98
+ when :anthropic
99
+ result = PWN::AI::Anthropic.chat(request: job[:prompt], spinner: false)
100
+ end
101
+ result = begin
102
+ result[:choices].last[:content]
103
+ rescue StandardError
104
+ result.to_s
105
+ end
106
+ elsif job[:ruby]
107
+ result = eval(job[:ruby], TOPLEVEL_BINDING) # rubocop:disable Security/Eval
108
+ elsif job[:script] && File.exist?(job[:script])
109
+ result = `#{job[:script]} 2>&1`
110
+ else
111
+ result = 'No prompt/ruby/script defined'
112
+ end
113
+
114
+ if job[:delivery] == 'log'
115
+ log_path = File.join(cron_dir, "#{job[:id]}.log")
116
+ File.open(log_path, 'a') do |f|
117
+ f.puts("[#{Time.now}] RUN #{job[:name]} (#{job[:id]})\n#{result}\n---")
118
+ end
119
+ end
120
+ rescue StandardError => e
121
+ status = 'error'
122
+ result = "ERROR: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}"
123
+ end
124
+
125
+ job[:last_run] = Time.now.utc.iso8601
126
+ job[:last_status] = status
127
+ jobs[job[:id]] = job
128
+ save_jobs(jobs)
129
+
130
+ { job: job, result: result, duration: Time.now - start, status: status }
131
+ end
132
+
133
+ # Supported Method Parameters::
134
+ # PWN::Cron.remove(id:)
135
+ public_class_method def self.remove(opts = {}) # rubocop:disable Naming/PredicateMethod
136
+ id = opts[:id]
137
+ jobs = load_jobs
138
+ jobs.delete(id)
139
+ save_jobs(jobs)
140
+ true
141
+ end
142
+
143
+ # Supported Method Parameters::
144
+ # PWN::Cron.enable/disable(id:)
145
+ public_class_method def self.enable(opts = {})
146
+ toggle(opts[:id], true)
147
+ end
148
+
149
+ public_class_method def self.disable(opts = {})
150
+ toggle(opts[:id], false)
151
+ end
152
+
153
+ # Install a crontab line that invokes this job via pwn
154
+ # (assumes /opt/pwn and rvm ruby-4.0.1@pwn - user can edit crontab)
155
+ public_class_method def self.install_crontab_entry(job)
156
+ cron_line = "#{job[:schedule]} cd /opt/pwn && /usr/local/rvm/bin/rvm ruby-4.0.1@pwn do ruby -I lib -e 'require \"pwn\"; PWN::Cron.run(id: \"#{job[:id]}\")' >> #{File.join(cron_dir, 'cron.log')} 2>&1"
157
+ # Append to user's crontab (non-destructive)
158
+ existing = `crontab -l 2>/dev/null || true`
159
+ unless existing.include?(job[:id])
160
+ new_cron = existing + "\n# pwn-cron #{job[:name]} (#{job[:id]})\n#{cron_line}\n"
161
+ IO.popen('crontab -', 'w') { |io| io.write(new_cron) }
162
+ end
163
+ cron_line
164
+ end
165
+
166
+ private_class_method def self.load_jobs
167
+ FileUtils.mkdir_p(cron_dir)
168
+ return {} unless File.exist?(JOBS_FILE)
169
+
170
+ YAML.safe_load_file(JOBS_FILE, symbolize_names: true) || {}
171
+ rescue StandardError
172
+ {}
173
+ end
174
+
175
+ private_class_method def self.save_jobs(jobs)
176
+ File.write(JOBS_FILE, YAML.dump(jobs))
177
+ end
178
+
179
+ private_class_method def self.toggle(id, enabled)
180
+ jobs = load_jobs
181
+ if jobs[id]
182
+ jobs[id][:enabled] = enabled
183
+ save_jobs(jobs)
184
+ end
185
+ jobs[id]
186
+ end
187
+
188
+ # Author(s):: 0day Inc. <support@0dayinc.com>
189
+
190
+ public_class_method def self.authors
191
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
192
+ end
193
+
194
+ # Display Usage for this Module
195
+ public_class_method def self.help
196
+ puts <<~USAGE
197
+ USAGE:
198
+ PWN::Cron.create(schedule: '0 * * * *', prompt: 'Run daily recon on target.com using NmapIt and report', name: 'daily-recon')
199
+ PWN::Cron.list
200
+ res = PWN::Cron.run(id: 'abc123')
201
+ PWN::Cron.enable(id: 'abc123')
202
+ PWN::Cron.disable(id: 'abc123')
203
+ PWN::Cron.remove(id: 'abc123')
204
+ # To have system cron call it, use install_crontab_entry or the :install_crontab option on create
205
+
206
+ #{self}.authors
207
+ USAGE
208
+ end
209
+ end
210
+ end
data/lib/pwn/driver.rb CHANGED
@@ -30,6 +30,15 @@ module PWN
30
30
  ) do |o|
31
31
  @opts[:pwn_dec_path] = o
32
32
  end
33
+
34
+ on_tail(
35
+ '-v',
36
+ '--version',
37
+ 'Print PWN version and exit'
38
+ ) do
39
+ puts PWN::VERSION
40
+ exit 0
41
+ end
33
42
  end
34
43
 
35
44
  def parse!
data/lib/pwn/memory.rb ADDED
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ module PWN
8
+ # PWN::Memory provides persistent cross-session memory for the pwn-ai agent
9
+ # (equivalent to Hermes agent memory providers). Facts, user preferences,
10
+ # environment details, lessons learned, and task state are stored in
11
+ # ~/.pwn/memory.json and survive across REPL restarts / pwn-ai sessions.
12
+ #
13
+ # The pwn-ai agent (in agent mode) automatically receives relevant memory
14
+ # injected into its system prompt. The agent can also call remember/recall
15
+ # via ruby code blocks during execution loops.
16
+ module Memory
17
+ MEMORY_FILE = File.join(Dir.home, '.pwn', 'memory.json')
18
+
19
+ # Supported Method Parameters::
20
+ # memory = PWN::Memory.load
21
+ public_class_method def self.load
22
+ FileUtils.mkdir_p(File.dirname(MEMORY_FILE))
23
+ return {} unless File.exist?(MEMORY_FILE)
24
+
25
+ JSON.parse(File.read(MEMORY_FILE), symbolize_names: true)
26
+ rescue StandardError
27
+ {}
28
+ end
29
+
30
+ # Supported Method Parameters::
31
+ # PWN::Memory.save(memory_hash)
32
+ public_class_method def self.save(mem = {})
33
+ FileUtils.mkdir_p(File.dirname(MEMORY_FILE))
34
+ File.write(MEMORY_FILE, JSON.pretty_generate(mem))
35
+ mem
36
+ end
37
+
38
+ # Supported Method Parameters::
39
+ # PWN::Memory.remember(
40
+ # key: 'required - Symbol or String key for the memory fact',
41
+ # value: 'required - The value (any JSON serializable)',
42
+ # category: 'optional - e.g. :fact, :preference, :lesson, :env (default: :fact)'
43
+ # )
44
+ public_class_method def self.remember(opts = {})
45
+ key = opts[:key]
46
+ value = opts[:value]
47
+ category = opts[:category] || :fact
48
+
49
+ raise 'ERROR: key and value are required' if key.nil? || value.nil?
50
+
51
+ mem = load
52
+ mem[key.to_sym] = {
53
+ value: value,
54
+ category: category.to_sym,
55
+ timestamp: Time.now.utc.iso8601,
56
+ source: 'pwn-ai'
57
+ }
58
+ save(mem)
59
+ mem[key.to_sym]
60
+ end
61
+
62
+ # Supported Method Parameters::
63
+ # results = PWN::Memory.recall(
64
+ # query: 'optional - string to search keys/values/categories (simple match)',
65
+ # category: 'optional - filter by category',
66
+ # limit: 'optional - max results (default 50)'
67
+ # )
68
+ public_class_method def self.recall(opts = {})
69
+ query = opts[:query].to_s.downcase
70
+ category = opts[:category]
71
+ limit = opts[:limit] || 50
72
+
73
+ mem = load
74
+ results = mem.select do |k, v|
75
+ match = true
76
+ match &&= k.to_s.downcase.include?(query) || v[:value].to_s.downcase.include?(query) || v[:category].to_s.downcase.include?(query) if query && !query.empty?
77
+ match &&= (v[:category] == category.to_sym) if category
78
+ match
79
+ end
80
+
81
+ results.to_a.first(limit).to_h
82
+ end
83
+
84
+ # Supported Method Parameters::
85
+ # PWN::Memory.forget(key)
86
+ public_class_method def self.forget(key) # rubocop:disable Naming/PredicateMethod
87
+ mem = load
88
+ mem.delete(key.to_sym)
89
+ save(mem)
90
+ true
91
+ end
92
+
93
+ # Supported Method Parameters::
94
+ # PWN::Memory.clear
95
+ public_class_method def self.clear
96
+ FileUtils.rm_f(MEMORY_FILE)
97
+ {}
98
+ end
99
+
100
+ # Supported Method Parameters::
101
+ # context = PWN::Memory.to_context(limit: 20)
102
+ # (used internally by pwn-ai hook to inject into system prompt)
103
+ public_class_method def self.to_context(opts = {})
104
+ limit = opts[:limit] || 20
105
+ mem = recall(limit: limit)
106
+ return '' if mem.empty?
107
+
108
+ ctx = "\n\nPERSISTENT MEMORY (cross-session facts, prefs, lessons - use PWN::Memory.remember to store new ones):\n"
109
+ mem.each do |k, v|
110
+ ctx += "- #{k} [#{v[:category]} @ #{v[:timestamp]}]: #{v[:value].to_s[0, 300]}\n"
111
+ end
112
+ ctx
113
+ end
114
+
115
+ # Author(s):: 0day Inc. <support@0dayinc.com>
116
+
117
+ public_class_method def self.authors
118
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
119
+ end
120
+
121
+ # Display Usage for this Module
122
+ public_class_method def self.help
123
+ puts <<~USAGE
124
+ USAGE:
125
+ mem = PWN::Memory.load
126
+ PWN::Memory.remember(key: :user_prefers_ruby, value: 'Always prefer pure Ruby + RestClient patterns', category: :preference)
127
+ facts = PWN::Memory.recall(query: 'recon', category: :fact, limit: 10)
128
+ PWN::Memory.forget(:some_key)
129
+ PWN::Memory.clear
130
+ context_str = PWN::Memory.to_context
131
+
132
+ #{self}.authors
133
+ USAGE
134
+ end
135
+ end
136
+ end