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
|
@@ -163,6 +163,39 @@
|
|
|
163
163
|
.delta.flat { color: var(--text-dim); }
|
|
164
164
|
.moments-sub { font-size: 12px; color: var(--text-dim); margin-top: 6px; }
|
|
165
165
|
|
|
166
|
+
/* Observations sidebar panel */
|
|
167
|
+
.obs-headline {
|
|
168
|
+
display: flex;
|
|
169
|
+
gap: 14px;
|
|
170
|
+
align-items: baseline;
|
|
171
|
+
margin-bottom: 6px;
|
|
172
|
+
}
|
|
173
|
+
.obs-headline .n {
|
|
174
|
+
font-size: 32px;
|
|
175
|
+
font-weight: 600;
|
|
176
|
+
letter-spacing: -0.02em;
|
|
177
|
+
line-height: 1;
|
|
178
|
+
}
|
|
179
|
+
.obs-headline .obs-stat { display: flex; flex-direction: column; gap: 2px; }
|
|
180
|
+
.obs-headline .obs-stat-n { font-size: 18px; font-weight: 600; color: var(--text); line-height: 1; }
|
|
181
|
+
.obs-headline .obs-stat-label { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
182
|
+
.obs-sub { font-size: 12px; color: var(--text-dim); margin: 8px 0; }
|
|
183
|
+
.obs-breakdowns { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
|
|
184
|
+
.obs-breakdown-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin-bottom: 4px; }
|
|
185
|
+
.obs-badges { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
186
|
+
.obs-badge {
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
gap: 4px;
|
|
190
|
+
padding: 2px 8px;
|
|
191
|
+
border-radius: 999px;
|
|
192
|
+
border: 1px solid var(--border);
|
|
193
|
+
background: var(--surface2);
|
|
194
|
+
font-size: 11px;
|
|
195
|
+
color: var(--text-dim);
|
|
196
|
+
}
|
|
197
|
+
.obs-badge .obs-badge-n { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
|
198
|
+
|
|
166
199
|
/* Knowledge-base sidebar panel */
|
|
167
200
|
.kb-totals {
|
|
168
201
|
display: flex;
|
|
@@ -1157,6 +1190,13 @@
|
|
|
1157
1190
|
<div class="moments-sub" id="moments-sub"></div>
|
|
1158
1191
|
</div>
|
|
1159
1192
|
|
|
1193
|
+
<div class="panel" id="panel-observations">
|
|
1194
|
+
<div class="panel-label">Episodic observations
|
|
1195
|
+
<span class="panel-hint" title="The 'what happened' log that complements facts ('what is true'). Observations accrue as the Observer runs, and become facts once corroborated ≥2×.">ⓘ</span>
|
|
1196
|
+
</div>
|
|
1197
|
+
<div id="obs-panel-body"></div>
|
|
1198
|
+
</div>
|
|
1199
|
+
|
|
1160
1200
|
<div class="panel" id="panel-review">
|
|
1161
1201
|
<div class="panel-label">Needs review</div>
|
|
1162
1202
|
<div class="review-rows" id="review-rows"></div>
|
|
@@ -1234,6 +1274,7 @@
|
|
|
1234
1274
|
</div>
|
|
1235
1275
|
<div class="drawer-tabs">
|
|
1236
1276
|
<div class="drawer-tab active" data-adv="knowledge">Knowledge</div>
|
|
1277
|
+
<div class="drawer-tab" data-adv="observations">Observations</div>
|
|
1237
1278
|
<div class="drawer-tab" data-adv="overview">Overview</div>
|
|
1238
1279
|
<div class="drawer-tab" data-adv="facts">Facts</div>
|
|
1239
1280
|
<div class="drawer-tab" data-adv="explore">Explore</div>
|
|
@@ -1253,6 +1294,13 @@
|
|
|
1253
1294
|
<div class="knowledge-body" id="knowledge-body"></div>
|
|
1254
1295
|
</div>
|
|
1255
1296
|
|
|
1297
|
+
<div class="drawer-panel" id="adv-observations">
|
|
1298
|
+
<div id="obs-summary" class="adv-card"></div>
|
|
1299
|
+
<div class="adv-card" style="padding: 0;">
|
|
1300
|
+
<div id="obs-recent"></div>
|
|
1301
|
+
</div>
|
|
1302
|
+
</div>
|
|
1303
|
+
|
|
1256
1304
|
<div class="drawer-panel" id="adv-overview">
|
|
1257
1305
|
<div id="overview-rows"></div>
|
|
1258
1306
|
<div class="adv-card">
|
|
@@ -1444,7 +1492,77 @@ async function api(path, params = {}) {
|
|
|
1444
1492
|
|
|
1445
1493
|
// ==================== Load cycle ====================
|
|
1446
1494
|
async function loadAll() {
|
|
1447
|
-
await Promise.all([loadHealth(), loadTrust(), loadMoments(), loadKnowledgePanel(), loadReusePanel()]);
|
|
1495
|
+
await Promise.all([loadHealth(), loadTrust(), loadMoments(), loadKnowledgePanel(), loadReusePanel(), loadObservationsPanel()]);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Sidebar "Episodic observations" panel — headline numbers + breakdowns.
|
|
1499
|
+
// The richer recent timeline lives in the Advanced drawer (loadObservations).
|
|
1500
|
+
async function loadObservationsPanel() {
|
|
1501
|
+
const data = await api('observations');
|
|
1502
|
+
const t = data.totals || {}, c = data.compression || {}, corr = data.corroboration || {};
|
|
1503
|
+
const body = document.getElementById('obs-panel-body');
|
|
1504
|
+
if (!body) return;
|
|
1505
|
+
|
|
1506
|
+
const active = t.active || 0;
|
|
1507
|
+
if (!active) {
|
|
1508
|
+
body.innerHTML = `
|
|
1509
|
+
<div class="feed-empty" style="padding: 24px 16px; text-align: left;">
|
|
1510
|
+
<h3>No observations yet</h3>
|
|
1511
|
+
<p>The episodic log — "what happened" — accrues as the Observer runs over your sessions. Once an observation is corroborated ≥2×, it becomes a fact.</p>
|
|
1512
|
+
</div>`;
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const promotable = corr.promotable || 0;
|
|
1517
|
+
body.innerHTML = `
|
|
1518
|
+
<div class="obs-headline">
|
|
1519
|
+
<div class="obs-stat">
|
|
1520
|
+
<span class="n">${active.toLocaleString()}</span>
|
|
1521
|
+
<span class="obs-stat-label">active</span>
|
|
1522
|
+
</div>
|
|
1523
|
+
<div class="obs-stat">
|
|
1524
|
+
<span class="obs-stat-n">${promotable.toLocaleString()}</span>
|
|
1525
|
+
<span class="obs-stat-label">ready to promote</span>
|
|
1526
|
+
</div>
|
|
1527
|
+
<div class="obs-stat">
|
|
1528
|
+
<span class="obs-stat-n">${c.ratio ? c.ratio + '×' : '—'}</span>
|
|
1529
|
+
<span class="obs-stat-label">compression</span>
|
|
1530
|
+
</div>
|
|
1531
|
+
</div>
|
|
1532
|
+
<div class="obs-sub">${promotable
|
|
1533
|
+
? `${promotable.toLocaleString()} ready to promote — observations corroborated ≥2× become facts (max seen ×${corr.max || 0}).`
|
|
1534
|
+
: `None ready yet — observations corroborated ≥2× become facts (max seen ×${corr.max || 0}).`}</div>
|
|
1535
|
+
<div class="obs-breakdowns">
|
|
1536
|
+
${renderObsBreakdown('By priority', priorityBadges(data.by_priority || {}))}
|
|
1537
|
+
${renderObsBreakdown('By kind', kindBadges(data.by_kind || {}))}
|
|
1538
|
+
</div>`;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const PRIORITY_MARKERS = {1: '🔴', 2: '🟡', 3: '🟢'};
|
|
1542
|
+
const PRIORITY_LABELS = {1: 'important', 2: 'maybe', 3: 'info'};
|
|
1543
|
+
|
|
1544
|
+
function priorityBadges(byPriority) {
|
|
1545
|
+
return [1, 2, 3]
|
|
1546
|
+
.filter(p => byPriority[p] || byPriority[String(p)])
|
|
1547
|
+
.map(p => {
|
|
1548
|
+
const n = byPriority[p] || byPriority[String(p)] || 0;
|
|
1549
|
+
return `<span class="obs-badge">${PRIORITY_MARKERS[p]} ${PRIORITY_LABELS[p]} <span class="obs-badge-n">${n}</span></span>`;
|
|
1550
|
+
}).join('');
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function kindBadges(byKind) {
|
|
1554
|
+
return Object.entries(byKind)
|
|
1555
|
+
.sort((a, b) => b[1] - a[1])
|
|
1556
|
+
.map(([kind, n]) => `<span class="obs-badge">${esc(kind)} <span class="obs-badge-n">${n}</span></span>`)
|
|
1557
|
+
.join('');
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function renderObsBreakdown(label, badgesHtml) {
|
|
1561
|
+
if (!badgesHtml) return '';
|
|
1562
|
+
return `<div>
|
|
1563
|
+
<div class="obs-breakdown-label">${label}</div>
|
|
1564
|
+
<div class="obs-badges">${badgesHtml}</div>
|
|
1565
|
+
</div>`;
|
|
1448
1566
|
}
|
|
1449
1567
|
|
|
1450
1568
|
async function loadKnowledgePanel() {
|
|
@@ -2180,7 +2298,41 @@ function switchAdvTab(name) {
|
|
|
2180
2298
|
|
|
2181
2299
|
// ==================== Advanced drawer loaders ====================
|
|
2182
2300
|
async function loadAdvanced() {
|
|
2183
|
-
await Promise.all([loadKnowledge(), loadOverview(), loadFacts(), loadEfficacy(), loadConflicts(), loadActivityLog(), loadTelemetry()]);
|
|
2301
|
+
await Promise.all([loadKnowledge(), loadObservations(), loadOverview(), loadFacts(), loadEfficacy(), loadConflicts(), loadActivityLog(), loadTelemetry()]);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
async function loadObservations() {
|
|
2305
|
+
const data = await api('observations');
|
|
2306
|
+
const t = data.totals || {}, c = data.compression || {}, corr = data.corroboration || {};
|
|
2307
|
+
const promotable = corr.promotable || 0;
|
|
2308
|
+
const summary = document.getElementById('obs-summary');
|
|
2309
|
+
if (summary) {
|
|
2310
|
+
summary.innerHTML = `
|
|
2311
|
+
<h3>Episodic observations <span style="color: var(--text-faint); font-weight: normal;">— what happened</span></h3>
|
|
2312
|
+
<div style="display: flex; flex-wrap: wrap; gap: 16px; color: var(--text-dim); font-size: 13px;">
|
|
2313
|
+
<span><span class="big">${(t.active || 0).toLocaleString()}</span> active</span>
|
|
2314
|
+
<span>${t.consolidated || 0} consolidated · ${t.expired || 0} expired · ${t.promoted || 0} promoted</span>
|
|
2315
|
+
<span>${promotable} ready to promote (max ×${corr.max || 0}) — observations corroborated ≥2× become facts</span>
|
|
2316
|
+
<span>compression ${c.ratio ? c.ratio + '×' : '—'} <span style="color: var(--text-faint);">(${(c.source_tokens || 0).toLocaleString()} → ${(c.observation_tokens || 0).toLocaleString()} tok)</span></span>
|
|
2317
|
+
</div>
|
|
2318
|
+
<div class="obs-breakdowns" style="margin-top: 12px;">
|
|
2319
|
+
${renderObsBreakdown('By priority', priorityBadges(data.by_priority || {}))}
|
|
2320
|
+
${renderObsBreakdown('By kind', kindBadges(data.by_kind || {}))}
|
|
2321
|
+
</div>`;
|
|
2322
|
+
}
|
|
2323
|
+
const recentEl = document.getElementById('obs-recent');
|
|
2324
|
+
const recent = data.recent || [];
|
|
2325
|
+
if (recentEl) {
|
|
2326
|
+
recentEl.innerHTML = recent.length ? recent.map(o => `
|
|
2327
|
+
<div style="padding: 8px 12px; border-bottom: 1px solid var(--border);">
|
|
2328
|
+
<span style="color: var(--text-faint); font-size: 11px;">#${o.id} · ${esc(o.kind)} · p${o.priority}${o.corroboration_count > 1 ? ' · ×' + o.corroboration_count : ''} · ${esc(o.observed_ago || '')}</span>
|
|
2329
|
+
<div style="color: var(--text); font-size: 13px;">${PRIORITY_MARKERS[o.priority] ? PRIORITY_MARKERS[o.priority] + ' ' : ''}${esc(o.body || '')}</div>
|
|
2330
|
+
</div>`).join('') : `
|
|
2331
|
+
<div class="feed-empty" style="text-align: left;">
|
|
2332
|
+
<h3>No observations yet</h3>
|
|
2333
|
+
<p>The episodic log — "what happened" — accrues from your sessions as the Observer runs. Once an observation is corroborated ≥2×, it gets promoted into a fact.</p>
|
|
2334
|
+
</div>`;
|
|
2335
|
+
}
|
|
2184
2336
|
}
|
|
2185
2337
|
|
|
2186
2338
|
async function loadTelemetry() {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Observability for the episodic observation layer. Surfaces counts by
|
|
6
|
+
# status/kind/priority, corroboration + promotion readiness, a Mastra-style
|
|
7
|
+
# compression ratio (source content tokens ÷ observation tokens), and a
|
|
8
|
+
# recent timeline. Aggregated across the project and global stores.
|
|
9
|
+
#
|
|
10
|
+
# Pulled out of Dashboard::API so the queries live next to the data.
|
|
11
|
+
class Observations
|
|
12
|
+
RECENT_LIMIT = 20
|
|
13
|
+
|
|
14
|
+
def initialize(manager)
|
|
15
|
+
@manager = manager
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def report
|
|
19
|
+
stores = observation_stores
|
|
20
|
+
return empty_report if stores.empty?
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
totals: totals(stores),
|
|
24
|
+
by_kind: by_field(stores, :kind),
|
|
25
|
+
by_priority: by_field(stores, :priority),
|
|
26
|
+
corroboration: corroboration(stores),
|
|
27
|
+
compression: compression(stores),
|
|
28
|
+
recent: recent(stores)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def observation_stores
|
|
35
|
+
[@manager.project_store, @manager.global_store].compact.select { |s| s.db.table_exists?(:observations) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def empty_report
|
|
39
|
+
{
|
|
40
|
+
totals: {active: 0, consolidated: 0, expired: 0, promoted: 0},
|
|
41
|
+
by_kind: {}, by_priority: {},
|
|
42
|
+
corroboration: {max: 0, promotable: 0},
|
|
43
|
+
compression: {observation_tokens: 0, source_tokens: 0, ratio: nil},
|
|
44
|
+
recent: []
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def totals(stores)
|
|
49
|
+
{
|
|
50
|
+
active: count_where(stores, status: "active"),
|
|
51
|
+
consolidated: count_where(stores, status: "consolidated"),
|
|
52
|
+
expired: count_where(stores, status: "expired"),
|
|
53
|
+
promoted: stores.sum { |s| s.observations.exclude(promoted_at: nil).count }
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def count_where(stores, **filter)
|
|
58
|
+
stores.sum { |s| s.observations.where(**filter).count }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def by_field(stores, field)
|
|
62
|
+
merged = Hash.new(0)
|
|
63
|
+
stores.each do |store|
|
|
64
|
+
store.observations.where(status: "active").group_and_count(field).each do |row|
|
|
65
|
+
merged[row[field]] += row[:count]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
merged
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def corroboration(stores)
|
|
72
|
+
threshold = Domain::Observation::PROMOTION_THRESHOLD
|
|
73
|
+
{
|
|
74
|
+
max: stores.map { |s| s.observations.where(status: "active").max(:corroboration_count) || 0 }.max,
|
|
75
|
+
promotable: stores.sum { |s|
|
|
76
|
+
s.observations.where(status: "active", promoted_at: nil).where { corroboration_count >= threshold }.count
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Source content tokens vs the tokens the observations distilled them into.
|
|
82
|
+
# ratio > 1 means the episodic log is a compression of its source.
|
|
83
|
+
def compression(stores)
|
|
84
|
+
obs_tokens = stores.sum { |s| s.observations.where(status: "active").sum(:token_count) || 0 }
|
|
85
|
+
source_tokens = stores.sum { |s| source_tokens_for(s) }
|
|
86
|
+
ratio = obs_tokens.zero? ? nil : (source_tokens.to_f / obs_tokens).round(1)
|
|
87
|
+
{observation_tokens: obs_tokens, source_tokens: source_tokens, ratio: ratio}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def source_tokens_for(store)
|
|
91
|
+
ids = store.observations
|
|
92
|
+
.where(status: "active").exclude(source_content_item_id: nil)
|
|
93
|
+
.distinct.select(:source_content_item_id)
|
|
94
|
+
.map { |r| r[:source_content_item_id] }
|
|
95
|
+
return 0 if ids.empty?
|
|
96
|
+
|
|
97
|
+
bytes = store.content_items.where(id: ids).sum(:byte_len) || 0
|
|
98
|
+
(bytes / 4.0).round
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def recent(stores)
|
|
102
|
+
stores
|
|
103
|
+
.flat_map { |s| s.recent_observations(limit: RECENT_LIMIT) }
|
|
104
|
+
.sort_by { |o| o[:observed_at].to_s }.reverse.first(RECENT_LIMIT)
|
|
105
|
+
.map do |o|
|
|
106
|
+
{
|
|
107
|
+
id: o[:id], kind: o[:kind], priority: o[:priority],
|
|
108
|
+
corroboration_count: o[:corroboration_count], body: o[:body],
|
|
109
|
+
observed_ago: Core::RelativeTime.format(o[:observed_at])
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -62,6 +62,7 @@ module ClaudeMemory
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
@server.mount_proc("/api/timeline") { |_req, res| with_fresh_connections { json_response(res, api.timeline) } }
|
|
65
|
+
@server.mount_proc("/api/observations") { |_req, res| with_fresh_connections { json_response(res, api.observations) } }
|
|
65
66
|
@server.mount_proc("/api/recall") { |req, res| with_fresh_connections { json_response(res, api.recall(req.query)) } }
|
|
66
67
|
@server.mount_proc("/api/conflicts") { |req, res| with_fresh_connections { handle_conflicts(api, req, res) } }
|
|
67
68
|
@server.mount_proc("/api/moments") { |req, res| with_fresh_connections { handle_moments(api, req, res) } }
|
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
module ClaudeMemory
|
|
4
4
|
module Distill
|
|
5
5
|
class Extraction
|
|
6
|
-
attr_reader :entities, :facts, :decisions, :signals
|
|
6
|
+
attr_reader :entities, :facts, :decisions, :signals, :observations
|
|
7
7
|
|
|
8
|
-
def initialize(entities: [], facts: [], decisions: [], signals: [])
|
|
8
|
+
def initialize(entities: [], facts: [], decisions: [], signals: [], observations: [])
|
|
9
9
|
@entities = entities
|
|
10
10
|
@facts = facts
|
|
11
11
|
@decisions = decisions
|
|
12
12
|
@signals = signals
|
|
13
|
+
@observations = observations
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def empty?
|
|
16
|
-
entities.empty? && facts.empty? && decisions.empty? && signals.empty?
|
|
17
|
+
entities.empty? && facts.empty? && decisions.empty? && signals.empty? && observations.empty?
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def to_h
|
|
@@ -21,7 +22,8 @@ module ClaudeMemory
|
|
|
21
22
|
entities: entities,
|
|
22
23
|
facts: facts,
|
|
23
24
|
decisions: decisions,
|
|
24
|
-
signals: signals
|
|
25
|
+
signals: signals,
|
|
26
|
+
observations: observations
|
|
25
27
|
}
|
|
26
28
|
end
|
|
27
29
|
end
|
|
@@ -21,7 +21,10 @@ module ClaudeMemory
|
|
|
21
21
|
ENTITY_PATTERNS = {
|
|
22
22
|
"database" => /\b(postgresql|postgres|mysql|sqlite|mongodb|redis)\b/i,
|
|
23
23
|
"framework" => /\b(rails|sinatra|django|express|next\.?js|react|vue)\b/i,
|
|
24
|
-
|
|
24
|
+
# `Go` is matched case-sensitively (via the inline (?-i:) flag) so the
|
|
25
|
+
# English verb "go" / "go-to" doesn't masquerade as the language; the
|
|
26
|
+
# other languages stay case-insensitive. `golang` normalizes to `go`.
|
|
27
|
+
"language" => /\b(ruby|python|javascript|typescript|rust|(?-i:Go)|golang)\b/i,
|
|
25
28
|
"platform" => /\b(aws|gcp|azure|heroku|vercel|netlify|docker|kubernetes)\b/i
|
|
26
29
|
}.freeze
|
|
27
30
|
|
|
@@ -35,17 +38,49 @@ module ClaudeMemory
|
|
|
35
38
|
/\buniversally\b/i
|
|
36
39
|
].freeze
|
|
37
40
|
|
|
41
|
+
# Observation-specific convention patterns: stricter than the shared
|
|
42
|
+
# CONVENTION_PATTERNS. Bare `always (.+)` / `never (.+)` / `we use (.+)`
|
|
43
|
+
# match code, prose, and instruction text ("never answer from memory",
|
|
44
|
+
# "never nil. def …"), so observations require explicit convention
|
|
45
|
+
# framing or a first-person "we always/never".
|
|
46
|
+
OBSERVATION_CONVENTION_PATTERNS = [
|
|
47
|
+
/\bconvention[:\s]+(.+)/i,
|
|
48
|
+
/\bstandard[:\s]+(.+)/i,
|
|
49
|
+
/\bwe\s+(?:should\s+)?(?:always|never)\s+(.+)/i
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
# Bodies that look like code / JSON / shell / markup / transcript rather
|
|
53
|
+
# than a prose statement. High-precision gate: the Layer-1 observer scrapes
|
|
54
|
+
# raw transcript spans, which on a code-heavy project are dominated by
|
|
55
|
+
# source, specs, docs, and tool output — none of which are observations.
|
|
56
|
+
# (2026-06-23 audit, improvements #74: the prior signature let 38/117
|
|
57
|
+
# obvious-noise rows through — spec fixtures like `kind: "decision"`,
|
|
58
|
+
# CHANGELOG table rows, benchmark tree output, the distiller's own source
|
|
59
|
+
# comments — and they were being injected into SessionStart.)
|
|
60
|
+
NOISE_BODY_SIGNATURE = Regexp.union(
|
|
61
|
+
/\bdef\s|\bclass\s|\bmodule\s/, # Ruby definitions
|
|
62
|
+
/=>|::|","|":\s*"|[{}]|\$\(|&&|\|\|/, # code / JSON / shell punctuation
|
|
63
|
+
/\w+:\s*["\[{\d]/, # code/JSON key: "value" / key: 1 / key: [
|
|
64
|
+
/\w\(/, # method/function call: expect(, insert_observation(
|
|
65
|
+
/\s\|\s/, # spaced table pipe (doc / CHANGELOG rows)
|
|
66
|
+
/[\u{2500}-\u{257f}]/, # box-drawing glyphs (tree / benchmark output)
|
|
67
|
+
/\(vector\)|\(text\)/, # benchmark mode labels
|
|
68
|
+
/parentUuid|isSidechain|toolUseID|hookName|"type":/ # raw JSONL transcript fields
|
|
69
|
+
)
|
|
70
|
+
|
|
38
71
|
def distill(text, content_item_id: nil)
|
|
39
72
|
entities = extract_entities(text)
|
|
40
73
|
facts = extract_facts(text, entities)
|
|
41
74
|
decisions = extract_decisions(text)
|
|
42
75
|
signals = extract_signals(text)
|
|
76
|
+
observations = extract_observations(text)
|
|
43
77
|
|
|
44
78
|
Extraction.new(
|
|
45
79
|
entities: entities,
|
|
46
80
|
facts: facts,
|
|
47
81
|
decisions: decisions,
|
|
48
|
-
signals: signals
|
|
82
|
+
signals: signals,
|
|
83
|
+
observations: observations
|
|
49
84
|
)
|
|
50
85
|
end
|
|
51
86
|
|
|
@@ -55,7 +90,9 @@ module ClaudeMemory
|
|
|
55
90
|
found = []
|
|
56
91
|
ENTITY_PATTERNS.each do |type, pattern|
|
|
57
92
|
text.scan(pattern).flatten.uniq.each do |name|
|
|
58
|
-
|
|
93
|
+
normalized = name.downcase
|
|
94
|
+
normalized = "go" if normalized == "golang"
|
|
95
|
+
found << {type: type, name: normalized, confidence: 0.7}
|
|
59
96
|
end
|
|
60
97
|
end
|
|
61
98
|
found.uniq { |e| [e[:type], e[:name]] }
|
|
@@ -103,6 +140,74 @@ module ClaudeMemory
|
|
|
103
140
|
signals
|
|
104
141
|
end
|
|
105
142
|
|
|
143
|
+
# Layer-1 Observer: emit episodic observations for the same signals the
|
|
144
|
+
# distiller can detect by regex. A decision being made and a convention
|
|
145
|
+
# being stated are both "things that happened" worth logging in the
|
|
146
|
+
# episodic layer, independent of the semantic facts they also produce.
|
|
147
|
+
# Decisions are 🔴 (important), conventions 🟡 (maybe) — the priority is
|
|
148
|
+
# an internal Observer/Reflector signal. Richer observations come from
|
|
149
|
+
# the Layer-2 Claude-as-observer pass (a later phase).
|
|
150
|
+
def extract_observations(text)
|
|
151
|
+
observations = []
|
|
152
|
+
scope_hint = global_scope_signal?(text) ? "global" : "project"
|
|
153
|
+
|
|
154
|
+
DECISION_PATTERNS.each do |pattern|
|
|
155
|
+
text.scan(pattern).flatten.each do |match|
|
|
156
|
+
observations << build_observation("decision", Domain::Observation::IMPORTANT, "decided to #{match.strip}", scope_hint)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
OBSERVATION_CONVENTION_PATTERNS.each do |pattern|
|
|
161
|
+
text.scan(pattern).flatten.each do |match|
|
|
162
|
+
observations << build_observation("preference", Domain::Observation::MAYBE, match.strip, scope_hint)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
observations.compact.uniq { |o| [o[:kind], o[:body]] }.first(10)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Returns nil for content that isn't a usable statement: code/JSON noise,
|
|
170
|
+
# or fewer than three words after trimming to the first sentence.
|
|
171
|
+
def build_observation(kind, priority, body, scope_hint)
|
|
172
|
+
cleaned = trim_to_statement(clean_observation_body(body))
|
|
173
|
+
return nil if cleaned.empty? || noise_body?(cleaned) || cleaned.split.size < 3
|
|
174
|
+
|
|
175
|
+
{kind: kind, priority: priority, body: cleaned.slice(0, 500), scope_hint: scope_hint}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Cap a captured span to its first sentence (and a hard length limit) so a
|
|
179
|
+
# greedy `.+` match can't swallow a whole code block or JSON line.
|
|
180
|
+
def trim_to_statement(text)
|
|
181
|
+
s = text.to_s.strip
|
|
182
|
+
(s[/\A.{0,240}?[.!?](?=\s|\z)/m] || s[0, 240]).to_s.strip
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A usable observation reads as a prose sentence. Reject anything that
|
|
186
|
+
# doesn't begin like one (leading /, |, ·, or box-drawing glyphs from a
|
|
187
|
+
# code comment or tool output) or that carries a code/markup/transcript
|
|
188
|
+
# signature.
|
|
189
|
+
def noise_body?(body)
|
|
190
|
+
return true unless body.match?(/\A[A-Za-z]/)
|
|
191
|
+
|
|
192
|
+
body.match?(NOISE_BODY_SIGNATURE)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The distiller scans raw transcript text, which is JSONL — so a captured
|
|
196
|
+
# body can carry JSON/escaping artifacts (`\n`, `\"`, a trailing `"}`,
|
|
197
|
+
# a leading `= `/`### ` from injected memory/markdown). Normalize them out:
|
|
198
|
+
# cleaner bodies read better in the injected log AND normalize more
|
|
199
|
+
# consistently, which is what the Reflector's dedup/corroboration keys off.
|
|
200
|
+
def clean_observation_body(body)
|
|
201
|
+
body.to_s
|
|
202
|
+
.gsub(/\\+[ntr]/, " ") # literal \n \t \r (even multiply-escaped) -> space
|
|
203
|
+
.gsub(/\\+"/, '"') # escaped quote -> quote
|
|
204
|
+
.gsub(/\\+/, "") # residual backslashes
|
|
205
|
+
.gsub(/\s+/, " ") # collapse whitespace
|
|
206
|
+
.sub(/\A[\s"'`,:=#*>\-\]}]+/, "") # leading JSON/markdown artifacts
|
|
207
|
+
.sub(/[\s"'`,:\-\]}]+\z/, "") # trailing JSON artifacts
|
|
208
|
+
.strip
|
|
209
|
+
end
|
|
210
|
+
|
|
106
211
|
def global_scope_signal?(text)
|
|
107
212
|
GLOBAL_SCOPE_PATTERNS.any? { |pattern| text.match?(pattern) }
|
|
108
213
|
end
|
|
@@ -76,11 +76,14 @@ module ClaudeMemory
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# Only facts are transformed; every other field must pass through
|
|
80
|
+
# unchanged (an earlier version silently dropped observations).
|
|
79
81
|
Distill::Extraction.new(
|
|
80
82
|
entities: extraction.entities,
|
|
81
83
|
facts: new_facts,
|
|
82
84
|
decisions: extraction.decisions,
|
|
83
|
-
signals: extraction.signals
|
|
85
|
+
signals: extraction.signals,
|
|
86
|
+
observations: extraction.observations
|
|
84
87
|
)
|
|
85
88
|
end
|
|
86
89
|
|
|
@@ -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
|