Class: ClaudeMemory::Sweep::Maintenance

Inherits:
Object
  • Object
show all
Defined in:
lib/claude_memory/sweep/maintenance.rb

Overview

Clean separation of individual maintenance operations from Sweeper’s budget-management orchestration. Each method performs a single operation and returns the count of affected records.

Source: QMD v2.0.1 Maintenance class pattern

Constant Summary collapse

RESTORE_STOPWORDS =

Short / noise tokens dropped before Jaccard comparison. Intentionally minimal — we want conservative token extraction that still treats “Rails 8.0” and “Rails 8.1” as overlapping.

%w[for the and with via of in on to by is are].to_set.freeze
RESTORE_JACCARD_THRESHOLD =
0.5
DEFAULT_CONFIG =
{
  proposed_fact_ttl_days: 14,
  disputed_fact_ttl_days: 30,
  content_retention_days: 30,
  mcp_tool_call_retention_days: 90
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(store, config: {}) ⇒ Maintenance

Returns a new instance of Maintenance.



25
26
27
28
# File 'lib/claude_memory/sweep/maintenance.rb', line 25

def initialize(store, config: {})
  @store = store
  @config = DEFAULT_CONFIG.merge(config)
end

Instance Attribute Details

#storeObject (readonly)

Returns the value of attribute store.



23
24
25
# File 'lib/claude_memory/sweep/maintenance.rb', line 23

def store
  @store
end

Instance Method Details

#backfill_vec_index(limit: 100) ⇒ Object

Backfill vector index for unindexed facts. Returns: Integer count of backfilled embeddings (0 if unavailable)



81
82
83
84
85
86
# File 'lib/claude_memory/sweep/maintenance.rb', line 81

def backfill_vec_index(limit: 100)
  with_vec_index do |vec_index|
    return vec_index.backfill_batch!(limit: limit)
  end
  0
end

#checkpoint_walObject

Checkpoint the SQLite WAL file for compaction. Returns: true



186
187
188
189
# File 'lib/claude_memory/sweep/maintenance.rb', line 186

def checkpoint_wal
  @store.checkpoint_wal
  true
end

#cleanup_vec_expired(limit: 100) ⇒ Object

Remove vector embeddings for superseded/expired facts. Returns: Integer count of cleaned embeddings (0 if unavailable)



90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/claude_memory/sweep/maintenance.rb', line 90

def cleanup_vec_expired(limit: 100)
  with_vec_index do |vec_index|
    stale_ids = @store.facts
      .where(status: %w[superseded expired])
      .where(Sequel.~(vec_indexed_at: nil))
      .select(:id)
      .limit(limit)
      .map { |r| r[:id] }

    stale_ids.each { |fact_id| vec_index.remove_embedding(fact_id) }
    return stale_ids.size
  end
  0
end

#expire_disputed_factsObject

Expire disputed facts older than TTL. Returns: Integer count of expired facts



42
43
44
45
46
47
48
# File 'lib/claude_memory/sweep/maintenance.rb', line 42

def expire_disputed_facts
  cutoff = cutoff_time(@config[:disputed_fact_ttl_days])
  @store.facts
    .where(status: "disputed")
    .where { created_at < cutoff }
    .update(status: "expired")
end

#expire_proposed_factsObject

Expire proposed facts older than TTL. Returns: Integer count of expired facts



32
33
34
35
36
37
38
# File 'lib/claude_memory/sweep/maintenance.rb', line 32

def expire_proposed_facts
  cutoff = cutoff_time(@config[:proposed_fact_ttl_days])
  @store.facts
    .where(status: "proposed")
    .where { created_at < cutoff }
    .update(status: "expired")
end

#prune_old_contentObject

Delete old content items not referenced by any provenance. Also removes their FTS index entries. Returns: Integer count of deleted content items



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/claude_memory/sweep/maintenance.rb', line 62

def prune_old_content
  cutoff = cutoff_time(@config[:content_retention_days])
  referenced_ids = @store.provenance.exclude(content_item_id: nil).select(:content_item_id)
  prunable = @store.content_items
    .where { ingested_at < cutoff }
    .exclude(id: referenced_ids)

  fts = ClaudeMemory::Index::LexicalFTS.new(@store)
  prunable.select(:id, :raw_text).each do |row|
    fts.remove_content_item(row[:id], row[:raw_text])
  rescue
    # FTS entry may not exist; skip
  end

  prunable.delete
end

#prune_old_mcp_tool_callsObject

Delete MCP tool-call telemetry rows older than retention window. Returns: Integer count of deleted rows (0 if table missing).



177
178
179
180
181
182
# File 'lib/claude_memory/sweep/maintenance.rb', line 177

def prune_old_mcp_tool_calls
  return 0 unless @store.db.table_exists?(:mcp_tool_calls)

  cutoff = cutoff_time(@config[:mcp_tool_call_retention_days])
  @store.mcp_tool_calls.where { called_at < cutoff }.delete
end

#prune_orphaned_provenanceObject

Delete provenance records referencing non-existent facts. Returns: Integer count of deleted provenance rows



52
53
54
55
56
57
# File 'lib/claude_memory/sweep/maintenance.rb', line 52

def prune_orphaned_provenance
  fact_ids = @store.facts.select(:id)
  @store.provenance
    .exclude(fact_id: fact_ids)
    .delete
end

#restore_multi_value_supersessions(predicate:, dry_run: false) ⇒ Hash

Restore superseded facts in a (subject, predicate) slot that were only superseded because of an obsolete single-value classification. Uses Jaccard-based token overlap to distinguish bug-superseded facts (token-disjoint siblings) from legitimate corrections (overlapping siblings).

Refuses to run on predicates still classified as single-value — they should stay superseded by design.

Never touches status: “rejected” facts (explicit user decisions).

Returns:

  • (Hash)

    restored, skipped_ambiguous, skipped_rejected, decisions



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/claude_memory/sweep/maintenance.rb', line 117

def restore_multi_value_supersessions(predicate:, dry_run: false)
  if ClaudeMemory::Resolve::PredicatePolicy.single?(predicate)
    raise ArgumentError, "Predicate '#{predicate}' is still classified single-value; refusing to restore"
  end

  result = {inspected: 0, restored: 0, skipped_ambiguous: 0, skipped_rejected: 0, decisions: []}

  rows_by_subject = @store.facts
    .where(predicate: predicate)
    .exclude(status: "rejected")
    .select(:id, :subject_entity_id, :object_literal, :status)
    .all
    .group_by { |r| r[:subject_entity_id] }

  rejected_by_subject = @store.facts
    .where(predicate: predicate, status: "rejected")
    .select(:id, :subject_entity_id, :object_literal)
    .all
    .group_by { |r| r[:subject_entity_id] }

  @store.db.transaction do
    rows_by_subject.each do |subject_id, rows|
      rejected_rows = rejected_by_subject[subject_id] || []
      siblings = rows + rejected_rows

      rows.each do |candidate|
        next unless candidate[:status] == "superseded"
        result[:inspected] += 1

        candidate_tokens = restore_tokenize(candidate[:object_literal])
        ambiguous_against = find_overlapping_siblings(candidate, siblings, candidate_tokens)

        if ambiguous_against.empty?
          result[:restored] += 1
          result[:decisions] << {
            subject_entity_id: subject_id,
            fact_id: candidate[:id],
            object: candidate[:object_literal],
            action: :restore
          }
          restore_fact!(candidate[:id]) unless dry_run
        else
          result[:skipped_ambiguous] += 1
          result[:decisions] << {
            subject_entity_id: subject_id,
            fact_id: candidate[:id],
            object: candidate[:object_literal],
            action: :skip_ambiguous,
            overlaps_with: ambiguous_against.map { |s| s[:object_literal] }
          }
        end
      end
    end
  end

  result
end

#vacuumObject

Run SQLite VACUUM to reclaim space. Returns: true



193
194
195
196
# File 'lib/claude_memory/sweep/maintenance.rb', line 193

def vacuum
  @store.db.run("VACUUM")
  true
end