active_harness 0.2.23 → 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 +4 -4
- data/lib/active_harness/memory/adapter/{file.rb → json_file.rb} +49 -17
- data/lib/active_harness/memory/adapter/postgresql.rb +212 -0
- data/lib/active_harness/memory/adapter/sqlite.rb +233 -0
- data/lib/active_harness/memory.rb +6 -4
- data/lib/active_harness.rb +1 -1
- data/lib/generators/active_harness/install/templates/memory/app_memory.rb +9 -9
- data/lib/generators/active_harness/memory/templates/memory.rb.tt +6 -6
- data/lib/generators/active_harness/memory_postgresql/memory_postgresql_generator.rb +23 -0
- data/lib/generators/active_harness/memory_postgresql/templates/create_active_harness_memory_turns.rb.tt +16 -0
- data/lib/generators/active_harness/memory_sqlite/memory_sqlite_generator.rb +23 -0
- data/lib/generators/active_harness/memory_sqlite/templates/create_active_harness_memory_turns.rb.tt +16 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c2a3e44b6a9f63f595ee483620a0aac1080825ce3f1d585696930db1be0991f7
|
|
4
|
+
data.tar.gz: 28382baad847f75cfdc4613c143b325d9cd4b8b31913df2812e76ff33dbd80bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
|
23
|
-
DEFAULT_PATH
|
|
24
|
-
DEFAULT_STORAGE_SIZE
|
|
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
|
|
29
|
-
@filename_opt
|
|
30
|
-
@pretty
|
|
31
|
-
@compact
|
|
32
|
-
@encoding
|
|
33
|
-
@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
|
|
36
|
-
@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
|
-
#
|
|
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,5 +1,7 @@
|
|
|
1
1
|
require_relative "memory/adapter/base"
|
|
2
|
-
require_relative "memory/adapter/
|
|
2
|
+
require_relative "memory/adapter/json_file"
|
|
3
|
+
require_relative "memory/adapter/postgresql"
|
|
4
|
+
require_relative "memory/adapter/sqlite"
|
|
3
5
|
|
|
4
6
|
module ActiveHarness
|
|
5
7
|
# Conversational memory for agents.
|
|
@@ -54,7 +56,7 @@ module ActiveHarness
|
|
|
54
56
|
#
|
|
55
57
|
class Memory
|
|
56
58
|
ADAPTERS = {
|
|
57
|
-
|
|
59
|
+
json_file: ->(**opts) { Adapter::JsonFile.new(**opts) }
|
|
58
60
|
}.freeze
|
|
59
61
|
|
|
60
62
|
attr_reader :session_id
|
|
@@ -64,7 +66,7 @@ module ActiveHarness
|
|
|
64
66
|
# -------------------------------------------------------------------------
|
|
65
67
|
# session_id — required; uniquely identifies this conversation
|
|
66
68
|
# depth — how many past turns to inject into messages (nil = all)
|
|
67
|
-
# adapter — :
|
|
69
|
+
# adapter — :json_file (default), or an adapter instance
|
|
68
70
|
# enabled — false disables all reads and writes (no-op mode)
|
|
69
71
|
# read_only — true: load history but never write new turns
|
|
70
72
|
# namespace — isolates history per-agent within a session
|
|
@@ -74,7 +76,7 @@ module ActiveHarness
|
|
|
74
76
|
def initialize(
|
|
75
77
|
session_id:,
|
|
76
78
|
depth: nil,
|
|
77
|
-
adapter: :
|
|
79
|
+
adapter: :json_file,
|
|
78
80
|
enabled: true,
|
|
79
81
|
read_only: false,
|
|
80
82
|
namespace: nil,
|
data/lib/active_harness.rb
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
class AppMemory < ActiveHarness::Memory
|
|
2
|
-
# Usage: AppMemory.new(
|
|
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
|
|
6
|
-
def initialize(
|
|
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
|
-
|
|
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(
|
|
1
|
+
class <%= class_name %>Memory < ActiveHarness::Memory::JsonFile
|
|
2
|
+
def initialize(file_name:, **opts)
|
|
3
3
|
super(
|
|
4
|
-
|
|
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
|
data/lib/generators/active_harness/memory_sqlite/templates/create_active_harness_memory_turns.rb.tt
ADDED
|
@@ -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.
|
|
4
|
+
version: 0.2.25
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- the-teacher
|
|
@@ -50,7 +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/
|
|
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
|
|
54
56
|
- lib/active_harness/pipeline.rb
|
|
55
57
|
- lib/active_harness/pipeline/hooks.rb
|
|
56
58
|
- lib/active_harness/pipeline/step.rb
|
|
@@ -91,6 +93,10 @@ files:
|
|
|
91
93
|
- lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb
|
|
92
94
|
- lib/generators/active_harness/memory/memory_generator.rb
|
|
93
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
|
|
94
100
|
- lib/generators/active_harness/pipeline/pipeline_generator.rb
|
|
95
101
|
- lib/generators/active_harness/pipeline/templates/pipeline.rb.tt
|
|
96
102
|
- lib/generators/active_harness/prompt/prompt_generator.rb
|