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
@@ -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"},
@@ -0,0 +1,68 @@
1
+ # Reflect
2
+
3
+ Consolidate the episodic observation log and promote corroborated observations into
4
+ durable facts. This is the manual, on-demand counterpart to the automatic Reflector
5
+ that runs during sweep — use it for a deeper pass.
6
+
7
+ ## Usage
8
+
9
+ ```
10
+ /reflect
11
+ /reflect --scope project
12
+ ```
13
+
14
+ ## Instructions
15
+
16
+ You are the Reflector for ClaudeMemory's episodic observation layer. Observations are
17
+ the "what happened" log; facts are the "what is true" store. Your job is to look across
18
+ the recent observations, find what has become a stable truth, and promote it — while
19
+ leaving one-off noise alone.
20
+
21
+ Work in three passes:
22
+
23
+ ### 1. Survey
24
+
25
+ Call `memory.observations` (use `important_only: true` first for the 🔴 entries, then a
26
+ broader pass). Read the log as a narrative of what has happened in this project.
27
+
28
+ ### 2. Consolidate related observations
29
+
30
+ Where several observations describe the **same thing in different words** (regex dedup only
31
+ catches exact matches), merge them with `memory.consolidate_observations(from_ids: […],
32
+ body: "<synthesis>")`. The synthesized observation inherits the **combined** corroboration of
33
+ its sources — which often tips it over the promotion threshold — and the originals are
34
+ tombstoned (preserved and linked, not deleted). Use the `#id` shown for each observation.
35
+
36
+ ### 3. Promote corroborated observations → facts
37
+
38
+ The promotion bridge is gated: an observation must have been **corroborated** (sighted
39
+ repeatedly — `corroboration_count` ≥ the threshold) before it can become a fact. This is
40
+ deliberate: requiring repeated sightings before commitment is an anti-hallucination gate
41
+ against one-off doc/example text.
42
+
43
+ For each observation that represents a **stable, repeated truth**:
44
+
45
+ - Call `memory.promote_observation` with `observation_id`, a `predicate`
46
+ (`decision` / `convention` / `architecture`), and an `object` that **embeds a reason**
47
+ ("… because …", "… so that …", "to avoid …"). A bare conclusion is dead weight.
48
+ - The tool refuses observations that are not yet corroborated — do not try to force them.
49
+ If something genuinely matters but has only been seen once, leave it; it will become
50
+ eligible once it recurs.
51
+
52
+ Skip observations that are:
53
+
54
+ - transient (debugging steps, one-off events),
55
+ - already captured as facts (check `memory.recall` / `memory.decisions` first),
56
+ - example/illustrative text rather than a claim about *this* project.
57
+
58
+ ### 4. Report
59
+
60
+ Summarize what you promoted (observation → fact) and what you intentionally left as
61
+ observations and why. Do not delete or rewrite observations — the deterministic Reflector
62
+ handles dedup/expiry during sweep; your job is the semantic judgment the regex pass can't make.
63
+
64
+ ## Notes
65
+
66
+ - Promotion preserves provenance: the new fact links back to the observation's source.
67
+ - Promoted observations are marked so they are not re-suggested.
68
+ - No extra API cost — this runs inside the existing Claude Code session.
@@ -13,7 +13,7 @@ module ClaudeMemory
13
13
  SCOPE_PROJECT = "project"
14
14
 
15
15
  def call(args)
16
- opts = parse_options(args, {scope: SCOPE_ALL, tools: false, tokens: false, stale: false, since_days: nil, stale_days: nil}) do |o|
16
+ opts = parse_options(args, {scope: SCOPE_ALL, tools: false, tokens: false, stale: false, observations: false, since_days: nil, stale_days: nil}) do |o|
17
17
  OptionParser.new do |parser|
18
18
  parser.banner = "Usage: claude-memory stats [options]"
19
19
  parser.on("--scope SCOPE", ["all", "global", "project"],
@@ -21,6 +21,7 @@ module ClaudeMemory
21
21
  parser.on("--tools", "Show MCP tool-call usage stats") { o[:tools] = true }
22
22
  parser.on("--tokens", "Show SessionStart context-injection token budget") { o[:tokens] = true }
23
23
  parser.on("--stale", "Show facts not recalled in CLAUDE_MEMORY_STALE_DAYS (default 14)") { o[:stale] = true }
24
+ parser.on("--observations", "Show episodic observation counts (status, kind, promotable)") { o[:observations] = true }
24
25
  parser.on("--since DAYS", Integer, "Limit --tools/--tokens to last N days") { |v| o[:since_days] = v }
25
26
  parser.on("--stale-days N", Integer, "Override staleness threshold for --stale") { |v| o[:stale_days] = v }
26
27
  end
@@ -31,6 +32,10 @@ module ClaudeMemory
31
32
  return print_mcp_tool_call_stats(opts[:since_days])
32
33
  end
33
34
 
35
+ if opts[:observations]
36
+ return print_observation_stats
37
+ end
38
+
34
39
  if opts[:tokens]
35
40
  return print_token_budget_stats(opts[:since_days])
36
41
  end
@@ -90,6 +95,60 @@ module ClaudeMemory
90
95
  0
91
96
  end
92
97
 
98
+ def print_observation_stats
99
+ manager = ClaudeMemory::Store::StoreManager.new
100
+ stores = %w[project global]
101
+ .filter_map { |scope| manager.store_if_exists(scope) }
102
+ .select { |store| store.db.table_exists?(:observations) }
103
+
104
+ stdout.puts "Observation Statistics (episodic layer)"
105
+ stdout.puts "=" * 50
106
+
107
+ threshold = ClaudeMemory::Domain::Observation::PROMOTION_THRESHOLD
108
+
109
+ total = stores.sum { |s| s.observations.count }
110
+ if total.zero?
111
+ stdout.puts "No observations recorded yet."
112
+ manager.close
113
+ return 0
114
+ end
115
+
116
+ active = stores.sum { |s| s.observations.where(status: "active").count }
117
+ consolidated = stores.sum { |s| s.observations.where(status: "consolidated").count }
118
+ expired = stores.sum { |s| s.observations.where(status: "expired").count }
119
+ promoted = stores.sum { |s| s.observations.exclude(promoted_at: nil).count }
120
+ promotable = stores.sum do |s|
121
+ s.observations.where(status: "active", promoted_at: nil)
122
+ .where { corroboration_count >= threshold }.count
123
+ end
124
+
125
+ stdout.puts "Active: #{active}"
126
+ stdout.puts "Consolidated: #{consolidated}"
127
+ stdout.puts "Expired: #{expired}"
128
+ stdout.puts "Promoted: #{promoted}"
129
+ stdout.puts "Promotable (>= #{threshold} sightings): #{promotable}"
130
+ stdout.puts
131
+
132
+ kinds = Hash.new(0)
133
+ stores.each do |store|
134
+ store.observations.where(status: "active").group_and_count(:kind).each do |row|
135
+ kinds[row[:kind]] += row[:count]
136
+ end
137
+ end
138
+
139
+ stdout.puts "By kind (active):"
140
+ if kinds.empty?
141
+ stdout.puts " (none)"
142
+ else
143
+ kinds.sort_by { |_k, v| -v }.each do |kind, count|
144
+ stdout.puts " #{count.to_s.rjust(4)} - #{kind}"
145
+ end
146
+ end
147
+
148
+ manager.close
149
+ 0
150
+ end
151
+
93
152
  def open_readonly(db_path)
94
153
  Sequel.connect("extralite://#{db_path}")
95
154
  end
@@ -456,6 +456,10 @@ module ClaudeMemory
456
456
  Efficacy::Reporter.report(events, timeframe: {since: since, session_id: session_id})
457
457
  end
458
458
 
459
+ def observations
460
+ Observations.new(@manager).report
461
+ end
462
+
459
463
  def timeline
460
464
  Timeline.new(@manager).days
461
465
  end