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.
- checksums.yaml +4 -4
- data/.claude/CLAUDE.md +1 -1
- data/.claude/rules/claude_memory.generated.md +1 -1
- data/.claude/settings.json +5 -0
- data/.claude/settings.local.json +9 -1
- data/.claude-plugin/marketplace.json +5 -2
- data/.claude-plugin/plugin.json +16 -3
- data/CHANGELOG.md +55 -0
- data/CLAUDE.md +27 -13
- data/README.md +6 -2
- data/Rakefile +22 -0
- data/db/migrations/011_add_tool_call_summaries.rb +18 -0
- data/db/migrations/012_add_vec_indexing_support.rb +19 -0
- data/docs/improvements.md +86 -66
- data/docs/influence/claude-mem.md +253 -0
- data/docs/influence/claude-supermemory.md +158 -430
- data/docs/influence/episodic-memory.md +217 -0
- data/docs/influence/grepai.md +163 -839
- data/docs/influence/kbs.md +437 -0
- data/docs/influence/qmd.md +139 -481
- data/hooks/hooks.json +19 -15
- data/lefthook.yml +4 -0
- data/lib/claude_memory/commands/checks/vec_check.rb +73 -0
- data/lib/claude_memory/commands/compact_command.rb +94 -0
- data/lib/claude_memory/commands/doctor_command.rb +1 -0
- data/lib/claude_memory/commands/export_command.rb +108 -0
- data/lib/claude_memory/commands/help_command.rb +2 -0
- data/lib/claude_memory/commands/hook_command.rb +110 -9
- data/lib/claude_memory/commands/index_command.rb +63 -8
- data/lib/claude_memory/commands/initializers/global_initializer.rb +26 -7
- data/lib/claude_memory/commands/initializers/project_initializer.rb +35 -12
- data/lib/claude_memory/commands/registry.rb +3 -1
- data/lib/claude_memory/hook/context_injector.rb +75 -0
- data/lib/claude_memory/hook/error_classifier.rb +67 -0
- data/lib/claude_memory/hook/handler.rb +21 -1
- data/lib/claude_memory/index/vector_index.rb +171 -0
- data/lib/claude_memory/infrastructure/schema_validator.rb +5 -1
- data/lib/claude_memory/ingest/ingester.rb +26 -1
- data/lib/claude_memory/ingest/observation_compressor.rb +177 -0
- data/lib/claude_memory/mcp/instructions_builder.rb +76 -0
- data/lib/claude_memory/mcp/server.rb +3 -1
- data/lib/claude_memory/mcp/tool_definitions.rb +15 -7
- data/lib/claude_memory/mcp/tools.rb +125 -2
- data/lib/claude_memory/publish.rb +28 -27
- data/lib/claude_memory/recall/dual_query_template.rb +1 -12
- data/lib/claude_memory/recall.rb +71 -17
- data/lib/claude_memory/store/sqlite_store.rb +17 -1
- data/lib/claude_memory/sweep/sweeper.rb +30 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +8 -0
- data/scripts/hook-runner.sh +14 -0
- data/scripts/serve-mcp.sh +14 -0
- data/skills/setup-memory/SKILL.md +6 -0
- metadata +31 -2
data/hooks/hooks.json
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "
|
|
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": "
|
|
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": "
|
|
35
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh ingest",
|
|
36
|
+
"timeout": 30
|
|
29
37
|
},
|
|
30
38
|
{
|
|
31
39
|
"type": "command",
|
|
32
|
-
"command": "
|
|
40
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.sh sweep",
|
|
41
|
+
"timeout": 30
|
|
33
42
|
}
|
|
34
43
|
]
|
|
35
44
|
}
|
|
36
45
|
],
|
|
37
|
-
"
|
|
46
|
+
"SessionEnd": [
|
|
38
47
|
{
|
|
39
|
-
"matcher": "idle_prompt",
|
|
40
48
|
"hooks": [
|
|
41
49
|
{
|
|
42
50
|
"type": "command",
|
|
43
|
-
"command": "
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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 = []
|