llmemory 0.1.16 → 0.2.0
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 +172 -0
- data/lib/llmemory/actions/reason.rb +49 -0
- data/lib/llmemory/actions.rb +8 -0
- data/lib/llmemory/configuration.rb +2 -0
- data/lib/llmemory/forget_log.rb +50 -0
- data/lib/llmemory/long_term/episodic/episode.rb +94 -0
- data/lib/llmemory/long_term/episodic/memory.rb +120 -0
- data/lib/llmemory/long_term/episodic/storage.rb +31 -0
- data/lib/llmemory/long_term/episodic/storages/base.rb +44 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +126 -0
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +74 -0
- data/lib/llmemory/long_term/episodic.rb +12 -0
- data/lib/llmemory/long_term/file_based/memory.rb +46 -0
- data/lib/llmemory/long_term/graph_based/memory.rb +30 -3
- data/lib/llmemory/long_term/procedural/memory.rb +116 -0
- data/lib/llmemory/long_term/procedural/skill.rb +93 -0
- data/lib/llmemory/long_term/procedural/storage.rb +31 -0
- data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +136 -0
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +80 -0
- data/lib/llmemory/long_term/procedural.rb +12 -0
- data/lib/llmemory/long_term.rb +3 -0
- data/lib/llmemory/memory.rb +9 -1
- data/lib/llmemory/memory_module.rb +55 -0
- data/lib/llmemory/reflection/reflector.rb +116 -0
- data/lib/llmemory/reflection.rb +8 -0
- data/lib/llmemory/retrieval/engine.rb +115 -6
- data/lib/llmemory/retrieval/feedback_store.rb +50 -0
- data/lib/llmemory/short_term/checkpoint.rb +2 -14
- data/lib/llmemory/short_term/session_lifecycle.rb +3 -10
- data/lib/llmemory/short_term/stores.rb +27 -0
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/working_memory.rb +83 -0
- data/lib/llmemory.rb +5 -0
- metadata +24 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Episodic
|
|
11
|
+
module Storages
|
|
12
|
+
class FileStorage < Base
|
|
13
|
+
def initialize(base_path: nil)
|
|
14
|
+
@base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
|
|
15
|
+
@base_path = File.expand_path(@base_path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def save_episode(user_id, episode)
|
|
19
|
+
id = episode[:id] || episode["id"] || "ep_#{next_seq(user_id)}"
|
|
20
|
+
data = stringify_for_json(episode).merge("id" => id, "user_id" => user_id)
|
|
21
|
+
data["created_at"] ||= Time.now.iso8601
|
|
22
|
+
File.write(episode_path(user_id, id), JSON.generate(data))
|
|
23
|
+
id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_episode(user_id, id)
|
|
27
|
+
path = episode_path(user_id, id)
|
|
28
|
+
return nil unless File.file?(path)
|
|
29
|
+
load_episode(path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list_episodes(user_id, limit: nil)
|
|
33
|
+
sorted = all_episodes(user_id).sort_by { |e| e[:created_at] }.reverse
|
|
34
|
+
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def search_episodes(user_id, query)
|
|
38
|
+
q = query.to_s.downcase
|
|
39
|
+
return list_episodes(user_id) if q.strip.empty?
|
|
40
|
+
all_episodes(user_id).select { |e| episode_text(e).downcase.include?(q) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def count_episodes(user_id)
|
|
44
|
+
dir = user_path(user_id, "episodes")
|
|
45
|
+
return 0 unless Dir.exist?(dir)
|
|
46
|
+
Dir.children(dir).count { |f| f.end_with?(".json") }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete_episodes(user_id, ids)
|
|
50
|
+
Array(ids).map(&:to_s).count do |id|
|
|
51
|
+
path = episode_path(user_id, id)
|
|
52
|
+
next false unless File.file?(path)
|
|
53
|
+
File.delete(path)
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def list_users
|
|
59
|
+
return [] unless Dir.exist?(@base_path)
|
|
60
|
+
Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "episodes")) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def all_episodes(user_id)
|
|
66
|
+
dir = user_path(user_id, "episodes")
|
|
67
|
+
return [] unless Dir.exist?(dir)
|
|
68
|
+
Dir.children(dir).select { |f| f.end_with?(".json") }.map { |f| load_episode(File.join(dir, f)) }.compact
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def load_episode(path)
|
|
72
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
73
|
+
data[:created_at] = parse_time(data[:created_at])
|
|
74
|
+
data
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def episode_text(episode)
|
|
80
|
+
parts = [episode[:summary], episode[:outcome]]
|
|
81
|
+
Array(episode[:steps]).each do |s|
|
|
82
|
+
next unless s.is_a?(Hash)
|
|
83
|
+
parts << s[:observation] << s[:action] << s[:result]
|
|
84
|
+
end
|
|
85
|
+
parts.compact.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stringify_for_json(episode)
|
|
89
|
+
JSON.parse(JSON.generate(episode))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def user_path(user_id, *parts)
|
|
93
|
+
safe = user_id.to_s.gsub(%r{[^\w\-.]}, "_")
|
|
94
|
+
File.join(@base_path, safe, *parts)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def episode_path(user_id, id)
|
|
98
|
+
dir = user_path(user_id, "episodes")
|
|
99
|
+
FileUtils.mkdir_p(dir)
|
|
100
|
+
File.join(dir, "#{id}.json")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def meta_path(user_id)
|
|
104
|
+
FileUtils.mkdir_p(user_path(user_id))
|
|
105
|
+
File.join(user_path(user_id), "meta.json")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def next_seq(user_id)
|
|
109
|
+
path = meta_path(user_id)
|
|
110
|
+
meta = File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
111
|
+
meta["episode_id_seq"] = (meta["episode_id_seq"] || 0) + 1
|
|
112
|
+
File.write(path, JSON.generate(meta))
|
|
113
|
+
meta["episode_id_seq"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_time(val)
|
|
117
|
+
return val if val.is_a?(Time)
|
|
118
|
+
Time.parse(val.to_s)
|
|
119
|
+
rescue ArgumentError
|
|
120
|
+
Time.now
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LongTerm
|
|
7
|
+
module Episodic
|
|
8
|
+
module Storages
|
|
9
|
+
class MemoryStorage < Base
|
|
10
|
+
def initialize
|
|
11
|
+
@episodes = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
@seq = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save_episode(user_id, episode)
|
|
16
|
+
@seq += 1
|
|
17
|
+
id = episode[:id] || episode["id"] || "ep_#{@seq}"
|
|
18
|
+
record = symbolize(episode).merge(id: id, user_id: user_id)
|
|
19
|
+
record[:created_at] ||= Time.now
|
|
20
|
+
@episodes[user_id] << record
|
|
21
|
+
id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get_episode(user_id, id)
|
|
25
|
+
@episodes[user_id].find { |e| e[:id] == id }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list_episodes(user_id, limit: nil)
|
|
29
|
+
sorted = @episodes[user_id].sort_by { |e| e[:created_at] }.reverse
|
|
30
|
+
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def search_episodes(user_id, query)
|
|
34
|
+
q = query.to_s.downcase
|
|
35
|
+
return list_episodes(user_id) if q.strip.empty?
|
|
36
|
+
@episodes[user_id].select { |e| episode_text(e).downcase.include?(q) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def count_episodes(user_id)
|
|
40
|
+
@episodes[user_id].size
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete_episodes(user_id, ids)
|
|
44
|
+
ids = Array(ids).map(&:to_s)
|
|
45
|
+
before = @episodes[user_id].size
|
|
46
|
+
@episodes[user_id].reject! { |e| ids.include?(e[:id].to_s) }
|
|
47
|
+
before - @episodes[user_id].size
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def list_users
|
|
51
|
+
@episodes.keys
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def symbolize(hash)
|
|
57
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def episode_text(episode)
|
|
61
|
+
parts = [episode[:summary], episode[:outcome]]
|
|
62
|
+
Array(episode[:steps]).each do |s|
|
|
63
|
+
next unless s.is_a?(Hash)
|
|
64
|
+
parts << (s[:observation] || s["observation"])
|
|
65
|
+
parts << (s[:action] || s["action"])
|
|
66
|
+
parts << (s[:result] || s["result"])
|
|
67
|
+
end
|
|
68
|
+
parts.compact.join("\n")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -5,11 +5,14 @@ require_relative "item"
|
|
|
5
5
|
require_relative "category"
|
|
6
6
|
require_relative "storage"
|
|
7
7
|
require_relative "../../noise_filter"
|
|
8
|
+
require_relative "../../memory_module"
|
|
8
9
|
|
|
9
10
|
module Llmemory
|
|
10
11
|
module LongTerm
|
|
11
12
|
module FileBased
|
|
12
13
|
class Memory
|
|
14
|
+
include Llmemory::MemoryModule
|
|
15
|
+
|
|
13
16
|
def initialize(user_id:, storage: nil, llm: nil, extractor: nil)
|
|
14
17
|
@user_id = user_id
|
|
15
18
|
@storage = storage || Storages.build
|
|
@@ -63,6 +66,7 @@ module Llmemory
|
|
|
63
66
|
|
|
64
67
|
items.first(top_k).each do |i|
|
|
65
68
|
out << {
|
|
69
|
+
id: i[:id] || i["id"],
|
|
66
70
|
text: i[:content] || i["content"],
|
|
67
71
|
timestamp: i[:created_at] || i["created_at"],
|
|
68
72
|
score: 1.0,
|
|
@@ -72,6 +76,7 @@ module Llmemory
|
|
|
72
76
|
end
|
|
73
77
|
resources.first([top_k - out.size, 0].max).each do |r|
|
|
74
78
|
out << {
|
|
79
|
+
id: r[:id] || r["id"],
|
|
75
80
|
text: r[:text] || r["text"],
|
|
76
81
|
timestamp: r[:created_at] || r["created_at"],
|
|
77
82
|
score: 0.9
|
|
@@ -85,6 +90,47 @@ module Llmemory
|
|
|
85
90
|
out
|
|
86
91
|
end
|
|
87
92
|
|
|
93
|
+
# Stores a single fact produced outside the extraction flow (e.g. by
|
|
94
|
+
# reflection over episodes), preserving caller-supplied provenance so the
|
|
95
|
+
# insight remains traceable to its source. Returns the item id.
|
|
96
|
+
def remember_fact(content:, category: "general", importance: 0.6, provenance: nil)
|
|
97
|
+
return nil if content.to_s.strip.empty?
|
|
98
|
+
@storage.save_item(
|
|
99
|
+
@user_id,
|
|
100
|
+
category: category.to_s,
|
|
101
|
+
content: content.to_s,
|
|
102
|
+
source_resource_id: nil,
|
|
103
|
+
importance: importance,
|
|
104
|
+
provenance: provenance
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# --- MemoryModule uniform interface ---
|
|
109
|
+
|
|
110
|
+
def write(payload, **_meta)
|
|
111
|
+
memorize(payload)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def list(user_id: nil, limit: nil)
|
|
115
|
+
@storage.list_items(user_id: user_id || @user_id, limit: limit)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def stats(user_id: nil)
|
|
119
|
+
{ items: @storage.count_items(user_id: user_id || @user_id) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Removes items/resources by id and records the removal in the audit log.
|
|
123
|
+
def forget(ids:, reason: nil)
|
|
124
|
+
requested = Array(ids).map(&:to_s)
|
|
125
|
+
existing = (@storage.get_all_items(@user_id) + @storage.get_all_resources(@user_id))
|
|
126
|
+
.map { |r| (r[:id] || r["id"]).to_s }
|
|
127
|
+
removed = requested & existing
|
|
128
|
+
@storage.archive_items(@user_id, removed)
|
|
129
|
+
@storage.archive_resources(@user_id, removed)
|
|
130
|
+
forget_log.record(@user_id, memory_type: "file_based", ids: removed, reason: reason)
|
|
131
|
+
removed.size
|
|
132
|
+
end
|
|
133
|
+
|
|
88
134
|
attr_reader :storage, :user_id
|
|
89
135
|
|
|
90
136
|
private
|
|
@@ -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
|
|
@@ -88,6 +91,7 @@ module Llmemory
|
|
|
88
91
|
results = hybrid_search(query, top_k: top_k)
|
|
89
92
|
results.map do |r|
|
|
90
93
|
{
|
|
94
|
+
id: r[:id],
|
|
91
95
|
text: r[:text],
|
|
92
96
|
timestamp: r[:created_at] || r[:timestamp],
|
|
93
97
|
score: r[:score] || 1.0,
|
|
@@ -102,6 +106,29 @@ module Llmemory
|
|
|
102
106
|
@graph_storage
|
|
103
107
|
end
|
|
104
108
|
|
|
109
|
+
# --- MemoryModule uniform interface ---
|
|
110
|
+
|
|
111
|
+
def write(payload, **_meta)
|
|
112
|
+
memorize(payload)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def list(user_id: nil, limit: nil)
|
|
116
|
+
@graph_storage.list_nodes(user_id || @user_id, limit: limit)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def stats(user_id: nil)
|
|
120
|
+
uid = user_id || @user_id
|
|
121
|
+
{ nodes: @graph_storage.count_nodes(uid), edges: @graph_storage.count_edges(uid) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Forgetting a knowledge graph is not a simple delete-by-id: edges are
|
|
125
|
+
# soft-archived and nodes can be left orphaned. A dedicated graph
|
|
126
|
+
# edge/node lifecycle (with orphan handling) is a deliberate follow-up.
|
|
127
|
+
def forget(ids:, reason: nil)
|
|
128
|
+
raise NotImplementedError,
|
|
129
|
+
"Graph forget is not implemented yet; edge/node lifecycle (archival + orphan handling) is a follow-up."
|
|
130
|
+
end
|
|
131
|
+
|
|
105
132
|
private
|
|
106
133
|
|
|
107
134
|
def build_vector_store
|
|
@@ -127,7 +154,7 @@ module Llmemory
|
|
|
127
154
|
out = vector_results.map do |v|
|
|
128
155
|
id = v[:id] || v["id"]
|
|
129
156
|
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] }
|
|
157
|
+
{ 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
158
|
end
|
|
132
159
|
|
|
133
160
|
node_ids = out.flat_map { |r| extract_node_ids_from_text(r[:text]) }.compact.uniq
|
|
@@ -139,7 +166,7 @@ module Llmemory
|
|
|
139
166
|
subj = @kg.find_node_by_id(e.subject_id)
|
|
140
167
|
obj = @kg.find_node_by_id(e.target_id)
|
|
141
168
|
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 }
|
|
169
|
+
out << { id: (e.id ? "edge_#{e.id}" : nil), text: edge_text, score: 0.85, created_at: e.created_at } unless out.any? { |o| o[:text] == edge_text }
|
|
143
170
|
end
|
|
144
171
|
end
|
|
145
172
|
|
|
@@ -152,7 +179,7 @@ module Llmemory
|
|
|
152
179
|
obj = @kg.find_node_by_id(e.target_id)
|
|
153
180
|
next unless subj && obj
|
|
154
181
|
edge_text = "#{subj.name} #{e.predicate} #{obj.name}"
|
|
155
|
-
out << { text: edge_text, score: 0.7, created_at: e.created_at }
|
|
182
|
+
out << { id: (e.id ? "edge_#{e.id}" : nil), text: edge_text, score: 0.7, created_at: e.created_at }
|
|
156
183
|
end
|
|
157
184
|
end
|
|
158
185
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "skill"
|
|
4
|
+
require_relative "storage"
|
|
5
|
+
require_relative "../../memory_module"
|
|
6
|
+
|
|
7
|
+
module Llmemory
|
|
8
|
+
module LongTerm
|
|
9
|
+
module Procedural
|
|
10
|
+
# Procedural long-term memory: a Voyager-style skill library. Agents
|
|
11
|
+
# register reusable skills (prompts, templates, code), retrieve them by
|
|
12
|
+
# relevance to the current task, and report outcomes so proven skills are
|
|
13
|
+
# preferred over unproven ones.
|
|
14
|
+
#
|
|
15
|
+
# Retrieval is keyword-based for now (vector search is a follow-up). The
|
|
16
|
+
# success rate of each skill is surfaced as `importance`, so the retrieval
|
|
17
|
+
# Engine ranks battle-tested skills higher (P3 importance weighting).
|
|
18
|
+
class Memory
|
|
19
|
+
include Llmemory::MemoryModule
|
|
20
|
+
|
|
21
|
+
attr_reader :user_id, :storage
|
|
22
|
+
|
|
23
|
+
def initialize(user_id:, storage: nil)
|
|
24
|
+
@user_id = user_id
|
|
25
|
+
@storage = storage || Storages.build
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Registers a skill. If `version` is omitted and a skill with the same
|
|
29
|
+
# name exists, the version auto-increments (skill evolution).
|
|
30
|
+
def register_skill(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil)
|
|
31
|
+
version ||= next_version_for(name)
|
|
32
|
+
skill = Skill.new(
|
|
33
|
+
id: nil, user_id: @user_id, name: name, body: body,
|
|
34
|
+
description: description, kind: kind, version: version
|
|
35
|
+
)
|
|
36
|
+
@storage.save_skill(@user_id, skill.to_h)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find_skill(query)
|
|
40
|
+
raw = @storage.search_skills(@user_id, query).first
|
|
41
|
+
raw && Skill.from_h(raw)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get_skill(id)
|
|
45
|
+
raw = @storage.get_skill(@user_id, id)
|
|
46
|
+
raw && Skill.from_h(raw)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def skills(limit: nil)
|
|
50
|
+
@storage.list_skills(@user_id, limit: limit).map { |s| Skill.from_h(s) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def count
|
|
54
|
+
@storage.count_skills(@user_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Records that applying a skill succeeded or failed. Feeds retrieval
|
|
58
|
+
# ranking and adaptive retrieval (P8). Returns the updated Skill.
|
|
59
|
+
def report_outcome(skill_id, success:)
|
|
60
|
+
raw = @storage.record_outcome(@user_id, skill_id, success: success)
|
|
61
|
+
raw && Skill.from_h(raw)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Retrieval Engine integration: skills ranked by relevance, recency and
|
|
65
|
+
# proven utility (success rate exposed as importance).
|
|
66
|
+
def search_candidates(query, user_id: nil, top_k: 20)
|
|
67
|
+
uid = user_id || @user_id
|
|
68
|
+
return [] unless uid == @user_id
|
|
69
|
+
|
|
70
|
+
@storage.search_skills(uid, query).first(top_k).map do |raw|
|
|
71
|
+
skill = Skill.from_h(raw)
|
|
72
|
+
{
|
|
73
|
+
id: skill.id,
|
|
74
|
+
text: skill.searchable_text,
|
|
75
|
+
timestamp: skill.created_at,
|
|
76
|
+
score: 1.0,
|
|
77
|
+
importance: skill.success_rate,
|
|
78
|
+
evergreen: false
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- MemoryModule uniform interface ---
|
|
84
|
+
|
|
85
|
+
def write(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, **_meta)
|
|
86
|
+
register_skill(name: name, body: body, description: description, kind: kind, version: version)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def list(user_id: nil, limit: nil)
|
|
90
|
+
skills(limit: limit)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stats(user_id: nil)
|
|
94
|
+
{ skills: count }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def forget(ids:, reason: nil)
|
|
98
|
+
requested = Array(ids).map(&:to_s)
|
|
99
|
+
existing = @storage.list_skills(@user_id).map { |s| (s[:id] || s["id"]).to_s }
|
|
100
|
+
removed = requested & existing
|
|
101
|
+
@storage.delete_skills(@user_id, removed)
|
|
102
|
+
forget_log.record(@user_id, memory_type: "procedural", ids: removed, reason: reason)
|
|
103
|
+
removed.size
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def next_version_for(name)
|
|
109
|
+
existing = @storage.find_skills_by_name(@user_id, name)
|
|
110
|
+
return 1 if existing.empty?
|
|
111
|
+
existing.map { |s| (s[:version] || s["version"] || 1).to_i }.max + 1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
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,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "storages/base"
|
|
4
|
+
require_relative "storages/memory_storage"
|
|
5
|
+
require_relative "storages/file_storage"
|
|
6
|
+
|
|
7
|
+
module Llmemory
|
|
8
|
+
module LongTerm
|
|
9
|
+
module Procedural
|
|
10
|
+
# Backward compatibility: Storage points to the in-memory backend.
|
|
11
|
+
Storage = Storages::MemoryStorage
|
|
12
|
+
|
|
13
|
+
module Storages
|
|
14
|
+
def self.build(store: nil, base_path: nil)
|
|
15
|
+
case (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
16
|
+
when :memory
|
|
17
|
+
MemoryStorage.new
|
|
18
|
+
when :file
|
|
19
|
+
FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
|
|
20
|
+
when :postgres, :database, :active_record, :activerecord
|
|
21
|
+
raise NotImplementedError,
|
|
22
|
+
"Procedural SQL/ActiveRecord storage is not implemented yet; use :memory or :file " \
|
|
23
|
+
"(or pass an explicit storage instance)."
|
|
24
|
+
else
|
|
25
|
+
MemoryStorage.new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
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
|