claude_memory 0.1.0 → 0.2.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/.mind.mv2.aLCUZd +0 -0
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +7 -1
- data/.claude/settings.json +0 -4
- data/.claude/settings.local.json +4 -1
- data/.claude-plugin/plugin.json +1 -1
- data/.claude.json +11 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +62 -11
- data/CLAUDE.md +87 -24
- data/README.md +76 -159
- data/docs/EXAMPLES.md +436 -0
- data/docs/RELEASE_NOTES_v0.2.0.md +179 -0
- data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +582 -0
- data/docs/SOCIAL_MEDIA_v0.2.0.md +420 -0
- data/docs/architecture.md +360 -0
- data/docs/expert_review.md +1718 -0
- data/docs/feature_adoption_plan.md +1241 -0
- data/docs/feature_adoption_plan_revised.md +2374 -0
- data/docs/improvements.md +1325 -0
- data/docs/quality_review.md +1544 -0
- data/docs/review_summary.md +480 -0
- data/lefthook.yml +10 -0
- data/lib/claude_memory/cli.rb +16 -844
- data/lib/claude_memory/commands/base_command.rb +95 -0
- data/lib/claude_memory/commands/changes_command.rb +39 -0
- data/lib/claude_memory/commands/conflicts_command.rb +37 -0
- data/lib/claude_memory/commands/db_init_command.rb +40 -0
- data/lib/claude_memory/commands/doctor_command.rb +147 -0
- data/lib/claude_memory/commands/explain_command.rb +65 -0
- data/lib/claude_memory/commands/help_command.rb +37 -0
- data/lib/claude_memory/commands/hook_command.rb +106 -0
- data/lib/claude_memory/commands/ingest_command.rb +47 -0
- data/lib/claude_memory/commands/init_command.rb +218 -0
- data/lib/claude_memory/commands/promote_command.rb +30 -0
- data/lib/claude_memory/commands/publish_command.rb +36 -0
- data/lib/claude_memory/commands/recall_command.rb +61 -0
- data/lib/claude_memory/commands/registry.rb +55 -0
- data/lib/claude_memory/commands/search_command.rb +43 -0
- data/lib/claude_memory/commands/serve_mcp_command.rb +16 -0
- data/lib/claude_memory/commands/sweep_command.rb +36 -0
- data/lib/claude_memory/commands/version_command.rb +13 -0
- data/lib/claude_memory/configuration.rb +38 -0
- data/lib/claude_memory/core/fact_id.rb +41 -0
- data/lib/claude_memory/core/null_explanation.rb +47 -0
- data/lib/claude_memory/core/null_fact.rb +30 -0
- data/lib/claude_memory/core/result.rb +143 -0
- data/lib/claude_memory/core/session_id.rb +37 -0
- data/lib/claude_memory/core/token_estimator.rb +33 -0
- data/lib/claude_memory/core/transcript_path.rb +37 -0
- data/lib/claude_memory/domain/conflict.rb +51 -0
- data/lib/claude_memory/domain/entity.rb +51 -0
- data/lib/claude_memory/domain/fact.rb +70 -0
- data/lib/claude_memory/domain/provenance.rb +48 -0
- data/lib/claude_memory/hook/exit_codes.rb +18 -0
- data/lib/claude_memory/hook/handler.rb +7 -2
- data/lib/claude_memory/index/index_query.rb +89 -0
- data/lib/claude_memory/index/index_query_logic.rb +41 -0
- data/lib/claude_memory/index/query_options.rb +67 -0
- data/lib/claude_memory/infrastructure/file_system.rb +29 -0
- data/lib/claude_memory/infrastructure/in_memory_file_system.rb +32 -0
- data/lib/claude_memory/ingest/content_sanitizer.rb +42 -0
- data/lib/claude_memory/ingest/ingester.rb +3 -0
- data/lib/claude_memory/ingest/privacy_tag.rb +48 -0
- data/lib/claude_memory/mcp/tools.rb +174 -1
- data/lib/claude_memory/publish.rb +29 -20
- data/lib/claude_memory/recall.rb +164 -16
- data/lib/claude_memory/resolve/resolver.rb +41 -37
- data/lib/claude_memory/shortcuts.rb +56 -0
- data/lib/claude_memory/store/store_manager.rb +35 -32
- data/lib/claude_memory/templates/hooks.example.json +0 -4
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +59 -21
- metadata +55 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# Base class for all CLI commands.
|
|
8
|
+
# Provides consistent interface for commands with I/O isolation for testing.
|
|
9
|
+
#
|
|
10
|
+
# @example Implementing a command
|
|
11
|
+
# class MyCommand < BaseCommand
|
|
12
|
+
# def call(args)
|
|
13
|
+
# opts = parse_options(args, {verbose: false}) do |o|
|
|
14
|
+
# OptionParser.new do |parser|
|
|
15
|
+
# parser.on("-v", "--verbose") { o[:verbose] = true }
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
# return 1 if opts.nil?
|
|
19
|
+
#
|
|
20
|
+
# # ... command logic ...
|
|
21
|
+
# success("Done!")
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @example Testing a command
|
|
26
|
+
# stdout = StringIO.new
|
|
27
|
+
# stderr = StringIO.new
|
|
28
|
+
# command = MyCommand.new(stdout: stdout, stderr: stderr)
|
|
29
|
+
# exit_code = command.call(["--verbose"])
|
|
30
|
+
# expect(exit_code).to eq(0)
|
|
31
|
+
# expect(stdout.string).to include("Done!")
|
|
32
|
+
class BaseCommand
|
|
33
|
+
attr_reader :stdout, :stderr, :stdin
|
|
34
|
+
|
|
35
|
+
# @param stdout [IO] output stream (default: $stdout)
|
|
36
|
+
# @param stderr [IO] error stream (default: $stderr)
|
|
37
|
+
# @param stdin [IO] input stream (default: $stdin)
|
|
38
|
+
def initialize(stdout: $stdout, stderr: $stderr, stdin: $stdin)
|
|
39
|
+
@stdout = stdout
|
|
40
|
+
@stderr = stderr
|
|
41
|
+
@stdin = stdin
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Execute the command with given arguments
|
|
45
|
+
# @param args [Array<String>] command line arguments
|
|
46
|
+
# @return [Integer] exit code (0 for success, non-zero for failure)
|
|
47
|
+
def call(args)
|
|
48
|
+
raise NotImplementedError, "Subclass must implement #call"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
protected
|
|
52
|
+
|
|
53
|
+
# Report successful command execution
|
|
54
|
+
# @param message [String, nil] optional success message
|
|
55
|
+
# @param exit_code [Integer] exit code (default: 0)
|
|
56
|
+
# @return [Integer] the exit code
|
|
57
|
+
def success(message = nil, exit_code: 0)
|
|
58
|
+
stdout.puts(message) if message
|
|
59
|
+
exit_code
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Report failed command execution
|
|
63
|
+
# @param message [String] error message
|
|
64
|
+
# @param exit_code [Integer] exit code (default: 1)
|
|
65
|
+
# @return [Integer] the exit code
|
|
66
|
+
def failure(message, exit_code: 1)
|
|
67
|
+
stderr.puts(message)
|
|
68
|
+
exit_code
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse command line options with error handling
|
|
72
|
+
# @param args [Array<String>] command line arguments
|
|
73
|
+
# @param defaults [Hash] default option values
|
|
74
|
+
# @yield [Hash] yields the options hash to configure the parser
|
|
75
|
+
# @return [Hash, nil] parsed options, or nil if parsing failed
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# opts = parse_options(args, {verbose: false}) do |o|
|
|
79
|
+
# OptionParser.new do |parser|
|
|
80
|
+
# parser.on("-v", "--verbose") { o[:verbose] = true }
|
|
81
|
+
# parser.on("--name NAME") { |v| o[:name] = v }
|
|
82
|
+
# end
|
|
83
|
+
# end
|
|
84
|
+
def parse_options(args, defaults = {})
|
|
85
|
+
opts = defaults.dup
|
|
86
|
+
parser = yield(opts)
|
|
87
|
+
parser.parse(args)
|
|
88
|
+
opts
|
|
89
|
+
rescue OptionParser::InvalidOption => e
|
|
90
|
+
failure(e.message)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Commands
|
|
5
|
+
# Shows recent fact changes
|
|
6
|
+
class ChangesCommand < BaseCommand
|
|
7
|
+
def call(args)
|
|
8
|
+
opts = parse_options(args, {since: nil, limit: 20, scope: "all"}) do |o|
|
|
9
|
+
OptionParser.new do |parser|
|
|
10
|
+
parser.on("--since ISO", "Since timestamp") { |v| o[:since] = v }
|
|
11
|
+
parser.on("--limit N", Integer, "Max results") { |v| o[:limit] = v }
|
|
12
|
+
parser.on("--scope SCOPE", "Scope: project, global, or all") { |v| o[:scope] = v }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
return 1 if opts.nil?
|
|
16
|
+
|
|
17
|
+
opts[:since] ||= (Time.now - 86400 * 7).utc.iso8601
|
|
18
|
+
|
|
19
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
20
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
21
|
+
|
|
22
|
+
changes = recall.changes(since: opts[:since], limit: opts[:limit], scope: opts[:scope])
|
|
23
|
+
if changes.empty?
|
|
24
|
+
stdout.puts "No changes since #{opts[:since]}."
|
|
25
|
+
else
|
|
26
|
+
stdout.puts "Changes since #{opts[:since]} (#{changes.size}):\n\n"
|
|
27
|
+
changes.each do |change|
|
|
28
|
+
source_label = change[:source] ? " [#{change[:source]}]" : ""
|
|
29
|
+
stdout.puts " [#{change[:id]}] #{change[:predicate]}: #{change[:object_literal]} (#{change[:status]})#{source_label}"
|
|
30
|
+
stdout.puts " Created: #{change[:created_at]}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
manager.close
|
|
35
|
+
0
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Commands
|
|
5
|
+
# Shows open conflicts in memory
|
|
6
|
+
class ConflictsCommand < BaseCommand
|
|
7
|
+
def call(args)
|
|
8
|
+
opts = parse_options(args, {scope: "all"}) do |o|
|
|
9
|
+
OptionParser.new do |parser|
|
|
10
|
+
parser.on("--scope SCOPE", "Scope: project, global, or all") { |v| o[:scope] = v }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
return 1 if opts.nil?
|
|
14
|
+
|
|
15
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
16
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
17
|
+
conflicts = recall.conflicts(scope: opts[:scope])
|
|
18
|
+
|
|
19
|
+
if conflicts.empty?
|
|
20
|
+
stdout.puts "No open conflicts."
|
|
21
|
+
else
|
|
22
|
+
stdout.puts "Open conflicts (#{conflicts.size}):\n\n"
|
|
23
|
+
conflicts.each do |c|
|
|
24
|
+
source_label = c[:source] ? " [#{c[:source]}]" : ""
|
|
25
|
+
stdout.puts " Conflict ##{c[:id]}: Fact #{c[:fact_a_id]} vs Fact #{c[:fact_b_id]}#{source_label}"
|
|
26
|
+
stdout.puts " Status: #{c[:status]}, Detected: #{c[:detected_at]}"
|
|
27
|
+
stdout.puts " Notes: #{c[:notes]}" if c[:notes]
|
|
28
|
+
stdout.puts
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
manager.close
|
|
33
|
+
0
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Commands
|
|
5
|
+
# Initializes SQLite databases
|
|
6
|
+
class DbInitCommand < BaseCommand
|
|
7
|
+
def call(args)
|
|
8
|
+
opts = parse_options(args, {global: false, project: false}) do |o|
|
|
9
|
+
OptionParser.new do |parser|
|
|
10
|
+
parser.banner = "Usage: claude-memory db:init [options]"
|
|
11
|
+
parser.on("--global", "Initialize global database (~/.claude/memory.sqlite3)") { o[:global] = true }
|
|
12
|
+
parser.on("--project", "Initialize project database (.claude/memory.sqlite3)") { o[:project] = true }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
return 1 if opts.nil?
|
|
16
|
+
|
|
17
|
+
# If neither flag specified, initialize both
|
|
18
|
+
opts[:global] = true if !opts[:global] && !opts[:project]
|
|
19
|
+
opts[:project] = true if !opts[:global] && !opts[:project]
|
|
20
|
+
|
|
21
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
22
|
+
|
|
23
|
+
if opts[:global]
|
|
24
|
+
manager.ensure_global!
|
|
25
|
+
stdout.puts "Global database initialized at #{manager.global_db_path}"
|
|
26
|
+
stdout.puts "Schema version: #{manager.global_store.schema_version}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if opts[:project]
|
|
30
|
+
manager.ensure_project!
|
|
31
|
+
stdout.puts "Project database initialized at #{manager.project_db_path}"
|
|
32
|
+
stdout.puts "Schema version: #{manager.project_store.schema_version}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
manager.close
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# Performs system health checks for ClaudeMemory
|
|
8
|
+
# Checks databases, snapshots, hooks configuration
|
|
9
|
+
class DoctorCommand < BaseCommand
|
|
10
|
+
def call(_args)
|
|
11
|
+
issues = []
|
|
12
|
+
warnings = []
|
|
13
|
+
|
|
14
|
+
stdout.puts "Claude Memory Doctor\n"
|
|
15
|
+
stdout.puts "=" * 40
|
|
16
|
+
|
|
17
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
18
|
+
|
|
19
|
+
stdout.puts "\n## Global Database"
|
|
20
|
+
check_database(manager.global_db_path, "global", issues, warnings)
|
|
21
|
+
|
|
22
|
+
stdout.puts "\n## Project Database"
|
|
23
|
+
check_database(manager.project_db_path, "project", issues, warnings)
|
|
24
|
+
|
|
25
|
+
manager.close
|
|
26
|
+
|
|
27
|
+
check_snapshot(warnings)
|
|
28
|
+
check_claude_md(warnings)
|
|
29
|
+
check_hooks_config(warnings)
|
|
30
|
+
|
|
31
|
+
stdout.puts
|
|
32
|
+
|
|
33
|
+
if warnings.any?
|
|
34
|
+
stdout.puts "Warnings:"
|
|
35
|
+
warnings.each { |w| stdout.puts " ⚠ #{w}" }
|
|
36
|
+
stdout.puts
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if issues.any?
|
|
40
|
+
stdout.puts "Issues:"
|
|
41
|
+
issues.each { |i| stderr.puts " ✗ #{i}" }
|
|
42
|
+
stdout.puts
|
|
43
|
+
stdout.puts "Run 'claude-memory init' to set up."
|
|
44
|
+
return 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
stdout.puts "All checks passed!"
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def check_database(db_path, label, issues, warnings)
|
|
54
|
+
if File.exist?(db_path)
|
|
55
|
+
stdout.puts "✓ #{label.capitalize} database exists: #{db_path}"
|
|
56
|
+
begin
|
|
57
|
+
store = ClaudeMemory::Store::SQLiteStore.new(db_path)
|
|
58
|
+
stdout.puts " Schema version: #{store.schema_version}"
|
|
59
|
+
|
|
60
|
+
fact_count = store.facts.count
|
|
61
|
+
stdout.puts " Facts: #{fact_count}"
|
|
62
|
+
|
|
63
|
+
content_count = store.content_items.count
|
|
64
|
+
stdout.puts " Content items: #{content_count}"
|
|
65
|
+
|
|
66
|
+
conflict_count = store.conflicts.where(status: "open").count
|
|
67
|
+
if conflict_count > 0
|
|
68
|
+
warnings << "#{label}: #{conflict_count} open conflict(s) need resolution"
|
|
69
|
+
end
|
|
70
|
+
stdout.puts " Open conflicts: #{conflict_count}"
|
|
71
|
+
|
|
72
|
+
last_ingest = store.content_items.max(:ingested_at)
|
|
73
|
+
if last_ingest
|
|
74
|
+
stdout.puts " Last ingest: #{last_ingest}"
|
|
75
|
+
elsif label == "project"
|
|
76
|
+
warnings << "#{label}: No content has been ingested yet"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
store.close
|
|
80
|
+
rescue => e
|
|
81
|
+
issues << "#{label} database error: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
elsif label == "global"
|
|
84
|
+
issues << "Global database not found: #{db_path}"
|
|
85
|
+
else
|
|
86
|
+
warnings << "Project database not found: #{db_path} (run 'claude-memory init')"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def check_snapshot(warnings)
|
|
91
|
+
if File.exist?(".claude/rules/claude_memory.generated.md")
|
|
92
|
+
stdout.puts "✓ Published snapshot exists"
|
|
93
|
+
else
|
|
94
|
+
warnings << "No published snapshot found. Run 'claude-memory publish'"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check_claude_md(warnings)
|
|
99
|
+
if File.exist?(".claude/CLAUDE.md")
|
|
100
|
+
content = File.read(".claude/CLAUDE.md")
|
|
101
|
+
if content.include?("claude_memory.generated.md")
|
|
102
|
+
stdout.puts "✓ CLAUDE.md imports snapshot"
|
|
103
|
+
else
|
|
104
|
+
warnings << "CLAUDE.md does not import snapshot"
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
warnings << "No .claude/CLAUDE.md found"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def check_hooks_config(warnings)
|
|
112
|
+
settings_path = ".claude/settings.json"
|
|
113
|
+
local_settings_path = ".claude/settings.local.json"
|
|
114
|
+
|
|
115
|
+
hooks_found = false
|
|
116
|
+
|
|
117
|
+
[settings_path, local_settings_path].each do |path|
|
|
118
|
+
next unless File.exist?(path)
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
config = JSON.parse(File.read(path))
|
|
122
|
+
if config["hooks"]&.any?
|
|
123
|
+
hooks_found = true
|
|
124
|
+
stdout.puts "✓ Hooks configured in #{path}"
|
|
125
|
+
|
|
126
|
+
expected_hooks = %w[Stop SessionStart PreCompact SessionEnd]
|
|
127
|
+
missing = expected_hooks - config["hooks"].keys
|
|
128
|
+
if missing.any?
|
|
129
|
+
warnings << "Missing recommended hooks in #{path}: #{missing.join(", ")}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
rescue JSON::ParserError
|
|
133
|
+
warnings << "Invalid JSON in #{path}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
unless hooks_found
|
|
138
|
+
warnings << "No hooks configured. Run 'claude-memory init' or configure manually."
|
|
139
|
+
stdout.puts "\n Manual fallback available:"
|
|
140
|
+
stdout.puts " claude-memory ingest --session-id <id> --transcript-path <path>"
|
|
141
|
+
stdout.puts " claude-memory sweep --budget 5"
|
|
142
|
+
stdout.puts " claude-memory publish"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Commands
|
|
5
|
+
# Explains a fact with provenance and relationships
|
|
6
|
+
class ExplainCommand < BaseCommand
|
|
7
|
+
def call(args)
|
|
8
|
+
fact_id = args.first&.to_i
|
|
9
|
+
unless fact_id && fact_id > 0
|
|
10
|
+
stderr.puts "Usage: claude-memory explain <fact_id> [--scope project|global]"
|
|
11
|
+
return 1
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
opts = parse_options(args[1..] || [], {scope: "project"}) do |o|
|
|
15
|
+
OptionParser.new do |parser|
|
|
16
|
+
parser.on("--scope SCOPE", "Scope: project or global") { |v| o[:scope] = v }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
return 1 if opts.nil?
|
|
20
|
+
|
|
21
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
22
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
23
|
+
|
|
24
|
+
explanation = recall.explain(fact_id, scope: opts[:scope])
|
|
25
|
+
if explanation.is_a?(ClaudeMemory::Core::NullExplanation)
|
|
26
|
+
stderr.puts "Fact #{fact_id} not found in #{opts[:scope]} database."
|
|
27
|
+
manager.close
|
|
28
|
+
return 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
stdout.puts "Fact ##{fact_id} (#{opts[:scope]}):"
|
|
32
|
+
print_fact(explanation[:fact])
|
|
33
|
+
print_receipts(explanation[:receipts])
|
|
34
|
+
|
|
35
|
+
if explanation[:supersedes].any?
|
|
36
|
+
stdout.puts " Supersedes: #{explanation[:supersedes].join(", ")}"
|
|
37
|
+
end
|
|
38
|
+
if explanation[:superseded_by].any?
|
|
39
|
+
stdout.puts " Superseded by: #{explanation[:superseded_by].join(", ")}"
|
|
40
|
+
end
|
|
41
|
+
if explanation[:conflicts].any?
|
|
42
|
+
stdout.puts " Conflicts: #{explanation[:conflicts].map { |c| c[:id] }.join(", ")}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
manager.close
|
|
46
|
+
0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def print_fact(fact)
|
|
52
|
+
stdout.puts " #{fact[:predicate]}: #{fact[:object_literal]}"
|
|
53
|
+
stdout.puts " Status: #{fact[:status]}, Confidence: #{fact[:confidence]}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def print_receipts(receipts)
|
|
57
|
+
return if receipts.empty?
|
|
58
|
+
stdout.puts " Receipts (#{receipts.size}):"
|
|
59
|
+
receipts.each do |r|
|
|
60
|
+
stdout.puts " - #{r[:quote] || "(no quote)"}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Commands
|
|
5
|
+
# Displays help information for claude-memory CLI
|
|
6
|
+
class HelpCommand < BaseCommand
|
|
7
|
+
def call(_args)
|
|
8
|
+
stdout.puts <<~HELP
|
|
9
|
+
claude-memory - Long-term memory for Claude Code
|
|
10
|
+
|
|
11
|
+
Usage: claude-memory <command> [options]
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
changes Show recent fact changes
|
|
15
|
+
conflicts Show open conflicts
|
|
16
|
+
db:init Initialize the SQLite database
|
|
17
|
+
doctor Check system health
|
|
18
|
+
explain Explain a fact with receipts
|
|
19
|
+
help Show this help message
|
|
20
|
+
hook Run hook entrypoints (ingest|sweep|publish)
|
|
21
|
+
init Initialize ClaudeMemory in a project
|
|
22
|
+
ingest Ingest transcript delta
|
|
23
|
+
promote Promote a project fact to global memory
|
|
24
|
+
publish Publish snapshot to Claude Code memory
|
|
25
|
+
recall Recall facts matching a query
|
|
26
|
+
search Search indexed content
|
|
27
|
+
serve-mcp Start MCP server
|
|
28
|
+
sweep Run maintenance/pruning
|
|
29
|
+
version Show version number
|
|
30
|
+
|
|
31
|
+
Run 'claude-memory <command> --help' for more information on a command.
|
|
32
|
+
HELP
|
|
33
|
+
0
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# Handles hook entrypoints (ingest, sweep, publish)
|
|
8
|
+
class HookCommand < BaseCommand
|
|
9
|
+
def call(args)
|
|
10
|
+
subcommand = args.first
|
|
11
|
+
|
|
12
|
+
unless subcommand
|
|
13
|
+
stderr.puts "Usage: claude-memory hook <ingest|sweep|publish> [options]"
|
|
14
|
+
stderr.puts "\nReads hook payload JSON from stdin."
|
|
15
|
+
return Hook::ExitCodes::ERROR
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
unless %w[ingest sweep publish].include?(subcommand)
|
|
19
|
+
stderr.puts "Unknown hook command: #{subcommand}"
|
|
20
|
+
stderr.puts "Available: ingest, sweep, publish"
|
|
21
|
+
return Hook::ExitCodes::ERROR
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
opts = parse_options(args[1..] || [], {db: ClaudeMemory.project_db_path}) do |o|
|
|
25
|
+
OptionParser.new do |parser|
|
|
26
|
+
parser.on("--db PATH", "Database path") { |v| o[:db] = v }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
return Hook::ExitCodes::ERROR if opts.nil?
|
|
30
|
+
|
|
31
|
+
payload = parse_hook_payload
|
|
32
|
+
return Hook::ExitCodes::ERROR unless payload
|
|
33
|
+
|
|
34
|
+
store = ClaudeMemory::Store::SQLiteStore.new(opts[:db])
|
|
35
|
+
handler = ClaudeMemory::Hook::Handler.new(store)
|
|
36
|
+
|
|
37
|
+
exit_code = case subcommand
|
|
38
|
+
when "ingest"
|
|
39
|
+
hook_ingest(handler, payload)
|
|
40
|
+
when "sweep"
|
|
41
|
+
hook_sweep(handler, payload)
|
|
42
|
+
when "publish"
|
|
43
|
+
hook_publish(handler, payload)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
store.close
|
|
47
|
+
exit_code
|
|
48
|
+
rescue ClaudeMemory::Hook::Handler::PayloadError => e
|
|
49
|
+
stderr.puts "Payload error: #{e.message}"
|
|
50
|
+
Hook::ExitCodes::ERROR
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def parse_hook_payload
|
|
56
|
+
input = stdin.read
|
|
57
|
+
JSON.parse(input)
|
|
58
|
+
rescue JSON::ParserError => e
|
|
59
|
+
stderr.puts "Invalid JSON payload: #{e.message}"
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def hook_ingest(handler, payload)
|
|
64
|
+
result = handler.ingest(payload)
|
|
65
|
+
|
|
66
|
+
case result[:status]
|
|
67
|
+
when :ingested
|
|
68
|
+
stdout.puts "Ingested #{result[:bytes_read]} bytes (content_id: #{result[:content_id]})"
|
|
69
|
+
Hook::ExitCodes::SUCCESS
|
|
70
|
+
when :no_change
|
|
71
|
+
stdout.puts "No new content to ingest"
|
|
72
|
+
Hook::ExitCodes::SUCCESS
|
|
73
|
+
when :skipped
|
|
74
|
+
stdout.puts "Skipped ingestion: #{result[:reason]}"
|
|
75
|
+
Hook::ExitCodes::WARNING
|
|
76
|
+
else
|
|
77
|
+
Hook::ExitCodes::ERROR
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def hook_sweep(handler, payload)
|
|
82
|
+
result = handler.sweep(payload)
|
|
83
|
+
stats = result[:stats]
|
|
84
|
+
|
|
85
|
+
stdout.puts "Sweep complete:"
|
|
86
|
+
stdout.puts " Elapsed: #{stats[:elapsed_seconds].round(2)}s"
|
|
87
|
+
stdout.puts " Budget honored: #{stats[:budget_honored]}"
|
|
88
|
+
|
|
89
|
+
Hook::ExitCodes::SUCCESS
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def hook_publish(handler, payload)
|
|
93
|
+
result = handler.publish(payload)
|
|
94
|
+
|
|
95
|
+
case result[:status]
|
|
96
|
+
when :updated
|
|
97
|
+
stdout.puts "Published snapshot to #{result[:path]}"
|
|
98
|
+
when :unchanged
|
|
99
|
+
stdout.puts "No changes - #{result[:path]} is up to date"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
Hook::ExitCodes::SUCCESS
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Commands
|
|
5
|
+
# Ingests transcript delta into memory database
|
|
6
|
+
class IngestCommand < BaseCommand
|
|
7
|
+
def call(args)
|
|
8
|
+
opts = parse_options(args, {source: "claude_code", db: ClaudeMemory.project_db_path}) do |o|
|
|
9
|
+
OptionParser.new do |parser|
|
|
10
|
+
parser.banner = "Usage: claude-memory ingest [options]"
|
|
11
|
+
parser.on("--source SOURCE", "Source identifier (default: claude_code)") { |v| o[:source] = v }
|
|
12
|
+
parser.on("--session-id ID", "Session identifier (required)") { |v| o[:session_id] = v }
|
|
13
|
+
parser.on("--transcript-path PATH", "Path to transcript file (required)") { |v| o[:transcript_path] = v }
|
|
14
|
+
parser.on("--db PATH", "Database path") { |v| o[:db] = v }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
unless opts && opts[:session_id] && opts[:transcript_path]
|
|
19
|
+
stderr.puts "\nError: --session-id and --transcript-path are required"
|
|
20
|
+
return 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
store = ClaudeMemory::Store::SQLiteStore.new(opts[:db])
|
|
24
|
+
ingester = ClaudeMemory::Ingest::Ingester.new(store)
|
|
25
|
+
|
|
26
|
+
result = ingester.ingest(
|
|
27
|
+
source: opts[:source],
|
|
28
|
+
session_id: opts[:session_id],
|
|
29
|
+
transcript_path: opts[:transcript_path]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
case result[:status]
|
|
33
|
+
when :ingested
|
|
34
|
+
stdout.puts "Ingested #{result[:bytes_read]} bytes (content_id: #{result[:content_id]})"
|
|
35
|
+
when :no_change
|
|
36
|
+
stdout.puts "No new content to ingest"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
store.close
|
|
40
|
+
0
|
|
41
|
+
rescue ClaudeMemory::Ingest::TranscriptReader::FileNotFoundError => e
|
|
42
|
+
stderr.puts "Error: #{e.message}"
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|