claude_memory 0.12.0 → 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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +44 -48
  4. data/.claude/settings.local.json +2 -1
  5. data/.claude-plugin/marketplace.json +2 -2
  6. data/.claude-plugin/plugin.json +3 -5
  7. data/CHANGELOG.md +52 -0
  8. data/CLAUDE.md +13 -8
  9. data/README.md +46 -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 +23 -7
  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/docs/soak/audit_2026-06-03_agent-training-program.json +53 -0
  22. data/docs/soak/audit_2026-06-03_agentic.json +31 -0
  23. data/docs/soak/audit_2026-06-03_ai-software-architect.json +19 -0
  24. data/docs/soak/audit_2026-06-03_chaos_to_the_rescue.json +60 -0
  25. data/docs/soak/audit_2026-06-03_claude_memory.json +55 -0
  26. data/docs/soak/audit_2026-06-03_daily-vibe.json +59 -0
  27. data/docs/soak/audit_2026-06-03_minerva-sky.json +19 -0
  28. data/docs/soak/audit_2026-06-03_nowreading.dev.json +19 -0
  29. data/docs/soak/audit_2026-06-03_ups.dev.json +55 -0
  30. data/docs/soak/baseline_2026-06-03.md +145 -0
  31. data/lib/claude_memory/audit/checks.rb +149 -0
  32. data/lib/claude_memory/audit/runner.rb +4 -0
  33. data/lib/claude_memory/commands/census_command.rb +1 -1
  34. data/lib/claude_memory/commands/checks/embeddings_check.rb +97 -0
  35. data/lib/claude_memory/commands/doctor_command.rb +1 -0
  36. data/lib/claude_memory/commands/hook_command.rb +16 -3
  37. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +3 -1
  38. data/lib/claude_memory/commands/install_skill_command.rb +4 -0
  39. data/lib/claude_memory/commands/observations_command.rb +367 -0
  40. data/lib/claude_memory/commands/registry.rb +2 -0
  41. data/lib/claude_memory/commands/setup_vectors_command.rb +182 -0
  42. data/lib/claude_memory/commands/skills/reflect.md +68 -0
  43. data/lib/claude_memory/commands/stats_command.rb +60 -1
  44. data/lib/claude_memory/dashboard/api.rb +4 -0
  45. data/lib/claude_memory/dashboard/index.html +154 -2
  46. data/lib/claude_memory/dashboard/observations.rb +115 -0
  47. data/lib/claude_memory/dashboard/server.rb +1 -0
  48. data/lib/claude_memory/distill/extraction.rb +6 -4
  49. data/lib/claude_memory/distill/null_distiller.rb +86 -3
  50. data/lib/claude_memory/distill/reference_material_detector.rb +4 -1
  51. data/lib/claude_memory/domain/observation.rb +118 -0
  52. data/lib/claude_memory/embeddings/generator.rb +1 -1
  53. data/lib/claude_memory/hook/context_injector.rb +100 -2
  54. data/lib/claude_memory/mcp/handlers/management_handlers.rb +113 -2
  55. data/lib/claude_memory/mcp/handlers/query_handlers.rb +48 -1
  56. data/lib/claude_memory/mcp/instructions_builder.rb +1 -0
  57. data/lib/claude_memory/mcp/query_guide.rb +28 -0
  58. data/lib/claude_memory/mcp/tool_definitions.rb +58 -0
  59. data/lib/claude_memory/mcp/tools.rb +3 -0
  60. data/lib/claude_memory/observe/observations_renderer.rb +49 -0
  61. data/lib/claude_memory/observe/reflector.rb +91 -0
  62. data/lib/claude_memory/publish.rb +53 -1
  63. data/lib/claude_memory/resolve/resolver.rb +45 -8
  64. data/lib/claude_memory/store/schema_manager.rb +1 -1
  65. data/lib/claude_memory/store/sqlite_store.rb +181 -0
  66. data/lib/claude_memory/sweep/maintenance.rb +15 -1
  67. data/lib/claude_memory/sweep/sweeper.rb +7 -1
  68. data/lib/claude_memory/version.rb +1 -1
  69. data/lib/claude_memory.rb +7 -0
  70. metadata +23 -1
@@ -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"},
@@ -45,6 +46,7 @@ module ClaudeMemory
45
46
  "digest" => {class: DigestCommand, description: "Render a weekly markdown digest of memory activity"},
46
47
  "show" => {class: ShowCommand, description: "Print what memory would inject at the next SessionStart"},
47
48
  "otel" => {class: OtelCommand, description: "Configure or inspect OpenTelemetry ingestion from Claude Code"},
49
+ "setup-vectors" => {class: SetupVectorsCommand, description: "Opt into vector recall — write provider env to .claude/settings.json + re-index"},
48
50
  "import-auto-memory" => {class: ImportAutoMemoryCommand, description: "Import Claude Code auto-memory .md files into project DB as facts"},
49
51
  "audit" => {class: AuditCommand, description: "Run memory health audit; report inconsistencies and optimizations"}
50
52
  }.freeze
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "optparse"
6
+
7
+ module ClaudeMemory
8
+ module Commands
9
+ # Guides the user through opting into vector recall with fastembed
10
+ # (or another provider). fastembed stays a dev/test gem dependency by
11
+ # design; this command is the documented opt-in path for end users.
12
+ #
13
+ # Steps:
14
+ # 1. Verify the chosen provider is loadable. For fastembed, surface
15
+ # a clear install command if the gem isn't on $LOAD_PATH.
16
+ # 2. Persist CLAUDE_MEMORY_EMBEDDING_PROVIDER (and optional model)
17
+ # into the project's .claude/settings.json env block, the same
18
+ # mechanism Claude Code uses for OTel env (see OTel::SettingsWriter).
19
+ # 3. Re-embed existing facts under the new provider (unless --no-reindex).
20
+ # 4. Report the final state — provider, dimensions, stored alignment.
21
+ class SetupVectorsCommand < BaseCommand
22
+ OWNED_KEYS = %w[
23
+ CLAUDE_MEMORY_EMBEDDING_PROVIDER
24
+ CLAUDE_MEMORY_EMBEDDING_MODEL
25
+ ].freeze
26
+
27
+ FASTEMBED_INSTALL_HINT = <<~HINT
28
+ fastembed is not installed. claude-memory keeps fastembed as a
29
+ dev/test dependency so the default gem install stays light. To
30
+ enable it, install the gem and re-run setup-vectors:
31
+
32
+ gem install fastembed
33
+ claude-memory setup-vectors
34
+
35
+ Or if you bundle, add to your Gemfile:
36
+
37
+ gem "fastembed"
38
+
39
+ Then `bundle install` and re-run setup-vectors. The first run
40
+ downloads the BAAI/bge-small-en-v1.5 ONNX model (~75MB).
41
+ HINT
42
+
43
+ def call(args)
44
+ opts = parse_opts(args)
45
+ return 1 if opts.nil?
46
+
47
+ return print_status if opts[:status]
48
+
49
+ provider_name = opts[:provider]
50
+ unless verify_provider_loadable(provider_name)
51
+ return 1
52
+ end
53
+
54
+ if opts[:dry_run]
55
+ stdout.puts "Would write to #{settings_path}:"
56
+ stdout.puts " CLAUDE_MEMORY_EMBEDDING_PROVIDER=#{provider_name}"
57
+ stdout.puts " CLAUDE_MEMORY_EMBEDDING_MODEL=#{opts[:model]}" if opts[:model]
58
+ stdout.puts(opts[:reindex] ? "Would re-index facts under the new provider" : "Would skip re-index (--no-reindex)")
59
+ return 0
60
+ end
61
+
62
+ write_settings(provider_name, opts[:model])
63
+
64
+ if opts[:reindex]
65
+ reindex_result = reindex(provider_name)
66
+ return 1 if reindex_result != 0
67
+ else
68
+ stdout.puts "Skipped re-index (--no-reindex). Run 'claude-memory index --force --provider=#{provider_name}' when ready."
69
+ end
70
+
71
+ report_final_state(provider_name)
72
+ 0
73
+ end
74
+
75
+ private
76
+
77
+ def parse_opts(args)
78
+ options = {provider: "fastembed", model: nil, reindex: true, dry_run: false, status: false}
79
+ parser = OptionParser.new do |o|
80
+ o.banner = "Usage: claude-memory setup-vectors [--provider=fastembed|api|tfidf] [--model=NAME] [--no-reindex] [--dry-run] [--status]"
81
+ o.on("--provider NAME", "Embedding provider (default: fastembed)") { |v| options[:provider] = v }
82
+ o.on("--model NAME", "Optional model name (e.g. BAAI/bge-small-en-v1.5)") { |v| options[:model] = v }
83
+ o.on("--no-reindex", "Skip re-embedding existing facts under the new provider") { options[:reindex] = false }
84
+ o.on("--dry-run", "Print what would change without writing or re-indexing") { options[:dry_run] = true }
85
+ o.on("--status", "Show the current provider config + stored alignment, then exit") { options[:status] = true }
86
+ end
87
+ parser.parse!(args.dup)
88
+ options
89
+ rescue OptionParser::InvalidOption => e
90
+ stderr.puts e.message
91
+ nil
92
+ end
93
+
94
+ def verify_provider_loadable(provider_name)
95
+ case provider_name
96
+ when "tfidf"
97
+ true # always available
98
+ when "fastembed"
99
+ require "fastembed"
100
+ true
101
+ when "api"
102
+ # api provider needs network + key but no gem; defer to runtime
103
+ true
104
+ else
105
+ stderr.puts "Unknown provider: #{provider_name}. Valid: tfidf, fastembed, api."
106
+ false
107
+ end
108
+ rescue LoadError
109
+ stderr.puts FASTEMBED_INSTALL_HINT
110
+ false
111
+ end
112
+
113
+ def settings_path
114
+ File.join(claude_dir, "settings.json")
115
+ end
116
+
117
+ def claude_dir
118
+ File.join(Configuration.new.project_dir, ".claude")
119
+ end
120
+
121
+ def write_settings(provider_name, model)
122
+ FileUtils.mkdir_p(claude_dir)
123
+ settings = load_settings
124
+ settings["env"] ||= {}
125
+ settings["env"]["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] = provider_name
126
+ if model
127
+ settings["env"]["CLAUDE_MEMORY_EMBEDDING_MODEL"] = model
128
+ else
129
+ settings["env"].delete("CLAUDE_MEMORY_EMBEDDING_MODEL")
130
+ end
131
+ File.write(settings_path, JSON.pretty_generate(settings) + "\n")
132
+ stdout.puts "✓ Wrote CLAUDE_MEMORY_EMBEDDING_PROVIDER=#{provider_name} to #{settings_path}"
133
+ stdout.puts "✓ Wrote CLAUDE_MEMORY_EMBEDDING_MODEL=#{model}" if model
134
+ end
135
+
136
+ def load_settings
137
+ return {} unless File.exist?(settings_path)
138
+ raw = File.read(settings_path)
139
+ return {} if raw.strip.empty?
140
+ parsed = JSON.parse(raw)
141
+ parsed.is_a?(Hash) ? parsed : {}
142
+ rescue JSON::ParserError => e
143
+ stderr.puts "settings.json parse error: #{e.message} — refusing to overwrite"
144
+ {}
145
+ end
146
+
147
+ def reindex(provider_name)
148
+ stdout.puts "→ Re-embedding facts under provider=#{provider_name}…"
149
+ IndexCommand.new(stdout: stdout, stderr: stderr).call(["--force", "--provider", provider_name])
150
+ end
151
+
152
+ def report_final_state(provider_name)
153
+ # The settings.json write only affects future sessions (Claude Code
154
+ # reads the env block at session start). For the current process
155
+ # the ENV var isn't set, so report what Embeddings.resolve would
156
+ # produce under the new env.
157
+ env_override = ENV.to_h.merge("CLAUDE_MEMORY_EMBEDDING_PROVIDER" => provider_name)
158
+ provider = Embeddings.resolve(provider_name, env: env_override)
159
+ stdout.puts
160
+ stdout.puts "Provider: #{provider.name}, dimensions: #{provider.dimensions}"
161
+ stdout.puts "Next session will use this provider. Run 'claude-memory doctor' to verify."
162
+ end
163
+
164
+ def print_status
165
+ # Resolve under current ENV to show what the next session will use
166
+ provider = Embeddings.resolve
167
+ stdout.puts "Current provider: #{provider.name}"
168
+ stdout.puts "Current dimensions: #{provider.dimensions}"
169
+ stdout.puts "Settings file: #{settings_path}"
170
+ env = load_settings.fetch("env", {})
171
+ relevant = env.slice(*OWNED_KEYS)
172
+ if relevant.any?
173
+ stdout.puts "Configured env:"
174
+ relevant.each { |k, v| stdout.puts " #{k}=#{v}" }
175
+ else
176
+ stdout.puts "Configured env: (none — using default tfidf)"
177
+ end
178
+ 0
179
+ end
180
+ end
181
+ end
182
+ end