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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
# Soft-rename / soft-removal mechanism for public-API surfaces.
|
|
5
|
+
# Used to mark an old name (CLI flag, MCP tool, Ruby method, hook
|
|
6
|
+
# field, predicate) as deprecated in `N.x.0` releases while keeping it
|
|
7
|
+
# functional for at least one minor cycle, with explicit removal no
|
|
8
|
+
# earlier than `(N+1).0.0`. This deprecation policy is documented in
|
|
9
|
+
# `docs/api_stability.md`.
|
|
10
|
+
#
|
|
11
|
+
# @example Deprecate a renamed CLI flag
|
|
12
|
+
# ClaudeMemory::Deprecations.warn(
|
|
13
|
+
# name: "claude-memory recall --legacy-mode",
|
|
14
|
+
# replacement: "--mode=legacy",
|
|
15
|
+
# removed_in: "1.0.0"
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example Deprecate a soft-renamed Ruby method
|
|
19
|
+
# ClaudeMemory::Deprecations.warn(
|
|
20
|
+
# name: "ClaudeMemory::Recall#legacy_query",
|
|
21
|
+
# replacement: "Recall#query",
|
|
22
|
+
# removed_in: "1.0.0",
|
|
23
|
+
# message: "Pass `mode: :legacy` to #query instead."
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
# Two suppression mechanisms keep deprecation noise manageable:
|
|
27
|
+
#
|
|
28
|
+
# - **Per-call-site dedupe**: same (name, caller_file:line) pair only
|
|
29
|
+
# emits once per process. Prevents tight loops or repeated callers
|
|
30
|
+
# from drowning the terminal.
|
|
31
|
+
# - **Env var opt-out**: `CLAUDE_MEMORY_NO_DEPRECATIONS=1` silences
|
|
32
|
+
# everything. Recommended for test fixtures and CI runs that
|
|
33
|
+
# knowingly exercise legacy paths.
|
|
34
|
+
module Deprecations
|
|
35
|
+
ENV_OPT_OUT = "CLAUDE_MEMORY_NO_DEPRECATIONS"
|
|
36
|
+
|
|
37
|
+
# Tracks already-emitted (name, caller-location) pairs for dedupe.
|
|
38
|
+
# Bounded — this is a long-lived process state but the cardinality
|
|
39
|
+
# is at most "every deprecated surface × every call site that
|
|
40
|
+
# touches it", which stays small in practice.
|
|
41
|
+
@emitted = {}
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Emit a deprecation warning to `output` (stderr by default).
|
|
46
|
+
#
|
|
47
|
+
# @param name [String] the deprecated identifier (CLI flag, method,
|
|
48
|
+
# tool name, etc.). Be specific: "ClaudeMemory::Recall#query(:legacy)"
|
|
49
|
+
# beats "Recall#query".
|
|
50
|
+
# @param replacement [String, nil] what users should switch to.
|
|
51
|
+
# Optional but strongly recommended — a deprecation without a
|
|
52
|
+
# migration path is annoying.
|
|
53
|
+
# @param removed_in [String, nil] target removal version, semver
|
|
54
|
+
# string. Conventionally the next major (`(N+1).0.0`).
|
|
55
|
+
# @param message [String, nil] free-form extra context. Use for
|
|
56
|
+
# subtle migration nuance the replacement string can't capture.
|
|
57
|
+
# @param caller_location [String, nil] override for testing.
|
|
58
|
+
# @param output [IO] override for testing. Default: $stderr.
|
|
59
|
+
# @return [Boolean] true if a warning was emitted, false if
|
|
60
|
+
# suppressed (env opt-out or already-emitted dedupe).
|
|
61
|
+
def warn(name:, replacement: nil, removed_in: nil, message: nil,
|
|
62
|
+
caller_location: nil, output: $stderr)
|
|
63
|
+
return false if suppressed?
|
|
64
|
+
|
|
65
|
+
location = caller_location || derive_caller_location
|
|
66
|
+
key = "#{name}@#{location}"
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
return false if @emitted[key]
|
|
70
|
+
@emitted[key] = true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
output.puts(format_warning(name: name, replacement: replacement,
|
|
74
|
+
removed_in: removed_in, message: message, location: location))
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Wipe the per-call-site dedupe state. Test-only — production
|
|
79
|
+
# callers should rely on the per-process behavior.
|
|
80
|
+
def reset!
|
|
81
|
+
@mutex.synchronize { @emitted.clear }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def suppressed?
|
|
87
|
+
ENV[ENV_OPT_OUT] == "1"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def derive_caller_location
|
|
91
|
+
# Skip: 0=this method, 1=warn, 2=actual caller
|
|
92
|
+
loc = caller_locations(3, 1)&.first
|
|
93
|
+
loc ? "#{loc.path}:#{loc.lineno}" : "unknown"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format_warning(name:, replacement:, removed_in:, message:, location:)
|
|
97
|
+
parts = ["[ClaudeMemory] DEPRECATION: #{name} is deprecated"]
|
|
98
|
+
parts << "scheduled for removal in #{removed_in}" if removed_in
|
|
99
|
+
parts << "use #{replacement} instead" if replacement
|
|
100
|
+
head = parts.join(", ") + "."
|
|
101
|
+
head += " #{message}" if message
|
|
102
|
+
"#{head} (called from #{location})"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Distill
|
|
5
|
+
# Catches facts that survived distillation without a reason clause.
|
|
6
|
+
# The SessionStart distillation prompt explicitly requires `decision`
|
|
7
|
+
# and `convention` facts to embed the reason ("— because …", "so that
|
|
8
|
+
# …", "to avoid …", "caused by …", "breaks when …"); facts that ship
|
|
9
|
+
# without one are dead weight once they go stale because nobody can
|
|
10
|
+
# recover the original justification by re-reading the row.
|
|
11
|
+
#
|
|
12
|
+
# This detector is the production-side mirror of that prompt
|
|
13
|
+
# constraint. It exists so the dashboard can quantify how many facts
|
|
14
|
+
# are slipping through the prompt's reason-clause requirement —
|
|
15
|
+
# higher bare-conclusion ratio means the LLM is producing low-quality
|
|
16
|
+
# extractions, which is a hallucination-rate proxy worth surfacing.
|
|
17
|
+
#
|
|
18
|
+
# Pure function, no side effects, safe to call in tight loops.
|
|
19
|
+
class BareConclusionDetector
|
|
20
|
+
# Predicates the prompt requires reasons for. Other predicates
|
|
21
|
+
# (uses_framework, uses_database, etc.) carry their meaning in the
|
|
22
|
+
# subject-predicate-object shape itself, so a bare object is fine.
|
|
23
|
+
GUARDED_PREDICATES = %w[decision convention].freeze
|
|
24
|
+
|
|
25
|
+
# Reason-clause signals lifted from the distill-transcripts skill
|
|
26
|
+
# prompt plus a small set of common natural-language variants. The
|
|
27
|
+
# match is case-insensitive and substring-anchored — any one signal
|
|
28
|
+
# qualifies the fact as "explained" even without an em dash.
|
|
29
|
+
REASON_PATTERNS = [
|
|
30
|
+
/\bbecause\b/i,
|
|
31
|
+
/\bso\s+that\b/i,
|
|
32
|
+
/\bso\s+the\b/i,
|
|
33
|
+
/\bso\s+we\b/i,
|
|
34
|
+
/\bin\s+order\s+to\b/i,
|
|
35
|
+
/\bto\s+avoid\b/i,
|
|
36
|
+
/\bto\s+prevent\b/i,
|
|
37
|
+
/\bto\s+ensure\b/i,
|
|
38
|
+
/\bto\s+support\b/i,
|
|
39
|
+
/\bto\s+allow\b/i,
|
|
40
|
+
/\bto\s+enable\b/i,
|
|
41
|
+
/\bto\s+make\b/i,
|
|
42
|
+
/\bto\s+fix\b/i,
|
|
43
|
+
/\bto\s+handle\b/i,
|
|
44
|
+
/\bcaused\s+by\b/i,
|
|
45
|
+
/\bbreaks\s+when\b/i,
|
|
46
|
+
/\bdue\s+to\b/i,
|
|
47
|
+
/\botherwise\b/i,
|
|
48
|
+
/\bwithout\s+(?:which|this|it)\b/i
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
# Returns true when the fact has a guarded predicate AND its object
|
|
52
|
+
# text shows no reason-clause signal. Returns false for any fact
|
|
53
|
+
# outside the guarded predicates so the metric isn't polluted by
|
|
54
|
+
# legitimately-bare facts (uses_database "sqlite" doesn't need a
|
|
55
|
+
# rationale embedded in its object).
|
|
56
|
+
#
|
|
57
|
+
# @param fact [Hash] with :predicate and :object_literal keys (or
|
|
58
|
+
# :predicate / :object — accepts both shapes used in the codebase)
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def bare_conclusion?(fact)
|
|
61
|
+
predicate = fact[:predicate].to_s
|
|
62
|
+
return false unless GUARDED_PREDICATES.include?(predicate)
|
|
63
|
+
|
|
64
|
+
object = (fact[:object_literal] || fact[:object]).to_s
|
|
65
|
+
return false if object.empty?
|
|
66
|
+
|
|
67
|
+
REASON_PATTERNS.none? { |re| object.match?(re) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -43,11 +43,28 @@ module ClaudeMemory
|
|
|
43
43
|
/\bby\s+[[:upper:]][[:alpha:]'-]+\s+[[:upper:]][[:alpha:]'-]+/
|
|
44
44
|
].freeze
|
|
45
45
|
|
|
46
|
-
# Predicates
|
|
47
|
-
# external projects ("From QMD
|
|
48
|
-
#
|
|
46
|
+
# Predicates inspected for object-text reference signals. Decisions
|
|
47
|
+
# stay decisions even when they cite external projects ("From QMD
|
|
48
|
+
# restudy: adopt X"); the object-text guard targets only
|
|
49
|
+
# `convention`, where misclassification is most common.
|
|
49
50
|
GUARDED_PREDICATES = %w[convention].freeze
|
|
50
51
|
|
|
52
|
+
# Stack-shaping single-value predicates that historically attract
|
|
53
|
+
# hallucinations from CLAUDE.md-style example text ("e.g., this app
|
|
54
|
+
# uses PostgreSQL"). For these predicates we additionally inspect the
|
|
55
|
+
# source quote for example markers — if the LLM extracted a stack
|
|
56
|
+
# fact from documentation example text, it's not a real project
|
|
57
|
+
# commitment. Added 2026-05-21 after the audit found 10 open
|
|
58
|
+
# conflicts driven by recurring example-text extraction.
|
|
59
|
+
QUOTE_GUARDED_PREDICATES = %w[uses_database uses_framework uses_language deployment_platform auth_method].freeze
|
|
60
|
+
|
|
61
|
+
# Example markers that signal the source text is documentation
|
|
62
|
+
# exemplifying a scope/predicate concept, not a real stack claim.
|
|
63
|
+
EXAMPLE_QUOTE_PATTERNS = [
|
|
64
|
+
/\b(?:e\.?g\.?|i\.?e\.?|for example|for instance|such as)[,:]?\s/i,
|
|
65
|
+
/\(\s*(?:e\.?g\.?|i\.?e\.?)[,.]/i
|
|
66
|
+
].freeze
|
|
67
|
+
|
|
51
68
|
def reclassify(extraction)
|
|
52
69
|
return extraction if extraction.facts.nil? || extraction.facts.empty?
|
|
53
70
|
|
|
@@ -68,11 +85,27 @@ module ClaudeMemory
|
|
|
68
85
|
end
|
|
69
86
|
|
|
70
87
|
def reference_material?(fact)
|
|
71
|
-
|
|
88
|
+
predicate = fact[:predicate].to_s
|
|
89
|
+
return true if convention_with_reference_object?(fact, predicate)
|
|
90
|
+
return true if stack_predicate_from_example_text?(fact, predicate)
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def convention_with_reference_object?(fact, predicate)
|
|
97
|
+
return false unless GUARDED_PREDICATES.include?(predicate)
|
|
72
98
|
object = fact[:object].to_s
|
|
73
99
|
return false if object.empty?
|
|
74
100
|
STRONG_PATTERNS.any? { |re| object.match?(re) }
|
|
75
101
|
end
|
|
102
|
+
|
|
103
|
+
def stack_predicate_from_example_text?(fact, predicate)
|
|
104
|
+
return false unless QUOTE_GUARDED_PREDICATES.include?(predicate)
|
|
105
|
+
quote = fact[:quote].to_s
|
|
106
|
+
return false if quote.empty?
|
|
107
|
+
EXAMPLE_QUOTE_PATTERNS.any? { |re| quote.match?(re) }
|
|
108
|
+
end
|
|
76
109
|
end
|
|
77
110
|
end
|
|
78
111
|
end
|
|
@@ -21,10 +21,14 @@ module ClaudeMemory
|
|
|
21
21
|
STATE_FILENAME = "auto_memory_mirror.json"
|
|
22
22
|
|
|
23
23
|
# Derive auto-memory directory from a project path using Claude Code's
|
|
24
|
-
# slug convention
|
|
25
|
-
# `/Users/me/src/
|
|
24
|
+
# slug convention. Both path separators and underscores are converted
|
|
25
|
+
# to hyphens — e.g. `/Users/me/src/my_app` →
|
|
26
|
+
# `~/.claude/projects/-Users-me-src-my-app/memory`. Before the
|
|
27
|
+
# underscore conversion was added (2026-05-21 audit), this method
|
|
28
|
+
# silently missed auto-memory for any project name containing `_`,
|
|
29
|
+
# including claude_memory itself.
|
|
26
30
|
def self.default_dir(project_path, claude_config_dir)
|
|
27
|
-
slug = project_path.to_s.tr("/", "-")
|
|
31
|
+
slug = project_path.to_s.tr("/_", "-")
|
|
28
32
|
File.join(claude_config_dir, "projects", slug, "memory")
|
|
29
33
|
end
|
|
30
34
|
|
|
@@ -32,11 +32,12 @@ module ClaudeMemory
|
|
|
32
32
|
# so a bare ID without scope is ambiguous.
|
|
33
33
|
attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope
|
|
34
34
|
|
|
35
|
-
def initialize(manager, source: nil, auto_memory_mirror: nil)
|
|
35
|
+
def initialize(manager, source: nil, auto_memory_mirror: nil, stale_threshold_days: nil)
|
|
36
36
|
@manager = manager
|
|
37
37
|
@source = source
|
|
38
38
|
@recall = Recall.new(manager)
|
|
39
39
|
@auto_memory_mirror = auto_memory_mirror
|
|
40
|
+
@stale_threshold_days = stale_threshold_days
|
|
40
41
|
@emitted_fact_ids = []
|
|
41
42
|
@emitted_subjects = []
|
|
42
43
|
@emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
|
|
@@ -108,11 +109,19 @@ module ClaudeMemory
|
|
|
108
109
|
predicate = fact[:predicate]
|
|
109
110
|
object = fact[:object_literal]
|
|
110
111
|
|
|
111
|
-
if subject && predicate && object
|
|
112
|
+
line = if subject && predicate && object
|
|
112
113
|
"#{subject}.#{predicate} = #{object}"
|
|
113
114
|
elsif object
|
|
114
115
|
object.to_s
|
|
115
116
|
end
|
|
117
|
+
return nil unless line
|
|
118
|
+
|
|
119
|
+
marker = Recall::StalenessAnnotator.marker_for(fact, threshold_days: stale_threshold_days)
|
|
120
|
+
marker ? "#{line} #{marker}" : line
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def stale_threshold_days
|
|
124
|
+
@stale_threshold_days ||= Configuration.new.injection_stale_days
|
|
116
125
|
end
|
|
117
126
|
|
|
118
127
|
def fetch_undistilled(limit)
|
|
@@ -93,6 +93,59 @@ module ClaudeMemory
|
|
|
93
93
|
result
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# First-week ROI nudge. Computes per-session metrics (facts
|
|
97
|
+
# contributed via Stop-hook ingest, percentage of those Claude
|
|
98
|
+
# actually used in recall/context-injection) and decides whether
|
|
99
|
+
# to print to the user. Quiets after MAX_NUDGES successful runs
|
|
100
|
+
# or when CLAUDE_MEMORY_NO_NUDGE=1.
|
|
101
|
+
#
|
|
102
|
+
# The "first ~10 sessions" gate is enforced by counting prior
|
|
103
|
+
# `roi_nudge` activity events with status=success across both
|
|
104
|
+
# stores. Once the user has seen the nudge enough times, memory
|
|
105
|
+
# gets out of the way; trust is established or it isn't.
|
|
106
|
+
MAX_NUDGES = 10
|
|
107
|
+
ENV_NUDGE_OPT_OUT = "CLAUDE_MEMORY_NO_NUDGE"
|
|
108
|
+
|
|
109
|
+
def nudge(payload)
|
|
110
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
111
|
+
session_id = payload["session_id"] || @config.session_id
|
|
112
|
+
|
|
113
|
+
# Cleanly silent on opt-out — no activity event, no record of
|
|
114
|
+
# having tried. Users who set the env var don't want a paper
|
|
115
|
+
# trail of suppressed nudges.
|
|
116
|
+
return {status: :silent, reason: "opt_out"} if @env[ENV_NUDGE_OPT_OUT] == "1"
|
|
117
|
+
return {status: :silent, reason: "no_session_id"} if session_id.nil? || session_id.empty?
|
|
118
|
+
|
|
119
|
+
prior = prior_nudge_count
|
|
120
|
+
if prior >= MAX_NUDGES
|
|
121
|
+
return {status: :silent, reason: "first_week_complete", prior_count: prior}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
contributed_ids = session_contributed_facts(session_id)
|
|
125
|
+
n = contributed_ids.size
|
|
126
|
+
|
|
127
|
+
if n.zero?
|
|
128
|
+
# Don't burn one of the user's 10 nudge slots on an empty
|
|
129
|
+
# session. Memory contributed nothing → no trust signal to
|
|
130
|
+
# surface; come back next session with real data.
|
|
131
|
+
return {status: :silent, reason: "no_contributions", prior_count: prior}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
used = session_used_facts(session_id, contributed_ids)
|
|
135
|
+
pct = (used * 100.0 / n).round
|
|
136
|
+
message = "memory contributed #{n} fact#{"s" unless n == 1} this session, %used = #{pct}%"
|
|
137
|
+
|
|
138
|
+
log_activity("roi_nudge", status: "success", session_id: session_id, t0: t0,
|
|
139
|
+
details: {n: n, used: used, pct: pct, prior_count: prior})
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
status: :emitted,
|
|
143
|
+
message: message,
|
|
144
|
+
n: n, used: used, pct: pct,
|
|
145
|
+
remaining: MAX_NUDGES - prior - 1
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
96
149
|
def context(payload)
|
|
97
150
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
151
|
|
|
@@ -105,7 +158,11 @@ module ClaudeMemory
|
|
|
105
158
|
|
|
106
159
|
log_activity("hook_context",
|
|
107
160
|
status: context_text ? "success" : "skipped", t0: t0,
|
|
108
|
-
details: {
|
|
161
|
+
details: {
|
|
162
|
+
context_length: context_text&.length,
|
|
163
|
+
context_tokens: Core::TokenEstimator.estimate(context_text),
|
|
164
|
+
source: source
|
|
165
|
+
})
|
|
109
166
|
|
|
110
167
|
{status: :ok, context: context_text}
|
|
111
168
|
rescue => e
|
|
@@ -126,6 +183,90 @@ module ClaudeMemory
|
|
|
126
183
|
project_path = payload["project_path"] || @config.project_dir
|
|
127
184
|
Store::StoreManager.new(project_path: project_path, env: @env)
|
|
128
185
|
end
|
|
186
|
+
|
|
187
|
+
# Cross-scope nudge counter. Counts both stores so a user with
|
|
188
|
+
# global facts only doesn't bypass the first-week limit.
|
|
189
|
+
def prior_nudge_count
|
|
190
|
+
manager_or_self.then do |m|
|
|
191
|
+
%w[project global].sum do |scope|
|
|
192
|
+
store = m.respond_to?(:store_if_exists) ? m.store_if_exists(scope) : nil
|
|
193
|
+
next 0 unless store
|
|
194
|
+
store.activity_events.where(event_type: "roi_nudge", status: "success").count
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
rescue Sequel::DatabaseError
|
|
198
|
+
# If we can't read the count, err on the side of "still in
|
|
199
|
+
# first week" so users keep getting feedback while we figure
|
|
200
|
+
# out what's wrong with the DB.
|
|
201
|
+
0
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Facts whose provenance points to content_items captured in
|
|
205
|
+
# this session. Active facts only — superseded/rejected ones
|
|
206
|
+
# don't count as memory contributing.
|
|
207
|
+
def session_contributed_facts(session_id)
|
|
208
|
+
return [] unless @store
|
|
209
|
+
@store.facts
|
|
210
|
+
.join(:provenance, fact_id: :id)
|
|
211
|
+
.join(:content_items, id: Sequel[:provenance][:content_item_id])
|
|
212
|
+
.where(Sequel[:content_items][:session_id] => session_id)
|
|
213
|
+
.where(Sequel[:facts][:status] => "active")
|
|
214
|
+
.select(Sequel[:facts][:id])
|
|
215
|
+
.distinct
|
|
216
|
+
.map { |row| row[:id] }
|
|
217
|
+
rescue Sequel::DatabaseError => e
|
|
218
|
+
ClaudeMemory.logger.debug("session_contributed_facts failed: #{e.message}")
|
|
219
|
+
[]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Of the given fact ids, how many appear in top_fact_ids of any
|
|
223
|
+
# recall or hook_context activity event tagged with this
|
|
224
|
+
# session_id?
|
|
225
|
+
def session_used_facts(session_id, fact_ids)
|
|
226
|
+
return 0 if fact_ids.empty?
|
|
227
|
+
return 0 unless @store
|
|
228
|
+
target = fact_ids.to_set
|
|
229
|
+
used = Set.new
|
|
230
|
+
|
|
231
|
+
@store.activity_events
|
|
232
|
+
.where(event_type: %w[recall hook_context], status: "success")
|
|
233
|
+
.where(session_id: session_id)
|
|
234
|
+
.select(:detail_json)
|
|
235
|
+
.all
|
|
236
|
+
.each do |row|
|
|
237
|
+
details = row[:detail_json] ? JSON.parse(row[:detail_json]) : {}
|
|
238
|
+
(details["top_fact_ids"] || []).each { |id| used << id if target.include?(id) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
used.size
|
|
242
|
+
rescue Sequel::DatabaseError, JSON::ParserError => e
|
|
243
|
+
ClaudeMemory.logger.debug("session_used_facts failed: #{e.message}")
|
|
244
|
+
0
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def manager_or_self
|
|
248
|
+
return @manager if @manager
|
|
249
|
+
# When the Handler was given only a single store (no manager),
|
|
250
|
+
# we still want to count nudges; treat the store like a single-
|
|
251
|
+
# scope manager via a tiny wrapper.
|
|
252
|
+
@_handler_store_facade ||= SingleStoreFacade.new(@store)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
class SingleStoreFacade
|
|
256
|
+
def initialize(store)
|
|
257
|
+
@store = store
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def store_if_exists(scope)
|
|
261
|
+
# The manager's store_if_exists returns nil for the absent
|
|
262
|
+
# scope; we don't know which scope this single store
|
|
263
|
+
# represents, so return it for "project" and nil for
|
|
264
|
+
# "global". Counts undercount global-only setups, which is
|
|
265
|
+
# acceptable — global-only users would normally pass a
|
|
266
|
+
# manager.
|
|
267
|
+
(scope == "project") ? @store : nil
|
|
268
|
+
end
|
|
269
|
+
end
|
|
129
270
|
end
|
|
130
271
|
end
|
|
131
272
|
end
|
|
@@ -284,7 +284,7 @@ module ClaudeMemory
|
|
|
284
284
|
},
|
|
285
285
|
{
|
|
286
286
|
name: "memory.decisions",
|
|
287
|
-
description: "List
|
|
287
|
+
description: "List facts with predicate=decision from both project and global memory (project first). Returns only `decision`-predicate facts — does not include `uses_database`, `uses_framework`, etc.",
|
|
288
288
|
inputSchema: {
|
|
289
289
|
type: "object",
|
|
290
290
|
properties: {
|
|
@@ -295,7 +295,7 @@ module ClaudeMemory
|
|
|
295
295
|
},
|
|
296
296
|
{
|
|
297
297
|
name: "memory.conventions",
|
|
298
|
-
description: "List
|
|
298
|
+
description: "List facts with predicate=convention from both project and global memory (project first). Use this to see project coding conventions alongside user-wide style preferences.",
|
|
299
299
|
inputSchema: {
|
|
300
300
|
type: "object",
|
|
301
301
|
properties: {
|
|
@@ -306,7 +306,7 @@ module ClaudeMemory
|
|
|
306
306
|
},
|
|
307
307
|
{
|
|
308
308
|
name: "memory.architecture",
|
|
309
|
-
description: "List
|
|
309
|
+
description: "List architecture facts and stack-shaping constraints (predicates: architecture, uses_database, uses_framework, uses_language, deployment_platform, auth_method) from both project and global memory.",
|
|
310
310
|
inputSchema: {
|
|
311
311
|
type: "object",
|
|
312
312
|
properties: {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module OTel
|
|
7
|
+
# Value object wrapping an OTel attribute hash (already flattened from
|
|
8
|
+
# the OTLP KeyValue representation by OtlpJsonEnvelope). Hides the
|
|
9
|
+
# primitive Hash from callers so panels and ingestors don't reach into
|
|
10
|
+
# raw JSON keys.
|
|
11
|
+
#
|
|
12
|
+
# All accessors return nil when the underlying attribute is missing.
|
|
13
|
+
# Frozen on construction — pass a fresh hash if you need to mutate.
|
|
14
|
+
class Attributes
|
|
15
|
+
# OTel keys we treat as "captured prompt content". Used by
|
|
16
|
+
# #contains_prompt_content? to flag privacy concerns regardless of
|
|
17
|
+
# which content flag was flipped (OTEL_LOG_USER_PROMPTS,
|
|
18
|
+
# OTEL_LOG_TOOL_CONTENT, OTEL_LOG_RAW_API_BODIES).
|
|
19
|
+
PROMPT_CONTENT_KEYS = %w[
|
|
20
|
+
prompt
|
|
21
|
+
body
|
|
22
|
+
tool_input
|
|
23
|
+
tool.output
|
|
24
|
+
full_command
|
|
25
|
+
user_prompt
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# @param hash [Hash] flattened attributes (string keys)
|
|
29
|
+
def initialize(hash)
|
|
30
|
+
@hash = (hash || {}).dup.freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Parse a JSON string into Attributes. Returns Attributes wrapping
|
|
35
|
+
# an empty hash for nil, blank, or unparseable input — matches the
|
|
36
|
+
# tolerance the existing dashboard panels expect.
|
|
37
|
+
#
|
|
38
|
+
# @param json_string [String, nil]
|
|
39
|
+
# @return [Attributes]
|
|
40
|
+
def self.from_json(json_string)
|
|
41
|
+
return new({}) if json_string.nil? || json_string.empty?
|
|
42
|
+
new(JSON.parse(json_string))
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
new({})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
@hash
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_json(*args)
|
|
52
|
+
@hash.to_json(*args)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](key)
|
|
56
|
+
@hash[key.to_s]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Claude Code attaches `prompt.id` to events that should be UNION'd into
|
|
60
|
+
# the prompt journey. See docs/claude_monitoring.md.
|
|
61
|
+
def prompt_id
|
|
62
|
+
@hash["prompt.id"] || @hash["prompt_id"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def session_id
|
|
66
|
+
@hash["session.id"] || @hash["session_id"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# GenAI semconv canonical key + Claude Code's `model` alias.
|
|
70
|
+
def model
|
|
71
|
+
@hash["gen_ai.request.model"] || @hash["model"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tool_name
|
|
75
|
+
@hash["tool_name"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Cost counter values arrive as the metric value, not as an attribute.
|
|
79
|
+
# The `type` attribute on token.usage tells us input/output/cacheRead/
|
|
80
|
+
# cacheCreation; this is only useful for token rows. Returns nil when
|
|
81
|
+
# missing so callers can guard with #compact.
|
|
82
|
+
def token_type
|
|
83
|
+
@hash["type"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Token count carried on a token.usage data point.
|
|
87
|
+
# @param row [Hash] otel_metrics row
|
|
88
|
+
def self.token_count(row)
|
|
89
|
+
(row[:value_int] || row[:value_float] || 0).to_i
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Tool execution duration in ms when the event is a tool_result.
|
|
93
|
+
# @return [Integer, nil]
|
|
94
|
+
def duration_ms
|
|
95
|
+
value = @hash["duration_ms"]
|
|
96
|
+
value&.to_i
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def query_source
|
|
100
|
+
@hash["query_source"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def speed
|
|
104
|
+
@hash["speed"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# True when any attribute carries actual prompt or body content. Used
|
|
108
|
+
# by panels to render a one-line privacy notice without auto-flipping
|
|
109
|
+
# OTEL_LOG_USER_PROMPTS.
|
|
110
|
+
def contains_prompt_content?
|
|
111
|
+
PROMPT_CONTENT_KEYS.any? do |key|
|
|
112
|
+
value = @hash[key]
|
|
113
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module OTel
|
|
5
|
+
# Canonical metric value types stored in otel_metrics.value_type.
|
|
6
|
+
module ValueType
|
|
7
|
+
INT = "int"
|
|
8
|
+
DOUBLE = "double"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Canonical event names emitted by Claude Code's OTel instrumentation.
|
|
12
|
+
# Used by panels for filtering and by the parser when stripping the
|
|
13
|
+
# `claude_code.` prefix off `event.name` attributes.
|
|
14
|
+
module EventName
|
|
15
|
+
USER_PROMPT = "user_prompt"
|
|
16
|
+
TOOL_RESULT = "tool_result"
|
|
17
|
+
API_REQUEST = "api_request"
|
|
18
|
+
API_ERROR = "api_error"
|
|
19
|
+
API_REQUEST_BODY = "api_request_body"
|
|
20
|
+
API_RESPONSE_BODY = "api_response_body"
|
|
21
|
+
|
|
22
|
+
API_PAIR = [API_REQUEST, API_ERROR].freeze
|
|
23
|
+
PROMPT_BODY_FAMILY = [USER_PROMPT, TOOL_RESULT, API_REQUEST_BODY, API_RESPONSE_BODY].freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Canonical metric names that the dashboard queries.
|
|
27
|
+
module MetricName
|
|
28
|
+
TOKEN_USAGE = "claude_code.token.usage"
|
|
29
|
+
COST_USAGE = "claude_code.cost.usage"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|