claude_memory 0.12.1 → 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 +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 +28 -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 +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/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 +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 +5 -0
- metadata +11 -1
|
@@ -226,6 +226,155 @@ module ClaudeMemory
|
|
|
226
226
|
end
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
+
# Scopes whose stores carry an observations table. Observation checks
|
|
230
|
+
# iterate both DBs because observations may be project- or global-scoped.
|
|
231
|
+
OBSERVATION_SCOPES = %i[project global].freeze
|
|
232
|
+
|
|
233
|
+
# Valid observation lifecycle states. Anything else means a writer or
|
|
234
|
+
# migration stamped a status the resolver/reflector never produce.
|
|
235
|
+
OBSERVATION_STATUSES = %w[active consolidated expired].freeze
|
|
236
|
+
|
|
237
|
+
def observation_stores(manager)
|
|
238
|
+
OBSERVATION_SCOPES
|
|
239
|
+
.map { |scope| [scope, manager.store_if_exists(scope.to_s)] }
|
|
240
|
+
.reject { |_, store| store.nil? }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# C011 — Orphaned observations (provenance points at a missing content item).
|
|
244
|
+
def orphaned_observations(manager)
|
|
245
|
+
observation_stores(manager).flat_map do |scope, store|
|
|
246
|
+
content_ids = store.content_items.select(:id)
|
|
247
|
+
orphans = store.observations
|
|
248
|
+
.exclude(source_content_item_id: nil)
|
|
249
|
+
.exclude(source_content_item_id: content_ids)
|
|
250
|
+
.select(:id)
|
|
251
|
+
.all
|
|
252
|
+
next [] if orphans.empty?
|
|
253
|
+
|
|
254
|
+
[Finding.new(
|
|
255
|
+
id: "C011",
|
|
256
|
+
severity: :warn,
|
|
257
|
+
title: "#{orphans.size} observation(s) in #{scope} DB reference a missing content item",
|
|
258
|
+
detail: "An observation's source_content_item_id should point at the content_items row it was distilled from. A dangling pointer means the source row was pruned or never existed, so the observation's provenance can no longer be explained.",
|
|
259
|
+
suggestion: "Inspect with memory.observations. These rows are append-only; if the provenance is unrecoverable, consolidate or expire them via the Reflector (PreCompact/SessionEnd) rather than deleting.",
|
|
260
|
+
fact_ids: orphans.map { |r| r[:id] }
|
|
261
|
+
)]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# C012 — Promotion consistency (promoted_at ⇔ promoted_fact_id, fact must exist + be active).
|
|
266
|
+
def observation_promotion_consistency(manager)
|
|
267
|
+
observation_stores(manager).flat_map do |scope, store|
|
|
268
|
+
active_fact_ids = store.facts.where(status: "active").select(:id)
|
|
269
|
+
|
|
270
|
+
missing_fact_id = store.observations
|
|
271
|
+
.exclude(promoted_at: nil)
|
|
272
|
+
.where(promoted_fact_id: nil)
|
|
273
|
+
.select(:id).all
|
|
274
|
+
dangling_fact = store.observations
|
|
275
|
+
.exclude(promoted_fact_id: nil)
|
|
276
|
+
.exclude(promoted_fact_id: store.facts.select(:id))
|
|
277
|
+
.select(:id, :promoted_fact_id).all
|
|
278
|
+
inactive_fact = store.observations
|
|
279
|
+
.exclude(promoted_fact_id: nil)
|
|
280
|
+
.exclude(promoted_fact_id: active_fact_ids)
|
|
281
|
+
.exclude(promoted_fact_id: dangling_fact.map { |r| r[:promoted_fact_id] })
|
|
282
|
+
.select(:id, :promoted_fact_id).all
|
|
283
|
+
missing_timestamp = store.observations
|
|
284
|
+
.exclude(promoted_fact_id: nil)
|
|
285
|
+
.where(promoted_at: nil)
|
|
286
|
+
.select(:id).all
|
|
287
|
+
|
|
288
|
+
obs_ids = (missing_fact_id + dangling_fact + inactive_fact + missing_timestamp).map { |r| r[:id] }.uniq
|
|
289
|
+
next [] if obs_ids.empty?
|
|
290
|
+
|
|
291
|
+
problems = []
|
|
292
|
+
problems << "#{missing_fact_id.size} promoted but missing promoted_fact_id" unless missing_fact_id.empty?
|
|
293
|
+
problems << "#{dangling_fact.size} promoted_fact_id pointing at a non-existent fact" unless dangling_fact.empty?
|
|
294
|
+
problems << "#{inactive_fact.size} promoted into a non-active fact" unless inactive_fact.empty?
|
|
295
|
+
problems << "#{missing_timestamp.size} have promoted_fact_id but no promoted_at" unless missing_timestamp.empty?
|
|
296
|
+
|
|
297
|
+
[Finding.new(
|
|
298
|
+
id: "C012",
|
|
299
|
+
severity: :error,
|
|
300
|
+
title: "#{obs_ids.size} observation(s) in #{scope} DB have inconsistent promotion state",
|
|
301
|
+
detail: "Promotion must be atomic: a promoted observation has both promoted_at set and promoted_fact_id pointing at an existing, active fact. Violations (#{problems.join("; ")}) mean mark_observation_promoted ran partially or the target fact was later rejected/superseded, leaving the observation pointing at nothing usable.",
|
|
302
|
+
suggestion: "Inspect the fact with claude-memory explain <fact_id>. If the fact was intentionally rejected, the observation should be re-opened for re-promotion via memory.promote_observation; if mark_observation_promoted half-ran, re-run promotion.",
|
|
303
|
+
fact_ids: obs_ids
|
|
304
|
+
)]
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# C013 — Tombstone-chain validity (consolidated_into must point to a real,
|
|
309
|
+
# non-self row and a consolidated observation must not stay active).
|
|
310
|
+
def observation_tombstone_chain(manager)
|
|
311
|
+
observation_stores(manager).flat_map do |scope, store|
|
|
312
|
+
obs_ids = store.observations.select(:id)
|
|
313
|
+
|
|
314
|
+
dangling = store.observations
|
|
315
|
+
.exclude(consolidated_into: nil)
|
|
316
|
+
.exclude(consolidated_into: obs_ids)
|
|
317
|
+
.select(:id, :consolidated_into).all
|
|
318
|
+
self_link = store.observations
|
|
319
|
+
.exclude(consolidated_into: nil)
|
|
320
|
+
.where(Sequel[:consolidated_into] => Sequel[:id])
|
|
321
|
+
.select(:id).all
|
|
322
|
+
active_but_tombstoned = store.observations
|
|
323
|
+
.exclude(consolidated_into: nil)
|
|
324
|
+
.where(status: "active")
|
|
325
|
+
.select(:id).all
|
|
326
|
+
consolidated_without_link = store.observations
|
|
327
|
+
.where(status: "consolidated", consolidated_into: nil)
|
|
328
|
+
.select(:id).all
|
|
329
|
+
|
|
330
|
+
flagged = (dangling + self_link + active_but_tombstoned + consolidated_without_link).map { |r| r[:id] }.uniq
|
|
331
|
+
next [] if flagged.empty?
|
|
332
|
+
|
|
333
|
+
problems = []
|
|
334
|
+
problems << "#{dangling.size} consolidated_into → missing observation" unless dangling.empty?
|
|
335
|
+
problems << "#{self_link.size} consolidated_into self-link" unless self_link.empty?
|
|
336
|
+
problems << "#{active_but_tombstoned.size} active yet have a consolidated_into target" unless active_but_tombstoned.empty?
|
|
337
|
+
problems << "#{consolidated_without_link.size} status=consolidated with no consolidated_into keeper" unless consolidated_without_link.empty?
|
|
338
|
+
|
|
339
|
+
[Finding.new(
|
|
340
|
+
id: "C013",
|
|
341
|
+
severity: :error,
|
|
342
|
+
title: "#{flagged.size} observation(s) in #{scope} DB have a broken tombstone chain",
|
|
343
|
+
detail: "Tombstoning is append-only: a superseded observation gets status=consolidated and consolidated_into pointing at the surviving keeper. Violations (#{problems.join("; ")}) corrupt the lineage — recall could surface a tombstoned row, or a consolidated row could orphan its history.",
|
|
344
|
+
suggestion: "Inspect with memory.observations. Re-run the deterministic Reflector (fires on PreCompact/SessionEnd) to re-derive consolidation; a self-link or active+tombstoned row indicates a Reflector bug — file it rather than hand-editing the append-only table.",
|
|
345
|
+
fact_ids: flagged
|
|
346
|
+
)]
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# C014 — Status / corroboration sanity (known status set, corroboration ≥ 1).
|
|
351
|
+
def observation_status_corroboration(manager)
|
|
352
|
+
observation_stores(manager).flat_map do |scope, store|
|
|
353
|
+
bad_status = store.observations
|
|
354
|
+
.exclude(status: OBSERVATION_STATUSES)
|
|
355
|
+
.select(:id).all
|
|
356
|
+
bad_corroboration = store.observations
|
|
357
|
+
.where { corroboration_count < 1 }
|
|
358
|
+
.select(:id).all
|
|
359
|
+
|
|
360
|
+
flagged = (bad_status + bad_corroboration).map { |r| r[:id] }.uniq
|
|
361
|
+
next [] if flagged.empty?
|
|
362
|
+
|
|
363
|
+
problems = []
|
|
364
|
+
problems << "#{bad_status.size} with status outside #{OBSERVATION_STATUSES.inspect}" unless bad_status.empty?
|
|
365
|
+
problems << "#{bad_corroboration.size} with corroboration_count < 1" unless bad_corroboration.empty?
|
|
366
|
+
|
|
367
|
+
[Finding.new(
|
|
368
|
+
id: "C014",
|
|
369
|
+
severity: :warn,
|
|
370
|
+
title: "#{flagged.size} observation(s) in #{scope} DB have invalid status/corroboration",
|
|
371
|
+
detail: "Every observation should carry a known lifecycle status (#{OBSERVATION_STATUSES.join("/")}) and at least one sighting (corroboration_count ≥ 1; a fresh insert counts as 1). Violations (#{problems.join("; ")}) break the promotion gate (which keys off corroboration) and the recall filters (which key off status).",
|
|
372
|
+
suggestion: "Inspect with memory.observations. A corroboration_count < 1 means increment_corroboration math went negative; an unknown status means a migration or external writer bypassed insert_observation. Re-derive via the Reflector if possible.",
|
|
373
|
+
fact_ids: flagged
|
|
374
|
+
)]
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
229
378
|
def normalize_convention(text)
|
|
230
379
|
text.to_s
|
|
231
380
|
.downcase
|
|
@@ -20,6 +20,10 @@ module ClaudeMemory
|
|
|
20
20
|
bare_conclusion_rate
|
|
21
21
|
project_starvation
|
|
22
22
|
auto_memory_unimported
|
|
23
|
+
orphaned_observations
|
|
24
|
+
observation_promotion_consistency
|
|
25
|
+
observation_tombstone_chain
|
|
26
|
+
observation_status_corroboration
|
|
23
27
|
].freeze
|
|
24
28
|
|
|
25
29
|
Result = Data.define(:findings, :stats) do
|
|
@@ -138,7 +138,7 @@ module ClaudeMemory
|
|
|
138
138
|
|
|
139
139
|
predicates = db[:facts].select(:predicate, :status).group_and_count(:predicate, :status).all
|
|
140
140
|
.each_with_object(Hash.new { |h, k| h[k] = Hash.new(0) }) do |row, acc|
|
|
141
|
-
|
|
141
|
+
acc[row[:predicate].to_s][row[:status].to_s] += row[:count].to_i
|
|
142
142
|
end
|
|
143
143
|
|
|
144
144
|
entity_types = db[:entities].group_and_count(:type).all.each_with_object(Hash.new(0)) do |row, acc|
|
|
@@ -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"},
|