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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b86647810e47140fff5732a066da4a16188704249d09db14720d8c565b6eaf0e
4
- data.tar.gz: 7c52c551746d22c29e41015098a68e166788bb614b0e0c2b064fa5fc0824989f
3
+ metadata.gz: 80f259aa60090b95d21110bdf1f91c2c91bd5334a1bc4e3effaec444f241f371
4
+ data.tar.gz: 0b07d9a6ce5e485a69f00dfb2ad7c0cd03a442e95817d310a65d85e40f9def2c
5
5
  SHA512:
6
- metadata.gz: f584d487eca13280b13f7a3e9f4b8eda60ed10ec8dbb45ff72483acbc76b7feb7f79fb5ae572640a31e87ade05b0762f07cacbecb778dffa6d62a7096231fb12
7
- data.tar.gz: c68e284c21fc22ccdf20160d9082575a47482b03c5a91fb3b52ec5a6c51b7f5cf13a8915bfd3fe2f5933aa6a52fe8c382e2373393b27d68d8ed1c7ac83766e49
6
+ metadata.gz: 464cc0765650869996ff24a4bf7e2b3ce4d3433a6c73cc9d63167173b04d14deedf0e169ca4579a7d7ac253b5a858f2b88629ce1b888bf48becfa46fad581979
7
+ data.tar.gz: 7624c8cd607cf882b13d500ee878b5412f49e13282014a33dc836cfa68c9bfdf9f759b89f53b51eccb38c32966f0cc66ae5d23a2297f2c965a67d81ccb8f437f
data/README.md CHANGED
@@ -70,6 +70,15 @@ 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
+
77
+ # Semantic (embedding) retrieval for episodic/procedural memory (opt-in;
78
+ # default off keeps them deterministic and network-free)
79
+ config.episodic_vector_enabled = false
80
+ config.procedural_vector_enabled = false
81
+
73
82
  # Pre-compaction memory flush (prevents knowledge loss when compacting)
74
83
  config.memory_flush_enabled = true
75
84
  config.memory_flush_threshold_tokens = 4000
@@ -125,7 +134,7 @@ rails g llmemory:install
125
134
  rails db:migrate
126
135
  ```
127
136
 
128
- La migración crea las tablas de long-term file-based (resources, items, categories), short-term (checkpoints) y, para graph-based, nodos, aristas y embeddings (`llmemory_nodes`, `llmemory_edges`, `llmemory_embeddings`). Para embeddings se usa pgvector; asegúrate de tener la extensión `vector` en PostgreSQL. Para usar ambas con ActiveRecord:
137
+ La migración crea las tablas de long-term file-based (resources, items, categories), short-term (checkpoints), episódica y procedural (`llmemory_episodes`, `llmemory_skills`; columna `data` JSONB) y, para graph-based, nodos, aristas y embeddings (`llmemory_nodes`, `llmemory_edges`, `llmemory_embeddings`). Para embeddings se usa pgvector; asegúrate de tener la extensión `vector` en PostgreSQL. Para usar ambas con ActiveRecord:
129
138
 
130
139
  ```ruby
131
140
  # config/application.rb o config/initializers/llmemory.rb
@@ -195,6 +204,174 @@ candidates = memory.search_candidates("job", top_k: 20)
195
204
 
196
205
  **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
206
 
207
+ ## Cognitive Memory (CoALA)
208
+
209
+ 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.
210
+
211
+ | CoALA concept | llmemory |
212
+ |---|---|
213
+ | Working memory | `Llmemory::WorkingMemory` |
214
+ | Episodic memory | `Llmemory::LongTerm::Episodic::Memory` |
215
+ | Semantic memory | `FileBased::Memory` / `GraphBased::Memory` |
216
+ | Procedural memory | `Llmemory::LongTerm::Procedural::Memory` |
217
+ | Reasoning action | `Llmemory::Actions::Reason` |
218
+ | Retrieval action | `Retrieval::Engine` (+ feedback, iterative) |
219
+ | Learning action | `memorize` / `record_episode` / `register_skill` / reflection |
220
+ | Uniform interface | `Llmemory::MemoryModule` (`read`/`write`/`list`/`stats`/`forget`) |
221
+
222
+ All three long-term memories below are **additive** — episodic and procedural coexist with semantic memory rather than replacing it. They support `:memory`, `:file`, `:postgres` and `:active_record` backends. Retrieval is keyword-based by default (tokenized, so multi-word queries work); semantic (embedding) retrieval is **opt-in** via `config.episodic_vector_enabled` / `config.procedural_vector_enabled` (or by injecting a `vector_store:`), which makes `search_candidates` hybrid (vector + keyword).
223
+
224
+ ### Working memory (structured, persists across LLM calls)
225
+
226
+ 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.
227
+
228
+ ```ruby
229
+ wm = Llmemory::WorkingMemory.new(user_id: "u1", session_id: "s1")
230
+ # or, from the unified API: memory.working_memory
231
+
232
+ wm.goals = ["plan a trip to Lisbon"]
233
+ wm.current_task = "find flights"
234
+ wm.set(:budget, 1000) # arbitrary custom slot
235
+
236
+ wm.goals # => ["plan a trip to Lisbon"]
237
+ wm.custom_slots # => { budget: 1000 }
238
+ wm.update(last_observation: "no direct flights", scratchpad: "try connections")
239
+ wm.to_h # full state; wm.clear! to reset
240
+ ```
241
+
242
+ Predefined slots: `goals`, `current_task`, `retrieved_context`, `scratchpad`, `last_observation`, `intermediate_reasoning`.
243
+
244
+ ### Reasoning action
245
+
246
+ 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.
247
+
248
+ ```ruby
249
+ Llmemory::Actions::Reason.call(
250
+ working_memory: wm,
251
+ template: "Goal: {{goals}}. Observation: {{last_observation}}. What is the next step?",
252
+ into: :intermediate_reasoning # slot to write to (nil to not write)
253
+ )
254
+ wm.intermediate_reasoning # => the LLM's answer
255
+
256
+ # A callable template gets the working memory; `parse` transforms the output before storing:
257
+ Llmemory::Actions::Reason.call(
258
+ working_memory: wm,
259
+ template: ->(w) { "List 3 options for #{w.current_task}" },
260
+ parse: ->(out) { out.split("\n") },
261
+ into: :scratchpad
262
+ )
263
+ ```
264
+
265
+ ### Episodic memory (trajectories of experience)
266
+
267
+ 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.
268
+
269
+ ```ruby
270
+ episodic = Llmemory::LongTerm::Episodic::Memory.new(user_id: "u1")
271
+
272
+ id = episodic.record_episode(
273
+ steps: [{ observation: "deploy failed", action: "rolled back", result: "service restored" }],
274
+ outcome: "recovered",
275
+ importance: 0.8
276
+ )
277
+
278
+ episodic.recent_episodes(limit: 5) # newest first
279
+ episodic.search_candidates("rolled back") # retrieval-compatible candidates
280
+ ```
281
+
282
+ ### Reflection (episodic → semantic)
283
+
284
+ 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).
285
+
286
+ ```ruby
287
+ semantic = Llmemory::LongTerm::FileBased::Memory.new(user_id: "u1")
288
+ reflector = Llmemory::Reflection::Reflector.new(episodic: episodic, semantic: semantic)
289
+
290
+ reflector.reflect(window: 10) # reads recent episodes -> LLM -> writes insights
291
+ # Each insight is stored with provenance { method: "reflection", sources: [{ type: "episode", id: ... }] }
292
+ ```
293
+
294
+ `semantic` must respond to `remember_fact(content:, category:, importance:, provenance:)` (file-based does; graph-based is a roadmap target).
295
+
296
+ ### Procedural memory (skill library)
297
+
298
+ 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.
299
+
300
+ ```ruby
301
+ skills = Llmemory::LongTerm::Procedural::Memory.new(user_id: "u1")
302
+
303
+ id = skills.register_skill(
304
+ name: "rollback", description: "revert a bad deploy",
305
+ body: "kubectl rollout undo deployment/$1", kind: "code" # kind: prompt | template | code
306
+ )
307
+ skills.register_skill(name: "rollback", body: "...newer...") # same name -> version auto-increments
308
+
309
+ skills.find_skill("revert deploy") # best match (a Skill)
310
+ skills.report_outcome(id, success: true) # feeds ranking + adaptive retrieval
311
+ ```
312
+
313
+ ### Uniform interface (MemoryModule)
314
+
315
+ The queryable long-term memories (file, graph, episodic, procedural) share one agent-facing contract, so a framework can treat them polymorphically:
316
+
317
+ ```ruby
318
+ memory.read(query, limit: 10) # retrieve relevant entries (delegates to search_candidates)
319
+ memory.write(...) # ingest (memorize / record_episode / register_skill)
320
+ memory.list(limit: 50) # enumerate stored entries
321
+ memory.stats # counts, e.g. { items: 12 } / { episodes: 4 } / { skills: 7 }
322
+ memory.forget(ids:, reason:) # see "Forgetting" below
323
+ ```
324
+
325
+ ### Provenance (lineage of every semantic datum)
326
+
327
+ 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.
328
+
329
+ ```ruby
330
+ item = storage.get_all_items("u1").first
331
+ item[:provenance]
332
+ # => { sources: [{ type: "resource", id: "res_3" }], method: "fact_extraction", confidence: 0.9, created_at: "..." }
333
+ ```
334
+
335
+ 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:)`.
336
+
337
+ ### Adaptive retrieval (feedback loop)
338
+
339
+ 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`.
340
+
341
+ ```ruby
342
+ engine = Retrieval::Engine.new(memory)
343
+ results = memory.read("deployment incidents") # candidates carry :id
344
+
345
+ engine.report_feedback(useful_ids: [results.first[:id]], harmful_ids: [])
346
+ # Next retrievals reweight accordingly. Set config.retrieval_feedback_weight = 0 to disable.
347
+ ```
348
+
349
+ ### Iterative retrieval (multi-hop)
350
+
351
+ Retrieve, reason about what is still missing, then retrieve again — for multi-hop questions a single pass would miss.
352
+
353
+ ```ruby
354
+ engine.iterative_retrieve(
355
+ "What is the capital of France and its population?",
356
+ max_hops: 3
357
+ )
358
+ # After each hop an LLM proposes a follow-up query (or "DONE"). Pass a custom
359
+ # `reasoner: ->(question, accumulated, hop) { ... }` to drive the loop yourself.
360
+ ```
361
+
362
+ ### Forgetting (unlearning with audit)
363
+
364
+ Remove entries by id, with an audit trail of what was forgotten, when and why.
365
+
366
+ ```ruby
367
+ removed = memory.forget(ids: [item_id], reason: "user requested deletion") # => count removed
368
+
369
+ Llmemory::ForgetLog.new.entries("u1")
370
+ # => [{ memory_type: "file_based", ids: ["item_7"], count: 1, reason: "user requested deletion", at: "..." }]
371
+ ```
372
+
373
+ Supported for file-based, episodic and procedural memory (hard delete by id). Graph forgetting (edge/node lifecycle with orphan handling) is a roadmap item.
374
+
198
375
  ## Advanced Memory Management
199
376
 
200
377
  These features improve robustness and efficiency, inspired by OpenClaw's memory system.
@@ -30,6 +30,26 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
30
30
  end
31
31
  add_index :llmemory_categories, [:user_id, :category_name], unique: true
32
32
 
33
+ # Episodic long-term memory (trajectories) — JSONB document per episode
34
+ create_table :llmemory_episodes, id: false do |t|
35
+ t.string :id, null: false, primary_key: true
36
+ t.string :user_id, null: false
37
+ t.jsonb :data, null: false, default: {}
38
+ t.text :search_text
39
+ t.timestamps
40
+ end
41
+ add_index :llmemory_episodes, :user_id
42
+
43
+ # Procedural long-term memory (skill library) — JSONB document per skill
44
+ create_table :llmemory_skills, id: false do |t|
45
+ t.string :id, null: false, primary_key: true
46
+ t.string :user_id, null: false
47
+ t.jsonb :data, null: false, default: {}
48
+ t.text :search_text
49
+ t.timestamps
50
+ end
51
+ add_index :llmemory_skills, :user_id
52
+
33
53
  create_table :llmemory_checkpoints do |t|
34
54
  t.string :user_id, null: false
35
55
  t.string :session_id, null: false
@@ -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
@@ -70,6 +70,14 @@ module Llmemory
70
70
  Llmemory::LongTerm::GraphBased::Storages::MemoryStorage.new
71
71
  end
72
72
  end
73
+
74
+ def episodic_storage(store_type = nil)
75
+ Llmemory::LongTerm::Episodic::Storages.build(store: (store_type || Llmemory.configuration.long_term_store))
76
+ end
77
+
78
+ def procedural_storage(store_type = nil)
79
+ Llmemory::LongTerm::Procedural::Storages.build(store: (store_type || Llmemory.configuration.long_term_store))
80
+ end
73
81
  end
74
82
  end
75
83
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class Episodic < Commands::Base
9
+ def option_parser(parser)
10
+ parser.on("--limit N", Integer, "Max number of episodes (newest first)") { |v| @limit = v }
11
+ parser.on("--store TYPE", "Storage type (memory|file|postgres|active_record)") { |v| @store_type = v }
12
+ end
13
+
14
+ def execute(argv, _opts)
15
+ user_id = argv.first
16
+ unless user_id
17
+ $stderr.puts "Usage: llmemory episodes USER_ID [--limit N] [--store TYPE]"
18
+ exit 1
19
+ end
20
+
21
+ storage = episodic_storage(@store_type)
22
+ episodes = storage.list_episodes(user_id, limit: @limit)
23
+
24
+ if episodes.empty?
25
+ puts "No episodes for user #{user_id}."
26
+ return
27
+ end
28
+
29
+ episodes.each do |e|
30
+ id = e[:id] || e["id"]
31
+ summary = e[:summary] || e["summary"]
32
+ outcome = e[:outcome] || e["outcome"]
33
+ importance = e[:importance] || e["importance"]
34
+ steps = e[:steps] || e["steps"] || []
35
+ puts "[#{id}] (importance: #{importance}; outcome: #{outcome || 'n/a'}) #{summary}"
36
+ puts " steps: #{Array(steps).size}"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class ForgetLog < Commands::Base
9
+ def execute(argv, _opts)
10
+ user_id = argv.first
11
+ unless user_id
12
+ $stderr.puts "Usage: llmemory forget-log USER_ID"
13
+ exit 1
14
+ end
15
+
16
+ entries = Llmemory::ForgetLog.new(store: short_term_store).entries(user_id)
17
+
18
+ if entries.empty?
19
+ puts "No forget audit entries for user #{user_id}."
20
+ return
21
+ end
22
+
23
+ entries.each do |e|
24
+ type = e[:memory_type] || e["memory_type"]
25
+ count = e[:count] || e["count"]
26
+ reason = e[:reason] || e["reason"]
27
+ at = e[:at] || e["at"]
28
+ ids = e[:ids] || e["ids"] || []
29
+ reason_str = reason ? " — #{reason}" : ""
30
+ puts "[#{at}] #{type}: removed #{count} (#{ids.join(', ')})#{reason_str}"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class Procedural < Commands::Base
9
+ def option_parser(parser)
10
+ parser.on("--limit N", Integer, "Max number of skills (newest first)") { |v| @limit = v }
11
+ parser.on("--store TYPE", "Storage type (memory|file|postgres|active_record)") { |v| @store_type = v }
12
+ end
13
+
14
+ def execute(argv, _opts)
15
+ user_id = argv.first
16
+ unless user_id
17
+ $stderr.puts "Usage: llmemory skills USER_ID [--limit N] [--store TYPE]"
18
+ exit 1
19
+ end
20
+
21
+ storage = procedural_storage(@store_type)
22
+ skills = storage.list_skills(user_id, limit: @limit)
23
+
24
+ if skills.empty?
25
+ puts "No skills for user #{user_id}."
26
+ return
27
+ end
28
+
29
+ skills.each do |s|
30
+ id = s[:id] || s["id"]
31
+ name = s[:name] || s["name"]
32
+ kind = s[:kind] || s["kind"]
33
+ version = s[:version] || s["version"]
34
+ succ = (s[:success_count] || s["success_count"] || 0).to_i
35
+ fail = (s[:failure_count] || s["failure_count"] || 0).to_i
36
+ total = succ + fail
37
+ rate = total.zero? ? "n/a" : format("%.2f", succ.to_f / total)
38
+ puts "[#{id}] #{name} v#{version} (#{kind}) — success rate: #{rate} (#{succ}/#{total})"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class Working < Commands::Base
9
+ def execute(argv, _opts)
10
+ user_id, session_id = argv
11
+ unless user_id && session_id
12
+ $stderr.puts "Usage: llmemory working USER_ID SESSION_ID"
13
+ exit 1
14
+ end
15
+
16
+ wm = Llmemory::WorkingMemory.new(user_id: user_id, session_id: session_id, store: short_term_store)
17
+ state = wm.to_h
18
+
19
+ if state.empty?
20
+ puts "Empty working memory for user #{user_id}, session #{session_id}."
21
+ return
22
+ end
23
+
24
+ state.each do |slot, value|
25
+ puts "#{slot}: #{value.inspect}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/llmemory/cli.rb CHANGED
@@ -5,6 +5,10 @@ require_relative "cli/commands/base"
5
5
  require_relative "cli/commands/users"
6
6
  require_relative "cli/commands/short_term"
7
7
  require_relative "cli/commands/long_term"
8
+ require_relative "cli/commands/episodic"
9
+ require_relative "cli/commands/procedural"
10
+ require_relative "cli/commands/working"
11
+ require_relative "cli/commands/forget_log"
8
12
  require_relative "cli/commands/stats"
9
13
  require_relative "cli/commands/search"
10
14
  require_relative "cli/commands/mcp"
@@ -47,6 +51,10 @@ module Llmemory
47
51
  "nodes" => Cli::Commands::LongTerm::Nodes,
48
52
  "edges" => Cli::Commands::LongTerm::Edges,
49
53
  "graph" => Cli::Commands::LongTerm::Graph,
54
+ "episodes" => Cli::Commands::Episodic,
55
+ "skills" => Cli::Commands::Procedural,
56
+ "working" => Cli::Commands::Working,
57
+ "forget_log" => Cli::Commands::ForgetLog,
50
58
  "search" => Cli::Commands::Search,
51
59
  "stats" => Cli::Commands::Stats,
52
60
  "mcp" => Cli::Commands::Mcp
@@ -68,6 +76,10 @@ module Llmemory
68
76
  nodes USER_ID List graph nodes (graph-based)
69
77
  edges USER_ID List graph edges (graph-based)
70
78
  graph USER_ID Export graph (--format dot|json)
79
+ episodes USER_ID List recorded episodes (episodic memory)
80
+ skills USER_ID List registered skills (procedural memory)
81
+ working USER_ID SESSION Show working-memory slots for a session
82
+ forget-log USER_ID Show audit of forgotten entries
71
83
  search USER_ID "query" Search in memory
72
84
  stats [USER_ID] Show statistics
73
85
  mcp [serve] Start MCP server for LLM agents
@@ -11,10 +11,13 @@ module Llmemory
11
11
  :long_term_type,
12
12
  :long_term_store,
13
13
  :long_term_storage_path,
14
+ :episodic_vector_enabled,
15
+ :procedural_vector_enabled,
14
16
  :database_url,
15
17
  :vector_store,
16
18
  :time_decay_half_life_days,
17
19
  :importance_weight,
20
+ :retrieval_feedback_weight,
18
21
  :max_retrieval_tokens,
19
22
  :prune_after_days,
20
23
  :compact_max_bytes,
@@ -54,10 +57,13 @@ module Llmemory
54
57
  @long_term_type = :file_based
55
58
  @long_term_store = :memory
56
59
  @long_term_storage_path = ENV["LLMEMORY_STORAGE_PATH"] || "./llmemory_data"
60
+ @episodic_vector_enabled = false
61
+ @procedural_vector_enabled = false
57
62
  @database_url = ENV["DATABASE_URL"]
58
63
  @vector_store = nil
59
64
  @time_decay_half_life_days = 30
60
65
  @importance_weight = 1.0
66
+ @retrieval_feedback_weight = 0.5
61
67
  @max_retrieval_tokens = 2000
62
68
  @prune_after_days = 90
63
69
  @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