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
|
@@ -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,64 @@ 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
|
|
61
94
|
end
|
|
62
95
|
|
|
63
96
|
def context(payload)
|
|
97
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
|
|
64
99
|
manager = @manager || build_manager(payload)
|
|
65
100
|
manager.ensure_both!
|
|
66
101
|
|
|
@@ -68,13 +103,25 @@ module ClaudeMemory
|
|
|
68
103
|
injector = ContextInjector.new(manager, source: source)
|
|
69
104
|
context_text = injector.generate_context
|
|
70
105
|
|
|
106
|
+
log_activity("hook_context",
|
|
107
|
+
status: context_text ? "success" : "skipped", t0: t0,
|
|
108
|
+
details: {context_length: context_text&.length, source: source})
|
|
109
|
+
|
|
71
110
|
{status: :ok, context: context_text}
|
|
72
111
|
rescue => e
|
|
112
|
+
log_activity("hook_context", status: "error", t0: t0,
|
|
113
|
+
details: {error: e.message})
|
|
73
114
|
{status: :error, context: nil, message: e.message}
|
|
74
115
|
end
|
|
75
116
|
|
|
76
117
|
private
|
|
77
118
|
|
|
119
|
+
def log_activity(event_type, status:, session_id: nil, t0: nil, details: nil)
|
|
120
|
+
duration_ms = t0 ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round : nil
|
|
121
|
+
ActivityLog.record(@store, event_type: event_type, status: status,
|
|
122
|
+
session_id: session_id, duration_ms: duration_ms, details: details)
|
|
123
|
+
end
|
|
124
|
+
|
|
78
125
|
def build_manager(payload)
|
|
79
126
|
project_path = payload["project_path"] || @config.project_dir
|
|
80
127
|
Store::StoreManager.new(project_path: project_path, env: @env)
|
|
@@ -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.
|
|
@@ -59,8 +59,9 @@ module ClaudeMemory
|
|
|
59
59
|
rescue JSON::ParserError => e
|
|
60
60
|
send_error(-32700, "Parse error: #{e.message}", 0)
|
|
61
61
|
rescue => e
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
# Per JSON-RPC 2.0: never respond to notifications, even on error
|
|
63
|
+
request_id = request&.fetch("id", nil)
|
|
64
|
+
send_error(-32603, "Internal error: #{e.message}", request_id) unless request_id.nil?
|
|
64
65
|
end
|
|
65
66
|
end
|
|
66
67
|
|
|
@@ -69,6 +70,11 @@ module ClaudeMemory
|
|
|
69
70
|
id = request["id"]
|
|
70
71
|
method = request["method"]
|
|
71
72
|
|
|
73
|
+
# Per JSON-RPC 2.0: a request without an id is a notification and
|
|
74
|
+
# MUST NOT receive a response. MCP relies on this for
|
|
75
|
+
# `notifications/initialized` after the initialize handshake.
|
|
76
|
+
return nil if id.nil?
|
|
77
|
+
|
|
72
78
|
case method
|
|
73
79
|
when "initialize"
|
|
74
80
|
handle_initialize(id, request["params"])
|
|
@@ -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
|
|
@@ -44,11 +44,34 @@ module ClaudeMemory
|
|
|
44
44
|
ToolDefinitions.all
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Tools that represent recall/query usage - tracked for efficacy
|
|
48
|
+
RECALL_TOOLS = %w[
|
|
49
|
+
memory.recall memory.recall_index memory.recall_semantic
|
|
50
|
+
memory.search_concepts memory.decisions memory.conventions memory.architecture
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# Write tools worth tracking
|
|
54
|
+
WRITE_TOOLS = %w[memory.store_extraction].freeze
|
|
55
|
+
|
|
56
|
+
TRACKED_TOOLS = (RECALL_TOOLS + WRITE_TOOLS).freeze
|
|
57
|
+
|
|
47
58
|
# Dispatch a tool call to the appropriate handler method.
|
|
48
59
|
# @param name [String] fully-qualified tool name (e.g. "memory.recall")
|
|
49
60
|
# @param arguments [Hash] tool arguments from the MCP request
|
|
50
61
|
# @return [Hash] structured result hash for the tool response
|
|
51
62
|
def call(name, arguments)
|
|
63
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
64
|
+
|
|
65
|
+
result = dispatch(name, arguments)
|
|
66
|
+
|
|
67
|
+
log_tool_activity(name, arguments, result, t0) if TRACKED_TOOLS.include?(name)
|
|
68
|
+
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def dispatch(name, arguments)
|
|
52
75
|
case name
|
|
53
76
|
when "memory.recall" then recall(arguments)
|
|
54
77
|
when "memory.recall_index" then recall_index(arguments)
|
|
@@ -74,6 +97,7 @@ module ClaudeMemory
|
|
|
74
97
|
when "memory.mark_distilled" then mark_distilled(arguments)
|
|
75
98
|
when "memory.check_setup" then check_setup
|
|
76
99
|
when "memory.list_projects" then list_projects
|
|
100
|
+
when "memory.activity" then activity(arguments)
|
|
77
101
|
else {error: "Unknown tool: #{name}"}
|
|
78
102
|
end
|
|
79
103
|
end
|
|
@@ -111,6 +135,130 @@ module ClaudeMemory
|
|
|
111
135
|
@legacy_store
|
|
112
136
|
end
|
|
113
137
|
end
|
|
138
|
+
|
|
139
|
+
def log_tool_activity(name, arguments, result, t0)
|
|
140
|
+
store = default_store
|
|
141
|
+
return unless store
|
|
142
|
+
|
|
143
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
144
|
+
event_type = WRITE_TOOLS.include?(name) ? "store_extraction" : "recall"
|
|
145
|
+
status = result[:error] ? "error" : "success"
|
|
146
|
+
session_id = extract_session_id(arguments)
|
|
147
|
+
|
|
148
|
+
details = {tool: name}
|
|
149
|
+
if event_type == "recall"
|
|
150
|
+
details[:query] = arguments["query"] || arguments["concepts"]&.join(", ")
|
|
151
|
+
details[:scope] = arguments["scope"]
|
|
152
|
+
details[:result_count] = extract_result_count(result)
|
|
153
|
+
# top_fact_ids is a flat list of the first 5 IDs; top_facts_by_scope
|
|
154
|
+
# groups the same IDs by source so dashboard readers can resolve
|
|
155
|
+
# each ID from the DB it actually came from. Fact IDs autoincrement
|
|
156
|
+
# per-DB, so a bare ID without scope is ambiguous.
|
|
157
|
+
scoped = extract_top_facts_scoped(result)
|
|
158
|
+
details[:top_fact_ids] = scoped.values.flatten.first(5)
|
|
159
|
+
details[:top_facts_by_scope] = scoped if scoped.any?
|
|
160
|
+
details[:results_by_scope] = extract_scope_breakdown(result)
|
|
161
|
+
else
|
|
162
|
+
details[:facts_created] = result[:facts_created]
|
|
163
|
+
details[:entities_created] = result[:entities_created]
|
|
164
|
+
details[:content_item_id] = result[:content_item_id]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
ActivityLog.record(store, event_type: event_type, status: status,
|
|
168
|
+
session_id: session_id, duration_ms: duration_ms, details: details.compact)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Probe a recall result for a count of returned items. Falls back to
|
|
172
|
+
# counting the first array-valued key among the shapes emitted by the
|
|
173
|
+
# various recall handlers (facts, results, items, concepts).
|
|
174
|
+
def extract_result_count(result)
|
|
175
|
+
return 0 unless result.is_a?(Hash)
|
|
176
|
+
[:fact_count, :count, :results_count].each do |key|
|
|
177
|
+
val = result[key]
|
|
178
|
+
return val if val.is_a?(Integer)
|
|
179
|
+
end
|
|
180
|
+
[:facts, :results, :items, :concepts, :conflicts].each do |key|
|
|
181
|
+
val = result[key]
|
|
182
|
+
return val.size if val.is_a?(Array)
|
|
183
|
+
end
|
|
184
|
+
0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Capture up to 5 fact ids from a recall result, grouped by source scope.
|
|
188
|
+
# Fact IDs autoincrement per-DB, so without scope a bare ID is ambiguous
|
|
189
|
+
# (project fact #1 and global fact #1 are different facts). Recall rows
|
|
190
|
+
# carry either a :source or :scope field identifying which DB the fact
|
|
191
|
+
# came from; we use that to group.
|
|
192
|
+
#
|
|
193
|
+
# @return [Hash{String => Array<Integer>}] e.g. {"project" => [5, 8], "global" => [1]}
|
|
194
|
+
def extract_top_facts_scoped(result, limit: 5)
|
|
195
|
+
return {} unless result.is_a?(Hash)
|
|
196
|
+
collection = [:facts, :results, :items].map { |k| result[k] }.find { |v| v.is_a?(Array) }
|
|
197
|
+
return {} unless collection
|
|
198
|
+
|
|
199
|
+
grouped = Hash.new { |h, k| h[k] = [] }
|
|
200
|
+
collection.first(limit).each do |row|
|
|
201
|
+
next unless row.is_a?(Hash)
|
|
202
|
+
fact = row[:fact] || row["fact"] || row
|
|
203
|
+
id = fact.is_a?(Hash) ? (fact[:id] || fact["id"]) : nil
|
|
204
|
+
next unless id
|
|
205
|
+
scope = row[:source] || row["source"] || fact[:scope] || fact["scope"] || "project"
|
|
206
|
+
grouped[scope.to_s] << id
|
|
207
|
+
end
|
|
208
|
+
grouped
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def extract_session_id(arguments)
|
|
212
|
+
(arguments.is_a?(Hash) && arguments["session_id"]) || Configuration.new.session_id
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Count returned items grouped by their :scope field so the dashboard
|
|
216
|
+
# can show whether a recall's hits came from global preferences, project
|
|
217
|
+
# facts, or both. Returns nil when the result shape doesn't carry facts.
|
|
218
|
+
# @return [Hash{String => Integer}, nil]
|
|
219
|
+
def extract_scope_breakdown(result)
|
|
220
|
+
return nil unless result.is_a?(Hash)
|
|
221
|
+
collection = [:facts, :results, :items].map { |k| result[k] }.find { |v| v.is_a?(Array) }
|
|
222
|
+
return nil unless collection
|
|
223
|
+
|
|
224
|
+
breakdown = Hash.new(0)
|
|
225
|
+
collection.each { |row|
|
|
226
|
+
next unless row.is_a?(Hash)
|
|
227
|
+
scope = row[:scope] || row["scope"] || row[:source] || row["source"] || "unknown"
|
|
228
|
+
breakdown[scope.to_s] += 1
|
|
229
|
+
}
|
|
230
|
+
breakdown.empty? ? nil : breakdown
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Return whichever store is available for activity logging. Delegates
|
|
234
|
+
# to StoreManager#default_store which prefers the project store and
|
|
235
|
+
# falls back to global — preventing silent drops of activity events
|
|
236
|
+
# when the project DB hasn't been initialized yet.
|
|
237
|
+
def default_store
|
|
238
|
+
return @legacy_store unless @manager
|
|
239
|
+
@manager.default_store(prefer: :project)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def activity(args)
|
|
243
|
+
store = default_store
|
|
244
|
+
return {error: "No database available"} unless store
|
|
245
|
+
|
|
246
|
+
limit = args["limit"] || 50
|
|
247
|
+
event_type = args["event_type"]
|
|
248
|
+
since = args["since"]
|
|
249
|
+
|
|
250
|
+
events = ActivityLog.recent(store, limit: limit, event_type: event_type, since: since)
|
|
251
|
+
summary = ActivityLog.summary(store, since: since)
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
event_count: events.size,
|
|
255
|
+
summary: summary,
|
|
256
|
+
events: events.map { |e|
|
|
257
|
+
e[:occurred_ago] = Core::RelativeTime.format(e[:occurred_at])
|
|
258
|
+
e
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
end
|
|
114
262
|
end
|
|
115
263
|
end
|
|
116
264
|
end
|