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.
Files changed (77) 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 +130 -0
  7. data/CLAUDE.md +30 -6
  8. data/README.md +66 -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 +371 -0
  13. data/docs/EXAMPLES.md +41 -2
  14. data/docs/GETTING_STARTED.md +33 -4
  15. data/docs/architecture.md +22 -7
  16. data/docs/audit-queries.md +131 -0
  17. data/docs/dashboard.md +192 -0
  18. data/docs/improvements.md +650 -9
  19. data/docs/influence/cq.md +187 -0
  20. data/docs/plugin.md +13 -6
  21. data/docs/quality_review.md +524 -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 +273 -0
  29. data/lib/claude_memory/commands/hook_command.rb +61 -2
  30. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
  31. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  32. data/lib/claude_memory/commands/registry.rb +7 -1
  33. data/lib/claude_memory/commands/show_command.rb +90 -0
  34. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  35. data/lib/claude_memory/commands/stats_command.rb +131 -2
  36. data/lib/claude_memory/commands/sweep_command.rb +2 -0
  37. data/lib/claude_memory/configuration.rb +16 -0
  38. data/lib/claude_memory/core/relative_time.rb +9 -0
  39. data/lib/claude_memory/dashboard/api.rb +610 -0
  40. data/lib/claude_memory/dashboard/conflicts.rb +279 -0
  41. data/lib/claude_memory/dashboard/efficacy.rb +127 -0
  42. data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
  43. data/lib/claude_memory/dashboard/health.rb +175 -0
  44. data/lib/claude_memory/dashboard/index.html +2707 -0
  45. data/lib/claude_memory/dashboard/knowledge.rb +136 -0
  46. data/lib/claude_memory/dashboard/moments.rb +244 -0
  47. data/lib/claude_memory/dashboard/reuse.rb +97 -0
  48. data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
  49. data/lib/claude_memory/dashboard/server.rb +211 -0
  50. data/lib/claude_memory/dashboard/timeline.rb +68 -0
  51. data/lib/claude_memory/dashboard/trust.rb +454 -0
  52. data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
  53. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  54. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  55. data/lib/claude_memory/hook/context_injector.rb +97 -3
  56. data/lib/claude_memory/hook/handler.rb +191 -3
  57. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  58. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  59. data/lib/claude_memory/mcp/text_summary.rb +29 -0
  60. data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
  61. data/lib/claude_memory/mcp/tools.rb +148 -0
  62. data/lib/claude_memory/publish.rb +13 -21
  63. data/lib/claude_memory/recall/stale_detector.rb +67 -0
  64. data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
  65. data/lib/claude_memory/resolve/resolver.rb +41 -11
  66. data/lib/claude_memory/store/llm_cache.rb +68 -0
  67. data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
  68. data/lib/claude_memory/store/schema_manager.rb +1 -1
  69. data/lib/claude_memory/store/sqlite_store.rb +47 -143
  70. data/lib/claude_memory/store/store_manager.rb +29 -0
  71. data/lib/claude_memory/sweep/maintenance.rb +216 -0
  72. data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
  73. data/lib/claude_memory/sweep/sweeper.rb +2 -0
  74. data/lib/claude_memory/templates/hooks.example.json +5 -0
  75. data/lib/claude_memory/version.rb +1 -1
  76. data/lib/claude_memory.rb +24 -0
  77. 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