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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +44 -48
  4. data/.claude/settings.local.json +2 -1
  5. data/.claude-plugin/marketplace.json +2 -2
  6. data/.claude-plugin/plugin.json +3 -5
  7. data/CHANGELOG.md +52 -0
  8. data/CLAUDE.md +13 -8
  9. data/README.md +46 -0
  10. data/db/migrations/019_add_observations.rb +43 -0
  11. data/db/migrations/020_add_observation_promotion.rb +33 -0
  12. data/docs/GETTING_STARTED.md +38 -0
  13. data/docs/api_stability.md +23 -7
  14. data/docs/architecture.md +18 -6
  15. data/docs/audit_runbook.md +67 -0
  16. data/docs/dashboard.md +28 -0
  17. data/docs/improvements.md +94 -1
  18. data/docs/influence/mastra-observational-memory.md +198 -0
  19. data/docs/influence/strands-agent-sops.md +163 -0
  20. data/docs/quality_review.md +45 -0
  21. data/docs/soak/audit_2026-06-03_agent-training-program.json +53 -0
  22. data/docs/soak/audit_2026-06-03_agentic.json +31 -0
  23. data/docs/soak/audit_2026-06-03_ai-software-architect.json +19 -0
  24. data/docs/soak/audit_2026-06-03_chaos_to_the_rescue.json +60 -0
  25. data/docs/soak/audit_2026-06-03_claude_memory.json +55 -0
  26. data/docs/soak/audit_2026-06-03_daily-vibe.json +59 -0
  27. data/docs/soak/audit_2026-06-03_minerva-sky.json +19 -0
  28. data/docs/soak/audit_2026-06-03_nowreading.dev.json +19 -0
  29. data/docs/soak/audit_2026-06-03_ups.dev.json +55 -0
  30. data/docs/soak/baseline_2026-06-03.md +145 -0
  31. data/lib/claude_memory/audit/checks.rb +149 -0
  32. data/lib/claude_memory/audit/runner.rb +4 -0
  33. data/lib/claude_memory/commands/census_command.rb +1 -1
  34. data/lib/claude_memory/commands/checks/embeddings_check.rb +97 -0
  35. data/lib/claude_memory/commands/doctor_command.rb +1 -0
  36. data/lib/claude_memory/commands/hook_command.rb +16 -3
  37. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +3 -1
  38. data/lib/claude_memory/commands/install_skill_command.rb +4 -0
  39. data/lib/claude_memory/commands/observations_command.rb +367 -0
  40. data/lib/claude_memory/commands/registry.rb +2 -0
  41. data/lib/claude_memory/commands/setup_vectors_command.rb +182 -0
  42. data/lib/claude_memory/commands/skills/reflect.md +68 -0
  43. data/lib/claude_memory/commands/stats_command.rb +60 -1
  44. data/lib/claude_memory/dashboard/api.rb +4 -0
  45. data/lib/claude_memory/dashboard/index.html +154 -2
  46. data/lib/claude_memory/dashboard/observations.rb +115 -0
  47. data/lib/claude_memory/dashboard/server.rb +1 -0
  48. data/lib/claude_memory/distill/extraction.rb +6 -4
  49. data/lib/claude_memory/distill/null_distiller.rb +86 -3
  50. data/lib/claude_memory/distill/reference_material_detector.rb +4 -1
  51. data/lib/claude_memory/domain/observation.rb +118 -0
  52. data/lib/claude_memory/embeddings/generator.rb +1 -1
  53. data/lib/claude_memory/hook/context_injector.rb +100 -2
  54. data/lib/claude_memory/mcp/handlers/management_handlers.rb +113 -2
  55. data/lib/claude_memory/mcp/handlers/query_handlers.rb +48 -1
  56. data/lib/claude_memory/mcp/instructions_builder.rb +1 -0
  57. data/lib/claude_memory/mcp/query_guide.rb +28 -0
  58. data/lib/claude_memory/mcp/tool_definitions.rb +58 -0
  59. data/lib/claude_memory/mcp/tools.rb +3 -0
  60. data/lib/claude_memory/observe/observations_renderer.rb +49 -0
  61. data/lib/claude_memory/observe/reflector.rb +91 -0
  62. data/lib/claude_memory/publish.rb +53 -1
  63. data/lib/claude_memory/resolve/resolver.rb +45 -8
  64. data/lib/claude_memory/store/schema_manager.rb +1 -1
  65. data/lib/claude_memory/store/sqlite_store.rb +181 -0
  66. data/lib/claude_memory/sweep/maintenance.rb +15 -1
  67. data/lib/claude_memory/sweep/sweeper.rb +7 -1
  68. data/lib/claude_memory/version.rb +1 -1
  69. data/lib/claude_memory.rb +7 -0
  70. 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)
@@ -5,7 +5,7 @@ module ClaudeMemory
5
5
  # Schema migration and version management for SQLiteStore.
6
6
  # Handles Sequel migrations, legacy version syncing, and initial setup.
7
7
  module SchemaManager
8
- SCHEMA_VERSION = 18
8
+ SCHEMA_VERSION = 20
9
9
 
10
10
  private
11
11
 
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeMemory
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.0"
5
5
  end
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"