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.
- checksums.yaml +4 -4
- data/README.md +172 -0
- data/lib/llmemory/actions/reason.rb +49 -0
- data/lib/llmemory/actions.rb +8 -0
- data/lib/llmemory/configuration.rb +2 -0
- data/lib/llmemory/forget_log.rb +50 -0
- data/lib/llmemory/long_term/episodic/memory.rb +27 -0
- data/lib/llmemory/long_term/episodic/storages/base.rb +5 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +9 -0
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +7 -0
- data/lib/llmemory/long_term/file_based/memory.rb +31 -0
- data/lib/llmemory/long_term/graph_based/memory.rb +30 -3
- data/lib/llmemory/long_term/procedural/memory.rb +116 -0
- data/lib/llmemory/long_term/procedural/skill.rb +93 -0
- data/lib/llmemory/long_term/procedural/storage.rb +31 -0
- data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +136 -0
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +80 -0
- data/lib/llmemory/long_term/procedural.rb +12 -0
- data/lib/llmemory/long_term.rb +2 -0
- data/lib/llmemory/memory.rb +9 -1
- data/lib/llmemory/memory_module.rb +55 -0
- data/lib/llmemory/retrieval/engine.rb +115 -6
- data/lib/llmemory/retrieval/feedback_store.rb +50 -0
- data/lib/llmemory/short_term/checkpoint.rb +2 -14
- data/lib/llmemory/short_term/session_lifecycle.rb +3 -10
- data/lib/llmemory/short_term/stores.rb +27 -0
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/working_memory.rb +83 -0
- data/lib/llmemory.rb +4 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c302656888e6373faedb5732525f76d118fd98fb67ece22278d886faec5ba3cd
|
|
4
|
+
data.tar.gz: ec0897226ec378e51e86e01cb66e938fbeea3db7e4c49911d7e3d08a08959d07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|