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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +63 -1
  4. data/.claude/skills/dashboard/SKILL.md +42 -0
  5. data/.claude/skills/release/SKILL.md +168 -0
  6. data/.claude-plugin/marketplace.json +1 -1
  7. data/.claude-plugin/plugin.json +1 -1
  8. data/CHANGELOG.md +92 -0
  9. data/CLAUDE.md +21 -5
  10. data/README.md +32 -2
  11. data/db/migrations/015_add_activity_events.rb +26 -0
  12. data/db/migrations/016_add_moment_feedback.rb +22 -0
  13. data/db/migrations/017_add_last_recalled_at.rb +15 -0
  14. data/docs/1_0_punchlist.md +190 -0
  15. data/docs/EXAMPLES.md +41 -2
  16. data/docs/GETTING_STARTED.md +31 -4
  17. data/docs/architecture.md +22 -7
  18. data/docs/audit-queries.md +131 -0
  19. data/docs/dashboard.md +172 -0
  20. data/docs/improvements.md +465 -9
  21. data/docs/influence/cq.md +187 -0
  22. data/docs/plugin.md +13 -6
  23. data/docs/quality_review.md +489 -172
  24. data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
  25. data/lib/claude_memory/activity_log.rb +86 -0
  26. data/lib/claude_memory/commands/census_command.rb +210 -0
  27. data/lib/claude_memory/commands/completion_command.rb +3 -0
  28. data/lib/claude_memory/commands/dashboard_command.rb +54 -0
  29. data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
  30. data/lib/claude_memory/commands/digest_command.rb +181 -0
  31. data/lib/claude_memory/commands/hook_command.rb +34 -0
  32. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  33. data/lib/claude_memory/commands/registry.rb +6 -1
  34. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  35. data/lib/claude_memory/commands/stats_command.rb +38 -1
  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 +285 -0
  52. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  53. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  54. data/lib/claude_memory/hook/context_injector.rb +97 -3
  55. data/lib/claude_memory/hook/handler.rb +50 -3
  56. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  57. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  58. data/lib/claude_memory/mcp/server.rb +8 -2
  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/version.rb +1 -1
  75. data/lib/claude_memory.rb +22 -0
  76. 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