claude_memory 0.9.1 → 0.11.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.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/skills/dashboard/SKILL.md +42 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +130 -0
- data/CLAUDE.md +30 -6
- data/README.md +66 -2
- data/db/migrations/015_add_activity_events.rb +26 -0
- data/db/migrations/016_add_moment_feedback.rb +22 -0
- data/db/migrations/017_add_last_recalled_at.rb +15 -0
- data/docs/1_0_punchlist.md +371 -0
- data/docs/EXAMPLES.md +41 -2
- data/docs/GETTING_STARTED.md +33 -4
- data/docs/architecture.md +22 -7
- data/docs/audit-queries.md +131 -0
- data/docs/dashboard.md +192 -0
- data/docs/improvements.md +650 -9
- data/docs/influence/cq.md +187 -0
- data/docs/plugin.md +13 -6
- data/docs/quality_review.md +524 -172
- data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
- data/lib/claude_memory/activity_log.rb +86 -0
- data/lib/claude_memory/commands/census_command.rb +210 -0
- data/lib/claude_memory/commands/completion_command.rb +3 -0
- data/lib/claude_memory/commands/dashboard_command.rb +54 -0
- data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
- data/lib/claude_memory/commands/digest_command.rb +273 -0
- data/lib/claude_memory/commands/hook_command.rb +61 -2
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
- data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
- data/lib/claude_memory/commands/registry.rb +7 -1
- data/lib/claude_memory/commands/show_command.rb +90 -0
- data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
- data/lib/claude_memory/commands/stats_command.rb +131 -2
- data/lib/claude_memory/commands/sweep_command.rb +2 -0
- data/lib/claude_memory/configuration.rb +16 -0
- data/lib/claude_memory/core/relative_time.rb +9 -0
- data/lib/claude_memory/dashboard/api.rb +610 -0
- data/lib/claude_memory/dashboard/conflicts.rb +279 -0
- data/lib/claude_memory/dashboard/efficacy.rb +127 -0
- data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
- data/lib/claude_memory/dashboard/health.rb +175 -0
- data/lib/claude_memory/dashboard/index.html +2707 -0
- data/lib/claude_memory/dashboard/knowledge.rb +136 -0
- data/lib/claude_memory/dashboard/moments.rb +244 -0
- data/lib/claude_memory/dashboard/reuse.rb +97 -0
- data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
- data/lib/claude_memory/dashboard/server.rb +211 -0
- data/lib/claude_memory/dashboard/timeline.rb +68 -0
- data/lib/claude_memory/dashboard/trust.rb +454 -0
- data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
- data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
- data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
- data/lib/claude_memory/hook/context_injector.rb +97 -3
- data/lib/claude_memory/hook/handler.rb +191 -3
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
- data/lib/claude_memory/mcp/query_guide.rb +11 -0
- data/lib/claude_memory/mcp/text_summary.rb +29 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
- data/lib/claude_memory/mcp/tools.rb +148 -0
- data/lib/claude_memory/publish.rb +13 -21
- data/lib/claude_memory/recall/stale_detector.rb +67 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
- data/lib/claude_memory/resolve/resolver.rb +41 -11
- data/lib/claude_memory/store/llm_cache.rb +68 -0
- data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +47 -143
- data/lib/claude_memory/store/store_manager.rb +29 -0
- data/lib/claude_memory/sweep/maintenance.rb +216 -0
- data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
- data/lib/claude_memory/sweep/sweeper.rb +2 -0
- data/lib/claude_memory/templates/hooks.example.json +5 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +24 -0
- metadata +51 -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
|