Module: ClaudeMemory::MCP::Handlers::ManagementHandlers

Included in:
Tools
Defined in:
lib/claude_memory/mcp/handlers/management_handlers.rb

Overview

Management tool handlers (store_extraction, promote, sweep, changes, conflicts)

Instance Method Summary collapse

Instance Method Details

#changes(args) ⇒ Object



227
228
229
230
231
232
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 227

def changes(args)
  since = args["since"] || (Time.now - 86400 * 7).utc.iso8601
  scope = args["scope"] || "all"
  list = @recall.changes(since: since, limit: args["limit"] || 20, scope: scope)
  ResponseFormatter.format_changes(since, list)
end

#coerce_observation(raw) ⇒ Object

Coerce one Claude-supplied observation into a clean candidate, or nil when it can’t be a usable episodic row. Defaults live here so the rest of the pipeline never sees a blank body or an out-of-range priority. Returns a symbol-keyed hash for Resolver#persist_observations.



69
70
71
72
73
74
75
76
77
78
79
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 69

def coerce_observation(raw)
  obs = symbolize_keys(raw)
  body = obs[:body].to_s.strip
  return nil if body.empty?

  kind = Domain::Observation::KINDS.include?(obs[:kind]) ? obs[:kind] : "event"
  priority = obs[:priority].to_i
  priority = Domain::Observation::INFO unless (Domain::Observation::IMPORTANT..Domain::Observation::INFO).cover?(priority)

  {body: body, kind: kind, priority: priority}
end

#conflicts(args) ⇒ Object



234
235
236
237
238
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 234

def conflicts(args)
  scope = args["scope"] || "all"
  list = @recall.conflicts(scope: scope)
  ResponseFormatter.format_conflicts(list)
end

#consolidate_observations(args) ⇒ Object

Semantic reflection: merge several related observations into one synthesized observation (the Claude-as-reflector pass). Validates the synthesized body at the border via coerce_observation, then delegates the atomic merge to the store.



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
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 143

def consolidate_observations(args)
  scope = args["scope"] || "project"
  store = get_store_for_scope(scope)
  return {error: "Database not available"} unless store

  from_ids = Array(args["from_ids"]).map(&:to_i).reject(&:zero?).uniq
  return {error: "from_ids must list at least 2 observation ids"} if from_ids.size < 2

  synthesized = coerce_observation(args)
  return {error: "body is required"} unless synthesized

  project_path = (scope == "global") ? nil : Configuration.new.project_dir
  result = store.consolidate_observations(
    from_ids, body: synthesized[:body], kind: synthesized[:kind],
    priority: synthesized[:priority], scope: scope, project_path: project_path
  )
  return {error: "Need at least 2 active #{scope} observations from that set to consolidate"} unless result

  {
    success: true,
    scope: scope,
    consolidated_into: result[:id],
    merged: result[:merged],
    corroboration_count: result[:corroboration_count]
  }
end

#mark_distilled(args) ⇒ Object



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 240

def mark_distilled(args)
  content_item_id = args["content_item_id"]
  facts_extracted = args["facts_extracted"] || 0

  store = find_store_for_content_item(content_item_id)
  return {error: "Content item #{content_item_id} not found"} unless store

  store.record_ingestion_metrics(
    content_item_id: content_item_id,
    input_tokens: 0,
    output_tokens: 0,
    facts_extracted: facts_extracted
  )

  {
    success: true,
    content_item_id: content_item_id,
    facts_extracted: facts_extracted
  }
end

#promote(args) ⇒ Object



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 170

def promote(args)
  return {error: "Promote requires StoreManager"} unless @manager

  fact_id = args["fact_id"]
  global_fact_id = @manager.promote_fact(fact_id)

  if global_fact_id
    {
      success: true,
      project_fact_id: fact_id,
      global_fact_id: global_fact_id,
      message: "Fact promoted to global memory"
    }
  else
    {error: "Fact #{fact_id} not found in project database"}
  end
end

#promote_observation(args) ⇒ Object

Promotion bridge: turn a corroborated observation into a structured fact. Server-side anti-hallucination gate — refuses to promote an observation that has not been sighted at least PROMOTION_THRESHOLD times. Creates the fact through the resolver (so supersession/conflict handling applies) and marks the observation promoted so it is not re-suggested.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 87

def promote_observation(args)
  scope = args["scope"] || "project"
  store = get_store_for_scope(scope)
  return {error: "Database not available"} unless store

  observation_id = args["observation_id"]
  return {error: "observation_id required"} if observation_id.nil?

  obs = store.observations.where(id: observation_id).first
  return {error: "Observation #{observation_id} not found in #{scope} database"} unless obs
  return {error: "Observation #{observation_id} already promoted (fact #{obs[:promoted_fact_id]})"} unless obs[:promoted_at].nil?

  threshold = Domain::Observation::PROMOTION_THRESHOLD
  if obs[:corroboration_count].to_i < threshold
    return {error: "Not yet corroborated: observation #{observation_id} has #{obs[:corroboration_count]} sighting(s), need #{threshold}. Promotion requires repeated corroboration (anti-hallucination gate)."}
  end

  predicate = args["predicate"]
  object = args["object"]
  return {error: "predicate and object are required"} if predicate.nil? || object.to_s.strip.empty?
  subject = args["subject"] || "repo"

  config = Configuration.new
  project_path = config.project_dir
  occurred_at = Time.now.utc.iso8601

  extraction = Distill::Extraction.new(
    facts: [{subject: subject, predicate: predicate, object: object, strength: "derived"}]
  )
  result = Resolve::Resolver.new(store).apply(
    extraction, content_item_id: obs[:source_content_item_id],
    occurred_at: occurred_at, project_path: project_path, scope: scope
  )

  # The resolver reports the id of the fact it actually touched
  # (inserted, reinforced, or disputed) — no need to re-query for it.
  fact_id = result[:fact_ids].compact.first
  return {error: "Promotion failed: the fact for observation #{observation_id} could not be resolved after creation"} unless fact_id

  store.mark_observation_promoted(observation_id, fact_id: fact_id)

  {
    success: true,
    observation_id: observation_id,
    fact_id: fact_id,
    predicate: Resolve::PredicatePolicy.canonicalize(predicate),
    object: object,
    corroboration_count: obs[:corroboration_count],
    facts_created: result[:facts_created]
  }
end

#reject_fact(args) ⇒ Object



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 188

def reject_fact(args)
  scope = args["scope"] || "project"
  store = get_store_for_scope(scope)
  return {error: "Database not available"} unless store

  fact_id = args["fact_id"]
  if fact_id.nil? && args["docid"]
    row = store.find_fact_by_docid(args["docid"])
    fact_id = row && row[:id]
  end
  return {error: "fact_id or docid required"} if fact_id.nil?

  result = store.reject_fact(fact_id, reason: args["reason"])
  return {error: "Fact #{fact_id} not found in #{scope} database"} if result.nil?

  {
    success: true,
    scope: scope,
    fact_id: fact_id,
    conflicts_resolved: result[:conflicts_resolved],
    message: "Fact rejected"
  }
end

#store_extraction(args) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 8

def store_extraction(args)
  scope = args["scope"] || "project"
  store = get_store_for_scope(scope)
  return {error: "Database not available"} unless store

  entities = (args["entities"] || []).map { |e| symbolize_keys(e) }
  facts = (args["facts"] || []).map { |f| symbolize_keys(f) }
  decisions = (args["decisions"] || []).map { |d| symbolize_keys(d) }
  # Layer-2 episodic observations. Claude's output is semi-trusted, so
  # each is coerced and validated at this boundary; invalid rows are
  # dropped rather than aborting the batch.
  observations = (args["observations"] || []).filter_map { |o| coerce_observation(o) }

  config = Configuration.new
  project_path = config.project_dir
  occurred_at = Time.now.utc.iso8601

  searchable_text = Core::TextBuilder.build_searchable_text(entities, facts, decisions)
  content_item_id = create_synthetic_content_item(store, searchable_text, project_path, occurred_at)
  index_content_item(store, content_item_id, searchable_text)

  extraction = Distill::Extraction.new(
    entities: entities,
    facts: facts,
    decisions: decisions,
    signals: [],
    observations: observations
  )

  # Guard against the LLM distiller labeling descriptions of external
  # projects (LOC counts, star counts, "X is a plugin by …") as
  # `convention`. Retag those as `reference` before resolution so
  # they don't pollute the Knowledge-base conventions list or get
  # returned by `memory.conventions`.
  extraction = Distill::ReferenceMaterialDetector.new.reclassify(extraction)

  resolver = Resolve::Resolver.new(store)
  result = resolver.apply(
    extraction,
    content_item_id: content_item_id,
    occurred_at: occurred_at,
    project_path: project_path,
    scope: scope
  )

  {
    success: true,
    scope: scope,
    content_item_id: content_item_id,
    entities_created: result[:entities_created],
    facts_created: result[:facts_created],
    facts_superseded: result[:facts_superseded],
    conflicts_created: result[:conflicts_created],
    observations_created: result[:observations_created]
  }
end

#sweep_now(args) ⇒ Object



212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/claude_memory/mcp/handlers/management_handlers.rb', line 212

def sweep_now(args)
  scope = args["scope"] || "project"
  store = get_store_for_scope(scope)
  return {error: "Database not available"} unless store

  sweeper = Sweep::Sweeper.new(store)
  budget = args["budget_seconds"] || 5
  stats = if args["escalate"]
    sweeper.run_with_escalation!(budget_seconds: budget)
  else
    sweeper.run!(budget_seconds: budget)
  end
  ResponseFormatter.format_sweep_stats(scope, stats)
end