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.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/memory.sqlite3-shm +0 -0
- data/.claude/memory.sqlite3-wal +0 -0
- data/.claude/settings.json +78 -6
- data/.claude/settings.local.json +5 -2
- data/.claude/skills/improve/SKILL.md +113 -25
- data/.claude-plugin/commands/distill-transcripts.md +98 -0
- data/.claude-plugin/commands/memory-recall.md +67 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -2
- data/CHANGELOG.md +74 -1
- data/CLAUDE.md +32 -6
- data/README.md +1 -1
- data/docs/improvements.md +51 -91
- data/docs/influence/lossless-claw.md +409 -0
- data/docs/quality_review.md +119 -224
- data/hooks/hooks.json +39 -7
- data/lib/claude_memory/commands/checks/distill_check.rb +61 -0
- data/lib/claude_memory/commands/checks/hooks_check.rb +2 -2
- data/lib/claude_memory/commands/checks/vec_check.rb +2 -1
- data/lib/claude_memory/commands/completion_command.rb +179 -0
- data/lib/claude_memory/commands/doctor_command.rb +2 -0
- data/lib/claude_memory/commands/help_command.rb +4 -0
- data/lib/claude_memory/commands/hook_command.rb +2 -1
- data/lib/claude_memory/commands/index_command.rb +100 -65
- data/lib/claude_memory/commands/initializers/database_ensurer.rb +16 -0
- data/lib/claude_memory/commands/initializers/global_initializer.rb +2 -1
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +55 -11
- data/lib/claude_memory/commands/initializers/project_initializer.rb +2 -1
- data/lib/claude_memory/commands/install_skill_command.rb +78 -0
- data/lib/claude_memory/commands/registry.rb +3 -1
- data/lib/claude_memory/commands/skills/distill-transcripts.md +98 -0
- data/lib/claude_memory/commands/skills/memory-recall.md +67 -0
- data/lib/claude_memory/core/fact_ranker.rb +2 -2
- data/lib/claude_memory/core/rr_fusion.rb +23 -6
- data/lib/claude_memory/core/snippet_extractor.rb +7 -3
- data/lib/claude_memory/core/text_builder.rb +11 -0
- data/lib/claude_memory/domain/provenance.rb +0 -1
- data/lib/claude_memory/embeddings/api_adapter.rb +96 -0
- data/lib/claude_memory/embeddings/dimension_check.rb +23 -0
- data/lib/claude_memory/embeddings/fastembed_adapter.rb +4 -0
- data/lib/claude_memory/embeddings/generator.rb +4 -0
- data/lib/claude_memory/embeddings/resolver.rb +18 -0
- data/lib/claude_memory/hook/context_injector.rb +58 -2
- data/lib/claude_memory/hook/distillation_runner.rb +46 -0
- data/lib/claude_memory/hook/handler.rb +11 -2
- data/lib/claude_memory/index/vector_index.rb +15 -2
- data/lib/claude_memory/infrastructure/schema_validator.rb +3 -3
- data/lib/claude_memory/mcp/error_classifier.rb +171 -0
- data/lib/claude_memory/mcp/handlers/context_handlers.rb +38 -0
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +145 -0
- data/lib/claude_memory/mcp/handlers/query_handlers.rb +115 -0
- data/lib/claude_memory/mcp/handlers/setup_handlers.rb +211 -0
- data/lib/claude_memory/mcp/handlers/shortcut_handlers.rb +37 -0
- data/lib/claude_memory/mcp/handlers/stats_handlers.rb +202 -0
- data/lib/claude_memory/mcp/instructions_builder.rb +64 -5
- data/lib/claude_memory/mcp/query_guide.rb +51 -22
- data/lib/claude_memory/mcp/response_formatter.rb +4 -1
- data/lib/claude_memory/mcp/server.rb +1 -0
- data/lib/claude_memory/mcp/text_summary.rb +28 -1
- data/lib/claude_memory/mcp/tool_definitions.rb +33 -3
- data/lib/claude_memory/mcp/tool_helpers.rb +43 -0
- data/lib/claude_memory/mcp/tools.rb +47 -681
- data/lib/claude_memory/recall/dual_engine.rb +105 -0
- data/lib/claude_memory/recall/legacy_engine.rb +138 -0
- data/lib/claude_memory/recall/query_core.rb +371 -0
- data/lib/claude_memory/recall.rb +29 -616
- data/lib/claude_memory/shortcuts.rb +4 -4
- data/lib/claude_memory/store/retry_handler.rb +61 -0
- data/lib/claude_memory/store/schema_manager.rb +68 -0
- data/lib/claude_memory/store/sqlite_store.rb +85 -201
- data/lib/claude_memory/sweep/maintenance.rb +126 -0
- data/lib/claude_memory/sweep/sweeper.rb +81 -75
- data/lib/claude_memory/templates/hooks.example.json +26 -7
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +12 -0
- data/v0.6.0.ANNOUNCE +32 -0
- 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
|
-
|
|
34
|
-
scores[fact_id]
|
|
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
|
-
|
|
44
|
-
scores[fact_id]
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
@@ -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 =>
|
|
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?
|