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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -0
  4. data/lib/llmemory/cli/commands/base.rb +8 -0
  5. data/lib/llmemory/cli/commands/episodic.rb +42 -0
  6. data/lib/llmemory/cli/commands/forget_log.rb +36 -0
  7. data/lib/llmemory/cli/commands/procedural.rb +44 -0
  8. data/lib/llmemory/cli/commands/working.rb +31 -0
  9. data/lib/llmemory/cli.rb +12 -0
  10. data/lib/llmemory/configuration.rb +4 -0
  11. data/lib/llmemory/long_term/episodic/memory.rb +71 -16
  12. data/lib/llmemory/long_term/episodic/storage.rb +7 -5
  13. data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
  14. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +93 -0
  15. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +135 -0
  16. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +2 -3
  17. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +2 -3
  18. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +11 -4
  19. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +16 -6
  20. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +2 -4
  21. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -4
  22. data/lib/llmemory/long_term/graph_based/memory.rb +77 -60
  23. data/lib/llmemory/long_term/procedural/memory.rb +71 -17
  24. data/lib/llmemory/long_term/procedural/storage.rb +7 -5
  25. data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
  26. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +104 -0
  27. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +148 -0
  28. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +2 -3
  29. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +2 -3
  30. data/lib/llmemory/mcp/server.rb +13 -1
  31. data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
  32. data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
  33. data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
  34. data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
  35. data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
  36. data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
  37. data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
  38. data/lib/llmemory/memory.rb +28 -3
  39. data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
  40. data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
  41. data/lib/llmemory/short_term/session_lifecycle.rb +19 -3
  42. data/lib/llmemory/tokenizer.rb +27 -0
  43. data/lib/llmemory/vector_store/active_record_store.rb +4 -3
  44. data/lib/llmemory/vector_store.rb +14 -0
  45. data/lib/llmemory/version.rb +1 -1
  46. data/lib/llmemory.rb +1 -0
  47. metadata +18 -1
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require_relative "base"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module Procedural
11
+ module Storages
12
+ # ActiveRecord backend. Stores each skill as a JSONB `data` document; AR
13
+ # auto-deserializes jsonb to a Hash (string keys), which Skill.from_h
14
+ # handles. Mirrors the file-based ActiveRecordStorage pattern.
15
+ class ActiveRecordStorage < Base
16
+ def initialize
17
+ self.class.load_models!
18
+ end
19
+
20
+ def self.load_models!
21
+ return if @models_loaded
22
+ require "active_record"
23
+ require_relative "active_record_models"
24
+ @models_loaded = true
25
+ end
26
+
27
+ def save_skill(user_id, skill)
28
+ id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
29
+ data = stringify(skill).merge("id" => id, "user_id" => user_id)
30
+ data["created_at"] ||= Time.now.utc.iso8601
31
+ rec = LlmemorySkill.find_or_initialize_by(id: id)
32
+ rec.user_id = user_id
33
+ rec.data = data
34
+ rec.search_text = searchable_text(data)
35
+ rec.created_at ||= Time.current
36
+ rec.save!
37
+ id
38
+ end
39
+
40
+ def get_skill(user_id, id)
41
+ LlmemorySkill.find_by(user_id: user_id, id: id)&.data
42
+ end
43
+
44
+ def list_skills(user_id, limit: nil)
45
+ scope = LlmemorySkill.where(user_id: user_id).order(created_at: :desc)
46
+ scope = scope.limit(limit) if limit && limit.to_i.positive?
47
+ scope.map(&:data)
48
+ end
49
+
50
+ def search_skills(user_id, query)
51
+ token_scope(LlmemorySkill.where(user_id: user_id), "search_text", query)
52
+ .order(created_at: :desc).map(&:data)
53
+ end
54
+
55
+ def find_skills_by_name(user_id, name)
56
+ LlmemorySkill.where(user_id: user_id).where("data->>'name' = ?", name.to_s).map(&:data)
57
+ end
58
+
59
+ def record_outcome(user_id, skill_id, success:)
60
+ rec = LlmemorySkill.find_by(user_id: user_id, id: skill_id)
61
+ return nil unless rec
62
+ data = rec.data || {}
63
+ key = success ? "success_count" : "failure_count"
64
+ data[key] = (data[key] || 0).to_i + 1
65
+ data["updated_at"] = Time.now.utc.iso8601
66
+ rec.data = data
67
+ rec.search_text = searchable_text(data)
68
+ rec.save!
69
+ data
70
+ end
71
+
72
+ def count_skills(user_id)
73
+ LlmemorySkill.where(user_id: user_id).count
74
+ end
75
+
76
+ def delete_skills(user_id, ids)
77
+ LlmemorySkill.where(user_id: user_id, id: Array(ids).map(&:to_s)).delete_all
78
+ end
79
+
80
+ def list_users
81
+ LlmemorySkill.distinct.pluck(:user_id)
82
+ end
83
+
84
+ private
85
+
86
+ def token_scope(scope, column, query)
87
+ tokens = Llmemory::Tokenizer.tokenize(query)
88
+ return scope if tokens.empty?
89
+ clause = tokens.map { "LOWER(#{column}) LIKE LOWER(?)" }.join(" OR ")
90
+ scope.where(clause, *tokens.map { |t| "%#{t}%" })
91
+ end
92
+
93
+ def stringify(hash)
94
+ JSON.parse(JSON.generate(hash))
95
+ end
96
+
97
+ def searchable_text(data)
98
+ [data["name"], data["description"], data["body"]].compact.join("\n")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require_relative "base"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module Procedural
11
+ module Storages
12
+ # PostgreSQL backend. Each skill is stored as a JSONB `data` document
13
+ # (plus id/user_id/created_at and a denormalized search_text), mirroring
14
+ # 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_skill(user_id, skill)
22
+ ensure_tables!
23
+ id = skill[:id] || skill["id"] || "skill_#{SecureRandom.hex(8)}"
24
+ data = symbolize(skill).merge(id: id, user_id: user_id)
25
+ data[:created_at] ||= Time.now.utc.iso8601
26
+ conn.exec_params(
27
+ "INSERT INTO llmemory_skills (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_skill(user_id, id)
36
+ ensure_tables!
37
+ rows = conn.exec_params("SELECT data FROM llmemory_skills 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_skills(user_id, limit: nil)
42
+ ensure_tables!
43
+ sql = "SELECT data FROM llmemory_skills 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_skills(user_id, query)
49
+ ensure_tables!
50
+ suffix, params = token_filter("search_text", query, 2)
51
+ conn.exec_params(
52
+ "SELECT data FROM llmemory_skills 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 find_skills_by_name(user_id, name)
58
+ ensure_tables!
59
+ conn.exec_params(
60
+ "SELECT data FROM llmemory_skills WHERE user_id = $1 AND data->>'name' = $2",
61
+ [user_id, name.to_s]
62
+ ).map { |r| parse_data(r["data"]) }
63
+ end
64
+
65
+ def record_outcome(user_id, skill_id, success:)
66
+ ensure_tables!
67
+ data = get_skill(user_id, skill_id)
68
+ return nil unless data
69
+ key = success ? :success_count : :failure_count
70
+ data[key] = (data[key] || 0).to_i + 1
71
+ data[:updated_at] = Time.now.utc.iso8601
72
+ conn.exec_params(
73
+ "UPDATE llmemory_skills SET data = $3::jsonb, search_text = $4 WHERE user_id = $1 AND id = $2",
74
+ [user_id, skill_id, JSON.generate(data), searchable_text(data)]
75
+ )
76
+ data
77
+ end
78
+
79
+ def count_skills(user_id)
80
+ ensure_tables!
81
+ conn.exec_params("SELECT COUNT(*) AS c FROM llmemory_skills WHERE user_id = $1", [user_id]).first["c"].to_i
82
+ end
83
+
84
+ def delete_skills(user_id, ids)
85
+ ensure_tables!
86
+ Array(ids).sum do |id|
87
+ conn.exec_params("DELETE FROM llmemory_skills WHERE user_id = $1 AND id = $2", [user_id, id]).cmd_tuples
88
+ end
89
+ end
90
+
91
+ def list_users
92
+ ensure_tables!
93
+ conn.exec("SELECT DISTINCT user_id FROM llmemory_skills").map { |r| r["user_id"] }
94
+ end
95
+
96
+ private
97
+
98
+ def conn
99
+ @connection ||= begin
100
+ require "pg"
101
+ PG.connect(@database_url)
102
+ end
103
+ end
104
+
105
+ def ensure_tables!
106
+ conn.exec(<<~SQL)
107
+ CREATE TABLE IF NOT EXISTS llmemory_skills (
108
+ id TEXT NOT NULL PRIMARY KEY,
109
+ user_id TEXT NOT NULL,
110
+ data JSONB NOT NULL DEFAULT '{}'::jsonb,
111
+ search_text TEXT,
112
+ created_at TIMESTAMPTZ NOT NULL
113
+ );
114
+ CREATE INDEX IF NOT EXISTS idx_llmemory_skills_user_id ON llmemory_skills(user_id);
115
+ SQL
116
+ end
117
+
118
+ def token_filter(column, query, start_index)
119
+ tokens = Llmemory::Tokenizer.tokenize(query)
120
+ return ["", []] if tokens.empty?
121
+ likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
122
+ [" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
123
+ end
124
+
125
+ def parse_data(value)
126
+ JSON.parse(value.to_s, symbolize_names: true)
127
+ rescue JSON::ParserError
128
+ {}
129
+ end
130
+
131
+ def symbolize(hash)
132
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
133
+ end
134
+
135
+ def searchable_text(data)
136
+ [data[:name], data[:description], data[:body]].compact.join("\n")
137
+ end
138
+
139
+ def created_at_value(data)
140
+ ca = data[:created_at]
141
+ return Time.now.utc.iso8601 if ca.nil?
142
+ ca.respond_to?(:iso8601) ? ca.iso8601 : ca.to_s
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -35,9 +35,8 @@ module Llmemory
35
35
  end
36
36
 
37
37
  def search_skills(user_id, query)
38
- q = query.to_s.downcase
39
- return list_skills(user_id) if q.strip.empty?
40
- all_skills(user_id).select { |s| skill_text(s).downcase.include?(q) }
38
+ return list_skills(user_id) if query.to_s.strip.empty?
39
+ all_skills(user_id).select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
41
40
  end
42
41
 
43
42
  def find_skills_by_name(user_id, name)
@@ -31,9 +31,8 @@ module Llmemory
31
31
  end
32
32
 
33
33
  def search_skills(user_id, query)
34
- q = query.to_s.downcase
35
- return list_skills(user_id) if q.strip.empty?
36
- @skills[user_id].select { |s| skill_text(s).downcase.include?(q) }
34
+ return list_skills(user_id) if query.to_s.strip.empty?
35
+ @skills[user_id].select { |s| Llmemory::Tokenizer.matches?(skill_text(s), query) }
37
36
  end
38
37
 
39
38
  def find_skills_by_name(user_id, name)
@@ -11,6 +11,12 @@ require_relative "tools/memory_consolidate"
11
11
  require_relative "tools/memory_stats"
12
12
  require_relative "tools/memory_info"
13
13
  require_relative "tools/memory_timeline_context"
14
+ require_relative "tools/memory_episode_record"
15
+ require_relative "tools/memory_episodes"
16
+ require_relative "tools/memory_skill_register"
17
+ require_relative "tools/memory_skill_report"
18
+ require_relative "tools/memory_skills"
19
+ require_relative "tools/memory_forget"
14
20
 
15
21
  module Llmemory
16
22
  module MCP
@@ -157,7 +163,13 @@ module Llmemory
157
163
  Tools::MemoryAddMessage,
158
164
  Tools::MemoryConsolidate,
159
165
  Tools::MemoryStats,
160
- Tools::MemoryInfo
166
+ Tools::MemoryInfo,
167
+ Tools::MemoryEpisodeRecord,
168
+ Tools::MemoryEpisodes,
169
+ Tools::MemorySkillRegister,
170
+ Tools::MemorySkillReport,
171
+ Tools::MemorySkills,
172
+ Tools::MemoryForget
161
173
  ]
162
174
  end
163
175
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module MCP
5
+ module Tools
6
+ class MemoryEpisodeRecord < ::MCP::Tool
7
+ description "Record an experience (episodic memory): a trajectory of steps with an optional summary, outcome and importance. Use to remember what just happened so it can be retrieved or distilled into knowledge later."
8
+
9
+ input_schema(
10
+ properties: {
11
+ user_id: { type: "string", description: "User identifier" },
12
+ steps: {
13
+ type: "array",
14
+ description: "Ordered list of steps (objects with observation/action/result)",
15
+ items: {
16
+ type: "object",
17
+ properties: {
18
+ observation: { type: "string" },
19
+ action: { type: "string" },
20
+ result: { type: "string" }
21
+ }
22
+ }
23
+ },
24
+ summary: { type: "string", description: "Optional summary (derived from steps if omitted)" },
25
+ outcome: { type: "string", description: "Outcome label, e.g. 'success', 'failure', 'recovered'" },
26
+ importance: { type: "number", description: "Importance 0-1 (default 0.5)" }
27
+ },
28
+ required: ["user_id", "steps"]
29
+ )
30
+
31
+ class << self
32
+ def call(user_id:, steps:, summary: nil, outcome: nil, importance: nil, server_context: nil)
33
+ memory = Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id)
34
+ id = memory.record_episode(
35
+ steps: Array(steps),
36
+ summary: summary,
37
+ outcome: outcome,
38
+ importance: importance.nil? ? 0.5 : importance.to_f
39
+ )
40
+ ::MCP::Tool::Response.new([{ type: "text", text: "Episode recorded: #{id}" }])
41
+ rescue => e
42
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error recording episode: #{e.message}" }], error: true)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module MCP
5
+ module Tools
6
+ class MemoryEpisodes < ::MCP::Tool
7
+ description "List recent episodes (episodic memory) for a user. Optionally filter by a keyword query."
8
+
9
+ input_schema(
10
+ properties: {
11
+ user_id: { type: "string", description: "User identifier" },
12
+ query: { type: "string", description: "Optional keyword filter" },
13
+ limit: { type: "integer", description: "Max episodes to return (default 10)" }
14
+ },
15
+ required: ["user_id"]
16
+ )
17
+
18
+ class << self
19
+ def call(user_id:, query: nil, limit: nil, server_context: nil)
20
+ memory = Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id)
21
+ cap = (limit || 10).to_i
22
+ episodes = if query.to_s.strip.empty?
23
+ memory.recent_episodes(limit: cap)
24
+ else
25
+ memory.search_candidates(query, top_k: cap).filter_map { |c| memory.find_episode(c[:id]) }
26
+ end
27
+
28
+ if episodes.empty?
29
+ return ::MCP::Tool::Response.new([{ type: "text", text: "No episodes for user #{user_id}." }])
30
+ end
31
+
32
+ lines = episodes.map do |ep|
33
+ "[#{ep.id}] (importance: #{ep.importance}; outcome: #{ep.outcome || 'n/a'}) #{ep.summary || ep.searchable_text[0, 120]}"
34
+ end
35
+ ::MCP::Tool::Response.new([{ type: "text", text: lines.join("\n") }])
36
+ rescue => e
37
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error listing episodes: #{e.message}" }], error: true)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module MCP
5
+ module Tools
6
+ class MemoryForget < ::MCP::Tool
7
+ description "Remove entries from a memory by their ids (the ids returned by retrieval / listing tools), recording the removal in the audit log. memory_type: file_based | graph_based | episodic | procedural."
8
+
9
+ input_schema(
10
+ properties: {
11
+ user_id: { type: "string", description: "User identifier" },
12
+ memory_type: { type: "string", description: "file_based | graph_based | episodic | procedural" },
13
+ ids: { type: "array", items: { type: "string" }, description: "Entry ids to forget" },
14
+ reason: { type: "string", description: "Optional reason (recorded in audit)" }
15
+ },
16
+ required: ["user_id", "memory_type", "ids"]
17
+ )
18
+
19
+ class << self
20
+ def call(user_id:, memory_type:, ids:, reason: nil, server_context: nil)
21
+ memory = build_memory(user_id, memory_type)
22
+ removed = memory.forget(ids: Array(ids), reason: reason)
23
+ ::MCP::Tool::Response.new([{
24
+ type: "text",
25
+ text: "Forgot #{removed} entries from #{memory_type} memory for user #{user_id}."
26
+ }])
27
+ rescue NotImplementedError => e
28
+ ::MCP::Tool::Response.new([{ type: "text", text: "Not supported: #{e.message}" }], error: true)
29
+ rescue => e
30
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error forgetting: #{e.message}" }], error: true)
31
+ end
32
+
33
+ private
34
+
35
+ def build_memory(user_id, memory_type)
36
+ case memory_type.to_s
37
+ when "file_based"
38
+ Llmemory::LongTerm::FileBased::Memory.new(user_id: user_id)
39
+ when "graph_based"
40
+ Llmemory::LongTerm::GraphBased::Memory.new(user_id: user_id)
41
+ when "episodic"
42
+ Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id)
43
+ when "procedural"
44
+ Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id)
45
+ else
46
+ raise ArgumentError, "Unknown memory_type: #{memory_type} (expected file_based|graph_based|episodic|procedural)"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -59,8 +59,10 @@ module Llmemory
59
59
  items = storage.search_items(user_id, query)
60
60
  return "" if items.empty?
61
61
 
62
- # Get timeline context around the first match
63
- top_item = items.first
62
+ # Anchor on the most precise match: keyword search is recall-oriented
63
+ # (tokenized OR), so prefer the item whose content contains the full
64
+ # query verbatim, falling back to the first match.
65
+ top_item = best_match(items, query)
64
66
  item_id = top_item[:id] || top_item["id"]
65
67
  return "" unless item_id
66
68
 
@@ -70,6 +72,12 @@ module Llmemory
70
72
  ""
71
73
  end
72
74
 
75
+ def best_match(items, query)
76
+ q = query.to_s.downcase.strip
77
+ return items.first if q.empty?
78
+ items.find { |i| (i[:content] || i["content"]).to_s.downcase.include?(q) } || items.first
79
+ end
80
+
73
81
  def build_storage
74
82
  if Llmemory.configuration.long_term_type.to_s == "graph_based"
75
83
  LongTerm::GraphBased::Storages.build
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module MCP
5
+ module Tools
6
+ class MemorySkillRegister < ::MCP::Tool
7
+ description "Register a reusable skill (procedural memory): a prompt, template or code snippet the agent can retrieve later. Re-registering the same name auto-increments the version."
8
+
9
+ input_schema(
10
+ properties: {
11
+ user_id: { type: "string", description: "User identifier" },
12
+ name: { type: "string", description: "Short identifier (skills with the same name get auto-versioned)" },
13
+ body: { type: "string", description: "The skill content (prompt / template / code)" },
14
+ description: { type: "string", description: "Optional human-readable description" },
15
+ kind: { type: "string", description: "prompt | template | code (default: prompt)" }
16
+ },
17
+ required: ["user_id", "name", "body"]
18
+ )
19
+
20
+ class << self
21
+ def call(user_id:, name:, body:, description: nil, kind: nil, server_context: nil)
22
+ memory = Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id)
23
+ id = memory.register_skill(
24
+ name: name, body: body, description: description,
25
+ kind: kind || Llmemory::LongTerm::Procedural::Skill::DEFAULT_KIND
26
+ )
27
+ ::MCP::Tool::Response.new([{ type: "text", text: "Skill registered: #{id} (#{name})" }])
28
+ rescue => e
29
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error registering skill: #{e.message}" }], error: true)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module MCP
5
+ module Tools
6
+ class MemorySkillReport < ::MCP::Tool
7
+ description "Report the outcome of applying a skill (success or failure). Feeds retrieval ranking: proven skills surface higher next time."
8
+
9
+ input_schema(
10
+ properties: {
11
+ user_id: { type: "string", description: "User identifier" },
12
+ skill_id: { type: "string", description: "Skill id (from MemorySkillRegister / MemorySkills)" },
13
+ success: { type: "boolean", description: "True if the skill worked; false otherwise" }
14
+ },
15
+ required: ["user_id", "skill_id", "success"]
16
+ )
17
+
18
+ class << self
19
+ def call(user_id:, skill_id:, success:, server_context: nil)
20
+ memory = Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id)
21
+ skill = memory.report_outcome(skill_id, success: success == true)
22
+ if skill.nil?
23
+ return ::MCP::Tool::Response.new([{ type: "text", text: "Skill not found: #{skill_id}" }], error: true)
24
+ end
25
+
26
+ text = "Outcome recorded for #{skill.name} (#{skill_id}): success #{skill.success_count} / failure #{skill.failure_count} (rate #{format('%.2f', skill.success_rate)})"
27
+ ::MCP::Tool::Response.new([{ type: "text", text: text }])
28
+ rescue => e
29
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error reporting outcome: #{e.message}" }], error: true)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module MCP
5
+ module Tools
6
+ class MemorySkills < ::MCP::Tool
7
+ description "List registered skills (procedural memory) for a user, ranked by proven utility when a query is given."
8
+
9
+ input_schema(
10
+ properties: {
11
+ user_id: { type: "string", description: "User identifier" },
12
+ query: { type: "string", description: "Optional keyword to filter skills" },
13
+ limit: { type: "integer", description: "Max skills to return (default 10)" }
14
+ },
15
+ required: ["user_id"]
16
+ )
17
+
18
+ class << self
19
+ def call(user_id:, query: nil, limit: nil, server_context: nil)
20
+ memory = Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id)
21
+ cap = (limit || 10).to_i
22
+ skills = if query.to_s.strip.empty?
23
+ memory.skills(limit: cap)
24
+ else
25
+ memory.search_candidates(query, top_k: cap).filter_map { |c| memory.get_skill(c[:id]) }
26
+ end
27
+
28
+ if skills.empty?
29
+ return ::MCP::Tool::Response.new([{ type: "text", text: "No skills for user #{user_id}." }])
30
+ end
31
+
32
+ lines = skills.map do |s|
33
+ "[#{s.id}] #{s.name} v#{s.version} (#{s.kind}) — success rate #{format('%.2f', s.success_rate)} (#{s.success_count}/#{s.success_count + s.failure_count})"
34
+ end
35
+ ::MCP::Tool::Response.new([{ type: "text", text: lines.join("\n") }])
36
+ rescue => e
37
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error listing skills: #{e.message}" }], error: true)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -10,11 +10,13 @@ module Llmemory
10
10
  DEFAULT_SESSION_ID = "default"
11
11
  STATE_KEY_MESSAGES = :messages
12
12
 
13
- def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, api_key: nil)
13
+ def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, episodic: nil, procedural: nil, api_key: nil)
14
14
  @user_id = user_id
15
15
  @session_id = session_id
16
16
  @checkpoint = checkpoint || ShortTerm::Checkpoint.new(user_id: user_id, session_id: session_id)
17
17
  @working_memory = working_memory
18
+ @episodic = episodic
19
+ @procedural = procedural
18
20
  @llm = api_key.to_s.empty? ? nil : Llmemory::LLM.client(api_key: api_key)
19
21
  type = long_term_type || Llmemory.configuration.long_term_type || :file_based
20
22
  @long_term = long_term || build_long_term(type)
@@ -22,12 +24,35 @@ module Llmemory
22
24
  end
23
25
 
24
26
  # Structured working memory for this session (CoALA working memory),
25
- # parallel to the message checkpoint. Lazily built so it costs nothing
26
- # unless an agent uses it.
27
+ # parallel to the message checkpoint. Lazily built.
27
28
  def working_memory
28
29
  @working_memory ||= WorkingMemory.new(user_id: @user_id, session_id: @session_id)
29
30
  end
30
31
 
32
+ # Episodic long-term memory (CoALA): records and retrieves agent trajectories.
33
+ # Additive — coexists with the semantic store (file/graph). Lazily built.
34
+ def episodic
35
+ @episodic ||= LongTerm::Episodic::Memory.new(user_id: @user_id)
36
+ end
37
+
38
+ # Procedural long-term memory (Voyager-style skill library). Lazily built.
39
+ def procedural
40
+ @procedural ||= LongTerm::Procedural::Memory.new(user_id: @user_id)
41
+ end
42
+
43
+ # Reflects over recent episodes and writes distilled insights to the
44
+ # semantic store (file/graph) with provenance back to source episodes.
45
+ def reflect!(window: 10, category: "insights")
46
+ Reflection::Reflector.new(episodic: episodic, semantic: @long_term, llm: @llm)
47
+ .reflect(window: window, category: category)
48
+ end
49
+
50
+ # Reasoning action: render a prompt from working memory, call the LLM, write
51
+ # the result back. Composable; does not touch long-term memory.
52
+ def reason(template:, into: Actions::Reason::DEFAULT_SLOT, parse: nil)
53
+ Actions::Reason.call(working_memory: working_memory, template: template, into: into, parse: parse, llm: @llm)
54
+ end
55
+
31
56
  def add_message(role:, content:)
32
57
  msgs = messages
33
58
  msgs << { role: role.to_sym, content: content.to_s }
@@ -53,7 +53,7 @@ module Llmemory
53
53
  private
54
54
 
55
55
  def tokenize(text)
56
- text.to_s.downcase.scan(/\b[a-z0-9]{2,}\b/)
56
+ Llmemory::Tokenizer.tokenize(text)
57
57
  end
58
58
  end
59
59
  end
@@ -49,7 +49,7 @@ module Llmemory
49
49
  end
50
50
 
51
51
  def tokenize(text)
52
- text.downcase.scan(/\b[a-z0-9]{2,}\b/).uniq
52
+ Llmemory::Tokenizer.tokenize(text).uniq
53
53
  end
54
54
  end
55
55
  end