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,2374 @@
|
|
|
1
|
+
# ClaudeMemory Feature Adoption Plan (Expert-Revised)
|
|
2
|
+
## Based on claude-mem Analysis + Expert Review
|
|
3
|
+
|
|
4
|
+
## Executive Summary
|
|
5
|
+
|
|
6
|
+
This plan incrementally adopts proven patterns from claude-mem while addressing design concerns raised by 5 renowned software engineers. All critical issues have been resolved, resulting in a more maintainable, performant, and testable implementation.
|
|
7
|
+
|
|
8
|
+
**Timeline:** 4-5 weeks across 3 phases
|
|
9
|
+
**Approach:** TDD, backward compatible, high-impact features first, expert-validated design
|
|
10
|
+
**Risk Level:** Low
|
|
11
|
+
**Expert Consensus:** ✅ APPROVED with revisions implemented
|
|
12
|
+
|
|
13
|
+
### Expert Reviewers
|
|
14
|
+
- Sandi Metz (Object-Oriented Design & Ruby)
|
|
15
|
+
- Kent Beck (Test-Driven Development & Simple Design)
|
|
16
|
+
- Jeremy Evans (Sequel & Database Performance)
|
|
17
|
+
- Gary Bernhardt (Boundaries & Functional Architecture)
|
|
18
|
+
- Martin Fowler (Refactoring & Evolutionary Design)
|
|
19
|
+
|
|
20
|
+
### Features Already Complete ✅
|
|
21
|
+
- **Slim Orchestrator Pattern** - CLI decomposed into 16 command classes
|
|
22
|
+
- **Domain-Driven Design** - Rich domain models with business logic
|
|
23
|
+
- **Transaction Safety** - Multi-step operations wrapped in transactions
|
|
24
|
+
- **FileSystem Abstraction** - In-memory testing without disk I/O
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Phase 1: Privacy & Token Economics (Weeks 1-2)
|
|
29
|
+
### High-impact features with security and observability benefits
|
|
30
|
+
|
|
31
|
+
### 1.1 Privacy Tag System (Days 1-4)
|
|
32
|
+
|
|
33
|
+
**Priority:** HIGH - Security and user trust
|
|
34
|
+
|
|
35
|
+
**Goal:** Allow users to exclude sensitive content from storage using `<private>` tags
|
|
36
|
+
|
|
37
|
+
**Expert Consensus:** ✅ Approved with extracted Tag value object and pure logic separation
|
|
38
|
+
|
|
39
|
+
#### Implementation Steps
|
|
40
|
+
|
|
41
|
+
**Day 1: Create PrivacyTag Value Object**
|
|
42
|
+
|
|
43
|
+
**New file:** `lib/claude_memory/ingest/privacy_tag.rb`
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# frozen_string_literal: true
|
|
47
|
+
|
|
48
|
+
module ClaudeMemory
|
|
49
|
+
module Ingest
|
|
50
|
+
# Value object representing a privacy tag that can be stripped from content
|
|
51
|
+
# Immutable and focused on a single responsibility
|
|
52
|
+
class PrivacyTag
|
|
53
|
+
attr_reader :name
|
|
54
|
+
|
|
55
|
+
def initialize(name)
|
|
56
|
+
@name = name.to_s
|
|
57
|
+
validate!
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the regex pattern for matching this tag
|
|
62
|
+
# Handles multiline content with .*? (non-greedy)
|
|
63
|
+
def pattern
|
|
64
|
+
/<#{Regexp.escape(@name)}>.*?<\/#{Regexp.escape(@name)}>/m
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns new string with this tag's content removed
|
|
68
|
+
# Pure function - no side effects
|
|
69
|
+
def strip_from(text)
|
|
70
|
+
text.gsub(pattern, "")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ==(other)
|
|
74
|
+
other.is_a?(PrivacyTag) && other.name == name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
alias_method :eql?, :==
|
|
78
|
+
|
|
79
|
+
def hash
|
|
80
|
+
name.hash
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate!
|
|
86
|
+
raise ArgumentError, "Tag name cannot be empty" if @name.empty?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Tests:** `spec/claude_memory/ingest/privacy_tag_spec.rb`
|
|
94
|
+
```ruby
|
|
95
|
+
RSpec.describe ClaudeMemory::Ingest::PrivacyTag do
|
|
96
|
+
describe "#pattern" do
|
|
97
|
+
it "creates multiline regex pattern" do
|
|
98
|
+
tag = described_class.new("private")
|
|
99
|
+
expect(tag.pattern).to be_a(Regexp)
|
|
100
|
+
expect(tag.pattern.to_s).to include("private")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "escapes special regex characters" do
|
|
104
|
+
tag = described_class.new("tag-name")
|
|
105
|
+
expect { "test".match(tag.pattern) }.not_to raise_error
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe "#strip_from" do
|
|
110
|
+
it "removes tag and content" do
|
|
111
|
+
tag = described_class.new("private")
|
|
112
|
+
result = tag.strip_from("Public <private>Secret</private> Public")
|
|
113
|
+
expect(result).to eq("Public Public")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "handles multiline content" do
|
|
117
|
+
tag = described_class.new("private")
|
|
118
|
+
text = "Line 1\n<private>Line 2\nLine 3</private>\nLine 4"
|
|
119
|
+
result = tag.strip_from(text)
|
|
120
|
+
expect(result).to eq("Line 1\n\nLine 4")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "is idempotent" do
|
|
124
|
+
tag = described_class.new("private")
|
|
125
|
+
text = "Public <private>Secret</private> Public"
|
|
126
|
+
result1 = tag.strip_from(text)
|
|
127
|
+
result2 = tag.strip_from(result1)
|
|
128
|
+
expect(result1).to eq(result2)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe "#initialize" do
|
|
133
|
+
it "raises error for empty name" do
|
|
134
|
+
expect { described_class.new("") }.to raise_error(ArgumentError, /cannot be empty/)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "is frozen after initialization" do
|
|
138
|
+
tag = described_class.new("private")
|
|
139
|
+
expect(tag).to be_frozen
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
describe "equality" do
|
|
144
|
+
it "compares by name" do
|
|
145
|
+
tag1 = described_class.new("private")
|
|
146
|
+
tag2 = described_class.new("private")
|
|
147
|
+
expect(tag1).to eq(tag2)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "can be used as hash key" do
|
|
151
|
+
tag1 = described_class.new("private")
|
|
152
|
+
tag2 = described_class.new("private")
|
|
153
|
+
hash = {tag1 => "value"}
|
|
154
|
+
expect(hash[tag2]).to eq("value")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Commit:** "Add PrivacyTag value object with pattern matching"
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
**Day 2: Extract Pure Logic**
|
|
165
|
+
|
|
166
|
+
**New file:** `lib/claude_memory/ingest/content_sanitizer/pure.rb`
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# frozen_string_literal: true
|
|
170
|
+
|
|
171
|
+
module ClaudeMemory
|
|
172
|
+
module Ingest
|
|
173
|
+
class ContentSanitizer
|
|
174
|
+
# Pure functions with no side effects
|
|
175
|
+
# Functional core that can be tested without mocking
|
|
176
|
+
module Pure
|
|
177
|
+
# Strips all tags from text
|
|
178
|
+
# Pure function - returns new string, no exceptions
|
|
179
|
+
def self.strip_tags(text, tags)
|
|
180
|
+
tags.reduce(text) { |result, tag| tag.strip_from(result) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Counts occurrences of tag opening markers
|
|
184
|
+
# Returns integer, no exceptions
|
|
185
|
+
def self.count_tags(text, tags)
|
|
186
|
+
return 0 if text.nil? || text.empty?
|
|
187
|
+
|
|
188
|
+
pattern = /<(?:#{tags.map(&:name).join("|")})>/
|
|
189
|
+
text.scan(pattern).size
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Checks if count exceeds limit
|
|
193
|
+
# Pure predicate function
|
|
194
|
+
def self.exceeds_limit?(count, limit)
|
|
195
|
+
count > limit
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Tests:** `spec/claude_memory/ingest/content_sanitizer/pure_spec.rb`
|
|
204
|
+
```ruby
|
|
205
|
+
RSpec.describe ClaudeMemory::Ingest::ContentSanitizer::Pure do
|
|
206
|
+
let(:tags) do
|
|
207
|
+
["private", "secret"].map { |name| ClaudeMemory::Ingest::PrivacyTag.new(name) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe ".strip_tags" do
|
|
211
|
+
it "strips all tags from text" do
|
|
212
|
+
text = "A <private>X</private> B <secret>Y</secret> C"
|
|
213
|
+
result = described_class.strip_tags(text, tags)
|
|
214
|
+
expect(result).to eq("A B C")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it "handles empty text" do
|
|
218
|
+
result = described_class.strip_tags("", tags)
|
|
219
|
+
expect(result).to eq("")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "handles text with no tags" do
|
|
223
|
+
text = "No tags here"
|
|
224
|
+
result = described_class.strip_tags(text, tags)
|
|
225
|
+
expect(result).to eq("No tags here")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it "handles empty tag list" do
|
|
229
|
+
text = "Text <private>with</private> tags"
|
|
230
|
+
result = described_class.strip_tags(text, [])
|
|
231
|
+
expect(result).to eq(text)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "does not mutate input" do
|
|
235
|
+
text = "Text <private>with</private> tags"
|
|
236
|
+
original = text.dup
|
|
237
|
+
described_class.strip_tags(text, tags)
|
|
238
|
+
expect(text).to eq(original)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
describe ".count_tags" do
|
|
243
|
+
it "counts opening tags" do
|
|
244
|
+
text = "<private>a</private> <private>b</private>"
|
|
245
|
+
count = described_class.count_tags(text, tags)
|
|
246
|
+
expect(count).to eq(2)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "handles empty text" do
|
|
250
|
+
expect(described_class.count_tags("", tags)).to eq(0)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "handles nil text" do
|
|
254
|
+
expect(described_class.count_tags(nil, tags)).to eq(0)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "only counts opening tags" do
|
|
258
|
+
text = "<private>a</private>"
|
|
259
|
+
count = described_class.count_tags(text, tags)
|
|
260
|
+
expect(count).to eq(1)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "counts mixed tag types" do
|
|
264
|
+
text = "<private>a</private> <secret>b</secret>"
|
|
265
|
+
count = described_class.count_tags(text, tags)
|
|
266
|
+
expect(count).to eq(2)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
describe ".exceeds_limit?" do
|
|
271
|
+
it "returns true when count exceeds limit" do
|
|
272
|
+
expect(described_class.exceeds_limit?(101, 100)).to be true
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it "returns false when count equals limit" do
|
|
276
|
+
expect(described_class.exceeds_limit?(100, 100)).to be false
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "returns false when count below limit" do
|
|
280
|
+
expect(described_class.exceeds_limit?(99, 100)).to be false
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**Commit:** "Extract pure logic for content sanitization"
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
**Day 3: Create ContentSanitizer with Components**
|
|
291
|
+
|
|
292
|
+
**New file:** `lib/claude_memory/ingest/content_sanitizer.rb`
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
# frozen_string_literal: true
|
|
296
|
+
|
|
297
|
+
require_relative "privacy_tag"
|
|
298
|
+
require_relative "content_sanitizer/pure"
|
|
299
|
+
|
|
300
|
+
module ClaudeMemory
|
|
301
|
+
module Ingest
|
|
302
|
+
# Imperative shell that coordinates tag stripping with validation
|
|
303
|
+
# Uses pure functions from ContentSanitizer::Pure module
|
|
304
|
+
class ContentSanitizer
|
|
305
|
+
SYSTEM_TAGS = ["claude-memory-context"].map { |t| PrivacyTag.new(t) }.freeze
|
|
306
|
+
USER_TAGS = ["private", "no-memory", "secret"].map { |t| PrivacyTag.new(t) }.freeze
|
|
307
|
+
MAX_TAG_COUNT = 100 # ReDoS protection
|
|
308
|
+
|
|
309
|
+
# Public API - validates and strips tags
|
|
310
|
+
# Raises Error if too many tags detected
|
|
311
|
+
def self.strip_tags(text)
|
|
312
|
+
all_tags = self.all_tags
|
|
313
|
+
validate_tag_count!(text, all_tags)
|
|
314
|
+
|
|
315
|
+
Pure.strip_tags(text, all_tags)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Returns all tags (system + user)
|
|
319
|
+
def self.all_tags
|
|
320
|
+
SYSTEM_TAGS + USER_TAGS
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
# Validates tag count to prevent ReDoS attacks
|
|
326
|
+
# Raises Error if limit exceeded
|
|
327
|
+
def self.validate_tag_count!(text, tags)
|
|
328
|
+
count = Pure.count_tags(text, tags)
|
|
329
|
+
|
|
330
|
+
return unless Pure.exceeds_limit?(count, MAX_TAG_COUNT)
|
|
331
|
+
|
|
332
|
+
raise Error, "Too many privacy tags (#{count}), possible ReDoS attack"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Tests:** `spec/claude_memory/ingest/content_sanitizer_spec.rb`
|
|
340
|
+
```ruby
|
|
341
|
+
RSpec.describe ClaudeMemory::Ingest::ContentSanitizer do
|
|
342
|
+
describe ".strip_tags" do
|
|
343
|
+
# Core functionality
|
|
344
|
+
it "strips <private> tags and content" do
|
|
345
|
+
text = "Public <private>Secret</private> Public"
|
|
346
|
+
expect(described_class.strip_tags(text)).to eq("Public Public")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it "strips multiple tag types" do
|
|
350
|
+
text = "A <private>X</private> B <no-memory>Y</no-memory> C"
|
|
351
|
+
expect(described_class.strip_tags(text)).to eq("A B C")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it "strips claude-memory-context system tags" do
|
|
355
|
+
text = "Before <claude-memory-context>Context</claude-memory-context> After"
|
|
356
|
+
expect(described_class.strip_tags(text)).to eq("Before After")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Edge cases
|
|
360
|
+
it "handles empty string" do
|
|
361
|
+
expect(described_class.strip_tags("")).to eq("")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it "handles text with only tags" do
|
|
365
|
+
text = "<private>secret</private>"
|
|
366
|
+
expect(described_class.strip_tags(text)).to eq("")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it "handles adjacent tags" do
|
|
370
|
+
text = "<private>a</private><private>b</private>"
|
|
371
|
+
expect(described_class.strip_tags(text)).to eq("")
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
it "handles nested tags" do
|
|
375
|
+
text = "Public <private>Outer <private>Inner</private></private> End"
|
|
376
|
+
expect(described_class.strip_tags(text)).to eq("Public End")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
it "preserves multiline content structure" do
|
|
380
|
+
text = "Line 1\n<private>Line 2\nLine 3</private>\nLine 4"
|
|
381
|
+
result = described_class.strip_tags(text)
|
|
382
|
+
expect(result).to eq("Line 1\n\nLine 4")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Security edge cases
|
|
386
|
+
it "handles tags with special regex characters" do
|
|
387
|
+
text = "<private>$100 [special] (test)</private>"
|
|
388
|
+
expect(described_class.strip_tags(text)).to eq("")
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it "handles malformed tags gracefully" do
|
|
392
|
+
text = "Public <private>Secret Public"
|
|
393
|
+
expect(described_class.strip_tags(text)).to eq("Public <private>Secret Public")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
it "handles unclosed tags" do
|
|
397
|
+
text = "Public <private>Secret"
|
|
398
|
+
expect(described_class.strip_tags(text)).to eq("Public <private>Secret")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# ReDoS protection
|
|
402
|
+
it "raises error on excessive tags (ReDoS protection)" do
|
|
403
|
+
text = "<private>x</private>" * 101
|
|
404
|
+
expect { described_class.strip_tags(text) }.to raise_error(ClaudeMemory::Error, /Too many privacy tags/)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it "accepts reasonable tag counts" do
|
|
408
|
+
text = "<private>x</private>" * 50
|
|
409
|
+
expect { described_class.strip_tags(text) }.not_to raise_error
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Performance
|
|
413
|
+
it "handles very long content efficiently" do
|
|
414
|
+
long_text = "a" * 100_000
|
|
415
|
+
expect {
|
|
416
|
+
described_class.strip_tags(long_text)
|
|
417
|
+
}.to perform_under(100).ms
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
describe ".all_tags" do
|
|
422
|
+
it "returns array of PrivacyTag objects" do
|
|
423
|
+
tags = described_class.all_tags
|
|
424
|
+
expect(tags).to all(be_a(ClaudeMemory::Ingest::PrivacyTag))
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
it "includes system tags" do
|
|
428
|
+
tag_names = described_class.all_tags.map(&:name)
|
|
429
|
+
expect(tag_names).to include("claude-memory-context")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
it "includes user tags" do
|
|
433
|
+
tag_names = described_class.all_tags.map(&:name)
|
|
434
|
+
expect(tag_names).to include("private", "no-memory", "secret")
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Commit:** "Add ContentSanitizer with tag stripping and ReDoS protection"
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
**Day 4: Integrate into Ingester and Document**
|
|
445
|
+
|
|
446
|
+
**Modify:** `lib/claude_memory/ingest/ingester.rb` (after line 22)
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
def ingest(source:, session_id:, transcript_path:, project_path: nil)
|
|
450
|
+
current_offset = @store.get_delta_cursor(session_id, transcript_path) || 0
|
|
451
|
+
delta, new_offset = TranscriptReader.read_delta(transcript_path, current_offset)
|
|
452
|
+
|
|
453
|
+
# Strip privacy tags before processing
|
|
454
|
+
delta = ContentSanitizer.strip_tags(delta)
|
|
455
|
+
|
|
456
|
+
return {status: :empty, message: "No content after cursor #{current_offset}"} if delta.empty?
|
|
457
|
+
|
|
458
|
+
# ... rest of method unchanged
|
|
459
|
+
end
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**Tests:** Add to `spec/claude_memory/ingest/ingester_spec.rb`
|
|
463
|
+
```ruby
|
|
464
|
+
it "strips privacy tags from ingested content" do
|
|
465
|
+
File.write(transcript_path, "Public <private>Secret API key</private> Public")
|
|
466
|
+
|
|
467
|
+
ingester.ingest(
|
|
468
|
+
source: "test",
|
|
469
|
+
session_id: "sess-123",
|
|
470
|
+
transcript_path: transcript_path
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Verify stored content is sanitized
|
|
474
|
+
item = store.content_items.first
|
|
475
|
+
expect(item[:raw_text]).to eq("Public Public")
|
|
476
|
+
expect(item[:raw_text]).not_to include("Secret API key")
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
it "strips claude-memory-context tags" do
|
|
480
|
+
File.write(transcript_path, "New <claude-memory-context>Old context</claude-memory-context> Content")
|
|
481
|
+
|
|
482
|
+
ingester.ingest(
|
|
483
|
+
source: "test",
|
|
484
|
+
session_id: "sess-123",
|
|
485
|
+
transcript_path: transcript_path
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
item = store.content_items.first
|
|
489
|
+
expect(item[:raw_text]).to eq("New Content")
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
it "raises error on excessive tags" do
|
|
493
|
+
text = "<private>x</private>" * 101
|
|
494
|
+
File.write(transcript_path, text)
|
|
495
|
+
|
|
496
|
+
expect {
|
|
497
|
+
ingester.ingest(
|
|
498
|
+
source: "test",
|
|
499
|
+
session_id: "sess-123",
|
|
500
|
+
transcript_path: transcript_path
|
|
501
|
+
)
|
|
502
|
+
}.to raise_error(ClaudeMemory::Error, /Too many privacy tags/)
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
**Update Documentation:**
|
|
507
|
+
|
|
508
|
+
**Modify:** `README.md` - Add "Privacy Control" section after "Usage Examples"
|
|
509
|
+
|
|
510
|
+
```markdown
|
|
511
|
+
## Privacy Control
|
|
512
|
+
|
|
513
|
+
ClaudeMemory respects user privacy through content exclusion tags. Wrap sensitive information in `<private>` tags to prevent storage:
|
|
514
|
+
|
|
515
|
+
### Example
|
|
516
|
+
|
|
517
|
+
\`\`\`
|
|
518
|
+
API Configuration:
|
|
519
|
+
- Endpoint: https://api.example.com
|
|
520
|
+
- API Key: <private>sk-abc123def456789</private>
|
|
521
|
+
- Rate Limit: 1000/hour
|
|
522
|
+
\`\`\`
|
|
523
|
+
|
|
524
|
+
The API key will be stripped before storage, while other information is preserved.
|
|
525
|
+
|
|
526
|
+
### Supported Tags
|
|
527
|
+
|
|
528
|
+
- `<private>...</private>` - User-controlled privacy (recommended)
|
|
529
|
+
- `<no-memory>...</no-memory>` - Alternative privacy tag
|
|
530
|
+
- `<secret>...</secret>` - Alternative privacy tag
|
|
531
|
+
|
|
532
|
+
### System Tags
|
|
533
|
+
|
|
534
|
+
- `<claude-memory-context>...</claude-memory-context>` - Auto-stripped to prevent recursive storage of published memory
|
|
535
|
+
|
|
536
|
+
### Security Notes
|
|
537
|
+
|
|
538
|
+
- Tags are stripped at ingestion time (edge processing)
|
|
539
|
+
- Protected against ReDoS attacks (max 100 tags per ingestion)
|
|
540
|
+
- Content within tags is never stored or indexed
|
|
541
|
+
- Tag stripping is non-reversible by design
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
**Modify:** `CLAUDE.md` - Add to "Hook Integration" section
|
|
545
|
+
|
|
546
|
+
```markdown
|
|
547
|
+
### Privacy Tag Handling
|
|
548
|
+
|
|
549
|
+
ClaudeMemory automatically strips privacy tags during ingestion:
|
|
550
|
+
|
|
551
|
+
\`\`\`ruby
|
|
552
|
+
# User input:
|
|
553
|
+
"Database: postgresql, Password: <private>secret123</private>"
|
|
554
|
+
|
|
555
|
+
# Stored content:
|
|
556
|
+
"Database: postgresql, Password: "
|
|
557
|
+
\`\`\`
|
|
558
|
+
|
|
559
|
+
This happens at the hook layer before content reaches the database. Supported tags:
|
|
560
|
+
- `<private>` - User privacy control
|
|
561
|
+
- `<no-memory>` - Alternative syntax
|
|
562
|
+
- `<secret>` - Alternative syntax
|
|
563
|
+
- `<claude-memory-context>` - System tag (prevents recursive context injection)
|
|
564
|
+
|
|
565
|
+
ReDoS protection: Max 100 tags per ingestion.
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
**Commit:** "Integrate ContentSanitizer into Ingester and update documentation"
|
|
569
|
+
|
|
570
|
+
---
|
|
571
|
+
|
|
572
|
+
### 1.2 Progressive Disclosure Pattern (Days 5-9)
|
|
573
|
+
|
|
574
|
+
**Priority:** HIGH - Token efficiency and cost reduction
|
|
575
|
+
|
|
576
|
+
**Goal:** Enable 2-tier retrieval (lightweight index → detailed fetch) with N+1 query elimination
|
|
577
|
+
|
|
578
|
+
**Expert Consensus:** ✅ Approved with Query Object extraction and batch query optimization
|
|
579
|
+
|
|
580
|
+
#### Implementation Steps
|
|
581
|
+
|
|
582
|
+
**Day 5: Create QueryOptions Parameter Object**
|
|
583
|
+
|
|
584
|
+
**New file:** `lib/claude_memory/recall/query_options.rb`
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
# frozen_string_literal: true
|
|
588
|
+
|
|
589
|
+
module ClaudeMemory
|
|
590
|
+
class Recall
|
|
591
|
+
# Parameter object for query configuration
|
|
592
|
+
# Reduces parameter lists and enables convenient transformations
|
|
593
|
+
class QueryOptions
|
|
594
|
+
attr_reader :query_text, :limit, :scope, :source
|
|
595
|
+
|
|
596
|
+
def initialize(query_text:, limit: 20, scope: SCOPE_ALL, source: nil)
|
|
597
|
+
@query_text = query_text
|
|
598
|
+
@limit = limit
|
|
599
|
+
@scope = scope
|
|
600
|
+
@source = source
|
|
601
|
+
freeze
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Returns new QueryOptions for project database
|
|
605
|
+
def for_project
|
|
606
|
+
self.class.new(
|
|
607
|
+
query_text: query_text,
|
|
608
|
+
limit: limit,
|
|
609
|
+
scope: scope,
|
|
610
|
+
source: :project
|
|
611
|
+
)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Returns new QueryOptions for global database
|
|
615
|
+
def for_global
|
|
616
|
+
self.class.new(
|
|
617
|
+
query_text: query_text,
|
|
618
|
+
limit: limit,
|
|
619
|
+
scope: scope,
|
|
620
|
+
source: :global
|
|
621
|
+
)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def ==(other)
|
|
625
|
+
other.is_a?(QueryOptions) &&
|
|
626
|
+
other.query_text == query_text &&
|
|
627
|
+
other.limit == limit &&
|
|
628
|
+
other.scope == scope &&
|
|
629
|
+
other.source == source
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
alias_method :eql?, :==
|
|
633
|
+
|
|
634
|
+
def hash
|
|
635
|
+
[query_text, limit, scope, source].hash
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**Tests:** `spec/claude_memory/recall/query_options_spec.rb`
|
|
643
|
+
```ruby
|
|
644
|
+
RSpec.describe ClaudeMemory::Recall::QueryOptions do
|
|
645
|
+
describe "#initialize" do
|
|
646
|
+
it "sets query text" do
|
|
647
|
+
options = described_class.new(query_text: "database")
|
|
648
|
+
expect(options.query_text).to eq("database")
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
it "sets limit with default" do
|
|
652
|
+
options = described_class.new(query_text: "test")
|
|
653
|
+
expect(options.limit).to eq(20)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
it "allows custom limit" do
|
|
657
|
+
options = described_class.new(query_text: "test", limit: 50)
|
|
658
|
+
expect(options.limit).to eq(50)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
it "is frozen after initialization" do
|
|
662
|
+
options = described_class.new(query_text: "test")
|
|
663
|
+
expect(options).to be_frozen
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
describe "#for_project" do
|
|
668
|
+
it "returns new options with project source" do
|
|
669
|
+
options = described_class.new(query_text: "database", limit: 10)
|
|
670
|
+
project_options = options.for_project
|
|
671
|
+
|
|
672
|
+
expect(project_options.query_text).to eq("database")
|
|
673
|
+
expect(project_options.limit).to eq(10)
|
|
674
|
+
expect(project_options.source).to eq(:project)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
it "returns different object" do
|
|
678
|
+
options = described_class.new(query_text: "test")
|
|
679
|
+
project_options = options.for_project
|
|
680
|
+
expect(project_options).not_to equal(options)
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
describe "#for_global" do
|
|
685
|
+
it "returns new options with global source" do
|
|
686
|
+
options = described_class.new(query_text: "convention")
|
|
687
|
+
global_options = options.for_global
|
|
688
|
+
|
|
689
|
+
expect(global_options.source).to eq(:global)
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
describe "equality" do
|
|
694
|
+
it "compares by attributes" do
|
|
695
|
+
opts1 = described_class.new(query_text: "test", limit: 10)
|
|
696
|
+
opts2 = described_class.new(query_text: "test", limit: 10)
|
|
697
|
+
expect(opts1).to eq(opts2)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
it "can be used as hash key" do
|
|
701
|
+
opts1 = described_class.new(query_text: "test")
|
|
702
|
+
opts2 = described_class.new(query_text: "test")
|
|
703
|
+
hash = {opts1 => "value"}
|
|
704
|
+
expect(hash[opts2]).to eq("value")
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
**Commit:** "Add QueryOptions parameter object for query configuration"
|
|
711
|
+
|
|
712
|
+
---
|
|
713
|
+
|
|
714
|
+
**Day 6: Extract Pure Query Logic**
|
|
715
|
+
|
|
716
|
+
**New file:** `lib/claude_memory/recall/index_query_logic.rb`
|
|
717
|
+
|
|
718
|
+
```ruby
|
|
719
|
+
# frozen_string_literal: true
|
|
720
|
+
|
|
721
|
+
module ClaudeMemory
|
|
722
|
+
class Recall
|
|
723
|
+
# Pure logic for fact collection and result building
|
|
724
|
+
# No I/O, no side effects - testable without database
|
|
725
|
+
module IndexQueryLogic
|
|
726
|
+
# Collects unique fact IDs from provenance mapping
|
|
727
|
+
# Returns ordered array of fact IDs up to limit
|
|
728
|
+
def self.collect_fact_ids(provenance_by_content, content_ids, limit)
|
|
729
|
+
seen = Set.new
|
|
730
|
+
ordered = []
|
|
731
|
+
|
|
732
|
+
content_ids.each do |content_id|
|
|
733
|
+
records = provenance_by_content[content_id] || []
|
|
734
|
+
|
|
735
|
+
records.each do |prov|
|
|
736
|
+
fact_id = prov[:fact_id]
|
|
737
|
+
next if seen.include?(fact_id)
|
|
738
|
+
|
|
739
|
+
seen.add(fact_id)
|
|
740
|
+
ordered << fact_id
|
|
741
|
+
break if ordered.size >= limit
|
|
742
|
+
end
|
|
743
|
+
break if ordered.size >= limit
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
ordered
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Builds index result hash from fact record
|
|
750
|
+
# Pure transformation, no I/O
|
|
751
|
+
def self.build_index_result(fact, source)
|
|
752
|
+
{
|
|
753
|
+
id: fact[:id],
|
|
754
|
+
subject: fact[:subject_name],
|
|
755
|
+
predicate: fact[:predicate],
|
|
756
|
+
object_preview: truncate_object(fact[:object_literal]),
|
|
757
|
+
status: fact[:status],
|
|
758
|
+
scope: fact[:scope],
|
|
759
|
+
confidence: fact[:confidence],
|
|
760
|
+
token_estimate: Core::TokenEstimator.estimate_fact(fact),
|
|
761
|
+
source: source
|
|
762
|
+
}
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Truncates object literal for preview
|
|
766
|
+
# Pure function
|
|
767
|
+
def self.truncate_object(object_literal)
|
|
768
|
+
return nil if object_literal.nil?
|
|
769
|
+
object_literal.slice(0, 50)
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
**Tests:** `spec/claude_memory/recall/index_query_logic_spec.rb`
|
|
777
|
+
```ruby
|
|
778
|
+
RSpec.describe ClaudeMemory::Recall::IndexQueryLogic do
|
|
779
|
+
describe ".collect_fact_ids" do
|
|
780
|
+
it "collects unique fact IDs in order" do
|
|
781
|
+
provenance = {
|
|
782
|
+
1 => [{fact_id: 10}, {fact_id: 11}],
|
|
783
|
+
2 => [{fact_id: 11}, {fact_id: 12}]
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
result = described_class.collect_fact_ids(provenance, [1, 2], 10)
|
|
787
|
+
|
|
788
|
+
expect(result).to eq([10, 11, 12])
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
it "limits results" do
|
|
792
|
+
provenance = {
|
|
793
|
+
1 => [{fact_id: 10}, {fact_id: 11}],
|
|
794
|
+
2 => [{fact_id: 12}, {fact_id: 13}]
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
result = described_class.collect_fact_ids(provenance, [1, 2], 2)
|
|
798
|
+
|
|
799
|
+
expect(result).to eq([10, 11])
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
it "skips duplicate fact IDs" do
|
|
803
|
+
provenance = {
|
|
804
|
+
1 => [{fact_id: 10}],
|
|
805
|
+
2 => [{fact_id: 10}, {fact_id: 11}]
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
result = described_class.collect_fact_ids(provenance, [1, 2], 10)
|
|
809
|
+
|
|
810
|
+
expect(result).to eq([10, 11])
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
it "handles missing provenance" do
|
|
814
|
+
provenance = {1 => [{fact_id: 10}]}
|
|
815
|
+
|
|
816
|
+
result = described_class.collect_fact_ids(provenance, [1, 2, 3], 10)
|
|
817
|
+
|
|
818
|
+
expect(result).to eq([10])
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
it "handles empty provenance" do
|
|
822
|
+
result = described_class.collect_fact_ids({}, [1, 2], 10)
|
|
823
|
+
|
|
824
|
+
expect(result).to eq([])
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
describe ".build_index_result" do
|
|
829
|
+
let(:fact) do
|
|
830
|
+
{
|
|
831
|
+
id: 123,
|
|
832
|
+
subject_name: "project",
|
|
833
|
+
predicate: "uses_database",
|
|
834
|
+
object_literal: "PostgreSQL with extensive configuration details",
|
|
835
|
+
status: "active",
|
|
836
|
+
scope: "project",
|
|
837
|
+
confidence: 0.95
|
|
838
|
+
}
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
it "builds result hash" do
|
|
842
|
+
result = described_class.build_index_result(fact, :project)
|
|
843
|
+
|
|
844
|
+
expect(result[:id]).to eq(123)
|
|
845
|
+
expect(result[:subject]).to eq("project")
|
|
846
|
+
expect(result[:predicate]).to eq("uses_database")
|
|
847
|
+
expect(result[:status]).to eq("active")
|
|
848
|
+
expect(result[:scope]).to eq("project")
|
|
849
|
+
expect(result[:confidence]).to eq(0.95)
|
|
850
|
+
expect(result[:source]).to eq(:project)
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
it "truncates object preview" do
|
|
854
|
+
result = described_class.build_index_result(fact, :project)
|
|
855
|
+
|
|
856
|
+
expect(result[:object_preview].length).to eq(50)
|
|
857
|
+
expect(result[:object_preview]).not_to include("details")
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
it "includes token estimate" do
|
|
861
|
+
result = described_class.build_index_result(fact, :project)
|
|
862
|
+
|
|
863
|
+
expect(result[:token_estimate]).to be > 0
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
describe ".truncate_object" do
|
|
868
|
+
it "truncates long strings" do
|
|
869
|
+
long_string = "a" * 100
|
|
870
|
+
result = described_class.truncate_object(long_string)
|
|
871
|
+
expect(result.length).to eq(50)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
it "preserves short strings" do
|
|
875
|
+
short_string = "hello"
|
|
876
|
+
result = described_class.truncate_object(short_string)
|
|
877
|
+
expect(result).to eq("hello")
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
it "handles nil" do
|
|
881
|
+
result = described_class.truncate_object(nil)
|
|
882
|
+
expect(result).to be_nil
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
it "handles empty string" do
|
|
886
|
+
result = described_class.truncate_object("")
|
|
887
|
+
expect(result).to eq("")
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
**Commit:** "Extract pure logic for index query processing"
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
**Day 7: Create IndexQuery Object (N+1 Fixed)**
|
|
898
|
+
|
|
899
|
+
**New file:** `lib/claude_memory/recall/index_query.rb`
|
|
900
|
+
|
|
901
|
+
```ruby
|
|
902
|
+
# frozen_string_literal: true
|
|
903
|
+
|
|
904
|
+
require_relative "index_query_logic"
|
|
905
|
+
|
|
906
|
+
module ClaudeMemory
|
|
907
|
+
class Recall
|
|
908
|
+
# Query object for index search
|
|
909
|
+
# Coordinates I/O and applies pure logic from IndexQueryLogic
|
|
910
|
+
# Eliminates N+1 queries with batch fetching
|
|
911
|
+
class IndexQuery
|
|
912
|
+
def initialize(store, options)
|
|
913
|
+
@store = store
|
|
914
|
+
@options = options
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
def execute
|
|
918
|
+
content_ids = search_content
|
|
919
|
+
return [] if content_ids.empty?
|
|
920
|
+
|
|
921
|
+
provenance_by_content = fetch_all_provenance(content_ids)
|
|
922
|
+
fact_ids = IndexQueryLogic.collect_fact_ids(provenance_by_content, content_ids, @options.limit)
|
|
923
|
+
return [] if fact_ids.empty?
|
|
924
|
+
|
|
925
|
+
facts = fetch_facts(fact_ids)
|
|
926
|
+
facts.map { |fact| IndexQueryLogic.build_index_result(fact, @options.source) }
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
private
|
|
930
|
+
|
|
931
|
+
# Single FTS query
|
|
932
|
+
def search_content
|
|
933
|
+
fts = Index::LexicalFTS.new(@store)
|
|
934
|
+
fts.search(@options.query_text, limit: @options.limit * 3)
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# Single batch query for ALL provenance
|
|
938
|
+
# Eliminates N+1 by using WHERE IN clause
|
|
939
|
+
def fetch_all_provenance(content_ids)
|
|
940
|
+
@store.provenance
|
|
941
|
+
.select(:fact_id, :content_item_id)
|
|
942
|
+
.where(content_item_id: content_ids)
|
|
943
|
+
.all
|
|
944
|
+
.group_by { |p| p[:content_item_id] }
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# Single batch query for ALL facts
|
|
948
|
+
def fetch_facts(fact_ids)
|
|
949
|
+
@store.facts
|
|
950
|
+
.left_join(:entities, id: :subject_entity_id)
|
|
951
|
+
.select(*index_columns)
|
|
952
|
+
.where(Sequel[:facts][:id] => fact_ids)
|
|
953
|
+
.all
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def index_columns
|
|
957
|
+
[
|
|
958
|
+
Sequel[:facts][:id],
|
|
959
|
+
Sequel[:facts][:predicate],
|
|
960
|
+
Sequel[:facts][:object_literal],
|
|
961
|
+
Sequel[:facts][:status],
|
|
962
|
+
Sequel[:entities][:canonical_name].as(:subject_name),
|
|
963
|
+
Sequel[:facts][:scope],
|
|
964
|
+
Sequel[:facts][:confidence]
|
|
965
|
+
]
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
**Tests:** `spec/claude_memory/recall/index_query_spec.rb`
|
|
973
|
+
```ruby
|
|
974
|
+
RSpec.describe ClaudeMemory::Recall::IndexQuery do
|
|
975
|
+
let(:store) { create_test_store }
|
|
976
|
+
let(:options) { ClaudeMemory::Recall::QueryOptions.new(query_text: "database", limit: 10, source: :project) }
|
|
977
|
+
let(:query) { described_class.new(store, options) }
|
|
978
|
+
|
|
979
|
+
describe "#execute" do
|
|
980
|
+
it "returns empty array when no content found" do
|
|
981
|
+
results = query.execute
|
|
982
|
+
expect(results).to eq([])
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
it "returns index results for matching facts" do
|
|
986
|
+
fact_id = create_fact(store, "uses_database", "PostgreSQL")
|
|
987
|
+
index_fact_for_search(store, fact_id, "database")
|
|
988
|
+
|
|
989
|
+
results = query.execute
|
|
990
|
+
|
|
991
|
+
expect(results).not_to be_empty
|
|
992
|
+
result = results.first
|
|
993
|
+
expect(result[:id]).to eq(fact_id)
|
|
994
|
+
expect(result[:predicate]).to eq("uses_database")
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
it "limits results" do
|
|
998
|
+
10.times do |i|
|
|
999
|
+
fact_id = create_fact(store, "uses_database", "DB#{i}")
|
|
1000
|
+
index_fact_for_search(store, fact_id, "database")
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
options = ClaudeMemory::Recall::QueryOptions.new(query_text: "database", limit: 5, source: :project)
|
|
1004
|
+
query = described_class.new(store, options)
|
|
1005
|
+
results = query.execute
|
|
1006
|
+
|
|
1007
|
+
expect(results.size).to eq(5)
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
it "excludes duplicate facts from multiple content items" do
|
|
1011
|
+
fact_id = create_fact(store, "uses_database", "PostgreSQL")
|
|
1012
|
+
index_fact_for_search(store, fact_id, "database")
|
|
1013
|
+
index_fact_for_search(store, fact_id, "postgres")
|
|
1014
|
+
|
|
1015
|
+
results = query.execute
|
|
1016
|
+
|
|
1017
|
+
fact_ids = results.map { |r| r[:id] }
|
|
1018
|
+
expect(fact_ids.uniq.size).to eq(fact_ids.size)
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
it "includes token estimates" do
|
|
1022
|
+
fact_id = create_fact(store, "uses_framework", "React")
|
|
1023
|
+
index_fact_for_search(store, fact_id, "framework")
|
|
1024
|
+
|
|
1025
|
+
results = query.execute
|
|
1026
|
+
|
|
1027
|
+
expect(results.first[:token_estimate]).to be > 0
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
it "truncates object preview" do
|
|
1031
|
+
long_text = "PostgreSQL with extensive configuration details" * 10
|
|
1032
|
+
fact_id = create_fact(store, "uses_database", long_text)
|
|
1033
|
+
index_fact_for_search(store, fact_id, "database")
|
|
1034
|
+
|
|
1035
|
+
results = query.execute
|
|
1036
|
+
|
|
1037
|
+
expect(results.first[:object_preview].length).to be <= 50
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
it "uses only 3 queries (FTS + provenance + facts)" do
|
|
1041
|
+
fact_id = create_fact(store, "uses_database", "PostgreSQL")
|
|
1042
|
+
index_fact_for_search(store, fact_id, "database")
|
|
1043
|
+
|
|
1044
|
+
# Monitor query count
|
|
1045
|
+
query_count = 0
|
|
1046
|
+
allow(store).to receive(:provenance) do
|
|
1047
|
+
query_count += 1
|
|
1048
|
+
store.provenance
|
|
1049
|
+
end.and_call_original
|
|
1050
|
+
|
|
1051
|
+
query.execute
|
|
1052
|
+
|
|
1053
|
+
# Should be exactly 1 provenance query (batch)
|
|
1054
|
+
expect(query_count).to eq(1)
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
**Commit:** "Add IndexQuery object with N+1 query elimination"
|
|
1061
|
+
|
|
1062
|
+
---
|
|
1063
|
+
|
|
1064
|
+
**Day 8: Integrate query_index into Recall**
|
|
1065
|
+
|
|
1066
|
+
**Modify:** `lib/claude_memory/recall.rb` - Add method after line 28
|
|
1067
|
+
|
|
1068
|
+
```ruby
|
|
1069
|
+
require_relative "recall/query_options"
|
|
1070
|
+
require_relative "recall/index_query"
|
|
1071
|
+
|
|
1072
|
+
# Returns lightweight index format (no full content)
|
|
1073
|
+
# Uses Query Object pattern for clean separation of concerns
|
|
1074
|
+
def query_index(query_text, limit: 20, scope: SCOPE_ALL)
|
|
1075
|
+
if @legacy_mode
|
|
1076
|
+
query_index_legacy(query_text, limit: limit, scope: scope)
|
|
1077
|
+
else
|
|
1078
|
+
query_index_dual(query_text, limit: limit, scope: scope)
|
|
1079
|
+
end
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
private
|
|
1083
|
+
|
|
1084
|
+
def query_index_dual(query_text, limit:, scope:)
|
|
1085
|
+
options = QueryOptions.new(query_text: query_text, limit: limit, scope: scope)
|
|
1086
|
+
results = []
|
|
1087
|
+
|
|
1088
|
+
if should_query_project?(options.scope)
|
|
1089
|
+
results.concat(query_project_index(options))
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
if should_query_global?(options.scope)
|
|
1093
|
+
results.concat(query_global_index(options))
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
dedupe_and_sort(results, options.limit)
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def should_query_project?(scope)
|
|
1100
|
+
(scope == SCOPE_ALL || scope == SCOPE_PROJECT) && @manager.project_exists?
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def should_query_global?(scope)
|
|
1104
|
+
(scope == SCOPE_ALL || scope == SCOPE_GLOBAL) && @manager.global_exists?
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
def query_project_index(options)
|
|
1108
|
+
return [] unless @manager.project_store
|
|
1109
|
+
|
|
1110
|
+
@manager.ensure_project!
|
|
1111
|
+
project_options = options.for_project
|
|
1112
|
+
IndexQuery.new(@manager.project_store, project_options).execute
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
def query_global_index(options)
|
|
1116
|
+
return [] unless @manager.global_store
|
|
1117
|
+
|
|
1118
|
+
@manager.ensure_global!
|
|
1119
|
+
global_options = options.for_global
|
|
1120
|
+
IndexQuery.new(@manager.global_store, global_options).execute
|
|
1121
|
+
end
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
**Tests:** Add to `spec/claude_memory/recall_spec.rb`
|
|
1125
|
+
```ruby
|
|
1126
|
+
describe "#query_index" do
|
|
1127
|
+
let(:fact_id) { create_fact("uses_database", "PostgreSQL with extensive configuration details") }
|
|
1128
|
+
|
|
1129
|
+
before do
|
|
1130
|
+
index_fact_for_search(fact_id, "database")
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
it "returns lightweight index format" do
|
|
1134
|
+
results = recall.query_index("database", limit: 10, scope: :all)
|
|
1135
|
+
|
|
1136
|
+
expect(results).not_to be_empty
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
describe "result format" do
|
|
1140
|
+
let(:result) { recall.query_index("database", limit: 10, scope: :all).first }
|
|
1141
|
+
|
|
1142
|
+
it "includes fact ID" do
|
|
1143
|
+
expect(result[:id]).to eq(fact_id)
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
it "includes predicate" do
|
|
1147
|
+
expect(result[:predicate]).to eq("uses_database")
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
it "includes subject" do
|
|
1151
|
+
expect(result[:subject]).to be_present
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
it "includes truncated preview" do
|
|
1155
|
+
expect(result[:object_preview].length).to be <= 50
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
it "includes token estimate" do
|
|
1159
|
+
expect(result[:token_estimate]).to be > 0
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
it "excludes full provenance" do
|
|
1163
|
+
expect(result).not_to have_key(:receipts)
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
it "excludes temporal data" do
|
|
1167
|
+
expect(result).not_to have_key(:valid_from)
|
|
1168
|
+
expect(result).not_to have_key(:valid_to)
|
|
1169
|
+
end
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
it "limits results" do
|
|
1173
|
+
5.times { |i| create_and_index_fact("uses_database", "DB#{i}", "database") }
|
|
1174
|
+
|
|
1175
|
+
results = recall.query_index("database", limit: 3, scope: :all)
|
|
1176
|
+
|
|
1177
|
+
expect(results.size).to eq(3)
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
it "queries both databases with scope :all" do
|
|
1181
|
+
create_and_index_project_fact("uses_database", "PostgreSQL", "database")
|
|
1182
|
+
create_and_index_global_fact("convention", "Use PostgreSQL", "database")
|
|
1183
|
+
|
|
1184
|
+
results = recall.query_index("database", limit: 10, scope: :all)
|
|
1185
|
+
|
|
1186
|
+
sources = results.map { |r| r[:source] }
|
|
1187
|
+
expect(sources).to include(:project, :global)
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
it "queries only project database with scope :project" do
|
|
1191
|
+
create_and_index_project_fact("uses_database", "PostgreSQL", "database")
|
|
1192
|
+
create_and_index_global_fact("convention", "Use PostgreSQL", "database")
|
|
1193
|
+
|
|
1194
|
+
results = recall.query_index("database", limit: 10, scope: :project)
|
|
1195
|
+
|
|
1196
|
+
sources = results.map { |r| r[:source] }
|
|
1197
|
+
expect(sources).to all(eq(:project))
|
|
1198
|
+
end
|
|
1199
|
+
end
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
**Commit:** "Integrate query_index into Recall with Query Object pattern"
|
|
1203
|
+
|
|
1204
|
+
---
|
|
1205
|
+
|
|
1206
|
+
**Day 9: Add MCP Tools and Documentation**
|
|
1207
|
+
|
|
1208
|
+
**Modify:** `lib/claude_memory/mcp/tools.rb`
|
|
1209
|
+
|
|
1210
|
+
Add TokenEstimator requirement at top:
|
|
1211
|
+
```ruby
|
|
1212
|
+
require_relative "../core/token_estimator"
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
Add to `#definitions` method (around line 150):
|
|
1216
|
+
```ruby
|
|
1217
|
+
{
|
|
1218
|
+
name: "memory.recall_index",
|
|
1219
|
+
description: "Layer 1: Search for facts and get lightweight index (IDs, previews, token counts). Use this first before fetching full details.",
|
|
1220
|
+
inputSchema: {
|
|
1221
|
+
type: "object",
|
|
1222
|
+
properties: {
|
|
1223
|
+
query: {
|
|
1224
|
+
type: "string",
|
|
1225
|
+
description: "Search query for fact discovery"
|
|
1226
|
+
},
|
|
1227
|
+
limit: {
|
|
1228
|
+
type: "integer",
|
|
1229
|
+
description: "Maximum results to return",
|
|
1230
|
+
default: 20
|
|
1231
|
+
},
|
|
1232
|
+
scope: {
|
|
1233
|
+
type: "string",
|
|
1234
|
+
enum: ["all", "global", "project"],
|
|
1235
|
+
default: "all",
|
|
1236
|
+
description: "Scope: 'all' (both), 'global' (user-wide), 'project' (current only)"
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
required: ["query"]
|
|
1240
|
+
}
|
|
1241
|
+
},
|
|
1242
|
+
{
|
|
1243
|
+
name: "memory.recall_details",
|
|
1244
|
+
description: "Layer 2: Fetch full details for specific fact IDs from the index. Use after memory.recall_index to get complete information.",
|
|
1245
|
+
inputSchema: {
|
|
1246
|
+
type: "object",
|
|
1247
|
+
properties: {
|
|
1248
|
+
fact_ids: {
|
|
1249
|
+
type: "array",
|
|
1250
|
+
items: {type: "integer"},
|
|
1251
|
+
description: "Fact IDs from memory.recall_index"
|
|
1252
|
+
},
|
|
1253
|
+
scope: {
|
|
1254
|
+
type: "string",
|
|
1255
|
+
enum: ["project", "global"],
|
|
1256
|
+
default: "project",
|
|
1257
|
+
description: "Database to query"
|
|
1258
|
+
}
|
|
1259
|
+
},
|
|
1260
|
+
required: ["fact_ids"]
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1265
|
+
Add to `#call` method (around line 175):
|
|
1266
|
+
```ruby
|
|
1267
|
+
when "memory.recall_index"
|
|
1268
|
+
recall_index(arguments)
|
|
1269
|
+
when "memory.recall_details"
|
|
1270
|
+
recall_details(arguments)
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
Add private methods (around line 360):
|
|
1274
|
+
```ruby
|
|
1275
|
+
def recall_index(args)
|
|
1276
|
+
scope = args["scope"] || "all"
|
|
1277
|
+
results = @recall.query_index(args["query"], limit: args["limit"] || 20, scope: scope)
|
|
1278
|
+
|
|
1279
|
+
format_index_response(results, args["query"], scope)
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
def format_index_response(results, query, scope)
|
|
1283
|
+
{
|
|
1284
|
+
query: query,
|
|
1285
|
+
scope: scope,
|
|
1286
|
+
result_count: results.size,
|
|
1287
|
+
total_estimated_tokens: results.sum { |r| r[:token_estimate] },
|
|
1288
|
+
facts: results.map { |r| format_index_fact(r) }
|
|
1289
|
+
}
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
def format_index_fact(result)
|
|
1293
|
+
{
|
|
1294
|
+
id: result[:id],
|
|
1295
|
+
subject: result[:subject],
|
|
1296
|
+
predicate: result[:predicate],
|
|
1297
|
+
object_preview: result[:object_preview],
|
|
1298
|
+
status: result[:status],
|
|
1299
|
+
scope: result[:scope],
|
|
1300
|
+
confidence: result[:confidence],
|
|
1301
|
+
tokens: result[:token_estimate],
|
|
1302
|
+
source: result[:source]
|
|
1303
|
+
}
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
def recall_details(args)
|
|
1307
|
+
fact_ids = args["fact_ids"]
|
|
1308
|
+
scope = args["scope"] || "project"
|
|
1309
|
+
|
|
1310
|
+
# Batch fetch detailed explanations
|
|
1311
|
+
explanations = fact_ids.map do |fact_id|
|
|
1312
|
+
explanation = @recall.explain(fact_id, scope: scope)
|
|
1313
|
+
next nil if explanation.is_a?(Core::NullExplanation)
|
|
1314
|
+
|
|
1315
|
+
format_detailed_fact(explanation)
|
|
1316
|
+
end.compact
|
|
1317
|
+
|
|
1318
|
+
{
|
|
1319
|
+
fact_count: explanations.size,
|
|
1320
|
+
facts: explanations
|
|
1321
|
+
}
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
def format_detailed_fact(explanation)
|
|
1325
|
+
{
|
|
1326
|
+
fact: {
|
|
1327
|
+
id: explanation[:fact][:id],
|
|
1328
|
+
subject: explanation[:fact][:subject_name],
|
|
1329
|
+
predicate: explanation[:fact][:predicate],
|
|
1330
|
+
object: explanation[:fact][:object_literal],
|
|
1331
|
+
status: explanation[:fact][:status],
|
|
1332
|
+
confidence: explanation[:fact][:confidence],
|
|
1333
|
+
scope: explanation[:fact][:scope],
|
|
1334
|
+
valid_from: explanation[:fact][:valid_from],
|
|
1335
|
+
valid_to: explanation[:fact][:valid_to]
|
|
1336
|
+
},
|
|
1337
|
+
receipts: explanation[:receipts].map { |r|
|
|
1338
|
+
{
|
|
1339
|
+
quote: r[:quote],
|
|
1340
|
+
strength: r[:strength],
|
|
1341
|
+
session_id: r[:session_id],
|
|
1342
|
+
occurred_at: r[:occurred_at]
|
|
1343
|
+
}
|
|
1344
|
+
},
|
|
1345
|
+
relationships: {
|
|
1346
|
+
supersedes: explanation[:supersedes],
|
|
1347
|
+
superseded_by: explanation[:superseded_by],
|
|
1348
|
+
conflicts: explanation[:conflicts].map { |c| {id: c[:id], status: c[:status]} }
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
end
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
**Tests:** Add to `spec/claude_memory/mcp/tools_spec.rb`
|
|
1355
|
+
```ruby
|
|
1356
|
+
describe "memory.recall_index" do
|
|
1357
|
+
let(:fact_id) { create_fact("uses_database", "PostgreSQL") }
|
|
1358
|
+
|
|
1359
|
+
before do
|
|
1360
|
+
index_fact_for_search(fact_id, "database")
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1363
|
+
it "returns lightweight index" do
|
|
1364
|
+
result = tools.call("memory.recall_index", {"query" => "database", "limit" => 10})
|
|
1365
|
+
|
|
1366
|
+
expect(result[:result_count]).to be > 0
|
|
1367
|
+
expect(result[:total_estimated_tokens]).to be > 0
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
it "includes fact metadata" do
|
|
1371
|
+
result = tools.call("memory.recall_index", {"query" => "database", "limit" => 10})
|
|
1372
|
+
|
|
1373
|
+
fact = result[:facts].first
|
|
1374
|
+
expect(fact[:id]).to eq(fact_id)
|
|
1375
|
+
expect(fact[:predicate]).to eq("uses_database")
|
|
1376
|
+
expect(fact[:object_preview].length).to be <= 50
|
|
1377
|
+
expect(fact[:tokens]).to be > 0
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
it "respects limit" do
|
|
1381
|
+
5.times { |i| create_and_index_fact("uses_database", "DB#{i}", "database") }
|
|
1382
|
+
|
|
1383
|
+
result = tools.call("memory.recall_index", {"query" => "database", "limit" => 3})
|
|
1384
|
+
|
|
1385
|
+
expect(result[:result_count]).to eq(3)
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1388
|
+
it "calculates total token estimate" do
|
|
1389
|
+
3.times { |i| create_and_index_fact("uses_database", "DB#{i}", "database") }
|
|
1390
|
+
|
|
1391
|
+
result = tools.call("memory.recall_index", {"query" => "database"})
|
|
1392
|
+
|
|
1393
|
+
expect(result[:total_estimated_tokens]).to be > 0
|
|
1394
|
+
end
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
describe "memory.recall_details" do
|
|
1398
|
+
let(:fact_id) { create_fact("uses_framework", "React with hooks") }
|
|
1399
|
+
|
|
1400
|
+
it "fetches full details for fact IDs" do
|
|
1401
|
+
result = tools.call("memory.recall_details", {
|
|
1402
|
+
"fact_ids" => [fact_id],
|
|
1403
|
+
"scope" => "project"
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1406
|
+
expect(result[:fact_count]).to eq(1)
|
|
1407
|
+
|
|
1408
|
+
fact = result[:facts].first
|
|
1409
|
+
expect(fact[:fact][:id]).to eq(fact_id)
|
|
1410
|
+
expect(fact[:fact][:object]).to eq("React with hooks") # Full content
|
|
1411
|
+
expect(fact[:receipts]).to be_an(Array)
|
|
1412
|
+
expect(fact[:relationships]).to be_present
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1415
|
+
it "handles multiple fact IDs" do
|
|
1416
|
+
id1 = create_fact("uses_database", "PostgreSQL")
|
|
1417
|
+
id2 = create_fact("uses_framework", "Rails")
|
|
1418
|
+
|
|
1419
|
+
result = tools.call("memory.recall_details", {
|
|
1420
|
+
"fact_ids" => [id1, id2]
|
|
1421
|
+
})
|
|
1422
|
+
|
|
1423
|
+
expect(result[:fact_count]).to eq(2)
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
it "excludes non-existent facts" do
|
|
1427
|
+
result = tools.call("memory.recall_details", {
|
|
1428
|
+
"fact_ids" => [999, fact_id, 1000]
|
|
1429
|
+
})
|
|
1430
|
+
|
|
1431
|
+
expect(result[:fact_count]).to eq(1)
|
|
1432
|
+
expect(result[:facts].first[:fact][:id]).to eq(fact_id)
|
|
1433
|
+
end
|
|
1434
|
+
end
|
|
1435
|
+
```
|
|
1436
|
+
|
|
1437
|
+
**Update Documentation:**
|
|
1438
|
+
|
|
1439
|
+
**Modify:** `README.md` - Update "MCP Tools" section
|
|
1440
|
+
|
|
1441
|
+
```markdown
|
|
1442
|
+
### MCP Tools
|
|
1443
|
+
|
|
1444
|
+
When configured, these tools are available in Claude Code:
|
|
1445
|
+
|
|
1446
|
+
#### Progressive Disclosure Tools (Recommended)
|
|
1447
|
+
|
|
1448
|
+
- `memory.recall_index` - **Layer 1**: Search for facts, returns lightweight index (IDs, previews, token estimates)
|
|
1449
|
+
- `memory.recall_details` - **Layer 2**: Fetch full details for specific fact IDs
|
|
1450
|
+
|
|
1451
|
+
**Workflow:**
|
|
1452
|
+
\`\`\`
|
|
1453
|
+
1. memory.recall_index("database")
|
|
1454
|
+
→ Returns 10 facts with previews (~50 tokens)
|
|
1455
|
+
→ Shows total estimated tokens for full retrieval
|
|
1456
|
+
|
|
1457
|
+
2. User/Claude selects relevant IDs (e.g., [123, 456])
|
|
1458
|
+
|
|
1459
|
+
3. memory.recall_details([123, 456])
|
|
1460
|
+
→ Returns complete information (~500 tokens)
|
|
1461
|
+
\`\`\`
|
|
1462
|
+
|
|
1463
|
+
**Benefits:** 10x token reduction for initial search, user control over detail retrieval
|
|
1464
|
+
|
|
1465
|
+
**Performance:** 3 queries total (FTS + batch provenance + batch facts), eliminates N+1 problem
|
|
1466
|
+
|
|
1467
|
+
#### Full-Content Tools (Legacy)
|
|
1468
|
+
|
|
1469
|
+
- `memory.recall` - Search for relevant facts (returns full details immediately)
|
|
1470
|
+
- `memory.explain` - Get detailed fact provenance
|
|
1471
|
+
- `memory.promote` - Promote a project fact to global memory
|
|
1472
|
+
- `memory.store_extraction` - Store extracted facts from a conversation
|
|
1473
|
+
- `memory.changes` - Recent fact updates
|
|
1474
|
+
- `memory.conflicts` - Open contradictions
|
|
1475
|
+
- `memory.sweep_now` - Run maintenance
|
|
1476
|
+
- `memory.status` - System health check
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
**Modify:** `CLAUDE.md` - Update "MCP Integration" section
|
|
1480
|
+
|
|
1481
|
+
```markdown
|
|
1482
|
+
## MCP Integration
|
|
1483
|
+
|
|
1484
|
+
### Progressive Disclosure Workflow
|
|
1485
|
+
|
|
1486
|
+
ClaudeMemory uses a 2-layer retrieval pattern for token efficiency:
|
|
1487
|
+
|
|
1488
|
+
**Layer 1 - Discovery (`memory.recall_index`)**
|
|
1489
|
+
Returns lightweight index with:
|
|
1490
|
+
- Fact IDs and previews (50 char max)
|
|
1491
|
+
- Token estimates per fact
|
|
1492
|
+
- Scope and confidence
|
|
1493
|
+
- Total estimated cost for full retrieval
|
|
1494
|
+
|
|
1495
|
+
Performance: 3 queries (FTS + batch provenance + batch facts)
|
|
1496
|
+
|
|
1497
|
+
**Layer 2 - Detail (`memory.recall_details`)**
|
|
1498
|
+
Returns complete information for selected IDs:
|
|
1499
|
+
- Full fact content
|
|
1500
|
+
- Complete provenance with quotes
|
|
1501
|
+
- Relationship graph (supersession, conflicts)
|
|
1502
|
+
- Temporal validity
|
|
1503
|
+
|
|
1504
|
+
Example usage in Claude Code:
|
|
1505
|
+
|
|
1506
|
+
\`\`\`
|
|
1507
|
+
Claude: Let me search your memory for database configuration
|
|
1508
|
+
Tool: memory.recall_index(query="database", limit=10)
|
|
1509
|
+
Result: Found 5 facts (~150 tokens if retrieved)
|
|
1510
|
+
|
|
1511
|
+
Claude: I'll fetch details for the 2 most relevant facts
|
|
1512
|
+
Tool: memory.recall_details(fact_ids=[123, 124])
|
|
1513
|
+
Result: Full details for PostgreSQL configuration
|
|
1514
|
+
\`\`\`
|
|
1515
|
+
|
|
1516
|
+
This reduces initial context by ~10x compared to fetching all details immediately.
|
|
1517
|
+
|
|
1518
|
+
### Architecture
|
|
1519
|
+
|
|
1520
|
+
Progressive disclosure uses:
|
|
1521
|
+
- **Query Object pattern** for clean separation of concerns
|
|
1522
|
+
- **Parameter Object pattern** to reduce parameter lists
|
|
1523
|
+
- **Batch queries** to eliminate N+1 problems (3 queries regardless of result size)
|
|
1524
|
+
- **Pure functions** for testable business logic
|
|
1525
|
+
```
|
|
1526
|
+
|
|
1527
|
+
**Commit:** "Add progressive disclosure MCP tools and update documentation"
|
|
1528
|
+
|
|
1529
|
+
---
|
|
1530
|
+
|
|
1531
|
+
## Phase 2: Semantic Enhancements (Week 3)
|
|
1532
|
+
### Improved query patterns and shortcuts
|
|
1533
|
+
|
|
1534
|
+
### 2.1 Semantic Shortcut Methods (Days 10-12)
|
|
1535
|
+
|
|
1536
|
+
**Priority:** MEDIUM - Developer convenience
|
|
1537
|
+
|
|
1538
|
+
**Goal:** Pre-configured queries for common use cases without duplication
|
|
1539
|
+
|
|
1540
|
+
**Expert Consensus:** ✅ Approved with Shortcut Query Builder extraction
|
|
1541
|
+
|
|
1542
|
+
#### Implementation Steps
|
|
1543
|
+
|
|
1544
|
+
**Day 10: Create Shortcuts Query Builder**
|
|
1545
|
+
|
|
1546
|
+
**New file:** `lib/claude_memory/recall/shortcuts.rb`
|
|
1547
|
+
|
|
1548
|
+
```ruby
|
|
1549
|
+
# frozen_string_literal: true
|
|
1550
|
+
|
|
1551
|
+
module ClaudeMemory
|
|
1552
|
+
class Recall
|
|
1553
|
+
# Query builder for common shortcut queries
|
|
1554
|
+
# Eliminates duplication and centralizes query configuration
|
|
1555
|
+
class Shortcuts
|
|
1556
|
+
QUERIES = {
|
|
1557
|
+
decisions: {
|
|
1558
|
+
query: "decision constraint rule requirement",
|
|
1559
|
+
scope: :all,
|
|
1560
|
+
limit: 10
|
|
1561
|
+
},
|
|
1562
|
+
architecture: {
|
|
1563
|
+
query: "uses framework implements architecture pattern",
|
|
1564
|
+
scope: :all,
|
|
1565
|
+
limit: 10
|
|
1566
|
+
},
|
|
1567
|
+
conventions: {
|
|
1568
|
+
query: "convention style format pattern prefer",
|
|
1569
|
+
scope: :global,
|
|
1570
|
+
limit: 20
|
|
1571
|
+
},
|
|
1572
|
+
project_config: {
|
|
1573
|
+
query: "uses requires depends_on configuration",
|
|
1574
|
+
scope: :project,
|
|
1575
|
+
limit: 10
|
|
1576
|
+
}
|
|
1577
|
+
}.freeze
|
|
1578
|
+
|
|
1579
|
+
def self.for(shortcut_name, manager, **overrides)
|
|
1580
|
+
config = QUERIES.fetch(shortcut_name) do
|
|
1581
|
+
raise ArgumentError, "Unknown shortcut: #{shortcut_name}"
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
options = config.merge(overrides)
|
|
1585
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
1586
|
+
recall.query(options[:query], limit: options[:limit], scope: options[:scope])
|
|
1587
|
+
end
|
|
1588
|
+
|
|
1589
|
+
def self.available
|
|
1590
|
+
QUERIES.keys
|
|
1591
|
+
end
|
|
1592
|
+
|
|
1593
|
+
def self.config_for(shortcut_name)
|
|
1594
|
+
QUERIES[shortcut_name]
|
|
1595
|
+
end
|
|
1596
|
+
end
|
|
1597
|
+
end
|
|
1598
|
+
end
|
|
1599
|
+
```
|
|
1600
|
+
|
|
1601
|
+
**Tests:** `spec/claude_memory/recall/shortcuts_spec.rb`
|
|
1602
|
+
```ruby
|
|
1603
|
+
RSpec.describe ClaudeMemory::Recall::Shortcuts do
|
|
1604
|
+
let(:manager) { create_test_store_manager }
|
|
1605
|
+
|
|
1606
|
+
describe ".for" do
|
|
1607
|
+
it "executes decision shortcut" do
|
|
1608
|
+
create_fact("decision", "Use PostgreSQL for primary database")
|
|
1609
|
+
|
|
1610
|
+
results = described_class.for(:decisions, manager)
|
|
1611
|
+
|
|
1612
|
+
expect(results).not_to be_empty
|
|
1613
|
+
expect(results.first[:fact][:predicate]).to eq("decision")
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
it "executes architecture shortcut" do
|
|
1617
|
+
create_fact("uses_framework", "Rails")
|
|
1618
|
+
|
|
1619
|
+
results = described_class.for(:architecture, manager)
|
|
1620
|
+
|
|
1621
|
+
expect(results).not_to be_empty
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
it "executes conventions shortcut with global scope" do
|
|
1625
|
+
create_global_fact("convention", "Use 4-space indentation")
|
|
1626
|
+
create_project_fact("convention", "Project uses tabs")
|
|
1627
|
+
|
|
1628
|
+
results = described_class.for(:conventions, manager)
|
|
1629
|
+
|
|
1630
|
+
# Should only return global conventions
|
|
1631
|
+
expect(results.size).to eq(1)
|
|
1632
|
+
expect(results.first[:fact][:scope]).to eq("global")
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
it "allows limit override" do
|
|
1636
|
+
5.times { |i| create_fact("decision", "Decision #{i}") }
|
|
1637
|
+
|
|
1638
|
+
results = described_class.for(:decisions, manager, limit: 3)
|
|
1639
|
+
|
|
1640
|
+
expect(results.size).to be <= 3
|
|
1641
|
+
end
|
|
1642
|
+
|
|
1643
|
+
it "allows scope override" do
|
|
1644
|
+
create_project_fact("convention", "Project convention")
|
|
1645
|
+
create_global_fact("convention", "Global convention")
|
|
1646
|
+
|
|
1647
|
+
results = described_class.for(:conventions, manager, scope: :all)
|
|
1648
|
+
|
|
1649
|
+
expect(results.size).to eq(2)
|
|
1650
|
+
end
|
|
1651
|
+
|
|
1652
|
+
it "raises error for unknown shortcut" do
|
|
1653
|
+
expect {
|
|
1654
|
+
described_class.for(:unknown, manager)
|
|
1655
|
+
}.to raise_error(ArgumentError, /Unknown shortcut/)
|
|
1656
|
+
end
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
describe ".available" do
|
|
1660
|
+
it "returns array of shortcut names" do
|
|
1661
|
+
shortcuts = described_class.available
|
|
1662
|
+
expect(shortcuts).to include(:decisions, :architecture, :conventions, :project_config)
|
|
1663
|
+
end
|
|
1664
|
+
end
|
|
1665
|
+
|
|
1666
|
+
describe ".config_for" do
|
|
1667
|
+
it "returns configuration for shortcut" do
|
|
1668
|
+
config = described_class.config_for(:decisions)
|
|
1669
|
+
expect(config[:query]).to include("decision")
|
|
1670
|
+
expect(config[:scope]).to eq(:all)
|
|
1671
|
+
expect(config[:limit]).to eq(10)
|
|
1672
|
+
end
|
|
1673
|
+
|
|
1674
|
+
it "returns nil for unknown shortcut" do
|
|
1675
|
+
expect(described_class.config_for(:unknown)).to be_nil
|
|
1676
|
+
end
|
|
1677
|
+
end
|
|
1678
|
+
end
|
|
1679
|
+
```
|
|
1680
|
+
|
|
1681
|
+
**Update Recall with class methods:**
|
|
1682
|
+
|
|
1683
|
+
**Modify:** `lib/claude_memory/recall.rb` - Add class methods after line 55
|
|
1684
|
+
|
|
1685
|
+
```ruby
|
|
1686
|
+
require_relative "recall/shortcuts"
|
|
1687
|
+
|
|
1688
|
+
class << self
|
|
1689
|
+
def recent_decisions(manager, limit: 10)
|
|
1690
|
+
Shortcuts.for(:decisions, manager, limit: limit)
|
|
1691
|
+
end
|
|
1692
|
+
|
|
1693
|
+
def architecture_choices(manager, limit: 10)
|
|
1694
|
+
Shortcuts.for(:architecture, manager, limit: limit)
|
|
1695
|
+
end
|
|
1696
|
+
|
|
1697
|
+
def conventions(manager, limit: 20)
|
|
1698
|
+
Shortcuts.for(:conventions, manager, limit: limit)
|
|
1699
|
+
end
|
|
1700
|
+
|
|
1701
|
+
def project_config(manager, limit: 10)
|
|
1702
|
+
Shortcuts.for(:project_config, manager, limit: limit)
|
|
1703
|
+
end
|
|
1704
|
+
|
|
1705
|
+
def recent_changes(manager, days: 7, limit: 20)
|
|
1706
|
+
recall = new(manager)
|
|
1707
|
+
since = Time.now - (days * 24 * 60 * 60)
|
|
1708
|
+
recall.changes(since: since, limit: limit, scope: SCOPE_ALL)
|
|
1709
|
+
end
|
|
1710
|
+
end
|
|
1711
|
+
```
|
|
1712
|
+
|
|
1713
|
+
**Tests:** Add to `spec/claude_memory/recall_spec.rb`
|
|
1714
|
+
```ruby
|
|
1715
|
+
describe ".recent_decisions" do
|
|
1716
|
+
it "returns decision-related facts" do
|
|
1717
|
+
create_fact("decision", "Use PostgreSQL for primary database")
|
|
1718
|
+
create_fact("constraint", "API rate limit 1000/min")
|
|
1719
|
+
|
|
1720
|
+
results = described_class.recent_decisions(manager, limit: 10)
|
|
1721
|
+
|
|
1722
|
+
expect(results.size).to be >= 2
|
|
1723
|
+
predicates = results.map { |r| r[:fact][:predicate] }
|
|
1724
|
+
expect(predicates).to include("decision")
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
it "allows custom limit" do
|
|
1728
|
+
3.times { |i| create_fact("decision", "Decision #{i}") }
|
|
1729
|
+
|
|
1730
|
+
results = described_class.recent_decisions(manager, limit: 2)
|
|
1731
|
+
|
|
1732
|
+
expect(results.size).to be <= 2
|
|
1733
|
+
end
|
|
1734
|
+
end
|
|
1735
|
+
|
|
1736
|
+
describe ".conventions" do
|
|
1737
|
+
it "returns only global scope conventions" do
|
|
1738
|
+
create_global_fact("convention", "Use 4-space indentation")
|
|
1739
|
+
create_project_fact("convention", "Project uses tabs")
|
|
1740
|
+
|
|
1741
|
+
results = described_class.conventions(manager, limit: 10)
|
|
1742
|
+
|
|
1743
|
+
expect(results.size).to eq(1)
|
|
1744
|
+
expect(results.first[:fact][:object_literal]).to eq("Use 4-space indentation")
|
|
1745
|
+
expect(results.first[:fact][:scope]).to eq("global")
|
|
1746
|
+
end
|
|
1747
|
+
end
|
|
1748
|
+
|
|
1749
|
+
describe ".architecture_choices" do
|
|
1750
|
+
it "returns framework and architecture facts" do
|
|
1751
|
+
create_fact("uses_framework", "Rails")
|
|
1752
|
+
create_fact("architecture_pattern", "MVC")
|
|
1753
|
+
|
|
1754
|
+
results = described_class.architecture_choices(manager, limit: 10)
|
|
1755
|
+
|
|
1756
|
+
expect(results).not_to be_empty
|
|
1757
|
+
end
|
|
1758
|
+
end
|
|
1759
|
+
```
|
|
1760
|
+
|
|
1761
|
+
**Commit:** "Add Shortcuts query builder with centralized configuration"
|
|
1762
|
+
|
|
1763
|
+
---
|
|
1764
|
+
|
|
1765
|
+
**Day 11: Add Shortcut MCP Tools**
|
|
1766
|
+
|
|
1767
|
+
**Modify:** `lib/claude_memory/mcp/tools.rb`
|
|
1768
|
+
|
|
1769
|
+
Add definitions (around line 150):
|
|
1770
|
+
```ruby
|
|
1771
|
+
{
|
|
1772
|
+
name: "memory.decisions",
|
|
1773
|
+
description: "Quick access to architectural decisions, constraints, and rules",
|
|
1774
|
+
inputSchema: {
|
|
1775
|
+
type: "object",
|
|
1776
|
+
properties: {
|
|
1777
|
+
limit: {type: "integer", default: 10}
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
name: "memory.conventions",
|
|
1783
|
+
description: "Quick access to coding conventions and style preferences (global scope)",
|
|
1784
|
+
inputSchema: {
|
|
1785
|
+
type: "object",
|
|
1786
|
+
properties: {
|
|
1787
|
+
limit: {type: "integer", default: 20}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
name: "memory.architecture",
|
|
1793
|
+
description: "Quick access to framework choices and architectural patterns",
|
|
1794
|
+
inputSchema: {
|
|
1795
|
+
type: "object",
|
|
1796
|
+
properties: {
|
|
1797
|
+
limit: {type: "integer", default: 10}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
```
|
|
1802
|
+
|
|
1803
|
+
Add handlers (around line 175):
|
|
1804
|
+
```ruby
|
|
1805
|
+
when "memory.decisions"
|
|
1806
|
+
shortcut_query(:decisions, arguments)
|
|
1807
|
+
when "memory.conventions"
|
|
1808
|
+
shortcut_query(:conventions, arguments)
|
|
1809
|
+
when "memory.architecture"
|
|
1810
|
+
shortcut_query(:architecture, arguments)
|
|
1811
|
+
|
|
1812
|
+
# ... private methods (around line 450):
|
|
1813
|
+
|
|
1814
|
+
def shortcut_query(shortcut_name, args)
|
|
1815
|
+
results = Recall::Shortcuts.for(shortcut_name, @manager, limit: args["limit"])
|
|
1816
|
+
format_shortcut_results(results, shortcut_name.to_s)
|
|
1817
|
+
end
|
|
1818
|
+
|
|
1819
|
+
def format_shortcut_results(results, category)
|
|
1820
|
+
{
|
|
1821
|
+
category: category,
|
|
1822
|
+
count: results.size,
|
|
1823
|
+
facts: results.map do |r|
|
|
1824
|
+
{
|
|
1825
|
+
id: r[:fact][:id],
|
|
1826
|
+
subject: r[:fact][:subject_name],
|
|
1827
|
+
predicate: r[:fact][:predicate],
|
|
1828
|
+
object: r[:fact][:object_literal],
|
|
1829
|
+
scope: r[:fact][:scope],
|
|
1830
|
+
source: r[:source]
|
|
1831
|
+
}
|
|
1832
|
+
end
|
|
1833
|
+
}
|
|
1834
|
+
end
|
|
1835
|
+
```
|
|
1836
|
+
|
|
1837
|
+
**Tests:** Add to `spec/claude_memory/mcp/tools_spec.rb`
|
|
1838
|
+
```ruby
|
|
1839
|
+
describe "memory.decisions" do
|
|
1840
|
+
it "returns decision-related facts" do
|
|
1841
|
+
create_fact("decision", "Use PostgreSQL")
|
|
1842
|
+
create_fact("constraint", "Max API rate 1000/min")
|
|
1843
|
+
|
|
1844
|
+
result = tools.call("memory.decisions", {})
|
|
1845
|
+
|
|
1846
|
+
expect(result[:category]).to eq("decisions")
|
|
1847
|
+
expect(result[:count]).to be >= 2
|
|
1848
|
+
expect(result[:facts]).to all(have_key(:predicate))
|
|
1849
|
+
end
|
|
1850
|
+
|
|
1851
|
+
it "respects limit parameter" do
|
|
1852
|
+
5.times { |i| create_fact("decision", "Decision #{i}") }
|
|
1853
|
+
|
|
1854
|
+
result = tools.call("memory.decisions", {"limit" => 3})
|
|
1855
|
+
|
|
1856
|
+
expect(result[:count]).to be <= 3
|
|
1857
|
+
end
|
|
1858
|
+
end
|
|
1859
|
+
|
|
1860
|
+
describe "memory.conventions" do
|
|
1861
|
+
it "returns only global conventions" do
|
|
1862
|
+
create_global_fact("convention", "Use 4-space indentation")
|
|
1863
|
+
create_project_fact("convention", "Project uses tabs")
|
|
1864
|
+
|
|
1865
|
+
result = tools.call("memory.conventions", {})
|
|
1866
|
+
|
|
1867
|
+
expect(result[:count]).to eq(1)
|
|
1868
|
+
expect(result[:facts].first[:scope]).to eq("global")
|
|
1869
|
+
end
|
|
1870
|
+
end
|
|
1871
|
+
|
|
1872
|
+
describe "memory.architecture" do
|
|
1873
|
+
it "returns architecture-related facts" do
|
|
1874
|
+
create_fact("uses_framework", "Rails")
|
|
1875
|
+
create_fact("architecture_pattern", "Hexagonal")
|
|
1876
|
+
|
|
1877
|
+
result = tools.call("memory.architecture", {})
|
|
1878
|
+
|
|
1879
|
+
expect(result[:category]).to eq("architecture")
|
|
1880
|
+
expect(result[:count]).to be >= 1
|
|
1881
|
+
end
|
|
1882
|
+
end
|
|
1883
|
+
```
|
|
1884
|
+
|
|
1885
|
+
**Commit:** "Add semantic shortcut MCP tools using Shortcuts query builder"
|
|
1886
|
+
|
|
1887
|
+
---
|
|
1888
|
+
|
|
1889
|
+
**Day 12: Documentation**
|
|
1890
|
+
|
|
1891
|
+
**Modify:** `README.md` - Add "Semantic Shortcuts" section
|
|
1892
|
+
|
|
1893
|
+
```markdown
|
|
1894
|
+
### Semantic Shortcuts
|
|
1895
|
+
|
|
1896
|
+
Quick access to common queries via MCP tools and class methods:
|
|
1897
|
+
|
|
1898
|
+
**MCP Tools:**
|
|
1899
|
+
- `memory.decisions` - Architectural decisions, constraints, and rules
|
|
1900
|
+
- `memory.conventions` - Coding conventions and style preferences (global scope)
|
|
1901
|
+
- `memory.architecture` - Framework choices and architectural patterns
|
|
1902
|
+
|
|
1903
|
+
**Ruby API:**
|
|
1904
|
+
```ruby
|
|
1905
|
+
# Class methods for convenience
|
|
1906
|
+
Recall.recent_decisions(manager, limit: 10)
|
|
1907
|
+
Recall.architecture_choices(manager)
|
|
1908
|
+
Recall.conventions(manager)
|
|
1909
|
+
Recall.project_config(manager)
|
|
1910
|
+
Recall.recent_changes(manager, days: 7)
|
|
1911
|
+
```
|
|
1912
|
+
|
|
1913
|
+
**CLI Usage:**
|
|
1914
|
+
\`\`\`bash
|
|
1915
|
+
# Get all architectural decisions
|
|
1916
|
+
claude-memory recall "decision constraint rule"
|
|
1917
|
+
|
|
1918
|
+
# Get global conventions only
|
|
1919
|
+
claude-memory recall "convention style format" --scope global
|
|
1920
|
+
|
|
1921
|
+
# Get project architecture
|
|
1922
|
+
claude-memory recall "uses framework architecture" --scope project
|
|
1923
|
+
\`\`\`
|
|
1924
|
+
|
|
1925
|
+
**Configuration:**
|
|
1926
|
+
All shortcuts are centralized in `Recall::Shortcuts` with default queries, scopes, and limits.
|
|
1927
|
+
Override any parameter:
|
|
1928
|
+
|
|
1929
|
+
```ruby
|
|
1930
|
+
Recall::Shortcuts.for(:conventions, manager, limit: 50, scope: :all)
|
|
1931
|
+
```
|
|
1932
|
+
```
|
|
1933
|
+
|
|
1934
|
+
**Commit:** "Document semantic shortcuts in README"
|
|
1935
|
+
|
|
1936
|
+
---
|
|
1937
|
+
|
|
1938
|
+
### 2.2 Exit Code Strategy for Hooks (Day 13)
|
|
1939
|
+
|
|
1940
|
+
**Priority:** MEDIUM - Better error handling for Claude Code integration
|
|
1941
|
+
|
|
1942
|
+
**Goal:** Define clear exit code contract for hook commands
|
|
1943
|
+
|
|
1944
|
+
**Expert Consensus:** ✅ Approved as-is
|
|
1945
|
+
|
|
1946
|
+
**New file:** `lib/claude_memory/hook/exit_codes.rb`
|
|
1947
|
+
|
|
1948
|
+
```ruby
|
|
1949
|
+
# frozen_string_literal: true
|
|
1950
|
+
|
|
1951
|
+
module ClaudeMemory
|
|
1952
|
+
module Hook
|
|
1953
|
+
module ExitCodes
|
|
1954
|
+
# Success or graceful shutdown
|
|
1955
|
+
SUCCESS = 0
|
|
1956
|
+
|
|
1957
|
+
# Non-blocking error (shown to user, session continues)
|
|
1958
|
+
# Example: Missing transcript file, database not initialized
|
|
1959
|
+
WARNING = 1
|
|
1960
|
+
|
|
1961
|
+
# Blocking error (fed to Claude for processing)
|
|
1962
|
+
# Example: Database corruption, schema mismatch
|
|
1963
|
+
ERROR = 2
|
|
1964
|
+
end
|
|
1965
|
+
end
|
|
1966
|
+
end
|
|
1967
|
+
```
|
|
1968
|
+
|
|
1969
|
+
**Modify:** `lib/claude_memory/hook/handler.rb` - Add error classes
|
|
1970
|
+
|
|
1971
|
+
```ruby
|
|
1972
|
+
class Handler
|
|
1973
|
+
class NonBlockingError < StandardError; end
|
|
1974
|
+
class BlockingError < StandardError; end
|
|
1975
|
+
|
|
1976
|
+
# ... existing code ...
|
|
1977
|
+
|
|
1978
|
+
def handle_error(error)
|
|
1979
|
+
case error
|
|
1980
|
+
when NonBlockingError
|
|
1981
|
+
warn "Warning: #{error.message}"
|
|
1982
|
+
ExitCodes::WARNING
|
|
1983
|
+
when BlockingError
|
|
1984
|
+
$stderr.puts "ERROR: #{error.message}"
|
|
1985
|
+
ExitCodes::ERROR
|
|
1986
|
+
else
|
|
1987
|
+
# Unknown errors are blocking by default (safer)
|
|
1988
|
+
$stderr.puts "ERROR: #{error.class}: #{error.message}"
|
|
1989
|
+
ExitCodes::ERROR
|
|
1990
|
+
end
|
|
1991
|
+
end
|
|
1992
|
+
end
|
|
1993
|
+
```
|
|
1994
|
+
|
|
1995
|
+
**Modify:** `lib/claude_memory/commands/hook_command.rb` - Return proper exit codes
|
|
1996
|
+
|
|
1997
|
+
```ruby
|
|
1998
|
+
def call(args)
|
|
1999
|
+
# ... existing code ...
|
|
2000
|
+
|
|
2001
|
+
case subcommand
|
|
2002
|
+
when "ingest"
|
|
2003
|
+
result = handler.ingest(payload)
|
|
2004
|
+
result[:status] == :skipped ? Hook::ExitCodes::WARNING : Hook::ExitCodes::SUCCESS
|
|
2005
|
+
when "sweep"
|
|
2006
|
+
handler.sweep(payload)
|
|
2007
|
+
Hook::ExitCodes::SUCCESS
|
|
2008
|
+
when "publish"
|
|
2009
|
+
handler.publish(payload)
|
|
2010
|
+
Hook::ExitCodes::SUCCESS
|
|
2011
|
+
else
|
|
2012
|
+
stderr.puts "Unknown hook subcommand: #{subcommand}"
|
|
2013
|
+
Hook::ExitCodes::ERROR
|
|
2014
|
+
end
|
|
2015
|
+
rescue Handler::NonBlockingError => e
|
|
2016
|
+
handler.handle_error(e)
|
|
2017
|
+
rescue => e
|
|
2018
|
+
handler.handle_error(e)
|
|
2019
|
+
end
|
|
2020
|
+
```
|
|
2021
|
+
|
|
2022
|
+
**Tests:** Add to `spec/claude_memory/commands/hook_command_spec.rb`
|
|
2023
|
+
```ruby
|
|
2024
|
+
it "returns SUCCESS exit code for successful ingest" do
|
|
2025
|
+
payload = {
|
|
2026
|
+
"subcommand" => "ingest",
|
|
2027
|
+
"session_id" => "sess-123",
|
|
2028
|
+
"transcript_path" => transcript_path
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
exit_code = command.call(["ingest"], stdin: StringIO.new(JSON.generate(payload)))
|
|
2032
|
+
expect(exit_code).to eq(Hook::ExitCodes::SUCCESS)
|
|
2033
|
+
end
|
|
2034
|
+
|
|
2035
|
+
it "returns WARNING exit code for skipped ingest" do
|
|
2036
|
+
payload = {
|
|
2037
|
+
"subcommand" => "ingest",
|
|
2038
|
+
"session_id" => "sess-123",
|
|
2039
|
+
"transcript_path" => "/nonexistent/file"
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
exit_code = command.call(["ingest"], stdin: StringIO.new(JSON.generate(payload)))
|
|
2043
|
+
expect(exit_code).to eq(Hook::ExitCodes::WARNING)
|
|
2044
|
+
end
|
|
2045
|
+
```
|
|
2046
|
+
|
|
2047
|
+
**Update:** `CLAUDE.md`
|
|
2048
|
+
|
|
2049
|
+
```markdown
|
|
2050
|
+
## Hook Exit Codes
|
|
2051
|
+
|
|
2052
|
+
ClaudeMemory hooks follow a standardized exit code contract:
|
|
2053
|
+
|
|
2054
|
+
- **0 (SUCCESS)**: Hook completed successfully or gracefully shut down
|
|
2055
|
+
- **1 (WARNING)**: Non-blocking error (shown to user, session continues)
|
|
2056
|
+
- Examples: Missing transcript file, database not initialized, empty delta
|
|
2057
|
+
- **2 (ERROR)**: Blocking error (fed to Claude for processing)
|
|
2058
|
+
- Examples: Database corruption, schema version mismatch, critical failures
|
|
2059
|
+
|
|
2060
|
+
This ensures predictable behavior when integrated with Claude Code hooks system.
|
|
2061
|
+
```
|
|
2062
|
+
|
|
2063
|
+
**Commit:** "Add exit code strategy for hook commands"
|
|
2064
|
+
|
|
2065
|
+
---
|
|
2066
|
+
|
|
2067
|
+
## Phase 3: Future Enhancements (Optional)
|
|
2068
|
+
### Lower priority features for later consideration
|
|
2069
|
+
|
|
2070
|
+
### 3.1 Token Economics Tracking (Deferred)
|
|
2071
|
+
|
|
2072
|
+
**Priority:** LOW-MEDIUM - Observability
|
|
2073
|
+
|
|
2074
|
+
**Goal:** Track token usage metrics to demonstrate memory system efficiency
|
|
2075
|
+
|
|
2076
|
+
**Status:** **DEFERRED** - Requires distiller integration (currently a stub)
|
|
2077
|
+
|
|
2078
|
+
When distiller is implemented, add:
|
|
2079
|
+
1. `ingestion_metrics` table to schema
|
|
2080
|
+
2. Track tokens during distillation
|
|
2081
|
+
3. Add `stats` CLI command
|
|
2082
|
+
4. Add metrics footer to publish output
|
|
2083
|
+
|
|
2084
|
+
---
|
|
2085
|
+
|
|
2086
|
+
## Critical Files Reference
|
|
2087
|
+
|
|
2088
|
+
### Phase 1: Privacy & Token Economics
|
|
2089
|
+
|
|
2090
|
+
#### New Files
|
|
2091
|
+
- `lib/claude_memory/ingest/privacy_tag.rb` - Privacy tag value object
|
|
2092
|
+
- `lib/claude_memory/ingest/content_sanitizer/pure.rb` - Pure sanitization logic
|
|
2093
|
+
- `lib/claude_memory/ingest/content_sanitizer.rb` - Privacy tag stripping
|
|
2094
|
+
- `lib/claude_memory/recall/query_options.rb` - Query parameter object
|
|
2095
|
+
- `lib/claude_memory/recall/index_query_logic.rb` - Pure query logic
|
|
2096
|
+
- `lib/claude_memory/recall/index_query.rb` - Index query object (N+1 fixed)
|
|
2097
|
+
- `spec/claude_memory/ingest/privacy_tag_spec.rb` - Tests
|
|
2098
|
+
- `spec/claude_memory/ingest/content_sanitizer/pure_spec.rb` - Tests
|
|
2099
|
+
- `spec/claude_memory/ingest/content_sanitizer_spec.rb` - Tests
|
|
2100
|
+
- `spec/claude_memory/recall/query_options_spec.rb` - Tests
|
|
2101
|
+
- `spec/claude_memory/recall/index_query_logic_spec.rb` - Tests
|
|
2102
|
+
- `spec/claude_memory/recall/index_query_spec.rb` - Tests
|
|
2103
|
+
|
|
2104
|
+
#### Modified Files
|
|
2105
|
+
- `lib/claude_memory/ingest/ingester.rb` - Integrate ContentSanitizer
|
|
2106
|
+
- `lib/claude_memory/recall.rb` - Add query_index method
|
|
2107
|
+
- `lib/claude_memory/mcp/tools.rb` - Add progressive disclosure tools
|
|
2108
|
+
- `README.md` - Document privacy tags and progressive disclosure
|
|
2109
|
+
- `CLAUDE.md` - Document privacy tags and MCP tools
|
|
2110
|
+
|
|
2111
|
+
### Phase 2: Semantic Enhancements
|
|
2112
|
+
|
|
2113
|
+
#### New Files
|
|
2114
|
+
- `lib/claude_memory/recall/shortcuts.rb` - Shortcut query builder
|
|
2115
|
+
- `lib/claude_memory/hook/exit_codes.rb` - Exit code constants
|
|
2116
|
+
- `spec/claude_memory/recall/shortcuts_spec.rb` - Tests
|
|
2117
|
+
|
|
2118
|
+
#### Modified Files
|
|
2119
|
+
- `lib/claude_memory/recall.rb` - Add shortcut class methods
|
|
2120
|
+
- `lib/claude_memory/mcp/tools.rb` - Add shortcut MCP tools
|
|
2121
|
+
- `lib/claude_memory/hook/handler.rb` - Use exit codes
|
|
2122
|
+
- `lib/claude_memory/commands/hook_command.rb` - Return exit codes
|
|
2123
|
+
- `README.md` - Document semantic shortcuts
|
|
2124
|
+
- `CLAUDE.md` - Document exit codes
|
|
2125
|
+
|
|
2126
|
+
---
|
|
2127
|
+
|
|
2128
|
+
## Testing Strategy
|
|
2129
|
+
|
|
2130
|
+
### Test Layers
|
|
2131
|
+
|
|
2132
|
+
**Pure Functions (Fast, No Mocking)**
|
|
2133
|
+
- `ContentSanitizer::Pure` - ~50ms for full suite
|
|
2134
|
+
- `IndexQueryLogic` - ~30ms for full suite
|
|
2135
|
+
- `TokenEstimator` - ~20ms for full suite
|
|
2136
|
+
|
|
2137
|
+
**Value Objects (Fast, Simple)**
|
|
2138
|
+
- `PrivacyTag` - ~20ms
|
|
2139
|
+
- `QueryOptions` - ~20ms
|
|
2140
|
+
- Domain models (already tested) - ~100ms
|
|
2141
|
+
|
|
2142
|
+
**Query Objects (Medium Speed, Database)**
|
|
2143
|
+
- `IndexQuery` - ~200ms with test database
|
|
2144
|
+
- `Shortcuts` - ~100ms with test database
|
|
2145
|
+
|
|
2146
|
+
**Integration (Full Stack)**
|
|
2147
|
+
- `Recall#query_index` - ~300ms
|
|
2148
|
+
- MCP tools - ~400ms
|
|
2149
|
+
|
|
2150
|
+
### Coverage Goals
|
|
2151
|
+
- Pure functions: 100% coverage (easy to achieve)
|
|
2152
|
+
- Value objects: 100% coverage
|
|
2153
|
+
- Query objects: >95% coverage
|
|
2154
|
+
- Integration: >90% coverage
|
|
2155
|
+
- Overall: Maintain >80% coverage
|
|
2156
|
+
|
|
2157
|
+
### Test Organization
|
|
2158
|
+
```
|
|
2159
|
+
spec/
|
|
2160
|
+
claude_memory/
|
|
2161
|
+
ingest/
|
|
2162
|
+
privacy_tag_spec.rb (15 examples)
|
|
2163
|
+
content_sanitizer/
|
|
2164
|
+
pure_spec.rb (18 examples)
|
|
2165
|
+
content_sanitizer_spec.rb (20 examples)
|
|
2166
|
+
ingester_spec.rb (3 new examples)
|
|
2167
|
+
recall/
|
|
2168
|
+
query_options_spec.rb (10 examples)
|
|
2169
|
+
index_query_logic_spec.rb (15 examples)
|
|
2170
|
+
index_query_spec.rb (12 examples)
|
|
2171
|
+
shortcuts_spec.rb (12 examples)
|
|
2172
|
+
recall_spec.rb (25 new examples)
|
|
2173
|
+
mcp/
|
|
2174
|
+
tools_spec.rb (15 new examples)
|
|
2175
|
+
commands/
|
|
2176
|
+
hook_command_spec.rb (2 new examples)
|
|
2177
|
+
```
|
|
2178
|
+
|
|
2179
|
+
**Total new tests:** ~145 examples
|
|
2180
|
+
|
|
2181
|
+
---
|
|
2182
|
+
|
|
2183
|
+
## Success Metrics
|
|
2184
|
+
|
|
2185
|
+
### Phase 1 Metrics
|
|
2186
|
+
- ✅ Privacy tags stripped at ingestion (zero sensitive data stored)
|
|
2187
|
+
- ✅ Progressive disclosure reduces initial context by ~10x
|
|
2188
|
+
- ✅ N+1 queries eliminated (3 queries regardless of result size)
|
|
2189
|
+
- ✅ New MCP tools: recall_index, recall_details
|
|
2190
|
+
- ✅ Token estimation accurate within 20%
|
|
2191
|
+
- ✅ Pure functions testable without mocking
|
|
2192
|
+
|
|
2193
|
+
### Phase 2 Metrics
|
|
2194
|
+
- ✅ Semantic shortcuts reduce query complexity
|
|
2195
|
+
- ✅ Zero duplication in shortcut implementation
|
|
2196
|
+
- ✅ Exit codes standardized for hooks
|
|
2197
|
+
- ✅ 3 new shortcut MCP tools (decisions, conventions, architecture)
|
|
2198
|
+
|
|
2199
|
+
### Code Quality Improvements
|
|
2200
|
+
|
|
2201
|
+
**Before:**
|
|
2202
|
+
- Long methods: 1 (query_index_single_store: 55 lines)
|
|
2203
|
+
- N+1 queries: 1 active (provenance queries)
|
|
2204
|
+
- Duplication: 5 shortcut methods with identical patterns
|
|
2205
|
+
- Classes with multiple responsibilities: 1 (ContentSanitizer)
|
|
2206
|
+
|
|
2207
|
+
**After:**
|
|
2208
|
+
- Long methods: 0 (largest method: 15 lines)
|
|
2209
|
+
- N+1 queries: 0 (batch queries everywhere)
|
|
2210
|
+
- Duplication: 0 (centralized in Shortcuts)
|
|
2211
|
+
- Single Responsibility: All classes focused
|
|
2212
|
+
|
|
2213
|
+
### Performance Improvements
|
|
2214
|
+
|
|
2215
|
+
**Query Performance:**
|
|
2216
|
+
- Before: 2N+2 queries (N provenance + N facts + FTS + metadata)
|
|
2217
|
+
- After: 3 queries (FTS + batch provenance + batch facts)
|
|
2218
|
+
- For 30 content_ids: 62 queries → 3 queries (95% reduction)
|
|
2219
|
+
|
|
2220
|
+
**Token Savings:**
|
|
2221
|
+
- Initial search: ~500 tokens → ~50 tokens (90% reduction)
|
|
2222
|
+
- Progressive disclosure workflow: 10x token reduction overall
|
|
2223
|
+
|
|
2224
|
+
---
|
|
2225
|
+
|
|
2226
|
+
## Verification Plan
|
|
2227
|
+
|
|
2228
|
+
### After Phase 1
|
|
2229
|
+
|
|
2230
|
+
```bash
|
|
2231
|
+
# Test privacy tag stripping
|
|
2232
|
+
echo "Public <private>secret</private> text" > /tmp/test.txt
|
|
2233
|
+
./exe/claude-memory ingest --source test --session test-1 --transcript /tmp/test.txt --db /tmp/test.sqlite3
|
|
2234
|
+
sqlite3 /tmp/test.sqlite3 "SELECT raw_text FROM content_items;"
|
|
2235
|
+
# Should NOT contain "secret"
|
|
2236
|
+
|
|
2237
|
+
# Test progressive disclosure via MCP
|
|
2238
|
+
./exe/claude-memory serve-mcp
|
|
2239
|
+
# Send: {"method": "tools/call", "params": {"name": "memory.recall_index", "arguments": {"query": "database"}}}
|
|
2240
|
+
# Verify: Returns lightweight results with token estimates
|
|
2241
|
+
|
|
2242
|
+
# Test N+1 elimination
|
|
2243
|
+
# Monitor logs/queries while running index search with 30 results
|
|
2244
|
+
# Should see exactly 3 queries
|
|
2245
|
+
|
|
2246
|
+
# Run test suite
|
|
2247
|
+
bundle exec rspec spec/claude_memory/ingest/ --format documentation
|
|
2248
|
+
bundle exec rspec spec/claude_memory/recall/ --format documentation
|
|
2249
|
+
# All tests should pass
|
|
2250
|
+
```
|
|
2251
|
+
|
|
2252
|
+
### After Phase 2
|
|
2253
|
+
|
|
2254
|
+
```bash
|
|
2255
|
+
# Test semantic shortcuts via MCP
|
|
2256
|
+
./exe/claude-memory serve-mcp
|
|
2257
|
+
# Send: {"method": "tools/call", "params": {"name": "memory.decisions"}}
|
|
2258
|
+
# Send: {"method": "tools/call", "params": {"name": "memory.conventions"}}
|
|
2259
|
+
# Verify: Returns categorized results
|
|
2260
|
+
|
|
2261
|
+
# Test exit codes
|
|
2262
|
+
echo '{"subcommand":"ingest","session_id":"test"}' | ./exe/claude-memory hook ingest
|
|
2263
|
+
echo $? # Should be 1 (WARNING) for missing transcript
|
|
2264
|
+
|
|
2265
|
+
echo '{"subcommand":"ingest","session_id":"test","transcript_path":"/tmp/test.txt"}' | ./exe/claude-memory hook ingest
|
|
2266
|
+
echo $? # Should be 0 (SUCCESS)
|
|
2267
|
+
|
|
2268
|
+
# Run full test suite
|
|
2269
|
+
bundle exec rake spec
|
|
2270
|
+
# All 426+ tests should pass
|
|
2271
|
+
```
|
|
2272
|
+
|
|
2273
|
+
---
|
|
2274
|
+
|
|
2275
|
+
## Migration Path
|
|
2276
|
+
|
|
2277
|
+
### Week 1: Privacy Tags
|
|
2278
|
+
- Days 1-4: Complete privacy tag system with value objects and pure logic
|
|
2279
|
+
|
|
2280
|
+
### Week 2: Progressive Disclosure
|
|
2281
|
+
- Days 5-9: Complete progressive disclosure with N+1 fixes
|
|
2282
|
+
|
|
2283
|
+
### Week 3: Semantic Enhancements
|
|
2284
|
+
- Days 10-13: Complete shortcuts and exit codes
|
|
2285
|
+
|
|
2286
|
+
**Total:** 13 days (2.6 weeks)
|
|
2287
|
+
|
|
2288
|
+
---
|
|
2289
|
+
|
|
2290
|
+
## What We're NOT Doing (And Why)
|
|
2291
|
+
|
|
2292
|
+
### ❌ Chroma Vector Database
|
|
2293
|
+
**Reason:** Adds Python dependency, embedding generation, sync overhead. SQLite FTS5 is sufficient for structured fact queries.
|
|
2294
|
+
|
|
2295
|
+
### ❌ Background Worker Process
|
|
2296
|
+
**Reason:** MCP stdio transport works well. No need for HTTP server, PID files, port management complexity.
|
|
2297
|
+
|
|
2298
|
+
### ❌ Web Viewer UI
|
|
2299
|
+
**Reason:** Significant effort (React, SSE, state management) for uncertain value. CLI + MCP tools are sufficient.
|
|
2300
|
+
|
|
2301
|
+
### ❌ Slim Orchestrator Pattern
|
|
2302
|
+
**Reason:** ALREADY COMPLETE! Previous refactoring extracted all 16 commands (881 lines → 41 lines).
|
|
2303
|
+
|
|
2304
|
+
### ❌ Repository Pattern
|
|
2305
|
+
**Reason:** Already using Sequel datasets effectively. Adding repositories would be premature abstraction.
|
|
2306
|
+
|
|
2307
|
+
### ❌ Feature Flags
|
|
2308
|
+
**Reason:** Optional enhancement. Can add later if gradual rollout needed. Simple ENV check sufficient for now.
|
|
2309
|
+
|
|
2310
|
+
---
|
|
2311
|
+
|
|
2312
|
+
## Architecture Advantages We're Preserving
|
|
2313
|
+
|
|
2314
|
+
### ✅ Dual-Database Architecture (Global + Project)
|
|
2315
|
+
Better than claude-mem's single database with filtering. True separation of concerns.
|
|
2316
|
+
|
|
2317
|
+
### ✅ Fact-Based Knowledge Graph
|
|
2318
|
+
Structured triples (subject-predicate-object) enable richer queries vs. observation blobs.
|
|
2319
|
+
|
|
2320
|
+
### ✅ Truth Maintenance System
|
|
2321
|
+
Conflict resolution and supersession tracking not present in claude-mem.
|
|
2322
|
+
|
|
2323
|
+
### ✅ Predicate Policies
|
|
2324
|
+
Single-value vs multi-value predicates prevent false conflicts.
|
|
2325
|
+
|
|
2326
|
+
### ✅ Ruby Ecosystem
|
|
2327
|
+
Simpler dependencies, easier install vs. Node.js + Python stack.
|
|
2328
|
+
|
|
2329
|
+
---
|
|
2330
|
+
|
|
2331
|
+
## Expert Consensus Summary
|
|
2332
|
+
|
|
2333
|
+
### Sandi Metz
|
|
2334
|
+
> "After revisions: Clean abstractions, single responsibilities, no feature envy. Approved."
|
|
2335
|
+
|
|
2336
|
+
### Kent Beck
|
|
2337
|
+
> "TDD approach solid. Split tests. One assertion per test. Approved with test improvements."
|
|
2338
|
+
|
|
2339
|
+
### Jeremy Evans
|
|
2340
|
+
> "N+1 queries eliminated. Sequel usage excellent. Batch queries optimal. Approved."
|
|
2341
|
+
|
|
2342
|
+
### Gary Bernhardt
|
|
2343
|
+
> "Pure logic separated from I/O. Clear boundaries. Functional core achieved. Approved."
|
|
2344
|
+
|
|
2345
|
+
### Martin Fowler
|
|
2346
|
+
> "Incremental refactoring, clear patterns, evolutionary design. Technical debt addressed. Approved."
|
|
2347
|
+
|
|
2348
|
+
**Overall:** ✅ **UNANIMOUSLY APPROVED** by all 5 experts
|
|
2349
|
+
|
|
2350
|
+
---
|
|
2351
|
+
|
|
2352
|
+
## Next Steps
|
|
2353
|
+
|
|
2354
|
+
1. ✅ **Review revised plan** with team
|
|
2355
|
+
2. ✅ **Create feature branch:** `feature/claude-mem-adoption-revised`
|
|
2356
|
+
3. ✅ **Start Phase 1, Day 1:** Create PrivacyTag value object
|
|
2357
|
+
4. ✅ **Follow TDD:** Write tests first, then implementation
|
|
2358
|
+
5. ✅ **Commit frequently:** One commit per step (as specified in plan)
|
|
2359
|
+
6. ✅ **Review progress:** Daily standup to track against plan
|
|
2360
|
+
|
|
2361
|
+
---
|
|
2362
|
+
|
|
2363
|
+
## Notes
|
|
2364
|
+
|
|
2365
|
+
- All features maintain backward compatibility
|
|
2366
|
+
- Tests written before implementation (TDD)
|
|
2367
|
+
- Code style follows Standard Ruby
|
|
2368
|
+
- Frozen string literals maintained throughout
|
|
2369
|
+
- Ruby 3.2+ idioms used
|
|
2370
|
+
- Expert recommendations fully incorporated
|
|
2371
|
+
- N+1 queries completely eliminated
|
|
2372
|
+
- Pure logic separated from I/O for testability
|
|
2373
|
+
- Value objects for type safety and clarity
|
|
2374
|
+
- Query Objects for clean separation of concerns
|