llmemory 0.2.0 → 0.2.2
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 +54 -3
- 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 +22 -0
- data/lib/llmemory/cli/commands/base.rb +8 -0
- data/lib/llmemory/cli/commands/episodic.rb +42 -0
- data/lib/llmemory/cli/commands/forget_log.rb +36 -0
- data/lib/llmemory/cli/commands/maintain.rb +62 -0
- data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
- data/lib/llmemory/cli/commands/procedural.rb +44 -0
- data/lib/llmemory/cli/commands/working.rb +31 -0
- data/lib/llmemory/cli.rb +18 -0
- data/lib/llmemory/configuration.rb +11 -1
- data/lib/llmemory/instrumentation.rb +33 -0
- data/lib/llmemory/llm/anthropic.rb +19 -15
- data/lib/llmemory/llm/openai.rb +16 -12
- data/lib/llmemory/long_term/episodic/memory.rb +94 -26
- data/lib/llmemory/long_term/episodic/storage.rb +7 -5
- data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +103 -0
- data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +156 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +28 -8
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +36 -6
- data/lib/llmemory/long_term/file_based/memory.rb +12 -4
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +15 -6
- data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +20 -8
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +6 -6
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +6 -6
- data/lib/llmemory/long_term/graph_based/memory.rb +89 -64
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +4 -2
- 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 +97 -30
- data/lib/llmemory/long_term/procedural/skill.rb +6 -2
- data/lib/llmemory/long_term/procedural/storage.rb +7 -5
- data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +114 -0
- data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +169 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +29 -9
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +37 -7
- 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 +17 -1
- data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
- data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
- data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
- data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
- data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
- data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
- data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
- data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
- data/lib/llmemory/memory.rb +48 -3
- data/lib/llmemory/memory_module.rb +13 -6
- data/lib/llmemory/reflection/reflector.rb +24 -20
- data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
- data/lib/llmemory/retrieval/engine.rb +25 -16
- data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
- data/lib/llmemory/short_term/session_lifecycle.rb +19 -3
- data/lib/llmemory/skill_mining/miner.rb +163 -0
- data/lib/llmemory/skill_mining.rb +8 -0
- data/lib/llmemory/tokenizer.rb +27 -0
- data/lib/llmemory/vector_store/active_record_store.rb +4 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
- data/lib/llmemory/vector_store.rb +14 -0
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +3 -0
- metadata +39 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Procedural
|
|
11
|
+
module Storages
|
|
12
|
+
# ActiveRecord backend. Stores each skill as a JSONB `data` document; AR
|
|
13
|
+
# auto-deserializes jsonb to a Hash (string keys), which Skill.from_h
|
|
14
|
+
# handles. Mirrors the file-based ActiveRecordStorage pattern.
|
|
15
|
+
class ActiveRecordStorage < Base
|
|
16
|
+
def initialize
|
|
17
|
+
self.class.load_models!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.load_models!
|
|
21
|
+
return if @models_loaded
|
|
22
|
+
require "active_record"
|
|
23
|
+
require_relative "active_record_models"
|
|
24
|
+
@models_loaded = true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def save_skill(user_id, skill)
|
|
28
|
+
id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
|
|
29
|
+
data = stringify(skill).merge("id" => id, "user_id" => user_id)
|
|
30
|
+
data["created_at"] ||= Time.now.utc.iso8601
|
|
31
|
+
rec = LlmemorySkill.find_or_initialize_by(id: id)
|
|
32
|
+
rec.user_id = user_id
|
|
33
|
+
rec.data = data
|
|
34
|
+
rec.search_text = searchable_text(data)
|
|
35
|
+
rec.created_at ||= Time.current
|
|
36
|
+
rec.save!
|
|
37
|
+
id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_skill(user_id, id)
|
|
41
|
+
LlmemorySkill.find_by(user_id: user_id, id: id)&.data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def list_skills(user_id, limit: nil, offset: nil)
|
|
45
|
+
scope = LlmemorySkill.where(user_id: user_id, archived_at: nil).order(created_at: :desc)
|
|
46
|
+
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
47
|
+
scope = scope.offset(offset) if offset && offset.to_i.positive?
|
|
48
|
+
scope.map(&:data)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def search_skills(user_id, query)
|
|
52
|
+
token_scope(LlmemorySkill.where(user_id: user_id, archived_at: nil), "search_text", query)
|
|
53
|
+
.order(created_at: :desc).map(&:data)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find_skills_by_name(user_id, name)
|
|
57
|
+
LlmemorySkill.where(user_id: user_id, archived_at: nil).where("data->>'name' = ?", name.to_s).map(&:data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def record_outcome(user_id, skill_id, success:)
|
|
61
|
+
rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
|
|
62
|
+
return nil unless rec
|
|
63
|
+
data = rec.data || {}
|
|
64
|
+
key = success ? "success_count" : "failure_count"
|
|
65
|
+
data[key] = (data[key] || 0).to_i + 1
|
|
66
|
+
data["updated_at"] = Time.now.utc.iso8601
|
|
67
|
+
rec.data = data
|
|
68
|
+
rec.search_text = searchable_text(data)
|
|
69
|
+
rec.save!
|
|
70
|
+
data
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def count_skills(user_id)
|
|
74
|
+
LlmemorySkill.where(user_id: user_id, archived_at: nil).count
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def delete_skills(user_id, ids)
|
|
78
|
+
LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def archive_skills(user_id, ids)
|
|
82
|
+
LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s), archived_at: nil)
|
|
83
|
+
.update_all(archived_at: Time.current)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def expired_skill_ids(user_id, cutoff:)
|
|
87
|
+
LlmemorySkill.where(user_id: user_id, archived_at: nil).where("created_at < ?", cutoff).pluck(:id)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def list_users
|
|
91
|
+
LlmemorySkill.distinct.pluck(:user_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def token_scope(scope, column, query)
|
|
97
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
98
|
+
return scope if tokens.empty?
|
|
99
|
+
clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
|
|
100
|
+
scope.where(clause, *tokens.map { |t| "%#{t}%" })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stringify(hash)
|
|
104
|
+
JSON.parse(JSON.generate(hash))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def searchable_text(data)
|
|
108
|
+
[data["name"], data["description"], data["body"]].compact.join("\n")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
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
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Procedural
|
|
11
|
+
module Storages
|
|
12
|
+
# PostgreSQL backend. Each skill is stored as a JSONB `data` document
|
|
13
|
+
# (plus id/user_id/created_at and a denormalized search_text), mirroring
|
|
14
|
+
# the file-based DatabaseStorage pattern.
|
|
15
|
+
class DatabaseStorage < Base
|
|
16
|
+
def initialize(database_url: nil)
|
|
17
|
+
@database_url = database_url || Llmemory.configuration.database_url
|
|
18
|
+
@connection = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save_skill(user_id, skill)
|
|
22
|
+
ensure_tables!
|
|
23
|
+
id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
|
|
24
|
+
data = symbolize(skill).merge(id: id, user_id: user_id)
|
|
25
|
+
data[:created_at] ||= Time.now.utc.iso8601
|
|
26
|
+
conn.exec_params(
|
|
27
|
+
"INSERT INTO llmemory_skills (id, user_id, data, search_text, created_at) " \
|
|
28
|
+
"VALUES ($1, $2, $3::jsonb, $4, $5) " \
|
|
29
|
+
"ON CONFLICT (id) DO UPDATE SET data = $3::jsonb, search_text = $4",
|
|
30
|
+
[id, user_id, JSON.generate(data), searchable_text(data), created_at_value(data)]
|
|
31
|
+
)
|
|
32
|
+
id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_skill(user_id, id)
|
|
36
|
+
ensure_tables!
|
|
37
|
+
rows = conn.exec_params("SELECT data FROM llmemory_skills WHERE user_id = $1 AND id = $2", [user_id, id])
|
|
38
|
+
rows.any? ? parse_data(rows.first["data"]) : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def list_skills(user_id, limit: nil, offset: nil)
|
|
42
|
+
ensure_tables!
|
|
43
|
+
sql = "SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL ORDER BY created_at DESC"
|
|
44
|
+
sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
|
|
45
|
+
sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
|
|
46
|
+
conn.exec_params(sql, [user_id]).map { |r| parse_data(r["data"]) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def search_skills(user_id, query)
|
|
50
|
+
ensure_tables!
|
|
51
|
+
suffix, params = token_filter("search_text", query, 2)
|
|
52
|
+
conn.exec_params(
|
|
53
|
+
"SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL#{suffix} ORDER BY created_at DESC",
|
|
54
|
+
[user_id, *params]
|
|
55
|
+
).map { |r| parse_data(r["data"]) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def find_skills_by_name(user_id, name)
|
|
59
|
+
ensure_tables!
|
|
60
|
+
conn.exec_params(
|
|
61
|
+
"SELECT data FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND data->>'name' = $2",
|
|
62
|
+
[user_id, name.to_s]
|
|
63
|
+
).map { |r| parse_data(r["data"]) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def record_outcome(user_id, skill_id, success:)
|
|
67
|
+
ensure_tables!
|
|
68
|
+
data = get_skill(user_id, skill_id)
|
|
69
|
+
return nil unless data
|
|
70
|
+
key = success ? :success_count : :failure_count
|
|
71
|
+
data[key] = (data[key] || 0).to_i + 1
|
|
72
|
+
data[:updated_at] = Time.now.utc.iso8601
|
|
73
|
+
conn.exec_params(
|
|
74
|
+
"UPDATE llmemory_skills SET data = $3::jsonb, search_text = $4 WHERE user_id = $1 AND id = $2",
|
|
75
|
+
[user_id, skill_id, JSON.generate(data), searchable_text(data)]
|
|
76
|
+
)
|
|
77
|
+
data
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def count_skills(user_id)
|
|
81
|
+
ensure_tables!
|
|
82
|
+
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
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delete_skills(user_id, ids)
|
|
86
|
+
ensure_tables!
|
|
87
|
+
Array(ids).sum do |id|
|
|
88
|
+
conn.exec_params("DELETE FROM llmemory_skills WHERE user_id = $1 AND id = $2", [user_id, id]).cmd_tuples
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def archive_skills(user_id, ids)
|
|
93
|
+
ensure_tables!
|
|
94
|
+
Array(ids).sum do |id|
|
|
95
|
+
conn.exec_params(
|
|
96
|
+
"UPDATE llmemory_skills SET archived_at = NOW() WHERE user_id = $1 AND id = $2 AND archived_at IS NULL",
|
|
97
|
+
[user_id, id]
|
|
98
|
+
).cmd_tuples
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def expired_skill_ids(user_id, cutoff:)
|
|
103
|
+
ensure_tables!
|
|
104
|
+
conn.exec_params(
|
|
105
|
+
"SELECT id FROM llmemory_skills WHERE user_id = $1 AND archived_at IS NULL AND created_at < $2",
|
|
106
|
+
[user_id, cutoff.iso8601]
|
|
107
|
+
).map { |r| r["id"] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def list_users
|
|
111
|
+
ensure_tables!
|
|
112
|
+
conn.exec("SELECT DISTINCT user_id FROM llmemory_skills").map { |r| r["user_id"] }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def conn
|
|
118
|
+
@connection ||= begin
|
|
119
|
+
require "pg"
|
|
120
|
+
PG.connect(@database_url)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ensure_tables!
|
|
125
|
+
conn.exec(<<~SQL)
|
|
126
|
+
CREATE TABLE IF NOT EXISTS llmemory_skills (
|
|
127
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
128
|
+
user_id TEXT NOT NULL,
|
|
129
|
+
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
130
|
+
search_text TEXT,
|
|
131
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
132
|
+
archived_at TIMESTAMPTZ
|
|
133
|
+
);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_llmemory_skills_user_id ON llmemory_skills(user_id);
|
|
135
|
+
ALTER TABLE llmemory_skills ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
|
136
|
+
SQL
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def token_filter(column, query, start_index)
|
|
140
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
141
|
+
return ["", []] if tokens.empty?
|
|
142
|
+
likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
|
|
143
|
+
[" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse_data(value)
|
|
147
|
+
JSON.parse(value.to_s, symbolize_names: true)
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
{}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def symbolize(hash)
|
|
153
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def searchable_text(data)
|
|
157
|
+
[data[:name], data[:description], data[:body]].compact.join("\n")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def created_at_value(data)
|
|
161
|
+
ca = data[:created_at]
|
|
162
|
+
return Time.now.utc.iso8601 if ca.nil?
|
|
163
|
+
ca.respond_to?(:iso8601) ? ca.iso8601 : ca.to_s
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -29,19 +29,19 @@ module Llmemory
|
|
|
29
29
|
load_skill(path)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def list_skills(user_id, limit: nil)
|
|
33
|
-
sorted =
|
|
32
|
+
def list_skills(user_id, limit: nil, offset: nil)
|
|
33
|
+
sorted = active_skills(user_id).sort_by { |s| s[:created_at] }.reverse
|
|
34
|
+
sorted = sorted.drop(offset.to_i) if offset && offset.to_i.positive?
|
|
34
35
|
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def search_skills(user_id, query)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
all_skills(user_id).select { |s| skill_text(s).downcase.include?(q) }
|
|
39
|
+
return list_skills(user_id) if query.to_s.strip.empty?
|
|
40
|
+
active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def find_skills_by_name(user_id, name)
|
|
44
|
-
|
|
44
|
+
active_skills(user_id).select { |s| s[:name].to_s == name.to_s }
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def record_outcome(user_id, skill_id, success:)
|
|
@@ -55,9 +55,7 @@ module Llmemory
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def count_skills(user_id)
|
|
58
|
-
|
|
59
|
-
return 0 unless Dir.exist?(dir)
|
|
60
|
-
Dir.children(dir).count { |f| f.end_with?(".json") }
|
|
58
|
+
active_skills(user_id).size
|
|
61
59
|
end
|
|
62
60
|
|
|
63
61
|
def delete_skills(user_id, ids)
|
|
@@ -69,6 +67,24 @@ module Llmemory
|
|
|
69
67
|
end
|
|
70
68
|
end
|
|
71
69
|
|
|
70
|
+
def archive_skills(user_id, ids)
|
|
71
|
+
Array(ids).map(&:to_s).count do |id|
|
|
72
|
+
path = skill_path(user_id, id)
|
|
73
|
+
next false unless File.file?(path)
|
|
74
|
+
data = JSON.parse(File.read(path))
|
|
75
|
+
next false if data["archived_at"]
|
|
76
|
+
data["archived_at"] = Time.now.iso8601
|
|
77
|
+
File.write(path, JSON.generate(data))
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def expired_skill_ids(user_id, cutoff:)
|
|
83
|
+
active_skills(user_id)
|
|
84
|
+
.select { |s| (s[:created_at] || Time.now) < cutoff }
|
|
85
|
+
.map { |s| s[:id].to_s }
|
|
86
|
+
end
|
|
87
|
+
|
|
72
88
|
def list_users
|
|
73
89
|
return [] unless Dir.exist?(@base_path)
|
|
74
90
|
Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
|
|
@@ -76,6 +92,10 @@ module Llmemory
|
|
|
76
92
|
|
|
77
93
|
private
|
|
78
94
|
|
|
95
|
+
def active_skills(user_id)
|
|
96
|
+
all_skills(user_id).reject { |s| s[:archived_at] }
|
|
97
|
+
end
|
|
98
|
+
|
|
79
99
|
def all_skills(user_id)
|
|
80
100
|
dir = user_path(user_id, "skills")
|
|
81
101
|
return [] unless Dir.exist?(dir)
|
|
@@ -25,19 +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
|
-
|
|
36
|
-
@skills[user_id].select { |s| skill_text(s).downcase.include?(q) }
|
|
35
|
+
return list_skills(user_id) if query.to_s.strip.empty?
|
|
36
|
+
active_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def find_skills_by_name(user_id, name)
|
|
40
|
-
|
|
40
|
+
active_skills(user_id).select { |s| s[:name].to_s == name.to_s }
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def record_outcome(user_id, skill_id, success:)
|
|
@@ -50,7 +50,7 @@ module Llmemory
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def count_skills(user_id)
|
|
53
|
-
|
|
53
|
+
active_skills(user_id).size
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def delete_skills(user_id, ids)
|
|
@@ -60,12 +60,42 @@ module Llmemory
|
|
|
60
60
|
before - @skills[user_id].size
|
|
61
61
|
end
|
|
62
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
|
+
|
|
63
81
|
def list_users
|
|
64
82
|
@skills.keys
|
|
65
83
|
end
|
|
66
84
|
|
|
67
85
|
private
|
|
68
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
|
+
|
|
69
99
|
def symbolize(hash)
|
|
70
100
|
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
71
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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Maintenance
|
|
5
|
+
# TTL expiry job: soft-archives episodic/procedural entries whose age
|
|
6
|
+
# exceeds the configured per-type TTL. Designed to run as a maintenance
|
|
7
|
+
# task (cron / Rails Job). Idempotent — already-archived entries are
|
|
8
|
+
# skipped by the storage layer.
|
|
9
|
+
#
|
|
10
|
+
# Reads `Llmemory.configuration.ttl_episodic_days` and
|
|
11
|
+
# `Llmemory.configuration.ttl_procedural_days`. A nil/zero TTL disables
|
|
12
|
+
# expiry for that memory type.
|
|
13
|
+
#
|
|
14
|
+
# Returns a hash `{ episodic: N, procedural: M }` with the number of
|
|
15
|
+
# entries archived per type for the given user.
|
|
16
|
+
class TTLExpiry
|
|
17
|
+
DEFAULT_REASON = "ttl_expired"
|
|
18
|
+
|
|
19
|
+
def self.run!(user_id, episodic: nil, procedural: nil, reason: DEFAULT_REASON)
|
|
20
|
+
new(user_id, episodic: episodic, procedural: procedural, reason: reason).run!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(user_id, episodic: nil, procedural: nil, reason: DEFAULT_REASON)
|
|
24
|
+
@user_id = user_id
|
|
25
|
+
@episodic = episodic
|
|
26
|
+
@procedural = procedural
|
|
27
|
+
@reason = reason
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run!
|
|
31
|
+
{
|
|
32
|
+
episodic: expire(memory: @episodic ||= Llmemory::LongTerm::Episodic::Memory.new(user_id: @user_id),
|
|
33
|
+
ttl_days: Llmemory.configuration.ttl_episodic_days),
|
|
34
|
+
procedural: expire(memory: @procedural ||= Llmemory::LongTerm::Procedural::Memory.new(user_id: @user_id),
|
|
35
|
+
ttl_days: Llmemory.configuration.ttl_procedural_days)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def expire(memory:, ttl_days:)
|
|
42
|
+
return 0 unless ttl_days && ttl_days.to_f.positive?
|
|
43
|
+
cutoff = Time.now - (ttl_days.to_f * 86400)
|
|
44
|
+
ids = memory.expired_ids(cutoff: cutoff)
|
|
45
|
+
return 0 if ids.empty?
|
|
46
|
+
memory.forget(ids: ids, reason: @reason, mode: :soft)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/llmemory/maintenance.rb
CHANGED
data/lib/llmemory/mcp/server.rb
CHANGED
|
@@ -11,6 +11,14 @@ require_relative "tools/memory_consolidate"
|
|
|
11
11
|
require_relative "tools/memory_stats"
|
|
12
12
|
require_relative "tools/memory_info"
|
|
13
13
|
require_relative "tools/memory_timeline_context"
|
|
14
|
+
require_relative "tools/memory_episode_record"
|
|
15
|
+
require_relative "tools/memory_episodes"
|
|
16
|
+
require_relative "tools/memory_skill_register"
|
|
17
|
+
require_relative "tools/memory_skill_report"
|
|
18
|
+
require_relative "tools/memory_skills"
|
|
19
|
+
require_relative "tools/memory_forget"
|
|
20
|
+
require_relative "tools/memory_mine_skills"
|
|
21
|
+
require_relative "tools/memory_maintain"
|
|
14
22
|
|
|
15
23
|
module Llmemory
|
|
16
24
|
module MCP
|
|
@@ -157,7 +165,15 @@ module Llmemory
|
|
|
157
165
|
Tools::MemoryAddMessage,
|
|
158
166
|
Tools::MemoryConsolidate,
|
|
159
167
|
Tools::MemoryStats,
|
|
160
|
-
Tools::MemoryInfo
|
|
168
|
+
Tools::MemoryInfo,
|
|
169
|
+
Tools::MemoryEpisodeRecord,
|
|
170
|
+
Tools::MemoryEpisodes,
|
|
171
|
+
Tools::MemorySkillRegister,
|
|
172
|
+
Tools::MemorySkillReport,
|
|
173
|
+
Tools::MemorySkills,
|
|
174
|
+
Tools::MemoryForget,
|
|
175
|
+
Tools::MemoryMineSkills,
|
|
176
|
+
Tools::MemoryMaintain
|
|
161
177
|
]
|
|
162
178
|
end
|
|
163
179
|
|