llmemory 0.1.17 → 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 (31) 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/memory.rb +27 -0
  8. data/lib/llmemory/long_term/episodic/storages/base.rb +5 -0
  9. data/lib/llmemory/long_term/episodic/storages/file_storage.rb +9 -0
  10. data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +7 -0
  11. data/lib/llmemory/long_term/file_based/memory.rb +31 -0
  12. data/lib/llmemory/long_term/graph_based/memory.rb +30 -3
  13. data/lib/llmemory/long_term/procedural/memory.rb +116 -0
  14. data/lib/llmemory/long_term/procedural/skill.rb +93 -0
  15. data/lib/llmemory/long_term/procedural/storage.rb +31 -0
  16. data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
  17. data/lib/llmemory/long_term/procedural/storages/file_storage.rb +136 -0
  18. data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +80 -0
  19. data/lib/llmemory/long_term/procedural.rb +12 -0
  20. data/lib/llmemory/long_term.rb +2 -0
  21. data/lib/llmemory/memory.rb +9 -1
  22. data/lib/llmemory/memory_module.rb +55 -0
  23. data/lib/llmemory/retrieval/engine.rb +115 -6
  24. data/lib/llmemory/retrieval/feedback_store.rb +50 -0
  25. data/lib/llmemory/short_term/checkpoint.rb +2 -14
  26. data/lib/llmemory/short_term/session_lifecycle.rb +3 -10
  27. data/lib/llmemory/short_term/stores.rb +27 -0
  28. data/lib/llmemory/version.rb +1 -1
  29. data/lib/llmemory/working_memory.rb +83 -0
  30. data/lib/llmemory.rb +4 -0
  31. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b86647810e47140fff5732a066da4a16188704249d09db14720d8c565b6eaf0e
4
- data.tar.gz: 7c52c551746d22c29e41015098a68e166788bb614b0e0c2b064fa5fc0824989f
3
+ metadata.gz: c302656888e6373faedb5732525f76d118fd98fb67ece22278d886faec5ba3cd
4
+ data.tar.gz: ec0897226ec378e51e86e01cb66e938fbeea3db7e4c49911d7e3d08a08959d07
5
5
  SHA512:
6
- metadata.gz: f584d487eca13280b13f7a3e9f4b8eda60ed10ec8dbb45ff72483acbc76b7feb7f79fb5ae572640a31e87ade05b0762f07cacbecb778dffa6d62a7096231fb12
7
- data.tar.gz: c68e284c21fc22ccdf20160d9082575a47482b03c5a91fb3b52ec5a6c51b7f5cf13a8915bfd3fe2f5933aa6a52fe8c382e2373393b27d68d8ed1c7ac83766e49
6
+ metadata.gz: 6cee9a244e42f198b9fd491d164d85d2524a30d6cf9ca0b4a4da3f1a012f7e2b4643ba1e4dd6838ba7ce32517d5425b77f62df1cb1334ac34d162a273cd6400f
7
+ data.tar.gz: 6d46031eda16a52b7c8b42b364dc5351a0eb18f0eeaa135271a3976a0ebba39db439e74fafa981b9cea062d70e2bd006b4576ccaccfff29bfd6ec6fe8011a411
data/README.md CHANGED
@@ -70,6 +70,10 @@ Llmemory.configure do |config|
70
70
  config.prune_after_days = 90
71
71
  config.compact_max_bytes = 8192 # max bytes before compact! triggers
72
72
 
73
+ # Retrieval ranking signals (see "Cognitive Memory (CoALA)")
74
+ config.importance_weight = 1.0 # how strongly importance multiplies the score (0 = ignore)
75
+ config.retrieval_feedback_weight = 0.5 # how strongly useful/harmful feedback shifts ranking (0 = ignore)
76
+
73
77
  # Pre-compaction memory flush (prevents knowledge loss when compacting)
74
78
  config.memory_flush_enabled = true
75
79
  config.memory_flush_threshold_tokens = 4000
@@ -195,6 +199,174 @@ candidates = memory.search_candidates("job", top_k: 20)
195
199
 
196
200
  **Graph storage:** `:memory` (in-memory) or `:active_record` (Rails). For ActiveRecord, run `rails g llmemory:install` and migrate; the migration creates `llmemory_nodes`, `llmemory_edges`, and `llmemory_embeddings` (pgvector). Enable the `vector` extension in PostgreSQL for embeddings.
197
201
 
202
+ ## Cognitive Memory (CoALA)
203
+
204
+ llmemory implements the memory and internal-action concepts from [CoALA — Cognitive Architectures for Language Agents](https://arxiv.org/abs/2309.02427) (Sumers et al., 2024), so a framework can build agents with episodic/semantic/procedural memory, structured working memory, and reasoning/retrieval/learning actions.
205
+
206
+ | CoALA concept | llmemory |
207
+ |---|---|
208
+ | Working memory | `Llmemory::WorkingMemory` |
209
+ | Episodic memory | `Llmemory::LongTerm::Episodic::Memory` |
210
+ | Semantic memory | `FileBased::Memory` / `GraphBased::Memory` |
211
+ | Procedural memory | `Llmemory::LongTerm::Procedural::Memory` |
212
+ | Reasoning action | `Llmemory::Actions::Reason` |
213
+ | Retrieval action | `Retrieval::Engine` (+ feedback, iterative) |
214
+ | Learning action | `memorize` / `record_episode` / `register_skill` / reflection |
215
+ | Uniform interface | `Llmemory::MemoryModule` (`read`/`write`/`list`/`stats`/`forget`) |
216
+
217
+ All three long-term memories below are **additive** — episodic and procedural coexist with semantic memory rather than replacing it. Episodic/procedural ship with `:memory` and `:file` backends (SQL/ActiveRecord and vector search are roadmap items); retrieval there is keyword-based.
218
+
219
+ ### Working memory (structured, persists across LLM calls)
220
+
221
+ A symbolic scratch space for the current session, distinct from the raw message buffer. Backed by the same pluggable short-term stores, under a namespaced session key so it never collides with messages.
222
+
223
+ ```ruby
224
+ wm = Llmemory::WorkingMemory.new(user_id: "u1", session_id: "s1")
225
+ # or, from the unified API: memory.working_memory
226
+
227
+ wm.goals = ["plan a trip to Lisbon"]
228
+ wm.current_task = "find flights"
229
+ wm.set(:budget, 1000) # arbitrary custom slot
230
+
231
+ wm.goals # => ["plan a trip to Lisbon"]
232
+ wm.custom_slots # => { budget: 1000 }
233
+ wm.update(last_observation: "no direct flights", scratchpad: "try connections")
234
+ wm.to_h # full state; wm.clear! to reset
235
+ ```
236
+
237
+ Predefined slots: `goals`, `current_task`, `retrieved_context`, `scratchpad`, `last_observation`, `intermediate_reasoning`.
238
+
239
+ ### Reasoning action
240
+
241
+ Read working memory, call the LLM, write the result back — CoALA's reasoning action. Composable (reason → retrieve → reason); it does not touch long-term memory.
242
+
243
+ ```ruby
244
+ Llmemory::Actions::Reason.call(
245
+ working_memory: wm,
246
+ template: "Goal: {{goals}}. Observation: {{last_observation}}. What is the next step?",
247
+ into: :intermediate_reasoning # slot to write to (nil to not write)
248
+ )
249
+ wm.intermediate_reasoning # => the LLM's answer
250
+
251
+ # A callable template gets the working memory; `parse` transforms the output before storing:
252
+ Llmemory::Actions::Reason.call(
253
+ working_memory: wm,
254
+ template: ->(w) { "List 3 options for #{w.current_task}" },
255
+ parse: ->(out) { out.split("\n") },
256
+ into: :scratchpad
257
+ )
258
+ ```
259
+
260
+ ### Episodic memory (trajectories of experience)
261
+
262
+ Records what happened — ordered steps `(observation → action → result)` plus a summary, outcome and importance — so experiences can be retrieved as examples or distilled into knowledge by reflection.
263
+
264
+ ```ruby
265
+ episodic = Llmemory::LongTerm::Episodic::Memory.new(user_id: "u1")
266
+
267
+ id = episodic.record_episode(
268
+ steps: [{ observation: "deploy failed", action: "rolled back", result: "service restored" }],
269
+ outcome: "recovered",
270
+ importance: 0.8
271
+ )
272
+
273
+ episodic.recent_episodes(limit: 5) # newest first
274
+ episodic.search_candidates("rolled back") # retrieval-compatible candidates
275
+ ```
276
+
277
+ ### Reflection (episodic → semantic)
278
+
279
+ Distills durable, higher-order insights from recent episodes and writes them to semantic memory with provenance back to the source episodes (the Reflexion / Generative Agents pattern).
280
+
281
+ ```ruby
282
+ semantic = Llmemory::LongTerm::FileBased::Memory.new(user_id: "u1")
283
+ reflector = Llmemory::Reflection::Reflector.new(episodic: episodic, semantic: semantic)
284
+
285
+ reflector.reflect(window: 10) # reads recent episodes -> LLM -> writes insights
286
+ # Each insight is stored with provenance { method: "reflection", sources: [{ type: "episode", id: ... }] }
287
+ ```
288
+
289
+ `semantic` must respond to `remember_fact(content:, category:, importance:, provenance:)` (file-based does; graph-based is a roadmap target).
290
+
291
+ ### Procedural memory (skill library)
292
+
293
+ A Voyager-style library of reusable skills (prompts, templates, code). Skills track success/failure, and their success rate is surfaced as `importance` so proven skills rank higher in retrieval.
294
+
295
+ ```ruby
296
+ skills = Llmemory::LongTerm::Procedural::Memory.new(user_id: "u1")
297
+
298
+ id = skills.register_skill(
299
+ name: "rollback", description: "revert a bad deploy",
300
+ body: "kubectl rollout undo deployment/$1", kind: "code" # kind: prompt | template | code
301
+ )
302
+ skills.register_skill(name: "rollback", body: "...newer...") # same name -> version auto-increments
303
+
304
+ skills.find_skill("revert deploy") # best match (a Skill)
305
+ skills.report_outcome(id, success: true) # feeds ranking + adaptive retrieval
306
+ ```
307
+
308
+ ### Uniform interface (MemoryModule)
309
+
310
+ The queryable long-term memories (file, graph, episodic, procedural) share one agent-facing contract, so a framework can treat them polymorphically:
311
+
312
+ ```ruby
313
+ memory.read(query, limit: 10) # retrieve relevant entries (delegates to search_candidates)
314
+ memory.write(...) # ingest (memorize / record_episode / register_skill)
315
+ memory.list(limit: 50) # enumerate stored entries
316
+ memory.stats # counts, e.g. { items: 12 } / { episodes: 4 } / { skills: 7 }
317
+ memory.forget(ids:, reason:) # see "Forgetting" below
318
+ ```
319
+
320
+ ### Provenance (lineage of every semantic datum)
321
+
322
+ Facts (items), graph nodes/edges and reflection insights carry provenance — where they came from, how they were produced, with what confidence — so a conclusion can be traced back to its source.
323
+
324
+ ```ruby
325
+ item = storage.get_all_items("u1").first
326
+ item[:provenance]
327
+ # => { sources: [{ type: "resource", id: "res_3" }], method: "fact_extraction", confidence: 0.9, created_at: "..." }
328
+ ```
329
+
330
+ Graph nodes/edges record a SHA-256 fingerprint of the ingested text (lineage without persisting the raw document). Build provenance directly with `Llmemory::Provenance.build(method:, sources:, confidence:)`.
331
+
332
+ ### Adaptive retrieval (feedback loop)
333
+
334
+ Tell the retrieval engine which retrieved items were useful or noisy; repeatedly-useful items rank higher in future retrievals, noise is dampened. Item ids come from the candidates returned by `read` / `search_candidates`.
335
+
336
+ ```ruby
337
+ engine = Retrieval::Engine.new(memory)
338
+ results = memory.read("deployment incidents") # candidates carry :id
339
+
340
+ engine.report_feedback(useful_ids: [results.first[:id]], harmful_ids: [])
341
+ # Next retrievals reweight accordingly. Set config.retrieval_feedback_weight = 0 to disable.
342
+ ```
343
+
344
+ ### Iterative retrieval (multi-hop)
345
+
346
+ Retrieve, reason about what is still missing, then retrieve again — for multi-hop questions a single pass would miss.
347
+
348
+ ```ruby
349
+ engine.iterative_retrieve(
350
+ "What is the capital of France and its population?",
351
+ max_hops: 3
352
+ )
353
+ # After each hop an LLM proposes a follow-up query (or "DONE"). Pass a custom
354
+ # `reasoner: ->(question, accumulated, hop) { ... }` to drive the loop yourself.
355
+ ```
356
+
357
+ ### Forgetting (unlearning with audit)
358
+
359
+ Remove entries by id, with an audit trail of what was forgotten, when and why.
360
+
361
+ ```ruby
362
+ removed = memory.forget(ids: [item_id], reason: "user requested deletion") # => count removed
363
+
364
+ Llmemory::ForgetLog.new.entries("u1")
365
+ # => [{ memory_type: "file_based", ids: ["item_7"], count: 1, reason: "user requested deletion", at: "..." }]
366
+ ```
367
+
368
+ Supported for file-based, episodic and procedural memory (hard delete by id). Graph forgetting (edge/node lifecycle with orphan handling) is a roadmap item.
369
+
198
370
  ## Advanced Memory Management
199
371
 
200
372
  These features improve robustness and efficiency, inspired by OpenClaw's memory system.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module Actions
5
+ # CoALA's "reasoning" action: read working memory, call the LLM, write the
6
+ # result back to working memory. Unlike retrieval (long-term -> working) or
7
+ # learning (working -> long-term), reasoning reads from and writes to working
8
+ # memory, producing new information for the current decision.
9
+ #
10
+ # It is a small, composable primitive — agents can chain reason -> retrieve
11
+ # -> reason — and deliberately does NOT touch long-term memory.
12
+ #
13
+ # Llmemory::Actions::Reason.call(
14
+ # working_memory: wm,
15
+ # template: "Given goals {{goals}}, what is the next step?",
16
+ # into: :intermediate_reasoning
17
+ # )
18
+ #
19
+ # `template` is either a String (with {{slot}} placeholders filled from
20
+ # working memory) or a callable that receives the WorkingMemory and returns
21
+ # the prompt. `parse` optionally transforms the raw LLM output before it is
22
+ # stored. `into` is the slot to write to (nil to reason without writing).
23
+ # Returns the parsed result.
24
+ class Reason
25
+ DEFAULT_SLOT = :intermediate_reasoning
26
+
27
+ def self.call(working_memory:, template:, into: DEFAULT_SLOT, parse: nil, llm: nil)
28
+ client = llm || Llmemory::LLM.client
29
+ prompt = render(template, working_memory)
30
+ output = client.invoke(prompt).to_s
31
+ result = parse ? parse.call(output) : output
32
+ working_memory.set(into, result) unless into.nil?
33
+ result
34
+ end
35
+
36
+ def self.render(template, working_memory)
37
+ return template.call(working_memory).to_s if template.respond_to?(:call)
38
+ interpolate(template.to_s, working_memory.to_h)
39
+ end
40
+
41
+ def self.interpolate(text, slots)
42
+ text.gsub(/\{\{(\w+)\}\}/) do
43
+ key = Regexp.last_match(1).to_sym
44
+ slots.key?(key) ? slots[key].to_s : ""
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actions/reason"
4
+
5
+ module Llmemory
6
+ module Actions
7
+ end
8
+ end
@@ -15,6 +15,7 @@ module Llmemory
15
15
  :vector_store,
16
16
  :time_decay_half_life_days,
17
17
  :importance_weight,
18
+ :retrieval_feedback_weight,
18
19
  :max_retrieval_tokens,
19
20
  :prune_after_days,
20
21
  :compact_max_bytes,
@@ -58,6 +59,7 @@ module Llmemory
58
59
  @vector_store = nil
59
60
  @time_decay_half_life_days = 30
60
61
  @importance_weight = 1.0
62
+ @retrieval_feedback_weight = 0.5
61
63
  @max_retrieval_tokens = 2000
62
64
  @prune_after_days = 90
63
65
  @compact_max_bytes = 8192
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "short_term/stores"
5
+
6
+ module Llmemory
7
+ # Append-only audit trail of forgotten memory entries. CoALA notes that
8
+ # modifying and deleting memory ("unlearning") are understudied; when an agent
9
+ # removes knowledge it should remain accountable for what was removed, when and
10
+ # why. ForgetLog records that trail, unified per user across memory types.
11
+ #
12
+ # Backed by the same pluggable short-term stores as the rest of the session
13
+ # layer, under a per-user pseudo-session key.
14
+ class ForgetLog
15
+ SESSION_KEY = "__forget_log__"
16
+
17
+ def initialize(store: nil)
18
+ @store = store || ShortTerm::Stores.build
19
+ end
20
+
21
+ def record(user_id, memory_type:, ids:, reason: nil)
22
+ ids = Array(ids).map(&:to_s)
23
+ entry = {
24
+ memory_type: memory_type.to_s,
25
+ ids: ids,
26
+ count: ids.size,
27
+ reason: reason,
28
+ at: Time.now.iso8601
29
+ }
30
+ log = entries(user_id)
31
+ log << entry
32
+ @store.save(user_id, SESSION_KEY, { "entries" => log })
33
+ entry
34
+ end
35
+
36
+ def entries(user_id)
37
+ state = @store.load(user_id, SESSION_KEY)
38
+ return [] unless state.is_a?(Hash)
39
+ list = state[:entries] || state["entries"]
40
+ list.is_a?(Array) ? list.map { |e| symbolize(e) } : []
41
+ end
42
+
43
+ private
44
+
45
+ def symbolize(entry)
46
+ return entry unless entry.is_a?(Hash)
47
+ entry.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
48
+ end
49
+ end
50
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "episode"
4
4
  require_relative "storage"
5
+ require_relative "../../memory_module"
5
6
 
6
7
  module Llmemory
7
8
  module LongTerm
@@ -14,6 +15,8 @@ module Llmemory
14
15
  # Deliberately LLM-free: recording and retrieval are deterministic. Higher
15
16
  # order summarization belongs to reflection.
16
17
  class Memory
18
+ include Llmemory::MemoryModule
19
+
17
20
  attr_reader :user_id, :storage
18
21
 
19
22
  def initialize(user_id:, storage: nil)
@@ -66,6 +69,7 @@ module Llmemory
66
69
  @storage.search_episodes(uid, query).first(top_k).map do |e|
67
70
  episode = Episode.from_h(e)
68
71
  {
72
+ id: episode.id,
69
73
  text: episode.summary.to_s.empty? ? episode.searchable_text : episode.summary,
70
74
  timestamp: episode.created_at,
71
75
  score: 1.0,
@@ -76,6 +80,29 @@ module Llmemory
76
80
  end
77
81
  end
78
82
 
83
+ # --- MemoryModule uniform interface ---
84
+
85
+ def write(steps:, summary: nil, outcome: nil, importance: 0.5, **_meta)
86
+ record_episode(steps: steps, summary: summary, outcome: outcome, importance: importance)
87
+ end
88
+
89
+ def list(user_id: nil, limit: nil)
90
+ episodes(limit: limit)
91
+ end
92
+
93
+ def stats(user_id: nil)
94
+ { episodes: count }
95
+ end
96
+
97
+ def forget(ids:, reason: nil)
98
+ requested = Array(ids).map(&:to_s)
99
+ existing = @storage.list_episodes(@user_id).map { |e| (e[:id] || e["id"]).to_s }
100
+ removed = requested & existing
101
+ @storage.delete_episodes(@user_id, removed)
102
+ forget_log.record(@user_id, memory_type: "episodic", ids: removed, reason: reason)
103
+ removed.size
104
+ end
105
+
79
106
  private
80
107
 
81
108
  # Cheap, deterministic summary when the caller does not provide one.
@@ -29,6 +29,11 @@ module Llmemory
29
29
  raise NotImplementedError, "#{self.class}#count_episodes must be implemented"
30
30
  end
31
31
 
32
+ # Deletes episodes by id. Returns the number actually removed.
33
+ def delete_episodes(user_id, ids)
34
+ raise NotImplementedError, "#{self.class}#delete_episodes must be implemented"
35
+ end
36
+
32
37
  def list_users
33
38
  raise NotImplementedError, "#{self.class}#list_users must be implemented"
34
39
  end
@@ -46,6 +46,15 @@ module Llmemory
46
46
  Dir.children(dir).count { |f| f.end_with?(".json") }
47
47
  end
48
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
+
49
58
  def list_users
50
59
  return [] unless Dir.exist?(@base_path)
51
60
  Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "episodes")) }
@@ -40,6 +40,13 @@ module Llmemory
40
40
  @episodes[user_id].size
41
41
  end
42
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
+
43
50
  def list_users
44
51
  @episodes.keys
45
52
  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
@@ -100,6 +105,32 @@ module Llmemory
100
105
  )
101
106
  end
102
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
+
103
134
  attr_reader :storage, :user_id
104
135
 
105
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