claude_memory 0.10.0 → 0.12.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +42 -64
  4. data/.claude/skills/release/SKILL.md +44 -6
  5. data/.claude/skills/study-repo/SKILL.md +15 -0
  6. data/.claude-plugin/commands/audit-memory.md +68 -0
  7. data/.claude-plugin/marketplace.json +1 -1
  8. data/.claude-plugin/plugin.json +1 -1
  9. data/CHANGELOG.md +70 -0
  10. data/CLAUDE.md +20 -5
  11. data/README.md +64 -2
  12. data/db/migrations/018_add_otel_telemetry.rb +81 -0
  13. data/docs/1_0_punchlist.md +522 -89
  14. data/docs/GETTING_STARTED.md +3 -1
  15. data/docs/api_stability.md +341 -0
  16. data/docs/architecture.md +3 -3
  17. data/docs/audit_runbook.md +209 -0
  18. data/docs/claude_monitoring.md +956 -0
  19. data/docs/dashboard.md +23 -3
  20. data/docs/improvements.md +329 -5
  21. data/docs/influence/ai-memory-systems-2026.md +403 -0
  22. data/docs/memory_audit_2026-05-21.md +303 -0
  23. data/docs/plugin.md +1 -1
  24. data/docs/quality_review.md +35 -0
  25. data/lib/claude_memory/audit/checks.rb +239 -0
  26. data/lib/claude_memory/audit/finding.rb +33 -0
  27. data/lib/claude_memory/audit/runner.rb +73 -0
  28. data/lib/claude_memory/commands/audit_command.rb +117 -0
  29. data/lib/claude_memory/commands/dashboard_command.rb +2 -1
  30. data/lib/claude_memory/commands/digest_command.rb +95 -3
  31. data/lib/claude_memory/commands/hook_command.rb +27 -2
  32. data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
  33. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
  34. data/lib/claude_memory/commands/otel_command.rb +240 -0
  35. data/lib/claude_memory/commands/registry.rb +5 -1
  36. data/lib/claude_memory/commands/show_command.rb +90 -0
  37. data/lib/claude_memory/commands/stats_command.rb +94 -2
  38. data/lib/claude_memory/configuration.rb +60 -0
  39. data/lib/claude_memory/core/fact_query_builder.rb +1 -0
  40. data/lib/claude_memory/dashboard/api.rb +8 -0
  41. data/lib/claude_memory/dashboard/index.html +140 -1
  42. data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
  43. data/lib/claude_memory/dashboard/server.rb +86 -0
  44. data/lib/claude_memory/dashboard/telemetry.rb +156 -0
  45. data/lib/claude_memory/dashboard/trust.rb +180 -11
  46. data/lib/claude_memory/deprecations.rb +106 -0
  47. data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
  48. data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
  49. data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
  50. data/lib/claude_memory/hook/context_injector.rb +11 -2
  51. data/lib/claude_memory/hook/handler.rb +142 -1
  52. data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
  53. data/lib/claude_memory/otel/attributes.rb +118 -0
  54. data/lib/claude_memory/otel/constants.rb +32 -0
  55. data/lib/claude_memory/otel/ingestor.rb +54 -0
  56. data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
  57. data/lib/claude_memory/otel/prompt_scope.rb +108 -0
  58. data/lib/claude_memory/otel/settings_writer.rb +122 -0
  59. data/lib/claude_memory/otel/status.rb +58 -0
  60. data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
  61. data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
  62. data/lib/claude_memory/resolve/resolver.rb +30 -3
  63. data/lib/claude_memory/shortcuts.rb +61 -18
  64. data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
  65. data/lib/claude_memory/store/schema_manager.rb +1 -1
  66. data/lib/claude_memory/store/sqlite_store.rb +136 -0
  67. data/lib/claude_memory/sweep/maintenance.rb +31 -1
  68. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  69. data/lib/claude_memory/templates/hooks.example.json +5 -0
  70. data/lib/claude_memory/version.rb +1 -1
  71. data/lib/claude_memory.rb +20 -0
  72. metadata +28 -1
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Audit
5
+ # Orchestrates the audit: opens a StoreManager, runs every check in
6
+ # CHECK_METHODS, collects findings, computes an exit code.
7
+ #
8
+ # The runner itself is read-only. Suggestions in each Finding name
9
+ # the commands a user (or skill) would run to remediate; the audit
10
+ # never writes.
11
+ class Runner
12
+ CHECK_METHODS = %i[
13
+ open_conflicts
14
+ single_cardinality_multiplicity
15
+ single_cardinality_churn
16
+ distillation_backlog
17
+ shortcut_decision_leak
18
+ shortcut_convention_scope
19
+ duplicate_global_conventions
20
+ bare_conclusion_rate
21
+ project_starvation
22
+ auto_memory_unimported
23
+ ].freeze
24
+
25
+ Result = Data.define(:findings, :stats) do
26
+ def errors = findings.select(&:error?)
27
+ def warnings = findings.select(&:warn?)
28
+ def info = findings.select(&:info?)
29
+ def ok? = errors.empty?
30
+ def exit_code = ok? ? 0 : 1
31
+ end
32
+
33
+ def initialize(manager: nil)
34
+ @manager = manager || Store::StoreManager.new
35
+ end
36
+
37
+ def run
38
+ findings = CHECK_METHODS.flat_map { |method| Checks.public_send(method, @manager) }
39
+ Result.new(findings: findings, stats: collect_stats)
40
+ end
41
+
42
+ private
43
+
44
+ def collect_stats
45
+ global = @manager.store_if_exists("global")
46
+ project = @manager.store_if_exists("project")
47
+ {
48
+ checks_run: CHECK_METHODS.size,
49
+ global: store_stats(global),
50
+ project: store_stats(project)
51
+ }
52
+ end
53
+
54
+ def store_stats(store)
55
+ return nil unless store
56
+ {
57
+ active_facts: store.facts.where(status: "active").count,
58
+ predicate_counts: predicate_distribution(store)
59
+ }
60
+ end
61
+
62
+ def predicate_distribution(store)
63
+ store.facts
64
+ .where(status: "active")
65
+ .group_and_count(:predicate)
66
+ .all
67
+ .map { |row| [row[:predicate], row[:count]] }
68
+ .sort_by { |_, c| -c }
69
+ .to_h
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module ClaudeMemory
7
+ module Commands
8
+ # Runs the memory health audit and prints findings. Exits non-zero
9
+ # when error-severity findings are present (unless --no-exit is
10
+ # given). JSON output is the stable surface — humans should not
11
+ # script against the text output.
12
+ class AuditCommand < BaseCommand
13
+ SEVERITY_RANK = {info: 0, warn: 1, error: 2}.freeze
14
+
15
+ def call(args)
16
+ opts = parse_opts(args)
17
+ return 1 if opts.nil?
18
+
19
+ manager = Store::StoreManager.new
20
+ result = Audit::Runner.new(manager: manager).run
21
+ filtered = filter_by_severity(result.findings, opts[:severity])
22
+
23
+ if opts[:json]
24
+ stdout.puts JSON.pretty_generate(payload(result, filtered))
25
+ else
26
+ render_text(result, filtered)
27
+ end
28
+
29
+ manager.close
30
+ opts[:no_exit] ? 0 : result.exit_code
31
+ end
32
+
33
+ def filter_by_severity(findings, threshold)
34
+ return findings if threshold.nil?
35
+ floor = SEVERITY_RANK.fetch(threshold) { return findings }
36
+ findings.select { |f| SEVERITY_RANK[f.severity] >= floor }
37
+ end
38
+
39
+ private
40
+
41
+ def parse_opts(args)
42
+ options = {json: false, no_exit: false, severity: nil}
43
+ parser = OptionParser.new do |o|
44
+ o.banner = "Usage: claude-memory audit [--json] [--no-exit] [--severity=error|warn|info]"
45
+ o.on("--json", "Emit JSON instead of text") { options[:json] = true }
46
+ o.on("--no-exit", "Always exit 0 even on error-severity findings") { options[:no_exit] = true }
47
+ o.on("--severity LEVEL", "Only show findings at or above LEVEL (error|warn|info)") { |v| options[:severity] = v.to_sym }
48
+ end
49
+ parser.parse!(args.dup)
50
+ options
51
+ rescue OptionParser::InvalidOption => e
52
+ stderr.puts e.message
53
+ nil
54
+ end
55
+
56
+ def payload(result, filtered)
57
+ {
58
+ ok: result.ok?,
59
+ checks_run: result.stats[:checks_run],
60
+ counts: {
61
+ error: result.errors.size,
62
+ warn: result.warnings.size,
63
+ info: result.info.size
64
+ },
65
+ stats: result.stats.except(:checks_run),
66
+ findings: filtered.map(&:to_h)
67
+ }
68
+ end
69
+
70
+ def render_text(result, filtered)
71
+ stdout.puts "Memory health audit — #{Time.now.utc.iso8601}"
72
+ stdout.puts("=" * 60)
73
+ render_stats(result.stats)
74
+ stdout.puts ""
75
+ render_summary(result)
76
+ stdout.puts ""
77
+ render_findings(filtered)
78
+ stdout.puts ""
79
+ stdout.puts(result.ok? ? "OK" : "FAIL")
80
+ end
81
+
82
+ def render_stats(stats)
83
+ %i[global project].each do |scope|
84
+ s = stats[scope]
85
+ next unless s
86
+ preds = s[:predicate_counts].map { |k, v| "#{k}=#{v}" }.join(", ")
87
+ stdout.puts "#{scope.to_s.capitalize.ljust(7)} #{s[:active_facts]} active facts #{preds}"
88
+ end
89
+ end
90
+
91
+ def render_summary(result)
92
+ stdout.puts "Checks run: #{result.stats[:checks_run]}"
93
+ stdout.puts "Errors: #{result.errors.size} Warnings: #{result.warnings.size} Info: #{result.info.size}"
94
+ end
95
+
96
+ def render_findings(findings)
97
+ if findings.empty?
98
+ stdout.puts "No findings."
99
+ return
100
+ end
101
+
102
+ findings.each do |f|
103
+ marker = case f.severity
104
+ when :error then "[ERROR]"
105
+ when :warn then "[WARN]"
106
+ when :info then "[INFO]"
107
+ end
108
+ stdout.puts "#{marker} #{f.id} #{f.title}"
109
+ stdout.puts " #{f.detail}"
110
+ stdout.puts " → #{f.suggestion}"
111
+ stdout.puts " fact_ids: #{f.fact_ids.first(20).inspect}#{" (+#{f.fact_ids.size - 20} more)" if f.fact_ids.size > 20}" if f.fact_ids.any?
112
+ stdout.puts ""
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -36,7 +36,8 @@ module ClaudeMemory
36
36
  manager.ensure_global! if manager.global_exists?
37
37
  manager.ensure_project! if manager.project_exists?
38
38
 
39
- stdout.puts "Starting ClaudeMemory dashboard on http://localhost:#{opts[:port]}"
39
+ stdout.puts "Starting ClaudeMemory dashboard on http://127.0.0.1:#{opts[:port]}"
40
+ stdout.puts "OTel receiver listening at http://127.0.0.1:#{opts[:port]}/v1/{metrics,logs,traces}"
40
41
  stdout.puts "Press Ctrl+C to stop."
41
42
 
42
43
  server = Dashboard::Server.new(
@@ -5,9 +5,11 @@ require "optparse"
5
5
  module ClaudeMemory
6
6
  module Commands
7
7
  # Weekly digest — a markdown summary of what memory did over the last N days.
8
- # Rolls up moment counts, new knowledge, utilization, conflicts, and user
9
- # feedback so users can see the value memory is delivering without
10
- # needing to visit the dashboard.
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.
11
13
  #
12
14
  # The data it aggregates all already exists (activity_events, facts,
13
15
  # conflicts, moment_feedback); this command only shapes it into a report.
@@ -48,6 +50,10 @@ module ClaudeMemory
48
50
  lines << ""
49
51
  lines << activity_section(manager, cutoff)
50
52
  lines << ""
53
+ lines << context_cost_section(manager)
54
+ lines << ""
55
+ lines << quality_section(manager, cutoff)
56
+ lines << ""
51
57
  lines << knowledge_section(manager, cutoff)
52
58
  lines << ""
53
59
  lines << utilization_section(manager)
@@ -124,6 +130,92 @@ module ClaudeMemory
124
130
  "## New knowledge\n\n_Unavailable: #{e.message}_"
125
131
  end
126
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
+
127
219
  def utilization_section(manager)
128
220
  util = Dashboard::Trust.new(manager).utilization
129
221
  pct = util[:ratio_pct]
@@ -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,6 +171,28 @@ 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"]
@@ -213,6 +237,7 @@ module ClaudeMemory
213
237
  details = {
214
238
  source: source,
215
239
  context_length: context_text&.length,
240
+ context_tokens: ClaudeMemory::Core::TokenEstimator.estimate(context_text),
216
241
  preview: context_text&.byteslice(0, CONTEXT_PREVIEW_BYTES),
217
242
  truncated: context_text ? context_text.bytesize > CONTEXT_PREVIEW_BYTES : false,
218
243
  top_fact_ids: injector.emitted_fact_ids.first(10),
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "optparse"
5
+
6
+ module ClaudeMemory
7
+ module Commands
8
+ # Imports Claude Code auto-memory files (~/.claude/projects/<slug>/memory/*.md)
9
+ # into the project database as durable facts. Before this command, those
10
+ # markdown files were only surfaced transiently via `Hook::AutoMemoryMirror`
11
+ # at SessionStart — they were invisible to `memory.recall` and the
12
+ # shortcut tools. Importing them as facts (predicate=convention for
13
+ # gotcha/feedback/project files, predicate=reference for reference
14
+ # type) makes that knowledge first-class queryable knowledge.
15
+ #
16
+ # Idempotent on object_literal prefix: re-running skips files whose
17
+ # body text is already present.
18
+ class ImportAutoMemoryCommand < BaseCommand
19
+ def call(args)
20
+ opts = parse_opts(args)
21
+ return 1 if opts.nil?
22
+
23
+ auto_dir = resolve_auto_dir
24
+ files = list_files(auto_dir)
25
+ if files.empty?
26
+ stdout.puts "No auto-memory files found in #{auto_dir}"
27
+ return 0
28
+ end
29
+
30
+ manager = Store::StoreManager.new
31
+ manager.ensure_project!
32
+ store = manager.project_store
33
+
34
+ imported = 0
35
+ skipped = 0
36
+ files.each do |path|
37
+ fact_data = parse_file(path)
38
+ next if fact_data.nil?
39
+
40
+ if already_imported?(store, fact_data[:object_literal])
41
+ skipped += 1
42
+ next
43
+ end
44
+
45
+ if opts[:dry_run]
46
+ stdout.puts "[DRY] #{File.basename(path)} → #{fact_data[:predicate]}"
47
+ else
48
+ insert(store, fact_data, path)
49
+ stdout.puts "Imported: #{File.basename(path)} → #{fact_data[:predicate]}"
50
+ end
51
+ imported += 1
52
+ end
53
+
54
+ stdout.puts ""
55
+ verb = opts[:dry_run] ? "Would import" : "Imported"
56
+ stdout.puts "#{verb}: #{imported} Skipped (already present): #{skipped}"
57
+ manager.close
58
+ 0
59
+ end
60
+
61
+ private
62
+
63
+ def parse_opts(args)
64
+ options = {dry_run: false}
65
+ parser = OptionParser.new do |o|
66
+ o.banner = "Usage: claude-memory import-auto-memory [--dry-run]"
67
+ o.on("--dry-run", "Show what would be imported without writing") { options[:dry_run] = true }
68
+ end
69
+ parser.parse!(args.dup)
70
+ options
71
+ rescue OptionParser::InvalidOption => e
72
+ stderr.puts e.message
73
+ nil
74
+ end
75
+
76
+ def resolve_auto_dir
77
+ config = Configuration.new
78
+ Hook::AutoMemoryMirror.default_dir(config.project_dir, config.claude_config_dir)
79
+ end
80
+
81
+ def list_files(dir)
82
+ return [] unless Dir.exist?(dir)
83
+ Dir.glob(File.join(dir, "*.md")).reject { |f| File.basename(f) == "MEMORY.md" }.sort
84
+ end
85
+
86
+ def parse_file(path)
87
+ text = File.read(path)
88
+ return nil unless (match = text.match(/\A---\n(.*?)\n---\n(.*)\z/m))
89
+
90
+ frontmatter = match[1]
91
+ body = match[2].strip
92
+
93
+ name = frontmatter[/^name:\s*(.+)/, 1]&.strip
94
+ type = frontmatter[/^type:\s*(.+)/, 1]&.strip
95
+ description = frontmatter[/^description:\s*(.+)/, 1]&.strip
96
+ return nil if name.nil? || type.nil?
97
+
98
+ predicate, subject, scope = map_type(type)
99
+ object = build_object(name, description, body)
100
+
101
+ {
102
+ subject: subject,
103
+ predicate: predicate,
104
+ object_literal: object,
105
+ scope: scope
106
+ }
107
+ end
108
+
109
+ def map_type(type)
110
+ case type
111
+ when "feedback", "user"
112
+ ["convention", "user", "global"]
113
+ when "reference"
114
+ ["reference", "repo", "project"]
115
+ else
116
+ # gotcha, project, anything else
117
+ ["convention", "repo", "project"]
118
+ end
119
+ end
120
+
121
+ # Build an object string that carries a reason clause so
122
+ # BareConclusionDetector does not flag the fact as bare. Auto-memory
123
+ # files conventionally include a **Why:** section; we surface the first
124
+ # 400 chars of the body alongside the name as the object text.
125
+ def build_object(name, description, body)
126
+ first_para = body.split("\n\n").first.to_s.strip
127
+ first_para = first_para[0..400] + "..." if first_para.length > 400
128
+
129
+ parts = [name]
130
+ parts << description if description && !description.empty? && description != name
131
+ parts << first_para unless first_para.empty?
132
+ text = parts.join(" — ").gsub(/\s+/, " ").strip
133
+
134
+ # If no reason clause is present, attach a stable suffix so the fact
135
+ # is structurally distinguishable from bare conclusions.
136
+ text += " (imported from project auto-memory; see source file for full reasoning)" unless reason_present?(text)
137
+ text
138
+ end
139
+
140
+ def reason_present?(text)
141
+ text.match?(/\b(because|so that|so the|so we|in order to|to avoid|to ensure|to prevent|prevents|otherwise|caused by|breaks when)\b/i)
142
+ end
143
+
144
+ def already_imported?(store, object_text)
145
+ needle = object_text[0..80].gsub(/[%_]/) { |c| "\\#{c}" }
146
+ !store.facts.where(Sequel.like(:object_literal, "#{needle}%")).limit(1).all.empty?
147
+ end
148
+
149
+ def insert(store, fact_data, path)
150
+ store.db.transaction do
151
+ subject_type = (fact_data[:subject] == "user") ? "person" : "repo"
152
+ subject_id = store.find_or_create_entity(type: subject_type, name: fact_data[:subject])
153
+
154
+ fact_id = store.insert_fact(
155
+ subject_entity_id: subject_id,
156
+ predicate: fact_data[:predicate],
157
+ object_literal: fact_data[:object_literal],
158
+ scope: fact_data[:scope],
159
+ project_path: (fact_data[:scope] == "global") ? nil : Configuration.new.project_dir
160
+ )
161
+
162
+ content_id = store.upsert_content_item(
163
+ source: "auto_memory_import",
164
+ session_id: nil,
165
+ text_hash: Digest::SHA256.hexdigest(path + fact_data[:object_literal]),
166
+ byte_len: fact_data[:object_literal].bytesize,
167
+ raw_text: fact_data[:object_literal]
168
+ )
169
+
170
+ store.insert_provenance(
171
+ fact_id: fact_id,
172
+ content_item_id: content_id,
173
+ quote: fact_data[:object_literal][0..200],
174
+ strength: "stated"
175
+ )
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -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" => [{