llmemory 0.2.0 → 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 +7 -2
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -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 +4 -0
- data/lib/llmemory/long_term/episodic/memory.rb +71 -16
- 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/database_storage.rb +135 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +2 -3
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +2 -3
- 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 +77 -60
- data/lib/llmemory/long_term/procedural/memory.rb +71 -17
- 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 +104 -0
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +148 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +2 -3
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +2 -3
- 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 +28 -3
- data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
- data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
- data/lib/llmemory/short_term/session_lifecycle.rb +19 -3
- 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.rb +1 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80f259aa60090b95d21110bdf1f91c2c91bd5334a1bc4e3effaec444f241f371
|
|
4
|
+
data.tar.gz: 0b07d9a6ce5e485a69f00dfb2ad7c0cd03a442e95817d310a65d85e40f9def2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 464cc0765650869996ff24a4bf7e2b3ce4d3433a6c73cc9d63167173b04d14deedf0e169ca4579a7d7ac253b5a858f2b88629ce1b888bf48becfa46fad581979
|
|
7
|
+
data.tar.gz: 7624c8cd607cf882b13d500ee878b5412f49e13282014a33dc836cfa68c9bfdf9f759b89f53b51eccb38c32966f0cc66ae5d23a2297f2c965a67d81ccb8f437f
|
data/README.md
CHANGED
|
@@ -74,6 +74,11 @@ Llmemory.configure do |config|
|
|
|
74
74
|
config.importance_weight = 1.0 # how strongly importance multiplies the score (0 = ignore)
|
|
75
75
|
config.retrieval_feedback_weight = 0.5 # how strongly useful/harmful feedback shifts ranking (0 = ignore)
|
|
76
76
|
|
|
77
|
+
# Semantic (embedding) retrieval for episodic/procedural memory (opt-in;
|
|
78
|
+
# default off keeps them deterministic and network-free)
|
|
79
|
+
config.episodic_vector_enabled = false
|
|
80
|
+
config.procedural_vector_enabled = false
|
|
81
|
+
|
|
77
82
|
# Pre-compaction memory flush (prevents knowledge loss when compacting)
|
|
78
83
|
config.memory_flush_enabled = true
|
|
79
84
|
config.memory_flush_threshold_tokens = 4000
|
|
@@ -129,7 +134,7 @@ rails g llmemory:install
|
|
|
129
134
|
rails db:migrate
|
|
130
135
|
```
|
|
131
136
|
|
|
132
|
-
La migración crea las tablas de long-term file-based (resources, items, categories), short-term (checkpoints) y, para graph-based, nodos, aristas y embeddings (`llmemory_nodes`, `llmemory_edges`, `llmemory_embeddings`). Para embeddings se usa pgvector; asegúrate de tener la extensión `vector` en PostgreSQL. Para usar ambas con ActiveRecord:
|
|
137
|
+
La migración crea las tablas de long-term file-based (resources, items, categories), short-term (checkpoints), episódica y procedural (`llmemory_episodes`, `llmemory_skills`; columna `data` JSONB) y, para graph-based, nodos, aristas y embeddings (`llmemory_nodes`, `llmemory_edges`, `llmemory_embeddings`). Para embeddings se usa pgvector; asegúrate de tener la extensión `vector` en PostgreSQL. Para usar ambas con ActiveRecord:
|
|
133
138
|
|
|
134
139
|
```ruby
|
|
135
140
|
# config/application.rb o config/initializers/llmemory.rb
|
|
@@ -214,7 +219,7 @@ llmemory implements the memory and internal-action concepts from [CoALA — Cogn
|
|
|
214
219
|
| Learning action | `memorize` / `record_episode` / `register_skill` / reflection |
|
|
215
220
|
| Uniform interface | `Llmemory::MemoryModule` (`read`/`write`/`list`/`stats`/`forget`) |
|
|
216
221
|
|
|
217
|
-
All three long-term memories below are **additive** — episodic and procedural coexist with semantic memory rather than replacing it.
|
|
222
|
+
All three long-term memories below are **additive** — episodic and procedural coexist with semantic memory rather than replacing it. They support `:memory`, `:file`, `:postgres` and `:active_record` backends. Retrieval is keyword-based by default (tokenized, so multi-word queries work); semantic (embedding) retrieval is **opt-in** via `config.episodic_vector_enabled` / `config.procedural_vector_enabled` (or by injecting a `vector_store:`), which makes `search_candidates` hybrid (vector + keyword).
|
|
218
223
|
|
|
219
224
|
### Working memory (structured, persists across LLM calls)
|
|
220
225
|
|
|
@@ -30,6 +30,26 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
|
|
|
30
30
|
end
|
|
31
31
|
add_index :llmemory_categories, [:user_id, :category_name], unique: true
|
|
32
32
|
|
|
33
|
+
# Episodic long-term memory (trajectories) — JSONB document per episode
|
|
34
|
+
create_table :llmemory_episodes, id: false do |t|
|
|
35
|
+
t.string :id, null: false, primary_key: true
|
|
36
|
+
t.string :user_id, null: false
|
|
37
|
+
t.jsonb :data, null: false, default: {}
|
|
38
|
+
t.text :search_text
|
|
39
|
+
t.timestamps
|
|
40
|
+
end
|
|
41
|
+
add_index :llmemory_episodes, :user_id
|
|
42
|
+
|
|
43
|
+
# Procedural long-term memory (skill library) — JSONB document per skill
|
|
44
|
+
create_table :llmemory_skills, id: false do |t|
|
|
45
|
+
t.string :id, null: false, primary_key: true
|
|
46
|
+
t.string :user_id, null: false
|
|
47
|
+
t.jsonb :data, null: false, default: {}
|
|
48
|
+
t.text :search_text
|
|
49
|
+
t.timestamps
|
|
50
|
+
end
|
|
51
|
+
add_index :llmemory_skills, :user_id
|
|
52
|
+
|
|
33
53
|
create_table :llmemory_checkpoints do |t|
|
|
34
54
|
t.string :user_id, null: false
|
|
35
55
|
t.string :session_id, null: false
|
|
@@ -70,6 +70,14 @@ module Llmemory
|
|
|
70
70
|
Llmemory::LongTerm::GraphBased::Storages::MemoryStorage.new
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
|
+
|
|
74
|
+
def episodic_storage(store_type = nil)
|
|
75
|
+
Llmemory::LongTerm::Episodic::Storages.build(store: (store_type || Llmemory.configuration.long_term_store))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def procedural_storage(store_type = nil)
|
|
79
|
+
Llmemory::LongTerm::Procedural::Storages.build(store: (store_type || Llmemory.configuration.long_term_store))
|
|
80
|
+
end
|
|
73
81
|
end
|
|
74
82
|
end
|
|
75
83
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module Cli
|
|
7
|
+
module Commands
|
|
8
|
+
class Episodic < Commands::Base
|
|
9
|
+
def option_parser(parser)
|
|
10
|
+
parser.on("--limit N", Integer, "Max number of episodes (newest first)") { |v| @limit = v }
|
|
11
|
+
parser.on("--store TYPE", "Storage type (memory|file|postgres|active_record)") { |v| @store_type = v }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute(argv, _opts)
|
|
15
|
+
user_id = argv.first
|
|
16
|
+
unless user_id
|
|
17
|
+
$stderr.puts "Usage: llmemory episodes USER_ID [--limit N] [--store TYPE]"
|
|
18
|
+
exit 1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
storage = episodic_storage(@store_type)
|
|
22
|
+
episodes = storage.list_episodes(user_id, limit: @limit)
|
|
23
|
+
|
|
24
|
+
if episodes.empty?
|
|
25
|
+
puts "No episodes for user #{user_id}."
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
episodes.each do |e|
|
|
30
|
+
id = e[:id] || e["id"]
|
|
31
|
+
summary = e[:summary] || e["summary"]
|
|
32
|
+
outcome = e[:outcome] || e["outcome"]
|
|
33
|
+
importance = e[:importance] || e["importance"]
|
|
34
|
+
steps = e[:steps] || e["steps"] || []
|
|
35
|
+
puts "[#{id}] (importance: #{importance}; outcome: #{outcome || 'n/a'}) #{summary}"
|
|
36
|
+
puts " steps: #{Array(steps).size}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module Cli
|
|
7
|
+
module Commands
|
|
8
|
+
class ForgetLog < Commands::Base
|
|
9
|
+
def execute(argv, _opts)
|
|
10
|
+
user_id = argv.first
|
|
11
|
+
unless user_id
|
|
12
|
+
$stderr.puts "Usage: llmemory forget-log USER_ID"
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
entries = Llmemory::ForgetLog.new(store: short_term_store).entries(user_id)
|
|
17
|
+
|
|
18
|
+
if entries.empty?
|
|
19
|
+
puts "No forget audit entries for user #{user_id}."
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
entries.each do |e|
|
|
24
|
+
type = e[:memory_type] || e["memory_type"]
|
|
25
|
+
count = e[:count] || e["count"]
|
|
26
|
+
reason = e[:reason] || e["reason"]
|
|
27
|
+
at = e[:at] || e["at"]
|
|
28
|
+
ids = e[:ids] || e["ids"] || []
|
|
29
|
+
reason_str = reason ? " — #{reason}" : ""
|
|
30
|
+
puts "[#{at}] #{type}: removed #{count} (#{ids.join(', ')})#{reason_str}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module Cli
|
|
7
|
+
module Commands
|
|
8
|
+
class Procedural < Commands::Base
|
|
9
|
+
def option_parser(parser)
|
|
10
|
+
parser.on("--limit N", Integer, "Max number of skills (newest first)") { |v| @limit = v }
|
|
11
|
+
parser.on("--store TYPE", "Storage type (memory|file|postgres|active_record)") { |v| @store_type = v }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute(argv, _opts)
|
|
15
|
+
user_id = argv.first
|
|
16
|
+
unless user_id
|
|
17
|
+
$stderr.puts "Usage: llmemory skills USER_ID [--limit N] [--store TYPE]"
|
|
18
|
+
exit 1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
storage = procedural_storage(@store_type)
|
|
22
|
+
skills = storage.list_skills(user_id, limit: @limit)
|
|
23
|
+
|
|
24
|
+
if skills.empty?
|
|
25
|
+
puts "No skills for user #{user_id}."
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
skills.each do |s|
|
|
30
|
+
id = s[:id] || s["id"]
|
|
31
|
+
name = s[:name] || s["name"]
|
|
32
|
+
kind = s[:kind] || s["kind"]
|
|
33
|
+
version = s[:version] || s["version"]
|
|
34
|
+
succ = (s[:success_count] || s["success_count"] || 0).to_i
|
|
35
|
+
fail = (s[:failure_count] || s["failure_count"] || 0).to_i
|
|
36
|
+
total = succ + fail
|
|
37
|
+
rate = total.zero? ? "n/a" : format("%.2f", succ.to_f / total)
|
|
38
|
+
puts "[#{id}] #{name} v#{version} (#{kind}) — success rate: #{rate} (#{succ}/#{total})"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module Cli
|
|
7
|
+
module Commands
|
|
8
|
+
class Working < Commands::Base
|
|
9
|
+
def execute(argv, _opts)
|
|
10
|
+
user_id, session_id = argv
|
|
11
|
+
unless user_id && session_id
|
|
12
|
+
$stderr.puts "Usage: llmemory working USER_ID SESSION_ID"
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
wm = Llmemory::WorkingMemory.new(user_id: user_id, session_id: session_id, store: short_term_store)
|
|
17
|
+
state = wm.to_h
|
|
18
|
+
|
|
19
|
+
if state.empty?
|
|
20
|
+
puts "Empty working memory for user #{user_id}, session #{session_id}."
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
state.each do |slot, value|
|
|
25
|
+
puts "#{slot}: #{value.inspect}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/llmemory/cli.rb
CHANGED
|
@@ -5,6 +5,10 @@ require_relative "cli/commands/base"
|
|
|
5
5
|
require_relative "cli/commands/users"
|
|
6
6
|
require_relative "cli/commands/short_term"
|
|
7
7
|
require_relative "cli/commands/long_term"
|
|
8
|
+
require_relative "cli/commands/episodic"
|
|
9
|
+
require_relative "cli/commands/procedural"
|
|
10
|
+
require_relative "cli/commands/working"
|
|
11
|
+
require_relative "cli/commands/forget_log"
|
|
8
12
|
require_relative "cli/commands/stats"
|
|
9
13
|
require_relative "cli/commands/search"
|
|
10
14
|
require_relative "cli/commands/mcp"
|
|
@@ -47,6 +51,10 @@ module Llmemory
|
|
|
47
51
|
"nodes" => Cli::Commands::LongTerm::Nodes,
|
|
48
52
|
"edges" => Cli::Commands::LongTerm::Edges,
|
|
49
53
|
"graph" => Cli::Commands::LongTerm::Graph,
|
|
54
|
+
"episodes" => Cli::Commands::Episodic,
|
|
55
|
+
"skills" => Cli::Commands::Procedural,
|
|
56
|
+
"working" => Cli::Commands::Working,
|
|
57
|
+
"forget_log" => Cli::Commands::ForgetLog,
|
|
50
58
|
"search" => Cli::Commands::Search,
|
|
51
59
|
"stats" => Cli::Commands::Stats,
|
|
52
60
|
"mcp" => Cli::Commands::Mcp
|
|
@@ -68,6 +76,10 @@ module Llmemory
|
|
|
68
76
|
nodes USER_ID List graph nodes (graph-based)
|
|
69
77
|
edges USER_ID List graph edges (graph-based)
|
|
70
78
|
graph USER_ID Export graph (--format dot|json)
|
|
79
|
+
episodes USER_ID List recorded episodes (episodic memory)
|
|
80
|
+
skills USER_ID List registered skills (procedural memory)
|
|
81
|
+
working USER_ID SESSION Show working-memory slots for a session
|
|
82
|
+
forget-log USER_ID Show audit of forgotten entries
|
|
71
83
|
search USER_ID "query" Search in memory
|
|
72
84
|
stats [USER_ID] Show statistics
|
|
73
85
|
mcp [serve] Start MCP server for LLM agents
|
|
@@ -11,6 +11,8 @@ module Llmemory
|
|
|
11
11
|
:long_term_type,
|
|
12
12
|
:long_term_store,
|
|
13
13
|
:long_term_storage_path,
|
|
14
|
+
:episodic_vector_enabled,
|
|
15
|
+
:procedural_vector_enabled,
|
|
14
16
|
:database_url,
|
|
15
17
|
:vector_store,
|
|
16
18
|
:time_decay_half_life_days,
|
|
@@ -55,6 +57,8 @@ module Llmemory
|
|
|
55
57
|
@long_term_type = :file_based
|
|
56
58
|
@long_term_store = :memory
|
|
57
59
|
@long_term_storage_path = ENV["LLMEMORY_STORAGE_PATH"] || "./llmemory_data"
|
|
60
|
+
@episodic_vector_enabled = false
|
|
61
|
+
@procedural_vector_enabled = false
|
|
58
62
|
@database_url = ENV["DATABASE_URL"]
|
|
59
63
|
@vector_store = nil
|
|
60
64
|
@time_decay_half_life_days = 30
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "episode"
|
|
4
4
|
require_relative "storage"
|
|
5
5
|
require_relative "../../memory_module"
|
|
6
|
+
require_relative "../../vector_store"
|
|
6
7
|
|
|
7
8
|
module Llmemory
|
|
8
9
|
module LongTerm
|
|
@@ -12,16 +13,19 @@ module Llmemory
|
|
|
12
13
|
# memory (file/graph), not replace it, and to feed reflection (P2), which
|
|
13
14
|
# distills episodes into semantic knowledge.
|
|
14
15
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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).
|
|
17
19
|
class Memory
|
|
18
20
|
include Llmemory::MemoryModule
|
|
19
21
|
|
|
20
22
|
attr_reader :user_id, :storage
|
|
21
23
|
|
|
22
|
-
def initialize(user_id:, storage: nil)
|
|
24
|
+
def initialize(user_id:, storage: nil, vector_store: nil)
|
|
23
25
|
@user_id = user_id
|
|
24
26
|
@storage = storage || Storages.build
|
|
27
|
+
@vector_store = vector_store
|
|
28
|
+
@vector_explicit = !vector_store.nil?
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
# Records a trajectory. `steps` is an array of hashes with any of
|
|
@@ -39,7 +43,9 @@ module Llmemory
|
|
|
39
43
|
episode.searchable_text, method: "episode_recording", confidence: episode.importance
|
|
40
44
|
)
|
|
41
45
|
record = episode.to_h.merge(provenance: provenance)
|
|
42
|
-
@storage.save_episode(@user_id, record)
|
|
46
|
+
id = @storage.save_episode(@user_id, record)
|
|
47
|
+
index_vector(id, episode.searchable_text)
|
|
48
|
+
id
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
def recent_episodes(limit: 10)
|
|
@@ -62,22 +68,17 @@ module Llmemory
|
|
|
62
68
|
# Retrieval Engine integration. Returns candidates shaped like the other
|
|
63
69
|
# long-term memories so the Engine can rank episodes by relevance,
|
|
64
70
|
# recency (temporal decay) and importance (P3), with provenance (P10).
|
|
71
|
+
# Hybrid (vector + keyword) when a vector store is active; otherwise
|
|
72
|
+
# keyword-only.
|
|
65
73
|
def search_candidates(query, user_id: nil, top_k: 20)
|
|
66
74
|
uid = user_id || @user_id
|
|
67
75
|
return [] unless uid == @user_id
|
|
68
76
|
|
|
69
|
-
@storage.search_episodes(uid, query).first(top_k).map
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
timestamp: episode.created_at,
|
|
75
|
-
score: 1.0,
|
|
76
|
-
importance: episode.importance,
|
|
77
|
-
evergreen: false,
|
|
78
|
-
provenance: e[:provenance] || e["provenance"]
|
|
79
|
-
}
|
|
80
|
-
end
|
|
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)
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
# --- MemoryModule uniform interface ---
|
|
@@ -105,6 +106,60 @@ module Llmemory
|
|
|
105
106
|
|
|
106
107
|
private
|
|
107
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
|
+
|
|
108
163
|
# Cheap, deterministic summary when the caller does not provide one.
|
|
109
164
|
# LLM-based summarization is reflection's job (P2).
|
|
110
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
|