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,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Index
|
|
5
|
+
class QueryOptions
|
|
6
|
+
SCOPE_ALL = :all
|
|
7
|
+
SCOPE_PROJECT = :project
|
|
8
|
+
SCOPE_GLOBAL = :global
|
|
9
|
+
|
|
10
|
+
DEFAULT_LIMIT = 20
|
|
11
|
+
DEFAULT_SCOPE = SCOPE_ALL
|
|
12
|
+
|
|
13
|
+
attr_reader :query_text, :limit, :scope, :source
|
|
14
|
+
|
|
15
|
+
def initialize(query_text:, limit: DEFAULT_LIMIT, scope: DEFAULT_SCOPE, source: nil)
|
|
16
|
+
@query_text = query_text
|
|
17
|
+
@limit = limit
|
|
18
|
+
@scope = scope.to_sym
|
|
19
|
+
@source = source&.to_sym
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def for_project
|
|
24
|
+
self.class.new(
|
|
25
|
+
query_text: query_text,
|
|
26
|
+
limit: limit,
|
|
27
|
+
scope: scope,
|
|
28
|
+
source: :project
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def for_global
|
|
33
|
+
self.class.new(
|
|
34
|
+
query_text: query_text,
|
|
35
|
+
limit: limit,
|
|
36
|
+
scope: scope,
|
|
37
|
+
source: :global
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def with_limit(new_limit)
|
|
42
|
+
self.class.new(
|
|
43
|
+
query_text: query_text,
|
|
44
|
+
limit: new_limit,
|
|
45
|
+
scope: scope,
|
|
46
|
+
source: source
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ==(other)
|
|
51
|
+
other.is_a?(QueryOptions) &&
|
|
52
|
+
other.query_text == query_text &&
|
|
53
|
+
other.limit == limit &&
|
|
54
|
+
other.scope == scope &&
|
|
55
|
+
other.source == source
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def eql?(other)
|
|
59
|
+
self == other
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def hash
|
|
63
|
+
[query_text, limit, scope, source].hash
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "digest/sha2"
|
|
5
|
+
|
|
6
|
+
module ClaudeMemory
|
|
7
|
+
module Infrastructure
|
|
8
|
+
# Real filesystem implementation
|
|
9
|
+
# Wraps File and FileUtils for dependency injection
|
|
10
|
+
class FileSystem
|
|
11
|
+
def exist?(path)
|
|
12
|
+
File.exist?(path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def read(path)
|
|
16
|
+
File.read(path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(path, content)
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
21
|
+
File.write(path, content)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def file_hash(path)
|
|
25
|
+
Digest::SHA256.file(path).hexdigest
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Infrastructure
|
|
7
|
+
# In-memory filesystem implementation for fast testing
|
|
8
|
+
# Does not touch the real filesystem
|
|
9
|
+
class InMemoryFileSystem
|
|
10
|
+
def initialize
|
|
11
|
+
@files = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def exist?(path)
|
|
15
|
+
@files.key?(path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(path)
|
|
19
|
+
@files.fetch(path) { raise Errno::ENOENT, path }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write(path, content)
|
|
23
|
+
@files[path] = content
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def file_hash(path)
|
|
27
|
+
content = read(path)
|
|
28
|
+
Digest::SHA256.hexdigest(content)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Ingest
|
|
5
|
+
class ContentSanitizer
|
|
6
|
+
SYSTEM_TAGS = ["claude-memory-context"].freeze
|
|
7
|
+
USER_TAGS = ["private", "no-memory", "secret"].freeze
|
|
8
|
+
MAX_TAG_COUNT = 100
|
|
9
|
+
|
|
10
|
+
def self.strip_tags(text)
|
|
11
|
+
tags = Pure.all_tags
|
|
12
|
+
validate_tag_count!(text, tags)
|
|
13
|
+
Pure.strip_tags(text, tags)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.validate_tag_count!(text, tags)
|
|
17
|
+
count = Pure.count_tags(text, tags)
|
|
18
|
+
raise Error, "Too many privacy tags (#{count}), possible ReDoS attack" if count > MAX_TAG_COUNT
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module Pure
|
|
22
|
+
def self.all_tags
|
|
23
|
+
@all_tags ||= begin
|
|
24
|
+
all_tag_names = ContentSanitizer::SYSTEM_TAGS + ContentSanitizer::USER_TAGS
|
|
25
|
+
all_tag_names.map { |name| PrivacyTag.new(name) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.strip_tags(text, tags)
|
|
30
|
+
tags.reduce(text) { |result, tag| tag.strip_from(result) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.count_tags(text, tags)
|
|
34
|
+
tags.sum do |tag|
|
|
35
|
+
opening_pattern = /<#{Regexp.escape(tag.name)}>/
|
|
36
|
+
text.scan(opening_pattern).size
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -17,6 +17,9 @@ module ClaudeMemory
|
|
|
17
17
|
|
|
18
18
|
return {status: :no_change, bytes_read: 0} if delta.nil?
|
|
19
19
|
|
|
20
|
+
# Strip privacy tags before storing
|
|
21
|
+
delta = ContentSanitizer.strip_tags(delta)
|
|
22
|
+
|
|
20
23
|
resolved_project = project_path || detect_project_path
|
|
21
24
|
|
|
22
25
|
text_hash = Digest::SHA256.hexdigest(delta)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Ingest
|
|
5
|
+
class PrivacyTag
|
|
6
|
+
attr_reader :name
|
|
7
|
+
|
|
8
|
+
def initialize(name)
|
|
9
|
+
@name = name.to_s.strip
|
|
10
|
+
validate!
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pattern
|
|
15
|
+
/<#{Regexp.escape(@name)}>.*?<\/#{Regexp.escape(@name)}>/m
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def strip_from(text)
|
|
19
|
+
# Strip repeatedly to handle nested tags
|
|
20
|
+
result = text
|
|
21
|
+
loop do
|
|
22
|
+
new_result = result.gsub(pattern, "")
|
|
23
|
+
break if new_result == result
|
|
24
|
+
result = new_result
|
|
25
|
+
end
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ==(other)
|
|
30
|
+
other.is_a?(PrivacyTag) && other.name == name
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def eql?(other)
|
|
34
|
+
self == other
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def hash
|
|
38
|
+
name.hash
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate!
|
|
44
|
+
raise Error, "Tag name cannot be empty" if @name.empty?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -31,6 +31,31 @@ module ClaudeMemory
|
|
|
31
31
|
required: ["query"]
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
name: "memory.recall_index",
|
|
36
|
+
description: "Layer 1: Search for facts and get lightweight index (IDs, previews, token counts). Use this first before fetching full details.",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
query: {type: "string", description: "Search query for fact discovery"},
|
|
41
|
+
limit: {type: "integer", description: "Maximum results to return", default: 20},
|
|
42
|
+
scope: {type: "string", enum: ["all", "global", "project"], description: "Scope: 'all' (both), 'global' (user-wide), 'project' (current only)", default: "all"}
|
|
43
|
+
},
|
|
44
|
+
required: ["query"]
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "memory.recall_details",
|
|
49
|
+
description: "Layer 2: Fetch full details for specific fact IDs from the index. Use after memory.recall_index to get complete information.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
fact_ids: {type: "array", items: {type: "integer"}, description: "Fact IDs from memory.recall_index"},
|
|
54
|
+
scope: {type: "string", enum: ["project", "global"], description: "Database to query", default: "project"}
|
|
55
|
+
},
|
|
56
|
+
required: ["fact_ids"]
|
|
57
|
+
}
|
|
58
|
+
},
|
|
34
59
|
{
|
|
35
60
|
name: "memory.explain",
|
|
36
61
|
description: "Get detailed explanation of a fact with provenance",
|
|
@@ -148,6 +173,36 @@ module ClaudeMemory
|
|
|
148
173
|
},
|
|
149
174
|
required: ["facts"]
|
|
150
175
|
}
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "memory.decisions",
|
|
179
|
+
description: "Quick access to architectural decisions, constraints, and rules",
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: {
|
|
183
|
+
limit: {type: "integer", default: 10, description: "Maximum results to return"}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "memory.conventions",
|
|
189
|
+
description: "Quick access to coding conventions and style preferences (global scope)",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
limit: {type: "integer", default: 20, description: "Maximum results to return"}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "memory.architecture",
|
|
199
|
+
description: "Quick access to framework choices and architectural patterns",
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: "object",
|
|
202
|
+
properties: {
|
|
203
|
+
limit: {type: "integer", default: 10, description: "Maximum results to return"}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
151
206
|
}
|
|
152
207
|
]
|
|
153
208
|
end
|
|
@@ -156,6 +211,10 @@ module ClaudeMemory
|
|
|
156
211
|
case name
|
|
157
212
|
when "memory.recall"
|
|
158
213
|
recall(arguments)
|
|
214
|
+
when "memory.recall_index"
|
|
215
|
+
recall_index(arguments)
|
|
216
|
+
when "memory.recall_details"
|
|
217
|
+
recall_details(arguments)
|
|
159
218
|
when "memory.explain"
|
|
160
219
|
explain(arguments)
|
|
161
220
|
when "memory.changes"
|
|
@@ -170,6 +229,12 @@ module ClaudeMemory
|
|
|
170
229
|
promote(arguments)
|
|
171
230
|
when "memory.store_extraction"
|
|
172
231
|
store_extraction(arguments)
|
|
232
|
+
when "memory.decisions"
|
|
233
|
+
decisions(arguments)
|
|
234
|
+
when "memory.conventions"
|
|
235
|
+
conventions(arguments)
|
|
236
|
+
when "memory.architecture"
|
|
237
|
+
architecture(arguments)
|
|
173
238
|
else
|
|
174
239
|
{error: "Unknown tool: #{name}"}
|
|
175
240
|
end
|
|
@@ -195,10 +260,80 @@ module ClaudeMemory
|
|
|
195
260
|
}
|
|
196
261
|
end
|
|
197
262
|
|
|
263
|
+
def recall_index(args)
|
|
264
|
+
scope = args["scope"] || "all"
|
|
265
|
+
results = @recall.query_index(args["query"], limit: args["limit"] || 20, scope: scope)
|
|
266
|
+
|
|
267
|
+
total_tokens = results.sum { |r| r[:token_estimate] }
|
|
268
|
+
|
|
269
|
+
{
|
|
270
|
+
query: args["query"],
|
|
271
|
+
scope: scope,
|
|
272
|
+
result_count: results.size,
|
|
273
|
+
total_estimated_tokens: total_tokens,
|
|
274
|
+
facts: results.map do |r|
|
|
275
|
+
{
|
|
276
|
+
id: r[:id],
|
|
277
|
+
subject: r[:subject],
|
|
278
|
+
predicate: r[:predicate],
|
|
279
|
+
object_preview: r[:object_preview],
|
|
280
|
+
status: r[:status],
|
|
281
|
+
scope: r[:scope],
|
|
282
|
+
confidence: r[:confidence],
|
|
283
|
+
tokens: r[:token_estimate],
|
|
284
|
+
source: r[:source]
|
|
285
|
+
}
|
|
286
|
+
end
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def recall_details(args)
|
|
291
|
+
fact_ids = args["fact_ids"]
|
|
292
|
+
scope = args["scope"] || "project"
|
|
293
|
+
|
|
294
|
+
# Batch fetch detailed explanations
|
|
295
|
+
explanations = fact_ids.map do |fact_id|
|
|
296
|
+
explanation = @recall.explain(fact_id, scope: scope)
|
|
297
|
+
next nil if explanation.is_a?(Core::NullExplanation)
|
|
298
|
+
|
|
299
|
+
{
|
|
300
|
+
fact: {
|
|
301
|
+
id: explanation[:fact][:id],
|
|
302
|
+
subject: explanation[:fact][:subject_name],
|
|
303
|
+
predicate: explanation[:fact][:predicate],
|
|
304
|
+
object: explanation[:fact][:object_literal],
|
|
305
|
+
status: explanation[:fact][:status],
|
|
306
|
+
confidence: explanation[:fact][:confidence],
|
|
307
|
+
scope: explanation[:fact][:scope],
|
|
308
|
+
valid_from: explanation[:fact][:valid_from],
|
|
309
|
+
valid_to: explanation[:fact][:valid_to]
|
|
310
|
+
},
|
|
311
|
+
receipts: explanation[:receipts].map { |r|
|
|
312
|
+
{
|
|
313
|
+
quote: r[:quote],
|
|
314
|
+
strength: r[:strength],
|
|
315
|
+
session_id: r[:session_id],
|
|
316
|
+
occurred_at: r[:occurred_at]
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
relationships: {
|
|
320
|
+
supersedes: explanation[:supersedes],
|
|
321
|
+
superseded_by: explanation[:superseded_by],
|
|
322
|
+
conflicts: explanation[:conflicts].map { |c| {id: c[:id], status: c[:status]} }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
end.compact
|
|
326
|
+
|
|
327
|
+
{
|
|
328
|
+
fact_count: explanations.size,
|
|
329
|
+
facts: explanations
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
|
|
198
333
|
def explain(args)
|
|
199
334
|
scope = args["scope"] || "project"
|
|
200
335
|
explanation = @recall.explain(args["fact_id"], scope: scope)
|
|
201
|
-
return {error: "Fact not found in #{scope} database"}
|
|
336
|
+
return {error: "Fact not found in #{scope} database"} if explanation.is_a?(Core::NullExplanation)
|
|
202
337
|
|
|
203
338
|
{
|
|
204
339
|
fact: {
|
|
@@ -394,6 +529,44 @@ module ClaudeMemory
|
|
|
394
529
|
end
|
|
395
530
|
end
|
|
396
531
|
|
|
532
|
+
def decisions(args)
|
|
533
|
+
return {error: "Decisions shortcut requires StoreManager"} unless @manager
|
|
534
|
+
|
|
535
|
+
results = Recall.recent_decisions(@manager, limit: args["limit"] || 10)
|
|
536
|
+
format_shortcut_results(results, "decisions")
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def conventions(args)
|
|
540
|
+
return {error: "Conventions shortcut requires StoreManager"} unless @manager
|
|
541
|
+
|
|
542
|
+
results = Recall.conventions(@manager, limit: args["limit"] || 20)
|
|
543
|
+
format_shortcut_results(results, "conventions")
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def architecture(args)
|
|
547
|
+
return {error: "Architecture shortcut requires StoreManager"} unless @manager
|
|
548
|
+
|
|
549
|
+
results = Recall.architecture_choices(@manager, limit: args["limit"] || 10)
|
|
550
|
+
format_shortcut_results(results, "architecture")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def format_shortcut_results(results, category)
|
|
554
|
+
{
|
|
555
|
+
category: category,
|
|
556
|
+
count: results.size,
|
|
557
|
+
facts: results.map do |r|
|
|
558
|
+
{
|
|
559
|
+
id: r[:fact][:id],
|
|
560
|
+
subject: r[:fact][:subject_name],
|
|
561
|
+
predicate: r[:fact][:predicate],
|
|
562
|
+
object: r[:fact][:object_literal],
|
|
563
|
+
scope: r[:fact][:scope],
|
|
564
|
+
source: r[:source]
|
|
565
|
+
}
|
|
566
|
+
end
|
|
567
|
+
}
|
|
568
|
+
end
|
|
569
|
+
|
|
397
570
|
def db_stats(store)
|
|
398
571
|
{
|
|
399
572
|
exists: true,
|
|
@@ -8,8 +8,9 @@ module ClaudeMemory
|
|
|
8
8
|
RULES_DIR = ".claude/rules"
|
|
9
9
|
GENERATED_FILE = "claude_memory.generated.md"
|
|
10
10
|
|
|
11
|
-
def initialize(store)
|
|
11
|
+
def initialize(store, file_system: Infrastructure::FileSystem.new)
|
|
12
12
|
@store = store
|
|
13
|
+
@fs = file_system
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def generate_snapshot(since: nil)
|
|
@@ -38,15 +39,13 @@ module ClaudeMemory
|
|
|
38
39
|
header + sections.compact.reject(&:empty?).join("\n")
|
|
39
40
|
end
|
|
40
41
|
|
|
41
|
-
def publish!(mode: :shared, granularity: :repo, since: nil)
|
|
42
|
+
def publish!(mode: :shared, granularity: :repo, since: nil, rules_dir: nil)
|
|
42
43
|
content = generate_snapshot(since: since)
|
|
43
|
-
path = output_path(mode)
|
|
44
|
-
|
|
45
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
44
|
+
path = output_path(mode, rules_dir: rules_dir)
|
|
46
45
|
|
|
47
46
|
if should_write?(path, content)
|
|
48
|
-
|
|
49
|
-
ensure_import_exists(mode, path)
|
|
47
|
+
@fs.write(path, content)
|
|
48
|
+
ensure_import_exists(mode, path, rules_dir: rules_dir)
|
|
50
49
|
{status: :updated, path: path}
|
|
51
50
|
else
|
|
52
51
|
{status: :unchanged, path: path}
|
|
@@ -55,17 +54,19 @@ module ClaudeMemory
|
|
|
55
54
|
|
|
56
55
|
private
|
|
57
56
|
|
|
58
|
-
def output_path(mode)
|
|
57
|
+
def output_path(mode, rules_dir: nil)
|
|
59
58
|
case mode
|
|
60
59
|
when :shared
|
|
61
|
-
|
|
60
|
+
dir = rules_dir || RULES_DIR
|
|
61
|
+
File.join(dir, GENERATED_FILE)
|
|
62
62
|
when :local
|
|
63
63
|
".claude_memory.local.md"
|
|
64
64
|
when :home
|
|
65
65
|
project_name = File.basename(Dir.pwd)
|
|
66
66
|
File.join(Dir.home, ".claude", "claude_memory", "#{project_name}.md")
|
|
67
67
|
else
|
|
68
|
-
|
|
68
|
+
dir = rules_dir || RULES_DIR
|
|
69
|
+
File.join(dir, GENERATED_FILE)
|
|
69
70
|
end
|
|
70
71
|
end
|
|
71
72
|
|
|
@@ -163,34 +164,42 @@ module ClaudeMemory
|
|
|
163
164
|
end
|
|
164
165
|
|
|
165
166
|
def should_write?(path, content)
|
|
166
|
-
return true unless
|
|
167
|
+
return true unless @fs.exist?(path)
|
|
167
168
|
|
|
168
|
-
existing_hash =
|
|
169
|
+
existing_hash = @fs.file_hash(path)
|
|
169
170
|
new_hash = Digest::SHA256.hexdigest(content)
|
|
170
171
|
existing_hash != new_hash
|
|
171
172
|
end
|
|
172
173
|
|
|
173
|
-
def ensure_import_exists(mode, path)
|
|
174
|
+
def ensure_import_exists(mode, path, rules_dir: nil)
|
|
174
175
|
return if mode == :local
|
|
175
176
|
|
|
176
|
-
|
|
177
|
+
# Determine CLAUDE.md location based on rules_dir
|
|
178
|
+
claude_md = if rules_dir
|
|
179
|
+
# If rules_dir is provided (e.g., /tmp/xyz/.claude/rules),
|
|
180
|
+
# CLAUDE.md should be in parent dir (e.g., /tmp/xyz/.claude/CLAUDE.md)
|
|
181
|
+
File.join(File.dirname(rules_dir), "CLAUDE.md")
|
|
182
|
+
else
|
|
183
|
+
".claude/CLAUDE.md"
|
|
184
|
+
end
|
|
185
|
+
|
|
177
186
|
import_line = case mode
|
|
178
187
|
when :shared
|
|
179
|
-
|
|
188
|
+
dir = rules_dir || RULES_DIR
|
|
189
|
+
"@#{dir}/#{GENERATED_FILE}"
|
|
180
190
|
when :home
|
|
181
191
|
"@~/#{path.sub(Dir.home + "/", "")}"
|
|
182
192
|
else
|
|
183
193
|
"@#{path}"
|
|
184
194
|
end
|
|
185
195
|
|
|
186
|
-
if
|
|
187
|
-
content =
|
|
196
|
+
if @fs.exist?(claude_md)
|
|
197
|
+
content = @fs.read(claude_md)
|
|
188
198
|
return if content.include?(import_line)
|
|
189
199
|
|
|
190
|
-
|
|
200
|
+
@fs.write(claude_md, content + "\n#{import_line}\n")
|
|
191
201
|
else
|
|
192
|
-
|
|
193
|
-
File.write(claude_md, "# Project Memory\n\n#{import_line}\n")
|
|
202
|
+
@fs.write(claude_md, "# Project Memory\n\n#{import_line}\n")
|
|
194
203
|
end
|
|
195
204
|
end
|
|
196
205
|
|