claude_memory 0.9.1 → 0.11.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/skills/dashboard/SKILL.md +42 -0
  4. data/.claude-plugin/marketplace.json +1 -1
  5. data/.claude-plugin/plugin.json +1 -1
  6. data/CHANGELOG.md +130 -0
  7. data/CLAUDE.md +30 -6
  8. data/README.md +66 -2
  9. data/db/migrations/015_add_activity_events.rb +26 -0
  10. data/db/migrations/016_add_moment_feedback.rb +22 -0
  11. data/db/migrations/017_add_last_recalled_at.rb +15 -0
  12. data/docs/1_0_punchlist.md +371 -0
  13. data/docs/EXAMPLES.md +41 -2
  14. data/docs/GETTING_STARTED.md +33 -4
  15. data/docs/architecture.md +22 -7
  16. data/docs/audit-queries.md +131 -0
  17. data/docs/dashboard.md +192 -0
  18. data/docs/improvements.md +650 -9
  19. data/docs/influence/cq.md +187 -0
  20. data/docs/plugin.md +13 -6
  21. data/docs/quality_review.md +524 -172
  22. data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
  23. data/lib/claude_memory/activity_log.rb +86 -0
  24. data/lib/claude_memory/commands/census_command.rb +210 -0
  25. data/lib/claude_memory/commands/completion_command.rb +3 -0
  26. data/lib/claude_memory/commands/dashboard_command.rb +54 -0
  27. data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
  28. data/lib/claude_memory/commands/digest_command.rb +273 -0
  29. data/lib/claude_memory/commands/hook_command.rb +61 -2
  30. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
  31. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  32. data/lib/claude_memory/commands/registry.rb +7 -1
  33. data/lib/claude_memory/commands/show_command.rb +90 -0
  34. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  35. data/lib/claude_memory/commands/stats_command.rb +131 -2
  36. data/lib/claude_memory/commands/sweep_command.rb +2 -0
  37. data/lib/claude_memory/configuration.rb +16 -0
  38. data/lib/claude_memory/core/relative_time.rb +9 -0
  39. data/lib/claude_memory/dashboard/api.rb +610 -0
  40. data/lib/claude_memory/dashboard/conflicts.rb +279 -0
  41. data/lib/claude_memory/dashboard/efficacy.rb +127 -0
  42. data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
  43. data/lib/claude_memory/dashboard/health.rb +175 -0
  44. data/lib/claude_memory/dashboard/index.html +2707 -0
  45. data/lib/claude_memory/dashboard/knowledge.rb +136 -0
  46. data/lib/claude_memory/dashboard/moments.rb +244 -0
  47. data/lib/claude_memory/dashboard/reuse.rb +97 -0
  48. data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
  49. data/lib/claude_memory/dashboard/server.rb +211 -0
  50. data/lib/claude_memory/dashboard/timeline.rb +68 -0
  51. data/lib/claude_memory/dashboard/trust.rb +454 -0
  52. data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
  53. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  54. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  55. data/lib/claude_memory/hook/context_injector.rb +97 -3
  56. data/lib/claude_memory/hook/handler.rb +191 -3
  57. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  58. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  59. data/lib/claude_memory/mcp/text_summary.rb +29 -0
  60. data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
  61. data/lib/claude_memory/mcp/tools.rb +148 -0
  62. data/lib/claude_memory/publish.rb +13 -21
  63. data/lib/claude_memory/recall/stale_detector.rb +67 -0
  64. data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
  65. data/lib/claude_memory/resolve/resolver.rb +41 -11
  66. data/lib/claude_memory/store/llm_cache.rb +68 -0
  67. data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
  68. data/lib/claude_memory/store/schema_manager.rb +1 -1
  69. data/lib/claude_memory/store/sqlite_store.rb +47 -143
  70. data/lib/claude_memory/store/store_manager.rb +29 -0
  71. data/lib/claude_memory/sweep/maintenance.rb +216 -0
  72. data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
  73. data/lib/claude_memory/sweep/sweeper.rb +2 -0
  74. data/lib/claude_memory/templates/hooks.example.json +5 -0
  75. data/lib/claude_memory/version.rb +1 -1
  76. data/lib/claude_memory.rb +24 -0
  77. metadata +51 -1
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ClaudeMemory
6
+ module Commands
7
+ # Weekly digest — a markdown summary of what memory did over the last N days.
8
+ # Sections (in order): Activity, Context cost, Quality, New knowledge,
9
+ # Utilization, Conflicts, Feedback. The Context cost and Quality
10
+ # sections (added 0.11.0) read from `Dashboard::Trust#token_budget` and
11
+ # `#quality_score` so users see the cost/pollution side-by-side with
12
+ # the value side without needing to visit the dashboard.
13
+ #
14
+ # The data it aggregates all already exists (activity_events, facts,
15
+ # conflicts, moment_feedback); this command only shapes it into a report.
16
+ class DigestCommand < BaseCommand
17
+ def call(args)
18
+ opts = parse_options(args, {since_days: 7, output: nil}) do |o|
19
+ OptionParser.new do |parser|
20
+ parser.banner = "Usage: claude-memory digest [options]"
21
+ parser.on("--since DAYS", Integer, "Coverage window in days (default: 7)") { |v| o[:since_days] = v }
22
+ parser.on("--output FILE", "Write to file instead of stdout") { |v| o[:output] = v }
23
+ end
24
+ end
25
+ return 1 if opts.nil?
26
+ return failure("--since must be positive") if opts[:since_days] <= 0
27
+
28
+ manager = Store::StoreManager.new
29
+ report = render_report(manager, opts[:since_days])
30
+ manager.close
31
+
32
+ if opts[:output]
33
+ File.write(opts[:output], report)
34
+ stderr.puts "Wrote digest to #{opts[:output]}"
35
+ else
36
+ stdout.puts report
37
+ end
38
+
39
+ 0
40
+ end
41
+
42
+ private
43
+
44
+ def render_report(manager, days)
45
+ cutoff = (Time.now.utc - days * 86_400).iso8601
46
+ lines = []
47
+ lines << "# ClaudeMemory Digest"
48
+ lines << ""
49
+ lines << "_Coverage: last #{days} day#{"s" unless days == 1} (since #{cutoff})_"
50
+ lines << ""
51
+ lines << activity_section(manager, cutoff)
52
+ lines << ""
53
+ lines << context_cost_section(manager)
54
+ lines << ""
55
+ lines << quality_section(manager, cutoff)
56
+ lines << ""
57
+ lines << knowledge_section(manager, cutoff)
58
+ lines << ""
59
+ lines << utilization_section(manager)
60
+ lines << ""
61
+ lines << conflicts_section(manager)
62
+ lines << ""
63
+ lines << feedback_section(manager, cutoff)
64
+ lines.join("\n") + "\n"
65
+ end
66
+
67
+ def activity_section(manager, cutoff)
68
+ store = manager.default_store(prefer: :project)
69
+ return "## Activity\n\n_No project database._" unless store
70
+
71
+ by_kind = store.activity_events
72
+ .where { occurred_at >= cutoff }
73
+ .group_and_count(:event_type)
74
+ .all
75
+ .to_h { |r| [r[:event_type], r[:count]] }
76
+
77
+ total = by_kind.values.sum
78
+ out = ["## Activity", ""]
79
+ out << "**Moments recorded:** #{total}"
80
+ out << ""
81
+ if total.zero?
82
+ out << "_No activity in this window._"
83
+ else
84
+ %w[recall store_extraction hook_context hook_ingest hook_sweep].each do |event_type|
85
+ count = by_kind[event_type] || 0
86
+ next if count.zero?
87
+ out << "- #{humanize_event(event_type)}: #{count}"
88
+ end
89
+ end
90
+ out.join("\n")
91
+ rescue Sequel::DatabaseError => e
92
+ "## Activity\n\n_Unavailable: #{e.message}_"
93
+ end
94
+
95
+ def humanize_event(event_type)
96
+ case event_type
97
+ when "recall" then "Recalls"
98
+ when "store_extraction" then "Facts extracted"
99
+ when "hook_context" then "Context injections"
100
+ when "hook_ingest" then "Transcripts ingested"
101
+ when "hook_sweep" then "Maintenance sweeps"
102
+ else event_type
103
+ end
104
+ end
105
+
106
+ def knowledge_section(manager, cutoff)
107
+ out = ["## New knowledge", ""]
108
+ counts = {}
109
+ %w[project global].each do |scope|
110
+ store = manager.store_if_exists(scope)
111
+ next unless store
112
+ store.facts
113
+ .where(status: "active")
114
+ .where { created_at >= cutoff }
115
+ .group_and_count(:predicate)
116
+ .all
117
+ .each { |r| counts[r[:predicate]] = (counts[r[:predicate]] || 0) + r[:count] }
118
+ end
119
+
120
+ if counts.empty?
121
+ out << "_No new facts in this window._"
122
+ else
123
+ total = counts.values.sum
124
+ out << "**New active facts:** #{total}"
125
+ out << ""
126
+ counts.sort_by { |_, c| -c }.each { |predicate, count| out << "- #{predicate}: #{count}" }
127
+ end
128
+ out.join("\n")
129
+ rescue Sequel::DatabaseError => e
130
+ "## New knowledge\n\n_Unavailable: #{e.message}_"
131
+ end
132
+
133
+ # The token cost of every SessionStart context injection, measured over
134
+ # the last 30 days (Trust panel's window — intentionally wider than the
135
+ # digest's coverage window so percentiles stay statistically meaningful
136
+ # on quiet weeks). Reports zero state explicitly so users know whether a
137
+ # missing number means "no injections" vs. "telemetry didn't fire".
138
+ def context_cost_section(manager)
139
+ tb = Dashboard::Trust.new(manager).token_budget
140
+ out = ["## Context cost", ""]
141
+ if tb[:sample_size].zero?
142
+ out << "_No context injections in the last #{tb[:window_days]} days._"
143
+ else
144
+ out << "**Per-session injected tokens (last #{tb[:window_days]}d, n=#{tb[:sample_size]}):**"
145
+ out << "- p50: #{tb[:p50]} tokens"
146
+ out << "- p95: #{tb[:p95]} tokens"
147
+ out << "- avg: #{tb[:avg]} tokens"
148
+ end
149
+ out.join("\n")
150
+ rescue Sequel::DatabaseError => e
151
+ "## Context cost\n\n_Unavailable: #{e.message}_"
152
+ end
153
+
154
+ # Hallucination-rate proxy. Reports two numbers per the
155
+ # `quality_review.md` 2026-04-30 investigation:
156
+ #
157
+ # - Live (last `window_days`, headline) — actionable signal of
158
+ # ongoing extraction quality.
159
+ # - Historical (all active facts, supplementary) — visible so
160
+ # legacy noise isn't hidden, but the headline is the live one.
161
+ #
162
+ # The split exists because the unwindowed metric mixed pre-prompt-
163
+ # commit bare conclusions with live data; users read the combined
164
+ # number as "ongoing quality" and that's misleading.
165
+ def quality_section(manager, cutoff)
166
+ out = ["## Quality", ""]
167
+ qs = Dashboard::Trust.new(manager).quality_score
168
+
169
+ if qs[:total_active].zero?
170
+ if qs[:historical][:total_active].zero?
171
+ out << "_No active facts to score yet._"
172
+ else
173
+ out << "_No facts extracted in the last #{qs[:window_days]} days._"
174
+ out << "- Historical (all active): score #{qs[:historical][:score]}/100, " \
175
+ "#{qs[:historical][:total_active]} facts, " \
176
+ "#{qs[:historical][:bare_conclusion_count]} bare, " \
177
+ "#{qs[:historical][:suspect_count]} suspect"
178
+ end
179
+ else
180
+ out << "**Live score (last #{qs[:window_days]}d):** #{qs[:score]}/100 _(higher is cleaner)_"
181
+ out << "- Suspect (reference material): #{qs[:suspect_count]} (#{qs[:suspect_pct]}%)"
182
+ out << "- Bare conclusions (decision/convention without reason): #{qs[:bare_conclusion_count]} (#{qs[:bare_pct]}%)"
183
+ if qs[:historical][:total_active] > qs[:total_active]
184
+ out << ""
185
+ out << "_Historical (all active): score #{qs[:historical][:score]}/100, " \
186
+ "#{qs[:historical][:total_active]} facts, " \
187
+ "#{qs[:historical][:bare_conclusion_count]} bare, " \
188
+ "#{qs[:historical][:suspect_count]} suspect_"
189
+ end
190
+ end
191
+
192
+ rate = rejection_rate_in_window(manager, cutoff)
193
+ out << ""
194
+ out << "**Rejection rate (in window):** #{rate[:rejected]} of #{rate[:created]} extracted facts rejected (#{rate[:pct]}%)"
195
+
196
+ out.join("\n")
197
+ rescue Sequel::DatabaseError => e
198
+ "## Quality\n\n_Unavailable: #{e.message}_"
199
+ end
200
+
201
+ # How many facts created in the digest window have since been
202
+ # rejected? Counts across both stores.
203
+ def rejection_rate_in_window(manager, cutoff)
204
+ created = 0
205
+ rejected = 0
206
+
207
+ %w[project global].each do |scope|
208
+ store = manager.store_if_exists(scope)
209
+ next unless store
210
+ dataset = store.facts.where { created_at >= cutoff }
211
+ created += dataset.count
212
+ rejected += dataset.where(status: "rejected").count
213
+ end
214
+
215
+ pct = created.zero? ? 0.0 : (rejected * 100.0 / created).round(1)
216
+ {created: created, rejected: rejected, pct: pct}
217
+ end
218
+
219
+ def utilization_section(manager)
220
+ util = Dashboard::Trust.new(manager).utilization
221
+ pct = util[:ratio_pct]
222
+ <<~SECTION.strip
223
+ ## Utilization
224
+
225
+ **Ratio (last #{util[:window_days]}d):** #{pct}%
226
+ - Extracted: #{util[:extracted]}
227
+ - Used (of those extracted): #{util[:used_from_extracted]}
228
+ - Total fact uses across recalls + injections: #{util[:used]}
229
+ SECTION
230
+ end
231
+
232
+ def conflicts_section(manager)
233
+ counts = Dashboard::Conflicts.new(manager).distinct_open_counts
234
+ total = counts[:total]
235
+ out = ["## Conflicts", ""]
236
+ if total.zero?
237
+ out << "_No open conflicts._"
238
+ else
239
+ out << "**Open contradictions:** #{total} distinct"
240
+ out << "- Project: #{counts[:project]}"
241
+ out << "- Global: #{counts[:global]}"
242
+ end
243
+ out.join("\n")
244
+ rescue Sequel::DatabaseError => e
245
+ "## Conflicts\n\n_Unavailable: #{e.message}_"
246
+ end
247
+
248
+ def feedback_section(manager, cutoff)
249
+ store = manager.default_store(prefer: :project)
250
+ return "## Feedback\n\n_No project database._" unless store
251
+
252
+ rows = store.moment_feedback.where { recorded_at >= cutoff }.all
253
+ up = rows.count { |r| r[:verdict] == "up" }
254
+ down = rows.count { |r| r[:verdict] == "down" }
255
+ total = up + down
256
+
257
+ out = ["## Feedback", ""]
258
+ if total.zero?
259
+ out << "_No thumbs in this window._"
260
+ else
261
+ ratio = ((up.to_f / total) * 100).round
262
+ out << "**Moments rated:** #{total}"
263
+ out << "- 👍 Up: #{up}"
264
+ out << "- 👎 Down: #{down}"
265
+ out << "- Positive ratio: #{ratio}%"
266
+ end
267
+ out.join("\n")
268
+ rescue Sequel::DatabaseError => e
269
+ "## Feedback\n\n_Unavailable: #{e.message}_"
270
+ end
271
+ end
272
+ end
273
+ end
@@ -19,9 +19,9 @@ module ClaudeMemory
19
19
  return Hook::ExitCodes::ERROR
20
20
  end
21
21
 
22
- unless %w[ingest sweep publish context].include?(subcommand)
22
+ unless %w[ingest sweep publish context nudge].include?(subcommand)
23
23
  stderr.puts "Unknown hook command: #{subcommand}"
24
- stderr.puts "Available: ingest, sweep, publish, context"
24
+ stderr.puts "Available: ingest, sweep, publish, context, nudge"
25
25
  return Hook::ExitCodes::ERROR
26
26
  end
27
27
 
@@ -63,6 +63,8 @@ module ClaudeMemory
63
63
  hook_publish(handler, payload)
64
64
  when "context"
65
65
  hook_context(payload, opts[:db])
66
+ when "nudge"
67
+ hook_nudge(payload, opts[:db])
66
68
  end
67
69
 
68
70
  store.close
@@ -169,17 +171,42 @@ module ClaudeMemory
169
171
  Hook::ExitCodes::SUCCESS
170
172
  end
171
173
 
174
+ def hook_nudge(payload, db_path)
175
+ # Nudge needs to count past nudge events across both stores,
176
+ # so prefer the manager-aware path. db_path overrides only
177
+ # the project store (useful for tests).
178
+ project_path = payload["project_path"] || payload["cwd"]
179
+ manager = ClaudeMemory::Store::StoreManager.new(
180
+ project_db_path: db_path, project_path: project_path
181
+ )
182
+ manager.ensure_both!
183
+ store = manager.project_store || manager.global_store
184
+
185
+ handler = ClaudeMemory::Hook::Handler.new(store, manager: manager)
186
+ result = handler.nudge(payload)
187
+
188
+ stdout.puts result[:message] if result[:status] == :emitted
189
+
190
+ manager.close
191
+ Hook::ExitCodes::SUCCESS
192
+ rescue => e
193
+ classify_error(e)
194
+ end
195
+
172
196
  def hook_context(payload, db_path)
173
197
  project_path = payload["project_path"] || payload["cwd"]
174
198
  source = payload["source"]
199
+ session_id = payload["session_id"]
175
200
  manager = ClaudeMemory::Store::StoreManager.new(
176
201
  project_db_path: db_path,
177
202
  project_path: project_path
178
203
  )
179
204
  manager.ensure_both!
180
205
 
206
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
181
207
  injector = ClaudeMemory::Hook::ContextInjector.new(manager, source: source)
182
208
  context_text = injector.generate_context
209
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
183
210
 
184
211
  if context_text
185
212
  response = {
@@ -191,12 +218,44 @@ module ClaudeMemory
191
218
  stdout.puts JSON.generate(response)
192
219
  end
193
220
 
221
+ record_context_activity(manager, context_text, injector,
222
+ session_id: session_id, source: source, duration_ms: duration_ms)
223
+
194
224
  manager.close
195
225
  Hook::ExitCodes::SUCCESS
196
226
  rescue => e
197
227
  classify_error(e)
198
228
  end
199
229
 
230
+ CONTEXT_PREVIEW_BYTES = 400
231
+
232
+ def record_context_activity(manager, context_text, injector, session_id:, source:, duration_ms:)
233
+ store = manager.project_store || manager.global_store
234
+ return unless store
235
+
236
+ by_scope = injector.emitted_facts_by_scope.transform_values { |ids| ids.first(10) }
237
+ details = {
238
+ source: source,
239
+ context_length: context_text&.length,
240
+ context_tokens: ClaudeMemory::Core::TokenEstimator.estimate(context_text),
241
+ preview: context_text&.byteslice(0, CONTEXT_PREVIEW_BYTES),
242
+ truncated: context_text ? context_text.bytesize > CONTEXT_PREVIEW_BYTES : false,
243
+ top_fact_ids: injector.emitted_fact_ids.first(10),
244
+ top_facts_by_scope: (by_scope if by_scope.any?),
245
+ top_subjects: injector.emitted_subjects.uniq.first(10),
246
+ fact_count: injector.emitted_fact_ids.size
247
+ }.compact
248
+
249
+ ClaudeMemory::ActivityLog.record(store,
250
+ event_type: "hook_context",
251
+ status: context_text ? "success" : "skipped",
252
+ session_id: session_id,
253
+ duration_ms: duration_ms,
254
+ details: details)
255
+ rescue => e
256
+ ClaudeMemory.logger.debug("record_context_activity failed: #{e.message}")
257
+ end
258
+
200
259
  def classify_error(error)
201
260
  exit_code = Hook::ErrorClassifier.exit_code_for(error)
202
261
 
@@ -19,8 +19,9 @@ module ClaudeMemory
19
19
  db_path = ClaudeMemory.project_db_path
20
20
  ingest_cmd = "claude-memory hook ingest --db #{db_path}"
21
21
  sweep_cmd = "claude-memory hook sweep --db #{db_path}"
22
+ nudge_cmd = "claude-memory hook nudge --db #{db_path}"
22
23
 
23
- hooks_config = build_hooks_config(ingest_cmd, sweep_cmd)
24
+ hooks_config = build_hooks_config(ingest_cmd, sweep_cmd, nudge_cmd)
24
25
 
25
26
  existing = load_json_file(settings_path)
26
27
  existing["hooks"] ||= {}
@@ -37,8 +38,9 @@ module ClaudeMemory
37
38
  db_path = ClaudeMemory.global_db_path
38
39
  ingest_cmd = "claude-memory hook ingest --db #{db_path}"
39
40
  sweep_cmd = "claude-memory hook sweep --db #{db_path}"
41
+ nudge_cmd = "claude-memory hook nudge --db #{db_path}"
40
42
 
41
- hooks_config = build_hooks_config(ingest_cmd, sweep_cmd)
43
+ hooks_config = build_hooks_config(ingest_cmd, sweep_cmd, nudge_cmd)
42
44
 
43
45
  existing = load_json_file(settings_path)
44
46
  existing["hooks"] ||= {}
@@ -96,7 +98,7 @@ module ClaudeMemory
96
98
 
97
99
  private
98
100
 
99
- def build_hooks_config(ingest_cmd, sweep_cmd)
101
+ def build_hooks_config(ingest_cmd, sweep_cmd, nudge_cmd = "claude-memory hook nudge")
100
102
  context_cmd = "claude-memory hook context"
101
103
 
102
104
  {
@@ -132,7 +134,8 @@ module ClaudeMemory
132
134
  {"type" => "command", "command" => ingest_cmd, "timeout" => 30,
133
135
  "statusMessage" => "Saving memory..."},
134
136
  {"type" => "command", "command" => sweep_cmd, "timeout" => 30,
135
- "statusMessage" => "Sweeping memory..."}
137
+ "statusMessage" => "Sweeping memory..."},
138
+ {"type" => "command", "command" => nudge_cmd, "timeout" => 5}
136
139
  ]
137
140
  }],
138
141
  "TaskCompleted" => [{
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ClaudeMemory
6
+ module Commands
7
+ # One-time cleanup for historical convention facts that are actually
8
+ # descriptions of external projects (LOC counts, star counts, author
9
+ # attributions, "X is a plugin…" templates). The Distill::ReferenceMaterialDetector
10
+ # now guards new writes in ManagementHandlers#store_extraction; this
11
+ # command walks existing rows and retags them to predicate=reference.
12
+ class ReclassifyReferencesCommand < BaseCommand
13
+ def call(args)
14
+ opts = parse_options(args, {scope: "project", dry_run: false}) do |o|
15
+ OptionParser.new do |parser|
16
+ parser.banner = "Usage: claude-memory reclassify-references [options]"
17
+ parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
18
+ parser.on("--dry-run", "Show what would be reclassified without writing") { o[:dry_run] = true }
19
+ end
20
+ end
21
+ return 1 if opts.nil?
22
+
23
+ manager = ClaudeMemory::Store::StoreManager.new
24
+ store = manager.store_for_scope(opts[:scope])
25
+
26
+ begin
27
+ result = Sweep::Maintenance.new(store).reclassify_references(dry_run: opts[:dry_run])
28
+ ensure
29
+ manager.close
30
+ end
31
+
32
+ print_result(opts, result)
33
+ 0
34
+ end
35
+
36
+ private
37
+
38
+ def print_result(opts, result)
39
+ mode = opts[:dry_run] ? "DRY RUN" : "RECLASSIFY"
40
+ stdout.puts "#{mode}: scope=#{opts[:scope]}"
41
+ stdout.puts "=" * 50
42
+ stdout.puts "Active conventions inspected: #{result[:inspected]}"
43
+ stdout.puts "Reclassified as reference: #{result[:reclassified]}"
44
+
45
+ return if result[:decisions].empty?
46
+
47
+ stdout.puts
48
+ stdout.puts "Decisions:"
49
+ result[:decisions].each do |d|
50
+ preview = d[:object].to_s[0, 100]
51
+ stdout.puts " fact ##{d[:fact_id]} #{preview}#{"…" if d[:object].to_s.length > 100}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -37,7 +37,13 @@ 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
- "restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"}
40
+ "restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"},
41
+ "dedupe-conflicts" => {class: DedupeConflictsCommand, description: "Deduplicate historical open conflict rows that describe the same pair"},
42
+ "reclassify-references" => {class: ReclassifyReferencesCommand, description: "Retag existing convention facts that match reference-material heuristics"},
43
+ "census" => {class: CensusCommand, description: "Aggregate predicate usage across project databases"},
44
+ "dashboard" => {class: DashboardCommand, description: "Open debugging dashboard"},
45
+ "digest" => {class: DigestCommand, description: "Render a weekly markdown digest of memory activity"},
46
+ "show" => {class: ShowCommand, description: "Print what memory would inject at the next SessionStart"}
41
47
  }.freeze
42
48
 
43
49
  # Find a command class by name
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ClaudeMemory
6
+ module Commands
7
+ # Prints what memory would inject on the next SessionStart.
8
+ #
9
+ # The trust answer to "is this still worth it?" requires
10
+ # inspectability: a user who can't see what memory will inject can't
11
+ # develop confidence in it. The CLAUDE.md alternative is `cat
12
+ # CLAUDE.md` — instant, plain English, no tooling. This command is
13
+ # the same one-line inspect surface for the curated facts the
14
+ # injector picks each session.
15
+ #
16
+ # Runs the exact `Hook::ContextInjector` path real sessions use, so
17
+ # what you see here is what Claude actually receives — not a
18
+ # rebuilt approximation that could drift.
19
+ #
20
+ # The default suppresses the "Pending Knowledge Extraction" dump
21
+ # (which contains raw transcript JSON intended for LLM distillation)
22
+ # so the output stays human-readable. Pass `--pending` to see the
23
+ # full fresh-session payload, including those raw items.
24
+ class ShowCommand < BaseCommand
25
+ VALID_SOURCES = %w[startup resume clear].freeze
26
+
27
+ # Any string outside FRESH_SESSION_SOURCES skips the pending-knowledge
28
+ # block. "preview" reads naturally in any debug log this surfaces in.
29
+ NON_FRESH_SOURCE = "preview"
30
+
31
+ def call(args)
32
+ opts = parse_options(args, {source: nil, pending: false}) do |o|
33
+ OptionParser.new do |parser|
34
+ parser.banner = "Usage: claude-memory show [--source SOURCE] [--pending]"
35
+ parser.on("--source SOURCE", VALID_SOURCES,
36
+ "Simulate fresh-session source (#{VALID_SOURCES.join(", ")}). " \
37
+ "Forces inclusion of pending-knowledge and auto-memory-mirror " \
38
+ "sections regardless of --pending.") { |v| o[:source] = v }
39
+ parser.on("--pending",
40
+ "Include the pending-knowledge dump (raw transcript JSON " \
41
+ "for LLM distillation). Default suppresses it for readability.") { o[:pending] = true }
42
+ end
43
+ end
44
+ return 1 if opts.nil?
45
+
46
+ effective_source = opts[:source] || (opts[:pending] ? nil : NON_FRESH_SOURCE)
47
+
48
+ manager = Store::StoreManager.new
49
+ manager.ensure_both!
50
+ injector = Hook::ContextInjector.new(manager, source: effective_source)
51
+ context = injector.generate_context
52
+
53
+ print_header(opts[:source])
54
+ stdout.puts ""
55
+
56
+ if context.nil? || context.strip.empty?
57
+ stdout.puts "_Memory has no facts to inject yet._"
58
+ stdout.puts ""
59
+ stdout.puts "Run a few Claude Code sessions in this project, or use"
60
+ stdout.puts "`memory.store_extraction` from a session to seed facts."
61
+ else
62
+ stdout.puts context
63
+ stdout.puts ""
64
+ print_footer(injector, context)
65
+ end
66
+
67
+ manager.close
68
+ 0
69
+ rescue Sequel::DatabaseError => e
70
+ failure("Database error: #{e.message}")
71
+ end
72
+
73
+ private
74
+
75
+ def print_header(source)
76
+ label = source ? " (source=#{source})" : ""
77
+ stdout.puts "## Memory snapshot — would be injected at next SessionStart#{label}"
78
+ end
79
+
80
+ def print_footer(injector, context)
81
+ tokens = Core::TokenEstimator.estimate(context)
82
+ fact_count = injector.emitted_fact_ids.size
83
+ stdout.puts "---"
84
+ stdout.puts "#{fact_count} fact#{"s" unless fact_count == 1} • " \
85
+ "~#{tokens} token#{"s" unless tokens == 1} • " \
86
+ "#{context.length} chars"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -35,12 +35,23 @@ For each content item, carefully read the raw_text and extract:
35
35
  architecture, uses_framework, uses_language, uses_database,
36
36
  deployment_platform, auth_method). Other snake_case predicates are
37
37
  accepted but fall through to the default multi-value policy.
38
- - object: The value
38
+ - object: The value. For **decision** and **convention** predicates, the object
39
+ MUST embed the reason — append a compact "— because ..." / "so that ..." /
40
+ "to avoid ..." clause, or include the trigger ("caused by X", "breaks when Y").
41
+ Bare conclusions without rationale are dead weight once they become stale:
42
+ a fact with a reason is recoverable, a fact without one is not. Architecture
43
+ facts should note the design *trade-off* if non-obvious.
39
44
  - confidence: 0.0-1.0
40
45
  - quote: Source excerpt (max 200 chars)
41
46
  - strength: "stated" (explicitly said) or "inferred" (implied)
42
47
  - scope_hint: "project" (this project only) or "global" (all projects)
43
48
 
49
+ Examples of the reasoning requirement:
50
+ - ❌ Bare: "Configuration class has instance methods only"
51
+ - ✅ With why: "Configuration class has instance methods only — stub with instance_double + allow(Configuration).to receive(:new) because class-level stubbing breaks isolation"
52
+ - ❌ Bare: "MCP tools return dual content + structuredContent"
53
+ - ✅ With why: "MCP tools return dual content + structuredContent so human-readable summaries and machine-parseable JSON ship in the same response; compact mode omits receipts for ~60% smaller payloads"
54
+
44
55
  **Decisions** — Choices made:
45
56
  - title: Short summary (max 100 chars)
46
57
  - summary: Full description
@@ -100,3 +111,4 @@ Return a summary:
100
111
  - Prefer "stated" strength over "inferred" unless clearly implied
101
112
  - Do NOT fabricate facts — only extract what's actually in the text
102
113
  - If text is mostly code/tool output with no conversational knowledge, mark as distilled with 0 facts
114
+ - Prefer one fact-with-reason over two facts-without. Length cost is worth it — stale facts with reasoning are recoverable, stale facts without are dead weight