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
|
@@ -9,6 +9,8 @@ module ClaudeMemory
|
|
|
9
9
|
MAX_DECISIONS = 5
|
|
10
10
|
MAX_CONVENTIONS = 5
|
|
11
11
|
MAX_ARCHITECTURE = 5
|
|
12
|
+
MAX_OBSERVATIONS = 10
|
|
13
|
+
MAX_PROMOTION_CANDIDATES = 5
|
|
12
14
|
MAX_UNDISTILLED = 3
|
|
13
15
|
MAX_TEXT_PER_ITEM = 1500
|
|
14
16
|
MAX_MIRROR_CANDIDATES = 5
|
|
@@ -30,7 +32,8 @@ module ClaudeMemory
|
|
|
30
32
|
# ({"project" => [...], "global" => [...]}) so telemetry can resolve
|
|
31
33
|
# each fact from the correct store. Fact IDs autoincrement per-DB,
|
|
32
34
|
# so a bare ID without scope is ambiguous.
|
|
33
|
-
attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope
|
|
35
|
+
attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope,
|
|
36
|
+
:emitted_observation_count
|
|
34
37
|
|
|
35
38
|
def initialize(manager, source: nil, auto_memory_mirror: nil, stale_threshold_days: nil)
|
|
36
39
|
@manager = manager
|
|
@@ -41,12 +44,14 @@ module ClaudeMemory
|
|
|
41
44
|
@emitted_fact_ids = []
|
|
42
45
|
@emitted_subjects = []
|
|
43
46
|
@emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
|
|
47
|
+
@emitted_observation_count = 0
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def generate_context
|
|
47
51
|
@emitted_fact_ids = []
|
|
48
52
|
@emitted_subjects = []
|
|
49
53
|
@emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
|
|
54
|
+
@emitted_observation_count = 0
|
|
50
55
|
sections = []
|
|
51
56
|
|
|
52
57
|
decisions = fetch(:decisions, MAX_DECISIONS)
|
|
@@ -58,9 +63,25 @@ module ClaudeMemory
|
|
|
58
63
|
architecture = fetch(:architecture, MAX_ARCHITECTURE)
|
|
59
64
|
sections << format_section("Architecture", architecture) if architecture.any?
|
|
60
65
|
|
|
66
|
+
# Block 1 of the two-block context: the episodic observation log. Sits
|
|
67
|
+
# ahead of the (fresh-session) undistilled "Pending Knowledge Extraction"
|
|
68
|
+
# tail (Block 2). Newest-first; only š“ carries a marker for the actor.
|
|
69
|
+
observations = fetch_observations(MAX_OBSERVATIONS)
|
|
70
|
+
@emitted_observation_count = observations.size
|
|
71
|
+
obs_section = Observe::ObservationsRenderer.render(observations)
|
|
72
|
+
sections << obs_section if obs_section
|
|
73
|
+
|
|
61
74
|
if fresh_session?
|
|
62
75
|
undistilled = fetch_undistilled(MAX_UNDISTILLED)
|
|
63
|
-
|
|
76
|
+
if undistilled.any?
|
|
77
|
+
sections << format_distillation_prompt(undistilled)
|
|
78
|
+
# The episodic-capture ask is its own prominent section (#72), not a
|
|
79
|
+
# buried paragraph inside the deep-distill prompt.
|
|
80
|
+
sections << format_observation_capture_prompt
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
promotion = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
|
|
84
|
+
sections << format_observation_reflection(promotion) if promotion.any?
|
|
64
85
|
|
|
65
86
|
mirror_candidates = fetch_mirror_candidates(MAX_MIRROR_CANDIDATES)
|
|
66
87
|
if mirror_candidates.any?
|
|
@@ -74,6 +95,19 @@ module ClaudeMemory
|
|
|
74
95
|
sections.join("\n")
|
|
75
96
|
end
|
|
76
97
|
|
|
98
|
+
# Reflection-only context for PreCompact (context pressure) ā just the
|
|
99
|
+
# promote/consolidate instructions for corroborated/related observations.
|
|
100
|
+
# PreCompact is the analog of Mastra's token-threshold reflection trigger;
|
|
101
|
+
# at compaction we nudge the Claude-as-reflector pass rather than
|
|
102
|
+
# re-inject the full snapshot (which would add tokens as the window fills).
|
|
103
|
+
# @return [String, nil]
|
|
104
|
+
def reflection_context
|
|
105
|
+
candidates = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
|
|
106
|
+
return nil if candidates.empty?
|
|
107
|
+
|
|
108
|
+
format_observation_reflection(candidates)
|
|
109
|
+
end
|
|
110
|
+
|
|
77
111
|
private
|
|
78
112
|
|
|
79
113
|
def fresh_session?
|
|
@@ -124,6 +158,67 @@ module ClaudeMemory
|
|
|
124
158
|
@stale_threshold_days ||= Configuration.new.injection_stale_days
|
|
125
159
|
end
|
|
126
160
|
|
|
161
|
+
# Automatic semantic reflection (Phase 4): surface observations that have
|
|
162
|
+
# been corroborated past the promotion threshold so Claude can promote
|
|
163
|
+
# them to facts inline, this session, at no extra API cost.
|
|
164
|
+
# Project store only: the reflection nudge renders bare `[obs #<id>]` and
|
|
165
|
+
# `memory.promote_observation`/`consolidate_observations` default to the
|
|
166
|
+
# project scope. Observation ids autoincrement *per-DB*, so mixing in
|
|
167
|
+
# global-store candidates would route a promote call to the wrong DB.
|
|
168
|
+
# Observations are written project-scoped on the ingest path today; if a
|
|
169
|
+
# global observation writer is ever added, scope-tag the nudge line
|
|
170
|
+
# (mirror @emitted_facts_by_scope) before broadening this.
|
|
171
|
+
def fetch_promotion_candidates(limit)
|
|
172
|
+
store = @manager.project_store
|
|
173
|
+
return [] unless store
|
|
174
|
+
|
|
175
|
+
store
|
|
176
|
+
.promotion_candidates(min_corroboration: Domain::Observation::PROMOTION_THRESHOLD, limit: limit)
|
|
177
|
+
.sort_by { |o| -(o[:corroboration_count] || 0) }
|
|
178
|
+
.first(limit)
|
|
179
|
+
rescue => e
|
|
180
|
+
ClaudeMemory.logger.warn("ContextInjector#fetch_promotion_candidates failed: #{e.message}")
|
|
181
|
+
[]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def format_observation_reflection(candidates)
|
|
185
|
+
lines = [
|
|
186
|
+
"## Observation Reflection",
|
|
187
|
+
"",
|
|
188
|
+
"**Promote:** these observations have recurred enough to be worth committing",
|
|
189
|
+
"as facts (corroboration gate passed). For each that represents a stable truth,",
|
|
190
|
+
"call `memory.promote_observation(observation_id, predicate, object)` ā embed a",
|
|
191
|
+
"reason in the object (\"⦠because ā¦\", \"⦠so that ā¦\"). Skip noise / already-captured.",
|
|
192
|
+
"",
|
|
193
|
+
"**Consolidate:** if several observations in the log above (by `#id`) describe the",
|
|
194
|
+
"same thing in different words, merge them with",
|
|
195
|
+
"`memory.consolidate_observations(from_ids: [ā¦], body: \"<synthesis>\")`. Their",
|
|
196
|
+
"corroboration combines, which can tip the merged observation past the promotion gate."
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
candidates.each do |obs|
|
|
200
|
+
lines << ""
|
|
201
|
+
lines << "- [obs ##{obs[:id]} Ć#{obs[:corroboration_count]}] #{obs[:body]}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
lines.join("\n")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def fetch_observations(limit)
|
|
208
|
+
stores = []
|
|
209
|
+
stores << @manager.project_store if @manager.project_store
|
|
210
|
+
stores << @manager.global_store if @manager.global_store
|
|
211
|
+
|
|
212
|
+
stores
|
|
213
|
+
.flat_map { |s| s.recent_observations(limit: limit) }
|
|
214
|
+
.sort_by { |o| o[:observed_at] || "" }
|
|
215
|
+
.reverse
|
|
216
|
+
.first(limit)
|
|
217
|
+
rescue => e
|
|
218
|
+
ClaudeMemory.logger.warn("ContextInjector#fetch_observations failed: #{e.message}")
|
|
219
|
+
[]
|
|
220
|
+
end
|
|
221
|
+
|
|
127
222
|
def fetch_undistilled(limit)
|
|
128
223
|
stores = []
|
|
129
224
|
stores << @manager.project_store if @manager.project_store
|
|
@@ -171,6 +266,34 @@ module ClaudeMemory
|
|
|
171
266
|
lines.join("\n")
|
|
172
267
|
end
|
|
173
268
|
|
|
269
|
+
# First-class, standalone ask for the episodic layer (#72). Authoring
|
|
270
|
+
# observations was previously a paragraph buried inside the optional
|
|
271
|
+
# deep-distill flow above, and that flow fires almost never ā so the
|
|
272
|
+
# episodic log was 100% Layer-1 scrapes. This decouples it: a prominent,
|
|
273
|
+
# lightweight instruction to log "what happened" directly, the same way
|
|
274
|
+
# the fact context rides the session. Effectiveness is measurable via the
|
|
275
|
+
# `mcp_extraction` content-item source (Layer-2) vs `claude_code` (Layer-1).
|
|
276
|
+
def format_observation_capture_prompt
|
|
277
|
+
<<~PROMPT.strip
|
|
278
|
+
## Log What Happened (episodic memory)
|
|
279
|
+
|
|
280
|
+
Record the recent narrative as **observations** ā "what happened",
|
|
281
|
+
complementing the facts above ("what is true"). For each discrete
|
|
282
|
+
event in the recent work above (a decision made, a preference stated,
|
|
283
|
+
a notable fix or outcome), call `memory.store_extraction` with an
|
|
284
|
+
`observations` array ā one entry per event:
|
|
285
|
+
|
|
286
|
+
- `body`: one concise sentence of what happened (embed a reason for
|
|
287
|
+
decisions/preferences ā "⦠because ā¦", "⦠so that ā¦")
|
|
288
|
+
- `kind`: `decision`, `preference`, or `event`
|
|
289
|
+
- `priority`: 1 important, 2 maybe, 3 info
|
|
290
|
+
|
|
291
|
+
Keep it to genuine events worth remembering ā skip routine steps and
|
|
292
|
+
code output. Observations accumulate and a corroborated one graduates
|
|
293
|
+
into a fact. Send them with the facts in the same call, or on their own.
|
|
294
|
+
PROMPT
|
|
295
|
+
end
|
|
296
|
+
|
|
174
297
|
def format_section(title, items)
|
|
175
298
|
items = items.compact.uniq
|
|
176
299
|
return nil if items.empty?
|
|
@@ -13,6 +13,10 @@ module ClaudeMemory
|
|
|
13
13
|
entities = (args["entities"] || []).map { |e| symbolize_keys(e) }
|
|
14
14
|
facts = (args["facts"] || []).map { |f| symbolize_keys(f) }
|
|
15
15
|
decisions = (args["decisions"] || []).map { |d| symbolize_keys(d) }
|
|
16
|
+
# Layer-2 episodic observations. Claude's output is semi-trusted, so
|
|
17
|
+
# each is coerced and validated at this boundary; invalid rows are
|
|
18
|
+
# dropped rather than aborting the batch.
|
|
19
|
+
observations = (args["observations"] || []).filter_map { |o| coerce_observation(o) }
|
|
16
20
|
|
|
17
21
|
config = Configuration.new
|
|
18
22
|
project_path = config.project_dir
|
|
@@ -26,7 +30,8 @@ module ClaudeMemory
|
|
|
26
30
|
entities: entities,
|
|
27
31
|
facts: facts,
|
|
28
32
|
decisions: decisions,
|
|
29
|
-
signals: []
|
|
33
|
+
signals: [],
|
|
34
|
+
observations: observations
|
|
30
35
|
)
|
|
31
36
|
|
|
32
37
|
# Guard against the LLM distiller labeling descriptions of external
|
|
@@ -52,7 +57,113 @@ module ClaudeMemory
|
|
|
52
57
|
entities_created: result[:entities_created],
|
|
53
58
|
facts_created: result[:facts_created],
|
|
54
59
|
facts_superseded: result[:facts_superseded],
|
|
55
|
-
conflicts_created: result[:conflicts_created]
|
|
60
|
+
conflicts_created: result[:conflicts_created],
|
|
61
|
+
observations_created: result[:observations_created]
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Coerce one Claude-supplied observation into a clean candidate, or nil
|
|
66
|
+
# when it can't be a usable episodic row. Defaults live here so the
|
|
67
|
+
# rest of the pipeline never sees a blank body or an out-of-range
|
|
68
|
+
# priority. Returns a symbol-keyed hash for Resolver#persist_observations.
|
|
69
|
+
def coerce_observation(raw)
|
|
70
|
+
obs = symbolize_keys(raw)
|
|
71
|
+
body = obs[:body].to_s.strip
|
|
72
|
+
return nil if body.empty?
|
|
73
|
+
|
|
74
|
+
kind = Domain::Observation::KINDS.include?(obs[:kind]) ? obs[:kind] : "event"
|
|
75
|
+
priority = obs[:priority].to_i
|
|
76
|
+
priority = Domain::Observation::INFO unless (Domain::Observation::IMPORTANT..Domain::Observation::INFO).cover?(priority)
|
|
77
|
+
|
|
78
|
+
{body: body, kind: kind, priority: priority}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Promotion bridge: turn a corroborated observation into a structured
|
|
82
|
+
# fact. Server-side anti-hallucination gate ā refuses to promote an
|
|
83
|
+
# observation that has not been sighted at least PROMOTION_THRESHOLD
|
|
84
|
+
# times. Creates the fact through the resolver (so supersession/conflict
|
|
85
|
+
# handling applies) and marks the observation promoted so it is not
|
|
86
|
+
# re-suggested.
|
|
87
|
+
def promote_observation(args)
|
|
88
|
+
scope = args["scope"] || "project"
|
|
89
|
+
store = get_store_for_scope(scope)
|
|
90
|
+
return {error: "Database not available"} unless store
|
|
91
|
+
|
|
92
|
+
observation_id = args["observation_id"]
|
|
93
|
+
return {error: "observation_id required"} if observation_id.nil?
|
|
94
|
+
|
|
95
|
+
obs = store.observations.where(id: observation_id).first
|
|
96
|
+
return {error: "Observation #{observation_id} not found in #{scope} database"} unless obs
|
|
97
|
+
return {error: "Observation #{observation_id} already promoted (fact #{obs[:promoted_fact_id]})"} unless obs[:promoted_at].nil?
|
|
98
|
+
|
|
99
|
+
threshold = Domain::Observation::PROMOTION_THRESHOLD
|
|
100
|
+
if obs[:corroboration_count].to_i < threshold
|
|
101
|
+
return {error: "Not yet corroborated: observation #{observation_id} has #{obs[:corroboration_count]} sighting(s), need #{threshold}. Promotion requires repeated corroboration (anti-hallucination gate)."}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
predicate = args["predicate"]
|
|
105
|
+
object = args["object"]
|
|
106
|
+
return {error: "predicate and object are required"} if predicate.nil? || object.to_s.strip.empty?
|
|
107
|
+
subject = args["subject"] || "repo"
|
|
108
|
+
|
|
109
|
+
config = Configuration.new
|
|
110
|
+
project_path = config.project_dir
|
|
111
|
+
occurred_at = Time.now.utc.iso8601
|
|
112
|
+
|
|
113
|
+
extraction = Distill::Extraction.new(
|
|
114
|
+
facts: [{subject: subject, predicate: predicate, object: object, strength: "derived"}]
|
|
115
|
+
)
|
|
116
|
+
result = Resolve::Resolver.new(store).apply(
|
|
117
|
+
extraction, content_item_id: obs[:source_content_item_id],
|
|
118
|
+
occurred_at: occurred_at, project_path: project_path, scope: scope
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# The resolver reports the id of the fact it actually touched
|
|
122
|
+
# (inserted, reinforced, or disputed) ā no need to re-query for it.
|
|
123
|
+
fact_id = result[:fact_ids].compact.first
|
|
124
|
+
return {error: "Promotion failed: the fact for observation #{observation_id} could not be resolved after creation"} unless fact_id
|
|
125
|
+
|
|
126
|
+
store.mark_observation_promoted(observation_id, fact_id: fact_id)
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
success: true,
|
|
130
|
+
observation_id: observation_id,
|
|
131
|
+
fact_id: fact_id,
|
|
132
|
+
predicate: Resolve::PredicatePolicy.canonicalize(predicate),
|
|
133
|
+
object: object,
|
|
134
|
+
corroboration_count: obs[:corroboration_count],
|
|
135
|
+
facts_created: result[:facts_created]
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Semantic reflection: merge several related observations into one
|
|
140
|
+
# synthesized observation (the Claude-as-reflector pass). Validates the
|
|
141
|
+
# synthesized body at the border via coerce_observation, then delegates
|
|
142
|
+
# the atomic merge to the store.
|
|
143
|
+
def consolidate_observations(args)
|
|
144
|
+
scope = args["scope"] || "project"
|
|
145
|
+
store = get_store_for_scope(scope)
|
|
146
|
+
return {error: "Database not available"} unless store
|
|
147
|
+
|
|
148
|
+
from_ids = Array(args["from_ids"]).map(&:to_i).reject(&:zero?).uniq
|
|
149
|
+
return {error: "from_ids must list at least 2 observation ids"} if from_ids.size < 2
|
|
150
|
+
|
|
151
|
+
synthesized = coerce_observation(args)
|
|
152
|
+
return {error: "body is required"} unless synthesized
|
|
153
|
+
|
|
154
|
+
project_path = (scope == "global") ? nil : Configuration.new.project_dir
|
|
155
|
+
result = store.consolidate_observations(
|
|
156
|
+
from_ids, body: synthesized[:body], kind: synthesized[:kind],
|
|
157
|
+
priority: synthesized[:priority], scope: scope, project_path: project_path
|
|
158
|
+
)
|
|
159
|
+
return {error: "Need at least 2 active #{scope} observations from that set to consolidate"} unless result
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
success: true,
|
|
163
|
+
scope: scope,
|
|
164
|
+
consolidated_into: result[:id],
|
|
165
|
+
merged: result[:merged],
|
|
166
|
+
corroboration_count: result[:corroboration_count]
|
|
56
167
|
}
|
|
57
168
|
end
|
|
58
169
|
|
|
@@ -49,7 +49,15 @@ module ClaudeMemory
|
|
|
49
49
|
explanation = @recall.explain(args["fact_id"], scope: scope)
|
|
50
50
|
return {error: "Fact not found in #{scope} database"} if explanation.is_a?(Core::NullExplanation)
|
|
51
51
|
|
|
52
|
-
ResponseFormatter.format_explanation(explanation, scope)
|
|
52
|
+
result = ResponseFormatter.format_explanation(explanation, scope)
|
|
53
|
+
|
|
54
|
+
# Episodic provenance: if this fact was promoted from observations,
|
|
55
|
+
# show the lineage back to them (reverse of observations.promoted_fact_id).
|
|
56
|
+
store = get_store_for_scope(scope)
|
|
57
|
+
promoted_from = store ? store.observations_for_fact(args["fact_id"]) : []
|
|
58
|
+
result[:promoted_from_observations] = promoted_from if result.is_a?(Hash) && promoted_from.any?
|
|
59
|
+
|
|
60
|
+
result
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def recall_semantic(args)
|
|
@@ -109,6 +117,45 @@ module ClaudeMemory
|
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
end
|
|
120
|
+
|
|
121
|
+
# List recent episodic observations (the "what happened" log). Read-only.
|
|
122
|
+
# Phase 1 queries one scope's store (default project); cross-scope merge
|
|
123
|
+
# comes with the stable-prefix injection phase.
|
|
124
|
+
def observations(args)
|
|
125
|
+
return database_not_found_error unless databases_exist?
|
|
126
|
+
|
|
127
|
+
scope = args["scope"] || "project"
|
|
128
|
+
limit = extract_limit(args, default: 20)
|
|
129
|
+
min_priority = (args["important_only"] == true) ? Domain::Observation::IMPORTANT : nil
|
|
130
|
+
|
|
131
|
+
store = get_store_for_scope(scope)
|
|
132
|
+
return {error: "Database not available"} unless store
|
|
133
|
+
|
|
134
|
+
rows = store.recent_observations(scope: scope, limit: limit, min_priority: min_priority)
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
observation_count: rows.size,
|
|
138
|
+
observations: rows.map { |row|
|
|
139
|
+
obs = Domain::Observation.new(row)
|
|
140
|
+
{
|
|
141
|
+
id: obs.id,
|
|
142
|
+
kind: obs.kind,
|
|
143
|
+
priority: obs.priority,
|
|
144
|
+
body: obs.body,
|
|
145
|
+
scope: obs.scope,
|
|
146
|
+
status: obs.status,
|
|
147
|
+
corroboration_count: obs.corroboration_count,
|
|
148
|
+
promoted_fact_id: obs.promoted_fact_id,
|
|
149
|
+
consolidated_into: obs.consolidated_into,
|
|
150
|
+
source_content_item_id: obs.source_content_item_id,
|
|
151
|
+
observed_at: obs.observed_at,
|
|
152
|
+
observed_ago: Core::RelativeTime.format(obs.observed_at)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
rescue Sequel::DatabaseError, Sequel::DatabaseConnectionError, Errno::ENOENT => e
|
|
157
|
+
classified_error(e, tool_name: "memory.observations")
|
|
158
|
+
end
|
|
112
159
|
end
|
|
113
160
|
end
|
|
114
161
|
end
|
|
@@ -125,6 +125,7 @@ module ClaudeMemory
|
|
|
125
125
|
- Before refactoring: call memory.decisions to understand why past choices were made
|
|
126
126
|
- When asked about preferences: global facts store user environment and style preferences across all projects
|
|
127
127
|
- When adding to the codebase: recall which files and patterns to follow (memory knows correct paths and relationships)
|
|
128
|
+
- When reviewing what happened or curating memory: call memory.observations to read the episodic log and surface corroborated patterns worth promoting to facts
|
|
128
129
|
GUIDANCE
|
|
129
130
|
end
|
|
130
131
|
|
|
@@ -87,6 +87,34 @@ module ClaudeMemory
|
|
|
87
87
|
- Use after: performing LLM-based fact extraction on undistilled content
|
|
88
88
|
- Cost: ~100 tokens per call
|
|
89
89
|
|
|
90
|
+
### Tier 6: Episodic Observation Management
|
|
91
|
+
|
|
92
|
+
Observations are the episodic "what happened" log that complements facts
|
|
93
|
+
("what is true"). They accrue automatically; these tools let you inspect
|
|
94
|
+
and curate them. The `/reflect` skill wraps the full surveyāconsolidateā
|
|
95
|
+
promote workflow if you want it guided.
|
|
96
|
+
|
|
97
|
+
**memory.observations** ā Read the episodic observation log
|
|
98
|
+
- Use for: surveying recent narrative ("what happened"), spotting patterns
|
|
99
|
+
that recur enough to become facts, checking promotion readiness
|
|
100
|
+
- Returns: observations with status/kind/priority, corroboration_count,
|
|
101
|
+
and promoted_fact_id where promoted
|
|
102
|
+
- Cost: ~200-500 tokens per call
|
|
103
|
+
|
|
104
|
+
**memory.promote_observation** ā Promote a corroborated observation to a fact
|
|
105
|
+
- Use when: an observation has been corroborated (seen ā„ 2 times) and
|
|
106
|
+
represents a stable truth worth committing as a structured fact
|
|
107
|
+
- Gate: refuses uncorroborated or already-promoted observations ā this is
|
|
108
|
+
the anti-hallucination defense against one-off doc/example text
|
|
109
|
+
- Embed a reason in the object ("⦠because ā¦", "⦠so that ā¦")
|
|
110
|
+
- Cost: ~200 tokens per call
|
|
111
|
+
|
|
112
|
+
**memory.consolidate_observations** ā Merge related observations into one
|
|
113
|
+
- Use when: several observations describe the same thing in different words
|
|
114
|
+
- Effect: corroboration counts combine (which can tip the merged row past
|
|
115
|
+
the promotion gate); sources are tombstoned (preserved, not deleted)
|
|
116
|
+
- Cost: ~200 tokens per call
|
|
117
|
+
|
|
90
118
|
## Recommended Workflow
|
|
91
119
|
|
|
92
120
|
1. **Start broad**: `memory.recall` or shortcut tools (decisions/conventions/architecture)
|
|
@@ -276,6 +276,19 @@ module ClaudeMemory
|
|
|
276
276
|
required: ["title", "summary"]
|
|
277
277
|
}
|
|
278
278
|
},
|
|
279
|
+
observations: {
|
|
280
|
+
type: "array",
|
|
281
|
+
description: "Episodic observations ā what happened this session (experimental, observational layer). Complements facts ('what is true') with 'what happened': decisions made, preferences stated, notable actions/outcomes. One per discrete event; embed a reason for decisions/preferences.",
|
|
282
|
+
items: {
|
|
283
|
+
type: "object",
|
|
284
|
+
properties: {
|
|
285
|
+
body: {type: "string", description: "Concise statement of what happened"},
|
|
286
|
+
kind: {type: "string", enum: %w[user_statement agent_action tool_result preference decision event], description: "What kind of event (default: event)"},
|
|
287
|
+
priority: {type: "integer", enum: [1, 2, 3], description: "Internal signal: 1=important, 2=maybe, 3=info (default: 3)"}
|
|
288
|
+
},
|
|
289
|
+
required: ["body"]
|
|
290
|
+
}
|
|
291
|
+
},
|
|
279
292
|
scope: {type: "string", enum: ["global", "project"], description: "Default scope for facts", default: "project"}
|
|
280
293
|
},
|
|
281
294
|
required: ["facts"]
|
|
@@ -451,6 +464,51 @@ module ClaudeMemory
|
|
|
451
464
|
}
|
|
452
465
|
},
|
|
453
466
|
annotations: READ_ONLY
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
name: "memory.observations",
|
|
470
|
+
description: "List recent episodic observations ā the 'what happened' log that complements facts ('what is true'). Append-only, newest first. Priority is an internal signal (1=important, 2=maybe, 3=info).",
|
|
471
|
+
inputSchema: {
|
|
472
|
+
type: "object",
|
|
473
|
+
properties: {
|
|
474
|
+
scope: {type: "string", enum: %w[project global], description: "Filter by scope; omit for any"},
|
|
475
|
+
limit: {type: "integer", default: 20, description: "Maximum observations to return"},
|
|
476
|
+
important_only: {type: "boolean", default: false, description: "Return only priority-1 (š“) observations"}
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
annotations: READ_ONLY
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: "memory.promote_observation",
|
|
483
|
+
description: "Promote a corroborated observation into a structured fact (the observationāfact bridge). Refuses observations sighted fewer than the corroboration threshold (anti-hallucination gate). Embed a reason in the object (becauseā¦/so thatā¦). Surfaced by the SessionStart 'Observation Reflection' section.",
|
|
484
|
+
inputSchema: {
|
|
485
|
+
type: "object",
|
|
486
|
+
properties: {
|
|
487
|
+
observation_id: {type: "integer", description: "The corroborated observation to promote"},
|
|
488
|
+
predicate: {type: "string", description: "Fact predicate (e.g. decision, convention, architecture)"},
|
|
489
|
+
object: {type: "string", description: "Fact object ā include a reason clause"},
|
|
490
|
+
subject: {type: "string", default: "repo", description: "Fact subject (default: repo)"},
|
|
491
|
+
scope: {type: "string", enum: %w[project global], default: "project"}
|
|
492
|
+
},
|
|
493
|
+
required: ["observation_id", "predicate", "object"]
|
|
494
|
+
},
|
|
495
|
+
annotations: WRITE
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "memory.consolidate_observations",
|
|
499
|
+
description: "Semantic reflection: merge several related observations (that say the same thing in different words) into one synthesized observation. Corroboration combines, which can tip the result over the promotion threshold. The originals are tombstoned (preserved, linked), not deleted.",
|
|
500
|
+
inputSchema: {
|
|
501
|
+
type: "object",
|
|
502
|
+
properties: {
|
|
503
|
+
from_ids: {type: "array", items: {type: "integer"}, minItems: 2, description: "Observation ids to merge (>= 2, same scope)"},
|
|
504
|
+
body: {type: "string", description: "The synthesized observation text"},
|
|
505
|
+
kind: {type: "string", enum: %w[user_statement agent_action tool_result preference decision event], description: "Kind of the merged observation (default: event)"},
|
|
506
|
+
priority: {type: "integer", enum: [1, 2, 3], description: "1=important, 2=maybe, 3=info (default: 3)"},
|
|
507
|
+
scope: {type: "string", enum: %w[project global], default: "project"}
|
|
508
|
+
},
|
|
509
|
+
required: ["from_ids", "body"]
|
|
510
|
+
},
|
|
511
|
+
annotations: WRITE
|
|
454
512
|
}
|
|
455
513
|
]
|
|
456
514
|
end
|
|
@@ -98,6 +98,9 @@ module ClaudeMemory
|
|
|
98
98
|
when "memory.check_setup" then check_setup
|
|
99
99
|
when "memory.list_projects" then list_projects
|
|
100
100
|
when "memory.activity" then activity(arguments)
|
|
101
|
+
when "memory.observations" then observations(arguments)
|
|
102
|
+
when "memory.promote_observation" then promote_observation(arguments)
|
|
103
|
+
when "memory.consolidate_observations" then consolidate_observations(arguments)
|
|
101
104
|
else {error: "Unknown tool: #{name}"}
|
|
102
105
|
end
|
|
103
106
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Observe
|
|
5
|
+
# Renders episodic observation rows into the actor-facing markdown block ā
|
|
6
|
+
# the front-loaded "what happened" log that complements the fact snapshot
|
|
7
|
+
# ("what is true").
|
|
8
|
+
#
|
|
9
|
+
# Priority is an internal Observer/Reflector signal. Following Mastra, only
|
|
10
|
+
# š“ (important) survives as a marker when shown to the actor; š”/š¢ are
|
|
11
|
+
# stripped as visual noise. The observation bodies themselves are always
|
|
12
|
+
# shown ā the emoji is the only thing filtered.
|
|
13
|
+
module ObservationsRenderer
|
|
14
|
+
IMPORTANT_MARKER = "š“"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param observations [Array<Hash>] rows with :body, :priority, :observed_at
|
|
19
|
+
# @param title [String] section heading
|
|
20
|
+
# @param intro [Boolean] include the one-line explainer (true for injection)
|
|
21
|
+
# @return [String, nil] markdown block, or nil when there is nothing to show
|
|
22
|
+
def render(observations, title: "Observations (what happened)", intro: true)
|
|
23
|
+
rows = Array(observations).reject { |o| o[:body].to_s.strip.empty? }
|
|
24
|
+
return nil if rows.empty?
|
|
25
|
+
|
|
26
|
+
lines = ["## #{title}"]
|
|
27
|
+
if intro
|
|
28
|
+
lines << ""
|
|
29
|
+
lines << "Episodic log of what happened in this project ā complements the facts above (what is true). Newest first."
|
|
30
|
+
end
|
|
31
|
+
lines << ""
|
|
32
|
+
rows.each { |obs| lines << format_line(obs) }
|
|
33
|
+
lines.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String] a single "- [#id] [š“ ]body (time ago)" log line. The
|
|
37
|
+
# id tag lets the reflection step reference an observation for
|
|
38
|
+
# promote/consolidate; it's omitted when the row carries no id.
|
|
39
|
+
def format_line(obs)
|
|
40
|
+
body = obs[:body].to_s.strip
|
|
41
|
+
id_tag = obs[:id] ? "[##{obs[:id]}] " : ""
|
|
42
|
+
marker = (obs[:priority] == Domain::Observation::IMPORTANT) ? "#{IMPORTANT_MARKER} " : ""
|
|
43
|
+
ago = Core::RelativeTime.format(obs[:observed_at])
|
|
44
|
+
suffix = ago ? " (#{ago})" : ""
|
|
45
|
+
"- #{id_tag}#{marker}#{body}#{suffix}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Observe
|
|
5
|
+
# Deterministic, free (no LLM) Reflector for the episodic observation log.
|
|
6
|
+
#
|
|
7
|
+
# Runs inside Sweep, which fires on PreCompact and SessionEnd ā Claude
|
|
8
|
+
# Code's context-pressure lifecycle events. That is the analog of Mastra's
|
|
9
|
+
# token-threshold-triggered reflection: "reflect when memory gets big" maps
|
|
10
|
+
# onto "reflect when the session is about to compact", without a wall-clock
|
|
11
|
+
# timer (Claude Code has no cron hook) and without extra API cost.
|
|
12
|
+
#
|
|
13
|
+
# Two passes, both provenance-preserving (tombstone, never hard-delete):
|
|
14
|
+
# - dedupe: collapse near-duplicate active observations (same scope) into
|
|
15
|
+
# the newest, linking losers via consolidated_into. Similarity is decided
|
|
16
|
+
# by an injected matcher (default: lexical token-overlap, #73) so the
|
|
17
|
+
# promotion gate can actually accumulate corroboration ā exact-string
|
|
18
|
+
# matching never folded varied wording, leaving every observation at
|
|
19
|
+
# corroboration 1 (the 2026-06-23 audit finding).
|
|
20
|
+
# - expire_stale_info: retire info-level (š¢ / priority 3) observations
|
|
21
|
+
# older than the TTL to bound context size. Important (š“) and maybe
|
|
22
|
+
# (š”) are never expired ā only the lowest-signal tier ages out.
|
|
23
|
+
#
|
|
24
|
+
# Semantic consolidation ("combine related items, surface patterns") is
|
|
25
|
+
# deliberately NOT here ā it needs the LLM and lands in the Phase-4
|
|
26
|
+
# Claude-as-reflector pass. This pass is pure Ruby so it can run shell-side
|
|
27
|
+
# in the sweep hook for free.
|
|
28
|
+
class Reflector
|
|
29
|
+
DEFAULT_INFO_TTL_DAYS = 30
|
|
30
|
+
|
|
31
|
+
# @return [Struct] counts from one reflection pass
|
|
32
|
+
Result = Struct.new(:deduped, :expired) do
|
|
33
|
+
def total
|
|
34
|
+
deduped + expired
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(store, info_ttl_days: DEFAULT_INFO_TTL_DAYS, matcher: TokenOverlapMatcher.new)
|
|
39
|
+
@store = store
|
|
40
|
+
@info_ttl_days = info_ttl_days
|
|
41
|
+
@matcher = matcher
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Result] number of observations deduped and expired
|
|
45
|
+
def reflect!
|
|
46
|
+
deduped = 0
|
|
47
|
+
expired = 0
|
|
48
|
+
@store.db.transaction do
|
|
49
|
+
deduped = dedupe
|
|
50
|
+
expired = expire_stale_info
|
|
51
|
+
end
|
|
52
|
+
Result.new(deduped: deduped, expired: expired)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def dedupe
|
|
58
|
+
active = @store.observations.where(status: "active").order(:id).all
|
|
59
|
+
active.group_by { |o| o[:scope] }.sum { |_scope, rows| dedupe_scope(rows) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Greedy clustering within one scope: the newest observation in a cluster
|
|
63
|
+
# is the keeper; older near-duplicates fold into it. O(n²) matcher calls,
|
|
64
|
+
# but n is bounded (#74 cut the inflow; expire_stale_info bounds the tail).
|
|
65
|
+
def dedupe_scope(rows)
|
|
66
|
+
return 0 if rows.size < 2
|
|
67
|
+
|
|
68
|
+
ordered = rows.sort_by { |r| [r[:observed_at].to_s, r[:id]] }.reverse
|
|
69
|
+
folded = {}
|
|
70
|
+
merged = 0
|
|
71
|
+
|
|
72
|
+
ordered.each do |keeper|
|
|
73
|
+
next if folded[keeper[:id]]
|
|
74
|
+
|
|
75
|
+
ordered.each do |other|
|
|
76
|
+
next if other[:id] == keeper[:id] || folded[other[:id]]
|
|
77
|
+
next unless @matcher.similar?(keeper[:body], other[:body])
|
|
78
|
+
|
|
79
|
+
# Fold the duplicate's sightings into the keeper before tombstoning
|
|
80
|
+
# so corroboration survives consolidation and can cross the promotion
|
|
81
|
+
# threshold. A duplicate IS a repeated sighting.
|
|
82
|
+
@store.increment_corroboration(keeper[:id], by: other[:corroboration_count] || 1)
|
|
83
|
+
@store.tombstone_observation(other[:id], into_id: keeper[:id])
|
|
84
|
+
folded[other[:id]] = true
|
|
85
|
+
merged += 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
folded[keeper[:id]] = true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
merged
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def expire_stale_info
|
|
95
|
+
cutoff = (Time.now - @info_ttl_days * 86400).utc.iso8601
|
|
96
|
+
ids = @store.observations
|
|
97
|
+
.where(status: "active", priority: Domain::Observation::INFO)
|
|
98
|
+
.where { observed_at < cutoff }
|
|
99
|
+
.select(:id)
|
|
100
|
+
.map { |r| r[:id] }
|
|
101
|
+
|
|
102
|
+
ids.each { |id| @store.expire_observation(id) }
|
|
103
|
+
ids.size
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|