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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "episode"
|
|
4
4
|
require_relative "storage"
|
|
5
|
+
require_relative "../../memory_module"
|
|
6
|
+
require_relative "../../vector_store"
|
|
5
7
|
|
|
6
8
|
module Llmemory
|
|
7
9
|
module LongTerm
|
|
@@ -11,14 +13,19 @@ module Llmemory
|
|
|
11
13
|
# memory (file/graph), not replace it, and to feed reflection (P2), which
|
|
12
14
|
# distills episodes into semantic knowledge.
|
|
13
15
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
+
# Recording/retrieval are deterministic and LLM-free by default. Semantic
|
|
17
|
+
# (embedding) retrieval is opt-in via `config.episodic_vector_enabled` or by
|
|
18
|
+
# injecting a `vector_store:`; when off, search is keyword-only (unchanged).
|
|
16
19
|
class Memory
|
|
20
|
+
include Llmemory::MemoryModule
|
|
21
|
+
|
|
17
22
|
attr_reader :user_id, :storage
|
|
18
23
|
|
|
19
|
-
def initialize(user_id:, storage: nil)
|
|
24
|
+
def initialize(user_id:, storage: nil, vector_store: nil)
|
|
20
25
|
@user_id = user_id
|
|
21
26
|
@storage = storage || Storages.build
|
|
27
|
+
@vector_store = vector_store
|
|
28
|
+
@vector_explicit = !vector_store.nil?
|
|
22
29
|
end
|
|
23
30
|
|
|
24
31
|
# Records a trajectory. `steps` is an array of hashes with any of
|
|
@@ -36,7 +43,9 @@ module Llmemory
|
|
|
36
43
|
episode.searchable_text, method: "episode_recording", confidence: episode.importance
|
|
37
44
|
)
|
|
38
45
|
record = episode.to_h.merge(provenance: provenance)
|
|
39
|
-
@storage.save_episode(@user_id, record)
|
|
46
|
+
id = @storage.save_episode(@user_id, record)
|
|
47
|
+
index_vector(id, episode.searchable_text)
|
|
48
|
+
id
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
def recent_episodes(limit: 10)
|
|
@@ -59,25 +68,98 @@ module Llmemory
|
|
|
59
68
|
# Retrieval Engine integration. Returns candidates shaped like the other
|
|
60
69
|
# long-term memories so the Engine can rank episodes by relevance,
|
|
61
70
|
# recency (temporal decay) and importance (P3), with provenance (P10).
|
|
71
|
+
# Hybrid (vector + keyword) when a vector store is active; otherwise
|
|
72
|
+
# keyword-only.
|
|
62
73
|
def search_candidates(query, user_id: nil, top_k: 20)
|
|
63
74
|
uid = user_id || @user_id
|
|
64
75
|
return [] unless uid == @user_id
|
|
65
76
|
|
|
66
|
-
@storage.search_episodes(uid, query).first(top_k).map
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
keyword = @storage.search_episodes(uid, query).first(top_k).map { |e| candidate_for(e, 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(steps:, summary: nil, outcome: nil, importance: 0.5, **_meta)
|
|
87
|
+
record_episode(steps: steps, summary: summary, outcome: outcome, importance: importance)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def list(user_id: nil, limit: nil)
|
|
91
|
+
episodes(limit: limit)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stats(user_id: nil)
|
|
95
|
+
{ episodes: count }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def forget(ids:, reason: nil)
|
|
99
|
+
requested = Array(ids).map(&:to_s)
|
|
100
|
+
existing = @storage.list_episodes(@user_id).map { |e| (e[:id] || e["id"]).to_s }
|
|
101
|
+
removed = requested & existing
|
|
102
|
+
@storage.delete_episodes(@user_id, removed)
|
|
103
|
+
forget_log.record(@user_id, memory_type: "episodic", ids: removed, reason: reason)
|
|
104
|
+
removed.size
|
|
77
105
|
end
|
|
78
106
|
|
|
79
107
|
private
|
|
80
108
|
|
|
109
|
+
# Active vector store: the injected one, or a config-gated lazy build.
|
|
110
|
+
# Returns nil when semantic search is disabled (default).
|
|
111
|
+
def vector_store
|
|
112
|
+
if @vector_explicit
|
|
113
|
+
@vector_store
|
|
114
|
+
elsif Llmemory.configuration.episodic_vector_enabled
|
|
115
|
+
@vector_store ||= Llmemory::VectorStore.build(source_type: "episode")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Best-effort embedding indexing; a failure must never break recording.
|
|
120
|
+
def index_vector(id, text)
|
|
121
|
+
vs = vector_store
|
|
122
|
+
return if vs.nil? || text.to_s.strip.empty?
|
|
123
|
+
embedding = vs.embed(text)
|
|
124
|
+
return unless embedding
|
|
125
|
+
vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
|
|
126
|
+
rescue StandardError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def vector_candidates(query, top_k, vs)
|
|
131
|
+
vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id).filter_map do |r|
|
|
132
|
+
raw = @storage.get_episode(@user_id, r[:id] || r["id"])
|
|
133
|
+
raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError
|
|
136
|
+
[]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def candidate_for(raw, score)
|
|
140
|
+
episode = Episode.from_h(raw)
|
|
141
|
+
{
|
|
142
|
+
id: episode.id,
|
|
143
|
+
text: episode.summary.to_s.empty? ? episode.searchable_text : episode.summary,
|
|
144
|
+
timestamp: episode.created_at,
|
|
145
|
+
score: score,
|
|
146
|
+
importance: episode.importance,
|
|
147
|
+
evergreen: false,
|
|
148
|
+
provenance: raw[:provenance] || raw["provenance"]
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Dedup by id keeping the higher score; highest score first, capped.
|
|
153
|
+
def merge_candidates(primary, secondary, top_k)
|
|
154
|
+
by_id = {}
|
|
155
|
+
(primary + secondary).each do |c|
|
|
156
|
+
key = c[:id] || c[:text]
|
|
157
|
+
existing = by_id[key]
|
|
158
|
+
by_id[key] = c if existing.nil? || c[:score].to_f > existing[:score].to_f
|
|
159
|
+
end
|
|
160
|
+
by_id.values.sort_by { |c| -c[:score].to_f }.first(top_k)
|
|
161
|
+
end
|
|
162
|
+
|
|
81
163
|
# Cheap, deterministic summary when the caller does not provide one.
|
|
82
164
|
# LLM-based summarization is reflection's job (P2).
|
|
83
165
|
def derive_summary(steps)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "storages/base"
|
|
4
4
|
require_relative "storages/memory_storage"
|
|
5
5
|
require_relative "storages/file_storage"
|
|
6
|
+
require_relative "storages/database_storage"
|
|
6
7
|
|
|
7
8
|
module Llmemory
|
|
8
9
|
module LongTerm
|
|
@@ -11,16 +12,17 @@ module Llmemory
|
|
|
11
12
|
Storage = Storages::MemoryStorage
|
|
12
13
|
|
|
13
14
|
module Storages
|
|
14
|
-
def self.build(store: nil, base_path: nil)
|
|
15
|
+
def self.build(store: nil, base_path: nil, database_url: nil)
|
|
15
16
|
case (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
16
17
|
when :memory
|
|
17
18
|
MemoryStorage.new
|
|
18
19
|
when :file
|
|
19
20
|
FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
|
|
20
|
-
when :postgres, :database
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
24
26
|
else
|
|
25
27
|
MemoryStorage.new
|
|
26
28
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Model for Episodic 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 Episodic
|
|
9
|
+
module Storages
|
|
10
|
+
class LlmemoryEpisode < ::ActiveRecord::Base
|
|
11
|
+
self.table_name = "llmemory_episodes"
|
|
12
|
+
self.primary_key = "id"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
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 Episodic
|
|
11
|
+
module Storages
|
|
12
|
+
# ActiveRecord backend. Stores each episode as a JSONB `data` document;
|
|
13
|
+
# AR auto-deserializes jsonb to a Hash (string keys), which Episode.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_episode(user_id, episode)
|
|
28
|
+
id = episode[:id] || episode["id"] || "ep_#{SecureRandom.hex(8)}"
|
|
29
|
+
data = stringify(episode).merge("id" => id, "user_id" => user_id)
|
|
30
|
+
data["created_at"] ||= Time.now.utc.iso8601
|
|
31
|
+
rec = LlmemoryEpisode.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_episode(user_id, id)
|
|
41
|
+
rec = LlmemoryEpisode.find_by(user_id: user_id, id: id)
|
|
42
|
+
rec&.data
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def list_episodes(user_id, limit: nil)
|
|
46
|
+
scope = LlmemoryEpisode.where(user_id: user_id).order(created_at: :desc)
|
|
47
|
+
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
48
|
+
scope.map(&:data)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def search_episodes(user_id, query)
|
|
52
|
+
token_scope(LlmemoryEpisode.where(user_id: user_id), "search_text", query)
|
|
53
|
+
.order(created_at: :desc).map(&:data)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def count_episodes(user_id)
|
|
57
|
+
LlmemoryEpisode.where(user_id: user_id).count
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def delete_episodes(user_id, ids)
|
|
61
|
+
LlmemoryEpisode.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def list_users
|
|
65
|
+
LlmemoryEpisode.distinct.pluck(:user_id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def token_scope(scope, column, query)
|
|
71
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
72
|
+
return scope if tokens.empty?
|
|
73
|
+
clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
|
|
74
|
+
scope.where(clause, *tokens.map { |t| "%#{t}%" })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def stringify(hash)
|
|
78
|
+
JSON.parse(JSON.generate(hash))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def searchable_text(data)
|
|
82
|
+
parts = [data["summary"], data["outcome"]]
|
|
83
|
+
Array(data["steps"]).each do |s|
|
|
84
|
+
next unless s.is_a?(Hash)
|
|
85
|
+
parts << s["observation"] << s["action"] << s["result"]
|
|
86
|
+
end
|
|
87
|
+
parts.compact.join("\n")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -29,6 +29,11 @@ module Llmemory
|
|
|
29
29
|
raise NotImplementedError, "#{self.class}#count_episodes must be implemented"
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Deletes episodes by id. Returns the number actually removed.
|
|
33
|
+
def delete_episodes(user_id, ids)
|
|
34
|
+
raise NotImplementedError, "#{self.class}#delete_episodes must be implemented"
|
|
35
|
+
end
|
|
36
|
+
|
|
32
37
|
def list_users
|
|
33
38
|
raise NotImplementedError, "#{self.class}#list_users must be implemented"
|
|
34
39
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
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 Episodic
|
|
11
|
+
module Storages
|
|
12
|
+
# PostgreSQL backend. Each episode is stored as a JSONB `data` document
|
|
13
|
+
# (plus id/user_id/created_at and a denormalized search_text for keyword
|
|
14
|
+
# search), mirroring 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_episode(user_id, episode)
|
|
22
|
+
ensure_tables!
|
|
23
|
+
id = episode[:id] || episode["id"] || "ep_#{SecureRandom.hex(8)}"
|
|
24
|
+
data = symbolize(episode).merge(id: id, user_id: user_id)
|
|
25
|
+
data[:created_at] ||= Time.now.utc.iso8601
|
|
26
|
+
conn.exec_params(
|
|
27
|
+
"INSERT INTO llmemory_episodes (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_episode(user_id, id)
|
|
36
|
+
ensure_tables!
|
|
37
|
+
rows = conn.exec_params("SELECT data FROM llmemory_episodes 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_episodes(user_id, limit: nil)
|
|
42
|
+
ensure_tables!
|
|
43
|
+
sql = "SELECT data FROM llmemory_episodes WHERE user_id = $1 ORDER BY created_at DESC"
|
|
44
|
+
sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
|
|
45
|
+
conn.exec_params(sql, [user_id]).map { |r| parse_data(r["data"]) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def search_episodes(user_id, query)
|
|
49
|
+
ensure_tables!
|
|
50
|
+
suffix, params = token_filter("search_text", query, 2)
|
|
51
|
+
conn.exec_params(
|
|
52
|
+
"SELECT data FROM llmemory_episodes WHERE user_id = $1#{suffix} ORDER BY created_at DESC",
|
|
53
|
+
[user_id, *params]
|
|
54
|
+
).map { |r| parse_data(r["data"]) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def count_episodes(user_id)
|
|
58
|
+
ensure_tables!
|
|
59
|
+
conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_episodes WHERE user_id = $1", [user_id]).first["c"].to_i
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def delete_episodes(user_id, ids)
|
|
63
|
+
ensure_tables!
|
|
64
|
+
Array(ids).sum do |id|
|
|
65
|
+
conn.exec_params("DELETE FROM llmemory_episodes WHERE user_id = $1 AND id = $2", [user_id, id]).cmd_tuples
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def list_users
|
|
70
|
+
ensure_tables!
|
|
71
|
+
conn.exec("SELECT DISTINCT user_id FROM llmemory_episodes").map { |r| r["user_id"] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def conn
|
|
77
|
+
@connection ||= begin
|
|
78
|
+
require "pg"
|
|
79
|
+
PG.connect(@database_url)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def ensure_tables!
|
|
84
|
+
conn.exec(<<~SQL)
|
|
85
|
+
CREATE TABLE IF NOT EXISTS llmemory_episodes (
|
|
86
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
87
|
+
user_id TEXT NOT NULL,
|
|
88
|
+
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
89
|
+
search_text TEXT,
|
|
90
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_llmemory_episodes_user_id ON llmemory_episodes(user_id);
|
|
93
|
+
SQL
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# OR-of-token LIKE filter (see file-based DatabaseStorage). [""] for an
|
|
97
|
+
# empty query => match all.
|
|
98
|
+
def token_filter(column, query, start_index)
|
|
99
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
100
|
+
return ["", []] if tokens.empty?
|
|
101
|
+
likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
|
|
102
|
+
[" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_data(value)
|
|
106
|
+
JSON.parse(value.to_s, symbolize_names: true)
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def symbolize(hash)
|
|
112
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def searchable_text(data)
|
|
116
|
+
parts = [data[:summary], data[:outcome]]
|
|
117
|
+
Array(data[:steps]).each do |s|
|
|
118
|
+
next unless s.is_a?(Hash)
|
|
119
|
+
parts << (s[:observation] || s["observation"])
|
|
120
|
+
parts << (s[:action] || s["action"])
|
|
121
|
+
parts << (s[:result] || s["result"])
|
|
122
|
+
end
|
|
123
|
+
parts.compact.join("\n")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def created_at_value(data)
|
|
127
|
+
ca = data[:created_at]
|
|
128
|
+
return Time.now.utc.iso8601 if ca.nil?
|
|
129
|
+
ca.respond_to?(:iso8601) ? ca.iso8601 : ca.to_s
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -35,9 +35,8 @@ module Llmemory
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def search_episodes(user_id, query)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
all_episodes(user_id).select { |e| episode_text(e).downcase.include?(q) }
|
|
38
|
+
return list_episodes(user_id) if query.to_s.strip.empty?
|
|
39
|
+
all_episodes(user_id).select { |e| Llmemory::Tokenizer.matches?(episode_text(e), query) }
|
|
41
40
|
end
|
|
42
41
|
|
|
43
42
|
def count_episodes(user_id)
|
|
@@ -46,6 +45,15 @@ module Llmemory
|
|
|
46
45
|
Dir.children(dir).count { |f| f.end_with?(".json") }
|
|
47
46
|
end
|
|
48
47
|
|
|
48
|
+
def delete_episodes(user_id, ids)
|
|
49
|
+
Array(ids).map(&:to_s).count do |id|
|
|
50
|
+
path = episode_path(user_id, id)
|
|
51
|
+
next false unless File.file?(path)
|
|
52
|
+
File.delete(path)
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
49
57
|
def list_users
|
|
50
58
|
return [] unless Dir.exist?(@base_path)
|
|
51
59
|
Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "episodes")) }
|
|
@@ -31,15 +31,21 @@ module Llmemory
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def search_episodes(user_id, query)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@episodes[user_id].select { |e| episode_text(e).downcase.include?(q) }
|
|
34
|
+
return list_episodes(user_id) if query.to_s.strip.empty?
|
|
35
|
+
@episodes[user_id].select { |e| Llmemory::Tokenizer.matches?(episode_text(e), query) }
|
|
37
36
|
end
|
|
38
37
|
|
|
39
38
|
def count_episodes(user_id)
|
|
40
39
|
@episodes[user_id].size
|
|
41
40
|
end
|
|
42
41
|
|
|
42
|
+
def delete_episodes(user_id, ids)
|
|
43
|
+
ids = Array(ids).map(&:to_s)
|
|
44
|
+
before = @episodes[user_id].size
|
|
45
|
+
@episodes[user_id].reject! { |e| ids.include?(e[:id].to_s) }
|
|
46
|
+
before - @episodes[user_id].size
|
|
47
|
+
end
|
|
48
|
+
|
|
43
49
|
def list_users
|
|
44
50
|
@episodes.keys
|
|
45
51
|
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
|
|
@@ -100,6 +105,32 @@ module Llmemory
|
|
|
100
105
|
)
|
|
101
106
|
end
|
|
102
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
|
+
|
|
103
134
|
attr_reader :storage, :user_id
|
|
104
135
|
|
|
105
136
|
private
|
|
@@ -64,13 +64,11 @@ module Llmemory
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def search_items(user_id, query)
|
|
67
|
-
|
|
68
|
-
LlmemoryItem.where(user_id: user_id).where("LOWER(content) LIKE LOWER(?)", q).map { |r| row_to_item(r) }
|
|
67
|
+
token_scope(LlmemoryItem.where(user_id: user_id), "content", query).map { |r| row_to_item(r) }
|
|
69
68
|
end
|
|
70
69
|
|
|
71
70
|
def search_resources(user_id, query)
|
|
72
|
-
|
|
73
|
-
LlmemoryResource.where(user_id: user_id).where("LOWER(text) LIKE LOWER(?)", q).map { |r| row_to_resource(r) }
|
|
71
|
+
token_scope(LlmemoryResource.where(user_id: user_id), "text", query).map { |r| row_to_resource(r) }
|
|
74
72
|
end
|
|
75
73
|
|
|
76
74
|
def get_resources_since(user_id, hours:)
|
|
@@ -181,6 +179,15 @@ module Llmemory
|
|
|
181
179
|
(str || "").to_s.gsub(/[%_\\]/) { |c| "\\#{c}" }
|
|
182
180
|
end
|
|
183
181
|
|
|
182
|
+
# OR-of-token LIKE scope for keyword search; unchanged scope (match all)
|
|
183
|
+
# when the query has no tokens.
|
|
184
|
+
def token_scope(scope, column, query)
|
|
185
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
186
|
+
return scope if tokens.empty?
|
|
187
|
+
clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
|
|
188
|
+
scope.where(clause, *tokens.map { |t| "%#{sanitize_like(t)}%" })
|
|
189
|
+
end
|
|
190
|
+
|
|
184
191
|
def row_to_item(r)
|
|
185
192
|
h = {
|
|
186
193
|
id: r.id,
|
|
@@ -65,20 +65,20 @@ module Llmemory
|
|
|
65
65
|
|
|
66
66
|
def search_items(user_id, query)
|
|
67
67
|
ensure_tables!
|
|
68
|
-
|
|
68
|
+
suffix, params = token_filter("content", query, 2)
|
|
69
69
|
rows = conn.exec_params(
|
|
70
|
-
"SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1
|
|
71
|
-
[user_id,
|
|
70
|
+
"SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1#{suffix}",
|
|
71
|
+
[user_id, *params]
|
|
72
72
|
)
|
|
73
73
|
rows_to_items(rows)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def search_resources(user_id, query)
|
|
77
77
|
ensure_tables!
|
|
78
|
-
|
|
78
|
+
suffix, params = token_filter("text", query, 2)
|
|
79
79
|
rows = conn.exec_params(
|
|
80
|
-
"SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1
|
|
81
|
-
[user_id,
|
|
80
|
+
"SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1#{suffix}",
|
|
81
|
+
[user_id, *params]
|
|
82
82
|
)
|
|
83
83
|
rows_to_resources(rows)
|
|
84
84
|
end
|
|
@@ -290,6 +290,16 @@ module Llmemory
|
|
|
290
290
|
end
|
|
291
291
|
end
|
|
292
292
|
|
|
293
|
+
# Builds an OR-of-token LIKE filter for keyword search. Returns
|
|
294
|
+
# ["" , []] for an empty query (match all). Tokens are [a-z0-9]{2,} so
|
|
295
|
+
# they carry no LIKE wildcards.
|
|
296
|
+
def token_filter(column, query, start_index)
|
|
297
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
298
|
+
return ["", []] if tokens.empty?
|
|
299
|
+
likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
|
|
300
|
+
[" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
|
|
301
|
+
end
|
|
302
|
+
|
|
293
303
|
def parse_provenance(value)
|
|
294
304
|
return nil if value.nil? || value.to_s.strip.empty?
|
|
295
305
|
JSON.parse(value, symbolize_names: true)
|
|
@@ -62,13 +62,11 @@ module Llmemory
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def search_items(user_id, query)
|
|
65
|
-
|
|
66
|
-
get_all_items(user_id).select { |i| (i[:content] || i["content"]).to_s.downcase.include?(query_lower) }
|
|
65
|
+
get_all_items(user_id).select { |i| Llmemory::Tokenizer.matches?(i[:content] || i["content"], query) }
|
|
67
66
|
end
|
|
68
67
|
|
|
69
68
|
def search_resources(user_id, query)
|
|
70
|
-
|
|
71
|
-
get_all_resources(user_id).select { |r| (r[:text] || r["text"]).to_s.downcase.include?(query_lower) }
|
|
69
|
+
get_all_resources(user_id).select { |r| Llmemory::Tokenizer.matches?(r[:text] || r["text"], query) }
|
|
72
70
|
end
|
|
73
71
|
|
|
74
72
|
def get_resources_since(user_id, hours:)
|
|
@@ -51,13 +51,11 @@ module Llmemory
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def search_items(user_id, query)
|
|
54
|
-
|
|
55
|
-
@items[user_id].select { |i| i[:content].to_s.downcase.include?(query_lower) }
|
|
54
|
+
@items[user_id].select { |i| Llmemory::Tokenizer.matches?(i[:content], query) }
|
|
56
55
|
end
|
|
57
56
|
|
|
58
57
|
def search_resources(user_id, query)
|
|
59
|
-
|
|
60
|
-
@resources[user_id].select { |r| r[:text].to_s.downcase.include?(query_lower) }
|
|
58
|
+
@resources[user_id].select { |r| Llmemory::Tokenizer.matches?(r[:text], query) }
|
|
61
59
|
end
|
|
62
60
|
|
|
63
61
|
def get_resources_since(user_id, hours:)
|