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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Ingest
|
|
7
|
+
# Compresses tool call observations into human-readable summaries.
|
|
8
|
+
# Reduces ~70% token usage vs raw tool I/O in provenance descriptions.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# compressor = ObservationCompressor.new
|
|
12
|
+
# summary = compressor.compress("Edit", '{"file_path":"/src/auth.rb","old_string":"def login","new_string":"def async_login"}')
|
|
13
|
+
# # => "Edited auth.rb: 'def login' → 'def async_login'"
|
|
14
|
+
class ObservationCompressor
|
|
15
|
+
MAX_SUMMARY_LENGTH = 200
|
|
16
|
+
|
|
17
|
+
def compress(tool_name, tool_input_json)
|
|
18
|
+
return nil if tool_input_json.nil? || tool_input_json.empty?
|
|
19
|
+
|
|
20
|
+
input = parse_input(tool_input_json)
|
|
21
|
+
return nil unless input
|
|
22
|
+
|
|
23
|
+
summary = case tool_name
|
|
24
|
+
when "Edit"
|
|
25
|
+
compress_edit(input)
|
|
26
|
+
when "Write"
|
|
27
|
+
compress_write(input)
|
|
28
|
+
when "Bash"
|
|
29
|
+
compress_bash(input)
|
|
30
|
+
when "Read"
|
|
31
|
+
compress_read(input)
|
|
32
|
+
when "Glob"
|
|
33
|
+
compress_glob(input)
|
|
34
|
+
when "Grep"
|
|
35
|
+
compress_grep(input)
|
|
36
|
+
when "Task"
|
|
37
|
+
compress_task(input)
|
|
38
|
+
when "WebFetch"
|
|
39
|
+
compress_web_fetch(input)
|
|
40
|
+
when "WebSearch"
|
|
41
|
+
compress_web_search(input)
|
|
42
|
+
when "NotebookEdit"
|
|
43
|
+
compress_notebook_edit(input)
|
|
44
|
+
else
|
|
45
|
+
compress_generic(tool_name, input)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
truncate(summary)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parse_input(json_str)
|
|
54
|
+
JSON.parse(json_str)
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compress_edit(input)
|
|
60
|
+
file = short_path(input["file_path"])
|
|
61
|
+
old_str = truncate_str(input["old_string"], 40)
|
|
62
|
+
new_str = truncate_str(input["new_string"], 40)
|
|
63
|
+
|
|
64
|
+
if old_str && new_str
|
|
65
|
+
"Edited #{file}: '#{old_str}' → '#{new_str}'"
|
|
66
|
+
elsif file
|
|
67
|
+
"Edited #{file}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def compress_write(input)
|
|
72
|
+
file = short_path(input["file_path"])
|
|
73
|
+
content = input["content"]
|
|
74
|
+
size = content ? content.length : 0
|
|
75
|
+
|
|
76
|
+
"Created #{file} (#{size} chars)"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def compress_bash(input)
|
|
80
|
+
command = input["command"]
|
|
81
|
+
return "Ran shell command" unless command
|
|
82
|
+
|
|
83
|
+
desc = input["description"]
|
|
84
|
+
cmd = truncate_str(command, 80)
|
|
85
|
+
|
|
86
|
+
if desc
|
|
87
|
+
"Ran: #{desc}"
|
|
88
|
+
else
|
|
89
|
+
"Ran: #{cmd}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def compress_read(input)
|
|
94
|
+
file = short_path(input["file_path"])
|
|
95
|
+
offset = input["offset"]
|
|
96
|
+
limit = input["limit"]
|
|
97
|
+
|
|
98
|
+
parts = ["Read #{file}"]
|
|
99
|
+
parts << "lines #{offset}-#{offset + limit}" if offset && limit
|
|
100
|
+
parts.join(" ")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def compress_glob(input)
|
|
104
|
+
pattern = input["pattern"]
|
|
105
|
+
path = input["path"]
|
|
106
|
+
|
|
107
|
+
if path
|
|
108
|
+
"Glob #{pattern} in #{short_path(path)}"
|
|
109
|
+
else
|
|
110
|
+
"Glob #{pattern}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def compress_grep(input)
|
|
115
|
+
pattern = input["pattern"]
|
|
116
|
+
path = input["path"]
|
|
117
|
+
|
|
118
|
+
if path
|
|
119
|
+
"Searched '#{truncate_str(pattern, 40)}' in #{short_path(path)}"
|
|
120
|
+
else
|
|
121
|
+
"Searched '#{truncate_str(pattern, 40)}'"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def compress_task(input)
|
|
126
|
+
desc = input["description"]
|
|
127
|
+
agent_type = input["subagent_type"]
|
|
128
|
+
|
|
129
|
+
if desc
|
|
130
|
+
"Spawned #{agent_type || "agent"}: #{truncate_str(desc, 60)}"
|
|
131
|
+
else
|
|
132
|
+
"Spawned #{agent_type || "agent"}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def compress_web_fetch(input)
|
|
137
|
+
url = input["url"]
|
|
138
|
+
"Fetched #{truncate_str(url, 60)}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def compress_web_search(input)
|
|
142
|
+
query = input["query"]
|
|
143
|
+
"Searched web: '#{truncate_str(query, 60)}'"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def compress_notebook_edit(input)
|
|
147
|
+
path = short_path(input["notebook_path"])
|
|
148
|
+
mode = input["edit_mode"] || "replace"
|
|
149
|
+
"Notebook #{mode} in #{path}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def compress_generic(tool_name, input)
|
|
153
|
+
keys = input.keys.first(3).join(", ")
|
|
154
|
+
"#{tool_name}(#{keys})"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def short_path(path)
|
|
158
|
+
return "unknown" unless path
|
|
159
|
+
|
|
160
|
+
File.basename(path)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def truncate_str(str, max_len)
|
|
164
|
+
return nil unless str
|
|
165
|
+
|
|
166
|
+
str = str.gsub(/\s+/, " ").strip
|
|
167
|
+
(str.length > max_len) ? str[0...max_len] + "..." : str
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def truncate(summary)
|
|
171
|
+
return nil unless summary
|
|
172
|
+
|
|
173
|
+
(summary.length > MAX_SUMMARY_LENGTH) ? summary[0...MAX_SUMMARY_LENGTH] + "..." : summary
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module MCP
|
|
5
|
+
# Generates dynamic MCP server instructions from database state.
|
|
6
|
+
# Injected into the LLM system prompt via the initialize response,
|
|
7
|
+
# giving Claude immediate context about memory state without extra tool calls.
|
|
8
|
+
#
|
|
9
|
+
# Source: QMD mcp.ts:91-98 (buildInstructions pattern)
|
|
10
|
+
module InstructionsBuilder
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def build(store_or_manager)
|
|
14
|
+
parts = ["ClaudeMemory v#{ClaudeMemory::VERSION} — long-term memory for Claude Code."]
|
|
15
|
+
|
|
16
|
+
if store_or_manager.is_a?(Store::StoreManager)
|
|
17
|
+
parts << database_summary(store_or_manager)
|
|
18
|
+
parts << conflict_summary(store_or_manager)
|
|
19
|
+
elsif store_or_manager.respond_to?(:facts)
|
|
20
|
+
parts << single_db_summary(store_or_manager)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
parts << usage_hint
|
|
24
|
+
parts.compact.join("\n\n")
|
|
25
|
+
rescue => _e
|
|
26
|
+
# Never fail initialization — return minimal instructions
|
|
27
|
+
"ClaudeMemory v#{ClaudeMemory::VERSION} — long-term memory for Claude Code."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def database_summary(manager)
|
|
31
|
+
lines = []
|
|
32
|
+
|
|
33
|
+
if manager.global_exists?
|
|
34
|
+
manager.ensure_global!
|
|
35
|
+
global = manager.global_store
|
|
36
|
+
g_facts = global.facts.where(status: "active").count
|
|
37
|
+
lines << "Global: #{g_facts} active facts"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if manager.project_exists?
|
|
41
|
+
manager.ensure_project!
|
|
42
|
+
project = manager.project_store
|
|
43
|
+
p_facts = project.facts.where(status: "active").count
|
|
44
|
+
lines << "Project: #{p_facts} active facts"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return nil if lines.empty?
|
|
48
|
+
"Database state: #{lines.join(", ")}."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def single_db_summary(store)
|
|
52
|
+
facts = store.facts.where(status: "active").count
|
|
53
|
+
"Database state: #{facts} active facts."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def conflict_summary(manager)
|
|
57
|
+
count = 0
|
|
58
|
+
|
|
59
|
+
if manager.global_exists?
|
|
60
|
+
count += manager.global_store.conflicts.where(status: "open").count
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if manager.project_exists?
|
|
64
|
+
count += manager.project_store.conflicts.where(status: "open").count
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return nil if count == 0
|
|
68
|
+
"#{count} open conflict#{"s" unless count == 1} — use memory.conflicts to review."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def usage_hint
|
|
72
|
+
"Use memory.recall to search facts, memory.decisions for architectural decisions, memory.conventions for coding style."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "instructions_builder"
|
|
4
5
|
require_relative "query_guide"
|
|
5
6
|
require_relative "text_summary"
|
|
6
7
|
|
|
@@ -85,7 +86,8 @@ module ClaudeMemory
|
|
|
85
86
|
serverInfo: {
|
|
86
87
|
name: "claude-memory",
|
|
87
88
|
version: ClaudeMemory::VERSION
|
|
88
|
-
}
|
|
89
|
+
},
|
|
90
|
+
instructions: InstructionsBuilder.build(@store_or_manager)
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
end
|
|
@@ -11,21 +11,21 @@ module ClaudeMemory
|
|
|
11
11
|
[
|
|
12
12
|
{
|
|
13
13
|
name: "memory.recall",
|
|
14
|
-
description: "Search facts matching a query from both global and project memory databases.",
|
|
14
|
+
description: "Search facts matching a query from both global and project memory databases. Returns full facts with provenance (~800 tokens/result, ~300 with compact: true). For token-efficient browsing, use memory.recall_index first (~200 tokens/result), then memory.recall_details for selected facts.",
|
|
15
15
|
inputSchema: {
|
|
16
16
|
type: "object",
|
|
17
17
|
properties: {
|
|
18
18
|
query: {type: "string", description: "Search query for existing knowledge (e.g., 'authentication flow', 'error handling', 'database setup')"},
|
|
19
19
|
limit: {type: "integer", description: "Max results", default: 10},
|
|
20
20
|
scope: {type: "string", enum: ["all", "global", "project"], description: "Filter by scope: 'all' (default), 'global', or 'project'", default: "all"},
|
|
21
|
-
compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
|
|
21
|
+
compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false}
|
|
22
22
|
},
|
|
23
23
|
required: ["query"]
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
name: "memory.recall_index",
|
|
28
|
-
description: "Lightweight search returning fact previews, IDs, and token costs.
|
|
28
|
+
description: "Lightweight search returning fact previews, IDs, and token costs (~200 tokens/result). Step 1 of progressive disclosure: browse results here, then call memory.recall_details with selected fact IDs for full information (~500 tokens/fact). Saves ~60% tokens vs memory.recall when you only need a few facts.",
|
|
29
29
|
inputSchema: {
|
|
30
30
|
type: "object",
|
|
31
31
|
properties: {
|
|
@@ -38,7 +38,7 @@ module ClaudeMemory
|
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
name: "memory.recall_details",
|
|
41
|
-
description: "Fetch full details for specific fact IDs.
|
|
41
|
+
description: "Fetch full details for specific fact IDs (~500 tokens/fact). Step 2 of progressive disclosure: use after memory.recall_index to get provenance and metadata for selected facts only.",
|
|
42
42
|
inputSchema: {
|
|
43
43
|
type: "object",
|
|
44
44
|
properties: {
|
|
@@ -234,7 +234,7 @@ module ClaudeMemory
|
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
236
|
name: "memory.recall_semantic",
|
|
237
|
-
description: "Search facts using semantic similarity (finds conceptually related facts using vector embeddings)",
|
|
237
|
+
description: "Search facts using semantic similarity (finds conceptually related facts using vector embeddings). ~800 tokens/result, ~300 with compact: true.",
|
|
238
238
|
inputSchema: {
|
|
239
239
|
type: "object",
|
|
240
240
|
properties: {
|
|
@@ -242,7 +242,7 @@ module ClaudeMemory
|
|
|
242
242
|
mode: {type: "string", enum: ["vector", "text", "both"], default: "both", description: "Search mode: vector (embeddings), text (FTS), or both (hybrid)"},
|
|
243
243
|
limit: {type: "integer", default: 10, description: "Maximum results to return"},
|
|
244
244
|
scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"},
|
|
245
|
-
compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
|
|
245
|
+
compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false}
|
|
246
246
|
},
|
|
247
247
|
required: ["query"]
|
|
248
248
|
}
|
|
@@ -262,7 +262,7 @@ module ClaudeMemory
|
|
|
262
262
|
},
|
|
263
263
|
limit: {type: "integer", default: 10, description: "Maximum results to return"},
|
|
264
264
|
scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"},
|
|
265
|
-
compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
|
|
265
|
+
compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false}
|
|
266
266
|
},
|
|
267
267
|
required: ["concepts"]
|
|
268
268
|
}
|
|
@@ -287,6 +287,14 @@ module ClaudeMemory
|
|
|
287
287
|
type: "object",
|
|
288
288
|
properties: {}
|
|
289
289
|
}
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: "memory.list_projects",
|
|
293
|
+
description: "List all known memory databases with fact counts and status. Shows global database, current project, and other projects discovered from promoted facts. Helps discover available search scopes before querying.",
|
|
294
|
+
inputSchema: {
|
|
295
|
+
type: "object",
|
|
296
|
+
properties: {}
|
|
297
|
+
}
|
|
290
298
|
}
|
|
291
299
|
]
|
|
292
300
|
end
|
|
@@ -68,6 +68,8 @@ module ClaudeMemory
|
|
|
68
68
|
fact_graph(arguments)
|
|
69
69
|
when "memory.check_setup"
|
|
70
70
|
check_setup
|
|
71
|
+
when "memory.list_projects"
|
|
72
|
+
list_projects
|
|
71
73
|
else
|
|
72
74
|
{error: "Unknown tool: #{name}"}
|
|
73
75
|
end
|
|
@@ -507,8 +509,112 @@ module ClaudeMemory
|
|
|
507
509
|
}
|
|
508
510
|
end
|
|
509
511
|
|
|
512
|
+
def list_projects
|
|
513
|
+
result = {global: nil, current_project: nil, other_projects: []}
|
|
514
|
+
|
|
515
|
+
if @manager
|
|
516
|
+
result[:global] = list_global_database
|
|
517
|
+
result[:current_project] = list_current_project
|
|
518
|
+
result[:other_projects] = discover_other_projects
|
|
519
|
+
elsif @legacy_store
|
|
520
|
+
result[:global] = {
|
|
521
|
+
exists: true,
|
|
522
|
+
path: @legacy_store.db.opts[:database],
|
|
523
|
+
facts_active: @legacy_store.facts.where(status: "active").count,
|
|
524
|
+
entities: @legacy_store.entities.count
|
|
525
|
+
}
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
result[:project_count] = 1 + result[:other_projects].size
|
|
529
|
+
result
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def list_global_database
|
|
533
|
+
if @manager.global_exists?
|
|
534
|
+
@manager.ensure_global!
|
|
535
|
+
store = @manager.global_store
|
|
536
|
+
{
|
|
537
|
+
exists: true,
|
|
538
|
+
path: @manager.global_db_path,
|
|
539
|
+
facts_active: store.facts.where(status: "active").count,
|
|
540
|
+
facts_total: store.facts.count,
|
|
541
|
+
entities: store.entities.count
|
|
542
|
+
}
|
|
543
|
+
else
|
|
544
|
+
{exists: false, path: @manager.global_db_path}
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def list_current_project
|
|
549
|
+
if @manager.project_exists?
|
|
550
|
+
@manager.ensure_project!
|
|
551
|
+
store = @manager.project_store
|
|
552
|
+
{
|
|
553
|
+
exists: true,
|
|
554
|
+
path: @manager.project_path,
|
|
555
|
+
db_path: @manager.project_db_path,
|
|
556
|
+
facts_active: store.facts.where(status: "active").count,
|
|
557
|
+
facts_total: store.facts.count,
|
|
558
|
+
entities: store.entities.count
|
|
559
|
+
}
|
|
560
|
+
else
|
|
561
|
+
{exists: false, path: @manager.project_path, db_path: @manager.project_db_path}
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def discover_other_projects
|
|
566
|
+
return [] unless @manager.global_exists?
|
|
567
|
+
|
|
568
|
+
@manager.ensure_global!
|
|
569
|
+
global = @manager.global_store
|
|
570
|
+
|
|
571
|
+
# Find project paths from promoted facts
|
|
572
|
+
promoted_paths = global.facts
|
|
573
|
+
.where(Sequel.like(:created_from, "promoted:%"))
|
|
574
|
+
.select(:created_from)
|
|
575
|
+
.distinct
|
|
576
|
+
.all
|
|
577
|
+
.filter_map { |f|
|
|
578
|
+
match = f[:created_from]&.match(/\Apromoted:(.+):\d+\z/)
|
|
579
|
+
match[1] if match
|
|
580
|
+
}
|
|
581
|
+
.uniq
|
|
582
|
+
|
|
583
|
+
# Also check for project_path values on facts
|
|
584
|
+
fact_paths = global.facts
|
|
585
|
+
.exclude(project_path: nil)
|
|
586
|
+
.select(:project_path)
|
|
587
|
+
.distinct
|
|
588
|
+
.all
|
|
589
|
+
.map { |f| f[:project_path] }
|
|
590
|
+
|
|
591
|
+
all_paths = (promoted_paths + fact_paths).uniq
|
|
592
|
+
current = @manager.project_path
|
|
593
|
+
|
|
594
|
+
all_paths.filter_map { |path|
|
|
595
|
+
next if path == current
|
|
596
|
+
|
|
597
|
+
db_path = File.join(path, ".claude", "memory.sqlite3")
|
|
598
|
+
entry = {path: path, db_path: db_path, exists: File.exist?(db_path)}
|
|
599
|
+
|
|
600
|
+
if entry[:exists]
|
|
601
|
+
begin
|
|
602
|
+
temp_store = Store::SQLiteStore.new(db_path)
|
|
603
|
+
entry[:facts_active] = temp_store.facts.where(status: "active").count
|
|
604
|
+
entry[:facts_total] = temp_store.facts.count
|
|
605
|
+
entry[:entities] = temp_store.entities.count
|
|
606
|
+
temp_store.close
|
|
607
|
+
rescue => _e
|
|
608
|
+
entry[:error] = "Could not read database"
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
entry
|
|
613
|
+
}
|
|
614
|
+
end
|
|
615
|
+
|
|
510
616
|
def db_stats(store)
|
|
511
|
-
{
|
|
617
|
+
stats = {
|
|
512
618
|
exists: true,
|
|
513
619
|
facts_total: store.facts.count,
|
|
514
620
|
facts_active: store.facts.where(status: "active").count,
|
|
@@ -516,12 +622,18 @@ module ClaudeMemory
|
|
|
516
622
|
open_conflicts: store.conflicts.where(status: "open").count,
|
|
517
623
|
schema_version: store.schema_version
|
|
518
624
|
}
|
|
625
|
+
|
|
626
|
+
vec_index = store.vector_index
|
|
627
|
+
stats[:vec_available] = vec_index.available?
|
|
628
|
+
stats[:vec_indexed] = vec_index.coverage_stats[:vec_indexed] if vec_index.available?
|
|
629
|
+
|
|
630
|
+
stats
|
|
519
631
|
end
|
|
520
632
|
|
|
521
633
|
def detailed_stats(store)
|
|
522
634
|
active_facts = store.facts.where(status: "active").count
|
|
523
635
|
|
|
524
|
-
{
|
|
636
|
+
stats = {
|
|
525
637
|
exists: true,
|
|
526
638
|
facts: fact_stats(store, active_facts),
|
|
527
639
|
entities: entity_stats(store),
|
|
@@ -530,6 +642,10 @@ module ClaudeMemory
|
|
|
530
642
|
conflicts: conflict_stats(store),
|
|
531
643
|
schema_version: store.schema_version
|
|
532
644
|
}
|
|
645
|
+
|
|
646
|
+
stats[:vec] = vec_stats(store, active_facts)
|
|
647
|
+
|
|
648
|
+
stats
|
|
533
649
|
end
|
|
534
650
|
|
|
535
651
|
def fact_stats(store, active_facts)
|
|
@@ -594,6 +710,13 @@ module ClaudeMemory
|
|
|
594
710
|
}
|
|
595
711
|
end
|
|
596
712
|
|
|
713
|
+
def vec_stats(store, _active_facts)
|
|
714
|
+
vec_index = store.vector_index
|
|
715
|
+
result = {available: vec_index.available?}
|
|
716
|
+
result.merge!(vec_index.coverage_stats) if vec_index.available?
|
|
717
|
+
result
|
|
718
|
+
end
|
|
719
|
+
|
|
597
720
|
def conflict_stats(store)
|
|
598
721
|
open = store.conflicts.where(status: "open").count
|
|
599
722
|
resolved = store.conflicts.where(status: "resolved").count
|
|
@@ -14,19 +14,8 @@ module ClaudeMemory
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def generate_snapshot(since: nil)
|
|
17
|
-
facts = fetch_active_facts
|
|
18
|
-
conflicts = @store.open_conflicts
|
|
19
|
-
recent_supersessions = fetch_recent_supersessions(since)
|
|
20
|
-
|
|
21
|
-
sections = []
|
|
22
|
-
sections << generate_decisions_section(facts)
|
|
23
|
-
sections << generate_conventions_section(facts)
|
|
24
|
-
sections << generate_constraints_section(facts)
|
|
25
|
-
sections << generate_conflicts_section(conflicts) if conflicts.any?
|
|
26
|
-
sections << generate_supersessions_section(recent_supersessions) if recent_supersessions.any?
|
|
27
|
-
|
|
28
17
|
header = <<~HEADER
|
|
29
|
-
<!--
|
|
18
|
+
<!--
|
|
30
19
|
This file is auto-generated by claude-memory.
|
|
31
20
|
Do not edit manually - changes will be overwritten.
|
|
32
21
|
Generated: #{Time.now.utc.iso8601}
|
|
@@ -36,14 +25,15 @@ module ClaudeMemory
|
|
|
36
25
|
|
|
37
26
|
HEADER
|
|
38
27
|
|
|
39
|
-
header +
|
|
28
|
+
header + generate_body(since: since)
|
|
40
29
|
end
|
|
41
30
|
|
|
42
31
|
def publish!(mode: :shared, granularity: :repo, since: nil, rules_dir: nil)
|
|
43
|
-
content = generate_snapshot(since: since)
|
|
44
32
|
path = output_path(mode, rules_dir: rules_dir)
|
|
33
|
+
body = generate_body(since: since)
|
|
45
34
|
|
|
46
|
-
if should_write?(path,
|
|
35
|
+
if should_write?(path, body)
|
|
36
|
+
content = generate_snapshot(since: since)
|
|
47
37
|
@fs.write(path, content)
|
|
48
38
|
ensure_import_exists(mode, path, rules_dir: rules_dir)
|
|
49
39
|
{status: :updated, path: path}
|
|
@@ -163,22 +153,33 @@ module ClaudeMemory
|
|
|
163
153
|
lines.join("\n") + "\n"
|
|
164
154
|
end
|
|
165
155
|
|
|
166
|
-
def
|
|
167
|
-
|
|
156
|
+
def generate_body(since: nil)
|
|
157
|
+
facts = fetch_active_facts
|
|
158
|
+
conflicts = @store.open_conflicts
|
|
159
|
+
recent_supersessions = fetch_recent_supersessions(since)
|
|
160
|
+
|
|
161
|
+
sections = []
|
|
162
|
+
sections << generate_decisions_section(facts)
|
|
163
|
+
sections << generate_conventions_section(facts)
|
|
164
|
+
sections << generate_constraints_section(facts)
|
|
165
|
+
sections << generate_conflicts_section(conflicts) if conflicts.any?
|
|
166
|
+
sections << generate_supersessions_section(recent_supersessions) if recent_supersessions.any?
|
|
168
167
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
sections.compact.reject(&:empty?).join("\n")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def should_write?(path, new_body)
|
|
172
|
+
return true unless @fs.exist?(path)
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
existing_hash != new_hash
|
|
174
|
+
existing_body = extract_body(@fs.read(path))
|
|
175
|
+
existing_body != new_body
|
|
177
176
|
end
|
|
178
177
|
|
|
179
|
-
def
|
|
180
|
-
#
|
|
181
|
-
content
|
|
178
|
+
def extract_body(content)
|
|
179
|
+
# Strip the HTML comment header and "# Project Memory" heading
|
|
180
|
+
content
|
|
181
|
+
.sub(/\A<!--.*?-->\s*/m, "")
|
|
182
|
+
.sub(/\A# Project Memory\s*/m, "")
|
|
182
183
|
end
|
|
183
184
|
|
|
184
185
|
def ensure_import_exists(mode, path, rules_dir: nil)
|
|
@@ -44,20 +44,9 @@ module ClaudeMemory
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def query_store(source_label, &operation)
|
|
47
|
-
store =
|
|
48
|
-
return [] unless store
|
|
49
|
-
|
|
50
|
-
ensure_store!(source_label)
|
|
47
|
+
store = @manager.store_for_scope(source_label.to_s)
|
|
51
48
|
operation.call(store, source_label)
|
|
52
49
|
end
|
|
53
|
-
|
|
54
|
-
def ensure_store!(source_label)
|
|
55
|
-
if source_label == :project
|
|
56
|
-
@manager.ensure_project!
|
|
57
|
-
else
|
|
58
|
-
@manager.ensure_global!
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
50
|
end
|
|
62
51
|
end
|
|
63
52
|
end
|