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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Domain
|
|
5
|
+
# Domain model representing an episodic observation — "what happened",
|
|
6
|
+
# as opposed to a Fact's "what is true". Instances are immutable (frozen).
|
|
7
|
+
#
|
|
8
|
+
# Priority follows Mastra's traffic-light scheme and is an internal signal
|
|
9
|
+
# for the Observer/Reflector pipeline: 1 = important (🔴), 2 = maybe (🟡),
|
|
10
|
+
# 3 = info only (🟢). Only 🔴 is meant to survive into the actor's prompt.
|
|
11
|
+
class Observation
|
|
12
|
+
KINDS = %w[user_statement agent_action tool_result preference decision event].freeze
|
|
13
|
+
IMPORTANT = 1
|
|
14
|
+
MAYBE = 2
|
|
15
|
+
INFO = 3
|
|
16
|
+
|
|
17
|
+
# Minimum corroboration (repeated sightings) before an observation may be
|
|
18
|
+
# promoted to a structured fact. The anti-hallucination gate: a one-off
|
|
19
|
+
# mention never becomes a committed fact.
|
|
20
|
+
PROMOTION_THRESHOLD = 2
|
|
21
|
+
|
|
22
|
+
attr_reader :id, :body, :kind, :priority, :scope, :project_path,
|
|
23
|
+
:source_content_item_id, :consolidated_into, :token_count,
|
|
24
|
+
:status, :session_id, :observed_at, :created_at, :reflected_at,
|
|
25
|
+
:corroboration_count, :promoted_at, :promoted_fact_id
|
|
26
|
+
|
|
27
|
+
# @param attributes [Hash] observation attributes (see column list)
|
|
28
|
+
# @raise [ArgumentError] if body is blank or priority is out of range
|
|
29
|
+
def initialize(attributes)
|
|
30
|
+
@id = attributes[:id]
|
|
31
|
+
@body = attributes[:body]
|
|
32
|
+
@kind = attributes[:kind] || "event"
|
|
33
|
+
@priority = attributes[:priority] || INFO
|
|
34
|
+
@scope = attributes[:scope] || "project"
|
|
35
|
+
@project_path = attributes[:project_path]
|
|
36
|
+
@source_content_item_id = attributes[:source_content_item_id]
|
|
37
|
+
@consolidated_into = attributes[:consolidated_into]
|
|
38
|
+
@token_count = attributes[:token_count]
|
|
39
|
+
@status = attributes[:status] || "active"
|
|
40
|
+
@session_id = attributes[:session_id]
|
|
41
|
+
@observed_at = attributes[:observed_at]
|
|
42
|
+
@created_at = attributes[:created_at]
|
|
43
|
+
@reflected_at = attributes[:reflected_at]
|
|
44
|
+
@corroboration_count = attributes[:corroboration_count] || 1
|
|
45
|
+
@promoted_at = attributes[:promoted_at]
|
|
46
|
+
@promoted_fact_id = attributes[:promoted_fact_id]
|
|
47
|
+
|
|
48
|
+
validate!
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] true when the observation has not been consolidated away
|
|
53
|
+
def active?
|
|
54
|
+
status == "active"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] true when the Reflector has merged this into another
|
|
58
|
+
def consolidated?
|
|
59
|
+
status == "consolidated"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] true when the Reflector retired this on TTL
|
|
63
|
+
def expired?
|
|
64
|
+
status == "expired"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] true once promoted into a structured fact
|
|
68
|
+
def promoted?
|
|
69
|
+
!promoted_at.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] true when corroborated enough to be promotion-eligible
|
|
73
|
+
def corroborated?(threshold)
|
|
74
|
+
corroboration_count >= threshold
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Boolean] true for 🔴 — the only priority shown to the actor
|
|
78
|
+
def important?
|
|
79
|
+
priority == IMPORTANT
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] true when scope is "global"
|
|
83
|
+
def global?
|
|
84
|
+
scope == "global"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Hash] all attributes as a plain hash
|
|
88
|
+
def to_h
|
|
89
|
+
{
|
|
90
|
+
id: id,
|
|
91
|
+
body: body,
|
|
92
|
+
kind: kind,
|
|
93
|
+
priority: priority,
|
|
94
|
+
scope: scope,
|
|
95
|
+
project_path: project_path,
|
|
96
|
+
source_content_item_id: source_content_item_id,
|
|
97
|
+
consolidated_into: consolidated_into,
|
|
98
|
+
token_count: token_count,
|
|
99
|
+
status: status,
|
|
100
|
+
session_id: session_id,
|
|
101
|
+
observed_at: observed_at,
|
|
102
|
+
created_at: created_at,
|
|
103
|
+
reflected_at: reflected_at,
|
|
104
|
+
corroboration_count: corroboration_count,
|
|
105
|
+
promoted_at: promoted_at,
|
|
106
|
+
promoted_fact_id: promoted_fact_id
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def validate!
|
|
113
|
+
raise ArgumentError, "body required" if body.nil? || body.empty?
|
|
114
|
+
raise ArgumentError, "priority must be 1, 2, or 3" unless (IMPORTANT..INFO).cover?(priority)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -65,7 +65,7 @@ module ClaudeMemory
|
|
|
65
65
|
return zero_vector if tokens.empty?
|
|
66
66
|
|
|
67
67
|
# Build term frequency map
|
|
68
|
-
tf_map = tokens.
|
|
68
|
+
tf_map = tokens.tally
|
|
69
69
|
|
|
70
70
|
# Normalize term frequencies
|
|
71
71
|
max_tf = tf_map.values.max.to_f
|
|
@@ -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,10 +63,21 @@ 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
|
sections << format_distillation_prompt(undistilled) if undistilled.any?
|
|
64
77
|
|
|
78
|
+
promotion = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
|
|
79
|
+
sections << format_observation_reflection(promotion) if promotion.any?
|
|
80
|
+
|
|
65
81
|
mirror_candidates = fetch_mirror_candidates(MAX_MIRROR_CANDIDATES)
|
|
66
82
|
if mirror_candidates.any?
|
|
67
83
|
sections << format_auto_memory_mirror(mirror_candidates)
|
|
@@ -74,6 +90,19 @@ module ClaudeMemory
|
|
|
74
90
|
sections.join("\n")
|
|
75
91
|
end
|
|
76
92
|
|
|
93
|
+
# Reflection-only context for PreCompact (context pressure) — just the
|
|
94
|
+
# promote/consolidate instructions for corroborated/related observations.
|
|
95
|
+
# PreCompact is the analog of Mastra's token-threshold reflection trigger;
|
|
96
|
+
# at compaction we nudge the Claude-as-reflector pass rather than
|
|
97
|
+
# re-inject the full snapshot (which would add tokens as the window fills).
|
|
98
|
+
# @return [String, nil]
|
|
99
|
+
def reflection_context
|
|
100
|
+
candidates = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
|
|
101
|
+
return nil if candidates.empty?
|
|
102
|
+
|
|
103
|
+
format_observation_reflection(candidates)
|
|
104
|
+
end
|
|
105
|
+
|
|
77
106
|
private
|
|
78
107
|
|
|
79
108
|
def fresh_session?
|
|
@@ -124,6 +153,67 @@ module ClaudeMemory
|
|
|
124
153
|
@stale_threshold_days ||= Configuration.new.injection_stale_days
|
|
125
154
|
end
|
|
126
155
|
|
|
156
|
+
# Automatic semantic reflection (Phase 4): surface observations that have
|
|
157
|
+
# been corroborated past the promotion threshold so Claude can promote
|
|
158
|
+
# them to facts inline, this session, at no extra API cost.
|
|
159
|
+
# Project store only: the reflection nudge renders bare `[obs #<id>]` and
|
|
160
|
+
# `memory.promote_observation`/`consolidate_observations` default to the
|
|
161
|
+
# project scope. Observation ids autoincrement *per-DB*, so mixing in
|
|
162
|
+
# global-store candidates would route a promote call to the wrong DB.
|
|
163
|
+
# Observations are written project-scoped on the ingest path today; if a
|
|
164
|
+
# global observation writer is ever added, scope-tag the nudge line
|
|
165
|
+
# (mirror @emitted_facts_by_scope) before broadening this.
|
|
166
|
+
def fetch_promotion_candidates(limit)
|
|
167
|
+
store = @manager.project_store
|
|
168
|
+
return [] unless store
|
|
169
|
+
|
|
170
|
+
store
|
|
171
|
+
.promotion_candidates(min_corroboration: Domain::Observation::PROMOTION_THRESHOLD, limit: limit)
|
|
172
|
+
.sort_by { |o| -(o[:corroboration_count] || 0) }
|
|
173
|
+
.first(limit)
|
|
174
|
+
rescue => e
|
|
175
|
+
ClaudeMemory.logger.warn("ContextInjector#fetch_promotion_candidates failed: #{e.message}")
|
|
176
|
+
[]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def format_observation_reflection(candidates)
|
|
180
|
+
lines = [
|
|
181
|
+
"## Observation Reflection",
|
|
182
|
+
"",
|
|
183
|
+
"**Promote:** these observations have recurred enough to be worth committing",
|
|
184
|
+
"as facts (corroboration gate passed). For each that represents a stable truth,",
|
|
185
|
+
"call `memory.promote_observation(observation_id, predicate, object)` — embed a",
|
|
186
|
+
"reason in the object (\"… because …\", \"… so that …\"). Skip noise / already-captured.",
|
|
187
|
+
"",
|
|
188
|
+
"**Consolidate:** if several observations in the log above (by `#id`) describe the",
|
|
189
|
+
"same thing in different words, merge them with",
|
|
190
|
+
"`memory.consolidate_observations(from_ids: […], body: \"<synthesis>\")`. Their",
|
|
191
|
+
"corroboration combines, which can tip the merged observation past the promotion gate."
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
candidates.each do |obs|
|
|
195
|
+
lines << ""
|
|
196
|
+
lines << "- [obs ##{obs[:id]} ×#{obs[:corroboration_count]}] #{obs[:body]}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
lines.join("\n")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def fetch_observations(limit)
|
|
203
|
+
stores = []
|
|
204
|
+
stores << @manager.project_store if @manager.project_store
|
|
205
|
+
stores << @manager.global_store if @manager.global_store
|
|
206
|
+
|
|
207
|
+
stores
|
|
208
|
+
.flat_map { |s| s.recent_observations(limit: limit) }
|
|
209
|
+
.sort_by { |o| o[:observed_at] || "" }
|
|
210
|
+
.reverse
|
|
211
|
+
.first(limit)
|
|
212
|
+
rescue => e
|
|
213
|
+
ClaudeMemory.logger.warn("ContextInjector#fetch_observations failed: #{e.message}")
|
|
214
|
+
[]
|
|
215
|
+
end
|
|
216
|
+
|
|
127
217
|
def fetch_undistilled(limit)
|
|
128
218
|
stores = []
|
|
129
219
|
stores << @manager.project_store if @manager.project_store
|
|
@@ -157,7 +247,15 @@ module ClaudeMemory
|
|
|
157
247
|
"in the object (e.g., \"… because …\", \"… so that …\", \"caused by …\",",
|
|
158
248
|
"\"breaks when …\"). A fact with a reason is recoverable once stale; a",
|
|
159
249
|
"bare conclusion is dead weight. Prefer one fact-with-reason over two",
|
|
160
|
-
"facts-without."
|
|
250
|
+
"facts-without.",
|
|
251
|
+
"",
|
|
252
|
+
"**Also log what happened (episodic layer):** in the same",
|
|
253
|
+
"`memory.store_extraction` call, populate `observations` — one per",
|
|
254
|
+
"discrete event (a decision made, a preference stated, a notable action",
|
|
255
|
+
"or outcome). Each: a concise `body` of what happened, a `kind`",
|
|
256
|
+
"(decision/preference/event/…), and a reason for decisions/preferences.",
|
|
257
|
+
"Observations record \"what happened\"; facts record \"what is true\". They",
|
|
258
|
+
"accumulate, and a corroborated observation can later graduate into a fact."
|
|
161
259
|
]
|
|
162
260
|
|
|
163
261
|
items.each do |item|
|
|
@@ -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
|