claude_memory 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/skills/dashboard/SKILL.md +42 -0
  4. data/.claude-plugin/marketplace.json +1 -1
  5. data/.claude-plugin/plugin.json +1 -1
  6. data/CHANGELOG.md +86 -0
  7. data/CLAUDE.md +21 -5
  8. data/README.md +32 -2
  9. data/db/migrations/015_add_activity_events.rb +26 -0
  10. data/db/migrations/016_add_moment_feedback.rb +22 -0
  11. data/db/migrations/017_add_last_recalled_at.rb +15 -0
  12. data/docs/1_0_punchlist.md +190 -0
  13. data/docs/EXAMPLES.md +41 -2
  14. data/docs/GETTING_STARTED.md +31 -4
  15. data/docs/architecture.md +22 -7
  16. data/docs/audit-queries.md +131 -0
  17. data/docs/dashboard.md +172 -0
  18. data/docs/improvements.md +465 -9
  19. data/docs/influence/cq.md +187 -0
  20. data/docs/plugin.md +13 -6
  21. data/docs/quality_review.md +489 -172
  22. data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
  23. data/lib/claude_memory/activity_log.rb +86 -0
  24. data/lib/claude_memory/commands/census_command.rb +210 -0
  25. data/lib/claude_memory/commands/completion_command.rb +3 -0
  26. data/lib/claude_memory/commands/dashboard_command.rb +54 -0
  27. data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
  28. data/lib/claude_memory/commands/digest_command.rb +181 -0
  29. data/lib/claude_memory/commands/hook_command.rb +34 -0
  30. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  31. data/lib/claude_memory/commands/registry.rb +6 -1
  32. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  33. data/lib/claude_memory/commands/stats_command.rb +38 -1
  34. data/lib/claude_memory/commands/sweep_command.rb +2 -0
  35. data/lib/claude_memory/configuration.rb +16 -0
  36. data/lib/claude_memory/core/relative_time.rb +9 -0
  37. data/lib/claude_memory/dashboard/api.rb +610 -0
  38. data/lib/claude_memory/dashboard/conflicts.rb +279 -0
  39. data/lib/claude_memory/dashboard/efficacy.rb +127 -0
  40. data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
  41. data/lib/claude_memory/dashboard/health.rb +175 -0
  42. data/lib/claude_memory/dashboard/index.html +2707 -0
  43. data/lib/claude_memory/dashboard/knowledge.rb +136 -0
  44. data/lib/claude_memory/dashboard/moments.rb +244 -0
  45. data/lib/claude_memory/dashboard/reuse.rb +97 -0
  46. data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
  47. data/lib/claude_memory/dashboard/server.rb +211 -0
  48. data/lib/claude_memory/dashboard/timeline.rb +68 -0
  49. data/lib/claude_memory/dashboard/trust.rb +285 -0
  50. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  51. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  52. data/lib/claude_memory/hook/context_injector.rb +97 -3
  53. data/lib/claude_memory/hook/handler.rb +50 -3
  54. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  55. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  56. data/lib/claude_memory/mcp/text_summary.rb +29 -0
  57. data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
  58. data/lib/claude_memory/mcp/tools.rb +148 -0
  59. data/lib/claude_memory/publish.rb +13 -21
  60. data/lib/claude_memory/recall/stale_detector.rb +67 -0
  61. data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
  62. data/lib/claude_memory/resolve/resolver.rb +41 -11
  63. data/lib/claude_memory/store/llm_cache.rb +68 -0
  64. data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
  65. data/lib/claude_memory/store/schema_manager.rb +1 -1
  66. data/lib/claude_memory/store/sqlite_store.rb +47 -143
  67. data/lib/claude_memory/store/store_manager.rb +29 -0
  68. data/lib/claude_memory/sweep/maintenance.rb +216 -0
  69. data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
  70. data/lib/claude_memory/sweep/sweeper.rb +2 -0
  71. data/lib/claude_memory/version.rb +1 -1
  72. data/lib/claude_memory.rb +22 -0
  73. metadata +49 -1
@@ -0,0 +1,610 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeMemory
6
+ module Dashboard
7
+ # JSON API backend for the dashboard. Routes/delegates to dedicated
8
+ # collaborator classes (Conflicts, Moments, Trust, Knowledge, Reuse,
9
+ # Timeline, Health, FactPresenter) for non-trivial logic; this class
10
+ # holds HTTP-shape concerns and the long-tail per-endpoint formatting
11
+ # that hasn't yet been extracted.
12
+ class API
13
+ def initialize(manager)
14
+ @manager = manager
15
+ end
16
+
17
+ def health
18
+ Health.new(@manager).report
19
+ end
20
+
21
+ def stats
22
+ result = {databases: {}}
23
+
24
+ {global: @manager.global_db_path, project: @manager.project_db_path}.each do |scope, path|
25
+ result[:databases][scope] = if File.exist?(path)
26
+ store = @manager.store_for_scope(scope.to_s)
27
+ db_stats(store, path)
28
+ else
29
+ {exists: false}
30
+ end
31
+ end
32
+
33
+ result
34
+ end
35
+
36
+ def activity(params = {})
37
+ store = default_store
38
+ return {events: [], summary: {}} unless store
39
+
40
+ limit = (params["limit"] || 100).to_i
41
+ event_type = params["event_type"]
42
+ since = params["since"]
43
+
44
+ events = ActivityLog.recent(store, limit: limit, event_type: event_type, since: since)
45
+ summary = ActivityLog.summary(store, since: since)
46
+
47
+ {
48
+ event_count: events.size,
49
+ summary: summary,
50
+ events: events.map { |e|
51
+ e[:occurred_ago] = Core::RelativeTime.format(e[:occurred_at])
52
+ e
53
+ }
54
+ }
55
+ end
56
+
57
+ def conflicts(params = {})
58
+ Conflicts.new(@manager).list(params)
59
+ end
60
+
61
+ def moments(params = {})
62
+ Moments.new(@manager).list(params)
63
+ end
64
+
65
+ def trust
66
+ Trust.new(@manager).snapshot
67
+ end
68
+
69
+ def knowledge(params = {})
70
+ Knowledge.new(@manager).summary(params)
71
+ end
72
+
73
+ def reuse(params = {})
74
+ Reuse.new(@manager).top(params)
75
+ end
76
+
77
+ def conflict_detail(id, scope = "project")
78
+ Conflicts.new(@manager).detail(id, scope)
79
+ end
80
+
81
+ def reject_conflict_fact(id, side:, reason: nil, scope: "project")
82
+ Conflicts.new(@manager).reject(id, side: side, reason: reason, scope: scope)
83
+ end
84
+
85
+ def reject_similar_conflicts(keeper_fact_id, reason: nil, scope: "project")
86
+ Conflicts.new(@manager).reject_similar(keeper_fact_id, reason: reason, scope: scope)
87
+ end
88
+
89
+ def moment_feedback(event_id, verdict:, note: nil)
90
+ store = default_store
91
+ return {error: "No project store"} unless store
92
+ return {error: "Invalid verdict (must be 'up' or 'down')"} unless %w[up down].include?(verdict)
93
+ event = store.activity_events.where(id: event_id.to_i).first
94
+ return {error: "Moment #{event_id} not found"} unless event
95
+
96
+ row = store.upsert_moment_feedback(event_id: event_id.to_i, verdict: verdict, note: note)
97
+ {success: true, feedback: serialize_feedback(row)}
98
+ end
99
+
100
+ def clear_moment_feedback(event_id)
101
+ store = default_store
102
+ return {error: "No project store"} unless store
103
+ deleted = store.clear_moment_feedback(event_id.to_i)
104
+ {success: true, deleted: deleted}
105
+ end
106
+
107
+ def session_summary(session_id)
108
+ store = default_store
109
+ return {session_id: session_id, events: 0} unless store && session_id
110
+
111
+ events = store.activity_events.where(session_id: session_id).all
112
+ recalls = events.select { |e| e[:event_type] == "recall" }
113
+ stores = events.select { |e| e[:event_type] == "store_extraction" }
114
+ ingests = events.select { |e| e[:event_type] == "hook_ingest" }
115
+
116
+ facts_recalled = recalls.sum { |e|
117
+ details = e[:detail_json] ? JSON.parse(e[:detail_json], symbolize_names: true) : {}
118
+ details[:result_count] || 0
119
+ }
120
+ facts_stored = stores.sum { |e|
121
+ details = e[:detail_json] ? JSON.parse(e[:detail_json], symbolize_names: true) : {}
122
+ details[:facts_created] || 0
123
+ }
124
+ total_latency = events.sum { |e| e[:duration_ms] || 0 }
125
+
126
+ {
127
+ session_id: session_id,
128
+ events: events.size,
129
+ recalls: recalls.size,
130
+ facts_recalled: facts_recalled,
131
+ facts_stored: facts_stored,
132
+ ingests: ingests.size,
133
+ total_latency_ms: total_latency
134
+ }
135
+ end
136
+
137
+ def activity_detail(id)
138
+ store = default_store
139
+ return {error: "No database available"} unless store
140
+
141
+ row = store.activity_events.where(id: id.to_i).first
142
+ return {error: "Event #{id} not found"} unless row
143
+
144
+ details = row[:detail_json] ? JSON.parse(row[:detail_json], symbolize_names: true) : {}
145
+ event = row.merge(
146
+ details: details,
147
+ occurred_ago: Core::RelativeTime.format(row[:occurred_at])
148
+ )
149
+ event.delete(:detail_json)
150
+
151
+ content_item_id = details[:content_id] || details[:content_item_id]
152
+ content_item = content_item_id ? load_content_item(store, content_item_id) : nil
153
+ linked_facts = if content_item_id
154
+ load_linked_facts(store, content_item_id)
155
+ else
156
+ scoped = ScopedFactResolver.scoped_ids_from_details(details)
157
+ scoped.any? ? ScopedFactResolver.resolve(@manager, scoped) : []
158
+ end
159
+
160
+ # For recalls, "what triggered this" is high-signal context that the
161
+ # raw event detail can't answer. Find the ingest immediately before
162
+ # this recall so the modal can show the user prompt / assistant turn
163
+ # that motivated the lookup. Time-window fallback when session_id is
164
+ # absent (MCP tool calls don't thread session_id).
165
+ trigger = (row[:event_type] == "recall") ? find_recall_trigger(store, row) : nil
166
+
167
+ {
168
+ event: event,
169
+ content_item: content_item,
170
+ linked_facts: linked_facts,
171
+ trigger: trigger
172
+ }.compact
173
+ end
174
+
175
+ # Find the hook_ingest event that most likely triggered a given recall.
176
+ # Recall events often arrive from MCP tool calls without a session_id,
177
+ # so we use time proximity: the last successful ingest before the recall
178
+ # within a small window.
179
+ TRIGGER_WINDOW_SECONDS = 600 # 10 min — a realistic session stretch
180
+
181
+ def find_recall_trigger(store, recall_row)
182
+ window_start = (Time.parse(recall_row[:occurred_at]) - TRIGGER_WINDOW_SECONDS).utc.iso8601
183
+ dataset = store.activity_events
184
+ .where(event_type: %w[hook_ingest hook_context])
185
+ .where(status: "success")
186
+ .where { occurred_at <= recall_row[:occurred_at] }
187
+ .where { occurred_at >= window_start }
188
+
189
+ if recall_row[:session_id]
190
+ dataset = dataset.where(session_id: recall_row[:session_id])
191
+ end
192
+
193
+ row = dataset.order(Sequel.desc(:occurred_at)).first
194
+ return nil unless row
195
+
196
+ details = row[:detail_json] ? JSON.parse(row[:detail_json], symbolize_names: true) : {}
197
+ content_item_id = details[:content_id] || details[:content_item_id]
198
+ content = content_item_id ? load_content_item(store, content_item_id) : nil
199
+
200
+ {
201
+ event_id: row[:id],
202
+ event_type: row[:event_type],
203
+ occurred_at: row[:occurred_at],
204
+ occurred_ago: Core::RelativeTime.format(row[:occurred_at]),
205
+ session_id: row[:session_id],
206
+ user_prompt: content ? extract_user_prompt(content[:raw_text_preview]) : nil,
207
+ content_item: content
208
+ }
209
+ rescue ArgumentError, JSON::ParserError, Sequel::DatabaseError => e
210
+ ClaudeMemory.logger.debug("find_recall_trigger failed: #{e.message}")
211
+ nil
212
+ end
213
+
214
+ # Claude Code transcripts are JSONL where each line is a user/assistant
215
+ # turn. Extract the most recent *human* user message (not a tool_result
216
+ # or Claude-Code command-stdout wrapper) so recall moments can show
217
+ # "what the user asked" instead of raw JSONL.
218
+ #
219
+ # Filters out:
220
+ # - tool_result entries (tool plumbing, not prompts)
221
+ # - <local-command-*> / <command-*> tagged content (Claude Code shell ops)
222
+ # - Blank / whitespace-only messages
223
+ #
224
+ # Returns nil on parse failure or when no human prompt is found.
225
+ def extract_user_prompt(raw_text)
226
+ return nil unless raw_text.is_a?(String) && !raw_text.empty?
227
+
228
+ raw_text.split("\n").reverse_each do |line|
229
+ next if line.strip.empty?
230
+ begin
231
+ turn = JSON.parse(line)
232
+ rescue JSON::ParserError
233
+ next
234
+ end
235
+ next unless turn.is_a?(Hash) && turn.dig("message", "role") == "user"
236
+
237
+ content = turn.dig("message", "content")
238
+ text = case content
239
+ when String then content
240
+ when Array
241
+ content.filter_map { |c|
242
+ next unless c.is_a?(Hash) && c["type"] == "text" && c["text"]
243
+ c["text"]
244
+ }.first
245
+ end
246
+
247
+ stripped = text.to_s.strip
248
+ next if stripped.empty?
249
+ next if plumbing_noise?(stripped)
250
+ return stripped
251
+ end
252
+ nil
253
+ end
254
+
255
+ COMMAND_TAG_RE = /\A<(?:local-command-[a-z]+|command-(?:name|args|message|stdout|stderr))\b/i
256
+
257
+ def plumbing_noise?(text)
258
+ return true if text.match?(COMMAND_TAG_RE)
259
+ return true if text.start_with?("[tool_") # tool_use / tool_result stringified
260
+ false
261
+ end
262
+
263
+ # Full detail view for a single fact — subject/predicate/object,
264
+ # confidence, scope, status, full provenance chain (with session_id
265
+ # and occurred_at from content_items). Supports either scope so
266
+ # the frontend can drill into both project and global facts.
267
+ def fact_detail(id, scope)
268
+ return {error: "Invalid scope"} unless %w[global project].include?(scope)
269
+ store = @manager.store_if_exists(scope)
270
+ return {error: "#{scope} store not available"} unless store
271
+
272
+ row = store.facts.where(id: id.to_i).first
273
+ return {error: "Fact #{id} not found in #{scope}"} unless row
274
+
275
+ detail = FactPresenter.new(store).with_provenance(row)
276
+ detail.merge(source: scope, valid_from: row[:valid_from], valid_to: row[:valid_to])
277
+ end
278
+
279
+ # Reject a single fact (not a conflict side). Thin wrapper over
280
+ # SQLiteStore#reject_fact which cascade-resolves any conflicts the
281
+ # fact happened to be involved in.
282
+ def reject_fact(id, reason: nil, scope: "project")
283
+ return {error: "Invalid scope"} unless %w[global project].include?(scope)
284
+ store = @manager.store_if_exists(scope)
285
+ return {error: "#{scope} store not available"} unless store
286
+
287
+ row = store.facts.where(id: id.to_i).first
288
+ return {error: "Fact #{id} not found in #{scope}"} unless row
289
+
290
+ result = store.reject_fact(id.to_i, reason: reason)
291
+ {
292
+ success: true,
293
+ fact_id: id.to_i,
294
+ scope: scope,
295
+ conflicts_resolved: result[:conflicts_resolved] || 0
296
+ }
297
+ end
298
+
299
+ # Live query tester. Reuses the production Recall pipeline so the
300
+ # dashboard shows exactly what Claude would see via memory.recall.
301
+ # Returns a bounded result set rendered through FactPresenter so
302
+ # shapes line up with the other surfaces (Facts tab, conflict detail).
303
+ def recall(params = {})
304
+ query_text = params["query"].to_s.strip
305
+ return {error: "query required"} if query_text.empty?
306
+
307
+ scope = params["scope"] || "all"
308
+ limit = (params["limit"] || 10).to_i.clamp(1, 50)
309
+ intent = params["intent"]
310
+
311
+ # Recall#query needs at least one store open. default_store gives
312
+ # us the best available; the engine takes it from there.
313
+ default_store
314
+ recaller = ClaudeMemory::Recall.new(@manager)
315
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
316
+ results = recaller.query(query_text, limit: limit, scope: scope, intent: intent)
317
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
318
+
319
+ facts = results.is_a?(Array) ? results : (results[:facts] || [])
320
+ {
321
+ query: query_text,
322
+ scope: scope,
323
+ limit: limit,
324
+ duration_ms: duration_ms,
325
+ count: facts.size,
326
+ facts: facts.map { |f| serialize_recall_fact(f) }
327
+ }
328
+ rescue => e
329
+ msg = e.message
330
+ # "disk image is malformed" from an FTS5 ORDER BY rank query almost
331
+ # always means the FTS5 auxiliary index is out of sync (common
332
+ # after a sqlite3 .recover restore or an interrupted write) — not
333
+ # real DB corruption. Suggest the rebuild command inline so a user
334
+ # looking at the dashboard knows exactly what to do.
335
+ if msg.include?("disk image is malformed")
336
+ {
337
+ error: "Recall failed: #{msg}",
338
+ 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."
339
+ }
340
+ else
341
+ {error: "Recall failed: #{msg}"}
342
+ end
343
+ end
344
+
345
+ # Promote a project-scoped fact into the global store. Delegates to
346
+ # StoreManager#promote_fact which copies the fact + entities +
347
+ # provenance atomically inside a global-store transaction.
348
+ def promote_fact(id)
349
+ global_id = @manager.promote_fact(id.to_i)
350
+ return {error: "Fact #{id} not found in project store"} if global_id.nil?
351
+
352
+ {
353
+ success: true,
354
+ project_fact_id: id.to_i,
355
+ global_fact_id: global_id
356
+ }
357
+ end
358
+
359
+ STALE_WINDOW_DAYS = 30
360
+
361
+ def facts(params = {})
362
+ scope = params["scope"] || "all"
363
+ limit = (params["limit"] || 50).to_i
364
+ offset = (params["offset"] || 0).to_i
365
+ status_filter = params["status"] || "active"
366
+ search = params["q"]
367
+ stale_only = params["stale"] == "true"
368
+
369
+ stores = facts_stores_for(scope)
370
+ return {facts: [], total: 0, limit: limit, offset: offset, scope: scope} if stores.empty?
371
+
372
+ # [scope, id] pairs seen in recent recalls. We exclude per-scope so
373
+ # project fact #5 being recalled doesn't hide global fact #5 from
374
+ # the stale view (and vice versa).
375
+ stale_excluded_pairs = stale_only ? facts_seen_in_recent_recalls : []
376
+ stale_excluded_by_scope = stale_excluded_pairs.group_by(&:first).transform_values { |pairs| pairs.map(&:last) }
377
+
378
+ collected = stores.flat_map { |source, store|
379
+ dataset = store.facts.where(status: status_filter)
380
+ dataset = dataset.where(Sequel.like(:predicate, "%#{search}%") | Sequel.like(:object_literal, "%#{search}%")) if search && !search.empty?
381
+ if stale_only
382
+ excluded = stale_excluded_by_scope[source] || []
383
+ dataset = dataset.exclude(id: excluded) if excluded.any?
384
+ end
385
+ rows = dataset.order(Sequel.desc(:created_at)).all
386
+ presented = FactPresenter.new(store).list_summary(rows)
387
+ presented.map { |f| f.merge(source: source) }
388
+ }
389
+ collected.sort_by! { |f| -Core::RelativeTime.to_epoch(f[:created_at]) }
390
+
391
+ {
392
+ total: collected.size,
393
+ limit: limit,
394
+ offset: offset,
395
+ scope: scope,
396
+ stale: stale_only,
397
+ facts: Array(collected[offset, limit])
398
+ }
399
+ end
400
+
401
+ # Aggregate scoped [scope, id] pairs that showed up in any successful
402
+ # recall over the stale window. Used to exclude "has been recalled
403
+ # recently" facts when the caller wants only the stale ones.
404
+ # Returns pairs rather than bare IDs so project fact #1 and global
405
+ # fact #1 don't collide.
406
+ def facts_seen_in_recent_recalls
407
+ store = default_store
408
+ return [] unless store
409
+ cutoff = (Time.now.utc - STALE_WINDOW_DAYS * 86_400).iso8601
410
+ pairs = Set.new
411
+ store.activity_events
412
+ .where(event_type: "recall", status: "success")
413
+ .where { occurred_at >= cutoff }
414
+ .select(:detail_json)
415
+ .all
416
+ .each do |row|
417
+ details = row[:detail_json] ? JSON.parse(row[:detail_json]) : {}
418
+ scoped = ScopedFactResolver.scoped_ids_from_details(details)
419
+ ScopedFactResolver.flat_pairs(scoped).each { |pair| pairs << pair }
420
+ end
421
+ pairs.to_a
422
+ rescue Sequel::DatabaseError, JSON::ParserError => e
423
+ ClaudeMemory.logger.debug("facts_seen_in_recent_recalls failed: #{e.message}")
424
+ []
425
+ end
426
+
427
+ def efficacy(params = {})
428
+ store = default_store
429
+ since = params["since"]
430
+ session_id = params["session_id"]
431
+ session_id = nil if session_id.to_s.empty?
432
+ return Efficacy::Reporter.report([], timeframe: {since: since, session_id: session_id}) unless store
433
+
434
+ # Session-scope lookup: most MCP tool calls don't carry session_id
435
+ # (Claude Code doesn't thread its session id into plugin MCP servers),
436
+ # so we correlate by time window instead — we find the session's
437
+ # first-to-most-recent activity from hook events (which do carry
438
+ # session_id) and pick up recall events that fell inside that window.
439
+ if session_id
440
+ window = session_window(store, session_id)
441
+ events = ActivityLog.recent(store, limit: 500, event_type: "recall", since: window[:since])
442
+ events = events.select { |e|
443
+ if e[:session_id].to_s.empty?
444
+ # MCP tool calls typically arrive without a session_id; fall
445
+ # back to time-window correlation with the session's hook
446
+ # events (which do carry session_id).
447
+ within_window?(e, window)
448
+ else
449
+ e[:session_id] == session_id
450
+ end
451
+ }
452
+ else
453
+ events = ActivityLog.recent(store, limit: 500, event_type: "recall", since: since)
454
+ end
455
+
456
+ Efficacy::Reporter.report(events, timeframe: {since: since, session_id: session_id})
457
+ end
458
+
459
+ def timeline
460
+ Timeline.new(@manager).days
461
+ end
462
+
463
+ private
464
+
465
+ CONTENT_ITEM_PREVIEW_BYTES = 8000
466
+
467
+ def serialize_feedback(row)
468
+ return nil unless row
469
+ {
470
+ event_id: row[:event_id],
471
+ verdict: row[:verdict],
472
+ note: row[:note],
473
+ recorded_at: row[:recorded_at],
474
+ recorded_ago: Core::RelativeTime.format(row[:recorded_at])
475
+ }
476
+ end
477
+
478
+ # Recall returns results in the shape {fact:, receipts:, source:} —
479
+ # the fact sub-hash carries the actual fields (subject_name, predicate,
480
+ # object_literal, scope, ...). Receipts are the provenance chain.
481
+ # We flatten to the dashboard's expected shape and surface the
482
+ # receipts count so users can see "this had 27 supporting quotes"
483
+ # at a glance without drilling in.
484
+ def serialize_recall_fact(result)
485
+ fact = result[:fact] || result["fact"] || result
486
+ receipts = result[:receipts] || result["receipts"] || []
487
+ source = result[:source] || result["source"]
488
+
489
+ {
490
+ id: fact[:id] || fact["id"],
491
+ docid: fact[:docid] || fact["docid"],
492
+ subject: fact[:subject_name] || fact["subject_name"] || fact[:subject] || fact["subject"],
493
+ predicate: fact[:predicate] || fact["predicate"],
494
+ object: fact[:object_literal] || fact["object_literal"] || fact[:object] || fact["object"],
495
+ scope: fact[:scope] || fact["scope"],
496
+ source: source.to_s,
497
+ score: fact[:score] || fact["score"],
498
+ confidence: fact[:confidence] || fact["confidence"],
499
+ created_at: fact[:created_at] || fact["created_at"],
500
+ receipts_count: receipts.is_a?(Array) ? receipts.size : nil
501
+ }.compact
502
+ end
503
+
504
+ # Return the {since:, until:} ISO timestamps of the first-to-last
505
+ # activity event we've seen for a given session_id. Used to correlate
506
+ # MCP recall events (which typically lack session_id) back to the
507
+ # Claude Code session that fired them via time proximity.
508
+ def session_window(store, session_id)
509
+ rows = store.activity_events.where(session_id: session_id).select(:occurred_at).all
510
+ return {since: nil, until: nil} if rows.empty?
511
+ timestamps = rows.map { |r| r[:occurred_at] }.compact
512
+ {since: timestamps.min, until: timestamps.max}
513
+ end
514
+
515
+ def within_window?(event, window)
516
+ return false unless window[:since] && window[:until]
517
+ ts = event[:occurred_at]
518
+ return false unless ts
519
+ ts.between?(window[:since], window[:until])
520
+ end
521
+
522
+ def facts_stores_for(scope)
523
+ case scope
524
+ when "project"
525
+ {"project" => @manager.store_if_exists("project")}.compact
526
+ when "global"
527
+ {"global" => @manager.store_if_exists("global")}.compact
528
+ else
529
+ {
530
+ "project" => @manager.store_if_exists("project"),
531
+ "global" => @manager.store_if_exists("global")
532
+ }.compact
533
+ end
534
+ end
535
+
536
+ def load_content_item(store, id)
537
+ row = store.content_items.where(id: id.to_i).first
538
+ return nil unless row
539
+
540
+ raw = row[:raw_text].to_s
541
+ truncated = raw.bytesize > CONTENT_ITEM_PREVIEW_BYTES
542
+ preview = truncated ? raw.byteslice(0, CONTENT_ITEM_PREVIEW_BYTES) : raw
543
+
544
+ {
545
+ id: row[:id],
546
+ source: row[:source],
547
+ session_id: row[:session_id],
548
+ transcript_path: row[:transcript_path],
549
+ project_path: row[:project_path],
550
+ byte_len: row[:byte_len],
551
+ occurred_at: row[:occurred_at],
552
+ ingested_at: row[:ingested_at],
553
+ raw_text_preview: preview,
554
+ truncated: truncated
555
+ }
556
+ end
557
+
558
+ def load_linked_facts(store, content_item_id)
559
+ rows = store.db[:facts]
560
+ .join(:provenance, fact_id: :id)
561
+ .where(Sequel[:provenance][:content_item_id] => content_item_id.to_i)
562
+ .select(Sequel[:facts].*)
563
+ .all
564
+ FactPresenter.new(store).list_summary(rows)
565
+ end
566
+
567
+ def load_facts_by_ids(store, ids)
568
+ return [] if ids.nil? || ids.empty?
569
+ rows = store.facts.where(id: ids.map(&:to_i)).all
570
+ # Preserve the order given by the caller (ranking from recall).
571
+ index = ids.each_with_index.to_h
572
+ ordered = rows.sort_by { |r| index[r[:id]] || Float::INFINITY }
573
+ FactPresenter.new(store).list_summary(ordered)
574
+ end
575
+
576
+ def default_store
577
+ @manager.default_store(prefer: :project)
578
+ end
579
+
580
+ def db_stats(store, path)
581
+ {
582
+ exists: true,
583
+ path: path,
584
+ size_bytes: File.size(path),
585
+ facts_total: store.facts.count,
586
+ facts_active: store.facts.where(status: "active").count,
587
+ facts_superseded: store.facts.where(status: "superseded").count,
588
+ entities_total: store.entities.count,
589
+ content_items: store.content_items.count,
590
+ open_conflicts: store.conflicts.where(status: "open").count,
591
+ schema_version: store.schema_version,
592
+ top_predicates: store.facts
593
+ .where(status: "active")
594
+ .group_and_count(:predicate)
595
+ .order(Sequel.desc(:count))
596
+ .limit(10)
597
+ .all
598
+ .map { |r| {predicate: r[:predicate], count: r[:count]} },
599
+ entity_types: store.entities
600
+ .group_and_count(:type)
601
+ .order(Sequel.desc(:count))
602
+ .all
603
+ .map { |r| {type: r[:type], count: r[:count]} }
604
+ }
605
+ rescue => e
606
+ {exists: true, path: path, error: e.message}
607
+ end
608
+ end
609
+ end
610
+ end