active_harness 0.2.24 → 0.2.26

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 (25) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_harness/agent/models.rb +1 -1
  3. data/lib/active_harness/agent/output_parser.rb +1 -1
  4. data/lib/active_harness/agent.rb +2 -2
  5. data/lib/active_harness/memory/adapter/{file.rb → json_file.rb} +49 -17
  6. data/lib/active_harness/memory/adapter/postgresql.rb +212 -0
  7. data/lib/active_harness/memory/adapter/sqlite.rb +233 -0
  8. data/lib/active_harness/memory.rb +6 -5
  9. data/lib/active_harness/pipeline/README.md +252 -0
  10. data/lib/active_harness/pipeline.rb +9 -19
  11. data/lib/active_harness/result.rb +14 -2
  12. data/lib/active_harness/tribunal/dsl.rb +3 -3
  13. data/lib/active_harness/tribunal/processing.rb +1 -1
  14. data/lib/active_harness/tribunal.rb +13 -2
  15. data/lib/active_harness.rb +1 -1
  16. data/lib/generators/active_harness/install/templates/memory/app_memory.rb +9 -9
  17. data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +1 -1
  18. data/lib/generators/active_harness/memory/templates/memory.rb.tt +6 -6
  19. data/lib/generators/active_harness/memory_postgresql/memory_postgresql_generator.rb +23 -0
  20. data/lib/generators/active_harness/memory_postgresql/templates/create_active_harness_memory_turns.rb.tt +16 -0
  21. data/lib/generators/active_harness/memory_sqlite/memory_sqlite_generator.rb +23 -0
  22. data/lib/generators/active_harness/memory_sqlite/templates/create_active_harness_memory_turns.rb.tt +16 -0
  23. data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +1 -1
  24. metadata +9 -3
  25. data/lib/active_harness/memory/json_file.rb +0 -44
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2fe80bd5361b92a9c034f2dae4d069f381620867140dd7242d3a6ba24f3a429
4
- data.tar.gz: 6cb9aaa5d2a388f8f79fe5794623fe8f0e49f4e19bffb352089e1069e8a8a6b0
3
+ metadata.gz: e251d772ac23a015473080cd54ec79dcd1d707b3ca9fa1c8de6132f8152fa873
4
+ data.tar.gz: 68674b1744f7696cb26cc02a505e60e8c6ae117c7f20a118a2e8102c8f0821f5
5
5
  SHA512:
6
- metadata.gz: 56050efdb3f23d189fbca002200699b6c98b7419eb5ec428b52ff5e9149065c4fa5d01822c448e632de23ce4bf291c5c50f80207ff46d6aa449bb0d01c5c1ba8
7
- data.tar.gz: 0f3b7726fa25f362d2d660c9f5474255da6c45db0ad26b03a24245e7973f64d87cfe78793e7a75fb80014c6a109a0b3ac33df134e65dabde9863d037b6760a1b
6
+ metadata.gz: 99ef61764b9c6b7564f1064c191cb89780670c7e24966556f3f52e3fa15b631e492c9c29a3ac7f601249db9515afed291fe2e6d6e333f9fb6c2043dbad22dac1
7
+ data.tar.gz: 8c8609086bc572183398cf20ff0ea2957e8acd8337db9dadfcb8045f1bbdbb7a708b3f3b27273809a717e91196abe4bf272778d521bfa0926338f0df03486e84
@@ -14,7 +14,7 @@ module ActiveHarness
14
14
  # Output format for this agent.
15
15
  #
16
16
  # format :text # default — output is returned as-is
17
- # format :json # output is parsed; result.parsed is a Ruby Hash/Array
17
+ # format :json # output is parsed; result.processed is a Ruby Hash/Array
18
18
  def format(type)
19
19
  unless %i[text json].include?(type)
20
20
  raise ArgumentError, "Unknown format :#{type}. Valid values: :text, :json"
@@ -39,7 +39,7 @@ module ActiveHarness
39
39
  begin
40
40
  parsed = JSON.parse(clean)
41
41
 
42
- # :after_parse — return value replaces parsed result stored in Result
42
+ # :after_parse — return value replaces processed result stored in Result
43
43
  transform_hook(:after_parse, parsed)
44
44
  rescue JSON::ParserError => e
45
45
  # :parse_error — if hook returns non-nil, it is used as fallback value
@@ -140,13 +140,13 @@ module ActiveHarness
140
140
 
141
141
  def build_result(response, entry, attempts, elapsed)
142
142
  raw = response[:content]
143
- parsed = parse_output(raw)
143
+ processed = parse_output(raw)
144
144
  usage = response[:usage]
145
145
 
146
146
  Result.new(
147
147
  input: @input,
148
148
  output: raw,
149
- parsed: parsed,
149
+ processed: processed,
150
150
  system_prompt: @system_prompt,
151
151
  provider: entry[:provider],
152
152
  model: entry[:model],
@@ -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,
@@ -0,0 +1,252 @@
1
+ # Pipeline
2
+
3
+ A pipeline chains multiple agents and tribunals into a sequential workflow.
4
+ Each step receives the current payload, can transform it, and can stop the pipeline early.
5
+
6
+ ## Basic usage
7
+
8
+ ```ruby
9
+ class SupportPipeline < ActiveHarness::Pipeline
10
+ step :translate, TranslationAgent
11
+
12
+ step :injection_guard do
13
+ use InjectionGuardAgent
14
+ stop_if ->(result) { result.processed["detected"] == true }
15
+ end
16
+
17
+ step :safety_tribunal do
18
+ use SafetyTribunal
19
+ stop_if ->(result) { result.verdict == false }
20
+ end
21
+ end
22
+
23
+ pipeline = SupportPipeline.new(input: "Hello", context: { user_id: 1 })
24
+ pipeline.call
25
+
26
+ pipeline.output # => final payload string (nil if stopped)
27
+ pipeline.stopped? # => false
28
+ pipeline.step_results # => { translate: <Result>, injection_guard: <Result>, ... }
29
+ ```
30
+
31
+ ## Step types
32
+
33
+ There are two kinds of classes a step can use.
34
+
35
+ **Agent step** — runs the agent, takes `result.output` as the new payload:
36
+
37
+ ```ruby
38
+ step :translate, TranslationAgent
39
+ ```
40
+
41
+ **Tribunal step** — runs the tribunal, returns a `Result` with `processed["verdict"]`.
42
+ Payload is never updated by a tribunal step (it always has `stop_if`):
43
+
44
+ ```ruby
45
+ step :safety_tribunal do
46
+ use SafetyTribunal
47
+ stop_if ->(result) { result.processed["verdict"] == false }
48
+ end
49
+ ```
50
+
51
+ ## Payload propagation
52
+
53
+ The payload starts as the value passed to `input:` and flows through the steps:
54
+
55
+ | Condition | Payload after step |
56
+ |-----------|--------------------|
57
+ | Agent step, no `stop_if` | Updated to `result.output` |
58
+ | Agent step with `stop_if` | Unchanged (guard step) |
59
+ | Tribunal step | Unchanged |
60
+
61
+ After each step the result is also stored in `context[step_name]`,
62
+ so later steps can read earlier results via `@context[:translate]` etc.
63
+
64
+ ## Stopping the pipeline
65
+
66
+ Any step can stop the pipeline by defining `stop_if`:
67
+
68
+ ```ruby
69
+ step :injection_guard do
70
+ use InjectionGuardAgent
71
+ stop_if ->(result) { result.processed["detected"] == true }
72
+ end
73
+ ```
74
+
75
+ When the condition is true:
76
+ - remaining steps are skipped
77
+ - `pipeline.stopped?` returns `true`
78
+ - `pipeline.stopped_at` holds the step name
79
+ - `pipeline.output` is `nil`
80
+
81
+ ## Events and hooks
82
+
83
+ ```ruby
84
+ class SupportPipeline < ActiveHarness::Pipeline
85
+ on_agent_event do |event, result| ... end # fires for every agent inside
86
+ on_tribunal_event do |event, verdict| ... end # fires for every tribunal inside
87
+ on_pipeline_event do |event, *args| ... end # :before_step, :after_step, :stopped, :complete
88
+ end
89
+ ```
90
+
91
+ Runtime streams can be passed at construction time:
92
+
93
+ ```ruby
94
+ SupportPipeline.new(
95
+ input: "...",
96
+ streams: { token: token_lambda, agent: agent_lambda }
97
+ )
98
+ ```
99
+
100
+ ## Memory
101
+
102
+ A memory object can be attached to the pipeline. It is loaded before the first step
103
+ and a record is written after successful completion (skipped if the pipeline stops early):
104
+
105
+ ```ruby
106
+ mem = ActiveHarness::Memory::JsonFile.new(file_name: "session_42")
107
+
108
+ SupportPipeline.new(input: "...", memory: mem).call
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Proposal: universal step interface
114
+
115
+ Currently `Pipeline::Step` special-cases two concrete classes: `Agent` and `Tribunal`.
116
+ This section explores making the pipeline open to any entity — a plain Ruby object,
117
+ a lambda, a nested pipeline, an HTTP call, a cache lookup — with no inheritance required.
118
+
119
+ The core question is: **what must a step return so the pipeline can drive it?**
120
+
121
+ ---
122
+
123
+ ### Option A — Duck-type protocol (minimal change)
124
+
125
+ Define a lightweight protocol. Any object that satisfies it can be a step:
126
+
127
+ ```ruby
128
+ # Contract: class responds to .new(input:, context:, params:, streams:)
129
+ # Instance responds to .call → returns an object with:
130
+ # .output — new payload (String or any value); nil keeps payload unchanged
131
+ # .stop? — true signals the pipeline to halt (replaces stop_if in step DSL)
132
+
133
+ class UppercaseStep
134
+ def initialize(input:, **); @input = input; end
135
+
136
+ def call
137
+ Pipeline::StepResult.new(output: @input.upcase)
138
+ end
139
+ end
140
+
141
+ step :upcase, UppercaseStep
142
+ ```
143
+
144
+ `Pipeline::StepResult` would be a tiny value object:
145
+
146
+ ```ruby
147
+ Pipeline::StepResult = Struct.new(:output, :stop, keyword_init: true) do
148
+ def stop? = stop
149
+ end
150
+ ```
151
+
152
+ **Pros:** almost no change to existing code; agents and tribunals get thin adapters.
153
+ **Cons:** every custom step must construct `StepResult`; slightly more boilerplate.
154
+
155
+ ---
156
+
157
+ ### Option B — Callable (lambda / proc) as a step
158
+
159
+ Allow any `Proc`/`lambda` directly, without a wrapper class:
160
+
161
+ ```ruby
162
+ step :sanitize, ->(payload, ctx) { payload.strip }
163
+
164
+ step :length_guard, ->(payload, ctx) {
165
+ payload.length > 1000 ? Pipeline::Stop : payload
166
+ }
167
+ ```
168
+
169
+ Return value rules:
170
+ - any value other than `Pipeline::Stop` → becomes new payload
171
+ - `Pipeline::Stop` (or `Pipeline::Stop.new(reason)`) → halts the pipeline
172
+
173
+ **Pros:** perfect for simple transformations and guards; zero boilerplate.
174
+ **Cons:** no access to `params:` or `streams:` without enlarging the lambda signature;
175
+ harder to test in isolation.
176
+
177
+ ---
178
+
179
+ ### Option C — Rack-style env hash
180
+
181
+ Each step receives and returns a single hash (`env`), similar to Rack middleware:
182
+
183
+ ```ruby
184
+ # env keys: :input, :output, :context, :params, :streams
185
+ # Return env to continue, return Pipeline::Stop to halt.
186
+
187
+ class UppercaseStep
188
+ def call(env)
189
+ env.merge(output: env[:input].upcase)
190
+ end
191
+ end
192
+
193
+ class LengthGuard
194
+ def call(env)
195
+ env[:input].length > 1000 ? Pipeline::Stop.new("too long") : env
196
+ end
197
+ end
198
+ ```
199
+
200
+ Steps become stateless (no `initialize`) — a single instance can be reused:
201
+
202
+ ```ruby
203
+ UPCASE = UppercaseStep.new
204
+
205
+ step :upcase, UPCASE
206
+ step :length_guard, LengthGuard.new
207
+ ```
208
+
209
+ **Pros:** stateless, composable, easy to test (`call(env)` in one line); nested
210
+ pipelines become trivial — a pipeline is just another object with `call(env)`.
211
+ **Cons:** largest departure from the current API; requires migrating Agent/Tribunal wrappers.
212
+
213
+ ---
214
+
215
+ ### Option D — `Pipeline::Callable` module (explicit contract)
216
+
217
+ A module that documents the contract and provides `StepResult` helpers:
218
+
219
+ ```ruby
220
+ class EnrichStep
221
+ include ActiveHarness::Pipeline::Callable # documents intent, no magic
222
+
223
+ def initialize(input:, context:, **); @input = input; @context = context; end
224
+
225
+ def call
226
+ data = ExternalService.fetch(@context[:user_id])
227
+ result(output: "#{@input} [enriched: #{data}]") # helper from Callable
228
+ end
229
+ end
230
+ ```
231
+
232
+ Agents and Tribunals include `Callable` automatically, so they work as before.
233
+ Any plain class can opt in with one `include`.
234
+
235
+ **Pros:** clear opt-in contract; helpers reduce boilerplate; IDE-friendly.
236
+ **Cons:** still requires `include`; doesn't help lambdas or nested pipelines directly.
237
+
238
+ ---
239
+
240
+ ### Comparison
241
+
242
+ | | No inheritance | Lambda support | Nested pipeline | Migration cost |
243
+ |---|---|---|---|---|
244
+ | **A — duck type** | yes | with wrapper | yes | low |
245
+ | **B — lambda** | yes | native | no | low |
246
+ | **C — Rack env** | yes | yes (`.call`) | yes (trivially) | high |
247
+ | **D — module** | yes (opt-in) | with wrapper | yes | low |
248
+
249
+ **Recommendation:** start with **B** (lambda steps) for simple cases and **A** (duck-type
250
+ protocol + `StepResult`) for structured steps — both require minimal changes to the
251
+ existing engine. Option C is the most powerful but is a bigger refactor; consider it
252
+ if nested pipelines or stateless reuse become a real need.
@@ -7,7 +7,7 @@ module ActiveHarness
7
7
  # class SupportPipeline < ActiveHarness::Pipeline
8
8
  # step :injection_guard do
9
9
  # use InjectionGuardAgent
10
- # stop_if ->(result) { result.parsed["detected"] == true }
10
+ # stop_if ->(result) { result.processed["detected"] == true }
11
11
  # end
12
12
  #
13
13
  # step :translate, TranslationAgent # shorthand — no stop_if
@@ -44,7 +44,7 @@ module ActiveHarness
44
44
  # Full block form:
45
45
  # step :injection_guard do
46
46
  # use InjectionGuardAgent
47
- # stop_if ->(result) { result.parsed["detected"] == true }
47
+ # stop_if ->(result) { result.processed["detected"] == true }
48
48
  # end
49
49
  def step(name, agent_class = nil, &block)
50
50
  pipeline_config[:steps] << Pipeline::Step.new(name, agent_class, &block)
@@ -210,23 +210,13 @@ module ActiveHarness
210
210
  end
211
211
 
212
212
  def execute_step(step)
213
- if step.tribunal?
214
- agent_streams = { token: @token_stream, agent: @agent_event_stream, tribunal: @tribunal_event_stream }.compact
215
- step.agent_class.new(
216
- input: @payload,
217
- context: @context.dup,
218
- params: @params,
219
- streams: agent_streams
220
- ).call
221
- else
222
- agent_streams = { token: @token_stream, agent: @agent_event_stream }.compact
223
- step.agent_class.new(
224
- input: @payload,
225
- context: @context.dup,
226
- params: @params,
227
- streams: agent_streams
228
- ).call.result
229
- end
213
+ streams = { token: @token_stream, agent: @agent_event_stream, tribunal: @tribunal_event_stream }.compact
214
+ step.agent_class.new(
215
+ input: @payload,
216
+ context: @context.dup,
217
+ params: @params,
218
+ streams: streams
219
+ ).call.result
230
220
  end
231
221
  end
232
222
  end
@@ -4,8 +4,20 @@ module ActiveHarness
4
4
  # Minimal result wrapper returned by Agent#call.
5
5
  #
6
6
  # output — raw string from the provider
7
- # parsed — for format :json: a Ruby Hash/Array; for format :text: same as output
7
+ # processed — for format :json: a Ruby Hash/Array; for format :text: same as output
8
8
  # usage — token counts: { input_tokens:, output_tokens:, total_tokens: } or nil for streaming
9
9
  # cost — { input_cost:, output_cost:, total_cost: } in USD, or nil if pricing unavailable
10
- Result = Struct.new(:input, :output, :parsed, :system_prompt, :provider, :model, :temperature, :model_list, :attempts, :execution_time, :usage, :cost, keyword_init: true)
10
+ Result = Struct.new(
11
+ :input,
12
+ :output,
13
+ :processed,
14
+ :system_prompt,
15
+ :provider, :model,
16
+ :temperature,
17
+ :model_list,
18
+ :attempts,
19
+ :execution_time,
20
+ :usage,
21
+ :cost,
22
+ keyword_init: true)
11
23
  end
@@ -13,7 +13,7 @@ module ActiveHarness
13
13
  # Receives the full results array; return value becomes #verdict.
14
14
  # Takes priority over +verdict+ strategy if both are declared.
15
15
  #
16
- # process { |results| results.all? { |r| r.parsed["result"] == true } }
16
+ # process { |results| results.all? { |r| r.processed["result"] == true } }
17
17
  def process(&block)
18
18
  tribunal_config[:process] = block
19
19
  end
@@ -31,11 +31,11 @@ module ActiveHarness
31
31
  # The block receives a single Result and must return a truthy/falsy value.
32
32
  #
33
33
  # verdict :unanimous do |result|
34
- # result.parsed["result"] == true
34
+ # result.processed["result"] == true
35
35
  # end
36
36
  #
37
37
  # verdict :majority, may_fail: 1 do |result|
38
- # result.parsed["result"] == true
38
+ # result.processed["result"] == true
39
39
  # end
40
40
  VALID_STRATEGIES = %i[unanimous majority].freeze
41
41
 
@@ -2,7 +2,7 @@ module ActiveHarness
2
2
  class Tribunal
3
3
  # Instance-level process block — overrides class-level block.
4
4
  #
5
- # tribunal.process { |results| results.count { |r| r.parsed["ok"] } >= 2 }
5
+ # tribunal.process { |results| results.count { |r| r.processed["ok"] } >= 2 }
6
6
  def process(&block)
7
7
  @process_block = block
8
8
  self
@@ -17,14 +17,14 @@ module ActiveHarness
17
17
  # timeout: 7
18
18
  # )
19
19
  # tribunal.on(:after_agent) { |result| puts result.model }
20
- # tribunal.process { |results| results.all? { |r| r.parsed["result"] == true } }
20
+ # tribunal.process { |results| results.all? { |r| r.processed["result"] == true } }
21
21
  # tribunal.call
22
22
  #
23
23
  # Subclass with DSL:
24
24
  # class ContentQualityTribunal < ActiveHarness::Tribunal
25
25
  # agents PolitenessAgent, ConstructivenessAgent
26
26
  # on(:after_agent) { |result| puts result.model }
27
- # process { |results| results.all? { |r| r.parsed["result"] == true } }
27
+ # process { |results| results.all? { |r| r.processed["result"] == true } }
28
28
  # end
29
29
  # ContentQualityTribunal.new(input: "...").call
30
30
  #
@@ -89,6 +89,17 @@ module ActiveHarness
89
89
  @agent_execution_times = []
90
90
  end
91
91
 
92
+ # Returns a Result with processed: { "verdict" => @verdict } so the pipeline
93
+ # can handle agents and tribunals through the same interface.
94
+ def result
95
+ Result.new(
96
+ input: @input,
97
+ output: nil,
98
+ processed: { "verdict" => @verdict },
99
+ execution_time: @execution_time
100
+ )
101
+ end
102
+
92
103
  # Run all agents in parallel, then compute the verdict.
93
104
  # Returns self so calls can be chained: tribunal.call.verdict
94
105
  #
@@ -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.26"
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
@@ -6,6 +6,6 @@ class SupportGuardTribunal < ActiveHarness::Tribunal
6
6
  agents SupportGuardAgent
7
7
 
8
8
  process do |results|
9
- results.none? { |r| r.parsed["spam"] == true }
9
+ results.none? { |r| r.processed["spam"] == true }
10
10
  end
11
11
  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
@@ -2,6 +2,6 @@ class <%= class_name %>Tribunal < ActiveHarness::Tribunal
2
2
  agents <%= class_name %>Agent
3
3
 
4
4
  process do |results|
5
- results.all? { |r| r.parsed["result"] == true }
5
+ results.all? { |r| r.processed["result"] == true }
6
6
  end
7
7
  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.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
@@ -50,9 +50,11 @@ 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
57
+ - lib/active_harness/pipeline/README.md
56
58
  - lib/active_harness/pipeline/hooks.rb
57
59
  - lib/active_harness/pipeline/step.rb
58
60
  - lib/active_harness/providers/PROVIDER_CONTRACT.md
@@ -92,6 +94,10 @@ files:
92
94
  - lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb
93
95
  - lib/generators/active_harness/memory/memory_generator.rb
94
96
  - lib/generators/active_harness/memory/templates/memory.rb.tt
97
+ - lib/generators/active_harness/memory_postgresql/memory_postgresql_generator.rb
98
+ - lib/generators/active_harness/memory_postgresql/templates/create_active_harness_memory_turns.rb.tt
99
+ - lib/generators/active_harness/memory_sqlite/memory_sqlite_generator.rb
100
+ - lib/generators/active_harness/memory_sqlite/templates/create_active_harness_memory_turns.rb.tt
95
101
  - lib/generators/active_harness/pipeline/pipeline_generator.rb
96
102
  - lib/generators/active_harness/pipeline/templates/pipeline.rb.tt
97
103
  - 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