claude_memory 0.12.0 → 0.13.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 +44 -48
- data/.claude/settings.local.json +2 -1
- data/.claude-plugin/marketplace.json +2 -2
- data/.claude-plugin/plugin.json +3 -5
- data/CHANGELOG.md +52 -0
- data/CLAUDE.md +13 -8
- data/README.md +46 -0
- data/db/migrations/019_add_observations.rb +43 -0
- data/db/migrations/020_add_observation_promotion.rb +33 -0
- data/docs/GETTING_STARTED.md +38 -0
- data/docs/api_stability.md +23 -7
- data/docs/architecture.md +18 -6
- data/docs/audit_runbook.md +67 -0
- data/docs/dashboard.md +28 -0
- data/docs/improvements.md +94 -1
- data/docs/influence/mastra-observational-memory.md +198 -0
- data/docs/influence/strands-agent-sops.md +163 -0
- data/docs/quality_review.md +45 -0
- data/docs/soak/audit_2026-06-03_agent-training-program.json +53 -0
- data/docs/soak/audit_2026-06-03_agentic.json +31 -0
- data/docs/soak/audit_2026-06-03_ai-software-architect.json +19 -0
- data/docs/soak/audit_2026-06-03_chaos_to_the_rescue.json +60 -0
- data/docs/soak/audit_2026-06-03_claude_memory.json +55 -0
- data/docs/soak/audit_2026-06-03_daily-vibe.json +59 -0
- data/docs/soak/audit_2026-06-03_minerva-sky.json +19 -0
- data/docs/soak/audit_2026-06-03_nowreading.dev.json +19 -0
- data/docs/soak/audit_2026-06-03_ups.dev.json +55 -0
- data/docs/soak/baseline_2026-06-03.md +145 -0
- data/lib/claude_memory/audit/checks.rb +149 -0
- data/lib/claude_memory/audit/runner.rb +4 -0
- data/lib/claude_memory/commands/census_command.rb +1 -1
- data/lib/claude_memory/commands/checks/embeddings_check.rb +97 -0
- data/lib/claude_memory/commands/doctor_command.rb +1 -0
- data/lib/claude_memory/commands/hook_command.rb +16 -3
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +3 -1
- data/lib/claude_memory/commands/install_skill_command.rb +4 -0
- data/lib/claude_memory/commands/observations_command.rb +367 -0
- data/lib/claude_memory/commands/registry.rb +2 -0
- data/lib/claude_memory/commands/setup_vectors_command.rb +182 -0
- data/lib/claude_memory/commands/skills/reflect.md +68 -0
- data/lib/claude_memory/commands/stats_command.rb +60 -1
- data/lib/claude_memory/dashboard/api.rb +4 -0
- data/lib/claude_memory/dashboard/index.html +154 -2
- data/lib/claude_memory/dashboard/observations.rb +115 -0
- data/lib/claude_memory/dashboard/server.rb +1 -0
- data/lib/claude_memory/distill/extraction.rb +6 -4
- data/lib/claude_memory/distill/null_distiller.rb +86 -3
- data/lib/claude_memory/distill/reference_material_detector.rb +4 -1
- data/lib/claude_memory/domain/observation.rb +118 -0
- data/lib/claude_memory/embeddings/generator.rb +1 -1
- data/lib/claude_memory/hook/context_injector.rb +100 -2
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +113 -2
- data/lib/claude_memory/mcp/handlers/query_handlers.rb +48 -1
- data/lib/claude_memory/mcp/instructions_builder.rb +1 -0
- data/lib/claude_memory/mcp/query_guide.rb +28 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +58 -0
- data/lib/claude_memory/mcp/tools.rb +3 -0
- data/lib/claude_memory/observe/observations_renderer.rb +49 -0
- data/lib/claude_memory/observe/reflector.rb +91 -0
- data/lib/claude_memory/publish.rb +53 -1
- data/lib/claude_memory/resolve/resolver.rb +45 -8
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +181 -0
- data/lib/claude_memory/sweep/maintenance.rb +15 -1
- data/lib/claude_memory/sweep/sweeper.rb +7 -1
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +7 -0
- metadata +23 -1
|
@@ -205,14 +205,26 @@ module ClaudeMemory
|
|
|
205
205
|
|
|
206
206
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
207
207
|
injector = ClaudeMemory::Hook::ContextInjector.new(manager, source: source)
|
|
208
|
-
|
|
208
|
+
# On PreCompact (context pressure) inject only the reflection nudge, not
|
|
209
|
+
# the full snapshot; everywhere else inject the full SessionStart context.
|
|
210
|
+
context_text = if payload["hook_event_name"] == "PreCompact"
|
|
211
|
+
injector.reflection_context
|
|
212
|
+
else
|
|
213
|
+
injector.generate_context
|
|
214
|
+
end
|
|
209
215
|
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
210
216
|
|
|
211
217
|
if context_text
|
|
212
218
|
response = {
|
|
213
219
|
hookSpecificOutput: {
|
|
214
220
|
hookEventName: "SessionStart",
|
|
215
|
-
|
|
221
|
+
# Wrap in <claude-memory-context> so a later ingest strips our own
|
|
222
|
+
# injected snapshot back out (ContentSanitizer lists this tag in
|
|
223
|
+
# SYSTEM_TAGS). Without the wrapper, memory's injected facts and
|
|
224
|
+
# observation log leak into the transcript and get re-distilled —
|
|
225
|
+
# a self-ingestion feedback loop. Claude still reads the content;
|
|
226
|
+
# only the re-ingestion path treats it as strippable.
|
|
227
|
+
additionalContext: "<claude-memory-context>\n#{context_text}\n</claude-memory-context>"
|
|
216
228
|
}
|
|
217
229
|
}
|
|
218
230
|
stdout.puts JSON.generate(response)
|
|
@@ -243,7 +255,8 @@ module ClaudeMemory
|
|
|
243
255
|
top_fact_ids: injector.emitted_fact_ids.first(10),
|
|
244
256
|
top_facts_by_scope: (by_scope if by_scope.any?),
|
|
245
257
|
top_subjects: injector.emitted_subjects.uniq.first(10),
|
|
246
|
-
fact_count: injector.emitted_fact_ids.size
|
|
258
|
+
fact_count: injector.emitted_fact_ids.size,
|
|
259
|
+
observation_count: injector.emitted_observation_count
|
|
247
260
|
}.compact
|
|
248
261
|
|
|
249
262
|
ClaudeMemory::ActivityLog.record(store,
|
|
@@ -126,7 +126,9 @@ module ClaudeMemory
|
|
|
126
126
|
{"type" => "command", "command" => ingest_cmd, "timeout" => 30,
|
|
127
127
|
"statusMessage" => "Saving memory..."},
|
|
128
128
|
{"type" => "command", "command" => sweep_cmd, "timeout" => 30,
|
|
129
|
-
"statusMessage" => "Sweeping memory..."}
|
|
129
|
+
"statusMessage" => "Sweeping memory..."},
|
|
130
|
+
{"type" => "command", "command" => context_cmd, "timeout" => 5,
|
|
131
|
+
"statusMessage" => "Reflecting on memory..."}
|
|
130
132
|
]
|
|
131
133
|
}],
|
|
132
134
|
"SessionEnd" => [{
|
|
@@ -15,6 +15,10 @@ module ClaudeMemory
|
|
|
15
15
|
"distill-transcripts" => {
|
|
16
16
|
file: "distill-transcripts.md",
|
|
17
17
|
description: "Distill transcripts — extract facts/entities/decisions from undistilled content"
|
|
18
|
+
},
|
|
19
|
+
"reflect" => {
|
|
20
|
+
file: "reflect.md",
|
|
21
|
+
description: "Reflect on observations — consolidate the episodic log and promote corroborated observations to facts"
|
|
18
22
|
}
|
|
19
23
|
}.freeze
|
|
20
24
|
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ClaudeMemory
|
|
7
|
+
module Commands
|
|
8
|
+
# CLI parity for the episodic observation layer — the "what happened" log
|
|
9
|
+
# that complements the semantic fact store ("what is true").
|
|
10
|
+
#
|
|
11
|
+
# Subcommands:
|
|
12
|
+
# observations [list] Summary: counts by status/kind/priority,
|
|
13
|
+
# corroboration + promotion readiness, compression
|
|
14
|
+
# ratio, and a recent timeline.
|
|
15
|
+
# observations promote <id> --predicate P --object O [--subject S] [--scope ...]
|
|
16
|
+
# observations consolidate <id1,id2,...> --body "<synthesis>" [--scope ...]
|
|
17
|
+
#
|
|
18
|
+
# The promote subcommand reuses the same corroboration gate and Resolver
|
|
19
|
+
# path as the memory.promote_observation MCP tool, so the anti-hallucination
|
|
20
|
+
# threshold is enforced identically across surfaces.
|
|
21
|
+
class ObservationsCommand < BaseCommand
|
|
22
|
+
def call(args)
|
|
23
|
+
subcommand = args.first
|
|
24
|
+
case subcommand
|
|
25
|
+
when "promote"
|
|
26
|
+
promote(args.drop(1))
|
|
27
|
+
when "consolidate"
|
|
28
|
+
consolidate(args.drop(1))
|
|
29
|
+
when "list", nil
|
|
30
|
+
list(args.drop(subcommand ? 1 : 0))
|
|
31
|
+
else
|
|
32
|
+
# Treat unknown first token as options to `list` (e.g. --json).
|
|
33
|
+
list(args)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# --- list (default) -----------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def list(args)
|
|
42
|
+
opts = parse_options(args, {limit: 20, kind: nil, status: "active", scope: nil, json: false}) do |o|
|
|
43
|
+
OptionParser.new do |parser|
|
|
44
|
+
parser.banner = "Usage: claude-memory observations [list] [options]"
|
|
45
|
+
parser.on("--limit N", Integer, "Max rows in the recent timeline (default: 20)") { |v| o[:limit] = v }
|
|
46
|
+
parser.on("--kind K", "Filter timeline by kind (e.g. decision, preference, event)") { |v| o[:kind] = v }
|
|
47
|
+
parser.on("--status S", "Filter timeline by status (active, consolidated, expired)") { |v| o[:status] = v }
|
|
48
|
+
parser.on("--scope SCOPE", %w[project global], "Limit to a single scope") { |v| o[:scope] = v }
|
|
49
|
+
parser.on("--json", "Emit machine-readable JSON") { o[:json] = true }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
return 1 if opts.nil?
|
|
53
|
+
|
|
54
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
55
|
+
stores = observation_stores(manager, opts[:scope])
|
|
56
|
+
|
|
57
|
+
report = build_report(stores, opts)
|
|
58
|
+
manager.close
|
|
59
|
+
|
|
60
|
+
if opts[:json]
|
|
61
|
+
stdout.puts(JSON.pretty_generate(report))
|
|
62
|
+
else
|
|
63
|
+
render_report(report)
|
|
64
|
+
end
|
|
65
|
+
0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_report(stores, opts)
|
|
69
|
+
{
|
|
70
|
+
totals: totals(stores),
|
|
71
|
+
by_kind: by_field(stores, :kind),
|
|
72
|
+
by_priority: by_field(stores, :priority),
|
|
73
|
+
corroboration: corroboration(stores),
|
|
74
|
+
compression: compression(stores),
|
|
75
|
+
recent: recent(stores, opts)
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_report(report)
|
|
80
|
+
stdout.puts "Observations (episodic 'what happened' log)"
|
|
81
|
+
stdout.puts "=" * 50
|
|
82
|
+
stdout.puts
|
|
83
|
+
|
|
84
|
+
render_totals(report[:totals])
|
|
85
|
+
stdout.puts
|
|
86
|
+
render_breakdown("By kind (active)", report[:by_kind])
|
|
87
|
+
stdout.puts
|
|
88
|
+
render_priority(report[:by_priority])
|
|
89
|
+
stdout.puts
|
|
90
|
+
render_corroboration(report[:corroboration])
|
|
91
|
+
stdout.puts
|
|
92
|
+
render_compression(report[:compression])
|
|
93
|
+
stdout.puts
|
|
94
|
+
render_recent(report[:recent])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render_totals(totals)
|
|
98
|
+
stdout.puts "Totals:"
|
|
99
|
+
stdout.puts " Active: #{totals[:active]}"
|
|
100
|
+
stdout.puts " Consolidated: #{totals[:consolidated]}"
|
|
101
|
+
stdout.puts " Expired: #{totals[:expired]}"
|
|
102
|
+
stdout.puts " Promoted: #{totals[:promoted]}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def render_breakdown(title, counts)
|
|
106
|
+
stdout.puts "#{title}:"
|
|
107
|
+
if counts.empty?
|
|
108
|
+
stdout.puts " (none)"
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
counts.sort_by { |_k, v| -v }.each do |key, count|
|
|
112
|
+
stdout.puts " #{count.to_s.rjust(4)} - #{key}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_priority(counts)
|
|
117
|
+
stdout.puts "By priority (active):"
|
|
118
|
+
if counts.empty?
|
|
119
|
+
stdout.puts " (none)"
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
counts.sort_by { |priority, _v| priority }.each do |priority, count|
|
|
123
|
+
stdout.puts " #{count.to_s.rjust(4)} - #{priority} (#{priority_label(priority)})"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def priority_label(priority)
|
|
128
|
+
case priority
|
|
129
|
+
when Domain::Observation::IMPORTANT then "important"
|
|
130
|
+
when Domain::Observation::MAYBE then "maybe"
|
|
131
|
+
else "info"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def render_corroboration(corroboration)
|
|
136
|
+
threshold = Domain::Observation::PROMOTION_THRESHOLD
|
|
137
|
+
stdout.puts "Corroboration:"
|
|
138
|
+
stdout.puts " Max sightings: #{corroboration[:max]}"
|
|
139
|
+
stdout.puts " Promotable (>= #{threshold} sightings, not yet promoted): #{corroboration[:promotable]}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def render_compression(compression)
|
|
143
|
+
ratio = compression[:ratio]
|
|
144
|
+
stdout.puts "Compression:"
|
|
145
|
+
stdout.puts " Observation tokens: #{compression[:observation_tokens]}"
|
|
146
|
+
stdout.puts " Source tokens: #{compression[:source_tokens]}"
|
|
147
|
+
if ratio
|
|
148
|
+
stdout.puts " Ratio (source / observation): #{ratio}x"
|
|
149
|
+
else
|
|
150
|
+
stdout.puts " Ratio (source / observation): n/a"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def render_recent(recent)
|
|
155
|
+
stdout.puts "Recent timeline:"
|
|
156
|
+
if recent.empty?
|
|
157
|
+
stdout.puts " (no observations)"
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
recent.each do |obs|
|
|
161
|
+
marker = priority_marker(obs[:priority])
|
|
162
|
+
stdout.puts " ##{obs[:id]} #{marker} [#{obs[:kind]}] x#{obs[:corroboration_count]} (#{obs[:observed_ago]})"
|
|
163
|
+
stdout.puts " #{obs[:body]}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def priority_marker(priority)
|
|
168
|
+
case priority
|
|
169
|
+
when Domain::Observation::IMPORTANT then "[!]"
|
|
170
|
+
when Domain::Observation::MAYBE then "[~]"
|
|
171
|
+
else "[ ]"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# --- read aggregation (mirrors Dashboard::Observations) -----------------
|
|
176
|
+
|
|
177
|
+
def observation_stores(manager, scope)
|
|
178
|
+
scopes = scope ? [scope] : %w[project global]
|
|
179
|
+
scopes.filter_map { |s| manager.store_if_exists(s) }
|
|
180
|
+
.select { |store| store.db.table_exists?(:observations) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def totals(stores)
|
|
184
|
+
{
|
|
185
|
+
active: count_where(stores, status: "active"),
|
|
186
|
+
consolidated: count_where(stores, status: "consolidated"),
|
|
187
|
+
expired: count_where(stores, status: "expired"),
|
|
188
|
+
promoted: stores.sum { |s| s.observations.exclude(promoted_at: nil).count }
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def count_where(stores, **filter)
|
|
193
|
+
stores.sum { |s| s.observations.where(**filter).count }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def by_field(stores, field)
|
|
197
|
+
merged = Hash.new(0)
|
|
198
|
+
stores.each do |store|
|
|
199
|
+
store.observations.where(status: "active").group_and_count(field).each do |row|
|
|
200
|
+
merged[row[field]] += row[:count]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
merged
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def corroboration(stores)
|
|
207
|
+
threshold = Domain::Observation::PROMOTION_THRESHOLD
|
|
208
|
+
{
|
|
209
|
+
max: stores.map { |s| s.observations.where(status: "active").max(:corroboration_count) || 0 }.max || 0,
|
|
210
|
+
promotable: stores.sum do |s|
|
|
211
|
+
s.observations.where(status: "active", promoted_at: nil)
|
|
212
|
+
.where { corroboration_count >= threshold }.count
|
|
213
|
+
end
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def compression(stores)
|
|
218
|
+
obs_tokens = stores.sum { |s| s.observations.where(status: "active").sum(:token_count) || 0 }
|
|
219
|
+
source_tokens = stores.sum { |s| source_tokens_for(s) }
|
|
220
|
+
ratio = obs_tokens.zero? ? nil : (source_tokens.to_f / obs_tokens).round(1)
|
|
221
|
+
{observation_tokens: obs_tokens, source_tokens: source_tokens, ratio: ratio}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def source_tokens_for(store)
|
|
225
|
+
ids = store.observations
|
|
226
|
+
.where(status: "active").exclude(source_content_item_id: nil)
|
|
227
|
+
.distinct.select(:source_content_item_id)
|
|
228
|
+
.map { |r| r[:source_content_item_id] }
|
|
229
|
+
return 0 if ids.empty?
|
|
230
|
+
|
|
231
|
+
bytes = store.content_items.where(id: ids).sum(:byte_len) || 0
|
|
232
|
+
(bytes / 4.0).round
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def recent(stores, opts)
|
|
236
|
+
rows = stores.flat_map do |store|
|
|
237
|
+
dataset = store.observations
|
|
238
|
+
dataset = dataset.where(status: opts[:status]) if opts[:status]
|
|
239
|
+
dataset = dataset.where(kind: opts[:kind]) if opts[:kind]
|
|
240
|
+
dataset.order(Sequel.desc(:observed_at), Sequel.desc(:id)).limit(opts[:limit]).all
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
rows.sort_by { |o| o[:observed_at].to_s }.reverse.first(opts[:limit]).map do |o|
|
|
244
|
+
{
|
|
245
|
+
id: o[:id], kind: o[:kind], priority: o[:priority],
|
|
246
|
+
corroboration_count: o[:corroboration_count], body: o[:body],
|
|
247
|
+
observed_ago: Core::RelativeTime.format(o[:observed_at])
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# --- promote ------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def promote(args)
|
|
255
|
+
opts = parse_options(args, {predicate: nil, object: nil, subject: "repo", scope: "project"}) do |o|
|
|
256
|
+
OptionParser.new do |parser|
|
|
257
|
+
parser.banner = "Usage: claude-memory observations promote <id> --predicate P --object O [options]"
|
|
258
|
+
parser.on("--predicate P", "Fact predicate (e.g. decision, convention)") { |v| o[:predicate] = v }
|
|
259
|
+
parser.on("--object O", "Fact object (the claim text)") { |v| o[:object] = v }
|
|
260
|
+
parser.on("--subject S", "Fact subject (default: repo)") { |v| o[:subject] = v }
|
|
261
|
+
parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
return 1 if opts.nil?
|
|
265
|
+
|
|
266
|
+
observation_id = parse_id(args.first)
|
|
267
|
+
return failure("Usage: claude-memory observations promote <id> --predicate P --object O") if observation_id.nil?
|
|
268
|
+
return failure("--predicate and --object are required") if opts[:predicate].nil? || opts[:object].to_s.strip.empty?
|
|
269
|
+
|
|
270
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
271
|
+
store = manager.store_for_scope(opts[:scope])
|
|
272
|
+
|
|
273
|
+
result = promote_observation(store, observation_id, opts)
|
|
274
|
+
manager.close
|
|
275
|
+
|
|
276
|
+
return failure(result[:error]) if result[:error]
|
|
277
|
+
|
|
278
|
+
stdout.puts "Promoted observation ##{observation_id} -> fact ##{result[:fact_id]}"
|
|
279
|
+
stdout.puts " #{result[:predicate]}: #{result[:object]}"
|
|
280
|
+
stdout.puts " Corroboration: #{result[:corroboration_count]} sighting(s)"
|
|
281
|
+
0
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Server-side corroboration gate + Resolver path — the same logic the
|
|
285
|
+
# memory.promote_observation MCP handler uses. Returns {error:} on refusal
|
|
286
|
+
# or {fact_id:, predicate:, object:, corroboration_count:} on success.
|
|
287
|
+
def promote_observation(store, observation_id, opts)
|
|
288
|
+
obs = store.observations.where(id: observation_id).first
|
|
289
|
+
return {error: "Observation #{observation_id} not found in #{opts[:scope]} database."} unless obs
|
|
290
|
+
return {error: "Observation #{observation_id} already promoted (fact ##{obs[:promoted_fact_id]})."} unless obs[:promoted_at].nil?
|
|
291
|
+
|
|
292
|
+
threshold = Domain::Observation::PROMOTION_THRESHOLD
|
|
293
|
+
if obs[:corroboration_count].to_i < threshold
|
|
294
|
+
return {error: "Not yet corroborated: observation #{observation_id} has #{obs[:corroboration_count]} sighting(s), need #{threshold} (anti-hallucination gate)."}
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
occurred_at = Time.now.utc.iso8601
|
|
298
|
+
project_path = (opts[:scope] == "global") ? nil : Configuration.new.project_dir
|
|
299
|
+
extraction = Distill::Extraction.new(
|
|
300
|
+
facts: [{subject: opts[:subject], predicate: opts[:predicate], object: opts[:object], strength: "derived"}]
|
|
301
|
+
)
|
|
302
|
+
result = Resolve::Resolver.new(store).apply(
|
|
303
|
+
extraction, content_item_id: obs[:source_content_item_id],
|
|
304
|
+
occurred_at: occurred_at, project_path: project_path, scope: opts[:scope]
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
fact_id = result[:fact_ids].compact.first
|
|
308
|
+
return {error: "Promotion failed: the fact for observation #{observation_id} could not be resolved."} unless fact_id
|
|
309
|
+
|
|
310
|
+
store.mark_observation_promoted(observation_id, fact_id: fact_id)
|
|
311
|
+
|
|
312
|
+
{
|
|
313
|
+
fact_id: fact_id,
|
|
314
|
+
predicate: Resolve::PredicatePolicy.canonicalize(opts[:predicate]),
|
|
315
|
+
object: opts[:object],
|
|
316
|
+
corroboration_count: obs[:corroboration_count]
|
|
317
|
+
}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# --- consolidate --------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def consolidate(args)
|
|
323
|
+
opts = parse_options(args, {body: nil, kind: "event", priority: Domain::Observation::INFO, scope: "project"}) do |o|
|
|
324
|
+
OptionParser.new do |parser|
|
|
325
|
+
parser.banner = "Usage: claude-memory observations consolidate <id1,id2,...> --body \"<synthesis>\" [options]"
|
|
326
|
+
parser.on("--body TEXT", "The synthesized observation text") { |v| o[:body] = v }
|
|
327
|
+
parser.on("--kind K", "Kind for the merged observation (default: event)") { |v| o[:kind] = v }
|
|
328
|
+
parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
return 1 if opts.nil?
|
|
332
|
+
|
|
333
|
+
from_ids = parse_id_list(args.first)
|
|
334
|
+
return failure("Usage: claude-memory observations consolidate <id1,id2,...> --body \"<synthesis>\"") if from_ids.size < 2
|
|
335
|
+
return failure("--body is required") if opts[:body].to_s.strip.empty?
|
|
336
|
+
|
|
337
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
338
|
+
store = manager.store_for_scope(opts[:scope])
|
|
339
|
+
project_path = (opts[:scope] == "global") ? nil : Configuration.new.project_dir
|
|
340
|
+
|
|
341
|
+
result = store.consolidate_observations(
|
|
342
|
+
from_ids, body: opts[:body].strip, kind: opts[:kind],
|
|
343
|
+
priority: opts[:priority], scope: opts[:scope], project_path: project_path
|
|
344
|
+
)
|
|
345
|
+
manager.close
|
|
346
|
+
|
|
347
|
+
return failure("Need at least 2 active #{opts[:scope]} observations from that set to consolidate.") if result.nil?
|
|
348
|
+
|
|
349
|
+
stdout.puts "Consolidated #{result[:merged]} observations -> ##{result[:id]}"
|
|
350
|
+
stdout.puts " Combined corroboration: #{result[:corroboration_count]} sighting(s)"
|
|
351
|
+
0
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# --- parsing helpers ----------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def parse_id(token)
|
|
357
|
+
return nil if token.nil? || !token.match?(/\A\d+\z/)
|
|
358
|
+
token.to_i
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def parse_id_list(token)
|
|
362
|
+
return [] if token.nil?
|
|
363
|
+
token.split(",").map(&:strip).select { |t| t.match?(/\A\d+\z/) }.map(&:to_i).uniq
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -37,6 +37,7 @@ module ClaudeMemory
|
|
|
37
37
|
"completion" => {class: CompletionCommand, description: "Generate shell completions"},
|
|
38
38
|
"embeddings" => {class: EmbeddingsCommand, description: "Inspect embedding backend"},
|
|
39
39
|
"reject" => {class: RejectCommand, description: "Mark a fact as rejected"},
|
|
40
|
+
"observations" => {class: ObservationsCommand, description: "Inspect, promote, or consolidate episodic observations"},
|
|
40
41
|
"restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"},
|
|
41
42
|
"dedupe-conflicts" => {class: DedupeConflictsCommand, description: "Deduplicate historical open conflict rows that describe the same pair"},
|
|
42
43
|
"reclassify-references" => {class: ReclassifyReferencesCommand, description: "Retag existing convention facts that match reference-material heuristics"},
|
|
@@ -45,6 +46,7 @@ module ClaudeMemory
|
|
|
45
46
|
"digest" => {class: DigestCommand, description: "Render a weekly markdown digest of memory activity"},
|
|
46
47
|
"show" => {class: ShowCommand, description: "Print what memory would inject at the next SessionStart"},
|
|
47
48
|
"otel" => {class: OtelCommand, description: "Configure or inspect OpenTelemetry ingestion from Claude Code"},
|
|
49
|
+
"setup-vectors" => {class: SetupVectorsCommand, description: "Opt into vector recall — write provider env to .claude/settings.json + re-index"},
|
|
48
50
|
"import-auto-memory" => {class: ImportAutoMemoryCommand, description: "Import Claude Code auto-memory .md files into project DB as facts"},
|
|
49
51
|
"audit" => {class: AuditCommand, description: "Run memory health audit; report inconsistencies and optimizations"}
|
|
50
52
|
}.freeze
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
|
|
7
|
+
module ClaudeMemory
|
|
8
|
+
module Commands
|
|
9
|
+
# Guides the user through opting into vector recall with fastembed
|
|
10
|
+
# (or another provider). fastembed stays a dev/test gem dependency by
|
|
11
|
+
# design; this command is the documented opt-in path for end users.
|
|
12
|
+
#
|
|
13
|
+
# Steps:
|
|
14
|
+
# 1. Verify the chosen provider is loadable. For fastembed, surface
|
|
15
|
+
# a clear install command if the gem isn't on $LOAD_PATH.
|
|
16
|
+
# 2. Persist CLAUDE_MEMORY_EMBEDDING_PROVIDER (and optional model)
|
|
17
|
+
# into the project's .claude/settings.json env block, the same
|
|
18
|
+
# mechanism Claude Code uses for OTel env (see OTel::SettingsWriter).
|
|
19
|
+
# 3. Re-embed existing facts under the new provider (unless --no-reindex).
|
|
20
|
+
# 4. Report the final state — provider, dimensions, stored alignment.
|
|
21
|
+
class SetupVectorsCommand < BaseCommand
|
|
22
|
+
OWNED_KEYS = %w[
|
|
23
|
+
CLAUDE_MEMORY_EMBEDDING_PROVIDER
|
|
24
|
+
CLAUDE_MEMORY_EMBEDDING_MODEL
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
FASTEMBED_INSTALL_HINT = <<~HINT
|
|
28
|
+
fastembed is not installed. claude-memory keeps fastembed as a
|
|
29
|
+
dev/test dependency so the default gem install stays light. To
|
|
30
|
+
enable it, install the gem and re-run setup-vectors:
|
|
31
|
+
|
|
32
|
+
gem install fastembed
|
|
33
|
+
claude-memory setup-vectors
|
|
34
|
+
|
|
35
|
+
Or if you bundle, add to your Gemfile:
|
|
36
|
+
|
|
37
|
+
gem "fastembed"
|
|
38
|
+
|
|
39
|
+
Then `bundle install` and re-run setup-vectors. The first run
|
|
40
|
+
downloads the BAAI/bge-small-en-v1.5 ONNX model (~75MB).
|
|
41
|
+
HINT
|
|
42
|
+
|
|
43
|
+
def call(args)
|
|
44
|
+
opts = parse_opts(args)
|
|
45
|
+
return 1 if opts.nil?
|
|
46
|
+
|
|
47
|
+
return print_status if opts[:status]
|
|
48
|
+
|
|
49
|
+
provider_name = opts[:provider]
|
|
50
|
+
unless verify_provider_loadable(provider_name)
|
|
51
|
+
return 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if opts[:dry_run]
|
|
55
|
+
stdout.puts "Would write to #{settings_path}:"
|
|
56
|
+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_PROVIDER=#{provider_name}"
|
|
57
|
+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_MODEL=#{opts[:model]}" if opts[:model]
|
|
58
|
+
stdout.puts(opts[:reindex] ? "Would re-index facts under the new provider" : "Would skip re-index (--no-reindex)")
|
|
59
|
+
return 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
write_settings(provider_name, opts[:model])
|
|
63
|
+
|
|
64
|
+
if opts[:reindex]
|
|
65
|
+
reindex_result = reindex(provider_name)
|
|
66
|
+
return 1 if reindex_result != 0
|
|
67
|
+
else
|
|
68
|
+
stdout.puts "Skipped re-index (--no-reindex). Run 'claude-memory index --force --provider=#{provider_name}' when ready."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
report_final_state(provider_name)
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def parse_opts(args)
|
|
78
|
+
options = {provider: "fastembed", model: nil, reindex: true, dry_run: false, status: false}
|
|
79
|
+
parser = OptionParser.new do |o|
|
|
80
|
+
o.banner = "Usage: claude-memory setup-vectors [--provider=fastembed|api|tfidf] [--model=NAME] [--no-reindex] [--dry-run] [--status]"
|
|
81
|
+
o.on("--provider NAME", "Embedding provider (default: fastembed)") { |v| options[:provider] = v }
|
|
82
|
+
o.on("--model NAME", "Optional model name (e.g. BAAI/bge-small-en-v1.5)") { |v| options[:model] = v }
|
|
83
|
+
o.on("--no-reindex", "Skip re-embedding existing facts under the new provider") { options[:reindex] = false }
|
|
84
|
+
o.on("--dry-run", "Print what would change without writing or re-indexing") { options[:dry_run] = true }
|
|
85
|
+
o.on("--status", "Show the current provider config + stored alignment, then exit") { options[:status] = true }
|
|
86
|
+
end
|
|
87
|
+
parser.parse!(args.dup)
|
|
88
|
+
options
|
|
89
|
+
rescue OptionParser::InvalidOption => e
|
|
90
|
+
stderr.puts e.message
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verify_provider_loadable(provider_name)
|
|
95
|
+
case provider_name
|
|
96
|
+
when "tfidf"
|
|
97
|
+
true # always available
|
|
98
|
+
when "fastembed"
|
|
99
|
+
require "fastembed"
|
|
100
|
+
true
|
|
101
|
+
when "api"
|
|
102
|
+
# api provider needs network + key but no gem; defer to runtime
|
|
103
|
+
true
|
|
104
|
+
else
|
|
105
|
+
stderr.puts "Unknown provider: #{provider_name}. Valid: tfidf, fastembed, api."
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
rescue LoadError
|
|
109
|
+
stderr.puts FASTEMBED_INSTALL_HINT
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def settings_path
|
|
114
|
+
File.join(claude_dir, "settings.json")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def claude_dir
|
|
118
|
+
File.join(Configuration.new.project_dir, ".claude")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def write_settings(provider_name, model)
|
|
122
|
+
FileUtils.mkdir_p(claude_dir)
|
|
123
|
+
settings = load_settings
|
|
124
|
+
settings["env"] ||= {}
|
|
125
|
+
settings["env"]["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] = provider_name
|
|
126
|
+
if model
|
|
127
|
+
settings["env"]["CLAUDE_MEMORY_EMBEDDING_MODEL"] = model
|
|
128
|
+
else
|
|
129
|
+
settings["env"].delete("CLAUDE_MEMORY_EMBEDDING_MODEL")
|
|
130
|
+
end
|
|
131
|
+
File.write(settings_path, JSON.pretty_generate(settings) + "\n")
|
|
132
|
+
stdout.puts "✓ Wrote CLAUDE_MEMORY_EMBEDDING_PROVIDER=#{provider_name} to #{settings_path}"
|
|
133
|
+
stdout.puts "✓ Wrote CLAUDE_MEMORY_EMBEDDING_MODEL=#{model}" if model
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def load_settings
|
|
137
|
+
return {} unless File.exist?(settings_path)
|
|
138
|
+
raw = File.read(settings_path)
|
|
139
|
+
return {} if raw.strip.empty?
|
|
140
|
+
parsed = JSON.parse(raw)
|
|
141
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
142
|
+
rescue JSON::ParserError => e
|
|
143
|
+
stderr.puts "settings.json parse error: #{e.message} — refusing to overwrite"
|
|
144
|
+
{}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def reindex(provider_name)
|
|
148
|
+
stdout.puts "→ Re-embedding facts under provider=#{provider_name}…"
|
|
149
|
+
IndexCommand.new(stdout: stdout, stderr: stderr).call(["--force", "--provider", provider_name])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def report_final_state(provider_name)
|
|
153
|
+
# The settings.json write only affects future sessions (Claude Code
|
|
154
|
+
# reads the env block at session start). For the current process
|
|
155
|
+
# the ENV var isn't set, so report what Embeddings.resolve would
|
|
156
|
+
# produce under the new env.
|
|
157
|
+
env_override = ENV.to_h.merge("CLAUDE_MEMORY_EMBEDDING_PROVIDER" => provider_name)
|
|
158
|
+
provider = Embeddings.resolve(provider_name, env: env_override)
|
|
159
|
+
stdout.puts
|
|
160
|
+
stdout.puts "Provider: #{provider.name}, dimensions: #{provider.dimensions}"
|
|
161
|
+
stdout.puts "Next session will use this provider. Run 'claude-memory doctor' to verify."
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def print_status
|
|
165
|
+
# Resolve under current ENV to show what the next session will use
|
|
166
|
+
provider = Embeddings.resolve
|
|
167
|
+
stdout.puts "Current provider: #{provider.name}"
|
|
168
|
+
stdout.puts "Current dimensions: #{provider.dimensions}"
|
|
169
|
+
stdout.puts "Settings file: #{settings_path}"
|
|
170
|
+
env = load_settings.fetch("env", {})
|
|
171
|
+
relevant = env.slice(*OWNED_KEYS)
|
|
172
|
+
if relevant.any?
|
|
173
|
+
stdout.puts "Configured env:"
|
|
174
|
+
relevant.each { |k, v| stdout.puts " #{k}=#{v}" }
|
|
175
|
+
else
|
|
176
|
+
stdout.puts "Configured env: (none — using default tfidf)"
|
|
177
|
+
end
|
|
178
|
+
0
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|