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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Hook
5
+ class DistillationRunner
6
+ MIN_TEXT_LENGTH = 200
7
+
8
+ def initialize(store, distiller: Distill::NullDistiller.new)
9
+ @store = store
10
+ @distiller = distiller
11
+ end
12
+
13
+ def distill_item(content_id, project_path:, scope: "project")
14
+ item = @store.get_content_item(content_id)
15
+ return unless item
16
+
17
+ raw_text = item[:raw_text]
18
+ return unless raw_text && raw_text.length >= MIN_TEXT_LENGTH
19
+
20
+ extraction = @distiller.distill(raw_text, content_item_id: content_id)
21
+ return if extraction.empty?
22
+
23
+ resolver = Resolve::Resolver.new(@store)
24
+ @store.db.transaction do
25
+ resolve_result = resolver.apply(
26
+ extraction, content_item_id: content_id,
27
+ project_path: project_path, scope: scope
28
+ )
29
+ @store.record_ingestion_metrics(
30
+ content_item_id: content_id, input_tokens: 0,
31
+ output_tokens: 0, facts_extracted: resolve_result[:facts_created]
32
+ )
33
+ end
34
+ rescue => e
35
+ ClaudeMemory.logger.warn("DistillationRunner#distill_item(#{content_id}) failed: #{e.class} - #{e.message}")
36
+ ClaudeMemory.logger.warn(e.backtrace.first(5).join("\n"))
37
+ end
38
+
39
+ def distill_batch(project_path:, limit: 5)
40
+ items = @store.undistilled_content_items(limit: limit, min_length: MIN_TEXT_LENGTH)
41
+ items.each { |item| distill_item(item[:id], project_path: project_path) }
42
+ items.size
43
+ end
44
+ end
45
+ end
46
+ end
@@ -23,12 +23,20 @@ module ClaudeMemory
23
23
  raise PayloadError, "Missing required field: transcript_path" if transcript_path.nil? || transcript_path.empty?
24
24
 
25
25
  ingester = Ingest::Ingester.new(@store, env: @env)
26
- ingester.ingest(
26
+ result = ingester.ingest(
27
27
  source: "claude_code",
28
28
  session_id: session_id,
29
29
  transcript_path: transcript_path,
30
30
  project_path: project_path
31
31
  )
32
+
33
+ if result[:status] == :ingested && result[:content_id]
34
+ DistillationRunner.new(@store).distill_item(
35
+ result[:content_id], project_path: project_path
36
+ )
37
+ end
38
+
39
+ result
32
40
  rescue Ingest::TranscriptReader::FileNotFoundError => e
33
41
  # Transcript file doesn't exist (e.g., headless Claude session)
34
42
  # This is expected, not an error - return success with no-op status
@@ -56,7 +64,8 @@ module ClaudeMemory
56
64
  manager = @manager || build_manager(payload)
57
65
  manager.ensure_both!
58
66
 
59
- injector = ContextInjector.new(manager)
67
+ source = payload["source"]
68
+ injector = ContextInjector.new(manager, source: source)
60
69
  context_text = injector.generate_context
61
70
 
62
71
  {status: :ok, context: context_text}
@@ -6,13 +6,16 @@ module ClaudeMemory
6
6
  # Follows the same lazy-init pattern as LexicalFTS:
7
7
  # the extension and virtual table are created on first use.
8
8
  class VectorIndex
9
- EMBEDDING_DIMENSIONS = 384
9
+ DEFAULT_DIMENSIONS = 384
10
+
11
+ attr_reader :dimensions
10
12
 
11
13
  def initialize(store)
12
14
  @store = store
13
15
  @db = store.db
14
16
  @available = nil
15
17
  @vec_table_ensured = false
18
+ @dimensions = store.get_meta("embedding_dimensions")&.to_i || DEFAULT_DIMENSIONS
16
19
  end
17
20
 
18
21
  # Is the sqlite-vec extension loadable?
@@ -121,6 +124,16 @@ module ClaudeMemory
121
124
  indexed_ids.size
122
125
  end
123
126
 
127
+ # Delete all entries from the vec0 virtual table.
128
+ # Used when clearing stale embeddings after a dimension change.
129
+ def clear!
130
+ return false unless available?
131
+
132
+ ensure_vec_table!
133
+ @db.run("DELETE FROM facts_vec")
134
+ true
135
+ end
136
+
124
137
  # Number of entries in the vec0 virtual table
125
138
  def count
126
139
  return 0 unless available?
@@ -162,7 +175,7 @@ module ClaudeMemory
162
175
 
163
176
  @db.run(<<~SQL)
164
177
  CREATE VIRTUAL TABLE IF NOT EXISTS facts_vec
165
- USING vec0(fact_id INTEGER PRIMARY KEY, embedding float[#{EMBEDDING_DIMENSIONS}] distance_metric=cosine)
178
+ USING vec0(fact_id INTEGER PRIMARY KEY, embedding float[#{@dimensions}] distance_metric=cosine)
166
179
  SQL
167
180
  @vec_table_ensured = true
168
181
  end
@@ -166,7 +166,7 @@ module ClaudeMemory
166
166
  end
167
167
 
168
168
  def check_embedding_dimensions(issues)
169
- # Check that all embeddings have correct dimensions (384)
169
+ expected = @store.get_meta("embedding_dimensions")&.to_i || 384
170
170
  facts_with_embeddings = @store.facts
171
171
  .where(Sequel.~(embedding_json: nil))
172
172
  .select(:id, :embedding_json)
@@ -174,8 +174,8 @@ module ClaudeMemory
174
174
 
175
175
  facts_with_embeddings.each do |fact|
176
176
  embedding = JSON.parse(fact[:embedding_json])
177
- if embedding.size != 384
178
- issues << {severity: "error", message: "Fact #{fact[:id]} has embedding with incorrect dimensions (#{embedding.size}, expected 384)"}
177
+ if embedding.size != expected
178
+ issues << {severity: "error", message: "Fact #{fact[:id]} has embedding with incorrect dimensions (#{embedding.size}, expected #{expected})"}
179
179
  break # Only report first occurrence
180
180
  end
181
181
  end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ # Classifies MCP tool errors into three tiers with structured responses.
6
+ #
7
+ # Benign: Empty results, first use, database not yet initialized.
8
+ # No error surfaced — return helpful guidance instead.
9
+ #
10
+ # Retryable: Database locked, temporary I/O, connection reset.
11
+ # Tool failed but may succeed on retry.
12
+ #
13
+ # Fatal: Schema corruption, invalid parameters, programming bugs.
14
+ # Requires user intervention to resolve.
15
+ #
16
+ # Source: supermemory src/lib/error-helpers.js:1-72
17
+ module ErrorClassifier
18
+ SEVERITY_BENIGN = "benign"
19
+ SEVERITY_RETRYABLE = "retryable"
20
+ SEVERITY_FATAL = "fatal"
21
+
22
+ # Errors that indicate temporary/transient failures
23
+ RETRYABLE_ERROR_NAMES = %w[
24
+ Sequel::DatabaseConnectionError
25
+ Extralite::BusyError
26
+ ].freeze
27
+
28
+ RETRYABLE_ERROR_CLASSES = [
29
+ Errno::EACCES,
30
+ Errno::EAGAIN,
31
+ IOError
32
+ ].freeze
33
+
34
+ # Errors that indicate permanent/programming failures
35
+ FATAL_ERROR_CLASSES = [
36
+ Errno::ENOSPC,
37
+ Errno::EROFS,
38
+ TypeError,
39
+ NoMethodError,
40
+ ArgumentError
41
+ ].freeze
42
+
43
+ module_function
44
+
45
+ def classify(error)
46
+ if retryable?(error)
47
+ SEVERITY_RETRYABLE
48
+ elsif fatal?(error)
49
+ SEVERITY_FATAL
50
+ elsif database_error?(error)
51
+ # Generic database errors default to retryable
52
+ SEVERITY_RETRYABLE
53
+ else
54
+ # Unknown errors default to fatal to surface issues
55
+ SEVERITY_FATAL
56
+ end
57
+ end
58
+
59
+ def build_error_response(error, tool_name: nil)
60
+ severity = classify(error)
61
+
62
+ case severity
63
+ when SEVERITY_RETRYABLE
64
+ {
65
+ error: "Temporary failure",
66
+ severity: SEVERITY_RETRYABLE,
67
+ message: retryable_message(error),
68
+ tool: tool_name,
69
+ retry: true
70
+ }
71
+ when SEVERITY_FATAL
72
+ {
73
+ error: "Operation failed",
74
+ severity: SEVERITY_FATAL,
75
+ message: fatal_message(error),
76
+ tool: tool_name,
77
+ retry: false,
78
+ recommendations: fatal_recommendations(error)
79
+ }
80
+ end
81
+ end
82
+
83
+ def build_benign_response(reason, tool_name: nil)
84
+ {
85
+ severity: SEVERITY_BENIGN,
86
+ message: benign_message(reason),
87
+ tool: tool_name,
88
+ results: []
89
+ }
90
+ end
91
+
92
+ def retryable?(error)
93
+ RETRYABLE_ERROR_CLASSES.any? { |klass| error.is_a?(klass) } ||
94
+ RETRYABLE_ERROR_NAMES.any? { |name| error.class.ancestors.any? { |a| a.name == name } }
95
+ end
96
+
97
+ def fatal?(error)
98
+ FATAL_ERROR_CLASSES.any? { |klass| error.is_a?(klass) }
99
+ end
100
+
101
+ def database_error?(error)
102
+ error.class.ancestors.any? { |a| a.name == "Sequel::DatabaseError" }
103
+ end
104
+
105
+ def retryable_message(error)
106
+ case error
107
+ when Errno::EACCES
108
+ "Database file is temporarily inaccessible. Another process may hold the lock."
109
+ when Errno::EAGAIN
110
+ "Resource temporarily unavailable. Try again shortly."
111
+ when IOError
112
+ "I/O error during database access. Connection may have been interrupted."
113
+ else
114
+ if error.class.name&.include?("Busy")
115
+ "Database is busy. Another operation is in progress."
116
+ else
117
+ "Temporary database error: #{error.message}"
118
+ end
119
+ end
120
+ end
121
+
122
+ def fatal_message(error)
123
+ case error
124
+ when Errno::ENOSPC
125
+ "No disk space available for database operations."
126
+ when Errno::EROFS
127
+ "Database is on a read-only filesystem."
128
+ when TypeError, NoMethodError
129
+ "Internal error in memory system."
130
+ when ArgumentError
131
+ "Invalid parameters provided."
132
+ else
133
+ "Database error: #{error.message}"
134
+ end
135
+ end
136
+
137
+ def fatal_recommendations(error)
138
+ recs = ["Run memory.check_setup to diagnose the issue"]
139
+
140
+ case error
141
+ when Errno::ENOSPC
142
+ recs << "Free up disk space and retry"
143
+ when Errno::EROFS
144
+ recs << "Check filesystem permissions"
145
+ else
146
+ if error.message.include?("corrupt") || error.message.include?("malformed")
147
+ recs << "Database may be corrupted — run: claude-memory doctor"
148
+ recs << "If unrecoverable: claude-memory init --force"
149
+ else
150
+ recs << "If persistent: claude-memory doctor"
151
+ end
152
+ end
153
+
154
+ recs
155
+ end
156
+
157
+ def benign_message(reason)
158
+ case reason
159
+ when :no_results
160
+ "No matching facts found. The memory system is working but has no relevant data for this query."
161
+ when :not_initialized
162
+ "Memory system not yet initialized. Facts will be stored as conversations happen."
163
+ when :empty_database
164
+ "Database exists but contains no facts yet. Use store_extraction to add facts."
165
+ else
166
+ "No data available."
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Context-aware query handlers (facts by tool, branch, directory)
7
+ module ContextHandlers
8
+ def facts_by_tool(args)
9
+ tool_name = args["tool_name"]
10
+ scope = extract_scope(args)
11
+ limit = extract_limit(args, default: 20)
12
+
13
+ results = @recall.facts_by_tool(tool_name, limit: limit, scope: scope)
14
+ ResponseFormatter.format_tool_facts(tool_name, scope, results)
15
+ end
16
+
17
+ def facts_by_context(args)
18
+ scope = extract_scope(args)
19
+ limit = extract_limit(args, default: 20)
20
+
21
+ if args["git_branch"]
22
+ results = @recall.facts_by_branch(args["git_branch"], limit: limit, scope: scope)
23
+ context_type = "git_branch"
24
+ context_value = args["git_branch"]
25
+ elsif args["cwd"]
26
+ results = @recall.facts_by_directory(args["cwd"], limit: limit, scope: scope)
27
+ context_type = "cwd"
28
+ context_value = args["cwd"]
29
+ else
30
+ return {error: "Must provide either git_branch or cwd parameter"}
31
+ end
32
+
33
+ ResponseFormatter.format_context_facts(context_type, context_value, scope, results)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Management tool handlers (store_extraction, promote, sweep, changes, conflicts)
7
+ module ManagementHandlers
8
+ def store_extraction(args)
9
+ scope = args["scope"] || "project"
10
+ store = get_store_for_scope(scope)
11
+ return {error: "Database not available"} unless store
12
+
13
+ entities = (args["entities"] || []).map { |e| symbolize_keys(e) }
14
+ facts = (args["facts"] || []).map { |f| symbolize_keys(f) }
15
+ decisions = (args["decisions"] || []).map { |d| symbolize_keys(d) }
16
+
17
+ config = Configuration.new
18
+ project_path = config.project_dir
19
+ occurred_at = Time.now.utc.iso8601
20
+
21
+ searchable_text = Core::TextBuilder.build_searchable_text(entities, facts, decisions)
22
+ content_item_id = create_synthetic_content_item(store, searchable_text, project_path, occurred_at)
23
+ index_content_item(store, content_item_id, searchable_text)
24
+
25
+ extraction = Distill::Extraction.new(
26
+ entities: entities,
27
+ facts: facts,
28
+ decisions: decisions,
29
+ signals: []
30
+ )
31
+
32
+ resolver = Resolve::Resolver.new(store)
33
+ result = resolver.apply(
34
+ extraction,
35
+ content_item_id: content_item_id,
36
+ occurred_at: occurred_at,
37
+ project_path: project_path,
38
+ scope: scope
39
+ )
40
+
41
+ {
42
+ success: true,
43
+ scope: scope,
44
+ entities_created: result[:entities_created],
45
+ facts_created: result[:facts_created],
46
+ facts_superseded: result[:facts_superseded],
47
+ conflicts_created: result[:conflicts_created]
48
+ }
49
+ end
50
+
51
+ def promote(args)
52
+ return {error: "Promote requires StoreManager"} unless @manager
53
+
54
+ fact_id = args["fact_id"]
55
+ global_fact_id = @manager.promote_fact(fact_id)
56
+
57
+ if global_fact_id
58
+ {
59
+ success: true,
60
+ project_fact_id: fact_id,
61
+ global_fact_id: global_fact_id,
62
+ message: "Fact promoted to global memory"
63
+ }
64
+ else
65
+ {error: "Fact #{fact_id} not found in project database"}
66
+ end
67
+ end
68
+
69
+ def sweep_now(args)
70
+ scope = args["scope"] || "project"
71
+ store = get_store_for_scope(scope)
72
+ return {error: "Database not available"} unless store
73
+
74
+ sweeper = Sweep::Sweeper.new(store)
75
+ budget = args["budget_seconds"] || 5
76
+ stats = if args["escalate"]
77
+ sweeper.run_with_escalation!(budget_seconds: budget)
78
+ else
79
+ sweeper.run!(budget_seconds: budget)
80
+ end
81
+ ResponseFormatter.format_sweep_stats(scope, stats)
82
+ end
83
+
84
+ def changes(args)
85
+ since = args["since"] || (Time.now - 86400 * 7).utc.iso8601
86
+ scope = args["scope"] || "all"
87
+ list = @recall.changes(since: since, limit: args["limit"] || 20, scope: scope)
88
+ ResponseFormatter.format_changes(since, list)
89
+ end
90
+
91
+ def conflicts(args)
92
+ scope = args["scope"] || "all"
93
+ list = @recall.conflicts(scope: scope)
94
+ ResponseFormatter.format_conflicts(list)
95
+ end
96
+
97
+ def mark_distilled(args)
98
+ content_item_id = args["content_item_id"]
99
+ facts_extracted = args["facts_extracted"] || 0
100
+
101
+ store = find_store_for_content_item(content_item_id)
102
+ return {error: "Content item #{content_item_id} not found"} unless store
103
+
104
+ store.record_ingestion_metrics(
105
+ content_item_id: content_item_id,
106
+ input_tokens: 0,
107
+ output_tokens: 0,
108
+ facts_extracted: facts_extracted
109
+ )
110
+
111
+ {
112
+ success: true,
113
+ content_item_id: content_item_id,
114
+ facts_extracted: facts_extracted
115
+ }
116
+ end
117
+
118
+ private
119
+
120
+ def create_synthetic_content_item(store, text, project_path, occurred_at)
121
+ text_hash = Digest::SHA256.hexdigest(text)
122
+ store.upsert_content_item(
123
+ source: "mcp_extraction",
124
+ session_id: "mcp-#{Time.now.to_i}",
125
+ transcript_path: nil,
126
+ project_path: project_path,
127
+ text_hash: text_hash,
128
+ byte_len: text.bytesize,
129
+ raw_text: text,
130
+ occurred_at: occurred_at
131
+ )
132
+ end
133
+
134
+ def index_content_item(store, content_item_id, text)
135
+ fts = Index::LexicalFTS.new(store)
136
+ fts.index_content_item(content_item_id, text)
137
+ end
138
+
139
+ def symbolize_keys(hash)
140
+ Core::TextBuilder.symbolize_keys(hash)
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Query and recall tool handlers
7
+ module QueryHandlers
8
+ def recall(args)
9
+ return database_not_found_error unless databases_exist?
10
+
11
+ scope = extract_scope(args)
12
+ limit = extract_limit(args)
13
+ compact = args["compact"] == true
14
+ query = args["query"]
15
+ intent = extract_intent(args)
16
+ results = @recall.query(query, limit: limit, scope: scope, include_raw_text: !compact, intent: intent)
17
+ ResponseFormatter.format_recall_results(results, compact: compact, query: query)
18
+ rescue Sequel::DatabaseError, Sequel::DatabaseConnectionError, Errno::ENOENT => e
19
+ classified_error(e, tool_name: "memory.recall")
20
+ end
21
+
22
+ def recall_index(args)
23
+ scope = extract_scope(args)
24
+ limit = extract_limit(args, default: 20)
25
+ intent = extract_intent(args)
26
+ results = @recall.query_index(args["query"], limit: limit, scope: scope, intent: intent)
27
+ ResponseFormatter.format_index_results(args["query"], scope, results)
28
+ end
29
+
30
+ def recall_details(args)
31
+ fact_ids = args["fact_ids"]
32
+ scope = args["scope"] || "project"
33
+
34
+ explanations = fact_ids.map do |fact_id|
35
+ explanation = @recall.explain(fact_id, scope: scope)
36
+ next nil if explanation.is_a?(Core::NullExplanation)
37
+
38
+ ResponseFormatter.format_detailed_explanation(explanation)
39
+ end.compact
40
+
41
+ {
42
+ fact_count: explanations.size,
43
+ facts: explanations
44
+ }
45
+ end
46
+
47
+ def explain(args)
48
+ scope = args["scope"] || "project"
49
+ explanation = @recall.explain(args["fact_id"], scope: scope)
50
+ return {error: "Fact not found in #{scope} database"} if explanation.is_a?(Core::NullExplanation)
51
+
52
+ ResponseFormatter.format_explanation(explanation, scope)
53
+ end
54
+
55
+ def recall_semantic(args)
56
+ query = args["query"]
57
+ mode = (args["mode"] || "both").to_sym
58
+ scope = extract_scope(args)
59
+ limit = extract_limit(args)
60
+ compact = args["compact"] == true
61
+ explain = args["explain"] == true
62
+ intent = extract_intent(args)
63
+
64
+ results = @recall.query_semantic(query, limit: limit, scope: scope, mode: mode, explain: explain, intent: intent)
65
+ ResponseFormatter.format_semantic_results(query, mode.to_s, scope, results, compact: compact)
66
+ end
67
+
68
+ def search_concepts(args)
69
+ concepts = args["concepts"]
70
+ scope = extract_scope(args)
71
+ limit = extract_limit(args)
72
+ compact = args["compact"] == true
73
+
74
+ return {error: "Must provide 2-5 concepts"} unless (2..5).cover?(concepts.size)
75
+
76
+ results = @recall.query_concepts(concepts, limit: limit, scope: scope)
77
+ ResponseFormatter.format_concept_results(concepts, scope, results, compact: compact)
78
+ end
79
+
80
+ def fact_graph(args)
81
+ fact_id = args["fact_id"]
82
+ depth = args["depth"] || 2
83
+ scope = args["scope"] || "project"
84
+
85
+ graph = @recall.fact_graph(fact_id, depth: depth, scope: scope)
86
+
87
+ return {error: "Fact #{fact_id} not found in #{scope} database"} if graph[:node_count] == 0
88
+
89
+ graph
90
+ end
91
+
92
+ def undistilled(args)
93
+ limit = args["limit"] || 3
94
+ min_length = args["min_length"] || 200
95
+ max_text = 2000
96
+
97
+ items = collect_undistilled_items(limit: limit, min_length: min_length)
98
+
99
+ {
100
+ count: items.size,
101
+ items: items.map { |item|
102
+ {
103
+ content_item_id: item[:id],
104
+ occurred_at: item[:occurred_at],
105
+ occurred_ago: Core::RelativeTime.format(item[:occurred_at]),
106
+ project_path: item[:project_path],
107
+ raw_text: Core::TextBuilder.truncate(item[:raw_text], max_text)
108
+ }
109
+ }
110
+ }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end