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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Domain
5
+ # Domain model representing an episodic observation — "what happened",
6
+ # as opposed to a Fact's "what is true". Instances are immutable (frozen).
7
+ #
8
+ # Priority follows Mastra's traffic-light scheme and is an internal signal
9
+ # for the Observer/Reflector pipeline: 1 = important (🔴), 2 = maybe (🟡),
10
+ # 3 = info only (🟢). Only 🔴 is meant to survive into the actor's prompt.
11
+ class Observation
12
+ KINDS = %w[user_statement agent_action tool_result preference decision event].freeze
13
+ IMPORTANT = 1
14
+ MAYBE = 2
15
+ INFO = 3
16
+
17
+ # Minimum corroboration (repeated sightings) before an observation may be
18
+ # promoted to a structured fact. The anti-hallucination gate: a one-off
19
+ # mention never becomes a committed fact.
20
+ PROMOTION_THRESHOLD = 2
21
+
22
+ attr_reader :id, :body, :kind, :priority, :scope, :project_path,
23
+ :source_content_item_id, :consolidated_into, :token_count,
24
+ :status, :session_id, :observed_at, :created_at, :reflected_at,
25
+ :corroboration_count, :promoted_at, :promoted_fact_id
26
+
27
+ # @param attributes [Hash] observation attributes (see column list)
28
+ # @raise [ArgumentError] if body is blank or priority is out of range
29
+ def initialize(attributes)
30
+ @id = attributes[:id]
31
+ @body = attributes[:body]
32
+ @kind = attributes[:kind] || "event"
33
+ @priority = attributes[:priority] || INFO
34
+ @scope = attributes[:scope] || "project"
35
+ @project_path = attributes[:project_path]
36
+ @source_content_item_id = attributes[:source_content_item_id]
37
+ @consolidated_into = attributes[:consolidated_into]
38
+ @token_count = attributes[:token_count]
39
+ @status = attributes[:status] || "active"
40
+ @session_id = attributes[:session_id]
41
+ @observed_at = attributes[:observed_at]
42
+ @created_at = attributes[:created_at]
43
+ @reflected_at = attributes[:reflected_at]
44
+ @corroboration_count = attributes[:corroboration_count] || 1
45
+ @promoted_at = attributes[:promoted_at]
46
+ @promoted_fact_id = attributes[:promoted_fact_id]
47
+
48
+ validate!
49
+ freeze
50
+ end
51
+
52
+ # @return [Boolean] true when the observation has not been consolidated away
53
+ def active?
54
+ status == "active"
55
+ end
56
+
57
+ # @return [Boolean] true when the Reflector has merged this into another
58
+ def consolidated?
59
+ status == "consolidated"
60
+ end
61
+
62
+ # @return [Boolean] true when the Reflector retired this on TTL
63
+ def expired?
64
+ status == "expired"
65
+ end
66
+
67
+ # @return [Boolean] true once promoted into a structured fact
68
+ def promoted?
69
+ !promoted_at.nil?
70
+ end
71
+
72
+ # @return [Boolean] true when corroborated enough to be promotion-eligible
73
+ def corroborated?(threshold)
74
+ corroboration_count >= threshold
75
+ end
76
+
77
+ # @return [Boolean] true for 🔴 — the only priority shown to the actor
78
+ def important?
79
+ priority == IMPORTANT
80
+ end
81
+
82
+ # @return [Boolean] true when scope is "global"
83
+ def global?
84
+ scope == "global"
85
+ end
86
+
87
+ # @return [Hash] all attributes as a plain hash
88
+ def to_h
89
+ {
90
+ id: id,
91
+ body: body,
92
+ kind: kind,
93
+ priority: priority,
94
+ scope: scope,
95
+ project_path: project_path,
96
+ source_content_item_id: source_content_item_id,
97
+ consolidated_into: consolidated_into,
98
+ token_count: token_count,
99
+ status: status,
100
+ session_id: session_id,
101
+ observed_at: observed_at,
102
+ created_at: created_at,
103
+ reflected_at: reflected_at,
104
+ corroboration_count: corroboration_count,
105
+ promoted_at: promoted_at,
106
+ promoted_fact_id: promoted_fact_id
107
+ }
108
+ end
109
+
110
+ private
111
+
112
+ def validate!
113
+ raise ArgumentError, "body required" if body.nil? || body.empty?
114
+ raise ArgumentError, "priority must be 1, 2, or 3" unless (IMPORTANT..INFO).cover?(priority)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -65,7 +65,7 @@ module ClaudeMemory
65
65
  return zero_vector if tokens.empty?
66
66
 
67
67
  # Build term frequency map
68
- tf_map = tokens.each_with_object(Hash.new(0)) { |token, h| h[token] += 1 }
68
+ tf_map = tokens.tally
69
69
 
70
70
  # Normalize term frequencies
71
71
  max_tf = tf_map.values.max.to_f
@@ -9,6 +9,8 @@ module ClaudeMemory
9
9
  MAX_DECISIONS = 5
10
10
  MAX_CONVENTIONS = 5
11
11
  MAX_ARCHITECTURE = 5
12
+ MAX_OBSERVATIONS = 10
13
+ MAX_PROMOTION_CANDIDATES = 5
12
14
  MAX_UNDISTILLED = 3
13
15
  MAX_TEXT_PER_ITEM = 1500
14
16
  MAX_MIRROR_CANDIDATES = 5
@@ -30,7 +32,8 @@ module ClaudeMemory
30
32
  # ({"project" => [...], "global" => [...]}) so telemetry can resolve
31
33
  # each fact from the correct store. Fact IDs autoincrement per-DB,
32
34
  # so a bare ID without scope is ambiguous.
33
- attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope
35
+ attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope,
36
+ :emitted_observation_count
34
37
 
35
38
  def initialize(manager, source: nil, auto_memory_mirror: nil, stale_threshold_days: nil)
36
39
  @manager = manager
@@ -41,12 +44,14 @@ module ClaudeMemory
41
44
  @emitted_fact_ids = []
42
45
  @emitted_subjects = []
43
46
  @emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
47
+ @emitted_observation_count = 0
44
48
  end
45
49
 
46
50
  def generate_context
47
51
  @emitted_fact_ids = []
48
52
  @emitted_subjects = []
49
53
  @emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
54
+ @emitted_observation_count = 0
50
55
  sections = []
51
56
 
52
57
  decisions = fetch(:decisions, MAX_DECISIONS)
@@ -58,10 +63,21 @@ module ClaudeMemory
58
63
  architecture = fetch(:architecture, MAX_ARCHITECTURE)
59
64
  sections << format_section("Architecture", architecture) if architecture.any?
60
65
 
66
+ # Block 1 of the two-block context: the episodic observation log. Sits
67
+ # ahead of the (fresh-session) undistilled "Pending Knowledge Extraction"
68
+ # tail (Block 2). Newest-first; only 🔴 carries a marker for the actor.
69
+ observations = fetch_observations(MAX_OBSERVATIONS)
70
+ @emitted_observation_count = observations.size
71
+ obs_section = Observe::ObservationsRenderer.render(observations)
72
+ sections << obs_section if obs_section
73
+
61
74
  if fresh_session?
62
75
  undistilled = fetch_undistilled(MAX_UNDISTILLED)
63
76
  sections << format_distillation_prompt(undistilled) if undistilled.any?
64
77
 
78
+ promotion = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
79
+ sections << format_observation_reflection(promotion) if promotion.any?
80
+
65
81
  mirror_candidates = fetch_mirror_candidates(MAX_MIRROR_CANDIDATES)
66
82
  if mirror_candidates.any?
67
83
  sections << format_auto_memory_mirror(mirror_candidates)
@@ -74,6 +90,19 @@ module ClaudeMemory
74
90
  sections.join("\n")
75
91
  end
76
92
 
93
+ # Reflection-only context for PreCompact (context pressure) — just the
94
+ # promote/consolidate instructions for corroborated/related observations.
95
+ # PreCompact is the analog of Mastra's token-threshold reflection trigger;
96
+ # at compaction we nudge the Claude-as-reflector pass rather than
97
+ # re-inject the full snapshot (which would add tokens as the window fills).
98
+ # @return [String, nil]
99
+ def reflection_context
100
+ candidates = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
101
+ return nil if candidates.empty?
102
+
103
+ format_observation_reflection(candidates)
104
+ end
105
+
77
106
  private
78
107
 
79
108
  def fresh_session?
@@ -124,6 +153,67 @@ module ClaudeMemory
124
153
  @stale_threshold_days ||= Configuration.new.injection_stale_days
125
154
  end
126
155
 
156
+ # Automatic semantic reflection (Phase 4): surface observations that have
157
+ # been corroborated past the promotion threshold so Claude can promote
158
+ # them to facts inline, this session, at no extra API cost.
159
+ # Project store only: the reflection nudge renders bare `[obs #<id>]` and
160
+ # `memory.promote_observation`/`consolidate_observations` default to the
161
+ # project scope. Observation ids autoincrement *per-DB*, so mixing in
162
+ # global-store candidates would route a promote call to the wrong DB.
163
+ # Observations are written project-scoped on the ingest path today; if a
164
+ # global observation writer is ever added, scope-tag the nudge line
165
+ # (mirror @emitted_facts_by_scope) before broadening this.
166
+ def fetch_promotion_candidates(limit)
167
+ store = @manager.project_store
168
+ return [] unless store
169
+
170
+ store
171
+ .promotion_candidates(min_corroboration: Domain::Observation::PROMOTION_THRESHOLD, limit: limit)
172
+ .sort_by { |o| -(o[:corroboration_count] || 0) }
173
+ .first(limit)
174
+ rescue => e
175
+ ClaudeMemory.logger.warn("ContextInjector#fetch_promotion_candidates failed: #{e.message}")
176
+ []
177
+ end
178
+
179
+ def format_observation_reflection(candidates)
180
+ lines = [
181
+ "## Observation Reflection",
182
+ "",
183
+ "**Promote:** these observations have recurred enough to be worth committing",
184
+ "as facts (corroboration gate passed). For each that represents a stable truth,",
185
+ "call `memory.promote_observation(observation_id, predicate, object)` — embed a",
186
+ "reason in the object (\"… because …\", \"… so that …\"). Skip noise / already-captured.",
187
+ "",
188
+ "**Consolidate:** if several observations in the log above (by `#id`) describe the",
189
+ "same thing in different words, merge them with",
190
+ "`memory.consolidate_observations(from_ids: […], body: \"<synthesis>\")`. Their",
191
+ "corroboration combines, which can tip the merged observation past the promotion gate."
192
+ ]
193
+
194
+ candidates.each do |obs|
195
+ lines << ""
196
+ lines << "- [obs ##{obs[:id]} ×#{obs[:corroboration_count]}] #{obs[:body]}"
197
+ end
198
+
199
+ lines.join("\n")
200
+ end
201
+
202
+ def fetch_observations(limit)
203
+ stores = []
204
+ stores << @manager.project_store if @manager.project_store
205
+ stores << @manager.global_store if @manager.global_store
206
+
207
+ stores
208
+ .flat_map { |s| s.recent_observations(limit: limit) }
209
+ .sort_by { |o| o[:observed_at] || "" }
210
+ .reverse
211
+ .first(limit)
212
+ rescue => e
213
+ ClaudeMemory.logger.warn("ContextInjector#fetch_observations failed: #{e.message}")
214
+ []
215
+ end
216
+
127
217
  def fetch_undistilled(limit)
128
218
  stores = []
129
219
  stores << @manager.project_store if @manager.project_store
@@ -157,7 +247,15 @@ module ClaudeMemory
157
247
  "in the object (e.g., \"… because …\", \"… so that …\", \"caused by …\",",
158
248
  "\"breaks when …\"). A fact with a reason is recoverable once stale; a",
159
249
  "bare conclusion is dead weight. Prefer one fact-with-reason over two",
160
- "facts-without."
250
+ "facts-without.",
251
+ "",
252
+ "**Also log what happened (episodic layer):** in the same",
253
+ "`memory.store_extraction` call, populate `observations` — one per",
254
+ "discrete event (a decision made, a preference stated, a notable action",
255
+ "or outcome). Each: a concise `body` of what happened, a `kind`",
256
+ "(decision/preference/event/…), and a reason for decisions/preferences.",
257
+ "Observations record \"what happened\"; facts record \"what is true\". They",
258
+ "accumulate, and a corroborated observation can later graduate into a fact."
161
259
  ]
162
260
 
163
261
  items.each do |item|
@@ -13,6 +13,10 @@ module ClaudeMemory
13
13
  entities = (args["entities"] || []).map { |e| symbolize_keys(e) }
14
14
  facts = (args["facts"] || []).map { |f| symbolize_keys(f) }
15
15
  decisions = (args["decisions"] || []).map { |d| symbolize_keys(d) }
16
+ # Layer-2 episodic observations. Claude's output is semi-trusted, so
17
+ # each is coerced and validated at this boundary; invalid rows are
18
+ # dropped rather than aborting the batch.
19
+ observations = (args["observations"] || []).filter_map { |o| coerce_observation(o) }
16
20
 
17
21
  config = Configuration.new
18
22
  project_path = config.project_dir
@@ -26,7 +30,8 @@ module ClaudeMemory
26
30
  entities: entities,
27
31
  facts: facts,
28
32
  decisions: decisions,
29
- signals: []
33
+ signals: [],
34
+ observations: observations
30
35
  )
31
36
 
32
37
  # Guard against the LLM distiller labeling descriptions of external
@@ -52,7 +57,113 @@ module ClaudeMemory
52
57
  entities_created: result[:entities_created],
53
58
  facts_created: result[:facts_created],
54
59
  facts_superseded: result[:facts_superseded],
55
- conflicts_created: result[:conflicts_created]
60
+ conflicts_created: result[:conflicts_created],
61
+ observations_created: result[:observations_created]
62
+ }
63
+ end
64
+
65
+ # Coerce one Claude-supplied observation into a clean candidate, or nil
66
+ # when it can't be a usable episodic row. Defaults live here so the
67
+ # rest of the pipeline never sees a blank body or an out-of-range
68
+ # priority. Returns a symbol-keyed hash for Resolver#persist_observations.
69
+ def coerce_observation(raw)
70
+ obs = symbolize_keys(raw)
71
+ body = obs[:body].to_s.strip
72
+ return nil if body.empty?
73
+
74
+ kind = Domain::Observation::KINDS.include?(obs[:kind]) ? obs[:kind] : "event"
75
+ priority = obs[:priority].to_i
76
+ priority = Domain::Observation::INFO unless (Domain::Observation::IMPORTANT..Domain::Observation::INFO).cover?(priority)
77
+
78
+ {body: body, kind: kind, priority: priority}
79
+ end
80
+
81
+ # Promotion bridge: turn a corroborated observation into a structured
82
+ # fact. Server-side anti-hallucination gate — refuses to promote an
83
+ # observation that has not been sighted at least PROMOTION_THRESHOLD
84
+ # times. Creates the fact through the resolver (so supersession/conflict
85
+ # handling applies) and marks the observation promoted so it is not
86
+ # re-suggested.
87
+ def promote_observation(args)
88
+ scope = args["scope"] || "project"
89
+ store = get_store_for_scope(scope)
90
+ return {error: "Database not available"} unless store
91
+
92
+ observation_id = args["observation_id"]
93
+ return {error: "observation_id required"} if observation_id.nil?
94
+
95
+ obs = store.observations.where(id: observation_id).first
96
+ return {error: "Observation #{observation_id} not found in #{scope} database"} unless obs
97
+ return {error: "Observation #{observation_id} already promoted (fact #{obs[:promoted_fact_id]})"} unless obs[:promoted_at].nil?
98
+
99
+ threshold = Domain::Observation::PROMOTION_THRESHOLD
100
+ if obs[:corroboration_count].to_i < threshold
101
+ return {error: "Not yet corroborated: observation #{observation_id} has #{obs[:corroboration_count]} sighting(s), need #{threshold}. Promotion requires repeated corroboration (anti-hallucination gate)."}
102
+ end
103
+
104
+ predicate = args["predicate"]
105
+ object = args["object"]
106
+ return {error: "predicate and object are required"} if predicate.nil? || object.to_s.strip.empty?
107
+ subject = args["subject"] || "repo"
108
+
109
+ config = Configuration.new
110
+ project_path = config.project_dir
111
+ occurred_at = Time.now.utc.iso8601
112
+
113
+ extraction = Distill::Extraction.new(
114
+ facts: [{subject: subject, predicate: predicate, object: object, strength: "derived"}]
115
+ )
116
+ result = Resolve::Resolver.new(store).apply(
117
+ extraction, content_item_id: obs[:source_content_item_id],
118
+ occurred_at: occurred_at, project_path: project_path, scope: scope
119
+ )
120
+
121
+ # The resolver reports the id of the fact it actually touched
122
+ # (inserted, reinforced, or disputed) — no need to re-query for it.
123
+ fact_id = result[:fact_ids].compact.first
124
+ return {error: "Promotion failed: the fact for observation #{observation_id} could not be resolved after creation"} unless fact_id
125
+
126
+ store.mark_observation_promoted(observation_id, fact_id: fact_id)
127
+
128
+ {
129
+ success: true,
130
+ observation_id: observation_id,
131
+ fact_id: fact_id,
132
+ predicate: Resolve::PredicatePolicy.canonicalize(predicate),
133
+ object: object,
134
+ corroboration_count: obs[:corroboration_count],
135
+ facts_created: result[:facts_created]
136
+ }
137
+ end
138
+
139
+ # Semantic reflection: merge several related observations into one
140
+ # synthesized observation (the Claude-as-reflector pass). Validates the
141
+ # synthesized body at the border via coerce_observation, then delegates
142
+ # the atomic merge to the store.
143
+ def consolidate_observations(args)
144
+ scope = args["scope"] || "project"
145
+ store = get_store_for_scope(scope)
146
+ return {error: "Database not available"} unless store
147
+
148
+ from_ids = Array(args["from_ids"]).map(&:to_i).reject(&:zero?).uniq
149
+ return {error: "from_ids must list at least 2 observation ids"} if from_ids.size < 2
150
+
151
+ synthesized = coerce_observation(args)
152
+ return {error: "body is required"} unless synthesized
153
+
154
+ project_path = (scope == "global") ? nil : Configuration.new.project_dir
155
+ result = store.consolidate_observations(
156
+ from_ids, body: synthesized[:body], kind: synthesized[:kind],
157
+ priority: synthesized[:priority], scope: scope, project_path: project_path
158
+ )
159
+ return {error: "Need at least 2 active #{scope} observations from that set to consolidate"} unless result
160
+
161
+ {
162
+ success: true,
163
+ scope: scope,
164
+ consolidated_into: result[:id],
165
+ merged: result[:merged],
166
+ corroboration_count: result[:corroboration_count]
56
167
  }
57
168
  end
58
169
 
@@ -49,7 +49,15 @@ module ClaudeMemory
49
49
  explanation = @recall.explain(args["fact_id"], scope: scope)
50
50
  return {error: "Fact not found in #{scope} database"} if explanation.is_a?(Core::NullExplanation)
51
51
 
52
- ResponseFormatter.format_explanation(explanation, scope)
52
+ result = ResponseFormatter.format_explanation(explanation, scope)
53
+
54
+ # Episodic provenance: if this fact was promoted from observations,
55
+ # show the lineage back to them (reverse of observations.promoted_fact_id).
56
+ store = get_store_for_scope(scope)
57
+ promoted_from = store ? store.observations_for_fact(args["fact_id"]) : []
58
+ result[:promoted_from_observations] = promoted_from if result.is_a?(Hash) && promoted_from.any?
59
+
60
+ result
53
61
  end
54
62
 
55
63
  def recall_semantic(args)
@@ -109,6 +117,45 @@ module ClaudeMemory
109
117
  }
110
118
  }
111
119
  end
120
+
121
+ # List recent episodic observations (the "what happened" log). Read-only.
122
+ # Phase 1 queries one scope's store (default project); cross-scope merge
123
+ # comes with the stable-prefix injection phase.
124
+ def observations(args)
125
+ return database_not_found_error unless databases_exist?
126
+
127
+ scope = args["scope"] || "project"
128
+ limit = extract_limit(args, default: 20)
129
+ min_priority = (args["important_only"] == true) ? Domain::Observation::IMPORTANT : nil
130
+
131
+ store = get_store_for_scope(scope)
132
+ return {error: "Database not available"} unless store
133
+
134
+ rows = store.recent_observations(scope: scope, limit: limit, min_priority: min_priority)
135
+
136
+ {
137
+ observation_count: rows.size,
138
+ observations: rows.map { |row|
139
+ obs = Domain::Observation.new(row)
140
+ {
141
+ id: obs.id,
142
+ kind: obs.kind,
143
+ priority: obs.priority,
144
+ body: obs.body,
145
+ scope: obs.scope,
146
+ status: obs.status,
147
+ corroboration_count: obs.corroboration_count,
148
+ promoted_fact_id: obs.promoted_fact_id,
149
+ consolidated_into: obs.consolidated_into,
150
+ source_content_item_id: obs.source_content_item_id,
151
+ observed_at: obs.observed_at,
152
+ observed_ago: Core::RelativeTime.format(obs.observed_at)
153
+ }
154
+ }
155
+ }
156
+ rescue Sequel::DatabaseError, Sequel::DatabaseConnectionError, Errno::ENOENT => e
157
+ classified_error(e, tool_name: "memory.observations")
158
+ end
112
159
  end
113
160
  end
114
161
  end
@@ -125,6 +125,7 @@ module ClaudeMemory
125
125
  - Before refactoring: call memory.decisions to understand why past choices were made
126
126
  - When asked about preferences: global facts store user environment and style preferences across all projects
127
127
  - When adding to the codebase: recall which files and patterns to follow (memory knows correct paths and relationships)
128
+ - When reviewing what happened or curating memory: call memory.observations to read the episodic log and surface corroborated patterns worth promoting to facts
128
129
  GUIDANCE
129
130
  end
130
131
 
@@ -87,6 +87,34 @@ module ClaudeMemory
87
87
  - Use after: performing LLM-based fact extraction on undistilled content
88
88
  - Cost: ~100 tokens per call
89
89
 
90
+ ### Tier 6: Episodic Observation Management
91
+
92
+ Observations are the episodic "what happened" log that complements facts
93
+ ("what is true"). They accrue automatically; these tools let you inspect
94
+ and curate them. The `/reflect` skill wraps the full survey→consolidate→
95
+ promote workflow if you want it guided.
96
+
97
+ **memory.observations** — Read the episodic observation log
98
+ - Use for: surveying recent narrative ("what happened"), spotting patterns
99
+ that recur enough to become facts, checking promotion readiness
100
+ - Returns: observations with status/kind/priority, corroboration_count,
101
+ and promoted_fact_id where promoted
102
+ - Cost: ~200-500 tokens per call
103
+
104
+ **memory.promote_observation** — Promote a corroborated observation to a fact
105
+ - Use when: an observation has been corroborated (seen ≥ 2 times) and
106
+ represents a stable truth worth committing as a structured fact
107
+ - Gate: refuses uncorroborated or already-promoted observations — this is
108
+ the anti-hallucination defense against one-off doc/example text
109
+ - Embed a reason in the object ("… because …", "… so that …")
110
+ - Cost: ~200 tokens per call
111
+
112
+ **memory.consolidate_observations** — Merge related observations into one
113
+ - Use when: several observations describe the same thing in different words
114
+ - Effect: corroboration counts combine (which can tip the merged row past
115
+ the promotion gate); sources are tombstoned (preserved, not deleted)
116
+ - Cost: ~200 tokens per call
117
+
90
118
  ## Recommended Workflow
91
119
 
92
120
  1. **Start broad**: `memory.recall` or shortcut tools (decisions/conventions/architecture)
@@ -276,6 +276,19 @@ module ClaudeMemory
276
276
  required: ["title", "summary"]
277
277
  }
278
278
  },
279
+ observations: {
280
+ type: "array",
281
+ description: "Episodic observations — what happened this session (experimental, observational layer). Complements facts ('what is true') with 'what happened': decisions made, preferences stated, notable actions/outcomes. One per discrete event; embed a reason for decisions/preferences.",
282
+ items: {
283
+ type: "object",
284
+ properties: {
285
+ body: {type: "string", description: "Concise statement of what happened"},
286
+ kind: {type: "string", enum: %w[user_statement agent_action tool_result preference decision event], description: "What kind of event (default: event)"},
287
+ priority: {type: "integer", enum: [1, 2, 3], description: "Internal signal: 1=important, 2=maybe, 3=info (default: 3)"}
288
+ },
289
+ required: ["body"]
290
+ }
291
+ },
279
292
  scope: {type: "string", enum: ["global", "project"], description: "Default scope for facts", default: "project"}
280
293
  },
281
294
  required: ["facts"]
@@ -451,6 +464,51 @@ module ClaudeMemory
451
464
  }
452
465
  },
453
466
  annotations: READ_ONLY
467
+ },
468
+ {
469
+ name: "memory.observations",
470
+ description: "List recent episodic observations — the 'what happened' log that complements facts ('what is true'). Append-only, newest first. Priority is an internal signal (1=important, 2=maybe, 3=info).",
471
+ inputSchema: {
472
+ type: "object",
473
+ properties: {
474
+ scope: {type: "string", enum: %w[project global], description: "Filter by scope; omit for any"},
475
+ limit: {type: "integer", default: 20, description: "Maximum observations to return"},
476
+ important_only: {type: "boolean", default: false, description: "Return only priority-1 (🔴) observations"}
477
+ }
478
+ },
479
+ annotations: READ_ONLY
480
+ },
481
+ {
482
+ name: "memory.promote_observation",
483
+ description: "Promote a corroborated observation into a structured fact (the observation→fact bridge). Refuses observations sighted fewer than the corroboration threshold (anti-hallucination gate). Embed a reason in the object (because…/so that…). Surfaced by the SessionStart 'Observation Reflection' section.",
484
+ inputSchema: {
485
+ type: "object",
486
+ properties: {
487
+ observation_id: {type: "integer", description: "The corroborated observation to promote"},
488
+ predicate: {type: "string", description: "Fact predicate (e.g. decision, convention, architecture)"},
489
+ object: {type: "string", description: "Fact object — include a reason clause"},
490
+ subject: {type: "string", default: "repo", description: "Fact subject (default: repo)"},
491
+ scope: {type: "string", enum: %w[project global], default: "project"}
492
+ },
493
+ required: ["observation_id", "predicate", "object"]
494
+ },
495
+ annotations: WRITE
496
+ },
497
+ {
498
+ name: "memory.consolidate_observations",
499
+ description: "Semantic reflection: merge several related observations (that say the same thing in different words) into one synthesized observation. Corroboration combines, which can tip the result over the promotion threshold. The originals are tombstoned (preserved, linked), not deleted.",
500
+ inputSchema: {
501
+ type: "object",
502
+ properties: {
503
+ from_ids: {type: "array", items: {type: "integer"}, minItems: 2, description: "Observation ids to merge (>= 2, same scope)"},
504
+ body: {type: "string", description: "The synthesized observation text"},
505
+ kind: {type: "string", enum: %w[user_statement agent_action tool_result preference decision event], description: "Kind of the merged observation (default: event)"},
506
+ priority: {type: "integer", enum: [1, 2, 3], description: "1=important, 2=maybe, 3=info (default: 3)"},
507
+ scope: {type: "string", enum: %w[project global], default: "project"}
508
+ },
509
+ required: ["from_ids", "body"]
510
+ },
511
+ annotations: WRITE
454
512
  }
455
513
  ]
456
514
  end
@@ -98,6 +98,9 @@ module ClaudeMemory
98
98
  when "memory.check_setup" then check_setup
99
99
  when "memory.list_projects" then list_projects
100
100
  when "memory.activity" then activity(arguments)
101
+ when "memory.observations" then observations(arguments)
102
+ when "memory.promote_observation" then promote_observation(arguments)
103
+ when "memory.consolidate_observations" then consolidate_observations(arguments)
101
104
  else {error: "Unknown tool: #{name}"}
102
105
  end
103
106
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Observe
5
+ # Renders episodic observation rows into the actor-facing markdown block —
6
+ # the front-loaded "what happened" log that complements the fact snapshot
7
+ # ("what is true").
8
+ #
9
+ # Priority is an internal Observer/Reflector signal. Following Mastra, only
10
+ # 🔴 (important) survives as a marker when shown to the actor; 🟡/🟢 are
11
+ # stripped as visual noise. The observation bodies themselves are always
12
+ # shown — the emoji is the only thing filtered.
13
+ module ObservationsRenderer
14
+ IMPORTANT_MARKER = "🔴"
15
+
16
+ module_function
17
+
18
+ # @param observations [Array<Hash>] rows with :body, :priority, :observed_at
19
+ # @param title [String] section heading
20
+ # @param intro [Boolean] include the one-line explainer (true for injection)
21
+ # @return [String, nil] markdown block, or nil when there is nothing to show
22
+ def render(observations, title: "Observations (what happened)", intro: true)
23
+ rows = Array(observations).reject { |o| o[:body].to_s.strip.empty? }
24
+ return nil if rows.empty?
25
+
26
+ lines = ["## #{title}"]
27
+ if intro
28
+ lines << ""
29
+ lines << "Episodic log of what happened in this project — complements the facts above (what is true). Newest first."
30
+ end
31
+ lines << ""
32
+ rows.each { |obs| lines << format_line(obs) }
33
+ lines.join("\n")
34
+ end
35
+
36
+ # @return [String] a single "- [#id] [🔴 ]body (time ago)" log line. The
37
+ # id tag lets the reflection step reference an observation for
38
+ # promote/consolidate; it's omitted when the row carries no id.
39
+ def format_line(obs)
40
+ body = obs[:body].to_s.strip
41
+ id_tag = obs[:id] ? "[##{obs[:id]}] " : ""
42
+ marker = (obs[:priority] == Domain::Observation::IMPORTANT) ? "#{IMPORTANT_MARKER} " : ""
43
+ ago = Core::RelativeTime.format(obs[:observed_at])
44
+ suffix = ago ? " (#{ago})" : ""
45
+ "- #{id_tag}#{marker}#{body}#{suffix}"
46
+ end
47
+ end
48
+ end
49
+ end