claude_memory 0.5.1 → 0.6.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/CLAUDE.md +1 -1
  3. data/.claude/rules/claude_memory.generated.md +1 -1
  4. data/.claude/settings.json +5 -0
  5. data/.claude/settings.local.json +9 -1
  6. data/.claude-plugin/marketplace.json +5 -2
  7. data/.claude-plugin/plugin.json +16 -3
  8. data/CHANGELOG.md +55 -0
  9. data/CLAUDE.md +27 -13
  10. data/README.md +6 -2
  11. data/Rakefile +22 -0
  12. data/db/migrations/011_add_tool_call_summaries.rb +18 -0
  13. data/db/migrations/012_add_vec_indexing_support.rb +19 -0
  14. data/docs/improvements.md +86 -66
  15. data/docs/influence/claude-mem.md +253 -0
  16. data/docs/influence/claude-supermemory.md +158 -430
  17. data/docs/influence/episodic-memory.md +217 -0
  18. data/docs/influence/grepai.md +163 -839
  19. data/docs/influence/kbs.md +437 -0
  20. data/docs/influence/qmd.md +139 -481
  21. data/hooks/hooks.json +19 -15
  22. data/lefthook.yml +4 -0
  23. data/lib/claude_memory/commands/checks/vec_check.rb +73 -0
  24. data/lib/claude_memory/commands/compact_command.rb +94 -0
  25. data/lib/claude_memory/commands/doctor_command.rb +1 -0
  26. data/lib/claude_memory/commands/export_command.rb +108 -0
  27. data/lib/claude_memory/commands/help_command.rb +2 -0
  28. data/lib/claude_memory/commands/hook_command.rb +110 -9
  29. data/lib/claude_memory/commands/index_command.rb +63 -8
  30. data/lib/claude_memory/commands/initializers/global_initializer.rb +26 -7
  31. data/lib/claude_memory/commands/initializers/project_initializer.rb +35 -12
  32. data/lib/claude_memory/commands/registry.rb +3 -1
  33. data/lib/claude_memory/hook/context_injector.rb +75 -0
  34. data/lib/claude_memory/hook/error_classifier.rb +67 -0
  35. data/lib/claude_memory/hook/handler.rb +21 -1
  36. data/lib/claude_memory/index/vector_index.rb +171 -0
  37. data/lib/claude_memory/infrastructure/schema_validator.rb +5 -1
  38. data/lib/claude_memory/ingest/ingester.rb +26 -1
  39. data/lib/claude_memory/ingest/observation_compressor.rb +177 -0
  40. data/lib/claude_memory/mcp/instructions_builder.rb +76 -0
  41. data/lib/claude_memory/mcp/server.rb +3 -1
  42. data/lib/claude_memory/mcp/tool_definitions.rb +15 -7
  43. data/lib/claude_memory/mcp/tools.rb +125 -2
  44. data/lib/claude_memory/publish.rb +28 -27
  45. data/lib/claude_memory/recall/dual_query_template.rb +1 -12
  46. data/lib/claude_memory/recall.rb +71 -17
  47. data/lib/claude_memory/store/sqlite_store.rb +17 -1
  48. data/lib/claude_memory/sweep/sweeper.rb +30 -0
  49. data/lib/claude_memory/version.rb +1 -1
  50. data/lib/claude_memory.rb +8 -0
  51. data/scripts/hook-runner.sh +14 -0
  52. data/scripts/serve-mcp.sh +14 -0
  53. data/skills/setup-memory/SKILL.md +6 -0
  54. metadata +31 -2
data/hooks/hooks.json CHANGED
@@ -5,7 +5,8 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "claude-memory hook ingest"
8
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh ingest",
9
+ "timeout": 10
9
10
  }
10
11
  ]
11
12
  }
@@ -15,7 +16,13 @@
15
16
  "hooks": [
16
17
  {
17
18
  "type": "command",
18
- "command": "claude-memory hook ingest"
19
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh ingest",
20
+ "timeout": 10
21
+ },
22
+ {
23
+ "type": "command",
24
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh context",
25
+ "timeout": 5
19
26
  }
20
27
  ]
21
28
  }
@@ -25,32 +32,29 @@
25
32
  "hooks": [
26
33
  {
27
34
  "type": "command",
28
- "command": "claude-memory hook ingest"
35
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh ingest",
36
+ "timeout": 30
29
37
  },
30
38
  {
31
39
  "type": "command",
32
- "command": "claude-memory hook publish"
40
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh sweep",
41
+ "timeout": 30
33
42
  }
34
43
  ]
35
44
  }
36
45
  ],
37
- "Notification": [
46
+ "SessionEnd": [
38
47
  {
39
- "matcher": "idle_prompt",
40
48
  "hooks": [
41
49
  {
42
50
  "type": "command",
43
- "command": "claude-memory hook sweep"
44
- }
45
- ]
46
- }
47
- ],
48
- "SessionEnd": [
49
- {
50
- "hooks": [
51
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh ingest",
52
+ "timeout": 30
53
+ },
51
54
  {
52
55
  "type": "command",
53
- "command": "claude-memory hook publish"
56
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh sweep",
57
+ "timeout": 30
54
58
  }
55
59
  ]
56
60
  }
data/lefthook.yml CHANGED
@@ -17,6 +17,10 @@ pre-commit:
17
17
  fi
18
18
  quality-review:
19
19
  run: |
20
+ if [ -n "$CLAUDECODE" ]; then
21
+ echo "Inside Claude Code session, skipping quality-review hook (run /review-commit manually)"
22
+ exit 0
23
+ fi
20
24
  staged_ruby=$(git diff --cached --name-only --diff-filter=ACM | grep '\.rb$' || true)
21
25
  if [ -n "$staged_ruby" ]; then
22
26
  echo "Running quality review on staged changes..."
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Commands
5
+ module Checks
6
+ # Checks sqlite-vec extension availability and index coverage
7
+ class VecCheck
8
+ def call
9
+ vec_available = check_vec_availability
10
+ coverage = check_vec_coverage
11
+
12
+ warnings = []
13
+ unless vec_available
14
+ warnings << "sqlite-vec extension not available (vector search uses slower JSON fallback)"
15
+ end
16
+
17
+ if vec_available && coverage && coverage[:coverage_pct] < 100 && coverage[:with_embedding] > 0
18
+ warnings << "Vec index coverage: #{coverage[:coverage_pct]}% (#{coverage[:vec_indexed]}/#{coverage[:with_embedding]} facts). Run 'claude-memory index --vec' to backfill."
19
+ end
20
+
21
+ {
22
+ status: warnings.any? ? :warning : :ok,
23
+ label: "sqlite-vec",
24
+ message: vec_available ? "sqlite-vec available" : "sqlite-vec not available",
25
+ details: {
26
+ available: vec_available,
27
+ coverage: coverage
28
+ },
29
+ warnings: warnings
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def check_vec_availability
36
+ require "sqlite_vec"
37
+ true
38
+ rescue LoadError
39
+ false
40
+ end
41
+
42
+ def check_vec_coverage
43
+ config = Configuration.new
44
+ totals = {vec_indexed: 0, with_embedding: 0, coverage_pct: 0}
45
+
46
+ [config.global_db_path, config.project_db_path].each do |db_path|
47
+ next unless File.exist?(db_path)
48
+
49
+ store = nil
50
+ begin
51
+ store = Store::SQLiteStore.new(db_path)
52
+ stats = store.vector_index.coverage_stats
53
+ totals[:with_embedding] += stats[:with_embedding]
54
+ totals[:vec_indexed] += stats[:vec_indexed]
55
+ rescue => _e
56
+ next
57
+ ensure
58
+ store&.close
59
+ end
60
+ end
61
+
62
+ totals[:coverage_pct] = if totals[:with_embedding] > 0
63
+ (totals[:vec_indexed] * 100.0 / totals[:with_embedding]).round(1)
64
+ else
65
+ 0
66
+ end
67
+
68
+ totals
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Commands
5
+ # Runs SQLite VACUUM and optional integrity check on memory databases.
6
+ # Reclaims space from deleted/superseded facts and defragments storage.
7
+ class CompactCommand < BaseCommand
8
+ def call(args)
9
+ opts = parse_options(args, {scope: "all", check: false}) do |o|
10
+ OptionParser.new do |parser|
11
+ parser.banner = "Usage: claude-memory compact [options]"
12
+ parser.on("--scope SCOPE", %w[all global project],
13
+ "Scope: all (default), global, or project") { |v| o[:scope] = v }
14
+ parser.on("--check", "Run integrity check before compacting") { o[:check] = true }
15
+ end
16
+ end
17
+ return 1 if opts.nil?
18
+
19
+ manager = ClaudeMemory::Store::StoreManager.new
20
+
21
+ if opts[:scope] == "all" || opts[:scope] == "global"
22
+ compact_database("global", manager.global_db_path, check: opts[:check])
23
+ end
24
+
25
+ if opts[:scope] == "all" || opts[:scope] == "project"
26
+ compact_database("project", manager.project_db_path, check: opts[:check])
27
+ end
28
+
29
+ manager.close
30
+ 0
31
+ end
32
+
33
+ private
34
+
35
+ def compact_database(label, db_path, check: false)
36
+ unless File.exist?(db_path)
37
+ stdout.puts "#{label}: database not found at #{db_path}"
38
+ return
39
+ end
40
+
41
+ size_before = File.size(db_path)
42
+
43
+ if check
44
+ stdout.puts "#{label}: running integrity check..."
45
+ result = run_integrity_check(db_path)
46
+ unless result == "ok"
47
+ stderr.puts "#{label}: integrity check failed: #{result}"
48
+ return
49
+ end
50
+ stdout.puts "#{label}: integrity check passed"
51
+ end
52
+
53
+ stdout.puts "#{label}: compacting..."
54
+ run_vacuum(db_path)
55
+
56
+ size_after = File.size(db_path)
57
+ saved = size_before - size_after
58
+
59
+ stdout.puts "#{label}: #{format_size(size_before)} -> #{format_size(size_after)} (#{format_saved(saved)})"
60
+ end
61
+
62
+ def run_vacuum(db_path)
63
+ store = ClaudeMemory::Store::SQLiteStore.new(db_path)
64
+ store.db.run("VACUUM")
65
+ store.close
66
+ end
67
+
68
+ def run_integrity_check(db_path)
69
+ store = ClaudeMemory::Store::SQLiteStore.new(db_path)
70
+ result = store.db.fetch("PRAGMA integrity_check").first[:integrity_check]
71
+ store.close
72
+ result
73
+ end
74
+
75
+ def format_size(bytes)
76
+ if bytes >= 1024 * 1024
77
+ "#{(bytes / (1024.0 * 1024.0)).round(2)} MB"
78
+ else
79
+ "#{(bytes / 1024.0).round(1)} KB"
80
+ end
81
+ end
82
+
83
+ def format_saved(bytes)
84
+ if bytes > 0
85
+ "saved #{format_size(bytes)}"
86
+ elsif bytes == 0
87
+ "no change"
88
+ else
89
+ "grew #{format_size(bytes.abs)}"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -20,6 +20,7 @@ module ClaudeMemory
20
20
  checks = [
21
21
  Checks::DatabaseCheck.new(manager.global_db_path, "global"),
22
22
  Checks::DatabaseCheck.new(manager.project_db_path, "project"),
23
+ Checks::VecCheck.new,
23
24
  Checks::SnapshotCheck.new,
24
25
  Checks::ClaudeMdCheck.new,
25
26
  Checks::HooksCheck.new
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeMemory
6
+ module Commands
7
+ # Exports facts, entities, and provenance to JSON for backup or migration.
8
+ # Output goes to stdout (default) or a file via --output.
9
+ class ExportCommand < BaseCommand
10
+ def call(args)
11
+ opts = parse_options(args, {scope: "project", status: "active", output: nil, pretty: false}) do |o|
12
+ OptionParser.new do |parser|
13
+ parser.banner = "Usage: claude-memory export [options]"
14
+ parser.on("--scope SCOPE", %w[all global project],
15
+ "Scope: project (default), global, or all") { |v| o[:scope] = v }
16
+ parser.on("--status STATUS", %w[active all],
17
+ "Fact status: active (default) or all") { |v| o[:status] = v }
18
+ parser.on("--output FILE", "Write to file instead of stdout") { |v| o[:output] = v }
19
+ parser.on("--pretty", "Pretty-print JSON output") { o[:pretty] = true }
20
+ end
21
+ end
22
+ return 1 if opts.nil?
23
+
24
+ manager = ClaudeMemory::Store::StoreManager.new
25
+ data = build_export(manager, opts[:scope], opts[:status])
26
+ manager.close
27
+
28
+ json = opts[:pretty] ? JSON.pretty_generate(data) : JSON.generate(data)
29
+
30
+ if opts[:output]
31
+ File.write(opts[:output], json)
32
+ stderr.puts "Exported #{data[:facts].size} facts to #{opts[:output]}"
33
+ else
34
+ stdout.puts json
35
+ end
36
+
37
+ 0
38
+ end
39
+
40
+ private
41
+
42
+ def build_export(manager, scope, status_filter)
43
+ export = {
44
+ version: ClaudeMemory::VERSION,
45
+ exported_at: Time.now.utc.iso8601,
46
+ scope: scope,
47
+ entities: [],
48
+ facts: []
49
+ }
50
+
51
+ if scope == "all" || scope == "global"
52
+ if manager.global_exists?
53
+ manager.ensure_global!
54
+ collect_from_store(manager.global_store, "global", status_filter, export)
55
+ end
56
+ end
57
+
58
+ if scope == "all" || scope == "project"
59
+ if manager.project_exists?
60
+ manager.ensure_project!
61
+ collect_from_store(manager.project_store, "project", status_filter, export)
62
+ end
63
+ end
64
+
65
+ export
66
+ end
67
+
68
+ def collect_from_store(store, source_label, status_filter, export)
69
+ # Collect entities
70
+ store.entities.each do |entity|
71
+ export[:entities] << {
72
+ id: entity[:id],
73
+ type: entity[:type],
74
+ name: entity[:canonical_name],
75
+ source: source_label
76
+ }
77
+ end
78
+
79
+ # Collect facts with provenance
80
+ facts_ds = store.facts
81
+ facts_ds = facts_ds.where(status: "active") if status_filter == "active"
82
+
83
+ facts_ds.each do |fact|
84
+ subject = store.entities.where(id: fact[:subject_entity_id]).first
85
+ receipts = store.provenance.where(fact_id: fact[:id]).all
86
+
87
+ export[:facts] << {
88
+ id: fact[:id],
89
+ docid: fact[:docid],
90
+ subject: subject&.[](:canonical_name),
91
+ predicate: fact[:predicate],
92
+ object: fact[:object_literal],
93
+ status: fact[:status],
94
+ confidence: fact[:confidence],
95
+ scope: fact[:scope],
96
+ valid_from: fact[:valid_from],
97
+ valid_to: fact[:valid_to],
98
+ created_at: fact[:created_at],
99
+ source: source_label,
100
+ receipts: receipts.map { |r|
101
+ {quote: r[:quote], strength: r[:strength]}
102
+ }
103
+ }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -12,10 +12,12 @@ module ClaudeMemory
12
12
 
13
13
  Commands:
14
14
  changes Show recent fact changes
15
+ compact Compact databases (VACUUM + integrity check)
15
16
  conflicts Show open conflicts
16
17
  db:init Initialize the SQLite database
17
18
  doctor Check system health
18
19
  explain Explain a fact with receipts
20
+ export Export facts to JSON for backup
19
21
  help Show this help message
20
22
  hook Run hook entrypoints (ingest|sweep|publish)
21
23
  init Initialize ClaudeMemory in a project
@@ -5,25 +5,30 @@ require "json"
5
5
  module ClaudeMemory
6
6
  module Commands
7
7
  # Handles hook entrypoints (ingest, sweep, publish)
8
+ # Supports --async flag to fork and run in background
8
9
  class HookCommand < BaseCommand
10
+ ASYNC_SUBCOMMANDS = %w[ingest sweep publish].freeze
11
+
9
12
  def call(args)
10
13
  subcommand = args.first
11
14
 
12
15
  unless subcommand
13
- stderr.puts "Usage: claude-memory hook <ingest|sweep|publish> [options]"
16
+ stderr.puts "Usage: claude-memory hook <ingest|sweep|publish|context> [options]"
14
17
  stderr.puts "\nReads hook payload JSON from stdin."
18
+ stderr.puts "Options: --async Run in background (ingest, sweep, publish only)"
15
19
  return Hook::ExitCodes::ERROR
16
20
  end
17
21
 
18
- unless %w[ingest sweep publish].include?(subcommand)
22
+ unless %w[ingest sweep publish context].include?(subcommand)
19
23
  stderr.puts "Unknown hook command: #{subcommand}"
20
- stderr.puts "Available: ingest, sweep, publish"
24
+ stderr.puts "Available: ingest, sweep, publish, context"
21
25
  return Hook::ExitCodes::ERROR
22
26
  end
23
27
 
24
- opts = parse_options(args[1..] || [], {db: ClaudeMemory.project_db_path}) do |o|
28
+ opts = parse_options(args[1..] || [], {db: ClaudeMemory.project_db_path, async: false}) do |o|
25
29
  OptionParser.new do |parser|
26
30
  parser.on("--db PATH", "Database path") { |v| o[:db] = v }
31
+ parser.on("--async", "Run in background (non-blocking)") { o[:async] = true }
27
32
  end
28
33
  end
29
34
  return Hook::ExitCodes::ERROR if opts.nil?
@@ -31,6 +36,21 @@ module ClaudeMemory
31
36
  payload = parse_hook_payload
32
37
  return Hook::ExitCodes::ERROR unless payload
33
38
 
39
+ if opts[:async] && ASYNC_SUBCOMMANDS.include?(subcommand)
40
+ return run_async(subcommand, payload, opts)
41
+ end
42
+
43
+ execute_sync(subcommand, payload, opts)
44
+ rescue ClaudeMemory::Hook::Handler::PayloadError => e
45
+ stderr.puts "Payload error: #{e.message}"
46
+ Hook::ExitCodes::ERROR
47
+ rescue => e
48
+ classify_error(e)
49
+ end
50
+
51
+ private
52
+
53
+ def execute_sync(subcommand, payload, opts)
34
54
  store = ClaudeMemory::Store::SQLiteStore.new(opts[:db])
35
55
  handler = ClaudeMemory::Hook::Handler.new(store)
36
56
 
@@ -41,16 +61,52 @@ module ClaudeMemory
41
61
  hook_sweep(handler, payload)
42
62
  when "publish"
43
63
  hook_publish(handler, payload)
64
+ when "context"
65
+ hook_context(payload, opts[:db])
44
66
  end
45
67
 
46
68
  store.close
47
69
  exit_code
48
- rescue ClaudeMemory::Hook::Handler::PayloadError => e
49
- stderr.puts "Payload error: #{e.message}"
50
- Hook::ExitCodes::ERROR
51
70
  end
52
71
 
53
- private
72
+ def run_async(subcommand, payload, opts)
73
+ pid = fork_background(subcommand, payload, opts)
74
+
75
+ if pid
76
+ Process.detach(pid)
77
+ stdout.puts "Running #{subcommand} in background (pid: #{pid})"
78
+ Hook::ExitCodes::SUCCESS
79
+ else
80
+ stderr.puts "Fork not available, falling back to synchronous execution"
81
+ execute_sync(subcommand, payload, opts)
82
+ end
83
+ end
84
+
85
+ def fork_background(subcommand, payload, opts)
86
+ Process.fork do
87
+ # Detach from parent I/O
88
+ $stdin.reopen(File::NULL, "r")
89
+ $stdout.reopen(File::NULL, "w")
90
+ $stderr.reopen(File::NULL, "w")
91
+
92
+ store = ClaudeMemory::Store::SQLiteStore.new(opts[:db])
93
+ handler = ClaudeMemory::Hook::Handler.new(store)
94
+
95
+ case subcommand
96
+ when "ingest" then handler.ingest(payload)
97
+ when "sweep" then handler.sweep(payload)
98
+ when "publish" then handler.publish(payload)
99
+ end
100
+
101
+ store.close
102
+ rescue
103
+ # Background process must not propagate errors
104
+ nil
105
+ end
106
+ rescue NotImplementedError
107
+ # Process.fork not available (e.g., JRuby)
108
+ nil
109
+ end
54
110
 
55
111
  def parse_hook_payload
56
112
  input = stdin.read
@@ -72,9 +128,13 @@ module ClaudeMemory
72
128
  Hook::ExitCodes::SUCCESS
73
129
  when :skipped
74
130
  # Different reasons for skipping have different severity
75
- if result[:reason] == "unchanged"
131
+ case result[:reason]
132
+ when "unchanged"
76
133
  stdout.puts "No new content to ingest"
77
134
  Hook::ExitCodes::SUCCESS
135
+ when "session_excluded"
136
+ stdout.puts "Session excluded by privacy marker"
137
+ Hook::ExitCodes::SUCCESS
78
138
  else
79
139
  # transcript_not_found or other skipped reasons
80
140
  stdout.puts "Skipped ingestion: #{result[:reason]}"
@@ -108,6 +168,47 @@ module ClaudeMemory
108
168
 
109
169
  Hook::ExitCodes::SUCCESS
110
170
  end
171
+
172
+ def hook_context(payload, db_path)
173
+ project_path = payload["project_path"] || payload["cwd"]
174
+ manager = ClaudeMemory::Store::StoreManager.new(
175
+ project_db_path: db_path,
176
+ project_path: project_path
177
+ )
178
+ manager.ensure_both!
179
+
180
+ injector = ClaudeMemory::Hook::ContextInjector.new(manager)
181
+ context_text = injector.generate_context
182
+
183
+ if context_text
184
+ response = {
185
+ hookSpecificOutput: {
186
+ hookEventName: "SessionStart",
187
+ additionalContext: context_text
188
+ }
189
+ }
190
+ stdout.puts JSON.generate(response)
191
+ end
192
+
193
+ manager.close
194
+ Hook::ExitCodes::SUCCESS
195
+ rescue => e
196
+ classify_error(e)
197
+ end
198
+
199
+ def classify_error(error)
200
+ exit_code = Hook::ErrorClassifier.exit_code_for(error)
201
+
202
+ if exit_code == Hook::ExitCodes::SUCCESS
203
+ # Transport/infrastructure error — degrade gracefully
204
+ stderr.puts "Hook degraded gracefully: #{error.class} - #{error.message}"
205
+ else
206
+ # Client/programming error — surface to developer
207
+ stderr.puts "Hook error: #{error.class} - #{error.message}"
208
+ end
209
+
210
+ exit_code
211
+ end
111
212
  end
112
213
  end
113
214
  end
@@ -9,12 +9,13 @@ module ClaudeMemory
9
9
  SCOPE_PROJECT = "project"
10
10
 
11
11
  def call(args)
12
- opts = parse_options(args, {scope: SCOPE_ALL, batch_size: 100, force: false}) do |o|
12
+ opts = parse_options(args, {scope: SCOPE_ALL, batch_size: 100, force: false, vec: false}) do |o|
13
13
  OptionParser.new do |parser|
14
14
  parser.banner = "Usage: claude-memory index [options]"
15
15
  parser.on("--scope SCOPE", "Scope: global, project, or all (default: all)") { |v| o[:scope] = v }
16
16
  parser.on("--batch-size SIZE", Integer, "Batch size (default: 100)") { |v| o[:batch_size] = v }
17
17
  parser.on("--force", "Re-index facts that already have embeddings") { o[:force] = true }
18
+ parser.on("--vec", "Backfill vec0 index from existing embeddings (no regeneration)") { o[:vec] = true }
18
19
  end
19
20
  end
20
21
  return 1 if opts.nil?
@@ -25,14 +26,14 @@ module ClaudeMemory
25
26
  return 1
26
27
  end
27
28
 
28
- generator = Embeddings::Generator.new
29
-
30
- if opts[:scope] == SCOPE_ALL || opts[:scope] == SCOPE_GLOBAL
31
- index_database("global", Configuration.global_db_path, generator, opts)
29
+ if opts[:vec]
30
+ return vec_backfill(opts)
32
31
  end
33
32
 
34
- if opts[:scope] == SCOPE_ALL || opts[:scope] == SCOPE_PROJECT
35
- index_database("project", Configuration.project_db_path, generator, opts)
33
+ generator = Embeddings::Generator.new
34
+
35
+ scopes_for(opts[:scope]).each do |label, db_path|
36
+ index_database(label, db_path, generator, opts)
36
37
  end
37
38
 
38
39
  0
@@ -40,6 +41,13 @@ module ClaudeMemory
40
41
 
41
42
  private
42
43
 
44
+ def scopes_for(scope)
45
+ pairs = []
46
+ pairs << ["global", Configuration.global_db_path] if scope == SCOPE_ALL || scope == SCOPE_GLOBAL
47
+ pairs << ["project", Configuration.project_db_path] if scope == SCOPE_ALL || scope == SCOPE_PROJECT
48
+ pairs
49
+ end
50
+
43
51
  def index_database(label, db_path, generator, opts)
44
52
  unless File.exist?(db_path)
45
53
  stdout.puts "#{label.capitalize} database not found, skipping..."
@@ -94,6 +102,11 @@ module ClaudeMemory
94
102
 
95
103
  stdout.puts "#{label.capitalize} database: Indexing #{facts.size} facts..."
96
104
 
105
+ vec_index = store.vector_index
106
+ if vec_index.available?
107
+ stdout.puts " sqlite-vec available, dual-writing to vec0 index"
108
+ end
109
+
97
110
  processed = checkpoint ? checkpoint[:processed_items] : 0
98
111
  begin
99
112
  facts.each_slice(opts[:batch_size]) do |batch|
@@ -106,9 +119,12 @@ module ClaudeMemory
106
119
  # Generate embedding
107
120
  embedding = generator.generate(text)
108
121
 
109
- # Store embedding
122
+ # Store embedding (JSON column)
110
123
  store.update_fact_embedding(fact[:id], embedding)
111
124
 
125
+ # Dual-write to vec0 if available (insert_embedding manages vec_indexed_at)
126
+ vec_index.insert_embedding(fact[:id], embedding) if vec_index.available?
127
+
112
128
  processed += 1
113
129
  end
114
130
 
@@ -137,6 +153,45 @@ module ClaudeMemory
137
153
  end
138
154
  end
139
155
 
156
+ def vec_backfill(opts)
157
+ scopes_for(opts[:scope]).each do |label, db_path|
158
+ unless File.exist?(db_path)
159
+ stdout.puts "#{label.capitalize} database not found, skipping..."
160
+ next
161
+ end
162
+
163
+ store = Store::SQLiteStore.new(db_path)
164
+ begin
165
+ vec_index = store.vector_index
166
+
167
+ unless vec_index.available?
168
+ stderr.puts "#{label.capitalize}: sqlite-vec not available, cannot backfill"
169
+ next
170
+ end
171
+
172
+ total = store.facts.where(vec_indexed_at: nil).where(Sequel.~(embedding_json: nil)).where(status: "active").count
173
+ if total == 0
174
+ stdout.puts "#{label.capitalize} database: All embeddings already in vec0 index"
175
+ next
176
+ end
177
+
178
+ stdout.puts "#{label.capitalize} database: Backfilling #{total} facts to vec0..."
179
+ backfilled = 0
180
+ loop do
181
+ count = vec_index.backfill_batch!(limit: opts[:batch_size])
182
+ break if count == 0
183
+ backfilled += count
184
+ stdout.puts " Backfilled #{backfilled}/#{total}..."
185
+ end
186
+ stdout.puts " Done! #{backfilled} facts indexed in vec0"
187
+ ensure
188
+ store.close
189
+ end
190
+ end
191
+
192
+ 0
193
+ end
194
+
140
195
  def build_fact_text(fact, store)
141
196
  # Build rich text representation for embedding
142
197
  parts = []