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.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +32 -2
- data/.claude/settings.json +30 -52
- data/.claude/settings.local.json +3 -1
- data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
- data/.claude-plugin/marketplace.json +2 -2
- data/.claude-plugin/plugin.json +3 -3
- data/.claude-plugin/scripts/hook-runner.sh +14 -0
- data/.claude-plugin/scripts/serve-mcp.sh +14 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +41 -0
- data/CLAUDE.md +31 -17
- data/README.md +35 -0
- data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
- data/db/migrations/014_canonicalize_predicates.rb +30 -0
- data/docs/improvements.md +58 -20
- data/docs/influence/claude-mem.md +1 -0
- data/docs/influence/claude-supermemory.md +1 -0
- data/docs/influence/episodic-memory.md +1 -0
- data/docs/influence/grepai.md +1 -0
- data/docs/influence/kbs.md +1 -0
- data/docs/influence/lossless-claw.md +1 -0
- data/docs/influence/qmd.md +1 -0
- data/lib/claude_memory/commands/completion_command.rb +1 -31
- data/lib/claude_memory/commands/embeddings_command.rb +198 -0
- data/lib/claude_memory/commands/help_command.rb +8 -1
- data/lib/claude_memory/commands/registry.rb +47 -34
- data/lib/claude_memory/commands/reject_command.rb +62 -0
- data/lib/claude_memory/commands/restore_command.rb +77 -0
- data/lib/claude_memory/commands/skills/distill-transcripts.md +5 -1
- data/lib/claude_memory/commands/stats_command.rb +98 -2
- data/lib/claude_memory/configuration.rb +14 -1
- data/lib/claude_memory/distill/json_schema.md +8 -4
- data/lib/claude_memory/distill/null_distiller.rb +2 -0
- data/lib/claude_memory/domain/entity.rb +13 -1
- data/lib/claude_memory/domain/fact.rb +26 -2
- data/lib/claude_memory/embeddings/api_adapter.rb +5 -4
- data/lib/claude_memory/embeddings/fastembed_adapter.rb +43 -13
- data/lib/claude_memory/embeddings/inspector.rb +91 -0
- data/lib/claude_memory/embeddings/model_registry.rb +210 -0
- data/lib/claude_memory/embeddings/resolver.rb +32 -6
- data/lib/claude_memory/ingest/ingester.rb +17 -0
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +24 -0
- data/lib/claude_memory/mcp/handlers/stats_handlers.rb +5 -2
- data/lib/claude_memory/mcp/instructions_builder.rb +17 -0
- data/lib/claude_memory/mcp/server.rb +22 -1
- data/lib/claude_memory/mcp/telemetry.rb +86 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +86 -3
- data/lib/claude_memory/mcp/tools.rb +10 -0
- data/lib/claude_memory/publish.rb +40 -5
- data/lib/claude_memory/recall.rb +81 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
- data/lib/claude_memory/resolve/resolver.rb +43 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +250 -1
- data/lib/claude_memory/store/store_manager.rb +50 -1
- data/lib/claude_memory/sweep/maintenance.rb +115 -1
- data/lib/claude_memory/sweep/sweeper.rb +3 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +5 -0
- metadata +26 -8
- data/.claude/memory.sqlite3-shm +0 -0
- data/.claude/memory.sqlite3-wal +0 -0
|
@@ -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
|
-
|
|
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
|
|