llmemory 0.1.16 → 0.2.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +172 -0
  3. data/lib/llmemory/actions/reason.rb +49 -0
  4. data/lib/llmemory/actions.rb +8 -0
  5. data/lib/llmemory/configuration.rb +2 -0
  6. data/lib/llmemory/forget_log.rb +50 -0
  7. data/lib/llmemory/long_term/episodic/episode.rb +94 -0
  8. data/lib/llmemory/long_term/episodic/memory.rb +120 -0
  9. data/lib/llmemory/long_term/episodic/storage.rb +31 -0
  10. data/lib/llmemory/long_term/episodic/storages/base.rb +44 -0
  11. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +126 -0
  12. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +74 -0
  13. data/lib/llmemory/long_term/episodic.rb +12 -0
  14. data/lib/llmemory/long_term/file_based/memory.rb +46 -0
  15. data/lib/llmemory/long_term/graph_based/memory.rb +30 -3
  16. data/lib/llmemory/long_term/procedural/memory.rb +116 -0
  17. data/lib/llmemory/long_term/procedural/skill.rb +93 -0
  18. data/lib/llmemory/long_term/procedural/storage.rb +31 -0
  19. data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
  20. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +136 -0
  21. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +80 -0
  22. data/lib/llmemory/long_term/procedural.rb +12 -0
  23. data/lib/llmemory/long_term.rb +3 -0
  24. data/lib/llmemory/memory.rb +9 -1
  25. data/lib/llmemory/memory_module.rb +55 -0
  26. data/lib/llmemory/reflection/reflector.rb +116 -0
  27. data/lib/llmemory/reflection.rb +8 -0
  28. data/lib/llmemory/retrieval/engine.rb +115 -6
  29. data/lib/llmemory/retrieval/feedback_store.rb +50 -0
  30. data/lib/llmemory/short_term/checkpoint.rb +2 -14
  31. data/lib/llmemory/short_term/session_lifecycle.rb +3 -10
  32. data/lib/llmemory/short_term/stores.rb +27 -0
  33. data/lib/llmemory/version.rb +1 -1
  34. data/lib/llmemory/working_memory.rb +83 -0
  35. data/lib/llmemory.rb +5 -0
  36. metadata +24 -1
@@ -0,0 +1,126 @@
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 Episodic
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_episode(user_id, episode)
19
+ id = episode[:id] || episode["id"] || "ep_#{next_seq(user_id)}"
20
+ data = stringify_for_json(episode).merge("id" => id, "user_id" => user_id)
21
+ data["created_at"] ||= Time.now.iso8601
22
+ File.write(episode_path(user_id, id), JSON.generate(data))
23
+ id
24
+ end
25
+
26
+ def get_episode(user_id, id)
27
+ path = episode_path(user_id, id)
28
+ return nil unless File.file?(path)
29
+ load_episode(path)
30
+ end
31
+
32
+ def list_episodes(user_id, limit: nil)
33
+ sorted = all_episodes(user_id).sort_by { |e| e[:created_at] }.reverse
34
+ limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
35
+ end
36
+
37
+ def search_episodes(user_id, query)
38
+ q = query.to_s.downcase
39
+ return list_episodes(user_id) if q.strip.empty?
40
+ all_episodes(user_id).select { |e| episode_text(e).downcase.include?(q) }
41
+ end
42
+
43
+ def count_episodes(user_id)
44
+ dir = user_path(user_id, "episodes")
45
+ return 0 unless Dir.exist?(dir)
46
+ Dir.children(dir).count { |f| f.end_with?(".json") }
47
+ end
48
+
49
+ def delete_episodes(user_id, ids)
50
+ Array(ids).map(&:to_s).count do |id|
51
+ path = episode_path(user_id, id)
52
+ next false unless File.file?(path)
53
+ File.delete(path)
54
+ true
55
+ end
56
+ end
57
+
58
+ def list_users
59
+ return [] unless Dir.exist?(@base_path)
60
+ Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "episodes")) }
61
+ end
62
+
63
+ private
64
+
65
+ def all_episodes(user_id)
66
+ dir = user_path(user_id, "episodes")
67
+ return [] unless Dir.exist?(dir)
68
+ Dir.children(dir).select { |f| f.end_with?(".json") }.map { |f| load_episode(File.join(dir, f)) }.compact
69
+ end
70
+
71
+ def load_episode(path)
72
+ data = JSON.parse(File.read(path), symbolize_names: true)
73
+ data[:created_at] = parse_time(data[:created_at])
74
+ data
75
+ rescue JSON::ParserError
76
+ nil
77
+ end
78
+
79
+ def episode_text(episode)
80
+ parts = [episode[:summary], episode[:outcome]]
81
+ Array(episode[:steps]).each do |s|
82
+ next unless s.is_a?(Hash)
83
+ parts << s[:observation] << s[:action] << s[:result]
84
+ end
85
+ parts.compact.join("\n")
86
+ end
87
+
88
+ def stringify_for_json(episode)
89
+ JSON.parse(JSON.generate(episode))
90
+ end
91
+
92
+ def user_path(user_id, *parts)
93
+ safe = user_id.to_s.gsub(%r{[^\w\-.]}, "_")
94
+ File.join(@base_path, safe, *parts)
95
+ end
96
+
97
+ def episode_path(user_id, id)
98
+ dir = user_path(user_id, "episodes")
99
+ FileUtils.mkdir_p(dir)
100
+ File.join(dir, "#{id}.json")
101
+ end
102
+
103
+ def meta_path(user_id)
104
+ FileUtils.mkdir_p(user_path(user_id))
105
+ File.join(user_path(user_id), "meta.json")
106
+ end
107
+
108
+ def next_seq(user_id)
109
+ path = meta_path(user_id)
110
+ meta = File.file?(path) ? JSON.parse(File.read(path)) : {}
111
+ meta["episode_id_seq"] = (meta["episode_id_seq"] || 0) + 1
112
+ File.write(path, JSON.generate(meta))
113
+ meta["episode_id_seq"]
114
+ end
115
+
116
+ def parse_time(val)
117
+ return val if val.is_a?(Time)
118
+ Time.parse(val.to_s)
119
+ rescue ArgumentError
120
+ Time.now
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module LongTerm
7
+ module Episodic
8
+ module Storages
9
+ class MemoryStorage < Base
10
+ def initialize
11
+ @episodes = Hash.new { |h, k| h[k] = [] }
12
+ @seq = 0
13
+ end
14
+
15
+ def save_episode(user_id, episode)
16
+ @seq += 1
17
+ id = episode[:id] || episode["id"] || "ep_#{@seq}"
18
+ record = symbolize(episode).merge(id: id, user_id: user_id)
19
+ record[:created_at] ||= Time.now
20
+ @episodes[user_id] << record
21
+ id
22
+ end
23
+
24
+ def get_episode(user_id, id)
25
+ @episodes[user_id].find { |e| e[:id] == id }
26
+ end
27
+
28
+ def list_episodes(user_id, limit: nil)
29
+ sorted = @episodes[user_id].sort_by { |e| e[:created_at] }.reverse
30
+ limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
31
+ end
32
+
33
+ def search_episodes(user_id, query)
34
+ q = query.to_s.downcase
35
+ return list_episodes(user_id) if q.strip.empty?
36
+ @episodes[user_id].select { |e| episode_text(e).downcase.include?(q) }
37
+ end
38
+
39
+ def count_episodes(user_id)
40
+ @episodes[user_id].size
41
+ end
42
+
43
+ def delete_episodes(user_id, ids)
44
+ ids = Array(ids).map(&:to_s)
45
+ before = @episodes[user_id].size
46
+ @episodes[user_id].reject! { |e| ids.include?(e[:id].to_s) }
47
+ before - @episodes[user_id].size
48
+ end
49
+
50
+ def list_users
51
+ @episodes.keys
52
+ end
53
+
54
+ private
55
+
56
+ def symbolize(hash)
57
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
58
+ end
59
+
60
+ def episode_text(episode)
61
+ parts = [episode[:summary], episode[:outcome]]
62
+ Array(episode[:steps]).each do |s|
63
+ next unless s.is_a?(Hash)
64
+ parts << (s[:observation] || s["observation"])
65
+ parts << (s[:action] || s["action"])
66
+ parts << (s[:result] || s["result"])
67
+ end
68
+ parts.compact.join("\n")
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "episodic/episode"
4
+ require_relative "episodic/storage"
5
+ require_relative "episodic/memory"
6
+
7
+ module Llmemory
8
+ module LongTerm
9
+ module Episodic
10
+ end
11
+ end
12
+ end
@@ -5,11 +5,14 @@ require_relative "item"
5
5
  require_relative "category"
6
6
  require_relative "storage"
7
7
  require_relative "../../noise_filter"
8
+ require_relative "../../memory_module"
8
9
 
9
10
  module Llmemory
10
11
  module LongTerm
11
12
  module FileBased
12
13
  class Memory
14
+ include Llmemory::MemoryModule
15
+
13
16
  def initialize(user_id:, storage: nil, llm: nil, extractor: nil)
14
17
  @user_id = user_id
15
18
  @storage = storage || Storages.build
@@ -63,6 +66,7 @@ module Llmemory
63
66
 
64
67
  items.first(top_k).each do |i|
65
68
  out << {
69
+ id: i[:id] || i["id"],
66
70
  text: i[:content] || i["content"],
67
71
  timestamp: i[:created_at] || i["created_at"],
68
72
  score: 1.0,
@@ -72,6 +76,7 @@ module Llmemory
72
76
  end
73
77
  resources.first([top_k - out.size, 0].max).each do |r|
74
78
  out << {
79
+ id: r[:id] || r["id"],
75
80
  text: r[:text] || r["text"],
76
81
  timestamp: r[:created_at] || r["created_at"],
77
82
  score: 0.9
@@ -85,6 +90,47 @@ module Llmemory
85
90
  out
86
91
  end
87
92
 
93
+ # Stores a single fact produced outside the extraction flow (e.g. by
94
+ # reflection over episodes), preserving caller-supplied provenance so the
95
+ # insight remains traceable to its source. Returns the item id.
96
+ def remember_fact(content:, category: "general", importance: 0.6, provenance: nil)
97
+ return nil if content.to_s.strip.empty?
98
+ @storage.save_item(
99
+ @user_id,
100
+ category: category.to_s,
101
+ content: content.to_s,
102
+ source_resource_id: nil,
103
+ importance: importance,
104
+ provenance: provenance
105
+ )
106
+ end
107
+
108
+ # --- MemoryModule uniform interface ---
109
+
110
+ def write(payload, **_meta)
111
+ memorize(payload)
112
+ end
113
+
114
+ def list(user_id: nil, limit: nil)
115
+ @storage.list_items(user_id: user_id || @user_id, limit: limit)
116
+ end
117
+
118
+ def stats(user_id: nil)
119
+ { items: @storage.count_items(user_id: user_id || @user_id) }
120
+ end
121
+
122
+ # Removes items/resources by id and records the removal in the audit log.
123
+ def forget(ids:, reason: nil)
124
+ requested = Array(ids).map(&:to_s)
125
+ existing = (@storage.get_all_items(@user_id) + @storage.get_all_resources(@user_id))
126
+ .map { |r| (r[:id] || r["id"]).to_s }
127
+ removed = requested & existing
128
+ @storage.archive_items(@user_id, removed)
129
+ @storage.archive_resources(@user_id, removed)
130
+ forget_log.record(@user_id, memory_type: "file_based", ids: removed, reason: reason)
131
+ removed.size
132
+ end
133
+
88
134
  attr_reader :storage, :user_id
89
135
 
90
136
  private
@@ -6,11 +6,14 @@ require_relative "knowledge_graph"
6
6
  require_relative "conflict_resolver"
7
7
  require_relative "storage"
8
8
  require_relative "../../noise_filter"
9
+ require_relative "../../memory_module"
9
10
 
10
11
  module Llmemory
11
12
  module LongTerm
12
13
  module GraphBased
13
14
  class Memory
15
+ include Llmemory::MemoryModule
16
+
14
17
  def initialize(user_id:, storage: nil, vector_store: nil, llm: nil, extractor: nil)
15
18
  @user_id = user_id
16
19
  @graph_storage = storage || Storages.build
@@ -88,6 +91,7 @@ module Llmemory
88
91
  results = hybrid_search(query, top_k: top_k)
89
92
  results.map do |r|
90
93
  {
94
+ id: r[:id],
91
95
  text: r[:text],
92
96
  timestamp: r[:created_at] || r[:timestamp],
93
97
  score: r[:score] || 1.0,
@@ -102,6 +106,29 @@ module Llmemory
102
106
  @graph_storage
103
107
  end
104
108
 
109
+ # --- MemoryModule uniform interface ---
110
+
111
+ def write(payload, **_meta)
112
+ memorize(payload)
113
+ end
114
+
115
+ def list(user_id: nil, limit: nil)
116
+ @graph_storage.list_nodes(user_id || @user_id, limit: limit)
117
+ end
118
+
119
+ def stats(user_id: nil)
120
+ uid = user_id || @user_id
121
+ { nodes: @graph_storage.count_nodes(uid), edges: @graph_storage.count_edges(uid) }
122
+ end
123
+
124
+ # Forgetting a knowledge graph is not a simple delete-by-id: edges are
125
+ # soft-archived and nodes can be left orphaned. A dedicated graph
126
+ # edge/node lifecycle (with orphan handling) is a deliberate follow-up.
127
+ def forget(ids:, reason: nil)
128
+ raise NotImplementedError,
129
+ "Graph forget is not implemented yet; edge/node lifecycle (archival + orphan handling) is a follow-up."
130
+ end
131
+
105
132
  private
106
133
 
107
134
  def build_vector_store
@@ -127,7 +154,7 @@ module Llmemory
127
154
  out = vector_results.map do |v|
128
155
  id = v[:id] || v["id"]
129
156
  meta = v[:metadata] || v["metadata"] || {}
130
- { text: meta["text"] || meta[:text] || id.to_s, score: v[:score] || v["score"] || 1.0, created_at: meta["created_at"] || meta[:created_at] }
157
+ { id: id, text: meta["text"] || meta[:text] || id.to_s, score: v[:score] || v["score"] || 1.0, created_at: meta["created_at"] || meta[:created_at] }
131
158
  end
132
159
 
133
160
  node_ids = out.flat_map { |r| extract_node_ids_from_text(r[:text]) }.compact.uniq
@@ -139,7 +166,7 @@ module Llmemory
139
166
  subj = @kg.find_node_by_id(e.subject_id)
140
167
  obj = @kg.find_node_by_id(e.target_id)
141
168
  edge_text = "#{subj&.name} #{e.predicate} #{obj&.name}"
142
- out << { text: edge_text, score: 0.85, created_at: e.created_at } unless out.any? { |o| o[:text] == edge_text }
169
+ out << { id: (e.id ? "edge_#{e.id}" : nil), text: edge_text, score: 0.85, created_at: e.created_at } unless out.any? { |o| o[:text] == edge_text }
143
170
  end
144
171
  end
145
172
 
@@ -152,7 +179,7 @@ module Llmemory
152
179
  obj = @kg.find_node_by_id(e.target_id)
153
180
  next unless subj && obj
154
181
  edge_text = "#{subj.name} #{e.predicate} #{obj.name}"
155
- out << { text: edge_text, score: 0.7, created_at: e.created_at }
182
+ out << { id: (e.id ? "edge_#{e.id}" : nil), text: edge_text, score: 0.7, created_at: e.created_at }
156
183
  end
157
184
  end
158
185
 
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skill"
4
+ require_relative "storage"
5
+ require_relative "../../memory_module"
6
+
7
+ module Llmemory
8
+ module LongTerm
9
+ module Procedural
10
+ # Procedural long-term memory: a Voyager-style skill library. Agents
11
+ # register reusable skills (prompts, templates, code), retrieve them by
12
+ # relevance to the current task, and report outcomes so proven skills are
13
+ # preferred over unproven ones.
14
+ #
15
+ # Retrieval is keyword-based for now (vector search is a follow-up). The
16
+ # success rate of each skill is surfaced as `importance`, so the retrieval
17
+ # Engine ranks battle-tested skills higher (P3 importance weighting).
18
+ class Memory
19
+ include Llmemory::MemoryModule
20
+
21
+ attr_reader :user_id, :storage
22
+
23
+ def initialize(user_id:, storage: nil)
24
+ @user_id = user_id
25
+ @storage = storage || Storages.build
26
+ end
27
+
28
+ # Registers a skill. If `version` is omitted and a skill with the same
29
+ # name exists, the version auto-increments (skill evolution).
30
+ def register_skill(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil)
31
+ version ||= next_version_for(name)
32
+ skill = Skill.new(
33
+ id: nil, user_id: @user_id, name: name, body: body,
34
+ description: description, kind: kind, version: version
35
+ )
36
+ @storage.save_skill(@user_id, skill.to_h)
37
+ end
38
+
39
+ def find_skill(query)
40
+ raw = @storage.search_skills(@user_id, query).first
41
+ raw && Skill.from_h(raw)
42
+ end
43
+
44
+ def get_skill(id)
45
+ raw = @storage.get_skill(@user_id, id)
46
+ raw && Skill.from_h(raw)
47
+ end
48
+
49
+ def skills(limit: nil)
50
+ @storage.list_skills(@user_id, limit: limit).map { |s| Skill.from_h(s) }
51
+ end
52
+
53
+ def count
54
+ @storage.count_skills(@user_id)
55
+ end
56
+
57
+ # Records that applying a skill succeeded or failed. Feeds retrieval
58
+ # ranking and adaptive retrieval (P8). Returns the updated Skill.
59
+ def report_outcome(skill_id, success:)
60
+ raw = @storage.record_outcome(@user_id, skill_id, success: success)
61
+ raw && Skill.from_h(raw)
62
+ end
63
+
64
+ # Retrieval Engine integration: skills ranked by relevance, recency and
65
+ # proven utility (success rate exposed as importance).
66
+ def search_candidates(query, user_id: nil, top_k: 20)
67
+ uid = user_id || @user_id
68
+ return [] unless uid == @user_id
69
+
70
+ @storage.search_skills(uid, query).first(top_k).map do |raw|
71
+ skill = Skill.from_h(raw)
72
+ {
73
+ id: skill.id,
74
+ text: skill.searchable_text,
75
+ timestamp: skill.created_at,
76
+ score: 1.0,
77
+ importance: skill.success_rate,
78
+ evergreen: false
79
+ }
80
+ end
81
+ end
82
+
83
+ # --- MemoryModule uniform interface ---
84
+
85
+ def write(name:, body:, description: nil, kind: Skill::DEFAULT_KIND, version: nil, **_meta)
86
+ register_skill(name: name, body: body, description: description, kind: kind, version: version)
87
+ end
88
+
89
+ def list(user_id: nil, limit: nil)
90
+ skills(limit: limit)
91
+ end
92
+
93
+ def stats(user_id: nil)
94
+ { skills: count }
95
+ end
96
+
97
+ def forget(ids:, reason: nil)
98
+ requested = Array(ids).map(&:to_s)
99
+ existing = @storage.list_skills(@user_id).map { |s| (s[:id] || s["id"]).to_s }
100
+ removed = requested & existing
101
+ @storage.delete_skills(@user_id, removed)
102
+ forget_log.record(@user_id, memory_type: "procedural", ids: removed, reason: reason)
103
+ removed.size
104
+ end
105
+
106
+ private
107
+
108
+ def next_version_for(name)
109
+ existing = @storage.find_skills_by_name(@user_id, name)
110
+ return 1 if existing.empty?
111
+ existing.map { |s| (s[:version] || s["version"] || 1).to_i }.max + 1
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Llmemory
6
+ module LongTerm
7
+ module Procedural
8
+ # A Skill is a reusable procedure an agent can retrieve and apply: a prompt,
9
+ # a template or a snippet of code. This is CoALA's "procedural memory" in the
10
+ # Voyager sense — a growing library of skills the agent learns and reuses.
11
+ #
12
+ # Skills track success/failure outcomes so proven skills can be preferred
13
+ # over unproven ones during retrieval (see #success_rate, and P8 adaptive
14
+ # retrieval).
15
+ class Skill
16
+ KINDS = %w[prompt template code].freeze
17
+ DEFAULT_KIND = "prompt"
18
+
19
+ attr_reader :id, :user_id, :name, :description, :body, :kind, :version,
20
+ :success_count, :failure_count, :created_at, :updated_at
21
+
22
+ def initialize(id:, user_id:, name:, body:, description: nil, kind: DEFAULT_KIND,
23
+ version: 1, success_count: 0, failure_count: 0, created_at: nil, updated_at: nil)
24
+ @id = id
25
+ @user_id = user_id
26
+ @name = name.to_s
27
+ @description = description
28
+ @body = body
29
+ @kind = normalize_kind(kind)
30
+ @version = version.to_i
31
+ @success_count = success_count.to_i
32
+ @failure_count = failure_count.to_i
33
+ @created_at = created_at || Time.now
34
+ @updated_at = updated_at || @created_at
35
+ end
36
+
37
+ # Proven utility in [0, 1]. Unproven skills (no outcomes) are neutral.
38
+ def success_rate
39
+ total = success_count + failure_count
40
+ total.zero? ? 0.5 : success_count.to_f / total
41
+ end
42
+
43
+ def searchable_text
44
+ [name, description, body].compact.map(&:to_s).reject(&:empty?).join("\n")
45
+ end
46
+
47
+ def normalize_kind(kind)
48
+ k = kind.to_s.strip.downcase
49
+ KINDS.include?(k) ? k : DEFAULT_KIND
50
+ end
51
+
52
+ def self.from_h(hash)
53
+ new(
54
+ id: hash[:id] || hash["id"],
55
+ user_id: hash[:user_id] || hash["user_id"],
56
+ name: hash[:name] || hash["name"],
57
+ description: hash[:description] || hash["description"],
58
+ body: hash[:body] || hash["body"],
59
+ kind: hash[:kind] || hash["kind"] || DEFAULT_KIND,
60
+ version: hash[:version] || hash["version"] || 1,
61
+ success_count: hash[:success_count] || hash["success_count"] || 0,
62
+ failure_count: hash[:failure_count] || hash["failure_count"] || 0,
63
+ created_at: parse_time(hash[:created_at] || hash["created_at"]),
64
+ updated_at: parse_time(hash[:updated_at] || hash["updated_at"])
65
+ )
66
+ end
67
+
68
+ def self.parse_time(value)
69
+ return value if value.nil? || value.is_a?(Time)
70
+ Time.parse(value.to_s)
71
+ rescue ArgumentError
72
+ nil
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ id: id,
78
+ user_id: user_id,
79
+ name: name,
80
+ description: description,
81
+ body: body,
82
+ kind: kind,
83
+ version: version,
84
+ success_count: success_count,
85
+ failure_count: failure_count,
86
+ created_at: created_at.respond_to?(:iso8601) ? created_at.iso8601(6) : created_at,
87
+ updated_at: updated_at.respond_to?(:iso8601) ? updated_at.iso8601(6) : updated_at
88
+ }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storages/base"
4
+ require_relative "storages/memory_storage"
5
+ require_relative "storages/file_storage"
6
+
7
+ module Llmemory
8
+ module LongTerm
9
+ module Procedural
10
+ # Backward compatibility: Storage points to the in-memory backend.
11
+ Storage = Storages::MemoryStorage
12
+
13
+ module Storages
14
+ def self.build(store: nil, base_path: nil)
15
+ case (store || Llmemory.configuration.long_term_store).to_s.to_sym
16
+ when :memory
17
+ MemoryStorage.new
18
+ when :file
19
+ FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
20
+ when :postgres, :database, :active_record, :activerecord
21
+ raise NotImplementedError,
22
+ "Procedural SQL/ActiveRecord storage is not implemented yet; use :memory or :file " \
23
+ "(or pass an explicit storage instance)."
24
+ else
25
+ MemoryStorage.new
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module LongTerm
5
+ module Procedural
6
+ module Storages
7
+ # Storage contract for procedural memory (skill library). Implementations
8
+ # persist Skill hashes, support keyword search and name lookup (for
9
+ # versioning), and record success/failure outcomes.
10
+ class Base
11
+ def save_skill(user_id, skill)
12
+ raise NotImplementedError, "#{self.class}#save_skill must be implemented"
13
+ end
14
+
15
+ def get_skill(user_id, id)
16
+ raise NotImplementedError, "#{self.class}#get_skill must be implemented"
17
+ end
18
+
19
+ def list_skills(user_id, limit: nil)
20
+ raise NotImplementedError, "#{self.class}#list_skills must be implemented"
21
+ end
22
+
23
+ def search_skills(user_id, query)
24
+ raise NotImplementedError, "#{self.class}#search_skills must be implemented"
25
+ end
26
+
27
+ def find_skills_by_name(user_id, name)
28
+ raise NotImplementedError, "#{self.class}#find_skills_by_name must be implemented"
29
+ end
30
+
31
+ # Increments the success or failure count of a skill and returns the
32
+ # updated skill hash (or nil if not found).
33
+ def record_outcome(user_id, skill_id, success:)
34
+ raise NotImplementedError, "#{self.class}#record_outcome must be implemented"
35
+ end
36
+
37
+ def count_skills(user_id)
38
+ raise NotImplementedError, "#{self.class}#count_skills must be implemented"
39
+ end
40
+
41
+ # Deletes skills by id. Returns the number actually removed.
42
+ def delete_skills(user_id, ids)
43
+ raise NotImplementedError, "#{self.class}#delete_skills must be implemented"
44
+ end
45
+
46
+ def list_users
47
+ raise NotImplementedError, "#{self.class}#list_users must be implemented"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end