llmemory 0.2.1 → 0.2.3
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/README.md +78 -1
- data/app/controllers/llmemory/dashboard/application_controller.rb +15 -1
- data/app/controllers/llmemory/dashboard/episodic_controller.rb +22 -0
- data/app/controllers/llmemory/dashboard/forget_log_controller.rb +12 -0
- data/app/controllers/llmemory/dashboard/maintenance_controller.rb +92 -0
- data/app/controllers/llmemory/dashboard/procedural_controller.rb +22 -0
- data/app/controllers/llmemory/dashboard/reflection_controller.rb +37 -0
- data/app/controllers/llmemory/dashboard/working_controller.rb +14 -0
- data/app/views/llmemory/dashboard/episodic/index.html.erb +37 -0
- data/app/views/llmemory/dashboard/forget_log/show.html.erb +23 -0
- data/app/views/llmemory/dashboard/maintenance/show.html.erb +65 -0
- data/app/views/llmemory/dashboard/procedural/index.html.erb +38 -0
- data/app/views/llmemory/dashboard/reflection/show.html.erb +29 -0
- data/app/views/llmemory/dashboard/users/show.html.erb +16 -0
- data/app/views/llmemory/dashboard/working/show.html.erb +20 -0
- data/config/routes.rb +14 -0
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +2 -0
- data/lib/llmemory/cli/commands/maintain.rb +62 -0
- data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
- data/lib/llmemory/cli.rb +6 -0
- data/lib/llmemory/configuration.rb +28 -2
- data/lib/llmemory/crypto/cipher.rb +147 -0
- data/lib/llmemory/crypto/field_helpers.rb +110 -0
- data/lib/llmemory/instrumentation.rb +33 -0
- data/lib/llmemory/llm/anthropic.rb +21 -16
- data/lib/llmemory/llm/openai.rb +18 -13
- data/lib/llmemory/long_term/episodic/memory.rb +27 -13
- data/lib/llmemory/long_term/episodic/storage.rb +11 -4
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +33 -10
- data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +51 -8
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +47 -9
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +35 -4
- data/lib/llmemory/long_term/file_based/memory.rb +12 -4
- data/lib/llmemory/long_term/file_based/storage.rb +11 -4
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +20 -12
- data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +28 -10
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +32 -16
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +4 -2
- data/lib/llmemory/long_term/graph_based/memory.rb +16 -7
- data/lib/llmemory/long_term/graph_based/storage.rb +3 -2
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +51 -23
- data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
- data/lib/llmemory/long_term/procedural/memory.rb +30 -16
- data/lib/llmemory/long_term/procedural/skill.rb +6 -2
- data/lib/llmemory/long_term/procedural/storage.rb +11 -4
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +47 -17
- data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +52 -10
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +49 -11
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +36 -5
- data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
- data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
- data/lib/llmemory/maintenance.rb +2 -0
- data/lib/llmemory/mcp/server.rb +5 -1
- data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
- data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
- data/lib/llmemory/memory.rb +60 -8
- data/lib/llmemory/memory_module.rb +13 -6
- data/lib/llmemory/reflection/reflector.rb +24 -20
- data/lib/llmemory/retrieval/engine.rb +25 -16
- data/lib/llmemory/short_term/checkpoint.rb +3 -2
- data/lib/llmemory/short_term/stores/active_record_store.rb +12 -10
- data/lib/llmemory/short_term/stores/memory_store.rb +1 -1
- data/lib/llmemory/short_term/stores/postgres_store.rb +11 -5
- data/lib/llmemory/short_term/stores/redis_store.rb +7 -5
- data/lib/llmemory/short_term/stores.rb +7 -6
- data/lib/llmemory/skill_mining/miner.rb +163 -0
- data/lib/llmemory/skill_mining.rb +8 -0
- data/lib/llmemory/vector_store/active_record_store.rb +24 -3
- data/lib/llmemory/vector_store/memory_store.rb +23 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
- data/lib/llmemory/vector_store.rb +4 -3
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +4 -0
- metadata +24 -1
|
@@ -17,10 +17,11 @@ module Llmemory
|
|
|
17
17
|
DEFAULT_KIND = "prompt"
|
|
18
18
|
|
|
19
19
|
attr_reader :id, :user_id, :name, :description, :body, :kind, :version,
|
|
20
|
-
:success_count, :failure_count, :created_at, :updated_at
|
|
20
|
+
:success_count, :failure_count, :provenance, :created_at, :updated_at
|
|
21
21
|
|
|
22
22
|
def initialize(id:, user_id:, name:, body:, description: nil, kind: DEFAULT_KIND,
|
|
23
|
-
version: 1, success_count: 0, failure_count: 0,
|
|
23
|
+
version: 1, success_count: 0, failure_count: 0, provenance: nil,
|
|
24
|
+
created_at: nil, updated_at: nil)
|
|
24
25
|
@id = id
|
|
25
26
|
@user_id = user_id
|
|
26
27
|
@name = name.to_s
|
|
@@ -30,6 +31,7 @@ module Llmemory
|
|
|
30
31
|
@version = version.to_i
|
|
31
32
|
@success_count = success_count.to_i
|
|
32
33
|
@failure_count = failure_count.to_i
|
|
34
|
+
@provenance = provenance
|
|
33
35
|
@created_at = created_at || Time.now
|
|
34
36
|
@updated_at = updated_at || @created_at
|
|
35
37
|
end
|
|
@@ -60,6 +62,7 @@ module Llmemory
|
|
|
60
62
|
version: hash[:version] || hash["version"] || 1,
|
|
61
63
|
success_count: hash[:success_count] || hash["success_count"] || 0,
|
|
62
64
|
failure_count: hash[:failure_count] || hash["failure_count"] || 0,
|
|
65
|
+
provenance: hash[:provenance] || hash["provenance"],
|
|
63
66
|
created_at: parse_time(hash[:created_at] || hash["created_at"]),
|
|
64
67
|
updated_at: parse_time(hash[:updated_at] || hash["updated_at"])
|
|
65
68
|
)
|
|
@@ -83,6 +86,7 @@ module Llmemory
|
|
|
83
86
|
version: version,
|
|
84
87
|
success_count: success_count,
|
|
85
88
|
failure_count: failure_count,
|
|
89
|
+
provenance: provenance,
|
|
86
90
|
created_at: created_at.respond_to?(:iso8601) ? created_at.iso8601(6) : created_at,
|
|
87
91
|
updated_at: updated_at.respond_to?(:iso8601) ? updated_at.iso8601(6) : updated_at
|
|
88
92
|
}
|
|
@@ -12,17 +12,24 @@ module Llmemory
|
|
|
12
12
|
Storage = Storages::MemoryStorage
|
|
13
13
|
|
|
14
14
|
module Storages
|
|
15
|
-
def self.build(store: nil, base_path: nil, database_url: nil)
|
|
15
|
+
def self.build(store: nil, base_path: nil, database_url: nil, cipher: nil)
|
|
16
|
+
resolved_cipher = cipher || Llmemory.build_cipher
|
|
16
17
|
case (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
17
18
|
when :memory
|
|
18
19
|
MemoryStorage.new
|
|
19
20
|
when :file
|
|
20
|
-
FileStorage.new(
|
|
21
|
+
FileStorage.new(
|
|
22
|
+
base_path: base_path || Llmemory.configuration.long_term_storage_path,
|
|
23
|
+
cipher: resolved_cipher
|
|
24
|
+
)
|
|
21
25
|
when :postgres, :database
|
|
22
|
-
DatabaseStorage.new(
|
|
26
|
+
DatabaseStorage.new(
|
|
27
|
+
database_url: database_url || Llmemory.configuration.database_url,
|
|
28
|
+
cipher: resolved_cipher
|
|
29
|
+
)
|
|
23
30
|
when :active_record, :activerecord
|
|
24
31
|
require_relative "storages/active_record_storage"
|
|
25
|
-
ActiveRecordStorage.new
|
|
32
|
+
ActiveRecordStorage.new(cipher: resolved_cipher)
|
|
26
33
|
else
|
|
27
34
|
MemoryStorage.new
|
|
28
35
|
end
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "securerandom"
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "base"
|
|
7
|
+
require_relative "../../../crypto/field_helpers"
|
|
7
8
|
|
|
8
9
|
module Llmemory
|
|
9
10
|
module LongTerm
|
|
@@ -13,7 +14,10 @@ module Llmemory
|
|
|
13
14
|
# auto-deserializes jsonb to a Hash (string keys), which Skill.from_h
|
|
14
15
|
# handles. Mirrors the file-based ActiveRecordStorage pattern.
|
|
15
16
|
class ActiveRecordStorage < Base
|
|
16
|
-
|
|
17
|
+
include Llmemory::Crypto::FieldHelpers
|
|
18
|
+
|
|
19
|
+
def initialize(cipher: nil)
|
|
20
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
17
21
|
self.class.load_models!
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -30,53 +34,71 @@ module Llmemory
|
|
|
30
34
|
data["created_at"] ||= Time.now.utc.iso8601
|
|
31
35
|
rec = LlmemorySkill.find_or_initialize_by(id: id)
|
|
32
36
|
rec.user_id = user_id
|
|
33
|
-
rec.data = data
|
|
34
|
-
rec.search_text = searchable_text(data)
|
|
37
|
+
rec.data = cipher.enabled? ? enc_json(data) : data
|
|
38
|
+
rec.search_text = enc(searchable_text(data))
|
|
35
39
|
rec.created_at ||= Time.current
|
|
36
40
|
rec.save!
|
|
37
41
|
id
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def get_skill(user_id, id)
|
|
41
|
-
LlmemorySkill.find_by(user_id: user_id, id: id)
|
|
45
|
+
rec = LlmemorySkill.find_by(user_id: user_id, id: id)
|
|
46
|
+
return nil unless rec
|
|
47
|
+
|
|
48
|
+
decode_data(rec.data)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
def list_skills(user_id, limit: nil)
|
|
45
|
-
scope = LlmemorySkill.where(user_id: user_id).order(created_at: :desc)
|
|
51
|
+
def list_skills(user_id, limit: nil, offset: nil)
|
|
52
|
+
scope = LlmemorySkill.where(user_id: user_id, archived_at: nil).order(created_at: :desc)
|
|
46
53
|
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
47
|
-
scope.
|
|
54
|
+
scope = scope.offset(offset) if offset && offset.to_i.positive?
|
|
55
|
+
scope.map { |r| decode_data(r.data) }
|
|
48
56
|
end
|
|
49
57
|
|
|
50
58
|
def search_skills(user_id, query)
|
|
51
|
-
token_scope(LlmemorySkill.where(user_id: user_id), "search_text", query)
|
|
52
|
-
.order(created_at: :desc).map(
|
|
59
|
+
token_scope(LlmemorySkill.where(user_id: user_id, archived_at: nil), "search_text", query)
|
|
60
|
+
.order(created_at: :desc).map { |r| decode_data(r.data) }
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def find_skills_by_name(user_id, name)
|
|
56
|
-
|
|
64
|
+
if cipher.enabled?
|
|
65
|
+
LlmemorySkill.where(user_id: user_id, archived_at: nil).map { |r| decode_data(r.data) }
|
|
66
|
+
.select { |s| (s[:name] || s["name"]).to_s == name.to_s }
|
|
67
|
+
else
|
|
68
|
+
LlmemorySkill.where(user_id: user_id, archived_at: nil).where("data->>'name' = ?", name.to_s).map(&:data)
|
|
69
|
+
end
|
|
57
70
|
end
|
|
58
71
|
|
|
59
72
|
def record_outcome(user_id, skill_id, success:)
|
|
60
73
|
rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
|
|
61
74
|
return nil unless rec
|
|
62
|
-
data = rec.data || {}
|
|
63
|
-
key = success ?
|
|
75
|
+
data = decode_data(rec.data) || {}
|
|
76
|
+
key = success ? :success_count : :failure_count
|
|
64
77
|
data[key] = (data[key] || 0).to_i + 1
|
|
65
|
-
data[
|
|
66
|
-
rec.data = data
|
|
67
|
-
rec.search_text = searchable_text(data)
|
|
78
|
+
data[:updated_at] = Time.now.utc.iso8601
|
|
79
|
+
rec.data = cipher.enabled? ? enc_json(data) : data
|
|
80
|
+
rec.search_text = enc(searchable_text(data))
|
|
68
81
|
rec.save!
|
|
69
82
|
data
|
|
70
83
|
end
|
|
71
84
|
|
|
72
85
|
def count_skills(user_id)
|
|
73
|
-
LlmemorySkill.where(user_id: user_id).count
|
|
86
|
+
LlmemorySkill.where(user_id: user_id, archived_at: nil).count
|
|
74
87
|
end
|
|
75
88
|
|
|
76
89
|
def delete_skills(user_id, ids)
|
|
77
90
|
LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
|
|
78
91
|
end
|
|
79
92
|
|
|
93
|
+
def archive_skills(user_id, ids)
|
|
94
|
+
LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s), archived_at: nil)
|
|
95
|
+
.update_all(archived_at: Time.current)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def expired_skill_ids(user_id, cutoff:)
|
|
99
|
+
LlmemorySkill.where(user_id: user_id, archived_at: nil).where("created_at < ?", cutoff).pluck(:id)
|
|
100
|
+
end
|
|
101
|
+
|
|
80
102
|
def list_users
|
|
81
103
|
LlmemorySkill.distinct.pluck(:user_id)
|
|
82
104
|
end
|
|
@@ -95,7 +117,15 @@ module Llmemory
|
|
|
95
117
|
end
|
|
96
118
|
|
|
97
119
|
def searchable_text(data)
|
|
98
|
-
|
|
120
|
+
h = data.is_a?(Hash) ? data : {}
|
|
121
|
+
[h["name"] || h[:name], h["description"] || h[:description], h["body"] || h[:body]].compact.join("\n")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def decode_data(raw)
|
|
125
|
+
return raw.transform_keys(&:to_sym) if raw.is_a?(Hash)
|
|
126
|
+
return dec_json(raw) if raw.is_a?(String) && cipher.encrypted?(raw)
|
|
127
|
+
|
|
128
|
+
raw
|
|
99
129
|
end
|
|
100
130
|
end
|
|
101
131
|
end
|
|
@@ -16,7 +16,7 @@ module Llmemory
|
|
|
16
16
|
raise NotImplementedError, "#{self.class}#get_skill must be implemented"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def list_skills(user_id, limit: nil)
|
|
19
|
+
def list_skills(user_id, limit: nil, offset: nil)
|
|
20
20
|
raise NotImplementedError, "#{self.class}#list_skills must be implemented"
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -43,6 +43,19 @@ module Llmemory
|
|
|
43
43
|
raise NotImplementedError, "#{self.class}#delete_skills must be implemented"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Soft-archives skills by id. Archived skills are excluded from
|
|
47
|
+
# list_skills / search_skills / count_skills but remain accessible via
|
|
48
|
+
# get_skill. Returns the number archived.
|
|
49
|
+
def archive_skills(user_id, ids)
|
|
50
|
+
raise NotImplementedError, "#{self.class}#archive_skills must be implemented"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns skills whose created_at is older than the cutoff and that are
|
|
54
|
+
# not already archived. Used by the TTL maintenance job.
|
|
55
|
+
def expired_skill_ids(user_id, cutoff:)
|
|
56
|
+
raise NotImplementedError, "#{self.class}#expired_skill_ids must be implemented"
|
|
57
|
+
end
|
|
58
|
+
|
|
46
59
|
def list_users
|
|
47
60
|
raise NotImplementedError, "#{self.class}#list_users must be implemented"
|
|
48
61
|
end
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "securerandom"
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "base"
|
|
7
|
+
require_relative "../../../crypto/field_helpers"
|
|
7
8
|
|
|
8
9
|
module Llmemory
|
|
9
10
|
module LongTerm
|
|
@@ -13,9 +14,12 @@ module Llmemory
|
|
|
13
14
|
# (plus id/user_id/created_at and a denormalized search_text), mirroring
|
|
14
15
|
# the file-based DatabaseStorage pattern.
|
|
15
16
|
class DatabaseStorage < Base
|
|
16
|
-
|
|
17
|
+
include Llmemory::Crypto::FieldHelpers
|
|
18
|
+
|
|
19
|
+
def initialize(database_url: nil, cipher: nil)
|
|
17
20
|
@database_url = database_url || Llmemory.configuration.database_url
|
|
18
21
|
@connection = nil
|
|
22
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def save_skill(user_id, skill)
|
|
@@ -27,7 +31,7 @@ module Llmemory
|
|
|
27
31
|
"INSERT INTO llmemory_skills (id, user_id, data, search_text, created_at) " \
|
|
28
32
|
"VALUES ($1, $2, $3::jsonb, $4, $5) " \
|
|
29
33
|
"ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4",
|
|
30
|
-
[id, user_id,
|
|
34
|
+
[id, user_id, store_data(data), enc(searchable_text(data)), created_at_value(data)]
|
|
31
35
|
)
|
|
32
36
|
id
|
|
33
37
|
end
|
|
@@ -38,10 +42,11 @@ module Llmemory
|
|
|
38
42
|
rows.any? ? parse_data(rows.first["data"]) : nil
|
|
39
43
|
end
|
|
40
44
|
|
|
41
|
-
def list_skills(user_id, limit: nil)
|
|
45
|
+
def list_skills(user_id, limit: nil, offset: nil)
|
|
42
46
|
ensure_tables!
|
|
43
|
-
sql = "SELECT data FROM llmemory_skills WHERE user_id = $1 ORDER BY created_at DESC"
|
|
47
|
+
sql = "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL ORDER BY created_at DESC"
|
|
44
48
|
sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
|
|
49
|
+
sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
|
|
45
50
|
conn.exec_params(sql, [user_id]).map { |r| parse_data(r["data"]) }
|
|
46
51
|
end
|
|
47
52
|
|
|
@@ -49,7 +54,7 @@ module Llmemory
|
|
|
49
54
|
ensure_tables!
|
|
50
55
|
suffix, params = token_filter("search_text", query, 2)
|
|
51
56
|
conn.exec_params(
|
|
52
|
-
"SELECT data FROM llmemory_skills WHERE user_id = $1#{suffix} ORDER BY created_at DESC",
|
|
57
|
+
"SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL#{suffix} ORDER BY created_at DESC",
|
|
53
58
|
[user_id, *params]
|
|
54
59
|
).map { |r| parse_data(r["data"]) }
|
|
55
60
|
end
|
|
@@ -57,7 +62,7 @@ module Llmemory
|
|
|
57
62
|
def find_skills_by_name(user_id, name)
|
|
58
63
|
ensure_tables!
|
|
59
64
|
conn.exec_params(
|
|
60
|
-
"SELECT data FROM llmemory_skills WHERE user_id = $1 AND data->>'name' = $2",
|
|
65
|
+
"SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND data->>'name' = $2",
|
|
61
66
|
[user_id, name.to_s]
|
|
62
67
|
).map { |r| parse_data(r["data"]) }
|
|
63
68
|
end
|
|
@@ -71,14 +76,14 @@ module Llmemory
|
|
|
71
76
|
data[:updated_at] = Time.now.utc.iso8601
|
|
72
77
|
conn.exec_params(
|
|
73
78
|
"UPDATE llmemory_skills SET data = $3::jsonb, search_text = $4 WHERE user_id = $1 AND id = $2",
|
|
74
|
-
[user_id, skill_id,
|
|
79
|
+
[user_id, skill_id, store_data(data), enc(searchable_text(data))]
|
|
75
80
|
)
|
|
76
81
|
data
|
|
77
82
|
end
|
|
78
83
|
|
|
79
84
|
def count_skills(user_id)
|
|
80
85
|
ensure_tables!
|
|
81
|
-
conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_skills WHERE user_id = $1", [user_id]).first["c"].to_i
|
|
86
|
+
conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL", [user_id]).first["c"].to_i
|
|
82
87
|
end
|
|
83
88
|
|
|
84
89
|
def delete_skills(user_id, ids)
|
|
@@ -88,6 +93,24 @@ module Llmemory
|
|
|
88
93
|
end
|
|
89
94
|
end
|
|
90
95
|
|
|
96
|
+
def archive_skills(user_id, ids)
|
|
97
|
+
ensure_tables!
|
|
98
|
+
Array(ids).sum do |id|
|
|
99
|
+
conn.exec_params(
|
|
100
|
+
"UPDATE llmemory_skills SET archived_at = NOW() WHERE user_id = $1 AND id = $2 AND archived_at IS NULL",
|
|
101
|
+
[user_id, id]
|
|
102
|
+
).cmd_tuples
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def expired_skill_ids(user_id, cutoff:)
|
|
107
|
+
ensure_tables!
|
|
108
|
+
conn.exec_params(
|
|
109
|
+
"SELECT id FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND created_at < $2",
|
|
110
|
+
[user_id, cutoff.iso8601]
|
|
111
|
+
).map { |r| r["id"] }
|
|
112
|
+
end
|
|
113
|
+
|
|
91
114
|
def list_users
|
|
92
115
|
ensure_tables!
|
|
93
116
|
conn.exec("SELECT DISTINCT user_id FROM llmemory_skills").map { |r| r["user_id"] }
|
|
@@ -109,9 +132,11 @@ module Llmemory
|
|
|
109
132
|
user_id TEXT NOT NULL,
|
|
110
133
|
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
111
134
|
search_text TEXT,
|
|
112
|
-
created_at TIMESTAMPTZ NOT NULL
|
|
135
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
136
|
+
archived_at TIMESTAMPTZ
|
|
113
137
|
);
|
|
114
138
|
CREATE INDEX IF NOT EXISTS idx_llmemory_skills_user_id ON llmemory_skills(user_id);
|
|
139
|
+
ALTER TABLE llmemory_skills ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
|
115
140
|
SQL
|
|
116
141
|
end
|
|
117
142
|
|
|
@@ -123,11 +148,28 @@ module Llmemory
|
|
|
123
148
|
end
|
|
124
149
|
|
|
125
150
|
def parse_data(value)
|
|
126
|
-
|
|
151
|
+
if value.is_a?(Hash)
|
|
152
|
+
return value.transform_keys(&:to_sym)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
str = value.to_s
|
|
156
|
+
if cipher.encrypted?(str)
|
|
157
|
+
cipher.decrypt_json(str)
|
|
158
|
+
else
|
|
159
|
+
JSON.parse(str, symbolize_names: true)
|
|
160
|
+
end
|
|
127
161
|
rescue JSON::ParserError
|
|
128
162
|
{}
|
|
129
163
|
end
|
|
130
164
|
|
|
165
|
+
def store_data(data)
|
|
166
|
+
if cipher.enabled?
|
|
167
|
+
JSON.generate(enc_json(data))
|
|
168
|
+
else
|
|
169
|
+
JSON.generate(data)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
131
173
|
def symbolize(hash)
|
|
132
174
|
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
133
175
|
end
|
|
@@ -4,22 +4,26 @@ require "fileutils"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "base"
|
|
7
|
+
require_relative "../../../crypto/field_helpers"
|
|
7
8
|
|
|
8
9
|
module Llmemory
|
|
9
10
|
module LongTerm
|
|
10
11
|
module Procedural
|
|
11
12
|
module Storages
|
|
12
13
|
class FileStorage < Base
|
|
13
|
-
|
|
14
|
+
include Llmemory::Crypto::FieldHelpers
|
|
15
|
+
|
|
16
|
+
def initialize(base_path: nil, cipher: nil)
|
|
14
17
|
@base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
|
|
15
18
|
@base_path = File.expand_path(@base_path)
|
|
19
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def save_skill(user_id, skill)
|
|
19
23
|
id = skill[:id] || skill["id"] || "skill_#{next_seq(user_id)}"
|
|
20
24
|
data = stringify_for_json(skill).merge("id" => id, "user_id" => user_id)
|
|
21
25
|
data["created_at"] ||= Time.now.iso8601(6)
|
|
22
|
-
|
|
26
|
+
write_skill_file(skill_path(user_id, id), data)
|
|
23
27
|
id
|
|
24
28
|
end
|
|
25
29
|
|
|
@@ -29,18 +33,19 @@ module Llmemory
|
|
|
29
33
|
load_skill(path)
|
|
30
34
|
end
|
|
31
35
|
|
|
32
|
-
def list_skills(user_id, limit: nil)
|
|
33
|
-
sorted =
|
|
36
|
+
def list_skills(user_id, limit: nil, offset: nil)
|
|
37
|
+
sorted = active_skills(user_id).sort_by { |s| s[:created_at] }.reverse
|
|
38
|
+
sorted = sorted.drop(offset.to_i) if offset && offset.to_i.positive?
|
|
34
39
|
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def search_skills(user_id, query)
|
|
38
43
|
return list_skills(user_id) if query.to_s.strip.empty?
|
|
39
|
-
|
|
44
|
+
active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
def find_skills_by_name(user_id, name)
|
|
43
|
-
|
|
48
|
+
active_skills(user_id).select { |s| s[:name].to_s == name.to_s }
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def record_outcome(user_id, skill_id, success:)
|
|
@@ -49,14 +54,12 @@ module Llmemory
|
|
|
49
54
|
key = success ? :success_count : :failure_count
|
|
50
55
|
skill[key] = (skill[key] || 0).to_i + 1
|
|
51
56
|
skill[:updated_at] = Time.now.iso8601(6)
|
|
52
|
-
|
|
57
|
+
write_skill_file(skill_path(user_id, skill_id), stringify_for_json(skill))
|
|
53
58
|
skill
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
def count_skills(user_id)
|
|
57
|
-
|
|
58
|
-
return 0 unless Dir.exist?(dir)
|
|
59
|
-
Dir.children(dir).count { |f| f.end_with?(".json") }
|
|
62
|
+
active_skills(user_id).size
|
|
60
63
|
end
|
|
61
64
|
|
|
62
65
|
def delete_skills(user_id, ids)
|
|
@@ -68,6 +71,24 @@ module Llmemory
|
|
|
68
71
|
end
|
|
69
72
|
end
|
|
70
73
|
|
|
74
|
+
def archive_skills(user_id, ids)
|
|
75
|
+
Array(ids).map(&:to_s).count do |id|
|
|
76
|
+
path = skill_path(user_id, id)
|
|
77
|
+
next false unless File.file?(path)
|
|
78
|
+
data = load_skill_raw(path)
|
|
79
|
+
next false if data["archived_at"]
|
|
80
|
+
data["archived_at"] = Time.now.iso8601
|
|
81
|
+
write_skill_file(path, data)
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def expired_skill_ids(user_id, cutoff:)
|
|
87
|
+
active_skills(user_id)
|
|
88
|
+
.select { |s| (s[:created_at] || Time.now) < cutoff }
|
|
89
|
+
.map { |s| s[:id].to_s }
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
def list_users
|
|
72
93
|
return [] unless Dir.exist?(@base_path)
|
|
73
94
|
Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
|
|
@@ -75,6 +96,10 @@ module Llmemory
|
|
|
75
96
|
|
|
76
97
|
private
|
|
77
98
|
|
|
99
|
+
def active_skills(user_id)
|
|
100
|
+
all_skills(user_id).reject { |s| s[:archived_at] }
|
|
101
|
+
end
|
|
102
|
+
|
|
78
103
|
def all_skills(user_id)
|
|
79
104
|
dir = user_path(user_id, "skills")
|
|
80
105
|
return [] unless Dir.exist?(dir)
|
|
@@ -82,7 +107,9 @@ module Llmemory
|
|
|
82
107
|
end
|
|
83
108
|
|
|
84
109
|
def load_skill(path)
|
|
85
|
-
data =
|
|
110
|
+
data = load_skill_raw(path)
|
|
111
|
+
return nil unless data
|
|
112
|
+
|
|
86
113
|
data[:created_at] = parse_time(data[:created_at])
|
|
87
114
|
data[:updated_at] = parse_time(data[:updated_at]) if data[:updated_at]
|
|
88
115
|
data
|
|
@@ -90,6 +117,17 @@ module Llmemory
|
|
|
90
117
|
nil
|
|
91
118
|
end
|
|
92
119
|
|
|
120
|
+
def load_skill_raw(path)
|
|
121
|
+
raw = File.read(path)
|
|
122
|
+
json = cipher.enabled? && cipher.encrypted?(raw) ? cipher.decrypt(raw) : raw
|
|
123
|
+
JSON.parse(json, symbolize_names: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def write_skill_file(path, data)
|
|
127
|
+
payload = JSON.generate(data)
|
|
128
|
+
File.write(path, cipher.enabled? ? cipher.encrypt(payload) : payload)
|
|
129
|
+
end
|
|
130
|
+
|
|
93
131
|
def skill_text(skill)
|
|
94
132
|
[skill[:name], skill[:description], skill[:body]].compact.join("\n")
|
|
95
133
|
end
|
|
@@ -25,18 +25,19 @@ module Llmemory
|
|
|
25
25
|
@skills[user_id].find { |s| s[:id] == id }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def list_skills(user_id, limit: nil)
|
|
29
|
-
sorted =
|
|
28
|
+
def list_skills(user_id, limit: nil, offset: nil)
|
|
29
|
+
sorted = active_skills(user_id).sort_by { |s| as_time(s[:created_at]) }.reverse
|
|
30
|
+
sorted = sorted.drop(offset.to_i) if offset && offset.to_i.positive?
|
|
30
31
|
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def search_skills(user_id, query)
|
|
34
35
|
return list_skills(user_id) if query.to_s.strip.empty?
|
|
35
|
-
|
|
36
|
+
active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
def find_skills_by_name(user_id, name)
|
|
39
|
-
|
|
40
|
+
active_skills(user_id).select { |s| s[:name].to_s == name.to_s }
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def record_outcome(user_id, skill_id, success:)
|
|
@@ -49,7 +50,7 @@ module Llmemory
|
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
def count_skills(user_id)
|
|
52
|
-
|
|
53
|
+
active_skills(user_id).size
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
def delete_skills(user_id, ids)
|
|
@@ -59,12 +60,42 @@ module Llmemory
|
|
|
59
60
|
before - @skills[user_id].size
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
def archive_skills(user_id, ids)
|
|
64
|
+
ids = Array(ids).map(&:to_s)
|
|
65
|
+
count = 0
|
|
66
|
+
@skills[user_id].each do |s|
|
|
67
|
+
next unless ids.include?(s[:id].to_s)
|
|
68
|
+
next if s[:archived_at]
|
|
69
|
+
s[:archived_at] = Time.now
|
|
70
|
+
count += 1
|
|
71
|
+
end
|
|
72
|
+
count
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def expired_skill_ids(user_id, cutoff:)
|
|
76
|
+
active_skills(user_id)
|
|
77
|
+
.select { |s| as_time(s[:created_at]) < cutoff }
|
|
78
|
+
.map { |s| s[:id].to_s }
|
|
79
|
+
end
|
|
80
|
+
|
|
62
81
|
def list_users
|
|
63
82
|
@skills.keys
|
|
64
83
|
end
|
|
65
84
|
|
|
66
85
|
private
|
|
67
86
|
|
|
87
|
+
def active_skills(user_id)
|
|
88
|
+
@skills[user_id].reject { |s| s[:archived_at] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def as_time(value)
|
|
92
|
+
return Time.now if value.nil?
|
|
93
|
+
return value if value.is_a?(Time)
|
|
94
|
+
Time.parse(value.to_s)
|
|
95
|
+
rescue ArgumentError
|
|
96
|
+
Time.now
|
|
97
|
+
end
|
|
98
|
+
|
|
68
99
|
def symbolize(hash)
|
|
69
100
|
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
70
101
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Maintenance
|
|
5
|
+
# The cognitive maintenance pass closes CoALA's learning loop in one
|
|
6
|
+
# scheduled step. Independently, the gem exposes consolidation (short-term ->
|
|
7
|
+
# semantic), reflection (episodic -> insights), skill mining (episodic ->
|
|
8
|
+
# procedural) and TTL expiry. This pass orchestrates them so an agent learns
|
|
9
|
+
# from its experience and keeps its memory healthy without the consumer
|
|
10
|
+
# wiring each step by hand.
|
|
11
|
+
#
|
|
12
|
+
# Designed to run as a maintenance task (cron / Rails Job), per user. Each
|
|
13
|
+
# step is isolated: a failure in one is captured in the returned report
|
|
14
|
+
# (`:errors`) and never aborts the others.
|
|
15
|
+
#
|
|
16
|
+
# Returns:
|
|
17
|
+
# {
|
|
18
|
+
# consolidated: true/false/nil, # nil when no `memory:` was supplied
|
|
19
|
+
# insights: [insight_id, ...],
|
|
20
|
+
# mined: [proposal_or_skill_id, ...],
|
|
21
|
+
# expired: { episodic: N, procedural: M },
|
|
22
|
+
# errors: { reflect: "...", mine: "...", ... } # only failed steps
|
|
23
|
+
# }
|
|
24
|
+
class CognitivePass
|
|
25
|
+
def self.run!(user_id, **kwargs)
|
|
26
|
+
new(user_id, **kwargs).run!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(user_id, memory: nil, episodic: nil, procedural: nil, semantic: nil,
|
|
30
|
+
llm: nil, reflect: true, mine_skills: nil, expire: true,
|
|
31
|
+
reflection_window: 10, mining_window: Llmemory::SkillMining::Miner::DEFAULT_WINDOW)
|
|
32
|
+
@user_id = user_id
|
|
33
|
+
@memory = memory
|
|
34
|
+
@episodic = episodic
|
|
35
|
+
@procedural = procedural
|
|
36
|
+
@semantic = semantic
|
|
37
|
+
@llm = llm
|
|
38
|
+
@reflect = reflect
|
|
39
|
+
@mine_skills = mine_skills.nil? ? Llmemory.configuration.skill_mining_enabled : mine_skills
|
|
40
|
+
@expire = expire
|
|
41
|
+
@reflection_window = reflection_window
|
|
42
|
+
@mining_window = mining_window
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run!
|
|
46
|
+
report = { consolidated: nil, insights: [], mined: [], expired: { episodic: 0, procedural: 0 }, errors: {} }
|
|
47
|
+
|
|
48
|
+
step(report, :consolidate) { report[:consolidated] = consolidate } if @memory
|
|
49
|
+
step(report, :reflect) { report[:insights] = reflect } if @reflect
|
|
50
|
+
step(report, :mine) { report[:mined] = mine } if @mine_skills
|
|
51
|
+
step(report, :expire) { report[:expired] = expire } if @expire
|
|
52
|
+
|
|
53
|
+
report
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def step(report, name)
|
|
59
|
+
yield
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
report[:errors][name] = e.message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def consolidate
|
|
65
|
+
@memory.consolidate!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reflect
|
|
69
|
+
Reflection::Reflector.new(episodic: episodic, semantic: semantic, llm: @llm)
|
|
70
|
+
.reflect(window: @reflection_window)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def mine
|
|
74
|
+
SkillMining::Miner.new(episodic: episodic, procedural: procedural, llm: @llm)
|
|
75
|
+
.mine(window: @mining_window, auto_register: true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def expire
|
|
79
|
+
TTLExpiry.run!(@user_id, episodic: episodic, procedural: procedural)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def episodic
|
|
83
|
+
@episodic ||= @memory&.episodic || Llmemory::LongTerm::Episodic::Memory.new(user_id: @user_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def procedural
|
|
87
|
+
@procedural ||= @memory&.procedural || Llmemory::LongTerm::Procedural::Memory.new(user_id: @user_id)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def semantic
|
|
91
|
+
@semantic ||= build_semantic
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_semantic
|
|
95
|
+
llm_opts = @llm ? { llm: @llm } : {}
|
|
96
|
+
case (Llmemory.configuration.long_term_type || :file_based).to_s.to_sym
|
|
97
|
+
when :graph_based
|
|
98
|
+
Llmemory::LongTerm::GraphBased::Memory.new(
|
|
99
|
+
user_id: @user_id, storage: Llmemory::LongTerm::GraphBased::Storages.build, **llm_opts
|
|
100
|
+
)
|
|
101
|
+
else
|
|
102
|
+
Llmemory::LongTerm::FileBased::Memory.new(
|
|
103
|
+
user_id: @user_id, storage: Llmemory::LongTerm::FileBased::Storages.build, **llm_opts
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|