claude_memory 0.1.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/CLAUDE.md +3 -0
  3. data/.claude/memory.sqlite3 +0 -0
  4. data/.claude/output-styles/memory-aware.md +21 -0
  5. data/.claude/rules/claude_memory.generated.md +21 -0
  6. data/.claude/settings.json +62 -0
  7. data/.claude/settings.local.json +21 -0
  8. data/.claude-plugin/marketplace.json +13 -0
  9. data/.claude-plugin/plugin.json +10 -0
  10. data/.mcp.json +11 -0
  11. data/CHANGELOG.md +36 -0
  12. data/CLAUDE.md +224 -0
  13. data/CODE_OF_CONDUCT.md +10 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +212 -0
  16. data/Rakefile +10 -0
  17. data/commands/analyze.md +29 -0
  18. data/commands/recall.md +17 -0
  19. data/commands/remember.md +26 -0
  20. data/docs/demo.md +126 -0
  21. data/docs/organizational_memory_playbook.md +291 -0
  22. data/docs/plan.md +411 -0
  23. data/docs/plugin.md +202 -0
  24. data/docs/updated_plan.md +453 -0
  25. data/exe/claude-memory +8 -0
  26. data/hooks/hooks.json +59 -0
  27. data/lib/claude_memory/cli.rb +869 -0
  28. data/lib/claude_memory/distill/distiller.rb +11 -0
  29. data/lib/claude_memory/distill/extraction.rb +29 -0
  30. data/lib/claude_memory/distill/json_schema.md +78 -0
  31. data/lib/claude_memory/distill/null_distiller.rb +123 -0
  32. data/lib/claude_memory/hook/handler.rb +49 -0
  33. data/lib/claude_memory/index/lexical_fts.rb +58 -0
  34. data/lib/claude_memory/ingest/ingester.rb +46 -0
  35. data/lib/claude_memory/ingest/transcript_reader.rb +21 -0
  36. data/lib/claude_memory/mcp/server.rb +127 -0
  37. data/lib/claude_memory/mcp/tools.rb +409 -0
  38. data/lib/claude_memory/publish.rb +201 -0
  39. data/lib/claude_memory/recall.rb +360 -0
  40. data/lib/claude_memory/resolve/predicate_policy.rb +30 -0
  41. data/lib/claude_memory/resolve/resolver.rb +152 -0
  42. data/lib/claude_memory/store/sqlite_store.rb +340 -0
  43. data/lib/claude_memory/store/store_manager.rb +139 -0
  44. data/lib/claude_memory/sweep/sweeper.rb +80 -0
  45. data/lib/claude_memory/templates/hooks.example.json +74 -0
  46. data/lib/claude_memory/templates/output-styles/memory-aware.md +21 -0
  47. data/lib/claude_memory/version.rb +5 -0
  48. data/lib/claude_memory.rb +36 -0
  49. data/sig/claude_memory.rbs +4 -0
  50. data/skills/analyze/SKILL.md +126 -0
  51. data/skills/memory/SKILL.md +82 -0
  52. metadata +123 -0
@@ -0,0 +1,869 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ClaudeMemory
6
+ class CLI
7
+ COMMANDS = %w[help version db:init].freeze
8
+
9
+ def initialize(args = ARGV, stdout: $stdout, stderr: $stderr, stdin: $stdin)
10
+ @args = args
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ @stdin = stdin
14
+ end
15
+
16
+ def run
17
+ command = @args.first || "help"
18
+
19
+ case command
20
+ when "help", "-h", "--help"
21
+ print_help
22
+ 0
23
+ when "version", "-v", "--version"
24
+ print_version
25
+ 0
26
+ when "db:init"
27
+ db_init
28
+ 0
29
+ when "init"
30
+ init_project
31
+ when "ingest"
32
+ ingest
33
+ when "search"
34
+ search
35
+ when "recall"
36
+ recall_cmd
37
+ when "explain"
38
+ explain_cmd
39
+ when "conflicts"
40
+ conflicts_cmd
41
+ when "changes"
42
+ changes_cmd
43
+ when "sweep"
44
+ sweep_cmd
45
+ when "serve-mcp"
46
+ serve_mcp
47
+ when "publish"
48
+ publish_cmd
49
+ when "hook"
50
+ hook_cmd
51
+ when "doctor"
52
+ doctor_cmd
53
+ when "promote"
54
+ promote_cmd
55
+ else
56
+ @stderr.puts "Unknown command: #{command}"
57
+ @stderr.puts "Run 'claude-memory help' for usage."
58
+ 1
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def print_help
65
+ @stdout.puts <<~HELP
66
+ claude-memory - Long-term memory for Claude Code
67
+
68
+ Usage: claude-memory <command> [options]
69
+
70
+ Commands:
71
+ changes Show recent fact changes
72
+ conflicts Show open conflicts
73
+ db:init Initialize the SQLite database
74
+ doctor Check system health
75
+ explain Explain a fact with receipts
76
+ help Show this help message
77
+ hook Run hook entrypoints (ingest|sweep|publish)
78
+ init Initialize ClaudeMemory in a project
79
+ ingest Ingest transcript delta
80
+ promote Promote a project fact to global memory
81
+ publish Publish snapshot to Claude Code memory
82
+ recall Recall facts matching a query
83
+ search Search indexed content
84
+ serve-mcp Start MCP server
85
+ sweep Run maintenance/pruning
86
+ version Show version number
87
+
88
+ Run 'claude-memory <command> --help' for more information on a command.
89
+ HELP
90
+ end
91
+
92
+ def print_version
93
+ @stdout.puts "claude-memory #{ClaudeMemory::VERSION}"
94
+ end
95
+
96
+ def db_init
97
+ opts = parse_db_init_options
98
+ manager = ClaudeMemory::Store::StoreManager.new
99
+
100
+ if opts[:global]
101
+ manager.ensure_global!
102
+ @stdout.puts "Global database initialized at #{manager.global_db_path}"
103
+ @stdout.puts "Schema version: #{manager.global_store.schema_version}"
104
+ end
105
+
106
+ if opts[:project]
107
+ manager.ensure_project!
108
+ @stdout.puts "Project database initialized at #{manager.project_db_path}"
109
+ @stdout.puts "Schema version: #{manager.project_store.schema_version}"
110
+ end
111
+
112
+ manager.close
113
+ end
114
+
115
+ def parse_db_init_options
116
+ opts = {global: false, project: false}
117
+
118
+ parser = OptionParser.new do |o|
119
+ o.banner = "Usage: claude-memory db:init [options]"
120
+ o.on("--global", "Initialize global database (~/.claude/memory.sqlite3)") { opts[:global] = true }
121
+ o.on("--project", "Initialize project database (.claude/memory.sqlite3)") { opts[:project] = true }
122
+ end
123
+
124
+ parser.parse!(@args[1..])
125
+
126
+ opts[:global] = true if !opts[:global] && !opts[:project]
127
+ opts[:project] = true if !opts[:global] && !opts[:project]
128
+
129
+ opts
130
+ end
131
+
132
+ def ingest
133
+ opts = parse_ingest_options
134
+ return 1 unless opts
135
+
136
+ store = ClaudeMemory::Store::SQLiteStore.new(opts[:db])
137
+ ingester = ClaudeMemory::Ingest::Ingester.new(store)
138
+
139
+ result = ingester.ingest(
140
+ source: opts[:source],
141
+ session_id: opts[:session_id],
142
+ transcript_path: opts[:transcript_path]
143
+ )
144
+
145
+ case result[:status]
146
+ when :ingested
147
+ @stdout.puts "Ingested #{result[:bytes_read]} bytes (content_id: #{result[:content_id]})"
148
+ when :no_change
149
+ @stdout.puts "No new content to ingest"
150
+ end
151
+
152
+ store.close
153
+ 0
154
+ rescue ClaudeMemory::Ingest::TranscriptReader::FileNotFoundError => e
155
+ @stderr.puts "Error: #{e.message}"
156
+ 1
157
+ end
158
+
159
+ def parse_ingest_options
160
+ opts = {source: "claude_code", db: ClaudeMemory.project_db_path}
161
+
162
+ parser = OptionParser.new do |o|
163
+ o.banner = "Usage: claude-memory ingest [options]"
164
+ o.on("--source SOURCE", "Source identifier (default: claude_code)") { |v| opts[:source] = v }
165
+ o.on("--session-id ID", "Session identifier (required)") { |v| opts[:session_id] = v }
166
+ o.on("--transcript-path PATH", "Path to transcript file (required)") { |v| opts[:transcript_path] = v }
167
+ o.on("--db PATH", "Database path") { |v| opts[:db] = v }
168
+ end
169
+
170
+ parser.parse!(@args[1..])
171
+
172
+ unless opts[:session_id] && opts[:transcript_path]
173
+ @stderr.puts parser.help
174
+ @stderr.puts "\nError: --session-id and --transcript-path are required"
175
+ return nil
176
+ end
177
+
178
+ opts
179
+ end
180
+
181
+ def search
182
+ query = @args[1]
183
+ unless query
184
+ @stderr.puts "Usage: claude-memory search <query> [--db PATH] [--limit N]"
185
+ return 1
186
+ end
187
+
188
+ opts = {limit: 10, scope: "all"}
189
+ OptionParser.new do |o|
190
+ o.on("--limit N", Integer, "Max results") { |v| opts[:limit] = v }
191
+ o.on("--scope SCOPE", "Scope: project, global, or all") { |v| opts[:scope] = v }
192
+ end.parse!(@args[2..])
193
+
194
+ manager = ClaudeMemory::Store::StoreManager.new
195
+ store = manager.store_for_scope((opts[:scope] == "global") ? "global" : "project")
196
+ fts = ClaudeMemory::Index::LexicalFTS.new(store)
197
+
198
+ ids = fts.search(query, limit: opts[:limit])
199
+ if ids.empty?
200
+ @stdout.puts "No results found."
201
+ else
202
+ @stdout.puts "Found #{ids.size} result(s):"
203
+ ids.each do |id|
204
+ text = store.content_items.where(id: id).get(:raw_text)
205
+ preview = text&.slice(0, 100)&.gsub(/\s+/, " ")
206
+ @stdout.puts " [#{id}] #{preview}..."
207
+ end
208
+ end
209
+
210
+ manager.close
211
+ 0
212
+ end
213
+
214
+ def recall_cmd
215
+ query = @args[1]
216
+ unless query
217
+ @stderr.puts "Usage: claude-memory recall <query> [--limit N] [--scope project|global|all]"
218
+ return 1
219
+ end
220
+
221
+ opts = {limit: 10, scope: "all"}
222
+ OptionParser.new do |o|
223
+ o.on("--limit N", Integer, "Max results") { |v| opts[:limit] = v }
224
+ o.on("--scope SCOPE", "Scope: project, global, or all") { |v| opts[:scope] = v }
225
+ end.parse!(@args[2..])
226
+
227
+ manager = ClaudeMemory::Store::StoreManager.new
228
+ recall = ClaudeMemory::Recall.new(manager)
229
+
230
+ results = recall.query(query, limit: opts[:limit], scope: opts[:scope])
231
+ if results.empty?
232
+ @stdout.puts "No facts found."
233
+ else
234
+ @stdout.puts "Found #{results.size} fact(s):\n\n"
235
+ results.each do |result|
236
+ print_fact(result[:fact], source: result[:source])
237
+ print_receipts(result[:receipts])
238
+ @stdout.puts
239
+ end
240
+ end
241
+
242
+ manager.close
243
+ 0
244
+ end
245
+
246
+ def explain_cmd
247
+ fact_id = @args[1]&.to_i
248
+ unless fact_id && fact_id > 0
249
+ @stderr.puts "Usage: claude-memory explain <fact_id> [--scope project|global]"
250
+ return 1
251
+ end
252
+
253
+ opts = {scope: "project"}
254
+ OptionParser.new do |o|
255
+ o.on("--scope SCOPE", "Scope: project or global") { |v| opts[:scope] = v }
256
+ end.parse!(@args[2..])
257
+
258
+ manager = ClaudeMemory::Store::StoreManager.new
259
+ recall = ClaudeMemory::Recall.new(manager)
260
+
261
+ explanation = recall.explain(fact_id, scope: opts[:scope])
262
+ if explanation.nil?
263
+ @stderr.puts "Fact #{fact_id} not found in #{opts[:scope]} database."
264
+ manager.close
265
+ return 1
266
+ end
267
+
268
+ @stdout.puts "Fact ##{fact_id} (#{opts[:scope]}):"
269
+ print_fact(explanation[:fact])
270
+ print_receipts(explanation[:receipts])
271
+
272
+ if explanation[:supersedes].any?
273
+ @stdout.puts " Supersedes: #{explanation[:supersedes].join(", ")}"
274
+ end
275
+ if explanation[:superseded_by].any?
276
+ @stdout.puts " Superseded by: #{explanation[:superseded_by].join(", ")}"
277
+ end
278
+ if explanation[:conflicts].any?
279
+ @stdout.puts " Conflicts: #{explanation[:conflicts].map { |c| c[:id] }.join(", ")}"
280
+ end
281
+
282
+ manager.close
283
+ 0
284
+ end
285
+
286
+ def conflicts_cmd
287
+ opts = {scope: "all"}
288
+ OptionParser.new do |o|
289
+ o.on("--scope SCOPE", "Scope: project, global, or all") { |v| opts[:scope] = v }
290
+ end.parse!(@args[1..])
291
+
292
+ manager = ClaudeMemory::Store::StoreManager.new
293
+ recall = ClaudeMemory::Recall.new(manager)
294
+ conflicts = recall.conflicts(scope: opts[:scope])
295
+
296
+ if conflicts.empty?
297
+ @stdout.puts "No open conflicts."
298
+ else
299
+ @stdout.puts "Open conflicts (#{conflicts.size}):\n\n"
300
+ conflicts.each do |c|
301
+ source_label = c[:source] ? " [#{c[:source]}]" : ""
302
+ @stdout.puts " Conflict ##{c[:id]}: Fact #{c[:fact_a_id]} vs Fact #{c[:fact_b_id]}#{source_label}"
303
+ @stdout.puts " Status: #{c[:status]}, Detected: #{c[:detected_at]}"
304
+ @stdout.puts " Notes: #{c[:notes]}" if c[:notes]
305
+ @stdout.puts
306
+ end
307
+ end
308
+
309
+ manager.close
310
+ 0
311
+ end
312
+
313
+ def changes_cmd
314
+ opts = {since: nil, limit: 20, scope: "all"}
315
+ OptionParser.new do |o|
316
+ o.on("--since ISO", "Since timestamp") { |v| opts[:since] = v }
317
+ o.on("--limit N", Integer, "Max results") { |v| opts[:limit] = v }
318
+ o.on("--scope SCOPE", "Scope: project, global, or all") { |v| opts[:scope] = v }
319
+ end.parse!(@args[1..])
320
+
321
+ opts[:since] ||= (Time.now - 86400 * 7).utc.iso8601
322
+
323
+ manager = ClaudeMemory::Store::StoreManager.new
324
+ recall = ClaudeMemory::Recall.new(manager)
325
+
326
+ changes = recall.changes(since: opts[:since], limit: opts[:limit], scope: opts[:scope])
327
+ if changes.empty?
328
+ @stdout.puts "No changes since #{opts[:since]}."
329
+ else
330
+ @stdout.puts "Changes since #{opts[:since]} (#{changes.size}):\n\n"
331
+ changes.each do |change|
332
+ source_label = change[:source] ? " [#{change[:source]}]" : ""
333
+ @stdout.puts " [#{change[:id]}] #{change[:predicate]}: #{change[:object_literal]} (#{change[:status]})#{source_label}"
334
+ @stdout.puts " Created: #{change[:created_at]}"
335
+ end
336
+ end
337
+
338
+ manager.close
339
+ 0
340
+ end
341
+
342
+ def print_fact(fact, source: nil)
343
+ source_label = source ? " [#{source}]" : ""
344
+ @stdout.puts " #{fact[:subject_name]}.#{fact[:predicate]} = #{fact[:object_literal]}#{source_label}"
345
+ @stdout.puts " Status: #{fact[:status]}, Confidence: #{fact[:confidence]}"
346
+ @stdout.puts " Valid: #{fact[:valid_from]} - #{fact[:valid_to] || "present"}"
347
+ end
348
+
349
+ def print_receipts(receipts)
350
+ return if receipts.empty?
351
+
352
+ @stdout.puts " Receipts:"
353
+ receipts.each do |r|
354
+ quote_preview = r[:quote]&.slice(0, 80)&.gsub(/\s+/, " ")
355
+ @stdout.puts " - [#{r[:strength]}] \"#{quote_preview}...\""
356
+ end
357
+ end
358
+
359
+ def sweep_cmd
360
+ opts = {budget: 5, scope: "project"}
361
+ OptionParser.new do |o|
362
+ o.on("--budget SECONDS", Integer, "Time budget in seconds") { |v| opts[:budget] = v }
363
+ o.on("--scope SCOPE", "Scope: project or global") { |v| opts[:scope] = v }
364
+ end.parse!(@args[1..])
365
+
366
+ manager = ClaudeMemory::Store::StoreManager.new
367
+ store = manager.store_for_scope(opts[:scope])
368
+ sweeper = ClaudeMemory::Sweep::Sweeper.new(store)
369
+
370
+ @stdout.puts "Running sweep on #{opts[:scope]} database with #{opts[:budget]}s budget..."
371
+ stats = sweeper.run!(budget_seconds: opts[:budget])
372
+
373
+ @stdout.puts "Sweep complete:"
374
+ @stdout.puts " Proposed facts expired: #{stats[:proposed_facts_expired]}"
375
+ @stdout.puts " Disputed facts expired: #{stats[:disputed_facts_expired]}"
376
+ @stdout.puts " Orphaned provenance deleted: #{stats[:orphaned_provenance_deleted]}"
377
+ @stdout.puts " Old content pruned: #{stats[:old_content_pruned]}"
378
+ @stdout.puts " Elapsed: #{stats[:elapsed_seconds].round(2)}s"
379
+ @stdout.puts " Budget honored: #{stats[:budget_honored]}"
380
+
381
+ manager.close
382
+ 0
383
+ end
384
+
385
+ def serve_mcp
386
+ manager = ClaudeMemory::Store::StoreManager.new
387
+ server = ClaudeMemory::MCP::Server.new(manager)
388
+ server.run
389
+ manager.close
390
+ 0
391
+ end
392
+
393
+ def publish_cmd
394
+ opts = {mode: :shared, granularity: :repo, since: nil, scope: "project"}
395
+ OptionParser.new do |o|
396
+ o.on("--mode MODE", "Mode: shared, local, or home") { |v| opts[:mode] = v.to_sym }
397
+ o.on("--granularity LEVEL", "Granularity: repo, paths, or nested") { |v| opts[:granularity] = v.to_sym }
398
+ o.on("--since ISO", "Include changes since timestamp") { |v| opts[:since] = v }
399
+ o.on("--scope SCOPE", "Scope: project or global") { |v| opts[:scope] = v }
400
+ end.parse!(@args[1..])
401
+
402
+ manager = ClaudeMemory::Store::StoreManager.new
403
+ store = manager.store_for_scope(opts[:scope])
404
+ publish = ClaudeMemory::Publish.new(store)
405
+
406
+ result = publish.publish!(mode: opts[:mode], granularity: opts[:granularity], since: opts[:since])
407
+
408
+ case result[:status]
409
+ when :updated
410
+ @stdout.puts "Published #{opts[:scope]} snapshot to #{result[:path]}"
411
+ when :unchanged
412
+ @stdout.puts "No changes - #{result[:path]} is up to date"
413
+ end
414
+
415
+ manager.close
416
+ 0
417
+ end
418
+
419
+ def promote_cmd
420
+ fact_id = @args[1]&.to_i
421
+ unless fact_id && fact_id > 0
422
+ @stderr.puts "Usage: claude-memory promote <fact_id>"
423
+ @stderr.puts "\nPromotes a project fact to the global database."
424
+ return 1
425
+ end
426
+
427
+ manager = ClaudeMemory::Store::StoreManager.new
428
+ global_fact_id = manager.promote_fact(fact_id)
429
+
430
+ if global_fact_id
431
+ @stdout.puts "Promoted fact ##{fact_id} to global database as fact ##{global_fact_id}"
432
+ else
433
+ @stderr.puts "Fact ##{fact_id} not found in project database."
434
+ manager.close
435
+ return 1
436
+ end
437
+
438
+ manager.close
439
+ 0
440
+ end
441
+
442
+ def hook_cmd
443
+ subcommand = @args[1]
444
+
445
+ unless subcommand
446
+ @stderr.puts "Usage: claude-memory hook <ingest|sweep|publish> [options]"
447
+ @stderr.puts "\nReads hook payload JSON from stdin."
448
+ return 1
449
+ end
450
+
451
+ unless %w[ingest sweep publish].include?(subcommand)
452
+ @stderr.puts "Unknown hook command: #{subcommand}"
453
+ @stderr.puts "Available: ingest, sweep, publish"
454
+ return 1
455
+ end
456
+
457
+ opts = {db: ClaudeMemory.project_db_path}
458
+ OptionParser.new do |o|
459
+ o.on("--db PATH", "Database path") { |v| opts[:db] = v }
460
+ end.parse!(@args[2..])
461
+
462
+ payload = parse_hook_payload
463
+ return 1 unless payload
464
+
465
+ store = ClaudeMemory::Store::SQLiteStore.new(opts[:db])
466
+ handler = ClaudeMemory::Hook::Handler.new(store)
467
+
468
+ case subcommand
469
+ when "ingest"
470
+ hook_ingest(handler, payload)
471
+ when "sweep"
472
+ hook_sweep(handler, payload)
473
+ when "publish"
474
+ hook_publish(handler, payload)
475
+ end
476
+
477
+ store.close
478
+ 0
479
+ rescue ClaudeMemory::Hook::Handler::PayloadError => e
480
+ @stderr.puts "Payload error: #{e.message}"
481
+ 1
482
+ rescue ClaudeMemory::Ingest::TranscriptReader::FileNotFoundError => e
483
+ @stderr.puts "Error: #{e.message}"
484
+ 1
485
+ end
486
+
487
+ def parse_hook_payload
488
+ input = @stdin.read
489
+ JSON.parse(input)
490
+ rescue JSON::ParserError => e
491
+ @stderr.puts "Invalid JSON payload: #{e.message}"
492
+ nil
493
+ end
494
+
495
+ def hook_ingest(handler, payload)
496
+ result = handler.ingest(payload)
497
+
498
+ case result[:status]
499
+ when :ingested
500
+ @stdout.puts "Ingested #{result[:bytes_read]} bytes (content_id: #{result[:content_id]})"
501
+ when :no_change
502
+ @stdout.puts "No new content to ingest"
503
+ end
504
+ end
505
+
506
+ def hook_sweep(handler, payload)
507
+ result = handler.sweep(payload)
508
+ stats = result[:stats]
509
+
510
+ @stdout.puts "Sweep complete:"
511
+ @stdout.puts " Elapsed: #{stats[:elapsed_seconds].round(2)}s"
512
+ @stdout.puts " Budget honored: #{stats[:budget_honored]}"
513
+ end
514
+
515
+ def hook_publish(handler, payload)
516
+ result = handler.publish(payload)
517
+
518
+ case result[:status]
519
+ when :updated
520
+ @stdout.puts "Published snapshot to #{result[:path]}"
521
+ when :unchanged
522
+ @stdout.puts "No changes - #{result[:path]} is up to date"
523
+ end
524
+ end
525
+
526
+ def init_project
527
+ opts = {global: false}
528
+ OptionParser.new do |o|
529
+ o.on("--global", "Install to global ~/.claude/ settings") { opts[:global] = true }
530
+ end.parse!(@args[1..])
531
+
532
+ if opts[:global]
533
+ init_global
534
+ else
535
+ init_local
536
+ end
537
+ end
538
+
539
+ def init_local
540
+ @stdout.puts "Initializing ClaudeMemory (project-local)...\n\n"
541
+
542
+ manager = ClaudeMemory::Store::StoreManager.new
543
+ manager.ensure_global!
544
+ @stdout.puts "✓ Global database: #{manager.global_db_path}"
545
+ manager.ensure_project!
546
+ @stdout.puts "✓ Project database: #{manager.project_db_path}"
547
+ manager.close
548
+
549
+ FileUtils.mkdir_p(".claude/rules")
550
+ @stdout.puts "✓ Created .claude/rules directory"
551
+
552
+ configure_project_hooks
553
+ configure_project_mcp
554
+ install_output_style
555
+
556
+ @stdout.puts "\n=== Setup Complete ===\n"
557
+ @stdout.puts "ClaudeMemory is now configured for this project."
558
+ @stdout.puts "\nDatabases:"
559
+ @stdout.puts " Global: ~/.claude/memory.sqlite3 (user-wide knowledge)"
560
+ @stdout.puts " Project: .claude/memory.sqlite3 (project-specific)"
561
+ @stdout.puts "\nNext steps:"
562
+ @stdout.puts " 1. Restart Claude Code to load the new configuration"
563
+ @stdout.puts " 2. Use Claude Code normally - transcripts will be ingested automatically"
564
+ @stdout.puts " 3. Run 'claude-memory promote <fact_id>' to move facts to global"
565
+ @stdout.puts " 4. Run 'claude-memory doctor' to verify setup"
566
+
567
+ 0
568
+ end
569
+
570
+ def init_global
571
+ @stdout.puts "Initializing ClaudeMemory (global only)...\n\n"
572
+
573
+ manager = ClaudeMemory::Store::StoreManager.new
574
+ manager.ensure_global!
575
+ @stdout.puts "✓ Created global database: #{manager.global_db_path}"
576
+ manager.close
577
+
578
+ configure_global_hooks
579
+ configure_global_mcp
580
+ configure_global_memory
581
+
582
+ @stdout.puts "\n=== Global Setup Complete ===\n"
583
+ @stdout.puts "ClaudeMemory is now configured globally."
584
+ @stdout.puts "\nNote: Run 'claude-memory init' in each project for project-specific memory."
585
+
586
+ 0
587
+ end
588
+
589
+ def configure_global_hooks
590
+ settings_path = File.join(Dir.home, ".claude", "settings.json")
591
+ db_path = ClaudeMemory.global_db_path
592
+
593
+ ingest_cmd = "claude-memory hook ingest --db #{db_path}"
594
+ sweep_cmd = "claude-memory hook sweep --db #{db_path}"
595
+
596
+ hooks_config = build_hooks_config(ingest_cmd, sweep_cmd)
597
+
598
+ existing = load_json_file(settings_path)
599
+ existing["hooks"] ||= {}
600
+ existing["hooks"].merge!(hooks_config["hooks"])
601
+
602
+ File.write(settings_path, JSON.pretty_generate(existing))
603
+ @stdout.puts "✓ Configured hooks in #{settings_path}"
604
+ end
605
+
606
+ def configure_global_mcp
607
+ mcp_path = File.join(Dir.home, ".claude.json")
608
+
609
+ existing = load_json_file(mcp_path)
610
+ existing["mcpServers"] ||= {}
611
+ existing["mcpServers"]["claude-memory"] = {
612
+ "type" => "stdio",
613
+ "command" => "claude-memory",
614
+ "args" => ["serve-mcp"]
615
+ }
616
+
617
+ File.write(mcp_path, JSON.pretty_generate(existing))
618
+ @stdout.puts "✓ Configured MCP server in #{mcp_path}"
619
+ end
620
+
621
+ def configure_global_memory
622
+ global_claude_dir = File.join(Dir.home, ".claude")
623
+ claude_md_path = File.join(global_claude_dir, "CLAUDE.md")
624
+
625
+ memory_instruction = <<~MD
626
+ # ClaudeMemory
627
+
628
+ ClaudeMemory is installed globally. Use these MCP tools:
629
+ - `memory.recall` - Search for relevant facts
630
+ - `memory.explain` - Get detailed fact provenance
631
+ - `memory.conflicts` - Show open contradictions
632
+ - `memory.status` - Check system health
633
+ MD
634
+
635
+ if File.exist?(claude_md_path)
636
+ content = File.read(claude_md_path)
637
+ if content.include?("ClaudeMemory")
638
+ @stdout.puts "✓ #{claude_md_path} already has ClaudeMemory instructions"
639
+ else
640
+ File.write(claude_md_path, content + "\n" + memory_instruction)
641
+ @stdout.puts "✓ Added ClaudeMemory instructions to #{claude_md_path}"
642
+ end
643
+ else
644
+ File.write(claude_md_path, memory_instruction)
645
+ @stdout.puts "✓ Created #{claude_md_path}"
646
+ end
647
+ end
648
+
649
+ def configure_project_hooks
650
+ hooks_path = ".claude/settings.json"
651
+ db_path = File.expand_path(ClaudeMemory.project_db_path)
652
+
653
+ ingest_cmd = "claude-memory hook ingest --db #{db_path}"
654
+ sweep_cmd = "claude-memory hook sweep --db #{db_path}"
655
+
656
+ hooks_config = build_hooks_config(ingest_cmd, sweep_cmd)
657
+
658
+ existing = load_json_file(hooks_path)
659
+ existing["hooks"] ||= {}
660
+ existing["hooks"].merge!(hooks_config["hooks"])
661
+
662
+ FileUtils.mkdir_p(".claude")
663
+ File.write(hooks_path, JSON.pretty_generate(existing))
664
+ @stdout.puts "✓ Configured hooks in #{hooks_path}"
665
+ end
666
+
667
+ def configure_project_mcp
668
+ mcp_path = ".mcp.json"
669
+
670
+ existing = load_json_file(mcp_path)
671
+ existing["mcpServers"] ||= {}
672
+ existing["mcpServers"]["claude-memory"] = {
673
+ "type" => "stdio",
674
+ "command" => "claude-memory",
675
+ "args" => ["serve-mcp"]
676
+ }
677
+
678
+ File.write(mcp_path, JSON.pretty_generate(existing))
679
+ @stdout.puts "✓ Configured MCP server in #{mcp_path}"
680
+ end
681
+
682
+ def install_output_style
683
+ templates_dir = File.expand_path("templates", __dir__)
684
+ style_source = File.join(templates_dir, "output-styles", "memory-aware.md")
685
+ style_dest = ".claude/output-styles/memory-aware.md"
686
+
687
+ FileUtils.mkdir_p(File.dirname(style_dest))
688
+ FileUtils.cp(style_source, style_dest)
689
+ @stdout.puts "✓ Installed output style at #{style_dest}"
690
+ end
691
+
692
+ def doctor_cmd
693
+ issues = []
694
+ warnings = []
695
+
696
+ @stdout.puts "Claude Memory Doctor\n"
697
+ @stdout.puts "=" * 40
698
+
699
+ manager = ClaudeMemory::Store::StoreManager.new
700
+
701
+ @stdout.puts "\n## Global Database"
702
+ check_database(manager.global_db_path, "global", issues, warnings)
703
+
704
+ @stdout.puts "\n## Project Database"
705
+ check_database(manager.project_db_path, "project", issues, warnings)
706
+
707
+ manager.close
708
+
709
+ if File.exist?(".claude/rules/claude_memory.generated.md")
710
+ @stdout.puts "✓ Published snapshot exists"
711
+ else
712
+ warnings << "No published snapshot found. Run 'claude-memory publish'"
713
+ end
714
+
715
+ if File.exist?(".claude/CLAUDE.md")
716
+ content = File.read(".claude/CLAUDE.md")
717
+ if content.include?("claude_memory.generated.md")
718
+ @stdout.puts "✓ CLAUDE.md imports snapshot"
719
+ else
720
+ warnings << "CLAUDE.md does not import snapshot"
721
+ end
722
+ else
723
+ warnings << "No .claude/CLAUDE.md found"
724
+ end
725
+
726
+ check_hooks_config(warnings)
727
+
728
+ @stdout.puts
729
+
730
+ if warnings.any?
731
+ @stdout.puts "Warnings:"
732
+ warnings.each { |w| @stdout.puts " ⚠ #{w}" }
733
+ @stdout.puts
734
+ end
735
+
736
+ if issues.any?
737
+ @stdout.puts "Issues:"
738
+ issues.each { |i| @stderr.puts " ✗ #{i}" }
739
+ @stdout.puts
740
+ @stdout.puts "Run 'claude-memory init' to set up."
741
+ return 1
742
+ end
743
+
744
+ @stdout.puts "All checks passed!"
745
+ 0
746
+ end
747
+
748
+ def check_database(db_path, label, issues, warnings)
749
+ if File.exist?(db_path)
750
+ @stdout.puts "✓ #{label.capitalize} database exists: #{db_path}"
751
+ begin
752
+ store = ClaudeMemory::Store::SQLiteStore.new(db_path)
753
+ @stdout.puts " Schema version: #{store.schema_version}"
754
+
755
+ fact_count = store.db.execute("SELECT COUNT(*) FROM facts").first.first
756
+ @stdout.puts " Facts: #{fact_count}"
757
+
758
+ content_count = store.db.execute("SELECT COUNT(*) FROM content_items").first.first
759
+ @stdout.puts " Content items: #{content_count}"
760
+
761
+ conflict_count = store.db.execute("SELECT COUNT(*) FROM conflicts WHERE status = 'open'").first.first
762
+ if conflict_count > 0
763
+ warnings << "#{label}: #{conflict_count} open conflict(s) need resolution"
764
+ end
765
+ @stdout.puts " Open conflicts: #{conflict_count}"
766
+
767
+ last_ingest = store.db.execute("SELECT MAX(ingested_at) FROM content_items").first.first
768
+ if last_ingest
769
+ @stdout.puts " Last ingest: #{last_ingest}"
770
+ elsif label == "project"
771
+ warnings << "#{label}: No content has been ingested yet"
772
+ end
773
+
774
+ store.close
775
+ rescue => e
776
+ issues << "#{label} database error: #{e.message}"
777
+ end
778
+ elsif label == "global"
779
+ issues << "Global database not found: #{db_path}"
780
+ else
781
+ warnings << "Project database not found: #{db_path} (run 'claude-memory init')"
782
+ end
783
+ end
784
+
785
+ def check_hooks_config(warnings)
786
+ settings_path = ".claude/settings.json"
787
+ local_settings_path = ".claude/settings.local.json"
788
+
789
+ hooks_found = false
790
+
791
+ [settings_path, local_settings_path].each do |path|
792
+ next unless File.exist?(path)
793
+
794
+ begin
795
+ config = JSON.parse(File.read(path))
796
+ if config["hooks"]&.any?
797
+ hooks_found = true
798
+ @stdout.puts "✓ Hooks configured in #{path}"
799
+
800
+ expected_hooks = %w[Stop SessionStart PreCompact SessionEnd]
801
+ missing = expected_hooks - config["hooks"].keys
802
+ if missing.any?
803
+ warnings << "Missing recommended hooks in #{path}: #{missing.join(", ")}"
804
+ end
805
+ end
806
+ rescue JSON::ParserError
807
+ warnings << "Invalid JSON in #{path}"
808
+ end
809
+ end
810
+
811
+ unless hooks_found
812
+ warnings << "No hooks configured. Run 'claude-memory init' or configure manually."
813
+ @stdout.puts "\n Manual fallback available:"
814
+ @stdout.puts " claude-memory ingest --session-id <id> --transcript-path <path>"
815
+ @stdout.puts " claude-memory sweep --budget 5"
816
+ @stdout.puts " claude-memory publish"
817
+ end
818
+ end
819
+
820
+ def load_json_file(path)
821
+ return {} unless File.exist?(path)
822
+
823
+ JSON.parse(File.read(path))
824
+ rescue JSON::ParserError
825
+ {}
826
+ end
827
+
828
+ def build_hooks_config(ingest_cmd, sweep_cmd)
829
+ {
830
+ "hooks" => {
831
+ "Stop" => [
832
+ {
833
+ "matcher" => "",
834
+ "hooks" => [
835
+ {"type" => "command", "command" => ingest_cmd, "timeout" => 10}
836
+ ]
837
+ }
838
+ ],
839
+ "SessionStart" => [
840
+ {
841
+ "matcher" => "",
842
+ "hooks" => [
843
+ {"type" => "command", "command" => ingest_cmd, "timeout" => 10}
844
+ ]
845
+ }
846
+ ],
847
+ "PreCompact" => [
848
+ {
849
+ "matcher" => "",
850
+ "hooks" => [
851
+ {"type" => "command", "command" => ingest_cmd, "timeout" => 30},
852
+ {"type" => "command", "command" => sweep_cmd, "timeout" => 30}
853
+ ]
854
+ }
855
+ ],
856
+ "SessionEnd" => [
857
+ {
858
+ "matcher" => "",
859
+ "hooks" => [
860
+ {"type" => "command", "command" => ingest_cmd, "timeout" => 30},
861
+ {"type" => "command", "command" => sweep_cmd, "timeout" => 30}
862
+ ]
863
+ }
864
+ ]
865
+ }
866
+ }
867
+ end
868
+ end
869
+ end