Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- **A git worktree no longer gets its own project database under Claude Code.** `Configuration#project_dir` read `CLAUDE_PROJECT_DIR` and returned it directly, so `git_main_repo_root` — added in 20c2731 precisely to stop "duplicate project databases when using git worktrees" — never ran in the environment it was written for. Claude Code sets that variable on every hook and MCP subprocess, pointing at the directory the session started in, so the worktree logic was unreachable outside a bare CLI run, and so was `CLAUDE_MEMORY_ISOLATE_WORKTREES`, which lives behind it. The two are now composed rather than one shadowing the other: the variable says which directory the session is in, git says which repo that directory belongs to, and `CLAUDE_MEMORY_ISOLATE_WORKTREES` remains the way to ask for per-worktree databases. A `CLAUDE_PROJECT_DIR` outside any repo is returned unchanged. Measured on one workspace: 58 of 70 redundant databases, 466 MiB and 704 facts, were worktrees duplicating their main repo. Resolution also no longer inherits `GIT_DIR`/`GIT_INDEX_FILE`, which git exports to its own hooks and which outrank `-C`, so a claude-memory command run from inside a git hook no longer resolves every project to whatever repository invoked the hook.
- **SessionStart no longer reports a spurious hook error on fresh sessions.** The plugin's SessionStart `ingest` hook runs before the new session's transcript file exists, and `transcript_not_found` fell into the catch-all skip branch that exits `WARNING` (1) — so every session started with "SessionStart:startup hook error ... No stderr output" (the skip message went to stdout, which Claude Code doesn't surface on failure). A missing transcript at SessionStart is the expected state, not a warning: it now exits `SUCCESS`, and the remaining catch-all skip reasons write to stderr so any future non-zero exit shows a real message.
- **The sweep no longer aborts on its own pruning step, so databases stop growing without bound.** `ingestion_metrics.content_item_id` was created (v7) as a plain `foreign_key ..., null: false` with no `ON DELETE` action, unlike the sibling `tool_calls` which cascades. `Sweep::Maintenance#prune_old_content` deletes aged content items carrying no provenance, and any such item that still had a metrics row raised `ForeignKeyConstraintViolation` — which escaped the sweeper, so one aged row aborted the entire sweep and nothing was ever pruned. Migration v22 rebuilds the table with `ON DELETE CASCADE`. Observed on a real workspace as a 507 MiB project DB holding 39 facts, where 340 content items were blocking the prune.
- **`sqlite-vec` no longer silently reports itself unavailable on installed gems.** extralite 2.15 has no `Database#load_extension`, so `SqliteVec.load(conn)` raised `NoMethodError`; `VectorIndex#load_extension!` rescued `StandardError` and returned false, which every caller renders as "sqlite-vec not available". The gemspec allowed `~> 2.14` (admitting 2.15) while `Gemfile.lock` pinned 2.14, so the suite and `bundle exec` never saw it and only real installs lost vector recall. 2.15 is the only affected release, since 2.14 and 3.x both have the method, so the dependency is now `>= 2.14, != 2.15, < 3.0` — excluding the one bad version rather than everything above it. 3.x is held back separately: it requires Ruby >= 3.4 where this gem allows >= 3.2, and it turns foreign keys on by default. The probe also keeps its failure reason in `VectorIndex#load_error`, so an unusable extension no longer looks like an absent one.

## [0.13.2] - 2026-06-27

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.
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ PATH
remote: .
specs:
claude_memory (0.13.2)
extralite (~> 2.14)
extralite (>= 2.14, < 3.0, != 2.15)
sequel (~> 5.102)
sqlite-vec (~> 0.1, >= 0.1.9)
webrick (~> 1.8)
Expand Down
8 changes: 7 additions & 1 deletion claude_memory.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]

spec.add_dependency "sequel", "~> 5.102"
spec.add_dependency "extralite", "~> 2.14"
# 2.15 alone has no Extralite::Database#load_extension, so SqliteVec.load
# raises NoMethodError and VectorIndex reports "sqlite-vec not available"
# for what is really a broken dependency. 2.14 and 3.x both have it, so
# this excludes the one bad release rather than everything above it.
# Held below 3.0 separately: it requires Ruby >= 3.4 (we allow >= 3.2)
# and turns foreign keys on by default.
spec.add_dependency "extralite", ">= 2.14", "!= 2.15", "< 3.0"
spec.add_dependency "sqlite-vec", "~> 0.1", ">= 0.1.9"
spec.add_dependency "webrick", "~> 1.8"
end
69 changes: 69 additions & 0 deletions db/migrations/022_cascade_ingestion_metrics.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

# Migration v22: cascade ingestion_metrics when its content item is deleted.
#
# ingestion_metrics.content_item_id was created (v7) as a plain
# `foreign_key ..., null: false` with no ON DELETE action, unlike its
# sibling tool_calls which cascades. Sweep::Maintenance#prune_old_content
# deletes aged content items that carry no provenance, and any such item
# that still has a metrics row raises ForeignKeyConstraintViolation. The
# raise escapes the sweeper, so a single aged row aborts the whole sweep
# and no pruning ever runs — databases grow without bound.
#
# SQLite cannot alter a foreign key in place, so rebuild the table.
Sequel.migration do
up do
# Drop rows whose content item is already gone; they would block the
# copy into a table that enforces the reference.
run <<~SQL
DELETE FROM ingestion_metrics
WHERE content_item_id NOT IN (SELECT id FROM content_items)
SQL

create_table(:ingestion_metrics_new) do
primary_key :id
foreign_key :content_item_id, :content_items, null: false, on_delete: :cascade
Integer :input_tokens
Integer :output_tokens
Integer :facts_extracted
String :created_at, null: false
end

run <<~SQL
INSERT INTO ingestion_metrics_new
(id, content_item_id, input_tokens, output_tokens, facts_extracted, created_at)
SELECT id, content_item_id, input_tokens, output_tokens, facts_extracted, created_at
FROM ingestion_metrics
SQL

drop_table(:ingestion_metrics)
rename_table(:ingestion_metrics_new, :ingestion_metrics)

run "CREATE INDEX IF NOT EXISTS idx_ingestion_metrics_content_item ON ingestion_metrics(content_item_id)"
run "CREATE INDEX IF NOT EXISTS idx_ingestion_metrics_created_at ON ingestion_metrics(created_at)"
end

down do
create_table(:ingestion_metrics_old) do
primary_key :id
foreign_key :content_item_id, :content_items, null: false
Integer :input_tokens
Integer :output_tokens
Integer :facts_extracted
String :created_at, null: false
end

run <<~SQL
INSERT INTO ingestion_metrics_old
(id, content_item_id, input_tokens, output_tokens, facts_extracted, created_at)
SELECT id, content_item_id, input_tokens, output_tokens, facts_extracted, created_at
FROM ingestion_metrics
SQL

drop_table(:ingestion_metrics)
rename_table(:ingestion_metrics_old, :ingestion_metrics)

run "CREATE INDEX IF NOT EXISTS idx_ingestion_metrics_content_item ON ingestion_metrics(content_item_id)"
run "CREATE INDEX IF NOT EXISTS idx_ingestion_metrics_created_at ON ingestion_metrics(created_at)"
end
end
8 changes: 6 additions & 2 deletions lib/claude_memory/commands/hook_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,13 @@ def hook_ingest(handler, payload)
when "session_excluded"
stdout.puts "Session excluded by privacy marker"
Hook::ExitCodes::SUCCESS
else
# transcript_not_found or other skipped reasons
when "transcript_not_found"
# Expected at SessionStart: a fresh session has no transcript yet
stdout.puts "Skipped ingestion: #{result[:reason]}"
Hook::ExitCodes::SUCCESS
else
# Claude Code only surfaces stderr for non-zero exits
stderr.puts "Skipped ingestion: #{result[:reason]}"
Hook::ExitCodes::WARNING
end
else
Expand Down
2 changes: 1 addition & 1 deletion lib/claude_memory/commands/index_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def vec_backfill(opts)
vec_index = store.vector_index

unless vec_index.available?
stderr.puts "#{label.capitalize}: sqlite-vec not available, cannot backfill"
stderr.puts "#{label.capitalize}: sqlite-vec not available, cannot backfill#{" (#{vec_index.load_error})" if vec_index.load_error}"
next
end

Expand Down
64 changes: 42 additions & 22 deletions lib/claude_memory/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ module ClaudeMemory
class Configuration
attr_reader :env

# Environment overrides that clear git's per-invocation variables. A Hash
# passed to Process.spawn is merged into the caller's environment, so a
# filtered copy of ENV does not remove anything - only an explicit nil
# unsets a variable.
GIT_ENV_SCRUB = %w[
GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR
GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
GIT_PREFIX GIT_CEILING_DIRECTORIES
].to_h { |k| [k, nil] }.freeze

# @param env [Hash] environment variables (default: ENV)
def initialize(env = ENV)
@env = env
Expand All @@ -21,7 +31,10 @@ def home_dir

# @return [String] project root directory (resolves git worktrees)
def project_dir
env["CLAUDE_PROJECT_DIR"] || resolve_project_dir
base = env["CLAUDE_PROJECT_DIR"] || Dir.pwd
return base if env["CLAUDE_MEMORY_ISOLATE_WORKTREES"]

git_main_repo_root(base) || base
end

# @return [String] Claude config directory (default: ~/.claude)
Expand Down Expand Up @@ -128,38 +141,45 @@ def settings_json_path

private

def resolve_project_dir
return Dir.pwd if env["CLAUDE_MEMORY_ISOLATE_WORKTREES"]

git_main_repo_root || Dir.pwd
end

# Resolve main repository root, even when running inside a git worktree.
# Uses --git-common-dir which returns the shared .git directory across
# all worktrees, preventing duplicate project databases per worktree.
def git_main_repo_root
common_dir = git_command("rev-parse --git-common-dir")
# Resolve the main repository root for +dir+, even when +dir+ is a git
# worktree. --git-common-dir returns the shared .git directory across all
# worktrees, so every worktree of a repo maps to one project database.
#
# CLAUDE_PROJECT_DIR is resolved through here rather than returned as-is.
# Claude Code sets it on every hook and MCP subprocess, pointing at the
# directory the session started in, so reading it directly would skip this
# entirely and mint a database per worktree. Asking for that is what
# CLAUDE_MEMORY_ISOLATE_WORKTREES is for, and it still does exactly that.
def git_main_repo_root(dir)
common_dir = git_command(dir, "rev-parse", "--git-common-dir")
return nil unless common_dir

if common_dir == ".git"
git_command("rev-parse --show-toplevel")
git_command(dir, "rev-parse", "--show-toplevel")
else
# Worktree - common_dir is absolute path to main repo's .git dir
# (or .git/worktrees parent). Resolve to repo root.
File.dirname(File.realpath(common_dir))
# Worktree - common_dir points at the main repo's .git dir. It can be
# relative, and git resolves it against +dir+, not the process cwd.
File.dirname(File.realpath(File.absolute_path(common_dir, dir)))
end
rescue Errno::ENOENT
# git not available or path doesn't exist
rescue Errno::ENOENT, Errno::ENOTDIR
# git not available, or the path does not exist
nil
end

# Run a git command and return stripped output, or nil on failure
def git_command(args)
output, status = Open3.capture2("git #{args}", err: File::NULL)
# Run a git command in +dir+ and return stripped output, or nil on failure.
# Array form, so a directory containing spaces or shell metacharacters is
# passed through as one argument rather than reparsed by a shell.
#
# GIT_ENV_SCRUB is passed so -C decides the repository. git exports GIT_DIR
# and GIT_INDEX_FILE to its own hooks, and those outrank -C, so a
# claude-memory command invoked from a git hook would otherwise resolve
# every project to whatever repository ran the hook.
def git_command(dir, *args)
output, status = Open3.capture2(GIT_ENV_SCRUB, "git", "-C", dir.to_s, *args, err: File::NULL)
return nil unless status.success?
stripped = output.strip
stripped.empty? ? nil : stripped
rescue Errno::ENOENT
rescue Errno::ENOENT, Errno::ENOTDIR
nil
end
end
Expand Down
11 changes: 10 additions & 1 deletion lib/claude_memory/index/vector_index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ def available?
@available = load_extension!
end

# Why the last extension probe failed, or nil when it succeeded.
# @return [String, nil]
attr_reader :load_error

# Insert (or replace) a fact's embedding into the vec0 virtual table.
# Also sets vec_indexed_at on the fact row.
# @param fact_id [Integer]
Expand Down Expand Up @@ -203,8 +207,13 @@ def load_extension!
@db.synchronize do |conn|
SqliteVec.load(conn)
end
@load_error = nil
true
rescue LoadError, StandardError
rescue LoadError, StandardError => e
# Keep the reason: a silently-false probe here reads as "no vector
# support configured" when it is really a broken dependency, and an
# unusable extension is indistinguishable from an absent one.
@load_error = "#{e.class}: #{e.message}"
false
end

Expand Down
2 changes: 1 addition & 1 deletion lib/claude_memory/store/schema_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Store
# Schema migration and version management for SQLiteStore.
# Handles Sequel migrations, legacy version syncing, and initial setup.
module SchemaManager
SCHEMA_VERSION = 21
SCHEMA_VERSION = 22

private

Expand Down
2 changes: 1 addition & 1 deletion spec/claude_memory/cli_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ def run_cli_with_stdin(*args, input:)

code = run_cli_with_stdin("hook", "ingest", "--db", db_path, input: payload)

expect(code).to eq(ClaudeMemory::Hook::ExitCodes::WARNING)
expect(code).to eq(ClaudeMemory::Hook::ExitCodes::SUCCESS)
expect(stdout.string).to include("Skipped ingestion: transcript_not_found")
end
end
Expand Down
5 changes: 3 additions & 2 deletions spec/claude_memory/commands/hook_command_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
expect(stdout.string).to include("excluded")
end

it "returns WARNING (1) for skipped ingest (missing file)" do
it "returns SUCCESS (0) for missing transcript (fresh session at SessionStart)" do
payload = {
"session_id" => "sess-123",
"transcript_path" => "/nonexistent/file.txt"
Expand All @@ -77,7 +77,8 @@

exit_code = command.call(["ingest", "--db", db_path])

expect(exit_code).to eq(ClaudeMemory::Hook::ExitCodes::WARNING)
expect(exit_code).to eq(ClaudeMemory::Hook::ExitCodes::SUCCESS)
expect(stdout.string).to include("transcript_not_found")
end

it "returns ERROR (2) for invalid JSON payload" do
Expand Down
Loading
Loading