claude_memory 0.13.0 → 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-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +10 -0
- data/docs/improvements.md +79 -0
- data/lib/claude_memory/distill/null_distiller.rb +24 -2
- data/lib/claude_memory/hook/context_injector.rb +35 -10
- data/lib/claude_memory/observe/reflector.rb +32 -16
- data/lib/claude_memory/observe/token_overlap_matcher.rb +55 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '03389773428d20d899e3320e349cfd6a55e9c92df7b00837cf85466fe0741985'
|
|
4
|
+
data.tar.gz: 14c91b7778987ffd1acbf11b5b58c4ccf0ed7eda6f187534ea27b4dcc987bf67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b16bf521bfd7c496f4723a6029f981121ea2a76643eafb5045ab9528ab51543b47caf7d51474f21d69bb725e9544aa85c295ad303ee94cd7267f3d8cf4233184
|
|
7
|
+
data.tar.gz: a3d8a65e03d9c62fb7d4e2feb1ec37b32c9513eb13fe3c7c6488922192a842e67ac237562b186278948cb4e8ef9372a8e5f2d63c45ab34e9a6ac4604102ff0d7
|
data/.claude/memory.sqlite3
CHANGED
|
Binary file
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"plugins": [
|
|
8
8
|
{
|
|
9
9
|
"name": "claude-memory",
|
|
10
|
-
"version": "0.13.
|
|
10
|
+
"version": "0.13.1",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Long-term memory for Claude Code. Recalls architecture, conventions, and decisions across sessions, plus an episodic observation log of what happened — so Claude explains your codebase without file traversal, follows your patterns, learns from corrections, and never re-asks what it already learned.",
|
|
13
13
|
"repository": "https://github.com/codenamev/claude_memory"
|
data/.claude-plugin/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-memory",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Long-term memory for Claude Code. Recalls architecture, conventions, and decisions across sessions, plus an episodic observation log of what happened — so Claude explains your codebase without file traversal, follows your patterns, learns from corrections, and never re-asks what it already learned.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Valentino Stoll",
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.13.1] - 2026-06-23
|
|
8
|
+
|
|
9
|
+
Theme: **The observational layer, audited and repaired.** A critical examination of every observation in a real (dogfooding) project DB found the episodic layer was producing ~no useful observations and injecting noise into sessions — three evidence-backed defects, now fixed (the data is in `docs/improvements.md` #72–#75). No schema changes, no breaking changes.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **High-precision Layer-1 observation filter (#74).** The high-recall Observer was scraping code, doc, and transcript fragments past `noise_body?` and injecting them into SessionStart (measured: 38 of 117 obvious-noise rows slipped through — spec fixtures, CHANGELOG table rows, benchmark tree output, even the distiller's own source comments). Strengthened the gate to reject code/JSON `key: "value"`, method calls, table pipes, box-drawing glyphs, `(vector)` labels, and raw JSONL fields, and to require a body to begin like a prose sentence. Verified against the real noise corpus: every sampled fragment now rejected, clean prose decisions/conventions kept.
|
|
14
|
+
- **Corroboration can finally accumulate (#73).** Dedup matched on exact normalized strings, so varied wording of the same event never folded — every observation stayed at `corroboration_count = 1` and the promotion gate could never fire. Replaced exact grouping with greedy clustering over an injected similarity matcher; the default `Observe::TokenOverlapMatcher` (lexical Jaccard, deterministic, free) folds near-duplicates so corroboration climbs toward promotion. Pure synonym paraphrases still need real embeddings (measured: tfidf can't separate them from unrelated text on short bodies) — injectable via the `matcher:` seam.
|
|
15
|
+
- **Observation capture elevated to a first-class SessionStart ask (#72).** Authoring observations was a paragraph buried in the optional deep-distill prompt, which fires almost never (`store_extraction` had zero calls in the layer's lifetime; Layer-1 auto-ingest carried ~100:1 of the load). Decoupled it into its own prominent `## Log What Happened` section. Whether the LLM-authored (Layer-2) path now fires is measurable via the `mcp_extraction` content-item source.
|
|
16
|
+
|
|
7
17
|
## [0.13.0] - 2026-06-18
|
|
8
18
|
|
|
9
19
|
Theme: **Episodic memory — a second kind of memory.** ClaudeMemory gains an append-only *observation* layer ("what happened") that complements the semantic fact store ("what is true"), modeled on [Mastra's Observational Memory](docs/influence/mastra-observational-memory.md). Observations accrue automatically, are deduplicated/consolidated by reflection, and are promoted to facts only after corroboration — making repeated sighting an anti-hallucination gate built into the memory model. Schema advances to v20 (additive; no breaking changes to existing facts/queries).
|
data/docs/improvements.md
CHANGED
|
@@ -504,6 +504,85 @@ Source: `docs/influence/mastra-observational-memory.md` — architecture study o
|
|
|
504
504
|
|
|
505
505
|
---
|
|
506
506
|
|
|
507
|
+
### 71. Exclude the project DB from the published gem (gem is 28MB, ~96MB of it the dogfooding DB)
|
|
508
|
+
|
|
509
|
+
Source: 2026-06-18 live observation while building the 0.13.0 release gem.
|
|
510
|
+
|
|
511
|
+
**Problem.** `claude_memory.gemspec` builds its file list from `git ls-files` and rejects `bin/ Gemfile .gitignore .rspec spec/ .github/ .standard.yml` — but **not** `.claude/memory.sqlite3`, which is tracked (per the "always commit the project DB" convention). So the published gem *ships the repo's own dogfooding memory database*: the working-tree DB is ~96MB, compressing to a **28MB gem** (v0.6.0 was 280KB; the gem has been silently growing — 0.9.1 was 19MB — as the DB accumulates). Gem users get nothing from it (they init their own empty DB on install), it bloats every download, and it's trending toward RubyGems' 100MB ceiling.
|
|
512
|
+
|
|
513
|
+
**Fix.** Add `.claude/` (or at least `.claude/memory.sqlite3` + WAL/SHM siblings) to the gemspec reject filter. Verify with `gem build` that the gem drops to <1MB and that nothing in the gem actually requires the file at runtime (it shouldn't — runtime opens the *user's* DB path via `Configuration`). Add a spec asserting `Gem::Specification.load(...).files` excludes `.claude/memory.sqlite3` so it can't regress.
|
|
514
|
+
|
|
515
|
+
**Why High.** Low effort, high impact: ~28MB → <1MB published gem, and it removes a slow-growing landmine before it actually exceeds the RubyGems size limit and blocks a release. Not introduced by 0.13.0 — pre-existing and compounding.
|
|
516
|
+
|
|
517
|
+
**Note on the convention.** This does *not* conflict with "always commit `.claude/memory.sqlite3`" — that's about repo reproducibility for collaborators. Shipping it *in the gem* is a separate, unintended consequence of the `git ls-files` manifest.
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
> **Observational-layer audit (2026-06-23).** A critical examination of every observation in this project's DB found the episodic layer is, in practice, producing ~no useful observations and is injecting noise into sessions. Four root causes (#72–#75), each backed by the live data below. Snapshot at audit time: **113 active observations, 0 consolidated, 0 expired, 0 promoted, every `corroboration_count = 1`**; only `decision`/`preference` kinds; every row traces to a `claude_code` transcript on the `observational-layer-*` branches. The count grew 99 → 105 → 113 *during* the audit session — the dogfooding loop is live and compounding. These supersede the optimistic framing of #68; the mechanism is sound, the inputs and the matching are not.
|
|
522
|
+
|
|
523
|
+
### 72. Layer-2 (Claude-as-observer) produces **zero** observations — the quality source is silent ⭐
|
|
524
|
+
|
|
525
|
+
Source: 2026-06-23 observational-layer audit.
|
|
526
|
+
|
|
527
|
+
**🟡 Structural fix shipped 2026-06-23** (option a). Decoupled the observation-capture ask from the buried, rarely-fired deep-distill paragraph into its own prominent SessionStart section (`ContextInjector#format_observation_capture_prompt` — "## Log What Happened"). This maximizes the chance Claude authors observations, but persistence still rides a `store_extraction` tool call, so **effectiveness is not yet proven** — whether Layer-2 now actually fires is measurable via the `mcp_extraction` content-item source and needs real-session validation (and ultimately the #75 eval). Not closing this until that signal turns positive.
|
|
528
|
+
|
|
529
|
+
**Problem.** The design's quality observations were always meant to come from **Layer-2** (Claude-as-observer): the SessionStart prompt (`ContextInjector#format_distillation_prompt`) asks Claude to populate the `observations` field of its `memory.store_extraction` call. **The vehicle is dormant.** Evidence (sharpened 2026-06-23 with `mcp_tool_calls.called_at` + `activity_events`):
|
|
530
|
+
- `store_extraction` was invoked **4 times ever — all on 2026-04-17 to 2026-04-30**, i.e. *six-plus weeks before the observational layer (and the `observations` parameter) shipped on 2026-06-16/18*. Those calls **could not** have carried observations; the field didn't exist yet.
|
|
531
|
+
- **Since the layer shipped, `store_extraction` has fired ZERO times.** Layer-2 has never run, not once, in the feature's entire life. (Corroborating: `store_extraction` creates a synthetic `source: "mcp_extraction"` content_item; **0 of the 113 observations trace to `mcp_extraction`** — all to `claude_code` ingest.)
|
|
532
|
+
- **Layer-1 auto-ingest dominates ~100:1**: `activity_events` shows **409 `hook_ingest` vs 4 `store_extraction` total**. Content flows in automatically on every Stop/SessionEnd hook without Claude's involvement, so the Layer-2 deep-distill path — gated on a *fresh session* with *undistilled ≥200-char* items that Claude must *choose* to act on — essentially never triggers.
|
|
533
|
+
|
|
534
|
+
**Why this is the highest-leverage finding.** Layer-1 (regex over raw transcript) *cannot* produce episodic narrative — it only scrapes fragments (see #74; #74 makes it high-precision, not narrative). The design delegated quality to Layer-2, but Layer-2 is structurally dormant: it isn't a prompt-wording problem (the `store_extraction` schema *does* expose `observations` with a good description, and the prompt *does* ask) — it's that **the path the observations ride on doesn't fire** in normal operation. So the episodic log is, and will remain, 100% Layer-1 scrapes until observation authoring is moved onto a path that actually runs.
|
|
535
|
+
|
|
536
|
+
**Fix (design fork — not a one-shot code change).** Options:
|
|
537
|
+
- **(a) Author observations on the Layer-1 hook path, but with an LLM.** The hook can't call Claude (no API budget), so this means: have the *next* SessionStart context inject the raw undistilled tail and ask Claude to emit observations directly as part of the normal turn (not gated behind a voluntary `store_extraction` deep-distill). Rides the session, no extra cost — same mechanism the fact-injection uses.
|
|
538
|
+
- **(b) Make Layer-2 fire reliably** — lower the fresh-session/≥200-char gate, or make observation emission a first-class, early, non-optional instruction. Risk: effectiveness is unmeasurable without real A/B sessions, and the headless-recall gap (`project_headless_retrieval_gap.md`) says Claude often won't call MCP tools at all.
|
|
539
|
+
- **(c) Derive observations from what Claude already produces** — the `decisions`/`facts` it extracts are higher-signal than regex scrapes; synthesize observations from those deterministically.
|
|
540
|
+
- Regardless: **add telemetry distinguishing Layer-1 vs Layer-2 observation provenance** so "is Layer-2 firing?" is a dashboard number, not a forensic dig.
|
|
541
|
+
|
|
542
|
+
**Cross-links.** Blocks the value premise of #68; #74/#73 only make the Layer-1 floor less bad and the loop functional — neither makes the log *good*. This is the one that does.
|
|
543
|
+
|
|
544
|
+
### 73. Observation dedup/corroboration is normalized-**exact**, so the promotion loop can never fire ⭐
|
|
545
|
+
|
|
546
|
+
**✅ Shipped 2026-06-23.** Replaced exact-string grouping with greedy clustering over an injected similarity matcher (`Reflector#dedupe_scope`). Default matcher is `Observe::TokenOverlapMatcher` — lexical Jaccard token-overlap (deterministic, free, no embedding dependency), threshold 0.5. Folds the common case (one event re-observed with slightly different wording → corroboration now accumulates and can cross the promotion gate) while keeping unrelated statements apart (Jaccard ~0). **Deliberate limitation, data-driven:** measured that tfidf cosine (0.32) can't separate pure synonym paraphrases from unrelated pairs (0.13) on short bodies, so neither the default lexical matcher nor tfidf folds "use SQLite" vs "chose SQLite" — that needs real embeddings, which can be injected via the `matcher:` seam (any object responding to `similar?(a, b)`) when fastembed is configured.
|
|
547
|
+
|
|
548
|
+
Source: 2026-06-23 observational-layer audit.
|
|
549
|
+
|
|
550
|
+
**Problem.** `Observe::Reflector#dedupe` folds observations by `group_by { [scope, normalize(body)] }` where `normalize` is just `downcase` + whitespace-collapse + strip. Two observations corroborate **only if their bodies are byte-identical after lowercasing**. Real captures of "the same thing" are never byte-identical — e.g. the four stored variants "PreCompact hook set.", "PreCompact hook set — the design's Mastra-token-threshold analog.", "PreCompact set alongside ingest + sweep." describe one event but never fold. Result, confirmed in the data: **every observation has `corroboration_count = 1`; 0 consolidated; 0 promoted.** The corroboration gate — the layer's headline anti-hallucination feature — is **dead by construction** on any varied text. It can only fire if the *exact same string* recurs, which regex fragments from different transcript chunks essentially never do.
|
|
551
|
+
|
|
552
|
+
**Fix.** Corroboration/dedup must be **semantic**, not exact: reuse the existing embedding stack (`Embeddings` + sqlite-vec) to fold observations above a similarity threshold, or fold on a normalized *subject+kind* key rather than the full body. Until then, the promotion gate provides no value and the "graduate after 2 sightings" story is unsupported. Add a spec that two paraphrases of one event corroborate.
|
|
553
|
+
|
|
554
|
+
**Cross-links.** Without this, #72's quality observations still wouldn't promote.
|
|
555
|
+
|
|
556
|
+
### 74. Layer-1 Observer ingests code/doc/transcript fragments; `noise_body?` lets ~⅓ through
|
|
557
|
+
|
|
558
|
+
**✅ Shipped 2026-06-23** (commit `d81a684`). Strengthened `NOISE_BODY_SIGNATURE` (code/JSON `key: "value"`, method calls, spaced table pipes, box-drawing glyphs, `(vector)` labels, JSONL fields) and added a prose-start requirement. Verified against the audit corpus: all five real noise samples now rejected, clean prose kept. **Residual (not a regression):** *truncated-prose* fragments with no code signature (e.g. "encompasses how to use fr…") can still slip — that's the greedy `.+` capture, and ultimately the Layer-2 question (#72), not the noise filter.
|
|
559
|
+
|
|
560
|
+
Source: 2026-06-23 observational-layer audit.
|
|
561
|
+
|
|
562
|
+
**Problem.** The Layer-1 Observer runs `decided to (.+)` / `we always|never (.+)` over **raw transcript text**, which on this repo (and any repo whose sessions discuss code) is saturated with trigger phrases inside source, specs, docs, and tool output. The `noise_body?` filter (`NOISE_BODY_SIGNATURE = /\bdef\s|\bclass\s|\bmodule\s|=>|::|","|":\s*"|[{}]|\$\(|&&|\|\|/`) is tuned for code-*syntax* and misses prose/table/transcript fragments. Measured against the live 113: the filter catches **39**, but **38 obvious-noise rows slip through** (≈44% look like noise by a conservative heuristic; manual review puts it higher). Concrete slipped examples actually sitting in the injected log:
|
|
563
|
+
- `[89] decided to use SQLite", kind: "decision", priority: 1) expect(id).to be_a(Integer)…` — a **spec fixture line**.
|
|
564
|
+
- `[104] decided to gate promotion on corroboration" | | Changes | Explicitly…` — a **CHANGELOG table row** (`| |` ≠ `||`, so it dodges the filter).
|
|
565
|
+
- `[48]–[55] / first-person `we always|never`)…` — fragments of the **distiller's own source-code comment**.
|
|
566
|
+
- `[99] · (vector) 78 ├─ How frozen_string_literal…` — **benchmark tree output**.
|
|
567
|
+
|
|
568
|
+
These are priority-1 `decision` rows, so they *are* injected into Block 1 of SessionStart (observed live in this session's own context) — spending context budget on garbage and risking misdirection (e.g. `[46] decided to use Postgres.`, a fixture string, implying a stack the project doesn't use).
|
|
569
|
+
|
|
570
|
+
**Fix.** Make Layer-1 high-precision-or-silent: reject bodies that look like code/markdown/transcript (leading `-`/`#`/`|`, table pipes, `key: "value"` shapes, tree glyphs `├─└─`, `(vector)`, backtick-dense spans, JSONL artifacts) — invert the default from high-recall to high-precision, since the recall here is ~all noise. Pair with the `ContentSanitizer`/Observer border. (This is the P1 item from the 2026-06-18 quality review, now empirically confirmed and worse than estimated.)
|
|
571
|
+
|
|
572
|
+
**Cross-links.** Even fully fixed, Layer-1 is a stopgap until #72; together they decide whether the log is signal or noise.
|
|
573
|
+
|
|
574
|
+
### 75. The episodic layer has no fair test — this repo is a pathological self-pollution case
|
|
575
|
+
|
|
576
|
+
Source: 2026-06-23 observational-layer audit.
|
|
577
|
+
|
|
578
|
+
**Problem.** Every observation traces to this project's *own* `claude_code` design transcripts, whose specs literally contain `insert_observation(body: "decided to use SQLite")` and whose docs are full of "decided to…" prose. claude_memory dogfooding on its own repo is the **worst possible self-test** for the Observer — it maximizes trigger-text density and self-ingestion. So the audit above measures *self-pollution*, not the design's ceiling; a normal Rails/Django app would look very different. We currently have **no measurement of the layer's value on a representative project**, and the optimistic compression/promotion story in #68 was never validated.
|
|
579
|
+
|
|
580
|
+
**Fix.** Stand up the deferred **LongMemEval-style episodic suite** (#67/#68 medium item) and/or capture a real non-claude_memory project trace as a fixture, and report observation precision (signal vs noise), corroboration/promotion rates, and compression on *that*. Treat "episodic layer adds value" as **unproven** in public materials until this exists (the 0.13.0 blog draft already hedges accordingly). Until then, the self-pollution makes the dashboard Observations panel actively misleading on this repo.
|
|
581
|
+
|
|
582
|
+
**Cross-links.** Gates any future episodic value claim; depends on #72–#74 being fixed first to be worth measuring.
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
507
586
|
## Medium Priority
|
|
508
587
|
|
|
509
588
|
### ~~18. Shell Completion for CLI~~ ✅ Implemented 2026-03-20
|
|
@@ -49,8 +49,24 @@ module ClaudeMemory
|
|
|
49
49
|
/\bwe\s+(?:should\s+)?(?:always|never)\s+(.+)/i
|
|
50
50
|
].freeze
|
|
51
51
|
|
|
52
|
-
# Bodies that look like code / JSON / shell
|
|
53
|
-
|
|
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
|
+
)
|
|
54
70
|
|
|
55
71
|
def distill(text, content_item_id: nil)
|
|
56
72
|
entities = extract_entities(text)
|
|
@@ -166,7 +182,13 @@ module ClaudeMemory
|
|
|
166
182
|
(s[/\A.{0,240}?[.!?](?=\s|\z)/m] || s[0, 240]).to_s.strip
|
|
167
183
|
end
|
|
168
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.
|
|
169
189
|
def noise_body?(body)
|
|
190
|
+
return true unless body.match?(/\A[A-Za-z]/)
|
|
191
|
+
|
|
170
192
|
body.match?(NOISE_BODY_SIGNATURE)
|
|
171
193
|
end
|
|
172
194
|
|
|
@@ -73,7 +73,12 @@ module ClaudeMemory
|
|
|
73
73
|
|
|
74
74
|
if fresh_session?
|
|
75
75
|
undistilled = fetch_undistilled(MAX_UNDISTILLED)
|
|
76
|
-
|
|
76
|
+
if undistilled.any?
|
|
77
|
+
sections << format_distillation_prompt(undistilled)
|
|
78
|
+
# The episodic-capture ask is its own prominent section (#72), not a
|
|
79
|
+
# buried paragraph inside the deep-distill prompt.
|
|
80
|
+
sections << format_observation_capture_prompt
|
|
81
|
+
end
|
|
77
82
|
|
|
78
83
|
promotion = fetch_promotion_candidates(MAX_PROMOTION_CANDIDATES)
|
|
79
84
|
sections << format_observation_reflection(promotion) if promotion.any?
|
|
@@ -247,15 +252,7 @@ module ClaudeMemory
|
|
|
247
252
|
"in the object (e.g., \"… because …\", \"… so that …\", \"caused by …\",",
|
|
248
253
|
"\"breaks when …\"). A fact with a reason is recoverable once stale; a",
|
|
249
254
|
"bare conclusion is dead weight. Prefer one fact-with-reason over two",
|
|
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."
|
|
255
|
+
"facts-without."
|
|
259
256
|
]
|
|
260
257
|
|
|
261
258
|
items.each do |item|
|
|
@@ -269,6 +266,34 @@ module ClaudeMemory
|
|
|
269
266
|
lines.join("\n")
|
|
270
267
|
end
|
|
271
268
|
|
|
269
|
+
# First-class, standalone ask for the episodic layer (#72). Authoring
|
|
270
|
+
# observations was previously a paragraph buried inside the optional
|
|
271
|
+
# deep-distill flow above, and that flow fires almost never — so the
|
|
272
|
+
# episodic log was 100% Layer-1 scrapes. This decouples it: a prominent,
|
|
273
|
+
# lightweight instruction to log "what happened" directly, the same way
|
|
274
|
+
# the fact context rides the session. Effectiveness is measurable via the
|
|
275
|
+
# `mcp_extraction` content-item source (Layer-2) vs `claude_code` (Layer-1).
|
|
276
|
+
def format_observation_capture_prompt
|
|
277
|
+
<<~PROMPT.strip
|
|
278
|
+
## Log What Happened (episodic memory)
|
|
279
|
+
|
|
280
|
+
Record the recent narrative as **observations** — "what happened",
|
|
281
|
+
complementing the facts above ("what is true"). For each discrete
|
|
282
|
+
event in the recent work above (a decision made, a preference stated,
|
|
283
|
+
a notable fix or outcome), call `memory.store_extraction` with an
|
|
284
|
+
`observations` array — one entry per event:
|
|
285
|
+
|
|
286
|
+
- `body`: one concise sentence of what happened (embed a reason for
|
|
287
|
+
decisions/preferences — "… because …", "… so that …")
|
|
288
|
+
- `kind`: `decision`, `preference`, or `event`
|
|
289
|
+
- `priority`: 1 important, 2 maybe, 3 info
|
|
290
|
+
|
|
291
|
+
Keep it to genuine events worth remembering — skip routine steps and
|
|
292
|
+
code output. Observations accumulate and a corroborated one graduates
|
|
293
|
+
into a fact. Send them with the facts in the same call, or on their own.
|
|
294
|
+
PROMPT
|
|
295
|
+
end
|
|
296
|
+
|
|
272
297
|
def format_section(title, items)
|
|
273
298
|
items = items.compact.uniq
|
|
274
299
|
return nil if items.empty?
|
|
@@ -11,8 +11,12 @@ module ClaudeMemory
|
|
|
11
11
|
# timer (Claude Code has no cron hook) and without extra API cost.
|
|
12
12
|
#
|
|
13
13
|
# Two passes, both provenance-preserving (tombstone, never hard-delete):
|
|
14
|
-
# - dedupe: collapse near-
|
|
15
|
-
#
|
|
14
|
+
# - dedupe: collapse near-duplicate active observations (same scope) into
|
|
15
|
+
# the newest, linking losers via consolidated_into. Similarity is decided
|
|
16
|
+
# by an injected matcher (default: lexical token-overlap, #73) so the
|
|
17
|
+
# promotion gate can actually accumulate corroboration — exact-string
|
|
18
|
+
# matching never folded varied wording, leaving every observation at
|
|
19
|
+
# corroboration 1 (the 2026-06-23 audit finding).
|
|
16
20
|
# - expire_stale_info: retire info-level (🟢 / priority 3) observations
|
|
17
21
|
# older than the TTL to bound context size. Important (🔴) and maybe
|
|
18
22
|
# (🟡) are never expired — only the lowest-signal tier ages out.
|
|
@@ -31,9 +35,10 @@ module ClaudeMemory
|
|
|
31
35
|
end
|
|
32
36
|
end
|
|
33
37
|
|
|
34
|
-
def initialize(store, info_ttl_days: DEFAULT_INFO_TTL_DAYS)
|
|
38
|
+
def initialize(store, info_ttl_days: DEFAULT_INFO_TTL_DAYS, matcher: TokenOverlapMatcher.new)
|
|
35
39
|
@store = store
|
|
36
40
|
@info_ttl_days = info_ttl_days
|
|
41
|
+
@matcher = matcher
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
# @return [Result] number of observations deduped and expired
|
|
@@ -51,21 +56,36 @@ module ClaudeMemory
|
|
|
51
56
|
|
|
52
57
|
def dedupe
|
|
53
58
|
active = @store.observations.where(status: "active").order(:id).all
|
|
59
|
+
active.group_by { |o| o[:scope] }.sum { |_scope, rows| dedupe_scope(rows) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Greedy clustering within one scope: the newest observation in a cluster
|
|
63
|
+
# is the keeper; older near-duplicates fold into it. O(n²) matcher calls,
|
|
64
|
+
# but n is bounded (#74 cut the inflow; expire_stale_info bounds the tail).
|
|
65
|
+
def dedupe_scope(rows)
|
|
66
|
+
return 0 if rows.size < 2
|
|
67
|
+
|
|
68
|
+
ordered = rows.sort_by { |r| [r[:observed_at].to_s, r[:id]] }.reverse
|
|
69
|
+
folded = {}
|
|
54
70
|
merged = 0
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
next if
|
|
72
|
+
ordered.each do |keeper|
|
|
73
|
+
next if folded[keeper[:id]]
|
|
74
|
+
|
|
75
|
+
ordered.each do |other|
|
|
76
|
+
next if other[:id] == keeper[:id] || folded[other[:id]]
|
|
77
|
+
next unless @matcher.similar?(keeper[:body], other[:body])
|
|
58
78
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
next if loser[:id] == keeper[:id]
|
|
62
|
-
# Fold the loser's sightings into the keeper before tombstoning so
|
|
63
|
-
# corroboration survives consolidation and can cross the promotion
|
|
79
|
+
# Fold the duplicate's sightings into the keeper before tombstoning
|
|
80
|
+
# so corroboration survives consolidation and can cross the promotion
|
|
64
81
|
# threshold. A duplicate IS a repeated sighting.
|
|
65
|
-
@store.increment_corroboration(keeper[:id], by:
|
|
66
|
-
@store.tombstone_observation(
|
|
82
|
+
@store.increment_corroboration(keeper[:id], by: other[:corroboration_count] || 1)
|
|
83
|
+
@store.tombstone_observation(other[:id], into_id: keeper[:id])
|
|
84
|
+
folded[other[:id]] = true
|
|
67
85
|
merged += 1
|
|
68
86
|
end
|
|
87
|
+
|
|
88
|
+
folded[keeper[:id]] = true
|
|
69
89
|
end
|
|
70
90
|
|
|
71
91
|
merged
|
|
@@ -82,10 +102,6 @@ module ClaudeMemory
|
|
|
82
102
|
ids.each { |id| @store.expire_observation(id) }
|
|
83
103
|
ids.size
|
|
84
104
|
end
|
|
85
|
-
|
|
86
|
-
def normalize(body)
|
|
87
|
-
body.to_s.downcase.gsub(/\s+/, " ").strip
|
|
88
|
-
end
|
|
89
105
|
end
|
|
90
106
|
end
|
|
91
107
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Observe
|
|
5
|
+
# Default observation-similarity matcher: lexical token-overlap (Jaccard).
|
|
6
|
+
#
|
|
7
|
+
# Deterministic, free, no embedding dependency — so it runs shell-side in
|
|
8
|
+
# the Reflector's sweep pass at no extra cost. Two bodies are "the same
|
|
9
|
+
# sighting" when their significant-word sets overlap past the threshold.
|
|
10
|
+
# This folds the common case (one event re-observed with slightly different
|
|
11
|
+
# wording — "PreCompact hook set." / "PreCompact hook set — the design
|
|
12
|
+
# analog", Jaccard 0.6) while keeping unrelated observations apart (distinct
|
|
13
|
+
# developer statements share ~no content words → Jaccard ~0).
|
|
14
|
+
#
|
|
15
|
+
# It does NOT capture pure synonym paraphrases ("use SQLite" vs "chose
|
|
16
|
+
# SQLite") — no free lexical method can on short text (measured 2026-06-23:
|
|
17
|
+
# tfidf cosine 0.32 for that pair, indistinguishable from unrelated pairs
|
|
18
|
+
# at 0.13). For paraphrase folding, inject a semantic matcher backed by real
|
|
19
|
+
# embeddings: the Reflector accepts any object responding to
|
|
20
|
+
# `similar?(body_a, body_b)`.
|
|
21
|
+
class TokenOverlapMatcher
|
|
22
|
+
DEFAULT_THRESHOLD = 0.5
|
|
23
|
+
|
|
24
|
+
# Function words carry no episodic signal; dropping them focuses the
|
|
25
|
+
# overlap on subject/verb content.
|
|
26
|
+
STOPWORDS = %w[
|
|
27
|
+
a an the to of in on at for and or but we i it is are was were be been
|
|
28
|
+
this that these those with as by from into our your their its do does
|
|
29
|
+
].to_set.freeze
|
|
30
|
+
|
|
31
|
+
def initialize(threshold: DEFAULT_THRESHOLD)
|
|
32
|
+
@threshold = threshold
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] true when the two bodies are near-duplicate sightings
|
|
36
|
+
def similar?(body_a, body_b)
|
|
37
|
+
a = significant_tokens(body_a)
|
|
38
|
+
b = significant_tokens(body_b)
|
|
39
|
+
return false if a.empty? || b.empty?
|
|
40
|
+
|
|
41
|
+
intersection = (a & b).size.to_f
|
|
42
|
+
union = (a | b).size
|
|
43
|
+
(intersection / union) >= @threshold
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def significant_tokens(body)
|
|
49
|
+
body.to_s.downcase.scan(/[a-z0-9]+/)
|
|
50
|
+
.reject { |word| word.length < 2 || STOPWORDS.include?(word) }
|
|
51
|
+
.to_set
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/claude_memory.rb
CHANGED
|
@@ -126,6 +126,7 @@ require_relative "claude_memory/domain/provenance"
|
|
|
126
126
|
require_relative "claude_memory/domain/conflict"
|
|
127
127
|
require_relative "claude_memory/domain/observation"
|
|
128
128
|
require_relative "claude_memory/observe/observations_renderer"
|
|
129
|
+
require_relative "claude_memory/observe/token_overlap_matcher"
|
|
129
130
|
require_relative "claude_memory/observe/reflector"
|
|
130
131
|
require_relative "claude_memory/embeddings/model_registry"
|
|
131
132
|
require_relative "claude_memory/embeddings/inspector"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude_memory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.13.
|
|
4
|
+
version: 0.13.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Valentino Stoll
|
|
@@ -368,6 +368,7 @@ files:
|
|
|
368
368
|
- lib/claude_memory/mcp/tools.rb
|
|
369
369
|
- lib/claude_memory/observe/observations_renderer.rb
|
|
370
370
|
- lib/claude_memory/observe/reflector.rb
|
|
371
|
+
- lib/claude_memory/observe/token_overlap_matcher.rb
|
|
371
372
|
- lib/claude_memory/otel/attributes.rb
|
|
372
373
|
- lib/claude_memory/otel/constants.rb
|
|
373
374
|
- lib/claude_memory/otel/ingestor.rb
|