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.
- checksums.yaml +4 -4
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +25 -0
- data/docs/improvements.md +83 -0
- data/lib/claude_memory/commands/checks/fts_rank_check.rb +60 -0
- data/lib/claude_memory/commands/doctor_command.rb +2 -0
- data/lib/claude_memory/commands/index_command.rb +22 -4
- data/lib/claude_memory/commands/setup_vectors_command.rb +9 -3
- data/lib/claude_memory/distill/null_distiller.rb +24 -2
- data/lib/claude_memory/hook/context_injector.rb +35 -10
- data/lib/claude_memory/index/lexical_fts.rb +47 -23
- data/lib/claude_memory/index/vector_index.rb +27 -0
- data/lib/claude_memory/mcp/server.rb +33 -1
- data/lib/claude_memory/observe/reflector.rb +32 -16
- data/lib/claude_memory/observe/token_overlap_matcher.rb +55 -0
- data/lib/claude_memory/sweep/maintenance.rb +22 -0
- data/lib/claude_memory/sweep/sweeper.rb +1 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +2 -0
- metadata +3 -25
- data/.claude/CLAUDE.md +0 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/output-styles/memory-aware.md +0 -1
- data/.claude/rules/claude_memory.generated.md +0 -87
- data/.claude/settings.json +0 -113
- data/.claude/settings.local.json +0 -59
- data/.claude/skills/check-memory/DEPRECATED.md +0 -29
- data/.claude/skills/check-memory/SKILL.md +0 -87
- data/.claude/skills/dashboard/SKILL.md +0 -42
- data/.claude/skills/debug-memory +0 -1
- data/.claude/skills/improve/SKILL.md +0 -631
- data/.claude/skills/improve/feature-patterns.md +0 -1221
- data/.claude/skills/memory-first-workflow +0 -1
- data/.claude/skills/quality-update/SKILL.md +0 -229
- data/.claude/skills/quality-update/implementation-guide.md +0 -346
- data/.claude/skills/release/SKILL.md +0 -206
- data/.claude/skills/review-commit/SKILL.md +0 -199
- data/.claude/skills/review-for-quality/SKILL.md +0 -154
- data/.claude/skills/review-for-quality/expert-checklists.md +0 -79
- data/.claude/skills/setup-memory +0 -1
- data/.claude/skills/study-repo/SKILL.md +0 -322
- data/.claude/skills/study-repo/analysis-template.md +0 -323
- data/.claude/skills/study-repo/focus-examples.md +0 -327
- data/.claude/skills/upgrade-dependencies/SKILL.md +0 -154
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fef30295649f5cdb709f3650bbdfe7411ddccd566a906eaa09247a1b88f895a5
|
|
4
|
+
data.tar.gz: 3edd0ef999cb0c4356ef83c43e20646eed8cc8b68b9a2c8c1963191935eaaf98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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"
|
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.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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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,
|
|
18
|
-
#
|
|
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
|
|
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?
|
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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?
|