claude_memory 0.12.1 → 0.13.1

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +6 -1
  4. data/.claude/settings.local.json +2 -1
  5. data/.claude-plugin/marketplace.json +2 -2
  6. data/.claude-plugin/plugin.json +2 -2
  7. data/CHANGELOG.md +38 -0
  8. data/CLAUDE.md +11 -6
  9. data/README.md +35 -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 +16 -5
  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 +173 -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/lib/claude_memory/audit/checks.rb +149 -0
  22. data/lib/claude_memory/audit/runner.rb +4 -0
  23. data/lib/claude_memory/commands/census_command.rb +1 -1
  24. data/lib/claude_memory/commands/hook_command.rb +16 -3
  25. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +3 -1
  26. data/lib/claude_memory/commands/install_skill_command.rb +4 -0
  27. data/lib/claude_memory/commands/observations_command.rb +367 -0
  28. data/lib/claude_memory/commands/registry.rb +1 -0
  29. data/lib/claude_memory/commands/skills/reflect.md +68 -0
  30. data/lib/claude_memory/commands/stats_command.rb +60 -1
  31. data/lib/claude_memory/dashboard/api.rb +4 -0
  32. data/lib/claude_memory/dashboard/index.html +154 -2
  33. data/lib/claude_memory/dashboard/observations.rb +115 -0
  34. data/lib/claude_memory/dashboard/server.rb +1 -0
  35. data/lib/claude_memory/distill/extraction.rb +6 -4
  36. data/lib/claude_memory/distill/null_distiller.rb +108 -3
  37. data/lib/claude_memory/distill/reference_material_detector.rb +4 -1
  38. data/lib/claude_memory/domain/observation.rb +118 -0
  39. data/lib/claude_memory/embeddings/generator.rb +1 -1
  40. data/lib/claude_memory/hook/context_injector.rb +125 -2
  41. data/lib/claude_memory/mcp/handlers/management_handlers.rb +113 -2
  42. data/lib/claude_memory/mcp/handlers/query_handlers.rb +48 -1
  43. data/lib/claude_memory/mcp/instructions_builder.rb +1 -0
  44. data/lib/claude_memory/mcp/query_guide.rb +28 -0
  45. data/lib/claude_memory/mcp/tool_definitions.rb +58 -0
  46. data/lib/claude_memory/mcp/tools.rb +3 -0
  47. data/lib/claude_memory/observe/observations_renderer.rb +49 -0
  48. data/lib/claude_memory/observe/reflector.rb +107 -0
  49. data/lib/claude_memory/observe/token_overlap_matcher.rb +55 -0
  50. data/lib/claude_memory/publish.rb +53 -1
  51. data/lib/claude_memory/resolve/resolver.rb +45 -8
  52. data/lib/claude_memory/store/schema_manager.rb +1 -1
  53. data/lib/claude_memory/store/sqlite_store.rb +181 -0
  54. data/lib/claude_memory/sweep/maintenance.rb +15 -1
  55. data/lib/claude_memory/sweep/sweeper.rb +7 -1
  56. data/lib/claude_memory/version.rb +1 -1
  57. data/lib/claude_memory.rb +6 -0
  58. metadata +12 -1
@@ -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,9 +63,25 @@ 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
- sections << format_distillation_prompt(undistilled) if undistilled.any?
76
+ if undistilled.any?
77
+ sections << format_distillation_prompt(undistilled)
78
+ # The episodic-capture ask is its own prominent section (#72), not a
79
+ # buried paragraph inside the deep-distill prompt.
80
+ sections << format_observation_capture_prompt
81
+ end
82
+
83
+ promotion = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
84
+ sections << format_observation_reflection(promotion) if promotion.any?
64
85
 
65
86
  mirror_candidates = fetch_mirror_candidates(MAX_MIRROR_CANDIDATES)
66
87
  if mirror_candidates.any?
@@ -74,6 +95,19 @@ module ClaudeMemory
74
95
  sections.join("\n")
75
96
  end
76
97
 
98
+ # Reflection-only context for PreCompact (context pressure) — just the
99
+ # promote/consolidate instructions for corroborated/related observations.
100
+ # PreCompact is the analog of Mastra's token-threshold reflection trigger;
101
+ # at compaction we nudge the Claude-as-reflector pass rather than
102
+ # re-inject the full snapshot (which would add tokens as the window fills).
103
+ # @return [String, nil]
104
+ def reflection_context
105
+ candidates = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
106
+ return nil if candidates.empty?
107
+
108
+ format_observation_reflection(candidates)
109
+ end
110
+
77
111
  private
78
112
 
79
113
  def fresh_session?
@@ -124,6 +158,67 @@ module ClaudeMemory
124
158
  @stale_threshold_days ||= Configuration.new.injection_stale_days
125
159
  end
126
160
 
161
+ # Automatic semantic reflection (Phase 4): surface observations that have
162
+ # been corroborated past the promotion threshold so Claude can promote
163
+ # them to facts inline, this session, at no extra API cost.
164
+ # Project store only: the reflection nudge renders bare `[obs #<id>]` and
165
+ # `memory.promote_observation`/`consolidate_observations` default to the
166
+ # project scope. Observation ids autoincrement *per-DB*, so mixing in
167
+ # global-store candidates would route a promote call to the wrong DB.
168
+ # Observations are written project-scoped on the ingest path today; if a
169
+ # global observation writer is ever added, scope-tag the nudge line
170
+ # (mirror @emitted_facts_by_scope) before broadening this.
171
+ def fetch_promotion_candidates(limit)
172
+ store = @manager.project_store
173
+ return [] unless store
174
+
175
+ store
176
+ .promotion_candidates(min_corroboration: Domain::Observation::PROMOTION_THRESHOLD, limit: limit)
177
+ .sort_by { |o| -(o[:corroboration_count] || 0) }
178
+ .first(limit)
179
+ rescue => e
180
+ ClaudeMemory.logger.warn("ContextInjector#fetch_promotion_candidates failed: #{e.message}")
181
+ []
182
+ end
183
+
184
+ def format_observation_reflection(candidates)
185
+ lines = [
186
+ "## Observation Reflection",
187
+ "",
188
+ "**Promote:** these observations have recurred enough to be worth committing",
189
+ "as facts (corroboration gate passed). For each that represents a stable truth,",
190
+ "call `memory.promote_observation(observation_id, predicate, object)` — embed a",
191
+ "reason in the object (\"… because …\", \"… so that …\"). Skip noise / already-captured.",
192
+ "",
193
+ "**Consolidate:** if several observations in the log above (by `#id`) describe the",
194
+ "same thing in different words, merge them with",
195
+ "`memory.consolidate_observations(from_ids: […], body: \"<synthesis>\")`. Their",
196
+ "corroboration combines, which can tip the merged observation past the promotion gate."
197
+ ]
198
+
199
+ candidates.each do |obs|
200
+ lines << ""
201
+ lines << "- [obs ##{obs[:id]} Ɨ#{obs[:corroboration_count]}] #{obs[:body]}"
202
+ end
203
+
204
+ lines.join("\n")
205
+ end
206
+
207
+ def fetch_observations(limit)
208
+ stores = []
209
+ stores << @manager.project_store if @manager.project_store
210
+ stores << @manager.global_store if @manager.global_store
211
+
212
+ stores
213
+ .flat_map { |s| s.recent_observations(limit: limit) }
214
+ .sort_by { |o| o[:observed_at] || "" }
215
+ .reverse
216
+ .first(limit)
217
+ rescue => e
218
+ ClaudeMemory.logger.warn("ContextInjector#fetch_observations failed: #{e.message}")
219
+ []
220
+ end
221
+
127
222
  def fetch_undistilled(limit)
128
223
  stores = []
129
224
  stores << @manager.project_store if @manager.project_store
@@ -171,6 +266,34 @@ module ClaudeMemory
171
266
  lines.join("\n")
172
267
  end
173
268
 
269
+ # First-class, standalone ask for the episodic layer (#72). Authoring
270
+ # observations was previously a paragraph buried inside the optional
271
+ # deep-distill flow above, and that flow fires almost never — so the
272
+ # episodic log was 100% Layer-1 scrapes. This decouples it: a prominent,
273
+ # lightweight instruction to log "what happened" directly, the same way
274
+ # the fact context rides the session. Effectiveness is measurable via the
275
+ # `mcp_extraction` content-item source (Layer-2) vs `claude_code` (Layer-1).
276
+ def format_observation_capture_prompt
277
+ <<~PROMPT.strip
278
+ ## Log What Happened (episodic memory)
279
+
280
+ Record the recent narrative as **observations** — "what happened",
281
+ complementing the facts above ("what is true"). For each discrete
282
+ event in the recent work above (a decision made, a preference stated,
283
+ a notable fix or outcome), call `memory.store_extraction` with an
284
+ `observations` array — one entry per event:
285
+
286
+ - `body`: one concise sentence of what happened (embed a reason for
287
+ decisions/preferences — "… because …", "… so that …")
288
+ - `kind`: `decision`, `preference`, or `event`
289
+ - `priority`: 1 important, 2 maybe, 3 info
290
+
291
+ Keep it to genuine events worth remembering — skip routine steps and
292
+ code output. Observations accumulate and a corroborated one graduates
293
+ into a fact. Send them with the facts in the same call, or on their own.
294
+ PROMPT
295
+ end
296
+
174
297
  def format_section(title, items)
175
298
  items = items.compact.uniq
176
299
  return nil if items.empty?
@@ -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
@@ -0,0 +1,107 @@
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-duplicate active observations (same scope) into
15
+ # the newest, linking losers via consolidated_into. Similarity is decided
16
+ # by an injected matcher (default: lexical token-overlap, #73) so the
17
+ # promotion gate can actually accumulate corroboration — exact-string
18
+ # matching never folded varied wording, leaving every observation at
19
+ # corroboration 1 (the 2026-06-23 audit finding).
20
+ # - expire_stale_info: retire info-level (🟢 / priority 3) observations
21
+ # older than the TTL to bound context size. Important (šŸ”“) and maybe
22
+ # (🟔) are never expired — only the lowest-signal tier ages out.
23
+ #
24
+ # Semantic consolidation ("combine related items, surface patterns") is
25
+ # deliberately NOT here — it needs the LLM and lands in the Phase-4
26
+ # Claude-as-reflector pass. This pass is pure Ruby so it can run shell-side
27
+ # in the sweep hook for free.
28
+ class Reflector
29
+ DEFAULT_INFO_TTL_DAYS = 30
30
+
31
+ # @return [Struct] counts from one reflection pass
32
+ Result = Struct.new(:deduped, :expired) do
33
+ def total
34
+ deduped + expired
35
+ end
36
+ end
37
+
38
+ def initialize(store, info_ttl_days: DEFAULT_INFO_TTL_DAYS, matcher: TokenOverlapMatcher.new)
39
+ @store = store
40
+ @info_ttl_days = info_ttl_days
41
+ @matcher = matcher
42
+ end
43
+
44
+ # @return [Result] number of observations deduped and expired
45
+ def reflect!
46
+ deduped = 0
47
+ expired = 0
48
+ @store.db.transaction do
49
+ deduped = dedupe
50
+ expired = expire_stale_info
51
+ end
52
+ Result.new(deduped: deduped, expired: expired)
53
+ end
54
+
55
+ private
56
+
57
+ def dedupe
58
+ active = @store.observations.where(status: "active").order(:id).all
59
+ active.group_by { |o| o[:scope] }.sum { |_scope, rows| dedupe_scope(rows) }
60
+ end
61
+
62
+ # Greedy clustering within one scope: the newest observation in a cluster
63
+ # is the keeper; older near-duplicates fold into it. O(n²) matcher calls,
64
+ # but n is bounded (#74 cut the inflow; expire_stale_info bounds the tail).
65
+ def dedupe_scope(rows)
66
+ return 0 if rows.size < 2
67
+
68
+ ordered = rows.sort_by { |r| [r[:observed_at].to_s, r[:id]] }.reverse
69
+ folded = {}
70
+ merged = 0
71
+
72
+ ordered.each do |keeper|
73
+ next if folded[keeper[:id]]
74
+
75
+ ordered.each do |other|
76
+ next if other[:id] == keeper[:id] || folded[other[:id]]
77
+ next unless @matcher.similar?(keeper[:body], other[:body])
78
+
79
+ # Fold the duplicate's sightings into the keeper before tombstoning
80
+ # so corroboration survives consolidation and can cross the promotion
81
+ # threshold. A duplicate IS a repeated sighting.
82
+ @store.increment_corroboration(keeper[:id], by: other[:corroboration_count] || 1)
83
+ @store.tombstone_observation(other[:id], into_id: keeper[:id])
84
+ folded[other[:id]] = true
85
+ merged += 1
86
+ end
87
+
88
+ folded[keeper[:id]] = true
89
+ end
90
+
91
+ merged
92
+ end
93
+
94
+ def expire_stale_info
95
+ cutoff = (Time.now - @info_ttl_days * 86400).utc.iso8601
96
+ ids = @store.observations
97
+ .where(status: "active", priority: Domain::Observation::INFO)
98
+ .where { observed_at < cutoff }
99
+ .select(:id)
100
+ .map { |r| r[:id] }
101
+
102
+ ids.each { |id| @store.expire_observation(id) }
103
+ ids.size
104
+ end
105
+ end
106
+ end
107
+ end