claude_memory 0.8.0 → 0.9.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +32 -2
  4. data/.claude/settings.json +30 -52
  5. data/.claude/settings.local.json +3 -1
  6. data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
  7. data/.claude-plugin/marketplace.json +2 -2
  8. data/.claude-plugin/plugin.json +3 -3
  9. data/.claude-plugin/scripts/hook-runner.sh +14 -0
  10. data/.claude-plugin/scripts/serve-mcp.sh +14 -0
  11. data/.ruby-version +1 -1
  12. data/CHANGELOG.md +41 -0
  13. data/CLAUDE.md +31 -17
  14. data/README.md +35 -0
  15. data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
  16. data/db/migrations/014_canonicalize_predicates.rb +30 -0
  17. data/docs/improvements.md +58 -20
  18. data/docs/influence/claude-mem.md +1 -0
  19. data/docs/influence/claude-supermemory.md +1 -0
  20. data/docs/influence/episodic-memory.md +1 -0
  21. data/docs/influence/grepai.md +1 -0
  22. data/docs/influence/kbs.md +1 -0
  23. data/docs/influence/lossless-claw.md +1 -0
  24. data/docs/influence/qmd.md +1 -0
  25. data/lib/claude_memory/commands/completion_command.rb +1 -31
  26. data/lib/claude_memory/commands/embeddings_command.rb +198 -0
  27. data/lib/claude_memory/commands/help_command.rb +8 -1
  28. data/lib/claude_memory/commands/registry.rb +47 -34
  29. data/lib/claude_memory/commands/reject_command.rb +62 -0
  30. data/lib/claude_memory/commands/restore_command.rb +77 -0
  31. data/lib/claude_memory/commands/skills/distill-transcripts.md +5 -1
  32. data/lib/claude_memory/commands/stats_command.rb +98 -2
  33. data/lib/claude_memory/configuration.rb +14 -1
  34. data/lib/claude_memory/distill/json_schema.md +8 -4
  35. data/lib/claude_memory/distill/null_distiller.rb +2 -0
  36. data/lib/claude_memory/domain/entity.rb +13 -1
  37. data/lib/claude_memory/domain/fact.rb +26 -2
  38. data/lib/claude_memory/embeddings/api_adapter.rb +5 -4
  39. data/lib/claude_memory/embeddings/fastembed_adapter.rb +43 -13
  40. data/lib/claude_memory/embeddings/inspector.rb +91 -0
  41. data/lib/claude_memory/embeddings/model_registry.rb +210 -0
  42. data/lib/claude_memory/embeddings/resolver.rb +32 -6
  43. data/lib/claude_memory/ingest/ingester.rb +17 -0
  44. data/lib/claude_memory/mcp/handlers/management_handlers.rb +24 -0
  45. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +5 -2
  46. data/lib/claude_memory/mcp/instructions_builder.rb +17 -0
  47. data/lib/claude_memory/mcp/server.rb +22 -1
  48. data/lib/claude_memory/mcp/telemetry.rb +86 -0
  49. data/lib/claude_memory/mcp/tool_definitions.rb +86 -3
  50. data/lib/claude_memory/mcp/tools.rb +10 -0
  51. data/lib/claude_memory/publish.rb +40 -5
  52. data/lib/claude_memory/recall.rb +81 -0
  53. data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
  54. data/lib/claude_memory/resolve/resolver.rb +43 -0
  55. data/lib/claude_memory/store/schema_manager.rb +1 -1
  56. data/lib/claude_memory/store/sqlite_store.rb +250 -1
  57. data/lib/claude_memory/store/store_manager.rb +50 -1
  58. data/lib/claude_memory/sweep/maintenance.rb +115 -1
  59. data/lib/claude_memory/sweep/sweeper.rb +3 -0
  60. data/lib/claude_memory/version.rb +1 -1
  61. data/lib/claude_memory.rb +5 -0
  62. metadata +26 -8
  63. data/.claude/memory.sqlite3-shm +0 -0
  64. data/.claude/memory.sqlite3-wal +0 -0
@@ -5,7 +5,7 @@ module ClaudeMemory
5
5
  # Schema migration and version management for SQLiteStore.
6
6
  # Handles Sequel migrations, legacy version syncing, and initial setup.
7
7
  module SchemaManager
8
- SCHEMA_VERSION = 12
8
+ SCHEMA_VERSION = 14
9
9
 
10
10
  private
11
11
 
@@ -11,12 +11,20 @@ require_relative "schema_manager"
11
11
 
12
12
  module ClaudeMemory
13
13
  module Store
14
+ # SQLite-backed fact store for ClaudeMemory.
15
+ # Manages all database tables (content_items, entities, facts, provenance,
16
+ # conflicts, fact_links, etc.) via Sequel with Extralite adapter.
17
+ # Includes RetryHandler for transient lock recovery and SchemaManager
18
+ # for automatic migrations on open.
14
19
  class SQLiteStore
15
20
  include RetryHandler
16
21
  include SchemaManager
17
22
 
23
+ # @return [Sequel::Database] the underlying Sequel database connection
18
24
  attr_reader :db
19
25
 
26
+ # Open (or create) a SQLite database and migrate to the current schema.
27
+ # @param db_path [String] filesystem path to the SQLite database file
20
28
  def initialize(db_path)
21
29
  @db_path = db_path
22
30
  @db = connect_database(db_path)
@@ -24,53 +32,117 @@ module ClaudeMemory
24
32
  ensure_schema!
25
33
  end
26
34
 
35
+ # Disconnect from the database.
36
+ # @return [void]
27
37
  def close
28
38
  @db.disconnect
29
39
  end
30
40
 
41
+ # Lazily-initialized vector index for semantic search.
42
+ # @return [Index::VectorIndex]
31
43
  def vector_index
32
44
  @vector_index ||= Index::VectorIndex.new(self)
33
45
  end
34
46
 
35
- # Checkpoint the WAL file to prevent unlimited growth
47
+ # Checkpoint the WAL file to prevent unlimited growth.
48
+ # @return [void]
36
49
  def checkpoint_wal
37
50
  @db.run("PRAGMA wal_checkpoint(TRUNCATE)")
38
51
  end
39
52
 
53
+ # Current schema version stored in the meta table.
54
+ # @return [Integer, nil]
40
55
  def schema_version
41
56
  @db[:meta].where(key: "schema_version").get(:value)&.to_i
42
57
  end
43
58
 
44
59
  # --- Table accessors ---
60
+ # Each returns a {Sequel::Dataset} bound to the corresponding table.
45
61
 
62
+ # @return [Sequel::Dataset]
46
63
  def content_items = @db[:content_items]
47
64
 
65
+ # @return [Sequel::Dataset]
48
66
  def delta_cursors = @db[:delta_cursors]
49
67
 
68
+ # @return [Sequel::Dataset]
50
69
  def entities = @db[:entities]
51
70
 
71
+ # @return [Sequel::Dataset]
52
72
  def entity_aliases = @db[:entity_aliases]
53
73
 
74
+ # @return [Sequel::Dataset]
54
75
  def facts = @db[:facts]
55
76
 
77
+ # @return [Sequel::Dataset]
56
78
  def provenance = @db[:provenance]
57
79
 
80
+ # @return [Sequel::Dataset]
58
81
  def fact_links = @db[:fact_links]
59
82
 
83
+ # @return [Sequel::Dataset]
60
84
  def conflicts = @db[:conflicts]
61
85
 
86
+ # @return [Sequel::Dataset]
62
87
  def tool_calls = @db[:tool_calls]
63
88
 
89
+ # @return [Sequel::Dataset]
64
90
  def operation_progress = @db[:operation_progress]
65
91
 
92
+ # @return [Sequel::Dataset]
66
93
  def schema_health = @db[:schema_health]
67
94
 
95
+ # @return [Sequel::Dataset]
68
96
  def ingestion_metrics = @db[:ingestion_metrics]
69
97
 
98
+ # @return [Sequel::Dataset]
70
99
  def llm_cache = @db[:llm_cache]
71
100
 
101
+ # @return [Sequel::Dataset]
102
+ def mcp_tool_calls = @db[:mcp_tool_calls]
103
+
104
+ # Record a single MCP tool invocation for telemetry.
105
+ # Inserts synchronously; callers wrap in with_retry at the call site
106
+ # if needed.
107
+ #
108
+ # @param tool_name [String] name of the MCP tool invoked
109
+ # @param duration_ms [Integer] execution time in milliseconds
110
+ # @param result_count [Integer, nil] number of results returned
111
+ # @param scope [String, nil] "global" or "project"
112
+ # @param error_class [String, nil] error class name if the call failed
113
+ # @param called_at [String, nil] ISO 8601 timestamp (defaults to now UTC)
114
+ # @return [Integer] inserted row id
115
+ def insert_mcp_tool_call(tool_name:, duration_ms:, result_count: nil, scope: nil, error_class: nil, called_at: nil)
116
+ mcp_tool_calls.insert(
117
+ tool_name: tool_name,
118
+ called_at: called_at || Time.now.utc.iso8601,
119
+ duration_ms: duration_ms,
120
+ result_count: result_count,
121
+ scope: scope,
122
+ error_class: error_class
123
+ )
124
+ end
125
+
72
126
  # --- Content items ---
73
127
 
128
+ # Insert a content item or return the existing id if a duplicate
129
+ # (same text_hash + session_id) already exists. Wrapped in retry logic.
130
+ #
131
+ # @param source [String] origin type (e.g. "transcript", "hook")
132
+ # @param text_hash [String] SHA-256 hex digest of the raw text
133
+ # @param byte_len [Integer] byte length of the raw text
134
+ # @param session_id [String, nil] Claude Code session identifier
135
+ # @param transcript_path [String, nil] filesystem path to the transcript file
136
+ # @param project_path [String, nil] project directory path
137
+ # @param occurred_at [String, nil] ISO 8601 timestamp (defaults to now UTC)
138
+ # @param raw_text [String, nil] original text content
139
+ # @param metadata [Hash, nil] additional metadata stored as JSON
140
+ # @param git_branch [String, nil] active git branch at ingestion time
141
+ # @param cwd [String, nil] working directory at ingestion time
142
+ # @param claude_version [String, nil] Claude Code version string
143
+ # @param thinking_level [String, nil] thinking level setting
144
+ # @param source_mtime [String, nil] ISO 8601 mtime of the source file
145
+ # @return [Integer] content item row id (existing or newly inserted)
74
146
  def upsert_content_item(source:, text_hash:, byte_len:, session_id: nil, transcript_path: nil,
75
147
  project_path: nil, occurred_at: nil, raw_text: nil, metadata: nil,
76
148
  git_branch: nil, cwd: nil, claude_version: nil, thinking_level: nil, source_mtime: nil)
@@ -99,10 +171,17 @@ module ClaudeMemory
99
171
  end
100
172
  end
101
173
 
174
+ # Fetch a single content item by primary key.
175
+ # @param id [Integer] content item id
176
+ # @return [Hash, nil]
102
177
  def get_content_item(id)
103
178
  content_items.where(id: id).first
104
179
  end
105
180
 
181
+ # Find a content item by transcript path and source modification time.
182
+ # @param transcript_path [String] filesystem path to the transcript
183
+ # @param mtime_iso8601 [String] ISO 8601 modification timestamp
184
+ # @return [Hash, nil]
106
185
  def content_item_by_transcript_and_mtime(transcript_path, mtime_iso8601)
107
186
  content_items
108
187
  .where(transcript_path: transcript_path, source_mtime: mtime_iso8601)
@@ -111,6 +190,12 @@ module ClaudeMemory
111
190
 
112
191
  # --- Tool calls ---
113
192
 
193
+ # Bulk-insert tool call records for a content item.
194
+ # @param content_item_id [Integer] owning content item id
195
+ # @param tool_calls_data [Array<Hash>] tool call hashes with keys
196
+ # :tool_name, :tool_input, :tool_result, :compressed_summary,
197
+ # :is_error, :timestamp
198
+ # @return [void]
114
199
  def insert_tool_calls(content_item_id, tool_calls_data)
115
200
  tool_calls_data.each do |tc|
116
201
  tool_calls.insert(
@@ -125,6 +210,9 @@ module ClaudeMemory
125
210
  end
126
211
  end
127
212
 
213
+ # Retrieve tool calls for a content item, ordered by timestamp.
214
+ # @param content_item_id [Integer] content item id
215
+ # @return [Array<Hash>]
128
216
  def tool_calls_for_content_item(content_item_id)
129
217
  tool_calls
130
218
  .where(content_item_id: content_item_id)
@@ -134,10 +222,19 @@ module ClaudeMemory
134
222
 
135
223
  # --- Delta cursors ---
136
224
 
225
+ # Get the last-read byte offset for a session/transcript pair.
226
+ # @param session_id [String] session identifier
227
+ # @param transcript_path [String] transcript file path
228
+ # @return [Integer, nil] byte offset, or nil if no cursor exists
137
229
  def get_delta_cursor(session_id, transcript_path)
138
230
  delta_cursors.where(session_id: session_id, transcript_path: transcript_path).get(:last_byte_offset)
139
231
  end
140
232
 
233
+ # Create or update the byte-offset cursor for a session/transcript pair.
234
+ # @param session_id [String] session identifier
235
+ # @param transcript_path [String] transcript file path
236
+ # @param offset [Integer] new byte offset
237
+ # @return [void]
141
238
  def update_delta_cursor(session_id, transcript_path, offset)
142
239
  now = Time.now.utc.iso8601
143
240
  delta_cursors
@@ -155,6 +252,10 @@ module ClaudeMemory
155
252
 
156
253
  # --- Entities ---
157
254
 
255
+ # Find an entity by its slug or create a new one.
256
+ # @param type [String] entity type (e.g. "database", "framework", "person")
257
+ # @param name [String] canonical entity name
258
+ # @return [Integer] entity row id
158
259
  def find_or_create_entity(type:, name:)
159
260
  slug = slugify(type, name)
160
261
  existing = entities.where(slug: slug).get(:id)
@@ -166,6 +267,21 @@ module ClaudeMemory
166
267
 
167
268
  # --- Facts ---
168
269
 
270
+ # Insert a new fact (subject-predicate-object triple) with an auto-generated docid.
271
+ #
272
+ # @param subject_entity_id [Integer] entity id for the subject
273
+ # @param predicate [String] predicate label (e.g. "uses_database", "depends_on")
274
+ # @param object_entity_id [Integer, nil] entity id for the object (if entity-valued)
275
+ # @param object_literal [String, nil] literal value for the object
276
+ # @param datatype [String, nil] datatype hint for the object literal
277
+ # @param polarity [String] "positive" or "negative"
278
+ # @param valid_from [String, nil] ISO 8601 validity start (defaults to now UTC)
279
+ # @param status [String] fact status ("active", "superseded", "rejected")
280
+ # @param confidence [Float] confidence score 0.0..1.0
281
+ # @param created_from [String, nil] provenance tag (e.g. "promoted:path:id")
282
+ # @param scope [String] "global" or "project"
283
+ # @param project_path [String, nil] project directory for project-scoped facts
284
+ # @return [Integer] inserted fact row id
169
285
  def insert_fact(subject_entity_id:, predicate:, object_entity_id: nil, object_literal: nil,
170
286
  datatype: nil, polarity: "positive", valid_from: nil, status: "active",
171
287
  confidence: 1.0, created_from: nil, scope: "project", project_path: nil)
@@ -189,10 +305,24 @@ module ClaudeMemory
189
305
  )
190
306
  end
191
307
 
308
+ # Look up a fact by its short document identifier.
309
+ # @param docid [String] 8-character hex document id
310
+ # @return [Hash, nil]
192
311
  def find_fact_by_docid(docid)
193
312
  facts.where(docid: docid).first
194
313
  end
195
314
 
315
+ # Selectively update one or more fields on a fact.
316
+ # Only provided (non-nil) keyword arguments are written. Setting scope
317
+ # to "global" automatically clears project_path.
318
+ #
319
+ # @param fact_id [Integer] fact row id
320
+ # @param status [String, nil] new status value
321
+ # @param valid_to [String, nil] ISO 8601 end-of-validity timestamp
322
+ # @param scope [String, nil] "global" or "project"
323
+ # @param project_path [String, nil] project directory (cleared when scope is "global")
324
+ # @param embedding [Array<Float>, nil] embedding vector to store as JSON
325
+ # @return [Boolean] true if any fields were updated, false if all args were nil
196
326
  def update_fact(fact_id, status: nil, valid_to: nil, scope: nil, project_path: nil, embedding: nil)
197
327
  updates = {}
198
328
  updates[:status] = status if status
@@ -213,10 +343,53 @@ module ClaudeMemory
213
343
  true
214
344
  end
215
345
 
346
+ # Overwrite the embedding vector for a fact.
347
+ # @param fact_id [Integer] fact row id
348
+ # @param embedding_vector [Array<Float>] embedding to store as JSON
349
+ # @return [void]
216
350
  def update_fact_embedding(fact_id, embedding_vector)
217
351
  facts.where(id: fact_id).update(embedding_json: embedding_vector.to_json)
218
352
  end
219
353
 
354
+ # Reject a fact as incorrect (e.g. a distiller hallucination).
355
+ # Sets status to "rejected", closes any open conflicts involving
356
+ # the fact, and records the reason in conflict notes when provided.
357
+ # All updates run in a single transaction.
358
+ #
359
+ # @param fact_id [Integer] fact row id to reject
360
+ # @param reason [String, nil] optional rejection reason appended to conflict notes
361
+ # @return [Hash, nil] +{rejected: true, conflicts_resolved: Integer}+
362
+ # or nil if the fact does not exist
363
+ def reject_fact(fact_id, reason: nil)
364
+ row = facts.where(id: fact_id).first
365
+ return nil unless row
366
+
367
+ now = Time.now.utc.iso8601
368
+ resolved = 0
369
+
370
+ @db.transaction do
371
+ facts.where(id: fact_id).update(status: "rejected", valid_to: now)
372
+
373
+ open_conflict_rows = conflicts
374
+ .where(status: "open")
375
+ .where { (fact_a_id =~ fact_id) | (fact_b_id =~ fact_id) }
376
+ .all
377
+
378
+ open_conflict_rows.each do |conflict|
379
+ suffix = reason ? " | resolved: rejected fact #{fact_id} (#{reason})" : " | resolved: rejected fact #{fact_id}"
380
+ notes = "#{conflict[:notes]}#{suffix}"
381
+ conflicts.where(id: conflict[:id]).update(status: "resolved", notes: notes)
382
+ end
383
+ resolved = open_conflict_rows.size
384
+ end
385
+
386
+ {rejected: true, conflicts_resolved: resolved}
387
+ end
388
+
389
+ # Retrieve active facts that have stored embeddings.
390
+ # @param limit [Integer] maximum rows to return
391
+ # @return [Array<Hash>] fact rows with :id, :subject_entity_id,
392
+ # :predicate, :object_literal, :embedding_json, :scope
220
393
  def facts_with_embeddings(limit: 1000)
221
394
  facts
222
395
  .where(Sequel.~(embedding_json: nil))
@@ -226,6 +399,12 @@ module ClaudeMemory
226
399
  .all
227
400
  end
228
401
 
402
+ # Find all facts for a given subject + predicate combination (a "slot").
403
+ # Used by the resolver to detect supersession and conflicts.
404
+ # @param subject_entity_id [Integer] subject entity id
405
+ # @param predicate [String] predicate label
406
+ # @param status [String] filter by status (default: "active")
407
+ # @return [Array<Hash>]
229
408
  def facts_for_slot(subject_entity_id, predicate, status: "active")
230
409
  facts
231
410
  .where(subject_entity_id: subject_entity_id, predicate: predicate, status: status)
@@ -237,6 +416,16 @@ module ClaudeMemory
237
416
 
238
417
  # --- Provenance ---
239
418
 
419
+ # Record a provenance link between a fact and its source evidence.
420
+ #
421
+ # @param fact_id [Integer] fact row id
422
+ # @param content_item_id [Integer, nil] source content item id
423
+ # @param quote [String, nil] verbatim quote from the source
424
+ # @param attribution_entity_id [Integer, nil] entity who stated the fact
425
+ # @param strength [String] evidence strength ("stated", "inferred", "derived")
426
+ # @param line_start [Integer, nil] starting line in source content
427
+ # @param line_end [Integer, nil] ending line in source content
428
+ # @return [Integer] inserted provenance row id
240
429
  def insert_provenance(fact_id:, content_item_id: nil, quote: nil, attribution_entity_id: nil, strength: "stated",
241
430
  line_start: nil, line_end: nil)
242
431
  provenance.insert(
@@ -250,12 +439,21 @@ module ClaudeMemory
250
439
  )
251
440
  end
252
441
 
442
+ # Retrieve all provenance records for a given fact.
443
+ # @param fact_id [Integer] fact row id
444
+ # @return [Array<Hash>]
253
445
  def provenance_for_fact(fact_id)
254
446
  provenance.where(fact_id: fact_id).all
255
447
  end
256
448
 
257
449
  # --- Conflicts & fact links ---
258
450
 
451
+ # Record a conflict between two facts.
452
+ # @param fact_a_id [Integer] first conflicting fact id
453
+ # @param fact_b_id [Integer] second conflicting fact id
454
+ # @param status [String] conflict status ("open" or "resolved")
455
+ # @param notes [String, nil] human-readable notes about the conflict
456
+ # @return [Integer] inserted conflict row id
259
457
  def insert_conflict(fact_a_id:, fact_b_id:, status: "open", notes: nil)
260
458
  now = Time.now.utc.iso8601
261
459
  conflicts.insert(
@@ -267,16 +465,27 @@ module ClaudeMemory
267
465
  )
268
466
  end
269
467
 
468
+ # Retrieve all unresolved conflicts.
469
+ # @return [Array<Hash>]
270
470
  def open_conflicts
271
471
  conflicts.where(status: "open").all
272
472
  end
273
473
 
474
+ # Create a directional link between two facts (e.g. supersession).
475
+ # @param from_fact_id [Integer] source fact id
476
+ # @param to_fact_id [Integer] target fact id
477
+ # @param link_type [String] relationship type (e.g. "supersedes", "conflicts_with")
478
+ # @return [Integer] inserted fact_link row id
274
479
  def insert_fact_link(from_fact_id:, to_fact_id:, link_type:)
275
480
  fact_links.insert(from_fact_id: from_fact_id, to_fact_id: to_fact_id, link_type: link_type)
276
481
  end
277
482
 
278
483
  # --- Ingestion metrics ---
279
484
 
485
+ # Fetch content items that have not yet been distilled, ordered newest first.
486
+ # @param limit [Integer] maximum rows to return
487
+ # @param min_length [Integer] minimum byte_len threshold
488
+ # @return [Array<Hash>]
280
489
  def undistilled_content_items(limit: 3, min_length: 200)
281
490
  content_items
282
491
  .left_join(:ingestion_metrics, content_item_id: :id)
@@ -288,6 +497,9 @@ module ClaudeMemory
288
497
  .all
289
498
  end
290
499
 
500
+ # Count content items that have not yet been distilled.
501
+ # @param min_length [Integer] minimum byte_len threshold
502
+ # @return [Integer]
291
503
  def count_undistilled(min_length: 200)
292
504
  content_items
293
505
  .left_join(:ingestion_metrics, content_item_id: :id)
@@ -296,6 +508,12 @@ module ClaudeMemory
296
508
  .count
297
509
  end
298
510
 
511
+ # Record token usage and extraction counts for a distillation run.
512
+ # @param content_item_id [Integer] content item that was distilled
513
+ # @param input_tokens [Integer] LLM input tokens consumed
514
+ # @param output_tokens [Integer] LLM output tokens consumed
515
+ # @param facts_extracted [Integer] number of facts extracted
516
+ # @return [Integer] inserted row id
299
517
  def record_ingestion_metrics(content_item_id:, input_tokens:, output_tokens:, facts_extracted:)
300
518
  ingestion_metrics.insert(
301
519
  content_item_id: content_item_id,
@@ -306,6 +524,8 @@ module ClaudeMemory
306
524
  )
307
525
  end
308
526
 
527
+ # Compute aggregate ingestion metrics across all distillation runs.
528
+ # @return [Hash, nil] totals and efficiency ratio, or nil if no data
309
529
  def aggregate_ingestion_metrics
310
530
  # standard:disable Performance/Detect (Sequel DSL requires .select{}.first)
311
531
  result = ingestion_metrics
@@ -338,6 +558,9 @@ module ClaudeMemory
338
558
  }
339
559
  end
340
560
 
561
+ # Mark all undistilled content items as distilled with zero token counts.
562
+ # Used for backfilling legacy content that predates the metrics table.
563
+ # @return [Integer] number of items backfilled
341
564
  def backfill_distillation_metrics!
342
565
  undistilled_ids = content_items
343
566
  .left_join(:ingestion_metrics, content_item_id: :id)
@@ -362,10 +585,21 @@ module ClaudeMemory
362
585
 
363
586
  # --- LLM cache ---
364
587
 
588
+ # Look up a cached LLM result by its cache key.
589
+ # @param cache_key [String] SHA-256 hex cache key
590
+ # @return [Hash, nil]
365
591
  def llm_cache_lookup(cache_key)
366
592
  llm_cache.where(cache_key: cache_key).first
367
593
  end
368
594
 
595
+ # Store or update a cached LLM result. Uses upsert on the cache_key.
596
+ # @param operation [String] operation name (e.g. "distill", "embed")
597
+ # @param model [String] model identifier
598
+ # @param input_hash [String] SHA-256 hex digest of the input
599
+ # @param result_json [String] JSON-serialized result
600
+ # @param input_tokens [Integer, nil] input tokens consumed
601
+ # @param output_tokens [Integer, nil] output tokens consumed
602
+ # @return [void]
369
603
  def llm_cache_store(operation:, model:, input_hash:, result_json:, input_tokens: nil, output_tokens: nil)
370
604
  cache_key = Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
371
605
 
@@ -388,11 +622,19 @@ module ClaudeMemory
388
622
  )
389
623
  end
390
624
 
625
+ # Compute the cache key for an LLM operation.
626
+ # @param operation [String] operation name
627
+ # @param model [String] model identifier
628
+ # @param input [String] raw input text
629
+ # @return [String] SHA-256 hex cache key
391
630
  def llm_cache_key(operation, model, input)
392
631
  input_hash = Digest::SHA256.hexdigest(input)
393
632
  Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
394
633
  end
395
634
 
635
+ # Delete LLM cache entries older than the given age.
636
+ # @param max_age_seconds [Integer] maximum age in seconds (default: 7 days)
637
+ # @return [Integer] number of rows deleted
396
638
  def llm_cache_prune(max_age_seconds: 604_800)
397
639
  cutoff = (Time.now - max_age_seconds).utc.iso8601
398
640
  llm_cache.where { created_at < cutoff }.delete
@@ -400,10 +642,17 @@ module ClaudeMemory
400
642
 
401
643
  # --- Meta ---
402
644
 
645
+ # Set a key-value pair in the meta table (upsert).
646
+ # @param key [String] metadata key
647
+ # @param value [String] metadata value
648
+ # @return [void]
403
649
  def set_meta(key, value)
404
650
  @db[:meta].insert_conflict(target: :key, update: {value: value}).insert(key: key, value: value)
405
651
  end
406
652
 
653
+ # Retrieve a value from the meta table.
654
+ # @param key [String] metadata key
655
+ # @return [String, nil]
407
656
  def get_meta(key)
408
657
  @db[:meta].where(key: key).get(:value)
409
658
  end
@@ -4,9 +4,25 @@ require "fileutils"
4
4
 
5
5
  module ClaudeMemory
6
6
  module Store
7
+ # Dual-database connection manager for global and project stores.
8
+ # Lazily opens SQLiteStore connections to the global database
9
+ # (~/.claude/memory.sqlite3) and the project database
10
+ # (.claude/memory.sqlite3 under the project root). Commands query
11
+ # both databases by default, with project facts taking precedence.
7
12
  class StoreManager
8
- attr_reader :global_store, :project_store, :project_path
13
+ # @return [SQLiteStore, nil] global store (nil until {#ensure_global!} is called)
14
+ attr_reader :global_store
9
15
 
16
+ # @return [SQLiteStore, nil] project store (nil until {#ensure_project!} is called)
17
+ attr_reader :project_store
18
+
19
+ # @return [String] project directory path
20
+ attr_reader :project_path
21
+
22
+ # @param global_db_path [String, nil] override path to the global database
23
+ # @param project_db_path [String, nil] override path to the project database
24
+ # @param project_path [String, nil] project directory (defaults to Configuration)
25
+ # @param env [Hash] environment variable hash (default: ENV)
10
26
  def initialize(global_db_path: nil, project_db_path: nil, project_path: nil, env: ENV)
11
27
  config = Configuration.new(env)
12
28
  @project_path = project_path || config.project_dir
@@ -17,14 +33,23 @@ module ClaudeMemory
17
33
  @project_store = nil
18
34
  end
19
35
 
36
+ # Default global database path from Configuration.
37
+ # @param env [Hash] environment variable hash
38
+ # @return [String]
20
39
  def self.default_global_db_path(env = ENV)
21
40
  Configuration.new(env).global_db_path
22
41
  end
23
42
 
43
+ # Default project database path for a given project directory.
44
+ # @param project_path [String] project directory (default: current working directory)
45
+ # @return [String]
24
46
  def self.default_project_db_path(project_path = Dir.pwd)
25
47
  Configuration.new.project_db_path(project_path)
26
48
  end
27
49
 
50
+ # Open the global store, creating the directory and database if needed.
51
+ # No-op if already open.
52
+ # @return [SQLiteStore] the global store
28
53
  def ensure_global!
29
54
  return @global_store if @global_store
30
55
 
@@ -32,6 +57,9 @@ module ClaudeMemory
32
57
  @global_store = SQLiteStore.new(@global_db_path)
33
58
  end
34
59
 
60
+ # Open the project store, creating the directory and database if needed.
61
+ # No-op if already open.
62
+ # @return [SQLiteStore] the project store
35
63
  def ensure_project!
36
64
  return @project_store if @project_store
37
65
 
@@ -39,23 +67,33 @@ module ClaudeMemory
39
67
  @project_store = SQLiteStore.new(@project_db_path)
40
68
  end
41
69
 
70
+ # Open both global and project stores.
71
+ # @return [void]
42
72
  def ensure_both!
43
73
  ensure_global!
44
74
  ensure_project!
45
75
  end
46
76
 
77
+ # @return [String] filesystem path to the global database
47
78
  attr_reader :global_db_path
48
79
 
80
+ # @return [String] filesystem path to the project database
49
81
  attr_reader :project_db_path
50
82
 
83
+ # Check whether the global database file exists on disk.
84
+ # @return [Boolean]
51
85
  def global_exists?
52
86
  File.exist?(@global_db_path)
53
87
  end
54
88
 
89
+ # Check whether the project database file exists on disk.
90
+ # @return [Boolean]
55
91
  def project_exists?
56
92
  File.exist?(@project_db_path)
57
93
  end
58
94
 
95
+ # Close both database connections and reset store references.
96
+ # @return [void]
59
97
  def close
60
98
  @global_store&.close
61
99
  @project_store&.close
@@ -63,6 +101,10 @@ module ClaudeMemory
63
101
  @project_store = nil
64
102
  end
65
103
 
104
+ # Return the appropriate store for a given scope string.
105
+ # @param scope [String] "global" or "project"
106
+ # @return [SQLiteStore]
107
+ # @raise [ArgumentError] if scope is not "global" or "project"
66
108
  def store_for_scope(scope)
67
109
  case scope
68
110
  when "global"
@@ -76,6 +118,13 @@ module ClaudeMemory
76
118
  end
77
119
  end
78
120
 
121
+ # Copy a project-scoped fact (with its entities and provenance) into the
122
+ # global store, making it available across all projects. Runs the global
123
+ # writes in a single transaction for atomicity.
124
+ #
125
+ # @param fact_id [Integer] project fact row id to promote
126
+ # @return [Integer, nil] the new global fact id, or nil if the fact/subject
127
+ # was not found in the project store
79
128
  def promote_fact(fact_id)
80
129
  ensure_both!
81
130