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,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,103 @@
|
|
|
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, offset: nil)
|
|
46
|
+
scope = LlmemoryEpisode.where(user_id: user_id, archived_at: nil).order(created_at: :desc)
|
|
47
|
+
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
48
|
+
scope = scope.offset(offset) if offset && offset.to_i.positive?
|
|
49
|
+
scope.map(&:data)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def search_episodes(user_id, query)
|
|
53
|
+
token_scope(LlmemoryEpisode.where(user_id: user_id, archived_at: nil), "search_text", query)
|
|
54
|
+
.order(created_at: :desc).map(&:data)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def count_episodes(user_id)
|
|
58
|
+
LlmemoryEpisode.where(user_id: user_id, archived_at: nil).count
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delete_episodes(user_id, ids)
|
|
62
|
+
LlmemoryEpisode.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def archive_episodes(user_id, ids)
|
|
66
|
+
LlmemoryEpisode.where(user_id: user_id, id: Array(ids).map(&:to_s), archived_at: nil)
|
|
67
|
+
.update_all(archived_at: Time.current)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def expired_episode_ids(user_id, cutoff:)
|
|
71
|
+
LlmemoryEpisode.where(user_id: user_id, archived_at: nil).where("created_at < ?", cutoff).pluck(:id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def list_users
|
|
75
|
+
LlmemoryEpisode.distinct.pluck(:user_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def token_scope(scope, column, query)
|
|
81
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
82
|
+
return scope if tokens.empty?
|
|
83
|
+
clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
|
|
84
|
+
scope.where(clause, *tokens.map { |t| "%#{t}%" })
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def stringify(hash)
|
|
88
|
+
JSON.parse(JSON.generate(hash))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def searchable_text(data)
|
|
92
|
+
parts = [data["summary"], data["outcome"]]
|
|
93
|
+
Array(data["steps"]).each do |s|
|
|
94
|
+
next unless s.is_a?(Hash)
|
|
95
|
+
parts << s["observation"] << s["action"] << s["result"]
|
|
96
|
+
end
|
|
97
|
+
parts.compact.join("\n")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -16,8 +16,8 @@ module Llmemory
|
|
|
16
16
|
raise NotImplementedError, "#{self.class}#get_episode must be implemented"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# Newest first. Optionally
|
|
20
|
-
def list_episodes(user_id, limit: nil)
|
|
19
|
+
# Newest first. Optionally paginated with offset/limit.
|
|
20
|
+
def list_episodes(user_id, limit: nil, offset: nil)
|
|
21
21
|
raise NotImplementedError, "#{self.class}#list_episodes must be implemented"
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -34,6 +34,19 @@ module Llmemory
|
|
|
34
34
|
raise NotImplementedError, "#{self.class}#delete_episodes must be implemented"
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Soft-archives episodes by id (sets archived_at on the record). Archived
|
|
38
|
+
# episodes are excluded from list_episodes / search_episodes / count_episodes
|
|
39
|
+
# but remain accessible via get_episode. Returns the number archived.
|
|
40
|
+
def archive_episodes(user_id, ids)
|
|
41
|
+
raise NotImplementedError, "#{self.class}#archive_episodes must be implemented"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns episodes whose created_at is older than the cutoff and that are
|
|
45
|
+
# not already archived. Used by the TTL maintenance job.
|
|
46
|
+
def expired_episode_ids(user_id, cutoff:)
|
|
47
|
+
raise NotImplementedError, "#{self.class}#expired_episode_ids must be implemented"
|
|
48
|
+
end
|
|
49
|
+
|
|
37
50
|
def list_users
|
|
38
51
|
raise NotImplementedError, "#{self.class}#list_users must be implemented"
|
|
39
52
|
end
|
|
@@ -0,0 +1,156 @@
|
|
|
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, offset: nil)
|
|
42
|
+
ensure_tables!
|
|
43
|
+
sql = "SELECT data FROM llmemory_episodes 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_episodes(user_id, query)
|
|
50
|
+
ensure_tables!
|
|
51
|
+
suffix, params = token_filter("search_text", query, 2)
|
|
52
|
+
conn.exec_params(
|
|
53
|
+
"SELECT data FROM llmemory_episodes 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 count_episodes(user_id)
|
|
59
|
+
ensure_tables!
|
|
60
|
+
conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL", [user_id]).first["c"].to_i
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def delete_episodes(user_id, ids)
|
|
64
|
+
ensure_tables!
|
|
65
|
+
Array(ids).sum do |id|
|
|
66
|
+
conn.exec_params("DELETE FROM llmemory_episodes WHERE user_id = $1 AND id = $2", [user_id, id]).cmd_tuples
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def archive_episodes(user_id, ids)
|
|
71
|
+
ensure_tables!
|
|
72
|
+
Array(ids).sum do |id|
|
|
73
|
+
conn.exec_params(
|
|
74
|
+
"UPDATE llmemory_episodes SET archived_at = NOW() WHERE user_id = $1 AND id = $2 AND archived_at IS NULL",
|
|
75
|
+
[user_id, id]
|
|
76
|
+
).cmd_tuples
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expired_episode_ids(user_id, cutoff:)
|
|
81
|
+
ensure_tables!
|
|
82
|
+
conn.exec_params(
|
|
83
|
+
"SELECT id FROM llmemory_episodes WHERE user_id = $1 AND archived_at IS NULL AND created_at < $2",
|
|
84
|
+
[user_id, cutoff.iso8601]
|
|
85
|
+
).map { |r| r["id"] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def list_users
|
|
89
|
+
ensure_tables!
|
|
90
|
+
conn.exec("SELECT DISTINCT user_id FROM llmemory_episodes").map { |r| r["user_id"] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def conn
|
|
96
|
+
@connection ||= begin
|
|
97
|
+
require "pg"
|
|
98
|
+
PG.connect(@database_url)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ensure_tables!
|
|
103
|
+
conn.exec(<<~SQL)
|
|
104
|
+
CREATE TABLE IF NOT EXISTS llmemory_episodes (
|
|
105
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
106
|
+
user_id TEXT NOT NULL,
|
|
107
|
+
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
108
|
+
search_text TEXT,
|
|
109
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
110
|
+
archived_at TIMESTAMPTZ
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_llmemory_episodes_user_id ON llmemory_episodes(user_id);
|
|
113
|
+
ALTER TABLE llmemory_episodes ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
|
114
|
+
SQL
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# OR-of-token LIKE filter (see file-based DatabaseStorage). [""] for an
|
|
118
|
+
# empty query => match all.
|
|
119
|
+
def token_filter(column, query, start_index)
|
|
120
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
121
|
+
return ["", []] if tokens.empty?
|
|
122
|
+
likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
|
|
123
|
+
[" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_data(value)
|
|
127
|
+
JSON.parse(value.to_s, symbolize_names: true)
|
|
128
|
+
rescue JSON::ParserError
|
|
129
|
+
{}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def symbolize(hash)
|
|
133
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def searchable_text(data)
|
|
137
|
+
parts = [data[:summary], data[:outcome]]
|
|
138
|
+
Array(data[:steps]).each do |s|
|
|
139
|
+
next unless s.is_a?(Hash)
|
|
140
|
+
parts << (s[:observation] || s["observation"])
|
|
141
|
+
parts << (s[:action] || s["action"])
|
|
142
|
+
parts << (s[:result] || s["result"])
|
|
143
|
+
end
|
|
144
|
+
parts.compact.join("\n")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def created_at_value(data)
|
|
148
|
+
ca = data[:created_at]
|
|
149
|
+
return Time.now.utc.iso8601 if ca.nil?
|
|
150
|
+
ca.respond_to?(:iso8601) ? ca.iso8601 : ca.to_s
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -29,21 +29,19 @@ module Llmemory
|
|
|
29
29
|
load_episode(path)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def list_episodes(user_id, limit: nil)
|
|
33
|
-
sorted =
|
|
32
|
+
def list_episodes(user_id, limit: nil, offset: nil)
|
|
33
|
+
sorted = active_episodes(user_id).sort_by { |e| e[: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_episodes(user_id, query)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
all_episodes(user_id).select { |e| episode_text(e).downcase.include?(q) }
|
|
39
|
+
return list_episodes(user_id) if query.to_s.strip.empty?
|
|
40
|
+
active_episodes(user_id).select { |e| Llmemory::Tokenizer.matches?(episode_text(e), query) }
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def count_episodes(user_id)
|
|
44
|
-
|
|
45
|
-
return 0 unless Dir.exist?(dir)
|
|
46
|
-
Dir.children(dir).count { |f| f.end_with?(".json") }
|
|
44
|
+
active_episodes(user_id).size
|
|
47
45
|
end
|
|
48
46
|
|
|
49
47
|
def delete_episodes(user_id, ids)
|
|
@@ -55,6 +53,24 @@ module Llmemory
|
|
|
55
53
|
end
|
|
56
54
|
end
|
|
57
55
|
|
|
56
|
+
def archive_episodes(user_id, ids)
|
|
57
|
+
Array(ids).map(&:to_s).count do |id|
|
|
58
|
+
path = episode_path(user_id, id)
|
|
59
|
+
next false unless File.file?(path)
|
|
60
|
+
data = JSON.parse(File.read(path))
|
|
61
|
+
next false if data["archived_at"]
|
|
62
|
+
data["archived_at"] = Time.now.iso8601
|
|
63
|
+
File.write(path, JSON.generate(data))
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def expired_episode_ids(user_id, cutoff:)
|
|
69
|
+
active_episodes(user_id)
|
|
70
|
+
.select { |e| (e[:created_at] || Time.now) < cutoff }
|
|
71
|
+
.map { |e| e[:id].to_s }
|
|
72
|
+
end
|
|
73
|
+
|
|
58
74
|
def list_users
|
|
59
75
|
return [] unless Dir.exist?(@base_path)
|
|
60
76
|
Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "episodes")) }
|
|
@@ -62,6 +78,10 @@ module Llmemory
|
|
|
62
78
|
|
|
63
79
|
private
|
|
64
80
|
|
|
81
|
+
def active_episodes(user_id)
|
|
82
|
+
all_episodes(user_id).reject { |e| e[:archived_at] }
|
|
83
|
+
end
|
|
84
|
+
|
|
65
85
|
def all_episodes(user_id)
|
|
66
86
|
dir = user_path(user_id, "episodes")
|
|
67
87
|
return [] unless Dir.exist?(dir)
|
|
@@ -25,19 +25,19 @@ module Llmemory
|
|
|
25
25
|
@episodes[user_id].find { |e| e[:id] == id }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def list_episodes(user_id, limit: nil)
|
|
29
|
-
sorted =
|
|
28
|
+
def list_episodes(user_id, limit: nil, offset: nil)
|
|
29
|
+
sorted = active_episodes(user_id).sort_by { |e| as_time(e[: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_episodes(user_id, query)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@episodes[user_id].select { |e| episode_text(e).downcase.include?(q) }
|
|
35
|
+
return list_episodes(user_id) if query.to_s.strip.empty?
|
|
36
|
+
active_episodes(user_id).select { |e| Llmemory::Tokenizer.matches?(episode_text(e), query) }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def count_episodes(user_id)
|
|
40
|
-
|
|
40
|
+
active_episodes(user_id).size
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def delete_episodes(user_id, ids)
|
|
@@ -47,12 +47,42 @@ module Llmemory
|
|
|
47
47
|
before - @episodes[user_id].size
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
def archive_episodes(user_id, ids)
|
|
51
|
+
ids = Array(ids).map(&:to_s)
|
|
52
|
+
count = 0
|
|
53
|
+
@episodes[user_id].each do |e|
|
|
54
|
+
next unless ids.include?(e[:id].to_s)
|
|
55
|
+
next if e[:archived_at]
|
|
56
|
+
e[:archived_at] = Time.now
|
|
57
|
+
count += 1
|
|
58
|
+
end
|
|
59
|
+
count
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def expired_episode_ids(user_id, cutoff:)
|
|
63
|
+
active_episodes(user_id)
|
|
64
|
+
.select { |e| as_time(e[:created_at]) < cutoff }
|
|
65
|
+
.map { |e| e[:id].to_s }
|
|
66
|
+
end
|
|
67
|
+
|
|
50
68
|
def list_users
|
|
51
69
|
@episodes.keys
|
|
52
70
|
end
|
|
53
71
|
|
|
54
72
|
private
|
|
55
73
|
|
|
74
|
+
def active_episodes(user_id)
|
|
75
|
+
@episodes[user_id].reject { |e| e[:archived_at] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def as_time(value)
|
|
79
|
+
return Time.now if value.nil?
|
|
80
|
+
return value if value.is_a?(Time)
|
|
81
|
+
Time.parse(value.to_s)
|
|
82
|
+
rescue ArgumentError
|
|
83
|
+
Time.now
|
|
84
|
+
end
|
|
85
|
+
|
|
56
86
|
def symbolize(hash)
|
|
57
87
|
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
58
88
|
end
|
|
@@ -108,11 +108,15 @@ module Llmemory
|
|
|
108
108
|
# --- MemoryModule uniform interface ---
|
|
109
109
|
|
|
110
110
|
def write(payload, **_meta)
|
|
111
|
-
|
|
111
|
+
result = nil
|
|
112
|
+
Llmemory::Instrumentation.instrument(:memory_write, memory_type: "file_based", user_id: @user_id) do
|
|
113
|
+
result = memorize(payload)
|
|
114
|
+
end
|
|
115
|
+
result
|
|
112
116
|
end
|
|
113
117
|
|
|
114
|
-
def list(user_id: nil, limit: nil)
|
|
115
|
-
@storage.list_items(user_id: user_id || @user_id, limit: limit)
|
|
118
|
+
def list(user_id: nil, limit: nil, offset: nil)
|
|
119
|
+
@storage.list_items(user_id: user_id || @user_id, limit: limit, offset: offset)
|
|
116
120
|
end
|
|
117
121
|
|
|
118
122
|
def stats(user_id: nil)
|
|
@@ -120,7 +124,10 @@ module Llmemory
|
|
|
120
124
|
end
|
|
121
125
|
|
|
122
126
|
# Removes items/resources by id and records the removal in the audit log.
|
|
123
|
-
|
|
127
|
+
# Note: file-based storages currently implement `archive_*` as physical
|
|
128
|
+
# removal — `mode: :soft` and `mode: :hard` are functionally equivalent
|
|
129
|
+
# here. Kept for API uniformity.
|
|
130
|
+
def forget(ids:, reason: nil, mode: :soft)
|
|
124
131
|
requested = Array(ids).map(&:to_s)
|
|
125
132
|
existing = (@storage.get_all_items(@user_id) + @storage.get_all_resources(@user_id))
|
|
126
133
|
.map { |r| (r[:id] || r["id"]).to_s }
|
|
@@ -128,6 +135,7 @@ module Llmemory
|
|
|
128
135
|
@storage.archive_items(@user_id, removed)
|
|
129
136
|
@storage.archive_resources(@user_id, removed)
|
|
130
137
|
forget_log.record(@user_id, memory_type: "file_based", ids: removed, reason: reason)
|
|
138
|
+
Llmemory::Instrumentation.instrument(:memory_forget, memory_type: "file_based", user_id: @user_id, count: removed.size, mode: mode)
|
|
131
139
|
removed.size
|
|
132
140
|
end
|
|
133
141
|
|
|
@@ -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:)
|
|
@@ -125,17 +123,19 @@ module Llmemory
|
|
|
125
123
|
LlmemoryCategory.distinct.pluck(:user_id)).uniq
|
|
126
124
|
end
|
|
127
125
|
|
|
128
|
-
def list_resources(user_id:, limit: nil)
|
|
126
|
+
def list_resources(user_id:, limit: nil, offset: nil)
|
|
129
127
|
scope = LlmemoryResource.where(user_id: user_id).order(:created_at)
|
|
130
128
|
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
129
|
+
scope = scope.offset(offset) if offset && offset.to_i.positive?
|
|
131
130
|
scope.map { |r| row_to_resource(r) }
|
|
132
131
|
end
|
|
133
132
|
|
|
134
|
-
def list_items(user_id:, category: nil, limit: nil)
|
|
133
|
+
def list_items(user_id:, category: nil, limit: nil, offset: nil)
|
|
135
134
|
scope = LlmemoryItem.where(user_id: user_id)
|
|
136
135
|
scope = scope.where(category: category) if category
|
|
137
136
|
scope = scope.order(:created_at)
|
|
138
137
|
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
138
|
+
scope = scope.offset(offset) if offset && offset.to_i.positive?
|
|
139
139
|
scope.map { |r| row_to_item(r) }
|
|
140
140
|
end
|
|
141
141
|
|
|
@@ -181,6 +181,15 @@ module Llmemory
|
|
|
181
181
|
(str || "").to_s.gsub(/[%_\\]/) { |c| "\\#{c}" }
|
|
182
182
|
end
|
|
183
183
|
|
|
184
|
+
# OR-of-token LIKE scope for keyword search; unchanged scope (match all)
|
|
185
|
+
# when the query has no tokens.
|
|
186
|
+
def token_scope(scope, column, query)
|
|
187
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
188
|
+
return scope if tokens.empty?
|
|
189
|
+
clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
|
|
190
|
+
scope.where(clause, *tokens.map { |t| "%#{sanitize_like(t)}%" })
|
|
191
|
+
end
|
|
192
|
+
|
|
184
193
|
def row_to_item(r)
|
|
185
194
|
h = {
|
|
186
195
|
id: r.id,
|
|
@@ -69,11 +69,11 @@ module Llmemory
|
|
|
69
69
|
raise NotImplementedError, "#{self.class}#list_users must be implemented"
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
def list_resources(user_id:, limit: nil)
|
|
72
|
+
def list_resources(user_id:, limit: nil, offset: nil)
|
|
73
73
|
raise NotImplementedError, "#{self.class}#list_resources must be implemented"
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
def list_items(user_id:, category: nil, limit: nil)
|
|
76
|
+
def list_items(user_id:, category: nil, limit: nil, offset: nil)
|
|
77
77
|
raise NotImplementedError, "#{self.class}#list_items must be implemented"
|
|
78
78
|
end
|
|
79
79
|
|
|
@@ -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
|
|
@@ -169,15 +169,16 @@ module Llmemory
|
|
|
169
169
|
conn.exec("SELECT DISTINCT user_id FROM llmemory_categories").map { |r| r["user_id"] }).uniq
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
-
def list_resources(user_id:, limit: nil)
|
|
172
|
+
def list_resources(user_id:, limit: nil, offset: nil)
|
|
173
173
|
ensure_tables!
|
|
174
174
|
sql = "SELECT id, text, created_at FROM llmemory_resources WHERE user_id = $1 ORDER BY created_at"
|
|
175
175
|
sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
|
|
176
|
+
sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
|
|
176
177
|
rows = conn.exec_params(sql, [user_id])
|
|
177
178
|
rows_to_resources(rows)
|
|
178
179
|
end
|
|
179
180
|
|
|
180
|
-
def list_items(user_id:, category: nil, limit: nil)
|
|
181
|
+
def list_items(user_id:, category: nil, limit: nil, offset: nil)
|
|
181
182
|
ensure_tables!
|
|
182
183
|
sql = "SELECT id, category, content, source_resource_id, importance, provenance, created_at FROM llmemory_items WHERE user_id = $1"
|
|
183
184
|
params = [user_id]
|
|
@@ -187,6 +188,7 @@ module Llmemory
|
|
|
187
188
|
end
|
|
188
189
|
sql += " ORDER BY created_at"
|
|
189
190
|
sql += " LIMIT #{limit.to_i}" if limit && limit.to_i.positive?
|
|
191
|
+
sql += " OFFSET #{offset.to_i}" if offset && offset.to_i.positive?
|
|
190
192
|
rows = params.size == 1 ? conn.exec_params(sql, params) : conn.exec_params(sql, params)
|
|
191
193
|
rows_to_items(rows)
|
|
192
194
|
end
|
|
@@ -290,6 +292,16 @@ module Llmemory
|
|
|
290
292
|
end
|
|
291
293
|
end
|
|
292
294
|
|
|
295
|
+
# Builds an OR-of-token LIKE filter for keyword search. Returns
|
|
296
|
+
# ["" , []] for an empty query (match all). Tokens are [a-z0-9]{2,} so
|
|
297
|
+
# they carry no LIKE wildcards.
|
|
298
|
+
def token_filter(column, query, start_index)
|
|
299
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
300
|
+
return ["", []] if tokens.empty?
|
|
301
|
+
likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
|
|
302
|
+
[" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
|
|
303
|
+
end
|
|
304
|
+
|
|
293
305
|
def parse_provenance(value)
|
|
294
306
|
return nil if value.nil? || value.to_s.strip.empty?
|
|
295
307
|
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:)
|
|
@@ -154,14 +152,16 @@ module Llmemory
|
|
|
154
152
|
Dir.children(@base_path).select { |e| File.directory?(File.join(@base_path, e)) && !e.start_with?(".") }
|
|
155
153
|
end
|
|
156
154
|
|
|
157
|
-
def list_resources(user_id:, limit: nil)
|
|
155
|
+
def list_resources(user_id:, limit: nil, offset: nil)
|
|
158
156
|
list = get_all_resources(user_id)
|
|
157
|
+
list = list.drop(offset.to_i) if offset && offset.to_i.positive?
|
|
159
158
|
limit ? list.take(limit) : list
|
|
160
159
|
end
|
|
161
160
|
|
|
162
|
-
def list_items(user_id:, category: nil, limit: nil)
|
|
161
|
+
def list_items(user_id:, category: nil, limit: nil, offset: nil)
|
|
163
162
|
list = get_all_items(user_id)
|
|
164
163
|
list = list.select { |i| (i[:category] || i["category"]).to_s == category.to_s } if category
|
|
164
|
+
list = list.drop(offset.to_i) if offset && offset.to_i.positive?
|
|
165
165
|
list = list.take(limit) if limit
|
|
166
166
|
list
|
|
167
167
|
end
|