claude_memory 0.13.0 → 0.13.2

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.claude-plugin/marketplace.json +1 -1
  3. data/.claude-plugin/plugin.json +1 -1
  4. data/CHANGELOG.md +25 -0
  5. data/docs/improvements.md +83 -0
  6. data/lib/claude_memory/commands/checks/fts_rank_check.rb +60 -0
  7. data/lib/claude_memory/commands/doctor_command.rb +2 -0
  8. data/lib/claude_memory/commands/index_command.rb +22 -4
  9. data/lib/claude_memory/commands/setup_vectors_command.rb +9 -3
  10. data/lib/claude_memory/distill/null_distiller.rb +24 -2
  11. data/lib/claude_memory/hook/context_injector.rb +35 -10
  12. data/lib/claude_memory/index/lexical_fts.rb +47 -23
  13. data/lib/claude_memory/index/vector_index.rb +27 -0
  14. data/lib/claude_memory/mcp/server.rb +33 -1
  15. data/lib/claude_memory/observe/reflector.rb +32 -16
  16. data/lib/claude_memory/observe/token_overlap_matcher.rb +55 -0
  17. data/lib/claude_memory/sweep/maintenance.rb +22 -0
  18. data/lib/claude_memory/sweep/sweeper.rb +1 -0
  19. data/lib/claude_memory/version.rb +1 -1
  20. data/lib/claude_memory.rb +2 -0
  21. metadata +3 -25
  22. data/.claude/CLAUDE.md +0 -4
  23. data/.claude/memory.sqlite3 +0 -0
  24. data/.claude/output-styles/memory-aware.md +0 -1
  25. data/.claude/rules/claude_memory.generated.md +0 -87
  26. data/.claude/settings.json +0 -113
  27. data/.claude/settings.local.json +0 -59
  28. data/.claude/skills/check-memory/DEPRECATED.md +0 -29
  29. data/.claude/skills/check-memory/SKILL.md +0 -87
  30. data/.claude/skills/dashboard/SKILL.md +0 -42
  31. data/.claude/skills/debug-memory +0 -1
  32. data/.claude/skills/improve/SKILL.md +0 -631
  33. data/.claude/skills/improve/feature-patterns.md +0 -1221
  34. data/.claude/skills/memory-first-workflow +0 -1
  35. data/.claude/skills/quality-update/SKILL.md +0 -229
  36. data/.claude/skills/quality-update/implementation-guide.md +0 -346
  37. data/.claude/skills/release/SKILL.md +0 -206
  38. data/.claude/skills/review-commit/SKILL.md +0 -199
  39. data/.claude/skills/review-for-quality/SKILL.md +0 -154
  40. data/.claude/skills/review-for-quality/expert-checklists.md +0 -79
  41. data/.claude/skills/setup-memory +0 -1
  42. data/.claude/skills/study-repo/SKILL.md +0 -322
  43. data/.claude/skills/study-repo/analysis-template.md +0 -323
  44. data/.claude/skills/study-repo/focus-examples.md +0 -327
  45. data/.claude/skills/upgrade-dependencies/SKILL.md +0 -154
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93e553fc87bd36ebe27b8709a28dea9bd206a669491327fc8b6340e6078a8f67
4
- data.tar.gz: c19722c5028ed5746f58792bb34e57939600deaa0a59b6ff00a421b3ed829bdc
3
+ metadata.gz: fef30295649f5cdb709f3650bbdfe7411ddccd566a906eaa09247a1b88f895a5
4
+ data.tar.gz: 3edd0ef999cb0c4356ef83c43e20646eed8cc8b68b9a2c8c1963191935eaaf98
5
5
  SHA512:
6
- metadata.gz: 7bd3a98c636c525e2bfc579e9e265ecef3a07311d4ba543d692abc092ddc0822df86832b9e5b53d802597a6dbe7495aa192210d6d4e5961c21383daac22cef09
7
- data.tar.gz: b587239c11c342551f35937ecdbb108254269306fa98fbaac6680d23f8e427a32f26cbb1524c8f19a08da1773e292b6d75df92e101cb724d59fd65017f275832
6
+ metadata.gz: e23ad823124ffec0b84d81d6f309dbc601ef5c475f827d21cfd4f9f9dbb7172a2c4832ded63c6f38bf9f46f060c5c4e04d94fe2bd9e2ed0044f7da58c38c70a8
7
+ data.tar.gz: d71ff604a9518affc8d153574dba34361b9b54b49fbffac228ff00c076ce05971b698782551a17bbfc072c84ce4f16e44680857671469db9d1d0c08e87cd9f94
@@ -7,7 +7,7 @@
7
7
  "plugins": [
8
8
  {
9
9
  "name": "claude-memory",
10
- "version": "0.13.0",
10
+ "version": "0.13.2",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
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,31 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.13.2] - 2026-06-27
8
+
9
+ Theme: **Robustness from a real bug report (issue #7) + a 28MB→606K gem.** Closes [#7](https://github.com/codenamev/claude_memory/issues/7) — three independent failure modes around embeddings, FTS, and process lifecycle — plus two backlog items surfaced alongside it. No schema changes, no breaking changes.
10
+
11
+ ### Fixed
12
+
13
+ - **Adopting a non-384-dim embedding model no longer hard-fails on an existing DB (#7 Finding 1).** The `facts_vec` vec0 table's width is immutable once created, and `embedding_dimensions` was only recorded *after* a successful index — so a tfidf/fresh DB silently created a 384 table and the first 768-dim insert raised `Expected 384 ... received 768`. `VectorIndex#recreate!` now drops + rebuilds `facts_vec` at the resolved width, and `IndexCommand#handle_dimension_mismatch` reads the table's actual width from its DDL and rebuilds up front (covering the unset-meta case, not just a recorded mismatch).
14
+ - **Corrupt FTS5 rank index is now detected and self-heals (#7 Finding 2 + #69).** Concurrent writers can leave `content_fts` so that plain `MATCH` works but `ORDER BY rank` raises "database disk image is malformed" — silently breaking recall while `PRAGMA integrity_check` passes. `doctor` now probes the rank path per DB (`Checks::FtsRankCheck`); the sweep (`PreCompact`/`SessionEnd`) auto-rebuilds on detection (`Sweep::Maintenance#repair_fts_rank`) so recall self-repairs without a manual `compact`; and `LexicalFTS#search` raises a `CorruptRankIndexError` with a compact hint instead of an unhandled stacktrace.
15
+ - **`serve-mcp` can no longer orphan (#7 Finding 3).** A hard kill of the client could leave the stdio MCP server blocked forever on `gets` (no stdin EOF), holding a SQLite connection — observed as serve-mcp processes lingering for days. A parent-death watchdog (`Server#orphaned?` + a 30s thread) exits the process once it's reparented away from its original parent.
16
+ - **The embedding provider no longer leaks into the test suite.** `setup-vectors` wrote `CLAUDE_MEMORY_EMBEDDING_PROVIDER` into the tracked `settings.json`, which Claude Code injects into every subprocess env — flipping provider-dependent specs. It now writes to `settings.local.json` (per-machine), and the suite clears `CLAUDE_MEMORY_EMBEDDING_*` for hermetic runs.
17
+
18
+ ### Changed
19
+
20
+ - **The published gem dropped from ~28MB to 606K (#71).** The gemspec's `git ls-files` manifest shipped the repo's own ~96MB dogfooding `.claude/memory.sqlite3` (and was trending toward RubyGems' 100MB ceiling). `.claude/` is now excluded from the manifest; users init their own DB, and the plugin manifest + commands/skills/output-styles (sourced at the top level) are unaffected. Regression-guarded by a manifest spec.
21
+
22
+ ## [0.13.1] - 2026-06-23
23
+
24
+ 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.
25
+
26
+ ### Fixed
27
+
28
+ - **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.
29
+ - **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.
30
+ - **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.
31
+
7
32
  ## [0.13.0] - 2026-06-18
8
33
 
9
34
  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
@@ -294,6 +294,8 @@ Source: 2026-04-28 1.0 readiness review (`docs/1_0_punchlist.md` #6)
294
294
 
295
295
  ### 69. Self-Heal the FTS Rank Index After Concurrent Ingest
296
296
 
297
+ **✅ Core shipped 2026-06-27** (detection + self-heal). **Proactive detection:** the doctor `Checks::FtsRankCheck` probes `MATCH … ORDER BY rank` per DB and flags corruption integrity_check misses (landed with issue #7 Finding 2, `2522044`). **Self-heal:** `Sweep::Maintenance#repair_fts_rank` probes the rank path each sweep (PreCompact/SessionEnd) and runs `LexicalFTS#rebuild!` on `malformed`, so recall self-repairs within the session that broke it — no manual `compact`. Also graceful: `LexicalFTS#search` now raises `CorruptRankIndexError` with a compact hint instead of a stacktrace. **Deferred:** the contention-reduction pragmas (busy_timeout + `BEGIN IMMEDIATE`) and the larger external-content-FTS5 redesign — they're risk/scope beyond the self-heal and not needed once the rank index auto-repairs.
298
+
297
299
  Source: 2026-06-16 live incident on `claude/observational-layer-design-7662r9` — observed first-hand, not a study.
298
300
 
299
301
  **Gap.** The contentless FTS5 index (`content_fts`) silently drifts into a broken state under concurrent writers: the ingest hook (`claude-memory hook ingest`) and the MCP server (`store_extraction` → `Index::LexicalFTS#index_content_item`) both write the same WAL DB, and a large ingest produces `"Database busy, retrying"` (`Store::RetryHandler`) followed by an FTS index where **plain `MATCH` works but `... ORDER BY rank` raises `database disk image is malformed`**. `integrity_check` passes and all rows are intact — so `recall`/`recall_index` ranking is silently degraded (the rank query throws or returns nothing) while nothing looks wrong. The only fix today is the user manually running `claude-memory compact` (which `rebuild_fts` + vacuums). Documented in `docs/influence/...` gotchas and surfaced reactively in the dashboard (`lib/claude_memory/dashboard/api.rb:338`), but never repaired automatically. Severe form (btree corruption, plain `MATCH` also failing) was separately seen when **two** memory MCP servers ran concurrently — de-duping to a single server removed that, but the benign rank-artifact still recurs from hook-vs-MCP contention alone.
@@ -504,6 +506,87 @@ Source: `docs/influence/mastra-observational-memory.md` — architecture study o
504
506
 
505
507
  ---
506
508
 
509
+ ### 71. Exclude the project DB from the published gem (gem is 28MB, ~96MB of it the dogfooding DB)
510
+
511
+ **✅ Shipped 2026-06-27.** Added `.claude/` to the gemspec reject filter; the published gem dropped from **28MB → 606K**. The plugin manifest (`.claude-plugin/`) and top-level `commands/`/`skills/`/`output-styles/` are preserved (the plugin sources them there, not from `.claude/`); runtime `.claude/output_styles` references resolve against the *user's* project dir. Regression-guarded by `spec/claude_memory/gemspec_manifest_spec.rb`.
512
+
513
+ Source: 2026-06-18 live observation while building the 0.13.0 release gem.
514
+
515
+ **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.
516
+
517
+ **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.
518
+
519
+ **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.
520
+
521
+ **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.
522
+
523
+ ---
524
+
525
+ > **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.
526
+
527
+ ### 72. Layer-2 (Claude-as-observer) produces **zero** observations — the quality source is silent ⭐
528
+
529
+ Source: 2026-06-23 observational-layer audit.
530
+
531
+ **🟡 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.
532
+
533
+ **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`):
534
+ - `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.
535
+ - **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.)
536
+ - **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.
537
+
538
+ **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.
539
+
540
+ **Fix (design fork — not a one-shot code change).** Options:
541
+ - **(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.
542
+ - **(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.
543
+ - **(c) Derive observations from what Claude already produces** — the `decisions`/`facts` it extracts are higher-signal than regex scrapes; synthesize observations from those deterministically.
544
+ - Regardless: **add telemetry distinguishing Layer-1 vs Layer-2 observation provenance** so "is Layer-2 firing?" is a dashboard number, not a forensic dig.
545
+
546
+ **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.
547
+
548
+ ### 73. Observation dedup/corroboration is normalized-**exact**, so the promotion loop can never fire ⭐
549
+
550
+ **✅ 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.
551
+
552
+ Source: 2026-06-23 observational-layer audit.
553
+
554
+ **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.
555
+
556
+ **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.
557
+
558
+ **Cross-links.** Without this, #72's quality observations still wouldn't promote.
559
+
560
+ ### 74. Layer-1 Observer ingests code/doc/transcript fragments; `noise_body?` lets ~⅓ through
561
+
562
+ **✅ 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.
563
+
564
+ Source: 2026-06-23 observational-layer audit.
565
+
566
+ **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:
567
+ - `[89] decided to use SQLite", kind: "decision", priority: 1) expect(id).to be_a(Integer)…` — a **spec fixture line**.
568
+ - `[104] decided to gate promotion on corroboration" | | Changes | Explicitly…` — a **CHANGELOG table row** (`| |` ≠ `||`, so it dodges the filter).
569
+ - `[48]–[55] / first-person `we always|never`)…` — fragments of the **distiller's own source-code comment**.
570
+ - `[99] · (vector) 78 ├─ How frozen_string_literal…` — **benchmark tree output**.
571
+
572
+ 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).
573
+
574
+ **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.)
575
+
576
+ **Cross-links.** Even fully fixed, Layer-1 is a stopgap until #72; together they decide whether the log is signal or noise.
577
+
578
+ ### 75. The episodic layer has no fair test — this repo is a pathological self-pollution case
579
+
580
+ Source: 2026-06-23 observational-layer audit.
581
+
582
+ **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.
583
+
584
+ **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.
585
+
586
+ **Cross-links.** Gates any future episodic value claim; depends on #72–#74 being fixed first to be worth measuring.
587
+
588
+ ---
589
+
507
590
  ## Medium Priority
508
591
 
509
592
  ### ~~18. Shell Completion for CLI~~ ✅ Implemented 2026-03-20
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Commands
5
+ module Checks
6
+ # Probes the FTS5 BM25 ranking path (`MATCH ... ORDER BY rank`) — exactly
7
+ # what recall issues. A contentless FTS5 index can corrupt such that plain
8
+ # MATCH, `SELECT count(*)`, and `PRAGMA integrity_check` all pass but
9
+ # `ORDER BY rank` raises "database disk image is malformed", silently
10
+ # breaking recall while every other check reports healthy (issue #7,
11
+ # Finding 2). Recoverable with `claude-memory compact`.
12
+ class FtsRankCheck
13
+ PROBE_TERM = "the" # extremely common; matches any non-trivial English corpus
14
+
15
+ def initialize(db_path, label)
16
+ @db_path = db_path
17
+ @label = label
18
+ end
19
+
20
+ def call
21
+ return skip_result unless File.exist?(@db_path)
22
+
23
+ store = Store::SQLiteStore.new(@db_path)
24
+ begin
25
+ Index::LexicalFTS.new(store).search(PROBE_TERM, limit: 1)
26
+ ok_result
27
+ ensure
28
+ store.close
29
+ end
30
+ rescue Index::LexicalFTS::CorruptRankIndexError
31
+ corrupt_result
32
+ rescue => e
33
+ # Anything unrelated (e.g. no FTS table) is not this check's concern.
34
+ {status: :ok, label: fts_label, message: "#{@label} FTS5 rank probe skipped: #{e.message}", details: {}}
35
+ end
36
+
37
+ private
38
+
39
+ def fts_label = "#{@label}_fts"
40
+
41
+ def ok_result
42
+ {status: :ok, label: fts_label, message: "#{@label} FTS5 rank path: healthy", details: {}}
43
+ end
44
+
45
+ def corrupt_result
46
+ {
47
+ status: :error,
48
+ label: fts_label,
49
+ message: "#{@label} FTS5 rank index is corrupt — recall is broken (integrity_check misses this). Run 'claude-memory compact' to rebuild.",
50
+ details: {}
51
+ }
52
+ end
53
+
54
+ def skip_result
55
+ {status: :ok, label: fts_label, message: "#{@label} FTS5: no database", details: {}}
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -22,6 +22,8 @@ module ClaudeMemory
22
22
  Checks::DatabaseCheck.new(manager.project_db_path, "project"),
23
23
  Checks::DistillCheck.new(manager.global_db_path, "global"),
24
24
  Checks::DistillCheck.new(manager.project_db_path, "project"),
25
+ Checks::FtsRankCheck.new(manager.global_db_path, "global"),
26
+ Checks::FtsRankCheck.new(manager.project_db_path, "project"),
25
27
  Checks::VecCheck.new,
26
28
  Checks::EmbeddingsCheck.new,
27
29
  Checks::SnapshotCheck.new,
@@ -77,12 +77,30 @@ module ClaudeMemory
77
77
  run_indexing(store, facts, generator, tracker, operation_id, checkpoint, opts)
78
78
  end
79
79
 
80
+ # Reconcile the vec0 table's width with the resolved provider before
81
+ # indexing (issue #7, Finding 1). The vec0 width is immutable once the
82
+ # table is created and was only recorded in meta *after* a successful run,
83
+ # so an old tfidf/fresh DB silently created a 384 table and the first
84
+ # non-384 insert hard-failed. We detect the table's actual width directly
85
+ # (not via the meta, which may be unset) and rebuild when it differs.
80
86
  def handle_dimension_mismatch(store, generator, label)
81
- check = Embeddings::DimensionCheck.call(store, generator)
82
- return unless check.status == :mismatch
87
+ target = generator.dimensions
88
+ # Record the resolved dimension up front so a fresh table is created at
89
+ # the right width on first insert, not left at the 384 default.
90
+ store.set_meta("embedding_dimensions", target.to_s)
91
+ store.set_meta("embedding_provider", generator.name)
83
92
 
84
- stdout.puts "#{label.capitalize}: Embedding dimensions changed (#{check.stored} → #{check.current}), clearing stale embeddings..."
85
- clear_stale_embeddings(store)
93
+ vec_index = store.vector_index
94
+ return unless vec_index.available?
95
+
96
+ actual = vec_index.table_dimensions
97
+ return if actual == target # table already at the right width
98
+
99
+ if actual # genuine change: existing facts must re-embed at the new width
100
+ stdout.puts "#{label.capitalize}: Embedding dimensions changed (#{actual} → #{target}), rebuilding vector table..."
101
+ clear_stale_embeddings(store)
102
+ end
103
+ vec_index.recreate!(target)
86
104
  end
87
105
 
88
106
  def find_facts_to_index(store, tracker, label, opts)
@@ -14,8 +14,8 @@ module ClaudeMemory
14
14
  # 1. Verify the chosen provider is loadable. For fastembed, surface
15
15
  # a clear install command if the gem isn't on $LOAD_PATH.
16
16
  # 2. Persist CLAUDE_MEMORY_EMBEDDING_PROVIDER (and optional model)
17
- # into the project's .claude/settings.json env block, the same
18
- # mechanism Claude Code uses for OTel env (see OTel::SettingsWriter).
17
+ # into the project's .claude/settings.local.json env block (per-machine,
18
+ # gitignored the provider is a developer choice, not a repo default).
19
19
  # 3. Re-embed existing facts under the new provider (unless --no-reindex).
20
20
  # 4. Report the final state — provider, dimensions, stored alignment.
21
21
  class SetupVectorsCommand < BaseCommand
@@ -110,8 +110,14 @@ module ClaudeMemory
110
110
  false
111
111
  end
112
112
 
113
+ # Write to settings.local.json (per-machine, gitignored), NOT the tracked
114
+ # settings.json: the embedding provider is a developer choice. Committing
115
+ # it would force the optional fastembed dependency on collaborators and —
116
+ # because Claude Code injects settings env into every subprocess — leak a
117
+ # non-default provider into the test suite, flipping provider-dependent
118
+ # specs (issue #7 follow-up, 2026-06-25).
113
119
  def settings_path
114
- File.join(claude_dir, "settings.json")
120
+ File.join(claude_dir, "settings.local.json")
115
121
  end
116
122
 
117
123
  def claude_dir
@@ -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 rather than a statement.
53
- NOISE_BODY_SIGNATURE = /\bdef\s|\bclass\s|\bmodule\s|=>|::|","|":\s*"|[{}]|\$\(|&&|\|\|/
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
- sections << format_distillation_prompt(undistilled) if undistilled.any?
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?
@@ -3,6 +3,15 @@
3
3
  module ClaudeMemory
4
4
  module Index
5
5
  class LexicalFTS
6
+ # Raised when the FTS5 BM25 ranking path fails as "malformed" while the
7
+ # rest of the DB is fine — a corrupt contentless FTS5 index that
8
+ # PRAGMA integrity_check misses (issue #7, Finding 2). Recoverable with
9
+ # `claude-memory compact` (which runs #rebuild!).
10
+ class CorruptRankIndexError < StandardError; end
11
+
12
+ RANK_CORRUPTION_HINT = "FTS5 rank index is corrupt — recall is broken even though " \
13
+ "the database otherwise looks healthy. Run `claude-memory compact` to rebuild it."
14
+
6
15
  def initialize(store)
7
16
  @store = store
8
17
  @db = store.db
@@ -35,17 +44,19 @@ module ClaudeMemory
35
44
  end
36
45
 
37
46
  escaped_query = escape_fts_query(query)
38
- if contentless?
39
- @db.fetch(
40
- "SELECT rowid AS content_item_id FROM content_fts WHERE text MATCH ? ORDER BY rank LIMIT ?",
41
- escaped_query, limit
42
- ).map { |row| row[:content_item_id] }
43
- else
44
- @db[:content_fts]
45
- .where(Sequel.lit("text MATCH ?", escaped_query))
46
- .order(:rank)
47
- .limit(limit)
48
- .select_map(:content_item_id)
47
+ with_rank_index do
48
+ if contentless?
49
+ @db.fetch(
50
+ "SELECT rowid AS content_item_id FROM content_fts WHERE text MATCH ? ORDER BY rank LIMIT ?",
51
+ escaped_query, limit
52
+ ).map { |row| row[:content_item_id] }
53
+ else
54
+ @db[:content_fts]
55
+ .where(Sequel.lit("text MATCH ?", escaped_query))
56
+ .order(:rank)
57
+ .limit(limit)
58
+ .select_map(:content_item_id)
59
+ end
49
60
  end
50
61
  end
51
62
 
@@ -59,18 +70,20 @@ module ClaudeMemory
59
70
  return [] if query.strip == "*"
60
71
 
61
72
  escaped_query = escape_fts_query(query)
62
- if contentless?
63
- @db.fetch(
64
- "SELECT rowid AS content_item_id, rank FROM content_fts WHERE text MATCH ? ORDER BY rank LIMIT ?",
65
- escaped_query, limit
66
- ).all
67
- else
68
- @db[:content_fts]
69
- .where(Sequel.lit("text MATCH ?", escaped_query))
70
- .order(:rank)
71
- .limit(limit)
72
- .select(Sequel.lit("content_item_id, rank"))
73
- .all
73
+ with_rank_index do
74
+ if contentless?
75
+ @db.fetch(
76
+ "SELECT rowid AS content_item_id, rank FROM content_fts WHERE text MATCH ? ORDER BY rank LIMIT ?",
77
+ escaped_query, limit
78
+ ).all
79
+ else
80
+ @db[:content_fts]
81
+ .where(Sequel.lit("text MATCH ?", escaped_query))
82
+ .order(:rank)
83
+ .limit(limit)
84
+ .select(Sequel.lit("content_item_id, rank"))
85
+ .all
86
+ end
74
87
  end
75
88
  end
76
89
 
@@ -117,6 +130,17 @@ module ClaudeMemory
117
130
 
118
131
  private
119
132
 
133
+ # Run a `MATCH ... ORDER BY rank` query, translating the narrow
134
+ # "malformed"-on-rank failure into an actionable error instead of an
135
+ # unhandled Extralite stacktrace (issue #7, Finding 2). Any other error
136
+ # propagates unchanged.
137
+ def with_rank_index
138
+ yield
139
+ rescue => e
140
+ raise unless e.message.to_s.include?("malformed")
141
+ raise CorruptRankIndexError, RANK_CORRUPTION_HINT
142
+ end
143
+
120
144
  def contentless?
121
145
  @contentless
122
146
  end
@@ -134,6 +134,33 @@ module ClaudeMemory
134
134
  true
135
135
  end
136
136
 
137
+ # Drop and rebuild facts_vec at `dimensions`. A vec0 column width is
138
+ # immutable once the table is created, so adopting a model of a different
139
+ # dimension (or any model on a DB whose table was created at the 384
140
+ # default) requires a full rebuild — clearing rows isn't enough (issue #7,
141
+ # Finding 1). Requires the sqlite-vec extension loaded so the vec0
142
+ # destructor runs on DROP.
143
+ # @param dimensions [Integer] new embedding width
144
+ def recreate!(dimensions)
145
+ return false unless available?
146
+
147
+ @dimensions = dimensions
148
+ @db.run("DROP TABLE IF EXISTS facts_vec")
149
+ @vec_table_ensured = false
150
+ ensure_vec_table!
151
+ true
152
+ end
153
+
154
+ # The width facts_vec was actually created with, parsed from its DDL — or
155
+ # nil when the table doesn't exist yet. Detects a stale-width table even
156
+ # when the embedding_dimensions meta was never written (old tfidf DBs),
157
+ # which is exactly the case that silently left a 384 table in place.
158
+ # @return [Integer, nil]
159
+ def table_dimensions
160
+ ddl = @db[:sqlite_master].where(type: "table", name: "facts_vec").get(:sql)
161
+ ddl && ddl[/embedding\s+float\[(\d+)\]/, 1]&.to_i
162
+ end
163
+
137
164
  # Number of entries in the vec0 virtual table
138
165
  def count
139
166
  return 0 unless available?
@@ -14,16 +14,19 @@ module ClaudeMemory
14
14
  # and writes JSON responses to output.
15
15
  class Server
16
16
  PROTOCOL_VERSION = "2024-11-05"
17
+ ORPHAN_CHECK_INTERVAL_SECONDS = 30
17
18
 
18
19
  # @param store_or_manager [Store::SQLiteStore, Store::StoreManager] database backend
19
20
  # @param input [IO] input stream for JSON-RPC requests (default: $stdin)
20
21
  # @param output [IO] output stream for JSON-RPC responses (default: $stdout)
21
- def initialize(store_or_manager, input: $stdin, output: $stdout)
22
+ # @param parent_pid [Integer] pid to watch for orphaning (default: caller's parent)
23
+ def initialize(store_or_manager, input: $stdin, output: $stdout, parent_pid: Process.ppid)
22
24
  @store_or_manager = store_or_manager
23
25
  @tools = Tools.new(store_or_manager)
24
26
  @telemetry = Telemetry.new(store_or_manager)
25
27
  @input = input
26
28
  @output = output
29
+ @parent_pid = parent_pid
27
30
  @running = false
28
31
  end
29
32
 
@@ -31,12 +34,24 @@ module ClaudeMemory
31
34
  # @return [void]
32
35
  def run
33
36
  @running = true
37
+ watchdog = start_orphan_watchdog
34
38
  while @running
35
39
  line = @input.gets
36
40
  break unless line
37
41
 
38
42
  handle_message(line.strip)
39
43
  end
44
+ ensure
45
+ watchdog&.kill
46
+ end
47
+
48
+ # True once our original parent has exited and we've been reparented
49
+ # (to PID 1 / a subreaper). Claude Code spawns one stdio MCP server per
50
+ # session; a hard kill of the client can leave the server blocked on
51
+ # `gets` forever, holding a SQLite connection — the orphan leak in issue
52
+ # #7, Finding 3. The watchdog uses this to terminate.
53
+ def orphaned?
54
+ @parent_pid > 1 && Process.ppid != @parent_pid
40
55
  end
41
56
 
42
57
  # Signal the read loop to exit after the current message.
@@ -47,6 +62,23 @@ module ClaudeMemory
47
62
 
48
63
  private
49
64
 
65
+ # Background watch that terminates the process when the parent dies, since
66
+ # the read loop is otherwise blocked indefinitely on `gets`. Returns the
67
+ # Thread, or nil when there's no meaningful parent to watch (e.g. a test
68
+ # harness or a manually-launched server). Uses `exit!` because the orphaned
69
+ # parent is gone — there's nothing reading our output to flush to, and the
70
+ # OS releases the SQLite connection on process exit.
71
+ def start_orphan_watchdog
72
+ return if @parent_pid <= 1
73
+
74
+ Thread.new do
75
+ loop do
76
+ sleep ORPHAN_CHECK_INTERVAL_SECONDS
77
+ exit!(0) if orphaned?
78
+ end
79
+ end
80
+ end
81
+
50
82
  # @return [void]
51
83
  def handle_message(line)
52
84
  return if line.empty?