claude_memory 0.12.1 → 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 (57) 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 +28 -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 +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/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 +86 -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 +100 -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 +91 -0
  49. data/lib/claude_memory/publish.rb +53 -1
  50. data/lib/claude_memory/resolve/resolver.rb +45 -8
  51. data/lib/claude_memory/store/schema_manager.rb +1 -1
  52. data/lib/claude_memory/store/sqlite_store.rb +181 -0
  53. data/lib/claude_memory/sweep/maintenance.rb +15 -1
  54. data/lib/claude_memory/sweep/sweeper.rb +7 -1
  55. data/lib/claude_memory/version.rb +1 -1
  56. data/lib/claude_memory.rb +5 -0
  57. metadata +11 -1
@@ -226,6 +226,155 @@ module ClaudeMemory
226
226
  end
227
227
  end
228
228
 
229
+ # Scopes whose stores carry an observations table. Observation checks
230
+ # iterate both DBs because observations may be project- or global-scoped.
231
+ OBSERVATION_SCOPES = %i[project global].freeze
232
+
233
+ # Valid observation lifecycle states. Anything else means a writer or
234
+ # migration stamped a status the resolver/reflector never produce.
235
+ OBSERVATION_STATUSES = %w[active consolidated expired].freeze
236
+
237
+ def observation_stores(manager)
238
+ OBSERVATION_SCOPES
239
+ .map { |scope| [scope, manager.store_if_exists(scope.to_s)] }
240
+ .reject { |_, store| store.nil? }
241
+ end
242
+
243
+ # C011 — Orphaned observations (provenance points at a missing content item).
244
+ def orphaned_observations(manager)
245
+ observation_stores(manager).flat_map do |scope, store|
246
+ content_ids = store.content_items.select(:id)
247
+ orphans = store.observations
248
+ .exclude(source_content_item_id: nil)
249
+ .exclude(source_content_item_id: content_ids)
250
+ .select(:id)
251
+ .all
252
+ next [] if orphans.empty?
253
+
254
+ [Finding.new(
255
+ id: "C011",
256
+ severity: :warn,
257
+ title: "#{orphans.size} observation(s) in #{scope} DB reference a missing content item",
258
+ detail: "An observation's source_content_item_id should point at the content_items row it was distilled from. A dangling pointer means the source row was pruned or never existed, so the observation's provenance can no longer be explained.",
259
+ suggestion: "Inspect with memory.observations. These rows are append-only; if the provenance is unrecoverable, consolidate or expire them via the Reflector (PreCompact/SessionEnd) rather than deleting.",
260
+ fact_ids: orphans.map { |r| r[:id] }
261
+ )]
262
+ end
263
+ end
264
+
265
+ # C012 — Promotion consistency (promoted_at ⇔ promoted_fact_id, fact must exist + be active).
266
+ def observation_promotion_consistency(manager)
267
+ observation_stores(manager).flat_map do |scope, store|
268
+ active_fact_ids = store.facts.where(status: "active").select(:id)
269
+
270
+ missing_fact_id = store.observations
271
+ .exclude(promoted_at: nil)
272
+ .where(promoted_fact_id: nil)
273
+ .select(:id).all
274
+ dangling_fact = store.observations
275
+ .exclude(promoted_fact_id: nil)
276
+ .exclude(promoted_fact_id: store.facts.select(:id))
277
+ .select(:id, :promoted_fact_id).all
278
+ inactive_fact = store.observations
279
+ .exclude(promoted_fact_id: nil)
280
+ .exclude(promoted_fact_id: active_fact_ids)
281
+ .exclude(promoted_fact_id: dangling_fact.map { |r| r[:promoted_fact_id] })
282
+ .select(:id, :promoted_fact_id).all
283
+ missing_timestamp = store.observations
284
+ .exclude(promoted_fact_id: nil)
285
+ .where(promoted_at: nil)
286
+ .select(:id).all
287
+
288
+ obs_ids = (missing_fact_id + dangling_fact + inactive_fact + missing_timestamp).map { |r| r[:id] }.uniq
289
+ next [] if obs_ids.empty?
290
+
291
+ problems = []
292
+ problems << "#{missing_fact_id.size} promoted but missing promoted_fact_id" unless missing_fact_id.empty?
293
+ problems << "#{dangling_fact.size} promoted_fact_id pointing at a non-existent fact" unless dangling_fact.empty?
294
+ problems << "#{inactive_fact.size} promoted into a non-active fact" unless inactive_fact.empty?
295
+ problems << "#{missing_timestamp.size} have promoted_fact_id but no promoted_at" unless missing_timestamp.empty?
296
+
297
+ [Finding.new(
298
+ id: "C012",
299
+ severity: :error,
300
+ title: "#{obs_ids.size} observation(s) in #{scope} DB have inconsistent promotion state",
301
+ detail: "Promotion must be atomic: a promoted observation has both promoted_at set and promoted_fact_id pointing at an existing, active fact. Violations (#{problems.join("; ")}) mean mark_observation_promoted ran partially or the target fact was later rejected/superseded, leaving the observation pointing at nothing usable.",
302
+ suggestion: "Inspect the fact with claude-memory explain <fact_id>. If the fact was intentionally rejected, the observation should be re-opened for re-promotion via memory.promote_observation; if mark_observation_promoted half-ran, re-run promotion.",
303
+ fact_ids: obs_ids
304
+ )]
305
+ end
306
+ end
307
+
308
+ # C013 — Tombstone-chain validity (consolidated_into must point to a real,
309
+ # non-self row and a consolidated observation must not stay active).
310
+ def observation_tombstone_chain(manager)
311
+ observation_stores(manager).flat_map do |scope, store|
312
+ obs_ids = store.observations.select(:id)
313
+
314
+ dangling = store.observations
315
+ .exclude(consolidated_into: nil)
316
+ .exclude(consolidated_into: obs_ids)
317
+ .select(:id, :consolidated_into).all
318
+ self_link = store.observations
319
+ .exclude(consolidated_into: nil)
320
+ .where(Sequel[:consolidated_into] => Sequel[:id])
321
+ .select(:id).all
322
+ active_but_tombstoned = store.observations
323
+ .exclude(consolidated_into: nil)
324
+ .where(status: "active")
325
+ .select(:id).all
326
+ consolidated_without_link = store.observations
327
+ .where(status: "consolidated", consolidated_into: nil)
328
+ .select(:id).all
329
+
330
+ flagged = (dangling + self_link + active_but_tombstoned + consolidated_without_link).map { |r| r[:id] }.uniq
331
+ next [] if flagged.empty?
332
+
333
+ problems = []
334
+ problems << "#{dangling.size} consolidated_into → missing observation" unless dangling.empty?
335
+ problems << "#{self_link.size} consolidated_into self-link" unless self_link.empty?
336
+ problems << "#{active_but_tombstoned.size} active yet have a consolidated_into target" unless active_but_tombstoned.empty?
337
+ problems << "#{consolidated_without_link.size} status=consolidated with no consolidated_into keeper" unless consolidated_without_link.empty?
338
+
339
+ [Finding.new(
340
+ id: "C013",
341
+ severity: :error,
342
+ title: "#{flagged.size} observation(s) in #{scope} DB have a broken tombstone chain",
343
+ detail: "Tombstoning is append-only: a superseded observation gets status=consolidated and consolidated_into pointing at the surviving keeper. Violations (#{problems.join("; ")}) corrupt the lineage — recall could surface a tombstoned row, or a consolidated row could orphan its history.",
344
+ suggestion: "Inspect with memory.observations. Re-run the deterministic Reflector (fires on PreCompact/SessionEnd) to re-derive consolidation; a self-link or active+tombstoned row indicates a Reflector bug — file it rather than hand-editing the append-only table.",
345
+ fact_ids: flagged
346
+ )]
347
+ end
348
+ end
349
+
350
+ # C014 — Status / corroboration sanity (known status set, corroboration ≥ 1).
351
+ def observation_status_corroboration(manager)
352
+ observation_stores(manager).flat_map do |scope, store|
353
+ bad_status = store.observations
354
+ .exclude(status: OBSERVATION_STATUSES)
355
+ .select(:id).all
356
+ bad_corroboration = store.observations
357
+ .where { corroboration_count < 1 }
358
+ .select(:id).all
359
+
360
+ flagged = (bad_status + bad_corroboration).map { |r| r[:id] }.uniq
361
+ next [] if flagged.empty?
362
+
363
+ problems = []
364
+ problems << "#{bad_status.size} with status outside #{OBSERVATION_STATUSES.inspect}" unless bad_status.empty?
365
+ problems << "#{bad_corroboration.size} with corroboration_count < 1" unless bad_corroboration.empty?
366
+
367
+ [Finding.new(
368
+ id: "C014",
369
+ severity: :warn,
370
+ title: "#{flagged.size} observation(s) in #{scope} DB have invalid status/corroboration",
371
+ detail: "Every observation should carry a known lifecycle status (#{OBSERVATION_STATUSES.join("/")}) and at least one sighting (corroboration_count ≥ 1; a fresh insert counts as 1). Violations (#{problems.join("; ")}) break the promotion gate (which keys off corroboration) and the recall filters (which key off status).",
372
+ suggestion: "Inspect with memory.observations. A corroboration_count < 1 means increment_corroboration math went negative; an unknown status means a migration or external writer bypassed insert_observation. Re-derive via the Reflector if possible.",
373
+ fact_ids: flagged
374
+ )]
375
+ end
376
+ end
377
+
229
378
  def normalize_convention(text)
230
379
  text.to_s
231
380
  .downcase
@@ -20,6 +20,10 @@ module ClaudeMemory
20
20
  bare_conclusion_rate
21
21
  project_starvation
22
22
  auto_memory_unimported
23
+ orphaned_observations
24
+ observation_promotion_consistency
25
+ observation_tombstone_chain
26
+ observation_status_corroboration
23
27
  ].freeze
24
28
 
25
29
  Result = Data.define(:findings, :stats) do
@@ -138,7 +138,7 @@ module ClaudeMemory
138
138
 
139
139
  predicates = db[:facts].select(:predicate, :status).group_and_count(:predicate, :status).all
140
140
  .each_with_object(Hash.new { |h, k| h[k] = Hash.new(0) }) do |row, acc|
141
- acc[row[:predicate].to_s][row[:status].to_s] += row[:count].to_i
141
+ acc[row[:predicate].to_s][row[:status].to_s] += row[:count].to_i
142
142
  end
143
143
 
144
144
  entity_types = db[:entities].group_and_count(:type).all.each_with_object(Hash.new(0)) do |row, acc|
@@ -205,14 +205,26 @@ module ClaudeMemory
205
205
 
206
206
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
207
207
  injector = ClaudeMemory::Hook::ContextInjector.new(manager, source: source)
208
- context_text = injector.generate_context
208
+ # On PreCompact (context pressure) inject only the reflection nudge, not
209
+ # the full snapshot; everywhere else inject the full SessionStart context.
210
+ context_text = if payload["hook_event_name"] == "PreCompact"
211
+ injector.reflection_context
212
+ else
213
+ injector.generate_context
214
+ end
209
215
  duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
210
216
 
211
217
  if context_text
212
218
  response = {
213
219
  hookSpecificOutput: {
214
220
  hookEventName: "SessionStart",
215
- additionalContext: context_text
221
+ # Wrap in <claude-memory-context> so a later ingest strips our own
222
+ # injected snapshot back out (ContentSanitizer lists this tag in
223
+ # SYSTEM_TAGS). Without the wrapper, memory's injected facts and
224
+ # observation log leak into the transcript and get re-distilled —
225
+ # a self-ingestion feedback loop. Claude still reads the content;
226
+ # only the re-ingestion path treats it as strippable.
227
+ additionalContext: "<claude-memory-context>\n#{context_text}\n</claude-memory-context>"
216
228
  }
217
229
  }
218
230
  stdout.puts JSON.generate(response)
@@ -243,7 +255,8 @@ module ClaudeMemory
243
255
  top_fact_ids: injector.emitted_fact_ids.first(10),
244
256
  top_facts_by_scope: (by_scope if by_scope.any?),
245
257
  top_subjects: injector.emitted_subjects.uniq.first(10),
246
- fact_count: injector.emitted_fact_ids.size
258
+ fact_count: injector.emitted_fact_ids.size,
259
+ observation_count: injector.emitted_observation_count
247
260
  }.compact
248
261
 
249
262
  ClaudeMemory::ActivityLog.record(store,
@@ -126,7 +126,9 @@ module ClaudeMemory
126
126
  {"type" => "command", "command" => ingest_cmd, "timeout" => 30,
127
127
  "statusMessage" => "Saving memory..."},
128
128
  {"type" => "command", "command" => sweep_cmd, "timeout" => 30,
129
- "statusMessage" => "Sweeping memory..."}
129
+ "statusMessage" => "Sweeping memory..."},
130
+ {"type" => "command", "command" => context_cmd, "timeout" => 5,
131
+ "statusMessage" => "Reflecting on memory..."}
130
132
  ]
131
133
  }],
132
134
  "SessionEnd" => [{
@@ -15,6 +15,10 @@ module ClaudeMemory
15
15
  "distill-transcripts" => {
16
16
  file: "distill-transcripts.md",
17
17
  description: "Distill transcripts — extract facts/entities/decisions from undistilled content"
18
+ },
19
+ "reflect" => {
20
+ file: "reflect.md",
21
+ description: "Reflect on observations — consolidate the episodic log and promote corroborated observations to facts"
18
22
  }
19
23
  }.freeze
20
24
 
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+
6
+ module ClaudeMemory
7
+ module Commands
8
+ # CLI parity for the episodic observation layer — the "what happened" log
9
+ # that complements the semantic fact store ("what is true").
10
+ #
11
+ # Subcommands:
12
+ # observations [list] Summary: counts by status/kind/priority,
13
+ # corroboration + promotion readiness, compression
14
+ # ratio, and a recent timeline.
15
+ # observations promote <id> --predicate P --object O [--subject S] [--scope ...]
16
+ # observations consolidate <id1,id2,...> --body "<synthesis>" [--scope ...]
17
+ #
18
+ # The promote subcommand reuses the same corroboration gate and Resolver
19
+ # path as the memory.promote_observation MCP tool, so the anti-hallucination
20
+ # threshold is enforced identically across surfaces.
21
+ class ObservationsCommand < BaseCommand
22
+ def call(args)
23
+ subcommand = args.first
24
+ case subcommand
25
+ when "promote"
26
+ promote(args.drop(1))
27
+ when "consolidate"
28
+ consolidate(args.drop(1))
29
+ when "list", nil
30
+ list(args.drop(subcommand ? 1 : 0))
31
+ else
32
+ # Treat unknown first token as options to `list` (e.g. --json).
33
+ list(args)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # --- list (default) -----------------------------------------------------
40
+
41
+ def list(args)
42
+ opts = parse_options(args, {limit: 20, kind: nil, status: "active", scope: nil, json: false}) do |o|
43
+ OptionParser.new do |parser|
44
+ parser.banner = "Usage: claude-memory observations [list] [options]"
45
+ parser.on("--limit N", Integer, "Max rows in the recent timeline (default: 20)") { |v| o[:limit] = v }
46
+ parser.on("--kind K", "Filter timeline by kind (e.g. decision, preference, event)") { |v| o[:kind] = v }
47
+ parser.on("--status S", "Filter timeline by status (active, consolidated, expired)") { |v| o[:status] = v }
48
+ parser.on("--scope SCOPE", %w[project global], "Limit to a single scope") { |v| o[:scope] = v }
49
+ parser.on("--json", "Emit machine-readable JSON") { o[:json] = true }
50
+ end
51
+ end
52
+ return 1 if opts.nil?
53
+
54
+ manager = ClaudeMemory::Store::StoreManager.new
55
+ stores = observation_stores(manager, opts[:scope])
56
+
57
+ report = build_report(stores, opts)
58
+ manager.close
59
+
60
+ if opts[:json]
61
+ stdout.puts(JSON.pretty_generate(report))
62
+ else
63
+ render_report(report)
64
+ end
65
+ 0
66
+ end
67
+
68
+ def build_report(stores, opts)
69
+ {
70
+ totals: totals(stores),
71
+ by_kind: by_field(stores, :kind),
72
+ by_priority: by_field(stores, :priority),
73
+ corroboration: corroboration(stores),
74
+ compression: compression(stores),
75
+ recent: recent(stores, opts)
76
+ }
77
+ end
78
+
79
+ def render_report(report)
80
+ stdout.puts "Observations (episodic 'what happened' log)"
81
+ stdout.puts "=" * 50
82
+ stdout.puts
83
+
84
+ render_totals(report[:totals])
85
+ stdout.puts
86
+ render_breakdown("By kind (active)", report[:by_kind])
87
+ stdout.puts
88
+ render_priority(report[:by_priority])
89
+ stdout.puts
90
+ render_corroboration(report[:corroboration])
91
+ stdout.puts
92
+ render_compression(report[:compression])
93
+ stdout.puts
94
+ render_recent(report[:recent])
95
+ end
96
+
97
+ def render_totals(totals)
98
+ stdout.puts "Totals:"
99
+ stdout.puts " Active: #{totals[:active]}"
100
+ stdout.puts " Consolidated: #{totals[:consolidated]}"
101
+ stdout.puts " Expired: #{totals[:expired]}"
102
+ stdout.puts " Promoted: #{totals[:promoted]}"
103
+ end
104
+
105
+ def render_breakdown(title, counts)
106
+ stdout.puts "#{title}:"
107
+ if counts.empty?
108
+ stdout.puts " (none)"
109
+ return
110
+ end
111
+ counts.sort_by { |_k, v| -v }.each do |key, count|
112
+ stdout.puts " #{count.to_s.rjust(4)} - #{key}"
113
+ end
114
+ end
115
+
116
+ def render_priority(counts)
117
+ stdout.puts "By priority (active):"
118
+ if counts.empty?
119
+ stdout.puts " (none)"
120
+ return
121
+ end
122
+ counts.sort_by { |priority, _v| priority }.each do |priority, count|
123
+ stdout.puts " #{count.to_s.rjust(4)} - #{priority} (#{priority_label(priority)})"
124
+ end
125
+ end
126
+
127
+ def priority_label(priority)
128
+ case priority
129
+ when Domain::Observation::IMPORTANT then "important"
130
+ when Domain::Observation::MAYBE then "maybe"
131
+ else "info"
132
+ end
133
+ end
134
+
135
+ def render_corroboration(corroboration)
136
+ threshold = Domain::Observation::PROMOTION_THRESHOLD
137
+ stdout.puts "Corroboration:"
138
+ stdout.puts " Max sightings: #{corroboration[:max]}"
139
+ stdout.puts " Promotable (>= #{threshold} sightings, not yet promoted): #{corroboration[:promotable]}"
140
+ end
141
+
142
+ def render_compression(compression)
143
+ ratio = compression[:ratio]
144
+ stdout.puts "Compression:"
145
+ stdout.puts " Observation tokens: #{compression[:observation_tokens]}"
146
+ stdout.puts " Source tokens: #{compression[:source_tokens]}"
147
+ if ratio
148
+ stdout.puts " Ratio (source / observation): #{ratio}x"
149
+ else
150
+ stdout.puts " Ratio (source / observation): n/a"
151
+ end
152
+ end
153
+
154
+ def render_recent(recent)
155
+ stdout.puts "Recent timeline:"
156
+ if recent.empty?
157
+ stdout.puts " (no observations)"
158
+ return
159
+ end
160
+ recent.each do |obs|
161
+ marker = priority_marker(obs[:priority])
162
+ stdout.puts " ##{obs[:id]} #{marker} [#{obs[:kind]}] x#{obs[:corroboration_count]} (#{obs[:observed_ago]})"
163
+ stdout.puts " #{obs[:body]}"
164
+ end
165
+ end
166
+
167
+ def priority_marker(priority)
168
+ case priority
169
+ when Domain::Observation::IMPORTANT then "[!]"
170
+ when Domain::Observation::MAYBE then "[~]"
171
+ else "[ ]"
172
+ end
173
+ end
174
+
175
+ # --- read aggregation (mirrors Dashboard::Observations) -----------------
176
+
177
+ def observation_stores(manager, scope)
178
+ scopes = scope ? [scope] : %w[project global]
179
+ scopes.filter_map { |s| manager.store_if_exists(s) }
180
+ .select { |store| store.db.table_exists?(:observations) }
181
+ end
182
+
183
+ def totals(stores)
184
+ {
185
+ active: count_where(stores, status: "active"),
186
+ consolidated: count_where(stores, status: "consolidated"),
187
+ expired: count_where(stores, status: "expired"),
188
+ promoted: stores.sum { |s| s.observations.exclude(promoted_at: nil).count }
189
+ }
190
+ end
191
+
192
+ def count_where(stores, **filter)
193
+ stores.sum { |s| s.observations.where(**filter).count }
194
+ end
195
+
196
+ def by_field(stores, field)
197
+ merged = Hash.new(0)
198
+ stores.each do |store|
199
+ store.observations.where(status: "active").group_and_count(field).each do |row|
200
+ merged[row[field]] += row[:count]
201
+ end
202
+ end
203
+ merged
204
+ end
205
+
206
+ def corroboration(stores)
207
+ threshold = Domain::Observation::PROMOTION_THRESHOLD
208
+ {
209
+ max: stores.map { |s| s.observations.where(status: "active").max(:corroboration_count) || 0 }.max || 0,
210
+ promotable: stores.sum do |s|
211
+ s.observations.where(status: "active", promoted_at: nil)
212
+ .where { corroboration_count >= threshold }.count
213
+ end
214
+ }
215
+ end
216
+
217
+ def compression(stores)
218
+ obs_tokens = stores.sum { |s| s.observations.where(status: "active").sum(:token_count) || 0 }
219
+ source_tokens = stores.sum { |s| source_tokens_for(s) }
220
+ ratio = obs_tokens.zero? ? nil : (source_tokens.to_f / obs_tokens).round(1)
221
+ {observation_tokens: obs_tokens, source_tokens: source_tokens, ratio: ratio}
222
+ end
223
+
224
+ def source_tokens_for(store)
225
+ ids = store.observations
226
+ .where(status: "active").exclude(source_content_item_id: nil)
227
+ .distinct.select(:source_content_item_id)
228
+ .map { |r| r[:source_content_item_id] }
229
+ return 0 if ids.empty?
230
+
231
+ bytes = store.content_items.where(id: ids).sum(:byte_len) || 0
232
+ (bytes / 4.0).round
233
+ end
234
+
235
+ def recent(stores, opts)
236
+ rows = stores.flat_map do |store|
237
+ dataset = store.observations
238
+ dataset = dataset.where(status: opts[:status]) if opts[:status]
239
+ dataset = dataset.where(kind: opts[:kind]) if opts[:kind]
240
+ dataset.order(Sequel.desc(:observed_at), Sequel.desc(:id)).limit(opts[:limit]).all
241
+ end
242
+
243
+ rows.sort_by { |o| o[:observed_at].to_s }.reverse.first(opts[:limit]).map do |o|
244
+ {
245
+ id: o[:id], kind: o[:kind], priority: o[:priority],
246
+ corroboration_count: o[:corroboration_count], body: o[:body],
247
+ observed_ago: Core::RelativeTime.format(o[:observed_at])
248
+ }
249
+ end
250
+ end
251
+
252
+ # --- promote ------------------------------------------------------------
253
+
254
+ def promote(args)
255
+ opts = parse_options(args, {predicate: nil, object: nil, subject: "repo", scope: "project"}) do |o|
256
+ OptionParser.new do |parser|
257
+ parser.banner = "Usage: claude-memory observations promote <id> --predicate P --object O [options]"
258
+ parser.on("--predicate P", "Fact predicate (e.g. decision, convention)") { |v| o[:predicate] = v }
259
+ parser.on("--object O", "Fact object (the claim text)") { |v| o[:object] = v }
260
+ parser.on("--subject S", "Fact subject (default: repo)") { |v| o[:subject] = v }
261
+ parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
262
+ end
263
+ end
264
+ return 1 if opts.nil?
265
+
266
+ observation_id = parse_id(args.first)
267
+ return failure("Usage: claude-memory observations promote <id> --predicate P --object O") if observation_id.nil?
268
+ return failure("--predicate and --object are required") if opts[:predicate].nil? || opts[:object].to_s.strip.empty?
269
+
270
+ manager = ClaudeMemory::Store::StoreManager.new
271
+ store = manager.store_for_scope(opts[:scope])
272
+
273
+ result = promote_observation(store, observation_id, opts)
274
+ manager.close
275
+
276
+ return failure(result[:error]) if result[:error]
277
+
278
+ stdout.puts "Promoted observation ##{observation_id} -> fact ##{result[:fact_id]}"
279
+ stdout.puts " #{result[:predicate]}: #{result[:object]}"
280
+ stdout.puts " Corroboration: #{result[:corroboration_count]} sighting(s)"
281
+ 0
282
+ end
283
+
284
+ # Server-side corroboration gate + Resolver path — the same logic the
285
+ # memory.promote_observation MCP handler uses. Returns {error:} on refusal
286
+ # or {fact_id:, predicate:, object:, corroboration_count:} on success.
287
+ def promote_observation(store, observation_id, opts)
288
+ obs = store.observations.where(id: observation_id).first
289
+ return {error: "Observation #{observation_id} not found in #{opts[:scope]} database."} unless obs
290
+ return {error: "Observation #{observation_id} already promoted (fact ##{obs[:promoted_fact_id]})."} unless obs[:promoted_at].nil?
291
+
292
+ threshold = Domain::Observation::PROMOTION_THRESHOLD
293
+ if obs[:corroboration_count].to_i < threshold
294
+ return {error: "Not yet corroborated: observation #{observation_id} has #{obs[:corroboration_count]} sighting(s), need #{threshold} (anti-hallucination gate)."}
295
+ end
296
+
297
+ occurred_at = Time.now.utc.iso8601
298
+ project_path = (opts[:scope] == "global") ? nil : Configuration.new.project_dir
299
+ extraction = Distill::Extraction.new(
300
+ facts: [{subject: opts[:subject], predicate: opts[:predicate], object: opts[:object], strength: "derived"}]
301
+ )
302
+ result = Resolve::Resolver.new(store).apply(
303
+ extraction, content_item_id: obs[:source_content_item_id],
304
+ occurred_at: occurred_at, project_path: project_path, scope: opts[:scope]
305
+ )
306
+
307
+ fact_id = result[:fact_ids].compact.first
308
+ return {error: "Promotion failed: the fact for observation #{observation_id} could not be resolved."} unless fact_id
309
+
310
+ store.mark_observation_promoted(observation_id, fact_id: fact_id)
311
+
312
+ {
313
+ fact_id: fact_id,
314
+ predicate: Resolve::PredicatePolicy.canonicalize(opts[:predicate]),
315
+ object: opts[:object],
316
+ corroboration_count: obs[:corroboration_count]
317
+ }
318
+ end
319
+
320
+ # --- consolidate --------------------------------------------------------
321
+
322
+ def consolidate(args)
323
+ opts = parse_options(args, {body: nil, kind: "event", priority: Domain::Observation::INFO, scope: "project"}) do |o|
324
+ OptionParser.new do |parser|
325
+ parser.banner = "Usage: claude-memory observations consolidate <id1,id2,...> --body \"<synthesis>\" [options]"
326
+ parser.on("--body TEXT", "The synthesized observation text") { |v| o[:body] = v }
327
+ parser.on("--kind K", "Kind for the merged observation (default: event)") { |v| o[:kind] = v }
328
+ parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
329
+ end
330
+ end
331
+ return 1 if opts.nil?
332
+
333
+ from_ids = parse_id_list(args.first)
334
+ return failure("Usage: claude-memory observations consolidate <id1,id2,...> --body \"<synthesis>\"") if from_ids.size < 2
335
+ return failure("--body is required") if opts[:body].to_s.strip.empty?
336
+
337
+ manager = ClaudeMemory::Store::StoreManager.new
338
+ store = manager.store_for_scope(opts[:scope])
339
+ project_path = (opts[:scope] == "global") ? nil : Configuration.new.project_dir
340
+
341
+ result = store.consolidate_observations(
342
+ from_ids, body: opts[:body].strip, kind: opts[:kind],
343
+ priority: opts[:priority], scope: opts[:scope], project_path: project_path
344
+ )
345
+ manager.close
346
+
347
+ return failure("Need at least 2 active #{opts[:scope]} observations from that set to consolidate.") if result.nil?
348
+
349
+ stdout.puts "Consolidated #{result[:merged]} observations -> ##{result[:id]}"
350
+ stdout.puts " Combined corroboration: #{result[:corroboration_count]} sighting(s)"
351
+ 0
352
+ end
353
+
354
+ # --- parsing helpers ----------------------------------------------------
355
+
356
+ def parse_id(token)
357
+ return nil if token.nil? || !token.match?(/\A\d+\z/)
358
+ token.to_i
359
+ end
360
+
361
+ def parse_id_list(token)
362
+ return [] if token.nil?
363
+ token.split(",").map(&:strip).select { |t| t.match?(/\A\d+\z/) }.map(&:to_i).uniq
364
+ end
365
+ end
366
+ end
367
+ end
@@ -37,6 +37,7 @@ module ClaudeMemory
37
37
  "completion" => {class: CompletionCommand, description: "Generate shell completions"},
38
38
  "embeddings" => {class: EmbeddingsCommand, description: "Inspect embedding backend"},
39
39
  "reject" => {class: RejectCommand, description: "Mark a fact as rejected"},
40
+ "observations" => {class: ObservationsCommand, description: "Inspect, promote, or consolidate episodic observations"},
40
41
  "restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"},
41
42
  "dedupe-conflicts" => {class: DedupeConflictsCommand, description: "Deduplicate historical open conflict rows that describe the same pair"},
42
43
  "reclassify-references" => {class: ReclassifyReferencesCommand, description: "Retag existing convention facts that match reference-material heuristics"},