claude_memory 0.8.0 → 0.9.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +32 -2
  4. data/.claude/settings.json +30 -52
  5. data/.claude/settings.local.json +3 -1
  6. data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
  7. data/.claude-plugin/marketplace.json +2 -2
  8. data/.claude-plugin/plugin.json +3 -3
  9. data/.claude-plugin/scripts/hook-runner.sh +14 -0
  10. data/.claude-plugin/scripts/serve-mcp.sh +14 -0
  11. data/.ruby-version +1 -1
  12. data/CHANGELOG.md +41 -0
  13. data/CLAUDE.md +31 -17
  14. data/README.md +35 -0
  15. data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
  16. data/db/migrations/014_canonicalize_predicates.rb +30 -0
  17. data/docs/improvements.md +58 -20
  18. data/docs/influence/claude-mem.md +1 -0
  19. data/docs/influence/claude-supermemory.md +1 -0
  20. data/docs/influence/episodic-memory.md +1 -0
  21. data/docs/influence/grepai.md +1 -0
  22. data/docs/influence/kbs.md +1 -0
  23. data/docs/influence/lossless-claw.md +1 -0
  24. data/docs/influence/qmd.md +1 -0
  25. data/lib/claude_memory/commands/completion_command.rb +1 -31
  26. data/lib/claude_memory/commands/embeddings_command.rb +198 -0
  27. data/lib/claude_memory/commands/help_command.rb +8 -1
  28. data/lib/claude_memory/commands/registry.rb +47 -34
  29. data/lib/claude_memory/commands/reject_command.rb +62 -0
  30. data/lib/claude_memory/commands/restore_command.rb +77 -0
  31. data/lib/claude_memory/commands/skills/distill-transcripts.md +5 -1
  32. data/lib/claude_memory/commands/stats_command.rb +98 -2
  33. data/lib/claude_memory/configuration.rb +14 -1
  34. data/lib/claude_memory/distill/json_schema.md +8 -4
  35. data/lib/claude_memory/distill/null_distiller.rb +2 -0
  36. data/lib/claude_memory/domain/entity.rb +13 -1
  37. data/lib/claude_memory/domain/fact.rb +26 -2
  38. data/lib/claude_memory/embeddings/api_adapter.rb +5 -4
  39. data/lib/claude_memory/embeddings/fastembed_adapter.rb +43 -13
  40. data/lib/claude_memory/embeddings/inspector.rb +91 -0
  41. data/lib/claude_memory/embeddings/model_registry.rb +210 -0
  42. data/lib/claude_memory/embeddings/resolver.rb +32 -6
  43. data/lib/claude_memory/ingest/ingester.rb +17 -0
  44. data/lib/claude_memory/mcp/handlers/management_handlers.rb +24 -0
  45. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +5 -2
  46. data/lib/claude_memory/mcp/instructions_builder.rb +17 -0
  47. data/lib/claude_memory/mcp/server.rb +22 -1
  48. data/lib/claude_memory/mcp/telemetry.rb +86 -0
  49. data/lib/claude_memory/mcp/tool_definitions.rb +86 -3
  50. data/lib/claude_memory/mcp/tools.rb +10 -0
  51. data/lib/claude_memory/publish.rb +40 -5
  52. data/lib/claude_memory/recall.rb +81 -0
  53. data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
  54. data/lib/claude_memory/resolve/resolver.rb +43 -0
  55. data/lib/claude_memory/store/schema_manager.rb +1 -1
  56. data/lib/claude_memory/store/sqlite_store.rb +250 -1
  57. data/lib/claude_memory/store/store_manager.rb +50 -1
  58. data/lib/claude_memory/sweep/maintenance.rb +115 -1
  59. data/lib/claude_memory/sweep/sweeper.rb +3 -0
  60. data/lib/claude_memory/version.rb +1 -1
  61. data/lib/claude_memory.rb +5 -0
  62. metadata +26 -8
  63. data/.claude/memory.sqlite3-shm +0 -0
  64. data/.claude/memory.sqlite3-wal +0 -0
@@ -3,48 +3,48 @@
3
3
  module ClaudeMemory
4
4
  module Commands
5
5
  # Registry for CLI command lookup and dispatch
6
- # Maps command names to command classes for dynamic dispatch
6
+ # Maps command names to command classes and short descriptions.
7
+ # Descriptions are authoritative for shell completion and help output;
8
+ # keep them current when adding commands.
7
9
  class Registry
8
- # Map of command names to class names
9
- # As more commands are extracted, add them here
10
+ # Map of command names to {class:, description:} entries.
11
+ # As more commands are extracted, add them here.
10
12
  COMMANDS = {
11
- "help" => "HelpCommand",
12
- "version" => "VersionCommand",
13
- "doctor" => "DoctorCommand",
14
- "stats" => "StatsCommand",
15
- "promote" => "PromoteCommand",
16
- "search" => "SearchCommand",
17
- "explain" => "ExplainCommand",
18
- "conflicts" => "ConflictsCommand",
19
- "changes" => "ChangesCommand",
20
- "recall" => "RecallCommand",
21
- "sweep" => "SweepCommand",
22
- "ingest" => "IngestCommand",
23
- "publish" => "PublishCommand",
24
- "db:init" => "DbInitCommand",
25
- "init" => "InitCommand",
26
- "uninstall" => "UninstallCommand",
27
- "serve-mcp" => "ServeMcpCommand",
28
- "hook" => "HookCommand",
29
- "index" => "IndexCommand",
30
- "recover" => "RecoverCommand",
31
- "compact" => "CompactCommand",
32
- "export" => "ExportCommand",
33
- "git-lfs" => "GitLfsCommand",
34
- "install-skill" => "InstallSkillCommand",
35
- "completion" => "CompletionCommand"
13
+ "help" => {class: HelpCommand, description: "Show help message"},
14
+ "version" => {class: VersionCommand, description: "Show version"},
15
+ "doctor" => {class: DoctorCommand, description: "Check system health"},
16
+ "stats" => {class: StatsCommand, description: "Show statistics"},
17
+ "promote" => {class: PromoteCommand, description: "Promote fact to global"},
18
+ "search" => {class: SearchCommand, description: "Search indexed content"},
19
+ "explain" => {class: ExplainCommand, description: "Explain a fact with receipts"},
20
+ "conflicts" => {class: ConflictsCommand, description: "Show open conflicts"},
21
+ "changes" => {class: ChangesCommand, description: "Show recent fact changes"},
22
+ "recall" => {class: RecallCommand, description: "Recall facts matching query"},
23
+ "sweep" => {class: SweepCommand, description: "Run maintenance"},
24
+ "ingest" => {class: IngestCommand, description: "Ingest transcript delta"},
25
+ "publish" => {class: PublishCommand, description: "Publish snapshot"},
26
+ "db:init" => {class: DbInitCommand, description: "Initialize database"},
27
+ "init" => {class: InitCommand, description: "Initialize ClaudeMemory"},
28
+ "uninstall" => {class: UninstallCommand, description: "Remove configuration"},
29
+ "serve-mcp" => {class: ServeMcpCommand, description: "Start MCP server"},
30
+ "hook" => {class: HookCommand, description: "Run hook entrypoints"},
31
+ "index" => {class: IndexCommand, description: "Index content"},
32
+ "recover" => {class: RecoverCommand, description: "Recover database"},
33
+ "compact" => {class: CompactCommand, description: "Compact databases"},
34
+ "export" => {class: ExportCommand, description: "Export facts to JSON"},
35
+ "git-lfs" => {class: GitLfsCommand, description: "Git LFS integration"},
36
+ "install-skill" => {class: InstallSkillCommand, description: "Install agent skills"},
37
+ "completion" => {class: CompletionCommand, description: "Generate shell completions"},
38
+ "embeddings" => {class: EmbeddingsCommand, description: "Inspect embedding backend"},
39
+ "reject" => {class: RejectCommand, description: "Mark a fact as rejected"},
40
+ "restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"}
36
41
  }.freeze
37
42
 
38
43
  # Find a command class by name
39
44
  # @param command_name [String] the command name (e.g., "help", "version")
40
45
  # @return [Class, nil] the command class, or nil if not found
41
46
  def self.find(command_name)
42
- return nil if command_name.nil?
43
-
44
- class_name = COMMANDS[command_name]
45
- return nil unless class_name
46
-
47
- Commands.const_get(class_name)
47
+ COMMANDS.dig(command_name, :class)
48
48
  end
49
49
 
50
50
  # Get all registered command names
@@ -59,6 +59,19 @@ module ClaudeMemory
59
59
  def self.registered?(command_name)
60
60
  COMMANDS.key?(command_name)
61
61
  end
62
+
63
+ # Get the short description for a command
64
+ # @param command_name [String] the command name
65
+ # @return [String, nil] the description, or nil if not registered
66
+ def self.description(command_name)
67
+ COMMANDS.dig(command_name, :description)
68
+ end
69
+
70
+ # Get all command descriptions as a hash
71
+ # @return [Hash{String => String}] command name → description
72
+ def self.descriptions
73
+ COMMANDS.transform_values { |entry| entry[:description] }
74
+ end
62
75
  end
63
76
  end
64
77
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ClaudeMemory
6
+ module Commands
7
+ # Mark a fact as rejected (e.g. a distiller hallucination).
8
+ # Closes any open conflicts involving the fact.
9
+ class RejectCommand < BaseCommand
10
+ # @param args [Array<String>] command line arguments (fact_id_or_docid, --scope, --reason)
11
+ # @return [Integer] exit code (0 for success, 1 for failure)
12
+ def call(args)
13
+ opts = parse_options(args, {scope: "project", reason: nil}) do |o|
14
+ OptionParser.new do |parser|
15
+ parser.banner = "Usage: claude-memory reject <fact_id_or_docid> [options]"
16
+ parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
17
+ parser.on("--reason TEXT", "Why this fact is wrong (recorded in conflict notes)") { |v| o[:reason] = v }
18
+ end
19
+ end
20
+ return 1 if opts.nil?
21
+
22
+ identifier = args.first
23
+ return failure("Usage: claude-memory reject <fact_id_or_docid> [options]") if identifier.nil? || identifier.empty?
24
+
25
+ manager = ClaudeMemory::Store::StoreManager.new
26
+ store = manager.store_for_scope(opts[:scope])
27
+
28
+ fact_id = resolve_fact_id(store, identifier)
29
+ unless fact_id
30
+ stderr.puts "Fact '#{identifier}' not found in #{opts[:scope]} database."
31
+ manager.close
32
+ return 1
33
+ end
34
+
35
+ result = store.reject_fact(fact_id, reason: opts[:reason])
36
+ manager.close
37
+
38
+ if result.nil?
39
+ stderr.puts "Fact ##{fact_id} not found."
40
+ return 1
41
+ end
42
+
43
+ stdout.puts "Rejected fact ##{fact_id} in #{opts[:scope]} database."
44
+ stdout.puts "Resolved #{result[:conflicts_resolved]} open conflict(s)." if result[:conflicts_resolved] > 0
45
+ 0
46
+ end
47
+
48
+ private
49
+
50
+ # Accept either a numeric fact id or an 8-char docid hex string.
51
+ # @param store [Store::SQLiteStore] database to look up the fact in
52
+ # @param identifier [String] numeric fact id or hex docid
53
+ # @return [Integer, nil] resolved fact id, or nil if not found
54
+ def resolve_fact_id(store, identifier)
55
+ return identifier.to_i if identifier.match?(/\A\d+\z/)
56
+
57
+ row = store.find_fact_by_docid(identifier)
58
+ row ? row[:id] : nil
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ClaudeMemory
6
+ module Commands
7
+ # One-time recovery for facts that were superseded because of an
8
+ # obsolete single-value predicate classification. See
9
+ # Sweep::Maintenance#restore_multi_value_supersessions for the algorithm
10
+ # and Jaccard heuristic.
11
+ class RestoreCommand < BaseCommand
12
+ # @param args [Array<String>] command line arguments (--predicate, --scope, --dry-run)
13
+ # @return [Integer] exit code (0 for success, 1 for failure)
14
+ def call(args)
15
+ opts = parse_options(args, {predicate: nil, scope: "project", dry_run: false}) do |o|
16
+ OptionParser.new do |parser|
17
+ parser.banner = "Usage: claude-memory restore --predicate NAME [options]"
18
+ parser.on("--predicate NAME", "Predicate to restore (e.g. uses_framework)") { |v| o[:predicate] = v }
19
+ parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
20
+ parser.on("--dry-run", "Show what would be restored without writing") { o[:dry_run] = true }
21
+ end
22
+ end
23
+ return 1 if opts.nil?
24
+
25
+ return failure("--predicate required (e.g. --predicate uses_framework)") if opts[:predicate].nil?
26
+
27
+ manager = ClaudeMemory::Store::StoreManager.new
28
+ store = manager.store_for_scope(opts[:scope])
29
+
30
+ begin
31
+ result = Sweep::Maintenance.new(store).restore_multi_value_supersessions(
32
+ predicate: opts[:predicate],
33
+ dry_run: opts[:dry_run]
34
+ )
35
+ rescue ArgumentError => e
36
+ stderr.puts e.message
37
+ manager.close
38
+ return 1
39
+ ensure
40
+ manager.close
41
+ end
42
+
43
+ print_result(opts, result)
44
+ 0
45
+ end
46
+
47
+ private
48
+
49
+ # Print a summary of restored and skipped facts.
50
+ # @param opts [Hash] parsed options including :predicate, :scope, :dry_run
51
+ # @param result [Hash] result from Sweep::Maintenance#restore_multi_value_supersessions
52
+ # @return [void]
53
+ def print_result(opts, result)
54
+ mode = opts[:dry_run] ? "DRY RUN" : "RESTORE"
55
+ stdout.puts "#{mode}: predicate=#{opts[:predicate]} scope=#{opts[:scope]}"
56
+ stdout.puts "=" * 50
57
+ stdout.puts "Inspected: #{result[:inspected]}"
58
+ stdout.puts "Restored: #{result[:restored]}"
59
+ stdout.puts "Skipped ambiguous: #{result[:skipped_ambiguous]}"
60
+
61
+ return if result[:decisions].empty?
62
+
63
+ stdout.puts
64
+ stdout.puts "Decisions:"
65
+ result[:decisions].each do |d|
66
+ case d[:action]
67
+ when :restore
68
+ stdout.puts " [restore] ##{d[:fact_id]} #{d[:object]}"
69
+ when :skip_ambiguous
70
+ overlaps = d[:overlaps_with].map { |o| "'#{o}'" }.join(", ")
71
+ stdout.puts " [skip] ##{d[:fact_id]} #{d[:object]} (overlaps: #{overlaps})"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -30,7 +30,11 @@ For each content item, carefully read the raw_text and extract:
30
30
 
31
31
  **Facts** — Knowledge learned:
32
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
33
+ - predicate: prefer a predicate from the canonical vocabulary defined in
34
+ `lib/claude_memory/resolve/predicate_policy.rb` (convention, decision,
35
+ architecture, uses_framework, uses_language, uses_database,
36
+ deployment_platform, auth_method). Other snake_case predicates are
37
+ accepted but fall through to the default multi-value policy.
34
38
  - object: The value
35
39
  - confidence: 0.0-1.0
36
40
  - quote: Source excerpt (max 200 chars)
@@ -13,15 +13,21 @@ module ClaudeMemory
13
13
  SCOPE_PROJECT = "project"
14
14
 
15
15
  def call(args)
16
- opts = parse_options(args, {scope: SCOPE_ALL}) do |o|
16
+ opts = parse_options(args, {scope: SCOPE_ALL, tools: false, since_days: nil}) do |o|
17
17
  OptionParser.new do |parser|
18
18
  parser.banner = "Usage: claude-memory stats [options]"
19
19
  parser.on("--scope SCOPE", ["all", "global", "project"],
20
20
  "Show stats for: all (default), global, or project") { |v| o[:scope] = v }
21
+ parser.on("--tools", "Show MCP tool-call usage stats") { o[:tools] = true }
22
+ parser.on("--since DAYS", Integer, "Limit --tools to last N days") { |v| o[:since_days] = v }
21
23
  end
22
24
  end
23
25
  return 1 if opts.nil?
24
26
 
27
+ if opts[:tools]
28
+ return print_mcp_tool_call_stats(opts[:since_days])
29
+ end
30
+
25
31
  manager = ClaudeMemory::Store::StoreManager.new
26
32
 
27
33
  stdout.puts "ClaudeMemory Statistics"
@@ -42,6 +48,10 @@ module ClaudeMemory
42
48
 
43
49
  private
44
50
 
51
+ def open_readonly(db_path)
52
+ Sequel.connect("extralite://#{db_path}")
53
+ end
54
+
45
55
  def print_database_stats(label, db_path)
46
56
  stdout.puts "## #{label} DATABASE"
47
57
  stdout.puts
@@ -53,7 +63,7 @@ module ClaudeMemory
53
63
  end
54
64
 
55
65
  begin
56
- db = Sequel.sqlite(db_path, readonly: true)
66
+ db = open_readonly(db_path)
57
67
 
58
68
  # Facts statistics
59
69
  print_fact_stats(db)
@@ -245,6 +255,92 @@ module ClaudeMemory
245
255
  # Format number with comma separators (e.g., 1234567 => "1,234,567")
246
256
  num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
247
257
  end
258
+
259
+ def print_mcp_tool_call_stats(since_days)
260
+ manager = ClaudeMemory::Store::StoreManager.new
261
+ db_path = manager.project_db_path
262
+
263
+ stdout.puts "MCP Tool Call Statistics"
264
+ stdout.puts "=" * 50
265
+
266
+ unless File.exist?(db_path)
267
+ stdout.puts "Project database does not exist: #{db_path}"
268
+ manager.close
269
+ return 0
270
+ end
271
+
272
+ db = open_readonly(db_path)
273
+
274
+ unless db.table_exists?(:mcp_tool_calls)
275
+ stdout.puts "No telemetry recorded yet (run MCP server first)."
276
+ db.disconnect
277
+ manager.close
278
+ return 0
279
+ end
280
+
281
+ dataset = db[:mcp_tool_calls]
282
+ if since_days
283
+ cutoff = (Time.now - since_days * 86400).utc.iso8601
284
+ dataset = dataset.where { called_at >= cutoff }
285
+ stdout.puts "Window: last #{since_days} day#{"s" unless since_days == 1}"
286
+ else
287
+ stdout.puts "Window: all time"
288
+ end
289
+ stdout.puts
290
+
291
+ total = dataset.count
292
+ if total.zero?
293
+ stdout.puts "No tool calls recorded in window."
294
+ db.disconnect
295
+ manager.close
296
+ return 0
297
+ end
298
+
299
+ errors = dataset.exclude(error_class: nil).count
300
+ error_rate = (errors * 100.0 / total).round(1)
301
+ stdout.puts "Total calls: #{format_number(total)}"
302
+ stdout.puts "Errors: #{format_number(errors)} (#{error_rate}%)"
303
+ stdout.puts
304
+
305
+ print_per_tool_breakdown(dataset)
306
+
307
+ db.disconnect
308
+ manager.close
309
+ 0
310
+ rescue Sequel::DatabaseError, Extralite::Error => e
311
+ stderr.puts "Error reading telemetry: #{e.message}"
312
+ 1
313
+ end
314
+
315
+ def print_per_tool_breakdown(dataset)
316
+ stdout.puts "Per-tool breakdown:"
317
+ stdout.puts " #{"Tool".ljust(28)} #{"Calls".rjust(7)} #{"Avg ms".rjust(8)} #{"P95 ms".rjust(8)} #{"Err %".rjust(6)}"
318
+
319
+ rows = dataset
320
+ .group_and_count(:tool_name)
321
+ .order(Sequel.desc(:count))
322
+ .all
323
+
324
+ rows.each do |row|
325
+ tool = row[:tool_name]
326
+ calls = row[:count]
327
+ durations = dataset.where(tool_name: tool).select_map(:duration_ms).sort
328
+ avg = (durations.sum.to_f / calls).round(1)
329
+ p95 = percentile(durations, 0.95)
330
+ tool_errors = dataset.where(tool_name: tool).exclude(error_class: nil).count
331
+ tool_err_rate = (tool_errors * 100.0 / calls).round(1)
332
+
333
+ stdout.puts " #{tool.to_s.ljust(28)} #{calls.to_s.rjust(7)} #{avg.to_s.rjust(8)} #{p95.to_s.rjust(8)} #{tool_err_rate.to_s.rjust(6)}"
334
+ end
335
+ end
336
+
337
+ def percentile(sorted, pct)
338
+ return 0 if sorted.empty?
339
+ idx = (sorted.size * pct).ceil - 1
340
+ idx = 0 if idx < 0
341
+ idx = sorted.size - 1 if idx >= sorted.size
342
+ sorted[idx]
343
+ end
248
344
  end
249
345
  end
250
346
  end
@@ -8,31 +8,44 @@ module ClaudeMemory
8
8
  class Configuration
9
9
  attr_reader :env
10
10
 
11
+ # @param env [Hash] environment variables (default: ENV)
11
12
  def initialize(env = ENV)
12
13
  @env = env
13
14
  end
14
15
 
16
+ # @return [String] user home directory
15
17
  def home_dir
16
18
  env["HOME"] || File.expand_path("~")
17
19
  end
18
20
 
21
+ # @return [String] project root directory (resolves git worktrees)
19
22
  def project_dir
20
23
  env["CLAUDE_PROJECT_DIR"] || resolve_project_dir
21
24
  end
22
25
 
26
+ # @return [String] Claude config directory (default: ~/.claude)
27
+ def claude_config_dir
28
+ env["CLAUDE_CONFIG_DIR"] || File.join(home_dir, ".claude")
29
+ end
30
+
31
+ # @return [String] path to global memory database
23
32
  def global_db_path
24
- File.join(home_dir, ".claude", "memory.sqlite3")
33
+ File.join(claude_config_dir, "memory.sqlite3")
25
34
  end
26
35
 
36
+ # @param project_path [String, nil] override project root (defaults to project_dir)
37
+ # @return [String] path to project memory database
27
38
  def project_db_path(project_path = nil)
28
39
  path = project_path || project_dir
29
40
  File.join(path, ".claude", "memory.sqlite3")
30
41
  end
31
42
 
43
+ # @return [String, nil] current Claude session ID from CLAUDE_SESSION_ID
32
44
  def session_id
33
45
  env["CLAUDE_SESSION_ID"]
34
46
  end
35
47
 
48
+ # @return [String, nil] path to current transcript from CLAUDE_TRANSCRIPT_PATH
36
49
  def transcript_path
37
50
  env["CLAUDE_TRANSCRIPT_PATH"]
38
51
  end
@@ -66,13 +66,17 @@ This document defines the schema for extracted knowledge from transcripts.
66
66
  - **conflict**: `{kind: "conflict", value: true}` - indicates contradictory information detected
67
67
  - **time_boundary**: `{kind: "time_boundary", value: "2024-01-15"}` - temporal boundary marker
68
68
 
69
- ## Predicate Types (MVP)
69
+ ## Predicate Types
70
+
71
+ Canonical vocabulary defined in `lib/claude_memory/resolve/predicate_policy.rb`.
70
72
 
71
73
  | Predicate | Cardinality | Exclusive |
72
74
  |-----------|-------------|-----------|
73
75
  | convention | multi | no |
74
- | decision | multi (by scope) | no |
75
- | auth_method | single | yes |
76
+ | decision | multi | no |
77
+ | architecture | multi | no |
78
+ | uses_framework | multi | no |
79
+ | uses_language | multi | no |
76
80
  | uses_database | single | yes |
77
- | uses_framework | single | yes |
78
81
  | deployment_platform | single | yes |
82
+ | auth_method | single | yes |
@@ -73,6 +73,8 @@ module ClaudeMemory
73
73
  facts << build_fact("uses_framework", entity[:name], text, scope_hint)
74
74
  when "platform"
75
75
  facts << build_fact("deployment_platform", entity[:name], text, scope_hint)
76
+ when "language"
77
+ facts << build_fact("uses_language", entity[:name], text, scope_hint)
76
78
  end
77
79
  end
78
80
 
@@ -2,10 +2,18 @@
2
2
 
3
3
  module ClaudeMemory
4
4
  module Domain
5
- # Domain model representing an entity (database, framework, person, etc.)
5
+ # Domain model representing an entity (database, framework, person, etc.).
6
+ # Instances are immutable (frozen).
6
7
  class Entity
7
8
  attr_reader :id, :type, :canonical_name, :slug, :created_at
8
9
 
10
+ # @param attributes [Hash] entity attributes
11
+ # @option attributes [Integer] :id database primary key
12
+ # @option attributes [String] :type entity category (required, e.g. "database", "framework", "person")
13
+ # @option attributes [String] :canonical_name display name (required)
14
+ # @option attributes [String] :slug URL-safe identifier (required)
15
+ # @option attributes [String] :created_at ISO 8601 creation timestamp
16
+ # @raise [ArgumentError] if type, canonical_name, or slug is blank
9
17
  def initialize(attributes)
10
18
  @id = attributes[:id]
11
19
  @type = attributes[:type]
@@ -17,18 +25,22 @@ module ClaudeMemory
17
25
  freeze
18
26
  end
19
27
 
28
+ # @return [Boolean] true when type is "database"
20
29
  def database?
21
30
  type == "database"
22
31
  end
23
32
 
33
+ # @return [Boolean] true when type is "framework"
24
34
  def framework?
25
35
  type == "framework"
26
36
  end
27
37
 
38
+ # @return [Boolean] true when type is "person"
28
39
  def person?
29
40
  type == "person"
30
41
  end
31
42
 
43
+ # @return [Hash] all attributes as a plain hash
32
44
  def to_h
33
45
  {
34
46
  id: id,
@@ -2,13 +2,27 @@
2
2
 
3
3
  module ClaudeMemory
4
4
  module Domain
5
- # Domain model representing a fact in the memory system
6
- # Encapsulates business logic and validation
5
+ # Domain model representing a fact in the memory system.
6
+ # Encapsulates business logic and validation. Instances are immutable (frozen).
7
7
  class Fact
8
8
  attr_reader :id, :docid, :subject_name, :predicate, :object_literal,
9
9
  :status, :confidence, :scope, :project_path,
10
10
  :valid_from, :valid_to, :created_at
11
11
 
12
+ # @param attributes [Hash] fact attributes
13
+ # @option attributes [Integer] :id database primary key
14
+ # @option attributes [Integer] :docid FTS document id
15
+ # @option attributes [String] :subject_name entity name of the subject
16
+ # @option attributes [String] :predicate relationship type (required)
17
+ # @option attributes [String] :object_literal literal value (required)
18
+ # @option attributes [String] :status one of "active", "superseded", "rejected", "disputed"
19
+ # @option attributes [Float] :confidence score between 0 and 1 (default: 1.0)
20
+ # @option attributes [String] :scope "project" or "global" (default: "project")
21
+ # @option attributes [String] :project_path path for project-scoped facts
22
+ # @option attributes [String] :valid_from ISO 8601 start of validity
23
+ # @option attributes [String] :valid_to ISO 8601 end of validity (nil if current)
24
+ # @option attributes [String] :created_at ISO 8601 creation timestamp
25
+ # @raise [ArgumentError] if predicate, object_literal, or confidence is invalid
12
26
  def initialize(attributes)
13
27
  @id = attributes[:id]
14
28
  @docid = attributes[:docid]
@@ -27,22 +41,32 @@ module ClaudeMemory
27
41
  freeze
28
42
  end
29
43
 
44
+ # @return [Boolean] true when status is "active"
30
45
  def active?
31
46
  status == "active"
32
47
  end
33
48
 
49
+ # @return [Boolean] true when status is "superseded"
34
50
  def superseded?
35
51
  status == "superseded"
36
52
  end
37
53
 
54
+ # @return [Boolean] true when status is "rejected"
55
+ def rejected?
56
+ status == "rejected"
57
+ end
58
+
59
+ # @return [Boolean] true when scope is "global"
38
60
  def global?
39
61
  scope == "global"
40
62
  end
41
63
 
64
+ # @return [Boolean] true when scope is "project"
42
65
  def project?
43
66
  scope == "project"
44
67
  end
45
68
 
69
+ # @return [Hash] all attributes as a plain hash
46
70
  def to_h
47
71
  {
48
72
  id: id,
@@ -22,19 +22,20 @@ module ClaudeMemory
22
22
  DEFAULT_API_URL = "https://api.openai.com/v1/embeddings"
23
23
  DEFAULT_MODEL = "text-embedding-3-small"
24
24
 
25
- def initialize(env: ENV)
25
+ def initialize(model: nil, env: ENV)
26
26
  @api_key = env["CLAUDE_MEMORY_EMBEDDING_API_KEY"] || env["OPENAI_API_KEY"]
27
27
  @api_url = env["CLAUDE_MEMORY_EMBEDDING_API_URL"] || DEFAULT_API_URL
28
- @model = env["CLAUDE_MEMORY_EMBEDDING_MODEL"] || DEFAULT_MODEL
28
+ @model = model || env["CLAUDE_MEMORY_EMBEDDING_MODEL"] || DEFAULT_MODEL
29
+ @known_dimensions = ModelRegistry.dimensions_for(@model)
29
30
 
30
31
  raise ArgumentError, "Set CLAUDE_MEMORY_EMBEDDING_API_KEY or OPENAI_API_KEY" unless @api_key
31
32
  end
32
33
 
33
34
  def name = "api"
34
35
 
35
- # Dimensions are lazy derived from the first API response and cached.
36
+ # Dimensions resolved from registry if known, otherwise lazy from first API response.
36
37
  def dimensions
37
- @dimensions ||= fetch_dimensions
38
+ @dimensions ||= @known_dimensions || fetch_dimensions
38
39
  end
39
40
 
40
41
  # Generate embedding for a query text.