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,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ClaudeMemory
|
|
7
|
+
module Dashboard
|
|
8
|
+
class Server
|
|
9
|
+
DEFAULT_PORT = 3377
|
|
10
|
+
|
|
11
|
+
def initialize(manager:, port: DEFAULT_PORT, open_browser: true)
|
|
12
|
+
@manager = manager
|
|
13
|
+
@port = port
|
|
14
|
+
@open_browser = open_browser
|
|
15
|
+
@server = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def start
|
|
19
|
+
@server = WEBrick::HTTPServer.new(
|
|
20
|
+
Port: @port,
|
|
21
|
+
Logger: WEBrick::Log.new(File::NULL),
|
|
22
|
+
AccessLog: []
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
mount_routes
|
|
26
|
+
|
|
27
|
+
trap("INT") { @server.shutdown }
|
|
28
|
+
trap("TERM") { @server.shutdown }
|
|
29
|
+
|
|
30
|
+
open_browser if @open_browser
|
|
31
|
+
@server.start
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stop
|
|
35
|
+
@server&.shutdown
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def mount_routes
|
|
41
|
+
api = API.new(@manager)
|
|
42
|
+
|
|
43
|
+
@server.mount_proc("/") { |_req, res| serve_html(res) }
|
|
44
|
+
@server.mount_proc("/api/health") { |_req, res| with_fresh_connections { json_response(res, api.health) } }
|
|
45
|
+
@server.mount_proc("/api/stats") { |_req, res| with_fresh_connections { json_response(res, api.stats) } }
|
|
46
|
+
@server.mount_proc("/api/activity") { |req, res|
|
|
47
|
+
with_fresh_connections {
|
|
48
|
+
if (id = activity_id_from_path(req.path))
|
|
49
|
+
json_response(res, api.activity_detail(id))
|
|
50
|
+
else
|
|
51
|
+
json_response(res, api.activity(req.query))
|
|
52
|
+
end
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
@server.mount_proc("/api/facts") { |req, res| with_fresh_connections { handle_facts(api, req, res) } }
|
|
56
|
+
@server.mount_proc("/api/efficacy") { |req, res| with_fresh_connections { json_response(res, api.efficacy(req.query)) } }
|
|
57
|
+
@server.mount_proc("/api/session") { |req, res|
|
|
58
|
+
with_fresh_connections {
|
|
59
|
+
session_id = req.query["session_id"]
|
|
60
|
+
json_response(res, api.session_summary(session_id))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
@server.mount_proc("/api/timeline") { |_req, res| with_fresh_connections { json_response(res, api.timeline) } }
|
|
64
|
+
@server.mount_proc("/api/recall") { |req, res| with_fresh_connections { json_response(res, api.recall(req.query)) } }
|
|
65
|
+
@server.mount_proc("/api/conflicts") { |req, res| with_fresh_connections { handle_conflicts(api, req, res) } }
|
|
66
|
+
@server.mount_proc("/api/moments") { |req, res| with_fresh_connections { handle_moments(api, req, res) } }
|
|
67
|
+
@server.mount_proc("/api/trust") { |_req, res| with_fresh_connections { json_response(res, api.trust) } }
|
|
68
|
+
@server.mount_proc("/api/knowledge") { |req, res| with_fresh_connections { json_response(res, api.knowledge(req.query)) } }
|
|
69
|
+
@server.mount_proc("/api/reuse") { |req, res| with_fresh_connections { json_response(res, api.reuse(req.query)) } }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# WAL-mode SQLite caches pages on reader connections; when the MCP
|
|
73
|
+
# server (or hooks, or any other writer) modifies the same DB
|
|
74
|
+
# concurrently, long-lived dashboard connections can see stale pages
|
|
75
|
+
# and surface "database disk image is malformed" errors even though
|
|
76
|
+
# PRAGMA integrity_check reports ok. Releasing connections after each
|
|
77
|
+
# HTTP request forces a fresh connection on the next read, matching
|
|
78
|
+
# what MCP::Server#release_connections does per tool call.
|
|
79
|
+
def with_fresh_connections
|
|
80
|
+
yield
|
|
81
|
+
ensure
|
|
82
|
+
release_connections
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def release_connections
|
|
86
|
+
return unless @manager
|
|
87
|
+
@manager.global_store&.db&.disconnect
|
|
88
|
+
@manager.project_store&.db&.disconnect
|
|
89
|
+
rescue Sequel::DatabaseError, Extralite::Error
|
|
90
|
+
# Best-effort; next call will reopen.
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def handle_moments(api, req, res)
|
|
94
|
+
feedback_id = moment_feedback_id_from_path(req.path)
|
|
95
|
+
|
|
96
|
+
if feedback_id && req.request_method == "POST"
|
|
97
|
+
body = parse_json_body(req)
|
|
98
|
+
json_response(res, api.moment_feedback(feedback_id, verdict: body["verdict"], note: body["note"]))
|
|
99
|
+
elsif feedback_id && req.request_method == "DELETE"
|
|
100
|
+
json_response(res, api.clear_moment_feedback(feedback_id))
|
|
101
|
+
else
|
|
102
|
+
json_response(res, api.moments(req.query))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def moment_feedback_id_from_path(path)
|
|
107
|
+
match = path.match(%r{\A/api/moments/(\d+)/feedback\z})
|
|
108
|
+
match && match[1]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def handle_conflicts(api, req, res)
|
|
112
|
+
reject_id = conflict_reject_id_from_path(req.path)
|
|
113
|
+
detail_id = conflict_id_from_path(req.path)
|
|
114
|
+
is_reject_similar = req.path == "/api/conflicts/reject_similar"
|
|
115
|
+
|
|
116
|
+
if req.request_method == "POST" && is_reject_similar
|
|
117
|
+
body = parse_json_body(req)
|
|
118
|
+
keeper_id = body["keeper_fact_id"]
|
|
119
|
+
reason = body["reason"]
|
|
120
|
+
scope = body["scope"] || req.query["scope"] || "project"
|
|
121
|
+
json_response(res, api.reject_similar_conflicts(keeper_id, reason: reason, scope: scope))
|
|
122
|
+
elsif req.request_method == "POST" && reject_id
|
|
123
|
+
body = parse_json_body(req)
|
|
124
|
+
side = body["side"]
|
|
125
|
+
reason = body["reason"]
|
|
126
|
+
scope = body["scope"] || req.query["scope"] || "project"
|
|
127
|
+
json_response(res, api.reject_conflict_fact(reject_id, side: side, reason: reason, scope: scope))
|
|
128
|
+
elsif detail_id
|
|
129
|
+
scope = req.query["scope"] || "project"
|
|
130
|
+
json_response(res, api.conflict_detail(detail_id, scope))
|
|
131
|
+
else
|
|
132
|
+
json_response(res, api.conflicts(req.query))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_json_body(req)
|
|
137
|
+
return {} if req.body.nil? || req.body.empty?
|
|
138
|
+
JSON.parse(req.body)
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
{}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def serve_html(res)
|
|
144
|
+
html_path = File.expand_path("index.html", __dir__)
|
|
145
|
+
res["Content-Type"] = "text/html; charset=utf-8"
|
|
146
|
+
res.body = File.read(html_path)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def activity_id_from_path(path)
|
|
150
|
+
match = path.match(%r{\A/api/activity/(\d+)\z})
|
|
151
|
+
match && match[1]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def fact_id_from_path(path)
|
|
155
|
+
match = path.match(%r{\A/api/facts/(\d+)\z})
|
|
156
|
+
match && match[1]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def fact_action_from_path(path)
|
|
160
|
+
match = path.match(%r{\A/api/facts/(\d+)/(reject|promote)\z})
|
|
161
|
+
match ? [match[1], match[2]] : nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle_facts(api, req, res)
|
|
165
|
+
action = fact_action_from_path(req.path)
|
|
166
|
+
detail_id = fact_id_from_path(req.path)
|
|
167
|
+
|
|
168
|
+
if req.request_method == "POST" && action
|
|
169
|
+
fact_id, verb = action
|
|
170
|
+
body = parse_json_body(req)
|
|
171
|
+
scope = body["scope"] || req.query["scope"] || "project"
|
|
172
|
+
case verb
|
|
173
|
+
when "reject"
|
|
174
|
+
json_response(res, api.reject_fact(fact_id, reason: body["reason"], scope: scope))
|
|
175
|
+
when "promote"
|
|
176
|
+
json_response(res, api.promote_fact(fact_id))
|
|
177
|
+
end
|
|
178
|
+
elsif detail_id
|
|
179
|
+
scope = req.query["scope"] || "project"
|
|
180
|
+
json_response(res, api.fact_detail(detail_id, scope))
|
|
181
|
+
else
|
|
182
|
+
json_response(res, api.facts(req.query))
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def conflict_id_from_path(path)
|
|
187
|
+
match = path.match(%r{\A/api/conflicts/(\d+)\z})
|
|
188
|
+
match && match[1]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def conflict_reject_id_from_path(path)
|
|
192
|
+
match = path.match(%r{\A/api/conflicts/(\d+)/reject\z})
|
|
193
|
+
match && match[1]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def json_response(res, data)
|
|
197
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
198
|
+
res["Access-Control-Allow-Origin"] = "*"
|
|
199
|
+
res.body = JSON.generate(data)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def open_browser
|
|
203
|
+
url = "http://localhost:#{@port}"
|
|
204
|
+
Thread.new do
|
|
205
|
+
sleep 0.5
|
|
206
|
+
system("open", url) || system("xdg-open", url) || system("start", url)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Daily activity rollup for the dashboard timeline view. Aggregates three
|
|
6
|
+
# event sources (fact creation, content ingestion, activity events) into
|
|
7
|
+
# per-day buckets covering the last 30 days. Returns the empty shape
|
|
8
|
+
# ({days: []}) when no project store is available so the dashboard can
|
|
9
|
+
# render before the first ingest.
|
|
10
|
+
class Timeline
|
|
11
|
+
LOOKBACK_DAYS = 30
|
|
12
|
+
|
|
13
|
+
def initialize(manager)
|
|
14
|
+
@manager = manager
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def days
|
|
18
|
+
store = @manager.default_store(prefer: :project)
|
|
19
|
+
return {days: []} unless store
|
|
20
|
+
|
|
21
|
+
cutoff = (Time.now - LOOKBACK_DAYS * 86_400).utc.iso8601
|
|
22
|
+
{days: build_days(store, cutoff)}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_days(store, cutoff)
|
|
28
|
+
fact_rows = group_count(store.facts, cutoff_field: :created_at, cutoff: cutoff)
|
|
29
|
+
content_rows = group_count(store.content_items, cutoff_field: :ingested_at, cutoff: cutoff)
|
|
30
|
+
event_rows = activity_event_rows(store, cutoff)
|
|
31
|
+
|
|
32
|
+
all_days = (fact_rows + content_rows + event_rows).map { |r| r[:day] }.uniq.sort
|
|
33
|
+
all_days.map { |day| compose_day(day, fact_rows, content_rows, event_rows) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def group_count(dataset, cutoff_field:, cutoff:)
|
|
37
|
+
dataset
|
|
38
|
+
.where { Sequel[cutoff_field] >= cutoff }
|
|
39
|
+
.select_group(Sequel.lit("DATE(#{cutoff_field})").as(:day))
|
|
40
|
+
.select_append { count(id).as(:count) }
|
|
41
|
+
.order(:day)
|
|
42
|
+
.all
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def activity_event_rows(store, cutoff)
|
|
46
|
+
return [] unless store.db.table_exists?(:activity_events)
|
|
47
|
+
|
|
48
|
+
store.activity_events
|
|
49
|
+
.where { occurred_at >= cutoff }
|
|
50
|
+
.select_group(Sequel.lit("DATE(occurred_at)").as(:day), :event_type)
|
|
51
|
+
.select_append { count(id).as(:count) }
|
|
52
|
+
.order(:day)
|
|
53
|
+
.all
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def compose_day(day, fact_rows, content_rows, event_rows)
|
|
57
|
+
day_events = event_rows.select { |r| r[:day] == day }
|
|
58
|
+
{
|
|
59
|
+
date: day,
|
|
60
|
+
facts_created: fact_rows.find { |r| r[:day] == day }&.dig(:count) || 0,
|
|
61
|
+
content_ingested: content_rows.find { |r| r[:day] == day }&.dig(:count) || 0,
|
|
62
|
+
hook_events: day_events.sum { |r| r[:count] },
|
|
63
|
+
recalls: day_events.select { |r| r[:event_type] == "recall" }.sum { |r| r[:count] }
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|