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,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# Weekly digest — a markdown summary of what memory did over the last N days.
|
|
8
|
+
# Sections (in order): Activity, Context cost, Quality, New knowledge,
|
|
9
|
+
# Utilization, Conflicts, Feedback. The Context cost and Quality
|
|
10
|
+
# sections (added 0.11.0) read from `Dashboard::Trust#token_budget` and
|
|
11
|
+
# `#quality_score` so users see the cost/pollution side-by-side with
|
|
12
|
+
# the value side without needing to visit the dashboard.
|
|
13
|
+
#
|
|
14
|
+
# The data it aggregates all already exists (activity_events, facts,
|
|
15
|
+
# conflicts, moment_feedback); this command only shapes it into a report.
|
|
16
|
+
class DigestCommand < BaseCommand
|
|
17
|
+
def call(args)
|
|
18
|
+
opts = parse_options(args, {since_days: 7, output: nil}) do |o|
|
|
19
|
+
OptionParser.new do |parser|
|
|
20
|
+
parser.banner = "Usage: claude-memory digest [options]"
|
|
21
|
+
parser.on("--since DAYS", Integer, "Coverage window in days (default: 7)") { |v| o[:since_days] = v }
|
|
22
|
+
parser.on("--output FILE", "Write to file instead of stdout") { |v| o[:output] = v }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
return 1 if opts.nil?
|
|
26
|
+
return failure("--since must be positive") if opts[:since_days] <= 0
|
|
27
|
+
|
|
28
|
+
manager = Store::StoreManager.new
|
|
29
|
+
report = render_report(manager, opts[:since_days])
|
|
30
|
+
manager.close
|
|
31
|
+
|
|
32
|
+
if opts[:output]
|
|
33
|
+
File.write(opts[:output], report)
|
|
34
|
+
stderr.puts "Wrote digest to #{opts[:output]}"
|
|
35
|
+
else
|
|
36
|
+
stdout.puts report
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def render_report(manager, days)
|
|
45
|
+
cutoff = (Time.now.utc - days * 86_400).iso8601
|
|
46
|
+
lines = []
|
|
47
|
+
lines << "# ClaudeMemory Digest"
|
|
48
|
+
lines << ""
|
|
49
|
+
lines << "_Coverage: last #{days} day#{"s" unless days == 1} (since #{cutoff})_"
|
|
50
|
+
lines << ""
|
|
51
|
+
lines << activity_section(manager, cutoff)
|
|
52
|
+
lines << ""
|
|
53
|
+
lines << context_cost_section(manager)
|
|
54
|
+
lines << ""
|
|
55
|
+
lines << quality_section(manager, cutoff)
|
|
56
|
+
lines << ""
|
|
57
|
+
lines << knowledge_section(manager, cutoff)
|
|
58
|
+
lines << ""
|
|
59
|
+
lines << utilization_section(manager)
|
|
60
|
+
lines << ""
|
|
61
|
+
lines << conflicts_section(manager)
|
|
62
|
+
lines << ""
|
|
63
|
+
lines << feedback_section(manager, cutoff)
|
|
64
|
+
lines.join("\n") + "\n"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def activity_section(manager, cutoff)
|
|
68
|
+
store = manager.default_store(prefer: :project)
|
|
69
|
+
return "## Activity\n\n_No project database._" unless store
|
|
70
|
+
|
|
71
|
+
by_kind = store.activity_events
|
|
72
|
+
.where { occurred_at >= cutoff }
|
|
73
|
+
.group_and_count(:event_type)
|
|
74
|
+
.all
|
|
75
|
+
.to_h { |r| [r[:event_type], r[:count]] }
|
|
76
|
+
|
|
77
|
+
total = by_kind.values.sum
|
|
78
|
+
out = ["## Activity", ""]
|
|
79
|
+
out << "**Moments recorded:** #{total}"
|
|
80
|
+
out << ""
|
|
81
|
+
if total.zero?
|
|
82
|
+
out << "_No activity in this window._"
|
|
83
|
+
else
|
|
84
|
+
%w[recall store_extraction hook_context hook_ingest hook_sweep].each do |event_type|
|
|
85
|
+
count = by_kind[event_type] || 0
|
|
86
|
+
next if count.zero?
|
|
87
|
+
out << "- #{humanize_event(event_type)}: #{count}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
out.join("\n")
|
|
91
|
+
rescue Sequel::DatabaseError => e
|
|
92
|
+
"## Activity\n\n_Unavailable: #{e.message}_"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def humanize_event(event_type)
|
|
96
|
+
case event_type
|
|
97
|
+
when "recall" then "Recalls"
|
|
98
|
+
when "store_extraction" then "Facts extracted"
|
|
99
|
+
when "hook_context" then "Context injections"
|
|
100
|
+
when "hook_ingest" then "Transcripts ingested"
|
|
101
|
+
when "hook_sweep" then "Maintenance sweeps"
|
|
102
|
+
else event_type
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def knowledge_section(manager, cutoff)
|
|
107
|
+
out = ["## New knowledge", ""]
|
|
108
|
+
counts = {}
|
|
109
|
+
%w[project global].each do |scope|
|
|
110
|
+
store = manager.store_if_exists(scope)
|
|
111
|
+
next unless store
|
|
112
|
+
store.facts
|
|
113
|
+
.where(status: "active")
|
|
114
|
+
.where { created_at >= cutoff }
|
|
115
|
+
.group_and_count(:predicate)
|
|
116
|
+
.all
|
|
117
|
+
.each { |r| counts[r[:predicate]] = (counts[r[:predicate]] || 0) + r[:count] }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if counts.empty?
|
|
121
|
+
out << "_No new facts in this window._"
|
|
122
|
+
else
|
|
123
|
+
total = counts.values.sum
|
|
124
|
+
out << "**New active facts:** #{total}"
|
|
125
|
+
out << ""
|
|
126
|
+
counts.sort_by { |_, c| -c }.each { |predicate, count| out << "- #{predicate}: #{count}" }
|
|
127
|
+
end
|
|
128
|
+
out.join("\n")
|
|
129
|
+
rescue Sequel::DatabaseError => e
|
|
130
|
+
"## New knowledge\n\n_Unavailable: #{e.message}_"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The token cost of every SessionStart context injection, measured over
|
|
134
|
+
# the last 30 days (Trust panel's window — intentionally wider than the
|
|
135
|
+
# digest's coverage window so percentiles stay statistically meaningful
|
|
136
|
+
# on quiet weeks). Reports zero state explicitly so users know whether a
|
|
137
|
+
# missing number means "no injections" vs. "telemetry didn't fire".
|
|
138
|
+
def context_cost_section(manager)
|
|
139
|
+
tb = Dashboard::Trust.new(manager).token_budget
|
|
140
|
+
out = ["## Context cost", ""]
|
|
141
|
+
if tb[:sample_size].zero?
|
|
142
|
+
out << "_No context injections in the last #{tb[:window_days]} days._"
|
|
143
|
+
else
|
|
144
|
+
out << "**Per-session injected tokens (last #{tb[:window_days]}d, n=#{tb[:sample_size]}):**"
|
|
145
|
+
out << "- p50: #{tb[:p50]} tokens"
|
|
146
|
+
out << "- p95: #{tb[:p95]} tokens"
|
|
147
|
+
out << "- avg: #{tb[:avg]} tokens"
|
|
148
|
+
end
|
|
149
|
+
out.join("\n")
|
|
150
|
+
rescue Sequel::DatabaseError => e
|
|
151
|
+
"## Context cost\n\n_Unavailable: #{e.message}_"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Hallucination-rate proxy. Reports two numbers per the
|
|
155
|
+
# `quality_review.md` 2026-04-30 investigation:
|
|
156
|
+
#
|
|
157
|
+
# - Live (last `window_days`, headline) — actionable signal of
|
|
158
|
+
# ongoing extraction quality.
|
|
159
|
+
# - Historical (all active facts, supplementary) — visible so
|
|
160
|
+
# legacy noise isn't hidden, but the headline is the live one.
|
|
161
|
+
#
|
|
162
|
+
# The split exists because the unwindowed metric mixed pre-prompt-
|
|
163
|
+
# commit bare conclusions with live data; users read the combined
|
|
164
|
+
# number as "ongoing quality" and that's misleading.
|
|
165
|
+
def quality_section(manager, cutoff)
|
|
166
|
+
out = ["## Quality", ""]
|
|
167
|
+
qs = Dashboard::Trust.new(manager).quality_score
|
|
168
|
+
|
|
169
|
+
if qs[:total_active].zero?
|
|
170
|
+
if qs[:historical][:total_active].zero?
|
|
171
|
+
out << "_No active facts to score yet._"
|
|
172
|
+
else
|
|
173
|
+
out << "_No facts extracted in the last #{qs[:window_days]} days._"
|
|
174
|
+
out << "- Historical (all active): score #{qs[:historical][:score]}/100, " \
|
|
175
|
+
"#{qs[:historical][:total_active]} facts, " \
|
|
176
|
+
"#{qs[:historical][:bare_conclusion_count]} bare, " \
|
|
177
|
+
"#{qs[:historical][:suspect_count]} suspect"
|
|
178
|
+
end
|
|
179
|
+
else
|
|
180
|
+
out << "**Live score (last #{qs[:window_days]}d):** #{qs[:score]}/100 _(higher is cleaner)_"
|
|
181
|
+
out << "- Suspect (reference material): #{qs[:suspect_count]} (#{qs[:suspect_pct]}%)"
|
|
182
|
+
out << "- Bare conclusions (decision/convention without reason): #{qs[:bare_conclusion_count]} (#{qs[:bare_pct]}%)"
|
|
183
|
+
if qs[:historical][:total_active] > qs[:total_active]
|
|
184
|
+
out << ""
|
|
185
|
+
out << "_Historical (all active): score #{qs[:historical][:score]}/100, " \
|
|
186
|
+
"#{qs[:historical][:total_active]} facts, " \
|
|
187
|
+
"#{qs[:historical][:bare_conclusion_count]} bare, " \
|
|
188
|
+
"#{qs[:historical][:suspect_count]} suspect_"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
rate = rejection_rate_in_window(manager, cutoff)
|
|
193
|
+
out << ""
|
|
194
|
+
out << "**Rejection rate (in window):** #{rate[:rejected]} of #{rate[:created]} extracted facts rejected (#{rate[:pct]}%)"
|
|
195
|
+
|
|
196
|
+
out.join("\n")
|
|
197
|
+
rescue Sequel::DatabaseError => e
|
|
198
|
+
"## Quality\n\n_Unavailable: #{e.message}_"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# How many facts created in the digest window have since been
|
|
202
|
+
# rejected? Counts across both stores.
|
|
203
|
+
def rejection_rate_in_window(manager, cutoff)
|
|
204
|
+
created = 0
|
|
205
|
+
rejected = 0
|
|
206
|
+
|
|
207
|
+
%w[project global].each do |scope|
|
|
208
|
+
store = manager.store_if_exists(scope)
|
|
209
|
+
next unless store
|
|
210
|
+
dataset = store.facts.where { created_at >= cutoff }
|
|
211
|
+
created += dataset.count
|
|
212
|
+
rejected += dataset.where(status: "rejected").count
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
pct = created.zero? ? 0.0 : (rejected * 100.0 / created).round(1)
|
|
216
|
+
{created: created, rejected: rejected, pct: pct}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def utilization_section(manager)
|
|
220
|
+
util = Dashboard::Trust.new(manager).utilization
|
|
221
|
+
pct = util[:ratio_pct]
|
|
222
|
+
<<~SECTION.strip
|
|
223
|
+
## Utilization
|
|
224
|
+
|
|
225
|
+
**Ratio (last #{util[:window_days]}d):** #{pct}%
|
|
226
|
+
- Extracted: #{util[:extracted]}
|
|
227
|
+
- Used (of those extracted): #{util[:used_from_extracted]}
|
|
228
|
+
- Total fact uses across recalls + injections: #{util[:used]}
|
|
229
|
+
SECTION
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def conflicts_section(manager)
|
|
233
|
+
counts = Dashboard::Conflicts.new(manager).distinct_open_counts
|
|
234
|
+
total = counts[:total]
|
|
235
|
+
out = ["## Conflicts", ""]
|
|
236
|
+
if total.zero?
|
|
237
|
+
out << "_No open conflicts._"
|
|
238
|
+
else
|
|
239
|
+
out << "**Open contradictions:** #{total} distinct"
|
|
240
|
+
out << "- Project: #{counts[:project]}"
|
|
241
|
+
out << "- Global: #{counts[:global]}"
|
|
242
|
+
end
|
|
243
|
+
out.join("\n")
|
|
244
|
+
rescue Sequel::DatabaseError => e
|
|
245
|
+
"## Conflicts\n\n_Unavailable: #{e.message}_"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def feedback_section(manager, cutoff)
|
|
249
|
+
store = manager.default_store(prefer: :project)
|
|
250
|
+
return "## Feedback\n\n_No project database._" unless store
|
|
251
|
+
|
|
252
|
+
rows = store.moment_feedback.where { recorded_at >= cutoff }.all
|
|
253
|
+
up = rows.count { |r| r[:verdict] == "up" }
|
|
254
|
+
down = rows.count { |r| r[:verdict] == "down" }
|
|
255
|
+
total = up + down
|
|
256
|
+
|
|
257
|
+
out = ["## Feedback", ""]
|
|
258
|
+
if total.zero?
|
|
259
|
+
out << "_No thumbs in this window._"
|
|
260
|
+
else
|
|
261
|
+
ratio = ((up.to_f / total) * 100).round
|
|
262
|
+
out << "**Moments rated:** #{total}"
|
|
263
|
+
out << "- 👍 Up: #{up}"
|
|
264
|
+
out << "- 👎 Down: #{down}"
|
|
265
|
+
out << "- Positive ratio: #{ratio}%"
|
|
266
|
+
end
|
|
267
|
+
out.join("\n")
|
|
268
|
+
rescue Sequel::DatabaseError => e
|
|
269
|
+
"## Feedback\n\n_Unavailable: #{e.message}_"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -19,9 +19,9 @@ module ClaudeMemory
|
|
|
19
19
|
return Hook::ExitCodes::ERROR
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
unless %w[ingest sweep publish context].include?(subcommand)
|
|
22
|
+
unless %w[ingest sweep publish context nudge].include?(subcommand)
|
|
23
23
|
stderr.puts "Unknown hook command: #{subcommand}"
|
|
24
|
-
stderr.puts "Available: ingest, sweep, publish, context"
|
|
24
|
+
stderr.puts "Available: ingest, sweep, publish, context, nudge"
|
|
25
25
|
return Hook::ExitCodes::ERROR
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -63,6 +63,8 @@ module ClaudeMemory
|
|
|
63
63
|
hook_publish(handler, payload)
|
|
64
64
|
when "context"
|
|
65
65
|
hook_context(payload, opts[:db])
|
|
66
|
+
when "nudge"
|
|
67
|
+
hook_nudge(payload, opts[:db])
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
store.close
|
|
@@ -169,17 +171,42 @@ module ClaudeMemory
|
|
|
169
171
|
Hook::ExitCodes::SUCCESS
|
|
170
172
|
end
|
|
171
173
|
|
|
174
|
+
def hook_nudge(payload, db_path)
|
|
175
|
+
# Nudge needs to count past nudge events across both stores,
|
|
176
|
+
# so prefer the manager-aware path. db_path overrides only
|
|
177
|
+
# the project store (useful for tests).
|
|
178
|
+
project_path = payload["project_path"] || payload["cwd"]
|
|
179
|
+
manager = ClaudeMemory::Store::StoreManager.new(
|
|
180
|
+
project_db_path: db_path, project_path: project_path
|
|
181
|
+
)
|
|
182
|
+
manager.ensure_both!
|
|
183
|
+
store = manager.project_store || manager.global_store
|
|
184
|
+
|
|
185
|
+
handler = ClaudeMemory::Hook::Handler.new(store, manager: manager)
|
|
186
|
+
result = handler.nudge(payload)
|
|
187
|
+
|
|
188
|
+
stdout.puts result[:message] if result[:status] == :emitted
|
|
189
|
+
|
|
190
|
+
manager.close
|
|
191
|
+
Hook::ExitCodes::SUCCESS
|
|
192
|
+
rescue => e
|
|
193
|
+
classify_error(e)
|
|
194
|
+
end
|
|
195
|
+
|
|
172
196
|
def hook_context(payload, db_path)
|
|
173
197
|
project_path = payload["project_path"] || payload["cwd"]
|
|
174
198
|
source = payload["source"]
|
|
199
|
+
session_id = payload["session_id"]
|
|
175
200
|
manager = ClaudeMemory::Store::StoreManager.new(
|
|
176
201
|
project_db_path: db_path,
|
|
177
202
|
project_path: project_path
|
|
178
203
|
)
|
|
179
204
|
manager.ensure_both!
|
|
180
205
|
|
|
206
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
181
207
|
injector = ClaudeMemory::Hook::ContextInjector.new(manager, source: source)
|
|
182
208
|
context_text = injector.generate_context
|
|
209
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
183
210
|
|
|
184
211
|
if context_text
|
|
185
212
|
response = {
|
|
@@ -191,12 +218,44 @@ module ClaudeMemory
|
|
|
191
218
|
stdout.puts JSON.generate(response)
|
|
192
219
|
end
|
|
193
220
|
|
|
221
|
+
record_context_activity(manager, context_text, injector,
|
|
222
|
+
session_id: session_id, source: source, duration_ms: duration_ms)
|
|
223
|
+
|
|
194
224
|
manager.close
|
|
195
225
|
Hook::ExitCodes::SUCCESS
|
|
196
226
|
rescue => e
|
|
197
227
|
classify_error(e)
|
|
198
228
|
end
|
|
199
229
|
|
|
230
|
+
CONTEXT_PREVIEW_BYTES = 400
|
|
231
|
+
|
|
232
|
+
def record_context_activity(manager, context_text, injector, session_id:, source:, duration_ms:)
|
|
233
|
+
store = manager.project_store || manager.global_store
|
|
234
|
+
return unless store
|
|
235
|
+
|
|
236
|
+
by_scope = injector.emitted_facts_by_scope.transform_values { |ids| ids.first(10) }
|
|
237
|
+
details = {
|
|
238
|
+
source: source,
|
|
239
|
+
context_length: context_text&.length,
|
|
240
|
+
context_tokens: ClaudeMemory::Core::TokenEstimator.estimate(context_text),
|
|
241
|
+
preview: context_text&.byteslice(0, CONTEXT_PREVIEW_BYTES),
|
|
242
|
+
truncated: context_text ? context_text.bytesize > CONTEXT_PREVIEW_BYTES : false,
|
|
243
|
+
top_fact_ids: injector.emitted_fact_ids.first(10),
|
|
244
|
+
top_facts_by_scope: (by_scope if by_scope.any?),
|
|
245
|
+
top_subjects: injector.emitted_subjects.uniq.first(10),
|
|
246
|
+
fact_count: injector.emitted_fact_ids.size
|
|
247
|
+
}.compact
|
|
248
|
+
|
|
249
|
+
ClaudeMemory::ActivityLog.record(store,
|
|
250
|
+
event_type: "hook_context",
|
|
251
|
+
status: context_text ? "success" : "skipped",
|
|
252
|
+
session_id: session_id,
|
|
253
|
+
duration_ms: duration_ms,
|
|
254
|
+
details: details)
|
|
255
|
+
rescue => e
|
|
256
|
+
ClaudeMemory.logger.debug("record_context_activity failed: #{e.message}")
|
|
257
|
+
end
|
|
258
|
+
|
|
200
259
|
def classify_error(error)
|
|
201
260
|
exit_code = Hook::ErrorClassifier.exit_code_for(error)
|
|
202
261
|
|
|
@@ -19,8 +19,9 @@ module ClaudeMemory
|
|
|
19
19
|
db_path = ClaudeMemory.project_db_path
|
|
20
20
|
ingest_cmd = "claude-memory hook ingest --db #{db_path}"
|
|
21
21
|
sweep_cmd = "claude-memory hook sweep --db #{db_path}"
|
|
22
|
+
nudge_cmd = "claude-memory hook nudge --db #{db_path}"
|
|
22
23
|
|
|
23
|
-
hooks_config = build_hooks_config(ingest_cmd, sweep_cmd)
|
|
24
|
+
hooks_config = build_hooks_config(ingest_cmd, sweep_cmd, nudge_cmd)
|
|
24
25
|
|
|
25
26
|
existing = load_json_file(settings_path)
|
|
26
27
|
existing["hooks"] ||= {}
|
|
@@ -37,8 +38,9 @@ module ClaudeMemory
|
|
|
37
38
|
db_path = ClaudeMemory.global_db_path
|
|
38
39
|
ingest_cmd = "claude-memory hook ingest --db #{db_path}"
|
|
39
40
|
sweep_cmd = "claude-memory hook sweep --db #{db_path}"
|
|
41
|
+
nudge_cmd = "claude-memory hook nudge --db #{db_path}"
|
|
40
42
|
|
|
41
|
-
hooks_config = build_hooks_config(ingest_cmd, sweep_cmd)
|
|
43
|
+
hooks_config = build_hooks_config(ingest_cmd, sweep_cmd, nudge_cmd)
|
|
42
44
|
|
|
43
45
|
existing = load_json_file(settings_path)
|
|
44
46
|
existing["hooks"] ||= {}
|
|
@@ -96,7 +98,7 @@ module ClaudeMemory
|
|
|
96
98
|
|
|
97
99
|
private
|
|
98
100
|
|
|
99
|
-
def build_hooks_config(ingest_cmd, sweep_cmd)
|
|
101
|
+
def build_hooks_config(ingest_cmd, sweep_cmd, nudge_cmd = "claude-memory hook nudge")
|
|
100
102
|
context_cmd = "claude-memory hook context"
|
|
101
103
|
|
|
102
104
|
{
|
|
@@ -132,7 +134,8 @@ module ClaudeMemory
|
|
|
132
134
|
{"type" => "command", "command" => ingest_cmd, "timeout" => 30,
|
|
133
135
|
"statusMessage" => "Saving memory..."},
|
|
134
136
|
{"type" => "command", "command" => sweep_cmd, "timeout" => 30,
|
|
135
|
-
"statusMessage" => "Sweeping memory..."}
|
|
137
|
+
"statusMessage" => "Sweeping memory..."},
|
|
138
|
+
{"type" => "command", "command" => nudge_cmd, "timeout" => 5}
|
|
136
139
|
]
|
|
137
140
|
}],
|
|
138
141
|
"TaskCompleted" => [{
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# One-time cleanup for historical convention facts that are actually
|
|
8
|
+
# descriptions of external projects (LOC counts, star counts, author
|
|
9
|
+
# attributions, "X is a plugin…" templates). The Distill::ReferenceMaterialDetector
|
|
10
|
+
# now guards new writes in ManagementHandlers#store_extraction; this
|
|
11
|
+
# command walks existing rows and retags them to predicate=reference.
|
|
12
|
+
class ReclassifyReferencesCommand < BaseCommand
|
|
13
|
+
def call(args)
|
|
14
|
+
opts = parse_options(args, {scope: "project", dry_run: false}) do |o|
|
|
15
|
+
OptionParser.new do |parser|
|
|
16
|
+
parser.banner = "Usage: claude-memory reclassify-references [options]"
|
|
17
|
+
parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
|
|
18
|
+
parser.on("--dry-run", "Show what would be reclassified without writing") { o[:dry_run] = true }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
return 1 if opts.nil?
|
|
22
|
+
|
|
23
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
24
|
+
store = manager.store_for_scope(opts[:scope])
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
result = Sweep::Maintenance.new(store).reclassify_references(dry_run: opts[:dry_run])
|
|
28
|
+
ensure
|
|
29
|
+
manager.close
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
print_result(opts, result)
|
|
33
|
+
0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def print_result(opts, result)
|
|
39
|
+
mode = opts[:dry_run] ? "DRY RUN" : "RECLASSIFY"
|
|
40
|
+
stdout.puts "#{mode}: scope=#{opts[:scope]}"
|
|
41
|
+
stdout.puts "=" * 50
|
|
42
|
+
stdout.puts "Active conventions inspected: #{result[:inspected]}"
|
|
43
|
+
stdout.puts "Reclassified as reference: #{result[:reclassified]}"
|
|
44
|
+
|
|
45
|
+
return if result[:decisions].empty?
|
|
46
|
+
|
|
47
|
+
stdout.puts
|
|
48
|
+
stdout.puts "Decisions:"
|
|
49
|
+
result[:decisions].each do |d|
|
|
50
|
+
preview = d[:object].to_s[0, 100]
|
|
51
|
+
stdout.puts " fact ##{d[:fact_id]} #{preview}#{"…" if d[:object].to_s.length > 100}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -37,7 +37,13 @@ module ClaudeMemory
|
|
|
37
37
|
"completion" => {class: CompletionCommand, description: "Generate shell completions"},
|
|
38
38
|
"embeddings" => {class: EmbeddingsCommand, description: "Inspect embedding backend"},
|
|
39
39
|
"reject" => {class: RejectCommand, description: "Mark a fact as rejected"},
|
|
40
|
-
"restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"}
|
|
40
|
+
"restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"},
|
|
41
|
+
"dedupe-conflicts" => {class: DedupeConflictsCommand, description: "Deduplicate historical open conflict rows that describe the same pair"},
|
|
42
|
+
"reclassify-references" => {class: ReclassifyReferencesCommand, description: "Retag existing convention facts that match reference-material heuristics"},
|
|
43
|
+
"census" => {class: CensusCommand, description: "Aggregate predicate usage across project databases"},
|
|
44
|
+
"dashboard" => {class: DashboardCommand, description: "Open debugging dashboard"},
|
|
45
|
+
"digest" => {class: DigestCommand, description: "Render a weekly markdown digest of memory activity"},
|
|
46
|
+
"show" => {class: ShowCommand, description: "Print what memory would inject at the next SessionStart"}
|
|
41
47
|
}.freeze
|
|
42
48
|
|
|
43
49
|
# Find a command class by name
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# Prints what memory would inject on the next SessionStart.
|
|
8
|
+
#
|
|
9
|
+
# The trust answer to "is this still worth it?" requires
|
|
10
|
+
# inspectability: a user who can't see what memory will inject can't
|
|
11
|
+
# develop confidence in it. The CLAUDE.md alternative is `cat
|
|
12
|
+
# CLAUDE.md` — instant, plain English, no tooling. This command is
|
|
13
|
+
# the same one-line inspect surface for the curated facts the
|
|
14
|
+
# injector picks each session.
|
|
15
|
+
#
|
|
16
|
+
# Runs the exact `Hook::ContextInjector` path real sessions use, so
|
|
17
|
+
# what you see here is what Claude actually receives — not a
|
|
18
|
+
# rebuilt approximation that could drift.
|
|
19
|
+
#
|
|
20
|
+
# The default suppresses the "Pending Knowledge Extraction" dump
|
|
21
|
+
# (which contains raw transcript JSON intended for LLM distillation)
|
|
22
|
+
# so the output stays human-readable. Pass `--pending` to see the
|
|
23
|
+
# full fresh-session payload, including those raw items.
|
|
24
|
+
class ShowCommand < BaseCommand
|
|
25
|
+
VALID_SOURCES = %w[startup resume clear].freeze
|
|
26
|
+
|
|
27
|
+
# Any string outside FRESH_SESSION_SOURCES skips the pending-knowledge
|
|
28
|
+
# block. "preview" reads naturally in any debug log this surfaces in.
|
|
29
|
+
NON_FRESH_SOURCE = "preview"
|
|
30
|
+
|
|
31
|
+
def call(args)
|
|
32
|
+
opts = parse_options(args, {source: nil, pending: false}) do |o|
|
|
33
|
+
OptionParser.new do |parser|
|
|
34
|
+
parser.banner = "Usage: claude-memory show [--source SOURCE] [--pending]"
|
|
35
|
+
parser.on("--source SOURCE", VALID_SOURCES,
|
|
36
|
+
"Simulate fresh-session source (#{VALID_SOURCES.join(", ")}). " \
|
|
37
|
+
"Forces inclusion of pending-knowledge and auto-memory-mirror " \
|
|
38
|
+
"sections regardless of --pending.") { |v| o[:source] = v }
|
|
39
|
+
parser.on("--pending",
|
|
40
|
+
"Include the pending-knowledge dump (raw transcript JSON " \
|
|
41
|
+
"for LLM distillation). Default suppresses it for readability.") { o[:pending] = true }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
return 1 if opts.nil?
|
|
45
|
+
|
|
46
|
+
effective_source = opts[:source] || (opts[:pending] ? nil : NON_FRESH_SOURCE)
|
|
47
|
+
|
|
48
|
+
manager = Store::StoreManager.new
|
|
49
|
+
manager.ensure_both!
|
|
50
|
+
injector = Hook::ContextInjector.new(manager, source: effective_source)
|
|
51
|
+
context = injector.generate_context
|
|
52
|
+
|
|
53
|
+
print_header(opts[:source])
|
|
54
|
+
stdout.puts ""
|
|
55
|
+
|
|
56
|
+
if context.nil? || context.strip.empty?
|
|
57
|
+
stdout.puts "_Memory has no facts to inject yet._"
|
|
58
|
+
stdout.puts ""
|
|
59
|
+
stdout.puts "Run a few Claude Code sessions in this project, or use"
|
|
60
|
+
stdout.puts "`memory.store_extraction` from a session to seed facts."
|
|
61
|
+
else
|
|
62
|
+
stdout.puts context
|
|
63
|
+
stdout.puts ""
|
|
64
|
+
print_footer(injector, context)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
manager.close
|
|
68
|
+
0
|
|
69
|
+
rescue Sequel::DatabaseError => e
|
|
70
|
+
failure("Database error: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def print_header(source)
|
|
76
|
+
label = source ? " (source=#{source})" : ""
|
|
77
|
+
stdout.puts "## Memory snapshot — would be injected at next SessionStart#{label}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def print_footer(injector, context)
|
|
81
|
+
tokens = Core::TokenEstimator.estimate(context)
|
|
82
|
+
fact_count = injector.emitted_fact_ids.size
|
|
83
|
+
stdout.puts "---"
|
|
84
|
+
stdout.puts "#{fact_count} fact#{"s" unless fact_count == 1} • " \
|
|
85
|
+
"~#{tokens} token#{"s" unless tokens == 1} • " \
|
|
86
|
+
"#{context.length} chars"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -35,12 +35,23 @@ For each content item, carefully read the raw_text and extract:
|
|
|
35
35
|
architecture, uses_framework, uses_language, uses_database,
|
|
36
36
|
deployment_platform, auth_method). Other snake_case predicates are
|
|
37
37
|
accepted but fall through to the default multi-value policy.
|
|
38
|
-
- object: The value
|
|
38
|
+
- object: The value. For **decision** and **convention** predicates, the object
|
|
39
|
+
MUST embed the reason — append a compact "— because ..." / "so that ..." /
|
|
40
|
+
"to avoid ..." clause, or include the trigger ("caused by X", "breaks when Y").
|
|
41
|
+
Bare conclusions without rationale are dead weight once they become stale:
|
|
42
|
+
a fact with a reason is recoverable, a fact without one is not. Architecture
|
|
43
|
+
facts should note the design *trade-off* if non-obvious.
|
|
39
44
|
- confidence: 0.0-1.0
|
|
40
45
|
- quote: Source excerpt (max 200 chars)
|
|
41
46
|
- strength: "stated" (explicitly said) or "inferred" (implied)
|
|
42
47
|
- scope_hint: "project" (this project only) or "global" (all projects)
|
|
43
48
|
|
|
49
|
+
Examples of the reasoning requirement:
|
|
50
|
+
- ❌ Bare: "Configuration class has instance methods only"
|
|
51
|
+
- ✅ With why: "Configuration class has instance methods only — stub with instance_double + allow(Configuration).to receive(:new) because class-level stubbing breaks isolation"
|
|
52
|
+
- ❌ Bare: "MCP tools return dual content + structuredContent"
|
|
53
|
+
- ✅ With why: "MCP tools return dual content + structuredContent so human-readable summaries and machine-parseable JSON ship in the same response; compact mode omits receipts for ~60% smaller payloads"
|
|
54
|
+
|
|
44
55
|
**Decisions** — Choices made:
|
|
45
56
|
- title: Short summary (max 100 chars)
|
|
46
57
|
- summary: Full description
|
|
@@ -100,3 +111,4 @@ Return a summary:
|
|
|
100
111
|
- Prefer "stated" strength over "inferred" unless clearly implied
|
|
101
112
|
- Do NOT fabricate facts — only extract what's actually in the text
|
|
102
113
|
- If text is mostly code/tool output with no conversational knowledge, mark as distilled with 0 facts
|
|
114
|
+
- Prefer one fact-with-reason over two facts-without. Length cost is worth it — stale facts with reasoning are recoverable, stale facts without are dead weight
|