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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +6 -1
  4. data/.claude/settings.local.json +2 -1
  5. data/.claude-plugin/marketplace.json +2 -2
  6. data/.claude-plugin/plugin.json +2 -2
  7. data/CHANGELOG.md +38 -0
  8. data/CLAUDE.md +11 -6
  9. data/README.md +35 -0
  10. data/db/migrations/019_add_observations.rb +43 -0
  11. data/db/migrations/020_add_observation_promotion.rb +33 -0
  12. data/docs/GETTING_STARTED.md +38 -0
  13. data/docs/api_stability.md +16 -5
  14. data/docs/architecture.md +18 -6
  15. data/docs/audit_runbook.md +67 -0
  16. data/docs/dashboard.md +28 -0
  17. data/docs/improvements.md +173 -1
  18. data/docs/influence/mastra-observational-memory.md +198 -0
  19. data/docs/influence/strands-agent-sops.md +163 -0
  20. data/docs/quality_review.md +45 -0
  21. data/lib/claude_memory/audit/checks.rb +149 -0
  22. data/lib/claude_memory/audit/runner.rb +4 -0
  23. data/lib/claude_memory/commands/census_command.rb +1 -1
  24. data/lib/claude_memory/commands/hook_command.rb +16 -3
  25. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +3 -1
  26. data/lib/claude_memory/commands/install_skill_command.rb +4 -0
  27. data/lib/claude_memory/commands/observations_command.rb +367 -0
  28. data/lib/claude_memory/commands/registry.rb +1 -0
  29. data/lib/claude_memory/commands/skills/reflect.md +68 -0
  30. data/lib/claude_memory/commands/stats_command.rb +60 -1
  31. data/lib/claude_memory/dashboard/api.rb +4 -0
  32. data/lib/claude_memory/dashboard/index.html +154 -2
  33. data/lib/claude_memory/dashboard/observations.rb +115 -0
  34. data/lib/claude_memory/dashboard/server.rb +1 -0
  35. data/lib/claude_memory/distill/extraction.rb +6 -4
  36. data/lib/claude_memory/distill/null_distiller.rb +108 -3
  37. data/lib/claude_memory/distill/reference_material_detector.rb +4 -1
  38. data/lib/claude_memory/domain/observation.rb +118 -0
  39. data/lib/claude_memory/embeddings/generator.rb +1 -1
  40. data/lib/claude_memory/hook/context_injector.rb +125 -2
  41. data/lib/claude_memory/mcp/handlers/management_handlers.rb +113 -2
  42. data/lib/claude_memory/mcp/handlers/query_handlers.rb +48 -1
  43. data/lib/claude_memory/mcp/instructions_builder.rb +1 -0
  44. data/lib/claude_memory/mcp/query_guide.rb +28 -0
  45. data/lib/claude_memory/mcp/tool_definitions.rb +58 -0
  46. data/lib/claude_memory/mcp/tools.rb +3 -0
  47. data/lib/claude_memory/observe/observations_renderer.rb +49 -0
  48. data/lib/claude_memory/observe/reflector.rb +107 -0
  49. data/lib/claude_memory/observe/token_overlap_matcher.rb +55 -0
  50. data/lib/claude_memory/publish.rb +53 -1
  51. data/lib/claude_memory/resolve/resolver.rb +45 -8
  52. data/lib/claude_memory/store/schema_manager.rb +1 -1
  53. data/lib/claude_memory/store/sqlite_store.rb +181 -0
  54. data/lib/claude_memory/sweep/maintenance.rb +15 -1
  55. data/lib/claude_memory/sweep/sweeper.rb +7 -1
  56. data/lib/claude_memory/version.rb +1 -1
  57. data/lib/claude_memory.rb +6 -0
  58. 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
- "language" => /\b(ruby|python|javascript|typescript|go|rust)\b/i,
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
- found << {type: type, name: name.downcase, confidence: 0.7}
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.each_with_object(Hash.new(0)) { |token, h| h[token] += 1 }
68
+ tf_map = tokens.tally
69
69
 
70
70
  # Normalize term frequencies
71
71
  max_tf = tf_map.values.max.to_f