Class: ClaudeMemory::Store::SQLiteStore

Inherits:
Object
  • Object
show all
Includes:
LLMCache, MetricsAggregator, RetryHandler, SchemaManager
Defined in:
lib/claude_memory/store/sqlite_store.rb

Overview

SQLite-backed fact store for ClaudeMemory. Manages all database tables (content_items, entities, facts, provenance, conflicts, fact_links, etc.) via Sequel with Extralite adapter. Includes RetryHandler for transient lock recovery and SchemaManager for automatic migrations on open.

Constant Summary

Constants included from SchemaManager

ClaudeMemory::Store::SchemaManager::SCHEMA_VERSION

Constants included from RetryHandler

RetryHandler::MAX_RETRIES, RetryHandler::RETRY_BASE_DELAY

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from MetricsAggregator

#aggregate_ingestion_metrics, #backfill_distillation_metrics!, #count_undistilled, #record_ingestion_metrics

Methods included from LLMCache

#llm_cache_key, #llm_cache_lookup, #llm_cache_prune, #llm_cache_store

Methods included from RetryHandler

#transaction_with_retry, #with_retry

Constructor Details

#initialize(db_path) ⇒ SQLiteStore

Open (or create) a SQLite database and migrate to the current schema.

Parameters:

  • db_path (String)

    filesystem path to the SQLite database file



32
33
34
35
36
37
# File 'lib/claude_memory/store/sqlite_store.rb', line 32

def initialize(db_path)
  @db_path = db_path
  @db = connect_database(db_path)

  ensure_schema!
end

Instance Attribute Details

#dbSequel::Database (readonly)

Returns the underlying Sequel database connection.

Returns:

  • (Sequel::Database)

    the underlying Sequel database connection



28
29
30
# File 'lib/claude_memory/store/sqlite_store.rb', line 28

def db
  @db
end

Instance Method Details

#activity_eventsSequel::Dataset

Returns:

  • (Sequel::Dataset)


109
# File 'lib/claude_memory/store/sqlite_store.rb', line 109

def activity_events = @db[:activity_events]

#bulk_insert_otel_events(rows) ⇒ Object



234
235
236
237
238
# File 'lib/claude_memory/store/sqlite_store.rb', line 234

def bulk_insert_otel_events(rows)
  return 0 if rows.empty?
  otel_events.multi_insert(rows.map { |r| otel_event_row(**r) })
  rows.size
end

#bulk_insert_otel_metrics(rows) ⇒ Object

Bulk insert OTel metric rows in a single SQL statement. Hot-path callers (the OTLP receiver) batch dozens of points per request; multi_insert avoids the per-row prepare/bind overhead.



210
211
212
213
214
# File 'lib/claude_memory/store/sqlite_store.rb', line 210

def bulk_insert_otel_metrics(rows)
  return 0 if rows.empty?
  otel_metrics.multi_insert(rows.map { |r| otel_metric_row(**r) })
  rows.size
end

#bulk_insert_otel_traces(rows) ⇒ Object



270
271
272
273
274
# File 'lib/claude_memory/store/sqlite_store.rb', line 270

def bulk_insert_otel_traces(rows)
  return 0 if rows.empty?
  otel_traces.multi_insert(rows.map { |r| otel_trace_row(**r) })
  rows.size
end

#checkpoint_walvoid

This method returns an undefined value.

Checkpoint the WAL file to prevent unlimited growth.



53
54
55
# File 'lib/claude_memory/store/sqlite_store.rb', line 53

def checkpoint_wal
  @db.run("PRAGMA wal_checkpoint(TRUNCATE)")
end

#clear_moment_feedback(event_id) ⇒ Integer

Remove the verdict for a moment, if any.

Returns:

  • (Integer)

    number of rows deleted (0 or 1)



159
160
161
# File 'lib/claude_memory/store/sqlite_store.rb', line 159

def clear_moment_feedback(event_id)
  with_retry { moment_feedback.where(event_id: event_id).delete }
end

#closevoid

This method returns an undefined value.

Disconnect from the database.



41
42
43
# File 'lib/claude_memory/store/sqlite_store.rb', line 41

def close
  @db.disconnect
end

#conflictsSequel::Dataset

Returns:

  • (Sequel::Dataset)


88
# File 'lib/claude_memory/store/sqlite_store.rb', line 88

def conflicts = @db[:conflicts]

#consolidate_observations(from_ids, body:, kind: "event", priority: 3, scope: "project", project_path: nil, source_content_item_id: nil, observed_at: nil) ⇒ Hash?

Semantic consolidation: merge several related observations into one synthesized observation, atomically. The new row carries the summed corroboration of its sources (combined sighting weight, which can tip it over the promotion threshold); each source is tombstoned into it. This is the Claude-as-reflector counterpart to the deterministic dedup — it collapses observations that say the same thing in different words, which exact-match dedup can’t.

Parameters:

  • from_ids (Array<Integer>)

    source observation ids (need >= 2 active in scope)

  • body (String)

    the synthesized observation text

Returns:

  • (Hash, nil)

    merged:, corroboration_count:, or nil when fewer than two of the ids are active in this scope



803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
# File 'lib/claude_memory/store/sqlite_store.rb', line 803

def consolidate_observations(from_ids, body:, kind: "event", priority: 3, scope: "project",
  project_path: nil, source_content_item_id: nil, observed_at: nil)
  with_retry("consolidate_observations") do
    @db.transaction do
      # Read the source set *inside* the transaction so the rows we sum
      # corroboration from are the same rows we tombstone — otherwise two
      # reflectors (PreCompact + SessionEnd) could each read the same
      # active sources and double-count or re-tombstone them.
      sources = observations
        .where(id: from_ids, status: "active", scope: scope)
        .select(:id, :corroboration_count)
        .all
      next nil if sources.size < 2

      now = Time.now.utc.iso8601
      combined = sources.sum { |s| s[:corroboration_count] || 1 }

      new_id = observations.insert(
        body: body, kind: kind, priority: priority, scope: scope, project_path: project_path,
        source_content_item_id: source_content_item_id,
        token_count: (body.length / 4.0).ceil, corroboration_count: combined,
        status: "active", observed_at: observed_at || now, created_at: now
      )
      # Re-assert `active` on the update so a source consolidated by a
      # racing writer between read and write is not tombstoned twice.
      observations.where(id: sources.map { |s| s[:id] }, status: "active")
        .update(status: "consolidated", consolidated_into: new_id, reflected_at: now)
      {id: new_id, merged: sources.size, corroboration_count: combined}
    end
  end
end

#content_item_by_transcript_and_mtime(transcript_path, mtime_iso8601) ⇒ Hash?

Find a content item by transcript path and source modification time.

Parameters:

  • transcript_path (String)

    filesystem path to the transcript

  • mtime_iso8601 (String)

    ISO 8601 modification timestamp

Returns:

  • (Hash, nil)


371
372
373
374
375
# File 'lib/claude_memory/store/sqlite_store.rb', line 371

def content_item_by_transcript_and_mtime(transcript_path, mtime_iso8601)
  content_items
    .where(transcript_path: transcript_path, source_mtime: mtime_iso8601)
    .first
end

#content_itemsSequel::Dataset

Returns:

  • (Sequel::Dataset)


67
# File 'lib/claude_memory/store/sqlite_store.rb', line 67

def content_items = @db[:content_items]

#delta_cursorsSequel::Dataset

Returns:

  • (Sequel::Dataset)


70
# File 'lib/claude_memory/store/sqlite_store.rb', line 70

def delta_cursors = @db[:delta_cursors]

#entitiesSequel::Dataset

Returns:

  • (Sequel::Dataset)


73
# File 'lib/claude_memory/store/sqlite_store.rb', line 73

def entities = @db[:entities]

#entity_aliasesSequel::Dataset

Returns:

  • (Sequel::Dataset)


76
# File 'lib/claude_memory/store/sqlite_store.rb', line 76

def entity_aliases = @db[:entity_aliases]

#expire_observation(observation_id) ⇒ Boolean

Retire a stale observation (status “expired”) without a consolidation target. Append-only — the row is preserved for provenance, just excluded from active recall. Used by the Reflector’s TTL pass.

Parameters:

  • observation_id (Integer)

Returns:

  • (Boolean)

    true if a row was updated



759
760
761
762
763
# File 'lib/claude_memory/store/sqlite_store.rb', line 759

def expire_observation(observation_id)
  now = Time.now.utc.iso8601
  updated = observations.where(id: observation_id).update(status: "expired", reflected_at: now)
  updated > 0
end

Returns:

  • (Sequel::Dataset)


85
# File 'lib/claude_memory/store/sqlite_store.rb', line 85

def fact_links = @db[:fact_links]

#factsSequel::Dataset

Returns:

  • (Sequel::Dataset)


79
# File 'lib/claude_memory/store/sqlite_store.rb', line 79

def facts = @db[:facts]

#facts_for_slot(subject_entity_id, predicate, status: "active") ⇒ Array<Hash>

Find all facts for a given subject + predicate combination (a “slot”). Used by the resolver to detect supersession and conflicts.

Parameters:

  • subject_entity_id (Integer)

    subject entity id

  • predicate (String)

    predicate label

  • status (String) (defaults to: "active")

    filter by status (default: “active”)

Returns:

  • (Array<Hash>)


594
595
596
597
598
599
600
601
# File 'lib/claude_memory/store/sqlite_store.rb', line 594

def facts_for_slot(subject_entity_id, predicate, status: "active")
  facts
    .where(subject_entity_id: subject_entity_id, predicate: predicate, status: status)
    .select(:id, :subject_entity_id, :predicate, :object_entity_id, :object_literal,
      :datatype, :polarity, :valid_from, :valid_to, :status, :confidence,
      :created_from, :created_at)
    .all
end

#facts_with_embeddings(limit: 1000) ⇒ Array<Hash>

Retrieve active facts that have stored embeddings.

Parameters:

  • limit (Integer) (defaults to: 1000)

    maximum rows to return

Returns:

  • (Array<Hash>)

    fact rows with :id, :subject_entity_id, :predicate, :object_literal, :embedding_json, :scope



579
580
581
582
583
584
585
586
# File 'lib/claude_memory/store/sqlite_store.rb', line 579

def facts_with_embeddings(limit: 1000)
  facts
    .where(Sequel.~(embedding_json: nil))
    .where(status: "active")
    .select(:id, :subject_entity_id, :predicate, :object_literal, :embedding_json, :scope)
    .limit(limit)
    .all
end

#find_fact_by_docid(docid) ⇒ Hash?

Look up a fact by its short document identifier.

Parameters:

  • docid (String)

    8-character hex document id

Returns:

  • (Hash, nil)


497
498
499
# File 'lib/claude_memory/store/sqlite_store.rb', line 497

def find_fact_by_docid(docid)
  facts.where(docid: docid).first
end

#find_or_create_entity(type:, name:) ⇒ Integer

Find an entity by its slug or create a new one.

Parameters:

  • type (String)

    entity type (e.g. “database”, “framework”, “person”)

  • name (String)

    canonical entity name

Returns:

  • (Integer)

    entity row id



445
446
447
448
449
450
451
452
# File 'lib/claude_memory/store/sqlite_store.rb', line 445

def find_or_create_entity(type:, name:)
  slug = slugify(type, name)
  existing = entities.where(slug: slug).get(:id)
  return existing if existing

  now = Time.now.utc.iso8601
  entities.insert(type: type, canonical_name: name, slug: slug, created_at: now)
end

#get_content_item(id) ⇒ Hash?

Fetch a single content item by primary key.

Parameters:

  • id (Integer)

    content item id

Returns:

  • (Hash, nil)


363
364
365
# File 'lib/claude_memory/store/sqlite_store.rb', line 363

def get_content_item(id)
  content_items.where(id: id).first
end

#get_delta_cursor(session_id, transcript_path) ⇒ Integer?

Get the last-read byte offset for a session/transcript pair.

Parameters:

  • session_id (String)

    session identifier

  • transcript_path (String)

    transcript file path

Returns:

  • (Integer, nil)

    byte offset, or nil if no cursor exists



415
416
417
# File 'lib/claude_memory/store/sqlite_store.rb', line 415

def get_delta_cursor(session_id, transcript_path)
  delta_cursors.where(session_id: session_id, transcript_path: transcript_path).get(:last_byte_offset)
end

#get_meta(key) ⇒ String?

Retrieve a value from the meta table.

Parameters:

  • key (String)

    metadata key

Returns:

  • (String, nil)


877
878
879
# File 'lib/claude_memory/store/sqlite_store.rb', line 877

def get_meta(key)
  @db[:meta].where(key: key).get(:value)
end

#increment_corroboration(observation_id, by: 1) ⇒ void

This method returns an undefined value.

Fold a duplicate’s sighting count into the keeper. Called by the Reflector’s dedup pass so corroboration survives consolidation — the signal the promotion gate keys off.

Parameters:

  • observation_id (Integer)

    keeper observation

  • by (Integer) (defaults to: 1)

    how much to add (the loser’s corroboration_count)



772
773
774
775
# File 'lib/claude_memory/store/sqlite_store.rb', line 772

def increment_corroboration(observation_id, by: 1)
  observations.where(id: observation_id)
    .update(corroboration_count: Sequel[:corroboration_count] + by)
end

#ingestion_metricsSequel::Dataset

Returns:

  • (Sequel::Dataset)


100
# File 'lib/claude_memory/store/sqlite_store.rb', line 100

def ingestion_metrics = @db[:ingestion_metrics]

#insert_conflict(fact_a_id:, fact_b_id:, status: "open", notes: nil) ⇒ Integer

Record a conflict between two facts.

Parameters:

  • fact_a_id (Integer)

    first conflicting fact id

  • fact_b_id (Integer)

    second conflicting fact id

  • status (String) (defaults to: "open")

    conflict status (“open” or “resolved”)

  • notes (String, nil) (defaults to: nil)

    human-readable notes about the conflict

Returns:

  • (Integer)

    inserted conflict row id



643
644
645
646
647
648
649
650
651
652
# File 'lib/claude_memory/store/sqlite_store.rb', line 643

def insert_conflict(fact_a_id:, fact_b_id:, status: "open", notes: nil)
  now = Time.now.utc.iso8601
  conflicts.insert(
    fact_a_id: fact_a_id,
    fact_b_id: fact_b_id,
    status: status,
    detected_at: now,
    notes: notes
  )
end

#insert_fact(subject_entity_id:, predicate:, object_entity_id: nil, object_literal: nil, datatype: nil, polarity: "positive", valid_from: nil, status: "active", confidence: 1.0, created_from: nil, scope: "project", project_path: nil) ⇒ Integer

Insert a new fact (subject-predicate-object triple) with an auto-generated docid.

Parameters:

  • subject_entity_id (Integer)

    entity id for the subject

  • predicate (String)

    predicate label (e.g. “uses_database”, “depends_on”)

  • object_entity_id (Integer, nil) (defaults to: nil)

    entity id for the object (if entity-valued)

  • object_literal (String, nil) (defaults to: nil)

    literal value for the object

  • datatype (String, nil) (defaults to: nil)

    datatype hint for the object literal

  • polarity (String) (defaults to: "positive")

    “positive” or “negative”

  • valid_from (String, nil) (defaults to: nil)

    ISO 8601 validity start (defaults to now UTC)

  • status (String) (defaults to: "active")

    fact status (“active”, “superseded”, “rejected”)

  • confidence (Float) (defaults to: 1.0)

    confidence score 0.0..1.0

  • created_from (String, nil) (defaults to: nil)

    provenance tag (e.g. “promoted:path:id”)

  • scope (String) (defaults to: "project")

    “global” or “project”

  • project_path (String, nil) (defaults to: nil)

    project directory for project-scoped facts

Returns:

  • (Integer)

    inserted fact row id



471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'lib/claude_memory/store/sqlite_store.rb', line 471

def insert_fact(subject_entity_id:, predicate:, object_entity_id: nil, object_literal: nil,
  datatype: nil, polarity: "positive", valid_from: nil, status: "active",
  confidence: 1.0, created_from: nil, scope: "project", project_path: nil)
  now = Time.now.utc.iso8601
  docid = generate_docid(subject_entity_id, predicate, object_literal, now)
  facts.insert(
    subject_entity_id: subject_entity_id,
    predicate: predicate,
    object_entity_id: object_entity_id,
    object_literal: object_literal,
    datatype: datatype,
    polarity: polarity,
    valid_from: valid_from || now,
    status: status,
    confidence: confidence,
    created_from: created_from,
    created_at: now,
    scope: scope,
    project_path: project_path,
    docid: docid
  )
end

Create a directional link between two facts (e.g. supersession).

Parameters:

  • from_fact_id (Integer)

    source fact id

  • to_fact_id (Integer)

    target fact id

  • link_type (String)

    relationship type (e.g. “supersedes”, “conflicts_with”)

Returns:

  • (Integer)

    inserted fact_link row id



665
666
667
# File 'lib/claude_memory/store/sqlite_store.rb', line 665

def insert_fact_link(from_fact_id:, to_fact_id:, link_type:)
  fact_links.insert(from_fact_id: from_fact_id, to_fact_id: to_fact_id, link_type: link_type)
end

#insert_mcp_tool_call(tool_name:, duration_ms:, result_count: nil, scope: nil, error_class: nil, called_at: nil) ⇒ Integer

Record a single MCP tool invocation for telemetry. Inserts synchronously; callers wrap in with_retry at the call site if needed.

Parameters:

  • tool_name (String)

    name of the MCP tool invoked

  • duration_ms (Integer)

    execution time in milliseconds

  • result_count (Integer, nil) (defaults to: nil)

    number of results returned

  • scope (String, nil) (defaults to: nil)

    “global” or “project”

  • error_class (String, nil) (defaults to: nil)

    error class name if the call failed

  • called_at (String, nil) (defaults to: nil)

    ISO 8601 timestamp (defaults to now UTC)

Returns:

  • (Integer)

    inserted row id



174
175
176
177
178
179
180
181
182
183
# File 'lib/claude_memory/store/sqlite_store.rb', line 174

def insert_mcp_tool_call(tool_name:, duration_ms:, result_count: nil, scope: nil, error_class: nil, called_at: nil)
  mcp_tool_calls.insert(
    tool_name: tool_name,
    called_at: called_at || Time.now.utc.iso8601,
    duration_ms: duration_ms,
    result_count: result_count,
    scope: scope,
    error_class: error_class
  )
end

#insert_observation(body:, kind: "event", priority: 3, scope: "project", project_path: nil, source_content_item_id: nil, session_id: nil, observed_at: nil, token_count: nil) ⇒ Integer

Insert an episodic observation. token_count is estimated from the body when not supplied (rough ~4 chars/token) so Phase 2 budget math has a value to work with.

Parameters:

  • body (String)

    dense narrative text (required)

  • kind (String) (defaults to: "event")

    one of Domain::Observation::KINDS

  • priority (Integer) (defaults to: 3)

    1=important, 2=maybe, 3=info

  • scope (String) (defaults to: "project")

    “project” or “global”

  • project_path (String, nil) (defaults to: nil)

    project directory for project-scoped rows

  • source_content_item_id (Integer, nil) (defaults to: nil)

    provenance link to the raw chunk

  • session_id (String, nil) (defaults to: nil)

    session that produced the observation

  • observed_at (String, nil) (defaults to: nil)

    ISO 8601 event time (defaults to now UTC)

  • token_count (Integer, nil) (defaults to: nil)

    precomputed token estimate

Returns:

  • (Integer)

    inserted observation row id



702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
# File 'lib/claude_memory/store/sqlite_store.rb', line 702

def insert_observation(body:, kind: "event", priority: 3, scope: "project",
  project_path: nil, source_content_item_id: nil, session_id: nil,
  observed_at: nil, token_count: nil)
  now = Time.now.utc.iso8601
  with_retry("insert_observation") do
    observations.insert(
      body: body,
      kind: kind,
      priority: priority,
      scope: scope,
      project_path: project_path,
      source_content_item_id: source_content_item_id,
      token_count: token_count || (body.length / 4.0).ceil,
      status: "active",
      session_id: session_id,
      observed_at: observed_at || now,
      created_at: now
    )
  end
end

#insert_otel_event(event_name:, occurred_at:, session_id: nil, prompt_id: nil, attributes: nil, resource: nil) ⇒ Integer

Insert one OTel log-style event row.

Parameters:

  • event_name (String)

    e.g. “user_prompt”, “tool_result”, “api_request”

  • occurred_at (String)

    ISO 8601 timestamp

  • session_id (String, nil) (defaults to: nil)
  • prompt_id (String, nil) (defaults to: nil)

    UUID correlating events from one prompt

  • attributes (Hash, nil) (defaults to: nil)
  • resource (Hash, nil) (defaults to: nil)

Returns:

  • (Integer)

    inserted row id



225
226
227
228
229
230
231
232
# File 'lib/claude_memory/store/sqlite_store.rb', line 225

def insert_otel_event(event_name:, occurred_at:, session_id: nil, prompt_id: nil,
  attributes: nil, resource: nil)
  otel_events.insert(otel_event_row(
    event_name: event_name, occurred_at: occurred_at,
    session_id: session_id, prompt_id: prompt_id,
    attributes: attributes, resource: resource
  ))
end

#insert_otel_metric(name:, value_type:, recorded_at:, value_int: nil, value_float: nil, unit: nil, attributes: nil, resource: nil) ⇒ Integer

Insert one OTel metric data point. Two value columns let us preserve int64 precision for counters (token counts) without losing fidelity in Float — see migration 018.

Parameters:

  • name (String)

    OTel metric name (e.g. “claude_code.token.usage”)

  • value_type (String)

    “int” or “double”

  • value_int (Integer, nil) (defaults to: nil)

    integer value when value_type == “int”

  • value_float (Float, nil) (defaults to: nil)

    float value when value_type == “double”

  • unit (String, nil) (defaults to: nil)

    OTel unit string (“tokens”, “USD”, “s”, …)

  • attributes (Hash, nil) (defaults to: nil)

    flattened attribute map

  • resource (Hash, nil) (defaults to: nil)

    resource attribute map

  • recorded_at (String)

    ISO 8601 timestamp

Returns:

  • (Integer)

    inserted row id



198
199
200
201
202
203
204
205
# File 'lib/claude_memory/store/sqlite_store.rb', line 198

def insert_otel_metric(name:, value_type:, recorded_at:, value_int: nil, value_float: nil,
  unit: nil, attributes: nil, resource: nil)
  otel_metrics.insert(otel_metric_row(
    name: name, value_type: value_type, recorded_at: recorded_at,
    value_int: value_int, value_float: value_float, unit: unit,
    attributes: attributes, resource: resource
  ))
end

#insert_otel_trace_span(trace_id:, span_id:, name:, recorded_at:, parent_span_id: nil, session_id: nil, prompt_id: nil, start_unix_nano: nil, end_unix_nano: nil, duration_ms: nil, status_code: nil, attributes: nil, resource: nil) ⇒ Integer

Insert one OTel trace span row. Only used when traces are explicitly opted in via Configuration#otel_traces_enabled?.

Parameters:

  • trace_id (String)
  • span_id (String)
  • name (String)
  • recorded_at (String)
  • parent_span_id (String, nil) (defaults to: nil)
  • session_id (String, nil) (defaults to: nil)
  • prompt_id (String, nil) (defaults to: nil)
  • start_unix_nano (Integer, nil) (defaults to: nil)
  • end_unix_nano (Integer, nil) (defaults to: nil)
  • duration_ms (Integer, nil) (defaults to: nil)
  • status_code (String, nil) (defaults to: nil)
  • attributes (Hash, nil) (defaults to: nil)
  • resource (Hash, nil) (defaults to: nil)

Returns:

  • (Integer)

    inserted row id



257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/claude_memory/store/sqlite_store.rb', line 257

def insert_otel_trace_span(trace_id:, span_id:, name:, recorded_at:,
  parent_span_id: nil, session_id: nil, prompt_id: nil,
  start_unix_nano: nil, end_unix_nano: nil, duration_ms: nil,
  status_code: nil, attributes: nil, resource: nil)
  otel_traces.insert(otel_trace_row(
    trace_id: trace_id, span_id: span_id, name: name, recorded_at: recorded_at,
    parent_span_id: parent_span_id, session_id: session_id, prompt_id: prompt_id,
    start_unix_nano: start_unix_nano, end_unix_nano: end_unix_nano,
    duration_ms: duration_ms, status_code: status_code,
    attributes: attributes, resource: resource
  ))
end

#insert_provenance(fact_id:, content_item_id: nil, quote: nil, attribution_entity_id: nil, strength: "stated", line_start: nil, line_end: nil) ⇒ Integer

Record a provenance link between a fact and its source evidence.

Parameters:

  • fact_id (Integer)

    fact row id

  • content_item_id (Integer, nil) (defaults to: nil)

    source content item id

  • quote (String, nil) (defaults to: nil)

    verbatim quote from the source

  • attribution_entity_id (Integer, nil) (defaults to: nil)

    entity who stated the fact

  • strength (String) (defaults to: "stated")

    evidence strength (“stated”, “inferred”, “derived”)

  • line_start (Integer, nil) (defaults to: nil)

    starting line in source content

  • line_end (Integer, nil) (defaults to: nil)

    ending line in source content

Returns:

  • (Integer)

    inserted provenance row id



615
616
617
618
619
620
621
622
623
624
625
626
# File 'lib/claude_memory/store/sqlite_store.rb', line 615

def insert_provenance(fact_id:, content_item_id: nil, quote: nil, attribution_entity_id: nil, strength: "stated",
  line_start: nil, line_end: nil)
  provenance.insert(
    fact_id: fact_id,
    content_item_id: content_item_id,
    quote: quote,
    attribution_entity_id: attribution_entity_id,
    strength: strength,
    line_start: line_start,
    line_end: line_end
  )
end

#insert_tool_calls(content_item_id, tool_calls_data) ⇒ void

This method returns an undefined value.

Bulk-insert tool call records for a content item.

Parameters:

  • content_item_id (Integer)

    owning content item id

  • tool_calls_data (Array<Hash>)

    tool call hashes with keys :tool_name, :tool_input, :tool_result, :compressed_summary, :is_error, :timestamp



385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/claude_memory/store/sqlite_store.rb', line 385

def insert_tool_calls(content_item_id, tool_calls_data)
  tool_calls_data.each do |tc|
    tool_calls.insert(
      content_item_id: content_item_id,
      tool_name: tc[:tool_name],
      tool_input: tc[:tool_input],
      tool_result: tc[:tool_result],
      compressed_summary: tc[:compressed_summary],
      is_error: tc[:is_error] || false,
      timestamp: tc[:timestamp]
    )
  end
end

#llm_cacheSequel::Dataset

Returns:

  • (Sequel::Dataset)


103
# File 'lib/claude_memory/store/sqlite_store.rb', line 103

def llm_cache = @db[:llm_cache]

#mark_observation_promoted(observation_id, fact_id:) ⇒ Boolean

Mark an observation as promoted to a structured fact. Append-only: the row is preserved (provenance), it just stops being a promotion candidate.

Parameters:

  • observation_id (Integer)
  • fact_id (Integer)

    the fact this observation was promoted into

Returns:

  • (Boolean)

    true if a row was updated



784
785
786
787
788
789
# File 'lib/claude_memory/store/sqlite_store.rb', line 784

def mark_observation_promoted(observation_id, fact_id:)
  now = Time.now.utc.iso8601
  updated = observations.where(id: observation_id)
    .update(promoted_at: now, promoted_fact_id: fact_id, reflected_at: now)
  updated > 0
end

#mcp_tool_callsSequel::Dataset

Returns:

  • (Sequel::Dataset)


106
# File 'lib/claude_memory/store/sqlite_store.rb', line 106

def mcp_tool_calls = @db[:mcp_tool_calls]

#moment_feedbackSequel::Dataset

Returns:

  • (Sequel::Dataset)


112
# File 'lib/claude_memory/store/sqlite_store.rb', line 112

def moment_feedback = @db[:moment_feedback]

#observationsSequel::Dataset

Returns:

  • (Sequel::Dataset)


124
# File 'lib/claude_memory/store/sqlite_store.rb', line 124

def observations = @db[:observations]

#observations_for_fact(fact_id) ⇒ Array<Hash>

Observations that were promoted into the given fact — the reverse of promoted_fact_id, for fact→observation provenance.

Parameters:

  • fact_id (Integer)

Returns:

  • (Array<Hash>)


857
858
859
860
861
862
# File 'lib/claude_memory/store/sqlite_store.rb', line 857

def observations_for_fact(fact_id)
  observations
    .where(promoted_fact_id: fact_id)
    .select(:id, :body, :kind, :corroboration_count, :observed_at)
    .all
end

#open_conflictsArray<Hash>

Retrieve all unresolved conflicts.

Returns:

  • (Array<Hash>)


656
657
658
# File 'lib/claude_memory/store/sqlite_store.rb', line 656

def open_conflicts
  conflicts.where(status: "open").all
end

#operation_progressSequel::Dataset

Returns:

  • (Sequel::Dataset)


94
# File 'lib/claude_memory/store/sqlite_store.rb', line 94

def operation_progress = @db[:operation_progress]

#otel_eventsSequel::Dataset

Returns:

  • (Sequel::Dataset)


118
# File 'lib/claude_memory/store/sqlite_store.rb', line 118

def otel_events = @db[:otel_events]

#otel_metricsSequel::Dataset

Returns:

  • (Sequel::Dataset)


115
# File 'lib/claude_memory/store/sqlite_store.rb', line 115

def otel_metrics = @db[:otel_metrics]

#otel_tracesSequel::Dataset

Returns:

  • (Sequel::Dataset)


121
# File 'lib/claude_memory/store/sqlite_store.rb', line 121

def otel_traces = @db[:otel_traces]

#promotion_candidates(scope: nil, min_corroboration: 2, limit: 10) ⇒ Array<Hash>

Active, not-yet-promoted observations corroborated at least ‘min_corroboration` times — i.e. eligible for promotion to a fact. Highest corroboration first.

Parameters:

  • scope (String, nil) (defaults to: nil)

    filter by scope; nil for any

  • min_corroboration (Integer) (defaults to: 2)

    sightings required (the gate)

  • limit (Integer) (defaults to: 10)

Returns:

  • (Array<Hash>)


843
844
845
846
847
848
849
850
# File 'lib/claude_memory/store/sqlite_store.rb', line 843

def promotion_candidates(scope: nil, min_corroboration: 2, limit: 10)
  ds = observations.where(status: "active", promoted_at: nil)
  ds = ds.where(scope: scope) if scope
  ds.where { corroboration_count >= min_corroboration }
    .order(Sequel.desc(:corroboration_count), Sequel.desc(:observed_at))
    .limit(limit)
    .all
end

#provenanceSequel::Dataset

Returns:

  • (Sequel::Dataset)


82
# File 'lib/claude_memory/store/sqlite_store.rb', line 82

def provenance = @db[:provenance]

#provenance_for_fact(fact_id) ⇒ Array<Hash>

Retrieve all provenance records for a given fact.

Parameters:

  • fact_id (Integer)

    fact row id

Returns:

  • (Array<Hash>)


631
632
633
# File 'lib/claude_memory/store/sqlite_store.rb', line 631

def provenance_for_fact(fact_id)
  provenance.where(fact_id: fact_id).all
end

#recent_observations(scope: nil, limit: 20, min_priority: nil) ⇒ Array<Hash>

Fetch active observations, newest first. Used by the memory.observations MCP tool and (later) the stable-prefix injection.

Parameters:

  • scope (String, nil) (defaults to: nil)

    filter by “project”/“global”; nil for any

  • limit (Integer) (defaults to: 20)

    maximum rows to return

  • min_priority (Integer, nil) (defaults to: nil)

    only rows with priority <= this (1 returns only 🔴; nil returns all)

Returns:

  • (Array<Hash>)


731
732
733
734
735
736
# File 'lib/claude_memory/store/sqlite_store.rb', line 731

def recent_observations(scope: nil, limit: 20, min_priority: nil)
  ds = observations.where(status: "active")
  ds = ds.where(scope: scope) if scope
  ds = ds.where { priority <= min_priority } if min_priority
  ds.order(Sequel.desc(:observed_at), Sequel.desc(:id)).limit(limit).all
end

#reject_fact(fact_id, reason: nil) ⇒ Hash?

Reject a fact as incorrect (e.g. a distiller hallucination). Sets status to “rejected”, closes any open conflicts involving the fact, and records the reason in conflict notes when provided. All updates run in a single transaction.

Parameters:

  • fact_id (Integer)

    fact row id to reject

  • reason (String, nil) (defaults to: nil)

    optional rejection reason appended to conflict notes

Returns:

  • (Hash, nil)

    {rejected: true, conflicts_resolved: Integer} or nil if the fact does not exist



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
# File 'lib/claude_memory/store/sqlite_store.rb', line 549

def reject_fact(fact_id, reason: nil)
  row = facts.where(id: fact_id).first
  return nil unless row

  now = Time.now.utc.iso8601
  resolved = 0

  @db.transaction do
    facts.where(id: fact_id).update(status: "rejected", valid_to: now)

    open_conflict_rows = conflicts
      .where(status: "open")
      .where { (fact_a_id =~ fact_id) | (fact_b_id =~ fact_id) }
      .all

    open_conflict_rows.each do |conflict|
      suffix = reason ? " | resolved: rejected fact #{fact_id} (#{reason})" : " | resolved: rejected fact #{fact_id}"
      notes = "#{conflict[:notes]}#{suffix}"
      conflicts.where(id: conflict[:id]).update(status: "resolved", notes: notes)
    end
    resolved = open_conflict_rows.size
  end

  {rejected: true, conflicts_resolved: resolved}
end

#schema_healthSequel::Dataset

Returns:

  • (Sequel::Dataset)


97
# File 'lib/claude_memory/store/sqlite_store.rb', line 97

def schema_health = @db[:schema_health]

#schema_versionInteger?

Current schema version stored in the meta table.

Returns:

  • (Integer, nil)


59
60
61
# File 'lib/claude_memory/store/sqlite_store.rb', line 59

def schema_version
  @db[:meta].where(key: "schema_version").get(:value)&.to_i
end

#set_meta(key, value) ⇒ void

This method returns an undefined value.

Set a key-value pair in the meta table (upsert).

Parameters:

  • key (String)

    metadata key

  • value (String)

    metadata value



870
871
872
# File 'lib/claude_memory/store/sqlite_store.rb', line 870

def set_meta(key, value)
  @db[:meta].insert_conflict(target: :key, update: {value: value}).insert(key: key, value: value)
end

#tombstone_observation(observation_id, into_id:) ⇒ Boolean

Tombstone an observation by pointing it at the consolidated row that replaced it (append-only supersession — the row is preserved, not deleted, mirroring fact_links). Used by the Reflector.

Parameters:

  • observation_id (Integer)

    the superseded observation

  • into_id (Integer)

    the consolidated observation it was merged into

Returns:

  • (Boolean)

    true if a row was updated



745
746
747
748
749
750
751
# File 'lib/claude_memory/store/sqlite_store.rb', line 745

def tombstone_observation(observation_id, into_id:)
  now = Time.now.utc.iso8601
  updated = observations.where(id: observation_id).update(
    status: "consolidated", consolidated_into: into_id, reflected_at: now
  )
  updated > 0
end

#tool_callsSequel::Dataset

Returns:

  • (Sequel::Dataset)


91
# File 'lib/claude_memory/store/sqlite_store.rb', line 91

def tool_calls = @db[:tool_calls]

#tool_calls_for_content_item(content_item_id) ⇒ Array<Hash>

Retrieve tool calls for a content item, ordered by timestamp.

Parameters:

  • content_item_id (Integer)

    content item id

Returns:

  • (Array<Hash>)


402
403
404
405
406
407
# File 'lib/claude_memory/store/sqlite_store.rb', line 402

def tool_calls_for_content_item(content_item_id)
  tool_calls
    .where(content_item_id: content_item_id)
    .order(:timestamp)
    .all
end

#undistilled_content_items(limit: 3, min_length: 200) ⇒ Array<Hash>

Fetch content items that have not yet been distilled, ordered newest first.

Parameters:

  • limit (Integer) (defaults to: 3)

    maximum rows to return

  • min_length (Integer) (defaults to: 200)

    minimum byte_len threshold

Returns:

  • (Array<Hash>)


675
676
677
678
679
680
681
682
683
684
# File 'lib/claude_memory/store/sqlite_store.rb', line 675

def undistilled_content_items(limit: 3, min_length: 200)
  content_items
    .left_join(:ingestion_metrics, content_item_id: :id)
    .where(Sequel[:ingestion_metrics][:id] => nil)
    .where { byte_len >= min_length }
    .order(Sequel.desc(:occurred_at))
    .limit(limit)
    .select_all(:content_items)
    .all
end

#update_delta_cursor(session_id, transcript_path, offset) ⇒ void

This method returns an undefined value.

Create or update the byte-offset cursor for a session/transcript pair.

Parameters:

  • session_id (String)

    session identifier

  • transcript_path (String)

    transcript file path

  • offset (Integer)

    new byte offset



424
425
426
427
428
429
430
431
432
433
434
435
436
437
# File 'lib/claude_memory/store/sqlite_store.rb', line 424

def update_delta_cursor(session_id, transcript_path, offset)
  now = Time.now.utc.iso8601
  delta_cursors
    .insert_conflict(
      target: [:session_id, :transcript_path],
      update: {last_byte_offset: offset, updated_at: now}
    )
    .insert(
      session_id: session_id,
      transcript_path: transcript_path,
      last_byte_offset: offset,
      updated_at: now
    )
end

#update_fact(fact_id, status: nil, valid_to: nil, scope: nil, project_path: nil, embedding: nil) ⇒ Boolean

Selectively update one or more fields on a fact. Only provided (non-nil) keyword arguments are written. Setting scope to “global” automatically clears project_path.

Parameters:

  • fact_id (Integer)

    fact row id

  • status (String, nil) (defaults to: nil)

    new status value

  • valid_to (String, nil) (defaults to: nil)

    ISO 8601 end-of-validity timestamp

  • scope (String, nil) (defaults to: nil)

    “global” or “project”

  • project_path (String, nil) (defaults to: nil)

    project directory (cleared when scope is “global”)

  • embedding (Array<Float>, nil) (defaults to: nil)

    embedding vector to store as JSON

Returns:

  • (Boolean)

    true if any fields were updated, false if all args were nil



512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
# File 'lib/claude_memory/store/sqlite_store.rb', line 512

def update_fact(fact_id, status: nil, valid_to: nil, scope: nil, project_path: nil, embedding: nil)
  updates = {}
  updates[:status] = status if status
  updates[:valid_to] = valid_to if valid_to

  if scope
    updates[:scope] = scope
    updates[:project_path] = (scope == "global") ? nil : project_path
  end

  if embedding
    updates[:embedding_json] = embedding.to_json
  end

  return false if updates.empty?

  facts.where(id: fact_id).update(updates)
  true
end

#update_fact_embedding(fact_id, embedding_vector) ⇒ void

This method returns an undefined value.

Overwrite the embedding vector for a fact.

Parameters:

  • fact_id (Integer)

    fact row id

  • embedding_vector (Array<Float>)

    embedding to store as JSON



536
537
538
# File 'lib/claude_memory/store/sqlite_store.rb', line 536

def update_fact_embedding(fact_id, embedding_vector)
  facts.where(id: fact_id).update(embedding_json: embedding_vector.to_json)
end

#upsert_content_item(source:, text_hash:, byte_len:, session_id: nil, transcript_path: nil, project_path: nil, occurred_at: nil, raw_text: nil, metadata: nil, git_branch: nil, cwd: nil, claude_version: nil, thinking_level: nil, source_mtime: nil) ⇒ Integer

Insert a content item or return the existing id if a duplicate (same text_hash + session_id) already exists. Wrapped in retry logic.

Parameters:

  • source (String)

    origin type (e.g. “transcript”, “hook”)

  • text_hash (String)

    SHA-256 hex digest of the raw text

  • byte_len (Integer)

    byte length of the raw text

  • session_id (String, nil) (defaults to: nil)

    Claude Code session identifier

  • transcript_path (String, nil) (defaults to: nil)

    filesystem path to the transcript file

  • project_path (String, nil) (defaults to: nil)

    project directory path

  • occurred_at (String, nil) (defaults to: nil)

    ISO 8601 timestamp (defaults to now UTC)

  • raw_text (String, nil) (defaults to: nil)

    original text content

  • metadata (Hash, nil) (defaults to: nil)

    additional metadata stored as JSON

  • git_branch (String, nil) (defaults to: nil)

    active git branch at ingestion time

  • cwd (String, nil) (defaults to: nil)

    working directory at ingestion time

  • claude_version (String, nil) (defaults to: nil)

    Claude Code version string

  • thinking_level (String, nil) (defaults to: nil)

    thinking level setting

  • source_mtime (String, nil) (defaults to: nil)

    ISO 8601 mtime of the source file

Returns:

  • (Integer)

    content item row id (existing or newly inserted)



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/claude_memory/store/sqlite_store.rb', line 332

def upsert_content_item(source:, text_hash:, byte_len:, session_id: nil, transcript_path: nil,
  project_path: nil, occurred_at: nil, raw_text: nil, metadata: nil,
  git_branch: nil, cwd: nil, claude_version: nil, thinking_level: nil, source_mtime: nil)
  with_retry("upsert_content_item") do
    existing = content_items.where(text_hash: text_hash, session_id: session_id).get(:id)
    return existing if existing

    now = Time.now.utc.iso8601
    content_items.insert(
      source: source,
      session_id: session_id,
      transcript_path: transcript_path,
      project_path: project_path,
      occurred_at: occurred_at || now,
      ingested_at: now,
      text_hash: text_hash,
      byte_len: byte_len,
      raw_text: raw_text,
      metadata_json: &.to_json,
      git_branch: git_branch,
      cwd: cwd,
      claude_version: claude_version,
      thinking_level: thinking_level,
      source_mtime: source_mtime
    )
  end
end

#upsert_moment_feedback(event_id:, verdict:, note: nil, recorded_at: nil) ⇒ Hash

Upsert a thumbs-up/down verdict for a moment. One row per event_id (unique constraint on the column) — repeat clicks overwrite. Returns the persisted row.

Parameters:

  • event_id (Integer)

    activity_events row id

  • verdict (String)

    “up” or “down”

  • note (String, nil) (defaults to: nil)

    optional freeform note

  • recorded_at (String, nil) (defaults to: nil)

    ISO 8601 timestamp (defaults to now UTC)

Returns:

  • (Hash)

    row after upsert

Raises:

  • (ArgumentError)


135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/claude_memory/store/sqlite_store.rb', line 135

def upsert_moment_feedback(event_id:, verdict:, note: nil, recorded_at: nil)
  raise ArgumentError, "verdict must be 'up' or 'down'" unless %w[up down].include?(verdict)

  ts = recorded_at || Time.now.utc.iso8601
  with_retry do
    @db.transaction do
      existing = moment_feedback.where(event_id: event_id).first
      if existing
        moment_feedback.where(id: existing[:id]).update(
          verdict: verdict, note: note, recorded_at: ts
        )
        moment_feedback.where(id: existing[:id]).first
      else
        id = moment_feedback.insert(
          event_id: event_id, verdict: verdict, note: note, recorded_at: ts
        )
        moment_feedback.where(id: id).first
      end
    end
  end
end

#vector_indexIndex::VectorIndex

Lazily-initialized vector index for semantic search.

Returns:



47
48
49
# File 'lib/claude_memory/store/sqlite_store.rb', line 47

def vector_index
  @vector_index ||= Index::VectorIndex.new(self)
end