claude_memory 0.12.0 → 0.13.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/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +44 -48
- data/.claude/settings.local.json +2 -1
- data/.claude-plugin/marketplace.json +2 -2
- data/.claude-plugin/plugin.json +3 -5
- data/CHANGELOG.md +52 -0
- data/CLAUDE.md +13 -8
- data/README.md +46 -0
- data/db/migrations/019_add_observations.rb +43 -0
- data/db/migrations/020_add_observation_promotion.rb +33 -0
- data/docs/GETTING_STARTED.md +38 -0
- data/docs/api_stability.md +23 -7
- data/docs/architecture.md +18 -6
- data/docs/audit_runbook.md +67 -0
- data/docs/dashboard.md +28 -0
- data/docs/improvements.md +94 -1
- data/docs/influence/mastra-observational-memory.md +198 -0
- data/docs/influence/strands-agent-sops.md +163 -0
- data/docs/quality_review.md +45 -0
- data/docs/soak/audit_2026-06-03_agent-training-program.json +53 -0
- data/docs/soak/audit_2026-06-03_agentic.json +31 -0
- data/docs/soak/audit_2026-06-03_ai-software-architect.json +19 -0
- data/docs/soak/audit_2026-06-03_chaos_to_the_rescue.json +60 -0
- data/docs/soak/audit_2026-06-03_claude_memory.json +55 -0
- data/docs/soak/audit_2026-06-03_daily-vibe.json +59 -0
- data/docs/soak/audit_2026-06-03_minerva-sky.json +19 -0
- data/docs/soak/audit_2026-06-03_nowreading.dev.json +19 -0
- data/docs/soak/audit_2026-06-03_ups.dev.json +55 -0
- data/docs/soak/baseline_2026-06-03.md +145 -0
- data/lib/claude_memory/audit/checks.rb +149 -0
- data/lib/claude_memory/audit/runner.rb +4 -0
- data/lib/claude_memory/commands/census_command.rb +1 -1
- data/lib/claude_memory/commands/checks/embeddings_check.rb +97 -0
- data/lib/claude_memory/commands/doctor_command.rb +1 -0
- data/lib/claude_memory/commands/hook_command.rb +16 -3
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +3 -1
- data/lib/claude_memory/commands/install_skill_command.rb +4 -0
- data/lib/claude_memory/commands/observations_command.rb +367 -0
- data/lib/claude_memory/commands/registry.rb +2 -0
- data/lib/claude_memory/commands/setup_vectors_command.rb +182 -0
- data/lib/claude_memory/commands/skills/reflect.md +68 -0
- data/lib/claude_memory/commands/stats_command.rb +60 -1
- data/lib/claude_memory/dashboard/api.rb +4 -0
- data/lib/claude_memory/dashboard/index.html +154 -2
- data/lib/claude_memory/dashboard/observations.rb +115 -0
- data/lib/claude_memory/dashboard/server.rb +1 -0
- data/lib/claude_memory/distill/extraction.rb +6 -4
- data/lib/claude_memory/distill/null_distiller.rb +86 -3
- data/lib/claude_memory/distill/reference_material_detector.rb +4 -1
- data/lib/claude_memory/domain/observation.rb +118 -0
- data/lib/claude_memory/embeddings/generator.rb +1 -1
- data/lib/claude_memory/hook/context_injector.rb +100 -2
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +113 -2
- data/lib/claude_memory/mcp/handlers/query_handlers.rb +48 -1
- data/lib/claude_memory/mcp/instructions_builder.rb +1 -0
- data/lib/claude_memory/mcp/query_guide.rb +28 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +58 -0
- data/lib/claude_memory/mcp/tools.rb +3 -0
- data/lib/claude_memory/observe/observations_renderer.rb +49 -0
- data/lib/claude_memory/observe/reflector.rb +91 -0
- data/lib/claude_memory/publish.rb +53 -1
- data/lib/claude_memory/resolve/resolver.rb +45 -8
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +181 -0
- data/lib/claude_memory/sweep/maintenance.rb +15 -1
- data/lib/claude_memory/sweep/sweeper.rb +7 -1
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +7 -0
- metadata +23 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Observe
|
|
5
|
+
# Deterministic, free (no LLM) Reflector for the episodic observation log.
|
|
6
|
+
#
|
|
7
|
+
# Runs inside Sweep, which fires on PreCompact and SessionEnd — Claude
|
|
8
|
+
# Code's context-pressure lifecycle events. That is the analog of Mastra's
|
|
9
|
+
# token-threshold-triggered reflection: "reflect when memory gets big" maps
|
|
10
|
+
# onto "reflect when the session is about to compact", without a wall-clock
|
|
11
|
+
# timer (Claude Code has no cron hook) and without extra API cost.
|
|
12
|
+
#
|
|
13
|
+
# Two passes, both provenance-preserving (tombstone, never hard-delete):
|
|
14
|
+
# - dedupe: collapse near-identical active observations (same scope,
|
|
15
|
+
# normalized body) into the newest, linking losers via consolidated_into.
|
|
16
|
+
# - expire_stale_info: retire info-level (🟢 / priority 3) observations
|
|
17
|
+
# older than the TTL to bound context size. Important (🔴) and maybe
|
|
18
|
+
# (🟡) are never expired — only the lowest-signal tier ages out.
|
|
19
|
+
#
|
|
20
|
+
# Semantic consolidation ("combine related items, surface patterns") is
|
|
21
|
+
# deliberately NOT here — it needs the LLM and lands in the Phase-4
|
|
22
|
+
# Claude-as-reflector pass. This pass is pure Ruby so it can run shell-side
|
|
23
|
+
# in the sweep hook for free.
|
|
24
|
+
class Reflector
|
|
25
|
+
DEFAULT_INFO_TTL_DAYS = 30
|
|
26
|
+
|
|
27
|
+
# @return [Struct] counts from one reflection pass
|
|
28
|
+
Result = Struct.new(:deduped, :expired) do
|
|
29
|
+
def total
|
|
30
|
+
deduped + expired
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(store, info_ttl_days: DEFAULT_INFO_TTL_DAYS)
|
|
35
|
+
@store = store
|
|
36
|
+
@info_ttl_days = info_ttl_days
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Result] number of observations deduped and expired
|
|
40
|
+
def reflect!
|
|
41
|
+
deduped = 0
|
|
42
|
+
expired = 0
|
|
43
|
+
@store.db.transaction do
|
|
44
|
+
deduped = dedupe
|
|
45
|
+
expired = expire_stale_info
|
|
46
|
+
end
|
|
47
|
+
Result.new(deduped: deduped, expired: expired)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def dedupe
|
|
53
|
+
active = @store.observations.where(status: "active").order(:id).all
|
|
54
|
+
merged = 0
|
|
55
|
+
|
|
56
|
+
active.group_by { |o| [o[:scope], normalize(o[:body])] }.each_value do |rows|
|
|
57
|
+
next if rows.size < 2
|
|
58
|
+
|
|
59
|
+
keeper = rows.max_by { |r| [r[:observed_at].to_s, r[:id]] }
|
|
60
|
+
rows.each do |loser|
|
|
61
|
+
next if loser[:id] == keeper[:id]
|
|
62
|
+
# Fold the loser's sightings into the keeper before tombstoning so
|
|
63
|
+
# corroboration survives consolidation and can cross the promotion
|
|
64
|
+
# threshold. A duplicate IS a repeated sighting.
|
|
65
|
+
@store.increment_corroboration(keeper[:id], by: loser[:corroboration_count] || 1)
|
|
66
|
+
@store.tombstone_observation(loser[:id], into_id: keeper[:id])
|
|
67
|
+
merged += 1
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
merged
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def expire_stale_info
|
|
75
|
+
cutoff = (Time.now - @info_ttl_days * 86400).utc.iso8601
|
|
76
|
+
ids = @store.observations
|
|
77
|
+
.where(status: "active", priority: Domain::Observation::INFO)
|
|
78
|
+
.where { observed_at < cutoff }
|
|
79
|
+
.select(:id)
|
|
80
|
+
.map { |r| r[:id] }
|
|
81
|
+
|
|
82
|
+
ids.each { |id| @store.expire_observation(id) }
|
|
83
|
+
ids.size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize(body)
|
|
87
|
+
body.to_s.downcase.gsub(/\s+/, " ").strip
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -9,6 +9,8 @@ module ClaudeMemory
|
|
|
9
9
|
class Publish
|
|
10
10
|
RULES_DIR = ".claude/rules"
|
|
11
11
|
GENERATED_FILE = "claude_memory.generated.md"
|
|
12
|
+
OBSERVATIONS_FILE = "claude_memory.observations.md"
|
|
13
|
+
MAX_PUBLISHED_OBSERVATIONS = 50
|
|
12
14
|
|
|
13
15
|
# @param store [Store::SQLiteStore] database store for reading facts
|
|
14
16
|
# @param file_system [Infrastructure::FileSystem] filesystem abstraction for I/O
|
|
@@ -45,7 +47,7 @@ module ClaudeMemory
|
|
|
45
47
|
path = output_path(mode, rules_dir: rules_dir)
|
|
46
48
|
body = generate_body(since: since)
|
|
47
49
|
|
|
48
|
-
if should_write?(path, body)
|
|
50
|
+
result = if should_write?(path, body)
|
|
49
51
|
content = generate_snapshot(since: since)
|
|
50
52
|
@fs.write(path, content)
|
|
51
53
|
ensure_import_exists(mode, path, rules_dir: rules_dir)
|
|
@@ -53,10 +55,60 @@ module ClaudeMemory
|
|
|
53
55
|
else
|
|
54
56
|
{status: :unchanged, path: path}
|
|
55
57
|
end
|
|
58
|
+
|
|
59
|
+
# The episodic observation log is published as a sibling artifact, not
|
|
60
|
+
# imported into CLAUDE.md — it reaches the session via the SessionStart
|
|
61
|
+
# context hook (Block 1). The file is a durable, diff-able snapshot.
|
|
62
|
+
publish_observations!(mode: mode, rules_dir: rules_dir)
|
|
63
|
+
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Write the episodic observation log to .claude/rules/ when there are
|
|
68
|
+
# observations to show. Mirrors publish!'s change-detection so the file is
|
|
69
|
+
# only rewritten when its body changes.
|
|
70
|
+
#
|
|
71
|
+
# @return [Hash] :status (:updated, :unchanged, or :empty) and :path
|
|
72
|
+
def publish_observations!(mode: :shared, rules_dir: nil)
|
|
73
|
+
body = observations_body
|
|
74
|
+
path = observations_path(mode, rules_dir: rules_dir)
|
|
75
|
+
return {status: :empty, path: path} if body.nil?
|
|
76
|
+
|
|
77
|
+
if should_write?(path, body)
|
|
78
|
+
@fs.write(path, observations_snapshot(body))
|
|
79
|
+
{status: :updated, path: path}
|
|
80
|
+
else
|
|
81
|
+
{status: :unchanged, path: path}
|
|
82
|
+
end
|
|
56
83
|
end
|
|
57
84
|
|
|
58
85
|
private
|
|
59
86
|
|
|
87
|
+
def observations_path(mode, rules_dir: nil)
|
|
88
|
+
dir = if mode == :home
|
|
89
|
+
File.join(Dir.home, ".claude", "claude_memory")
|
|
90
|
+
else
|
|
91
|
+
rules_dir || RULES_DIR
|
|
92
|
+
end
|
|
93
|
+
File.join(dir, OBSERVATIONS_FILE)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def observations_body
|
|
97
|
+
rows = @store.recent_observations(limit: MAX_PUBLISHED_OBSERVATIONS)
|
|
98
|
+
Observe::ObservationsRenderer.render(rows, title: "Project Observations")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def observations_snapshot(body)
|
|
102
|
+
<<~HEADER + body
|
|
103
|
+
<!--
|
|
104
|
+
This file is auto-generated by claude-memory.
|
|
105
|
+
Do not edit manually - changes will be overwritten.
|
|
106
|
+
Generated: #{Time.now.utc.iso8601}
|
|
107
|
+
-->
|
|
108
|
+
|
|
109
|
+
HEADER
|
|
110
|
+
end
|
|
111
|
+
|
|
60
112
|
def output_path(mode, rules_dir: nil)
|
|
61
113
|
case mode
|
|
62
114
|
when :shared
|
|
@@ -27,7 +27,8 @@ module ClaudeMemory
|
|
|
27
27
|
# @param project_path [String, nil] project path for scoped facts
|
|
28
28
|
# @param scope [String] default scope for facts ("project" or "global")
|
|
29
29
|
# @return [Hash] counts keyed by :entities_created, :facts_created,
|
|
30
|
-
# :facts_superseded, :conflicts_created, :provenance_created
|
|
30
|
+
# :facts_superseded, :conflicts_created, :provenance_created,
|
|
31
|
+
# :observations_created, plus :fact_ids (see below)
|
|
31
32
|
def apply(extraction, content_item_id: nil, occurred_at: nil, project_path: nil, scope: "project")
|
|
32
33
|
occurred_at ||= Time.now.utc.iso8601
|
|
33
34
|
|
|
@@ -36,7 +37,14 @@ module ClaudeMemory
|
|
|
36
37
|
facts_created: 0,
|
|
37
38
|
facts_superseded: 0,
|
|
38
39
|
conflicts_created: 0,
|
|
39
|
-
provenance_created: 0
|
|
40
|
+
provenance_created: 0,
|
|
41
|
+
observations_created: 0,
|
|
42
|
+
# Ids of the facts each input touched (insert/reinforce/conflict),
|
|
43
|
+
# positionally aligned with extraction.facts — so callers like the
|
|
44
|
+
# promotion bridge don't have to re-query for what the resolver
|
|
45
|
+
# already knows. Entries are `nil` where the fact was discarded or
|
|
46
|
+
# lost a conflict, so consumers wanting only real ids must `.compact`.
|
|
47
|
+
fact_ids: []
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
# Wrap entire extraction in a single transaction for better concurrency
|
|
@@ -52,7 +60,16 @@ module ClaudeMemory
|
|
|
52
60
|
result[:facts_superseded] += outcome[:superseded]
|
|
53
61
|
result[:conflicts_created] += outcome[:conflicts]
|
|
54
62
|
result[:provenance_created] += outcome[:provenance]
|
|
63
|
+
result[:fact_ids] << outcome[:fact_id]
|
|
55
64
|
end
|
|
65
|
+
|
|
66
|
+
# Episodic layer: persist observations alongside facts. Append-only,
|
|
67
|
+
# no truth maintenance — observations accumulate; the Reflector
|
|
68
|
+
# consolidates them later. Older extractions (and the empty default)
|
|
69
|
+
# carry no observations, so fact behavior is unchanged.
|
|
70
|
+
result[:observations_created] = persist_observations(
|
|
71
|
+
extraction, content_item_id, occurred_at, project_path: project_path, scope: scope
|
|
72
|
+
)
|
|
56
73
|
end
|
|
57
74
|
|
|
58
75
|
result
|
|
@@ -60,6 +77,25 @@ module ClaudeMemory
|
|
|
60
77
|
|
|
61
78
|
private
|
|
62
79
|
|
|
80
|
+
# Write each observation candidate from the extraction as an episodic
|
|
81
|
+
# row. scope_hint is advisory and never overrides the resolver-determined
|
|
82
|
+
# scope (the same discipline facts follow).
|
|
83
|
+
def persist_observations(extraction, content_item_id, occurred_at, project_path:, scope:)
|
|
84
|
+
candidates = extraction.respond_to?(:observations) ? extraction.observations : []
|
|
85
|
+
candidates.each do |obs|
|
|
86
|
+
@store.insert_observation(
|
|
87
|
+
body: obs[:body],
|
|
88
|
+
kind: obs[:kind] || "event",
|
|
89
|
+
priority: obs[:priority] || Domain::Observation::INFO,
|
|
90
|
+
scope: scope,
|
|
91
|
+
project_path: (scope == "global") ? nil : project_path,
|
|
92
|
+
source_content_item_id: content_item_id,
|
|
93
|
+
observed_at: occurred_at
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
candidates.size
|
|
97
|
+
end
|
|
98
|
+
|
|
63
99
|
def resolve_entities(entities)
|
|
64
100
|
entity_ids = {}
|
|
65
101
|
entities.uniq { |e| [e[:type], e[:name]] }.each do |e|
|
|
@@ -162,7 +198,7 @@ module ClaudeMemory
|
|
|
162
198
|
# example text against a single-cardinality predicate that
|
|
163
199
|
# already has a confirmed value. Returning zero across the
|
|
164
200
|
# board keeps the resolver-result accounting consistent.
|
|
165
|
-
{created: 0, superseded: 0, conflicts: 0, provenance: 0}
|
|
201
|
+
{created: 0, superseded: 0, conflicts: 0, provenance: 0, fact_id: nil}
|
|
166
202
|
else
|
|
167
203
|
apply_insert(fact_data, subject_id, entity_ids, content_item_id, occurred_at, existing_facts, resolution,
|
|
168
204
|
project_path: project_path, scope: scope)
|
|
@@ -173,7 +209,7 @@ module ClaudeMemory
|
|
|
173
209
|
object_entity_id = entity_ids[fact_data[:object]]
|
|
174
210
|
matching = existing_facts.find { |f| values_match?(f, fact_data[:object], object_entity_id) }
|
|
175
211
|
add_provenance(matching[:id], content_item_id, fact_data)
|
|
176
|
-
{created: 0, superseded: 0, conflicts: 0, provenance: 1}
|
|
212
|
+
{created: 0, superseded: 0, conflicts: 0, provenance: 1, fact_id: matching[:id]}
|
|
177
213
|
end
|
|
178
214
|
|
|
179
215
|
def apply_conflict(existing_facts, fact_data, subject_id, content_item_id, occurred_at, project_path:, scope:)
|
|
@@ -188,12 +224,12 @@ module ClaudeMemory
|
|
|
188
224
|
matching = existing_disputed.find { |f| values_match?(f, fact_data[:object], nil) }
|
|
189
225
|
if matching
|
|
190
226
|
add_provenance(matching[:id], content_item_id, fact_data)
|
|
191
|
-
return {created: 0, superseded: 0, conflicts: 0, provenance: 1}
|
|
227
|
+
return {created: 0, superseded: 0, conflicts: 0, provenance: 1, fact_id: matching[:id]}
|
|
192
228
|
end
|
|
193
229
|
|
|
194
|
-
create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at,
|
|
230
|
+
disputed_fact_id = create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at,
|
|
195
231
|
project_path: project_path, scope: scope)
|
|
196
|
-
{created: 0, superseded: 0, conflicts: 1, provenance: 0}
|
|
232
|
+
{created: 0, superseded: 0, conflicts: 1, provenance: 0, fact_id: disputed_fact_id}
|
|
197
233
|
end
|
|
198
234
|
|
|
199
235
|
def apply_insert(fact_data, subject_id, entity_ids, content_item_id, occurred_at, existing_facts, resolution, project_path:, scope:)
|
|
@@ -208,7 +244,7 @@ module ClaudeMemory
|
|
|
208
244
|
link_superseded_facts(fact_id, existing_facts) if superseded_count > 0
|
|
209
245
|
add_provenance(fact_id, content_item_id, fact_data)
|
|
210
246
|
|
|
211
|
-
{created: 1, superseded: superseded_count, conflicts: 0, provenance: 1}
|
|
247
|
+
{created: 1, superseded: superseded_count, conflicts: 0, provenance: 1, fact_id: fact_id}
|
|
212
248
|
end
|
|
213
249
|
|
|
214
250
|
def insert_new_fact(fact_data, subject_id, entity_ids, occurred_at, project_path:, scope:)
|
|
@@ -278,6 +314,7 @@ module ClaudeMemory
|
|
|
278
314
|
)
|
|
279
315
|
|
|
280
316
|
add_provenance(new_fact_id, content_item_id, new_fact_data)
|
|
317
|
+
new_fact_id
|
|
281
318
|
end
|
|
282
319
|
|
|
283
320
|
def add_provenance(fact_id, content_item_id, fact_data)
|
|
@@ -120,6 +120,9 @@ module ClaudeMemory
|
|
|
120
120
|
# @return [Sequel::Dataset]
|
|
121
121
|
def otel_traces = @db[:otel_traces]
|
|
122
122
|
|
|
123
|
+
# @return [Sequel::Dataset]
|
|
124
|
+
def observations = @db[:observations]
|
|
125
|
+
|
|
123
126
|
# Upsert a thumbs-up/down verdict for a moment. One row per event_id
|
|
124
127
|
# (unique constraint on the column) — repeat clicks overwrite. Returns
|
|
125
128
|
# the persisted row.
|
|
@@ -680,6 +683,184 @@ module ClaudeMemory
|
|
|
680
683
|
.all
|
|
681
684
|
end
|
|
682
685
|
|
|
686
|
+
# --- Observations (episodic layer) ---
|
|
687
|
+
|
|
688
|
+
# Insert an episodic observation. token_count is estimated from the body
|
|
689
|
+
# when not supplied (rough ~4 chars/token) so Phase 2 budget math has a
|
|
690
|
+
# value to work with.
|
|
691
|
+
#
|
|
692
|
+
# @param body [String] dense narrative text (required)
|
|
693
|
+
# @param kind [String] one of Domain::Observation::KINDS
|
|
694
|
+
# @param priority [Integer] 1=important, 2=maybe, 3=info
|
|
695
|
+
# @param scope [String] "project" or "global"
|
|
696
|
+
# @param project_path [String, nil] project directory for project-scoped rows
|
|
697
|
+
# @param source_content_item_id [Integer, nil] provenance link to the raw chunk
|
|
698
|
+
# @param session_id [String, nil] session that produced the observation
|
|
699
|
+
# @param observed_at [String, nil] ISO 8601 event time (defaults to now UTC)
|
|
700
|
+
# @param token_count [Integer, nil] precomputed token estimate
|
|
701
|
+
# @return [Integer] inserted observation row id
|
|
702
|
+
def insert_observation(body:, kind: "event", priority: 3, scope: "project",
|
|
703
|
+
project_path: nil, source_content_item_id: nil, session_id: nil,
|
|
704
|
+
observed_at: nil, token_count: nil)
|
|
705
|
+
now = Time.now.utc.iso8601
|
|
706
|
+
with_retry("insert_observation") do
|
|
707
|
+
observations.insert(
|
|
708
|
+
body: body,
|
|
709
|
+
kind: kind,
|
|
710
|
+
priority: priority,
|
|
711
|
+
scope: scope,
|
|
712
|
+
project_path: project_path,
|
|
713
|
+
source_content_item_id: source_content_item_id,
|
|
714
|
+
token_count: token_count || (body.length / 4.0).ceil,
|
|
715
|
+
status: "active",
|
|
716
|
+
session_id: session_id,
|
|
717
|
+
observed_at: observed_at || now,
|
|
718
|
+
created_at: now
|
|
719
|
+
)
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Fetch active observations, newest first. Used by the memory.observations
|
|
724
|
+
# MCP tool and (later) the stable-prefix injection.
|
|
725
|
+
#
|
|
726
|
+
# @param scope [String, nil] filter by "project"/"global"; nil for any
|
|
727
|
+
# @param limit [Integer] maximum rows to return
|
|
728
|
+
# @param min_priority [Integer, nil] only rows with priority <= this
|
|
729
|
+
# (1 returns only 🔴; nil returns all)
|
|
730
|
+
# @return [Array<Hash>]
|
|
731
|
+
def recent_observations(scope: nil, limit: 20, min_priority: nil)
|
|
732
|
+
ds = observations.where(status: "active")
|
|
733
|
+
ds = ds.where(scope: scope) if scope
|
|
734
|
+
ds = ds.where { priority <= min_priority } if min_priority
|
|
735
|
+
ds.order(Sequel.desc(:observed_at), Sequel.desc(:id)).limit(limit).all
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Tombstone an observation by pointing it at the consolidated row that
|
|
739
|
+
# replaced it (append-only supersession — the row is preserved, not
|
|
740
|
+
# deleted, mirroring fact_links). Used by the Reflector.
|
|
741
|
+
#
|
|
742
|
+
# @param observation_id [Integer] the superseded observation
|
|
743
|
+
# @param into_id [Integer] the consolidated observation it was merged into
|
|
744
|
+
# @return [Boolean] true if a row was updated
|
|
745
|
+
def tombstone_observation(observation_id, into_id:)
|
|
746
|
+
now = Time.now.utc.iso8601
|
|
747
|
+
updated = observations.where(id: observation_id).update(
|
|
748
|
+
status: "consolidated", consolidated_into: into_id, reflected_at: now
|
|
749
|
+
)
|
|
750
|
+
updated > 0
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Retire a stale observation (status "expired") without a consolidation
|
|
754
|
+
# target. Append-only — the row is preserved for provenance, just
|
|
755
|
+
# excluded from active recall. Used by the Reflector's TTL pass.
|
|
756
|
+
#
|
|
757
|
+
# @param observation_id [Integer]
|
|
758
|
+
# @return [Boolean] true if a row was updated
|
|
759
|
+
def expire_observation(observation_id)
|
|
760
|
+
now = Time.now.utc.iso8601
|
|
761
|
+
updated = observations.where(id: observation_id).update(status: "expired", reflected_at: now)
|
|
762
|
+
updated > 0
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Fold a duplicate's sighting count into the keeper. Called by the
|
|
766
|
+
# Reflector's dedup pass so corroboration survives consolidation — the
|
|
767
|
+
# signal the promotion gate keys off.
|
|
768
|
+
#
|
|
769
|
+
# @param observation_id [Integer] keeper observation
|
|
770
|
+
# @param by [Integer] how much to add (the loser's corroboration_count)
|
|
771
|
+
# @return [void]
|
|
772
|
+
def increment_corroboration(observation_id, by: 1)
|
|
773
|
+
observations.where(id: observation_id)
|
|
774
|
+
.update(corroboration_count: Sequel[:corroboration_count] + by)
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Mark an observation as promoted to a structured fact. Append-only: the
|
|
778
|
+
# row is preserved (provenance), it just stops being a promotion
|
|
779
|
+
# candidate.
|
|
780
|
+
#
|
|
781
|
+
# @param observation_id [Integer]
|
|
782
|
+
# @param fact_id [Integer] the fact this observation was promoted into
|
|
783
|
+
# @return [Boolean] true if a row was updated
|
|
784
|
+
def mark_observation_promoted(observation_id, fact_id:)
|
|
785
|
+
now = Time.now.utc.iso8601
|
|
786
|
+
updated = observations.where(id: observation_id)
|
|
787
|
+
.update(promoted_at: now, promoted_fact_id: fact_id, reflected_at: now)
|
|
788
|
+
updated > 0
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# Semantic consolidation: merge several related observations into one
|
|
792
|
+
# synthesized observation, atomically. The new row carries the *summed*
|
|
793
|
+
# corroboration of its sources (combined sighting weight, which can tip it
|
|
794
|
+
# over the promotion threshold); each source is tombstoned into it. This
|
|
795
|
+
# is the Claude-as-reflector counterpart to the deterministic dedup — it
|
|
796
|
+
# collapses observations that say the same thing in different words, which
|
|
797
|
+
# exact-match dedup can't.
|
|
798
|
+
#
|
|
799
|
+
# @param from_ids [Array<Integer>] source observation ids (need >= 2 active in scope)
|
|
800
|
+
# @param body [String] the synthesized observation text
|
|
801
|
+
# @return [Hash, nil] {id:, merged:, corroboration_count:}, or nil when
|
|
802
|
+
# fewer than two of the ids are active in this scope
|
|
803
|
+
def consolidate_observations(from_ids, body:, kind: "event", priority: 3, scope: "project",
|
|
804
|
+
project_path: nil, source_content_item_id: nil, observed_at: nil)
|
|
805
|
+
with_retry("consolidate_observations") do
|
|
806
|
+
@db.transaction do
|
|
807
|
+
# Read the source set *inside* the transaction so the rows we sum
|
|
808
|
+
# corroboration from are the same rows we tombstone — otherwise two
|
|
809
|
+
# reflectors (PreCompact + SessionEnd) could each read the same
|
|
810
|
+
# active sources and double-count or re-tombstone them.
|
|
811
|
+
sources = observations
|
|
812
|
+
.where(id: from_ids, status: "active", scope: scope)
|
|
813
|
+
.select(:id, :corroboration_count)
|
|
814
|
+
.all
|
|
815
|
+
next nil if sources.size < 2
|
|
816
|
+
|
|
817
|
+
now = Time.now.utc.iso8601
|
|
818
|
+
combined = sources.sum { |s| s[:corroboration_count] || 1 }
|
|
819
|
+
|
|
820
|
+
new_id = observations.insert(
|
|
821
|
+
body: body, kind: kind, priority: priority, scope: scope, project_path: project_path,
|
|
822
|
+
source_content_item_id: source_content_item_id,
|
|
823
|
+
token_count: (body.length / 4.0).ceil, corroboration_count: combined,
|
|
824
|
+
status: "active", observed_at: observed_at || now, created_at: now
|
|
825
|
+
)
|
|
826
|
+
# Re-assert `active` on the update so a source consolidated by a
|
|
827
|
+
# racing writer between read and write is not tombstoned twice.
|
|
828
|
+
observations.where(id: sources.map { |s| s[:id] }, status: "active")
|
|
829
|
+
.update(status: "consolidated", consolidated_into: new_id, reflected_at: now)
|
|
830
|
+
{id: new_id, merged: sources.size, corroboration_count: combined}
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# Active, not-yet-promoted observations corroborated at least
|
|
836
|
+
# `min_corroboration` times — i.e. eligible for promotion to a fact.
|
|
837
|
+
# Highest corroboration first.
|
|
838
|
+
#
|
|
839
|
+
# @param scope [String, nil] filter by scope; nil for any
|
|
840
|
+
# @param min_corroboration [Integer] sightings required (the gate)
|
|
841
|
+
# @param limit [Integer]
|
|
842
|
+
# @return [Array<Hash>]
|
|
843
|
+
def promotion_candidates(scope: nil, min_corroboration: 2, limit: 10)
|
|
844
|
+
ds = observations.where(status: "active", promoted_at: nil)
|
|
845
|
+
ds = ds.where(scope: scope) if scope
|
|
846
|
+
ds.where { corroboration_count >= min_corroboration }
|
|
847
|
+
.order(Sequel.desc(:corroboration_count), Sequel.desc(:observed_at))
|
|
848
|
+
.limit(limit)
|
|
849
|
+
.all
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# Observations that were promoted into the given fact — the reverse of
|
|
853
|
+
# promoted_fact_id, for fact→observation provenance.
|
|
854
|
+
#
|
|
855
|
+
# @param fact_id [Integer]
|
|
856
|
+
# @return [Array<Hash>]
|
|
857
|
+
def observations_for_fact(fact_id)
|
|
858
|
+
observations
|
|
859
|
+
.where(promoted_fact_id: fact_id)
|
|
860
|
+
.select(:id, :body, :kind, :corroboration_count, :observed_at)
|
|
861
|
+
.all
|
|
862
|
+
end
|
|
863
|
+
|
|
683
864
|
# --- Meta ---
|
|
684
865
|
|
|
685
866
|
# Set a key-value pair in the meta table (upsert).
|
|
@@ -20,7 +20,8 @@ module ClaudeMemory
|
|
|
20
20
|
mcp_tool_call_retention_days: 90,
|
|
21
21
|
otel_metric_retention_days: 30,
|
|
22
22
|
otel_event_retention_days: 14,
|
|
23
|
-
otel_trace_retention_days: 7
|
|
23
|
+
otel_trace_retention_days: 7,
|
|
24
|
+
observation_info_ttl_days: 30
|
|
24
25
|
}.freeze
|
|
25
26
|
|
|
26
27
|
attr_reader :store
|
|
@@ -394,6 +395,19 @@ module ClaudeMemory
|
|
|
394
395
|
result
|
|
395
396
|
end
|
|
396
397
|
|
|
398
|
+
# Run the deterministic observation Reflector (dedupe near-identical
|
|
399
|
+
# observations + expire stale info-level ones). Free, no LLM —
|
|
400
|
+
# provenance-preserving (tombstone, never delete).
|
|
401
|
+
# Returns: Hash {deduped:, expired:}
|
|
402
|
+
def reflect_observations
|
|
403
|
+
return {deduped: 0, expired: 0} unless @store.db.table_exists?(:observations)
|
|
404
|
+
|
|
405
|
+
result = ClaudeMemory::Observe::Reflector.new(
|
|
406
|
+
@store, info_ttl_days: @config[:observation_info_ttl_days]
|
|
407
|
+
).reflect!
|
|
408
|
+
{deduped: result.deduped, expired: result.expired}
|
|
409
|
+
end
|
|
410
|
+
|
|
397
411
|
# Run SQLite VACUUM to reclaim space.
|
|
398
412
|
# Returns: true
|
|
399
413
|
def vacuum
|
|
@@ -11,6 +11,7 @@ module ClaudeMemory
|
|
|
11
11
|
otel_metric_retention_days: 30,
|
|
12
12
|
otel_event_retention_days: 14,
|
|
13
13
|
otel_trace_retention_days: 7,
|
|
14
|
+
observation_info_ttl_days: 30,
|
|
14
15
|
default_budget_seconds: 5
|
|
15
16
|
}.freeze
|
|
16
17
|
|
|
@@ -51,6 +52,11 @@ module ClaudeMemory
|
|
|
51
52
|
run_if_within_budget { @stats[:otel_traces_pruned] = maintenance.prune_old_otel_traces }
|
|
52
53
|
run_if_within_budget { @stats[:vec_backfilled] = maintenance.backfill_vec_index }
|
|
53
54
|
run_if_within_budget { @stats[:vec_cleaned] = maintenance.cleanup_vec_expired }
|
|
55
|
+
run_if_within_budget do
|
|
56
|
+
reflection = maintenance.reflect_observations
|
|
57
|
+
@stats[:observations_deduped] = reflection[:deduped]
|
|
58
|
+
@stats[:observations_expired] = reflection[:expired]
|
|
59
|
+
end
|
|
54
60
|
run_if_within_budget { @stats[:wal_checkpointed] = maintenance.checkpoint_wal }
|
|
55
61
|
|
|
56
62
|
@stats[:elapsed_seconds] = Time.now - @start_time
|
|
@@ -110,7 +116,7 @@ module ClaudeMemory
|
|
|
110
116
|
content_retention_days: @config[:content_retention_days] / 2
|
|
111
117
|
}
|
|
112
118
|
else
|
|
113
|
-
@config.slice(:proposed_fact_ttl_days, :disputed_fact_ttl_days, :content_retention_days)
|
|
119
|
+
@config.slice(:proposed_fact_ttl_days, :disputed_fact_ttl_days, :content_retention_days, :observation_info_ttl_days)
|
|
114
120
|
end
|
|
115
121
|
|
|
116
122
|
Maintenance.new(@store, config: config)
|
data/lib/claude_memory.rb
CHANGED
|
@@ -38,6 +38,7 @@ require_relative "claude_memory/commands/checks/claude_md_check"
|
|
|
38
38
|
require_relative "claude_memory/commands/checks/hooks_check"
|
|
39
39
|
require_relative "claude_memory/commands/checks/reporter"
|
|
40
40
|
require_relative "claude_memory/commands/checks/vec_check"
|
|
41
|
+
require_relative "claude_memory/commands/checks/embeddings_check"
|
|
41
42
|
require_relative "claude_memory/commands/checks/distill_check"
|
|
42
43
|
require_relative "claude_memory/commands/help_command"
|
|
43
44
|
require_relative "claude_memory/commands/version_command"
|
|
@@ -72,6 +73,7 @@ require_relative "claude_memory/commands/install_skill_command"
|
|
|
72
73
|
require_relative "claude_memory/commands/completion_command"
|
|
73
74
|
require_relative "claude_memory/commands/embeddings_command"
|
|
74
75
|
require_relative "claude_memory/commands/reject_command"
|
|
76
|
+
require_relative "claude_memory/commands/observations_command"
|
|
75
77
|
require_relative "claude_memory/commands/restore_command"
|
|
76
78
|
require_relative "claude_memory/commands/dedupe_conflicts_command"
|
|
77
79
|
require_relative "claude_memory/commands/reclassify_references_command"
|
|
@@ -94,6 +96,7 @@ require_relative "claude_memory/dashboard/trust"
|
|
|
94
96
|
require_relative "claude_memory/dashboard/knowledge"
|
|
95
97
|
require_relative "claude_memory/dashboard/reuse"
|
|
96
98
|
require_relative "claude_memory/dashboard/timeline"
|
|
99
|
+
require_relative "claude_memory/dashboard/observations"
|
|
97
100
|
require_relative "claude_memory/dashboard/health"
|
|
98
101
|
require_relative "claude_memory/dashboard/telemetry"
|
|
99
102
|
require_relative "claude_memory/dashboard/prompt_journey"
|
|
@@ -102,6 +105,7 @@ require_relative "claude_memory/dashboard/server"
|
|
|
102
105
|
require_relative "claude_memory/commands/digest_command"
|
|
103
106
|
require_relative "claude_memory/commands/show_command"
|
|
104
107
|
require_relative "claude_memory/commands/otel_command"
|
|
108
|
+
require_relative "claude_memory/commands/setup_vectors_command"
|
|
105
109
|
require_relative "claude_memory/commands/import_auto_memory_command"
|
|
106
110
|
require_relative "claude_memory/audit/finding"
|
|
107
111
|
require_relative "claude_memory/audit/checks"
|
|
@@ -120,6 +124,9 @@ require_relative "claude_memory/domain/fact"
|
|
|
120
124
|
require_relative "claude_memory/domain/entity"
|
|
121
125
|
require_relative "claude_memory/domain/provenance"
|
|
122
126
|
require_relative "claude_memory/domain/conflict"
|
|
127
|
+
require_relative "claude_memory/domain/observation"
|
|
128
|
+
require_relative "claude_memory/observe/observations_renderer"
|
|
129
|
+
require_relative "claude_memory/observe/reflector"
|
|
123
130
|
require_relative "claude_memory/embeddings/model_registry"
|
|
124
131
|
require_relative "claude_memory/embeddings/inspector"
|
|
125
132
|
require_relative "claude_memory/embeddings/generator"
|