Class: ClaudeMemory::Dashboard::API

Inherits:
Object
  • Object
show all
Defined in:
lib/claude_memory/dashboard/api.rb

Overview

JSON API backend for the dashboard. Routes/delegates to dedicated collaborator classes (Conflicts, Moments, Trust, Knowledge, Reuse, Timeline, Health, FactPresenter) for non-trivial logic; this class holds HTTP-shape concerns and the long-tail per-endpoint formatting that hasn’t yet been extracted.

Constant Summary collapse

TRIGGER_WINDOW_SECONDS =

Find the hook_ingest event that most likely triggered a given recall. Recall events often arrive from MCP tool calls without a session_id, so we use time proximity: the last successful ingest before the recall within a small window.

600
COMMAND_TAG_RE =
/\A<(?:local-command-[a-z]+|command-(?:name|args|message|stdout|stderr))\b/i
STALE_WINDOW_DAYS =
30

Instance Method Summary collapse

Constructor Details

#initialize(manager) ⇒ API

Returns a new instance of API.



13
14
15
# File 'lib/claude_memory/dashboard/api.rb', line 13

def initialize(manager)
  @manager = manager
end

Instance Method Details

#activity(params = {}) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/claude_memory/dashboard/api.rb', line 36

def activity(params = {})
  store = default_store
  return {events: [], summary: {}} unless store

  limit = (params["limit"] || 100).to_i
  event_type = params["event_type"]
  since = params["since"]

  events = ActivityLog.recent(store, limit: limit, event_type: event_type, since: since)
  summary = ActivityLog.summary(store, since: since)

  {
    event_count: events.size,
    summary: summary,
    events: events.map { |e|
      e[:occurred_ago] = Core::RelativeTime.format(e[:occurred_at])
      e
    }
  }
end

#activity_detail(id) ⇒ Object



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/dashboard/api.rb', line 137

def activity_detail(id)
  store = default_store
  return {error: "No database available"} unless store

  row = store.activity_events.where(id: id.to_i).first
  return {error: "Event #{id} not found"} unless row

  details = row[:detail_json] ? JSON.parse(row[:detail_json], symbolize_names: true) : {}
  event = row.merge(
    details: details,
    occurred_ago: Core::RelativeTime.format(row[:occurred_at])
  )
  event.delete(:detail_json)

  content_item_id = details[:content_id] || details[:content_item_id]
  content_item = content_item_id ? load_content_item(store, content_item_id) : nil
  linked_facts = if content_item_id
    load_linked_facts(store, content_item_id)
  else
    scoped = ScopedFactResolver.scoped_ids_from_details(details)
    scoped.any? ? ScopedFactResolver.resolve(@manager, scoped) : []
  end

  # For recalls, "what triggered this" is high-signal context that the
  # raw event detail can't answer. Find the ingest immediately before
  # this recall so the modal can show the user prompt / assistant turn
  # that motivated the lookup. Time-window fallback when session_id is
  # absent (MCP tool calls don't thread session_id).
  trigger = (row[:event_type] == "recall") ? find_recall_trigger(store, row) : nil

  {
    event: event,
    content_item: content_item,
    linked_facts: linked_facts,
    trigger: trigger
  }.compact
end

#clear_moment_feedback(event_id) ⇒ Object



100
101
102
103
104
105
# File 'lib/claude_memory/dashboard/api.rb', line 100

def clear_moment_feedback(event_id)
  store = default_store
  return {error: "No project store"} unless store
  deleted = store.clear_moment_feedback(event_id.to_i)
  {success: true, deleted: deleted}
end

#conflict_detail(id, scope = "project") ⇒ Object



77
78
79
# File 'lib/claude_memory/dashboard/api.rb', line 77

def conflict_detail(id, scope = "project")
  Conflicts.new(@manager).detail(id, scope)
end

#conflicts(params = {}) ⇒ Object



57
58
59
# File 'lib/claude_memory/dashboard/api.rb', line 57

def conflicts(params = {})
  Conflicts.new(@manager).list(params)
end

#efficacy(params = {}) ⇒ Object



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'lib/claude_memory/dashboard/api.rb', line 427

def efficacy(params = {})
  store = default_store
  since = params["since"]
  session_id = params["session_id"]
  session_id = nil if session_id.to_s.empty?
  return Efficacy::Reporter.report([], timeframe: {since: since, session_id: session_id}) unless store

  # Session-scope lookup: most MCP tool calls don't carry session_id
  # (Claude Code doesn't thread its session id into plugin MCP servers),
  # so we correlate by time window instead — we find the session's
  # first-to-most-recent activity from hook events (which do carry
  # session_id) and pick up recall events that fell inside that window.
  if session_id
    window = session_window(store, session_id)
    events = ActivityLog.recent(store, limit: 500, event_type: "recall", since: window[:since])
    events = events.select { |e|
      if e[:session_id].to_s.empty?
        # MCP tool calls typically arrive without a session_id; fall
        # back to time-window correlation with the session's hook
        # events (which do carry session_id).
        within_window?(e, window)
      else
        e[:session_id] == session_id
      end
    }
  else
    events = ActivityLog.recent(store, limit: 500, event_type: "recall", since: since)
  end

  Efficacy::Reporter.report(events, timeframe: {since: since, session_id: session_id})
end

#extract_user_prompt(raw_text) ⇒ Object

Claude Code transcripts are JSONL where each line is a user/assistant turn. Extract the most recent human user message (not a tool_result or Claude-Code command-stdout wrapper) so recall moments can show “what the user asked” instead of raw JSONL.

Filters out:

  • tool_result entries (tool plumbing, not prompts)

  • <local-command-*> / <command-*> tagged content (Claude Code shell ops)

  • Blank / whitespace-only messages

Returns nil on parse failure or when no human prompt is found.



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/claude_memory/dashboard/api.rb', line 225

def extract_user_prompt(raw_text)
  return nil unless raw_text.is_a?(String) && !raw_text.empty?

  raw_text.split("\n").reverse_each do |line|
    next if line.strip.empty?
    begin
      turn = JSON.parse(line)
    rescue JSON::ParserError
      next
    end
    next unless turn.is_a?(Hash) && turn.dig("message", "role") == "user"

    content = turn.dig("message", "content")
    text = case content
    when String then content
    when Array
      content.filter_map { |c|
        next unless c.is_a?(Hash) && c["type"] == "text" && c["text"]
        c["text"]
      }.first
    end

    stripped = text.to_s.strip
    next if stripped.empty?
    next if plumbing_noise?(stripped)
    return stripped
  end
  nil
end

#fact_detail(id, scope) ⇒ Object

Full detail view for a single fact — subject/predicate/object, confidence, scope, status, full provenance chain (with session_id and occurred_at from content_items). Supports either scope so the frontend can drill into both project and global facts.



267
268
269
270
271
272
273
274
275
276
277
# File 'lib/claude_memory/dashboard/api.rb', line 267

def fact_detail(id, scope)
  return {error: "Invalid scope"} unless %w[global project].include?(scope)
  store = @manager.store_if_exists(scope)
  return {error: "#{scope} store not available"} unless store

  row = store.facts.where(id: id.to_i).first
  return {error: "Fact #{id} not found in #{scope}"} unless row

  detail = FactPresenter.new(store).with_provenance(row)
  detail.merge(source: scope, valid_from: row[:valid_from], valid_to: row[:valid_to])
end

#facts(params = {}) ⇒ Object



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/claude_memory/dashboard/api.rb', line 361

def facts(params = {})
  scope = params["scope"] || "all"
  limit = (params["limit"] || 50).to_i
  offset = (params["offset"] || 0).to_i
  status_filter = params["status"] || "active"
  search = params["q"]
  stale_only = params["stale"] == "true"

  stores = facts_stores_for(scope)
  return {facts: [], total: 0, limit: limit, offset: offset, scope: scope} if stores.empty?

  # [scope, id] pairs seen in recent recalls. We exclude per-scope so
  # project fact #5 being recalled doesn't hide global fact #5 from
  # the stale view (and vice versa).
  stale_excluded_pairs = stale_only ? facts_seen_in_recent_recalls : []
  stale_excluded_by_scope = stale_excluded_pairs.group_by(&:first).transform_values { |pairs| pairs.map(&:last) }

  collected = stores.flat_map { |source, store|
    dataset = store.facts.where(status: status_filter)
    dataset = dataset.where(Sequel.like(:predicate, "%#{search}%") | Sequel.like(:object_literal, "%#{search}%")) if search && !search.empty?
    if stale_only
      excluded = stale_excluded_by_scope[source] || []
      dataset = dataset.exclude(id: excluded) if excluded.any?
    end
    rows = dataset.order(Sequel.desc(:created_at)).all
    presented = FactPresenter.new(store).list_summary(rows)
    presented.map { |f| f.merge(source: source) }
  }
  collected.sort_by! { |f| -Core::RelativeTime.to_epoch(f[:created_at]) }

  {
    total: collected.size,
    limit: limit,
    offset: offset,
    scope: scope,
    stale: stale_only,
    facts: Array(collected[offset, limit])
  }
end

#facts_seen_in_recent_recallsObject

Aggregate scoped [scope, id] pairs that showed up in any successful recall over the stale window. Used to exclude “has been recalled recently” facts when the caller wants only the stale ones. Returns pairs rather than bare IDs so project fact #1 and global fact #1 don’t collide.



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'lib/claude_memory/dashboard/api.rb', line 406

def facts_seen_in_recent_recalls
  store = default_store
  return [] unless store
  cutoff = (Time.now.utc - STALE_WINDOW_DAYS * 86_400).iso8601
  pairs = Set.new
  store.activity_events
    .where(event_type: "recall", status: "success")
    .where { occurred_at >= cutoff }
    .select(:detail_json)
    .all
    .each do |row|
      details = row[:detail_json] ? JSON.parse(row[:detail_json]) : {}
      scoped = ScopedFactResolver.scoped_ids_from_details(details)
      ScopedFactResolver.flat_pairs(scoped).each { |pair| pairs << pair }
    end
  pairs.to_a
rescue Sequel::DatabaseError, JSON::ParserError => e
  ClaudeMemory.logger.debug("facts_seen_in_recent_recalls failed: #{e.message}")
  []
end

#find_recall_trigger(store, recall_row) ⇒ Object

10 min — a realistic session stretch



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/claude_memory/dashboard/api.rb', line 181

def find_recall_trigger(store, recall_row)
  window_start = (Time.parse(recall_row[:occurred_at]) - TRIGGER_WINDOW_SECONDS).utc.iso8601
  dataset = store.activity_events
    .where(event_type: %w[hook_ingest hook_context])
    .where(status: "success")
    .where { occurred_at <= recall_row[:occurred_at] }
    .where { occurred_at >= window_start }

  if recall_row[:session_id]
    dataset = dataset.where(session_id: recall_row[:session_id])
  end

  row = dataset.order(Sequel.desc(:occurred_at)).first
  return nil unless row

  details = row[:detail_json] ? JSON.parse(row[:detail_json], symbolize_names: true) : {}
  content_item_id = details[:content_id] || details[:content_item_id]
  content = content_item_id ? load_content_item(store, content_item_id) : nil

  {
    event_id: row[:id],
    event_type: row[:event_type],
    occurred_at: row[:occurred_at],
    occurred_ago: Core::RelativeTime.format(row[:occurred_at]),
    session_id: row[:session_id],
    user_prompt: content ? extract_user_prompt(content[:raw_text_preview]) : nil,
    content_item: content
  }
rescue ArgumentError, JSON::ParserError, Sequel::DatabaseError => e
  ClaudeMemory.logger.debug("find_recall_trigger failed: #{e.message}")
  nil
end

#healthObject



17
18
19
# File 'lib/claude_memory/dashboard/api.rb', line 17

def health
  Health.new(@manager).report
end

#knowledge(params = {}) ⇒ Object



69
70
71
# File 'lib/claude_memory/dashboard/api.rb', line 69

def knowledge(params = {})
  Knowledge.new(@manager).summary(params)
end

#moment_feedback(event_id, verdict:, note: nil) ⇒ Object



89
90
91
92
93
94
95
96
97
98
# File 'lib/claude_memory/dashboard/api.rb', line 89

def moment_feedback(event_id, verdict:, note: nil)
  store = default_store
  return {error: "No project store"} unless store
  return {error: "Invalid verdict (must be 'up' or 'down')"} unless %w[up down].include?(verdict)
  event = store.activity_events.where(id: event_id.to_i).first
  return {error: "Moment #{event_id} not found"} unless event

  row = store.upsert_moment_feedback(event_id: event_id.to_i, verdict: verdict, note: note)
  {success: true, feedback: serialize_feedback(row)}
end

#moments(params = {}) ⇒ Object



61
62
63
# File 'lib/claude_memory/dashboard/api.rb', line 61

def moments(params = {})
  Moments.new(@manager).list(params)
end

#plumbing_noise?(text) ⇒ Boolean

Returns:

  • (Boolean)


257
258
259
260
261
# File 'lib/claude_memory/dashboard/api.rb', line 257

def plumbing_noise?(text)
  return true if text.match?(COMMAND_TAG_RE)
  return true if text.start_with?("[tool_") # tool_use / tool_result stringified
  false
end

#promote_fact(id) ⇒ Object

Promote a project-scoped fact into the global store. Delegates to StoreManager#promote_fact which copies the fact + entities + provenance atomically inside a global-store transaction.



348
349
350
351
352
353
354
355
356
357
# File 'lib/claude_memory/dashboard/api.rb', line 348

def promote_fact(id)
  global_id = @manager.promote_fact(id.to_i)
  return {error: "Fact #{id} not found in project store"} if global_id.nil?

  {
    success: true,
    project_fact_id: id.to_i,
    global_fact_id: global_id
  }
end

#recall(params = {}) ⇒ Object

Live query tester. Reuses the production Recall pipeline so the dashboard shows exactly what Claude would see via memory.recall. Returns a bounded result set rendered through FactPresenter so shapes line up with the other surfaces (Facts tab, conflict detail).



303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/claude_memory/dashboard/api.rb', line 303

def recall(params = {})
  query_text = params["query"].to_s.strip
  return {error: "query required"} if query_text.empty?

  scope = params["scope"] || "all"
  limit = (params["limit"] || 10).to_i.clamp(1, 50)
  intent = params["intent"]

  # Recall#query needs at least one store open. default_store gives
  # us the best available; the engine takes it from there.
  default_store
  recaller = ClaudeMemory::Recall.new(@manager)
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  results = recaller.query(query_text, limit: limit, scope: scope, intent: intent)
  duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round

  facts = results.is_a?(Array) ? results : (results[:facts] || [])
  {
    query: query_text,
    scope: scope,
    limit: limit,
    duration_ms: duration_ms,
    count: facts.size,
    facts: facts.map { |f| serialize_recall_fact(f) }
  }
rescue => e
  msg = e.message
  # "disk image is malformed" from an FTS5 ORDER BY rank query almost
  # always means the FTS5 auxiliary index is out of sync (common
  # after a sqlite3 .recover restore or an interrupted write) — not
  # real DB corruption. Suggest the rebuild command inline so a user
  # looking at the dashboard knows exactly what to do.
  if msg.include?("disk image is malformed")
    {
      error: "Recall failed: #{msg}",
      hint: "Looks like the FTS5 index is out of sync. Try `claude-memory compact --scope project` (or --scope global) from a terminal to rebuild the search index. This is usually a harmless artifact of a prior DB recovery, not real corruption."
    }
  else
    {error: "Recall failed: #{msg}"}
  end
end

#reject_conflict_fact(id, side:, reason: nil, scope: "project") ⇒ Object



81
82
83
# File 'lib/claude_memory/dashboard/api.rb', line 81

def reject_conflict_fact(id, side:, reason: nil, scope: "project")
  Conflicts.new(@manager).reject(id, side: side, reason: reason, scope: scope)
end

#reject_fact(id, reason: nil, scope: "project") ⇒ Object

Reject a single fact (not a conflict side). Thin wrapper over SQLiteStore#reject_fact which cascade-resolves any conflicts the fact happened to be involved in.



282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/claude_memory/dashboard/api.rb', line 282

def reject_fact(id, reason: nil, scope: "project")
  return {error: "Invalid scope"} unless %w[global project].include?(scope)
  store = @manager.store_if_exists(scope)
  return {error: "#{scope} store not available"} unless store

  row = store.facts.where(id: id.to_i).first
  return {error: "Fact #{id} not found in #{scope}"} unless row

  result = store.reject_fact(id.to_i, reason: reason)
  {
    success: true,
    fact_id: id.to_i,
    scope: scope,
    conflicts_resolved: result[:conflicts_resolved] || 0
  }
end

#reject_similar_conflicts(keeper_fact_id, reason: nil, scope: "project") ⇒ Object



85
86
87
# File 'lib/claude_memory/dashboard/api.rb', line 85

def reject_similar_conflicts(keeper_fact_id, reason: nil, scope: "project")
  Conflicts.new(@manager).reject_similar(keeper_fact_id, reason: reason, scope: scope)
end

#reuse(params = {}) ⇒ Object



73
74
75
# File 'lib/claude_memory/dashboard/api.rb', line 73

def reuse(params = {})
  Reuse.new(@manager).top(params)
end

#session_summary(session_id) ⇒ Object



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
# File 'lib/claude_memory/dashboard/api.rb', line 107

def session_summary(session_id)
  store = default_store
  return {session_id: session_id, events: 0} unless store && session_id

  events = store.activity_events.where(session_id: session_id).all
  recalls = events.select { |e| e[:event_type] == "recall" }
  stores = events.select { |e| e[:event_type] == "store_extraction" }
  ingests = events.select { |e| e[:event_type] == "hook_ingest" }

  facts_recalled = recalls.sum { |e|
    details = e[:detail_json] ? JSON.parse(e[:detail_json], symbolize_names: true) : {}
    details[:result_count] || 0
  }
  facts_stored = stores.sum { |e|
    details = e[:detail_json] ? JSON.parse(e[:detail_json], symbolize_names: true) : {}
    details[:facts_created] || 0
  }
  total_latency = events.sum { |e| e[:duration_ms] || 0 }

  {
    session_id: session_id,
    events: events.size,
    recalls: recalls.size,
    facts_recalled: facts_recalled,
    facts_stored: facts_stored,
    ingests: ingests.size,
    total_latency_ms: total_latency
  }
end

#statsObject



21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/claude_memory/dashboard/api.rb', line 21

def stats
  result = {databases: {}}

  {global: @manager.global_db_path, project: @manager.project_db_path}.each do |scope, path|
    result[:databases][scope] = if File.exist?(path)
      store = @manager.store_for_scope(scope.to_s)
      db_stats(store, path)
    else
      {exists: false}
    end
  end

  result
end

#timelineObject



459
460
461
# File 'lib/claude_memory/dashboard/api.rb', line 459

def timeline
  Timeline.new(@manager).days
end

#trustObject



65
66
67
# File 'lib/claude_memory/dashboard/api.rb', line 65

def trust
  Trust.new(@manager).snapshot
end