attio-mcp 1.1.0 → 1.1.2

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 (100) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -2
  3. package/dist/api/operations/search.d.ts +5 -2
  4. package/dist/api/operations/search.d.ts.map +1 -1
  5. package/dist/api/operations/search.js +469 -9
  6. package/dist/api/operations/search.js.map +1 -1
  7. package/dist/cli.js +1 -1
  8. package/dist/cli.js.map +1 -1
  9. package/dist/handlers/tool-configs/openai/index.d.ts +2 -2
  10. package/dist/handlers/tool-configs/openai/index.d.ts.map +1 -1
  11. package/dist/handlers/tool-configs/openai/index.js +13 -2
  12. package/dist/handlers/tool-configs/openai/index.js.map +1 -1
  13. package/dist/handlers/tool-configs/universal/core/crud-operations.d.ts.map +1 -1
  14. package/dist/handlers/tool-configs/universal/core/crud-operations.js +22 -3
  15. package/dist/handlers/tool-configs/universal/core/crud-operations.js.map +1 -1
  16. package/dist/handlers/tool-configs/universal/index.d.ts +4 -18
  17. package/dist/handlers/tool-configs/universal/index.d.ts.map +1 -1
  18. package/dist/handlers/tool-configs/universal/index.js +25 -5
  19. package/dist/handlers/tool-configs/universal/index.js.map +1 -1
  20. package/dist/handlers/tools/registry.d.ts +3 -10
  21. package/dist/handlers/tools/registry.d.ts.map +1 -1
  22. package/dist/objects/deals/index.d.ts +1 -0
  23. package/dist/objects/deals/index.d.ts.map +1 -1
  24. package/dist/objects/deals/index.js +2 -0
  25. package/dist/objects/deals/index.js.map +1 -1
  26. package/dist/objects/deals/search.d.ts +16 -0
  27. package/dist/objects/deals/search.d.ts.map +1 -0
  28. package/dist/objects/deals/search.js +59 -0
  29. package/dist/objects/deals/search.js.map +1 -0
  30. package/dist/prompts/handlers.d.ts +1 -1
  31. package/dist/prompts/handlers.d.ts.map +1 -1
  32. package/dist/prompts/handlers.js +8 -9
  33. package/dist/prompts/handlers.js.map +1 -1
  34. package/dist/prompts/v1/utils/token-metadata.d.ts +3 -3
  35. package/dist/prompts/v1/utils/token-metadata.d.ts.map +1 -1
  36. package/dist/prompts/v1/utils/token-metadata.js +7 -7
  37. package/dist/prompts/v1/utils/token-metadata.js.map +1 -1
  38. package/dist/prompts/v1/utils/validation.d.ts +1 -1
  39. package/dist/prompts/v1/utils/validation.d.ts.map +1 -1
  40. package/dist/prompts/v1/utils/validation.js +2 -2
  41. package/dist/prompts/v1/utils/validation.js.map +1 -1
  42. package/dist/server/createServer.d.ts +2 -2
  43. package/dist/server/createServer.d.ts.map +1 -1
  44. package/dist/server/createServer.js +3 -3
  45. package/dist/server/createServer.js.map +1 -1
  46. package/dist/services/CachingService.d.ts +57 -0
  47. package/dist/services/CachingService.d.ts.map +1 -1
  48. package/dist/services/CachingService.js +112 -0
  49. package/dist/services/CachingService.js.map +1 -1
  50. package/dist/services/UniversalSearchService.d.ts +0 -12
  51. package/dist/services/UniversalSearchService.d.ts.map +1 -1
  52. package/dist/services/UniversalSearchService.js +19 -133
  53. package/dist/services/UniversalSearchService.js.map +1 -1
  54. package/dist/services/search-strategies/CompanySearchStrategy.d.ts.map +1 -1
  55. package/dist/services/search-strategies/CompanySearchStrategy.js +13 -11
  56. package/dist/services/search-strategies/CompanySearchStrategy.js.map +1 -1
  57. package/dist/services/search-strategies/DealSearchStrategy.d.ts +38 -0
  58. package/dist/services/search-strategies/DealSearchStrategy.d.ts.map +1 -0
  59. package/dist/services/search-strategies/DealSearchStrategy.js +119 -0
  60. package/dist/services/search-strategies/DealSearchStrategy.js.map +1 -0
  61. package/dist/services/search-strategies/NoteSearchStrategy.d.ts +62 -0
  62. package/dist/services/search-strategies/NoteSearchStrategy.d.ts.map +1 -0
  63. package/dist/services/search-strategies/NoteSearchStrategy.js +224 -0
  64. package/dist/services/search-strategies/NoteSearchStrategy.js.map +1 -0
  65. package/dist/services/search-strategies/PeopleSearchStrategy.d.ts.map +1 -1
  66. package/dist/services/search-strategies/PeopleSearchStrategy.js +13 -15
  67. package/dist/services/search-strategies/PeopleSearchStrategy.js.map +1 -1
  68. package/dist/services/search-strategies/index.d.ts +2 -0
  69. package/dist/services/search-strategies/index.d.ts.map +1 -1
  70. package/dist/services/search-strategies/index.js +2 -0
  71. package/dist/services/search-strategies/index.js.map +1 -1
  72. package/dist/services/search-strategies/interfaces.d.ts +6 -0
  73. package/dist/services/search-strategies/interfaces.d.ts.map +1 -1
  74. package/dist/services/search-utilities/SearchScorer.d.ts +4 -0
  75. package/dist/services/search-utilities/SearchScorer.d.ts.map +1 -0
  76. package/dist/services/search-utilities/SearchScorer.js +243 -0
  77. package/dist/services/search-utilities/SearchScorer.js.map +1 -0
  78. package/dist/services/search-utilities/SearchUtilities.d.ts +5 -0
  79. package/dist/services/search-utilities/SearchUtilities.d.ts.map +1 -1
  80. package/dist/services/search-utilities/SearchUtilities.js +20 -0
  81. package/dist/services/search-utilities/SearchUtilities.js.map +1 -1
  82. package/dist/smithery.d.ts +5 -2
  83. package/dist/smithery.d.ts.map +1 -1
  84. package/dist/smithery.js +20 -2
  85. package/dist/smithery.js.map +1 -1
  86. package/dist/utils/mcp-discovery.d.ts.map +1 -1
  87. package/dist/utils/mcp-discovery.js +19 -1
  88. package/dist/utils/mcp-discovery.js.map +1 -1
  89. package/dist/utils/token-count.d.ts +3 -3
  90. package/dist/utils/token-count.d.ts.map +1 -1
  91. package/dist/utils/token-count.js +48 -8
  92. package/dist/utils/token-count.js.map +1 -1
  93. package/dist/utils/token-footprint-analyzer.d.ts +2 -2
  94. package/dist/utils/token-footprint-analyzer.d.ts.map +1 -1
  95. package/dist/utils/token-footprint-analyzer.js +10 -10
  96. package/dist/utils/token-footprint-analyzer.js.map +1 -1
  97. package/dist/utils/validation/phone-validation.d.ts.map +1 -1
  98. package/dist/utils/validation/phone-validation.js +3 -4
  99. package/dist/utils/validation/phone-validation.js.map +1 -1
  100. package/package.json +5 -2
package/CHANGELOG.md CHANGED
@@ -17,6 +17,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
17
 
18
18
  ### Deprecated
19
19
 
20
+ ## [1.1.2] - 2025-10-08
21
+
22
+ ### Fixed
23
+
24
+ - **Multi-token search query logic** - Fixed fallback search to require ALL tokens in multi-word queries instead of ANY token
25
+ - **Problem**: Multi-word searches (e.g., "Alpha Beta Company") returned 100+ records matching ANY token ("Alpha" OR "Beta" OR "Company"), pushing exact matches out of results
26
+ - **Solution**: Multi-token queries now use AND-of-OR structure - each token must match somewhere (in name OR domains), but all tokens must match (cross-field flexibility with precision)
27
+ - **Result**: Multi-word name searches now return exact matches in top results instead of being buried at position #101+
28
+ - Single-token queries unchanged (still use simple `$contains`)
29
+ - This improves precision for company/people searches with multi-word names
30
+ - **records_search relevance** (#885) - Added client-side scoring with uFuzzy and LRU caching so exact domain/name/email matches rank first while repeated queries stay within the 3 s budget
31
+ - **Deal search fast path optimization** (#885) - Extended fast path optimization to deals with 72-80% performance improvement
32
+ - Sequential candidate execution: exact match ($eq) → substring match ($contains)
33
+ - LRU caching with 5-minute TTL delivers 8ms cached responses (99.9% faster than ~2.8s cold searches)
34
+ - Created `DealSearchStrategy` following same pattern as companies/people
35
+ - Removed obsolete `searchDeals()` and `queryDealRecords()` methods (100+ lines)
36
+ - Fixed original bug where deals only supported exact name matches
37
+ - **Notes search functionality - Partial fix with API limitation** (#888)
38
+ - Created `NoteSearchStrategy` following established search strategy pattern
39
+ - **API Limitation Discovered**: Attio Notes API `/v2/notes` endpoint returns empty array when called without filters
40
+ - Confirmed via direct API testing: `GET /v2/notes` returns `{"data": []}`
41
+ - API requires `parent_object` and/or `parent_record_id` filters to return notes
42
+ - This prevents searching across all notes in a workspace
43
+ - **What Works**: `list-notes` tool with parent record filtering (e.g., list all notes on a specific company)
44
+ - **What Doesn't Work**: Global note search without parent filters (e.g., "find all notes containing 'demo'")
45
+ - **Workaround**: Use `list-notes` with explicit parent filters to search within a specific record's notes
46
+ - Implemented client-side filtering for title/content when parent filters are provided
47
+ - Added smart caching (30s TTL) with parent-aware cache keys to prevent cross-contamination
48
+ - Removed obsolete `searchNotes()` fallback method (85 lines)
49
+ - **Recommendation**: Request Attio to add workspace-wide notes endpoint or search capability
50
+
20
51
  ## [1.1.0] - 2025-10-06
21
52
 
22
53
  This release enhances developer experience with intelligent prompts, comprehensive tool standardization, and strengthens enterprise readiness with security hardening and validation improvements.
package/README.md CHANGED
@@ -177,8 +177,8 @@ For complete prompt documentation, see [docs/prompts/v1-catalog.md](./docs/promp
177
177
  ### 🤝 **OpenAI MCP Compatibility**
178
178
 
179
179
  - **Developer Mode Ready**: Every tool now publishes MCP safety annotations (`readOnlyHint`, `destructiveHint`) so OpenAI Developer Mode can auto-approve reads and request confirmation for writes.
180
- - **Search Compatibility Surface**: The `search` and `fetch` tools remain available for OpenAI's baseline MCP support. Set `ATTIO_MCP_TOOL_MODE=search` to expose only these read-only endpoints (plus `aaa-health-check`) when Developer Mode is unavailable.
181
- - **Default Behaviour**: With `ATTIO_MCP_TOOL_MODE` unset, the full universal tool set is exposed—matching Claude’s experience—while OpenAI users still see the compatibility wrappers.
180
+ - **Full Tool Access (Default)**: All 33 universal tools are exposed by default. Do NOT set `ATTIO_MCP_TOOL_MODE` in Smithery configuration for full access.
181
+ - **Search-Only Mode**: To restrict to read-only tools (`search`, `fetch`, `aaa-health-check`), explicitly configure `ATTIO_MCP_TOOL_MODE: 'search'` in Smithery dashboard when Developer Mode is unavailable.
182
182
  - **Detailed Guide**: See [docs/chatgpt-developer-mode.md](./docs/chatgpt-developer-mode.md) for environment variables, approval flows, and validation tips.
183
183
  - **User Documentation**: See the [ChatGPT Developer Mode docs](./docs/chatgpt-developer-mode.md) for a complete walkthrough of approval flows and setup instructions.
184
184
 
@@ -608,6 +608,7 @@ Comprehensive documentation is available in the [docs directory](./docs):
608
608
 
609
609
  - [Warning Filter Configuration](./docs/configuration/warning-filters.md) - Understanding cosmetic vs semantic mismatches, ESLint budgets, and suppression strategies
610
610
  - [Field Verification Configuration](./docs/configuration/field-verification.md) - Field persistence verification and validation settings
611
+ - [Search Scoring Configuration](./docs/configuration/search-scoring.md) - Environment variables for relevance scoring, caching, and operator validation
611
612
 
612
613
  ### **API Reference**
613
614
 
@@ -10,10 +10,13 @@ import { AttioRecord, ResourceType } from '../../types/attio.js';
10
10
  *
11
11
  * @param objectType - The type of object to search (people or companies)
12
12
  * @param query - Search query string
13
- * @param retryConfig - Optional retry configuration
13
+ * @param options - Optional search options (limit, retryConfig)
14
14
  * @returns Array of matching records
15
15
  */
16
- export declare function searchObject<T extends AttioRecord>(objectType: ResourceType, query: string, retryConfig?: Partial<RetryConfig>): Promise<T[]>;
16
+ export declare function searchObject<T extends AttioRecord>(objectType: ResourceType, query: string, options?: {
17
+ limit?: number;
18
+ retryConfig?: Partial<RetryConfig>;
19
+ }): Promise<T[]>;
17
20
  /**
18
21
  * Generic function to search any object type with advanced filtering capabilities
19
22
  *
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/api/operations/search.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAiB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAK5D,OAAO,EACL,WAAW,EACX,YAAY,EAEb,MAAM,wBAAwB,CAAC;AA4GhC;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,CAAC,SAAS,WAAW,EACtD,UAAU,EAAE,YAAY,EACxB,KAAK,EAAE,MAAM,EACb,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,CAAC,EAAE,CAAC,CAsBd;AAED;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,SAAS,WAAW,EAC9D,UAAU,EAAE,YAAY,EACxB,OAAO,CAAC,EAAE,gBAAgB,EAC1B,KAAK,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,EACf,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,CAAC,EAAE,CAAC,CAuId;AAED;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,CAAC,SAAS,WAAW,EACrD,UAAU,EAAE,YAAY,EACxB,KAAK,CAAC,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,CAAC,EAAE,CAAC,CA0Bd"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/api/operations/search.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAiB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAK5D,OAAO,EACL,WAAW,EACX,YAAY,EAEb,MAAM,wBAAwB,CAAC;AAyhBhC;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,CAAC,SAAS,WAAW,EACtD,UAAU,EAAE,YAAY,EACxB,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAA;CAAE,GAC/D,OAAO,CAAC,CAAC,EAAE,CAAC,CA4Ld;AAED;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,SAAS,WAAW,EAC9D,UAAU,EAAE,YAAY,EACxB,OAAO,CAAC,EAAE,gBAAgB,EAC1B,KAAK,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,EACf,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,CAAC,EAAE,CAAC,CAuId;AAED;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,CAAC,SAAS,WAAW,EACrD,UAAU,EAAE,YAAY,EACxB,KAAK,CAAC,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,CAAC,EAAE,CAAC,CA0Bd"}
@@ -9,6 +9,248 @@ import { FilterValidationError } from '../../errors/api-errors.js';
9
9
  import { ErrorEnhancer } from '../../errors/enhanced-api-errors.js';
10
10
  import { transformFiltersToApiFormat } from '../../utils/record-utils.js';
11
11
  import { ResourceType, } from '../../types/attio.js';
12
+ import { LRUCache } from 'lru-cache';
13
+ import { scoreAndRank } from '../../services/search-utilities/SearchScorer.js';
14
+ import { createScopedLogger } from '../../utils/logger.js';
15
+ const logger = createScopedLogger('api.operations', 'search');
16
+ const ENABLE_SEARCH_SCORING = process.env.ENABLE_SEARCH_SCORING !== 'false';
17
+ const SEARCH_CACHE_TTL_MS = Number.parseInt(process.env.SEARCH_CACHE_TTL_MS ?? `${5 * 60 * 1000}`, 10);
18
+ const SEARCH_CACHE_MAX = Number.parseInt(process.env.SEARCH_CACHE_MAX ?? '500', 10);
19
+ const SEARCH_FETCH_MULTIPLIER = Number.parseInt(process.env.SEARCH_FETCH_MULTIPLIER ?? '5', 10);
20
+ const SEARCH_FETCH_MIN = Number.parseInt(process.env.SEARCH_FETCH_MIN ?? '50', 10);
21
+ const SEARCH_FAST_PATH_LIMIT = Number.parseInt(process.env.SEARCH_FAST_PATH_LIMIT ?? '5', 10);
22
+ const DEFAULT_FETCH_LIMIT = Number.parseInt(process.env.SEARCH_DEFAULT_LIMIT ?? '20', 10);
23
+ const searchCache = new LRUCache({
24
+ max: Number.isFinite(SEARCH_CACHE_MAX) ? SEARCH_CACHE_MAX : 500,
25
+ ttl: Number.isFinite(SEARCH_CACHE_TTL_MS)
26
+ ? SEARCH_CACHE_TTL_MS
27
+ : 5 * 60 * 1000,
28
+ });
29
+ function getCacheKey(objectType, query, limit) {
30
+ return `${objectType}:${limit}:${query.toLowerCase()}`;
31
+ }
32
+ function normalizeDomainValue(value) {
33
+ return value
34
+ .toLowerCase()
35
+ .replace(/^https?:\/\//, '')
36
+ .replace(/^www\./, '')
37
+ .trim();
38
+ }
39
+ function normalizePlainValue(value) {
40
+ return value.toLowerCase().trim();
41
+ }
42
+ function extractStringsFromField(fieldValue, keys = []) {
43
+ const results = [];
44
+ const pushString = (candidate) => {
45
+ if (typeof candidate === 'string' && candidate.trim()) {
46
+ results.push(candidate);
47
+ }
48
+ };
49
+ if (!fieldValue) {
50
+ return results;
51
+ }
52
+ if (typeof fieldValue === 'string') {
53
+ pushString(fieldValue);
54
+ return results;
55
+ }
56
+ if (Array.isArray(fieldValue)) {
57
+ fieldValue.forEach((item) => {
58
+ if (typeof item === 'string') {
59
+ pushString(item);
60
+ }
61
+ else if (item && typeof item === 'object') {
62
+ keys.forEach((key) => {
63
+ pushString(item[key]);
64
+ });
65
+ }
66
+ });
67
+ return results;
68
+ }
69
+ if (fieldValue && typeof fieldValue === 'object') {
70
+ keys.forEach((key) => {
71
+ pushString(fieldValue[key]);
72
+ });
73
+ }
74
+ return results;
75
+ }
76
+ function extractNormalizedName(record) {
77
+ const values = (record?.values ?? {});
78
+ const candidates = [];
79
+ if ('name' in values) {
80
+ candidates.push(values.name);
81
+ }
82
+ if ('full_name' in values) {
83
+ candidates.push(values.full_name);
84
+ }
85
+ if ('title' in values) {
86
+ candidates.push(values.title);
87
+ }
88
+ const normalizeCandidate = (candidate) => {
89
+ if (typeof candidate === 'string' && candidate.trim()) {
90
+ return normalizePlainValue(candidate);
91
+ }
92
+ if (Array.isArray(candidate)) {
93
+ for (const item of candidate) {
94
+ const normalized = normalizeCandidate(item);
95
+ if (normalized) {
96
+ return normalized;
97
+ }
98
+ }
99
+ }
100
+ if (candidate && typeof candidate === 'object') {
101
+ const obj = candidate;
102
+ const nested = obj.full_name ?? obj.formatted ?? obj.value ?? obj.name;
103
+ if (typeof nested === 'string' && nested.trim()) {
104
+ return normalizePlainValue(nested);
105
+ }
106
+ }
107
+ return null;
108
+ };
109
+ for (const candidate of candidates) {
110
+ const normalized = normalizeCandidate(candidate);
111
+ if (normalized) {
112
+ return normalized;
113
+ }
114
+ }
115
+ return '';
116
+ }
117
+ /**
118
+ * Build fast-path candidates for structured queries.
119
+ *
120
+ * Issue #885: Attio API supports $eq operator (verified 2025-10-07)
121
+ * Supported operators: $eq, $contains, $starts_with, $ends_with, $not_empty
122
+ * NOT supported: $equals, $in
123
+ *
124
+ * @see scripts/test-attio-operators.mjs for operator verification tests
125
+ */
126
+ function buildFastPathCandidates(objectType, parsed, originalQuery) {
127
+ const candidates = [];
128
+ const trimmedQuery = originalQuery.trim();
129
+ if (objectType === ResourceType.COMPANIES && parsed.domains.length === 1) {
130
+ const normalizedDomain = normalizeDomainValue(parsed.domains[0]);
131
+ candidates.push({
132
+ filter: { domains: { $eq: normalizedDomain } },
133
+ kind: 'domain',
134
+ strategy: 'eq',
135
+ value: normalizedDomain,
136
+ });
137
+ candidates.push({
138
+ filter: { domains: { $contains: normalizedDomain } },
139
+ kind: 'domain',
140
+ strategy: 'contains',
141
+ value: normalizedDomain,
142
+ });
143
+ }
144
+ if (parsed.emails.length === 1) {
145
+ const emailValue = parsed.emails[0].toLowerCase();
146
+ candidates.push({
147
+ filter: { email_addresses: { $eq: emailValue } },
148
+ kind: 'email',
149
+ strategy: 'eq',
150
+ value: emailValue,
151
+ });
152
+ candidates.push({
153
+ filter: { email_addresses: { $contains: emailValue } },
154
+ kind: 'email',
155
+ strategy: 'contains',
156
+ value: emailValue,
157
+ });
158
+ }
159
+ // Try all phone variants as fast path candidates (parser creates normalized forms)
160
+ if (parsed.phones.length > 0) {
161
+ parsed.phones.forEach((phoneValue) => {
162
+ candidates.push({
163
+ filter: { phone_numbers: { $eq: phoneValue } },
164
+ kind: 'phone',
165
+ strategy: 'eq',
166
+ value: phoneValue,
167
+ });
168
+ });
169
+ }
170
+ const hasStructuredQuery = parsed.domains.length > 0 ||
171
+ parsed.emails.length > 0 ||
172
+ parsed.phones.length > 0;
173
+ if (trimmedQuery &&
174
+ !hasStructuredQuery &&
175
+ (objectType === ResourceType.COMPANIES ||
176
+ objectType === ResourceType.PEOPLE ||
177
+ objectType === ResourceType.DEALS)) {
178
+ const normalizedName = normalizePlainValue(trimmedQuery);
179
+ candidates.push({
180
+ filter: { name: { $eq: trimmedQuery } },
181
+ kind: 'name',
182
+ strategy: 'eq',
183
+ value: normalizedName,
184
+ });
185
+ candidates.push({
186
+ filter: { name: { $contains: trimmedQuery } },
187
+ kind: 'name',
188
+ strategy: 'contains',
189
+ value: normalizedName,
190
+ });
191
+ }
192
+ return candidates;
193
+ }
194
+ function recordMatchesFastPath(record, candidate) {
195
+ const values = (record?.values ?? {});
196
+ switch (candidate.kind) {
197
+ case 'domain': {
198
+ const domains = extractStringsFromField(values.domains, [
199
+ 'domain',
200
+ 'value',
201
+ ]).map(normalizeDomainValue);
202
+ if (candidate.strategy === 'eq') {
203
+ return domains.includes(candidate.value);
204
+ }
205
+ return domains.some((domain) => domain.includes(candidate.value));
206
+ }
207
+ case 'email': {
208
+ const emails = extractStringsFromField(values.email_addresses, [
209
+ 'email_address',
210
+ 'value',
211
+ ]).map(normalizePlainValue);
212
+ if (candidate.strategy === 'eq') {
213
+ return emails.includes(candidate.value);
214
+ }
215
+ return emails.some((email) => email.includes(candidate.value));
216
+ }
217
+ case 'phone': {
218
+ const phones = extractStringsFromField(values.phone_numbers, [
219
+ 'phone_number', // Main normalized field
220
+ 'original_phone_number', // Original input
221
+ 'number', // Legacy fallback
222
+ 'normalized', // Legacy fallback
223
+ 'value', // Legacy fallback
224
+ ]);
225
+ const normalizedSet = new Set();
226
+ phones.forEach((phone) => {
227
+ normalizedSet.add(phone);
228
+ normalizedSet.add(phone.replace(/\s+/g, ''));
229
+ // Also try with/without leading +
230
+ if (phone.startsWith('+')) {
231
+ normalizedSet.add(phone.substring(1));
232
+ }
233
+ else {
234
+ normalizedSet.add(`+${phone}`);
235
+ }
236
+ });
237
+ return (normalizedSet.has(candidate.value) ||
238
+ normalizedSet.has(candidate.value.replace(/\s+/g, '')));
239
+ }
240
+ case 'name': {
241
+ const recordName = extractNormalizedName(record);
242
+ if (!recordName) {
243
+ return false;
244
+ }
245
+ if (candidate.strategy === 'eq') {
246
+ return recordName === candidate.value;
247
+ }
248
+ return recordName.includes(candidate.value);
249
+ }
250
+ default:
251
+ return false;
252
+ }
253
+ }
12
254
  function createLegacyFilter(objectType, query) {
13
255
  if (objectType === ResourceType.PEOPLE) {
14
256
  return {
@@ -28,12 +270,12 @@ function addCondition(collection, seen, condition) {
28
270
  collection.push(condition);
29
271
  }
30
272
  }
31
- function buildSearchFilter(objectType, query) {
273
+ function buildSearchFilter(objectType, query, parsedOverride) {
32
274
  const trimmedQuery = query.trim();
33
275
  if (!trimmedQuery) {
34
276
  return createLegacyFilter(objectType, query);
35
277
  }
36
- const parsed = parseQuery(trimmedQuery);
278
+ const parsed = parsedOverride ?? parseQuery(trimmedQuery);
37
279
  const conditions = [];
38
280
  const seen = new Set();
39
281
  parsed.emails.forEach((email) => {
@@ -63,6 +305,99 @@ function buildSearchFilter(objectType, query) {
63
305
  tokenTargets.add('domains');
64
306
  }
65
307
  const uniqueTokens = Array.from(new Set(parsed.tokens.filter((token) => token.length > 1)));
308
+ // AND-of-OR search strategy (#885 fix):
309
+ // For each token, allow it to match ANY field (name OR domains OR email/phone)
310
+ // But ALL tokens must match SOMEWHERE
311
+ // This provides precision (all tokens required) + cross-field flexibility (name + domain tokens)
312
+ //
313
+ // Example: "Elite Styles Beauty Mindful Program"
314
+ // - "Elite" can match name OR domains
315
+ // - "Styles" can match name OR domains
316
+ // - "Mindful" can match name OR domains (will match domain mindfulbeautyprogram.com)
317
+ // - etc.
318
+ //
319
+ // Result: Company "Elite Styles And Beauty" (mindfulbeautyprogram.com) matches because:
320
+ // - "Elite" matches name ✓
321
+ // - "Styles" matches name ✓
322
+ // - "Beauty" matches name ✓
323
+ // - "Mindful" matches domain ✓
324
+ // - "Program" matches domain ✓
325
+ if (uniqueTokens.length > 0) {
326
+ if (uniqueTokens.length === 1) {
327
+ // Single token: create OR condition across all relevant fields
328
+ const token = uniqueTokens[0];
329
+ tokenTargets.forEach((field) => {
330
+ addCondition(conditions, seen, {
331
+ [field]: { $contains: token },
332
+ });
333
+ });
334
+ }
335
+ else {
336
+ // Multiple tokens: each token must match at least one field (AND of ORs)
337
+ const tokenAndConditions = uniqueTokens.map((token) => {
338
+ const tokenFieldConditions = [];
339
+ tokenTargets.forEach((field) => {
340
+ tokenFieldConditions.push({
341
+ [field]: { $contains: token },
342
+ });
343
+ });
344
+ return {
345
+ $or: tokenFieldConditions,
346
+ };
347
+ });
348
+ // Combine all token conditions with AND
349
+ addCondition(conditions, seen, {
350
+ $and: tokenAndConditions,
351
+ });
352
+ }
353
+ }
354
+ if (conditions.length === 0) {
355
+ return createLegacyFilter(objectType, trimmedQuery);
356
+ }
357
+ if (conditions.length === 1) {
358
+ return conditions[0];
359
+ }
360
+ return {
361
+ $or: conditions,
362
+ };
363
+ }
364
+ /**
365
+ * Build OR-only fallback filter for when AND-of-OR returns zero results
366
+ * Uses the same parsed structure but allows ANY token to match (no AND requirement)
367
+ */
368
+ function buildORFallbackFilter(objectType, parsed) {
369
+ const conditions = [];
370
+ const seen = new Set();
371
+ // Include all structured fields as before
372
+ parsed.emails.forEach((email) => {
373
+ addCondition(conditions, seen, {
374
+ email_addresses: { $contains: email },
375
+ });
376
+ });
377
+ parsed.phones.forEach((phone) => {
378
+ addCondition(conditions, seen, {
379
+ phone_numbers: { $contains: phone },
380
+ });
381
+ });
382
+ if (objectType === ResourceType.COMPANIES) {
383
+ parsed.domains.forEach((domain) => {
384
+ addCondition(conditions, seen, {
385
+ domains: { $contains: domain },
386
+ });
387
+ });
388
+ }
389
+ const tokenTargets = new Set();
390
+ tokenTargets.add('name');
391
+ if (objectType === ResourceType.PEOPLE) {
392
+ tokenTargets.add('email_addresses');
393
+ tokenTargets.add('phone_numbers');
394
+ }
395
+ if (objectType === ResourceType.COMPANIES) {
396
+ tokenTargets.add('domains');
397
+ }
398
+ const uniqueTokens = Array.from(new Set(parsed.tokens.filter((token) => token.length > 1)));
399
+ // OR-only fallback: each token can match any field (no AND requirement)
400
+ // This provides maximum recall when the stricter AND-of-OR filter returns zero results
66
401
  uniqueTokens.forEach((token) => {
67
402
  tokenTargets.forEach((field) => {
68
403
  addCondition(conditions, seen, {
@@ -71,7 +406,8 @@ function buildSearchFilter(objectType, query) {
71
406
  });
72
407
  });
73
408
  if (conditions.length === 0) {
74
- return createLegacyFilter(objectType, trimmedQuery);
409
+ // Shouldn't happen, but return safe fallback
410
+ return { name: { $contains: parsed.tokens.join(' ') } };
75
411
  }
76
412
  if (conditions.length === 1) {
77
413
  return conditions[0];
@@ -85,27 +421,151 @@ function buildSearchFilter(objectType, query) {
85
421
  *
86
422
  * @param objectType - The type of object to search (people or companies)
87
423
  * @param query - Search query string
88
- * @param retryConfig - Optional retry configuration
424
+ * @param options - Optional search options (limit, retryConfig)
89
425
  * @returns Array of matching records
90
426
  */
91
- export async function searchObject(objectType, query, retryConfig) {
427
+ export async function searchObject(objectType, query, options) {
428
+ const retryConfig = options?.retryConfig;
92
429
  const api = getLazyAttioClient();
93
430
  const path = `/objects/${objectType}/records/query`;
94
- const filter = buildSearchFilter(objectType, query);
431
+ const trimmedQuery = query.trim();
432
+ const scoringEnabled = ENABLE_SEARCH_SCORING && trimmedQuery.length > 0;
433
+ // Use caller's limit if provided, otherwise fall back to default
434
+ const requestedLimit = options?.limit;
435
+ const baseLimit = requestedLimit !== undefined && requestedLimit > 0
436
+ ? requestedLimit
437
+ : Number.isFinite(DEFAULT_FETCH_LIMIT) && DEFAULT_FETCH_LIMIT > 0
438
+ ? DEFAULT_FETCH_LIMIT
439
+ : 20;
440
+ const multiplier = Number.isFinite(SEARCH_FETCH_MULTIPLIER) && SEARCH_FETCH_MULTIPLIER > 0
441
+ ? SEARCH_FETCH_MULTIPLIER
442
+ : 5;
443
+ const minimumFetch = Number.isFinite(SEARCH_FETCH_MIN) && SEARCH_FETCH_MIN > 0
444
+ ? SEARCH_FETCH_MIN
445
+ : 50;
446
+ const fastPathLimit = Number.isFinite(SEARCH_FAST_PATH_LIMIT) && SEARCH_FAST_PATH_LIMIT > 0
447
+ ? Math.max(baseLimit, SEARCH_FAST_PATH_LIMIT)
448
+ : baseLimit;
449
+ const parsedQuery = trimmedQuery ? parseQuery(trimmedQuery) : null;
450
+ const cacheKey = scoringEnabled && trimmedQuery
451
+ ? getCacheKey(objectType, trimmedQuery, baseLimit)
452
+ : null;
453
+ if (scoringEnabled && cacheKey) {
454
+ const cached = searchCache.get(cacheKey);
455
+ if (cached) {
456
+ return cached;
457
+ }
458
+ }
459
+ if (scoringEnabled && parsedQuery) {
460
+ const fastCandidates = buildFastPathCandidates(objectType, parsedQuery, trimmedQuery);
461
+ for (const candidate of fastCandidates) {
462
+ const candidateLimit = candidate.kind === 'name' && candidate.strategy === 'contains'
463
+ ? Math.min(100, Math.max(minimumFetch, baseLimit * multiplier))
464
+ : candidate.kind === 'name' && candidate.strategy === 'eq'
465
+ ? baseLimit
466
+ : fastPathLimit;
467
+ logger.debug('[FastPath] Trying candidate', {
468
+ kind: candidate.kind,
469
+ strategy: candidate.strategy,
470
+ filter: candidate.filter,
471
+ limit: candidateLimit,
472
+ });
473
+ try {
474
+ const fastResponse = await callWithRetry(async () => {
475
+ return api.post(path, {
476
+ filter: candidate.filter,
477
+ limit: candidateLimit,
478
+ });
479
+ }, retryConfig);
480
+ const fastData = Array.isArray(fastResponse?.data?.data)
481
+ ? fastResponse?.data?.data
482
+ : [];
483
+ logger.debug('[FastPath] Received results', {
484
+ count: fastData.length,
485
+ });
486
+ if (!fastData.length) {
487
+ logger.debug('[FastPath] No results for candidate');
488
+ continue;
489
+ }
490
+ const hasMatch = fastData.some((record) => recordMatchesFastPath(record, candidate));
491
+ if (!hasMatch) {
492
+ logger.debug('[FastPath] Validation failed', {
493
+ candidateKind: candidate.kind,
494
+ candidateStrategy: candidate.strategy,
495
+ candidateValue: candidate.value,
496
+ resultCount: fastData.length,
497
+ });
498
+ continue;
499
+ }
500
+ logger.debug('[FastPath] Validation passed, ranking');
501
+ const rankedFast = scoreAndRank(trimmedQuery, fastData, parsedQuery);
502
+ const truncatedFast = rankedFast.slice(0, baseLimit);
503
+ if (cacheKey) {
504
+ searchCache.set(cacheKey, truncatedFast);
505
+ }
506
+ return truncatedFast;
507
+ }
508
+ catch (error) {
509
+ logger.debug('[FastPath] Error executing candidate', {
510
+ kind: candidate.kind,
511
+ strategy: candidate.strategy,
512
+ filter: candidate.filter,
513
+ error: error instanceof Error ? error.message : String(error),
514
+ });
515
+ void error; // Non-fatal fast-path failure; fall back to next candidate
516
+ }
517
+ }
518
+ }
519
+ const filter = buildSearchFilter(objectType, query, parsedQuery ?? undefined);
520
+ const fetchLimit = scoringEnabled
521
+ ? Math.max(minimumFetch, baseLimit * multiplier)
522
+ : baseLimit;
95
523
  return callWithRetry(async () => {
96
524
  try {
97
525
  const response = await api.post(path, {
98
526
  filter,
527
+ limit: fetchLimit,
99
528
  });
100
- return response?.data?.data || [];
529
+ const rawData = response?.data?.data;
530
+ let data = Array.isArray(rawData) ? rawData : [];
531
+ // OR-only fallback when AND-of-OR returns zero results (#885 recall fix)
532
+ // This handles over-constrained queries like "Beauty Glow Aesthetics Frisco"
533
+ // where some tokens don't exist but the record should still match
534
+ // Runs regardless of scoring state - scoring only affects ranking, not recall
535
+ if (data.length === 0 && parsedQuery) {
536
+ logger.debug('[Fallback] Zero results from AND-of-OR, trying OR-only', {
537
+ query: trimmedQuery,
538
+ objectType,
539
+ });
540
+ const fallbackFilter = buildORFallbackFilter(objectType, parsedQuery);
541
+ const fallbackResponse = await api.post(path, {
542
+ filter: fallbackFilter,
543
+ limit: fetchLimit,
544
+ });
545
+ const fallbackRawData = fallbackResponse?.data?.data;
546
+ data = Array.isArray(fallbackRawData)
547
+ ? fallbackRawData
548
+ : [];
549
+ logger.debug('[Fallback] OR-only results', {
550
+ count: data.length,
551
+ });
552
+ }
553
+ if (!scoringEnabled || data.length <= 1) {
554
+ const truncated = data.slice(0, baseLimit);
555
+ return truncated;
556
+ }
557
+ const ranked = scoreAndRank(trimmedQuery, data, parsedQuery ?? undefined);
558
+ const truncated = ranked.slice(0, baseLimit);
559
+ if (cacheKey) {
560
+ searchCache.set(cacheKey, truncated);
561
+ }
562
+ return truncated;
101
563
  }
102
564
  catch (error) {
103
565
  const apiError = error;
104
- // Handle 404 errors with custom message
105
566
  if (apiError.response && apiError.response.status === 404) {
106
567
  throw new Error(`No ${objectType} found matching '${query}'`);
107
568
  }
108
- // Let upstream handlers create specific, rich error objects from the original Axios error.
109
569
  throw error;
110
570
  }
111
571
  }, retryConfig);