active_harness 0.2.24 → 0.2.25

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2fe80bd5361b92a9c034f2dae4d069f381620867140dd7242d3a6ba24f3a429
4
- data.tar.gz: 6cb9aaa5d2a388f8f79fe5794623fe8f0e49f4e19bffb352089e1069e8a8a6b0
3
+ metadata.gz: c2a3e44b6a9f63f595ee483620a0aac1080825ce3f1d585696930db1be0991f7
4
+ data.tar.gz: 28382baad847f75cfdc4613c143b325d9cd4b8b31913df2812e76ff33dbd80bf
5
5
  SHA512:
6
- metadata.gz: 56050efdb3f23d189fbca002200699b6c98b7419eb5ec428b52ff5e9149065c4fa5d01822c448e632de23ce4bf291c5c50f80207ff46d6aa449bb0d01c5c1ba8
7
- data.tar.gz: 0f3b7726fa25f362d2d660c9f5474255da6c45db0ad26b03a24245e7973f64d87cfe78793e7a75fb80014c6a109a0b3ac33df134e65dabde9863d037b6760a1b
6
+ metadata.gz: f0b0422ecb620e3d9749133d17dcfd66b0328cbaf60ebe17b8c835ceb8be94fdb7c94a3b980afa85a4251ba4b42acae05bb9ad5250c8ab3c9def0e92e9094ff6
7
+ data.tar.gz: 5e2502fbc8ad9cf559c9cd9aecb8ea8e78abc330f442991e99a21e8cf7a3b1aa4682f3e926b3632157932ee32f1549a3e5861764b4883f7271154d81ad4dbbdb
@@ -4,7 +4,7 @@ require "fileutils"
4
4
  module ActiveHarness
5
5
  class Memory
6
6
  module Adapter
7
- # Persists memory as JSON files on disk.
7
+ # File-backed memory adapter — stores turns as JSON on disk.
8
8
  #
9
9
  # Each session is stored in one file:
10
10
  # <path>/<session_id>.json (no namespace)
@@ -19,21 +19,21 @@ module ActiveHarness
19
19
  # storage_size — max turns kept in file (default: 1000)
20
20
  # eviction_percent — % of oldest turns to drop (default: 10)
21
21
  # on_trim — Proc called with trimmed turns (default: nil)
22
- class File < Base
23
- DEFAULT_PATH = "storage/ai/memory"
24
- DEFAULT_STORAGE_SIZE = 1000
22
+ class JsonFile < Base
23
+ DEFAULT_PATH = "storage/ai/memory"
24
+ DEFAULT_STORAGE_SIZE = 1000
25
25
  DEFAULT_TRIM_PERCENT = 10
26
26
 
27
27
  def initialize(opts = {})
28
- @path = opts.fetch(:path, DEFAULT_PATH)
29
- @filename_opt = opts[:filename]
30
- @pretty = opts.fetch(:pretty, false)
31
- @compact = opts.fetch(:compact, false)
32
- @encoding = opts.fetch(:encoding, "UTF-8")
33
- @storage_size = opts.fetch(:storage_size, DEFAULT_STORAGE_SIZE)
28
+ @path = opts.fetch(:path, DEFAULT_PATH)
29
+ @filename_opt = opts[:filename]
30
+ @pretty = opts.fetch(:pretty, false)
31
+ @compact = opts.fetch(:compact, false)
32
+ @encoding = opts.fetch(:encoding, "UTF-8")
33
+ @storage_size = opts.fetch(:storage_size, DEFAULT_STORAGE_SIZE)
34
34
  @trim_percent = opts.fetch(:eviction_percent, DEFAULT_TRIM_PERCENT)
35
- @on_trim = opts[:on_trim]
36
- @namespace = opts[:namespace]
35
+ @on_trim = opts[:on_trim]
36
+ @namespace = opts[:namespace]
37
37
 
38
38
  @session_id = nil
39
39
  @turns = []
@@ -55,13 +55,12 @@ module ActiveHarness
55
55
  end
56
56
 
57
57
  def close
58
- # File adapter writes immediately on each write, nothing to flush.
58
+ # Writes immediately on each write nothing to flush.
59
59
  end
60
60
 
61
61
  def delete
62
62
  path = file_path
63
63
  ::FileUtils.rm_f(path)
64
- # remove parent dir only if it's a namespace dir and now empty
65
64
  dir = ::File.dirname(path)
66
65
  if @namespace && Dir.exist?(dir) && Dir.empty?(dir)
67
66
  Dir.rmdir(dir)
@@ -77,8 +76,6 @@ module ActiveHarness
77
76
  name = resolve_filename
78
77
  if @namespace
79
78
  ::File.join(@path, @session_id.to_s, "#{@namespace}.json")
80
- elsif @filename_opt
81
- ::File.join(@path, name)
82
79
  else
83
80
  ::File.join(@path, name)
84
81
  end
@@ -100,7 +97,6 @@ module ActiveHarness
100
97
  data = JSON.parse(raw, symbolize_names: true)
101
98
  turns = Array(data[:turns])
102
99
 
103
- # Normalise compact format (q/a) to full format (request/response)
104
100
  turns.map do |t|
105
101
  if t.key?(:q)
106
102
  { request: t[:q], response: t[:a] }
@@ -137,5 +133,41 @@ module ActiveHarness
137
133
  end
138
134
  end
139
135
  end
136
+
137
+ # Convenience Memory subclass for file-backed storage.
138
+ #
139
+ # file_name: replaces session_id — may contain slashes to create
140
+ # subdirectories under storage_path, e.g. "users/42/chat"
141
+ # Final file is always <storage_path>/<file_name>.json
142
+ # storage_path: base directory (default: "storage/ai/memory")
143
+ #
144
+ # Path traversal is rejected: segments equal to "." or ".." or containing
145
+ # null bytes raise ArgumentError before any file I/O happens.
146
+ # Missing directories are created automatically on the first write.
147
+ class JsonFile < Memory
148
+ def initialize(file_name:, storage_path: Adapter::JsonFile::DEFAULT_PATH, **opts)
149
+ super(
150
+ session_id: sanitize!(file_name),
151
+ adapter: Adapter::JsonFile.new(opts.merge(path: storage_path)),
152
+ **opts
153
+ )
154
+ end
155
+
156
+ private
157
+
158
+ def sanitize!(raw)
159
+ parts = raw.to_s.split("/").map(&:strip).reject(&:empty?)
160
+ raise ArgumentError, "file_name must not be empty" if parts.empty?
161
+
162
+ parts.each do |part|
163
+ if part == ".." || part == "." || part.include?("\0")
164
+ raise ArgumentError, "Invalid file_name segment: #{part.inspect}"
165
+ end
166
+ end
167
+
168
+ parts.last.sub!(/\.json\z/i, "")
169
+ parts.join("/")
170
+ end
171
+ end
140
172
  end
141
173
  end
@@ -0,0 +1,212 @@
1
+ require "json"
2
+
3
+ module ActiveHarness
4
+ class Memory
5
+ module Adapter
6
+ # PostgreSQL-backed memory adapter.
7
+ #
8
+ # Requires the 'pg' gem — add it to your Gemfile yourself:
9
+ # gem 'pg'
10
+ #
11
+ # Connection options (one of):
12
+ # connection: — a PG::Connection instance you manage
13
+ # url: — connection string; adapter opens and closes the connection
14
+ # host:, port:, dbname:, user:, password: — adapter opens and closes the connection
15
+ #
16
+ # Storage options:
17
+ # table_name: — default "active_harness_memory_turns"
18
+ # storage_size: — max turns per session kept in the table (default 1000)
19
+ # eviction_percent — % of oldest turns to drop when limit is hit (default 10)
20
+ # on_trim: — Proc called with evicted turns
21
+ # namespace: — isolates turns within a session
22
+ class Postgresql < Base
23
+ DEFAULT_TABLE = "active_harness_memory_turns"
24
+ DEFAULT_STORAGE_SIZE = 1000
25
+ DEFAULT_TRIM_PERCENT = 10
26
+
27
+ TABLE_NAME_RE = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
28
+
29
+ def initialize(opts = {})
30
+ @table = opts.fetch(:table_name, DEFAULT_TABLE).to_s
31
+ @storage_size = opts.fetch(:storage_size, DEFAULT_STORAGE_SIZE)
32
+ @trim_percent = opts.fetch(:eviction_percent, DEFAULT_TRIM_PERCENT)
33
+ @on_trim = opts[:on_trim]
34
+ @namespace = opts[:namespace]
35
+
36
+ unless @table.match?(TABLE_NAME_RE)
37
+ raise ArgumentError, "Invalid table_name: #{@table.inspect}"
38
+ end
39
+
40
+ # Connection source — one of: instance, url, keyword args.
41
+ @borrowed_conn = opts[:connection]
42
+ @conn_url = opts[:url]
43
+ @conn_kwargs = opts.slice(:host, :port, :dbname, :user, :password)
44
+
45
+ @owned_conn = nil
46
+ @session_id = nil
47
+ @turns = []
48
+ end
49
+
50
+ def open(session_id)
51
+ @session_id = session_id
52
+ ensure_connection!
53
+ @turns = fetch_turns
54
+ end
55
+
56
+ def read
57
+ @turns.dup
58
+ end
59
+
60
+ def write(turn)
61
+ insert_turn(turn)
62
+ @turns << turn
63
+ trim_if_needed!
64
+ end
65
+
66
+ def close
67
+ if @owned_conn
68
+ @owned_conn.close rescue nil
69
+ @owned_conn = nil
70
+ end
71
+ end
72
+
73
+ def delete
74
+ conn.exec_params(
75
+ "DELETE FROM #{@table} " \
76
+ "WHERE session_id = $1 AND (namespace IS NOT DISTINCT FROM $2)",
77
+ [@session_id, @namespace]
78
+ )
79
+ @turns = []
80
+ end
81
+
82
+ # -----------------------------------------------------------------------
83
+ private
84
+ # -----------------------------------------------------------------------
85
+
86
+ def conn
87
+ @borrowed_conn || @owned_conn ||
88
+ raise("PostgreSQL connection not open — call open(session_id) first")
89
+ end
90
+
91
+ def ensure_connection!
92
+ return if @borrowed_conn || @owned_conn
93
+
94
+ load_pg!
95
+
96
+ @owned_conn =
97
+ if @conn_url
98
+ PG.connect(@conn_url)
99
+ elsif @conn_kwargs.any?
100
+ PG.connect(**@conn_kwargs)
101
+ else
102
+ raise ArgumentError,
103
+ "PostgreSQL adapter requires one of: connection:, url:, " \
104
+ "or connection keyword args (host:, port:, dbname:, user:, password:)"
105
+ end
106
+ end
107
+
108
+ def load_pg!
109
+ require "pg"
110
+ rescue LoadError
111
+ raise LoadError,
112
+ "The 'pg' gem is required for the PostgreSQL memory adapter. " \
113
+ "Add it to your Gemfile: gem 'pg'"
114
+ end
115
+
116
+ def fetch_turns
117
+ result = conn.exec_params(
118
+ "SELECT request, response, meta " \
119
+ "FROM #{@table} " \
120
+ "WHERE session_id = $1 AND (namespace IS NOT DISTINCT FROM $2) " \
121
+ "ORDER BY id ASC",
122
+ [@session_id, @namespace]
123
+ )
124
+
125
+ result.map do |row|
126
+ turn = { request: row["request"], response: row["response"] }
127
+ meta = JSON.parse(row["meta"] || "{}", symbolize_names: true)
128
+ meta.empty? ? turn : turn.merge(meta)
129
+ end
130
+ end
131
+
132
+ def insert_turn(turn)
133
+ meta = turn.reject { |k, _| k == :request || k == :response }
134
+
135
+ conn.exec_params(
136
+ "INSERT INTO #{@table} (session_id, namespace, request, response, meta) " \
137
+ "VALUES ($1, $2, $3, $4, $5::jsonb)",
138
+ [
139
+ @session_id,
140
+ @namespace,
141
+ turn[:request].to_s,
142
+ turn[:response].to_s,
143
+ JSON.generate(meta)
144
+ ]
145
+ )
146
+ end
147
+
148
+ def trim_if_needed!
149
+ return unless @storage_size
150
+ return if @turns.size <= @storage_size
151
+
152
+ to_delete = [@turns.size * @trim_percent / 100, 1].max
153
+
154
+ if @on_trim
155
+ trimmed = @turns.first(to_delete)
156
+ @on_trim.call(trimmed)
157
+ end
158
+
159
+ conn.exec_params(
160
+ "DELETE FROM #{@table} WHERE id IN (" \
161
+ " SELECT id FROM #{@table} " \
162
+ " WHERE session_id = $1 AND (namespace IS NOT DISTINCT FROM $2) " \
163
+ " ORDER BY id ASC LIMIT $3" \
164
+ ")",
165
+ [@session_id, @namespace, to_delete]
166
+ )
167
+
168
+ @turns.shift(to_delete)
169
+ end
170
+ end
171
+ end
172
+
173
+ # Convenience Memory subclass for PostgreSQL-backed storage.
174
+ #
175
+ # Requires the 'pg' gem installed by the application:
176
+ # gem 'pg'
177
+ #
178
+ # Usage — plain Ruby (adapter owns the connection):
179
+ # mem = ActiveHarness::Memory::Postgresql.new(
180
+ # session_id: "user_42",
181
+ # url: ENV["DATABASE_URL"],
182
+ # depth: 10
183
+ # )
184
+ # mem.load
185
+ # # ... use ...
186
+ # mem.close
187
+ #
188
+ # Usage — Rails (borrow the AR raw connection):
189
+ # mem = ActiveHarness::Memory::Postgresql.new(
190
+ # session_id: "user_42",
191
+ # connection: ActiveRecord::Base.connection.raw_connection
192
+ # )
193
+ class Postgresql < Memory
194
+ def initialize(session_id:, namespace: nil, on_trim: nil, **opts)
195
+ mem_keys = %i[depth enabled read_only async]
196
+ mem_opts = opts.slice(*mem_keys)
197
+ pg_opts = opts.reject { |k, _| mem_keys.include?(k) }
198
+
199
+ pg_opts[:namespace] = namespace if namespace
200
+ pg_opts[:on_trim] = on_trim if on_trim
201
+
202
+ super(
203
+ session_id: session_id,
204
+ adapter: Adapter::Postgresql.new(pg_opts),
205
+ namespace: namespace,
206
+ on_trim: on_trim,
207
+ **mem_opts
208
+ )
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,233 @@
1
+ require "json"
2
+
3
+ module ActiveHarness
4
+ class Memory
5
+ module Adapter
6
+ # SQLite-backed memory adapter.
7
+ #
8
+ # Requires the 'sqlite3' gem — add it to your Gemfile yourself:
9
+ # gem 'sqlite3'
10
+ #
11
+ # Connection options (one of):
12
+ # database: — path to the SQLite file; adapter opens and closes the connection
13
+ # use ":memory:" for an in-process, non-persistent database
14
+ # connection: — an existing SQLite3::Database instance you manage
15
+ #
16
+ # Storage options:
17
+ # table_name: — default "active_harness_memory_turns"
18
+ # storage_size: — max turns per session kept in the table (default 1000)
19
+ # eviction_percent — % of oldest turns to drop when limit is hit (default 10)
20
+ # on_trim: — Proc called with evicted turns
21
+ # namespace: — isolates turns within a session
22
+ class Sqlite < Base
23
+ DEFAULT_TABLE = "active_harness_memory_turns"
24
+ DEFAULT_STORAGE_SIZE = 1000
25
+ DEFAULT_TRIM_PERCENT = 10
26
+
27
+ TABLE_NAME_RE = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
28
+
29
+ def initialize(opts = {})
30
+ @table = opts.fetch(:table_name, DEFAULT_TABLE).to_s
31
+ @storage_size = opts.fetch(:storage_size, DEFAULT_STORAGE_SIZE)
32
+ @trim_percent = opts.fetch(:eviction_percent, DEFAULT_TRIM_PERCENT)
33
+ @on_trim = opts[:on_trim]
34
+ @namespace = opts[:namespace]
35
+
36
+ unless @table.match?(TABLE_NAME_RE)
37
+ raise ArgumentError, "Invalid table_name: #{@table.inspect}"
38
+ end
39
+
40
+ @borrowed_conn = opts[:connection]
41
+ @db_path = opts[:database]
42
+ @owned_conn = nil
43
+ @session_id = nil
44
+ @turns = []
45
+ end
46
+
47
+ def open(session_id)
48
+ @session_id = session_id
49
+ ensure_connection!
50
+ @turns = fetch_turns
51
+ end
52
+
53
+ def read
54
+ @turns.dup
55
+ end
56
+
57
+ def write(turn)
58
+ insert_turn(turn)
59
+ @turns << turn
60
+ trim_if_needed!
61
+ end
62
+
63
+ def close
64
+ if @owned_conn
65
+ @owned_conn.close rescue nil
66
+ @owned_conn = nil
67
+ end
68
+ end
69
+
70
+ def delete
71
+ db.execute(
72
+ "DELETE FROM #{@table} WHERE session_id = ? AND (namespace IS ?)",
73
+ [@session_id, @namespace]
74
+ )
75
+ @turns = []
76
+ end
77
+
78
+ # -----------------------------------------------------------------------
79
+ private
80
+ # -----------------------------------------------------------------------
81
+
82
+ def db
83
+ @borrowed_conn || @owned_conn ||
84
+ raise("SQLite connection not open — call open(session_id) first")
85
+ end
86
+
87
+ def ensure_connection!
88
+ return if @borrowed_conn || @owned_conn
89
+
90
+ load_sqlite3!
91
+
92
+ unless @db_path
93
+ raise ArgumentError,
94
+ "SQLite adapter requires database: (file path or ':memory:')"
95
+ end
96
+
97
+ conn = SQLite3::Database.new(@db_path)
98
+ conn.execute("PRAGMA journal_mode=WAL")
99
+ @owned_conn = conn
100
+ end
101
+
102
+ def load_sqlite3!
103
+ require "sqlite3"
104
+ rescue LoadError
105
+ raise LoadError,
106
+ "The 'sqlite3' gem is required for the SQLite memory adapter. " \
107
+ "Add it to your Gemfile: gem 'sqlite3'"
108
+ end
109
+
110
+ def fetch_turns
111
+ rows = db.execute(
112
+ "SELECT request, response, meta " \
113
+ "FROM #{@table} " \
114
+ "WHERE session_id = ? AND (namespace IS ?) " \
115
+ "ORDER BY id ASC",
116
+ [@session_id, @namespace]
117
+ )
118
+ rows.map do |row|
119
+ # row is Array (default) or Hash (results_as_hash=true) — handle both
120
+ req, resp, meta_str =
121
+ row.is_a?(Hash) ? row.values_at("request", "response", "meta") : row
122
+ turn = { request: req, response: resp }
123
+ meta = JSON.parse(meta_str || "{}", symbolize_names: true)
124
+ meta.empty? ? turn : turn.merge(meta)
125
+ end
126
+ end
127
+
128
+ def insert_turn(turn)
129
+ meta = turn.reject { |k, _| k == :request || k == :response }
130
+ db.execute(
131
+ "INSERT INTO #{@table} (session_id, namespace, request, response, meta) " \
132
+ "VALUES (?, ?, ?, ?, ?)",
133
+ [
134
+ @session_id,
135
+ @namespace,
136
+ turn[:request].to_s,
137
+ turn[:response].to_s,
138
+ JSON.generate(meta)
139
+ ]
140
+ )
141
+ end
142
+
143
+ def trim_if_needed!
144
+ return unless @storage_size
145
+ return if @turns.size <= @storage_size
146
+
147
+ to_delete = [@turns.size * @trim_percent / 100, 1].max
148
+
149
+ if @on_trim
150
+ trimmed = @turns.first(to_delete)
151
+ @on_trim.call(trimmed)
152
+ end
153
+
154
+ db.execute(
155
+ "DELETE FROM #{@table} WHERE id IN (" \
156
+ " SELECT id FROM #{@table} " \
157
+ " WHERE session_id = ? AND (namespace IS ?) " \
158
+ " ORDER BY id ASC LIMIT ?" \
159
+ ")",
160
+ [@session_id, @namespace, to_delete]
161
+ )
162
+
163
+ @turns.shift(to_delete)
164
+ end
165
+ end
166
+ end
167
+
168
+ # Convenience Memory subclass for SQLite-backed storage.
169
+ #
170
+ # Usage — plain Ruby (adapter owns the connection):
171
+ # mem = ActiveHarness::Memory::Sqlite.new(
172
+ # session_id: "user_42",
173
+ # database: "storage/ai/memory.sqlite3",
174
+ # depth: 10
175
+ # )
176
+ # mem.load
177
+ # # ... use ...
178
+ # mem.close
179
+ #
180
+ # Usage — Rails (borrow AR raw connection for SQLite3 adapter):
181
+ # mem = ActiveHarness::Memory::Sqlite.new(
182
+ # session_id: "user_42",
183
+ # connection: ActiveRecord::Base.connection.raw_connection
184
+ # )
185
+ #
186
+ # Plain Ruby schema setup (run once before first use):
187
+ # ActiveHarness::Memory::Sqlite.create_schema!("storage/ai/memory.sqlite3")
188
+ class Sqlite < Memory
189
+ # SQL to create the schema — run once before first use in plain Ruby.
190
+ SCHEMA_SQL = <<~SQL.freeze
191
+ CREATE TABLE IF NOT EXISTS active_harness_memory_turns (
192
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
193
+ session_id TEXT NOT NULL,
194
+ namespace TEXT,
195
+ request TEXT NOT NULL,
196
+ response TEXT NOT NULL,
197
+ meta TEXT NOT NULL DEFAULT '{}',
198
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
199
+ );
200
+ CREATE INDEX IF NOT EXISTS idx_ah_memory_turns_session
201
+ ON active_harness_memory_turns (session_id, namespace, id);
202
+ SQL
203
+
204
+ # Create the schema on an existing database or a file path.
205
+ # Safe to call multiple times — uses CREATE TABLE IF NOT EXISTS.
206
+ def self.create_schema!(db_or_path)
207
+ require "sqlite3"
208
+ conn = db_or_path.is_a?(String) ? SQLite3::Database.new(db_or_path) : db_or_path
209
+ SCHEMA_SQL.split(";").map(&:strip).reject(&:empty?).each { |sql| conn.execute(sql) }
210
+ rescue LoadError
211
+ raise LoadError,
212
+ "The 'sqlite3' gem is required. Add it to your Gemfile: gem 'sqlite3'"
213
+ end
214
+
215
+ def initialize(session_id:, namespace: nil, on_trim: nil, **opts)
216
+ mem_keys = %i[depth enabled read_only async]
217
+ mem_opts = opts.slice(*mem_keys)
218
+ sq_opts = opts.reject { |k, _| mem_keys.include?(k) }
219
+
220
+ sq_opts[:namespace] = namespace if namespace
221
+ sq_opts[:on_trim] = on_trim if on_trim
222
+
223
+ super(
224
+ session_id: session_id,
225
+ adapter: Adapter::Sqlite.new(sq_opts),
226
+ namespace: namespace,
227
+ on_trim: on_trim,
228
+ **mem_opts
229
+ )
230
+ end
231
+ end
232
+ end
233
+ end
@@ -1,6 +1,7 @@
1
1
  require_relative "memory/adapter/base"
2
- require_relative "memory/adapter/file"
3
- require_relative "memory/json_file"
2
+ require_relative "memory/adapter/json_file"
3
+ require_relative "memory/adapter/postgresql"
4
+ require_relative "memory/adapter/sqlite"
4
5
 
5
6
  module ActiveHarness
6
7
  # Conversational memory for agents.
@@ -55,7 +56,7 @@ module ActiveHarness
55
56
  #
56
57
  class Memory
57
58
  ADAPTERS = {
58
- file: ->(**opts) { Adapter::File.new(**opts) }
59
+ json_file: ->(**opts) { Adapter::JsonFile.new(**opts) }
59
60
  }.freeze
60
61
 
61
62
  attr_reader :session_id
@@ -65,7 +66,7 @@ module ActiveHarness
65
66
  # -------------------------------------------------------------------------
66
67
  # session_id — required; uniquely identifies this conversation
67
68
  # depth — how many past turns to inject into messages (nil = all)
68
- # adapter — :file (default), or an adapter instance
69
+ # adapter — :json_file (default), or an adapter instance
69
70
  # enabled — false disables all reads and writes (no-op mode)
70
71
  # read_only — true: load history but never write new turns
71
72
  # namespace — isolates history per-agent within a session
@@ -75,7 +76,7 @@ module ActiveHarness
75
76
  def initialize(
76
77
  session_id:,
77
78
  depth: nil,
78
- adapter: :file,
79
+ adapter: :json_file,
79
80
  enabled: true,
80
81
  read_only: false,
81
82
  namespace: nil,
@@ -30,7 +30,7 @@ require_relative "active_harness/pipeline"
30
30
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
31
31
 
32
32
  module ActiveHarness
33
- VERSION = "0.2.24"
33
+ VERSION = "0.2.25"
34
34
 
35
35
  class << self
36
36
  # Configure ActiveHarness.
@@ -1,16 +1,16 @@
1
- class AppMemory < ActiveHarness::Memory
2
- # Usage: AppMemory.new(session_id: "user_42")
1
+ class AppMemory < ActiveHarness::Memory::JsonFile
2
+ # Usage: AppMemory.new(file_name: "users/42")
3
3
  #
4
- # Wraps ActiveHarness::Memory with project defaults so callers
5
- # only need to pass a session_id.
6
- def initialize(session_id:)
4
+ # Wraps ActiveHarness::Memory::JsonFile with project defaults so callers
5
+ # only need to pass a file_name. Slashes create subdirectories.
6
+ def initialize(file_name:, **opts)
7
7
  super(
8
- session_id: session_id,
8
+ file_name: file_name,
9
+ storage_path: Rails.root.join("storage", "ai", "memory").to_s,
9
10
  depth: 10,
10
- adapter: :file,
11
- path: Rails.root.join("storage", "ai", "memory").to_s,
12
11
  storage_size: 200,
13
- pretty: Rails.env.development?
12
+ pretty: Rails.env.development?,
13
+ **opts
14
14
  )
15
15
  end
16
16
  end
@@ -1,12 +1,12 @@
1
- class <%= class_name %>Memory < ActiveHarness::Memory
2
- def initialize(session_id:)
1
+ class <%= class_name %>Memory < ActiveHarness::Memory::JsonFile
2
+ def initialize(file_name:, **opts)
3
3
  super(
4
- session_id: session_id,
4
+ file_name: file_name,
5
+ storage_path: Rails.root.join("storage", "ai", "memory").to_s,
5
6
  depth: 10,
6
- adapter: :file,
7
- path: Rails.root.join("storage", "ai", "memory").to_s,
8
7
  storage_size: 200,
9
- pretty: Rails.env.development?
8
+ pretty: Rails.env.development?,
9
+ **opts
10
10
  )
11
11
  end
12
12
  end
@@ -0,0 +1,23 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module ActiveHarness
5
+ module Generators
6
+ class MemoryPostgresqlGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates the ActiveHarness PostgreSQL memory migration"
12
+
13
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def create_migration_file
18
+ migration_template "create_active_harness_memory_turns.rb.tt",
19
+ "db/migrate/create_active_harness_memory_turns.rb"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ class CreateActiveHarnessMemoryTurns < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :active_harness_memory_turns do |t|
4
+ t.text :session_id, null: false
5
+ t.text :namespace
6
+ t.text :request, null: false
7
+ t.text :response, null: false
8
+ t.jsonb :meta, null: false, default: {}
9
+ t.datetime :created_at, null: false, default: -> { "NOW()" }
10
+ end
11
+
12
+ add_index :active_harness_memory_turns,
13
+ [:session_id, :namespace, :id],
14
+ name: "idx_ah_memory_turns_session"
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module ActiveHarness
5
+ module Generators
6
+ class MemorySqliteGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates the ActiveHarness SQLite memory migration"
12
+
13
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def create_migration_file
18
+ migration_template "create_active_harness_memory_turns.rb.tt",
19
+ "db/migrate/create_active_harness_memory_turns.rb"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ class CreateActiveHarnessMemoryTurns < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :active_harness_memory_turns do |t|
4
+ t.text :session_id, null: false
5
+ t.text :namespace
6
+ t.text :request, null: false
7
+ t.text :response, null: false
8
+ t.text :meta, null: false, default: "{}"
9
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
10
+ end
11
+
12
+ add_index :active_harness_memory_turns,
13
+ [:session_id, :namespace, :id],
14
+ name: "idx_ah_memory_turns_session"
15
+ end
16
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.24
4
+ version: 0.2.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
@@ -50,8 +50,9 @@ files:
50
50
  - lib/active_harness/http/streaming_client.rb
51
51
  - lib/active_harness/memory.rb
52
52
  - lib/active_harness/memory/adapter/base.rb
53
- - lib/active_harness/memory/adapter/file.rb
54
- - lib/active_harness/memory/json_file.rb
53
+ - lib/active_harness/memory/adapter/json_file.rb
54
+ - lib/active_harness/memory/adapter/postgresql.rb
55
+ - lib/active_harness/memory/adapter/sqlite.rb
55
56
  - lib/active_harness/pipeline.rb
56
57
  - lib/active_harness/pipeline/hooks.rb
57
58
  - lib/active_harness/pipeline/step.rb
@@ -92,6 +93,10 @@ files:
92
93
  - lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb
93
94
  - lib/generators/active_harness/memory/memory_generator.rb
94
95
  - lib/generators/active_harness/memory/templates/memory.rb.tt
96
+ - lib/generators/active_harness/memory_postgresql/memory_postgresql_generator.rb
97
+ - lib/generators/active_harness/memory_postgresql/templates/create_active_harness_memory_turns.rb.tt
98
+ - lib/generators/active_harness/memory_sqlite/memory_sqlite_generator.rb
99
+ - lib/generators/active_harness/memory_sqlite/templates/create_active_harness_memory_turns.rb.tt
95
100
  - lib/generators/active_harness/pipeline/pipeline_generator.rb
96
101
  - lib/generators/active_harness/pipeline/templates/pipeline.rb.tt
97
102
  - lib/generators/active_harness/prompt/prompt_generator.rb
@@ -1,44 +0,0 @@
1
- module ActiveHarness
2
- class Memory
3
- # File-backed memory with a path-safe file_name interface.
4
- #
5
- # Differences from the base Memory class:
6
- # - file_name: replaces session_id — may contain slashes to create
7
- # subdirectories under storage_path, e.g. "users/42/chat"
8
- # Final file is always <storage_path>/<file_name>.json
9
- # - storage_path: replaces the adapter-level path: option
10
- # - adapter: always :file — no other adapter can be passed
11
- #
12
- # Path traversal is rejected: segments equal to "." or ".." or containing
13
- # null bytes raise ArgumentError before any file I/O happens.
14
- # Missing directories are created automatically on the first write.
15
- class JsonFile < Memory
16
- DEFAULT_STORAGE_PATH = "storage/ai/memory"
17
-
18
- def initialize(file_name:, storage_path: DEFAULT_STORAGE_PATH, **opts)
19
- super(
20
- session_id: sanitize!(file_name),
21
- adapter: :file,
22
- path: storage_path,
23
- **opts
24
- )
25
- end
26
-
27
- private
28
-
29
- def sanitize!(raw)
30
- parts = raw.to_s.split("/").map(&:strip).reject(&:empty?)
31
- raise ArgumentError, "file_name must not be empty" if parts.empty?
32
-
33
- parts.each do |part|
34
- if part == ".." || part == "." || part.include?("\0")
35
- raise ArgumentError, "Invalid file_name segment: #{part.inspect}"
36
- end
37
- end
38
-
39
- parts.last.sub!(/\.json\z/i, "")
40
- parts.join("/")
41
- end
42
- end
43
- end
44
- end