claude_memory 0.10.0 → 0.12.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/rules/claude_memory.generated.md +42 -64
- data/.claude/skills/release/SKILL.md +44 -6
- data/.claude/skills/study-repo/SKILL.md +15 -0
- data/.claude-plugin/commands/audit-memory.md +68 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +70 -0
- data/CLAUDE.md +20 -5
- data/README.md +64 -2
- data/db/migrations/018_add_otel_telemetry.rb +81 -0
- data/docs/1_0_punchlist.md +522 -89
- data/docs/GETTING_STARTED.md +3 -1
- data/docs/api_stability.md +341 -0
- data/docs/architecture.md +3 -3
- data/docs/audit_runbook.md +209 -0
- data/docs/claude_monitoring.md +956 -0
- data/docs/dashboard.md +23 -3
- data/docs/improvements.md +329 -5
- data/docs/influence/ai-memory-systems-2026.md +403 -0
- data/docs/memory_audit_2026-05-21.md +303 -0
- data/docs/plugin.md +1 -1
- data/docs/quality_review.md +35 -0
- data/lib/claude_memory/audit/checks.rb +239 -0
- data/lib/claude_memory/audit/finding.rb +33 -0
- data/lib/claude_memory/audit/runner.rb +73 -0
- data/lib/claude_memory/commands/audit_command.rb +117 -0
- data/lib/claude_memory/commands/dashboard_command.rb +2 -1
- data/lib/claude_memory/commands/digest_command.rb +95 -3
- data/lib/claude_memory/commands/hook_command.rb +27 -2
- data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
- data/lib/claude_memory/commands/otel_command.rb +240 -0
- data/lib/claude_memory/commands/registry.rb +5 -1
- data/lib/claude_memory/commands/show_command.rb +90 -0
- data/lib/claude_memory/commands/stats_command.rb +94 -2
- data/lib/claude_memory/configuration.rb +60 -0
- data/lib/claude_memory/core/fact_query_builder.rb +1 -0
- data/lib/claude_memory/dashboard/api.rb +8 -0
- data/lib/claude_memory/dashboard/index.html +140 -1
- data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
- data/lib/claude_memory/dashboard/server.rb +86 -0
- data/lib/claude_memory/dashboard/telemetry.rb +156 -0
- data/lib/claude_memory/dashboard/trust.rb +180 -11
- data/lib/claude_memory/deprecations.rb +106 -0
- data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
- data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
- data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
- data/lib/claude_memory/hook/context_injector.rb +11 -2
- data/lib/claude_memory/hook/handler.rb +142 -1
- data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
- data/lib/claude_memory/otel/attributes.rb +118 -0
- data/lib/claude_memory/otel/constants.rb +32 -0
- data/lib/claude_memory/otel/ingestor.rb +54 -0
- data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
- data/lib/claude_memory/otel/prompt_scope.rb +108 -0
- data/lib/claude_memory/otel/settings_writer.rb +122 -0
- data/lib/claude_memory/otel/status.rb +58 -0
- data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
- data/lib/claude_memory/resolve/resolver.rb +30 -3
- data/lib/claude_memory/shortcuts.rb +61 -18
- data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +136 -0
- data/lib/claude_memory/sweep/maintenance.rb +31 -1
- data/lib/claude_memory/sweep/sweeper.rb +6 -0
- data/lib/claude_memory/templates/hooks.example.json +5 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +20 -0
- metadata +28 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Audit
|
|
5
|
+
# Orchestrates the audit: opens a StoreManager, runs every check in
|
|
6
|
+
# CHECK_METHODS, collects findings, computes an exit code.
|
|
7
|
+
#
|
|
8
|
+
# The runner itself is read-only. Suggestions in each Finding name
|
|
9
|
+
# the commands a user (or skill) would run to remediate; the audit
|
|
10
|
+
# never writes.
|
|
11
|
+
class Runner
|
|
12
|
+
CHECK_METHODS = %i[
|
|
13
|
+
open_conflicts
|
|
14
|
+
single_cardinality_multiplicity
|
|
15
|
+
single_cardinality_churn
|
|
16
|
+
distillation_backlog
|
|
17
|
+
shortcut_decision_leak
|
|
18
|
+
shortcut_convention_scope
|
|
19
|
+
duplicate_global_conventions
|
|
20
|
+
bare_conclusion_rate
|
|
21
|
+
project_starvation
|
|
22
|
+
auto_memory_unimported
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
Result = Data.define(:findings, :stats) do
|
|
26
|
+
def errors = findings.select(&:error?)
|
|
27
|
+
def warnings = findings.select(&:warn?)
|
|
28
|
+
def info = findings.select(&:info?)
|
|
29
|
+
def ok? = errors.empty?
|
|
30
|
+
def exit_code = ok? ? 0 : 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(manager: nil)
|
|
34
|
+
@manager = manager || Store::StoreManager.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run
|
|
38
|
+
findings = CHECK_METHODS.flat_map { |method| Checks.public_send(method, @manager) }
|
|
39
|
+
Result.new(findings: findings, stats: collect_stats)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def collect_stats
|
|
45
|
+
global = @manager.store_if_exists("global")
|
|
46
|
+
project = @manager.store_if_exists("project")
|
|
47
|
+
{
|
|
48
|
+
checks_run: CHECK_METHODS.size,
|
|
49
|
+
global: store_stats(global),
|
|
50
|
+
project: store_stats(project)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def store_stats(store)
|
|
55
|
+
return nil unless store
|
|
56
|
+
{
|
|
57
|
+
active_facts: store.facts.where(status: "active").count,
|
|
58
|
+
predicate_counts: predicate_distribution(store)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def predicate_distribution(store)
|
|
63
|
+
store.facts
|
|
64
|
+
.where(status: "active")
|
|
65
|
+
.group_and_count(:predicate)
|
|
66
|
+
.all
|
|
67
|
+
.map { |row| [row[:predicate], row[:count]] }
|
|
68
|
+
.sort_by { |_, c| -c }
|
|
69
|
+
.to_h
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module ClaudeMemory
|
|
7
|
+
module Commands
|
|
8
|
+
# Runs the memory health audit and prints findings. Exits non-zero
|
|
9
|
+
# when error-severity findings are present (unless --no-exit is
|
|
10
|
+
# given). JSON output is the stable surface — humans should not
|
|
11
|
+
# script against the text output.
|
|
12
|
+
class AuditCommand < BaseCommand
|
|
13
|
+
SEVERITY_RANK = {info: 0, warn: 1, error: 2}.freeze
|
|
14
|
+
|
|
15
|
+
def call(args)
|
|
16
|
+
opts = parse_opts(args)
|
|
17
|
+
return 1 if opts.nil?
|
|
18
|
+
|
|
19
|
+
manager = Store::StoreManager.new
|
|
20
|
+
result = Audit::Runner.new(manager: manager).run
|
|
21
|
+
filtered = filter_by_severity(result.findings, opts[:severity])
|
|
22
|
+
|
|
23
|
+
if opts[:json]
|
|
24
|
+
stdout.puts JSON.pretty_generate(payload(result, filtered))
|
|
25
|
+
else
|
|
26
|
+
render_text(result, filtered)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
manager.close
|
|
30
|
+
opts[:no_exit] ? 0 : result.exit_code
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def filter_by_severity(findings, threshold)
|
|
34
|
+
return findings if threshold.nil?
|
|
35
|
+
floor = SEVERITY_RANK.fetch(threshold) { return findings }
|
|
36
|
+
findings.select { |f| SEVERITY_RANK[f.severity] >= floor }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def parse_opts(args)
|
|
42
|
+
options = {json: false, no_exit: false, severity: nil}
|
|
43
|
+
parser = OptionParser.new do |o|
|
|
44
|
+
o.banner = "Usage: claude-memory audit [--json] [--no-exit] [--severity=error|warn|info]"
|
|
45
|
+
o.on("--json", "Emit JSON instead of text") { options[:json] = true }
|
|
46
|
+
o.on("--no-exit", "Always exit 0 even on error-severity findings") { options[:no_exit] = true }
|
|
47
|
+
o.on("--severity LEVEL", "Only show findings at or above LEVEL (error|warn|info)") { |v| options[:severity] = v.to_sym }
|
|
48
|
+
end
|
|
49
|
+
parser.parse!(args.dup)
|
|
50
|
+
options
|
|
51
|
+
rescue OptionParser::InvalidOption => e
|
|
52
|
+
stderr.puts e.message
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def payload(result, filtered)
|
|
57
|
+
{
|
|
58
|
+
ok: result.ok?,
|
|
59
|
+
checks_run: result.stats[:checks_run],
|
|
60
|
+
counts: {
|
|
61
|
+
error: result.errors.size,
|
|
62
|
+
warn: result.warnings.size,
|
|
63
|
+
info: result.info.size
|
|
64
|
+
},
|
|
65
|
+
stats: result.stats.except(:checks_run),
|
|
66
|
+
findings: filtered.map(&:to_h)
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_text(result, filtered)
|
|
71
|
+
stdout.puts "Memory health audit — #{Time.now.utc.iso8601}"
|
|
72
|
+
stdout.puts("=" * 60)
|
|
73
|
+
render_stats(result.stats)
|
|
74
|
+
stdout.puts ""
|
|
75
|
+
render_summary(result)
|
|
76
|
+
stdout.puts ""
|
|
77
|
+
render_findings(filtered)
|
|
78
|
+
stdout.puts ""
|
|
79
|
+
stdout.puts(result.ok? ? "OK" : "FAIL")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_stats(stats)
|
|
83
|
+
%i[global project].each do |scope|
|
|
84
|
+
s = stats[scope]
|
|
85
|
+
next unless s
|
|
86
|
+
preds = s[:predicate_counts].map { |k, v| "#{k}=#{v}" }.join(", ")
|
|
87
|
+
stdout.puts "#{scope.to_s.capitalize.ljust(7)} #{s[:active_facts]} active facts #{preds}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_summary(result)
|
|
92
|
+
stdout.puts "Checks run: #{result.stats[:checks_run]}"
|
|
93
|
+
stdout.puts "Errors: #{result.errors.size} Warnings: #{result.warnings.size} Info: #{result.info.size}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_findings(findings)
|
|
97
|
+
if findings.empty?
|
|
98
|
+
stdout.puts "No findings."
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
findings.each do |f|
|
|
103
|
+
marker = case f.severity
|
|
104
|
+
when :error then "[ERROR]"
|
|
105
|
+
when :warn then "[WARN]"
|
|
106
|
+
when :info then "[INFO]"
|
|
107
|
+
end
|
|
108
|
+
stdout.puts "#{marker} #{f.id} #{f.title}"
|
|
109
|
+
stdout.puts " #{f.detail}"
|
|
110
|
+
stdout.puts " → #{f.suggestion}"
|
|
111
|
+
stdout.puts " fact_ids: #{f.fact_ids.first(20).inspect}#{" (+#{f.fact_ids.size - 20} more)" if f.fact_ids.size > 20}" if f.fact_ids.any?
|
|
112
|
+
stdout.puts ""
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -36,7 +36,8 @@ module ClaudeMemory
|
|
|
36
36
|
manager.ensure_global! if manager.global_exists?
|
|
37
37
|
manager.ensure_project! if manager.project_exists?
|
|
38
38
|
|
|
39
|
-
stdout.puts "Starting ClaudeMemory dashboard on http://
|
|
39
|
+
stdout.puts "Starting ClaudeMemory dashboard on http://127.0.0.1:#{opts[:port]}"
|
|
40
|
+
stdout.puts "OTel receiver listening at http://127.0.0.1:#{opts[:port]}/v1/{metrics,logs,traces}"
|
|
40
41
|
stdout.puts "Press Ctrl+C to stop."
|
|
41
42
|
|
|
42
43
|
server = Dashboard::Server.new(
|
|
@@ -5,9 +5,11 @@ require "optparse"
|
|
|
5
5
|
module ClaudeMemory
|
|
6
6
|
module Commands
|
|
7
7
|
# Weekly digest — a markdown summary of what memory did over the last N days.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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.
|
|
11
13
|
#
|
|
12
14
|
# The data it aggregates all already exists (activity_events, facts,
|
|
13
15
|
# conflicts, moment_feedback); this command only shapes it into a report.
|
|
@@ -48,6 +50,10 @@ module ClaudeMemory
|
|
|
48
50
|
lines << ""
|
|
49
51
|
lines << activity_section(manager, cutoff)
|
|
50
52
|
lines << ""
|
|
53
|
+
lines << context_cost_section(manager)
|
|
54
|
+
lines << ""
|
|
55
|
+
lines << quality_section(manager, cutoff)
|
|
56
|
+
lines << ""
|
|
51
57
|
lines << knowledge_section(manager, cutoff)
|
|
52
58
|
lines << ""
|
|
53
59
|
lines << utilization_section(manager)
|
|
@@ -124,6 +130,92 @@ module ClaudeMemory
|
|
|
124
130
|
"## New knowledge\n\n_Unavailable: #{e.message}_"
|
|
125
131
|
end
|
|
126
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
|
+
|
|
127
219
|
def utilization_section(manager)
|
|
128
220
|
util = Dashboard::Trust.new(manager).utilization
|
|
129
221
|
pct = util[:ratio_pct]
|
|
@@ -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,6 +171,28 @@ 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"]
|
|
@@ -213,6 +237,7 @@ module ClaudeMemory
|
|
|
213
237
|
details = {
|
|
214
238
|
source: source,
|
|
215
239
|
context_length: context_text&.length,
|
|
240
|
+
context_tokens: ClaudeMemory::Core::TokenEstimator.estimate(context_text),
|
|
216
241
|
preview: context_text&.byteslice(0, CONTEXT_PREVIEW_BYTES),
|
|
217
242
|
truncated: context_text ? context_text.bytesize > CONTEXT_PREVIEW_BYTES : false,
|
|
218
243
|
top_fact_ids: injector.emitted_fact_ids.first(10),
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module ClaudeMemory
|
|
7
|
+
module Commands
|
|
8
|
+
# Imports Claude Code auto-memory files (~/.claude/projects/<slug>/memory/*.md)
|
|
9
|
+
# into the project database as durable facts. Before this command, those
|
|
10
|
+
# markdown files were only surfaced transiently via `Hook::AutoMemoryMirror`
|
|
11
|
+
# at SessionStart — they were invisible to `memory.recall` and the
|
|
12
|
+
# shortcut tools. Importing them as facts (predicate=convention for
|
|
13
|
+
# gotcha/feedback/project files, predicate=reference for reference
|
|
14
|
+
# type) makes that knowledge first-class queryable knowledge.
|
|
15
|
+
#
|
|
16
|
+
# Idempotent on object_literal prefix: re-running skips files whose
|
|
17
|
+
# body text is already present.
|
|
18
|
+
class ImportAutoMemoryCommand < BaseCommand
|
|
19
|
+
def call(args)
|
|
20
|
+
opts = parse_opts(args)
|
|
21
|
+
return 1 if opts.nil?
|
|
22
|
+
|
|
23
|
+
auto_dir = resolve_auto_dir
|
|
24
|
+
files = list_files(auto_dir)
|
|
25
|
+
if files.empty?
|
|
26
|
+
stdout.puts "No auto-memory files found in #{auto_dir}"
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
manager = Store::StoreManager.new
|
|
31
|
+
manager.ensure_project!
|
|
32
|
+
store = manager.project_store
|
|
33
|
+
|
|
34
|
+
imported = 0
|
|
35
|
+
skipped = 0
|
|
36
|
+
files.each do |path|
|
|
37
|
+
fact_data = parse_file(path)
|
|
38
|
+
next if fact_data.nil?
|
|
39
|
+
|
|
40
|
+
if already_imported?(store, fact_data[:object_literal])
|
|
41
|
+
skipped += 1
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if opts[:dry_run]
|
|
46
|
+
stdout.puts "[DRY] #{File.basename(path)} → #{fact_data[:predicate]}"
|
|
47
|
+
else
|
|
48
|
+
insert(store, fact_data, path)
|
|
49
|
+
stdout.puts "Imported: #{File.basename(path)} → #{fact_data[:predicate]}"
|
|
50
|
+
end
|
|
51
|
+
imported += 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
stdout.puts ""
|
|
55
|
+
verb = opts[:dry_run] ? "Would import" : "Imported"
|
|
56
|
+
stdout.puts "#{verb}: #{imported} Skipped (already present): #{skipped}"
|
|
57
|
+
manager.close
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse_opts(args)
|
|
64
|
+
options = {dry_run: false}
|
|
65
|
+
parser = OptionParser.new do |o|
|
|
66
|
+
o.banner = "Usage: claude-memory import-auto-memory [--dry-run]"
|
|
67
|
+
o.on("--dry-run", "Show what would be imported without writing") { options[:dry_run] = true }
|
|
68
|
+
end
|
|
69
|
+
parser.parse!(args.dup)
|
|
70
|
+
options
|
|
71
|
+
rescue OptionParser::InvalidOption => e
|
|
72
|
+
stderr.puts e.message
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_auto_dir
|
|
77
|
+
config = Configuration.new
|
|
78
|
+
Hook::AutoMemoryMirror.default_dir(config.project_dir, config.claude_config_dir)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def list_files(dir)
|
|
82
|
+
return [] unless Dir.exist?(dir)
|
|
83
|
+
Dir.glob(File.join(dir, "*.md")).reject { |f| File.basename(f) == "MEMORY.md" }.sort
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_file(path)
|
|
87
|
+
text = File.read(path)
|
|
88
|
+
return nil unless (match = text.match(/\A---\n(.*?)\n---\n(.*)\z/m))
|
|
89
|
+
|
|
90
|
+
frontmatter = match[1]
|
|
91
|
+
body = match[2].strip
|
|
92
|
+
|
|
93
|
+
name = frontmatter[/^name:\s*(.+)/, 1]&.strip
|
|
94
|
+
type = frontmatter[/^type:\s*(.+)/, 1]&.strip
|
|
95
|
+
description = frontmatter[/^description:\s*(.+)/, 1]&.strip
|
|
96
|
+
return nil if name.nil? || type.nil?
|
|
97
|
+
|
|
98
|
+
predicate, subject, scope = map_type(type)
|
|
99
|
+
object = build_object(name, description, body)
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
subject: subject,
|
|
103
|
+
predicate: predicate,
|
|
104
|
+
object_literal: object,
|
|
105
|
+
scope: scope
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def map_type(type)
|
|
110
|
+
case type
|
|
111
|
+
when "feedback", "user"
|
|
112
|
+
["convention", "user", "global"]
|
|
113
|
+
when "reference"
|
|
114
|
+
["reference", "repo", "project"]
|
|
115
|
+
else
|
|
116
|
+
# gotcha, project, anything else
|
|
117
|
+
["convention", "repo", "project"]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Build an object string that carries a reason clause so
|
|
122
|
+
# BareConclusionDetector does not flag the fact as bare. Auto-memory
|
|
123
|
+
# files conventionally include a **Why:** section; we surface the first
|
|
124
|
+
# 400 chars of the body alongside the name as the object text.
|
|
125
|
+
def build_object(name, description, body)
|
|
126
|
+
first_para = body.split("\n\n").first.to_s.strip
|
|
127
|
+
first_para = first_para[0..400] + "..." if first_para.length > 400
|
|
128
|
+
|
|
129
|
+
parts = [name]
|
|
130
|
+
parts << description if description && !description.empty? && description != name
|
|
131
|
+
parts << first_para unless first_para.empty?
|
|
132
|
+
text = parts.join(" — ").gsub(/\s+/, " ").strip
|
|
133
|
+
|
|
134
|
+
# If no reason clause is present, attach a stable suffix so the fact
|
|
135
|
+
# is structurally distinguishable from bare conclusions.
|
|
136
|
+
text += " (imported from project auto-memory; see source file for full reasoning)" unless reason_present?(text)
|
|
137
|
+
text
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def reason_present?(text)
|
|
141
|
+
text.match?(/\b(because|so that|so the|so we|in order to|to avoid|to ensure|to prevent|prevents|otherwise|caused by|breaks when)\b/i)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def already_imported?(store, object_text)
|
|
145
|
+
needle = object_text[0..80].gsub(/[%_]/) { |c| "\\#{c}" }
|
|
146
|
+
!store.facts.where(Sequel.like(:object_literal, "#{needle}%")).limit(1).all.empty?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def insert(store, fact_data, path)
|
|
150
|
+
store.db.transaction do
|
|
151
|
+
subject_type = (fact_data[:subject] == "user") ? "person" : "repo"
|
|
152
|
+
subject_id = store.find_or_create_entity(type: subject_type, name: fact_data[:subject])
|
|
153
|
+
|
|
154
|
+
fact_id = store.insert_fact(
|
|
155
|
+
subject_entity_id: subject_id,
|
|
156
|
+
predicate: fact_data[:predicate],
|
|
157
|
+
object_literal: fact_data[:object_literal],
|
|
158
|
+
scope: fact_data[:scope],
|
|
159
|
+
project_path: (fact_data[:scope] == "global") ? nil : Configuration.new.project_dir
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
content_id = store.upsert_content_item(
|
|
163
|
+
source: "auto_memory_import",
|
|
164
|
+
session_id: nil,
|
|
165
|
+
text_hash: Digest::SHA256.hexdigest(path + fact_data[:object_literal]),
|
|
166
|
+
byte_len: fact_data[:object_literal].bytesize,
|
|
167
|
+
raw_text: fact_data[:object_literal]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
store.insert_provenance(
|
|
171
|
+
fact_id: fact_id,
|
|
172
|
+
content_item_id: content_id,
|
|
173
|
+
quote: fact_data[:object_literal][0..200],
|
|
174
|
+
strength: "stated"
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -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" => [{
|