llmemory 0.2.1 → 0.2.3
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 +78 -1
- data/app/controllers/llmemory/dashboard/application_controller.rb +15 -1
- data/app/controllers/llmemory/dashboard/episodic_controller.rb +22 -0
- data/app/controllers/llmemory/dashboard/forget_log_controller.rb +12 -0
- data/app/controllers/llmemory/dashboard/maintenance_controller.rb +92 -0
- data/app/controllers/llmemory/dashboard/procedural_controller.rb +22 -0
- data/app/controllers/llmemory/dashboard/reflection_controller.rb +37 -0
- data/app/controllers/llmemory/dashboard/working_controller.rb +14 -0
- data/app/views/llmemory/dashboard/episodic/index.html.erb +37 -0
- data/app/views/llmemory/dashboard/forget_log/show.html.erb +23 -0
- data/app/views/llmemory/dashboard/maintenance/show.html.erb +65 -0
- data/app/views/llmemory/dashboard/procedural/index.html.erb +38 -0
- data/app/views/llmemory/dashboard/reflection/show.html.erb +29 -0
- data/app/views/llmemory/dashboard/users/show.html.erb +16 -0
- data/app/views/llmemory/dashboard/working/show.html.erb +20 -0
- data/config/routes.rb +14 -0
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +2 -0
- data/lib/llmemory/cli/commands/maintain.rb +62 -0
- data/lib/llmemory/cli/commands/mine_skills.rb +50 -0
- data/lib/llmemory/cli.rb +6 -0
- data/lib/llmemory/configuration.rb +28 -2
- data/lib/llmemory/crypto/cipher.rb +147 -0
- data/lib/llmemory/crypto/field_helpers.rb +110 -0
- data/lib/llmemory/instrumentation.rb +33 -0
- data/lib/llmemory/llm/anthropic.rb +21 -16
- data/lib/llmemory/llm/openai.rb +18 -13
- data/lib/llmemory/long_term/episodic/memory.rb +27 -13
- data/lib/llmemory/long_term/episodic/storage.rb +11 -4
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +33 -10
- data/lib/llmemory/long_term/episodic/storages/base.rb +15 -2
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +51 -8
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +47 -9
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +35 -4
- data/lib/llmemory/long_term/file_based/memory.rb +12 -4
- data/lib/llmemory/long_term/file_based/storage.rb +11 -4
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +20 -12
- data/lib/llmemory/long_term/file_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +28 -10
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +32 -16
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +4 -2
- data/lib/llmemory/long_term/graph_based/memory.rb +16 -7
- data/lib/llmemory/long_term/graph_based/storage.rb +3 -2
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +51 -23
- data/lib/llmemory/long_term/graph_based/storages/base.rb +2 -2
- data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +4 -2
- data/lib/llmemory/long_term/procedural/memory.rb +30 -16
- data/lib/llmemory/long_term/procedural/skill.rb +6 -2
- data/lib/llmemory/long_term/procedural/storage.rb +11 -4
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +47 -17
- data/lib/llmemory/long_term/procedural/storages/base.rb +14 -1
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +52 -10
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +49 -11
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +36 -5
- data/lib/llmemory/maintenance/cognitive_pass.rb +109 -0
- data/lib/llmemory/maintenance/ttl_expiry.rb +50 -0
- data/lib/llmemory/maintenance.rb +2 -0
- data/lib/llmemory/mcp/server.rb +5 -1
- data/lib/llmemory/mcp/tools/memory_maintain.rb +53 -0
- data/lib/llmemory/mcp/tools/memory_mine_skills.rb +53 -0
- data/lib/llmemory/memory.rb +60 -8
- data/lib/llmemory/memory_module.rb +13 -6
- data/lib/llmemory/reflection/reflector.rb +24 -20
- data/lib/llmemory/retrieval/engine.rb +25 -16
- data/lib/llmemory/short_term/checkpoint.rb +3 -2
- data/lib/llmemory/short_term/stores/active_record_store.rb +12 -10
- data/lib/llmemory/short_term/stores/memory_store.rb +1 -1
- data/lib/llmemory/short_term/stores/postgres_store.rb +11 -5
- data/lib/llmemory/short_term/stores/redis_store.rb +7 -5
- data/lib/llmemory/short_term/stores.rb +7 -6
- data/lib/llmemory/skill_mining/miner.rb +163 -0
- data/lib/llmemory/skill_mining.rb +8 -0
- data/lib/llmemory/vector_store/active_record_store.rb +24 -3
- data/lib/llmemory/vector_store/memory_store.rb +23 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +11 -7
- data/lib/llmemory/vector_store.rb +4 -3
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +4 -0
- metadata +24 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e44ccb1c23fc659d9607e1eb3181e598a57edb95f47b94df4060ad46bfe7c31
|
|
4
|
+
data.tar.gz: 5d80e1fefb1dd77cbdc8c0b4d24e588abaf6f18c570db10e3fc0b2c808b09461
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: edb2f849efc9a6d1dbabc0be0a1210bf21e77f33174b965e3b22e9df3ca90aa536e47152c75891e548d6b43a08d82cfda29afa59d3b2e777aa144f4a3900570f
|
|
7
|
+
data.tar.gz: a8c84411d9c262dee5b5a0c07835c05816283a17749c1b6b282c75694dc56a497d8fb9f2b18eeccd242a82d5dce36234342ede75a13e2739ad11f99fb23ae005
|
data/README.md
CHANGED
|
@@ -65,6 +65,12 @@ Llmemory.configure do |config|
|
|
|
65
65
|
config.long_term_store = :memory # or :file, :postgres, :active_record
|
|
66
66
|
config.long_term_storage_path = "./llmemory_data" # for :file
|
|
67
67
|
config.database_url = ENV["DATABASE_URL"] # for :postgres
|
|
68
|
+
|
|
69
|
+
# Optional encryption at rest (AES-256-GCM). Requires a key; isolates data
|
|
70
|
+
# cryptographically per key (e.g. per agent/user). See "Encryption at rest".
|
|
71
|
+
config.encryption_enabled = false
|
|
72
|
+
config.encryption_key = ENV["LLMEMORY_ENCRYPTION_KEY"]
|
|
73
|
+
|
|
68
74
|
config.time_decay_half_life_days = 30
|
|
69
75
|
config.max_retrieval_tokens = 2000
|
|
70
76
|
config.prune_after_days = 90
|
|
@@ -112,6 +118,31 @@ Llmemory.configure do |config|
|
|
|
112
118
|
end
|
|
113
119
|
```
|
|
114
120
|
|
|
121
|
+
## Encryption at rest
|
|
122
|
+
|
|
123
|
+
Optional AES-256-GCM encryption protects persisted memory. Without the key, stored data is unreadable — useful for isolating agents or tenants.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# Global default key (applies to all Memory instances)
|
|
127
|
+
Llmemory.configure do |config|
|
|
128
|
+
config.encryption_enabled = true
|
|
129
|
+
config.encryption_key = ENV["LLMEMORY_ENCRYPTION_KEY"]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
memory = Llmemory::Memory.new(user_id: "agent-1")
|
|
133
|
+
|
|
134
|
+
# Per-instance key override (isolates this agent even if global config differs)
|
|
135
|
+
memory = Llmemory::Memory.new(user_id: "agent-1", encryption_key: "tenant-specific-secret")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**What is encrypted:** conversation checkpoints (redis/postgres/active_record), file-based facts/resources/categories, episodic/procedural documents, graph node names/types/predicates (deterministic) and properties (random IV). **Vector embeddings are not encrypted** (required for pgvector search); associated `text_content` metadata is encrypted.
|
|
139
|
+
|
|
140
|
+
**Trade-offs:**
|
|
141
|
+
- Database keyword search (`LIKE`, BM25 on encrypted columns) no longer works on ciphertext; file backends still search in memory after decrypt.
|
|
142
|
+
- `:memory` backends are in-process only and are **not** encrypted at rest.
|
|
143
|
+
- Existing plaintext data remains readable (markers `enc:v1:` / `encd:v1:`); new writes are encrypted when enabled.
|
|
144
|
+
- Deterministic encryption on graph identifiers leaks equality (same name ⇒ same ciphertext) but keeps graph traversal working.
|
|
145
|
+
|
|
115
146
|
## Long-Term Storage
|
|
116
147
|
|
|
117
148
|
Long-term memory can use different backends:
|
|
@@ -216,7 +247,7 @@ llmemory implements the memory and internal-action concepts from [CoALA — Cogn
|
|
|
216
247
|
| Procedural memory | `Llmemory::LongTerm::Procedural::Memory` |
|
|
217
248
|
| Reasoning action | `Llmemory::Actions::Reason` |
|
|
218
249
|
| Retrieval action | `Retrieval::Engine` (+ feedback, iterative) |
|
|
219
|
-
| Learning action | `memorize` / `record_episode` / `register_skill` / reflection |
|
|
250
|
+
| Learning action | `memorize` / `record_episode` / `register_skill` / reflection / skill mining |
|
|
220
251
|
| Uniform interface | `Llmemory::MemoryModule` (`read`/`write`/`list`/`stats`/`forget`) |
|
|
221
252
|
|
|
222
253
|
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).
|
|
@@ -310,6 +341,39 @@ skills.find_skill("revert deploy") # best match (a Skill)
|
|
|
310
341
|
skills.report_outcome(id, success: true) # feeds ranking + adaptive retrieval
|
|
311
342
|
```
|
|
312
343
|
|
|
344
|
+
### Skill mining (episodic → procedural)
|
|
345
|
+
|
|
346
|
+
Rather than writing every skill by hand, mine them from successful episode trajectories (Voyager's contribution: procedural memory grows from lived experience). Mining is **human-in-the-loop by default** — it returns proposals and writes nothing until you opt in.
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
miner = Llmemory::SkillMining::Miner.new(episodic: episodic, procedural: skills)
|
|
350
|
+
|
|
351
|
+
proposals = miner.mine(window: 20) # => [{ name:, kind:, body:, description:, confidence: }, ...]
|
|
352
|
+
miner.mine(window: 20, outcomes: ["success"]) # deterministic pre-filter by outcome label
|
|
353
|
+
ids = miner.mine(window: 20, auto_register: true) # register proposals straight away
|
|
354
|
+
|
|
355
|
+
# Registered skills carry provenance { method: "skill_mining", sources: [{ type: "episode", id: ... }] }.
|
|
356
|
+
# From the unified API: memory.mine_skills!(auto_register: true)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Cognitive maintenance pass (closing the loop)
|
|
360
|
+
|
|
361
|
+
One scheduled step that runs the whole learning loop — consolidate → reflect → mine skills → TTL expiry — for a user. Each step is isolated: a failure is captured in the report and never aborts the others.
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
report = Llmemory::Maintenance::CognitivePass.run!(
|
|
365
|
+
"u1",
|
|
366
|
+
reflect: true, mine_skills: true, expire: true, # toggle steps
|
|
367
|
+
reflection_window: 10, mining_window: 20
|
|
368
|
+
)
|
|
369
|
+
# => { consolidated:, insights: [...], mined: [...], expired: { episodic:, procedural: }, errors: {} }
|
|
370
|
+
|
|
371
|
+
# From the unified API (wires in the live session, so consolidate! runs too):
|
|
372
|
+
memory.maintain!(mine_skills: true)
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`mine_skills` defaults to `config.skill_mining_enabled` (default `false`). Run it from cron or a Rails job. Components (`episodic:`, `procedural:`, `semantic:`, `llm:`) are injectable; built per-config when omitted.
|
|
376
|
+
|
|
313
377
|
### Uniform interface (MemoryModule)
|
|
314
378
|
|
|
315
379
|
The queryable long-term memories (file, graph, episodic, procedural) share one agent-facing contract, so a framework can treat them polymorphically:
|
|
@@ -489,6 +553,14 @@ llmemory edges USER_ID [--subject NODE_ID] [--limit N]
|
|
|
489
553
|
llmemory graph USER_ID [--format dot|json]
|
|
490
554
|
llmemory search USER_ID "query" [--type short|long|all]
|
|
491
555
|
llmemory stats [USER_ID]
|
|
556
|
+
|
|
557
|
+
# Cognitive memory (CoALA)
|
|
558
|
+
llmemory episodes USER_ID [--limit N]
|
|
559
|
+
llmemory skills USER_ID [--limit N]
|
|
560
|
+
llmemory working USER_ID SESSION_ID
|
|
561
|
+
llmemory forget-log USER_ID
|
|
562
|
+
llmemory mine-skills USER_ID [--window N] [--outcomes success,recovered] [--register]
|
|
563
|
+
llmemory maintain USER_ID [--[no-]reflect] [--mine-skills] [--[no-]expire] [--window N]
|
|
492
564
|
```
|
|
493
565
|
|
|
494
566
|
Use `--store TYPE` where applicable to override the configured store (e.g. `memory`, `redis`, `postgres`, `active_record` for short-term; same or `file` for long-term file-based).
|
|
@@ -615,6 +687,11 @@ MCP_TOKEN=your-secret-token llmemory mcp serve --http --port 443 \
|
|
|
615
687
|
| `memory_consolidate` | Extract facts from conversation to long-term |
|
|
616
688
|
| `memory_stats` | Get memory statistics for a user |
|
|
617
689
|
| `memory_info` | Documentation on how to use the tools |
|
|
690
|
+
| `memory_episode_record` / `memory_episodes` | Record / list episodic trajectories |
|
|
691
|
+
| `memory_skill_register` / `memory_skill_report` / `memory_skills` | Register / outcome-track / list procedural skills |
|
|
692
|
+
| `memory_forget` | Forget entries by id (audited) across any memory type |
|
|
693
|
+
| `memory_mine_skills` | Mine reusable skills from episodes (proposals by default; `auto_register` to save) |
|
|
694
|
+
| `memory_maintain` | Run the cognitive maintenance pass (reflect → mine → expire) and return a report |
|
|
618
695
|
|
|
619
696
|
### Configuration for Claude Code
|
|
620
697
|
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module Llmemory
|
|
4
4
|
module Dashboard
|
|
5
5
|
class ApplicationController < ActionController::Base
|
|
6
|
-
helper_method :short_term_store, :file_based_storage, :graph_based_storage,
|
|
6
|
+
helper_method :short_term_store, :file_based_storage, :graph_based_storage,
|
|
7
|
+
:episodic_storage, :procedural_storage, :forget_log,
|
|
8
|
+
:file_based?, :graph_based?
|
|
7
9
|
|
|
8
10
|
protected
|
|
9
11
|
|
|
@@ -19,6 +21,18 @@ module Llmemory
|
|
|
19
21
|
@graph_based_storage ||= build_graph_based_storage
|
|
20
22
|
end
|
|
21
23
|
|
|
24
|
+
def episodic_storage
|
|
25
|
+
@episodic_storage ||= Llmemory::LongTerm::Episodic::Storages.build
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def procedural_storage
|
|
29
|
+
@procedural_storage ||= Llmemory::LongTerm::Procedural::Storages.build
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def forget_log
|
|
33
|
+
@forget_log ||= Llmemory::ForgetLog.new
|
|
34
|
+
end
|
|
35
|
+
|
|
22
36
|
def long_term_type
|
|
23
37
|
Llmemory.configuration.long_term_type.to_s
|
|
24
38
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
class EpisodicController < ApplicationController
|
|
6
|
+
def index
|
|
7
|
+
@user_id = params[:user_id]
|
|
8
|
+
@limit = (params[:limit].presence || 50).to_i
|
|
9
|
+
@offset = (params[:offset].presence || 0).to_i
|
|
10
|
+
@episodes = episodic_storage.list_episodes(@user_id, limit: @limit, offset: @offset)
|
|
11
|
+
@total = episodic_storage.count_episodes(@user_id)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def forget
|
|
15
|
+
memory = Llmemory::LongTerm::Episodic::Memory.new(user_id: params[:user_id], storage: episodic_storage)
|
|
16
|
+
mode = params[:mode].to_s == "hard" ? :hard : :soft
|
|
17
|
+
memory.forget(ids: [params[:id]], reason: params[:reason], mode: mode)
|
|
18
|
+
redirect_to user_episodic_path(params[:user_id]), notice: "Forgot episode #{params[:id]} (#{mode})."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Llmemory
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Cognitive maintenance surface (SF20): trigger a maintenance pass and review
|
|
4
|
+
# mined-skill proposals before registering them (human-in-the-loop).
|
|
5
|
+
class MaintenanceController < ApplicationController
|
|
6
|
+
def show
|
|
7
|
+
@user_id = params[:user_id]
|
|
8
|
+
@window = (params[:window].presence || 10).to_i
|
|
9
|
+
@recent_episodes = episodic_storage.list_episodes(@user_id, limit: @window)
|
|
10
|
+
@proposals = session_proposals
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Runs the full cognitive pass (reflect -> mine -> expire) and reports.
|
|
14
|
+
def run
|
|
15
|
+
user_id = params[:user_id]
|
|
16
|
+
window = (params[:window].presence || 10).to_i
|
|
17
|
+
report = Llmemory::Maintenance::CognitivePass.run!(
|
|
18
|
+
user_id,
|
|
19
|
+
episodic: episodic_memory(user_id),
|
|
20
|
+
procedural: procedural_memory(user_id),
|
|
21
|
+
semantic: build_semantic_memory(user_id),
|
|
22
|
+
mine_skills: params[:mine_skills].present?,
|
|
23
|
+
reflection_window: window
|
|
24
|
+
)
|
|
25
|
+
redirect_to user_maintenance_path(user_id), notice: pass_notice(report)
|
|
26
|
+
rescue Llmemory::LLMError => e
|
|
27
|
+
redirect_to user_maintenance_path(user_id), alert: "Maintenance pass failed: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Mines skills WITHOUT registering — returns proposals for review.
|
|
31
|
+
def mine
|
|
32
|
+
user_id = params[:user_id]
|
|
33
|
+
window = (params[:window].presence || 20).to_i
|
|
34
|
+
proposals = Llmemory::SkillMining::Miner.new(
|
|
35
|
+
episodic: episodic_memory(user_id), procedural: procedural_memory(user_id)
|
|
36
|
+
).mine(window: window, auto_register: false)
|
|
37
|
+
store_proposals(proposals)
|
|
38
|
+
redirect_to user_maintenance_path(user_id), notice: "Mined #{proposals.size} skill proposal(s) for review."
|
|
39
|
+
rescue Llmemory::LLMError => e
|
|
40
|
+
redirect_to user_maintenance_path(user_id), alert: "Skill mining failed: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Registers a single reviewed proposal into procedural memory.
|
|
44
|
+
def register
|
|
45
|
+
user_id = params[:user_id]
|
|
46
|
+
procedural_memory(user_id).register_skill(
|
|
47
|
+
name: params[:name], body: params[:body], description: params[:description].presence,
|
|
48
|
+
kind: params[:kind].presence || Llmemory::LongTerm::Procedural::Skill::DEFAULT_KIND,
|
|
49
|
+
provenance: Llmemory::Provenance.build(method: "skill_mining")
|
|
50
|
+
)
|
|
51
|
+
redirect_to user_maintenance_path(user_id), notice: "Registered skill #{params[:name]}."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def episodic_memory(user_id)
|
|
57
|
+
Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id, storage: episodic_storage)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def procedural_memory(user_id)
|
|
61
|
+
Llmemory::LongTerm::Procedural::Memory.new(user_id: user_id, storage: procedural_storage)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_semantic_memory(user_id)
|
|
65
|
+
if graph_based?
|
|
66
|
+
Llmemory::LongTerm::GraphBased::Memory.new(user_id: user_id, storage: graph_based_storage)
|
|
67
|
+
else
|
|
68
|
+
Llmemory::LongTerm::FileBased::Memory.new(user_id: user_id, storage: file_based_storage)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pass_notice(report)
|
|
73
|
+
expired = report[:expired] || {}
|
|
74
|
+
msg = "Pass complete: #{Array(report[:insights]).size} insight(s), " \
|
|
75
|
+
"#{Array(report[:mined]).size} skill(s) mined, " \
|
|
76
|
+
"expired episodic=#{expired[:episodic] || 0} procedural=#{expired[:procedural] || 0}."
|
|
77
|
+
errors = report[:errors] || {}
|
|
78
|
+
errors.empty? ? msg : "#{msg} Errors: #{errors.map { |k, v| "#{k}: #{v}" }.join('; ')}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Proposals are stashed in the session so they survive the redirect to
|
|
82
|
+
# `show` without being persisted to a store before the user confirms.
|
|
83
|
+
def store_proposals(proposals)
|
|
84
|
+
session[:llmemory_skill_proposals] = proposals
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def session_proposals
|
|
88
|
+
Array(session.delete(:llmemory_skill_proposals)).map { |p| p.respond_to?(:symbolize_keys) ? p.symbolize_keys : p }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
class ProceduralController < ApplicationController
|
|
6
|
+
def index
|
|
7
|
+
@user_id = params[:user_id]
|
|
8
|
+
@limit = (params[:limit].presence || 50).to_i
|
|
9
|
+
@offset = (params[:offset].presence || 0).to_i
|
|
10
|
+
@skills = procedural_storage.list_skills(@user_id, limit: @limit, offset: @offset)
|
|
11
|
+
@total = procedural_storage.count_skills(@user_id)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def forget
|
|
15
|
+
memory = Llmemory::LongTerm::Procedural::Memory.new(user_id: params[:user_id], storage: procedural_storage)
|
|
16
|
+
mode = params[:mode].to_s == "hard" ? :hard : :soft
|
|
17
|
+
memory.forget(ids: [params[:id]], reason: params[:reason], mode: mode)
|
|
18
|
+
redirect_to user_procedural_path(params[:user_id]), notice: "Forgot skill #{params[:id]} (#{mode})."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Read-only landing for reflection. Triggers (POST #run) build a Reflector
|
|
6
|
+
# over the configured episodic + semantic memories and run a single pass
|
|
7
|
+
# over the last N episodes.
|
|
8
|
+
class ReflectionController < ApplicationController
|
|
9
|
+
def show
|
|
10
|
+
@user_id = params[:user_id]
|
|
11
|
+
@recent_window = (params[:window].presence || 10).to_i
|
|
12
|
+
@recent_episodes = episodic_storage.list_episodes(@user_id, limit: @recent_window)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
user_id = params[:user_id]
|
|
17
|
+
window = (params[:window].presence || 10).to_i
|
|
18
|
+
episodic = Llmemory::LongTerm::Episodic::Memory.new(user_id: user_id, storage: episodic_storage)
|
|
19
|
+
semantic = build_semantic_memory(user_id)
|
|
20
|
+
insight_ids = Llmemory::Reflection::Reflector.new(episodic: episodic, semantic: semantic).reflect(window: window)
|
|
21
|
+
redirect_to user_reflection_path(user_id), notice: "Reflection produced #{insight_ids.size} insight(s)."
|
|
22
|
+
rescue Llmemory::LLMError => e
|
|
23
|
+
redirect_to user_reflection_path(user_id), alert: "Reflection failed: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def build_semantic_memory(user_id)
|
|
29
|
+
if graph_based?
|
|
30
|
+
Llmemory::LongTerm::GraphBased::Memory.new(user_id: user_id, storage: graph_based_storage)
|
|
31
|
+
else
|
|
32
|
+
Llmemory::LongTerm::FileBased::Memory.new(user_id: user_id, storage: file_based_storage)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
class WorkingController < ApplicationController
|
|
6
|
+
def show
|
|
7
|
+
@user_id = params[:user_id]
|
|
8
|
+
@session_id = params[:session_id]
|
|
9
|
+
@working = Llmemory::WorkingMemory.new(user_id: @user_id, session_id: @session_id, store: short_term_store)
|
|
10
|
+
@slots = @working.to_h
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<h2>Episodic memory: <%= @user_id %></h2>
|
|
3
|
+
<p>Total active episodes: <strong><%= @total %></strong></p>
|
|
4
|
+
<% if @episodes.blank? %>
|
|
5
|
+
<p class="empty">No episodes.</p>
|
|
6
|
+
<% else %>
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr><th>ID</th><th>Summary</th><th>Outcome</th><th>Steps</th><th>Created</th><th>Forget</th></tr>
|
|
10
|
+
</thead>
|
|
11
|
+
<tbody>
|
|
12
|
+
<% @episodes.each do |e| %>
|
|
13
|
+
<% id = e[:id] || e["id"] %>
|
|
14
|
+
<tr>
|
|
15
|
+
<td><%= id %></td>
|
|
16
|
+
<td><%= truncate((e[:summary] || e["summary"]).to_s, length: 80) %></td>
|
|
17
|
+
<td><%= e[:outcome] || e["outcome"] %></td>
|
|
18
|
+
<td><%= Array(e[:steps] || e["steps"]).size %></td>
|
|
19
|
+
<td><%= e[:created_at] || e["created_at"] %></td>
|
|
20
|
+
<td>
|
|
21
|
+
<%= button_to "Soft", forget_user_episodic_path(@user_id), params: { id: id, mode: "soft" }, method: :post %>
|
|
22
|
+
<%= button_to "Hard", forget_user_episodic_path(@user_id), params: { id: id, mode: "hard" }, method: :post, data: { confirm: "Hard-delete is irreversible." } %>
|
|
23
|
+
</td>
|
|
24
|
+
</tr>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
<p>
|
|
29
|
+
<% if @offset.positive? %>
|
|
30
|
+
<a href="?offset=<%= [@offset - @limit, 0].max %>&limit=<%= @limit %>">« Newer</a>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% if (@offset + @limit) < @total %>
|
|
33
|
+
<a href="?offset=<%= @offset + @limit %>&limit=<%= @limit %>">Older »</a>
|
|
34
|
+
<% end %>
|
|
35
|
+
</p>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<h2>Forget log: <%= @user_id %></h2>
|
|
3
|
+
<p>Audit trail of removals (soft-archive + hard-delete). Recorded by <code>MemoryModule#forget</code>.</p>
|
|
4
|
+
<% if @entries.blank? %>
|
|
5
|
+
<p class="empty">No forget events.</p>
|
|
6
|
+
<% else %>
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr><th>Time</th><th>Memory type</th><th>Ids</th><th>Reason</th></tr>
|
|
10
|
+
</thead>
|
|
11
|
+
<tbody>
|
|
12
|
+
<% @entries.each do |entry| %>
|
|
13
|
+
<tr>
|
|
14
|
+
<td><%= entry[:archived_at] || entry["archived_at"] %></td>
|
|
15
|
+
<td><%= entry[:memory_type] || entry["memory_type"] %></td>
|
|
16
|
+
<td><code><%= Array(entry[:ids] || entry["ids"]).join(", ") %></code></td>
|
|
17
|
+
<td><%= entry[:reason] || entry["reason"] %></td>
|
|
18
|
+
</tr>
|
|
19
|
+
<% end %>
|
|
20
|
+
</tbody>
|
|
21
|
+
</table>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<h2>Cognitive maintenance: <%= @user_id %></h2>
|
|
3
|
+
<p>The maintenance pass closes the CoALA learning loop: reflect (episodes → insights), mine skills (episodes → procedural), and expire entries past their TTL. Each step is isolated — a failure in one does not abort the others.</p>
|
|
4
|
+
|
|
5
|
+
<%= form_with url: run_user_maintenance_path(@user_id), method: :post, local: true do %>
|
|
6
|
+
<label>Reflection window (episodes): <%= number_field_tag :window, @window, min: 1, max: 100 %></label>
|
|
7
|
+
<label><%= check_box_tag :mine_skills, "1", true %> Mine & register skills</label>
|
|
8
|
+
<%= submit_tag "Run maintenance pass" %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="card">
|
|
13
|
+
<h3>Review mined skills (human-in-the-loop)</h3>
|
|
14
|
+
<p>Mine proposals without registering them, then register the ones you want.</p>
|
|
15
|
+
|
|
16
|
+
<%= form_with url: mine_user_maintenance_path(@user_id), method: :post, local: true do %>
|
|
17
|
+
<label>Mining window (episodes): <%= number_field_tag :window, 20, min: 1, max: 100 %></label>
|
|
18
|
+
<%= submit_tag "Mine proposals" %>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<% if @proposals.present? %>
|
|
22
|
+
<table>
|
|
23
|
+
<thead>
|
|
24
|
+
<tr><th>Name</th><th>Kind</th><th>Confidence</th><th>Body</th><th>Register</th></tr>
|
|
25
|
+
</thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<% @proposals.each do |p| %>
|
|
28
|
+
<tr>
|
|
29
|
+
<td><%= p[:name] %></td>
|
|
30
|
+
<td><%= p[:kind] %></td>
|
|
31
|
+
<td><%= p[:confidence] %></td>
|
|
32
|
+
<td><%= truncate(p[:body].to_s, length: 120) %></td>
|
|
33
|
+
<td>
|
|
34
|
+
<%= button_to "Register", register_user_maintenance_path(@user_id), method: :post, params: {
|
|
35
|
+
name: p[:name], body: p[:body], kind: p[:kind], description: p[:description]
|
|
36
|
+
} %>
|
|
37
|
+
</td>
|
|
38
|
+
</tr>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
<% end %>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="card">
|
|
46
|
+
<h3>Last <%= @window %> episodes</h3>
|
|
47
|
+
<% if @recent_episodes.blank? %>
|
|
48
|
+
<p class="empty">No episodes to learn from.</p>
|
|
49
|
+
<% else %>
|
|
50
|
+
<table>
|
|
51
|
+
<thead>
|
|
52
|
+
<tr><th>ID</th><th>Summary</th><th>Outcome</th></tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
<% @recent_episodes.each do |e| %>
|
|
56
|
+
<tr>
|
|
57
|
+
<td><%= e[:id] || e["id"] %></td>
|
|
58
|
+
<td><%= truncate((e[:summary] || e["summary"]).to_s, length: 100) %></td>
|
|
59
|
+
<td><%= e[:outcome] || e["outcome"] %></td>
|
|
60
|
+
</tr>
|
|
61
|
+
<% end %>
|
|
62
|
+
</tbody>
|
|
63
|
+
</table>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<h2>Procedural memory (skills): <%= @user_id %></h2>
|
|
3
|
+
<p>Total active skills: <strong><%= @total %></strong></p>
|
|
4
|
+
<% if @skills.blank? %>
|
|
5
|
+
<p class="empty">No skills.</p>
|
|
6
|
+
<% else %>
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr><th>ID</th><th>Name</th><th>Description</th><th>Success</th><th>Failure</th><th>Version</th><th>Forget</th></tr>
|
|
10
|
+
</thead>
|
|
11
|
+
<tbody>
|
|
12
|
+
<% @skills.each do |s| %>
|
|
13
|
+
<% id = s[:id] || s["id"] %>
|
|
14
|
+
<tr>
|
|
15
|
+
<td><%= id %></td>
|
|
16
|
+
<td><%= s[:name] || s["name"] %></td>
|
|
17
|
+
<td><%= truncate((s[:description] || s["description"]).to_s, length: 100) %></td>
|
|
18
|
+
<td><%= (s[:success_count] || s["success_count"]).to_i %></td>
|
|
19
|
+
<td><%= (s[:failure_count] || s["failure_count"]).to_i %></td>
|
|
20
|
+
<td><%= s[:version] || s["version"] %></td>
|
|
21
|
+
<td>
|
|
22
|
+
<%= button_to "Soft", forget_user_procedural_path(@user_id), params: { id: id, mode: "soft" }, method: :post %>
|
|
23
|
+
<%= button_to "Hard", forget_user_procedural_path(@user_id), params: { id: id, mode: "hard" }, method: :post, data: { confirm: "Hard-delete is irreversible." } %>
|
|
24
|
+
</td>
|
|
25
|
+
</tr>
|
|
26
|
+
<% end %>
|
|
27
|
+
</tbody>
|
|
28
|
+
</table>
|
|
29
|
+
<p>
|
|
30
|
+
<% if @offset.positive? %>
|
|
31
|
+
<a href="?offset=<%= [@offset - @limit, 0].max %>&limit=<%= @limit %>">« Newer</a>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% if (@offset + @limit) < @total %>
|
|
34
|
+
<a href="?offset=<%= @offset + @limit %>&limit=<%= @limit %>">Older »</a>
|
|
35
|
+
<% end %>
|
|
36
|
+
</p>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<h2>Reflection: <%= @user_id %></h2>
|
|
3
|
+
<p>The reflector distills the last N episodes into semantic insights (Reflexion / Generative Agents pattern), with provenance to the source episodes.</p>
|
|
4
|
+
|
|
5
|
+
<%= form_with url: run_user_reflection_path(@user_id), method: :post, local: true do %>
|
|
6
|
+
<label>Window (episodes): <%= number_field_tag :window, @recent_window, min: 1, max: 100 %></label>
|
|
7
|
+
<%= submit_tag "Run reflection" %>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<h3>Last <%= @recent_window %> episodes</h3>
|
|
11
|
+
<% if @recent_episodes.blank? %>
|
|
12
|
+
<p class="empty">No episodes to reflect over.</p>
|
|
13
|
+
<% else %>
|
|
14
|
+
<table>
|
|
15
|
+
<thead>
|
|
16
|
+
<tr><th>ID</th><th>Summary</th><th>Outcome</th></tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<% @recent_episodes.each do |e| %>
|
|
20
|
+
<tr>
|
|
21
|
+
<td><%= e[:id] || e["id"] %></td>
|
|
22
|
+
<td><%= truncate((e[:summary] || e["summary"]).to_s, length: 100) %></td>
|
|
23
|
+
<td><%= e[:outcome] || e["outcome"] %></td>
|
|
24
|
+
</tr>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
@@ -10,5 +10,21 @@
|
|
|
10
10
|
<% end %>
|
|
11
11
|
| <a href="<%= user_stats_path(@user_id) %>">Stats</a>
|
|
12
12
|
</p>
|
|
13
|
+
<p>
|
|
14
|
+
<strong>Cognitive memory:</strong>
|
|
15
|
+
<a href="<%= user_episodic_path(@user_id) %>">Episodes</a>
|
|
16
|
+
| <a href="<%= user_procedural_path(@user_id) %>">Skills</a>
|
|
17
|
+
| <a href="<%= user_reflection_path(@user_id) %>">Reflection</a>
|
|
18
|
+
| <a href="<%= user_maintenance_path(@user_id) %>">Maintenance</a>
|
|
19
|
+
| <a href="<%= user_forget_log_path(@user_id) %>">Forget log</a>
|
|
20
|
+
</p>
|
|
13
21
|
<p>Sessions: <%= @sessions.join(", ") %></p>
|
|
22
|
+
<% if @sessions.any? %>
|
|
23
|
+
<p>
|
|
24
|
+
<strong>Working memory:</strong>
|
|
25
|
+
<% @sessions.each do |s| %>
|
|
26
|
+
<a href="<%= user_working_path(@user_id, s) %>"><%= s %></a>
|
|
27
|
+
<% end %>
|
|
28
|
+
</p>
|
|
29
|
+
<% end %>
|
|
14
30
|
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<div class="card">
|
|
2
|
+
<h2>Working memory: <%= @user_id %> / <%= @session_id %></h2>
|
|
3
|
+
<% if @slots.blank? %>
|
|
4
|
+
<p class="empty">No slots set.</p>
|
|
5
|
+
<% else %>
|
|
6
|
+
<table>
|
|
7
|
+
<thead>
|
|
8
|
+
<tr><th>Slot</th><th>Value</th></tr>
|
|
9
|
+
</thead>
|
|
10
|
+
<tbody>
|
|
11
|
+
<% @slots.each do |slot, value| %>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><code><%= slot %></code></td>
|
|
14
|
+
<td><pre><%= value.is_a?(String) ? value : value.inspect %></pre></td>
|
|
15
|
+
</tr>
|
|
16
|
+
<% end %>
|
|
17
|
+
</tbody>
|
|
18
|
+
</table>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -9,4 +9,18 @@ Llmemory::Dashboard::Engine.routes.draw do
|
|
|
9
9
|
get "u/:user_id/graph", to: "graph#index", as: :user_graph
|
|
10
10
|
get "u/:user_id/stats", to: "stats#index", as: :user_stats
|
|
11
11
|
get "search", to: "search#index", as: :search
|
|
12
|
+
|
|
13
|
+
# Cognitive memory (CoALA) surface
|
|
14
|
+
get "u/:user_id/episodic", to: "episodic#index", as: :user_episodic
|
|
15
|
+
post "u/:user_id/episodic/forget", to: "episodic#forget", as: :forget_user_episodic
|
|
16
|
+
get "u/:user_id/procedural", to: "procedural#index", as: :user_procedural
|
|
17
|
+
post "u/:user_id/procedural/forget", to: "procedural#forget", as: :forget_user_procedural
|
|
18
|
+
get "u/:user_id/working/:session_id", to: "working#show", as: :user_working
|
|
19
|
+
get "u/:user_id/reflection", to: "reflection#show", as: :user_reflection
|
|
20
|
+
post "u/:user_id/reflection/run", to: "reflection#run", as: :run_user_reflection
|
|
21
|
+
get "u/:user_id/forget_log", to: "forget_log#show", as: :user_forget_log
|
|
22
|
+
get "u/:user_id/maintenance", to: "maintenance#show", as: :user_maintenance
|
|
23
|
+
post "u/:user_id/maintenance/run", to: "maintenance#run", as: :run_user_maintenance
|
|
24
|
+
post "u/:user_id/maintenance/mine", to: "maintenance#mine", as: :mine_user_maintenance
|
|
25
|
+
post "u/:user_id/maintenance/register", to: "maintenance#register", as: :register_user_maintenance
|
|
12
26
|
end
|
|
@@ -36,6 +36,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
|
|
|
36
36
|
t.string :user_id, null: false
|
|
37
37
|
t.jsonb :data, null: false, default: {}
|
|
38
38
|
t.text :search_text
|
|
39
|
+
t.datetime :archived_at
|
|
39
40
|
t.timestamps
|
|
40
41
|
end
|
|
41
42
|
add_index :llmemory_episodes, :user_id
|
|
@@ -46,6 +47,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
|
|
|
46
47
|
t.string :user_id, null: false
|
|
47
48
|
t.jsonb :data, null: false, default: {}
|
|
48
49
|
t.text :search_text
|
|
50
|
+
t.datetime :archived_at
|
|
49
51
|
t.timestamps
|
|
50
52
|
end
|
|
51
53
|
add_index :llmemory_skills, :user_id
|