claude_memory 0.2.0 → 0.3.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.o2N83S +0 -0
- data/.claude/CLAUDE.md +1 -0
- data/.claude/rules/claude_memory.generated.md +28 -9
- data/.claude/settings.local.json +9 -1
- data/.claude/skills/check-memory/SKILL.md +77 -0
- data/.claude/skills/improve/SKILL.md +532 -0
- data/.claude/skills/improve/feature-patterns.md +1221 -0
- data/.claude/skills/quality-update/SKILL.md +229 -0
- data/.claude/skills/quality-update/implementation-guide.md +346 -0
- data/.claude/skills/review-commit/SKILL.md +199 -0
- data/.claude/skills/review-for-quality/SKILL.md +154 -0
- data/.claude/skills/review-for-quality/expert-checklists.md +79 -0
- data/.claude/skills/setup-memory/SKILL.md +168 -0
- data/.claude/skills/study-repo/SKILL.md +307 -0
- data/.claude/skills/study-repo/analysis-template.md +323 -0
- data/.claude/skills/study-repo/focus-examples.md +327 -0
- data/CHANGELOG.md +133 -0
- data/CLAUDE.md +130 -11
- data/README.md +117 -10
- data/db/migrations/001_create_initial_schema.rb +117 -0
- data/db/migrations/002_add_project_scoping.rb +33 -0
- data/db/migrations/003_add_session_metadata.rb +42 -0
- data/db/migrations/004_add_fact_embeddings.rb +20 -0
- data/db/migrations/005_add_incremental_sync.rb +21 -0
- data/db/migrations/006_add_operation_tracking.rb +40 -0
- data/db/migrations/007_add_ingestion_metrics.rb +26 -0
- data/docs/.claude/mind.mv2.lock +0 -0
- data/docs/GETTING_STARTED.md +587 -0
- data/docs/RELEASE_NOTES_v0.2.0.md +0 -1
- data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +0 -2
- data/docs/architecture.md +9 -8
- data/docs/auto_init_design.md +230 -0
- data/docs/improvements.md +557 -731
- data/docs/influence/.gitkeep +13 -0
- data/docs/influence/grepai.md +933 -0
- data/docs/influence/qmd.md +2195 -0
- data/docs/plugin.md +257 -11
- data/docs/quality_review.md +472 -1273
- data/docs/remaining_improvements.md +330 -0
- data/lefthook.yml +13 -0
- data/lib/claude_memory/commands/checks/claude_md_check.rb +41 -0
- data/lib/claude_memory/commands/checks/database_check.rb +120 -0
- data/lib/claude_memory/commands/checks/hooks_check.rb +112 -0
- data/lib/claude_memory/commands/checks/reporter.rb +110 -0
- data/lib/claude_memory/commands/checks/snapshot_check.rb +30 -0
- data/lib/claude_memory/commands/doctor_command.rb +12 -129
- data/lib/claude_memory/commands/help_command.rb +1 -0
- data/lib/claude_memory/commands/hook_command.rb +9 -2
- data/lib/claude_memory/commands/index_command.rb +169 -0
- data/lib/claude_memory/commands/ingest_command.rb +1 -1
- data/lib/claude_memory/commands/init_command.rb +5 -197
- data/lib/claude_memory/commands/initializers/database_ensurer.rb +30 -0
- data/lib/claude_memory/commands/initializers/global_initializer.rb +85 -0
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +156 -0
- data/lib/claude_memory/commands/initializers/mcp_configurator.rb +56 -0
- data/lib/claude_memory/commands/initializers/memory_instructions_writer.rb +135 -0
- data/lib/claude_memory/commands/initializers/project_initializer.rb +111 -0
- data/lib/claude_memory/commands/recover_command.rb +75 -0
- data/lib/claude_memory/commands/registry.rb +5 -1
- data/lib/claude_memory/commands/stats_command.rb +239 -0
- data/lib/claude_memory/commands/uninstall_command.rb +226 -0
- data/lib/claude_memory/core/batch_loader.rb +32 -0
- data/lib/claude_memory/core/concept_ranker.rb +73 -0
- data/lib/claude_memory/core/embedding_candidate_builder.rb +37 -0
- data/lib/claude_memory/core/fact_collector.rb +51 -0
- data/lib/claude_memory/core/fact_query_builder.rb +154 -0
- data/lib/claude_memory/core/fact_ranker.rb +113 -0
- data/lib/claude_memory/core/result_builder.rb +54 -0
- data/lib/claude_memory/core/result_sorter.rb +25 -0
- data/lib/claude_memory/core/scope_filter.rb +61 -0
- data/lib/claude_memory/core/text_builder.rb +29 -0
- data/lib/claude_memory/embeddings/generator.rb +161 -0
- data/lib/claude_memory/embeddings/similarity.rb +69 -0
- data/lib/claude_memory/hook/handler.rb +4 -3
- data/lib/claude_memory/index/lexical_fts.rb +7 -2
- data/lib/claude_memory/infrastructure/operation_tracker.rb +158 -0
- data/lib/claude_memory/infrastructure/schema_validator.rb +206 -0
- data/lib/claude_memory/ingest/content_sanitizer.rb +6 -7
- data/lib/claude_memory/ingest/ingester.rb +99 -15
- data/lib/claude_memory/ingest/metadata_extractor.rb +57 -0
- data/lib/claude_memory/ingest/tool_extractor.rb +71 -0
- data/lib/claude_memory/mcp/response_formatter.rb +331 -0
- data/lib/claude_memory/mcp/server.rb +19 -0
- data/lib/claude_memory/mcp/setup_status_analyzer.rb +73 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +279 -0
- data/lib/claude_memory/mcp/tool_helpers.rb +80 -0
- data/lib/claude_memory/mcp/tools.rb +330 -320
- data/lib/claude_memory/recall/dual_query_template.rb +63 -0
- data/lib/claude_memory/recall.rb +304 -237
- data/lib/claude_memory/resolve/resolver.rb +52 -49
- data/lib/claude_memory/store/sqlite_store.rb +210 -144
- data/lib/claude_memory/store/store_manager.rb +6 -6
- data/lib/claude_memory/sweep/sweeper.rb +6 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +35 -3
- metadata +71 -11
- data/.claude/.mind.mv2.aLCUZd +0 -0
- data/.claude/memory.sqlite3 +0 -0
- data/.mcp.json +0 -11
- /data/docs/{feature_adoption_plan.md → plans/feature_adoption_plan.md} +0 -0
- /data/docs/{feature_adoption_plan_revised.md → plans/feature_adoption_plan_revised.md} +0 -0
- /data/docs/{plan.md → plans/plan.md} +0 -0
- /data/docs/{updated_plan.md → plans/updated_plan.md} +0 -0
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
# Feature Implementation Patterns
|
|
2
|
+
|
|
3
|
+
## Expert Review Applied
|
|
4
|
+
|
|
5
|
+
This guide demonstrates best practices from our Ruby experts:
|
|
6
|
+
- **Sandi Metz**: Small methods, dependency injection, SRP
|
|
7
|
+
- **Jeremy Evans**: DateTime columns, proper Sequel usage
|
|
8
|
+
- **Kent Beck**: Clear intent, testable design
|
|
9
|
+
- **Avdi Grimm**: Confident code, meaningful return values
|
|
10
|
+
- **Gary Bernhardt**: Boundaries, functional core/imperative shell
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Common Feature Types & Recipes
|
|
15
|
+
|
|
16
|
+
### Pattern 1: Adding a Database Table
|
|
17
|
+
|
|
18
|
+
**Use Case**: ROI metrics, tool usage tracking, logs
|
|
19
|
+
|
|
20
|
+
**Recipe:**
|
|
21
|
+
```ruby
|
|
22
|
+
# 1. Increment SCHEMA_VERSION in sqlite_store.rb
|
|
23
|
+
SCHEMA_VERSION = 7
|
|
24
|
+
|
|
25
|
+
# 2. Add migration method
|
|
26
|
+
def migrate_to_v7!
|
|
27
|
+
@db.create_table?(:ingestion_metrics) do
|
|
28
|
+
primary_key :id
|
|
29
|
+
foreign_key :content_item_id, :content_items, null: false
|
|
30
|
+
Integer :input_tokens, null: false, default: 0
|
|
31
|
+
Integer :output_tokens, null: false, default: 0
|
|
32
|
+
Integer :facts_extracted, null: false, default: 0
|
|
33
|
+
DateTime :created_at, null: false # ✅ Jeremy Evans: Use DateTime, not String
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 3. Call in run_migrations!
|
|
38
|
+
def run_migrations!
|
|
39
|
+
current = schema_version
|
|
40
|
+
migrate_to_v7! if current < 7
|
|
41
|
+
update_schema_version(SCHEMA_VERSION)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 4. Add accessor method
|
|
45
|
+
def ingestion_metrics
|
|
46
|
+
@db[:ingestion_metrics]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# ✅ Sandi Metz: Extract small, focused methods
|
|
52
|
+
def schema_version
|
|
53
|
+
@db.fetch("PRAGMA user_version").first[:user_version]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def update_schema_version(version)
|
|
57
|
+
@db.run("PRAGMA user_version = #{version}")
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Expert Notes:**
|
|
62
|
+
- ✅ **Jeremy Evans**: DateTime columns are more efficient than String timestamps
|
|
63
|
+
- ✅ **Sandi Metz**: Small helper methods for schema version management
|
|
64
|
+
- ✅ **Kent Beck**: Migration method name reveals intent
|
|
65
|
+
|
|
66
|
+
**Tests:**
|
|
67
|
+
```ruby
|
|
68
|
+
# spec/claude_memory/store/sqlite_store_spec.rb
|
|
69
|
+
RSpec.describe ClaudeMemory::Store::SQLiteStore do
|
|
70
|
+
describe "schema version 7" do
|
|
71
|
+
it "creates ingestion_metrics table" do
|
|
72
|
+
expect(store.db.table_exists?(:ingestion_metrics)).to be true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "uses DateTime for created_at column" do
|
|
76
|
+
schema = store.db.schema(:ingestion_metrics)
|
|
77
|
+
created_at_column = schema.find { |col| col[0] == :created_at }
|
|
78
|
+
|
|
79
|
+
expect(created_at_column[1][:type]).to eq(:datetime)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "includes all required columns" do
|
|
83
|
+
columns = store.db.schema(:ingestion_metrics).map { |c| c[0] }
|
|
84
|
+
expected = [:id, :content_item_id, :input_tokens, :output_tokens,
|
|
85
|
+
:facts_extracted, :created_at]
|
|
86
|
+
|
|
87
|
+
expect(columns).to match_array(expected)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Time Estimate**: 15-20 minutes
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### Pattern 2: Adding a New CLI Command
|
|
98
|
+
|
|
99
|
+
**Use Case**: Stats enhancements, embed command, new utilities
|
|
100
|
+
|
|
101
|
+
**Recipe:**
|
|
102
|
+
```ruby
|
|
103
|
+
# 1. Create command file
|
|
104
|
+
# lib/claude_memory/commands/metrics_command.rb
|
|
105
|
+
module ClaudeMemory
|
|
106
|
+
module Commands
|
|
107
|
+
class MetricsCommand < BaseCommand
|
|
108
|
+
# ✅ Gary Bernhardt: Inject dependencies, don't create them
|
|
109
|
+
def initialize(stdout: $stdout, stderr: $stderr, store_manager: nil)
|
|
110
|
+
super(stdout: stdout, stderr: stderr)
|
|
111
|
+
@store_manager = store_manager
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def call(args)
|
|
115
|
+
opts = parse_options(args)
|
|
116
|
+
return 1 if opts.nil?
|
|
117
|
+
|
|
118
|
+
# ✅ Gary Bernhardt: Ensure cleanup even on exception
|
|
119
|
+
manager = store_manager_for(opts)
|
|
120
|
+
|
|
121
|
+
result = execute_command(manager, opts)
|
|
122
|
+
output_result(result, opts)
|
|
123
|
+
|
|
124
|
+
0
|
|
125
|
+
ensure
|
|
126
|
+
manager&.close
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# ✅ Sandi Metz: Small, focused methods
|
|
132
|
+
def parse_options(args)
|
|
133
|
+
opts = default_options
|
|
134
|
+
|
|
135
|
+
OptionParser.new do |parser|
|
|
136
|
+
parser.banner = "Usage: claude-memory metrics [options]"
|
|
137
|
+
parser.on("--format FORMAT", ["text", "json"], "Output format") do |f|
|
|
138
|
+
opts[:format] = f
|
|
139
|
+
end
|
|
140
|
+
end.parse!(args)
|
|
141
|
+
|
|
142
|
+
opts
|
|
143
|
+
rescue OptionParser::InvalidOption => e
|
|
144
|
+
stderr.puts "Error: #{e.message}"
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def default_options
|
|
149
|
+
{ format: "text" }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ✅ Gary Bernhardt: Factory method for testability
|
|
153
|
+
def store_manager_for(opts)
|
|
154
|
+
@store_manager || Store::StoreManager.new
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ✅ Kent Beck: Method name reveals intent
|
|
158
|
+
def execute_command(manager, opts)
|
|
159
|
+
MetricsCalculator.new(manager.global_store).calculate
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ✅ Avdi Grimm: Tell, don't ask - formatter knows how to format
|
|
163
|
+
def output_result(result, opts)
|
|
164
|
+
formatter = formatter_for(opts[:format])
|
|
165
|
+
stdout.puts formatter.format(result)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def formatter_for(format)
|
|
169
|
+
case format
|
|
170
|
+
when "json"
|
|
171
|
+
JsonMetricsFormatter.new
|
|
172
|
+
else
|
|
173
|
+
TextMetricsFormatter.new
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# ✅ Gary Bernhardt: Pure calculation logic, no I/O
|
|
179
|
+
class MetricsCalculator
|
|
180
|
+
def initialize(store)
|
|
181
|
+
@store = store
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def calculate
|
|
185
|
+
{
|
|
186
|
+
total_input_tokens: sum_column(:input_tokens),
|
|
187
|
+
total_output_tokens: sum_column(:output_tokens),
|
|
188
|
+
total_facts: sum_column(:facts_extracted),
|
|
189
|
+
efficiency: calculate_efficiency
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def sum_column(column)
|
|
196
|
+
@store.ingestion_metrics.sum(column) || 0
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def calculate_efficiency
|
|
200
|
+
facts = sum_column(:facts_extracted)
|
|
201
|
+
tokens = sum_column(:input_tokens)
|
|
202
|
+
|
|
203
|
+
return 0.0 if tokens.zero?
|
|
204
|
+
|
|
205
|
+
(facts.to_f / tokens * 1000).round(2)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ✅ Sandi Metz: Single responsibility - knows how to format
|
|
210
|
+
class TextMetricsFormatter
|
|
211
|
+
def format(metrics)
|
|
212
|
+
[
|
|
213
|
+
"Token Economics:",
|
|
214
|
+
" Input tokens: #{metrics[:total_input_tokens]}",
|
|
215
|
+
" Output tokens: #{metrics[:total_output_tokens]}",
|
|
216
|
+
" Facts extracted: #{metrics[:total_facts]}",
|
|
217
|
+
" Efficiency: #{metrics[:efficiency]} facts/1k tokens"
|
|
218
|
+
].join("\n")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
class JsonMetricsFormatter
|
|
223
|
+
def format(metrics)
|
|
224
|
+
JSON.pretty_generate(metrics)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# 2. Register in registry
|
|
231
|
+
# lib/claude_memory/commands/registry.rb
|
|
232
|
+
COMMANDS = {
|
|
233
|
+
# ... existing
|
|
234
|
+
"metrics" => MetricsCommand
|
|
235
|
+
}.freeze
|
|
236
|
+
|
|
237
|
+
# 3. Add to help text
|
|
238
|
+
# lib/claude_memory/commands/help_command.rb
|
|
239
|
+
" metrics Display token usage and efficiency metrics"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Expert Notes:**
|
|
243
|
+
- ✅ **Gary Bernhardt**: Dependencies injected, pure calculator logic separated from I/O
|
|
244
|
+
- ✅ **Sandi Metz**: Small classes with single responsibility (calculator, formatters)
|
|
245
|
+
- ✅ **Kent Beck**: Clear method names that reveal intent
|
|
246
|
+
- ✅ **Avdi Grimm**: No nil checks needed, formatters handle their own formatting
|
|
247
|
+
|
|
248
|
+
**Tests:**
|
|
249
|
+
```ruby
|
|
250
|
+
# spec/claude_memory/commands/metrics_command_spec.rb
|
|
251
|
+
RSpec.describe ClaudeMemory::Commands::MetricsCommand do
|
|
252
|
+
let(:stdout) { StringIO.new }
|
|
253
|
+
let(:stderr) { StringIO.new }
|
|
254
|
+
let(:store_manager) { instance_double(ClaudeMemory::Store::StoreManager) }
|
|
255
|
+
let(:global_store) { instance_double(ClaudeMemory::Store::SQLiteStore) }
|
|
256
|
+
let(:command) do
|
|
257
|
+
described_class.new(
|
|
258
|
+
stdout: stdout,
|
|
259
|
+
stderr: stderr,
|
|
260
|
+
store_manager: store_manager
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
before do
|
|
265
|
+
allow(store_manager).to receive(:global_store).and_return(global_store)
|
|
266
|
+
allow(store_manager).to receive(:close)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
describe "#call" do
|
|
270
|
+
let(:metrics_dataset) { double("metrics_dataset") }
|
|
271
|
+
|
|
272
|
+
before do
|
|
273
|
+
allow(global_store).to receive(:ingestion_metrics).and_return(metrics_dataset)
|
|
274
|
+
allow(metrics_dataset).to receive(:sum).with(:input_tokens).and_return(1000)
|
|
275
|
+
allow(metrics_dataset).to receive(:sum).with(:output_tokens).and_return(500)
|
|
276
|
+
allow(metrics_dataset).to receive(:sum).with(:facts_extracted).and_return(10)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "returns success exit code" do
|
|
280
|
+
expect(command.call([])).to eq(0)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
it "closes store manager even on exception" do
|
|
284
|
+
allow(global_store).to receive(:ingestion_metrics).and_raise("DB error")
|
|
285
|
+
|
|
286
|
+
expect { command.call([]) }.to raise_error("DB error")
|
|
287
|
+
expect(store_manager).to have_received(:close)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it "displays metrics in text format by default" do
|
|
291
|
+
command.call([])
|
|
292
|
+
output = stdout.string
|
|
293
|
+
|
|
294
|
+
expect(output).to include("Token Economics:")
|
|
295
|
+
expect(output).to include("Input tokens: 1000")
|
|
296
|
+
expect(output).to include("Facts extracted: 10")
|
|
297
|
+
expect(output).to include("Efficiency: 10.0 facts/1k tokens")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
context "with --format json" do
|
|
301
|
+
it "outputs JSON format" do
|
|
302
|
+
command.call(["--format", "json"])
|
|
303
|
+
|
|
304
|
+
output = JSON.parse(stdout.string)
|
|
305
|
+
expect(output["total_input_tokens"]).to eq(1000)
|
|
306
|
+
expect(output["efficiency"]).to eq(10.0)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
context "with invalid option" do
|
|
311
|
+
it "returns error code" do
|
|
312
|
+
expect(command.call(["--invalid"])).to eq(1)
|
|
313
|
+
expect(stderr.string).to include("Error:")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# spec/claude_memory/commands/metrics_calculator_spec.rb
|
|
320
|
+
RSpec.describe ClaudeMemory::Commands::MetricsCalculator do
|
|
321
|
+
let(:store) { instance_double(ClaudeMemory::Store::SQLiteStore) }
|
|
322
|
+
let(:metrics_dataset) { double("metrics_dataset") }
|
|
323
|
+
let(:calculator) { described_class.new(store) }
|
|
324
|
+
|
|
325
|
+
before do
|
|
326
|
+
allow(store).to receive(:ingestion_metrics).and_return(metrics_dataset)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
describe "#calculate" do
|
|
330
|
+
context "with data" do
|
|
331
|
+
before do
|
|
332
|
+
allow(metrics_dataset).to receive(:sum).with(:input_tokens).and_return(2000)
|
|
333
|
+
allow(metrics_dataset).to receive(:sum).with(:output_tokens).and_return(1000)
|
|
334
|
+
allow(metrics_dataset).to receive(:sum).with(:facts_extracted).and_return(20)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it "calculates efficiency correctly" do
|
|
338
|
+
result = calculator.calculate
|
|
339
|
+
expect(result[:efficiency]).to eq(10.0) # 20 facts / 2000 tokens * 1000
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
context "with zero tokens" do
|
|
344
|
+
before do
|
|
345
|
+
allow(metrics_dataset).to receive(:sum).and_return(0)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it "returns zero efficiency without dividing by zero" do
|
|
349
|
+
result = calculator.calculate
|
|
350
|
+
expect(result[:efficiency]).to eq(0.0)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**Time Estimate**: 30-40 minutes (includes formatter extraction)
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
### Pattern 3: Adding Columns to Existing Table
|
|
362
|
+
|
|
363
|
+
**Use Case**: Session metadata, enhanced tracking
|
|
364
|
+
|
|
365
|
+
**Recipe:**
|
|
366
|
+
```ruby
|
|
367
|
+
# 1. Increment schema version
|
|
368
|
+
SCHEMA_VERSION = 8
|
|
369
|
+
|
|
370
|
+
# 2. Add migration
|
|
371
|
+
def migrate_to_v8!
|
|
372
|
+
@db.alter_table :content_items do
|
|
373
|
+
add_column :git_branch, String
|
|
374
|
+
add_column :cwd, String
|
|
375
|
+
add_column :claude_version, String
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# 3. Create parameter object for content item data
|
|
380
|
+
# ✅ Sandi Metz: Parameter object reduces method signature complexity
|
|
381
|
+
# lib/claude_memory/domain/content_item_params.rb
|
|
382
|
+
module ClaudeMemory
|
|
383
|
+
module Domain
|
|
384
|
+
class ContentItemParams
|
|
385
|
+
attr_reader :source, :text_hash, :byte_len, :session_id,
|
|
386
|
+
:transcript_path, :git_branch, :cwd, :claude_version
|
|
387
|
+
|
|
388
|
+
def initialize(source:, text_hash:, byte_len:, **optional)
|
|
389
|
+
@source = source
|
|
390
|
+
@text_hash = text_hash
|
|
391
|
+
@byte_len = byte_len
|
|
392
|
+
@session_id = optional[:session_id]
|
|
393
|
+
@transcript_path = optional[:transcript_path]
|
|
394
|
+
@git_branch = optional[:git_branch]
|
|
395
|
+
@cwd = optional[:cwd]
|
|
396
|
+
@claude_version = optional[:claude_version]
|
|
397
|
+
|
|
398
|
+
freeze # ✅ Gary Bernhardt: Immutable value object
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def to_h
|
|
402
|
+
{
|
|
403
|
+
source: source,
|
|
404
|
+
text_hash: text_hash,
|
|
405
|
+
byte_len: byte_len,
|
|
406
|
+
session_id: session_id,
|
|
407
|
+
transcript_path: transcript_path,
|
|
408
|
+
git_branch: git_branch,
|
|
409
|
+
cwd: cwd,
|
|
410
|
+
claude_version: claude_version,
|
|
411
|
+
ingested_at: Time.now.utc # ✅ Jeremy Evans: Use Time object
|
|
412
|
+
}
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# 4. Update insert method to use parameter object
|
|
419
|
+
def upsert_content_item(params)
|
|
420
|
+
# ✅ Avdi Grimm: Accept parameter object or hash
|
|
421
|
+
params = Domain::ContentItemParams.new(**params) unless params.is_a?(Domain::ContentItemParams)
|
|
422
|
+
|
|
423
|
+
@db[:content_items].insert_conflict(
|
|
424
|
+
target: [:session_id, :text_hash],
|
|
425
|
+
update: { ingested_at: Sequel.function(:datetime, 'now') }
|
|
426
|
+
).insert(params.to_h)
|
|
427
|
+
end
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
**Expert Notes:**
|
|
431
|
+
- ✅ **Sandi Metz**: Parameter object eliminates long parameter lists
|
|
432
|
+
- ✅ **Gary Bernhardt**: Immutable value object (frozen)
|
|
433
|
+
- ✅ **Jeremy Evans**: Use Time objects instead of ISO8601 strings
|
|
434
|
+
- ✅ **Avdi Grimm**: Duck typing - accepts object or hash
|
|
435
|
+
|
|
436
|
+
**Tests:**
|
|
437
|
+
```ruby
|
|
438
|
+
RSpec.describe ClaudeMemory::Domain::ContentItemParams do
|
|
439
|
+
describe "#initialize" do
|
|
440
|
+
it "accepts required parameters" do
|
|
441
|
+
params = described_class.new(
|
|
442
|
+
source: "test",
|
|
443
|
+
text_hash: "abc123",
|
|
444
|
+
byte_len: 100
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
expect(params.source).to eq("test")
|
|
448
|
+
expect(params.text_hash).to eq("abc123")
|
|
449
|
+
expect(params.byte_len).to eq(100)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
it "accepts optional parameters" do
|
|
453
|
+
params = described_class.new(
|
|
454
|
+
source: "test",
|
|
455
|
+
text_hash: "abc123",
|
|
456
|
+
byte_len: 100,
|
|
457
|
+
git_branch: "main",
|
|
458
|
+
cwd: "/path/to/project"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
expect(params.git_branch).to eq("main")
|
|
462
|
+
expect(params.cwd).to eq("/path/to/project")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
it "creates immutable object" do
|
|
466
|
+
params = described_class.new(
|
|
467
|
+
source: "test",
|
|
468
|
+
text_hash: "abc123",
|
|
469
|
+
byte_len: 100
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
expect(params).to be_frozen
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
describe "#to_h" do
|
|
477
|
+
it "converts to hash with timestamp" do
|
|
478
|
+
params = described_class.new(
|
|
479
|
+
source: "test",
|
|
480
|
+
text_hash: "abc123",
|
|
481
|
+
byte_len: 100,
|
|
482
|
+
git_branch: "feature/test"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
hash = params.to_h
|
|
486
|
+
|
|
487
|
+
expect(hash[:source]).to eq("test")
|
|
488
|
+
expect(hash[:git_branch]).to eq("feature/test")
|
|
489
|
+
expect(hash[:ingested_at]).to be_a(Time)
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
RSpec.describe "upsert_content_item with new columns" do
|
|
495
|
+
it "stores metadata using parameter object" do
|
|
496
|
+
params = ClaudeMemory::Domain::ContentItemParams.new(
|
|
497
|
+
source: "test",
|
|
498
|
+
text_hash: "abc123",
|
|
499
|
+
byte_len: 100,
|
|
500
|
+
git_branch: "feature/test",
|
|
501
|
+
cwd: "/path/to/project"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
id = store.upsert_content_item(params)
|
|
505
|
+
item = store.content_items.where(id: id).first
|
|
506
|
+
|
|
507
|
+
expect(item[:git_branch]).to eq("feature/test")
|
|
508
|
+
expect(item[:cwd]).to eq("/path/to/project")
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
it "accepts hash for backward compatibility" do
|
|
512
|
+
id = store.upsert_content_item(
|
|
513
|
+
source: "test",
|
|
514
|
+
text_hash: "def456",
|
|
515
|
+
byte_len: 200,
|
|
516
|
+
git_branch: "main"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
item = store.content_items.where(id: id).first
|
|
520
|
+
expect(item[:git_branch]).to eq("main")
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
**Time Estimate**: 20-25 minutes (includes parameter object)
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
### Pattern 4: Enhancing Statistics Output
|
|
530
|
+
|
|
531
|
+
**Use Case**: Better reporting, ROI metrics, aggregations
|
|
532
|
+
|
|
533
|
+
**Recipe:**
|
|
534
|
+
```ruby
|
|
535
|
+
# ✅ Gary Bernhardt: Pure statistics calculator, no I/O
|
|
536
|
+
# lib/claude_memory/domain/statistics_calculator.rb
|
|
537
|
+
module ClaudeMemory
|
|
538
|
+
module Domain
|
|
539
|
+
class StatisticsCalculator
|
|
540
|
+
def initialize(metrics_data)
|
|
541
|
+
@metrics_data = metrics_data
|
|
542
|
+
freeze
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def calculate
|
|
546
|
+
Statistics.new(
|
|
547
|
+
total_input_tokens: total_input_tokens,
|
|
548
|
+
total_output_tokens: total_output_tokens,
|
|
549
|
+
total_facts: total_facts,
|
|
550
|
+
efficiency: efficiency
|
|
551
|
+
)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
private
|
|
555
|
+
|
|
556
|
+
def total_input_tokens
|
|
557
|
+
@metrics_data.sum { |m| m[:input_tokens] }
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def total_output_tokens
|
|
561
|
+
@metrics_data.sum { |m| m[:output_tokens] }
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def total_facts
|
|
565
|
+
@metrics_data.sum { |m| m[:facts_extracted] }
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def efficiency
|
|
569
|
+
return 0.0 if total_input_tokens.zero?
|
|
570
|
+
|
|
571
|
+
(total_facts.to_f / total_input_tokens * 1000).round(2)
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# ✅ Avdi Grimm: Result object instead of hash
|
|
576
|
+
class Statistics
|
|
577
|
+
attr_reader :total_input_tokens, :total_output_tokens, :total_facts, :efficiency
|
|
578
|
+
|
|
579
|
+
def initialize(total_input_tokens:, total_output_tokens:, total_facts:, efficiency:)
|
|
580
|
+
@total_input_tokens = total_input_tokens
|
|
581
|
+
@total_output_tokens = total_output_tokens
|
|
582
|
+
@total_facts = total_facts
|
|
583
|
+
@efficiency = efficiency
|
|
584
|
+
freeze
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def efficient?
|
|
588
|
+
efficiency > 5.0 # More than 5 facts per 1k tokens
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def to_h
|
|
592
|
+
{
|
|
593
|
+
total_input_tokens: total_input_tokens,
|
|
594
|
+
total_output_tokens: total_output_tokens,
|
|
595
|
+
total_facts: total_facts,
|
|
596
|
+
efficiency: efficiency
|
|
597
|
+
}
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# ✅ Sandi Metz: Small methods, single responsibility
|
|
604
|
+
# lib/claude_memory/commands/stats_command.rb
|
|
605
|
+
module ClaudeMemory
|
|
606
|
+
module Commands
|
|
607
|
+
class StatsCommand < BaseCommand
|
|
608
|
+
def call(args)
|
|
609
|
+
manager = Store::StoreManager.new
|
|
610
|
+
|
|
611
|
+
display_basic_stats(manager)
|
|
612
|
+
display_metrics_stats(manager) if has_metrics?(manager)
|
|
613
|
+
|
|
614
|
+
0
|
|
615
|
+
ensure
|
|
616
|
+
manager&.close
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
private
|
|
620
|
+
|
|
621
|
+
def display_basic_stats(manager)
|
|
622
|
+
stdout.puts "Facts: #{count_facts(manager)}"
|
|
623
|
+
stdout.puts "Entities: #{count_entities(manager)}"
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def display_metrics_stats(manager)
|
|
627
|
+
stats = calculate_statistics(manager)
|
|
628
|
+
formatter = StatisticsFormatter.new(stdout)
|
|
629
|
+
formatter.format(stats)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def has_metrics?(manager)
|
|
633
|
+
manager.global_store.db.table_exists?(:ingestion_metrics)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def calculate_statistics(manager)
|
|
637
|
+
metrics_data = manager.global_store.ingestion_metrics.all
|
|
638
|
+
Domain::StatisticsCalculator.new(metrics_data).calculate
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def count_facts(manager)
|
|
642
|
+
manager.global_store.facts.count
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def count_entities(manager)
|
|
646
|
+
manager.global_store.entities.count
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# ✅ Sandi Metz: Formatter has single responsibility
|
|
651
|
+
class StatisticsFormatter
|
|
652
|
+
def initialize(output)
|
|
653
|
+
@output = output
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def format(statistics)
|
|
657
|
+
@output.puts "\nToken Economics:"
|
|
658
|
+
@output.puts " Input tokens: #{statistics.total_input_tokens}"
|
|
659
|
+
@output.puts " Output tokens: #{statistics.total_output_tokens}"
|
|
660
|
+
@output.puts " Facts extracted: #{statistics.total_facts}"
|
|
661
|
+
@output.puts " Efficiency: #{statistics.efficiency} facts/1k tokens"
|
|
662
|
+
@output.puts " Status: #{efficiency_status(statistics)}"
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
private
|
|
666
|
+
|
|
667
|
+
def efficiency_status(statistics)
|
|
668
|
+
statistics.efficient? ? "Good" : "Could be improved"
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
**Expert Notes:**
|
|
676
|
+
- ✅ **Gary Bernhardt**: Pure calculator (no I/O), data passed in
|
|
677
|
+
- ✅ **Avdi Grimm**: Statistics result object with behavior (efficient?)
|
|
678
|
+
- ✅ **Sandi Metz**: Small classes with single responsibility
|
|
679
|
+
- ✅ **Kent Beck**: Calculator can be tested without database
|
|
680
|
+
|
|
681
|
+
**Tests:**
|
|
682
|
+
```ruby
|
|
683
|
+
RSpec.describe ClaudeMemory::Domain::StatisticsCalculator do
|
|
684
|
+
describe "#calculate" do
|
|
685
|
+
let(:metrics_data) do
|
|
686
|
+
[
|
|
687
|
+
{ input_tokens: 1000, output_tokens: 500, facts_extracted: 10 },
|
|
688
|
+
{ input_tokens: 2000, output_tokens: 1000, facts_extracted: 15 }
|
|
689
|
+
]
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
it "calculates totals correctly" do
|
|
693
|
+
calculator = described_class.new(metrics_data)
|
|
694
|
+
stats = calculator.calculate
|
|
695
|
+
|
|
696
|
+
expect(stats.total_input_tokens).to eq(3000)
|
|
697
|
+
expect(stats.total_output_tokens).to eq(1500)
|
|
698
|
+
expect(stats.total_facts).to eq(25)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
it "calculates efficiency as facts per 1k tokens" do
|
|
702
|
+
calculator = described_class.new(metrics_data)
|
|
703
|
+
stats = calculator.calculate
|
|
704
|
+
|
|
705
|
+
# 25 facts / 3000 tokens * 1000 = 8.33
|
|
706
|
+
expect(stats.efficiency).to eq(8.33)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
context "with no data" do
|
|
710
|
+
it "returns zero efficiency" do
|
|
711
|
+
calculator = described_class.new([])
|
|
712
|
+
stats = calculator.calculate
|
|
713
|
+
|
|
714
|
+
expect(stats.efficiency).to eq(0.0)
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
RSpec.describe ClaudeMemory::Domain::Statistics do
|
|
721
|
+
describe "#efficient?" do
|
|
722
|
+
it "returns true when efficiency > 5.0" do
|
|
723
|
+
stats = described_class.new(
|
|
724
|
+
total_input_tokens: 1000,
|
|
725
|
+
total_output_tokens: 500,
|
|
726
|
+
total_facts: 10,
|
|
727
|
+
efficiency: 10.0
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
expect(stats).to be_efficient
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
it "returns false when efficiency <= 5.0" do
|
|
734
|
+
stats = described_class.new(
|
|
735
|
+
total_input_tokens: 1000,
|
|
736
|
+
total_output_tokens: 500,
|
|
737
|
+
total_facts: 3,
|
|
738
|
+
efficiency: 3.0
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
expect(stats).not_to be_efficient
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
**Time Estimate**: 30-35 minutes (includes result object)
|
|
748
|
+
|
|
749
|
+
---
|
|
750
|
+
|
|
751
|
+
### Pattern 5: Adding Command Line Flags
|
|
752
|
+
|
|
753
|
+
**Use Case**: --async, --verbose, --format options
|
|
754
|
+
|
|
755
|
+
**Recipe:**
|
|
756
|
+
```ruby
|
|
757
|
+
# ✅ Sandi Metz: Small, focused methods
|
|
758
|
+
module ClaudeMemory
|
|
759
|
+
module Commands
|
|
760
|
+
class ConfigurableCommand < BaseCommand
|
|
761
|
+
def call(args)
|
|
762
|
+
opts = parse_options(args)
|
|
763
|
+
return 1 if opts.nil?
|
|
764
|
+
|
|
765
|
+
execute_with_options(opts)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
private
|
|
769
|
+
|
|
770
|
+
def parse_options(args)
|
|
771
|
+
opts = default_options
|
|
772
|
+
|
|
773
|
+
OptionParser.new do |parser|
|
|
774
|
+
configure_parser(parser, opts)
|
|
775
|
+
end.parse!(args)
|
|
776
|
+
|
|
777
|
+
opts
|
|
778
|
+
rescue OptionParser::InvalidOption => e
|
|
779
|
+
stderr.puts "Error: #{e.message}"
|
|
780
|
+
nil
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def configure_parser(parser, opts)
|
|
784
|
+
parser.banner = "Usage: claude-memory command [options]"
|
|
785
|
+
parser.on("--async", "Run in background") { opts[:async] = true }
|
|
786
|
+
parser.on("--verbose", "Verbose output") { opts[:verbose] = true }
|
|
787
|
+
parser.on("--format FORMAT", ["text", "json"], "Output format") do |f|
|
|
788
|
+
opts[:format] = f
|
|
789
|
+
end
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def default_options
|
|
793
|
+
{ async: false, verbose: false, format: "text" }
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# ✅ Kent Beck: Method name reveals intent
|
|
797
|
+
def execute_with_options(opts)
|
|
798
|
+
if opts[:async]
|
|
799
|
+
execute_in_background(opts)
|
|
800
|
+
else
|
|
801
|
+
execute_synchronously(opts)
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def execute_in_background(opts)
|
|
806
|
+
BackgroundExecutor.new(stdout).execute do
|
|
807
|
+
perform_work(opts)
|
|
808
|
+
end
|
|
809
|
+
0
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def execute_synchronously(opts)
|
|
813
|
+
result = perform_work(opts)
|
|
814
|
+
output_result(result, opts)
|
|
815
|
+
0
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def perform_work(opts)
|
|
819
|
+
# Actual work implementation
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def output_result(result, opts)
|
|
823
|
+
# Output formatting
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
# ✅ Sandi Metz: Extract background execution to separate class
|
|
828
|
+
class BackgroundExecutor
|
|
829
|
+
def initialize(output)
|
|
830
|
+
@output = output
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def execute(&block)
|
|
834
|
+
pid = Process.fork(&block)
|
|
835
|
+
Process.detach(pid)
|
|
836
|
+
@output.puts "Running in background (PID: #{pid})"
|
|
837
|
+
rescue NotImplementedError
|
|
838
|
+
# Windows doesn't support fork
|
|
839
|
+
@output.puts "Background execution not supported on this platform"
|
|
840
|
+
block.call
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
**Expert Notes:**
|
|
848
|
+
- ✅ **Sandi Metz**: Small methods, background executor extracted
|
|
849
|
+
- ✅ **Kent Beck**: Clear method names (execute_in_background vs execute_synchronously)
|
|
850
|
+
- ✅ **Gary Bernhardt**: Separation of concerns
|
|
851
|
+
|
|
852
|
+
**Tests:**
|
|
853
|
+
```ruby
|
|
854
|
+
RSpec.describe ClaudeMemory::Commands::BackgroundExecutor do
|
|
855
|
+
let(:output) { StringIO.new }
|
|
856
|
+
let(:executor) { described_class.new(output) }
|
|
857
|
+
|
|
858
|
+
describe "#execute" do
|
|
859
|
+
it "forks process and reports PID" do
|
|
860
|
+
allow(Process).to receive(:fork).and_yield.and_return(12345)
|
|
861
|
+
allow(Process).to receive(:detach)
|
|
862
|
+
|
|
863
|
+
work_done = false
|
|
864
|
+
executor.execute { work_done = true }
|
|
865
|
+
|
|
866
|
+
expect(work_done).to be true
|
|
867
|
+
expect(output.string).to include("PID: 12345")
|
|
868
|
+
expect(Process).to have_received(:detach).with(12345)
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
context "on Windows" do
|
|
872
|
+
it "executes synchronously when fork not available" do
|
|
873
|
+
allow(Process).to receive(:fork).and_raise(NotImplementedError)
|
|
874
|
+
|
|
875
|
+
work_done = false
|
|
876
|
+
executor.execute { work_done = true }
|
|
877
|
+
|
|
878
|
+
expect(work_done).to be true
|
|
879
|
+
expect(output.string).to include("not supported")
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
**Time Estimate**: 20-25 minutes
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
### Pattern 6: Background Processing (Simple Fork)
|
|
891
|
+
|
|
892
|
+
**Use Case**: Non-blocking hook execution
|
|
893
|
+
|
|
894
|
+
**Recipe:**
|
|
895
|
+
```ruby
|
|
896
|
+
# lib/claude_memory/commands/hook_ingest_command.rb
|
|
897
|
+
module ClaudeMemory
|
|
898
|
+
module Commands
|
|
899
|
+
class HookIngestCommand < BaseCommand
|
|
900
|
+
def call(args)
|
|
901
|
+
opts = parse_options(args)
|
|
902
|
+
return 1 if opts.nil?
|
|
903
|
+
|
|
904
|
+
payload = read_stdin
|
|
905
|
+
|
|
906
|
+
if opts[:async]
|
|
907
|
+
execute_async(payload, opts)
|
|
908
|
+
else
|
|
909
|
+
execute_sync(payload, opts)
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
private
|
|
914
|
+
|
|
915
|
+
def execute_async(payload, opts)
|
|
916
|
+
# ✅ Gary Bernhardt: Use Configuration for paths
|
|
917
|
+
log_path = LogPath.for_async_operation
|
|
918
|
+
|
|
919
|
+
BackgroundIngester.new(log_path, stdout).ingest(payload)
|
|
920
|
+
0
|
|
921
|
+
rescue => e
|
|
922
|
+
stderr.puts "Failed to start background process: #{e.message}"
|
|
923
|
+
1
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def execute_sync(payload, opts)
|
|
927
|
+
result = perform_ingestion(payload)
|
|
928
|
+
stdout.puts "Ingested: #{result.facts_count} facts"
|
|
929
|
+
0
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def perform_ingestion(payload)
|
|
933
|
+
manager = Store::StoreManager.new
|
|
934
|
+
ingester = Ingest::Ingester.new(manager)
|
|
935
|
+
result = ingester.ingest(payload)
|
|
936
|
+
manager.close
|
|
937
|
+
result
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
# ✅ Sandi Metz: Extract background logic to separate class
|
|
942
|
+
class BackgroundIngester
|
|
943
|
+
def initialize(log_path, output)
|
|
944
|
+
@log_path = log_path
|
|
945
|
+
@output = output
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def ingest(payload)
|
|
949
|
+
pid = fork_and_ingest(payload)
|
|
950
|
+
Process.detach(pid)
|
|
951
|
+
report_started(pid)
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
private
|
|
955
|
+
|
|
956
|
+
def fork_and_ingest(payload)
|
|
957
|
+
Process.fork do
|
|
958
|
+
redirect_output_to_log
|
|
959
|
+
perform_ingestion(payload)
|
|
960
|
+
exit 0
|
|
961
|
+
end
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
def redirect_output_to_log
|
|
965
|
+
$stdout.reopen(@log_path, "a")
|
|
966
|
+
$stderr.reopen(@log_path, "a")
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
def perform_ingestion(payload)
|
|
970
|
+
manager = Store::StoreManager.new
|
|
971
|
+
ingester = Ingest::Ingester.new(manager)
|
|
972
|
+
ingester.ingest(payload)
|
|
973
|
+
manager.close
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def report_started(pid)
|
|
977
|
+
@output.puts "Ingestion started in background (PID: #{pid})"
|
|
978
|
+
@output.puts "Logs: #{@log_path}"
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
# ✅ Gary Bernhardt: Use Configuration for path resolution
|
|
983
|
+
class LogPath
|
|
984
|
+
def self.for_async_operation
|
|
985
|
+
if Configuration.project_dir
|
|
986
|
+
File.join(Configuration.project_dir, ".claude", "memory_ingest.log")
|
|
987
|
+
else
|
|
988
|
+
File.join(Configuration.home_dir, ".claude", "memory_ingest.log")
|
|
989
|
+
end
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
**Expert Notes:**
|
|
997
|
+
- ✅ **Sandi Metz**: Background ingester is separate class with single responsibility
|
|
998
|
+
- ✅ **Gary Bernhardt**: Configuration used instead of direct ENV access
|
|
999
|
+
- ✅ **Kent Beck**: Small methods with clear purpose
|
|
1000
|
+
- ✅ **Avdi Grimm**: Confident code, no nil checks needed
|
|
1001
|
+
|
|
1002
|
+
**Tests:**
|
|
1003
|
+
```ruby
|
|
1004
|
+
RSpec.describe ClaudeMemory::Commands::BackgroundIngester do
|
|
1005
|
+
let(:log_path) { "/tmp/test.log" }
|
|
1006
|
+
let(:output) { StringIO.new }
|
|
1007
|
+
let(:ingester) { described_class.new(log_path, output) }
|
|
1008
|
+
|
|
1009
|
+
describe "#ingest" do
|
|
1010
|
+
let(:payload) { { transcript_delta: "test content" } }
|
|
1011
|
+
|
|
1012
|
+
it "forks process and detaches" do
|
|
1013
|
+
allow(Process).to receive(:fork).and_yield.and_return(99999)
|
|
1014
|
+
allow(Process).to receive(:detach)
|
|
1015
|
+
|
|
1016
|
+
# Mock the actual ingestion
|
|
1017
|
+
allow_any_instance_of(ClaudeMemory::Ingest::Ingester)
|
|
1018
|
+
.to receive(:ingest)
|
|
1019
|
+
.and_return(double(facts_count: 5))
|
|
1020
|
+
|
|
1021
|
+
ingester.ingest(payload)
|
|
1022
|
+
|
|
1023
|
+
expect(Process).to have_received(:fork)
|
|
1024
|
+
expect(Process).to have_received(:detach).with(99999)
|
|
1025
|
+
expect(output.string).to include("PID: 99999")
|
|
1026
|
+
expect(output.string).to include("Logs: #{log_path}")
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
RSpec.describe ClaudeMemory::Commands::LogPath do
|
|
1032
|
+
describe ".for_async_operation" do
|
|
1033
|
+
context "in project directory" do
|
|
1034
|
+
before do
|
|
1035
|
+
allow(ClaudeMemory::Configuration).to receive(:project_dir)
|
|
1036
|
+
.and_return("/path/to/project")
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
it "returns project log path" do
|
|
1040
|
+
path = described_class.for_async_operation
|
|
1041
|
+
expect(path).to eq("/path/to/project/.claude/memory_ingest.log")
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
context "outside project directory" do
|
|
1046
|
+
before do
|
|
1047
|
+
allow(ClaudeMemory::Configuration).to receive(:project_dir).and_return(nil)
|
|
1048
|
+
allow(ClaudeMemory::Configuration).to receive(:home_dir).and_return("/home/user")
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
it "returns home directory log path" do
|
|
1052
|
+
path = described_class.for_async_operation
|
|
1053
|
+
expect(path).to eq("/home/user/.claude/memory_ingest.log")
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
**Important Notes:**
|
|
1061
|
+
- Test fork behavior with mocks
|
|
1062
|
+
- Windows doesn't support fork (consider fallback)
|
|
1063
|
+
- Always detach process to avoid zombies
|
|
1064
|
+
- Use Configuration for path resolution
|
|
1065
|
+
|
|
1066
|
+
**Time Estimate**: 45-60 minutes
|
|
1067
|
+
|
|
1068
|
+
---
|
|
1069
|
+
|
|
1070
|
+
## Expert Principles Applied
|
|
1071
|
+
|
|
1072
|
+
### Sandi Metz (POODR)
|
|
1073
|
+
- ✅ Small methods (< 5 lines ideal)
|
|
1074
|
+
- ✅ Single responsibility per class
|
|
1075
|
+
- ✅ Parameter objects for long parameter lists
|
|
1076
|
+
- ✅ Extract formatters, calculators, executors
|
|
1077
|
+
|
|
1078
|
+
### Jeremy Evans (Sequel)
|
|
1079
|
+
- ✅ DateTime columns instead of String timestamps
|
|
1080
|
+
- ✅ Proper Sequel dataset usage
|
|
1081
|
+
- ✅ Transaction safety where needed
|
|
1082
|
+
|
|
1083
|
+
### Kent Beck (TDD, Simple Design)
|
|
1084
|
+
- ✅ Method names reveal intent
|
|
1085
|
+
- ✅ Testable design (dependency injection)
|
|
1086
|
+
- ✅ Test edge cases (zero values, errors)
|
|
1087
|
+
|
|
1088
|
+
### Avdi Grimm (Confident Ruby)
|
|
1089
|
+
- ✅ Result objects instead of hashes
|
|
1090
|
+
- ✅ No nil checks (use null objects if needed)
|
|
1091
|
+
- ✅ Duck typing (accept object or hash)
|
|
1092
|
+
- ✅ Immutable value objects (frozen)
|
|
1093
|
+
|
|
1094
|
+
### Gary Bernhardt (Boundaries)
|
|
1095
|
+
- ✅ Pure calculators (no I/O in logic)
|
|
1096
|
+
- ✅ Dependency injection for testability
|
|
1097
|
+
- ✅ Configuration class for ENV access
|
|
1098
|
+
- ✅ Ensure resource cleanup (ensure blocks)
|
|
1099
|
+
|
|
1100
|
+
---
|
|
1101
|
+
|
|
1102
|
+
## Feature Complexity Assessment
|
|
1103
|
+
|
|
1104
|
+
### Quick Assessment Checklist
|
|
1105
|
+
|
|
1106
|
+
**Low Complexity** (15-30 min):
|
|
1107
|
+
- [ ] Pure Ruby, no external dependencies
|
|
1108
|
+
- [ ] Clear, well-defined requirements
|
|
1109
|
+
- [ ] Existing patterns to follow
|
|
1110
|
+
- [ ] Straightforward testing
|
|
1111
|
+
|
|
1112
|
+
**Medium Complexity** (30-60 min):
|
|
1113
|
+
- [ ] Requires new gem dependency
|
|
1114
|
+
- [ ] Some architectural decisions needed
|
|
1115
|
+
- [ ] Background processing (simple fork)
|
|
1116
|
+
- [ ] Multiple files affected
|
|
1117
|
+
|
|
1118
|
+
**High Complexity** (60+ min or skip):
|
|
1119
|
+
- [ ] External services required
|
|
1120
|
+
- [ ] Daemon/worker management
|
|
1121
|
+
- [ ] Web UI components
|
|
1122
|
+
- [ ] Cross-platform compatibility issues
|
|
1123
|
+
- [ ] Security-critical code
|
|
1124
|
+
|
|
1125
|
+
### Common Pitfalls & Solutions
|
|
1126
|
+
|
|
1127
|
+
1. **String Timestamps**
|
|
1128
|
+
- ❌ Problem: `String :created_at`
|
|
1129
|
+
- ✅ Solution: `DateTime :created_at`
|
|
1130
|
+
|
|
1131
|
+
2. **Direct ENV Access**
|
|
1132
|
+
- ❌ Problem: `ENV["CLAUDE_PROJECT_DIR"]`
|
|
1133
|
+
- ✅ Solution: `Configuration.project_dir`
|
|
1134
|
+
|
|
1135
|
+
3. **Long Parameter Lists**
|
|
1136
|
+
- ❌ Problem: 7+ parameters
|
|
1137
|
+
- ✅ Solution: Parameter object
|
|
1138
|
+
|
|
1139
|
+
4. **Creating Dependencies in Methods**
|
|
1140
|
+
- ❌ Problem: `store = Store.new` inside method
|
|
1141
|
+
- ✅ Solution: Inject dependency
|
|
1142
|
+
|
|
1143
|
+
5. **No Resource Cleanup**
|
|
1144
|
+
- ❌ Problem: `manager.close` can be skipped
|
|
1145
|
+
- ✅ Solution: `ensure` block
|
|
1146
|
+
|
|
1147
|
+
6. **Nil Checks Everywhere**
|
|
1148
|
+
- ❌ Problem: `return nil unless x`
|
|
1149
|
+
- ✅ Solution: Result objects or null objects
|
|
1150
|
+
|
|
1151
|
+
7. **Mixed I/O and Logic**
|
|
1152
|
+
- ❌ Problem: Database queries in calculator
|
|
1153
|
+
- ✅ Solution: Pass data to pure calculator
|
|
1154
|
+
|
|
1155
|
+
8. **Vague Method Names**
|
|
1156
|
+
- ❌ Problem: `do_something`, `process`
|
|
1157
|
+
- ✅ Solution: `calculate_efficiency`, `execute_in_background`
|
|
1158
|
+
|
|
1159
|
+
---
|
|
1160
|
+
|
|
1161
|
+
## When to Split into Multiple Commits
|
|
1162
|
+
|
|
1163
|
+
**Split when:**
|
|
1164
|
+
- Schema change + feature implementation (2 commits)
|
|
1165
|
+
- Core feature + CLI command (2 commits)
|
|
1166
|
+
- Multiple independent enhancements (separate commits)
|
|
1167
|
+
|
|
1168
|
+
**Keep together when:**
|
|
1169
|
+
- Feature + tests (same commit)
|
|
1170
|
+
- Command + help text (same commit)
|
|
1171
|
+
- Implementation + error handling (same commit)
|
|
1172
|
+
- Parameter object + method using it (same commit)
|
|
1173
|
+
|
|
1174
|
+
---
|
|
1175
|
+
|
|
1176
|
+
## Testing Strategies
|
|
1177
|
+
|
|
1178
|
+
### Pure Calculators (Fast)
|
|
1179
|
+
```ruby
|
|
1180
|
+
# No mocks needed - pure logic
|
|
1181
|
+
it "calculates efficiency" do
|
|
1182
|
+
calculator = StatisticsCalculator.new(data)
|
|
1183
|
+
expect(calculator.calculate.efficiency).to eq(10.0)
|
|
1184
|
+
end
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
### Commands (With Dependency Injection)
|
|
1188
|
+
```ruby
|
|
1189
|
+
# Inject test doubles
|
|
1190
|
+
let(:store_manager) { instance_double(Store::StoreManager) }
|
|
1191
|
+
let(:command) { described_class.new(store_manager: store_manager) }
|
|
1192
|
+
|
|
1193
|
+
it "uses injected store manager" do
|
|
1194
|
+
command.call([])
|
|
1195
|
+
expect(store_manager).to have_received(:global_store)
|
|
1196
|
+
end
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
### Resource Cleanup
|
|
1200
|
+
```ruby
|
|
1201
|
+
it "closes manager even on exception" do
|
|
1202
|
+
allow(store).to receive(:facts).and_raise("DB error")
|
|
1203
|
+
|
|
1204
|
+
expect { command.call([]) }.to raise_error("DB error")
|
|
1205
|
+
expect(manager).to have_received(:close)
|
|
1206
|
+
end
|
|
1207
|
+
```
|
|
1208
|
+
|
|
1209
|
+
### Result Objects
|
|
1210
|
+
```ruby
|
|
1211
|
+
it "returns statistics object with behavior" do
|
|
1212
|
+
stats = calculator.calculate
|
|
1213
|
+
|
|
1214
|
+
expect(stats).to respond_to(:efficient?)
|
|
1215
|
+
expect(stats.efficient?).to be true
|
|
1216
|
+
end
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
---
|
|
1220
|
+
|
|
1221
|
+
**Remember:** These patterns demonstrate best practices from all five experts. Use them as templates when implementing new features with `/improve`.
|