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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/.mind.mv2.o2N83S +0 -0
  3. data/.claude/CLAUDE.md +1 -0
  4. data/.claude/rules/claude_memory.generated.md +28 -9
  5. data/.claude/settings.local.json +9 -1
  6. data/.claude/skills/check-memory/SKILL.md +77 -0
  7. data/.claude/skills/improve/SKILL.md +532 -0
  8. data/.claude/skills/improve/feature-patterns.md +1221 -0
  9. data/.claude/skills/quality-update/SKILL.md +229 -0
  10. data/.claude/skills/quality-update/implementation-guide.md +346 -0
  11. data/.claude/skills/review-commit/SKILL.md +199 -0
  12. data/.claude/skills/review-for-quality/SKILL.md +154 -0
  13. data/.claude/skills/review-for-quality/expert-checklists.md +79 -0
  14. data/.claude/skills/setup-memory/SKILL.md +168 -0
  15. data/.claude/skills/study-repo/SKILL.md +307 -0
  16. data/.claude/skills/study-repo/analysis-template.md +323 -0
  17. data/.claude/skills/study-repo/focus-examples.md +327 -0
  18. data/CHANGELOG.md +133 -0
  19. data/CLAUDE.md +130 -11
  20. data/README.md +117 -10
  21. data/db/migrations/001_create_initial_schema.rb +117 -0
  22. data/db/migrations/002_add_project_scoping.rb +33 -0
  23. data/db/migrations/003_add_session_metadata.rb +42 -0
  24. data/db/migrations/004_add_fact_embeddings.rb +20 -0
  25. data/db/migrations/005_add_incremental_sync.rb +21 -0
  26. data/db/migrations/006_add_operation_tracking.rb +40 -0
  27. data/db/migrations/007_add_ingestion_metrics.rb +26 -0
  28. data/docs/.claude/mind.mv2.lock +0 -0
  29. data/docs/GETTING_STARTED.md +587 -0
  30. data/docs/RELEASE_NOTES_v0.2.0.md +0 -1
  31. data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +0 -2
  32. data/docs/architecture.md +9 -8
  33. data/docs/auto_init_design.md +230 -0
  34. data/docs/improvements.md +557 -731
  35. data/docs/influence/.gitkeep +13 -0
  36. data/docs/influence/grepai.md +933 -0
  37. data/docs/influence/qmd.md +2195 -0
  38. data/docs/plugin.md +257 -11
  39. data/docs/quality_review.md +472 -1273
  40. data/docs/remaining_improvements.md +330 -0
  41. data/lefthook.yml +13 -0
  42. data/lib/claude_memory/commands/checks/claude_md_check.rb +41 -0
  43. data/lib/claude_memory/commands/checks/database_check.rb +120 -0
  44. data/lib/claude_memory/commands/checks/hooks_check.rb +112 -0
  45. data/lib/claude_memory/commands/checks/reporter.rb +110 -0
  46. data/lib/claude_memory/commands/checks/snapshot_check.rb +30 -0
  47. data/lib/claude_memory/commands/doctor_command.rb +12 -129
  48. data/lib/claude_memory/commands/help_command.rb +1 -0
  49. data/lib/claude_memory/commands/hook_command.rb +9 -2
  50. data/lib/claude_memory/commands/index_command.rb +169 -0
  51. data/lib/claude_memory/commands/ingest_command.rb +1 -1
  52. data/lib/claude_memory/commands/init_command.rb +5 -197
  53. data/lib/claude_memory/commands/initializers/database_ensurer.rb +30 -0
  54. data/lib/claude_memory/commands/initializers/global_initializer.rb +85 -0
  55. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +156 -0
  56. data/lib/claude_memory/commands/initializers/mcp_configurator.rb +56 -0
  57. data/lib/claude_memory/commands/initializers/memory_instructions_writer.rb +135 -0
  58. data/lib/claude_memory/commands/initializers/project_initializer.rb +111 -0
  59. data/lib/claude_memory/commands/recover_command.rb +75 -0
  60. data/lib/claude_memory/commands/registry.rb +5 -1
  61. data/lib/claude_memory/commands/stats_command.rb +239 -0
  62. data/lib/claude_memory/commands/uninstall_command.rb +226 -0
  63. data/lib/claude_memory/core/batch_loader.rb +32 -0
  64. data/lib/claude_memory/core/concept_ranker.rb +73 -0
  65. data/lib/claude_memory/core/embedding_candidate_builder.rb +37 -0
  66. data/lib/claude_memory/core/fact_collector.rb +51 -0
  67. data/lib/claude_memory/core/fact_query_builder.rb +154 -0
  68. data/lib/claude_memory/core/fact_ranker.rb +113 -0
  69. data/lib/claude_memory/core/result_builder.rb +54 -0
  70. data/lib/claude_memory/core/result_sorter.rb +25 -0
  71. data/lib/claude_memory/core/scope_filter.rb +61 -0
  72. data/lib/claude_memory/core/text_builder.rb +29 -0
  73. data/lib/claude_memory/embeddings/generator.rb +161 -0
  74. data/lib/claude_memory/embeddings/similarity.rb +69 -0
  75. data/lib/claude_memory/hook/handler.rb +4 -3
  76. data/lib/claude_memory/index/lexical_fts.rb +7 -2
  77. data/lib/claude_memory/infrastructure/operation_tracker.rb +158 -0
  78. data/lib/claude_memory/infrastructure/schema_validator.rb +206 -0
  79. data/lib/claude_memory/ingest/content_sanitizer.rb +6 -7
  80. data/lib/claude_memory/ingest/ingester.rb +99 -15
  81. data/lib/claude_memory/ingest/metadata_extractor.rb +57 -0
  82. data/lib/claude_memory/ingest/tool_extractor.rb +71 -0
  83. data/lib/claude_memory/mcp/response_formatter.rb +331 -0
  84. data/lib/claude_memory/mcp/server.rb +19 -0
  85. data/lib/claude_memory/mcp/setup_status_analyzer.rb +73 -0
  86. data/lib/claude_memory/mcp/tool_definitions.rb +279 -0
  87. data/lib/claude_memory/mcp/tool_helpers.rb +80 -0
  88. data/lib/claude_memory/mcp/tools.rb +330 -320
  89. data/lib/claude_memory/recall/dual_query_template.rb +63 -0
  90. data/lib/claude_memory/recall.rb +304 -237
  91. data/lib/claude_memory/resolve/resolver.rb +52 -49
  92. data/lib/claude_memory/store/sqlite_store.rb +210 -144
  93. data/lib/claude_memory/store/store_manager.rb +6 -6
  94. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  95. data/lib/claude_memory/version.rb +1 -1
  96. data/lib/claude_memory.rb +35 -3
  97. metadata +71 -11
  98. data/.claude/.mind.mv2.aLCUZd +0 -0
  99. data/.claude/memory.sqlite3 +0 -0
  100. data/.mcp.json +0 -11
  101. /data/docs/{feature_adoption_plan.md → plans/feature_adoption_plan.md} +0 -0
  102. /data/docs/{feature_adoption_plan_revised.md → plans/feature_adoption_plan_revised.md} +0 -0
  103. /data/docs/{plan.md → plans/plan.md} +0 -0
  104. /data/docs/{updated_plan.md → plans/updated_plan.md} +0 -0
@@ -20,15 +20,19 @@ module ClaudeMemory
20
20
  provenance_created: 0
21
21
  }
22
22
 
23
- entity_ids = resolve_entities(extraction.entities)
24
- result[:entities_created] = entity_ids.size
25
-
26
- extraction.facts.each do |fact_data|
27
- outcome = resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
28
- result[:facts_created] += outcome[:created]
29
- result[:facts_superseded] += outcome[:superseded]
30
- result[:conflicts_created] += outcome[:conflicts]
31
- result[:provenance_created] += outcome[:provenance]
23
+ # Wrap entire extraction in a single transaction for better concurrency
24
+ # This reduces database lock time compared to per-fact transactions
25
+ @store.db.transaction do
26
+ entity_ids = resolve_entities(extraction.entities)
27
+ result[:entities_created] = entity_ids.size
28
+
29
+ extraction.facts.each do |fact_data|
30
+ outcome = resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
31
+ result[:facts_created] += outcome[:created]
32
+ result[:facts_superseded] += outcome[:superseded]
33
+ result[:conflicts_created] += outcome[:conflicts]
34
+ result[:provenance_created] += outcome[:provenance]
35
+ end
32
36
  end
33
37
 
34
38
  result
@@ -57,51 +61,50 @@ module ClaudeMemory
57
61
 
58
62
  existing_facts = @store.facts_for_slot(subject_id, predicate)
59
63
 
60
- # Wrap all database operations in a transaction for atomicity
61
- @store.db.transaction do
62
- if PredicatePolicy.single?(predicate) && existing_facts.any?
63
- matching = existing_facts.find { |f| values_match?(f, object_val, object_entity_id) }
64
- if matching
65
- add_provenance(matching[:id], content_item_id, fact_data)
66
- outcome[:provenance] = 1
67
- return outcome
68
- elsif supersession_signal?(fact_data)
69
- supersede_facts(existing_facts, occurred_at)
70
- outcome[:superseded] = existing_facts.size
71
- else
72
- create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at)
73
- outcome[:conflicts] = 1
74
- return outcome
75
- end
64
+ # No transaction wrapper needed - handled by apply method
65
+ # This allows all facts to be processed in a single transaction
66
+ if PredicatePolicy.single?(predicate) && existing_facts.any?
67
+ matching = existing_facts.find { |f| values_match?(f, object_val, object_entity_id) }
68
+ if matching
69
+ add_provenance(matching[:id], content_item_id, fact_data)
70
+ outcome[:provenance] = 1
71
+ return outcome
72
+ elsif supersession_signal?(fact_data)
73
+ supersede_facts(existing_facts, occurred_at)
74
+ outcome[:superseded] = existing_facts.size
75
+ else
76
+ create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at)
77
+ outcome[:conflicts] = 1
78
+ return outcome
76
79
  end
80
+ end
77
81
 
78
- fact_scope = fact_data[:scope_hint] || @current_scope
79
- fact_project = (fact_scope == "global") ? nil : @current_project_path
80
-
81
- fact_id = @store.insert_fact(
82
- subject_entity_id: subject_id,
83
- predicate: predicate,
84
- object_entity_id: object_entity_id,
85
- object_literal: object_val,
86
- polarity: fact_data[:polarity] || "positive",
87
- confidence: fact_data[:confidence] || 1.0,
88
- valid_from: occurred_at,
89
- scope: fact_scope,
90
- project_path: fact_project
91
- )
92
- outcome[:created] = 1
93
-
94
- if existing_facts.any? && outcome[:superseded] > 0
95
- existing_facts.each do |old_fact|
96
- @store.insert_fact_link(from_fact_id: fact_id, to_fact_id: old_fact[:id], link_type: "supersedes")
97
- end
98
- end
82
+ fact_scope = fact_data[:scope_hint] || @current_scope
83
+ fact_project = (fact_scope == "global") ? nil : @current_project_path
99
84
 
100
- add_provenance(fact_id, content_item_id, fact_data)
101
- outcome[:provenance] = 1
85
+ fact_id = @store.insert_fact(
86
+ subject_entity_id: subject_id,
87
+ predicate: predicate,
88
+ object_entity_id: object_entity_id,
89
+ object_literal: object_val,
90
+ polarity: fact_data[:polarity] || "positive",
91
+ confidence: fact_data[:confidence] || 1.0,
92
+ valid_from: occurred_at,
93
+ scope: fact_scope,
94
+ project_path: fact_project
95
+ )
96
+ outcome[:created] = 1
102
97
 
103
- outcome
98
+ if existing_facts.any? && outcome[:superseded] > 0
99
+ existing_facts.each do |old_fact|
100
+ @store.insert_fact_link(from_fact_id: fact_id, to_fact_id: old_fact[:id], link_type: "supersedes")
101
+ end
104
102
  end
103
+
104
+ add_provenance(fact_id, content_item_id, fact_data)
105
+ outcome[:provenance] = 1
106
+
107
+ outcome
105
108
  end
106
109
 
107
110
  def supersession_signal?(fact_data)
@@ -1,25 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sequel"
4
+ require "sequel/extensions/migration"
4
5
  require "json"
6
+ require "extralite"
7
+ require "sequel/adapters/extralite"
5
8
 
6
9
  module ClaudeMemory
7
10
  module Store
8
11
  class SQLiteStore
9
- SCHEMA_VERSION = 2
12
+ SCHEMA_VERSION = 7
10
13
 
11
14
  attr_reader :db
12
15
 
13
16
  def initialize(db_path)
14
17
  @db_path = db_path
15
- @db = Sequel.sqlite(db_path)
18
+ @db = connect_database(db_path)
19
+
20
+ configure_pragmas
21
+
16
22
  ensure_schema!
17
23
  end
18
24
 
25
+ private
26
+
27
+ def connect_database(db_path)
28
+ # Extralite adapter: Better performance and GVL release
29
+ Sequel.connect("extralite:#{db_path}")
30
+ end
31
+
32
+ def configure_pragmas
33
+ # Enable WAL mode for better concurrency
34
+ # - Multiple readers don't block each other
35
+ # - Writers don't block readers
36
+ # - Safer concurrent hook execution
37
+ @db.run("PRAGMA journal_mode = WAL")
38
+ @db.run("PRAGMA synchronous = NORMAL")
39
+
40
+ # Set busy timeout to 30 seconds (increased from 5s)
41
+ # - Allows much longer wait times before raising BusyException
42
+ # - Critical for concurrent hook execution with MCP server
43
+ # - Combined with ingester retry logic, provides ~5 minutes total wait
44
+ # - Extralite releases GVL for better threading performance
45
+ @db.run("PRAGMA busy_timeout = 30000")
46
+ end
47
+
48
+ public
49
+
19
50
  def close
20
51
  @db.disconnect
21
52
  end
22
53
 
54
+ # Checkpoint the WAL file to prevent unlimited growth
55
+ # This truncates the WAL after checkpointing
56
+ # Should be called periodically during maintenance/sweep operations
57
+ def checkpoint_wal
58
+ @db.run("PRAGMA wal_checkpoint(TRUNCATE)")
59
+ end
60
+
23
61
  def schema_version
24
62
  @db[:meta].where(key: "schema_version").get(:value)&.to_i
25
63
  end
@@ -56,155 +94,25 @@ module ClaudeMemory
56
94
  @db[:conflicts]
57
95
  end
58
96
 
59
- private
60
-
61
- def ensure_schema!
62
- create_tables!
63
- run_migrations!
64
- set_meta("schema_version", SCHEMA_VERSION.to_s)
65
- set_meta("created_at", Time.now.utc.iso8601) unless get_meta("created_at")
97
+ def tool_calls
98
+ @db[:tool_calls]
66
99
  end
67
100
 
68
- def run_migrations!
69
- current = get_meta("schema_version")&.to_i || 0
70
-
71
- migrate_to_v2! if current < 2
101
+ def operation_progress
102
+ @db[:operation_progress]
72
103
  end
73
104
 
74
- def migrate_to_v2!
75
- columns = @db.schema(:content_items).map(&:first)
76
- unless columns.include?(:project_path)
77
- @db.alter_table(:content_items) do
78
- add_column :project_path, String
79
- end
80
- end
81
-
82
- columns = @db.schema(:facts).map(&:first)
83
- unless columns.include?(:scope)
84
- @db.alter_table(:facts) do
85
- add_column :scope, String, default: "project"
86
- add_column :project_path, String
87
- add_index :scope, name: :idx_facts_scope
88
- add_index :project_path, name: :idx_facts_project
89
- end
90
- end
105
+ def schema_health
106
+ @db[:schema_health]
91
107
  end
92
108
 
93
- def create_tables!
94
- @db.create_table?(:meta) do
95
- String :key, primary_key: true
96
- String :value
97
- end
98
-
99
- @db.create_table?(:content_items) do
100
- primary_key :id
101
- String :source, null: false
102
- String :session_id
103
- String :transcript_path
104
- String :project_path
105
- String :occurred_at
106
- String :ingested_at, null: false
107
- String :text_hash, null: false
108
- Integer :byte_len, null: false
109
- String :raw_text, text: true
110
- String :metadata_json, text: true
111
- end
112
-
113
- @db.create_table?(:delta_cursors) do
114
- primary_key :id
115
- String :session_id, null: false
116
- String :transcript_path, null: false
117
- Integer :last_byte_offset, null: false, default: 0
118
- String :updated_at, null: false
119
- unique [:session_id, :transcript_path]
120
- end
121
-
122
- @db.create_table?(:entities) do
123
- primary_key :id
124
- String :type, null: false
125
- String :canonical_name, null: false
126
- String :slug, null: false, unique: true
127
- String :created_at, null: false
128
- end
129
-
130
- @db.create_table?(:entity_aliases) do
131
- primary_key :id
132
- foreign_key :entity_id, :entities, null: false
133
- String :source
134
- String :alias, null: false
135
- Float :confidence, default: 1.0
136
- end
137
-
138
- @db.create_table?(:facts) do
139
- primary_key :id
140
- foreign_key :subject_entity_id, :entities
141
- String :predicate, null: false
142
- foreign_key :object_entity_id, :entities
143
- String :object_literal
144
- String :datatype
145
- String :polarity, default: "positive"
146
- String :valid_from
147
- String :valid_to
148
- String :status, default: "active"
149
- Float :confidence, default: 1.0
150
- String :created_from
151
- String :created_at, null: false
152
- String :scope, default: "project"
153
- String :project_path
154
- end
155
-
156
- @db.create_table?(:provenance) do
157
- primary_key :id
158
- foreign_key :fact_id, :facts, null: false
159
- foreign_key :content_item_id, :content_items
160
- String :quote, text: true
161
- foreign_key :attribution_entity_id, :entities
162
- String :strength, default: "stated"
163
- end
164
-
165
- @db.create_table?(:fact_links) do
166
- primary_key :id
167
- foreign_key :from_fact_id, :facts, null: false
168
- foreign_key :to_fact_id, :facts, null: false
169
- String :link_type, null: false
170
- end
171
-
172
- @db.create_table?(:conflicts) do
173
- primary_key :id
174
- foreign_key :fact_a_id, :facts, null: false
175
- foreign_key :fact_b_id, :facts, null: false
176
- String :status, default: "open"
177
- String :detected_at, null: false
178
- String :notes, text: true
179
- end
180
-
181
- create_index_if_not_exists(:facts, :predicate, :idx_facts_predicate)
182
- create_index_if_not_exists(:facts, :subject_entity_id, :idx_facts_subject)
183
- create_index_if_not_exists(:facts, :status, :idx_facts_status)
184
- create_index_if_not_exists(:facts, :scope, :idx_facts_scope)
185
- create_index_if_not_exists(:facts, :project_path, :idx_facts_project)
186
- create_index_if_not_exists(:provenance, :fact_id, :idx_provenance_fact)
187
- create_index_if_not_exists(:entity_aliases, :entity_id, :idx_entity_aliases_entity)
188
- create_index_if_not_exists(:content_items, :session_id, :idx_content_items_session)
189
- create_index_if_not_exists(:content_items, :project_path, :idx_content_items_project)
190
- end
191
-
192
- def create_index_if_not_exists(table, column, name)
193
- @db.run("CREATE INDEX IF NOT EXISTS #{name} ON #{table}(#{column})")
194
- end
195
-
196
- def set_meta(key, value)
197
- @db[:meta].insert_conflict(target: :key, update: {value: value}).insert(key: key, value: value)
198
- end
199
-
200
- def get_meta(key)
201
- @db[:meta].where(key: key).get(:value)
109
+ def ingestion_metrics
110
+ @db[:ingestion_metrics]
202
111
  end
203
112
 
204
- public
205
-
206
113
  def upsert_content_item(source:, text_hash:, byte_len:, session_id: nil, transcript_path: nil,
207
- project_path: nil, occurred_at: nil, raw_text: nil, metadata: nil)
114
+ project_path: nil, occurred_at: nil, raw_text: nil, metadata: nil,
115
+ git_branch: nil, cwd: nil, claude_version: nil, thinking_level: nil, source_mtime: nil)
208
116
  existing = content_items.where(text_hash: text_hash, session_id: session_id).get(:id)
209
117
  return existing if existing
210
118
 
@@ -219,10 +127,41 @@ module ClaudeMemory
219
127
  text_hash: text_hash,
220
128
  byte_len: byte_len,
221
129
  raw_text: raw_text,
222
- metadata_json: metadata&.to_json
130
+ metadata_json: metadata&.to_json,
131
+ git_branch: git_branch,
132
+ cwd: cwd,
133
+ claude_version: claude_version,
134
+ thinking_level: thinking_level,
135
+ source_mtime: source_mtime
223
136
  )
224
137
  end
225
138
 
139
+ def content_item_by_transcript_and_mtime(transcript_path, mtime_iso8601)
140
+ content_items
141
+ .where(transcript_path: transcript_path, source_mtime: mtime_iso8601)
142
+ .first
143
+ end
144
+
145
+ def insert_tool_calls(content_item_id, tool_calls_data)
146
+ tool_calls_data.each do |tc|
147
+ tool_calls.insert(
148
+ content_item_id: content_item_id,
149
+ tool_name: tc[:tool_name],
150
+ tool_input: tc[:tool_input],
151
+ tool_result: tc[:tool_result],
152
+ is_error: tc[:is_error] || false,
153
+ timestamp: tc[:timestamp]
154
+ )
155
+ end
156
+ end
157
+
158
+ def tool_calls_for_content_item(content_item_id)
159
+ tool_calls
160
+ .where(content_item_id: content_item_id)
161
+ .order(:timestamp)
162
+ .all
163
+ end
164
+
226
165
  def get_delta_cursor(session_id, transcript_path)
227
166
  delta_cursors.where(session_id: session_id, transcript_path: transcript_path).get(:last_byte_offset)
228
167
  end
@@ -272,7 +211,7 @@ module ClaudeMemory
272
211
  )
273
212
  end
274
213
 
275
- def update_fact(fact_id, status: nil, valid_to: nil, scope: nil, project_path: nil)
214
+ def update_fact(fact_id, status: nil, valid_to: nil, scope: nil, project_path: nil, embedding: nil)
276
215
  updates = {}
277
216
  updates[:status] = status if status
278
217
  updates[:valid_to] = valid_to if valid_to
@@ -282,12 +221,29 @@ module ClaudeMemory
282
221
  updates[:project_path] = (scope == "global") ? nil : project_path
283
222
  end
284
223
 
224
+ if embedding
225
+ updates[:embedding_json] = embedding.to_json
226
+ end
227
+
285
228
  return false if updates.empty?
286
229
 
287
230
  facts.where(id: fact_id).update(updates)
288
231
  true
289
232
  end
290
233
 
234
+ def update_fact_embedding(fact_id, embedding_vector)
235
+ facts.where(id: fact_id).update(embedding_json: embedding_vector.to_json)
236
+ end
237
+
238
+ def facts_with_embeddings(limit: 1000)
239
+ facts
240
+ .where(Sequel.~(embedding_json: nil))
241
+ .where(status: "active")
242
+ .select(:id, :subject_entity_id, :predicate, :object_literal, :embedding_json, :scope)
243
+ .limit(limit)
244
+ .all
245
+ end
246
+
291
247
  def facts_for_slot(subject_entity_id, predicate, status: "active")
292
248
  facts
293
249
  .where(subject_entity_id: subject_entity_id, predicate: predicate, status: status)
@@ -330,8 +286,118 @@ module ClaudeMemory
330
286
  fact_links.insert(from_fact_id: from_fact_id, to_fact_id: to_fact_id, link_type: link_type)
331
287
  end
332
288
 
289
+ # Record token usage metrics for a distillation operation
290
+ #
291
+ # @param content_item_id [Integer] The content item that was distilled
292
+ # @param input_tokens [Integer] Tokens sent to the API
293
+ # @param output_tokens [Integer] Tokens returned from the API
294
+ # @param facts_extracted [Integer] Number of facts extracted
295
+ # @return [Integer] The created metric record ID
296
+ def record_ingestion_metrics(content_item_id:, input_tokens:, output_tokens:, facts_extracted:)
297
+ ingestion_metrics.insert(
298
+ content_item_id: content_item_id,
299
+ input_tokens: input_tokens,
300
+ output_tokens: output_tokens,
301
+ facts_extracted: facts_extracted,
302
+ created_at: Time.now.utc.iso8601
303
+ )
304
+ end
305
+
306
+ # Get aggregate metrics across all distillation operations
307
+ #
308
+ # @return [Hash] Aggregated metrics with keys:
309
+ # - total_input_tokens: Total tokens sent to API
310
+ # - total_output_tokens: Total tokens returned from API
311
+ # - total_facts_extracted: Total facts extracted
312
+ # - total_operations: Number of distillation operations
313
+ # - avg_facts_per_1k_input_tokens: Average efficiency metric
314
+ def aggregate_ingestion_metrics
315
+ # standard:disable Performance/Detect (Sequel DSL requires .select{}.first)
316
+ result = ingestion_metrics
317
+ .select {
318
+ [
319
+ sum(:input_tokens).as(:total_input),
320
+ sum(:output_tokens).as(:total_output),
321
+ sum(:facts_extracted).as(:total_facts),
322
+ count(:id).as(:total_ops)
323
+ ]
324
+ }
325
+ .first
326
+ # standard:enable Performance/Detect
327
+
328
+ return nil if result.nil? || result[:total_ops].to_i.zero?
329
+
330
+ total_input = result[:total_input].to_i
331
+ total_output = result[:total_output].to_i
332
+ total_facts = result[:total_facts].to_i
333
+ total_ops = result[:total_ops].to_i
334
+
335
+ efficiency = total_input.zero? ? 0.0 : (total_facts.to_f / total_input * 1000).round(2)
336
+
337
+ {
338
+ total_input_tokens: total_input,
339
+ total_output_tokens: total_output,
340
+ total_facts_extracted: total_facts,
341
+ total_operations: total_ops,
342
+ avg_facts_per_1k_input_tokens: efficiency
343
+ }
344
+ end
345
+
333
346
  private
334
347
 
348
+ def ensure_schema!
349
+ migrations_path = File.expand_path("../../../db/migrations", __dir__)
350
+
351
+ # Handle backward compatibility: databases created with old migration system
352
+ sync_legacy_schema_version!
353
+
354
+ # Run Sequel migrations to bring database to target version
355
+ Sequel::Migrator.run(@db, migrations_path, target: SCHEMA_VERSION)
356
+
357
+ # Set created_at timestamp on first initialization
358
+ set_meta("created_at", Time.now.utc.iso8601) unless get_meta("created_at")
359
+
360
+ # Sync legacy schema_version meta key with Sequel's schema_info
361
+ # This maintains backwards compatibility with code that reads schema_version
362
+ sequel_version = @db[:schema_info].get(:version) if @db.table_exists?(:schema_info)
363
+ set_meta("schema_version", sequel_version.to_s) if sequel_version
364
+ end
365
+
366
+ # Sync legacy schema_version from meta table to Sequel's schema_info
367
+ # Handles two cases:
368
+ # 1. No schema_info table exists (old system, pre-Sequel migrations)
369
+ # 2. schema_info exists but is out of sync with meta.schema_version
370
+ def sync_legacy_schema_version!
371
+ return unless @db.table_exists?(:meta)
372
+
373
+ meta_version = get_meta("schema_version")&.to_i
374
+ return unless meta_version && meta_version >= 2
375
+
376
+ # Verify database actually has v2+ schema (defensive check)
377
+ columns = @db.schema(:content_items).map(&:first) if @db.table_exists?(:content_items)
378
+ return unless columns&.include?(:project_path)
379
+
380
+ # Create or update schema_info to match meta.schema_version
381
+ @db.create_table?(:schema_info) do
382
+ Integer :version, null: false, default: 0
383
+ end
384
+
385
+ sequel_version = @db[:schema_info].get(:version)
386
+ if sequel_version.nil? || sequel_version < meta_version
387
+ # Update schema_info to match meta (old system's version)
388
+ @db[:schema_info].delete
389
+ @db[:schema_info].insert(version: meta_version)
390
+ end
391
+ end
392
+
393
+ def set_meta(key, value)
394
+ @db[:meta].insert_conflict(target: :key, update: {value: value}).insert(key: key, value: value)
395
+ end
396
+
397
+ def get_meta(key)
398
+ @db[:meta].where(key: key).get(:value)
399
+ end
400
+
335
401
  def slugify(type, name)
336
402
  "#{type}:#{name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_|_$/, "")}"
337
403
  end
@@ -8,21 +8,21 @@ module ClaudeMemory
8
8
  attr_reader :global_store, :project_store, :project_path
9
9
 
10
10
  def initialize(global_db_path: nil, project_db_path: nil, project_path: nil, env: ENV)
11
- @project_path = project_path || env["CLAUDE_PROJECT_DIR"] || Dir.pwd
12
- @global_db_path = global_db_path || self.class.default_global_db_path(env)
13
- @project_db_path = project_db_path || self.class.default_project_db_path(@project_path)
11
+ config = Configuration.new(env)
12
+ @project_path = project_path || config.project_dir
13
+ @global_db_path = global_db_path || config.global_db_path
14
+ @project_db_path = project_db_path || config.project_db_path(@project_path)
14
15
 
15
16
  @global_store = nil
16
17
  @project_store = nil
17
18
  end
18
19
 
19
20
  def self.default_global_db_path(env = ENV)
20
- home = env["HOME"] || File.expand_path("~")
21
- File.join(home, ".claude", "memory.sqlite3")
21
+ Configuration.new(env).global_db_path
22
22
  end
23
23
 
24
24
  def self.default_project_db_path(project_path = Dir.pwd)
25
- File.join(project_path, ".claude", "memory.sqlite3")
25
+ Configuration.new.project_db_path(project_path)
26
26
  end
27
27
 
28
28
  def ensure_global!
@@ -31,6 +31,7 @@ module ClaudeMemory
31
31
  expire_disputed_facts if within_budget?
32
32
  prune_orphaned_provenance if within_budget?
33
33
  prune_old_content if within_budget?
34
+ checkpoint_wal if within_budget?
34
35
 
35
36
  @stats[:elapsed_seconds] = Time.now - @start_time
36
37
  @stats[:budget_honored] = @stats[:elapsed_seconds] <= budget
@@ -75,6 +76,11 @@ module ClaudeMemory
75
76
  .exclude(id: referenced_ids)
76
77
  .delete
77
78
  end
79
+
80
+ def checkpoint_wal
81
+ @store.checkpoint_wal
82
+ @stats[:wal_checkpointed] = true
83
+ end
78
84
  end
79
85
  end
80
86
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeMemory
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end