mcp-databnf 1.0.1 → 1.1.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/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
 
9
9
  // src/version.ts
10
- var PKG_VERSION = "1.0.1";
10
+ var PKG_VERSION = "1.1.0";
11
11
  var REPO_URL = "https://github.com/smeet666/mcp-databnf";
12
12
 
13
13
  // src/config.ts
@@ -395,8 +395,36 @@ function escapeLiteral(value) {
395
395
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "");
396
396
  }
397
397
  var literal = (value) => `"${escapeLiteral(value)}"`;
398
+ var INVISIBLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/gu;
399
+ function withoutInvisibles(input) {
400
+ return input.replace(INVISIBLE, "");
401
+ }
402
+ function invisibleCharacters(input) {
403
+ const found = [];
404
+ for (const character of input.match(INVISIBLE) ?? []) {
405
+ if (!found.includes(character)) found.push(character);
406
+ }
407
+ return found;
408
+ }
409
+ function charactersSetAside(input) {
410
+ const found = [];
411
+ for (const character of withoutInvisibles(input).normalize("NFC")) {
412
+ if (/[\p{L}\p{N}'\-\s]/u.test(character) || /[‘’ʼ]/u.test(character)) continue;
413
+ if (!found.includes(character)) found.push(character);
414
+ }
415
+ return found;
416
+ }
417
+ function readSearchText(input) {
418
+ const words = toSearchWords(input);
419
+ return {
420
+ words,
421
+ terms: toIndexTerms(words),
422
+ invisible: invisibleCharacters(input),
423
+ setAside: charactersSetAside(input)
424
+ };
425
+ }
398
426
  function toSearchWords(input) {
399
- const cleaned = input.normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
427
+ const cleaned = withoutInvisibles(input).normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
400
428
  const words = [];
401
429
  for (const raw of cleaned.split(" ")) {
402
430
  const word = raw.replace(/^['-]+/u, "").replace(/['-]+$/u, "");
@@ -406,6 +434,15 @@ function toSearchWords(input) {
406
434
  }
407
435
  return words;
408
436
  }
437
+ function toIndexTerms(words) {
438
+ const terms = [];
439
+ for (const word of words) {
440
+ for (const piece of word.split(/['-]+/u)) {
441
+ if (piece !== "" && !terms.includes(piece)) terms.push(piece);
442
+ }
443
+ }
444
+ return terms;
445
+ }
409
446
  function containsAllWords(words) {
410
447
  if (words.length === 0) {
411
448
  throw invalidInput(
@@ -477,31 +514,47 @@ var TEXT_WINDOW = 400;
477
514
  var prefixed = (body) => `${PREFIX_BLOCK}
478
515
  ${body.trim()}
479
516
  `;
517
+ function windowOccupancy(predicate, words) {
518
+ return `
519
+ {
520
+ SELECT (COUNT(*) AS ?windowRows) WHERE {
521
+ SELECT DISTINCT ?windowEntity ?windowText WHERE {
522
+ ?windowEntity ${predicate} ?windowText .
523
+ ?windowText bif:contains ${containsAllWords(words)} .
524
+ }
525
+ LIMIT ${TEXT_WINDOW}
526
+ }
527
+ }`;
528
+ }
480
529
  function searchAuthorsQuery(words, limit, offset) {
481
530
  const take = boundedInteger(limit, 1, 50, "limit") + 1;
482
531
  const skip = boundedInteger(offset, 0, 5e3, "offset");
483
532
  return prefixed(`
484
- SELECT ?person ?name ?label ?birthYear ?deathYear ?role WHERE {
533
+ SELECT ?person ?name ?label ?birthYear ?deathYear ?role ?windowRows WHERE {
485
534
  {
486
- SELECT DISTINCT ?person WHERE {
487
- {
488
- SELECT DISTINCT ?person ?name WHERE {
489
- ?person foaf:name ?name .
490
- ?name bif:contains ${containsAllWords(words)} .
535
+ {
536
+ SELECT DISTINCT ?person WHERE {
537
+ {
538
+ SELECT DISTINCT ?person ?name WHERE {
539
+ ?person foaf:name ?name .
540
+ ?name bif:contains ${containsAllWords(words)} .
541
+ }
542
+ LIMIT ${TEXT_WINDOW}
491
543
  }
492
- LIMIT ${TEXT_WINDOW}
544
+ ?person a foaf:Person .
493
545
  }
494
- ?person a foaf:Person .
546
+ ORDER BY ?person
547
+ LIMIT ${take} OFFSET ${skip}
495
548
  }
496
- ORDER BY ?person
497
- LIMIT ${take} OFFSET ${skip}
549
+ OPTIONAL { ?person foaf:name ?name }
550
+ OPTIONAL { ?record foaf:focus ?person ; skos:prefLabel ?label }
551
+ OPTIONAL { ?person bnf:firstYear ?birthYear }
552
+ OPTIONAL { ?person bnf:lastYear ?deathYear }
553
+ OPTIONAL { ?person rdag2:biographicalInformation ?role }
498
554
  }
499
- OPTIONAL { ?person foaf:name ?name }
500
- OPTIONAL { ?record foaf:focus ?person ; skos:prefLabel ?label }
501
- OPTIONAL { ?person bnf:firstYear ?birthYear }
502
- OPTIONAL { ?person bnf:lastYear ?deathYear }
503
- OPTIONAL { ?person rdag2:biographicalInformation ?role }
504
- }`);
555
+ UNION${windowOccupancy("foaf:name", words)}
556
+ }
557
+ ORDER BY ?person`);
505
558
  }
506
559
  var MOST_TRIPLES = 2e3;
507
560
  function authorQuery(id) {
@@ -524,29 +577,54 @@ function searchWorksQuery(words, limit, offset) {
524
577
  const take = boundedInteger(limit, 1, 50, "limit") + 1;
525
578
  const skip = boundedInteger(offset, 0, 5e3, "offset");
526
579
  return prefixed(`
527
- SELECT ?work ?title ?date ?status ?creator ?creatorName WHERE {
580
+ SELECT ?work ?title ?date ?status ?creator ?creatorName ?windowRows WHERE {
528
581
  {
529
- SELECT DISTINCT ?work WHERE {
530
- {
531
- SELECT DISTINCT ?work ?title WHERE {
532
- ?work dcterms:title ?title .
533
- ?title bif:contains ${containsAllWords(words)} .
582
+ {
583
+ SELECT DISTINCT ?work WHERE {
584
+ {
585
+ SELECT DISTINCT ?work ?title WHERE {
586
+ ?work dcterms:title ?title .
587
+ ?title bif:contains ${containsAllWords(words)} .
588
+ }
589
+ LIMIT ${TEXT_WINDOW}
534
590
  }
535
- LIMIT ${TEXT_WINDOW}
591
+ ?work a rdafrbr:Work .
536
592
  }
537
- ?work a rdafrbr:Work .
593
+ ORDER BY ?work
594
+ LIMIT ${take} OFFSET ${skip}
595
+ }
596
+ OPTIONAL { ?work dcterms:title ?title }
597
+ OPTIONAL { ?work dcterms:date ?date }
598
+ OPTIONAL { ?work rdae:statusOfIdentification ?status }
599
+ OPTIONAL {
600
+ ?work dcterms:creator ?creator .
601
+ OPTIONAL { ?creator foaf:name ?creatorName }
602
+ }
603
+ }
604
+ UNION${windowOccupancy("dcterms:title", words)}
605
+ }
606
+ ORDER BY ?work`);
607
+ }
608
+ function worksByCreatorQuery(id, limit, offset) {
609
+ const person = iriOf(id);
610
+ const take = boundedInteger(limit, 1, 50, "limit") + 1;
611
+ const skip = boundedInteger(offset, 0, 5e3, "offset");
612
+ return prefixed(`
613
+ SELECT ?work ?title ?date ?year ?status ?form WHERE {
614
+ {
615
+ SELECT DISTINCT ?work WHERE {
616
+ ?work dcterms:creator ${person} .
538
617
  }
539
618
  ORDER BY ?work
540
619
  LIMIT ${take} OFFSET ${skip}
541
620
  }
542
621
  OPTIONAL { ?work dcterms:title ?title }
543
622
  OPTIONAL { ?work dcterms:date ?date }
623
+ OPTIONAL { ?work bnf:firstYear ?year }
544
624
  OPTIONAL { ?work rdae:statusOfIdentification ?status }
545
- OPTIONAL {
546
- ?work dcterms:creator ?creator .
547
- OPTIONAL { ?creator foaf:name ?creatorName }
548
- }
549
- }`);
625
+ OPTIONAL { ?work rdae:formOfWork ?form }
626
+ }
627
+ ORDER BY ?work`);
550
628
  }
551
629
  function workQuery(id) {
552
630
  const work = iriOf(id);
@@ -584,7 +662,8 @@ SELECT ?edition ?title ?date ?year ?publisher ?place ?editionStatement
584
662
  OPTIONAL { ?edition rdfs:seeAlso ?catalogue }
585
663
  OPTIONAL { ?edition rdarel:electronicReproduction ?reproduction }
586
664
  OPTIONAL { ?edition bnf:OCR ?ocr }
587
- }`);
665
+ }
666
+ ORDER BY ?edition`);
588
667
  }
589
668
  function digitisedForWorkQuery(id, limit) {
590
669
  const work = iriOf(id);
@@ -721,12 +800,28 @@ function toDigitisedLink(address, role, fromId, fromTitle) {
721
800
  if (!segment) return null;
722
801
  const ark = segment.split(".")[0] ?? segment;
723
802
  if (ark === "") return null;
724
- return { ark, url: address, role, fromId, fromTitle };
803
+ return { ark, url: address, rendering: renderingOf(address, ark), role, fromId, fromTitle };
804
+ }
805
+ function renderingOf(address, ark) {
806
+ const path = address.split(/[?#]/)[0] ?? address;
807
+ const after = path.slice(path.indexOf(`ark:/12148/${ark}`) + `ark:/12148/${ark}`.length);
808
+ const rendering = after.replace(/^[./]+/, "").replace(/\/+$/, "");
809
+ return rendering === "" ? null : rendering;
725
810
  }
726
811
  function page(rows, limit, skipped) {
727
812
  const more = rows.length > limit;
728
813
  return skipped > 0 ? { rows: rows.slice(0, limit), hasMore: more, skipped } : { rows: rows.slice(0, limit), hasMore: more };
729
814
  }
815
+ function readWindowOccupancy(results) {
816
+ for (const row of results.results.bindings) {
817
+ const raw = text(row.windowRows);
818
+ if (raw === null) continue;
819
+ const count = Number(raw);
820
+ if (Number.isInteger(count)) return count >= TEXT_WINDOW;
821
+ }
822
+ return null;
823
+ }
824
+ var isOccupancyRow = (row, entity) => row.windowRows !== void 0 && row[entity] === void 0;
730
825
  function byPredicate(results) {
731
826
  const index = /* @__PURE__ */ new Map();
732
827
  for (const row of results.results.bindings) {
@@ -781,6 +876,7 @@ function toAuthorSummaries(results, limit) {
781
876
  const seen = /* @__PURE__ */ new Set();
782
877
  let skipped = 0;
783
878
  for (const row of results.results.bindings) {
879
+ if (isOccupancyRow(row, "person")) continue;
784
880
  const iri = text(row.person);
785
881
  const id = iri === null ? null : idFromIri(iri);
786
882
  if (iri === null || id === null) {
@@ -799,7 +895,7 @@ function toAuthorSummaries(results, limit) {
799
895
  sourceUrl: publicPageFor(iri)
800
896
  });
801
897
  }
802
- return page(rows, limit, skipped);
898
+ return { ...page(rows, limit, skipped), indexWindowFull: readWindowOccupancy(results) };
803
899
  }
804
900
  function toAuthorDetail(results, id, pageUrl) {
805
901
  const index = byPredicate(results);
@@ -863,6 +959,7 @@ function toWorkSummaries(results, limit) {
863
959
  const byId = /* @__PURE__ */ new Map();
864
960
  let skipped = 0;
865
961
  for (const row of results.results.bindings) {
962
+ if (isOccupancyRow(row, "work")) continue;
866
963
  const iri = text(row.work);
867
964
  const id = iri === null ? null : idFromIri(iri);
868
965
  if (iri === null || id === null) {
@@ -888,6 +985,46 @@ function toWorkSummaries(results, limit) {
888
985
  work.creators.push({ id: creatorId, name: text(row.creatorName) });
889
986
  }
890
987
  }
988
+ return {
989
+ ...page(
990
+ order.map((id) => byId.get(id)),
991
+ limit,
992
+ skipped
993
+ ),
994
+ indexWindowFull: readWindowOccupancy(results)
995
+ };
996
+ }
997
+ function toAuthoredWorks(results, limit) {
998
+ const order = [];
999
+ const byId = /* @__PURE__ */ new Map();
1000
+ let skipped = 0;
1001
+ for (const row of results.results.bindings) {
1002
+ const iri = text(row.work);
1003
+ const id = iri === null ? null : idFromIri(iri);
1004
+ if (iri === null || id === null) {
1005
+ skipped += 1;
1006
+ continue;
1007
+ }
1008
+ let work = byId.get(id);
1009
+ if (!work) {
1010
+ work = {
1011
+ id,
1012
+ title: text(row.title),
1013
+ date: text(row.date),
1014
+ year: integer(row.year),
1015
+ forms: [],
1016
+ status: readStatus(text(row.status), iri),
1017
+ sourceUrl: publicPageFor(iri)
1018
+ };
1019
+ byId.set(id, work);
1020
+ order.push(id);
1021
+ }
1022
+ const form = text(row.form);
1023
+ if (form !== null && form.startsWith("http")) {
1024
+ const term = vocabularyTerm(form);
1025
+ if (!work.forms.includes(term)) work.forms.push(term);
1026
+ }
1027
+ }
891
1028
  return page(
892
1029
  order.map((id) => byId.get(id)),
893
1030
  limit,
@@ -1187,6 +1324,25 @@ var BnfClient = class {
1187
1324
  words(input) {
1188
1325
  return toSearchWords(input);
1189
1326
  }
1327
+ /**
1328
+ * The terms the index requires, which is what a row's presence rests on.
1329
+ *
1330
+ * The index splits inside a word at an apostrophe and at a hyphen, so a word
1331
+ * a caller wrote as one can reach it as two, each required on its own.
1332
+ */
1333
+ indexTerms(words) {
1334
+ return toIndexTerms(words);
1335
+ }
1336
+ /**
1337
+ * How a piece of caller text became the terms the search was run with.
1338
+ *
1339
+ * A tool reports the reading back, so the same reading has to be the one the
1340
+ * query was built from: the words, the terms the index makes of them, and
1341
+ * the characters that reached neither.
1342
+ */
1343
+ searchReading(input) {
1344
+ return readSearchText(input);
1345
+ }
1190
1346
  searchAuthors(name, limit, offset) {
1191
1347
  const words = toSearchWords(name);
1192
1348
  const query = searchAuthorsQuery(words, limit, offset);
@@ -1203,7 +1359,7 @@ var BnfClient = class {
1203
1359
  (results) => toAuthorDetail(results, id.id, id.pageUrl)
1204
1360
  );
1205
1361
  if (!result.data.types.includes(PERSON_CLASS)) {
1206
- await this.refuse(id, result.data.types, "a person", "get_work reads a work.");
1362
+ await this.refuse(id, "a person", "get_work reads a work.");
1207
1363
  }
1208
1364
  return result;
1209
1365
  }
@@ -1214,8 +1370,8 @@ var BnfClient = class {
1214
1370
  * something else are two different answers, and only one of them means the
1215
1371
  * caller should go looking elsewhere.
1216
1372
  */
1217
- async refuse(id, types, wanted, hint) {
1218
- const known = types.length > 0 ? types : (await this.read(`types:${id.id}`, kindQuery(id), toTypes)).data;
1373
+ async refuse(id, wanted, hint) {
1374
+ const known = (await this.read(`types:${id.id}`, kindQuery(id), toTypes)).data;
1219
1375
  if (known.length === 0) {
1220
1376
  throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
1221
1377
  hint: "Find the identifier with search_authors or search_works rather than writing one.",
@@ -1245,13 +1401,26 @@ var BnfClient = class {
1245
1401
  if (!result.data.types.some((type) => WORK_CLASSES.has(type))) {
1246
1402
  await this.refuse(
1247
1403
  id,
1248
- result.data.types,
1249
1404
  "a work",
1250
1405
  "get_author reads a person, and list_editions reads the editions of a work."
1251
1406
  );
1252
1407
  }
1253
1408
  return result;
1254
1409
  }
1410
+ /**
1411
+ * The works one person is named the creator of.
1412
+ *
1413
+ * The record is not checked here. A person the catalogue credits with nothing
1414
+ * and an address that names no person both answer with no rows, and telling
1415
+ * them apart costs a second query that only an empty answer needs.
1416
+ */
1417
+ worksByAuthor(id, limit, offset) {
1418
+ return this.read(
1419
+ `author-works:${id.id}:${limit}:${offset}`,
1420
+ worksByCreatorQuery(id, limit, offset),
1421
+ (results) => toAuthoredWorks(results, limit)
1422
+ );
1423
+ }
1255
1424
  listEditions(id, limit, offset) {
1256
1425
  const query = editionsQuery(id, limit, offset);
1257
1426
  return this.read(
@@ -1354,7 +1523,12 @@ var retrievedAtSchema = z2.string().describe(
1354
1523
  );
1355
1524
  var digitisedLinkSchema = z2.object({
1356
1525
  ark: z2.string().describe("The Gallica ARK identifying the document."),
1357
- url: z2.string().describe("Open this to see the document. This server does not read it."),
1526
+ url: z2.string().describe(
1527
+ "The address the catalogue publishes, for a person to open. Read it beside 'rendering': an address asking for a rendering opens that view of the document rather than the document. This server does not read either."
1528
+ ),
1529
+ rendering: z2.string().nullable().describe(
1530
+ "The view the address asks Gallica for, read off what follows the ARK name in it, such as 'thumbnail' for a small image or 'item' for one leaf. Null when the address names the document itself. This says what was asked for, not what comes back."
1531
+ ),
1358
1532
  role: z2.enum(["reproduction", "ocr", "depiction"]).describe(
1359
1533
  "'reproduction' is the edition itself digitised. 'ocr' is the text a machine read off it. 'depiction' is an image illustrating the record, which can be a portrait or a page mentioning the subject rather than the work."
1360
1534
  ),
@@ -1394,7 +1568,33 @@ function truncate(text2, maxChars) {
1394
1568
  if (text2.length <= maxChars) return text2;
1395
1569
  return `${text2.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
1396
1570
  }
1397
- var NO_RANKING = "The full-text index answers whether a record matches, and it does not score how well. These rows are in the order the index returned them, so the row a person would call the obvious answer can sit anywhere in the list, or on a later page.";
1571
+ var WINDOW_FULL_CAVEAT = "The window this search reads off the index came back full, so records carrying these words sit past what was read and this list is a part of the matches. Which records fill a full window is settled by the index as it answers, so another reading of the same search can bring back other rows. Requiring one more word narrows the reading and reaches what a full window leaves out.";
1572
+ var WINDOW_FULL_AND_NO_MORE = "'has_more' is false because the rows of this window stop here, and the window came back full: it says where the reading stopped rather than that the catalogue holds nothing further under these words.";
1573
+ var FORMS_CAVEAT = "The codes under 'forms' are the terms of the BnF's work-form vocabulary a record points at, passed on as they stand: the vocabulary publishes no label for them here, so some read as words, such as 'roman' or 'poesi', and others, such as 'te', say nothing on their own. A work carrying no code is a work whose form the catalogue does not state.";
1574
+ var FORMS_FILTER_CAVEAT = "A form is stated on some works and left unsaid on others, so keeping the rows that carry one code finds the works declaring it and never all the works of that form.";
1575
+ var SEVERAL_RECORDS_CAVEAT = "The BnF keeps more than one authority record for some people, and a record can carry a name and nothing else while a second one carries the dates, the occupation and the works. Everything here is read off this record, so search_authors under the name will show the other records the file holds and let you compare them.";
1576
+ function readingNotes(reading, field) {
1577
+ const notes = [];
1578
+ const quoted = (values) => values.map((value) => `"${value}"`).join(", ");
1579
+ if (reading.invisible.length > 0) {
1580
+ notes.push(
1581
+ `The text held ${reading.invisible.length} character(s) that mark nothing on a screen, such as a control character or a zero-width space. They were removed before the words were cut, so a word one of them sat inside was searched for whole rather than as two terms the index would each require.`
1582
+ );
1583
+ }
1584
+ if (reading.setAside.length > 0) {
1585
+ notes.push(
1586
+ `Only letters and digits reach the index: ${quoted(reading.setAside)} in the text ${reading.setAside.length === 1 ? "was" : "were"} set aside, and what was searched for is ${quoted(reading.terms)}.`
1587
+ );
1588
+ }
1589
+ const single = reading.terms.filter((term) => [...term].length === 1);
1590
+ if (single.length > 0) {
1591
+ notes.push(
1592
+ `${quoted(single)} ${single.length === 1 ? "is a term of one character" : "are terms of one character"} and ${single.length === 1 ? "is" : "are"} required like any other: a record has to carry ${single.length === 1 ? "it" : "them"} in its ${field} for a row to be a match. Leaving ${single.length === 1 ? "it" : "them"} out widens the search.`
1593
+ );
1594
+ }
1595
+ return notes;
1596
+ }
1597
+ var NO_RANKING = "The full-text index answers whether a record matches, and it does not score how well. Rows are ordered by the address of the record, which measures nothing, so the row a person would call the obvious answer can sit anywhere in the list, or on a later page. That order is the one the pages are cut along, so reading page after page reaches every match once.";
1398
1598
  async function classifyEmptyPage(page2, firstPageHasRow) {
1399
1599
  if (page2 === 1) return "absent";
1400
1600
  try {
@@ -1403,18 +1603,42 @@ async function classifyEmptyPage(page2, firstPageHasRow) {
1403
1603
  return "undetermined";
1404
1604
  }
1405
1605
  }
1406
- var GALLICA_CAVEAT = "These are links for a person to open. This server reads the BnF catalogue and never requests gallica.bnf.fr, so it cannot say whether a document opens, what it contains, or on what terms it may be reused.";
1407
- var PROVISIONAL_CAVEAT = "A provisional record is one the BnF created to hold a title it has catalogued and not yet established as a work of its own. Its identifier can change, and it can be merged into an established record.";
1408
-
1409
- // src/tools/findDigitised.ts
1410
1606
  var PERSON_TYPE = "http://xmlns.com/foaf/0.1/Person";
1411
1607
  var WORK_TYPES = [
1412
1608
  "http://rdvocab.info/uri/schema/FRBRentitiesRDA/Work",
1413
1609
  "http://rdaregistry.info/Elements/c/#C10001"
1414
1610
  ];
1611
+ function recordKindOf(types) {
1612
+ if (types.includes(PERSON_TYPE)) return "person";
1613
+ if (types.some((type) => WORK_TYPES.includes(type))) return "work";
1614
+ return null;
1615
+ }
1616
+ function headingYears(label) {
1617
+ const bracketed = /\(([^()]*)\)\s*$/.exec(label);
1618
+ const [birth, death] = (bracketed?.[1] ?? "").split("-");
1619
+ const year = (part) => part !== void 0 && /^\d{4}$/.test(part.trim()) ? Number(part.trim()) : null;
1620
+ return { birth: year(birth), death: year(death) };
1621
+ }
1622
+ function headingYearConflicts(label, birthYear, deathYear) {
1623
+ if (label === null) return [];
1624
+ const stated = headingYears(label);
1625
+ const conflicts = [];
1626
+ if (stated.birth !== null && birthYear !== null && stated.birth !== birthYear) {
1627
+ conflicts.push(`${stated.birth} against 'birth_year' ${birthYear}`);
1628
+ }
1629
+ if (stated.death !== null && deathYear !== null && stated.death !== deathYear) {
1630
+ conflicts.push(`${stated.death} against 'death_year' ${deathYear}`);
1631
+ }
1632
+ return conflicts;
1633
+ }
1634
+ var GALLICA_CAVEAT = "These are links for a person to open. This server reads the BnF catalogue and never requests gallica.bnf.fr, so it cannot say whether a document opens, what it contains, or on what terms it may be reused.";
1635
+ var ALIGNMENTS_CAVEAT = "The addresses under 'same_as' are the BnF's own alignments to other files, passed on exactly as the catalogue publishes them, character for character. This server requests data.bnf.fr alone and does not open them, so it cannot say whether one still answers or what stands at the other end.";
1636
+ var PROVISIONAL_CAVEAT = "A provisional record is one the BnF created to hold a title it has catalogued and not yet established as a work of its own. Its identifier can change, and it can be merged into an established record.";
1637
+
1638
+ // src/tools/findDigitised.ts
1415
1639
  var findDigitisedDescription = [
1416
1640
  "Gather the digitised documents the Biblioth\xE8que nationale de France catalogue attaches to one person or one work, and return them as links.",
1417
- "Give the identifier of either; the tool reads what kind of record it is and follows the right path. For a work it walks the editions; for a person it takes the images on the record and the digitised editions of the works they are credited with.",
1641
+ "Give the identifier of either; the tool asks the catalogue what kind of record it is and follows the right path. For a work it walks the editions; for a person it takes the images on the record and the digitised editions of the works they are credited with. The 'kind' returned is what the catalogue types the record as.",
1418
1642
  "Every result is a link for someone to open. This server reads the BnF catalogue and never requests gallica.bnf.fr, so it reports nothing about what is at the other end: not whether the document opens, not what it contains, not on what terms it may be reused.",
1419
1643
  "It returns links and nothing else: no publisher, no date, no ISBN. Use list_editions when which edition a copy belongs to matters.",
1420
1644
  "A 'depiction' illustrates a record and can be a page that merely mentions the subject. A 'reproduction' is an edition digitised. An 'ocr' link names a machine-read text of a document, which this server does not fetch."
@@ -1424,7 +1648,7 @@ var findDigitisedInput = strictInput({
1424
1648
  "A person or work identifier, such as 'cb119219976' or 'cb11970626n'. The argument is named 'id' rather than 'work_id' or 'author_id' because it takes either."
1425
1649
  ),
1426
1650
  kind: z3.enum(["auto", "person", "work"]).default("auto").describe(
1427
- "Which kind of record the identifier names. 'auto' asks the catalogue, which costs one extra query."
1651
+ "What you expect the identifier to name. The catalogue is asked either way, and a kind it contradicts is refused rather than followed. 'auto' states no expectation."
1428
1652
  ),
1429
1653
  limit: z3.number().int().min(1).max(200).default(40)
1430
1654
  });
@@ -1432,11 +1656,13 @@ var findDigitisedOutput = z3.object({
1432
1656
  id: z3.string(),
1433
1657
  kind: z3.enum(["person", "work"]).describe("What the catalogue types this record as."),
1434
1658
  links: z3.array(digitisedLinkSchema),
1435
- counts: z3.object({
1659
+ links_returned_by_role: z3.object({
1436
1660
  reproduction: z3.number().int(),
1437
1661
  ocr: z3.number().int(),
1438
1662
  depiction: z3.number().int()
1439
- }).describe("Links returned on this page, by role. These count links, not documents."),
1663
+ }).describe(
1664
+ "How many of the links in this answer carry each role. These count the links returned here, which is neither a count of documents nor a count of what the catalogue attaches: several links can name one document, and 'has_more' says when links were left out."
1665
+ ),
1440
1666
  has_more: z3.boolean(),
1441
1667
  retrieved_at: retrievedAtSchema,
1442
1668
  notes: z3.array(z3.string())
@@ -1444,43 +1670,48 @@ var findDigitisedOutput = z3.object({
1444
1670
  async function runFindDigitised(client, args) {
1445
1671
  try {
1446
1672
  const id = client.identify(args.id);
1447
- let kind;
1448
1673
  const notes = [];
1449
- if (args.kind === "auto") {
1450
- if (id.kind === "temp-work") {
1451
- kind = "work";
1452
- } else {
1453
- const types = await client.types(id);
1454
- if (types.data.length === 0) {
1455
- return toToolError(
1456
- notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
1457
- hint: "Find the identifier with search_authors or search_works rather than writing one.",
1674
+ let kind;
1675
+ if (id.kind === "temp-work") {
1676
+ kind = "work";
1677
+ } else {
1678
+ const types = await client.types(id);
1679
+ if (types.data.length === 0) {
1680
+ return toToolError(
1681
+ notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
1682
+ hint: "Find the identifier with search_authors or search_works rather than writing one.",
1683
+ url: id.pageUrl
1684
+ })
1685
+ );
1686
+ }
1687
+ const found = recordKindOf(types.data);
1688
+ if (found === null) {
1689
+ return toToolError(
1690
+ notFound(
1691
+ `"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
1692
+ {
1693
+ hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
1458
1694
  url: id.pageUrl
1459
- })
1460
- );
1461
- }
1462
- if (types.data.includes(PERSON_TYPE)) kind = "person";
1463
- else if (types.data.some((type) => WORK_TYPES.includes(type))) kind = "work";
1464
- else {
1465
- return toToolError(
1466
- notFound(
1467
- `"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
1468
- {
1469
- hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
1470
- url: id.pageUrl
1471
- }
1472
- )
1473
- );
1474
- }
1695
+ }
1696
+ )
1697
+ );
1475
1698
  }
1476
- } else {
1477
- kind = args.kind;
1699
+ kind = found;
1700
+ }
1701
+ if (args.kind !== "auto" && args.kind !== kind) {
1702
+ return toToolError(
1703
+ invalidInput(
1704
+ `The call states that "${id.id}" is a ${args.kind}, and data.bnf.fr describes it as a ${kind}.`,
1705
+ `Leave 'kind' out, or pass '${kind}'. Following the ${args.kind} path over a ${kind} record answers with part of the record or with none of it, and nothing in that answer would say so.`
1706
+ )
1707
+ );
1478
1708
  }
1479
1709
  const { data, cached, retrievedAt } = kind === "person" ? await client.digitisedForPerson(id, args.limit) : await client.digitisedForWork(id, args.limit);
1480
1710
  if (cached) notes.push("Served from this server's short-lived in-memory cache.");
1481
1711
  const links = data.rows.map((link) => ({
1482
1712
  ark: link.ark,
1483
1713
  url: link.url,
1714
+ rendering: link.rendering,
1484
1715
  role: link.role,
1485
1716
  from_id: link.fromId,
1486
1717
  from_title: link.fromTitle
@@ -1490,12 +1721,18 @@ async function runFindDigitised(client, args) {
1490
1721
  ocr: countRole(data.rows, "ocr"),
1491
1722
  depiction: countRole(data.rows, "depiction")
1492
1723
  };
1724
+ const rendered = links.filter((link) => link.rendering !== null);
1493
1725
  if (links.length > 0) notes.push(GALLICA_CAVEAT);
1494
1726
  if (counts.depiction > 0) {
1495
1727
  notes.push(
1496
1728
  `${counts.depiction === 1 ? "One of these is an image" : `${counts.depiction} of these are images`} the catalogue uses to illustrate the record. Such an image can be a portrait, a title page, or a newspaper page that mentions the subject in passing, and the catalogue does not say which.`
1497
1729
  );
1498
1730
  }
1731
+ if (rendered.length > 0) {
1732
+ notes.push(
1733
+ `${rendered.length === 1 ? "One of these addresses opens" : `${rendered.length} of these addresses open`} a rendering of a document rather than the document: what follows the ARK name in the address, such as 'thumbnail', is the view being asked for, and 'ark' names the document that view was taken from. Read 'rendering' before quoting a link as the document itself.`
1734
+ );
1735
+ }
1499
1736
  if (counts.ocr > 0) {
1500
1737
  notes.push(
1501
1738
  `${counts.ocr === 1 ? "One of these points" : `${counts.ocr} of these point`} at a text a machine read off a scanned document. This server names that text and does not read it.`
@@ -1505,6 +1742,9 @@ async function runFindDigitised(client, args) {
1505
1742
  notes.push(
1506
1743
  args.limit >= 200 ? "The catalogue points at more documents than this tool will return in one answer, and it does not page: 200 is the ceiling. Read the editions with list_editions to reach the rest, one page at a time." : `More links exist than were returned. Raise 'limit' to see more of them, up to 200. This tool does not page, so a higher limit re-reads the links already shown rather than continuing after them.`
1507
1744
  );
1745
+ notes.push(
1746
+ "The figures under 'links_returned_by_role' count the links in this answer, so they fall short of what the catalogue attaches by however much was left out."
1747
+ );
1508
1748
  }
1509
1749
  if (links.length === 0) {
1510
1750
  notes.push(
@@ -1521,7 +1761,7 @@ ${links.map((link, index) => {
1521
1761
  id: id.id,
1522
1762
  kind,
1523
1763
  links,
1524
- counts,
1764
+ links_returned_by_role: counts,
1525
1765
  has_more: data.hasMore,
1526
1766
  retrieved_at: retrievedAt,
1527
1767
  notes
@@ -1541,7 +1781,7 @@ var getAuthorDescription = [
1541
1781
  "Read one person's record in the Biblioth\xE8que nationale de France authority file, by the identifier search_authors returns.",
1542
1782
  "It carries the dates and places of birth and death, the occupation the record names, the language and country it associates with the person, the field of activity with its Dewey class, and the addresses for the same person in VIAF, IdRef, DBpedia, Wikidata and ISNI.",
1543
1783
  "It carries no biography. The field the BnF calls biographical information is a job title on most records and a single word on many, so quote it as what the catalogue states and do not build a life story out of it.",
1544
- "It does not list the person's works. The catalogue links them, and no tool here walks that link: search_works reads titles, so a work is found by its title rather than by its author.",
1784
+ "It lists no work. list_works walks from this record to the works the catalogue names the person the creator of, and states what that link does and does not reach.",
1545
1785
  "'depictions' are images data.bnf.fr attaches to the person. They are links for a person to open; this server does not read them, and an image can be a page that mentions the person rather than a portrait of them."
1546
1786
  ].join(" ");
1547
1787
  var getAuthorInput = strictInput({
@@ -1575,7 +1815,9 @@ var getAuthorOutput = z4.object({
1575
1815
  fields: z4.array(z4.string()).describe("Fields of activity in the words of the record."),
1576
1816
  dewey_classes: z4.array(z4.string()).describe("Dewey classes the fields point at, such as '800' for literature."),
1577
1817
  same_as: z4.record(z4.string(), z4.array(z4.string())).describe("Addresses for the same person in other files, grouped by file."),
1578
- catalogue_url: z4.string().nullable().describe("The record in the BnF general catalogue."),
1818
+ catalogue_url: z4.string().nullable().describe(
1819
+ "The address of this record in the BnF general catalogue, as the record itself points at it. Null when the record states none, which is a silence in the record rather than a record the general catalogue lacks: no address is built in its place."
1820
+ ),
1579
1821
  record_created: z4.string().nullable().describe("When the authority record was created."),
1580
1822
  record_modified: z4.string().nullable().describe("When it was last changed."),
1581
1823
  source_url: z4.string()
@@ -1598,17 +1840,36 @@ async function runGetAuthor(client, args) {
1598
1840
  `The BnF states "${data.biographicalInformation}" as this person's biographical information. That field holds an occupation rather than a life, and this is all the catalogue says.`
1599
1841
  );
1600
1842
  }
1843
+ if (data.biographicalInformation !== null && data.biographicalInformation === data.occupation) {
1844
+ notes.push(
1845
+ "This record states the same text under 'biographical_information' and 'occupation', character for character. It is one statement written in two fields rather than two the record makes separately."
1846
+ );
1847
+ }
1848
+ const headings = [data.label, ...data.otherNames.map((other) => other.label)];
1849
+ const conflicts = [
1850
+ ...new Set(
1851
+ headings.flatMap(
1852
+ (heading) => headingYearConflicts(heading, data.birthYear, data.deathYear)
1853
+ )
1854
+ )
1855
+ ];
1856
+ if (conflicts.length > 0) {
1857
+ notes.push(
1858
+ `This record states its dates twice and the two disagree: the heading says ${conflicts.join(", and ")}. Both are passed on as the BnF publishes them, and the record says nothing about which is right, so read source_url before citing either.`
1859
+ );
1860
+ }
1601
1861
  if (data.deathDate === null && data.deathYear === null) {
1862
+ const dated = data.birthDate !== null || data.birthYear !== null;
1602
1863
  notes.push(
1603
- "The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two."
1864
+ dated ? "The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two." : `This record states no date of birth and no date of death, so it dates the person nowhere and says nothing about whether they are living. ${SEVERAL_RECORDS_CAVEAT}`
1604
1865
  );
1605
1866
  }
1606
1867
  if (Object.keys(data.sameAs).length > 0) {
1607
1868
  notes.push(
1608
- "The addresses under 'same_as' are the BnF's own alignments to other files. Following one is how to reach a biography, which this catalogue does not hold."
1869
+ `${ALIGNMENTS_CAVEAT} Following one is how to reach a biography, which this catalogue does not hold.`
1609
1870
  );
1610
1871
  }
1611
- if (data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
1872
+ if (args.include_depictions && data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
1612
1873
  const structured = {
1613
1874
  author: {
1614
1875
  id: data.id,
@@ -1643,6 +1904,7 @@ async function runGetAuthor(client, args) {
1643
1904
  structured.depictions = data.depictions.map((link) => ({
1644
1905
  ark: link.ark,
1645
1906
  url: link.url,
1907
+ rendering: link.rendering,
1646
1908
  role: link.role,
1647
1909
  from_id: link.fromId,
1648
1910
  from_title: link.fromTitle
@@ -1680,6 +1942,7 @@ import { z as z5 } from "zod";
1680
1942
  var getWorkDescription = [
1681
1943
  "Read one work's record in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works returns.",
1682
1944
  "It carries the title, everyone the record credits, the date the BnF gives the work, its language, its form and its subject with the Dewey class.",
1945
+ "'forms' holds the work-form vocabulary's own terms, which carry no label in this dataset, so some read as words and some do not. A record stating none has a form the catalogue does not state.",
1683
1946
  "'status' says whether the record is established or provisional, and 'status_statement' repeats what the catalogue itself states. A provisional identifier can change, so cite an established one where there is a choice.",
1684
1947
  "'expression_count' counts the expressions the record links, which is not a count of published editions: use list_editions for those."
1685
1948
  ].join(" ");
@@ -1700,7 +1963,9 @@ var getWorkOutput = z5.object({
1700
1963
  first_year: z5.number().int().nullable(),
1701
1964
  creators: z5.array(z5.object({ id: z5.string(), name: z5.string().nullable() })),
1702
1965
  languages: z5.array(z5.string()).describe("ISO 639-2 codes."),
1703
- forms: z5.array(z5.string()).describe("Forms from the BnF work-form vocabulary, such as 'poesi' for poetry."),
1966
+ forms: z5.array(z5.string()).describe(
1967
+ "Terms of the BnF work-form vocabulary, as the terms themselves. The vocabulary publishes no label for them here, so they are not translated: some read as words, such as 'poesi', and some do not, such as 'te'. An empty list is a form the record does not state."
1968
+ ),
1704
1969
  subjects: z5.array(z5.string()).describe("Subjects in the words of the record."),
1705
1970
  dewey_classes: z5.array(z5.string()),
1706
1971
  status: z5.enum(["established", "provisional"]),
@@ -1709,7 +1974,9 @@ var getWorkOutput = z5.object({
1709
1974
  "Expressions the record links: translations, adaptations, recordings. This is not a count of published editions. Null when the record was longer than one query reads, since the number would then be the ceiling rather than the count."
1710
1975
  ),
1711
1976
  same_as: z5.record(z5.string(), z5.array(z5.string())),
1712
- catalogue_url: z5.string().nullable(),
1977
+ catalogue_url: z5.string().nullable().describe(
1978
+ "The address of this record in the BnF general catalogue, as the record itself points at it. Null when the record states none, which is the ordinary case on work records: it is a silence in the record rather than a record the general catalogue lacks, and no address is built in its place."
1979
+ ),
1713
1980
  source_url: z5.string()
1714
1981
  }),
1715
1982
  depictions: z5.array(digitisedLinkSchema).optional(),
@@ -1744,12 +2011,14 @@ async function runGetWork(client, args) {
1744
2011
  `The record links ${data.expressionCount} expression(s): translations, adaptations and recordings of this work. Published editions are a separate count, and list_editions is what reads them.`
1745
2012
  );
1746
2013
  }
2014
+ if (data.forms.length > 0) notes.push(FORMS_CAVEAT);
1747
2015
  if (data.creators.length === 0) {
1748
2016
  notes.push(
1749
2017
  "The record credits nobody with this work. That is normal for anonymous and traditional works, and it also happens on records a cataloguer has not finished."
1750
2018
  );
1751
2019
  }
1752
- if (data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
2020
+ if (Object.keys(data.sameAs).length > 0) notes.push(ALIGNMENTS_CAVEAT);
2021
+ if (args.include_depictions && data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
1753
2022
  const structured = {
1754
2023
  work: {
1755
2024
  id: data.id,
@@ -1777,6 +2046,7 @@ async function runGetWork(client, args) {
1777
2046
  structured.depictions = data.depictions.map((link) => ({
1778
2047
  ark: link.ark,
1779
2048
  url: link.url,
2049
+ rendering: link.rendering,
1780
2050
  role: link.role,
1781
2051
  from_id: link.fromId,
1782
2052
  from_title: link.fromTitle
@@ -1803,7 +2073,7 @@ var listEditionsDescription = [
1803
2073
  "List the published editions of one work in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works or get_work returns.",
1804
2074
  "Each row carries the publisher, the place, the year, the edition statement, the extent in the words of the record, the ISBN when there is one, and the record in the BnF general catalogue.",
1805
2075
  "An edition that has been digitised carries a link under 'digitised'. That link says a copy exists at that address; this server never opens it, so it cannot say whether the document is readable, complete, or free to reuse.",
1806
- "Rows are in the order the catalogue holds them, which is neither by date nor by importance."
2076
+ "Rows are ordered by the address the catalogue gives each edition, which is neither by date nor by importance."
1807
2077
  ].join(" ");
1808
2078
  var listEditionsInput = strictInput({
1809
2079
  work_id: z6.string().min(1).max(200).describe("The work identifier, such as 'cb11970626n'."),
@@ -1815,8 +2085,12 @@ var editionRowSchema = z6.object({
1815
2085
  title: z6.string().nullable().describe("The title as this edition carries it."),
1816
2086
  date: z6.string().nullable().describe("The date as published, which can be a phrase such as '[s.d.]' for an undated one."),
1817
2087
  year: z6.number().int().nullable().describe("The year, when the record states one as a number."),
1818
- publisher: z6.string().nullable(),
1819
- place: z6.string().nullable(),
2088
+ publisher: z6.string().nullable().describe(
2089
+ "The publisher as the record writes it, square brackets included. A bracketed part is the cataloguer speaking rather than the item: '[s.n.]' is a publisher the item does not name, and a role such as '[\xE9d., distrib.]' is what the named house did rather than part of its name."
2090
+ ),
2091
+ place: z6.string().nullable().describe(
2092
+ "The place of publication as the record writes it, square brackets included. '[Paris]' is a place the cataloguer supplied because the item does not print one, and '[S.l.]' is an item stating no place at all."
2093
+ ),
1820
2094
  edition_statement: z6.string().nullable().describe("Such as a numbered or revised edition."),
1821
2095
  extent: z6.string().nullable().describe("The extent in the words of the record: pages, volumes, discs."),
1822
2096
  isbn: z6.string().nullable(),
@@ -1858,6 +2132,7 @@ async function runListEditions(client, args) {
1858
2132
  digitised: row.digitised.map((link) => ({
1859
2133
  ark: link.ark,
1860
2134
  url: link.url,
2135
+ rendering: link.rendering,
1861
2136
  role: link.role,
1862
2137
  from_id: link.fromId,
1863
2138
  from_title: link.fromTitle
@@ -1866,6 +2141,14 @@ async function runListEditions(client, args) {
1866
2141
  }));
1867
2142
  const digitisedCount = editions.filter((edition) => edition.digitised.length > 0).length;
1868
2143
  if (digitisedCount > 0) notes.push(GALLICA_CAVEAT);
2144
+ const bracketed = editions.some(
2145
+ (edition) => (edition.publisher?.includes("[") ?? false) || (edition.place?.includes("[") ?? false)
2146
+ );
2147
+ if (bracketed) {
2148
+ notes.push(
2149
+ "Square brackets in 'place' and 'publisher' are the cataloguer's own, kept as the record writes them: '[S.l.]' and '[s.n.]' are an item naming no place and no publisher, a bracketed name is one supplied from outside the item, and a bracketed role such as '[\xE9d., distrib.]' says what a named house did. Drop them before quoting a value as what the item prints."
2150
+ );
2151
+ }
1869
2152
  if (data.hasMore) {
1870
2153
  notes.push(`More editions exist beyond this page. Ask for page ${args.page + 1}.`);
1871
2154
  }
@@ -1874,13 +2157,14 @@ async function runListEditions(client, args) {
1874
2157
  `Page ${args.page} holds no row, which means the list ended earlier rather than that the work has no editions. Ask for page 1 to see it from the start.`
1875
2158
  );
1876
2159
  } else if (editions.length === 0) {
2160
+ await assertWork(client, id);
1877
2161
  notes.push(
1878
2162
  `The catalogue links no published edition to "${id.id}". A work record can exist with none: it happens on provisional records, and on works the BnF describes through an anthology rather than through an edition of their own. Check get_work to see what kind of record this is.`
1879
2163
  );
1880
2164
  }
1881
2165
  if (editions.length > 0) {
1882
2166
  notes.push(
1883
- "Rows are in the order the catalogue holds them. It is neither chronological nor an order of importance, so a first edition can appear anywhere in the list."
2167
+ "Rows are ordered by the address the catalogue gives each edition. It is neither chronological nor an order of importance, so a first edition can appear anywhere in the list. That order is the one the pages are cut along, so reading page after page reaches every edition once."
1884
2168
  );
1885
2169
  }
1886
2170
  const body = editions.length === 0 ? args.page > 1 ? `Page ${args.page} of the editions of "${id.id}" holds no row.` : `The BnF catalogue links no edition to the work "${id.id}".` : `${editions.length} edition(s) of "${id.id}":
@@ -1914,57 +2198,237 @@ ${editions.map((edition, index) => {
1914
2198
  return toToolError(error);
1915
2199
  }
1916
2200
  }
2201
+ async function assertWork(client, id) {
2202
+ if (id.kind === "temp-work") return;
2203
+ const types = await client.types(id);
2204
+ if (types.data.length === 0) {
2205
+ throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
2206
+ hint: "Find the identifier with search_works, or with list_works for the works of one person.",
2207
+ url: id.pageUrl
2208
+ });
2209
+ }
2210
+ if (recordKindOf(types.data) !== "work") {
2211
+ throw notFound(
2212
+ `"${id.id}" is described by data.bnf.fr, and it is not a work: it is typed ${types.data.join(", ")}.`,
2213
+ {
2214
+ hint: "This tool reads the editions of a work. For a person, list_works reaches the works they are credited with and get_author reads their record.",
2215
+ url: id.pageUrl
2216
+ }
2217
+ );
2218
+ }
2219
+ }
1917
2220
 
1918
- // src/tools/searchAuthors.ts
2221
+ // src/tools/listWorks.ts
1919
2222
  import { z as z7 } from "zod";
2223
+ var PERSON_TYPE2 = "http://xmlns.com/foaf/0.1/Person";
2224
+ var SCOPE = "A work is on this list because the BnF catalogue names this person as the creator of it. That is one link in the catalogue and not a bibliography: the catalogue credits a person on a record in other ways, and the BnF holds printed editions whose work it has never established as a record of its own.";
2225
+ var DATE_CAVEAT = "'date' and 'year' are the date of the work as the record gives it, which dates the work rather than any printing of it. A provisional record is made from a title the BnF has catalogued, so its date can be that of the printing it was made from. For the year a copy was printed, read list_editions.";
2226
+ var ORDER_CAVEAT = "Rows are ordered by the address the catalogue gives each work, which is neither chronological nor an order of importance, so a first work can appear anywhere in the list. That order is the one the pages are cut along, so reading page after page reaches every work once.";
2227
+ var listWorksDescription = [
2228
+ "List the works the Biblioth\xE8que nationale de France catalogue names one person the creator of, by the identifier search_authors returns. This is the tool for 'what did this person write'.",
2229
+ "Each row carries the title, the date the record gives the work, the form codes the catalogue points at, whether the record is established or provisional, and the identifier get_work, list_editions and find_digitised take.",
2230
+ "It is not a bibliography. It reports one link the catalogue holds, and the catalogue credits a person on a record in other ways and holds editions whose work it has never established, so a work missing here is not a work the person did not write.",
2231
+ "The form codes are the work-form vocabulary's own terms and carry no label in this dataset, so some read as words and some do not. A work stating no code has a form the catalogue does not state, so keeping the rows carrying one code finds the works that declare it and never all the works of that form.",
2232
+ "Rows are ordered by the address the catalogue gives each work, which is neither chronological nor an order of importance. No total is reported, because the catalogue counts nothing here and a count would read as a measure of what the person wrote."
2233
+ ].join(" ");
2234
+ var listWorksInput = strictInput({
2235
+ author_id: z7.string().min(1).max(200).describe(
2236
+ "The person identifier from search_authors, such as 'cb119219976'. A full data.bnf.fr address is accepted too."
2237
+ ),
2238
+ limit: z7.number().int().min(1).max(50).default(10),
2239
+ page: z7.number().int().min(1).max(100).default(1)
2240
+ });
2241
+ var authoredWorkRowSchema = z7.object({
2242
+ id: z7.string().describe("Pass this to get_work, list_editions or find_digitised."),
2243
+ title: z7.string().nullable(),
2244
+ date: z7.string().nullable().describe(
2245
+ "The date the record gives the work, as published. It dates the work rather than an edition of it."
2246
+ ),
2247
+ year: z7.number().int().nullable().describe("The same date as a number, when the record has one."),
2248
+ forms: z7.array(z7.string()).describe(
2249
+ "Terms of the BnF work-form vocabulary, as the terms themselves. The vocabulary publishes no label for them here, so they are not translated: some read as words, such as 'roman', and some do not, such as 'te'. An empty list is a form the record does not state, not a work without one."
2250
+ ),
2251
+ status: z7.enum(["established", "provisional"]).describe(
2252
+ "'established' is a work the BnF has settled as a record of its own. 'provisional' is a title it holds while a cataloguer settles it, whose identifier can change."
2253
+ ),
2254
+ source_url: z7.string()
2255
+ });
2256
+ var listWorksOutput = z7.object({
2257
+ author_id: z7.string(),
2258
+ works: z7.array(authoredWorkRowSchema),
2259
+ page: z7.number().int(),
2260
+ has_more: z7.boolean(),
2261
+ retrieved_at: retrievedAtSchema,
2262
+ notes: z7.array(z7.string())
2263
+ });
2264
+ function emptyPageNote(emptiness, page2, authorId) {
2265
+ if (emptiness === "past_the_end") {
2266
+ return `Page ${page2} holds no row because it sits past the last row of this listing. The catalogue does link works to "${authorId}", and they are on earlier pages: call again with page=1, or with a lower page number, to read them.`;
2267
+ }
2268
+ if (emptiness === "undetermined") {
2269
+ return `Page ${page2} holds no row, and the first page of the same listing could not be read to find out why. This is either the catalogue linking no work to "${authorId}", or a page sitting past the last row of a listing that does hold some. Call again with page=1: rows there mean the second.`;
2270
+ }
2271
+ return `The BnF catalogue links no work to "${authorId}" as its creator. The record exists and this link is empty on it, which is a statement about the catalogue rather than about what the person wrote. ${SEVERAL_RECORDS_CAVEAT}`;
2272
+ }
2273
+ function emptyPageBody(emptiness, page2, authorId) {
2274
+ if (emptiness === "past_the_end") {
2275
+ return `Page ${page2} of the works linked to "${authorId}" holds no row: it sits past the last row. Call again with page=1.`;
2276
+ }
2277
+ if (emptiness === "undetermined") {
2278
+ return `Page ${page2} of the works linked to "${authorId}" holds no row, and whether the rows stop before it could not be read. Call again with page=1.`;
2279
+ }
2280
+ return `The BnF catalogue links no work to "${authorId}" as its creator.`;
2281
+ }
2282
+ async function runListWorks(client, args) {
2283
+ try {
2284
+ const id = client.identify(args.author_id);
2285
+ const offset = (args.page - 1) * args.limit;
2286
+ const { data, cached, retrievedAt } = await client.worksByAuthor(id, args.limit, offset);
2287
+ const notes = [];
2288
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2289
+ const works = data.rows.map((row) => ({
2290
+ id: row.id,
2291
+ title: row.title,
2292
+ date: row.date,
2293
+ year: row.year,
2294
+ forms: row.forms,
2295
+ status: row.status,
2296
+ source_url: row.sourceUrl
2297
+ }));
2298
+ notes.push(SCOPE);
2299
+ if (data.skipped !== void 0 && data.skipped > 0) {
2300
+ notes.push(
2301
+ `${data.skipped} row(s) the endpoint sent could not be read, because each named a record at an address this client cannot address. The page is short by that much rather than the catalogue holding less.`
2302
+ );
2303
+ }
2304
+ let emptiness = "absent";
2305
+ if (works.length === 0) {
2306
+ emptiness = await classifyEmptyPage(args.page, async () => {
2307
+ const first = await client.worksByAuthor(id, 1, 0);
2308
+ return first.data.rows.length > 0;
2309
+ });
2310
+ if (emptiness === "absent") await assertPerson(client, id);
2311
+ notes.push(emptyPageNote(emptiness, args.page, id.id));
2312
+ } else {
2313
+ if (data.hasMore) {
2314
+ notes.push(
2315
+ `More works exist beyond this page. Ask for page ${args.page + 1}. This server reports no total: a count of the works one link reaches would read as a count of what the person wrote, and the catalogue does not carry that.`
2316
+ );
2317
+ }
2318
+ notes.push(`${FORMS_CAVEAT} ${FORMS_FILTER_CAVEAT}`);
2319
+ if (works.some((work) => work.status === "provisional")) notes.push(PROVISIONAL_CAVEAT);
2320
+ notes.push(ORDER_CAVEAT, DATE_CAVEAT);
2321
+ }
2322
+ const body = works.length === 0 ? emptyPageBody(emptiness, args.page, id.id) : `${works.length} work(s) the catalogue links to "${id.id}" as their creator:
2323
+ ${works.map((work, index) => {
2324
+ const when = work.date ? ` (${work.date})` : "";
2325
+ const forms = work.forms.length > 0 ? ` \xB7 form: ${work.forms.join(", ")}` : "";
2326
+ const status = work.status === "provisional" ? " \xB7 provisional record" : "";
2327
+ return `${index + 1}. ${work.title ?? work.id}${when}${forms}${status} \xB7 id: ${work.id}
2328
+ ${work.source_url}`;
2329
+ }).join("\n")}`;
2330
+ return ok(
2331
+ {
2332
+ author_id: id.id,
2333
+ works,
2334
+ page: args.page,
2335
+ has_more: data.hasMore,
2336
+ retrieved_at: retrievedAt,
2337
+ notes
2338
+ },
2339
+ body,
2340
+ { retrievedAt, notes, sourceUrl: id.pageUrl }
2341
+ );
2342
+ } catch (error) {
2343
+ return toToolError(error);
2344
+ }
2345
+ }
2346
+ async function assertPerson(client, id) {
2347
+ const types = await client.types(id);
2348
+ if (types.data.length === 0) {
2349
+ throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
2350
+ hint: "Find the identifier with search_authors rather than writing one.",
2351
+ url: id.pageUrl
2352
+ });
2353
+ }
2354
+ if (!types.data.includes(PERSON_TYPE2)) {
2355
+ throw notFound(
2356
+ `"${id.id}" is described by data.bnf.fr, and it is not a person: it is typed ${types.data.join(", ")}.`,
2357
+ {
2358
+ hint: "This tool walks from a person to the works they are credited with. For a work, list_editions reads its editions and get_work reads the record.",
2359
+ url: id.pageUrl
2360
+ }
2361
+ );
2362
+ }
2363
+ }
2364
+
2365
+ // src/tools/searchAuthors.ts
2366
+ import { z as z8 } from "zod";
1920
2367
  var searchAuthorsDescription = [
1921
2368
  "Find a person in the Biblioth\xE8que nationale de France authority file by name, and get the identifier the other tools take.",
1922
2369
  "This matches the name the BnF records, so it takes a surname, a full name or both names in either order. It does not read biographies, so it cannot find a person from what they wrote or what they did.",
2370
+ "It reads the person records and nothing else: an organisation, a conference or a place is outside it, and an answer holding no row says nothing about those.",
2371
+ "The match is letter for letter, so a name written with other accents or under another transliteration is a different search: try the spellings a library would use before concluding the BnF holds nobody of that name.",
1923
2372
  "Several rows can carry one name: the BnF keeps more than one authority record for some people, and many people share a name. Read 'birth_year', 'death_year' and 'role' to tell them apart, and show the caller the choice rather than picking one.",
1924
- "Rows come back in the order the index returned them, which is not an order of relevance."
2373
+ "Rows come back ordered by the address of the record, which is not an order of relevance. That order is the one the pages are cut along, so paging through a search reaches every match once.",
2374
+ "One search reads a fixed window of the index. 'index_window_full' says whether that window came back full: when it did, names sit past what was read, 'has_more' being false says where the reading stopped, and another reading of the same search can bring back other rows. Narrow the name to reach further."
1925
2375
  ].join(" ");
1926
2376
  var searchAuthorsInput = strictInput({
1927
- name: z7.string().min(1).max(200).describe("The person's name, such as 'Rimbaud' or 'Arthur Rimbaud'."),
1928
- limit: z7.number().int().min(1).max(50).default(10),
1929
- page: z7.number().int().min(1).max(100).default(1)
2377
+ name: z8.string().min(1).max(200).describe("The person's name, such as 'Rimbaud' or 'Arthur Rimbaud'."),
2378
+ limit: z8.number().int().min(1).max(50).default(10),
2379
+ page: z8.number().int().min(1).max(100).default(1)
1930
2380
  });
1931
- var authorRowSchema = z7.object({
1932
- id: z7.string().describe("Pass this to get_author, or to find_digitised."),
1933
- name: z7.string().nullable().describe("The name as data.bnf.fr writes it."),
1934
- label: z7.string().nullable().describe("The authority heading, which usually carries the dates in brackets."),
1935
- birth_year: z7.number().int().nullable(),
1936
- death_year: z7.number().int().nullable().describe("Null when the record states none."),
1937
- role: z7.string().nullable().describe(
2381
+ var authorRowSchema = z8.object({
2382
+ id: z8.string().describe("Pass this to get_author, or to find_digitised."),
2383
+ name: z8.string().nullable().describe("The name as data.bnf.fr writes it."),
2384
+ label: z8.string().nullable().describe("The authority heading, which usually carries the dates in brackets."),
2385
+ birth_year: z8.number().int().nullable(),
2386
+ death_year: z8.number().int().nullable().describe("Null when the record states none."),
2387
+ role: z8.string().nullable().describe(
1938
2388
  "What the record says the person did. This field holds a job title far more often than a sentence, and is frequently a single word."
1939
2389
  ),
1940
- source_url: z7.string().describe("The record's page. Show this when citing it.")
2390
+ source_url: z8.string().describe("The record's page. Show this when citing it.")
1941
2391
  });
1942
- var searchAuthorsOutput = z7.object({
1943
- name: z7.string().describe("The name asked for."),
1944
- words_searched: z7.array(z7.string()).describe("The words the index was actually asked for, after punctuation was set aside."),
1945
- authors: z7.array(authorRowSchema),
1946
- page: z7.number().int(),
1947
- has_more: z7.boolean().describe("Whether the index held at least one further match beyond this page."),
2392
+ var searchAuthorsOutput = z8.object({
2393
+ name: z8.string().describe("The name asked for."),
2394
+ words_searched: z8.array(z8.string()).describe(
2395
+ "The terms the index required, each of which has to appear in the name. A word is cut at an apostrophe and at a hyphen before the index sees it, so a name written as one word can appear here as two, and a record carrying the pieces apart is a match."
2396
+ ),
2397
+ authors: z8.array(authorRowSchema),
2398
+ page: z8.number().int(),
2399
+ has_more: z8.boolean().describe(
2400
+ "Whether the endpoint held at least one further row beyond this page, within the window the search read off the index. Read it alongside 'index_window_full': false on a full window says where the reading stopped rather than that the file holds nobody else."
2401
+ ),
2402
+ index_window_full: z8.boolean().nullable().describe(
2403
+ "Whether the fixed window this search reads off the index came back full. True means records carrying these words sit past what was read, so the rows here are a part of the matches and another reading can bring back other rows. False means the window held everything that matched. Null when the endpoint stated no occupancy, which claims nothing either way."
2404
+ ),
1948
2405
  retrieved_at: retrievedAtSchema,
1949
- notes: z7.array(z7.string())
2406
+ notes: z8.array(z8.string())
1950
2407
  });
2408
+ var SPELLING_CAVEAT = "The index matches the words letter for letter, so a name written with other accents, or under another transliteration, is a different search reaching different records. A short answer is no evidence that the file holds nobody else of that name: ask again with the accents dropped, with them restored, and under the transliterations a library uses.";
1951
2409
  function lifespan(birthYear, deathYear) {
1952
2410
  if (birthYear !== null && deathYear !== null) return ` (${birthYear}\u2013${deathYear})`;
1953
2411
  if (birthYear !== null) return ` (born ${birthYear})`;
1954
2412
  if (deathYear !== null) return ` (died ${deathYear})`;
1955
2413
  return "";
1956
2414
  }
1957
- function emptyPageNote(emptiness, page2, words) {
2415
+ function emptyPageNote2(emptiness, page2, words, windowFull) {
1958
2416
  const quoted = words.map((word) => `"${word}"`).join(", ");
2417
+ if (windowFull) {
2418
+ return `The window this search reads off the index came back full, and no person record survived the reading: every row of it named something the authority file types as another kind of heading. Whether a person carries ${quoted} in a name is unanswered here rather than answered no. Narrow the search, by adding a word or by writing a fuller form of the name, so that the window reaches further into the index.`;
2419
+ }
1959
2420
  if (emptiness === "past_the_end") {
1960
2421
  return `Page ${page2} holds no row because it sits past the last row of this search. Records do carry ${quoted} in a name, and they are on earlier pages: call again with page=1, or with a lower page number, to read them.`;
1961
2422
  }
1962
2423
  if (emptiness === "undetermined") {
1963
2424
  return `Page ${page2} holds no row, and reading the first page of the same search to find out why did not answer. This is either a name carrying ${quoted} matching no record, or a page sitting past the last row of a search that does match. Call again with page=1: rows there mean the second.`;
1964
2425
  }
1965
- return `No record in the BnF authority file carries every one of these words in a name: ${quoted}. A search here reads names, so a person known by a pen name is found under that name rather than their own.`;
2426
+ return `No person record in the BnF authority file carries every one of these words in a name: ${quoted}. This search reads the person records and nothing else, so an organisation, a conference, a place or a subject heading is outside what was looked at, whether or not the BnF holds a heading for one. It reads names, so a person known by a pen name is found under that name rather than their own.`;
1966
2427
  }
1967
- function emptyPageBody(emptiness, page2, name) {
2428
+ function emptyPageBody2(emptiness, page2, name, windowFull) {
2429
+ if (windowFull) {
2430
+ return `No person record survived the reading for "${name}", out of a window of the index that came back full. Narrow the search.`;
2431
+ }
1968
2432
  if (emptiness === "past_the_end") {
1969
2433
  return `Page ${page2} of the search for "${name}" holds no row: it sits past the last row. Call again with page=1.`;
1970
2434
  }
@@ -1975,7 +2439,8 @@ function emptyPageBody(emptiness, page2, name) {
1975
2439
  }
1976
2440
  async function runSearchAuthors(client, args) {
1977
2441
  try {
1978
- const words = client.words(args.name);
2442
+ const reading = client.searchReading(args.name);
2443
+ const words = reading.words;
1979
2444
  if (words.length === 0) {
1980
2445
  return toToolError(
1981
2446
  invalidInput(
@@ -1994,8 +2459,21 @@ async function runSearchAuthors(client, args) {
1994
2459
  );
1995
2460
  }
1996
2461
  const { data, cached, retrievedAt } = await client.searchAuthors(args.name, args.limit, offset);
2462
+ const terms = reading.terms;
2463
+ const windowFull = data.indexWindowFull ?? null;
1997
2464
  const notes = [];
1998
2465
  if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2466
+ if (windowFull) {
2467
+ notes.push(WINDOW_FULL_CAVEAT);
2468
+ if (!data.hasMore) notes.push(WINDOW_FULL_AND_NO_MORE);
2469
+ }
2470
+ const split = words.filter((word) => /['-]/u.test(word));
2471
+ if (split.length > 0) {
2472
+ notes.push(
2473
+ `${split.map((word) => `"${word}"`).join(", ")} reached the index as separate terms: it cuts a word at an apostrophe and at a hyphen, and requires each piece to appear in the name rather than in that arrangement, so a record writing the pieces apart is a match.`
2474
+ );
2475
+ }
2476
+ notes.push(...readingNotes(reading, "name"));
1999
2477
  const authors = data.rows.map((row) => ({
2000
2478
  id: row.id,
2001
2479
  name: row.name,
@@ -2006,6 +2484,16 @@ async function runSearchAuthors(client, args) {
2006
2484
  source_url: row.sourceUrl
2007
2485
  }));
2008
2486
  if (authors.length > 0) notes.push(NO_RANKING);
2487
+ notes.push(SPELLING_CAVEAT);
2488
+ const disputed = authors.map((author) => ({
2489
+ label: author.label ?? author.name,
2490
+ conflicts: headingYearConflicts(author.label, author.birth_year, author.death_year)
2491
+ })).filter((row) => row.conflicts.length > 0);
2492
+ if (disputed.length > 0) {
2493
+ notes.push(
2494
+ `${disputed.map((row) => `"${row.label}" states ${row.conflicts.join(", and ")}`).join("; ")}. A record stating its dates twice can disagree with itself, and the BnF publishes both. These fields are what tells two people of one name apart, so open source_url before telling them apart by a date the record disputes.`
2495
+ );
2496
+ }
2009
2497
  const repeated = /* @__PURE__ */ new Map();
2010
2498
  for (const author of authors) {
2011
2499
  const key = (author.name ?? "").toLocaleLowerCase("fr");
@@ -2024,18 +2512,13 @@ async function runSearchAuthors(client, args) {
2024
2512
  }
2025
2513
  let emptiness = "absent";
2026
2514
  if (authors.length === 0) {
2027
- emptiness = await classifyEmptyPage(args.page, async () => {
2515
+ emptiness = windowFull ? "absent" : await classifyEmptyPage(args.page, async () => {
2028
2516
  const first = await client.searchAuthors(args.name, 1, 0);
2029
2517
  return first.data.rows.length > 0;
2030
2518
  });
2031
- notes.push(emptyPageNote(emptiness, args.page, words));
2519
+ notes.push(emptyPageNote2(emptiness, args.page, terms, windowFull === true));
2032
2520
  }
2033
- if (words.length < args.name.trim().split(/\s+/).length) {
2034
- notes.push(
2035
- `The index was asked for ${words.map((word) => `"${word}"`).join(", ")}; anything in the name that was not a word was set aside.`
2036
- );
2037
- }
2038
- const body = authors.length === 0 ? emptyPageBody(emptiness, args.page, args.name) : `${authors.length} record(s) for "${args.name}":
2521
+ const body = authors.length === 0 ? emptyPageBody2(emptiness, args.page, args.name, windowFull === true) : `${authors.length} record(s) for "${args.name}":
2039
2522
  ${authors.map((author, index) => {
2040
2523
  const life = lifespan(author.birth_year, author.death_year);
2041
2524
  const role = author.role ? ` \xB7 ${author.role}` : "";
@@ -2045,10 +2528,11 @@ ${authors.map((author, index) => {
2045
2528
  return ok(
2046
2529
  {
2047
2530
  name: args.name,
2048
- words_searched: words,
2531
+ words_searched: terms,
2049
2532
  authors,
2050
2533
  page: args.page,
2051
2534
  has_more: data.hasMore,
2535
+ index_window_full: windowFull,
2052
2536
  retrieved_at: retrievedAt,
2053
2537
  notes
2054
2538
  },
@@ -2061,40 +2545,51 @@ ${authors.map((author, index) => {
2061
2545
  }
2062
2546
 
2063
2547
  // src/tools/searchWorks.ts
2064
- import { z as z8 } from "zod";
2548
+ import { z as z9 } from "zod";
2065
2549
  var searchWorksDescription = [
2066
2550
  "Find a work in the Biblioth\xE8que nationale de France catalogue by words in its title, and get the identifier get_work and list_editions take.",
2067
- "A row matches when its title carries every word given. The index returns no measure of how well a row matches, so the rows are in index order and the work a person would call the obvious answer can sit anywhere in the list or on a later page.",
2551
+ "A row matches when its title carries every word given. The index returns no measure of how well a row matches, so the rows are ordered by the address of the record and the work a person would call the obvious answer can sit anywhere in the list or on a later page.",
2068
2552
  "A study of a book carries the book's title, so a search for a famous title returns the criticism alongside the work. Read 'creators' to tell them apart.",
2069
- "This reads titles and nothing else. It cannot find the works of a named person: searching a person's name returns works written about them, whose creators are their critics.",
2070
- "'status' says whether the BnF has established the work as a record of its own or holds it provisionally under a title it has catalogued."
2553
+ "This reads titles and nothing else. Searching a person's name returns the works written about them, whose creators are their critics; list_works is what walks from a person to the works the catalogue names them the creator of.",
2554
+ "'status' says whether the BnF has established the work as a record of its own or holds it provisionally under a title it has catalogued.",
2555
+ "One search reads a fixed window of the index, and that window is read before the works are told apart from everything else the index holds, so even a short list can rest on a full one. 'index_window_full' says which: when it is true, titles sit past what was read, 'has_more' being false says where the reading stopped, and another reading of the same search can bring back other rows. Add a word to the title to reach further."
2071
2556
  ].join(" ");
2072
2557
  var searchWorksInput = strictInput({
2073
- title: z8.string().min(1).max(200).describe("Words from the title, such as 'saison enfer'. Every word given has to appear."),
2074
- limit: z8.number().int().min(1).max(50).default(10),
2075
- page: z8.number().int().min(1).max(100).default(1)
2558
+ title: z9.string().min(1).max(200).describe("Words from the title, such as 'saison enfer'. Every word given has to appear."),
2559
+ limit: z9.number().int().min(1).max(50).default(10),
2560
+ page: z9.number().int().min(1).max(100).default(1)
2076
2561
  });
2077
- var workRowSchema = z8.object({
2078
- id: z8.string().describe("Pass this to get_work, list_editions or find_digitised."),
2079
- title: z8.string().nullable(),
2080
- date: z8.string().nullable().describe("The year the record gives the work, as published."),
2081
- creators: z8.array(z8.object({ id: z8.string(), name: z8.string().nullable() })).describe("Everyone the record credits. Empty when the record credits nobody."),
2082
- status: z8.enum(["established", "provisional"]).describe(
2562
+ var workRowSchema = z9.object({
2563
+ id: z9.string().describe("Pass this to get_work, list_editions or find_digitised."),
2564
+ title: z9.string().nullable(),
2565
+ date: z9.string().nullable().describe("The year the record gives the work, as published."),
2566
+ creators: z9.array(z9.object({ id: z9.string(), name: z9.string().nullable() })).describe("Everyone the record credits. Empty when the record credits nobody."),
2567
+ status: z9.enum(["established", "provisional"]).describe(
2083
2568
  "'established' is a work the BnF has settled as a record of its own. 'provisional' is a title it holds while a cataloguer settles it, whose identifier can change."
2084
2569
  ),
2085
- source_url: z8.string()
2570
+ source_url: z9.string()
2086
2571
  });
2087
- var searchWorksOutput = z8.object({
2088
- title: z8.string().describe("The words asked for."),
2089
- words_searched: z8.array(z8.string()),
2090
- works: z8.array(workRowSchema),
2091
- page: z8.number().int(),
2092
- has_more: z8.boolean(),
2572
+ var searchWorksOutput = z9.object({
2573
+ title: z9.string().describe("The words asked for."),
2574
+ words_searched: z9.array(z9.string()).describe(
2575
+ "The terms the index required, each of which has to appear in the title. A word is cut at an apostrophe and at a hyphen before the index sees it, so a word given as one can appear here as two, and a record carrying the pieces apart is a match."
2576
+ ),
2577
+ works: z9.array(workRowSchema),
2578
+ page: z9.number().int(),
2579
+ has_more: z9.boolean().describe(
2580
+ "Whether the endpoint held at least one further row beyond this page, within the window the search read off the index. Read it alongside 'index_window_full': false on a full window says where the reading stopped rather than that the catalogue holds nothing else."
2581
+ ),
2582
+ index_window_full: z9.boolean().nullable().describe(
2583
+ "Whether the fixed window this search reads off the index came back full. The window is read before the works are separated from the rest of the index, so a short list can rest on a full window. True means titles carrying these words sit past what was read, and another reading can bring back other rows. False means the window held everything that matched. Null when the endpoint stated no occupancy."
2584
+ ),
2093
2585
  retrieved_at: retrievedAtSchema,
2094
- notes: z8.array(z8.string())
2586
+ notes: z9.array(z9.string())
2095
2587
  });
2096
- function emptyPageNote2(emptiness, page2, words) {
2588
+ function emptyPageNote3(emptiness, page2, words, windowFull) {
2097
2589
  const quoted = words.map((word) => `"${word}"`).join(", ");
2590
+ if (windowFull) {
2591
+ return `The window this search reads off the index came back full, and no work record survived the reading: every row of it named something the catalogue types as another kind of record. Whether a work carries ${quoted} in its title is unanswered here rather than answered no. Add a word to the title so that the window reaches further into the index.`;
2592
+ }
2098
2593
  if (emptiness === "past_the_end") {
2099
2594
  return `Page ${page2} holds no row because it sits past the last row of this search. Works do carry ${quoted} in their title, and they are on earlier pages: call again with page=1, or with a lower page number, to read them.`;
2100
2595
  }
@@ -2103,7 +2598,10 @@ function emptyPageNote2(emptiness, page2, words) {
2103
2598
  }
2104
2599
  return `No work in the BnF catalogue has a title carrying every one of these words: ${quoted}. Every word given has to appear, so dropping one widens the search.`;
2105
2600
  }
2106
- function emptyPageBody2(emptiness, page2, title) {
2601
+ function emptyPageBody3(emptiness, page2, title, windowFull) {
2602
+ if (windowFull) {
2603
+ return `No work record survived the reading for "${title}", out of a window of the index that came back full. Add a word to the title.`;
2604
+ }
2107
2605
  if (emptiness === "past_the_end") {
2108
2606
  return `Page ${page2} of the search for "${title}" holds no row: it sits past the last row. Call again with page=1.`;
2109
2607
  }
@@ -2114,7 +2612,8 @@ function emptyPageBody2(emptiness, page2, title) {
2114
2612
  }
2115
2613
  async function runSearchWorks(client, args) {
2116
2614
  try {
2117
- const words = client.words(args.title);
2615
+ const reading = client.searchReading(args.title);
2616
+ const words = reading.words;
2118
2617
  if (words.length === 0) {
2119
2618
  return toToolError(
2120
2619
  invalidInput(
@@ -2133,8 +2632,21 @@ async function runSearchWorks(client, args) {
2133
2632
  );
2134
2633
  }
2135
2634
  const { data, cached, retrievedAt } = await client.searchWorks(args.title, args.limit, offset);
2635
+ const terms = reading.terms;
2636
+ const windowFull = data.indexWindowFull ?? null;
2136
2637
  const notes = [];
2137
2638
  if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2639
+ if (windowFull) {
2640
+ notes.push(WINDOW_FULL_CAVEAT);
2641
+ if (!data.hasMore) notes.push(WINDOW_FULL_AND_NO_MORE);
2642
+ }
2643
+ const split = words.filter((word) => /['-]/u.test(word));
2644
+ if (split.length > 0) {
2645
+ notes.push(
2646
+ `${split.map((word) => `"${word}"`).join(", ")} reached the index as separate terms: it cuts a word at an apostrophe and at a hyphen, and requires each piece to appear in the title rather than in that arrangement, so a record writing the pieces apart is a match.`
2647
+ );
2648
+ }
2649
+ notes.push(...readingNotes(reading, "title"));
2138
2650
  const works = data.rows.map((row) => ({
2139
2651
  id: row.id,
2140
2652
  title: row.title,
@@ -2145,9 +2657,9 @@ async function runSearchWorks(client, args) {
2145
2657
  }));
2146
2658
  if (works.length > 0) notes.push(NO_RANKING);
2147
2659
  if (works.some((work) => work.status === "provisional")) notes.push(PROVISIONAL_CAVEAT);
2148
- if (words.length > 2) {
2660
+ if (terms.length > 2) {
2149
2661
  notes.push(
2150
- `All ${words.length} words were required: ${words.map((word) => `"${word}"`).join(", ")}. A record catalogued under a shorter form of the title carries fewer of them and falls out of the answer, so dropping a word widens the search.`
2662
+ `All ${terms.length} terms were required: ${terms.map((term) => `"${term}"`).join(", ")}. A record catalogued under a shorter form of the title carries fewer of them and falls out of the answer, so dropping a word widens the search.`
2151
2663
  );
2152
2664
  }
2153
2665
  if (works.length > 0 && works.every((work) => work.status === "provisional")) {
@@ -2162,13 +2674,13 @@ async function runSearchWorks(client, args) {
2162
2674
  }
2163
2675
  let emptiness = "absent";
2164
2676
  if (works.length === 0) {
2165
- emptiness = await classifyEmptyPage(args.page, async () => {
2677
+ emptiness = windowFull ? "absent" : await classifyEmptyPage(args.page, async () => {
2166
2678
  const first = await client.searchWorks(args.title, 1, 0);
2167
2679
  return first.data.rows.length > 0;
2168
2680
  });
2169
- notes.push(emptyPageNote2(emptiness, args.page, words));
2681
+ notes.push(emptyPageNote3(emptiness, args.page, terms, windowFull === true));
2170
2682
  }
2171
- const body = works.length === 0 ? emptyPageBody2(emptiness, args.page, args.title) : `${works.length} work(s) whose title carries ${words.map((word) => `"${word}"`).join(" and ")}:
2683
+ const body = works.length === 0 ? emptyPageBody3(emptiness, args.page, args.title, windowFull === true) : `${works.length} work(s) whose title carries ${terms.map((term) => `"${term}"`).join(" and ")}:
2172
2684
  ${works.map((work, index) => {
2173
2685
  const by = work.creators.length > 0 ? ` \xB7 ${work.creators.map((creator) => creator.name ?? creator.id).join(", ")}` : "";
2174
2686
  const when = work.date ? ` (${work.date})` : "";
@@ -2179,10 +2691,11 @@ ${works.map((work, index) => {
2179
2691
  return ok(
2180
2692
  {
2181
2693
  title: args.title,
2182
- words_searched: words,
2694
+ words_searched: terms,
2183
2695
  works,
2184
2696
  page: args.page,
2185
2697
  has_more: data.hasMore,
2698
+ index_window_full: windowFull,
2186
2699
  retrieved_at: retrievedAt,
2187
2700
  notes
2188
2701
  },
@@ -2203,9 +2716,10 @@ var READ_ONLY = {
2203
2716
  };
2204
2717
  var INSTRUCTIONS = [
2205
2718
  "Tools for data.bnf.fr, the open catalogue of the Biblioth\xE8que nationale de France. No API key and no account are needed.",
2206
- "The usual path is search_authors or search_works to get an identifier, then get_author, get_work or list_editions to read the record it names.",
2719
+ "The usual path is search_authors or search_works to get an identifier, then get_author, get_work or list_editions to read the record it names. What a person wrote is list_works, which walks from a person to the works the catalogue names them the creator of; a title search cannot answer that, because it reads titles and a person's name in a title belongs to the books written about them.",
2720
+ "A list of a person's works is one link the catalogue holds and not a bibliography: the catalogue credits a person on a record in other ways, and the BnF holds editions whose work it has never established as a record of its own, so a work missing from the list is not a work the person did not write. The catalogue states a work's form as a code from a vocabulary it publishes no label for, and it states no form at all on many works, so keeping the rows carrying one code finds the works that declare it and never all the works of that form.",
2207
2721
  "The BnF publishes these metadata on one condition: name the source and state the date they were retrieved. Every answer carries 'retrieved_at' for that reason, and repeating both alongside what you show is what the licence asks for.",
2208
- "Both searches read a full-text index that answers whether a title or a name matches and never scores how well, so rows come back in index order. Never present the first row as the best answer, and never describe a count as a ranking.",
2722
+ "Both searches read a full-text index that answers whether a title or a name matches and never scores how well, so rows come back ordered by the address of the record. Never present the first row as the best answer, and never describe a count as a ranking. Every listing here is paged along that same order, so a page and the page after it hold different records and nothing between them is skipped.",
2209
2723
  "One person can hold several authority records, and search_authors returns all of them: show the caller the choice rather than picking one.",
2210
2724
  "A work record is either established, with an ARK of its own, or provisional, addressed under 'temp-work' while a cataloguer settles it. A provisional identifier can change, so prefer an established one when citing.",
2211
2725
  "The catalogue describes what the BnF holds; it does not hold the texts. A record can point at a digitised copy on Gallica, and those links are returned as addresses for a person to open. This server never requests gallica.bnf.fr, because the BnF places its metadata and its digitised contents under two different regimes, so it can say a document exists at a link and nothing about what is there.",
@@ -2279,6 +2793,17 @@ function createServer(options = {}) {
2279
2793
  },
2280
2794
  async (args) => runListEditions(client, args)
2281
2795
  );
2796
+ server.registerTool(
2797
+ "list_works",
2798
+ {
2799
+ title: "List the works a person is credited with",
2800
+ description: listWorksDescription,
2801
+ inputSchema: listWorksInput,
2802
+ outputSchema: listWorksOutput,
2803
+ annotations: READ_ONLY
2804
+ },
2805
+ async (args) => runListWorks(client, args)
2806
+ );
2282
2807
  server.registerTool(
2283
2808
  "find_digitised",
2284
2809
  {