Class: ClaudeMemory::Dashboard::API
- Inherits:
-
Object
- Object
- ClaudeMemory::Dashboard::API
- 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
- #activity(params = {}) ⇒ Object
- #activity_detail(id) ⇒ Object
- #clear_moment_feedback(event_id) ⇒ Object
- #conflict_detail(id, scope = "project") ⇒ Object
- #conflicts(params = {}) ⇒ Object
- #efficacy(params = {}) ⇒ Object
-
#extract_user_prompt(raw_text) ⇒ Object
Claude Code transcripts are JSONL where each line is a user/assistant turn.
-
#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).
- #facts(params = {}) ⇒ Object
-
#facts_seen_in_recent_recalls ⇒ Object
Aggregate scoped [scope, id] pairs that showed up in any successful recall over the stale window.
-
#find_recall_trigger(store, recall_row) ⇒ Object
10 min — a realistic session stretch.
- #health ⇒ Object
-
#initialize(manager) ⇒ API
constructor
A new instance of API.
- #knowledge(params = {}) ⇒ Object
- #moment_feedback(event_id, verdict:, note: nil) ⇒ Object
- #moments(params = {}) ⇒ Object
- #plumbing_noise?(text) ⇒ Boolean
-
#promote_fact(id) ⇒ Object
Promote a project-scoped fact into the global store.
-
#recall(params = {}) ⇒ Object
Live query tester.
- #reject_conflict_fact(id, side:, reason: nil, scope: "project") ⇒ Object
-
#reject_fact(id, reason: nil, scope: "project") ⇒ Object
Reject a single fact (not a conflict side).
- #reject_similar_conflicts(keeper_fact_id, reason: nil, scope: "project") ⇒ Object
- #reuse(params = {}) ⇒ Object
- #session_summary(session_id) ⇒ Object
- #stats ⇒ Object
- #timeline ⇒ Object
- #trust ⇒ Object
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_recalls ⇒ Object
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.}") [] 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.}") nil end |
#health ⇒ Object
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
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. # "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 |
#stats ⇒ Object
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 |