claude_memory 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/skills/dashboard/SKILL.md +42 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +130 -0
- data/CLAUDE.md +30 -6
- data/README.md +66 -2
- data/db/migrations/015_add_activity_events.rb +26 -0
- data/db/migrations/016_add_moment_feedback.rb +22 -0
- data/db/migrations/017_add_last_recalled_at.rb +15 -0
- data/docs/1_0_punchlist.md +371 -0
- data/docs/EXAMPLES.md +41 -2
- data/docs/GETTING_STARTED.md +33 -4
- data/docs/architecture.md +22 -7
- data/docs/audit-queries.md +131 -0
- data/docs/dashboard.md +192 -0
- data/docs/improvements.md +650 -9
- data/docs/influence/cq.md +187 -0
- data/docs/plugin.md +13 -6
- data/docs/quality_review.md +524 -172
- data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
- data/lib/claude_memory/activity_log.rb +86 -0
- data/lib/claude_memory/commands/census_command.rb +210 -0
- data/lib/claude_memory/commands/completion_command.rb +3 -0
- data/lib/claude_memory/commands/dashboard_command.rb +54 -0
- data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
- data/lib/claude_memory/commands/digest_command.rb +273 -0
- data/lib/claude_memory/commands/hook_command.rb +61 -2
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
- data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
- data/lib/claude_memory/commands/registry.rb +7 -1
- data/lib/claude_memory/commands/show_command.rb +90 -0
- data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
- data/lib/claude_memory/commands/stats_command.rb +131 -2
- data/lib/claude_memory/commands/sweep_command.rb +2 -0
- data/lib/claude_memory/configuration.rb +16 -0
- data/lib/claude_memory/core/relative_time.rb +9 -0
- data/lib/claude_memory/dashboard/api.rb +610 -0
- data/lib/claude_memory/dashboard/conflicts.rb +279 -0
- data/lib/claude_memory/dashboard/efficacy.rb +127 -0
- data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
- data/lib/claude_memory/dashboard/health.rb +175 -0
- data/lib/claude_memory/dashboard/index.html +2707 -0
- data/lib/claude_memory/dashboard/knowledge.rb +136 -0
- data/lib/claude_memory/dashboard/moments.rb +244 -0
- data/lib/claude_memory/dashboard/reuse.rb +97 -0
- data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
- data/lib/claude_memory/dashboard/server.rb +211 -0
- data/lib/claude_memory/dashboard/timeline.rb +68 -0
- data/lib/claude_memory/dashboard/trust.rb +454 -0
- data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
- data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
- data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
- data/lib/claude_memory/hook/context_injector.rb +97 -3
- data/lib/claude_memory/hook/handler.rb +191 -3
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
- data/lib/claude_memory/mcp/query_guide.rb +11 -0
- data/lib/claude_memory/mcp/text_summary.rb +29 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
- data/lib/claude_memory/mcp/tools.rb +148 -0
- data/lib/claude_memory/publish.rb +13 -21
- data/lib/claude_memory/recall/stale_detector.rb +67 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
- data/lib/claude_memory/resolve/resolver.rb +41 -11
- data/lib/claude_memory/store/llm_cache.rb +68 -0
- data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +47 -143
- data/lib/claude_memory/store/store_manager.rb +29 -0
- data/lib/claude_memory/sweep/maintenance.rb +216 -0
- data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
- data/lib/claude_memory/sweep/sweeper.rb +2 -0
- data/lib/claude_memory/templates/hooks.example.json +5 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +24 -0
- metadata +51 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module ClaudeMemory
|
|
8
|
+
module Hook
|
|
9
|
+
# Mirrors Claude Code auto-memory (~/.claude/projects/<slug>/memory/*.md)
|
|
10
|
+
# into extraction candidates surfaced alongside the SessionStart distillation
|
|
11
|
+
# prompt. Diffs files against a per-project state file (mtime+md5) so only
|
|
12
|
+
# new or changed entries are emitted. Idempotent — unchanged files are
|
|
13
|
+
# skipped on re-run.
|
|
14
|
+
#
|
|
15
|
+
# The emission is a *hint* to Claude that auto-memory has content worth
|
|
16
|
+
# mirroring into claude_memory via `memory.store_extraction`. The mirror
|
|
17
|
+
# never writes facts itself; the normal extraction review flow still applies.
|
|
18
|
+
class AutoMemoryMirror
|
|
19
|
+
MAX_CANDIDATES = 5
|
|
20
|
+
MAX_TEXT_PER_ITEM = 1500
|
|
21
|
+
STATE_FILENAME = "auto_memory_mirror.json"
|
|
22
|
+
|
|
23
|
+
# Derive auto-memory directory from a project path using Claude Code's
|
|
24
|
+
# slug convention (path separators → hyphens). E.g.
|
|
25
|
+
# `/Users/me/src/app` → `~/.claude/projects/-Users-me-src-app/memory`.
|
|
26
|
+
def self.default_dir(project_path, claude_config_dir)
|
|
27
|
+
slug = project_path.to_s.tr("/", "-")
|
|
28
|
+
File.join(claude_config_dir, "projects", slug, "memory")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.default_state_file(project_path)
|
|
32
|
+
File.join(project_path, ".claude", STATE_FILENAME)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(auto_memory_dir:, state_file:)
|
|
36
|
+
@auto_memory_dir = auto_memory_dir
|
|
37
|
+
@state_file = state_file
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Array<Hash>] candidate entries — each {name:, path:, content:, signature:}
|
|
41
|
+
def pending_candidates(limit: MAX_CANDIDATES)
|
|
42
|
+
return [] unless Dir.exist?(@auto_memory_dir)
|
|
43
|
+
|
|
44
|
+
state = load_state
|
|
45
|
+
files = Dir.glob(File.join(@auto_memory_dir, "*.md")).sort_by { |p| -File.mtime(p).to_i }
|
|
46
|
+
|
|
47
|
+
files.each_with_object([]) do |path, candidates|
|
|
48
|
+
break candidates if candidates.size >= limit
|
|
49
|
+
name = File.basename(path)
|
|
50
|
+
signature = file_signature(path)
|
|
51
|
+
prior = state[name]
|
|
52
|
+
next if prior.is_a?(Hash) && prior["md5"] == signature[:md5]
|
|
53
|
+
|
|
54
|
+
candidates << {
|
|
55
|
+
name: name,
|
|
56
|
+
path: path,
|
|
57
|
+
content: Core::TextBuilder.truncate(safe_read(path), MAX_TEXT_PER_ITEM),
|
|
58
|
+
signature: signature
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
rescue => e
|
|
62
|
+
ClaudeMemory.logger.warn("AutoMemoryMirror#pending_candidates failed: #{e.message}")
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Record the given candidates as the new baseline so they won't be
|
|
67
|
+
# re-emitted until their content changes. Call only after the candidates
|
|
68
|
+
# have actually been surfaced to the user.
|
|
69
|
+
def commit(candidates)
|
|
70
|
+
return if candidates.empty?
|
|
71
|
+
|
|
72
|
+
state = load_state
|
|
73
|
+
candidates.each do |c|
|
|
74
|
+
state[c[:name]] = {"md5" => c[:signature][:md5], "mtime" => c[:signature][:mtime]}
|
|
75
|
+
end
|
|
76
|
+
write_state(state)
|
|
77
|
+
rescue => e
|
|
78
|
+
ClaudeMemory.logger.warn("AutoMemoryMirror#commit failed: #{e.message}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def file_signature(path)
|
|
84
|
+
bytes = safe_read(path)
|
|
85
|
+
{
|
|
86
|
+
md5: Digest::MD5.hexdigest(bytes),
|
|
87
|
+
mtime: File.mtime(path).to_i
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def safe_read(path)
|
|
92
|
+
File.read(path)
|
|
93
|
+
rescue => e
|
|
94
|
+
ClaudeMemory.logger.debug("AutoMemoryMirror read failed for #{path}: #{e.message}")
|
|
95
|
+
""
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load_state
|
|
99
|
+
return {} unless File.exist?(@state_file)
|
|
100
|
+
parsed = JSON.parse(File.read(@state_file))
|
|
101
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
{}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_state(state)
|
|
107
|
+
FileUtils.mkdir_p(File.dirname(@state_file))
|
|
108
|
+
File.write(@state_file, JSON.pretty_generate(state))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -11,6 +11,7 @@ module ClaudeMemory
|
|
|
11
11
|
MAX_ARCHITECTURE = 5
|
|
12
12
|
MAX_UNDISTILLED = 3
|
|
13
13
|
MAX_TEXT_PER_ITEM = 1500
|
|
14
|
+
MAX_MIRROR_CANDIDATES = 5
|
|
14
15
|
|
|
15
16
|
FRESH_SESSION_SOURCES = %w[startup resume clear].freeze
|
|
16
17
|
|
|
@@ -20,13 +21,31 @@ module ClaudeMemory
|
|
|
20
21
|
architecture: {query: "uses framework implements architecture pattern", scope: "all"}
|
|
21
22
|
}.freeze
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
# Fact IDs and subjects that `generate_context` injected on the most recent
|
|
25
|
+
# call. Both are empty until `generate_context` has been invoked. Populated
|
|
26
|
+
# in call order (decisions → conventions → architecture) so benchmark
|
|
27
|
+
# harnesses can attribute sections if they care.
|
|
28
|
+
#
|
|
29
|
+
# emitted_facts_by_scope groups the IDs by the DB they came from
|
|
30
|
+
# ({"project" => [...], "global" => [...]}) so telemetry can resolve
|
|
31
|
+
# each fact from the correct store. Fact IDs autoincrement per-DB,
|
|
32
|
+
# so a bare ID without scope is ambiguous.
|
|
33
|
+
attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope
|
|
34
|
+
|
|
35
|
+
def initialize(manager, source: nil, auto_memory_mirror: nil)
|
|
24
36
|
@manager = manager
|
|
25
37
|
@source = source
|
|
26
38
|
@recall = Recall.new(manager)
|
|
39
|
+
@auto_memory_mirror = auto_memory_mirror
|
|
40
|
+
@emitted_fact_ids = []
|
|
41
|
+
@emitted_subjects = []
|
|
42
|
+
@emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
|
|
27
43
|
end
|
|
28
44
|
|
|
29
45
|
def generate_context
|
|
46
|
+
@emitted_fact_ids = []
|
|
47
|
+
@emitted_subjects = []
|
|
48
|
+
@emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
|
|
30
49
|
sections = []
|
|
31
50
|
|
|
32
51
|
decisions = fetch(:decisions, MAX_DECISIONS)
|
|
@@ -41,6 +60,12 @@ module ClaudeMemory
|
|
|
41
60
|
if fresh_session?
|
|
42
61
|
undistilled = fetch_undistilled(MAX_UNDISTILLED)
|
|
43
62
|
sections << format_distillation_prompt(undistilled) if undistilled.any?
|
|
63
|
+
|
|
64
|
+
mirror_candidates = fetch_mirror_candidates(MAX_MIRROR_CANDIDATES)
|
|
65
|
+
if mirror_candidates.any?
|
|
66
|
+
sections << format_auto_memory_mirror(mirror_candidates)
|
|
67
|
+
auto_memory_mirror.commit(mirror_candidates)
|
|
68
|
+
end
|
|
44
69
|
end
|
|
45
70
|
|
|
46
71
|
return nil if sections.empty?
|
|
@@ -57,7 +82,20 @@ module ClaudeMemory
|
|
|
57
82
|
def fetch(category, limit)
|
|
58
83
|
config = QUERIES.fetch(category)
|
|
59
84
|
results = @recall.query(config[:query], limit: limit, scope: config[:scope])
|
|
60
|
-
results.
|
|
85
|
+
results.filter_map do |r|
|
|
86
|
+
fact = r[:fact]
|
|
87
|
+
next unless fact
|
|
88
|
+
formatted = format_fact(fact)
|
|
89
|
+
next unless formatted
|
|
90
|
+
if fact[:id]
|
|
91
|
+
@emitted_fact_ids << fact[:id]
|
|
92
|
+
scope_key = (r[:source] || fact[:scope] || "project").to_s
|
|
93
|
+
@emitted_facts_by_scope[scope_key] << fact[:id]
|
|
94
|
+
end
|
|
95
|
+
subject = fact[:subject_name] || fact[:subject_entity_id]
|
|
96
|
+
@emitted_subjects << subject.to_s if subject
|
|
97
|
+
formatted
|
|
98
|
+
end
|
|
61
99
|
rescue => e
|
|
62
100
|
ClaudeMemory.logger.debug("ContextInjector#fetch(#{category}) failed: #{e.message}")
|
|
63
101
|
[]
|
|
@@ -104,7 +142,13 @@ module ClaudeMemory
|
|
|
104
142
|
"followed by `memory.mark_distilled` for each item.",
|
|
105
143
|
"",
|
|
106
144
|
"**What to extract:** technology decisions, conventions, preferences, architecture",
|
|
107
|
-
"**What to skip:** debugging steps, code output, transient errors"
|
|
145
|
+
"**What to skip:** debugging steps, code output, transient errors",
|
|
146
|
+
"",
|
|
147
|
+
"**Reasoning requirement:** decisions and conventions MUST embed a reason",
|
|
148
|
+
"in the object (e.g., \"… because …\", \"… so that …\", \"caused by …\",",
|
|
149
|
+
"\"breaks when …\"). A fact with a reason is recoverable once stale; a",
|
|
150
|
+
"bare conclusion is dead weight. Prefer one fact-with-reason over two",
|
|
151
|
+
"facts-without."
|
|
108
152
|
]
|
|
109
153
|
|
|
110
154
|
items.each do |item|
|
|
@@ -126,6 +170,56 @@ module ClaudeMemory
|
|
|
126
170
|
items.each { |item| lines << "- #{item}" }
|
|
127
171
|
lines.join("\n")
|
|
128
172
|
end
|
|
173
|
+
|
|
174
|
+
def fetch_mirror_candidates(limit)
|
|
175
|
+
mirror = auto_memory_mirror
|
|
176
|
+
return [] unless mirror
|
|
177
|
+
mirror.pending_candidates(limit: limit)
|
|
178
|
+
rescue => e
|
|
179
|
+
ClaudeMemory.logger.warn("ContextInjector#fetch_mirror_candidates failed: #{e.message}")
|
|
180
|
+
[]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def auto_memory_mirror
|
|
184
|
+
@auto_memory_mirror ||= build_default_mirror
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_default_mirror
|
|
188
|
+
project_path = @manager.respond_to?(:project_path) ? @manager.project_path : nil
|
|
189
|
+
return nil unless project_path
|
|
190
|
+
|
|
191
|
+
config = Configuration.new
|
|
192
|
+
AutoMemoryMirror.new(
|
|
193
|
+
auto_memory_dir: AutoMemoryMirror.default_dir(project_path, config.claude_config_dir),
|
|
194
|
+
state_file: AutoMemoryMirror.default_state_file(project_path)
|
|
195
|
+
)
|
|
196
|
+
rescue => e
|
|
197
|
+
ClaudeMemory.logger.debug("ContextInjector#build_default_mirror failed: #{e.message}")
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def format_auto_memory_mirror(candidates)
|
|
202
|
+
lines = [
|
|
203
|
+
"## Auto-Memory Mirror Candidates",
|
|
204
|
+
"",
|
|
205
|
+
"The following auto-memory entries (from `~/.claude/projects/<slug>/memory/`)",
|
|
206
|
+
"are new or changed since the last mirror. Consider extracting them into",
|
|
207
|
+
"claude_memory via `memory.store_extraction` so future sessions can recall",
|
|
208
|
+
"them via `memory.conventions` / `memory.recall_semantic`.",
|
|
209
|
+
"",
|
|
210
|
+
"**Review discipline applies:** only extract high-signal entries (gotchas,",
|
|
211
|
+
"feedback, references). Skip transient project state. Preserve the `**Why:**`",
|
|
212
|
+
"and `**How to apply:**` reasoning when present."
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
candidates.each do |candidate|
|
|
216
|
+
lines << ""
|
|
217
|
+
lines << "### #{candidate[:name]}"
|
|
218
|
+
lines << candidate[:content]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
lines.join("\n")
|
|
222
|
+
end
|
|
129
223
|
end
|
|
130
224
|
end
|
|
131
225
|
end
|
|
@@ -22,6 +22,8 @@ module ClaudeMemory
|
|
|
22
22
|
raise PayloadError, "Missing required field: session_id" if session_id.nil? || session_id.empty?
|
|
23
23
|
raise PayloadError, "Missing required field: transcript_path" if transcript_path.nil? || transcript_path.empty?
|
|
24
24
|
|
|
25
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
|
|
25
27
|
ingester = Ingest::Ingester.new(@store, env: @env)
|
|
26
28
|
result = ingester.ingest(
|
|
27
29
|
source: "claude_code",
|
|
@@ -36,31 +38,117 @@ module ClaudeMemory
|
|
|
36
38
|
)
|
|
37
39
|
end
|
|
38
40
|
|
|
41
|
+
log_activity("hook_ingest",
|
|
42
|
+
status: (result[:status] == :ingested) ? "success" : "skipped",
|
|
43
|
+
session_id: session_id, t0: t0,
|
|
44
|
+
details: {bytes_read: result[:bytes_read], content_id: result[:content_id],
|
|
45
|
+
reason: result[:reason]}.compact)
|
|
46
|
+
|
|
39
47
|
result
|
|
40
48
|
rescue Ingest::TranscriptReader::FileNotFoundError => e
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
log_activity("hook_ingest", status: "skipped", session_id: session_id, t0: t0,
|
|
50
|
+
details: {reason: "transcript_not_found"})
|
|
43
51
|
{status: :skipped, reason: "transcript_not_found", message: e.message}
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
def sweep(payload)
|
|
55
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
|
|
47
57
|
budget = payload.fetch("budget", DEFAULT_SWEEP_BUDGET).to_i
|
|
48
58
|
sweeper = Sweep::Sweeper.new(@store)
|
|
49
59
|
stats = sweeper.run!(budget_seconds: budget)
|
|
60
|
+
stats[:recall_timestamps_refreshed] = refresh_recall_timestamps
|
|
61
|
+
|
|
62
|
+
log_activity("hook_sweep", status: "success", t0: t0,
|
|
63
|
+
details: {elapsed_seconds: stats[:elapsed_seconds],
|
|
64
|
+
budget_honored: stats[:budget_honored]})
|
|
50
65
|
|
|
51
66
|
{stats: stats}
|
|
52
67
|
end
|
|
53
68
|
|
|
69
|
+
# Sweep-derived staleness data. Skips silently if the sweep was given a
|
|
70
|
+
# store-only handler (no manager); the cross-DB refresher requires the
|
|
71
|
+
# manager because project events can touch global facts and vice versa.
|
|
72
|
+
def refresh_recall_timestamps
|
|
73
|
+
return {project: 0, global: 0} unless @manager
|
|
74
|
+
Sweep::RecallTimestampRefresher.new(@manager).refresh!
|
|
75
|
+
rescue Sequel::DatabaseError => e
|
|
76
|
+
ClaudeMemory.logger.debug("recall timestamp refresh failed: #{e.message}")
|
|
77
|
+
{project: 0, global: 0, error: e.message}
|
|
78
|
+
end
|
|
79
|
+
|
|
54
80
|
def publish(payload)
|
|
81
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
|
|
55
83
|
mode = payload.fetch("mode", "shared").to_sym
|
|
56
84
|
since = payload["since"]
|
|
57
85
|
rules_dir = payload["rules_dir"]
|
|
58
86
|
|
|
59
87
|
publisher = Publish.new(@store)
|
|
60
|
-
publisher.publish!(mode: mode, since: since, rules_dir: rules_dir)
|
|
88
|
+
result = publisher.publish!(mode: mode, since: since, rules_dir: rules_dir)
|
|
89
|
+
|
|
90
|
+
log_activity("hook_publish", status: "success", t0: t0,
|
|
91
|
+
details: {mode: mode.to_s, publish_status: result[:status].to_s})
|
|
92
|
+
|
|
93
|
+
result
|
|
94
|
+
end
|
|
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
|
+
}
|
|
61
147
|
end
|
|
62
148
|
|
|
63
149
|
def context(payload)
|
|
150
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
151
|
+
|
|
64
152
|
manager = @manager || build_manager(payload)
|
|
65
153
|
manager.ensure_both!
|
|
66
154
|
|
|
@@ -68,17 +156,117 @@ module ClaudeMemory
|
|
|
68
156
|
injector = ContextInjector.new(manager, source: source)
|
|
69
157
|
context_text = injector.generate_context
|
|
70
158
|
|
|
159
|
+
log_activity("hook_context",
|
|
160
|
+
status: context_text ? "success" : "skipped", t0: t0,
|
|
161
|
+
details: {
|
|
162
|
+
context_length: context_text&.length,
|
|
163
|
+
context_tokens: Core::TokenEstimator.estimate(context_text),
|
|
164
|
+
source: source
|
|
165
|
+
})
|
|
166
|
+
|
|
71
167
|
{status: :ok, context: context_text}
|
|
72
168
|
rescue => e
|
|
169
|
+
log_activity("hook_context", status: "error", t0: t0,
|
|
170
|
+
details: {error: e.message})
|
|
73
171
|
{status: :error, context: nil, message: e.message}
|
|
74
172
|
end
|
|
75
173
|
|
|
76
174
|
private
|
|
77
175
|
|
|
176
|
+
def log_activity(event_type, status:, session_id: nil, t0: nil, details: nil)
|
|
177
|
+
duration_ms = t0 ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round : nil
|
|
178
|
+
ActivityLog.record(@store, event_type: event_type, status: status,
|
|
179
|
+
session_id: session_id, duration_ms: duration_ms, details: details)
|
|
180
|
+
end
|
|
181
|
+
|
|
78
182
|
def build_manager(payload)
|
|
79
183
|
project_path = payload["project_path"] || @config.project_dir
|
|
80
184
|
Store::StoreManager.new(project_path: project_path, env: @env)
|
|
81
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
|
|
82
270
|
end
|
|
83
271
|
end
|
|
84
272
|
end
|
|
@@ -29,6 +29,13 @@ module ClaudeMemory
|
|
|
29
29
|
signals: []
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
+
# Guard against the LLM distiller labeling descriptions of external
|
|
33
|
+
# projects (LOC counts, star counts, "X is a plugin by …") as
|
|
34
|
+
# `convention`. Retag those as `reference` before resolution so
|
|
35
|
+
# they don't pollute the Knowledge-base conventions list or get
|
|
36
|
+
# returned by `memory.conventions`.
|
|
37
|
+
extraction = Distill::ReferenceMaterialDetector.new.reclassify(extraction)
|
|
38
|
+
|
|
32
39
|
resolver = Resolve::Resolver.new(store)
|
|
33
40
|
result = resolver.apply(
|
|
34
41
|
extraction,
|
|
@@ -41,6 +48,7 @@ module ClaudeMemory
|
|
|
41
48
|
{
|
|
42
49
|
success: true,
|
|
43
50
|
scope: scope,
|
|
51
|
+
content_item_id: content_item_id,
|
|
44
52
|
entities_created: result[:entities_created],
|
|
45
53
|
facts_created: result[:facts_created],
|
|
46
54
|
facts_superseded: result[:facts_superseded],
|
|
@@ -11,6 +11,17 @@ module ClaudeMemory
|
|
|
11
11
|
PROMPT_TEXT = <<~GUIDE
|
|
12
12
|
# ClaudeMemory Search Strategy Guide
|
|
13
13
|
|
|
14
|
+
## Mental Model — V = R/C
|
|
15
|
+
|
|
16
|
+
Memory's value per interaction is governed by one relation:
|
|
17
|
+
|
|
18
|
+
- **R** — judgment retained from prior interactions (decisions, corrections, reasons, why-lines)
|
|
19
|
+
- **C** — context that must be rebuilt from scratch each session
|
|
20
|
+
|
|
21
|
+
**Goal**: raise R, lower C. Recall relevant facts (↑R) using the cheapest tool that answers the question (↓C). Repeat-corrections and re-derivations are the clearest failure signals — both mean R didn't propagate.
|
|
22
|
+
|
|
23
|
+
Every tool below is an instrument for one of those two levers. Pick accordingly.
|
|
24
|
+
|
|
14
25
|
## Tool Escalation — Cheap to Expensive
|
|
15
26
|
|
|
16
27
|
Start with fast, cheap tools. Escalate only when you need more detail.
|
|
@@ -31,6 +31,8 @@ module ClaudeMemory
|
|
|
31
31
|
when "memory.undistilled" then summarize_undistilled(result)
|
|
32
32
|
when "memory.mark_distilled" then summarize_mark_distilled(result)
|
|
33
33
|
when "memory.check_setup" then summarize_check_setup(result)
|
|
34
|
+
when "memory.activity" then summarize_activity(result)
|
|
35
|
+
when "memory.list_projects" then summarize_list_projects(result)
|
|
34
36
|
else JSON.generate(result)
|
|
35
37
|
end
|
|
36
38
|
end
|
|
@@ -275,6 +277,33 @@ module ClaudeMemory
|
|
|
275
277
|
lines.join("\n")
|
|
276
278
|
end
|
|
277
279
|
|
|
280
|
+
def self.summarize_activity(result)
|
|
281
|
+
events = result[:events] || []
|
|
282
|
+
return "No activity events recorded." if events.empty?
|
|
283
|
+
|
|
284
|
+
summary = result[:summary] || {}
|
|
285
|
+
lines = ["#{result[:event_count]} event(s):"]
|
|
286
|
+
summary.each do |type, counts|
|
|
287
|
+
parts = counts.map { |status, count| "#{count} #{status}" }
|
|
288
|
+
lines << " #{type}: #{parts.join(", ")}"
|
|
289
|
+
end
|
|
290
|
+
lines.join("\n")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def self.summarize_list_projects(result)
|
|
294
|
+
lines = ["Projects:"]
|
|
295
|
+
if result[:global]
|
|
296
|
+
lines << "- Global: #{result[:global][:facts_active] || 0} active facts"
|
|
297
|
+
end
|
|
298
|
+
if result[:current_project]
|
|
299
|
+
lines << "- Current: #{result[:current_project][:facts_active] || 0} active facts"
|
|
300
|
+
end
|
|
301
|
+
(result[:other_projects] || []).each do |p|
|
|
302
|
+
lines << "- #{p[:path]}: #{p[:facts_active] || 0} active facts"
|
|
303
|
+
end
|
|
304
|
+
lines.join("\n")
|
|
305
|
+
end
|
|
306
|
+
|
|
278
307
|
# Format fact identifier: prefer docid if available, fall back to integer id
|
|
279
308
|
def self.fact_label(fact)
|
|
280
309
|
fact[:docid] || fact[:id]
|
|
@@ -438,6 +438,19 @@ module ClaudeMemory
|
|
|
438
438
|
properties: {}
|
|
439
439
|
},
|
|
440
440
|
annotations: READ_ONLY
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "memory.activity",
|
|
444
|
+
description: "View recent activity events (hook executions, recalls, context injections). Shows what happened behind the scenes for debugging and observability.",
|
|
445
|
+
inputSchema: {
|
|
446
|
+
type: "object",
|
|
447
|
+
properties: {
|
|
448
|
+
limit: {type: "integer", default: 50, description: "Maximum events to return"},
|
|
449
|
+
event_type: {type: "string", enum: %w[hook_ingest hook_context hook_sweep hook_publish recall store_extraction], description: "Filter by event type"},
|
|
450
|
+
since: {type: "string", description: "ISO 8601 timestamp lower bound"}
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
annotations: READ_ONLY
|
|
441
454
|
}
|
|
442
455
|
]
|
|
443
456
|
end
|