llmemory 0.1.17 → 0.2.1
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 +178 -1
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -0
- data/lib/llmemory/actions/reason.rb +49 -0
- data/lib/llmemory/actions.rb +8 -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/procedural.rb +44 -0
- data/lib/llmemory/cli/commands/working.rb +31 -0
- data/lib/llmemory/cli.rb +12 -0
- data/lib/llmemory/configuration.rb +6 -0
- data/lib/llmemory/forget_log.rb +50 -0
- data/lib/llmemory/long_term/episodic/memory.rb +97 -15
- 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 +93 -0
- data/lib/llmemory/long_term/episodic/storages/base.rb +5 -0
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +135 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +11 -3
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +9 -3
- data/lib/llmemory/long_term/file_based/memory.rb +31 -0
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +11 -4
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +16 -6
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +2 -4
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -4
- data/lib/llmemory/long_term/graph_based/memory.rb +95 -51
- data/lib/llmemory/long_term/procedural/memory.rb +170 -0
- data/lib/llmemory/long_term/procedural/skill.rb +93 -0
- data/lib/llmemory/long_term/procedural/storage.rb +33 -0
- data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +104 -0
- data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +148 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +135 -0
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +79 -0
- data/lib/llmemory/long_term/procedural.rb +12 -0
- data/lib/llmemory/long_term.rb +2 -0
- data/lib/llmemory/mcp/server.rb +13 -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_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 +34 -1
- data/lib/llmemory/memory_module.rb +55 -0
- data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
- data/lib/llmemory/retrieval/engine.rb +115 -6
- data/lib/llmemory/retrieval/feedback_store.rb +50 -0
- data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
- data/lib/llmemory/short_term/checkpoint.rb +2 -14
- data/lib/llmemory/short_term/session_lifecycle.rb +22 -13
- data/lib/llmemory/short_term/stores.rb +27 -0
- data/lib/llmemory/tokenizer.rb +27 -0
- data/lib/llmemory/vector_store/active_record_store.rb +4 -3
- data/lib/llmemory/vector_store.rb +14 -0
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/working_memory.rb +83 -0
- data/lib/llmemory.rb +5 -0
- metadata +32 -1
|
@@ -6,11 +6,14 @@ require_relative "knowledge_graph"
|
|
|
6
6
|
require_relative "conflict_resolver"
|
|
7
7
|
require_relative "storage"
|
|
8
8
|
require_relative "../../noise_filter"
|
|
9
|
+
require_relative "../../memory_module"
|
|
9
10
|
|
|
10
11
|
module Llmemory
|
|
11
12
|
module LongTerm
|
|
12
13
|
module GraphBased
|
|
13
14
|
class Memory
|
|
15
|
+
include Llmemory::MemoryModule
|
|
16
|
+
|
|
14
17
|
def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil)
|
|
15
18
|
@user_id = user_id
|
|
16
19
|
@graph_storage = storage || Storages.build
|
|
@@ -25,14 +28,95 @@ module Llmemory
|
|
|
25
28
|
text = Llmemory.configuration.noise_filter_enabled ? NoiseFilter.filter?(conversation_text) : conversation_text.to_s
|
|
26
29
|
return true if text.strip.empty?
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
data = { entities: [], relations: [] } unless data.is_a?(Hash)
|
|
30
|
-
entities = Array(data[:entities] || data["entities"])
|
|
31
|
-
relations = Array(data[:relations] || data["relations"])
|
|
32
|
-
|
|
31
|
+
entities, relations = extract_graph(text)
|
|
33
32
|
return true if entities.empty? && relations.empty?
|
|
34
33
|
|
|
35
34
|
provenance = Llmemory::Provenance.from_text_fingerprint(text, method: "entity_relation_extraction")
|
|
35
|
+
ingest(entities, relations, provenance)
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def retrieve(query, top_k: 10)
|
|
40
|
+
results = hybrid_search(query, top_k: top_k)
|
|
41
|
+
format_as_context(results)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def search_candidates(query, user_id: nil, top_k: 20)
|
|
45
|
+
uid = user_id || @user_id
|
|
46
|
+
return [] unless uid == @user_id
|
|
47
|
+
results = hybrid_search(query, top_k: top_k)
|
|
48
|
+
results.map do |r|
|
|
49
|
+
{
|
|
50
|
+
id: r[:id],
|
|
51
|
+
text: r[:text],
|
|
52
|
+
timestamp: r[:created_at] || r[:timestamp],
|
|
53
|
+
score: r[:score] || 1.0,
|
|
54
|
+
importance: r[:importance]
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_reader :user_id
|
|
60
|
+
|
|
61
|
+
def storage
|
|
62
|
+
@graph_storage
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Stores a fact produced outside the conversational flow (e.g. a
|
|
66
|
+
# reflection insight) by extracting entities/relations from `content` and
|
|
67
|
+
# adding them to the graph, preserving caller-supplied provenance. Lets
|
|
68
|
+
# the Reflector target graph-based semantic memory.
|
|
69
|
+
def remember_fact(content:, category: nil, importance: nil, provenance: nil)
|
|
70
|
+
return nil if content.to_s.strip.empty?
|
|
71
|
+
entities, relations = extract_graph(content)
|
|
72
|
+
return nil if entities.empty? && relations.empty?
|
|
73
|
+
prov = provenance || Llmemory::Provenance.from_text_fingerprint(content, method: "reflection")
|
|
74
|
+
ingest(entities, relations, prov)
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# --- MemoryModule uniform interface ---
|
|
79
|
+
|
|
80
|
+
def write(payload, **_meta)
|
|
81
|
+
memorize(payload)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def list(user_id: nil, limit: nil)
|
|
85
|
+
@graph_storage.list_nodes(user_id || @user_id, limit: limit)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stats(user_id: nil)
|
|
89
|
+
uid = user_id || @user_id
|
|
90
|
+
{ nodes: @graph_storage.count_nodes(uid), edges: @graph_storage.count_edges(uid) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Forgets relations by archiving the edges identified by the candidate
|
|
94
|
+
# ids returned from #read/#search_candidates (edge ids), recording the
|
|
95
|
+
# removal in the audit log. Edges are soft-archived (archived_at) so they
|
|
96
|
+
# no longer appear in retrieval; nodes are left in place (a node may still
|
|
97
|
+
# be referenced by other active edges). Returns the number archived.
|
|
98
|
+
def forget(ids:, reason: nil)
|
|
99
|
+
archived = Array(ids).map(&:to_s).select { |edge_id| @kg.archive_edge(edge_id) }
|
|
100
|
+
forget_log.record(@user_id, memory_type: "graph_based", ids: archived, reason: reason)
|
|
101
|
+
archived.size
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def build_vector_store
|
|
107
|
+
Llmemory::VectorStore.build(source_type: "edge")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extract_graph(text)
|
|
111
|
+
data = @extractor.extract(text) rescue { entities: [], relations: [] }
|
|
112
|
+
data = { entities: [], relations: [] } unless data.is_a?(Hash)
|
|
113
|
+
[Array(data[:entities] || data["entities"]), Array(data[:relations] || data["relations"])]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Adds entities and relations to the graph (nodes, edges, embeddings) with
|
|
117
|
+
# the given provenance. Shared by memorize (conversation) and
|
|
118
|
+
# remember_fact (reflection).
|
|
119
|
+
def ingest(entities, relations, provenance)
|
|
36
120
|
name_to_id = {}
|
|
37
121
|
|
|
38
122
|
entities.each do |e|
|
|
@@ -67,52 +151,12 @@ module Llmemory
|
|
|
67
151
|
@conflict_resolver.resolve(edge)
|
|
68
152
|
edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: { "provenance" => provenance })
|
|
69
153
|
|
|
70
|
-
|
|
71
|
-
embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(
|
|
154
|
+
edge_text = "#{subject} #{predicate} #{object}"
|
|
155
|
+
embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(edge_text) : nil
|
|
72
156
|
if embedding && @vector_store.respond_to?(:store)
|
|
73
|
-
@vector_store.store(id:
|
|
157
|
+
@vector_store.store(id: edge_id, embedding: embedding, metadata: { text: edge_text, created_at: Time.now }, user_id: @user_id)
|
|
74
158
|
end
|
|
75
159
|
end
|
|
76
|
-
|
|
77
|
-
true
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def retrieve(query, top_k: 10)
|
|
81
|
-
results = hybrid_search(query, top_k: top_k)
|
|
82
|
-
format_as_context(results)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def search_candidates(query, user_id: nil, top_k: 20)
|
|
86
|
-
uid = user_id || @user_id
|
|
87
|
-
return [] unless uid == @user_id
|
|
88
|
-
results = hybrid_search(query, top_k: top_k)
|
|
89
|
-
results.map do |r|
|
|
90
|
-
{
|
|
91
|
-
text: r[:text],
|
|
92
|
-
timestamp: r[:created_at] || r[:timestamp],
|
|
93
|
-
score: r[:score] || 1.0,
|
|
94
|
-
importance: r[:importance]
|
|
95
|
-
}
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
attr_reader :user_id
|
|
100
|
-
|
|
101
|
-
def storage
|
|
102
|
-
@graph_storage
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
private
|
|
106
|
-
|
|
107
|
-
def build_vector_store
|
|
108
|
-
emb = Llmemory::VectorStore::OpenAIEmbeddings.new
|
|
109
|
-
store_type = (Llmemory.configuration.long_term_store || :memory).to_s.to_sym
|
|
110
|
-
if store_type == :active_record || store_type == :activerecord
|
|
111
|
-
require_relative "../../vector_store/active_record_store"
|
|
112
|
-
Llmemory::VectorStore::ActiveRecordStore.new(embedding_provider: emb)
|
|
113
|
-
else
|
|
114
|
-
Llmemory::VectorStore::MemoryStore.new(embedding_provider: emb)
|
|
115
|
-
end
|
|
116
160
|
end
|
|
117
161
|
|
|
118
162
|
def hybrid_search(query, top_k:)
|
|
@@ -127,7 +171,7 @@ module Llmemory
|
|
|
127
171
|
out = vector_results.map do |v|
|
|
128
172
|
id = v[:id] || v["id"]
|
|
129
173
|
meta = v[:metadata] || v["metadata"] || {}
|
|
130
|
-
{ text: meta["text"] || meta[:text] || id.to_s, score: v[:score] || v["score"] || 1.0, created_at: meta["created_at"] || meta[:created_at] }
|
|
174
|
+
{ id: id, text: meta["text"] || meta[:text] || id.to_s, score: v[:score] || v["score"] || 1.0, created_at: meta["created_at"] || meta[:created_at] }
|
|
131
175
|
end
|
|
132
176
|
|
|
133
177
|
node_ids = out.flat_map { |r| extract_node_ids_from_text(r[:text]) }.compact.uniq
|
|
@@ -139,7 +183,7 @@ module Llmemory
|
|
|
139
183
|
subj = @kg.find_node_by_id(e.subject_id)
|
|
140
184
|
obj = @kg.find_node_by_id(e.target_id)
|
|
141
185
|
edge_text = "#{subj&.name} #{e.predicate} #{obj&.name}"
|
|
142
|
-
out << { text: edge_text, score: 0.85, created_at: e.created_at } unless out.any? { |o| o[:text] == edge_text }
|
|
186
|
+
out << { id: e.id, text: edge_text, score: 0.85, created_at: e.created_at } unless out.any? { |o| o[:text] == edge_text }
|
|
143
187
|
end
|
|
144
188
|
end
|
|
145
189
|
|
|
@@ -152,7 +196,7 @@ module Llmemory
|
|
|
152
196
|
obj = @kg.find_node_by_id(e.target_id)
|
|
153
197
|
next unless subj && obj
|
|
154
198
|
edge_text = "#{subj.name} #{e.predicate} #{obj.name}"
|
|
155
|
-
out << { text: edge_text, score: 0.7, created_at: e.created_at }
|
|
199
|
+
out << { id: e.id, text: edge_text, score: 0.7, created_at: e.created_at }
|
|
156
200
|
end
|
|
157
201
|
end
|
|
158
202
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "skill"
|
|
4
|
+
require_relative "storage"
|
|
5
|
+
require_relative "../../memory_module"
|
|
6
|
+
require_relative "../../vector_store"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Procedural
|
|
11
|
+
# Procedural long-term memory: a Voyager-style skill library. Agents
|
|
12
|
+
# register reusable skills (prompts, templates, code), retrieve them by
|
|
13
|
+
# relevance to the current task, and report outcomes so proven skills are
|
|
14
|
+
# preferred over unproven ones.
|
|
15
|
+
#
|
|
16
|
+
# The success rate of each skill is surfaced as `importance`, so the
|
|
17
|
+
# retrieval Engine ranks battle-tested skills higher (P3). Semantic
|
|
18
|
+
# (embedding) retrieval is opt-in via `config.procedural_vector_enabled` or
|
|
19
|
+
# by injecting a `vector_store:`; when off, search is keyword-only.
|
|
20
|
+
class Memory
|
|
21
|
+
include Llmemory::MemoryModule
|
|
22
|
+
|
|
23
|
+
attr_reader :user_id, :storage
|
|
24
|
+
|
|
25
|
+
def initialize(user_id:, storage: nil, vector_store: nil)
|
|
26
|
+
@user_id = user_id
|
|
27
|
+
@storage = storage || Storages.build
|
|
28
|
+
@vector_store = vector_store
|
|
29
|
+
@vector_explicit = !vector_store.nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Registers a skill. If `version` is omitted and a skill with the same
|
|
33
|
+
# name exists, the version auto-increments (skill evolution).
|
|
34
|
+
def register_skill(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil)
|
|
35
|
+
version ||= next_version_for(name)
|
|
36
|
+
skill = Skill.new(
|
|
37
|
+
id: nil, user_id: @user_id, name: name, body: body,
|
|
38
|
+
description: description, kind: kind, version: version
|
|
39
|
+
)
|
|
40
|
+
id = @storage.save_skill(@user_id, skill.to_h)
|
|
41
|
+
index_vector(id, skill.searchable_text)
|
|
42
|
+
id
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_skill(query)
|
|
46
|
+
raw = @storage.search_skills(@user_id, query).first
|
|
47
|
+
raw && Skill.from_h(raw)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_skill(id)
|
|
51
|
+
raw = @storage.get_skill(@user_id, id)
|
|
52
|
+
raw && Skill.from_h(raw)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def skills(limit: nil)
|
|
56
|
+
@storage.list_skills(@user_id, limit: limit).map { |s| Skill.from_h(s) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def count
|
|
60
|
+
@storage.count_skills(@user_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Records that applying a skill succeeded or failed. Feeds retrieval
|
|
64
|
+
# ranking and adaptive retrieval (P8). Returns the updated Skill.
|
|
65
|
+
def report_outcome(skill_id, success:)
|
|
66
|
+
raw = @storage.record_outcome(@user_id, skill_id, success: success)
|
|
67
|
+
raw && Skill.from_h(raw)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Retrieval Engine integration: skills ranked by relevance, recency and
|
|
71
|
+
# proven utility (success rate exposed as importance). Hybrid (vector +
|
|
72
|
+
# keyword) when a vector store is active; otherwise keyword-only.
|
|
73
|
+
def search_candidates(query, user_id: nil, top_k: 20)
|
|
74
|
+
uid = user_id || @user_id
|
|
75
|
+
return [] unless uid == @user_id
|
|
76
|
+
|
|
77
|
+
keyword = @storage.search_skills(uid, query).first(top_k).map { |raw| candidate_for(raw, 1.0) }
|
|
78
|
+
vs = vector_store
|
|
79
|
+
return keyword unless vs
|
|
80
|
+
|
|
81
|
+
merge_candidates(vector_candidates(query, top_k, vs), keyword, top_k)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# --- MemoryModule uniform interface ---
|
|
85
|
+
|
|
86
|
+
def write(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, **_meta)
|
|
87
|
+
register_skill(name: name, body: body, description: description, kind: kind, version: version)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def list(user_id: nil, limit: nil)
|
|
91
|
+
skills(limit: limit)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stats(user_id: nil)
|
|
95
|
+
{ skills: count }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def forget(ids:, reason: nil)
|
|
99
|
+
requested = Array(ids).map(&:to_s)
|
|
100
|
+
existing = @storage.list_skills(@user_id).map { |s| (s[:id] || s["id"]).to_s }
|
|
101
|
+
removed = requested & existing
|
|
102
|
+
@storage.delete_skills(@user_id, removed)
|
|
103
|
+
forget_log.record(@user_id, memory_type: "procedural", ids: removed, reason: reason)
|
|
104
|
+
removed.size
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def next_version_for(name)
|
|
110
|
+
existing = @storage.find_skills_by_name(@user_id, name)
|
|
111
|
+
return 1 if existing.empty?
|
|
112
|
+
existing.map { |s| (s[:version] || s["version"] || 1).to_i }.max + 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Active vector store: injected, or a config-gated lazy build; nil when
|
|
116
|
+
# semantic search is disabled (default).
|
|
117
|
+
def vector_store
|
|
118
|
+
if @vector_explicit
|
|
119
|
+
@vector_store
|
|
120
|
+
elsif Llmemory.configuration.procedural_vector_enabled
|
|
121
|
+
@vector_store ||= Llmemory::VectorStore.build(source_type: "skill")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Best-effort embedding indexing; failures never break registration.
|
|
126
|
+
def index_vector(id, text)
|
|
127
|
+
vs = vector_store
|
|
128
|
+
return if vs.nil? || text.to_s.strip.empty?
|
|
129
|
+
embedding = vs.embed(text)
|
|
130
|
+
return unless embedding
|
|
131
|
+
vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def vector_candidates(query, top_k, vs)
|
|
137
|
+
vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id).filter_map do |r|
|
|
138
|
+
raw = @storage.get_skill(@user_id, r[:id] || r["id"])
|
|
139
|
+
raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
|
|
140
|
+
end
|
|
141
|
+
rescue StandardError
|
|
142
|
+
[]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def candidate_for(raw, score)
|
|
146
|
+
skill = Skill.from_h(raw)
|
|
147
|
+
{
|
|
148
|
+
id: skill.id,
|
|
149
|
+
text: skill.searchable_text,
|
|
150
|
+
timestamp: skill.created_at,
|
|
151
|
+
score: score,
|
|
152
|
+
importance: skill.success_rate,
|
|
153
|
+
evergreen: false
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Dedup by id keeping the higher score; highest score first, capped.
|
|
158
|
+
def merge_candidates(primary, secondary, top_k)
|
|
159
|
+
by_id = {}
|
|
160
|
+
(primary + secondary).each do |c|
|
|
161
|
+
key = c[:id] || c[:text]
|
|
162
|
+
existing = by_id[key]
|
|
163
|
+
by_id[key] = c if existing.nil? || c[:score].to_f > existing[:score].to_f
|
|
164
|
+
end
|
|
165
|
+
by_id.values.sort_by { |c| -c[:score].to_f }.first(top_k)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LongTerm
|
|
7
|
+
module Procedural
|
|
8
|
+
# A Skill is a reusable procedure an agent can retrieve and apply: a prompt,
|
|
9
|
+
# a template or a snippet of code. This is CoALA's "procedural memory" in the
|
|
10
|
+
# Voyager sense — a growing library of skills the agent learns and reuses.
|
|
11
|
+
#
|
|
12
|
+
# Skills track success/failure outcomes so proven skills can be preferred
|
|
13
|
+
# over unproven ones during retrieval (see #success_rate, and P8 adaptive
|
|
14
|
+
# retrieval).
|
|
15
|
+
class Skill
|
|
16
|
+
KINDS = %w[prompt template code].freeze
|
|
17
|
+
DEFAULT_KIND = "prompt"
|
|
18
|
+
|
|
19
|
+
attr_reader :id, :user_id, :name, :description, :body, :kind, :version,
|
|
20
|
+
:success_count, :failure_count, :created_at, :updated_at
|
|
21
|
+
|
|
22
|
+
def initialize(id:, user_id:, name:, body:, description: nil, kind: DEFAULT_KIND,
|
|
23
|
+
version: 1, success_count: 0, failure_count: 0, created_at: nil, updated_at: nil)
|
|
24
|
+
@id = id
|
|
25
|
+
@user_id = user_id
|
|
26
|
+
@name = name.to_s
|
|
27
|
+
@description = description
|
|
28
|
+
@body = body
|
|
29
|
+
@kind = normalize_kind(kind)
|
|
30
|
+
@version = version.to_i
|
|
31
|
+
@success_count = success_count.to_i
|
|
32
|
+
@failure_count = failure_count.to_i
|
|
33
|
+
@created_at = created_at || Time.now
|
|
34
|
+
@updated_at = updated_at || @created_at
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Proven utility in [0, 1]. Unproven skills (no outcomes) are neutral.
|
|
38
|
+
def success_rate
|
|
39
|
+
total = success_count + failure_count
|
|
40
|
+
total.zero? ? 0.5 : success_count.to_f / total
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def searchable_text
|
|
44
|
+
[name, description, body].compact.map(&:to_s).reject(&:empty?).join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_kind(kind)
|
|
48
|
+
k = kind.to_s.strip.downcase
|
|
49
|
+
KINDS.include?(k) ? k : DEFAULT_KIND
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from_h(hash)
|
|
53
|
+
new(
|
|
54
|
+
id: hash[:id] || hash["id"],
|
|
55
|
+
user_id: hash[:user_id] || hash["user_id"],
|
|
56
|
+
name: hash[:name] || hash["name"],
|
|
57
|
+
description: hash[:description] || hash["description"],
|
|
58
|
+
body: hash[:body] || hash["body"],
|
|
59
|
+
kind: hash[:kind] || hash["kind"] || DEFAULT_KIND,
|
|
60
|
+
version: hash[:version] || hash["version"] || 1,
|
|
61
|
+
success_count: hash[:success_count] || hash["success_count"] || 0,
|
|
62
|
+
failure_count: hash[:failure_count] || hash["failure_count"] || 0,
|
|
63
|
+
created_at: parse_time(hash[:created_at] || hash["created_at"]),
|
|
64
|
+
updated_at: parse_time(hash[:updated_at] || hash["updated_at"])
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.parse_time(value)
|
|
69
|
+
return value if value.nil? || value.is_a?(Time)
|
|
70
|
+
Time.parse(value.to_s)
|
|
71
|
+
rescue ArgumentError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{
|
|
77
|
+
id: id,
|
|
78
|
+
user_id: user_id,
|
|
79
|
+
name: name,
|
|
80
|
+
description: description,
|
|
81
|
+
body: body,
|
|
82
|
+
kind: kind,
|
|
83
|
+
version: version,
|
|
84
|
+
success_count: success_count,
|
|
85
|
+
failure_count: failure_count,
|
|
86
|
+
created_at: created_at.respond_to?(:iso8601) ? created_at.iso8601(6) : created_at,
|
|
87
|
+
updated_at: updated_at.respond_to?(:iso8601) ? updated_at.iso8601(6) : updated_at
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "storages/base"
|
|
4
|
+
require_relative "storages/memory_storage"
|
|
5
|
+
require_relative "storages/file_storage"
|
|
6
|
+
require_relative "storages/database_storage"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Procedural
|
|
11
|
+
# Backward compatibility: Storage points to the in-memory backend.
|
|
12
|
+
Storage = Storages::MemoryStorage
|
|
13
|
+
|
|
14
|
+
module Storages
|
|
15
|
+
def self.build(store: nil, base_path: nil, database_url: nil)
|
|
16
|
+
case (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
17
|
+
when :memory
|
|
18
|
+
MemoryStorage.new
|
|
19
|
+
when :file
|
|
20
|
+
FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
|
|
21
|
+
when :postgres, :database
|
|
22
|
+
DatabaseStorage.new(database_url: database_url || Llmemory.configuration.database_url)
|
|
23
|
+
when :active_record, :activerecord
|
|
24
|
+
require_relative "storages/active_record_storage"
|
|
25
|
+
ActiveRecordStorage.new
|
|
26
|
+
else
|
|
27
|
+
MemoryStorage.new
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Model for Procedural ActiveRecordStorage. Loaded only when using
|
|
4
|
+
# store: :active_record. JSONB `data` auto-deserializes to a Hash in Rails 5+.
|
|
5
|
+
|
|
6
|
+
module Llmemory
|
|
7
|
+
module LongTerm
|
|
8
|
+
module Procedural
|
|
9
|
+
module Storages
|
|
10
|
+
class LlmemorySkill < ::ActiveRecord::Base
|
|
11
|
+
self.table_name = "llmemory_skills"
|
|
12
|
+
self.primary_key = "id"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
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)
|
|
45
|
+
scope = LlmemorySkill.where(user_id: user_id).order(created_at: :desc)
|
|
46
|
+
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
47
|
+
scope.map(&:data)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def search_skills(user_id, query)
|
|
51
|
+
token_scope(LlmemorySkill.where(user_id: user_id), "search_text", query)
|
|
52
|
+
.order(created_at: :desc).map(&:data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def find_skills_by_name(user_id, name)
|
|
56
|
+
LlmemorySkill.where(user_id: user_id).where("data->>'name' = ?", name.to_s).map(&:data)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def record_outcome(user_id, skill_id, success:)
|
|
60
|
+
rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
|
|
61
|
+
return nil unless rec
|
|
62
|
+
data = rec.data || {}
|
|
63
|
+
key = success ? "success_count" : "failure_count"
|
|
64
|
+
data[key] = (data[key] || 0).to_i + 1
|
|
65
|
+
data["updated_at"] = Time.now.utc.iso8601
|
|
66
|
+
rec.data = data
|
|
67
|
+
rec.search_text = searchable_text(data)
|
|
68
|
+
rec.save!
|
|
69
|
+
data
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def count_skills(user_id)
|
|
73
|
+
LlmemorySkill.where(user_id: user_id).count
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete_skills(user_id, ids)
|
|
77
|
+
LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def list_users
|
|
81
|
+
LlmemorySkill.distinct.pluck(:user_id)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def token_scope(scope, column, query)
|
|
87
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
88
|
+
return scope if tokens.empty?
|
|
89
|
+
clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
|
|
90
|
+
scope.where(clause, *tokens.map { |t| "%#{t}%" })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stringify(hash)
|
|
94
|
+
JSON.parse(JSON.generate(hash))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def searchable_text(data)
|
|
98
|
+
[data["name"], data["description"], data["body"]].compact.join("\n")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module LongTerm
|
|
5
|
+
module Procedural
|
|
6
|
+
module Storages
|
|
7
|
+
# Storage contract for procedural memory (skill library). Implementations
|
|
8
|
+
# persist Skill hashes, support keyword search and name lookup (for
|
|
9
|
+
# versioning), and record success/failure outcomes.
|
|
10
|
+
class Base
|
|
11
|
+
def save_skill(user_id, skill)
|
|
12
|
+
raise NotImplementedError, "#{self.class}#save_skill must be implemented"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_skill(user_id, id)
|
|
16
|
+
raise NotImplementedError, "#{self.class}#get_skill must be implemented"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def list_skills(user_id, limit: nil)
|
|
20
|
+
raise NotImplementedError, "#{self.class}#list_skills must be implemented"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def search_skills(user_id, query)
|
|
24
|
+
raise NotImplementedError, "#{self.class}#search_skills must be implemented"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find_skills_by_name(user_id, name)
|
|
28
|
+
raise NotImplementedError, "#{self.class}#find_skills_by_name must be implemented"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Increments the success or failure count of a skill and returns the
|
|
32
|
+
# updated skill hash (or nil if not found).
|
|
33
|
+
def record_outcome(user_id, skill_id, success:)
|
|
34
|
+
raise NotImplementedError, "#{self.class}#record_outcome must be implemented"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def count_skills(user_id)
|
|
38
|
+
raise NotImplementedError, "#{self.class}#count_skills must be implemented"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Deletes skills by id. Returns the number actually removed.
|
|
42
|
+
def delete_skills(user_id, ids)
|
|
43
|
+
raise NotImplementedError, "#{self.class}#delete_skills must be implemented"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def list_users
|
|
47
|
+
raise NotImplementedError, "#{self.class}#list_users must be implemented"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|