claude_memory 0.7.0 → 0.8.0

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/memory.sqlite3-shm +0 -0
  4. data/.claude/memory.sqlite3-wal +0 -0
  5. data/.claude/settings.json +78 -6
  6. data/.claude/settings.local.json +5 -2
  7. data/.claude/skills/improve/SKILL.md +113 -25
  8. data/.claude-plugin/commands/distill-transcripts.md +98 -0
  9. data/.claude-plugin/commands/memory-recall.md +67 -0
  10. data/.claude-plugin/marketplace.json +1 -1
  11. data/.claude-plugin/plugin.json +1 -2
  12. data/CHANGELOG.md +74 -1
  13. data/CLAUDE.md +32 -6
  14. data/README.md +1 -1
  15. data/docs/improvements.md +51 -91
  16. data/docs/influence/lossless-claw.md +409 -0
  17. data/docs/quality_review.md +119 -224
  18. data/hooks/hooks.json +39 -7
  19. data/lib/claude_memory/commands/checks/distill_check.rb +61 -0
  20. data/lib/claude_memory/commands/checks/hooks_check.rb +2 -2
  21. data/lib/claude_memory/commands/checks/vec_check.rb +2 -1
  22. data/lib/claude_memory/commands/completion_command.rb +179 -0
  23. data/lib/claude_memory/commands/doctor_command.rb +2 -0
  24. data/lib/claude_memory/commands/help_command.rb +4 -0
  25. data/lib/claude_memory/commands/hook_command.rb +2 -1
  26. data/lib/claude_memory/commands/index_command.rb +100 -65
  27. data/lib/claude_memory/commands/initializers/database_ensurer.rb +16 -0
  28. data/lib/claude_memory/commands/initializers/global_initializer.rb +2 -1
  29. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +55 -11
  30. data/lib/claude_memory/commands/initializers/project_initializer.rb +2 -1
  31. data/lib/claude_memory/commands/install_skill_command.rb +78 -0
  32. data/lib/claude_memory/commands/registry.rb +3 -1
  33. data/lib/claude_memory/commands/skills/distill-transcripts.md +98 -0
  34. data/lib/claude_memory/commands/skills/memory-recall.md +67 -0
  35. data/lib/claude_memory/core/fact_ranker.rb +2 -2
  36. data/lib/claude_memory/core/rr_fusion.rb +23 -6
  37. data/lib/claude_memory/core/snippet_extractor.rb +7 -3
  38. data/lib/claude_memory/core/text_builder.rb +11 -0
  39. data/lib/claude_memory/domain/provenance.rb +0 -1
  40. data/lib/claude_memory/embeddings/api_adapter.rb +96 -0
  41. data/lib/claude_memory/embeddings/dimension_check.rb +23 -0
  42. data/lib/claude_memory/embeddings/fastembed_adapter.rb +4 -0
  43. data/lib/claude_memory/embeddings/generator.rb +4 -0
  44. data/lib/claude_memory/embeddings/resolver.rb +18 -0
  45. data/lib/claude_memory/hook/context_injector.rb +58 -2
  46. data/lib/claude_memory/hook/distillation_runner.rb +46 -0
  47. data/lib/claude_memory/hook/handler.rb +11 -2
  48. data/lib/claude_memory/index/vector_index.rb +15 -2
  49. data/lib/claude_memory/infrastructure/schema_validator.rb +3 -3
  50. data/lib/claude_memory/mcp/error_classifier.rb +171 -0
  51. data/lib/claude_memory/mcp/handlers/context_handlers.rb +38 -0
  52. data/lib/claude_memory/mcp/handlers/management_handlers.rb +145 -0
  53. data/lib/claude_memory/mcp/handlers/query_handlers.rb +115 -0
  54. data/lib/claude_memory/mcp/handlers/setup_handlers.rb +211 -0
  55. data/lib/claude_memory/mcp/handlers/shortcut_handlers.rb +37 -0
  56. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +202 -0
  57. data/lib/claude_memory/mcp/instructions_builder.rb +64 -5
  58. data/lib/claude_memory/mcp/query_guide.rb +51 -22
  59. data/lib/claude_memory/mcp/response_formatter.rb +4 -1
  60. data/lib/claude_memory/mcp/server.rb +1 -0
  61. data/lib/claude_memory/mcp/text_summary.rb +28 -1
  62. data/lib/claude_memory/mcp/tool_definitions.rb +33 -3
  63. data/lib/claude_memory/mcp/tool_helpers.rb +43 -0
  64. data/lib/claude_memory/mcp/tools.rb +47 -681
  65. data/lib/claude_memory/recall/dual_engine.rb +105 -0
  66. data/lib/claude_memory/recall/legacy_engine.rb +138 -0
  67. data/lib/claude_memory/recall/query_core.rb +371 -0
  68. data/lib/claude_memory/recall.rb +29 -616
  69. data/lib/claude_memory/shortcuts.rb +4 -4
  70. data/lib/claude_memory/store/retry_handler.rb +61 -0
  71. data/lib/claude_memory/store/schema_manager.rb +68 -0
  72. data/lib/claude_memory/store/sqlite_store.rb +85 -201
  73. data/lib/claude_memory/sweep/maintenance.rb +126 -0
  74. data/lib/claude_memory/sweep/sweeper.rb +81 -75
  75. data/lib/claude_memory/templates/hooks.example.json +26 -7
  76. data/lib/claude_memory/version.rb +1 -1
  77. data/lib/claude_memory.rb +12 -0
  78. data/v0.6.0.ANNOUNCE +32 -0
  79. metadata +27 -1
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Commands
5
+ # Installs embedded skill files (agent definitions) to ~/.claude/commands/
6
+ # for use as Claude Code slash commands.
7
+ class InstallSkillCommand < BaseCommand
8
+ SKILLS_DIR = File.expand_path("../skills", __FILE__)
9
+
10
+ AVAILABLE_SKILLS = {
11
+ "memory-recall" => {
12
+ file: "memory-recall.md",
13
+ description: "Memory recall agent — chains recall → explain → fact_graph"
14
+ },
15
+ "distill-transcripts" => {
16
+ file: "distill-transcripts.md",
17
+ description: "Distill transcripts — extract facts/entities/decisions from undistilled content"
18
+ }
19
+ }.freeze
20
+
21
+ def call(args)
22
+ opts = parse_options(args, {list: false, force: false}) do |o|
23
+ OptionParser.new do |parser|
24
+ parser.banner = "Usage: claude-memory install-skill [SKILL_NAME] [options]"
25
+ parser.on("--list", "List available skills") { o[:list] = true }
26
+ parser.on("--force", "Overwrite existing files") { o[:force] = true }
27
+ end
28
+ end
29
+ return 1 if opts.nil?
30
+
31
+ if opts[:list] || args.empty?
32
+ return list_skills
33
+ end
34
+
35
+ skill_name = args.first
36
+ install_skill(skill_name, force: opts[:force])
37
+ end
38
+
39
+ private
40
+
41
+ def list_skills
42
+ stdout.puts "Available skills:"
43
+ AVAILABLE_SKILLS.each do |name, info|
44
+ stdout.puts " #{name} — #{info[:description]}"
45
+ end
46
+ stdout.puts ""
47
+ stdout.puts "Install with: claude-memory install-skill <name>"
48
+ 0
49
+ end
50
+
51
+ def install_skill(name, force: false)
52
+ skill = AVAILABLE_SKILLS[name]
53
+ unless skill
54
+ return failure("Unknown skill: #{name}. Run --list to see available skills.")
55
+ end
56
+
57
+ source = File.join(SKILLS_DIR, skill[:file])
58
+ unless File.exist?(source)
59
+ return failure("Skill file not found: #{source}")
60
+ end
61
+
62
+ target_dir = File.join(Dir.home, ".claude", "commands")
63
+ FileUtils.mkdir_p(target_dir)
64
+
65
+ target = File.join(target_dir, skill[:file])
66
+
67
+ if File.exist?(target) && !force
68
+ return failure("#{target} already exists. Use --force to overwrite.")
69
+ end
70
+
71
+ FileUtils.cp(source, target)
72
+ stdout.puts "Installed #{name} to #{target}"
73
+ stdout.puts "Use as: /#{File.basename(name, ".md")} <query>"
74
+ 0
75
+ end
76
+ end
77
+ end
78
+ end
@@ -30,7 +30,9 @@ module ClaudeMemory
30
30
  "recover" => "RecoverCommand",
31
31
  "compact" => "CompactCommand",
32
32
  "export" => "ExportCommand",
33
- "git-lfs" => "GitLfsCommand"
33
+ "git-lfs" => "GitLfsCommand",
34
+ "install-skill" => "InstallSkillCommand",
35
+ "completion" => "CompletionCommand"
34
36
  }.freeze
35
37
 
36
38
  # Find a command class by name
@@ -0,0 +1,98 @@
1
+ # Distill Transcripts
2
+
3
+ Extract structured knowledge (facts, entities, decisions) from undistilled transcript content and persist it to long-term memory.
4
+
5
+ ## Usage
6
+
7
+ ```
8
+ /distill-transcripts
9
+ /distill-transcripts --limit 10
10
+ ```
11
+
12
+ ## Instructions
13
+
14
+ You are a knowledge extraction specialist. Your job is to read raw transcript content and extract structured facts, entities, and decisions, then persist them via the memory.store_extraction MCP tool.
15
+
16
+ ### Step 1: Get Undistilled Content
17
+
18
+ Call `memory.undistilled` with `limit: 10` to get transcript content that hasn't been processed yet.
19
+
20
+ If no items are returned, report "No undistilled content found" and stop.
21
+
22
+ ### Step 2: Extract Knowledge (per item)
23
+
24
+ For each content item, carefully read the raw_text and extract:
25
+
26
+ **Entities** — Named things mentioned:
27
+ - type: database, framework, language, platform, repo, module, person, service
28
+ - name: Canonical name (e.g., "PostgreSQL" not "postgres")
29
+ - confidence: 0.0-1.0
30
+
31
+ **Facts** — Knowledge learned:
32
+ - subject: Entity name or "repo" for project-level facts
33
+ - predicate: uses_database, uses_framework, convention, decision, auth_method, deployment_platform, depends_on, testing_strategy
34
+ - object: The value
35
+ - confidence: 0.0-1.0
36
+ - quote: Source excerpt (max 200 chars)
37
+ - strength: "stated" (explicitly said) or "inferred" (implied)
38
+ - scope_hint: "project" (this project only) or "global" (all projects)
39
+
40
+ **Decisions** — Choices made:
41
+ - title: Short summary (max 100 chars)
42
+ - summary: Full description
43
+ - status_hint: "accepted", "proposed", or "rejected"
44
+
45
+ ### What to Extract
46
+
47
+ - Technology choices ("we use PostgreSQL", "switched to React")
48
+ - Conventions ("always use frozen_string_literal", "test files go in spec/")
49
+ - Architectural decisions ("API uses REST", "auth via JWT")
50
+ - Preferences ("prefer 4-space indent", "use Standard Ruby")
51
+ - Project structure ("migrations in db/migrations/", "commands in commands/")
52
+
53
+ ### What to Skip
54
+
55
+ - Debugging steps and transient errors
56
+ - Code output and tool observations
57
+ - File contents that were just being read
58
+ - Ephemeral task details ("fix this test", "run the linter")
59
+ - Information already obvious from the codebase itself
60
+
61
+ ### Scope Detection
62
+
63
+ Set scope_hint to "global" when the text contains signals like:
64
+ - "I always...", "in all my projects...", "my preference is..."
65
+ - "everywhere", "across all repos"
66
+
67
+ Default to "project" for everything else.
68
+
69
+ ### Step 3: Persist Each Extraction
70
+
71
+ For each content item with extracted knowledge:
72
+
73
+ 1. Call `memory.store_extraction` with the entities, facts, and decisions arrays
74
+ 2. Call `memory.mark_distilled` with the content_item_id and facts_extracted count
75
+ 3. If nothing was extracted, still call `memory.mark_distilled` with facts_extracted: 0
76
+
77
+ ### Step 4: Report
78
+
79
+ Return a summary:
80
+
81
+ ```
82
+ ## Distillation Complete
83
+
84
+ - Items processed: N
85
+ - Facts extracted: N
86
+ - Entities found: N
87
+ - Decisions captured: N
88
+ - Items skipped (nothing to extract): N
89
+ ```
90
+
91
+ ### Guidelines
92
+
93
+ - Process items one at a time to keep extractions focused
94
+ - Use `compact: true` on `memory.undistilled` for smaller responses
95
+ - Be conservative — only extract facts you're confident about (>0.7)
96
+ - Prefer "stated" strength over "inferred" unless clearly implied
97
+ - Do NOT fabricate facts — only extract what's actually in the text
98
+ - If text is mostly code/tool output with no conversational knowledge, mark as distilled with 0 facts
@@ -0,0 +1,67 @@
1
+ # Memory Recall Agent
2
+
3
+ Search long-term memory for facts, decisions, conventions, and architectural knowledge. Chains multiple memory tools to build comprehensive answers while saving main-agent context.
4
+
5
+ ## Usage
6
+
7
+ Provide a natural language query describing what you want to recall:
8
+
9
+ ```
10
+ /memory-recall database migration strategy
11
+ /memory-recall authentication decisions
12
+ /memory-recall testing conventions
13
+ ```
14
+
15
+ ## Workflow
16
+
17
+ 1. **Fast lookup** — Start with `memory.recall` for keyword matches
18
+ 2. **Semantic search** — If recall returns few results, try `memory.recall_semantic` for conceptual matches
19
+ 3. **Shortcuts** — For known categories, use `memory.decisions`, `memory.conventions`, or `memory.architecture`
20
+ 4. **Deep dive** — For specific facts, use `memory.explain` to get provenance and `memory.fact_graph` to see relationships
21
+ 5. **Synthesize** — Combine findings into a concise, structured answer
22
+
23
+ ## Instructions
24
+
25
+ You are a memory recall specialist. Given a query, search ClaudeMemory using the available MCP tools and return a synthesized answer.
26
+
27
+ ### Step 1: Initial Search
28
+
29
+ Run `memory.recall` with the user's query. If the query mentions decisions, conventions, or architecture, also run the appropriate shortcut tool in parallel.
30
+
31
+ ### Step 2: Expand if Needed
32
+
33
+ If Step 1 returns fewer than 3 results:
34
+ - Try `memory.recall_semantic` with a rephrased version of the query
35
+ - Try `memory.search_concepts` with 2-3 key concepts extracted from the query
36
+
37
+ ### Step 3: Enrich Key Facts
38
+
39
+ For the top 2-3 most relevant facts:
40
+ - Run `memory.explain` to get provenance (where the fact came from)
41
+ - If relationships matter, run `memory.fact_graph` to see connected facts
42
+
43
+ ### Step 4: Synthesize
44
+
45
+ Return a structured response:
46
+
47
+ ```
48
+ ## Memory Recall Results
49
+
50
+ ### Key Facts
51
+ - [Fact 1 with provenance]
52
+ - [Fact 2 with provenance]
53
+
54
+ ### Context
55
+ [How these facts relate to the query]
56
+
57
+ ### Confidence
58
+ [High/Medium/Low based on number and freshness of supporting facts]
59
+ ```
60
+
61
+ ### Guidelines
62
+
63
+ - Prefer `memory.recall` (fast, token-efficient) before escalating to semantic search
64
+ - Use `compact: true` on all tool calls to minimize token usage
65
+ - Do NOT fabricate facts — only report what memory tools return
66
+ - If no relevant facts found, say so clearly rather than guessing
67
+ - Include fact IDs so the main agent can reference them
@@ -88,8 +88,8 @@ module ClaudeMemory
88
88
  # @param text_results [Array<Hash>] Results from text search with :fact and :similarity
89
89
  # @param limit [Integer] Maximum results to return
90
90
  # @return [Array<Hash>] Merged results sorted by RRF score descending
91
- def self.merge_search_results(vector_results, text_results, limit)
92
- RRFusion.fuse(vector_results, text_results, limit)
91
+ def self.merge_search_results(vector_results, text_results, limit, explain: false)
92
+ RRFusion.fuse(vector_results, text_results, limit, explain: explain)
93
93
  end
94
94
  end
95
95
  end
@@ -22,16 +22,23 @@ module ClaudeMemory
22
22
  # @param vector_weight [Float] Weight multiplier for vector rankings (default 1.0)
23
23
  # @param text_weight [Float] Weight multiplier for text rankings (default 1.0)
24
24
  # @return [Array<Hash>] Fused results sorted by RRF score, with :similarity set to RRF score
25
- def self.fuse(vector_results, text_results, limit, vector_weight: 1.0, text_weight: 1.0)
25
+ def self.fuse(vector_results, text_results, limit, vector_weight: 1.0, text_weight: 1.0, explain: false)
26
26
  scores = {}
27
+ traces = {} if explain
27
28
  fact_data = {}
28
29
 
29
30
  # Score vector results by rank position
30
31
  vector_results.each_with_index do |result, idx|
31
32
  fact_id = result[:fact][:id]
32
33
  rank = idx + 1 # 1-based rank
33
- scores[fact_id] = (scores[fact_id] || 0.0) + (vector_weight / (K + rank))
34
- scores[fact_id] += TOP_BONUS.fetch(rank, 0.0)
34
+ contribution = (vector_weight / (K + rank)) + TOP_BONUS.fetch(rank, 0.0)
35
+ scores[fact_id] = (scores[fact_id] || 0.0) + contribution
36
+ if explain
37
+ traces[fact_id] ||= {vec_rank: nil, vec_score: nil, fts_rank: nil, fts_score: nil, vec_rrf: nil, fts_rrf: nil}
38
+ traces[fact_id][:vec_rank] = rank
39
+ traces[fact_id][:vec_score] = result[:similarity]
40
+ traces[fact_id][:vec_rrf] = contribution.round(6)
41
+ end
35
42
  # Prefer vector result data (has real similarity score)
36
43
  fact_data[fact_id] = result
37
44
  end
@@ -40,8 +47,14 @@ module ClaudeMemory
40
47
  text_results.each_with_index do |result, idx|
41
48
  fact_id = result[:fact][:id]
42
49
  rank = idx + 1
43
- scores[fact_id] = (scores[fact_id] || 0.0) + (text_weight / (K + rank))
44
- scores[fact_id] += TOP_BONUS.fetch(rank, 0.0)
50
+ contribution = (text_weight / (K + rank)) + TOP_BONUS.fetch(rank, 0.0)
51
+ scores[fact_id] = (scores[fact_id] || 0.0) + contribution
52
+ if explain
53
+ traces[fact_id] ||= {vec_rank: nil, vec_score: nil, fts_rank: nil, fts_score: nil, vec_rrf: nil, fts_rrf: nil}
54
+ traces[fact_id][:fts_rank] = rank
55
+ traces[fact_id][:fts_score] = result[:similarity]
56
+ traces[fact_id][:fts_rrf] = contribution.round(6)
57
+ end
45
58
  # Only use text data if not already present from vector
46
59
  fact_data[fact_id] ||= result
47
60
  end
@@ -50,7 +63,11 @@ module ClaudeMemory
50
63
  scores
51
64
  .sort_by { |_id, score| -score }
52
65
  .take(limit)
53
- .map { |fact_id, score| fact_data[fact_id].merge(similarity: score) }
66
+ .map do |fact_id, score|
67
+ merged = fact_data[fact_id].merge(similarity: score)
68
+ merged[:score_trace] = traces[fact_id].merge(rrf_final: score.round(6)) if explain
69
+ merged
70
+ end
54
71
  end
55
72
  end
56
73
  end
@@ -32,8 +32,7 @@ module ClaudeMemory
32
32
 
33
33
  lines = parsed[:lines]
34
34
  best_line_idx = parsed[:best_line_idx]
35
- start_idx = [best_line_idx - CONTEXT_BEFORE, 0].max
36
- end_idx = [best_line_idx + CONTEXT_AFTER, lines.size - 1].min
35
+ start_idx, end_idx = snippet_range(lines, best_line_idx)
37
36
 
38
37
  {
39
38
  snippet: build_snippet(lines, best_line_idx),
@@ -81,10 +80,15 @@ module ClaudeMemory
81
80
  end
82
81
 
83
82
  # @api private
84
- def self.build_snippet(lines, center_idx)
83
+ def self.snippet_range(lines, center_idx)
85
84
  start_idx = [center_idx - CONTEXT_BEFORE, 0].max
86
85
  end_idx = [center_idx + CONTEXT_AFTER, lines.size - 1].min
86
+ [start_idx, end_idx]
87
+ end
87
88
 
89
+ # @api private
90
+ def self.build_snippet(lines, center_idx)
91
+ start_idx, end_idx = snippet_range(lines, center_idx)
88
92
  snippet = lines[start_idx..end_idx].join("\n")
89
93
  truncate(snippet)
90
94
  end
@@ -18,6 +18,17 @@ module ClaudeMemory
18
18
  parts.join(" ").strip
19
19
  end
20
20
 
21
+ # Truncate text to a maximum length with a suffix
22
+ # @param text [String, nil] Text to truncate
23
+ # @param max_length [Integer] Maximum length before truncation
24
+ # @param suffix [String] Suffix to append when truncated
25
+ # @return [String] Truncated text or original if within limit
26
+ def self.truncate(text, max_length, suffix: "...")
27
+ return "" if text.nil?
28
+ return text if text.length <= max_length
29
+ text[0, max_length] + suffix
30
+ end
31
+
21
32
  # Transform hash keys from strings to symbols
22
33
  # @param hash [Hash] Hash with string or symbol keys
23
34
  # @return [Hash] Hash with symbolized keys
@@ -41,7 +41,6 @@ module ClaudeMemory
41
41
 
42
42
  def validate!
43
43
  raise ArgumentError, "fact_id required" if fact_id.nil?
44
- raise ArgumentError, "content_item_id required" if content_item_id.nil?
45
44
  end
46
45
  end
47
46
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module ClaudeMemory
8
+ module Embeddings
9
+ # Adapter for any OpenAI-compatible /v1/embeddings endpoint.
10
+ # Works with OpenAI, Voyage, Ollama, LiteLLM, etc.
11
+ #
12
+ # Required ENV:
13
+ # CLAUDE_MEMORY_EMBEDDING_API_KEY or OPENAI_API_KEY
14
+ #
15
+ # Optional ENV:
16
+ # CLAUDE_MEMORY_EMBEDDING_API_URL (default: https://api.openai.com/v1/embeddings)
17
+ # CLAUDE_MEMORY_EMBEDDING_MODEL (default: text-embedding-3-small)
18
+ #
19
+ class ApiAdapter
20
+ class ApiError < StandardError; end
21
+
22
+ DEFAULT_API_URL = "https://api.openai.com/v1/embeddings"
23
+ DEFAULT_MODEL = "text-embedding-3-small"
24
+
25
+ def initialize(env: ENV)
26
+ @api_key = env["CLAUDE_MEMORY_EMBEDDING_API_KEY"] || env["OPENAI_API_KEY"]
27
+ @api_url = env["CLAUDE_MEMORY_EMBEDDING_API_URL"] || DEFAULT_API_URL
28
+ @model = env["CLAUDE_MEMORY_EMBEDDING_MODEL"] || DEFAULT_MODEL
29
+
30
+ raise ArgumentError, "Set CLAUDE_MEMORY_EMBEDDING_API_KEY or OPENAI_API_KEY" unless @api_key
31
+ end
32
+
33
+ def name = "api"
34
+
35
+ # Dimensions are lazy — derived from the first API response and cached.
36
+ def dimensions
37
+ @dimensions ||= fetch_dimensions
38
+ end
39
+
40
+ # Generate embedding for a query text.
41
+ # @param text [String] input text to embed
42
+ # @return [Array<Float>] embedding vector
43
+ def generate(text)
44
+ return zero_vector if text.nil? || text.empty?
45
+
46
+ response = call_api(text)
47
+ embedding = response.dig("data", 0, "embedding")
48
+
49
+ raise ApiError, "No embedding returned in API response" unless embedding
50
+
51
+ @dimensions ||= embedding.size
52
+ embedding
53
+ end
54
+
55
+ # Alias for passage encoding — API providers don't distinguish query vs passage
56
+ alias_method :generate_passage, :generate
57
+
58
+ private
59
+
60
+ def fetch_dimensions
61
+ # Make a minimal API call to discover dimensions
62
+ embedding = generate("dimension probe")
63
+ embedding.size
64
+ end
65
+
66
+ def call_api(text)
67
+ uri = URI(@api_url)
68
+ http = Net::HTTP.new(uri.host, uri.port)
69
+ http.use_ssl = uri.scheme == "https"
70
+ http.open_timeout = 10
71
+ http.read_timeout = 30
72
+
73
+ request = Net::HTTP::Post.new(uri.path)
74
+ request["Authorization"] = "Bearer #{@api_key}"
75
+ request["Content-Type"] = "application/json"
76
+ request.body = JSON.generate({input: text, model: @model})
77
+
78
+ response = http.request(request)
79
+
80
+ unless response.is_a?(Net::HTTPSuccess)
81
+ raise ApiError, "HTTP #{response.code}: #{response.body}"
82
+ end
83
+
84
+ JSON.parse(response.body)
85
+ end
86
+
87
+ def zero_vector
88
+ # If dimensions haven't been discovered yet, we can't return a properly-sized zero vector.
89
+ # Return empty array; callers handle nil/empty gracefully.
90
+ return [] unless @dimensions
91
+
92
+ Array.new(@dimensions, 0.0)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Embeddings
5
+ # Value object that detects embedding dimension mismatches.
6
+ # Returns a Result so the caller decides how to handle mismatches —
7
+ # no hidden side effects like dropping tables.
8
+ class DimensionCheck
9
+ Result = Data.define(:status, :stored, :current)
10
+
11
+ # @param store [Store::SQLiteStore] database to check meta against
12
+ # @param provider [#dimensions] embedding provider
13
+ # @return [Result] status is :fresh, :match, or :mismatch
14
+ def self.call(store, provider)
15
+ stored = store.get_meta("embedding_dimensions")&.to_i
16
+ return Result.new(status: :fresh, stored: nil, current: provider.dimensions) unless stored
17
+ return Result.new(status: :match, stored: stored, current: provider.dimensions) if stored == provider.dimensions
18
+
19
+ Result.new(status: :mismatch, stored: stored, current: provider.dimensions)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -17,6 +17,10 @@ module ClaudeMemory
17
17
  EMBEDDING_DIM = 384
18
18
  DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
19
19
 
20
+ def name = "fastembed"
21
+
22
+ def dimensions = EMBEDDING_DIM
23
+
20
24
  def initialize(model_name: DEFAULT_MODEL)
21
25
  require "fastembed"
22
26
  @model = Fastembed::TextEmbedding.new(model_name: model_name)
@@ -12,6 +12,10 @@ module ClaudeMemory
12
12
  class Generator
13
13
  EMBEDDING_DIM = 384
14
14
 
15
+ def name = "tfidf"
16
+
17
+ def dimensions = EMBEDDING_DIM
18
+
15
19
  # Common technical terms and programming concepts for vocabulary
16
20
  VOCABULARY = %w[
17
21
  database framework library module class function method
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Embeddings
5
+ # Resolves an embedding provider by name or ENV.
6
+ # Three providers: tfidf (default), fastembed, api.
7
+ def self.resolve(name = nil, env: ENV)
8
+ provider = name || env["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] || "tfidf"
9
+
10
+ case provider
11
+ when "tfidf" then Generator.new
12
+ when "fastembed" then FastembedAdapter.new
13
+ when "api" then ApiAdapter.new(env: env)
14
+ else raise ArgumentError, "Unknown embedding provider: #{provider}. Available: tfidf, fastembed, api"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -9,6 +9,10 @@ module ClaudeMemory
9
9
  MAX_DECISIONS = 5
10
10
  MAX_CONVENTIONS = 5
11
11
  MAX_ARCHITECTURE = 5
12
+ MAX_UNDISTILLED = 3
13
+ MAX_TEXT_PER_ITEM = 1500
14
+
15
+ FRESH_SESSION_SOURCES = %w[startup resume clear].freeze
12
16
 
13
17
  QUERIES = {
14
18
  decisions: {query: "decision constraint rule requirement", scope: "all"},
@@ -16,8 +20,9 @@ module ClaudeMemory
16
20
  architecture: {query: "uses framework implements architecture pattern", scope: "all"}
17
21
  }.freeze
18
22
 
19
- def initialize(manager)
23
+ def initialize(manager, source: nil)
20
24
  @manager = manager
25
+ @source = source
21
26
  @recall = Recall.new(manager)
22
27
  end
23
28
 
@@ -33,6 +38,11 @@ module ClaudeMemory
33
38
  architecture = fetch(:architecture, MAX_ARCHITECTURE)
34
39
  sections << format_section("Architecture", architecture) if architecture.any?
35
40
 
41
+ if fresh_session?
42
+ undistilled = fetch_undistilled(MAX_UNDISTILLED)
43
+ sections << format_distillation_prompt(undistilled) if undistilled.any?
44
+ end
45
+
36
46
  return nil if sections.empty?
37
47
 
38
48
  sections.join("\n")
@@ -40,11 +50,16 @@ module ClaudeMemory
40
50
 
41
51
  private
42
52
 
53
+ def fresh_session?
54
+ @source.nil? || FRESH_SESSION_SOURCES.include?(@source)
55
+ end
56
+
43
57
  def fetch(category, limit)
44
58
  config = QUERIES.fetch(category)
45
59
  results = @recall.query(config[:query], limit: limit, scope: config[:scope])
46
60
  results.map { |r| format_fact(r[:fact]) }
47
- rescue => _e
61
+ rescue => e
62
+ ClaudeMemory.logger.debug("ContextInjector#fetch(#{category}) failed: #{e.message}")
48
63
  []
49
64
  end
50
65
 
@@ -62,6 +77,47 @@ module ClaudeMemory
62
77
  end
63
78
  end
64
79
 
80
+ def fetch_undistilled(limit)
81
+ stores = []
82
+ stores << @manager.project_store if @manager.project_store
83
+ stores << @manager.global_store if @manager.global_store
84
+
85
+ items = stores.flat_map { |s|
86
+ s.undistilled_content_items(limit: limit, min_length: 200)
87
+ }
88
+
89
+ items
90
+ .sort_by { |i| i[:occurred_at] || "" }
91
+ .reverse
92
+ .first(limit)
93
+ rescue => e
94
+ ClaudeMemory.logger.warn("ContextInjector#fetch_undistilled failed: #{e.message}")
95
+ []
96
+ end
97
+
98
+ def format_distillation_prompt(items)
99
+ lines = [
100
+ "## Pending Knowledge Extraction",
101
+ "",
102
+ "The following transcript segments haven't been deeply analyzed yet.",
103
+ "Extract facts, entities, and decisions, then call `memory.store_extraction`",
104
+ "followed by `memory.mark_distilled` for each item.",
105
+ "",
106
+ "**What to extract:** technology decisions, conventions, preferences, architecture",
107
+ "**What to skip:** debugging steps, code output, transient errors"
108
+ ]
109
+
110
+ items.each do |item|
111
+ ago = Core::RelativeTime.format(item[:occurred_at]) || "unknown"
112
+ truncated = Core::TextBuilder.truncate(item[:raw_text], MAX_TEXT_PER_ITEM)
113
+ lines << ""
114
+ lines << "### Content Item #{item[:id]} (#{ago})"
115
+ lines << truncated
116
+ end
117
+
118
+ lines.join("\n")
119
+ end
120
+
65
121
  def format_section(title, items)
66
122
  items = items.compact.uniq
67
123
  return nil if items.empty?