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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +178 -1
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +20 -0
  4. data/lib/llmemory/actions/reason.rb +49 -0
  5. data/lib/llmemory/actions.rb +8 -0
  6. data/lib/llmemory/cli/commands/base.rb +8 -0
  7. data/lib/llmemory/cli/commands/episodic.rb +42 -0
  8. data/lib/llmemory/cli/commands/forget_log.rb +36 -0
  9. data/lib/llmemory/cli/commands/procedural.rb +44 -0
  10. data/lib/llmemory/cli/commands/working.rb +31 -0
  11. data/lib/llmemory/cli.rb +12 -0
  12. data/lib/llmemory/configuration.rb +6 -0
  13. data/lib/llmemory/forget_log.rb +50 -0
  14. data/lib/llmemory/long_term/episodic/memory.rb +97 -15
  15. data/lib/llmemory/long_term/episodic/storage.rb +7 -5
  16. data/lib/llmemory/long_term/episodic/storages/active_record_models.rb +17 -0
  17. data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +93 -0
  18. data/lib/llmemory/long_term/episodic/storages/base.rb +5 -0
  19. data/lib/llmemory/long_term/episodic/storages/database_storage.rb +135 -0
  20. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +11 -3
  21. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +9 -3
  22. data/lib/llmemory/long_term/file_based/memory.rb +31 -0
  23. data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +11 -4
  24. data/lib/llmemory/long_term/file_based/storages/database_storage.rb +16 -6
  25. data/lib/llmemory/long_term/file_based/storages/file_storage.rb +2 -4
  26. data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -4
  27. data/lib/llmemory/long_term/graph_based/memory.rb +95 -51
  28. data/lib/llmemory/long_term/procedural/memory.rb +170 -0
  29. data/lib/llmemory/long_term/procedural/skill.rb +93 -0
  30. data/lib/llmemory/long_term/procedural/storage.rb +33 -0
  31. data/lib/llmemory/long_term/procedural/storages/active_record_models.rb +17 -0
  32. data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +104 -0
  33. data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
  34. data/lib/llmemory/long_term/procedural/storages/database_storage.rb +148 -0
  35. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +135 -0
  36. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +79 -0
  37. data/lib/llmemory/long_term/procedural.rb +12 -0
  38. data/lib/llmemory/long_term.rb +2 -0
  39. data/lib/llmemory/mcp/server.rb +13 -1
  40. data/lib/llmemory/mcp/tools/memory_episode_record.rb +48 -0
  41. data/lib/llmemory/mcp/tools/memory_episodes.rb +43 -0
  42. data/lib/llmemory/mcp/tools/memory_forget.rb +53 -0
  43. data/lib/llmemory/mcp/tools/memory_retrieve.rb +10 -2
  44. data/lib/llmemory/mcp/tools/memory_skill_register.rb +35 -0
  45. data/lib/llmemory/mcp/tools/memory_skill_report.rb +35 -0
  46. data/lib/llmemory/mcp/tools/memory_skills.rb +43 -0
  47. data/lib/llmemory/memory.rb +34 -1
  48. data/lib/llmemory/memory_module.rb +55 -0
  49. data/lib/llmemory/retrieval/bm25_scorer.rb +1 -1
  50. data/lib/llmemory/retrieval/engine.rb +115 -6
  51. data/lib/llmemory/retrieval/feedback_store.rb +50 -0
  52. data/lib/llmemory/retrieval/mmr_reranker.rb +1 -1
  53. data/lib/llmemory/short_term/checkpoint.rb +2 -14
  54. data/lib/llmemory/short_term/session_lifecycle.rb +22 -13
  55. data/lib/llmemory/short_term/stores.rb +27 -0
  56. data/lib/llmemory/tokenizer.rb +27 -0
  57. data/lib/llmemory/vector_store/active_record_store.rb +4 -3
  58. data/lib/llmemory/vector_store.rb +14 -0
  59. data/lib/llmemory/version.rb +1 -1
  60. data/lib/llmemory/working_memory.rb +83 -0
  61. data/lib/llmemory.rb +5 -0
  62. metadata +32 -1
@@ -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
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "base"
7
+
8
+ module Llmemory
9
+ module LongTerm
10
+ module Procedural
11
+ module Storages
12
+ class FileStorage < Base
13
+ def initialize(base_path: nil)
14
+ @base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
15
+ @base_path = File.expand_path(@base_path)
16
+ end
17
+
18
+ def save_skill(user_id, skill)
19
+ id = skill[:id] || skill["id"] || "skill_#{next_seq(user_id)}"
20
+ data = stringify_for_json(skill).merge("id" => id, "user_id" => user_id)
21
+ data["created_at"] ||= Time.now.iso8601(6)
22
+ File.write(skill_path(user_id, id), JSON.generate(data))
23
+ id
24
+ end
25
+
26
+ def get_skill(user_id, id)
27
+ path = skill_path(user_id, id)
28
+ return nil unless File.file?(path)
29
+ load_skill(path)
30
+ end
31
+
32
+ def list_skills(user_id, limit: nil)
33
+ sorted = all_skills(user_id).sort_by { |s| s[:created_at] }.reverse
34
+ limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
35
+ end
36
+
37
+ def search_skills(user_id, query)
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) }
40
+ end
41
+
42
+ def find_skills_by_name(user_id, name)
43
+ all_skills(user_id).select { |s| s[:name].to_s == name.to_s }
44
+ end
45
+
46
+ def record_outcome(user_id, skill_id, success:)
47
+ skill = get_skill(user_id, skill_id)
48
+ return nil unless skill
49
+ key = success ? :success_count : :failure_count
50
+ skill[key] = (skill[key] || 0).to_i + 1
51
+ skill[:updated_at] = Time.now.iso8601(6)
52
+ File.write(skill_path(user_id, skill_id), JSON.generate(stringify_for_json(skill)))
53
+ skill
54
+ end
55
+
56
+ def count_skills(user_id)
57
+ dir = user_path(user_id, "skills")
58
+ return 0 unless Dir.exist?(dir)
59
+ Dir.children(dir).count { |f| f.end_with?(".json") }
60
+ end
61
+
62
+ def delete_skills(user_id, ids)
63
+ Array(ids).map(&:to_s).count do |id|
64
+ path = skill_path(user_id, id)
65
+ next false unless File.file?(path)
66
+ File.delete(path)
67
+ true
68
+ end
69
+ end
70
+
71
+ def list_users
72
+ return [] unless Dir.exist?(@base_path)
73
+ Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
74
+ end
75
+
76
+ private
77
+
78
+ def all_skills(user_id)
79
+ dir = user_path(user_id, "skills")
80
+ return [] unless Dir.exist?(dir)
81
+ Dir.children(dir).select { |f| f.end_with?(".json") }.map { |f| load_skill(File.join(dir, f)) }.compact
82
+ end
83
+
84
+ def load_skill(path)
85
+ data = JSON.parse(File.read(path), symbolize_names: true)
86
+ data[:created_at] = parse_time(data[:created_at])
87
+ data[:updated_at] = parse_time(data[:updated_at]) if data[:updated_at]
88
+ data
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+
93
+ def skill_text(skill)
94
+ [skill[:name], skill[:description], skill[:body]].compact.join("\n")
95
+ end
96
+
97
+ def stringify_for_json(skill)
98
+ JSON.parse(JSON.generate(skill))
99
+ end
100
+
101
+ def user_path(user_id, *parts)
102
+ safe = user_id.to_s.gsub(%r{[^\w\-.]}, "_")
103
+ File.join(@base_path, safe, *parts)
104
+ end
105
+
106
+ def skill_path(user_id, id)
107
+ dir = user_path(user_id, "skills")
108
+ FileUtils.mkdir_p(dir)
109
+ File.join(dir, "#{id}.json")
110
+ end
111
+
112
+ def meta_path(user_id)
113
+ FileUtils.mkdir_p(user_path(user_id))
114
+ File.join(user_path(user_id), "meta.json")
115
+ end
116
+
117
+ def next_seq(user_id)
118
+ path = meta_path(user_id)
119
+ meta = File.file?(path) ? JSON.parse(File.read(path)) : {}
120
+ meta["skill_id_seq"] = (meta["skill_id_seq"] || 0) + 1
121
+ File.write(path, JSON.generate(meta))
122
+ meta["skill_id_seq"]
123
+ end
124
+
125
+ def parse_time(val)
126
+ return val if val.is_a?(Time)
127
+ Time.parse(val.to_s)
128
+ rescue ArgumentError
129
+ Time.now
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module LongTerm
7
+ module Procedural
8
+ module Storages
9
+ class MemoryStorage < Base
10
+ def initialize
11
+ @skills = Hash.new { |h, k| h[k] = [] }
12
+ @seq = 0
13
+ end
14
+
15
+ def save_skill(user_id, skill)
16
+ @seq += 1
17
+ id = skill[:id] || skill["id"] || "skill_#{@seq}"
18
+ record = symbolize(skill).merge(id: id, user_id: user_id)
19
+ record[:created_at] ||= Time.now
20
+ @skills[user_id] << record
21
+ id
22
+ end
23
+
24
+ def get_skill(user_id, id)
25
+ @skills[user_id].find { |s| s[:id] == id }
26
+ end
27
+
28
+ def list_skills(user_id, limit: nil)
29
+ sorted = @skills[user_id].sort_by { |s| s[:created_at] }.reverse
30
+ limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
31
+ end
32
+
33
+ def search_skills(user_id, query)
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) }
36
+ end
37
+
38
+ def find_skills_by_name(user_id, name)
39
+ @skills[user_id].select { |s| s[:name].to_s == name.to_s }
40
+ end
41
+
42
+ def record_outcome(user_id, skill_id, success:)
43
+ skill = get_skill(user_id, skill_id)
44
+ return nil unless skill
45
+ key = success ? :success_count : :failure_count
46
+ skill[key] = (skill[key] || 0).to_i + 1
47
+ skill[:updated_at] = Time.now
48
+ skill
49
+ end
50
+
51
+ def count_skills(user_id)
52
+ @skills[user_id].size
53
+ end
54
+
55
+ def delete_skills(user_id, ids)
56
+ ids = Array(ids).map(&:to_s)
57
+ before = @skills[user_id].size
58
+ @skills[user_id].reject! { |s| ids.include?(s[:id].to_s) }
59
+ before - @skills[user_id].size
60
+ end
61
+
62
+ def list_users
63
+ @skills.keys
64
+ end
65
+
66
+ private
67
+
68
+ def symbolize(hash)
69
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
70
+ end
71
+
72
+ def skill_text(skill)
73
+ [skill[:name], skill[:description], skill[:body]].compact.join("\n")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "procedural/skill"
4
+ require_relative "procedural/storage"
5
+ require_relative "procedural/memory"
6
+
7
+ module Llmemory
8
+ module LongTerm
9
+ module Procedural
10
+ end
11
+ end
12
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "memory_module"
3
4
  require_relative "long_term/file_based"
4
5
  require_relative "long_term/graph_based"
5
6
  require_relative "long_term/episodic"
7
+ require_relative "long_term/procedural"
6
8
 
7
9
  module Llmemory
8
10
  module LongTerm
@@ -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