@cyanheads/openalex-mcp-server 0.10.1 → 0.11.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.
- package/AGENTS.md +14 -11
- package/CLAUDE.md +14 -11
- package/README.md +34 -16
- package/changelog/0.11.x/0.11.0.md +41 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +1 -1
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/prompts/definitions/literature-review.prompt.js +1 -1
- package/dist/mcp-server/prompts/definitions/research-landscape.prompt.js +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js +10 -5
- package/dist/mcp-server/tools/definitions/analyze-trends.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts +6 -9
- package/dist/mcp-server/tools/definitions/citation-graph.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js +43 -33
- package/dist/mcp-server/tools/definitions/citation-graph.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/describe-fields.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js +25 -1
- package/dist/mcp-server/tools/definitions/describe-fields.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js +44 -16
- package/dist/mcp-server/tools/definitions/resolve-name.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts +22 -18
- package/dist/mcp-server/tools/definitions/search-entities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-entities.tool.js +194 -85
- package/dist/mcp-server/tools/definitions/search-entities.tool.js.map +1 -1
- package/dist/mcp-server/tools/escape-markdown.d.ts +26 -11
- package/dist/mcp-server/tools/escape-markdown.d.ts.map +1 -1
- package/dist/mcp-server/tools/escape-markdown.js +95 -26
- package/dist/mcp-server/tools/escape-markdown.js.map +1 -1
- package/dist/mcp-server/tools/page-size.d.ts +38 -7
- package/dist/mcp-server/tools/page-size.d.ts.map +1 -1
- package/dist/mcp-server/tools/page-size.js +37 -11
- package/dist/mcp-server/tools/page-size.js.map +1 -1
- package/dist/mcp-server/tools/record-page.d.ts +174 -0
- package/dist/mcp-server/tools/record-page.d.ts.map +1 -0
- package/dist/mcp-server/tools/record-page.js +392 -0
- package/dist/mcp-server/tools/record-page.js.map +1 -0
- package/dist/mcp-server/tools/render-entity-record.d.ts +3 -1
- package/dist/mcp-server/tools/render-entity-record.d.ts.map +1 -1
- package/dist/mcp-server/tools/render-entity-record.js +7 -2
- package/dist/mcp-server/tools/render-entity-record.js.map +1 -1
- package/dist/services/openalex/field-catalog.json +107 -0
- package/dist/services/openalex/openalex-service.d.ts +18 -7
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +84 -63
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/openalex/types.d.ts +13 -2
- package/dist/services/openalex/types.d.ts.map +1 -1
- package/dist/services/openalex/types.js +28 -1
- package/dist/services/openalex/types.js.map +1 -1
- package/package.json +1 -1
- package/server.json +5 -5
- package/skills/openalex-guide/SKILL.md +77 -0
- package/dist/mcp-server/tools/response-budget.d.ts +0 -160
- package/dist/mcp-server/tools/response-budget.d.ts.map +0 -1
- package/dist/mcp-server/tools/response-budget.js +0 -536
- package/dist/mcp-server/tools/response-budget.js.map +0 -1
|
@@ -6,14 +6,25 @@ import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
|
6
6
|
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
7
7
|
import { ATTR_MCP_TOOL_NAME, createCounter } from '@cyanheads/mcp-ts-core/utils';
|
|
8
8
|
import { getServerConfig } from '../../../config/server-config.js';
|
|
9
|
-
import {
|
|
9
|
+
import { escapeMarkdown } from '../escape-markdown.js';
|
|
10
|
+
import { clampCount, countSchema, pageSizeSchema } from '../page-size.js';
|
|
11
|
+
import { buildRecordPage, buildSlicePage, formatRecordPage, windowsOutputSchema, } from '../record-page.js';
|
|
10
12
|
import { renderBudgetTrailer } from '../render-budget.js';
|
|
11
|
-
import { fitRecordsToBudget, fitSliceToBudget, formatRecordPage, joinNotices, measurePageFrame, omittedOutputSchema, overBudgetOutputSchema, windowsOutputSchema, } from '../response-budget.js';
|
|
12
13
|
import { parseNativeFilters, quoteCallerText } from '../../../services/openalex/filter-values.js';
|
|
13
14
|
import { getOpenAlexService, selectRecordKey } from '../../../services/openalex/openalex-service.js';
|
|
14
15
|
import { ENTITY_TYPES } from '../../../services/openalex/types.js';
|
|
16
|
+
const PER_PAGE_CAP = 100;
|
|
17
|
+
const PER_PAGE_DEFAULT = 25;
|
|
15
18
|
const SEMANTIC_PER_PAGE_CAP = 50;
|
|
16
|
-
|
|
19
|
+
/** The most authorships OpenAlex stores per work, the longest array a slice needs to cover. */
|
|
20
|
+
const SLICE_LIMIT_CAP = 5000;
|
|
21
|
+
const SLICE_LIMIT_DEFAULT = 50;
|
|
22
|
+
/**
|
|
23
|
+
* The largest sample OpenAlex draws, and the most records of one it serves by page number:
|
|
24
|
+
* `page × per_page` above it is refused upstream even inside a smaller draw.
|
|
25
|
+
*/
|
|
26
|
+
const SAMPLE_MAX = 10_000;
|
|
27
|
+
const SAMPLE_MAX_LABEL = SAMPLE_MAX.toLocaleString('en-US');
|
|
17
28
|
/** What `meta.count` counts, in the rendered header. */
|
|
18
29
|
const RESULT_NOUN = 'result(s)';
|
|
19
30
|
const canonicalFilters = z.record(z.string(), z.string());
|
|
@@ -45,8 +56,8 @@ const filterStringConversions = createCounter('openalex.filter_string.conversion
|
|
|
45
56
|
* answers an entity-by-ID URL identically whether or not search params ride along.
|
|
46
57
|
*
|
|
47
58
|
* Pagination (`per_page`, `cursor`, `page`) is deliberately absent: a singleton lookup is one
|
|
48
|
-
* record by definition, and
|
|
49
|
-
*
|
|
59
|
+
* record by definition, so page size and position have nothing to apply to. Naming them would be
|
|
60
|
+
* noise, not a warning.
|
|
50
61
|
*/
|
|
51
62
|
function searchOnlyParams(input) {
|
|
52
63
|
const parts = [];
|
|
@@ -72,7 +83,8 @@ function searchOnlyParams(input) {
|
|
|
72
83
|
* Echo the criteria that actually ran. An `id` lookup echoes `entity_type` and `id` only —
|
|
73
84
|
* echoing a filter the singleton path never applied reads as confirmation that it did, which
|
|
74
85
|
* is the one thing this field exists not to do. Caller text is JSON-encoded, so a line break in
|
|
75
|
-
* it cannot start a heading or end the line the echo renders on.
|
|
86
|
+
* it cannot start a heading or end the line the echo renders on. Raw, as the `echo` field
|
|
87
|
+
* carries it; a notice repeating it escapes it for Markdown.
|
|
76
88
|
*/
|
|
77
89
|
function buildSearchEcho(input) {
|
|
78
90
|
const parts = [`entity_type=${input.entity_type}`];
|
|
@@ -84,16 +96,24 @@ function buildSearchEcho(input) {
|
|
|
84
96
|
* Is this call continuing a paginated traversal rather than opening one? A `cursor` is the
|
|
85
97
|
* keyword/exact signal — the schema rejects a blank one, so its presence is always a real
|
|
86
98
|
* continuation rather than a mistake OpenAlex answers with page 1; a `page` past the first is
|
|
87
|
-
* the semantic
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
99
|
+
* the signal for semantic search and a seeded sample, the only listings `page` survives the
|
|
100
|
+
* handler's preconditions on. Only on a continuing call does a page of zero results mean the
|
|
101
|
+
* traversal is finished — on a first call it means nothing matched, and the two want opposite
|
|
102
|
+
* advice.
|
|
91
103
|
*/
|
|
92
104
|
function isContinuationPage(input) {
|
|
93
105
|
return input.cursor !== undefined || (input.page ?? 1) > 1;
|
|
94
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether records remain past this page of a `page`-walked listing that reaches `reachable`
|
|
109
|
+
* records — semantic candidates, or a sample's draw. A pointer to the next page appears only
|
|
110
|
+
* then: an exactly full last page has none to point at. (gh #126)
|
|
111
|
+
*/
|
|
112
|
+
function hasNextPage(page, perPage, reachable) {
|
|
113
|
+
return page * perPage < reachable;
|
|
114
|
+
}
|
|
95
115
|
export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
96
|
-
description: 'Search, filter, sort, or retrieve by ID. Covers all OpenAlex entity types (works, authors, sources, institutions, topics, keywords, publishers, funders). Pass `id` to retrieve a single entity. Otherwise, use `query` and/or `filters` for discovery. Filters take a record object; an unambiguous native string such as "issn:0920-8542" is also accepted when enabled and disclosed in `notice`. Native clauses split on commas outside paired double quotes and the first colon; duplicate keys and bare fragments are rejected. Any filter value sent unquoted must have paired double quotes; balanced search phrases remain valid. Supports keyword search with boolean operators, exact phrase matching,
|
|
116
|
+
description: 'Search, filter, sort, or retrieve by ID. Covers all OpenAlex entity types (works, authors, sources, institutions, topics, keywords, publishers, funders, awards). Pass `id` to retrieve a single entity. Otherwise, use `query` and/or `filters` for discovery. Filters take a record object; an unambiguous native string such as "issn:0920-8542" is also accepted when enabled and disclosed in `notice`. Native clauses split on commas outside paired double quotes and the first colon; duplicate keys and bare fragments are rejected. Any filter value sent unquoted must have paired double quotes; balanced search phrases remain valid. Supports keyword search with boolean operators, exact phrase matching, AI semantic search, and seeded random samples. Use openalex_resolve_name to resolve names to IDs before filtering. Searches and ID lookups return a curated set of fields by default; pass `select` to override with specific fields, or `["*"]` for the full record. A page holds `per_page` records and `next_cursor` continues it — semantic search and a seeded `sample` continue with `page` instead; an `id` lookup returns every selected array whole, and `slice` pages one array by count.',
|
|
97
117
|
sourceUrl: 'https://github.com/cyanheads/openalex-mcp-server/blob/main/src/mcp-server/tools/definitions/search-entities.tool.ts',
|
|
98
118
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
99
119
|
errors: [
|
|
@@ -121,9 +141,15 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
121
141
|
{
|
|
122
142
|
reason: 'semantic_per_page_cap',
|
|
123
143
|
code: JsonRpcErrorCode.ValidationError,
|
|
124
|
-
when: `A search (no \`id\`) set per_page above
|
|
144
|
+
when: `A semantic search (no \`id\`) set per_page above its cap of ${SEMANTIC_PER_PAGE_CAP} on a server with OPENALEX_CLAMP_PER_PAGE=false; with clamping on (the default), it returns ${SEMANTIC_PER_PAGE_CAP} and says so.`,
|
|
125
145
|
recovery: `Reduce per_page to ${SEMANTIC_PER_PAGE_CAP} or less, or switch search_mode to keyword.`,
|
|
126
146
|
},
|
|
147
|
+
{
|
|
148
|
+
reason: 'response_too_large',
|
|
149
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
150
|
+
when: "The response's structuredContent or content[] text would exceed 8,000,000 bytes — a wide `select` on a large page, or a record with very long arrays. A page is never shortened to fit.",
|
|
151
|
+
recovery: 'Rerun with the per_page or slice.limit the error names when it names one, or narrow `select` to the fields you need; for a single oversized record, page its largest array with `slice`.',
|
|
152
|
+
},
|
|
127
153
|
{
|
|
128
154
|
reason: 'semantic_without_query',
|
|
129
155
|
code: JsonRpcErrorCode.ValidationError,
|
|
@@ -139,20 +165,26 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
139
165
|
{
|
|
140
166
|
reason: 'page_without_semantic',
|
|
141
167
|
code: JsonRpcErrorCode.ValidationError,
|
|
142
|
-
when: 'A search supplied `page`
|
|
143
|
-
recovery: 'Drop `page` and paginate with `cursor`,
|
|
168
|
+
when: 'A search supplied `page` with neither search_mode "semantic" nor `sample`.',
|
|
169
|
+
recovery: 'Drop `page` and paginate with `cursor`, set search_mode to "semantic", or pass `sample` with a `seed` — the only searches `page` walks.',
|
|
144
170
|
},
|
|
145
171
|
{
|
|
146
172
|
reason: 'sample_with_cursor',
|
|
147
173
|
code: JsonRpcErrorCode.ValidationError,
|
|
148
174
|
when: 'A search (no `id`) provided both `sample` and `cursor`.',
|
|
149
|
-
recovery: '
|
|
175
|
+
recovery: 'A sample walks its draw with `page` and a `seed`, not `cursor`; remove `cursor`, or remove `sample`.',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
reason: 'sample_page_without_seed',
|
|
179
|
+
code: JsonRpcErrorCode.ValidationError,
|
|
180
|
+
when: 'A search (no `id`) provided `page` with `sample` but no `seed`, which OpenAlex refuses: an unseeded sample redraws on every call, so its pages would overlap.',
|
|
181
|
+
recovery: 'Pass `seed` to page a sample — an unseeded sample redraws on every call — or drop `page`.',
|
|
150
182
|
},
|
|
151
183
|
{
|
|
152
|
-
reason: '
|
|
184
|
+
reason: 'sample_page_too_deep',
|
|
153
185
|
code: JsonRpcErrorCode.ValidationError,
|
|
154
|
-
when:
|
|
155
|
-
recovery:
|
|
186
|
+
when: `A sample page reaches past record ${SAMPLE_MAX_LABEL} (\`page\` × \`per_page\` above ${SAMPLE_MAX_LABEL}), the most of a sample OpenAlex serves by page number.`,
|
|
187
|
+
recovery: `Keep \`page\` × \`per_page\` at or under ${SAMPLE_MAX_LABEL}, with a per_page that divides ${SAMPLE_MAX_LABEL} — 25, 50, or 100 — so the last page ends on record ${SAMPLE_MAX_LABEL}; at per_page 100 that is page 100.`,
|
|
156
188
|
},
|
|
157
189
|
{
|
|
158
190
|
reason: 'sample_with_semantic',
|
|
@@ -188,7 +220,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
188
220
|
reason: 'id_type_mismatch',
|
|
189
221
|
code: JsonRpcErrorCode.ValidationError,
|
|
190
222
|
when: '`id` is a native OpenAlex ID whose letter names another entity type than `entity_type`, or a concept ID (C…), a type this server does not serve.',
|
|
191
|
-
recovery: 'Pass the entity_type the ID letter names — W works, A authors, S sources, I institutions, T topics, P publishers, F funders. A concept ID (C…) has no entity_type here; search topics for the concept by name with openalex_resolve_name instead.',
|
|
223
|
+
recovery: 'Pass the entity_type the ID letter names — W works, A authors, S sources, I institutions, T topics, P publishers, F funders, G awards. A concept ID (C…) has no entity_type here; search topics for the concept by name with openalex_resolve_name instead.',
|
|
192
224
|
thrownBy: 'service',
|
|
193
225
|
},
|
|
194
226
|
{
|
|
@@ -337,7 +369,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
337
369
|
.string()
|
|
338
370
|
.min(1)
|
|
339
371
|
.optional()
|
|
340
|
-
.describe('Retrieve a single entity by ID. Supports: OpenAlex ID ("W2741809807", or its OpenAlex record-page or API URL such as "https://openalex.org/works/W2741809807" or "https://api.openalex.org/works/W2741809807"), whose letter must name `entity_type` — W works, A authors, S sources, I institutions, T topics, P publishers, F funders; DOI ("10.1038/nature12373"; under entity_type "funders", a funder\'s Crossref Funder Registry DOI such as "10.13039/100000001"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361" — one ROR can name an institution, a funder, and a publisher, and `entity_type` picks which), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678"). Keywords are identified by slug rather than a native ID — pass either the slug ("groundwater") or the URL a search returns ("https://openalex.org/keywords/groundwater"). A PMCID is recognized too, bare ("PMC1234567") or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work\'s PMID or DOI instead. When provided, `query`, `search_mode`, `filters`, `sort`, `sample`, and `seed` are not applied — the returned record is the entity at that ID regardless of them, and the response `notice` names any you passed. `select` still applies: the curated per-entity-type default is returned unless you pass `select` (use `["*"]` for the complete record).
|
|
372
|
+
.describe('Retrieve a single entity by ID. Supports: OpenAlex ID ("W2741809807", or its OpenAlex record-page or API URL such as "https://openalex.org/works/W2741809807" or "https://api.openalex.org/works/W2741809807"), whose letter must name `entity_type` — W works, A authors, S sources, I institutions, T topics, P publishers, F funders, G awards; DOI ("10.1038/nature12373"; under entity_type "funders", a funder\'s Crossref Funder Registry DOI such as "10.13039/100000001"), ORCID ("0000-0002-1825-0097"), ROR ("https://ror.org/00hx57361" — one ROR can name an institution, a funder, and a publisher, and `entity_type` picks which), PMID ("12345678" or "https://pubmed.ncbi.nlm.nih.gov/12345678"), ISSN ("1234-5678"). Keywords are identified by slug rather than a native ID — pass either the slug ("groundwater") or the URL a search returns ("https://openalex.org/keywords/groundwater"). A PMCID is recognized too, bare ("PMC1234567") or as a PubMed Central URL, but OpenAlex indexes no PMCIDs, so it resolves nothing — pass the work\'s PMID or DOI instead. When provided, `query`, `search_mode`, `filters`, `sort`, `sample`, and `seed` are not applied — the returned record is the entity at that ID regardless of them, and the response `notice` names any you passed. `select` still applies: the curated per-entity-type default is returned unless you pass `select` (use `["*"]` for the complete record). Every selected array returns whole; `slice` pages a long one by count. To filter, drop `id` and search. Use openalex_resolve_name to find the ID if unknown.'),
|
|
341
373
|
query: z
|
|
342
374
|
.string()
|
|
343
375
|
.min(1)
|
|
@@ -357,10 +389,10 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
357
389
|
select: z
|
|
358
390
|
.array(z.string())
|
|
359
391
|
.optional()
|
|
360
|
-
.describe('OpenAlex top-level field names to return. Always returned: `id`, `display_name` — additional fields you list are appended. A curated default per entity type applies to both searches and single-entity (`id`) lookups; pass field names to override it, or `["*"]` to retrieve the complete record (every field). Only top-level fields project, so a nested value is requested by its parent object: bibliometrics (`h_index`, `i10_index`, `2yr_mean_citedness`) live under `summary_stats` on authors, sources, institutions, publishers, and funders, and naming a leaf returns that object. On sources, `publisher` returns `host_organization_name`: the publisher of a journal, the hosting institution of a repository, and null on most sources. On works, `author_list` returns each author in authorship order as `{id, display_name, orcid}` (`orcid` null when absent), a compact alternative to `authorships`, which also carries institutions and affiliations; neither the default nor `["*"]` includes it. Invalid field names produce an error identifying the rejected field.
|
|
361
|
-
per_page: pageSizeSchema(
|
|
362
|
-
.
|
|
363
|
-
.describe(
|
|
392
|
+
.describe('OpenAlex top-level field names to return. Always returned: `id`, `display_name` — additional fields you list are appended. A curated default per entity type applies to both searches and single-entity (`id`) lookups; pass field names to override it, or `["*"]` to retrieve the complete record (every field). Only top-level fields project, so a nested value is requested by its parent object: bibliometrics (`h_index`, `i10_index`, `2yr_mean_citedness`) live under `summary_stats` on authors, sources, institutions, publishers, and funders, and naming a leaf returns that object. On sources, `publisher` returns `host_organization_name`: the publisher of a journal, the hosting institution of a repository, and null on most sources. On works, `author_list` returns each author in authorship order as `{id, display_name, orcid}` (`orcid` null when absent), a compact alternative to `authorships`, which also carries institutions and affiliations; neither the default nor `["*"]` includes it. Invalid field names produce an error identifying the rejected field. A wide projection on a large page makes a large response, so name the fields you need. Not applied with `slice`. Example: ["doi", "author_list", "primary_topic"].'),
|
|
393
|
+
per_page: pageSizeSchema(PER_PAGE_CAP)
|
|
394
|
+
.optional()
|
|
395
|
+
.describe(`Results per page. Default ${PER_PAGE_DEFAULT}, or under \`sample\` the sample size up to ${PER_PAGE_CAP}, so a sample of ${PER_PAGE_CAP} or fewer returns whole in one call. A larger value returns at most ${PER_PAGE_CAP} and says so — at most ${SEMANTIC_PER_PAGE_CAP} under search_mode "semantic", which OpenAlex also limits to about 1 request per second. A server set to OPENALEX_CLAMP_PER_PAGE=false rejects a value over either cap instead. An \`id\` lookup ignores page size.`),
|
|
364
396
|
cursor: z
|
|
365
397
|
.string()
|
|
366
398
|
.min(1)
|
|
@@ -371,18 +403,14 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
371
403
|
.int()
|
|
372
404
|
.min(1)
|
|
373
405
|
.optional()
|
|
374
|
-
.describe(
|
|
375
|
-
sample:
|
|
376
|
-
.number()
|
|
377
|
-
.int()
|
|
378
|
-
.min(1)
|
|
379
|
-
.max(SAMPLE_MAX)
|
|
406
|
+
.describe(`Page number (1-based) for semantic search and for a seeded \`sample\`, the two searches that paginate with \`page\` instead of \`cursor\`. Semantic search ranks a query-dependent candidate set whose size \`meta.count\` reports, so the last reachable page is ceil(meta.count / per_page) — e.g. page 14 for a count of 70 with per_page=5. A sample draws min(sample, meta.count) records, so its last page is ceil(min(sample, meta.count) / per_page); paging a sample requires \`seed\`, and OpenAlex serves only its first ${SAMPLE_MAX_LABEL} records, so a page × per_page above ${SAMPLE_MAX_LABEL} is rejected. Passing it with any other search is rejected.`),
|
|
407
|
+
sample: countSchema(1, SAMPLE_MAX)
|
|
380
408
|
.optional()
|
|
381
|
-
.describe(`Return a random sample of this many entities matching the filters
|
|
409
|
+
.describe(`Return a random sample of this many entities matching the filters, up to ${SAMPLE_MAX_LABEL}; a larger value draws ${SAMPLE_MAX_LABEL} and says so (rejected instead on a server set to OPENALEX_CLAMP_PER_PAGE=false). The sample comes back \`per_page\` records at a time — by default the whole sample up to ${PER_PAGE_CAP} — and a larger one is walked with \`page\` and a \`seed\`, which fixes the draw; without \`seed\` only its first page is returned. \`meta.count\` stays the population matching the filters. Keyword and exact modes only: OpenAlex does not sample a semantic search, so \`sample\` with search_mode "semantic" is rejected. Cannot be combined with \`cursor\` or \`sort\` — a sample has no order, and a search passing either is rejected. Useful for unbiased exploration: spot-checking filter correctness, stratified review prompts, or generating exploration sets without bias toward most-cited.`),
|
|
382
410
|
seed: z
|
|
383
411
|
.string()
|
|
384
412
|
.optional()
|
|
385
|
-
.describe('Deterministic seed for `sample`. Same seed + same filters = same
|
|
413
|
+
.describe('Deterministic seed for `sample`. Same seed + same filters = the same draw, page for page — required to walk a sample larger than one page with `page`, and worth passing whenever reproducibility matters. Has no effect without `sample`, and a search that passes it alone is rejected.'),
|
|
386
414
|
slice: z
|
|
387
415
|
.object({
|
|
388
416
|
field: z
|
|
@@ -394,23 +422,26 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
394
422
|
.min(0)
|
|
395
423
|
.default(0)
|
|
396
424
|
.describe('0-based index of the first element to return.'),
|
|
425
|
+
limit: countSchema(1, SLICE_LIMIT_CAP)
|
|
426
|
+
.default(SLICE_LIMIT_DEFAULT)
|
|
427
|
+
.describe(`Elements to return. Default ${SLICE_LIMIT_DEFAULT}; a larger value returns at most ${SLICE_LIMIT_CAP.toLocaleString('en-US')} and says so (rejected instead on a server set to OPENALEX_CLAMP_PER_PAGE=false).`),
|
|
397
428
|
})
|
|
398
429
|
.optional()
|
|
399
|
-
.describe('Page one array of the record at `id`: returns `id`, `display_name`, and `field` from `offset`
|
|
430
|
+
.describe('Page one array of the record at `id`: returns `id`, `display_name`, and `limit` elements of `field` from `offset` — fewer only at the array end — with the window in `windows`, whose `next` call repeats `limit` from the following offset. Requires `id`; replaces `select`. An offset at or past the end returns an empty window. A 100-author list cap can be continued on the singleton; at 5,000 stored authors the byline remains possibly capped, but `next` is null once stored entries are exhausted.'),
|
|
400
431
|
}),
|
|
401
432
|
output: z.object({
|
|
402
433
|
meta: z
|
|
403
434
|
.object({
|
|
404
435
|
count: z
|
|
405
436
|
.number()
|
|
406
|
-
.describe('Total results matching the query/filters. Under search_mode "semantic" it is instead the size of the ranked candidate set — the most results `page` can reach — not an exhaustive match total.'),
|
|
437
|
+
.describe('Total results matching the query/filters — under `sample` too, whose draw holds min(sample, count) records. Under search_mode "semantic" it is instead the size of the ranked candidate set — the most results `page` can reach — not an exhaustive match total.'),
|
|
407
438
|
per_page: z
|
|
408
439
|
.number()
|
|
409
|
-
.describe('Page size OpenAlex echoed for this request — the applied per_page after clamping or
|
|
440
|
+
.describe('Page size OpenAlex echoed for this request — the applied per_page after clamping or its default, not the number of records returned. A singleton reports 1. A short or exhausted page carries fewer records than this.'),
|
|
410
441
|
next_cursor: z
|
|
411
442
|
.string()
|
|
412
443
|
.nullable()
|
|
413
|
-
.describe('Cursor for next page
|
|
444
|
+
.describe('Cursor for the next page; pass it back unchanged as `cursor`. null when no further page exists — always under semantic search and `sample`, which continue with `page` — except on a final page that exactly fills `per_page`: OpenAlex still returns a cursor there, and it leads to an empty page.'),
|
|
414
445
|
})
|
|
415
446
|
.describe('Result metadata including pagination.'),
|
|
416
447
|
results: z
|
|
@@ -418,7 +449,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
418
449
|
.object({
|
|
419
450
|
id: z
|
|
420
451
|
.string()
|
|
421
|
-
.describe('OpenAlex ID URL (e.g., "https://openalex.org/W2741809807"). `windows`
|
|
452
|
+
.describe('OpenAlex ID URL (e.g., "https://openalex.org/W2741809807"). `windows` names records by the bare ID ("W2741809807"); a keyword keeps its URL.'),
|
|
422
453
|
display_name: z
|
|
423
454
|
.string()
|
|
424
455
|
.nullable()
|
|
@@ -426,10 +457,8 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
426
457
|
})
|
|
427
458
|
.loose()
|
|
428
459
|
.describe('A single OpenAlex entity record. `id` is always present and `display_name` is always returned (though it may be null); additional fields vary by entity_type and `select`.'))
|
|
429
|
-
.describe('OpenAlex entity objects. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — except identifier and URL fields such as `id`, `doi`, `ids`, and `*_url`, returned exactly as OpenAlex stores them; an abstract arrives reconstructed as `abstract`, and a selected `author_list` as one `{id, display_name, orcid}` entry per author. Additional fields depend on entity_type and select.
|
|
430
|
-
omitted: omittedOutputSchema,
|
|
460
|
+
.describe('OpenAlex entity objects. Text values are plain text — HTML entities decoded, HTML/JATS/MathML markup removed — except identifier and URL fields such as `id`, `doi`, `ids`, and `*_url`, returned exactly as OpenAlex stores them; an abstract arrives reconstructed as `abstract`, and a selected `author_list` as one `{id, display_name, orcid}` entry per author. Additional fields depend on entity_type and select. Possibly capped author arrays and `slice` windows are in `windows`.'),
|
|
431
461
|
windows: windowsOutputSchema,
|
|
432
|
-
over_budget: overBudgetOutputSchema,
|
|
433
462
|
}),
|
|
434
463
|
// Agent-facing context for the success path — the query/filters as parsed, the
|
|
435
464
|
// total match count, and recovery guidance for empty results. Populated via
|
|
@@ -442,7 +471,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
442
471
|
notice: z
|
|
443
472
|
.string()
|
|
444
473
|
.optional()
|
|
445
|
-
.describe('Guidance notice. Set when per_page was clamped (requested and applied sizes), a native filter string was converted, a first call has no matches, a continuation is exhausted, an id lookup ignores search criteria, semantic search reports a candidate total
|
|
474
|
+
.describe('Guidance notice. Set when per_page, sample, or slice.limit was clamped (requested and applied sizes), a native filter string was converted, a first call has no matches, a continuation is exhausted, an id lookup ignores search criteria, semantic search reports a candidate total and whether a later `page` holds more, a sample draws more than one page (the next `page`, its last page, or that `seed` makes it pageable), an author array is possibly capped (100 list entries or 5,000 stored singleton entries), a slice offset is past the end, or slice replaces select. Absent otherwise.'),
|
|
446
475
|
budget: z
|
|
447
476
|
.object({
|
|
448
477
|
costUsd: z
|
|
@@ -489,6 +518,9 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
489
518
|
* to each other fails it on constraints it never met. The dropped-parameter notice below
|
|
490
519
|
* reports them instead. The `id` predicate mirrors the service's own truthiness branch.
|
|
491
520
|
*/
|
|
521
|
+
// `per_page` as the caller set it, or its default: the whole sample up to a page, else 25.
|
|
522
|
+
const requestedPerPage = input.per_page ??
|
|
523
|
+
(input.sample !== undefined ? Math.min(input.sample, PER_PAGE_CAP) : PER_PAGE_DEFAULT);
|
|
492
524
|
if (!input.id) {
|
|
493
525
|
// Semantic search matches an embedding of the query text, so with no query there is
|
|
494
526
|
// nothing to embed — the request silently degraded to a plain unfiltered listing whose
|
|
@@ -496,32 +528,33 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
496
528
|
if (input.search_mode === 'semantic' && !input.query) {
|
|
497
529
|
throw ctx.fail('semantic_without_query', 'Semantic search needs `query` text to embed — none was supplied.', { searchMode: input.search_mode });
|
|
498
530
|
}
|
|
499
|
-
|
|
500
|
-
|
|
531
|
+
// With clamping on, an over-cap semantic page size clamps below instead. A semantic sample
|
|
532
|
+
// is refused below as `sample_with_semantic` whatever its page size, which the sample sets.
|
|
533
|
+
if (input.search_mode === 'semantic' &&
|
|
534
|
+
input.sample === undefined &&
|
|
535
|
+
requestedPerPage > SEMANTIC_PER_PAGE_CAP &&
|
|
536
|
+
!getServerConfig().clampPerPage) {
|
|
537
|
+
throw ctx.fail('semantic_per_page_cap', `Semantic search supports at most ${SEMANTIC_PER_PAGE_CAP} results per page. Reduce per_page or switch search_mode.`, { searchMode: input.search_mode, perPage: requestedPerPage, cap: SEMANTIC_PER_PAGE_CAP });
|
|
501
538
|
}
|
|
502
539
|
// OpenAlex answers `search.semantic` plus a cursor with a 400 ("Cursor pagination is not
|
|
503
540
|
// supported with semantic search"), so forwarding it spends a round trip to learn that.
|
|
504
541
|
if (input.search_mode === 'semantic' && input.cursor !== undefined) {
|
|
505
542
|
throw ctx.fail('semantic_with_cursor', 'Semantic search does not accept `cursor` — walk its candidates with `page` (1-based) instead.', { searchMode: input.search_mode, cursor: input.cursor });
|
|
506
543
|
}
|
|
507
|
-
// The inverse: `page`
|
|
508
|
-
// traversal — silently returning page 1 again rather than the page
|
|
509
|
-
if (input.page !== undefined &&
|
|
510
|
-
|
|
544
|
+
// The inverse: `page` walks semantic candidates and samples only, and OpenAlex ignores it on
|
|
545
|
+
// a cursor traversal — silently returning page 1 again rather than the page asked for.
|
|
546
|
+
if (input.page !== undefined &&
|
|
547
|
+
input.search_mode !== 'semantic' &&
|
|
548
|
+
input.sample === undefined) {
|
|
549
|
+
throw ctx.fail('page_without_semantic', `\`page\` applies to semantic search and samples only — search_mode is "${input.search_mode}" with no \`sample\`, which paginates with \`cursor\`.`, { searchMode: input.search_mode, page: input.page });
|
|
511
550
|
}
|
|
512
551
|
if (input.sample !== undefined && input.cursor !== undefined) {
|
|
513
|
-
throw ctx.fail('sample_with_cursor', '
|
|
514
|
-
}
|
|
515
|
-
// Sampling pins `per_page` to `sample` and returns that one page, so there is no second
|
|
516
|
-
// page for `page` to address — the same exclusion `cursor` has above. Reachable under
|
|
517
|
-
// semantic mode alone; every other mode rejects `page` at the check before this one.
|
|
518
|
-
if (input.sample !== undefined && input.page !== undefined) {
|
|
519
|
-
throw ctx.fail('sample_with_page', 'Sampling returns one page only — `sample` cannot be combined with `page` pagination.', { sample: input.sample, page: input.page });
|
|
552
|
+
throw ctx.fail('sample_with_cursor', '`sample` cannot be combined with `cursor` — a seeded sample pages with `page`.', { sample: input.sample, cursor: input.cursor });
|
|
520
553
|
}
|
|
521
554
|
// OpenAlex ignores `sample` and `seed` under `search.semantic` and returns the ranked page,
|
|
522
555
|
// so forwarding the pair would label a relevance ranking as a random sample. The population
|
|
523
556
|
// request would also carry `cursor=*` beside `search.semantic`, which upstream rejects.
|
|
524
|
-
// Checked
|
|
557
|
+
// Checked ahead of the sample-paging checks below: a semantic sample is wrong at any page.
|
|
525
558
|
if (input.sample !== undefined && input.search_mode === 'semantic') {
|
|
526
559
|
throw ctx.fail('sample_with_semantic', 'Semantic search does not sample — OpenAlex returns the same ranked candidates under every seed.', { sample: input.sample, searchMode: input.search_mode });
|
|
527
560
|
}
|
|
@@ -533,10 +566,49 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
533
566
|
if (input.seed !== undefined && input.sample === undefined) {
|
|
534
567
|
throw ctx.fail('seed_without_sample', '`seed` is only meaningful with `sample` — pass `sample` to enable random sampling.', { seed: input.seed });
|
|
535
568
|
}
|
|
569
|
+
// OpenAlex refuses `page` on an unseeded sample (HTTP 400): each call redraws it.
|
|
570
|
+
if (input.sample !== undefined && input.page !== undefined && input.seed === undefined) {
|
|
571
|
+
throw ctx.fail('sample_page_without_seed', '`page` walks a sample only with `seed` — an unseeded sample redraws on every call, so its pages would overlap.', { sample: input.sample, page: input.page });
|
|
572
|
+
}
|
|
573
|
+
// OpenAlex serves the first 10,000 records of a sample by page number and answers a page
|
|
574
|
+
// past them with a 400, even inside a smaller draw. Measured at the per_page the call
|
|
575
|
+
// will send, which a clamp can only lower.
|
|
576
|
+
const sentPerPage = Math.min(requestedPerPage, PER_PAGE_CAP);
|
|
577
|
+
if (input.sample !== undefined &&
|
|
578
|
+
input.page !== undefined &&
|
|
579
|
+
input.page * sentPerPage > SAMPLE_MAX) {
|
|
580
|
+
const last = input.page * sentPerPage;
|
|
581
|
+
throw ctx.fail('sample_page_too_deep', `Page ${input.page} at per_page ${sentPerPage} reaches record ${last.toLocaleString('en-US')}, past the first ${SAMPLE_MAX_LABEL} records OpenAlex serves from a sample.`, { sample: input.sample, page: input.page, perPage: sentPerPage, maxRecords: SAMPLE_MAX });
|
|
582
|
+
}
|
|
536
583
|
}
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
584
|
+
const semantic = input.search_mode === 'semantic';
|
|
585
|
+
// An `id` lookup draws no sample, so its `sample` is neither clamped nor disclosed.
|
|
586
|
+
const sample = input.id || input.sample === undefined
|
|
587
|
+
? undefined
|
|
588
|
+
: clampCount({
|
|
589
|
+
input: 'sample',
|
|
590
|
+
requested: input.sample,
|
|
591
|
+
maximum: SAMPLE_MAX,
|
|
592
|
+
tool: 'openalex_search_entities',
|
|
593
|
+
}, ctx);
|
|
594
|
+
const appliedSample = sample?.applied ?? input.sample;
|
|
595
|
+
const pageSize = input.id
|
|
596
|
+
? { applied: requestedPerPage, notice: undefined }
|
|
597
|
+
: clampCount({
|
|
598
|
+
input: 'per_page',
|
|
599
|
+
requested: requestedPerPage,
|
|
600
|
+
maximum: semantic ? SEMANTIC_PER_PAGE_CAP : PER_PAGE_CAP,
|
|
601
|
+
...(semantic && { maximumLabel: 'semantic-search maximum' }),
|
|
602
|
+
tool: 'openalex_search_entities',
|
|
603
|
+
}, ctx);
|
|
604
|
+
const sliceLimit = slice
|
|
605
|
+
? clampCount({
|
|
606
|
+
input: 'slice.limit',
|
|
607
|
+
requested: slice.limit,
|
|
608
|
+
maximum: SLICE_LIMIT_CAP,
|
|
609
|
+
tool: 'openalex_search_entities',
|
|
610
|
+
}, ctx)
|
|
611
|
+
: undefined;
|
|
540
612
|
const service = getOpenAlexService();
|
|
541
613
|
const result = await service.search({
|
|
542
614
|
entityType: input.entity_type,
|
|
@@ -549,7 +621,7 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
549
621
|
perPage: pageSize.applied,
|
|
550
622
|
cursor: input.cursor,
|
|
551
623
|
page: input.page,
|
|
552
|
-
sample:
|
|
624
|
+
sample: appliedSample,
|
|
553
625
|
seed: input.seed,
|
|
554
626
|
}, ctx);
|
|
555
627
|
ctx.log.info('Search completed', {
|
|
@@ -559,11 +631,15 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
559
631
|
resultCount: result.results.length,
|
|
560
632
|
totalCount: result.meta.count,
|
|
561
633
|
});
|
|
562
|
-
const echo = buildSearchEcho(input);
|
|
634
|
+
const echo = buildSearchEcho({ ...input, sample: appliedSample });
|
|
563
635
|
ctx.enrich({ echo, totalCount: result.meta.count });
|
|
564
636
|
const notices = [];
|
|
637
|
+
if (sample?.notice)
|
|
638
|
+
notices.push(sample.notice);
|
|
565
639
|
if (pageSize.notice)
|
|
566
640
|
notices.push(pageSize.notice);
|
|
641
|
+
if (sliceLimit?.notice)
|
|
642
|
+
notices.push(sliceLimit.notice);
|
|
567
643
|
if (normalized?.converted) {
|
|
568
644
|
notices.push('Converted native filter string to a filters record object.');
|
|
569
645
|
}
|
|
@@ -576,33 +652,64 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
576
652
|
notices.push(`\`id\` takes precedence — ${ignored.join(', ')} ${verb} not applied, and the record below is the entity at that ID regardless of ${pronoun}. Drop \`id\` to run ${pronoun} as a search.`);
|
|
577
653
|
}
|
|
578
654
|
else if (result.results.length === 0) {
|
|
655
|
+
// A notice is authored Markdown, so the caller text the echo repeats is escaped in it.
|
|
656
|
+
const criteria = escapeMarkdown(echo);
|
|
579
657
|
notices.push(isContinuationPage(input)
|
|
580
|
-
? `Pagination exhausted for ${
|
|
581
|
-
: `No matches for ${
|
|
658
|
+
? `Pagination exhausted for ${criteria} — the previous page was the last, so this one came back empty. Stop paging rather than broadening the search.`
|
|
659
|
+
: `No matches for ${criteria}. Try broadening the query, removing filters, or switching search_mode.`);
|
|
582
660
|
}
|
|
661
|
+
/**
|
|
662
|
+
* Semantic search and samples carry no `next_cursor`, so these notices are their only
|
|
663
|
+
* continuation signal. The empty page past the last carries the exhausted advice above alone.
|
|
664
|
+
*/
|
|
665
|
+
const page = input.page ?? 1;
|
|
666
|
+
const exhausted = result.results.length === 0 && isContinuationPage(input);
|
|
583
667
|
// `meta.count` reports the ranked candidate set under semantic search, not the population
|
|
584
668
|
// matching the query. Its size is query-dependent (OpenAlex serves 70 for some queries,
|
|
585
669
|
// 50 for others), so the notice states the count it got rather than a fixed ceiling.
|
|
586
|
-
if (!input.id &&
|
|
587
|
-
|
|
670
|
+
if (!input.id && semantic && !exhausted) {
|
|
671
|
+
const continuation = hasNextPage(page, pageSize.applied, result.meta.count)
|
|
672
|
+
? ` Continue with \`page: ${page + 1}\` for the rest of the candidates.`
|
|
673
|
+
: result.results.length > 0
|
|
674
|
+
? ' The candidate set ends on this page.'
|
|
675
|
+
: '';
|
|
676
|
+
notices.push(`Semantic search ranks a candidate set rather than every match, so \`meta.count\` (${result.meta.count}) is the number of candidates \`page\` can reach, not the total number of matching records.${continuation}`);
|
|
677
|
+
}
|
|
678
|
+
// `meta.count` stays the population under `sample`; the draw is the smaller of the two. A
|
|
679
|
+
// draw that fits one page needs no notice — the page holds all of it.
|
|
680
|
+
const drawn = sample ? Math.min(sample.applied, result.meta.count) : 0;
|
|
681
|
+
if (result.results.length > 0 && drawn > pageSize.applied) {
|
|
682
|
+
const size = `${drawn.toLocaleString('en-US')} records`;
|
|
683
|
+
// OpenAlex serves no sample page reaching past record 10,000, so at a per_page that does not
|
|
684
|
+
// divide 10,000 the records after its last whole page are out of reach at that size.
|
|
685
|
+
const reachable = Math.min(drawn, Math.floor(SAMPLE_MAX / pageSize.applied) * pageSize.applied);
|
|
686
|
+
notices.push(input.seed === undefined
|
|
687
|
+
? `This sample draws ${size}, more than one page of ${pageSize.applied}, so only its first page is returned. Pass a \`seed\` to fix the draw and walk it with \`page\` — an unseeded sample redraws on every call.`
|
|
688
|
+
: hasNextPage(page, pageSize.applied, reachable)
|
|
689
|
+
? `This seeded sample draws ${size}, ${pageSize.applied} per page; continue with \`page: ${page + 1}\` and the same \`seed\`.`
|
|
690
|
+
: reachable < drawn
|
|
691
|
+
? `This seeded sample draws ${size}, ${pageSize.applied} per page, and this is the last page OpenAlex serves at that size. Records ${(reachable + 1).toLocaleString('en-US')}–${drawn.toLocaleString('en-US')} are reachable only at a per_page that divides ${SAMPLE_MAX_LABEL}, such as 25, 50, or 100.`
|
|
692
|
+
: `This seeded sample draws ${size}, ${pageSize.applied} per page; the draw ends on this page.`);
|
|
588
693
|
}
|
|
589
694
|
if (slice && input.select) {
|
|
590
695
|
notices.push('`slice` returns only `id`, `display_name`, and the sliced field, so `select` was not applied.');
|
|
591
696
|
}
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
697
|
+
const context = {
|
|
698
|
+
entityType: input.entity_type,
|
|
699
|
+
meta: {
|
|
700
|
+
count: result.meta.count,
|
|
701
|
+
per_page: result.meta.per_page,
|
|
702
|
+
next_cursor: result.meta.next_cursor,
|
|
703
|
+
},
|
|
704
|
+
noun: RESULT_NOUN,
|
|
598
705
|
echo,
|
|
599
706
|
totalCount: result.meta.count,
|
|
600
707
|
totalLabel: 'Total',
|
|
601
|
-
|
|
602
|
-
}
|
|
603
|
-
let
|
|
708
|
+
notices,
|
|
709
|
+
};
|
|
710
|
+
let outcome;
|
|
604
711
|
// A slice without an `id` was refused as `slice_without_id`, so `input.id` only narrows here.
|
|
605
|
-
if (slice && input.id) {
|
|
712
|
+
if (slice && sliceLimit && input.id) {
|
|
606
713
|
// The one-field projection lands under its record key — `authors` arrives as `authorships`,
|
|
607
714
|
// and `author_list` as itself.
|
|
608
715
|
const field = selectRecordKey(input.entity_type, slice.field);
|
|
@@ -614,27 +721,29 @@ export const searchEntitiesTool = tool('openalex_search_entities', {
|
|
|
614
721
|
if (slice.offset > items.length) {
|
|
615
722
|
notices.push(`slice.offset ${slice.offset} is past the end of \`${field}\`, which holds ${items.length} element(s), so nothing follows.`);
|
|
616
723
|
}
|
|
617
|
-
|
|
724
|
+
outcome = buildSlicePage({
|
|
725
|
+
...context,
|
|
618
726
|
record,
|
|
619
727
|
field,
|
|
620
728
|
offset: slice.offset,
|
|
621
|
-
|
|
622
|
-
measureFrame,
|
|
729
|
+
limit: sliceLimit.applied,
|
|
623
730
|
});
|
|
624
731
|
}
|
|
625
732
|
else {
|
|
626
|
-
|
|
733
|
+
outcome = buildRecordPage({
|
|
734
|
+
...context,
|
|
627
735
|
records: result.results,
|
|
628
|
-
entityType: input.entity_type,
|
|
629
736
|
path: input.id ? 'lookup' : 'list',
|
|
630
|
-
|
|
631
|
-
|
|
737
|
+
continuation: semantic || sample ? 'page' : 'cursor',
|
|
738
|
+
page,
|
|
632
739
|
});
|
|
633
740
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
741
|
+
if (outcome.kind === 'too_large') {
|
|
742
|
+
throw ctx.fail('response_too_large', outcome.message, outcome.data);
|
|
743
|
+
}
|
|
744
|
+
if (outcome.notice)
|
|
745
|
+
ctx.enrich.notice(outcome.notice);
|
|
746
|
+
return outcome.page;
|
|
638
747
|
},
|
|
639
748
|
format: (result) => formatRecordPage(result, RESULT_NOUN),
|
|
640
749
|
});
|