claude_memory 0.12.1 → 0.13.1
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 +6 -1
- data/.claude/settings.local.json +2 -1
- data/.claude-plugin/marketplace.json +2 -2
- data/.claude-plugin/plugin.json +2 -2
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +11 -6
- data/README.md +35 -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 +16 -5
- data/docs/architecture.md +18 -6
- data/docs/audit_runbook.md +67 -0
- data/docs/dashboard.md +28 -0
- data/docs/improvements.md +173 -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/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/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 +1 -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 +108 -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 +125 -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 +107 -0
- data/lib/claude_memory/observe/token_overlap_matcher.rb +55 -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 +6 -0
- metadata +12 -1
|
@@ -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"},
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Reflect
|
|
2
|
+
|
|
3
|
+
Consolidate the episodic observation log and promote corroborated observations into
|
|
4
|
+
durable facts. This is the manual, on-demand counterpart to the automatic Reflector
|
|
5
|
+
that runs during sweep — use it for a deeper pass.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/reflect
|
|
11
|
+
/reflect --scope project
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Instructions
|
|
15
|
+
|
|
16
|
+
You are the Reflector for ClaudeMemory's episodic observation layer. Observations are
|
|
17
|
+
the "what happened" log; facts are the "what is true" store. Your job is to look across
|
|
18
|
+
the recent observations, find what has become a stable truth, and promote it — while
|
|
19
|
+
leaving one-off noise alone.
|
|
20
|
+
|
|
21
|
+
Work in three passes:
|
|
22
|
+
|
|
23
|
+
### 1. Survey
|
|
24
|
+
|
|
25
|
+
Call `memory.observations` (use `important_only: true` first for the 🔴 entries, then a
|
|
26
|
+
broader pass). Read the log as a narrative of what has happened in this project.
|
|
27
|
+
|
|
28
|
+
### 2. Consolidate related observations
|
|
29
|
+
|
|
30
|
+
Where several observations describe the **same thing in different words** (regex dedup only
|
|
31
|
+
catches exact matches), merge them with `memory.consolidate_observations(from_ids: […],
|
|
32
|
+
body: "<synthesis>")`. The synthesized observation inherits the **combined** corroboration of
|
|
33
|
+
its sources — which often tips it over the promotion threshold — and the originals are
|
|
34
|
+
tombstoned (preserved and linked, not deleted). Use the `#id` shown for each observation.
|
|
35
|
+
|
|
36
|
+
### 3. Promote corroborated observations → facts
|
|
37
|
+
|
|
38
|
+
The promotion bridge is gated: an observation must have been **corroborated** (sighted
|
|
39
|
+
repeatedly — `corroboration_count` ≥ the threshold) before it can become a fact. This is
|
|
40
|
+
deliberate: requiring repeated sightings before commitment is an anti-hallucination gate
|
|
41
|
+
against one-off doc/example text.
|
|
42
|
+
|
|
43
|
+
For each observation that represents a **stable, repeated truth**:
|
|
44
|
+
|
|
45
|
+
- Call `memory.promote_observation` with `observation_id`, a `predicate`
|
|
46
|
+
(`decision` / `convention` / `architecture`), and an `object` that **embeds a reason**
|
|
47
|
+
("… because …", "… so that …", "to avoid …"). A bare conclusion is dead weight.
|
|
48
|
+
- The tool refuses observations that are not yet corroborated — do not try to force them.
|
|
49
|
+
If something genuinely matters but has only been seen once, leave it; it will become
|
|
50
|
+
eligible once it recurs.
|
|
51
|
+
|
|
52
|
+
Skip observations that are:
|
|
53
|
+
|
|
54
|
+
- transient (debugging steps, one-off events),
|
|
55
|
+
- already captured as facts (check `memory.recall` / `memory.decisions` first),
|
|
56
|
+
- example/illustrative text rather than a claim about *this* project.
|
|
57
|
+
|
|
58
|
+
### 4. Report
|
|
59
|
+
|
|
60
|
+
Summarize what you promoted (observation → fact) and what you intentionally left as
|
|
61
|
+
observations and why. Do not delete or rewrite observations — the deterministic Reflector
|
|
62
|
+
handles dedup/expiry during sweep; your job is the semantic judgment the regex pass can't make.
|
|
63
|
+
|
|
64
|
+
## Notes
|
|
65
|
+
|
|
66
|
+
- Promotion preserves provenance: the new fact links back to the observation's source.
|
|
67
|
+
- Promoted observations are marked so they are not re-suggested.
|
|
68
|
+
- No extra API cost — this runs inside the existing Claude Code session.
|
|
@@ -13,7 +13,7 @@ module ClaudeMemory
|
|
|
13
13
|
SCOPE_PROJECT = "project"
|
|
14
14
|
|
|
15
15
|
def call(args)
|
|
16
|
-
opts = parse_options(args, {scope: SCOPE_ALL, tools: false, tokens: false, stale: false, since_days: nil, stale_days: nil}) do |o|
|
|
16
|
+
opts = parse_options(args, {scope: SCOPE_ALL, tools: false, tokens: false, stale: false, observations: false, since_days: nil, stale_days: nil}) do |o|
|
|
17
17
|
OptionParser.new do |parser|
|
|
18
18
|
parser.banner = "Usage: claude-memory stats [options]"
|
|
19
19
|
parser.on("--scope SCOPE", ["all", "global", "project"],
|
|
@@ -21,6 +21,7 @@ module ClaudeMemory
|
|
|
21
21
|
parser.on("--tools", "Show MCP tool-call usage stats") { o[:tools] = true }
|
|
22
22
|
parser.on("--tokens", "Show SessionStart context-injection token budget") { o[:tokens] = true }
|
|
23
23
|
parser.on("--stale", "Show facts not recalled in CLAUDE_MEMORY_STALE_DAYS (default 14)") { o[:stale] = true }
|
|
24
|
+
parser.on("--observations", "Show episodic observation counts (status, kind, promotable)") { o[:observations] = true }
|
|
24
25
|
parser.on("--since DAYS", Integer, "Limit --tools/--tokens to last N days") { |v| o[:since_days] = v }
|
|
25
26
|
parser.on("--stale-days N", Integer, "Override staleness threshold for --stale") { |v| o[:stale_days] = v }
|
|
26
27
|
end
|
|
@@ -31,6 +32,10 @@ module ClaudeMemory
|
|
|
31
32
|
return print_mcp_tool_call_stats(opts[:since_days])
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
if opts[:observations]
|
|
36
|
+
return print_observation_stats
|
|
37
|
+
end
|
|
38
|
+
|
|
34
39
|
if opts[:tokens]
|
|
35
40
|
return print_token_budget_stats(opts[:since_days])
|
|
36
41
|
end
|
|
@@ -90,6 +95,60 @@ module ClaudeMemory
|
|
|
90
95
|
0
|
|
91
96
|
end
|
|
92
97
|
|
|
98
|
+
def print_observation_stats
|
|
99
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
100
|
+
stores = %w[project global]
|
|
101
|
+
.filter_map { |scope| manager.store_if_exists(scope) }
|
|
102
|
+
.select { |store| store.db.table_exists?(:observations) }
|
|
103
|
+
|
|
104
|
+
stdout.puts "Observation Statistics (episodic layer)"
|
|
105
|
+
stdout.puts "=" * 50
|
|
106
|
+
|
|
107
|
+
threshold = ClaudeMemory::Domain::Observation::PROMOTION_THRESHOLD
|
|
108
|
+
|
|
109
|
+
total = stores.sum { |s| s.observations.count }
|
|
110
|
+
if total.zero?
|
|
111
|
+
stdout.puts "No observations recorded yet."
|
|
112
|
+
manager.close
|
|
113
|
+
return 0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
active = stores.sum { |s| s.observations.where(status: "active").count }
|
|
117
|
+
consolidated = stores.sum { |s| s.observations.where(status: "consolidated").count }
|
|
118
|
+
expired = stores.sum { |s| s.observations.where(status: "expired").count }
|
|
119
|
+
promoted = stores.sum { |s| s.observations.exclude(promoted_at: nil).count }
|
|
120
|
+
promotable = stores.sum do |s|
|
|
121
|
+
s.observations.where(status: "active", promoted_at: nil)
|
|
122
|
+
.where { corroboration_count >= threshold }.count
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
stdout.puts "Active: #{active}"
|
|
126
|
+
stdout.puts "Consolidated: #{consolidated}"
|
|
127
|
+
stdout.puts "Expired: #{expired}"
|
|
128
|
+
stdout.puts "Promoted: #{promoted}"
|
|
129
|
+
stdout.puts "Promotable (>= #{threshold} sightings): #{promotable}"
|
|
130
|
+
stdout.puts
|
|
131
|
+
|
|
132
|
+
kinds = Hash.new(0)
|
|
133
|
+
stores.each do |store|
|
|
134
|
+
store.observations.where(status: "active").group_and_count(:kind).each do |row|
|
|
135
|
+
kinds[row[:kind]] += row[:count]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
stdout.puts "By kind (active):"
|
|
140
|
+
if kinds.empty?
|
|
141
|
+
stdout.puts " (none)"
|
|
142
|
+
else
|
|
143
|
+
kinds.sort_by { |_k, v| -v }.each do |kind, count|
|
|
144
|
+
stdout.puts " #{count.to_s.rjust(4)} - #{kind}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
manager.close
|
|
149
|
+
0
|
|
150
|
+
end
|
|
151
|
+
|
|
93
152
|
def open_readonly(db_path)
|
|
94
153
|
Sequel.connect("extralite://#{db_path}")
|
|
95
154
|
end
|
|
@@ -456,6 +456,10 @@ module ClaudeMemory
|
|
|
456
456
|
Efficacy::Reporter.report(events, timeframe: {since: since, session_id: session_id})
|
|
457
457
|
end
|
|
458
458
|
|
|
459
|
+
def observations
|
|
460
|
+
Observations.new(@manager).report
|
|
461
|
+
end
|
|
462
|
+
|
|
459
463
|
def timeline
|
|
460
464
|
Timeline.new(@manager).days
|
|
461
465
|
end
|