claude_memory 0.9.0 → 0.10.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 +63 -1
- data/.claude/skills/dashboard/SKILL.md +42 -0
- data/.claude/skills/release/SKILL.md +168 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +92 -0
- data/CLAUDE.md +21 -5
- data/README.md +32 -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 +190 -0
- data/docs/EXAMPLES.md +41 -2
- data/docs/GETTING_STARTED.md +31 -4
- data/docs/architecture.md +22 -7
- data/docs/audit-queries.md +131 -0
- data/docs/dashboard.md +172 -0
- data/docs/improvements.md +465 -9
- data/docs/influence/cq.md +187 -0
- data/docs/plugin.md +13 -6
- data/docs/quality_review.md +489 -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 +181 -0
- data/lib/claude_memory/commands/hook_command.rb +34 -0
- data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
- data/lib/claude_memory/commands/registry.rb +6 -1
- data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
- data/lib/claude_memory/commands/stats_command.rb +38 -1
- 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 +285 -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 +50 -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/server.rb +8 -2
- 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/version.rb +1 -1
- data/lib/claude_memory.rb +22 -0
- metadata +50 -1
|
@@ -172,14 +172,17 @@ module ClaudeMemory
|
|
|
172
172
|
def hook_context(payload, db_path)
|
|
173
173
|
project_path = payload["project_path"] || payload["cwd"]
|
|
174
174
|
source = payload["source"]
|
|
175
|
+
session_id = payload["session_id"]
|
|
175
176
|
manager = ClaudeMemory::Store::StoreManager.new(
|
|
176
177
|
project_db_path: db_path,
|
|
177
178
|
project_path: project_path
|
|
178
179
|
)
|
|
179
180
|
manager.ensure_both!
|
|
180
181
|
|
|
182
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
181
183
|
injector = ClaudeMemory::Hook::ContextInjector.new(manager, source: source)
|
|
182
184
|
context_text = injector.generate_context
|
|
185
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
183
186
|
|
|
184
187
|
if context_text
|
|
185
188
|
response = {
|
|
@@ -191,12 +194,43 @@ module ClaudeMemory
|
|
|
191
194
|
stdout.puts JSON.generate(response)
|
|
192
195
|
end
|
|
193
196
|
|
|
197
|
+
record_context_activity(manager, context_text, injector,
|
|
198
|
+
session_id: session_id, source: source, duration_ms: duration_ms)
|
|
199
|
+
|
|
194
200
|
manager.close
|
|
195
201
|
Hook::ExitCodes::SUCCESS
|
|
196
202
|
rescue => e
|
|
197
203
|
classify_error(e)
|
|
198
204
|
end
|
|
199
205
|
|
|
206
|
+
CONTEXT_PREVIEW_BYTES = 400
|
|
207
|
+
|
|
208
|
+
def record_context_activity(manager, context_text, injector, session_id:, source:, duration_ms:)
|
|
209
|
+
store = manager.project_store || manager.global_store
|
|
210
|
+
return unless store
|
|
211
|
+
|
|
212
|
+
by_scope = injector.emitted_facts_by_scope.transform_values { |ids| ids.first(10) }
|
|
213
|
+
details = {
|
|
214
|
+
source: source,
|
|
215
|
+
context_length: context_text&.length,
|
|
216
|
+
preview: context_text&.byteslice(0, CONTEXT_PREVIEW_BYTES),
|
|
217
|
+
truncated: context_text ? context_text.bytesize > CONTEXT_PREVIEW_BYTES : false,
|
|
218
|
+
top_fact_ids: injector.emitted_fact_ids.first(10),
|
|
219
|
+
top_facts_by_scope: (by_scope if by_scope.any?),
|
|
220
|
+
top_subjects: injector.emitted_subjects.uniq.first(10),
|
|
221
|
+
fact_count: injector.emitted_fact_ids.size
|
|
222
|
+
}.compact
|
|
223
|
+
|
|
224
|
+
ClaudeMemory::ActivityLog.record(store,
|
|
225
|
+
event_type: "hook_context",
|
|
226
|
+
status: context_text ? "success" : "skipped",
|
|
227
|
+
session_id: session_id,
|
|
228
|
+
duration_ms: duration_ms,
|
|
229
|
+
details: details)
|
|
230
|
+
rescue => e
|
|
231
|
+
ClaudeMemory.logger.debug("record_context_activity failed: #{e.message}")
|
|
232
|
+
end
|
|
233
|
+
|
|
200
234
|
def classify_error(error)
|
|
201
235
|
exit_code = Hook::ErrorClassifier.exit_code_for(error)
|
|
202
236
|
|
|
@@ -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,12 @@ 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"}
|
|
41
46
|
}.freeze
|
|
42
47
|
|
|
43
48
|
# Find a command class by name
|
|
@@ -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
|
|
@@ -13,13 +13,15 @@ module ClaudeMemory
|
|
|
13
13
|
SCOPE_PROJECT = "project"
|
|
14
14
|
|
|
15
15
|
def call(args)
|
|
16
|
-
opts = parse_options(args, {scope: SCOPE_ALL, tools: false, since_days: nil}) do |o|
|
|
16
|
+
opts = parse_options(args, {scope: SCOPE_ALL, tools: false, stale: false, since_days: nil, stale_days: nil}) do |o|
|
|
17
17
|
OptionParser.new do |parser|
|
|
18
18
|
parser.banner = "Usage: claude-memory stats [options]"
|
|
19
19
|
parser.on("--scope SCOPE", ["all", "global", "project"],
|
|
20
20
|
"Show stats for: all (default), global, or project") { |v| o[:scope] = v }
|
|
21
21
|
parser.on("--tools", "Show MCP tool-call usage stats") { o[:tools] = true }
|
|
22
|
+
parser.on("--stale", "Show facts not recalled in CLAUDE_MEMORY_STALE_DAYS (default 14)") { o[:stale] = true }
|
|
22
23
|
parser.on("--since DAYS", Integer, "Limit --tools to last N days") { |v| o[:since_days] = v }
|
|
24
|
+
parser.on("--stale-days N", Integer, "Override staleness threshold for --stale") { |v| o[:stale_days] = v }
|
|
23
25
|
end
|
|
24
26
|
end
|
|
25
27
|
return 1 if opts.nil?
|
|
@@ -28,6 +30,10 @@ module ClaudeMemory
|
|
|
28
30
|
return print_mcp_tool_call_stats(opts[:since_days])
|
|
29
31
|
end
|
|
30
32
|
|
|
33
|
+
if opts[:stale]
|
|
34
|
+
return print_stale_facts(opts[:stale_days])
|
|
35
|
+
end
|
|
36
|
+
|
|
31
37
|
manager = ClaudeMemory::Store::StoreManager.new
|
|
32
38
|
|
|
33
39
|
stdout.puts "ClaudeMemory Statistics"
|
|
@@ -48,6 +54,37 @@ module ClaudeMemory
|
|
|
48
54
|
|
|
49
55
|
private
|
|
50
56
|
|
|
57
|
+
def print_stale_facts(override_days)
|
|
58
|
+
threshold = override_days || ClaudeMemory::Configuration.new.stale_days
|
|
59
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
60
|
+
result = ClaudeMemory::Recall::StaleDetector.stale_facts(manager, threshold_days: threshold)
|
|
61
|
+
|
|
62
|
+
stdout.puts "Stale facts (last_recalled_at older than #{threshold} day#{"s" unless threshold == 1})"
|
|
63
|
+
stdout.puts "=" * 60
|
|
64
|
+
|
|
65
|
+
if result[:total].zero?
|
|
66
|
+
stdout.puts "No stale facts."
|
|
67
|
+
stdout.puts ""
|
|
68
|
+
stdout.puts "Run `claude-memory sweep` to refresh last_recalled_at from activity_events."
|
|
69
|
+
else
|
|
70
|
+
stdout.puts "Total: #{result[:total]} (project=#{result[:project].size}, global=#{result[:global].size})"
|
|
71
|
+
stdout.puts ""
|
|
72
|
+
%i[project global].each do |scope|
|
|
73
|
+
rows = result[scope]
|
|
74
|
+
next if rows.empty?
|
|
75
|
+
stdout.puts "## #{scope.to_s.upcase}"
|
|
76
|
+
rows.each do |row|
|
|
77
|
+
last = row[:last_recalled_at] || "never"
|
|
78
|
+
stdout.puts " ##{row[:id]} [#{row[:predicate]}] #{row[:object_literal]&.slice(0, 80)} (last: #{last})"
|
|
79
|
+
end
|
|
80
|
+
stdout.puts ""
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
manager.close
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
|
|
51
88
|
def open_readonly(db_path)
|
|
52
89
|
Sequel.connect("extralite://#{db_path}")
|
|
53
90
|
end
|
|
@@ -19,12 +19,14 @@ module ClaudeMemory
|
|
|
19
19
|
|
|
20
20
|
stdout.puts "Running sweep on #{opts[:scope]} database with #{opts[:budget]}s budget..."
|
|
21
21
|
stats = sweeper.run!(budget_seconds: opts[:budget])
|
|
22
|
+
refresh_counts = ClaudeMemory::Sweep::RecallTimestampRefresher.new(manager).refresh!
|
|
22
23
|
|
|
23
24
|
stdout.puts "Sweep complete:"
|
|
24
25
|
stdout.puts " Proposed facts expired: #{stats[:proposed_facts_expired]}"
|
|
25
26
|
stdout.puts " Disputed facts expired: #{stats[:disputed_facts_expired]}"
|
|
26
27
|
stdout.puts " Orphaned provenance deleted: #{stats[:orphaned_provenance_deleted]}"
|
|
27
28
|
stdout.puts " Old content pruned: #{stats[:old_content_pruned]}"
|
|
29
|
+
stdout.puts " Recall timestamps refreshed: project=#{refresh_counts[:project]}, global=#{refresh_counts[:global]}"
|
|
28
30
|
stdout.puts " Elapsed: #{stats[:elapsed_seconds].round(2)}s"
|
|
29
31
|
stdout.puts " Budget honored: #{stats[:budget_honored]}"
|
|
30
32
|
|
|
@@ -50,6 +50,22 @@ module ClaudeMemory
|
|
|
50
50
|
env["CLAUDE_TRANSCRIPT_PATH"]
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Default staleness threshold (in days) for #35 access-based staleness.
|
|
54
|
+
# Active facts whose last_recalled_at is older than this — or never set,
|
|
55
|
+
# for facts created earlier than the same window — are flagged as
|
|
56
|
+
# candidates for review. Override via CLAUDE_MEMORY_STALE_DAYS.
|
|
57
|
+
DEFAULT_STALE_DAYS = 14
|
|
58
|
+
|
|
59
|
+
# @return [Integer] staleness threshold in days
|
|
60
|
+
def stale_days
|
|
61
|
+
raw = env["CLAUDE_MEMORY_STALE_DAYS"]
|
|
62
|
+
return DEFAULT_STALE_DAYS if raw.nil? || raw.empty?
|
|
63
|
+
parsed = Integer(raw, 10)
|
|
64
|
+
(parsed > 0) ? parsed : DEFAULT_STALE_DAYS
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
DEFAULT_STALE_DAYS
|
|
67
|
+
end
|
|
68
|
+
|
|
53
69
|
private
|
|
54
70
|
|
|
55
71
|
def resolve_project_dir
|
|
@@ -37,6 +37,15 @@ module ClaudeMemory
|
|
|
37
37
|
nil
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Parse a timestamp value into a Unix epoch integer; returns 0 when the
|
|
41
|
+
# value is unparseable. Used by sort comparators that need a stable
|
|
42
|
+
# numeric key without an exception path.
|
|
43
|
+
def self.to_epoch(value)
|
|
44
|
+
Time.parse(value.to_s).to_i
|
|
45
|
+
rescue ArgumentError, TypeError
|
|
46
|
+
0
|
|
47
|
+
end
|
|
48
|
+
|
|
40
49
|
def self.format_absolute(time)
|
|
41
50
|
time.strftime("%Y-%m-%d")
|
|
42
51
|
end
|