@kainoa/5c-mcp 0.4.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/server.js ADDED
@@ -0,0 +1,4968 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server.ts
4
+ import { readFileSync, realpathSync } from "node:fs";
5
+ import { createServer } from "node:http";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ // ../core/dist/index.js
9
+ import { createHash } from "node:crypto";
10
+ import {
11
+ mkdir,
12
+ open,
13
+ readFile,
14
+ rename,
15
+ unlink,
16
+ writeFile
17
+ } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { z } from "zod";
21
+
22
+ // ../../node_modules/.pnpm/minisearch@7.2.0/node_modules/minisearch/dist/es/index.js
23
+ var ENTRIES = "ENTRIES";
24
+ var KEYS = "KEYS";
25
+ var VALUES = "VALUES";
26
+ var LEAF = "";
27
+ var TreeIterator = class {
28
+ constructor(set, type) {
29
+ const node = set._tree;
30
+ const keys = Array.from(node.keys());
31
+ this.set = set;
32
+ this._type = type;
33
+ this._path = keys.length > 0 ? [{ node, keys }] : [];
34
+ }
35
+ next() {
36
+ const value = this.dive();
37
+ this.backtrack();
38
+ return value;
39
+ }
40
+ dive() {
41
+ if (this._path.length === 0) {
42
+ return { done: true, value: void 0 };
43
+ }
44
+ const { node, keys } = last$1(this._path);
45
+ if (last$1(keys) === LEAF) {
46
+ return { done: false, value: this.result() };
47
+ }
48
+ const child = node.get(last$1(keys));
49
+ this._path.push({ node: child, keys: Array.from(child.keys()) });
50
+ return this.dive();
51
+ }
52
+ backtrack() {
53
+ if (this._path.length === 0) {
54
+ return;
55
+ }
56
+ const keys = last$1(this._path).keys;
57
+ keys.pop();
58
+ if (keys.length > 0) {
59
+ return;
60
+ }
61
+ this._path.pop();
62
+ this.backtrack();
63
+ }
64
+ key() {
65
+ return this.set._prefix + this._path.map(({ keys }) => last$1(keys)).filter((key) => key !== LEAF).join("");
66
+ }
67
+ value() {
68
+ return last$1(this._path).node.get(LEAF);
69
+ }
70
+ result() {
71
+ switch (this._type) {
72
+ case VALUES:
73
+ return this.value();
74
+ case KEYS:
75
+ return this.key();
76
+ default:
77
+ return [this.key(), this.value()];
78
+ }
79
+ }
80
+ [Symbol.iterator]() {
81
+ return this;
82
+ }
83
+ };
84
+ var last$1 = (array) => {
85
+ return array[array.length - 1];
86
+ };
87
+ var fuzzySearch = (node, query, maxDistance) => {
88
+ const results = /* @__PURE__ */ new Map();
89
+ if (query === void 0)
90
+ return results;
91
+ const n = query.length + 1;
92
+ const m = n + maxDistance;
93
+ const matrix = new Uint8Array(m * n).fill(maxDistance + 1);
94
+ for (let j = 0; j < n; ++j)
95
+ matrix[j] = j;
96
+ for (let i = 1; i < m; ++i)
97
+ matrix[i * n] = i;
98
+ recurse(node, query, maxDistance, results, matrix, 1, n, "");
99
+ return results;
100
+ };
101
+ var recurse = (node, query, maxDistance, results, matrix, m, n, prefix) => {
102
+ const offset = m * n;
103
+ key: for (const key of node.keys()) {
104
+ if (key === LEAF) {
105
+ const distance = matrix[offset - 1];
106
+ if (distance <= maxDistance) {
107
+ results.set(prefix, [node.get(key), distance]);
108
+ }
109
+ } else {
110
+ let i = m;
111
+ for (let pos = 0; pos < key.length; ++pos, ++i) {
112
+ const char = key[pos];
113
+ const thisRowOffset = n * i;
114
+ const prevRowOffset = thisRowOffset - n;
115
+ let minDistance = matrix[thisRowOffset];
116
+ const jmin = Math.max(0, i - maxDistance - 1);
117
+ const jmax = Math.min(n - 1, i + maxDistance);
118
+ for (let j = jmin; j < jmax; ++j) {
119
+ const different = char !== query[j];
120
+ const rpl = matrix[prevRowOffset + j] + +different;
121
+ const del = matrix[prevRowOffset + j + 1] + 1;
122
+ const ins = matrix[thisRowOffset + j] + 1;
123
+ const dist = matrix[thisRowOffset + j + 1] = Math.min(rpl, del, ins);
124
+ if (dist < minDistance)
125
+ minDistance = dist;
126
+ }
127
+ if (minDistance > maxDistance) {
128
+ continue key;
129
+ }
130
+ }
131
+ recurse(node.get(key), query, maxDistance, results, matrix, i, n, prefix + key);
132
+ }
133
+ }
134
+ };
135
+ var SearchableMap = class _SearchableMap {
136
+ /**
137
+ * The constructor is normally called without arguments, creating an empty
138
+ * map. In order to create a {@link SearchableMap} from an iterable or from an
139
+ * object, check {@link SearchableMap.from} and {@link
140
+ * SearchableMap.fromObject}.
141
+ *
142
+ * The constructor arguments are for internal use, when creating derived
143
+ * mutable views of a map at a prefix.
144
+ */
145
+ constructor(tree = /* @__PURE__ */ new Map(), prefix = "") {
146
+ this._size = void 0;
147
+ this._tree = tree;
148
+ this._prefix = prefix;
149
+ }
150
+ /**
151
+ * Creates and returns a mutable view of this {@link SearchableMap},
152
+ * containing only entries that share the given prefix.
153
+ *
154
+ * ### Usage:
155
+ *
156
+ * ```javascript
157
+ * let map = new SearchableMap()
158
+ * map.set("unicorn", 1)
159
+ * map.set("universe", 2)
160
+ * map.set("university", 3)
161
+ * map.set("unique", 4)
162
+ * map.set("hello", 5)
163
+ *
164
+ * let uni = map.atPrefix("uni")
165
+ * uni.get("unique") // => 4
166
+ * uni.get("unicorn") // => 1
167
+ * uni.get("hello") // => undefined
168
+ *
169
+ * let univer = map.atPrefix("univer")
170
+ * univer.get("unique") // => undefined
171
+ * univer.get("universe") // => 2
172
+ * univer.get("university") // => 3
173
+ * ```
174
+ *
175
+ * @param prefix The prefix
176
+ * @return A {@link SearchableMap} representing a mutable view of the original
177
+ * Map at the given prefix
178
+ */
179
+ atPrefix(prefix) {
180
+ if (!prefix.startsWith(this._prefix)) {
181
+ throw new Error("Mismatched prefix");
182
+ }
183
+ const [node, path] = trackDown(this._tree, prefix.slice(this._prefix.length));
184
+ if (node === void 0) {
185
+ const [parentNode, key] = last(path);
186
+ for (const k of parentNode.keys()) {
187
+ if (k !== LEAF && k.startsWith(key)) {
188
+ const node2 = /* @__PURE__ */ new Map();
189
+ node2.set(k.slice(key.length), parentNode.get(k));
190
+ return new _SearchableMap(node2, prefix);
191
+ }
192
+ }
193
+ }
194
+ return new _SearchableMap(node, prefix);
195
+ }
196
+ /**
197
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/clear
198
+ */
199
+ clear() {
200
+ this._size = void 0;
201
+ this._tree.clear();
202
+ }
203
+ /**
204
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/delete
205
+ * @param key Key to delete
206
+ */
207
+ delete(key) {
208
+ this._size = void 0;
209
+ return remove(this._tree, key);
210
+ }
211
+ /**
212
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries
213
+ * @return An iterator iterating through `[key, value]` entries.
214
+ */
215
+ entries() {
216
+ return new TreeIterator(this, ENTRIES);
217
+ }
218
+ /**
219
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach
220
+ * @param fn Iteration function
221
+ */
222
+ forEach(fn) {
223
+ for (const [key, value] of this) {
224
+ fn(key, value, this);
225
+ }
226
+ }
227
+ /**
228
+ * Returns a Map of all the entries that have a key within the given edit
229
+ * distance from the search key. The keys of the returned Map are the matching
230
+ * keys, while the values are two-element arrays where the first element is
231
+ * the value associated to the key, and the second is the edit distance of the
232
+ * key to the search key.
233
+ *
234
+ * ### Usage:
235
+ *
236
+ * ```javascript
237
+ * let map = new SearchableMap()
238
+ * map.set('hello', 'world')
239
+ * map.set('hell', 'yeah')
240
+ * map.set('ciao', 'mondo')
241
+ *
242
+ * // Get all entries that match the key 'hallo' with a maximum edit distance of 2
243
+ * map.fuzzyGet('hallo', 2)
244
+ * // => Map(2) { 'hello' => ['world', 1], 'hell' => ['yeah', 2] }
245
+ *
246
+ * // In the example, the "hello" key has value "world" and edit distance of 1
247
+ * // (change "e" to "a"), the key "hell" has value "yeah" and edit distance of 2
248
+ * // (change "e" to "a", delete "o")
249
+ * ```
250
+ *
251
+ * @param key The search key
252
+ * @param maxEditDistance The maximum edit distance (Levenshtein)
253
+ * @return A Map of the matching keys to their value and edit distance
254
+ */
255
+ fuzzyGet(key, maxEditDistance) {
256
+ return fuzzySearch(this._tree, key, maxEditDistance);
257
+ }
258
+ /**
259
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/get
260
+ * @param key Key to get
261
+ * @return Value associated to the key, or `undefined` if the key is not
262
+ * found.
263
+ */
264
+ get(key) {
265
+ const node = lookup(this._tree, key);
266
+ return node !== void 0 ? node.get(LEAF) : void 0;
267
+ }
268
+ /**
269
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/has
270
+ * @param key Key
271
+ * @return True if the key is in the map, false otherwise
272
+ */
273
+ has(key) {
274
+ const node = lookup(this._tree, key);
275
+ return node !== void 0 && node.has(LEAF);
276
+ }
277
+ /**
278
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys
279
+ * @return An `Iterable` iterating through keys
280
+ */
281
+ keys() {
282
+ return new TreeIterator(this, KEYS);
283
+ }
284
+ /**
285
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/set
286
+ * @param key Key to set
287
+ * @param value Value to associate to the key
288
+ * @return The {@link SearchableMap} itself, to allow chaining
289
+ */
290
+ set(key, value) {
291
+ if (typeof key !== "string") {
292
+ throw new Error("key must be a string");
293
+ }
294
+ this._size = void 0;
295
+ const node = createPath(this._tree, key);
296
+ node.set(LEAF, value);
297
+ return this;
298
+ }
299
+ /**
300
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/size
301
+ */
302
+ get size() {
303
+ if (this._size) {
304
+ return this._size;
305
+ }
306
+ this._size = 0;
307
+ const iter = this.entries();
308
+ while (!iter.next().done)
309
+ this._size += 1;
310
+ return this._size;
311
+ }
312
+ /**
313
+ * Updates the value at the given key using the provided function. The function
314
+ * is called with the current value at the key, and its return value is used as
315
+ * the new value to be set.
316
+ *
317
+ * ### Example:
318
+ *
319
+ * ```javascript
320
+ * // Increment the current value by one
321
+ * searchableMap.update('somekey', (currentValue) => currentValue == null ? 0 : currentValue + 1)
322
+ * ```
323
+ *
324
+ * If the value at the given key is or will be an object, it might not require
325
+ * re-assignment. In that case it is better to use `fetch()`, because it is
326
+ * faster.
327
+ *
328
+ * @param key The key to update
329
+ * @param fn The function used to compute the new value from the current one
330
+ * @return The {@link SearchableMap} itself, to allow chaining
331
+ */
332
+ update(key, fn) {
333
+ if (typeof key !== "string") {
334
+ throw new Error("key must be a string");
335
+ }
336
+ this._size = void 0;
337
+ const node = createPath(this._tree, key);
338
+ node.set(LEAF, fn(node.get(LEAF)));
339
+ return this;
340
+ }
341
+ /**
342
+ * Fetches the value of the given key. If the value does not exist, calls the
343
+ * given function to create a new value, which is inserted at the given key
344
+ * and subsequently returned.
345
+ *
346
+ * ### Example:
347
+ *
348
+ * ```javascript
349
+ * const map = searchableMap.fetch('somekey', () => new Map())
350
+ * map.set('foo', 'bar')
351
+ * ```
352
+ *
353
+ * @param key The key to update
354
+ * @param initial A function that creates a new value if the key does not exist
355
+ * @return The existing or new value at the given key
356
+ */
357
+ fetch(key, initial) {
358
+ if (typeof key !== "string") {
359
+ throw new Error("key must be a string");
360
+ }
361
+ this._size = void 0;
362
+ const node = createPath(this._tree, key);
363
+ let value = node.get(LEAF);
364
+ if (value === void 0) {
365
+ node.set(LEAF, value = initial());
366
+ }
367
+ return value;
368
+ }
369
+ /**
370
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/values
371
+ * @return An `Iterable` iterating through values.
372
+ */
373
+ values() {
374
+ return new TreeIterator(this, VALUES);
375
+ }
376
+ /**
377
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/@@iterator
378
+ */
379
+ [Symbol.iterator]() {
380
+ return this.entries();
381
+ }
382
+ /**
383
+ * Creates a {@link SearchableMap} from an `Iterable` of entries
384
+ *
385
+ * @param entries Entries to be inserted in the {@link SearchableMap}
386
+ * @return A new {@link SearchableMap} with the given entries
387
+ */
388
+ static from(entries) {
389
+ const tree = new _SearchableMap();
390
+ for (const [key, value] of entries) {
391
+ tree.set(key, value);
392
+ }
393
+ return tree;
394
+ }
395
+ /**
396
+ * Creates a {@link SearchableMap} from the iterable properties of a JavaScript object
397
+ *
398
+ * @param object Object of entries for the {@link SearchableMap}
399
+ * @return A new {@link SearchableMap} with the given entries
400
+ */
401
+ static fromObject(object) {
402
+ return _SearchableMap.from(Object.entries(object));
403
+ }
404
+ };
405
+ var trackDown = (tree, key, path = []) => {
406
+ if (key.length === 0 || tree == null) {
407
+ return [tree, path];
408
+ }
409
+ for (const k of tree.keys()) {
410
+ if (k !== LEAF && key.startsWith(k)) {
411
+ path.push([tree, k]);
412
+ return trackDown(tree.get(k), key.slice(k.length), path);
413
+ }
414
+ }
415
+ path.push([tree, key]);
416
+ return trackDown(void 0, "", path);
417
+ };
418
+ var lookup = (tree, key) => {
419
+ if (key.length === 0 || tree == null) {
420
+ return tree;
421
+ }
422
+ for (const k of tree.keys()) {
423
+ if (k !== LEAF && key.startsWith(k)) {
424
+ return lookup(tree.get(k), key.slice(k.length));
425
+ }
426
+ }
427
+ };
428
+ var createPath = (node, key) => {
429
+ const keyLength = key.length;
430
+ outer: for (let pos = 0; node && pos < keyLength; ) {
431
+ for (const k of node.keys()) {
432
+ if (k !== LEAF && key[pos] === k[0]) {
433
+ const len = Math.min(keyLength - pos, k.length);
434
+ let offset = 1;
435
+ while (offset < len && key[pos + offset] === k[offset])
436
+ ++offset;
437
+ const child2 = node.get(k);
438
+ if (offset === k.length) {
439
+ node = child2;
440
+ } else {
441
+ const intermediate = /* @__PURE__ */ new Map();
442
+ intermediate.set(k.slice(offset), child2);
443
+ node.set(key.slice(pos, pos + offset), intermediate);
444
+ node.delete(k);
445
+ node = intermediate;
446
+ }
447
+ pos += offset;
448
+ continue outer;
449
+ }
450
+ }
451
+ const child = /* @__PURE__ */ new Map();
452
+ node.set(key.slice(pos), child);
453
+ return child;
454
+ }
455
+ return node;
456
+ };
457
+ var remove = (tree, key) => {
458
+ const [node, path] = trackDown(tree, key);
459
+ if (node === void 0) {
460
+ return;
461
+ }
462
+ node.delete(LEAF);
463
+ if (node.size === 0) {
464
+ cleanup(path);
465
+ } else if (node.size === 1) {
466
+ const [key2, value] = node.entries().next().value;
467
+ merge(path, key2, value);
468
+ }
469
+ };
470
+ var cleanup = (path) => {
471
+ if (path.length === 0) {
472
+ return;
473
+ }
474
+ const [node, key] = last(path);
475
+ node.delete(key);
476
+ if (node.size === 0) {
477
+ cleanup(path.slice(0, -1));
478
+ } else if (node.size === 1) {
479
+ const [key2, value] = node.entries().next().value;
480
+ if (key2 !== LEAF) {
481
+ merge(path.slice(0, -1), key2, value);
482
+ }
483
+ }
484
+ };
485
+ var merge = (path, key, value) => {
486
+ if (path.length === 0) {
487
+ return;
488
+ }
489
+ const [node, nodeKey] = last(path);
490
+ node.set(nodeKey + key, value);
491
+ node.delete(nodeKey);
492
+ };
493
+ var last = (array) => {
494
+ return array[array.length - 1];
495
+ };
496
+ var OR = "or";
497
+ var AND = "and";
498
+ var AND_NOT = "and_not";
499
+ var MiniSearch = class _MiniSearch {
500
+ /**
501
+ * @param options Configuration options
502
+ *
503
+ * ### Examples:
504
+ *
505
+ * ```javascript
506
+ * // Create a search engine that indexes the 'title' and 'text' fields of your
507
+ * // documents:
508
+ * const miniSearch = new MiniSearch({ fields: ['title', 'text'] })
509
+ * ```
510
+ *
511
+ * ### ID Field:
512
+ *
513
+ * ```javascript
514
+ * // Your documents are assumed to include a unique 'id' field, but if you want
515
+ * // to use a different field for document identification, you can set the
516
+ * // 'idField' option:
517
+ * const miniSearch = new MiniSearch({ idField: 'key', fields: ['title', 'text'] })
518
+ * ```
519
+ *
520
+ * ### Options and defaults:
521
+ *
522
+ * ```javascript
523
+ * // The full set of options (here with their default value) is:
524
+ * const miniSearch = new MiniSearch({
525
+ * // idField: field that uniquely identifies a document
526
+ * idField: 'id',
527
+ *
528
+ * // extractField: function used to get the value of a field in a document.
529
+ * // By default, it assumes the document is a flat object with field names as
530
+ * // property keys and field values as string property values, but custom logic
531
+ * // can be implemented by setting this option to a custom extractor function.
532
+ * extractField: (document, fieldName) => document[fieldName],
533
+ *
534
+ * // tokenize: function used to split fields into individual terms. By
535
+ * // default, it is also used to tokenize search queries, unless a specific
536
+ * // `tokenize` search option is supplied. When tokenizing an indexed field,
537
+ * // the field name is passed as the second argument.
538
+ * tokenize: (string, _fieldName) => string.split(SPACE_OR_PUNCTUATION),
539
+ *
540
+ * // processTerm: function used to process each tokenized term before
541
+ * // indexing. It can be used for stemming and normalization. Return a falsy
542
+ * // value in order to discard a term. By default, it is also used to process
543
+ * // search queries, unless a specific `processTerm` option is supplied as a
544
+ * // search option. When processing a term from a indexed field, the field
545
+ * // name is passed as the second argument.
546
+ * processTerm: (term, _fieldName) => term.toLowerCase(),
547
+ *
548
+ * // searchOptions: default search options, see the `search` method for
549
+ * // details
550
+ * searchOptions: undefined,
551
+ *
552
+ * // fields: document fields to be indexed. Mandatory, but not set by default
553
+ * fields: undefined
554
+ *
555
+ * // storeFields: document fields to be stored and returned as part of the
556
+ * // search results.
557
+ * storeFields: []
558
+ * })
559
+ * ```
560
+ */
561
+ constructor(options) {
562
+ if ((options === null || options === void 0 ? void 0 : options.fields) == null) {
563
+ throw new Error('MiniSearch: option "fields" must be provided');
564
+ }
565
+ const autoVacuum = options.autoVacuum == null || options.autoVacuum === true ? defaultAutoVacuumOptions : options.autoVacuum;
566
+ this._options = {
567
+ ...defaultOptions,
568
+ ...options,
569
+ autoVacuum,
570
+ searchOptions: { ...defaultSearchOptions, ...options.searchOptions || {} },
571
+ autoSuggestOptions: { ...defaultAutoSuggestOptions, ...options.autoSuggestOptions || {} }
572
+ };
573
+ this._index = new SearchableMap();
574
+ this._documentCount = 0;
575
+ this._documentIds = /* @__PURE__ */ new Map();
576
+ this._idToShortId = /* @__PURE__ */ new Map();
577
+ this._fieldIds = {};
578
+ this._fieldLength = /* @__PURE__ */ new Map();
579
+ this._avgFieldLength = [];
580
+ this._nextId = 0;
581
+ this._storedFields = /* @__PURE__ */ new Map();
582
+ this._dirtCount = 0;
583
+ this._currentVacuum = null;
584
+ this._enqueuedVacuum = null;
585
+ this._enqueuedVacuumConditions = defaultVacuumConditions;
586
+ this.addFields(this._options.fields);
587
+ }
588
+ /**
589
+ * Adds a document to the index
590
+ *
591
+ * @param document The document to be indexed
592
+ */
593
+ add(document) {
594
+ const { extractField, stringifyField, tokenize, processTerm, fields, idField } = this._options;
595
+ const id = extractField(document, idField);
596
+ if (id == null) {
597
+ throw new Error(`MiniSearch: document does not have ID field "${idField}"`);
598
+ }
599
+ if (this._idToShortId.has(id)) {
600
+ throw new Error(`MiniSearch: duplicate ID ${id}`);
601
+ }
602
+ const shortDocumentId = this.addDocumentId(id);
603
+ this.saveStoredFields(shortDocumentId, document);
604
+ for (const field of fields) {
605
+ const fieldValue = extractField(document, field);
606
+ if (fieldValue == null)
607
+ continue;
608
+ const tokens = tokenize(stringifyField(fieldValue, field), field);
609
+ const fieldId = this._fieldIds[field];
610
+ const uniqueTerms = new Set(tokens).size;
611
+ this.addFieldLength(shortDocumentId, fieldId, this._documentCount - 1, uniqueTerms);
612
+ for (const term of tokens) {
613
+ const processedTerm = processTerm(term, field);
614
+ if (Array.isArray(processedTerm)) {
615
+ for (const t of processedTerm) {
616
+ this.addTerm(fieldId, shortDocumentId, t);
617
+ }
618
+ } else if (processedTerm) {
619
+ this.addTerm(fieldId, shortDocumentId, processedTerm);
620
+ }
621
+ }
622
+ }
623
+ }
624
+ /**
625
+ * Adds all the given documents to the index
626
+ *
627
+ * @param documents An array of documents to be indexed
628
+ */
629
+ addAll(documents) {
630
+ for (const document of documents)
631
+ this.add(document);
632
+ }
633
+ /**
634
+ * Adds all the given documents to the index asynchronously.
635
+ *
636
+ * Returns a promise that resolves (to `undefined`) when the indexing is done.
637
+ * This method is useful when index many documents, to avoid blocking the main
638
+ * thread. The indexing is performed asynchronously and in chunks.
639
+ *
640
+ * @param documents An array of documents to be indexed
641
+ * @param options Configuration options
642
+ * @return A promise resolving to `undefined` when the indexing is done
643
+ */
644
+ addAllAsync(documents, options = {}) {
645
+ const { chunkSize = 10 } = options;
646
+ const acc = { chunk: [], promise: Promise.resolve() };
647
+ const { chunk, promise } = documents.reduce(({ chunk: chunk2, promise: promise2 }, document, i) => {
648
+ chunk2.push(document);
649
+ if ((i + 1) % chunkSize === 0) {
650
+ return {
651
+ chunk: [],
652
+ promise: promise2.then(() => new Promise((resolve) => setTimeout(resolve, 0))).then(() => this.addAll(chunk2))
653
+ };
654
+ } else {
655
+ return { chunk: chunk2, promise: promise2 };
656
+ }
657
+ }, acc);
658
+ return promise.then(() => this.addAll(chunk));
659
+ }
660
+ /**
661
+ * Removes the given document from the index.
662
+ *
663
+ * The document to remove must NOT have changed between indexing and removal,
664
+ * otherwise the index will be corrupted.
665
+ *
666
+ * This method requires passing the full document to be removed (not just the
667
+ * ID), and immediately removes the document from the inverted index, allowing
668
+ * memory to be released. A convenient alternative is {@link
669
+ * MiniSearch#discard}, which needs only the document ID, and has the same
670
+ * visible effect, but delays cleaning up the index until the next vacuuming.
671
+ *
672
+ * @param document The document to be removed
673
+ */
674
+ remove(document) {
675
+ const { tokenize, processTerm, extractField, stringifyField, fields, idField } = this._options;
676
+ const id = extractField(document, idField);
677
+ if (id == null) {
678
+ throw new Error(`MiniSearch: document does not have ID field "${idField}"`);
679
+ }
680
+ const shortId = this._idToShortId.get(id);
681
+ if (shortId == null) {
682
+ throw new Error(`MiniSearch: cannot remove document with ID ${id}: it is not in the index`);
683
+ }
684
+ for (const field of fields) {
685
+ const fieldValue = extractField(document, field);
686
+ if (fieldValue == null)
687
+ continue;
688
+ const tokens = tokenize(stringifyField(fieldValue, field), field);
689
+ const fieldId = this._fieldIds[field];
690
+ const uniqueTerms = new Set(tokens).size;
691
+ this.removeFieldLength(shortId, fieldId, this._documentCount, uniqueTerms);
692
+ for (const term of tokens) {
693
+ const processedTerm = processTerm(term, field);
694
+ if (Array.isArray(processedTerm)) {
695
+ for (const t of processedTerm) {
696
+ this.removeTerm(fieldId, shortId, t);
697
+ }
698
+ } else if (processedTerm) {
699
+ this.removeTerm(fieldId, shortId, processedTerm);
700
+ }
701
+ }
702
+ }
703
+ this._storedFields.delete(shortId);
704
+ this._documentIds.delete(shortId);
705
+ this._idToShortId.delete(id);
706
+ this._fieldLength.delete(shortId);
707
+ this._documentCount -= 1;
708
+ }
709
+ /**
710
+ * Removes all the given documents from the index. If called with no arguments,
711
+ * it removes _all_ documents from the index.
712
+ *
713
+ * @param documents The documents to be removed. If this argument is omitted,
714
+ * all documents are removed. Note that, for removing all documents, it is
715
+ * more efficient to call this method with no arguments than to pass all
716
+ * documents.
717
+ */
718
+ removeAll(documents) {
719
+ if (documents) {
720
+ for (const document of documents)
721
+ this.remove(document);
722
+ } else if (arguments.length > 0) {
723
+ throw new Error("Expected documents to be present. Omit the argument to remove all documents.");
724
+ } else {
725
+ this._index = new SearchableMap();
726
+ this._documentCount = 0;
727
+ this._documentIds = /* @__PURE__ */ new Map();
728
+ this._idToShortId = /* @__PURE__ */ new Map();
729
+ this._fieldLength = /* @__PURE__ */ new Map();
730
+ this._avgFieldLength = [];
731
+ this._storedFields = /* @__PURE__ */ new Map();
732
+ this._nextId = 0;
733
+ }
734
+ }
735
+ /**
736
+ * Discards the document with the given ID, so it won't appear in search results
737
+ *
738
+ * It has the same visible effect of {@link MiniSearch.remove} (both cause the
739
+ * document to stop appearing in searches), but a different effect on the
740
+ * internal data structures:
741
+ *
742
+ * - {@link MiniSearch#remove} requires passing the full document to be
743
+ * removed as argument, and removes it from the inverted index immediately.
744
+ *
745
+ * - {@link MiniSearch#discard} instead only needs the document ID, and
746
+ * works by marking the current version of the document as discarded, so it
747
+ * is immediately ignored by searches. This is faster and more convenient
748
+ * than {@link MiniSearch#remove}, but the index is not immediately
749
+ * modified. To take care of that, vacuuming is performed after a certain
750
+ * number of documents are discarded, cleaning up the index and allowing
751
+ * memory to be released.
752
+ *
753
+ * After discarding a document, it is possible to re-add a new version, and
754
+ * only the new version will appear in searches. In other words, discarding
755
+ * and re-adding a document works exactly like removing and re-adding it. The
756
+ * {@link MiniSearch.replace} method can also be used to replace a document
757
+ * with a new version.
758
+ *
759
+ * #### Details about vacuuming
760
+ *
761
+ * Repetite calls to this method would leave obsolete document references in
762
+ * the index, invisible to searches. Two mechanisms take care of cleaning up:
763
+ * clean up during search, and vacuuming.
764
+ *
765
+ * - Upon search, whenever a discarded ID is found (and ignored for the
766
+ * results), references to the discarded document are removed from the
767
+ * inverted index entries for the search terms. This ensures that subsequent
768
+ * searches for the same terms do not need to skip these obsolete references
769
+ * again.
770
+ *
771
+ * - In addition, vacuuming is performed automatically by default (see the
772
+ * `autoVacuum` field in {@link Options}) after a certain number of
773
+ * documents are discarded. Vacuuming traverses all terms in the index,
774
+ * cleaning up all references to discarded documents. Vacuuming can also be
775
+ * triggered manually by calling {@link MiniSearch#vacuum}.
776
+ *
777
+ * @param id The ID of the document to be discarded
778
+ */
779
+ discard(id) {
780
+ const shortId = this._idToShortId.get(id);
781
+ if (shortId == null) {
782
+ throw new Error(`MiniSearch: cannot discard document with ID ${id}: it is not in the index`);
783
+ }
784
+ this._idToShortId.delete(id);
785
+ this._documentIds.delete(shortId);
786
+ this._storedFields.delete(shortId);
787
+ (this._fieldLength.get(shortId) || []).forEach((fieldLength, fieldId) => {
788
+ this.removeFieldLength(shortId, fieldId, this._documentCount, fieldLength);
789
+ });
790
+ this._fieldLength.delete(shortId);
791
+ this._documentCount -= 1;
792
+ this._dirtCount += 1;
793
+ this.maybeAutoVacuum();
794
+ }
795
+ maybeAutoVacuum() {
796
+ if (this._options.autoVacuum === false) {
797
+ return;
798
+ }
799
+ const { minDirtFactor, minDirtCount, batchSize, batchWait } = this._options.autoVacuum;
800
+ this.conditionalVacuum({ batchSize, batchWait }, { minDirtCount, minDirtFactor });
801
+ }
802
+ /**
803
+ * Discards the documents with the given IDs, so they won't appear in search
804
+ * results
805
+ *
806
+ * It is equivalent to calling {@link MiniSearch#discard} for all the given
807
+ * IDs, but with the optimization of triggering at most one automatic
808
+ * vacuuming at the end.
809
+ *
810
+ * Note: to remove all documents from the index, it is faster and more
811
+ * convenient to call {@link MiniSearch.removeAll} with no argument, instead
812
+ * of passing all IDs to this method.
813
+ */
814
+ discardAll(ids) {
815
+ const autoVacuum = this._options.autoVacuum;
816
+ try {
817
+ this._options.autoVacuum = false;
818
+ for (const id of ids) {
819
+ this.discard(id);
820
+ }
821
+ } finally {
822
+ this._options.autoVacuum = autoVacuum;
823
+ }
824
+ this.maybeAutoVacuum();
825
+ }
826
+ /**
827
+ * It replaces an existing document with the given updated version
828
+ *
829
+ * It works by discarding the current version and adding the updated one, so
830
+ * it is functionally equivalent to calling {@link MiniSearch#discard}
831
+ * followed by {@link MiniSearch#add}. The ID of the updated document should
832
+ * be the same as the original one.
833
+ *
834
+ * Since it uses {@link MiniSearch#discard} internally, this method relies on
835
+ * vacuuming to clean up obsolete document references from the index, allowing
836
+ * memory to be released (see {@link MiniSearch#discard}).
837
+ *
838
+ * @param updatedDocument The updated document to replace the old version
839
+ * with
840
+ */
841
+ replace(updatedDocument) {
842
+ const { idField, extractField } = this._options;
843
+ const id = extractField(updatedDocument, idField);
844
+ this.discard(id);
845
+ this.add(updatedDocument);
846
+ }
847
+ /**
848
+ * Triggers a manual vacuuming, cleaning up references to discarded documents
849
+ * from the inverted index
850
+ *
851
+ * Vacuuming is only useful for applications that use the {@link
852
+ * MiniSearch#discard} or {@link MiniSearch#replace} methods.
853
+ *
854
+ * By default, vacuuming is performed automatically when needed (controlled by
855
+ * the `autoVacuum` field in {@link Options}), so there is usually no need to
856
+ * call this method, unless one wants to make sure to perform vacuuming at a
857
+ * specific moment.
858
+ *
859
+ * Vacuuming traverses all terms in the inverted index in batches, and cleans
860
+ * up references to discarded documents from the posting list, allowing memory
861
+ * to be released.
862
+ *
863
+ * The method takes an optional object as argument with the following keys:
864
+ *
865
+ * - `batchSize`: the size of each batch (1000 by default)
866
+ *
867
+ * - `batchWait`: the number of milliseconds to wait between batches (10 by
868
+ * default)
869
+ *
870
+ * On large indexes, vacuuming could have a non-negligible cost: batching
871
+ * avoids blocking the thread for long, diluting this cost so that it is not
872
+ * negatively affecting the application. Nonetheless, this method should only
873
+ * be called when necessary, and relying on automatic vacuuming is usually
874
+ * better.
875
+ *
876
+ * It returns a promise that resolves (to undefined) when the clean up is
877
+ * completed. If vacuuming is already ongoing at the time this method is
878
+ * called, a new one is enqueued immediately after the ongoing one, and a
879
+ * corresponding promise is returned. However, no more than one vacuuming is
880
+ * enqueued on top of the ongoing one, even if this method is called more
881
+ * times (enqueuing multiple ones would be useless).
882
+ *
883
+ * @param options Configuration options for the batch size and delay. See
884
+ * {@link VacuumOptions}.
885
+ */
886
+ vacuum(options = {}) {
887
+ return this.conditionalVacuum(options);
888
+ }
889
+ conditionalVacuum(options, conditions) {
890
+ if (this._currentVacuum) {
891
+ this._enqueuedVacuumConditions = this._enqueuedVacuumConditions && conditions;
892
+ if (this._enqueuedVacuum != null) {
893
+ return this._enqueuedVacuum;
894
+ }
895
+ this._enqueuedVacuum = this._currentVacuum.then(() => {
896
+ const conditions2 = this._enqueuedVacuumConditions;
897
+ this._enqueuedVacuumConditions = defaultVacuumConditions;
898
+ return this.performVacuuming(options, conditions2);
899
+ });
900
+ return this._enqueuedVacuum;
901
+ }
902
+ if (this.vacuumConditionsMet(conditions) === false) {
903
+ return Promise.resolve();
904
+ }
905
+ this._currentVacuum = this.performVacuuming(options);
906
+ return this._currentVacuum;
907
+ }
908
+ async performVacuuming(options, conditions) {
909
+ const initialDirtCount = this._dirtCount;
910
+ if (this.vacuumConditionsMet(conditions)) {
911
+ const batchSize = options.batchSize || defaultVacuumOptions.batchSize;
912
+ const batchWait = options.batchWait || defaultVacuumOptions.batchWait;
913
+ let i = 1;
914
+ for (const [term, fieldsData] of this._index) {
915
+ for (const [fieldId, fieldIndex] of fieldsData) {
916
+ for (const [shortId] of fieldIndex) {
917
+ if (this._documentIds.has(shortId)) {
918
+ continue;
919
+ }
920
+ if (fieldIndex.size <= 1) {
921
+ fieldsData.delete(fieldId);
922
+ } else {
923
+ fieldIndex.delete(shortId);
924
+ }
925
+ }
926
+ }
927
+ if (this._index.get(term).size === 0) {
928
+ this._index.delete(term);
929
+ }
930
+ if (i % batchSize === 0) {
931
+ await new Promise((resolve) => setTimeout(resolve, batchWait));
932
+ }
933
+ i += 1;
934
+ }
935
+ this._dirtCount -= initialDirtCount;
936
+ }
937
+ await null;
938
+ this._currentVacuum = this._enqueuedVacuum;
939
+ this._enqueuedVacuum = null;
940
+ }
941
+ vacuumConditionsMet(conditions) {
942
+ if (conditions == null) {
943
+ return true;
944
+ }
945
+ let { minDirtCount, minDirtFactor } = conditions;
946
+ minDirtCount = minDirtCount || defaultAutoVacuumOptions.minDirtCount;
947
+ minDirtFactor = minDirtFactor || defaultAutoVacuumOptions.minDirtFactor;
948
+ return this.dirtCount >= minDirtCount && this.dirtFactor >= minDirtFactor;
949
+ }
950
+ /**
951
+ * Is `true` if a vacuuming operation is ongoing, `false` otherwise
952
+ */
953
+ get isVacuuming() {
954
+ return this._currentVacuum != null;
955
+ }
956
+ /**
957
+ * The number of documents discarded since the most recent vacuuming
958
+ */
959
+ get dirtCount() {
960
+ return this._dirtCount;
961
+ }
962
+ /**
963
+ * A number between 0 and 1 giving an indication about the proportion of
964
+ * documents that are discarded, and can therefore be cleaned up by vacuuming.
965
+ * A value close to 0 means that the index is relatively clean, while a higher
966
+ * value means that the index is relatively dirty, and vacuuming could release
967
+ * memory.
968
+ */
969
+ get dirtFactor() {
970
+ return this._dirtCount / (1 + this._documentCount + this._dirtCount);
971
+ }
972
+ /**
973
+ * Returns `true` if a document with the given ID is present in the index and
974
+ * available for search, `false` otherwise
975
+ *
976
+ * @param id The document ID
977
+ */
978
+ has(id) {
979
+ return this._idToShortId.has(id);
980
+ }
981
+ /**
982
+ * Returns the stored fields (as configured in the `storeFields` constructor
983
+ * option) for the given document ID. Returns `undefined` if the document is
984
+ * not present in the index.
985
+ *
986
+ * @param id The document ID
987
+ */
988
+ getStoredFields(id) {
989
+ const shortId = this._idToShortId.get(id);
990
+ if (shortId == null) {
991
+ return void 0;
992
+ }
993
+ return this._storedFields.get(shortId);
994
+ }
995
+ /**
996
+ * Search for documents matching the given search query.
997
+ *
998
+ * The result is a list of scored document IDs matching the query, sorted by
999
+ * descending score, and each including data about which terms were matched and
1000
+ * in which fields.
1001
+ *
1002
+ * ### Basic usage:
1003
+ *
1004
+ * ```javascript
1005
+ * // Search for "zen art motorcycle" with default options: terms have to match
1006
+ * // exactly, and individual terms are joined with OR
1007
+ * miniSearch.search('zen art motorcycle')
1008
+ * // => [ { id: 2, score: 2.77258, match: { ... } }, { id: 4, score: 1.38629, match: { ... } } ]
1009
+ * ```
1010
+ *
1011
+ * ### Restrict search to specific fields:
1012
+ *
1013
+ * ```javascript
1014
+ * // Search only in the 'title' field
1015
+ * miniSearch.search('zen', { fields: ['title'] })
1016
+ * ```
1017
+ *
1018
+ * ### Field boosting:
1019
+ *
1020
+ * ```javascript
1021
+ * // Boost a field
1022
+ * miniSearch.search('zen', { boost: { title: 2 } })
1023
+ * ```
1024
+ *
1025
+ * ### Prefix search:
1026
+ *
1027
+ * ```javascript
1028
+ * // Search for "moto" with prefix search (it will match documents
1029
+ * // containing terms that start with "moto" or "neuro")
1030
+ * miniSearch.search('moto neuro', { prefix: true })
1031
+ * ```
1032
+ *
1033
+ * ### Fuzzy search:
1034
+ *
1035
+ * ```javascript
1036
+ * // Search for "ismael" with fuzzy search (it will match documents containing
1037
+ * // terms similar to "ismael", with a maximum edit distance of 0.2 term.length
1038
+ * // (rounded to nearest integer)
1039
+ * miniSearch.search('ismael', { fuzzy: 0.2 })
1040
+ * ```
1041
+ *
1042
+ * ### Combining strategies:
1043
+ *
1044
+ * ```javascript
1045
+ * // Mix of exact match, prefix search, and fuzzy search
1046
+ * miniSearch.search('ismael mob', {
1047
+ * prefix: true,
1048
+ * fuzzy: 0.2
1049
+ * })
1050
+ * ```
1051
+ *
1052
+ * ### Advanced prefix and fuzzy search:
1053
+ *
1054
+ * ```javascript
1055
+ * // Perform fuzzy and prefix search depending on the search term. Here
1056
+ * // performing prefix and fuzzy search only on terms longer than 3 characters
1057
+ * miniSearch.search('ismael mob', {
1058
+ * prefix: term => term.length > 3
1059
+ * fuzzy: term => term.length > 3 ? 0.2 : null
1060
+ * })
1061
+ * ```
1062
+ *
1063
+ * ### Combine with AND:
1064
+ *
1065
+ * ```javascript
1066
+ * // Combine search terms with AND (to match only documents that contain both
1067
+ * // "motorcycle" and "art")
1068
+ * miniSearch.search('motorcycle art', { combineWith: 'AND' })
1069
+ * ```
1070
+ *
1071
+ * ### Combine with AND_NOT:
1072
+ *
1073
+ * There is also an AND_NOT combinator, that finds documents that match the
1074
+ * first term, but do not match any of the other terms. This combinator is
1075
+ * rarely useful with simple queries, and is meant to be used with advanced
1076
+ * query combinations (see later for more details).
1077
+ *
1078
+ * ### Filtering results:
1079
+ *
1080
+ * ```javascript
1081
+ * // Filter only results in the 'fiction' category (assuming that 'category'
1082
+ * // is a stored field)
1083
+ * miniSearch.search('motorcycle art', {
1084
+ * filter: (result) => result.category === 'fiction'
1085
+ * })
1086
+ * ```
1087
+ *
1088
+ * ### Wildcard query
1089
+ *
1090
+ * Searching for an empty string (assuming the default tokenizer) returns no
1091
+ * results. Sometimes though, one needs to match all documents, like in a
1092
+ * "wildcard" search. This is possible by passing the special value
1093
+ * {@link MiniSearch.wildcard} as the query:
1094
+ *
1095
+ * ```javascript
1096
+ * // Return search results for all documents
1097
+ * miniSearch.search(MiniSearch.wildcard)
1098
+ * ```
1099
+ *
1100
+ * Note that search options such as `filter` and `boostDocument` are still
1101
+ * applied, influencing which results are returned, and their order:
1102
+ *
1103
+ * ```javascript
1104
+ * // Return search results for all documents in the 'fiction' category
1105
+ * miniSearch.search(MiniSearch.wildcard, {
1106
+ * filter: (result) => result.category === 'fiction'
1107
+ * })
1108
+ * ```
1109
+ *
1110
+ * ### Advanced combination of queries:
1111
+ *
1112
+ * It is possible to combine different subqueries with OR, AND, and AND_NOT,
1113
+ * and even with different search options, by passing a query expression
1114
+ * tree object as the first argument, instead of a string.
1115
+ *
1116
+ * ```javascript
1117
+ * // Search for documents that contain "zen" and ("motorcycle" or "archery")
1118
+ * miniSearch.search({
1119
+ * combineWith: 'AND',
1120
+ * queries: [
1121
+ * 'zen',
1122
+ * {
1123
+ * combineWith: 'OR',
1124
+ * queries: ['motorcycle', 'archery']
1125
+ * }
1126
+ * ]
1127
+ * })
1128
+ *
1129
+ * // Search for documents that contain ("apple" or "pear") but not "juice" and
1130
+ * // not "tree"
1131
+ * miniSearch.search({
1132
+ * combineWith: 'AND_NOT',
1133
+ * queries: [
1134
+ * {
1135
+ * combineWith: 'OR',
1136
+ * queries: ['apple', 'pear']
1137
+ * },
1138
+ * 'juice',
1139
+ * 'tree'
1140
+ * ]
1141
+ * })
1142
+ * ```
1143
+ *
1144
+ * Each node in the expression tree can be either a string, or an object that
1145
+ * supports all {@link SearchOptions} fields, plus a `queries` array field for
1146
+ * subqueries.
1147
+ *
1148
+ * Note that, while this can become complicated to do by hand for complex or
1149
+ * deeply nested queries, it provides a formalized expression tree API for
1150
+ * external libraries that implement a parser for custom query languages.
1151
+ *
1152
+ * @param query Search query
1153
+ * @param searchOptions Search options. Each option, if not given, defaults to the corresponding value of `searchOptions` given to the constructor, or to the library default.
1154
+ */
1155
+ search(query, searchOptions = {}) {
1156
+ const { searchOptions: globalSearchOptions } = this._options;
1157
+ const searchOptionsWithDefaults = { ...globalSearchOptions, ...searchOptions };
1158
+ const rawResults = this.executeQuery(query, searchOptions);
1159
+ const results = [];
1160
+ for (const [docId, { score, terms, match }] of rawResults) {
1161
+ const quality = terms.length || 1;
1162
+ const result = {
1163
+ id: this._documentIds.get(docId),
1164
+ score: score * quality,
1165
+ terms: Object.keys(match),
1166
+ queryTerms: terms,
1167
+ match
1168
+ };
1169
+ Object.assign(result, this._storedFields.get(docId));
1170
+ if (searchOptionsWithDefaults.filter == null || searchOptionsWithDefaults.filter(result)) {
1171
+ results.push(result);
1172
+ }
1173
+ }
1174
+ if (query === _MiniSearch.wildcard && searchOptionsWithDefaults.boostDocument == null) {
1175
+ return results;
1176
+ }
1177
+ results.sort(byScore);
1178
+ return results;
1179
+ }
1180
+ /**
1181
+ * Provide suggestions for the given search query
1182
+ *
1183
+ * The result is a list of suggested modified search queries, derived from the
1184
+ * given search query, each with a relevance score, sorted by descending score.
1185
+ *
1186
+ * By default, it uses the same options used for search, except that by
1187
+ * default it performs prefix search on the last term of the query, and
1188
+ * combine terms with `'AND'` (requiring all query terms to match). Custom
1189
+ * options can be passed as a second argument. Defaults can be changed upon
1190
+ * calling the {@link MiniSearch} constructor, by passing a
1191
+ * `autoSuggestOptions` option.
1192
+ *
1193
+ * ### Basic usage:
1194
+ *
1195
+ * ```javascript
1196
+ * // Get suggestions for 'neuro':
1197
+ * miniSearch.autoSuggest('neuro')
1198
+ * // => [ { suggestion: 'neuromancer', terms: [ 'neuromancer' ], score: 0.46240 } ]
1199
+ * ```
1200
+ *
1201
+ * ### Multiple words:
1202
+ *
1203
+ * ```javascript
1204
+ * // Get suggestions for 'zen ar':
1205
+ * miniSearch.autoSuggest('zen ar')
1206
+ * // => [
1207
+ * // { suggestion: 'zen archery art', terms: [ 'zen', 'archery', 'art' ], score: 1.73332 },
1208
+ * // { suggestion: 'zen art', terms: [ 'zen', 'art' ], score: 1.21313 }
1209
+ * // ]
1210
+ * ```
1211
+ *
1212
+ * ### Fuzzy suggestions:
1213
+ *
1214
+ * ```javascript
1215
+ * // Correct spelling mistakes using fuzzy search:
1216
+ * miniSearch.autoSuggest('neromancer', { fuzzy: 0.2 })
1217
+ * // => [ { suggestion: 'neuromancer', terms: [ 'neuromancer' ], score: 1.03998 } ]
1218
+ * ```
1219
+ *
1220
+ * ### Filtering:
1221
+ *
1222
+ * ```javascript
1223
+ * // Get suggestions for 'zen ar', but only within the 'fiction' category
1224
+ * // (assuming that 'category' is a stored field):
1225
+ * miniSearch.autoSuggest('zen ar', {
1226
+ * filter: (result) => result.category === 'fiction'
1227
+ * })
1228
+ * // => [
1229
+ * // { suggestion: 'zen archery art', terms: [ 'zen', 'archery', 'art' ], score: 1.73332 },
1230
+ * // { suggestion: 'zen art', terms: [ 'zen', 'art' ], score: 1.21313 }
1231
+ * // ]
1232
+ * ```
1233
+ *
1234
+ * @param queryString Query string to be expanded into suggestions
1235
+ * @param options Search options. The supported options and default values
1236
+ * are the same as for the {@link MiniSearch#search} method, except that by
1237
+ * default prefix search is performed on the last term in the query, and terms
1238
+ * are combined with `'AND'`.
1239
+ * @return A sorted array of suggestions sorted by relevance score.
1240
+ */
1241
+ autoSuggest(queryString, options = {}) {
1242
+ options = { ...this._options.autoSuggestOptions, ...options };
1243
+ const suggestions = /* @__PURE__ */ new Map();
1244
+ for (const { score, terms } of this.search(queryString, options)) {
1245
+ const phrase = terms.join(" ");
1246
+ const suggestion = suggestions.get(phrase);
1247
+ if (suggestion != null) {
1248
+ suggestion.score += score;
1249
+ suggestion.count += 1;
1250
+ } else {
1251
+ suggestions.set(phrase, { score, terms, count: 1 });
1252
+ }
1253
+ }
1254
+ const results = [];
1255
+ for (const [suggestion, { score, terms, count }] of suggestions) {
1256
+ results.push({ suggestion, terms, score: score / count });
1257
+ }
1258
+ results.sort(byScore);
1259
+ return results;
1260
+ }
1261
+ /**
1262
+ * Total number of documents available to search
1263
+ */
1264
+ get documentCount() {
1265
+ return this._documentCount;
1266
+ }
1267
+ /**
1268
+ * Number of terms in the index
1269
+ */
1270
+ get termCount() {
1271
+ return this._index.size;
1272
+ }
1273
+ /**
1274
+ * Deserializes a JSON index (serialized with `JSON.stringify(miniSearch)`)
1275
+ * and instantiates a MiniSearch instance. It should be given the same options
1276
+ * originally used when serializing the index.
1277
+ *
1278
+ * ### Usage:
1279
+ *
1280
+ * ```javascript
1281
+ * // If the index was serialized with:
1282
+ * let miniSearch = new MiniSearch({ fields: ['title', 'text'] })
1283
+ * miniSearch.addAll(documents)
1284
+ *
1285
+ * const json = JSON.stringify(miniSearch)
1286
+ * // It can later be deserialized like this:
1287
+ * miniSearch = MiniSearch.loadJSON(json, { fields: ['title', 'text'] })
1288
+ * ```
1289
+ *
1290
+ * @param json JSON-serialized index
1291
+ * @param options configuration options, same as the constructor
1292
+ * @return An instance of MiniSearch deserialized from the given JSON.
1293
+ */
1294
+ static loadJSON(json, options) {
1295
+ if (options == null) {
1296
+ throw new Error("MiniSearch: loadJSON should be given the same options used when serializing the index");
1297
+ }
1298
+ return this.loadJS(JSON.parse(json), options);
1299
+ }
1300
+ /**
1301
+ * Async equivalent of {@link MiniSearch.loadJSON}
1302
+ *
1303
+ * This function is an alternative to {@link MiniSearch.loadJSON} that returns
1304
+ * a promise, and loads the index in batches, leaving pauses between them to avoid
1305
+ * blocking the main thread. It tends to be slower than the synchronous
1306
+ * version, but does not block the main thread, so it can be a better choice
1307
+ * when deserializing very large indexes.
1308
+ *
1309
+ * @param json JSON-serialized index
1310
+ * @param options configuration options, same as the constructor
1311
+ * @return A Promise that will resolve to an instance of MiniSearch deserialized from the given JSON.
1312
+ */
1313
+ static async loadJSONAsync(json, options) {
1314
+ if (options == null) {
1315
+ throw new Error("MiniSearch: loadJSON should be given the same options used when serializing the index");
1316
+ }
1317
+ return this.loadJSAsync(JSON.parse(json), options);
1318
+ }
1319
+ /**
1320
+ * Returns the default value of an option. It will throw an error if no option
1321
+ * with the given name exists.
1322
+ *
1323
+ * @param optionName Name of the option
1324
+ * @return The default value of the given option
1325
+ *
1326
+ * ### Usage:
1327
+ *
1328
+ * ```javascript
1329
+ * // Get default tokenizer
1330
+ * MiniSearch.getDefault('tokenize')
1331
+ *
1332
+ * // Get default term processor
1333
+ * MiniSearch.getDefault('processTerm')
1334
+ *
1335
+ * // Unknown options will throw an error
1336
+ * MiniSearch.getDefault('notExisting')
1337
+ * // => throws 'MiniSearch: unknown option "notExisting"'
1338
+ * ```
1339
+ */
1340
+ static getDefault(optionName) {
1341
+ if (defaultOptions.hasOwnProperty(optionName)) {
1342
+ return getOwnProperty(defaultOptions, optionName);
1343
+ } else {
1344
+ throw new Error(`MiniSearch: unknown option "${optionName}"`);
1345
+ }
1346
+ }
1347
+ /**
1348
+ * @ignore
1349
+ */
1350
+ static loadJS(js, options) {
1351
+ const { index, documentIds, fieldLength, storedFields, serializationVersion } = js;
1352
+ const miniSearch = this.instantiateMiniSearch(js, options);
1353
+ miniSearch._documentIds = objectToNumericMap(documentIds);
1354
+ miniSearch._fieldLength = objectToNumericMap(fieldLength);
1355
+ miniSearch._storedFields = objectToNumericMap(storedFields);
1356
+ for (const [shortId, id] of miniSearch._documentIds) {
1357
+ miniSearch._idToShortId.set(id, shortId);
1358
+ }
1359
+ for (const [term, data] of index) {
1360
+ const dataMap = /* @__PURE__ */ new Map();
1361
+ for (const fieldId of Object.keys(data)) {
1362
+ let indexEntry = data[fieldId];
1363
+ if (serializationVersion === 1) {
1364
+ indexEntry = indexEntry.ds;
1365
+ }
1366
+ dataMap.set(parseInt(fieldId, 10), objectToNumericMap(indexEntry));
1367
+ }
1368
+ miniSearch._index.set(term, dataMap);
1369
+ }
1370
+ return miniSearch;
1371
+ }
1372
+ /**
1373
+ * @ignore
1374
+ */
1375
+ static async loadJSAsync(js, options) {
1376
+ const { index, documentIds, fieldLength, storedFields, serializationVersion } = js;
1377
+ const miniSearch = this.instantiateMiniSearch(js, options);
1378
+ miniSearch._documentIds = await objectToNumericMapAsync(documentIds);
1379
+ miniSearch._fieldLength = await objectToNumericMapAsync(fieldLength);
1380
+ miniSearch._storedFields = await objectToNumericMapAsync(storedFields);
1381
+ for (const [shortId, id] of miniSearch._documentIds) {
1382
+ miniSearch._idToShortId.set(id, shortId);
1383
+ }
1384
+ let count = 0;
1385
+ for (const [term, data] of index) {
1386
+ const dataMap = /* @__PURE__ */ new Map();
1387
+ for (const fieldId of Object.keys(data)) {
1388
+ let indexEntry = data[fieldId];
1389
+ if (serializationVersion === 1) {
1390
+ indexEntry = indexEntry.ds;
1391
+ }
1392
+ dataMap.set(parseInt(fieldId, 10), await objectToNumericMapAsync(indexEntry));
1393
+ }
1394
+ if (++count % 1e3 === 0)
1395
+ await wait(0);
1396
+ miniSearch._index.set(term, dataMap);
1397
+ }
1398
+ return miniSearch;
1399
+ }
1400
+ /**
1401
+ * @ignore
1402
+ */
1403
+ static instantiateMiniSearch(js, options) {
1404
+ const { documentCount, nextId, fieldIds, averageFieldLength, dirtCount, serializationVersion } = js;
1405
+ if (serializationVersion !== 1 && serializationVersion !== 2) {
1406
+ throw new Error("MiniSearch: cannot deserialize an index created with an incompatible version");
1407
+ }
1408
+ const miniSearch = new _MiniSearch(options);
1409
+ miniSearch._documentCount = documentCount;
1410
+ miniSearch._nextId = nextId;
1411
+ miniSearch._idToShortId = /* @__PURE__ */ new Map();
1412
+ miniSearch._fieldIds = fieldIds;
1413
+ miniSearch._avgFieldLength = averageFieldLength;
1414
+ miniSearch._dirtCount = dirtCount || 0;
1415
+ miniSearch._index = new SearchableMap();
1416
+ return miniSearch;
1417
+ }
1418
+ /**
1419
+ * @ignore
1420
+ */
1421
+ executeQuery(query, searchOptions = {}) {
1422
+ if (query === _MiniSearch.wildcard) {
1423
+ return this.executeWildcardQuery(searchOptions);
1424
+ }
1425
+ if (typeof query !== "string") {
1426
+ const options2 = { ...searchOptions, ...query, queries: void 0 };
1427
+ const results2 = query.queries.map((subquery) => this.executeQuery(subquery, options2));
1428
+ return this.combineResults(results2, options2.combineWith);
1429
+ }
1430
+ const { tokenize, processTerm, searchOptions: globalSearchOptions } = this._options;
1431
+ const options = { tokenize, processTerm, ...globalSearchOptions, ...searchOptions };
1432
+ const { tokenize: searchTokenize, processTerm: searchProcessTerm } = options;
1433
+ const terms = searchTokenize(query).flatMap((term) => searchProcessTerm(term)).filter((term) => !!term);
1434
+ const queries = terms.map(termToQuerySpec(options));
1435
+ const results = queries.map((query2) => this.executeQuerySpec(query2, options));
1436
+ return this.combineResults(results, options.combineWith);
1437
+ }
1438
+ /**
1439
+ * @ignore
1440
+ */
1441
+ executeQuerySpec(query, searchOptions) {
1442
+ const options = { ...this._options.searchOptions, ...searchOptions };
1443
+ const boosts = (options.fields || this._options.fields).reduce((boosts2, field) => ({ ...boosts2, [field]: getOwnProperty(options.boost, field) || 1 }), {});
1444
+ const { boostDocument, weights, maxFuzzy, bm25: bm25params } = options;
1445
+ const { fuzzy: fuzzyWeight, prefix: prefixWeight } = { ...defaultSearchOptions.weights, ...weights };
1446
+ const data = this._index.get(query.term);
1447
+ const results = this.termResults(query.term, query.term, 1, query.termBoost, data, boosts, boostDocument, bm25params);
1448
+ let prefixMatches;
1449
+ let fuzzyMatches;
1450
+ if (query.prefix) {
1451
+ prefixMatches = this._index.atPrefix(query.term);
1452
+ }
1453
+ if (query.fuzzy) {
1454
+ const fuzzy = query.fuzzy === true ? 0.2 : query.fuzzy;
1455
+ const maxDistance = fuzzy < 1 ? Math.min(maxFuzzy, Math.round(query.term.length * fuzzy)) : fuzzy;
1456
+ if (maxDistance)
1457
+ fuzzyMatches = this._index.fuzzyGet(query.term, maxDistance);
1458
+ }
1459
+ if (prefixMatches) {
1460
+ for (const [term, data2] of prefixMatches) {
1461
+ const distance = term.length - query.term.length;
1462
+ if (!distance) {
1463
+ continue;
1464
+ }
1465
+ fuzzyMatches === null || fuzzyMatches === void 0 ? void 0 : fuzzyMatches.delete(term);
1466
+ const weight = prefixWeight * term.length / (term.length + 0.3 * distance);
1467
+ this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
1468
+ }
1469
+ }
1470
+ if (fuzzyMatches) {
1471
+ for (const term of fuzzyMatches.keys()) {
1472
+ const [data2, distance] = fuzzyMatches.get(term);
1473
+ if (!distance) {
1474
+ continue;
1475
+ }
1476
+ const weight = fuzzyWeight * term.length / (term.length + distance);
1477
+ this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
1478
+ }
1479
+ }
1480
+ return results;
1481
+ }
1482
+ /**
1483
+ * @ignore
1484
+ */
1485
+ executeWildcardQuery(searchOptions) {
1486
+ const results = /* @__PURE__ */ new Map();
1487
+ const options = { ...this._options.searchOptions, ...searchOptions };
1488
+ for (const [shortId, id] of this._documentIds) {
1489
+ const score = options.boostDocument ? options.boostDocument(id, "", this._storedFields.get(shortId)) : 1;
1490
+ results.set(shortId, {
1491
+ score,
1492
+ terms: [],
1493
+ match: {}
1494
+ });
1495
+ }
1496
+ return results;
1497
+ }
1498
+ /**
1499
+ * @ignore
1500
+ */
1501
+ combineResults(results, combineWith = OR) {
1502
+ if (results.length === 0) {
1503
+ return /* @__PURE__ */ new Map();
1504
+ }
1505
+ const operator = combineWith.toLowerCase();
1506
+ const combinator = combinators[operator];
1507
+ if (!combinator) {
1508
+ throw new Error(`Invalid combination operator: ${combineWith}`);
1509
+ }
1510
+ return results.reduce(combinator) || /* @__PURE__ */ new Map();
1511
+ }
1512
+ /**
1513
+ * Allows serialization of the index to JSON, to possibly store it and later
1514
+ * deserialize it with {@link MiniSearch.loadJSON}.
1515
+ *
1516
+ * Normally one does not directly call this method, but rather call the
1517
+ * standard JavaScript `JSON.stringify()` passing the {@link MiniSearch}
1518
+ * instance, and JavaScript will internally call this method. Upon
1519
+ * deserialization, one must pass to {@link MiniSearch.loadJSON} the same
1520
+ * options used to create the original instance that was serialized.
1521
+ *
1522
+ * ### Usage:
1523
+ *
1524
+ * ```javascript
1525
+ * // Serialize the index:
1526
+ * let miniSearch = new MiniSearch({ fields: ['title', 'text'] })
1527
+ * miniSearch.addAll(documents)
1528
+ * const json = JSON.stringify(miniSearch)
1529
+ *
1530
+ * // Later, to deserialize it:
1531
+ * miniSearch = MiniSearch.loadJSON(json, { fields: ['title', 'text'] })
1532
+ * ```
1533
+ *
1534
+ * @return A plain-object serializable representation of the search index.
1535
+ */
1536
+ toJSON() {
1537
+ const index = [];
1538
+ for (const [term, fieldIndex] of this._index) {
1539
+ const data = {};
1540
+ for (const [fieldId, freqs] of fieldIndex) {
1541
+ data[fieldId] = Object.fromEntries(freqs);
1542
+ }
1543
+ index.push([term, data]);
1544
+ }
1545
+ return {
1546
+ documentCount: this._documentCount,
1547
+ nextId: this._nextId,
1548
+ documentIds: Object.fromEntries(this._documentIds),
1549
+ fieldIds: this._fieldIds,
1550
+ fieldLength: Object.fromEntries(this._fieldLength),
1551
+ averageFieldLength: this._avgFieldLength,
1552
+ storedFields: Object.fromEntries(this._storedFields),
1553
+ dirtCount: this._dirtCount,
1554
+ index,
1555
+ serializationVersion: 2
1556
+ };
1557
+ }
1558
+ /**
1559
+ * @ignore
1560
+ */
1561
+ termResults(sourceTerm, derivedTerm, termWeight, termBoost, fieldTermData, fieldBoosts, boostDocumentFn, bm25params, results = /* @__PURE__ */ new Map()) {
1562
+ if (fieldTermData == null)
1563
+ return results;
1564
+ for (const field of Object.keys(fieldBoosts)) {
1565
+ const fieldBoost = fieldBoosts[field];
1566
+ const fieldId = this._fieldIds[field];
1567
+ const fieldTermFreqs = fieldTermData.get(fieldId);
1568
+ if (fieldTermFreqs == null)
1569
+ continue;
1570
+ let matchingFields = fieldTermFreqs.size;
1571
+ const avgFieldLength = this._avgFieldLength[fieldId];
1572
+ for (const docId of fieldTermFreqs.keys()) {
1573
+ if (!this._documentIds.has(docId)) {
1574
+ this.removeTerm(fieldId, docId, derivedTerm);
1575
+ matchingFields -= 1;
1576
+ continue;
1577
+ }
1578
+ const docBoost = boostDocumentFn ? boostDocumentFn(this._documentIds.get(docId), derivedTerm, this._storedFields.get(docId)) : 1;
1579
+ if (!docBoost)
1580
+ continue;
1581
+ const termFreq = fieldTermFreqs.get(docId);
1582
+ const fieldLength = this._fieldLength.get(docId)[fieldId];
1583
+ const rawScore = calcBM25Score(termFreq, matchingFields, this._documentCount, fieldLength, avgFieldLength, bm25params);
1584
+ const weightedScore = termWeight * termBoost * fieldBoost * docBoost * rawScore;
1585
+ const result = results.get(docId);
1586
+ if (result) {
1587
+ result.score += weightedScore;
1588
+ assignUniqueTerm(result.terms, sourceTerm);
1589
+ const match = getOwnProperty(result.match, derivedTerm);
1590
+ if (match) {
1591
+ match.push(field);
1592
+ } else {
1593
+ result.match[derivedTerm] = [field];
1594
+ }
1595
+ } else {
1596
+ results.set(docId, {
1597
+ score: weightedScore,
1598
+ terms: [sourceTerm],
1599
+ match: { [derivedTerm]: [field] }
1600
+ });
1601
+ }
1602
+ }
1603
+ }
1604
+ return results;
1605
+ }
1606
+ /**
1607
+ * @ignore
1608
+ */
1609
+ addTerm(fieldId, documentId, term) {
1610
+ const indexData = this._index.fetch(term, createMap);
1611
+ let fieldIndex = indexData.get(fieldId);
1612
+ if (fieldIndex == null) {
1613
+ fieldIndex = /* @__PURE__ */ new Map();
1614
+ fieldIndex.set(documentId, 1);
1615
+ indexData.set(fieldId, fieldIndex);
1616
+ } else {
1617
+ const docs = fieldIndex.get(documentId);
1618
+ fieldIndex.set(documentId, (docs || 0) + 1);
1619
+ }
1620
+ }
1621
+ /**
1622
+ * @ignore
1623
+ */
1624
+ removeTerm(fieldId, documentId, term) {
1625
+ if (!this._index.has(term)) {
1626
+ this.warnDocumentChanged(documentId, fieldId, term);
1627
+ return;
1628
+ }
1629
+ const indexData = this._index.fetch(term, createMap);
1630
+ const fieldIndex = indexData.get(fieldId);
1631
+ if (fieldIndex == null || fieldIndex.get(documentId) == null) {
1632
+ this.warnDocumentChanged(documentId, fieldId, term);
1633
+ } else if (fieldIndex.get(documentId) <= 1) {
1634
+ if (fieldIndex.size <= 1) {
1635
+ indexData.delete(fieldId);
1636
+ } else {
1637
+ fieldIndex.delete(documentId);
1638
+ }
1639
+ } else {
1640
+ fieldIndex.set(documentId, fieldIndex.get(documentId) - 1);
1641
+ }
1642
+ if (this._index.get(term).size === 0) {
1643
+ this._index.delete(term);
1644
+ }
1645
+ }
1646
+ /**
1647
+ * @ignore
1648
+ */
1649
+ warnDocumentChanged(shortDocumentId, fieldId, term) {
1650
+ for (const fieldName of Object.keys(this._fieldIds)) {
1651
+ if (this._fieldIds[fieldName] === fieldId) {
1652
+ this._options.logger("warn", `MiniSearch: document with ID ${this._documentIds.get(shortDocumentId)} has changed before removal: term "${term}" was not present in field "${fieldName}". Removing a document after it has changed can corrupt the index!`, "version_conflict");
1653
+ return;
1654
+ }
1655
+ }
1656
+ }
1657
+ /**
1658
+ * @ignore
1659
+ */
1660
+ addDocumentId(documentId) {
1661
+ const shortDocumentId = this._nextId;
1662
+ this._idToShortId.set(documentId, shortDocumentId);
1663
+ this._documentIds.set(shortDocumentId, documentId);
1664
+ this._documentCount += 1;
1665
+ this._nextId += 1;
1666
+ return shortDocumentId;
1667
+ }
1668
+ /**
1669
+ * @ignore
1670
+ */
1671
+ addFields(fields) {
1672
+ for (let i = 0; i < fields.length; i++) {
1673
+ this._fieldIds[fields[i]] = i;
1674
+ }
1675
+ }
1676
+ /**
1677
+ * @ignore
1678
+ */
1679
+ addFieldLength(documentId, fieldId, count, length) {
1680
+ let fieldLengths = this._fieldLength.get(documentId);
1681
+ if (fieldLengths == null)
1682
+ this._fieldLength.set(documentId, fieldLengths = []);
1683
+ fieldLengths[fieldId] = length;
1684
+ const averageFieldLength = this._avgFieldLength[fieldId] || 0;
1685
+ const totalFieldLength = averageFieldLength * count + length;
1686
+ this._avgFieldLength[fieldId] = totalFieldLength / (count + 1);
1687
+ }
1688
+ /**
1689
+ * @ignore
1690
+ */
1691
+ removeFieldLength(documentId, fieldId, count, length) {
1692
+ if (count === 1) {
1693
+ this._avgFieldLength[fieldId] = 0;
1694
+ return;
1695
+ }
1696
+ const totalFieldLength = this._avgFieldLength[fieldId] * count - length;
1697
+ this._avgFieldLength[fieldId] = totalFieldLength / (count - 1);
1698
+ }
1699
+ /**
1700
+ * @ignore
1701
+ */
1702
+ saveStoredFields(documentId, doc) {
1703
+ const { storeFields, extractField } = this._options;
1704
+ if (storeFields == null || storeFields.length === 0) {
1705
+ return;
1706
+ }
1707
+ let documentFields = this._storedFields.get(documentId);
1708
+ if (documentFields == null)
1709
+ this._storedFields.set(documentId, documentFields = {});
1710
+ for (const fieldName of storeFields) {
1711
+ const fieldValue = extractField(doc, fieldName);
1712
+ if (fieldValue !== void 0)
1713
+ documentFields[fieldName] = fieldValue;
1714
+ }
1715
+ }
1716
+ };
1717
+ MiniSearch.wildcard = Symbol("*");
1718
+ var getOwnProperty = (object, property) => Object.prototype.hasOwnProperty.call(object, property) ? object[property] : void 0;
1719
+ var combinators = {
1720
+ [OR]: (a, b) => {
1721
+ for (const docId of b.keys()) {
1722
+ const existing = a.get(docId);
1723
+ if (existing == null) {
1724
+ a.set(docId, b.get(docId));
1725
+ } else {
1726
+ const { score, terms, match } = b.get(docId);
1727
+ existing.score = existing.score + score;
1728
+ existing.match = Object.assign(existing.match, match);
1729
+ assignUniqueTerms(existing.terms, terms);
1730
+ }
1731
+ }
1732
+ return a;
1733
+ },
1734
+ [AND]: (a, b) => {
1735
+ const combined = /* @__PURE__ */ new Map();
1736
+ for (const docId of b.keys()) {
1737
+ const existing = a.get(docId);
1738
+ if (existing == null)
1739
+ continue;
1740
+ const { score, terms, match } = b.get(docId);
1741
+ assignUniqueTerms(existing.terms, terms);
1742
+ combined.set(docId, {
1743
+ score: existing.score + score,
1744
+ terms: existing.terms,
1745
+ match: Object.assign(existing.match, match)
1746
+ });
1747
+ }
1748
+ return combined;
1749
+ },
1750
+ [AND_NOT]: (a, b) => {
1751
+ for (const docId of b.keys())
1752
+ a.delete(docId);
1753
+ return a;
1754
+ }
1755
+ };
1756
+ var defaultBM25params = { k: 1.2, b: 0.7, d: 0.5 };
1757
+ var calcBM25Score = (termFreq, matchingCount, totalCount, fieldLength, avgFieldLength, bm25params) => {
1758
+ const { k, b, d } = bm25params;
1759
+ const invDocFreq = Math.log(1 + (totalCount - matchingCount + 0.5) / (matchingCount + 0.5));
1760
+ return invDocFreq * (d + termFreq * (k + 1) / (termFreq + k * (1 - b + b * fieldLength / avgFieldLength)));
1761
+ };
1762
+ var termToQuerySpec = (options) => (term, i, terms) => {
1763
+ const fuzzy = typeof options.fuzzy === "function" ? options.fuzzy(term, i, terms) : options.fuzzy || false;
1764
+ const prefix = typeof options.prefix === "function" ? options.prefix(term, i, terms) : options.prefix === true;
1765
+ const termBoost = typeof options.boostTerm === "function" ? options.boostTerm(term, i, terms) : 1;
1766
+ return { term, fuzzy, prefix, termBoost };
1767
+ };
1768
+ var defaultOptions = {
1769
+ idField: "id",
1770
+ extractField: (document, fieldName) => document[fieldName],
1771
+ stringifyField: (fieldValue, fieldName) => fieldValue.toString(),
1772
+ tokenize: (text) => text.split(SPACE_OR_PUNCTUATION),
1773
+ processTerm: (term) => term.toLowerCase(),
1774
+ fields: void 0,
1775
+ searchOptions: void 0,
1776
+ storeFields: [],
1777
+ logger: (level, message) => {
1778
+ if (typeof (console === null || console === void 0 ? void 0 : console[level]) === "function")
1779
+ console[level](message);
1780
+ },
1781
+ autoVacuum: true
1782
+ };
1783
+ var defaultSearchOptions = {
1784
+ combineWith: OR,
1785
+ prefix: false,
1786
+ fuzzy: false,
1787
+ maxFuzzy: 6,
1788
+ boost: {},
1789
+ weights: { fuzzy: 0.45, prefix: 0.375 },
1790
+ bm25: defaultBM25params
1791
+ };
1792
+ var defaultAutoSuggestOptions = {
1793
+ combineWith: AND,
1794
+ prefix: (term, i, terms) => i === terms.length - 1
1795
+ };
1796
+ var defaultVacuumOptions = { batchSize: 1e3, batchWait: 10 };
1797
+ var defaultVacuumConditions = { minDirtFactor: 0.1, minDirtCount: 20 };
1798
+ var defaultAutoVacuumOptions = { ...defaultVacuumOptions, ...defaultVacuumConditions };
1799
+ var assignUniqueTerm = (target, term) => {
1800
+ if (!target.includes(term))
1801
+ target.push(term);
1802
+ };
1803
+ var assignUniqueTerms = (target, source) => {
1804
+ for (const term of source) {
1805
+ if (!target.includes(term))
1806
+ target.push(term);
1807
+ }
1808
+ };
1809
+ var byScore = ({ score: a }, { score: b }) => b - a;
1810
+ var createMap = () => /* @__PURE__ */ new Map();
1811
+ var objectToNumericMap = (object) => {
1812
+ const map = /* @__PURE__ */ new Map();
1813
+ for (const key of Object.keys(object)) {
1814
+ map.set(parseInt(key, 10), object[key]);
1815
+ }
1816
+ return map;
1817
+ };
1818
+ var objectToNumericMapAsync = async (object) => {
1819
+ const map = /* @__PURE__ */ new Map();
1820
+ let count = 0;
1821
+ for (const key of Object.keys(object)) {
1822
+ map.set(parseInt(key, 10), object[key]);
1823
+ if (++count % 1e3 === 0) {
1824
+ await wait(0);
1825
+ }
1826
+ }
1827
+ return map;
1828
+ };
1829
+ var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1830
+ var SPACE_OR_PUNCTUATION = /[\n\r\p{Z}\p{P}]+/u;
1831
+
1832
+ // ../core/dist/index.js
1833
+ var rawDateSchema = z.object({
1834
+ year: z.number(),
1835
+ month: z.number().min(1).max(12),
1836
+ day: z.number().min(1).max(31)
1837
+ });
1838
+ var rawCourseCodeSchema = z.object({
1839
+ department: z.string(),
1840
+ courseNumber: z.number(),
1841
+ suffix: z.string(),
1842
+ affiliation: z.string()
1843
+ });
1844
+ var rawScheduleSchema = z.object({
1845
+ startTime: z.number(),
1846
+ // seconds since midnight
1847
+ endTime: z.number(),
1848
+ days: z.array(z.string()),
1849
+ locations: z.array(z.string())
1850
+ });
1851
+ var rawSectionSchema = z.object({
1852
+ courseAreas: z.array(z.string()),
1853
+ credits: z.number(),
1854
+ permCount: z.number().default(0),
1855
+ seatsTotal: z.number(),
1856
+ seatsFilled: z.number(),
1857
+ status: z.string(),
1858
+ // O | C | R | U (keep raw; map to labels downstream)
1859
+ startDate: rawDateSchema,
1860
+ endDate: rawDateSchema,
1861
+ instructors: z.array(z.object({ name: z.string() })),
1862
+ course: z.object({
1863
+ code: rawCourseCodeSchema,
1864
+ title: z.string(),
1865
+ description: z.string(),
1866
+ primaryAssociation: z.string(),
1867
+ potentialError: z.boolean().default(false)
1868
+ }),
1869
+ schedules: z.array(rawScheduleSchema),
1870
+ potentialError: z.boolean().default(false),
1871
+ identifier: z.object({
1872
+ department: z.string(),
1873
+ courseNumber: z.number(),
1874
+ suffix: z.string(),
1875
+ affiliation: z.string(),
1876
+ sectionNumber: z.number(),
1877
+ year: z.number(),
1878
+ term: z.string(),
1879
+ // FA | SP | SU
1880
+ half: z.nullable(z.object({ prefix: z.string(), number: z.number() }))
1881
+ })
1882
+ });
1883
+ var rawSectionsSchema = z.array(rawSectionSchema);
1884
+ var rawHistoryEntrySchema = z.object({
1885
+ code: rawCourseCodeSchema,
1886
+ terms: z.array(z.object({ year: z.number(), term: z.string() }))
1887
+ });
1888
+ var rawHistorySchema = z.array(rawHistoryEntrySchema);
1889
+ var rawTermSchema = z.object({ year: z.number(), term: z.string() });
1890
+ var rawCourseAreaSchema = z.object({
1891
+ area: z.string(),
1892
+ description: z.string()
1893
+ });
1894
+ var termSchema = z.string().regex(/^(FA|SP|SU)\d{4}$/, "Term must look like FA2026");
1895
+ var daySchema = z.enum(["M", "T", "W", "R", "F", "S", "U"]);
1896
+ var statusSchema = z.enum(["O", "C", "R", "U"]);
1897
+ var meetingSchema = z.object({
1898
+ days: z.array(daySchema),
1899
+ startMin: z.number(),
1900
+ // minutes since midnight (math)
1901
+ endMin: z.number(),
1902
+ startStr: z.string(),
1903
+ // "13:15" (quote)
1904
+ endStr: z.string(),
1905
+ start12: z.string(),
1906
+ // "1:15 PM" (display)
1907
+ end12: z.string(),
1908
+ locations: z.array(z.string())
1909
+ });
1910
+ var sectionSchema = z.object({
1911
+ id: z.string(),
1912
+ // "CSCI 051 PO-01 FA2026" (canonical, frozen v1)
1913
+ term: termSchema,
1914
+ year: z.number(),
1915
+ termCode: z.enum(["FA", "SP", "SU"]),
1916
+ half: z.nullable(z.object({ prefix: z.string(), number: z.number() })),
1917
+ dept: z.string(),
1918
+ number: z.number(),
1919
+ suffix: z.string(),
1920
+ school: z.string(),
1921
+ // affiliation, uppercase (PO/CM/HM/SC/PZ + KS/JP/…)
1922
+ sectionNo: z.number(),
1923
+ code: z.string(),
1924
+ // "CSCI 051" (dept + zero-padded + suffix, no school)
1925
+ title: z.string(),
1926
+ description: z.string(),
1927
+ areas: z.array(z.string()),
1928
+ instructors: z.array(z.string()),
1929
+ meetings: z.array(meetingSchema),
1930
+ credits: z.number(),
1931
+ status: statusSchema,
1932
+ statusLabel: z.string(),
1933
+ // Open | Closed | Reopened | Unknown
1934
+ seats: z.object({
1935
+ filled: z.number(),
1936
+ total: z.number(),
1937
+ available: z.number(),
1938
+ permCount: z.number()
1939
+ }),
1940
+ startDate: z.string(),
1941
+ // ISO
1942
+ endDate: z.string(),
1943
+ potentialError: z.boolean(),
1944
+ primaryAssociation: z.string(),
1945
+ affiliation: z.string()
1946
+ });
1947
+ var cacheMetaSchema = z.object({
1948
+ term: z.string(),
1949
+ fetched_at: z.string(),
1950
+ etag: z.string().nullable(),
1951
+ hash: z.string(),
1952
+ count: z.number(),
1953
+ source_url: z.string(),
1954
+ ttl_kind: z.string(),
1955
+ stale: z.boolean(),
1956
+ source: z.enum(["live", "stale-cache", "offline-file"]),
1957
+ quarantined: z.number().int().nonnegative().default(0),
1958
+ // Sections dropped because a later row produced the same canonical ID
1959
+ // (e.g. two half-semester rows collapsing onto one ID). Surfaced, never silent.
1960
+ duplicate_ids: z.number().int().nonnegative().optional()
1961
+ });
1962
+ var DEFAULT_API_BASE = "https://banana.hyperschedule.io/v4";
1963
+ var USER_AGENT = "5C-Index/0.1 (+research/edu; cache 60s)";
1964
+ function apiBase() {
1965
+ const override = process.env.FIVE_C_API_BASE?.trim();
1966
+ return override ? override.replace(/\/+$/, "") : DEFAULT_API_BASE;
1967
+ }
1968
+ async function sleep(ms) {
1969
+ return new Promise((r) => setTimeout(r, ms));
1970
+ }
1971
+ async function fetchJson(url, validate, opts = {}, fetchFn = globalThis.fetch) {
1972
+ const { etag, timeoutMs = 15e3, retries = 3 } = opts;
1973
+ let lastErr = null;
1974
+ for (let attempt = 0; attempt <= retries; attempt++) {
1975
+ const ctrl = new AbortController();
1976
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
1977
+ try {
1978
+ const res = await fetchFn(url, {
1979
+ signal: ctrl.signal,
1980
+ headers: {
1981
+ Accept: "application/json",
1982
+ "User-Agent": USER_AGENT,
1983
+ ...etag ? { "If-None-Match": etag } : {}
1984
+ }
1985
+ });
1986
+ if (res.status === 304) {
1987
+ clearTimeout(timer);
1988
+ return { data: void 0, etag: etag ?? null, notModified: true };
1989
+ }
1990
+ if (res.status === 404) {
1991
+ const err = new Error(`Not found: ${url}`);
1992
+ err.code = "E_NOT_FOUND_UPSTREAM";
1993
+ throw err;
1994
+ }
1995
+ if (res.status === 429 || res.status >= 500) {
1996
+ const retryAfter = res.headers.get("Retry-After");
1997
+ const wait2 = retryAfter ? Math.min(Number.parseInt(retryAfter, 10) * 1e3 || 2e3, 15e3) : Math.min(500 * 2 ** attempt + Math.random() * 250, 8e3);
1998
+ lastErr = new Error(`Upstream ${res.status} for ${url}`);
1999
+ if (attempt < retries) {
2000
+ clearTimeout(timer);
2001
+ await sleep(wait2);
2002
+ continue;
2003
+ }
2004
+ throw lastErr;
2005
+ }
2006
+ if (!res.ok) {
2007
+ const err = new Error(`Upstream ${res.status} for ${url}`);
2008
+ err.code = "E_FETCH";
2009
+ throw err;
2010
+ }
2011
+ const json = await res.json();
2012
+ clearTimeout(timer);
2013
+ return {
2014
+ data: validate(json),
2015
+ etag: res.headers.get("ETag"),
2016
+ notModified: false
2017
+ };
2018
+ } catch (err) {
2019
+ clearTimeout(timer);
2020
+ if (err.code === "E_NOT_FOUND_UPSTREAM") throw err;
2021
+ if (err.name === "AbortError") {
2022
+ lastErr = new Error(`Timeout fetching ${url}`);
2023
+ } else {
2024
+ lastErr = err;
2025
+ }
2026
+ if (attempt < retries && (!err.code || err.code === "E_FETCH")) {
2027
+ if (err.code === "E_FETCH") throw err;
2028
+ await sleep(Math.min(500 * 2 ** attempt + Math.random() * 250, 8e3));
2029
+ continue;
2030
+ }
2031
+ throw lastErr;
2032
+ }
2033
+ }
2034
+ throw lastErr;
2035
+ }
2036
+ function fetchAllTerms(opts, fetchFn) {
2037
+ return fetchJson(
2038
+ `${apiBase()}/term/all`,
2039
+ (u) => rawTermSchema.array().parse(u),
2040
+ opts,
2041
+ fetchFn
2042
+ );
2043
+ }
2044
+ function fetchCurrentTerm(opts, fetchFn) {
2045
+ return fetchJson(
2046
+ `${apiBase()}/term/current`,
2047
+ (u) => rawTermSchema.parse(u),
2048
+ opts,
2049
+ fetchFn
2050
+ );
2051
+ }
2052
+ function fetchOfferingHistory(term, opts, fetchFn) {
2053
+ return fetchJson(
2054
+ `${apiBase()}/offering-history/${term}`,
2055
+ (u) => rawHistorySchema.parse(u),
2056
+ opts,
2057
+ fetchFn
2058
+ );
2059
+ }
2060
+ var SCHOOL_ALIASES = {
2061
+ pomona: "PO",
2062
+ "pomona college": "PO",
2063
+ po: "PO",
2064
+ // Registrar data uses CM (not CMC) — all Claremont McKenna inputs map to CM.
2065
+ cm: "CM",
2066
+ cmc: "CM",
2067
+ "claremont mckenna": "CM",
2068
+ "claremont mckenna college": "CM",
2069
+ mckenna: "CM",
2070
+ "harvey mudd": "HM",
2071
+ "harvey mudd college": "HM",
2072
+ mudd: "HM",
2073
+ hmc: "HM",
2074
+ hm: "HM",
2075
+ scripps: "SC",
2076
+ "scripps college": "SC",
2077
+ sc: "SC",
2078
+ pitzer: "PZ",
2079
+ "pitzer college": "PZ",
2080
+ pz: "PZ",
2081
+ cgu: "CG",
2082
+ cg: "CG",
2083
+ keck: "KS",
2084
+ ks: "KS"
2085
+ };
2086
+ function normalizeSchool(input) {
2087
+ const key = input.trim().toLowerCase();
2088
+ if (SCHOOL_ALIASES[key]) return SCHOOL_ALIASES[key];
2089
+ return input.trim().toUpperCase();
2090
+ }
2091
+ var DISTINCTIVE_SCHOOL_WORDS = {
2092
+ mudd: "HM",
2093
+ hmc: "HM",
2094
+ pomona: "PO",
2095
+ pitzer: "PZ",
2096
+ scripps: "SC",
2097
+ mckenna: "CM",
2098
+ cmc: "CM",
2099
+ keck: "KS"
2100
+ };
2101
+ function detectSchoolMention(text) {
2102
+ const words = text.toLowerCase().split(/[^a-z]+/).filter(Boolean);
2103
+ for (const w of words) {
2104
+ const hit = DISTINCTIVE_SCHOOL_WORDS[w];
2105
+ if (hit) return hit;
2106
+ }
2107
+ return null;
2108
+ }
2109
+ function secToMin(sec) {
2110
+ return Math.floor(sec / 60);
2111
+ }
2112
+ function minToStr(min) {
2113
+ const h = Math.floor(min / 60);
2114
+ const m = min % 60;
2115
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
2116
+ }
2117
+ function minTo12(min) {
2118
+ const h24 = Math.floor(min / 60);
2119
+ const m = min % 60;
2120
+ const suffix = h24 >= 12 ? "PM" : "AM";
2121
+ const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
2122
+ return `${h12}:${String(m).padStart(2, "0")} ${suffix}`;
2123
+ }
2124
+ function sectionId(args) {
2125
+ const num = String(args.number).padStart(3, "0");
2126
+ const sec = String(args.sectionNo).padStart(2, "0");
2127
+ return `${args.dept.toUpperCase()} ${num}${args.suffix.toUpperCase()} ${args.school.toUpperCase()}-${sec} ${args.term.toUpperCase()}`;
2128
+ }
2129
+ function courseCode(dept, number, suffix) {
2130
+ return `${dept.toUpperCase()} ${String(number).padStart(3, "0")}${suffix.toUpperCase()}`;
2131
+ }
2132
+ var CODE_RE = /^([A-Za-z]{2,4})?\s*(\d{1,3}[A-Za-z]?)?(?:\s*[- ]?\s*([A-Za-z]{2}))?(?:\s*[- ]?\s*0?(\d{1,2}))?\s*$/;
2133
+ var TERM_RE = /\b((?:FA|SP|SU)\s*\d{2,4}|(?:Fall|Spring|Summer)\s*\d{2,4}|\d{4}\s*(?:FA|SP|SU))\b/i;
2134
+ function parseCode(input) {
2135
+ const out = {};
2136
+ let rest = input.trim();
2137
+ const termMatch = rest.match(TERM_RE);
2138
+ if (termMatch) {
2139
+ const normalized = normalizeTerm(termMatch[1]);
2140
+ if (normalized) out.term = normalized;
2141
+ rest = rest.replace(termMatch[0], " ").trim();
2142
+ }
2143
+ const m = rest.match(CODE_RE);
2144
+ if (!m || !m[1] && !m[2]) return out;
2145
+ if (m[1]) out.dept = m[1].toUpperCase();
2146
+ if (m[2]) {
2147
+ const numPart = m[2].match(/^(\d+)([A-Za-z]?)$/);
2148
+ if (numPart) {
2149
+ out.number = Number.parseInt(numPart[1], 10);
2150
+ out.numberRaw = numPart[1];
2151
+ if (numPart[2]) out.suffix = numPart[2].toUpperCase();
2152
+ }
2153
+ }
2154
+ if (m[3]) out.school = normalizeSchool(m[3]);
2155
+ if (m[4]) out.sectionNo = Number.parseInt(m[4], 10);
2156
+ return out;
2157
+ }
2158
+ function normalizeTerm(input) {
2159
+ const t = input.trim().toUpperCase().replace(/[\s;_-]+/g, "");
2160
+ const seasonMap = {
2161
+ FA: "FA",
2162
+ F: "FA",
2163
+ FALL: "FA",
2164
+ SP: "SP",
2165
+ S: "SP",
2166
+ SPRING: "SP",
2167
+ P: "SP",
2168
+ SU: "SU",
2169
+ SUMMER: "SU",
2170
+ U: "SU"
2171
+ };
2172
+ let m = t.match(/^(FA|SP|SU|FALL|SPRING|SUMMER|F|S|P|U)(\d{2}|\d{4})$/);
2173
+ if (m) {
2174
+ const season = seasonMap[m[1]];
2175
+ const year = m[2].length === 2 ? `20${m[2]}` : m[2];
2176
+ return `${season}${year}`;
2177
+ }
2178
+ m = t.match(/^(\d{4})(FA|SP|SU|FALL|SPRING|SUMMER)$/);
2179
+ if (m) {
2180
+ const season = m[2] === "FALL" ? "FA" : m[2] === "SPRING" ? "SP" : m[2] === "SUMMER" ? "SU" : m[2];
2181
+ return `${season}${m[1]}`;
2182
+ }
2183
+ m = t.match(/^(\d{4})(FA|SP|SU)$/);
2184
+ if (m) return `${m[2]}${m[1]}`;
2185
+ return null;
2186
+ }
2187
+ function termLabel(term) {
2188
+ const m = term.match(/^(FA|SP|SU)(\d{4})$/);
2189
+ if (!m) return term;
2190
+ const season = m[1] === "FA" ? "Fall" : m[1] === "SP" ? "Spring" : "Summer";
2191
+ return `${season} ${m[2]}`;
2192
+ }
2193
+ function isRegistrable(status) {
2194
+ return status === "O" || status === "R";
2195
+ }
2196
+ var SEASON_ORDER = { SP: 0, SU: 1, FA: 2 };
2197
+ function termRecency(term) {
2198
+ const m = term.match(/^(FA|SP|SU)(\d{4})$/);
2199
+ if (!m) return Number.NaN;
2200
+ return Number(m[2]) * 10 + (SEASON_ORDER[m[1]] ?? 0);
2201
+ }
2202
+ function compareTermRecencyDesc(a, b) {
2203
+ const ra = termRecency(a);
2204
+ const rb = termRecency(b);
2205
+ if (Number.isNaN(ra) && Number.isNaN(rb)) return a.localeCompare(b);
2206
+ if (Number.isNaN(ra)) return 1;
2207
+ if (Number.isNaN(rb)) return -1;
2208
+ return rb - ra;
2209
+ }
2210
+ var DAY_ALIASES = {
2211
+ M: "M",
2212
+ MON: "M",
2213
+ T: "T",
2214
+ TU: "T",
2215
+ TUE: "T",
2216
+ W: "W",
2217
+ WED: "W",
2218
+ R: "R",
2219
+ TH: "R",
2220
+ THU: "R",
2221
+ F: "F",
2222
+ FRI: "F",
2223
+ S: "S",
2224
+ SA: "S",
2225
+ SAT: "S",
2226
+ U: "U",
2227
+ SU: "U",
2228
+ SUN: "U"
2229
+ };
2230
+ function normalizeDays(input) {
2231
+ const t = input.trim().toUpperCase();
2232
+ if (t === "DAILY" || t === "WEEKDAYS") return ["M", "T", "W", "R", "F"];
2233
+ if (t === "WEEKENDS") return ["S", "U"];
2234
+ if (t === "ANY") return [];
2235
+ const cleaned = t.replace(/[^A-Z]/g, " ");
2236
+ const tokens = cleaned.split(/\s+/).filter(Boolean);
2237
+ const out = [];
2238
+ for (const tok of tokens) {
2239
+ let rest = tok;
2240
+ const matched = [];
2241
+ let ok = true;
2242
+ while (rest.length > 0) {
2243
+ const three = DAY_ALIASES[rest.slice(0, 3)];
2244
+ const two = DAY_ALIASES[rest.slice(0, 2)];
2245
+ const one = DAY_ALIASES[rest.slice(0, 1)];
2246
+ if (three && rest.length >= 3 && ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].includes(
2247
+ rest.slice(0, 3)
2248
+ )) {
2249
+ matched.push(three);
2250
+ rest = rest.slice(3);
2251
+ } else if (two && ["TU", "TH", "SA", "SU", "MO", "WE", "FR"].includes(rest.slice(0, 2))) {
2252
+ matched.push(two);
2253
+ rest = rest.slice(2);
2254
+ } else if (one) {
2255
+ matched.push(one);
2256
+ rest = rest.slice(1);
2257
+ } else {
2258
+ ok = false;
2259
+ break;
2260
+ }
2261
+ }
2262
+ if (!ok) return null;
2263
+ out.push(...matched);
2264
+ }
2265
+ const order = ["M", "T", "W", "R", "F", "S", "U"];
2266
+ const unique = [...new Set(out)].sort(
2267
+ (a, b) => order.indexOf(a) - order.indexOf(b)
2268
+ );
2269
+ return unique.length === 0 ? null : unique;
2270
+ }
2271
+ function parseTime(input) {
2272
+ let t = input.trim().toLowerCase();
2273
+ t = t.replace(/^(after|before|at|around)\s+/, "");
2274
+ if (t === "morning" || t === "mornings") return 8 * 60;
2275
+ if (t === "afternoon" || t === "afternoons") return 12 * 60;
2276
+ if (t === "evening" || t === "evenings" || t === "night") return 17 * 60;
2277
+ if (t === "noon") return 12 * 60;
2278
+ if (t === "midnight") return 0;
2279
+ const m = t.match(/^(\d{1,4})(?::(\d{2}))?\s*([ap])\.?m?\.?$/);
2280
+ if (m) {
2281
+ let h;
2282
+ let min = m[2] ? Number.parseInt(m[2], 10) : 0;
2283
+ if (m[1].length > 2 && !m[2]) {
2284
+ h = Number.parseInt(m[1].slice(0, -2), 10);
2285
+ min = Number.parseInt(m[1].slice(-2), 10);
2286
+ } else {
2287
+ h = Number.parseInt(m[1], 10);
2288
+ }
2289
+ const ap = m[3];
2290
+ if (h < 1 || h > 12 || min > 59) return null;
2291
+ if (ap === "p" && h !== 12) h += 12;
2292
+ if (ap === "a" && h === 12) h = 0;
2293
+ return h * 60 + min;
2294
+ }
2295
+ const m24 = t.match(/^(\d{1,2})(?::(\d{2}))?$/);
2296
+ if (m24) {
2297
+ const h = Number.parseInt(m24[1], 10);
2298
+ const min = m24[2] ? Number.parseInt(m24[2], 10) : 0;
2299
+ if (h > 23 || min > 59) return null;
2300
+ return h * 60 + min;
2301
+ }
2302
+ const m4 = t.match(/^(\d{3,4})$/);
2303
+ if (m4) {
2304
+ const h = Number.parseInt(m4[1].slice(0, -2), 10);
2305
+ const min = Number.parseInt(m4[1].slice(-2), 10);
2306
+ if (h > 23 || min > 59) return null;
2307
+ return h * 60 + min;
2308
+ }
2309
+ return null;
2310
+ }
2311
+ var STATUS_LABELS = {
2312
+ O: "Open",
2313
+ C: "Closed",
2314
+ R: "Reopened",
2315
+ U: "Unknown"
2316
+ };
2317
+ function toISO(d) {
2318
+ const mm = String(d.month).padStart(2, "0");
2319
+ const dd = String(d.day).padStart(2, "0");
2320
+ return `${d.year}-${mm}-${dd}`;
2321
+ }
2322
+ var VALID_DAYS = /* @__PURE__ */ new Set(["M", "T", "W", "R", "F", "S", "U"]);
2323
+ function normalizeSection(raw) {
2324
+ const id = raw.identifier;
2325
+ const term = `${id.term}${id.year}`;
2326
+ const school = id.affiliation.toUpperCase();
2327
+ const status = ["O", "C", "R", "U"].includes(raw.status) ? raw.status : "U";
2328
+ const meetings = [];
2329
+ let droppedMeetings = false;
2330
+ for (const s of raw.schedules.filter(
2331
+ (x) => !(x.startTime === 0 && x.endTime === 0 && x.days.length === 0)
2332
+ )) {
2333
+ const startMin = secToMin(s.startTime);
2334
+ const endMin = secToMin(s.endTime);
2335
+ if (endMin <= startMin) {
2336
+ droppedMeetings = true;
2337
+ continue;
2338
+ }
2339
+ meetings.push({
2340
+ days: s.days.filter((d) => VALID_DAYS.has(d)),
2341
+ startMin,
2342
+ endMin,
2343
+ startStr: minToStr(startMin),
2344
+ endStr: minToStr(endMin),
2345
+ start12: minTo12(startMin),
2346
+ end12: minTo12(endMin),
2347
+ locations: s.locations.map((l) => l.trim()).filter((l) => l.length > 0)
2348
+ });
2349
+ }
2350
+ const available = Math.max(0, raw.seatsTotal - raw.seatsFilled);
2351
+ return {
2352
+ id: sectionId({
2353
+ dept: id.department,
2354
+ number: id.courseNumber,
2355
+ suffix: id.suffix,
2356
+ school,
2357
+ sectionNo: id.sectionNumber,
2358
+ term
2359
+ }),
2360
+ term,
2361
+ year: id.year,
2362
+ termCode: ["FA", "SP", "SU"].includes(id.term) ? id.term : "FA",
2363
+ half: id.half,
2364
+ dept: id.department.toUpperCase(),
2365
+ number: id.courseNumber,
2366
+ suffix: id.suffix.toUpperCase(),
2367
+ school,
2368
+ sectionNo: id.sectionNumber,
2369
+ code: courseCode(id.department, id.courseNumber, id.suffix),
2370
+ title: raw.course.title,
2371
+ description: raw.course.description,
2372
+ areas: raw.courseAreas,
2373
+ instructors: raw.instructors.map((i) => i.name.replace(/\s+/g, " ").trim()),
2374
+ meetings,
2375
+ credits: raw.credits,
2376
+ status,
2377
+ statusLabel: STATUS_LABELS[status] ?? "Unknown",
2378
+ seats: {
2379
+ filled: raw.seatsFilled,
2380
+ total: raw.seatsTotal,
2381
+ available,
2382
+ permCount: raw.permCount
2383
+ },
2384
+ startDate: toISO(raw.startDate),
2385
+ endDate: toISO(raw.endDate),
2386
+ potentialError: raw.potentialError || raw.course.potentialError || droppedMeetings,
2387
+ primaryAssociation: raw.course.primaryAssociation,
2388
+ affiliation: school
2389
+ };
2390
+ }
2391
+ var SEAT_TTL_MS = 6e4;
2392
+ var SCHEDULE_TTL_MS = 30 * 6e4;
2393
+ var HISTORY_TTL_MS = 24 * 60 * 6e4;
2394
+ var CURRENT_TERM_TTL_MS = 5 * 6e4;
2395
+ var NEGATIVE_TTL_MS = 5 * 6e4;
2396
+ var HISTORY_MEM_CAP = 8;
2397
+ function cacheDir() {
2398
+ const override = process.env.FIVE_C_DATA_DIR ?? process.env.FIVE_C_CACHE_DIR ?? null;
2399
+ return override ?? join(homedir(), ".5c-index", "cache");
2400
+ }
2401
+ function files(term) {
2402
+ const dir = cacheDir();
2403
+ return {
2404
+ dir,
2405
+ data: join(dir, `${term}.json`),
2406
+ meta: join(dir, `${term}.meta.json`),
2407
+ history: join(dir, `${term}.history.json`),
2408
+ neg: join(dir, `${term}.neg.json`)
2409
+ };
2410
+ }
2411
+ function currentTermFile() {
2412
+ return join(cacheDir(), "current-term.json");
2413
+ }
2414
+ function sha1(s) {
2415
+ return createHash("sha1").update(s).digest("hex");
2416
+ }
2417
+ var mem = /* @__PURE__ */ new Map();
2418
+ var memHistory = /* @__PURE__ */ new Map();
2419
+ var negative = /* @__PURE__ */ new Map();
2420
+ var inflightRefresh = /* @__PURE__ */ new Map();
2421
+ var inflightHistory = /* @__PURE__ */ new Map();
2422
+ var currentTermMem = null;
2423
+ function getCurrentTermMem() {
2424
+ return currentTermMem;
2425
+ }
2426
+ function setCurrentTermMem(value, at) {
2427
+ currentTermMem = { value, at };
2428
+ }
2429
+ function isStale(meta) {
2430
+ const t = Date.parse(meta.fetched_at);
2431
+ if (!Number.isFinite(t)) return true;
2432
+ const age = Date.now() - t;
2433
+ if (!Number.isFinite(age) || age < 0) return true;
2434
+ return age > SEAT_TTL_MS;
2435
+ }
2436
+ function historyIsStale(fetched_at) {
2437
+ const t = Date.parse(fetched_at);
2438
+ if (!Number.isFinite(t)) return true;
2439
+ const age = Date.now() - t;
2440
+ if (!Number.isFinite(age) || age < 0) return true;
2441
+ return age > HISTORY_TTL_MS;
2442
+ }
2443
+ function nowISO() {
2444
+ return (/* @__PURE__ */ new Date()).toISOString();
2445
+ }
2446
+ function sleep2(ms) {
2447
+ return new Promise((r) => setTimeout(r, ms));
2448
+ }
2449
+ async function atomicWrite(path, content) {
2450
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
2451
+ await writeFile(tmp, content);
2452
+ try {
2453
+ const fh = await open(tmp, "r");
2454
+ try {
2455
+ await fh.sync();
2456
+ } finally {
2457
+ await fh.close();
2458
+ }
2459
+ await rename(tmp, path);
2460
+ } catch {
2461
+ try {
2462
+ await unlink(tmp);
2463
+ } catch {
2464
+ }
2465
+ throw new Error(`Atomic write failed for ${path}`);
2466
+ }
2467
+ }
2468
+ function countTolerance(metaCount) {
2469
+ return Math.max(10, Math.floor(metaCount * 0.1));
2470
+ }
2471
+ function validateSnapshot(sections, meta) {
2472
+ const m = cacheMetaSchema.safeParse(meta);
2473
+ if (!m.success) return null;
2474
+ const s = sectionSchema.array().safeParse(sections);
2475
+ if (!s.success) return null;
2476
+ if (s.data.length === 0) return null;
2477
+ if (Math.abs(s.data.length - m.data.count) > countTolerance(m.data.count)) {
2478
+ return null;
2479
+ }
2480
+ return { sections: s.data, meta: m.data };
2481
+ }
2482
+ function quarantineRawSections(data) {
2483
+ const whole = rawSectionsSchema.safeParse(data);
2484
+ if (whole.success) return { raw: whole.data, quarantined: 0 };
2485
+ if (!Array.isArray(data)) return { raw: [], quarantined: 0 };
2486
+ const good = [];
2487
+ let quarantined = 0;
2488
+ for (const item of data) {
2489
+ const r = rawSectionSchema.safeParse(item);
2490
+ if (r.success) good.push(r.data);
2491
+ else quarantined++;
2492
+ }
2493
+ return { raw: good, quarantined };
2494
+ }
2495
+ async function readSnapshot(T) {
2496
+ const f = files(T);
2497
+ for (let attempt = 0; attempt < 2; attempt++) {
2498
+ let metaRaw;
2499
+ let dataRaw;
2500
+ try {
2501
+ metaRaw = await readFile(f.meta, "utf8");
2502
+ dataRaw = await readFile(f.data, "utf8");
2503
+ } catch {
2504
+ return null;
2505
+ }
2506
+ let metaParsed;
2507
+ try {
2508
+ metaParsed = JSON.parse(metaRaw);
2509
+ } catch {
2510
+ return null;
2511
+ }
2512
+ const meta = metaParsed;
2513
+ if (typeof meta?.hash === "string" && sha1(dataRaw) !== meta.hash) {
2514
+ if (attempt === 0) {
2515
+ await sleep2(50);
2516
+ continue;
2517
+ }
2518
+ return null;
2519
+ }
2520
+ let sectionsRaw;
2521
+ try {
2522
+ sectionsRaw = JSON.parse(dataRaw);
2523
+ } catch {
2524
+ return null;
2525
+ }
2526
+ return validateSnapshot(sectionsRaw, metaParsed);
2527
+ }
2528
+ return null;
2529
+ }
2530
+ async function loadTerm(term, opts = {}) {
2531
+ const T = term.toUpperCase();
2532
+ const cached = mem.get(T);
2533
+ if (cached) {
2534
+ if (cached.sections.length === 0) {
2535
+ mem.delete(T);
2536
+ } else if (!isStale(cached.meta)) {
2537
+ return cached;
2538
+ }
2539
+ }
2540
+ const disk = await readSnapshot(T);
2541
+ if (disk) {
2542
+ disk.meta.stale = isStale(disk.meta);
2543
+ disk.meta.source = disk.meta.stale ? "stale-cache" : "live";
2544
+ mem.set(T, disk);
2545
+ if (disk.meta.stale && opts.allowStale !== false) {
2546
+ void refreshTerm(T, { fetchFn: opts.fetchFn }).catch(() => {
2547
+ });
2548
+ return disk;
2549
+ }
2550
+ if (!disk.meta.stale) return disk;
2551
+ } else {
2552
+ const staleMem = mem.get(T);
2553
+ if (staleMem && staleMem.sections.length > 0 && opts.allowStale !== false) {
2554
+ staleMem.meta.stale = true;
2555
+ staleMem.meta.source = "stale-cache";
2556
+ void refreshTerm(T, { fetchFn: opts.fetchFn }).catch(() => {
2557
+ });
2558
+ return staleMem;
2559
+ }
2560
+ }
2561
+ return refreshTerm(T, { fetchFn: opts.fetchFn });
2562
+ }
2563
+ async function readNegative(T) {
2564
+ const f = files(T);
2565
+ try {
2566
+ const raw = await readFile(f.neg, "utf8");
2567
+ const at = JSON.parse(raw).at;
2568
+ if (typeof at === "number" && Number.isFinite(at)) {
2569
+ if (Date.now() - at < NEGATIVE_TTL_MS) {
2570
+ negative.set(T, at);
2571
+ return at;
2572
+ }
2573
+ void unlink(f.neg).catch(() => {
2574
+ });
2575
+ negative.delete(T);
2576
+ return null;
2577
+ }
2578
+ } catch {
2579
+ }
2580
+ const memAt = negative.get(T);
2581
+ if (memAt !== void 0) {
2582
+ if (Date.now() - memAt < NEGATIVE_TTL_MS) return memAt;
2583
+ negative.delete(T);
2584
+ }
2585
+ return null;
2586
+ }
2587
+ async function writeNegative(T) {
2588
+ const at = Date.now();
2589
+ negative.set(T, at);
2590
+ try {
2591
+ const f = files(T);
2592
+ await mkdir(f.dir, { recursive: true });
2593
+ await atomicWrite(f.neg, JSON.stringify({ at }));
2594
+ } catch {
2595
+ }
2596
+ }
2597
+ async function clearNegative(T) {
2598
+ negative.delete(T);
2599
+ try {
2600
+ await unlink(files(T).neg);
2601
+ } catch {
2602
+ }
2603
+ }
2604
+ function termNotFound(T) {
2605
+ return Object.assign(new Error(`Term ${T} not available upstream (404)`), {
2606
+ code: "E_TERM_NOT_FOUND"
2607
+ });
2608
+ }
2609
+ async function refreshTerm(term, opts = {}) {
2610
+ const T = term.toUpperCase();
2611
+ if (!opts.force) {
2612
+ const neg = await readNegative(T);
2613
+ if (neg !== null) {
2614
+ throw Object.assign(
2615
+ new Error(`Term ${T} recently 404'd upstream; retry shortly`),
2616
+ { code: "E_TERM_NOT_FOUND" }
2617
+ );
2618
+ }
2619
+ }
2620
+ const key = opts.force ? `${T}:force` : T;
2621
+ const inflight = inflightRefresh.get(key);
2622
+ if (inflight) return inflight;
2623
+ const p = refreshTermInner(T, opts).then((entry) => {
2624
+ inflightRefresh.delete(key);
2625
+ return entry;
2626
+ }).catch((err) => {
2627
+ inflightRefresh.delete(key);
2628
+ throw err;
2629
+ });
2630
+ inflightRefresh.set(key, p);
2631
+ return p;
2632
+ }
2633
+ async function refreshTermInner(T, opts) {
2634
+ const f = files(T);
2635
+ let prevEtag = null;
2636
+ try {
2637
+ const metaRaw = await readFile(f.meta, "utf8");
2638
+ prevEtag = JSON.parse(metaRaw).etag ?? null;
2639
+ } catch {
2640
+ }
2641
+ async function doFetch(etag) {
2642
+ return fetchJson(
2643
+ `${apiBase()}/sections/${T}`,
2644
+ (u) => u,
2645
+ { etag: opts.force ? null : etag },
2646
+ opts.fetchFn
2647
+ );
2648
+ }
2649
+ let res;
2650
+ try {
2651
+ res = await doFetch(prevEtag);
2652
+ } catch (err) {
2653
+ if (err.code === "E_NOT_FOUND_UPSTREAM") {
2654
+ await writeNegative(T);
2655
+ const disk2 = await readSnapshot(T);
2656
+ if (disk2) {
2657
+ disk2.meta.stale = true;
2658
+ disk2.meta.source = "offline-file";
2659
+ mem.set(T, disk2);
2660
+ return disk2;
2661
+ }
2662
+ throw termNotFound(T);
2663
+ }
2664
+ const disk = await readSnapshot(T);
2665
+ if (disk) {
2666
+ disk.meta.stale = true;
2667
+ disk.meta.source = "offline-file";
2668
+ mem.set(T, disk);
2669
+ return disk;
2670
+ }
2671
+ throw err;
2672
+ }
2673
+ if (res === void 0 || res.notModified) {
2674
+ const disk = await readSnapshot(T);
2675
+ if (!disk) {
2676
+ try {
2677
+ res = await doFetch(null);
2678
+ } catch (err) {
2679
+ if (err.code === "E_NOT_FOUND_UPSTREAM") {
2680
+ await writeNegative(T);
2681
+ throw termNotFound(T);
2682
+ }
2683
+ throw err;
2684
+ }
2685
+ if (res === void 0 || res.notModified) {
2686
+ throw termNotFound(T);
2687
+ }
2688
+ return persistFresh(T, res.data, res.etag);
2689
+ }
2690
+ disk.meta.fetched_at = nowISO();
2691
+ disk.meta.stale = false;
2692
+ disk.meta.source = "live";
2693
+ await atomicWrite(f.meta, JSON.stringify(disk.meta, null, 1));
2694
+ mem.set(T, disk);
2695
+ return disk;
2696
+ }
2697
+ return persistFresh(T, res.data, res.etag);
2698
+ }
2699
+ async function persistFresh(T, data, etag) {
2700
+ const f = files(T);
2701
+ const { raw, quarantined } = quarantineRawSections(data);
2702
+ if (raw.length === 0) {
2703
+ const disk = await readSnapshot(T);
2704
+ if (disk) {
2705
+ disk.meta.stale = true;
2706
+ disk.meta.source = "offline-file";
2707
+ mem.set(T, disk);
2708
+ return disk;
2709
+ }
2710
+ throw Object.assign(
2711
+ new Error(`Upstream payload for term ${T} contained no usable sections`),
2712
+ { code: "E_FETCH" }
2713
+ );
2714
+ }
2715
+ const sections = raw.map(normalizeSection).filter((s) => s.term === T);
2716
+ if (sections.length === 0) {
2717
+ const disk = await readSnapshot(T);
2718
+ if (disk) {
2719
+ disk.meta.stale = true;
2720
+ disk.meta.source = "offline-file";
2721
+ mem.set(T, disk);
2722
+ return disk;
2723
+ }
2724
+ throw Object.assign(
2725
+ new Error(`No sections for term ${T} after term filter`),
2726
+ { code: "E_FETCH" }
2727
+ );
2728
+ }
2729
+ const seen = /* @__PURE__ */ new Set();
2730
+ const deduped = [];
2731
+ let duplicateIds = 0;
2732
+ for (const s of sections) {
2733
+ if (seen.has(s.id)) {
2734
+ duplicateIds++;
2735
+ continue;
2736
+ }
2737
+ seen.add(s.id);
2738
+ deduped.push(s);
2739
+ }
2740
+ const prevDisk = await readSnapshot(T);
2741
+ if (prevDisk) {
2742
+ const total = raw.length + quarantined;
2743
+ const badRatio = total > 0 ? quarantined / total : 0;
2744
+ const droppedBelowHalf = deduped.length < Math.ceil(prevDisk.sections.length / 2);
2745
+ if (quarantined >= 10 && badRatio > 0.05 || droppedBelowHalf) {
2746
+ prevDisk.meta.stale = true;
2747
+ prevDisk.meta.source = "offline-file";
2748
+ mem.set(T, prevDisk);
2749
+ throw Object.assign(
2750
+ new Error(
2751
+ `Refusing snapshot for ${T}: ${quarantined} quarantined row(s), count ${prevDisk.sections.length} \u2192 ${deduped.length}. Keeping last-good snapshot.`
2752
+ ),
2753
+ { code: "E_FETCH" }
2754
+ );
2755
+ }
2756
+ }
2757
+ const dataText = JSON.stringify(deduped);
2758
+ const meta = {
2759
+ term: T,
2760
+ fetched_at: nowISO(),
2761
+ etag,
2762
+ hash: sha1(dataText),
2763
+ count: deduped.length,
2764
+ source_url: `${apiBase()}/sections/${T}`,
2765
+ ttl_kind: "seats:60s/schedules:30min",
2766
+ stale: false,
2767
+ source: "live",
2768
+ quarantined,
2769
+ duplicate_ids: duplicateIds
2770
+ };
2771
+ await mkdir(f.dir, { recursive: true });
2772
+ await atomicWrite(f.data, dataText);
2773
+ await atomicWrite(f.meta, JSON.stringify(meta, null, 1));
2774
+ const entry = { sections: deduped, meta };
2775
+ mem.set(T, entry);
2776
+ await clearNegative(T);
2777
+ try {
2778
+ if (currentTermMem?.value === T) {
2779
+ await writeCurrentTermFile(T);
2780
+ } else {
2781
+ const cur = await readCurrentTermFile();
2782
+ if (cur?.value === T) await writeCurrentTermFile(T);
2783
+ }
2784
+ } catch {
2785
+ }
2786
+ return entry;
2787
+ }
2788
+ async function readCurrentTermFile() {
2789
+ try {
2790
+ const raw = await readFile(currentTermFile(), "utf8");
2791
+ const parsed = JSON.parse(raw);
2792
+ if (typeof parsed?.value !== "string" || !/^(FA|SP|SU)\d{4}$/.test(parsed.value) || typeof parsed?.at !== "number" || !Number.isFinite(parsed.at)) {
2793
+ return null;
2794
+ }
2795
+ return { value: parsed.value, at: parsed.at };
2796
+ } catch {
2797
+ return null;
2798
+ }
2799
+ }
2800
+ async function writeCurrentTermFile(value) {
2801
+ const payload = { value, at: Date.now() };
2802
+ try {
2803
+ await mkdir(cacheDir(), { recursive: true });
2804
+ await atomicWrite(currentTermFile(), JSON.stringify(payload));
2805
+ } catch {
2806
+ }
2807
+ setCurrentTermMem(payload.value, payload.at);
2808
+ return payload;
2809
+ }
2810
+ function memHistorySet(T, payload) {
2811
+ memHistory.delete(T);
2812
+ memHistory.set(T, payload);
2813
+ while (memHistory.size > HISTORY_MEM_CAP) {
2814
+ const oldest = memHistory.keys().next();
2815
+ if (oldest.done) break;
2816
+ memHistory.delete(oldest.value);
2817
+ }
2818
+ }
2819
+ async function loadHistory(term, opts = {}) {
2820
+ const T = term.toUpperCase();
2821
+ const cached = memHistory.get(T);
2822
+ if (cached && !historyIsStale(cached.fetched_at)) {
2823
+ memHistory.delete(T);
2824
+ memHistory.set(T, cached);
2825
+ return { ...cached, stale: false };
2826
+ }
2827
+ const inflight = inflightHistory.get(T);
2828
+ if (inflight) return inflight;
2829
+ const p = loadHistoryInner(T, opts, cached ?? null).then((entry) => {
2830
+ inflightHistory.delete(T);
2831
+ return entry;
2832
+ }).catch((err) => {
2833
+ inflightHistory.delete(T);
2834
+ throw err;
2835
+ });
2836
+ inflightHistory.set(T, p);
2837
+ return p;
2838
+ }
2839
+ async function loadHistoryInner(T, opts, staleMem) {
2840
+ const f = files(T);
2841
+ let staleDisk = null;
2842
+ try {
2843
+ const raw = await readFile(f.history, "utf8");
2844
+ const parsed = JSON.parse(raw);
2845
+ const entries = rawHistorySchema.safeParse(parsed?.entries);
2846
+ if (entries.success && typeof parsed?.fetched_at === "string" && !historyIsStale(parsed.fetched_at)) {
2847
+ const payload = { entries: entries.data, fetched_at: parsed.fetched_at };
2848
+ memHistorySet(T, payload);
2849
+ return { ...payload, stale: false };
2850
+ }
2851
+ if (entries.success && typeof parsed?.fetched_at === "string") {
2852
+ staleDisk = { entries: entries.data, fetched_at: parsed.fetched_at };
2853
+ }
2854
+ } catch {
2855
+ }
2856
+ try {
2857
+ const res = await fetchOfferingHistory(T, {}, opts.fetchFn);
2858
+ const entries = rawHistorySchema.parse(res.data);
2859
+ const payload = { entries, fetched_at: nowISO() };
2860
+ await mkdir(f.dir, { recursive: true });
2861
+ await atomicWrite(f.history, JSON.stringify(payload));
2862
+ memHistorySet(T, payload);
2863
+ return { ...payload, stale: false };
2864
+ } catch (err) {
2865
+ if (staleMem) {
2866
+ memHistorySet(T, staleMem);
2867
+ return { ...staleMem, stale: true };
2868
+ }
2869
+ if (staleDisk) {
2870
+ memHistorySet(T, staleDisk);
2871
+ return { ...staleDisk, stale: true };
2872
+ }
2873
+ if (err.code === "E_NOT_FOUND_UPSTREAM") {
2874
+ throw termNotFound(T);
2875
+ }
2876
+ throw err;
2877
+ }
2878
+ }
2879
+ var WALK = {
2880
+ PO: { PO: 0, SC: 5, CM: 8, HM: 10, PZ: 12 },
2881
+ SC: { PO: 5, SC: 0, CM: 5, HM: 8, PZ: 10 },
2882
+ CM: { PO: 8, SC: 5, CM: 0, HM: 6, PZ: 8 },
2883
+ HM: { PO: 10, SC: 8, CM: 6, HM: 0, PZ: 5 },
2884
+ PZ: { PO: 12, SC: 10, CM: 8, HM: 5, PZ: 0 }
2885
+ };
2886
+ function walkNeed(a, b) {
2887
+ if (a === "BLOCK" || b === "BLOCK") return 0;
2888
+ return WALK[a]?.[b] ?? (a === b ? 0 : 10);
2889
+ }
2890
+ function meetingsOverlap(a, b) {
2891
+ if (!a.days.some((d) => b.days.includes(d))) return false;
2892
+ return a.startMin < b.endMin && b.startMin < a.endMin;
2893
+ }
2894
+ function overlapMinutes(a, b) {
2895
+ if (!a.days.some((d) => b.days.includes(d))) return 0;
2896
+ return Math.max(
2897
+ 0,
2898
+ Math.min(a.endMin, b.endMin) - Math.max(a.startMin, b.startMin)
2899
+ );
2900
+ }
2901
+ function dateOverlap(a, b) {
2902
+ if (!a.half && !b.half) return true;
2903
+ return a.startDate <= b.endDate && b.startDate <= a.endDate;
2904
+ }
2905
+ function sectionsOverlap(a, b) {
2906
+ if (!dateOverlap(a, b)) return false;
2907
+ for (const ma of a.meetings) {
2908
+ for (const mb of b.meetings) {
2909
+ if (meetingsOverlap(ma, mb)) return true;
2910
+ }
2911
+ }
2912
+ return false;
2913
+ }
2914
+ function findConflicts(sections, passingMinutes = 10) {
2915
+ const conflicts = [];
2916
+ const warnings = [];
2917
+ for (let i = 0; i < sections.length; i++) {
2918
+ for (let j = i + 1; j < sections.length; j++) {
2919
+ const a = sections[i];
2920
+ const b = sections[j];
2921
+ if (a.meetings.length === 0 || b.meetings.length === 0) {
2922
+ warnings.push(
2923
+ `Time TBA for ${a.meetings.length === 0 ? a.id : b.id}; skipped in conflict check.`
2924
+ );
2925
+ continue;
2926
+ }
2927
+ if (!dateOverlap(a, b)) continue;
2928
+ for (const ma of a.meetings) {
2929
+ for (const mb of b.meetings) {
2930
+ const common = ma.days.filter((d) => mb.days.includes(d));
2931
+ if (common.length === 0) continue;
2932
+ const ov = overlapMinutes(ma, mb);
2933
+ if (ov > 0) {
2934
+ const start = Math.max(ma.startMin, mb.startMin);
2935
+ const end = Math.min(ma.endMin, mb.endMin);
2936
+ conflicts.push({
2937
+ a: a.id,
2938
+ b: b.id,
2939
+ days: common,
2940
+ overlap: `${minToStr(start)}-${minToStr(end)}`,
2941
+ overlap_minutes: ov,
2942
+ severity: "hard"
2943
+ });
2944
+ } else {
2945
+ const gap = mb.startMin >= ma.endMin ? mb.startMin - ma.endMin : ma.startMin >= mb.endMin ? ma.startMin - mb.endMin : 0;
2946
+ if (gap < passingMinutes) {
2947
+ conflicts.push({
2948
+ a: a.id,
2949
+ b: b.id,
2950
+ days: common,
2951
+ overlap: `gap ${gap}min (${minToStr(ma.startMin)}-${minToStr(ma.endMin)} vs ${minToStr(mb.startMin)}-${minToStr(mb.endMin)})`,
2952
+ overlap_minutes: 0,
2953
+ severity: "tight"
2954
+ });
2955
+ }
2956
+ }
2957
+ }
2958
+ }
2959
+ }
2960
+ }
2961
+ return { conflicts, warnings };
2962
+ }
2963
+ function travelWarnings(sections, passingMinutes = 10) {
2964
+ const out = [];
2965
+ const days = ["M", "T", "W", "R", "F", "S", "U"];
2966
+ for (const day of days) {
2967
+ const blocks = sections.flatMap(
2968
+ (s) => s.meetings.filter((m) => m.days.includes(day)).map((m) => ({ s, m }))
2969
+ );
2970
+ blocks.sort((x, y) => x.m.startMin - y.m.startMin);
2971
+ for (let i = 0; i + 1 < blocks.length; i++) {
2972
+ const prev = blocks[i];
2973
+ const next = blocks[i + 1];
2974
+ if (prev.s.id === next.s.id) continue;
2975
+ if (!dateOverlap(prev.s, next.s)) continue;
2976
+ if (prev.m.endMin > next.m.startMin) continue;
2977
+ const gap = next.m.startMin - prev.m.endMin;
2978
+ const sameLoc = !!prev.m.locations[0] && !!next.m.locations[0] && prev.m.locations[0].toLowerCase() === next.m.locations[0].toLowerCase();
2979
+ const need = sameLoc ? 0 : walkNeed(prev.s.school, next.s.school);
2980
+ if (prev.s.school !== next.s.school && !sameLoc && (gap < need || gap <= passingMinutes)) {
2981
+ out.push({
2982
+ from: prev.s.id,
2983
+ to: next.s.id,
2984
+ from_school: prev.s.school,
2985
+ to_school: next.s.school,
2986
+ gap_minutes: gap,
2987
+ message: gap < need ? `TIGHT: only ~${gap}min from ${prev.s.school} to ${next.s.school} on ${day} (${minToStr(prev.m.endMin)} \u2192 ${minToStr(next.m.startMin)}), needs ~${need}min.` : `Cross-campus ${prev.s.school}\u2192${next.s.school} on ${day} with ~${gap}min gap.`
2988
+ });
2989
+ }
2990
+ }
2991
+ }
2992
+ return out;
2993
+ }
2994
+ function totalCredits(sections) {
2995
+ return Math.round(sections.reduce((sum, s) => sum + s.credits, 0) * 100) / 100;
2996
+ }
2997
+ function weeklyGrid(sections) {
2998
+ const days = ["M", "T", "W", "R", "F", "S", "U"];
2999
+ const lines = [];
3000
+ for (const day of days) {
3001
+ const blocks = sections.flatMap(
3002
+ (s) => s.meetings.filter((m) => m.days.includes(day)).map((m) => ({ s, m }))
3003
+ );
3004
+ blocks.sort((a, b) => a.m.startMin - b.m.startMin);
3005
+ if (blocks.length === 0) {
3006
+ lines.push(`${day}: \u2014`);
3007
+ continue;
3008
+ }
3009
+ const cells = blocks.map(
3010
+ (b) => `${minToStr(b.m.startMin)}-${minToStr(b.m.endMin)} ${b.s.code} (${b.s.school})`
3011
+ ).join(" | ");
3012
+ lines.push(`${day}: ${cells}`);
3013
+ }
3014
+ return lines.join("\n");
3015
+ }
3016
+ function checkSchedule(sections, opts = {}) {
3017
+ const passing = opts.passingMinutes ?? 10;
3018
+ const { conflicts, warnings } = findConflicts(sections, passing);
3019
+ const travel = travelWarnings(sections, passing);
3020
+ const seat_problems = sections.filter((s) => s.school !== "BLOCK").filter((s) => !isRegistrable(s.status) || s.seats.available <= 0).map((s) => ({
3021
+ id: s.id,
3022
+ reason: !isRegistrable(s.status) ? `Closed by registrar (status ${s.status}; cap ${s.seats.total}, filled ${s.seats.filled})` : `No remaining capacity (status ${s.status}; filled ${s.seats.filled}/${s.seats.total})`
3023
+ }));
3024
+ const skipped_tba = sections.filter((s) => s.meetings.length === 0).map((s) => s.id);
3025
+ for (const s of sections) {
3026
+ if (s.potentialError) {
3027
+ warnings.push(
3028
+ `${s.id}: source data flagged potential_error; treat its times as unreliable.`
3029
+ );
3030
+ }
3031
+ }
3032
+ const hard = conflicts.filter((c) => c.severity === "hard");
3033
+ return {
3034
+ ok: hard.length === 0 && seat_problems.length === 0,
3035
+ complete: skipped_tba.length === 0 && sections.every((s) => !s.potentialError),
3036
+ skipped_tba,
3037
+ total_credits: totalCredits(sections),
3038
+ class_count: sections.length,
3039
+ conflicts,
3040
+ travel_warnings: travel,
3041
+ seat_problems,
3042
+ warnings,
3043
+ weekly_grid: weeklyGrid(sections)
3044
+ };
3045
+ }
3046
+ var FilterError = class extends Error {
3047
+ code = "E_BAD_FILTER";
3048
+ hint;
3049
+ constructor(message, hint) {
3050
+ super(message);
3051
+ this.hint = hint;
3052
+ }
3053
+ };
3054
+ var KNOWN_SCHOOLS = [
3055
+ "PO",
3056
+ "CM",
3057
+ "HM",
3058
+ "SC",
3059
+ "PZ",
3060
+ "CG",
3061
+ "KS",
3062
+ "JP",
3063
+ "JT",
3064
+ "JM",
3065
+ "AF",
3066
+ "CH",
3067
+ "AA"
3068
+ ];
3069
+ var SCHOOLS_HINT = `Valid: ${KNOWN_SCHOOLS.join(" ")} (codes or names like Pomona, CMC, Harvey Mudd).`;
3070
+ function normalizeFilters(raw) {
3071
+ const out = { normalized: {}, warnings: [] };
3072
+ const list = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
3073
+ const schools = list(raw.school);
3074
+ if (schools.length > 0) {
3075
+ out.schools = schools.map((s) => {
3076
+ const code = normalizeSchool(s);
3077
+ if (!KNOWN_SCHOOLS.includes(code)) {
3078
+ throw new FilterError(`Unknown school '${s}'.`, SCHOOLS_HINT);
3079
+ }
3080
+ return code;
3081
+ });
3082
+ out.normalized.school = out.schools;
3083
+ }
3084
+ const depts = list(raw.dept);
3085
+ if (depts.length > 0) {
3086
+ out.departments = depts.map((d) => {
3087
+ const clean = d.trim().toUpperCase();
3088
+ if (!/^[A-Z]{2,4}$/.test(clean)) {
3089
+ throw new FilterError(
3090
+ `Bad --dept '${d}'.`,
3091
+ "Use 2\u20134 letter codes, e.g. --dept CSCI MATH."
3092
+ );
3093
+ }
3094
+ return clean;
3095
+ });
3096
+ out.normalized.dept = out.departments;
3097
+ }
3098
+ if (raw.days) {
3099
+ const days = normalizeDays(raw.days);
3100
+ if (!days) {
3101
+ throw new FilterError(
3102
+ `Unknown days '${raw.days}'.`,
3103
+ "Valid: M T W R F S U combos like MWF, TR, T/R, TuTh. R=Thursday."
3104
+ );
3105
+ }
3106
+ out.days = days;
3107
+ out.normalized.days = days.join("");
3108
+ }
3109
+ if (raw.after) {
3110
+ const min = parseTime(raw.after);
3111
+ if (min === null) {
3112
+ throw new FilterError(
3113
+ `Unparseable time '${raw.after}'.`,
3114
+ "Try --after 1pm, --after 13:00, --after 10:00am."
3115
+ );
3116
+ }
3117
+ out.afterMin = min;
3118
+ out.normalized.after = raw.after;
3119
+ }
3120
+ if (raw.before) {
3121
+ const min = parseTime(raw.before);
3122
+ if (min === null) {
3123
+ throw new FilterError(
3124
+ `Unparseable time '${raw.before}'.`,
3125
+ "Try --before 5pm, --before 17:00."
3126
+ );
3127
+ }
3128
+ out.beforeMin = min;
3129
+ out.normalized.before = raw.before;
3130
+ }
3131
+ if (out.afterMin !== void 0 && out.beforeMin !== void 0 && out.afterMin > out.beforeMin) {
3132
+ throw new FilterError(
3133
+ `Inverted time window: after ${raw.after} but before ${raw.before}.`,
3134
+ "The window must satisfy after <= before."
3135
+ );
3136
+ }
3137
+ if (raw.instructor) {
3138
+ out.instructor = raw.instructor.trim();
3139
+ out.normalized.instructor = out.instructor;
3140
+ }
3141
+ const areas = list(raw.area);
3142
+ if (areas.length > 0) {
3143
+ out.areas = areas.map((a) => a.trim().toUpperCase());
3144
+ out.normalized.area = out.areas;
3145
+ }
3146
+ if (raw.credits) {
3147
+ const m = raw.credits.match(/^(\d+(?:\.\d+)?)(?:\s*-\s*(\d+(?:\.\d+)?))?$/);
3148
+ if (!m) {
3149
+ throw new FilterError(
3150
+ `Unparseable credits '${raw.credits}'.`,
3151
+ "Try --credits 1 or --credits 0.5-1."
3152
+ );
3153
+ }
3154
+ out.creditsMin = Number.parseFloat(m[1]);
3155
+ out.creditsMax = m[2] ? Number.parseFloat(m[2]) : Number.parseFloat(m[1]);
3156
+ }
3157
+ if (raw.creditsMin !== void 0) out.creditsMin = raw.creditsMin;
3158
+ if (raw.creditsMax !== void 0) out.creditsMax = raw.creditsMax;
3159
+ if (raw.open !== void 0) out.openOnly = raw.open;
3160
+ if (raw.excludeConflictsWith && raw.excludeConflictsWith.length > 0) {
3161
+ out.excludeConflictIds = raw.excludeConflictsWith;
3162
+ }
3163
+ return out;
3164
+ }
3165
+ function matchesFilters(s, f) {
3166
+ if (f.excludeConflictIds && f.excludeConflictIds.length > 0 && f.excludeConflictIds.includes(s.id)) {
3167
+ return false;
3168
+ }
3169
+ if (f.schools && f.schools.length > 0 && !f.schools.includes(s.school))
3170
+ return false;
3171
+ if (f.departments && f.departments.length > 0 && !f.departments.includes(s.dept)) {
3172
+ return false;
3173
+ }
3174
+ if (f.codePrefix) {
3175
+ const flat = `${s.dept}${String(s.number).padStart(3, "0")}${s.suffix}`;
3176
+ if (!flat.startsWith(f.codePrefix.toUpperCase())) return false;
3177
+ }
3178
+ if (f.days && f.days.length > 0) {
3179
+ const sectionDays = new Set(s.meetings.flatMap((m) => m.days));
3180
+ if (s.meetings.length === 0) return false;
3181
+ for (const d of f.days) if (!sectionDays.has(d)) return false;
3182
+ }
3183
+ const afterMin = f.afterMin;
3184
+ if (afterMin !== void 0) {
3185
+ if (s.meetings.length === 0) return false;
3186
+ if (!s.meetings.every((m) => m.startMin >= afterMin)) return false;
3187
+ }
3188
+ const beforeMin = f.beforeMin;
3189
+ if (beforeMin !== void 0) {
3190
+ if (s.meetings.length === 0) return false;
3191
+ if (!s.meetings.every((m) => m.endMin <= beforeMin)) return false;
3192
+ }
3193
+ if (f.instructor) {
3194
+ const q = f.instructor.toLowerCase();
3195
+ if (!s.instructors.some((i) => i.toLowerCase().includes(q))) return false;
3196
+ }
3197
+ if (f.areas && f.areas.length > 0) {
3198
+ const upper = s.areas.map((a) => a.toUpperCase());
3199
+ if (!f.areas.some((a) => upper.includes(a))) return false;
3200
+ }
3201
+ if (f.creditsMin !== void 0 && s.credits < f.creditsMin) return false;
3202
+ if (f.creditsMax !== void 0 && s.credits > f.creditsMax) return false;
3203
+ if (f.openOnly && !isRegistrable(s.status)) return false;
3204
+ return true;
3205
+ }
3206
+ function emptyHistory(codeQuery, note, provenance) {
3207
+ return {
3208
+ code: codeQuery.trim().toUpperCase().slice(0, 32),
3209
+ offerings: [],
3210
+ rotation_note: note,
3211
+ verdict: "No history found.",
3212
+ ...provenance
3213
+ };
3214
+ }
3215
+ function courseHistory(entries, sections, codeQuery, opts = {}) {
3216
+ const limit = opts.limit ?? 6;
3217
+ const provenance = {
3218
+ fetched_at: opts.fetched_at ?? "",
3219
+ stale: opts.stale ?? false,
3220
+ snapshot_term: opts.snapshot_term ?? "",
3221
+ enrichment: opts.enrichment ?? null
3222
+ };
3223
+ const parsed = parseCode(codeQuery);
3224
+ if (!parsed.dept && parsed.number === void 0) {
3225
+ return emptyHistory(
3226
+ codeQuery,
3227
+ `No offering history found for '${codeQuery}'. Try a course code like 'CSCI 151'.`,
3228
+ provenance
3229
+ );
3230
+ }
3231
+ if (parsed.number === void 0) {
3232
+ return emptyHistory(
3233
+ codeQuery,
3234
+ "History needs a course number, e.g. 'CSCI 151'. Department-only queries match too many courses.",
3235
+ provenance
3236
+ );
3237
+ }
3238
+ const matched = entries.filter(
3239
+ (e) => e.code.department.toUpperCase() === parsed.dept && e.code.courseNumber === parsed.number && (parsed.suffix ? (e.code.suffix ?? "").toUpperCase() === parsed.suffix : true)
3240
+ );
3241
+ if (matched.length === 0) {
3242
+ return emptyHistory(
3243
+ codeQuery,
3244
+ `No offering history found for '${codeQuery}'.`,
3245
+ provenance
3246
+ );
3247
+ }
3248
+ const byAff = /* @__PURE__ */ new Map();
3249
+ for (const e of matched) {
3250
+ const aff = e.code.affiliation.toUpperCase();
3251
+ const list = byAff.get(aff) ?? [];
3252
+ list.push(e);
3253
+ byAff.set(aff, list);
3254
+ }
3255
+ const campusFilter = (opts.campuses ?? []).map(normalizeSchool);
3256
+ const wantedSchools = parsed.school ? [parsed.school] : campusFilter.length > 0 ? campusFilter : [...byAff.keys()];
3257
+ const available = wantedSchools.filter((s) => byAff.has(s));
3258
+ if (available.length === 0) {
3259
+ return emptyHistory(
3260
+ codeQuery,
3261
+ `No offering history for ${parsed.dept} ${String(parsed.number).padStart(3, "0")} at ${wantedSchools.join(", ")}; it exists at ${[...byAff.keys()].join(", ")}.`,
3262
+ provenance
3263
+ );
3264
+ }
3265
+ const chosen = available.map((school) => {
3266
+ const list = [...byAff.get(school) ?? []].sort(
3267
+ (a, b) => b.terms.length - a.terms.length
3268
+ );
3269
+ return { school, entry: list[0] };
3270
+ });
3271
+ const byTerm = /* @__PURE__ */ new Map();
3272
+ for (const s of sections) {
3273
+ const group = byTerm.get(s.term) ?? [];
3274
+ group.push(s);
3275
+ byTerm.set(s.term, group);
3276
+ }
3277
+ const enrichFor = (school, termKey) => (byTerm.get(termKey) ?? []).filter(
3278
+ (s) => s.dept === parsed.dept && s.number === parsed.number && (!parsed.suffix || s.suffix === parsed.suffix) && s.school === school
3279
+ );
3280
+ const allTermKeys = chosen.flatMap(
3281
+ ({ school, entry }) => entry.terms.map((t) => ({ school, key: `${t.term}${t.year}` }))
3282
+ );
3283
+ const code = `${parsed.dept} ${String(parsed.number).padStart(3, "0")}${parsed.suffix ?? ""}`;
3284
+ const sortedKeys = [...new Set(allTermKeys.map((x) => x.key))].sort(
3285
+ compareTermRecencyDesc
3286
+ );
3287
+ const offerings = [];
3288
+ for (const key of sortedKeys.slice(0, limit)) {
3289
+ for (const { school, entry } of chosen) {
3290
+ if (!entry.terms.some((t) => `${t.term}${t.year}` === key)) continue;
3291
+ const inTerm = enrichFor(school, key);
3292
+ const instructors = [...new Set(inTerm.flatMap((s) => s.instructors))];
3293
+ const first = inTerm[0];
3294
+ offerings.push({
3295
+ term: key,
3296
+ instructors,
3297
+ campus: first?.school ?? school,
3298
+ seats: first ? { capacity: first.seats.total, enrolled: first.seats.filled } : null
3299
+ });
3300
+ }
3301
+ }
3302
+ const windowKeys = [...new Set(allTermKeys.map((x) => x.key))].sort(
3303
+ compareTermRecencyDesc
3304
+ );
3305
+ const span = windowKeys.length > 1 ? `${termLabel(windowKeys[windowKeys.length - 1])}\u2013${termLabel(windowKeys[0])}` : termLabel(windowKeys[0] ?? "");
3306
+ const fa = windowKeys.filter((k) => k.startsWith("FA")).length;
3307
+ const sp = windowKeys.filter((k) => k.startsWith("SP")).length;
3308
+ const total = windowKeys.length;
3309
+ let cadence = "irregularly";
3310
+ if (fa > 0 && sp === 0) cadence = "fall-only";
3311
+ else if (sp > 0 && fa === 0) cadence = "spring-only";
3312
+ else if (fa > 0 && sp > 0) cadence = "both semesters";
3313
+ const shortWindow = total <= 2 ? " Short window; treat any rotation pattern as weak evidence." : "";
3314
+ if (chosen.length > 1) {
3315
+ const schools = chosen.map((c) => c.school).join(", ");
3316
+ return {
3317
+ code,
3318
+ offerings,
3319
+ rotation_note: `${code} has separate histories at ${schools}; offerings below are labeled by campus. Specify a school for a single-course verdict.`,
3320
+ verdict: `History exists at ${schools}; specify a school (e.g. '${code} ${chosen[0].school}') for a per-course verdict.`,
3321
+ ...provenance
3322
+ };
3323
+ }
3324
+ let verdict;
3325
+ if (fa > 0 && sp === 0) {
3326
+ verdict = `Fall-only in observed history (FA ${fa}, SP 0; window ${span}).${shortWindow}`;
3327
+ } else if (sp > 0 && fa === 0) {
3328
+ verdict = `Spring-only in observed history (SP ${sp}, FA 0; window ${span}).${shortWindow}`;
3329
+ } else if (sp > fa + 1) {
3330
+ verdict = `Usually spring (SP ${sp} vs FA ${fa} in window ${span}).${shortWindow}`;
3331
+ } else if (fa > sp + 1) {
3332
+ verdict = `Usually fall (FA ${fa} vs SP ${sp} in window ${span}).${shortWindow}`;
3333
+ } else {
3334
+ verdict = `Offered both semesters (FA ${fa}, SP ${sp} in window ${span}).${shortWindow}`;
3335
+ }
3336
+ const rotation_note = `Offered in ${total} term(s) across ${span} in available history (${cadence}: FA ${fa}, SP ${sp}). Instructors are only known for terms with a local snapshot.`;
3337
+ return { code, offerings, rotation_note, verdict, ...provenance };
3338
+ }
3339
+ function toDoc(s) {
3340
+ return {
3341
+ id: s.id,
3342
+ code: `${s.dept} ${String(s.number).padStart(3, "0")}${s.suffix} ${s.school}`,
3343
+ title: s.title,
3344
+ instructors: s.instructors.join(" "),
3345
+ areas: s.areas.join(" "),
3346
+ description: s.description
3347
+ };
3348
+ }
3349
+ function codeAwareTokenize(text) {
3350
+ return text.toLowerCase().replace(/([a-z])(\d)/g, "$1 $2").split(/[^a-z0-9]+/).filter((t) => t.length > 0);
3351
+ }
3352
+ var BOOST = {
3353
+ code: 5,
3354
+ title: 3,
3355
+ instructors: 2.5,
3356
+ areas: 1.5,
3357
+ description: 1
3358
+ };
3359
+ var MiniSearchProvider = class {
3360
+ mini;
3361
+ byId = /* @__PURE__ */ new Map();
3362
+ docs = [];
3363
+ built = false;
3364
+ constructor() {
3365
+ this.mini = new MiniSearch({
3366
+ fields: ["code", "title", "instructors", "areas", "description"],
3367
+ storeFields: ["id", "code", "title"],
3368
+ searchOptions: {
3369
+ boost: BOOST,
3370
+ prefix: true,
3371
+ fuzzy: 0.3,
3372
+ combineWith: "AND"
3373
+ },
3374
+ tokenize: codeAwareTokenize,
3375
+ processTerm: (t) => t.toLowerCase()
3376
+ });
3377
+ }
3378
+ build(sections) {
3379
+ this.setSections(sections);
3380
+ this.ensureIndex();
3381
+ }
3382
+ /** Cheap path: byId only, no MiniSearch build (~65-90ms saved). */
3383
+ setSections(sections) {
3384
+ this.byId = new Map(sections.map((s) => [s.id, s]));
3385
+ this.docs = sections.map(toDoc);
3386
+ this.built = false;
3387
+ this.mini.removeAll();
3388
+ }
3389
+ /** Build the MiniSearch index on first full-text need. Idempotent. */
3390
+ ensureIndex() {
3391
+ if (this.built) return;
3392
+ this.mini.removeAll();
3393
+ if (this.docs.length > 0) this.mini.addAll(this.docs);
3394
+ this.built = true;
3395
+ }
3396
+ get indexBuilt() {
3397
+ return this.built;
3398
+ }
3399
+ /**
3400
+ * Candidate-union pipeline. Deterministic pre-passes (exact code, code
3401
+ * prefix, dept) contribute scored candidates; free-text queries union
3402
+ * title-phrase candidates with full-text results. Hard filters apply
3403
+ * ONCE over the union, so a filter narrower than a pre-pass can never
3404
+ * silently delete valid matches. Code-shaped queries stay strict: a
3405
+ * dept+number that matches nothing is "not found", never fuzzy soup.
3406
+ */
3407
+ query(q, f) {
3408
+ const query = q.trim();
3409
+ const tokens = codeAwareTokenize(query);
3410
+ const parsed = parseCode(query);
3411
+ const all = [...this.byId.values()];
3412
+ const cand = /* @__PURE__ */ new Map();
3413
+ const add = (s, score, why) => {
3414
+ const cur = cand.get(s.id);
3415
+ if (!cur) {
3416
+ cand.set(s.id, { s, score, why: [...why] });
3417
+ return;
3418
+ }
3419
+ if (score > cur.score) cur.score = score;
3420
+ for (const w of why) if (!cur.why.includes(w)) cur.why.push(w);
3421
+ };
3422
+ const codeShaped = !!parsed.dept && parsed.number !== void 0;
3423
+ if (codeShaped) {
3424
+ const num = String(parsed.number).padStart(3, "0");
3425
+ const exact = all.filter(
3426
+ (s) => s.dept === parsed.dept && String(s.number).padStart(3, "0") === num && s.suffix === (parsed.suffix ?? "") && (!parsed.school || s.school === parsed.school)
3427
+ );
3428
+ if (exact.length > 0) {
3429
+ for (const s of exact) add(s, 100, ["exact-code"]);
3430
+ return this.finalize([...cand.values()], f, tokens);
3431
+ }
3432
+ const prefix = `${parsed.dept}${parsed.number}`.toLowerCase();
3433
+ const prefixed = all.filter(
3434
+ (s) => s.dept === parsed.dept && (!parsed.suffix || s.suffix === parsed.suffix) && (!parsed.school || s.school === parsed.school) && `${s.dept}${s.number}`.toLowerCase().startsWith(prefix)
3435
+ );
3436
+ if (prefixed.length > 0) {
3437
+ for (const s of prefixed) add(s, 50, ["code-prefix"]);
3438
+ return this.finalize([...cand.values()], f, tokens);
3439
+ }
3440
+ return this.finalize([], f, tokens);
3441
+ }
3442
+ if (parsed.dept && parsed.number === void 0) {
3443
+ const deptHit = all.filter(
3444
+ (s) => s.dept === parsed.dept && (!parsed.school || s.school === parsed.school)
3445
+ );
3446
+ for (const s of deptHit) add(s, 50, ["dept"]);
3447
+ }
3448
+ if (query.length === 0) {
3449
+ for (const s of all) add(s, 1, ["browse"]);
3450
+ return this.finalize([...cand.values()], f, tokens);
3451
+ }
3452
+ const phrase = query.replace(/^"|"$/g, "").toLowerCase();
3453
+ if (phrase.length >= 4) {
3454
+ for (const s of all.filter((x) => x.title.toLowerCase().includes(phrase)))
3455
+ add(s, 30, ["title-phrase"]);
3456
+ }
3457
+ this.ensureIndex();
3458
+ let results = this.mini.search(query);
3459
+ let orFallback = false;
3460
+ if (results.length === 0 && tokens.length > 1) {
3461
+ results = this.mini.search(query, { combineWith: "OR" });
3462
+ orFallback = true;
3463
+ }
3464
+ const ql = query.toLowerCase();
3465
+ for (const r of results) {
3466
+ const s = this.byId.get(r.id);
3467
+ if (!s) continue;
3468
+ const why = [];
3469
+ if (s.title.toLowerCase().includes(ql)) why.push(`title:${ql}`);
3470
+ if (s.dept.toLowerCase() === ql || s.code.toLowerCase().startsWith(ql)) {
3471
+ why.push(`code:${ql}`);
3472
+ }
3473
+ if (s.instructors.some((i) => i.toLowerCase().includes(ql))) {
3474
+ why.push(`instructor:${ql}`);
3475
+ }
3476
+ let score = r.score;
3477
+ if (orFallback) {
3478
+ const coverage = Object.keys(r.match ?? {}).length;
3479
+ score = r.score * 0.5 * (1 + coverage);
3480
+ why.push("or-fallback");
3481
+ }
3482
+ add(s, score, why.length > 0 ? why : ["text"]);
3483
+ }
3484
+ return this.finalize([...cand.values()], f, tokens);
3485
+ }
3486
+ finalize(scored, f, queryTokens = []) {
3487
+ const out = [];
3488
+ for (const { s, score, why } of scored) {
3489
+ if (!matchesFilters(s, f)) continue;
3490
+ let adj = score;
3491
+ if (queryTokens.length > 0) {
3492
+ const titleTokens = new Set(codeAwareTokenize(s.title));
3493
+ if (queryTokens.every((t) => titleTokens.has(t))) {
3494
+ adj += 8;
3495
+ why.push("title-words");
3496
+ }
3497
+ }
3498
+ if (s.status === "O") adj += 5;
3499
+ else if (s.status === "C") adj -= 10;
3500
+ else if (s.status === "R") adj += 2;
3501
+ if (s.seats.available >= 3) adj += 3;
3502
+ else if (s.seats.available > 0) adj += 1;
3503
+ if (s.potentialError) adj -= 5;
3504
+ out.push({
3505
+ section: s,
3506
+ score: adj,
3507
+ match: why,
3508
+ fetched_at: "",
3509
+ stale: false
3510
+ });
3511
+ }
3512
+ out.sort(
3513
+ (a, b) => b.score - a.score || b.section.seats.available - a.section.seats.available || a.section.id.localeCompare(b.section.id)
3514
+ );
3515
+ return out;
3516
+ }
3517
+ toJSON() {
3518
+ this.ensureIndex();
3519
+ return JSON.stringify({
3520
+ index: this.mini.toJSON(),
3521
+ ids: [...this.byId.keys()]
3522
+ });
3523
+ }
3524
+ get size() {
3525
+ return this.byId.size;
3526
+ }
3527
+ };
3528
+ function mergedDays(s) {
3529
+ const order = ["M", "T", "W", "R", "F", "S", "U"];
3530
+ const set = new Set(s.meetings.flatMap((m) => m.days));
3531
+ return order.filter((d) => set.has(d)).join("");
3532
+ }
3533
+ function toCompactHit(h, fetched_at, explain, stale = false) {
3534
+ const s = h.section;
3535
+ return {
3536
+ id: s.id,
3537
+ term: s.term,
3538
+ school: s.school,
3539
+ code: s.code,
3540
+ section: String(s.sectionNo).padStart(2, "0"),
3541
+ title: s.title,
3542
+ instructors: s.instructors,
3543
+ // Summary across meetings; the per-block truth is in meetings[].
3544
+ days: mergedDays(s),
3545
+ start: s.meetings.length > 0 ? s.meetings[0].startStr : null,
3546
+ end: s.meetings.length > 0 ? s.meetings[0].endStr : null,
3547
+ meetings: s.meetings.length === 0 ? [] : s.meetings.map((m) => ({
3548
+ days: m.days.join(""),
3549
+ start: m.startStr,
3550
+ end: m.endStr,
3551
+ location: m.locations[0] ?? null
3552
+ })),
3553
+ credits: s.credits,
3554
+ potential_error: s.potentialError,
3555
+ seats_open: s.seats.available,
3556
+ seats_total: s.seats.total,
3557
+ status: s.status,
3558
+ match: {
3559
+ score: Math.round(h.score * 100) / 100,
3560
+ why: explain ? h.match : h.match.slice(0, 1)
3561
+ },
3562
+ fetched_at,
3563
+ stale
3564
+ };
3565
+ }
3566
+ function paginate(hits, opts) {
3567
+ const total = hits.length;
3568
+ const slice = hits.slice(opts.offset, opts.offset + opts.limit);
3569
+ const has_more = opts.offset + opts.limit < total;
3570
+ const hint = total === 0 ? "No matches. Try fewer filters, a different term, or drop --open." : opts.offset >= total ? `Offset ${opts.offset} is past the end (total ${total}); re-run with --offset 0 or narrow filters.` : has_more ? `${total} hits, showing ${slice.length}. Re-run with --offset ${opts.offset + opts.limit} or narrow filters.` : null;
3571
+ return {
3572
+ term: opts.term,
3573
+ total,
3574
+ limit: opts.limit,
3575
+ offset: opts.offset,
3576
+ has_more,
3577
+ next_offset: has_more ? opts.offset + opts.limit : null,
3578
+ hits: slice.map(
3579
+ (h) => toCompactHit(h, opts.fetched_at, opts.explain, opts.stale)
3580
+ ),
3581
+ hint,
3582
+ suggestions: opts.suggestions ?? [],
3583
+ fetched_at: opts.fetched_at,
3584
+ stale: opts.stale,
3585
+ snapshot_id: opts.snapshot_id ?? null
3586
+ };
3587
+ }
3588
+ var providers = /* @__PURE__ */ new Map();
3589
+ function getProvider(term, sections, hash) {
3590
+ const key = `${term}:${hash}`;
3591
+ const hit = providers.get(key);
3592
+ if (hit) return hit;
3593
+ if (providers.size >= 4) {
3594
+ const oldest = providers.keys().next();
3595
+ if (!oldest.done) providers.delete(oldest.value);
3596
+ }
3597
+ const provider = new MiniSearchProvider();
3598
+ provider.setSections(sections);
3599
+ providers.set(key, provider);
3600
+ return provider;
3601
+ }
3602
+ function snapshotMeta(m) {
3603
+ return {
3604
+ fetched_at: m.fetched_at,
3605
+ stale: m.stale,
3606
+ quarantined: m.quarantined ?? 0,
3607
+ duplicate_ids: m.duplicate_ids ?? 0
3608
+ };
3609
+ }
3610
+ function combinedMeta(metas) {
3611
+ const first = metas[0];
3612
+ if (!first) {
3613
+ return { fetched_at: "", stale: false, quarantined: 0, duplicate_ids: 0 };
3614
+ }
3615
+ return {
3616
+ fetched_at: first.fetched_at,
3617
+ stale: metas.some((m) => m.stale),
3618
+ quarantined: metas.reduce((n, m) => n + (m.quarantined ?? 0), 0),
3619
+ duplicate_ids: metas.reduce((n, m) => n + (m.duplicate_ids ?? 0), 0)
3620
+ };
3621
+ }
3622
+ function badTermError(input) {
3623
+ return Object.assign(
3624
+ new Error(`Unparseable term '${input}'. Use a term like FA2026.`),
3625
+ {
3626
+ code: "E_BAD_TERM",
3627
+ hint: "Use a term like FA2026 \u2014 run list_terms to see available terms."
3628
+ }
3629
+ );
3630
+ }
3631
+ function usageError(message, hint) {
3632
+ return Object.assign(new Error(message), { code: "E_USAGE", hint });
3633
+ }
3634
+ async function resolveTerm(raw, fetchFn) {
3635
+ if (raw === void 0) return defaultTerm(fetchFn);
3636
+ const norm = normalizeTerm(raw);
3637
+ if (!norm) throw badTermError(raw);
3638
+ return norm.toUpperCase();
3639
+ }
3640
+ async function loadTermStrict(term, fetchFn, allowFallback) {
3641
+ try {
3642
+ const value = await loadTerm(term, { fetchFn });
3643
+ return value;
3644
+ } catch (err) {
3645
+ const e = err;
3646
+ if (e.code !== "E_TERM_NOT_FOUND") throw err;
3647
+ if (!allowFallback) {
3648
+ try {
3649
+ const { available: available2 } = await listTerms({ limit: 8, fetchFn });
3650
+ e.available = available2.map(
3651
+ (t) => t.code
3652
+ );
3653
+ } catch {
3654
+ }
3655
+ e.hint = "Term not published upstream. Run list_terms for available terms, or opt into serving the newest available term.";
3656
+ throw e;
3657
+ }
3658
+ const { available } = await listTerms({ limit: 8, fetchFn });
3659
+ const served = available[0]?.code;
3660
+ if (!served || served === term) throw err;
3661
+ const value = await loadTerm(served, { fetchFn });
3662
+ return {
3663
+ ...value,
3664
+ fallback: { requested: term, served },
3665
+ warning: `Term ${term} is not published; showing ${served} instead.`
3666
+ };
3667
+ }
3668
+ }
3669
+ function isCompleteIdentity(parsed) {
3670
+ return !!parsed.dept && parsed.number !== void 0 && !!parsed.school && parsed.sectionNo !== void 0;
3671
+ }
3672
+ function resolveInput(input, parsed, targetTerm, sections, strict) {
3673
+ if (isCompleteIdentity(parsed)) {
3674
+ const canonical = sectionId({
3675
+ dept: parsed.dept,
3676
+ number: parsed.number,
3677
+ suffix: parsed.suffix ?? "",
3678
+ school: parsed.school,
3679
+ sectionNo: parsed.sectionNo,
3680
+ term: targetTerm
3681
+ });
3682
+ const exact = sections.find((s) => s.id === canonical);
3683
+ return exact ? { section: exact } : {};
3684
+ }
3685
+ if (strict) return {};
3686
+ if (!parsed.dept && parsed.number === void 0) return {};
3687
+ const matches = sections.filter(
3688
+ (s) => (!parsed.dept || s.dept === parsed.dept) && (parsed.number === void 0 || s.number === parsed.number) && (!parsed.suffix || s.suffix === parsed.suffix) && (!parsed.school || s.school === parsed.school) && (parsed.sectionNo === void 0 || s.sectionNo === parsed.sectionNo)
3689
+ );
3690
+ if (matches.length === 1) return { section: matches[0] };
3691
+ if (matches.length > 1) return { ambiguous: matches.map((s) => s.id) };
3692
+ return {};
3693
+ }
3694
+ async function loadTermsForInputs(termKeys, fetchFn, allowFallback) {
3695
+ const loaded = /* @__PURE__ */ new Map();
3696
+ const terms = [];
3697
+ let fallback;
3698
+ let warning;
3699
+ for (const t of termKeys) {
3700
+ const v = await loadTermStrict(t, fetchFn, allowFallback);
3701
+ if (v.fallback && !fallback) {
3702
+ fallback = v.fallback;
3703
+ warning = v.warning;
3704
+ }
3705
+ loaded.set(v.meta.term, { sections: v.sections, meta: v.meta });
3706
+ terms.push({
3707
+ term: v.meta.term,
3708
+ fetched_at: v.meta.fetched_at,
3709
+ stale: v.meta.stale
3710
+ });
3711
+ }
3712
+ return { loaded, terms, ...fallback ? { fallback, warning } : {} };
3713
+ }
3714
+ function validateIdList(ids) {
3715
+ if (!Array.isArray(ids) || ids.some((i) => typeof i !== "string")) {
3716
+ throw usageError(
3717
+ "Expected an array of section ID strings.",
3718
+ "Pass IDs like 'CSCI 005 HM-01 FA2026'."
3719
+ );
3720
+ }
3721
+ }
3722
+ async function searchClasses(query, opts = {}) {
3723
+ const filters = normalizeFilters({
3724
+ school: opts.schools,
3725
+ dept: opts.depts,
3726
+ days: opts.days,
3727
+ after: opts.after,
3728
+ before: opts.before,
3729
+ instructor: opts.instructor,
3730
+ area: opts.areas,
3731
+ credits: opts.credits,
3732
+ open: opts.open,
3733
+ excludeConflictsWith: opts.excludeConflictsWith
3734
+ });
3735
+ if (opts.code !== void 0 && opts.code.trim() !== "") {
3736
+ const parsed = parseCode(opts.code);
3737
+ if (!parsed.dept && parsed.number === void 0) {
3738
+ throw new FilterError(
3739
+ `Unparseable --code '${opts.code}'.`,
3740
+ "Try --code 'CSCI 051', --code CSCI, or --code 'CSCI 051 PO'."
3741
+ );
3742
+ }
3743
+ if (parsed.dept) {
3744
+ let prefix = parsed.dept;
3745
+ if (parsed.numberRaw !== void 0) {
3746
+ prefix += parsed.numberRaw.length === 2 ? parsed.numberRaw : parsed.numberRaw.padStart(3, "0");
3747
+ }
3748
+ if (parsed.suffix) prefix += parsed.suffix;
3749
+ filters.codePrefix = prefix;
3750
+ if (parsed.school && !filters.schools) {
3751
+ filters.schools = [parsed.school];
3752
+ }
3753
+ }
3754
+ }
3755
+ const requested = await resolveTerm(opts.term, opts.fetchFn);
3756
+ const { sections, meta, fallback, warning } = await loadTermStrict(
3757
+ requested,
3758
+ opts.fetchFn,
3759
+ opts.allowTermFallback ?? false
3760
+ );
3761
+ const served = fallback?.served ?? requested;
3762
+ const provider = getProvider(served, sections, meta.hash);
3763
+ let hits = provider.query(query ?? "", filters);
3764
+ hits = sortHits(hits, opts.sort ?? "relevance");
3765
+ if (filters.excludeConflictIds && filters.excludeConflictIds.length > 0) {
3766
+ const anchors = filters.excludeConflictIds;
3767
+ const missing = anchors.filter((id) => !sections.some((s) => s.id === id));
3768
+ if (missing.length > 0) {
3769
+ throw Object.assign(
3770
+ new Error(
3771
+ `Unknown exclusion anchor(s): ${missing.join(", ")}. Anchors must be canonical IDs in the searched term.`
3772
+ ),
3773
+ {
3774
+ code: "E_NOT_FOUND",
3775
+ not_found: missing,
3776
+ hint: "Resolve IDs via search first, then pass them verbatim."
3777
+ }
3778
+ );
3779
+ }
3780
+ const anchored = sections.filter((s) => anchors.includes(s.id));
3781
+ hits = hits.filter(
3782
+ (h) => !anchors.includes(h.section.id) && !anchored.some(
3783
+ (a) => a.id !== h.section.id && sectionsOverlap(a, h.section)
3784
+ )
3785
+ );
3786
+ }
3787
+ const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
3788
+ const offset = Math.max(opts.offset ?? 0, 0);
3789
+ const suggestions = recallSuggestions(query ?? "", filters, sections);
3790
+ if (resultWillBeEmptyCodeQuery(query ?? "", hits)) {
3791
+ const parsed = parseCode(query ?? "");
3792
+ const label = `${parsed.dept} ${String(parsed.number).padStart(3, "0")}${parsed.suffix ?? ""}`.trim();
3793
+ suggestions.unshift(
3794
+ `No ${label} in ${served} \u2014 check the course number, or browse with --dept ${parsed.dept} (departments:["${parsed.dept}"]).`
3795
+ );
3796
+ }
3797
+ const result = paginate(hits, {
3798
+ limit,
3799
+ offset,
3800
+ term: served,
3801
+ fetched_at: meta.fetched_at,
3802
+ stale: meta.stale,
3803
+ explain: opts.explain ?? false,
3804
+ snapshot_id: meta.hash.slice(0, 12),
3805
+ suggestions
3806
+ });
3807
+ return {
3808
+ result,
3809
+ normalized: { ...filters.normalized, term: served },
3810
+ meta: snapshotMeta(meta),
3811
+ ...fallback ? { fallback, warning } : {}
3812
+ };
3813
+ }
3814
+ var MAX_IDS = 20;
3815
+ async function getClasses(ids, opts = {}) {
3816
+ validateIdList(ids);
3817
+ if (ids.length === 0) {
3818
+ throw usageError(
3819
+ "get needs at least one ID.",
3820
+ "Pass 1-20 canonical section IDs."
3821
+ );
3822
+ }
3823
+ if (ids.length > MAX_IDS) {
3824
+ throw usageError(
3825
+ `Too many IDs (${ids.length}); the cap is ${MAX_IDS}.`,
3826
+ "Split the batch into multiple calls."
3827
+ );
3828
+ }
3829
+ const explicitTerm = opts.term !== void 0 ? await resolveTerm(opts.term, opts.fetchFn) : void 0;
3830
+ const inputs = ids.map((id) => ({ id, parsed: parseCode(id) }));
3831
+ const needDefault = explicitTerm === void 0 && inputs.some((x) => !x.parsed.term);
3832
+ const defTerm = needDefault ? await defaultTerm(opts.fetchFn) : null;
3833
+ const termKey = (x) => {
3834
+ const t = x.parsed.term ?? explicitTerm ?? defTerm;
3835
+ if (!t) throw badTermError("");
3836
+ return t;
3837
+ };
3838
+ const distinctTerms = [...new Set(inputs.map(termKey))];
3839
+ const { loaded, terms, fallback, warning } = await loadTermsForInputs(
3840
+ distinctTerms,
3841
+ opts.fetchFn,
3842
+ opts.allowTermFallback ?? false
3843
+ );
3844
+ const classes = [];
3845
+ const not_found = [];
3846
+ const ambiguous = [];
3847
+ const suggestions = [];
3848
+ for (const x of inputs) {
3849
+ const t = termKey(x);
3850
+ const { sections } = loaded.get(t) ?? { sections: [] };
3851
+ const res = resolveInput(x.id, x.parsed, t, sections, false);
3852
+ const section = res.section;
3853
+ if (section) {
3854
+ if (!classes.some((c) => c.id === section.id)) classes.push(section);
3855
+ continue;
3856
+ }
3857
+ if (res.ambiguous && res.ambiguous.length > 0) {
3858
+ ambiguous.push({ input: x.id, section_ids: res.ambiguous });
3859
+ continue;
3860
+ }
3861
+ not_found.push(x.id);
3862
+ const sug = suggestIds(sections, x.id);
3863
+ if (sug.length > 0) suggestions.push({ input: x.id, ids: sug });
3864
+ }
3865
+ return {
3866
+ result: { classes, not_found, ambiguous, suggestions, terms },
3867
+ meta: combinedMeta([...loaded.values()].map((v) => v.meta)),
3868
+ terms,
3869
+ ...fallback ? { fallback, warning } : {}
3870
+ };
3871
+ }
3872
+ async function checkScheduleByIds(ids, opts = {}) {
3873
+ validateIdList(ids);
3874
+ if (ids.length === 0) {
3875
+ throw usageError(
3876
+ "check needs at least one ID.",
3877
+ "Pass canonical section IDs, e.g. 'CSCI 005 HM-01 FA2026'."
3878
+ );
3879
+ }
3880
+ if (ids.length > MAX_IDS) {
3881
+ throw usageError(
3882
+ `Too many IDs (${ids.length}); the cap is ${MAX_IDS}.`,
3883
+ "Split the schedule into multiple checks."
3884
+ );
3885
+ }
3886
+ const explicitTerm = opts.term !== void 0 ? await resolveTerm(opts.term, opts.fetchFn) : void 0;
3887
+ const inputs = ids.map((id) => ({ id, parsed: parseCode(id) }));
3888
+ const needDefault = explicitTerm === void 0 && inputs.some((x) => !x.parsed.term);
3889
+ const defTerm = needDefault ? await defaultTerm(opts.fetchFn) : null;
3890
+ const termKey = (x) => {
3891
+ const t = x.parsed.term ?? explicitTerm ?? defTerm;
3892
+ if (!t) throw badTermError("");
3893
+ return t;
3894
+ };
3895
+ const distinctTerms = [...new Set(inputs.map(termKey))];
3896
+ const { loaded, terms, fallback, warning } = await loadTermsForInputs(
3897
+ distinctTerms,
3898
+ opts.fetchFn,
3899
+ opts.allowTermFallback ?? false
3900
+ );
3901
+ const resolved = [];
3902
+ const failed = [];
3903
+ for (const x of inputs) {
3904
+ const t = termKey(x);
3905
+ const { sections } = loaded.get(t) ?? { sections: [] };
3906
+ const res = resolveInput(x.id, x.parsed, t, sections, true);
3907
+ const section = res.section;
3908
+ if (section) {
3909
+ if (!resolved.some((s) => s.id === section.id)) resolved.push(section);
3910
+ } else {
3911
+ failed.push(x.id);
3912
+ }
3913
+ }
3914
+ if (failed.length > 0) {
3915
+ throw Object.assign(
3916
+ new Error(
3917
+ `Unknown or incomplete section ID(s): ${failed.join(", ")}. Schedule checks need full IDs (dept, number, school, section, term).`
3918
+ ),
3919
+ {
3920
+ code: "E_NOT_FOUND",
3921
+ not_found: failed,
3922
+ hint: "Search first, then pass IDs like 'CSCI 005 HM-01 FA2026' verbatim."
3923
+ }
3924
+ );
3925
+ }
3926
+ const resolvedTerms = [...new Set(resolved.map((s) => s.term))];
3927
+ if (resolvedTerms.length > 1) {
3928
+ throw usageError(
3929
+ `check is term-scoped; these IDs span ${resolvedTerms.join(", ")}.`,
3930
+ "Run one check per term."
3931
+ );
3932
+ }
3933
+ const result = checkSchedule(resolved, {
3934
+ passingMinutes: opts.passingMinutes
3935
+ });
3936
+ return {
3937
+ result,
3938
+ sections: resolved,
3939
+ resolved: resolved.map((s) => s.id),
3940
+ meta: combinedMeta([...loaded.values()].map((v) => v.meta)),
3941
+ terms,
3942
+ ...fallback ? { fallback, warning } : {}
3943
+ };
3944
+ }
3945
+ async function listTerms(opts = {}) {
3946
+ const res = await fetchAllTerms({}, opts.fetchFn);
3947
+ let current = "FA2026";
3948
+ try {
3949
+ const cur = await fetchCurrentTerm({}, opts.fetchFn);
3950
+ current = `${cur.data.term}${cur.data.year}`;
3951
+ } catch {
3952
+ const all = res.data.map((t) => `${t.term}${t.year}`).sort(compareTermRecencyDesc);
3953
+ current = all[0] ?? current;
3954
+ }
3955
+ const limit = opts.limit ?? 6;
3956
+ const infos = res.data.map((t) => {
3957
+ const code = `${t.term}${t.year}`;
3958
+ return {
3959
+ code,
3960
+ label: termLabel(code),
3961
+ year: t.year,
3962
+ term: t.term,
3963
+ is_current: code === current
3964
+ };
3965
+ }).sort((a, b) => compareTermRecencyDesc(a.code, b.code));
3966
+ const sliced = infos.slice(0, limit);
3967
+ if (!sliced.some((t) => t.is_current)) {
3968
+ const cur = infos.find((t) => t.is_current);
3969
+ if (cur) {
3970
+ if (sliced.length === limit) sliced.pop();
3971
+ sliced.unshift(cur);
3972
+ }
3973
+ }
3974
+ return { current, available: sliced };
3975
+ }
3976
+ async function getCourseHistory(code, opts = {}) {
3977
+ const requested = await resolveTerm(opts.term, opts.fetchFn);
3978
+ const { sections, meta, fallback, warning } = await loadTermStrict(
3979
+ requested,
3980
+ opts.fetchFn,
3981
+ opts.allowTermFallback ?? false
3982
+ );
3983
+ const served = fallback?.served ?? requested;
3984
+ const provenance = {
3985
+ // fetched_at/stale describe the HISTORY source; enrichment describes
3986
+ // the sections snapshot. Two sources, two clocks.
3987
+ snapshot_term: served,
3988
+ enrichment: {
3989
+ term: served,
3990
+ fetched_at: meta.fetched_at,
3991
+ stale: meta.stale
3992
+ }
3993
+ };
3994
+ let entries = [];
3995
+ let historyFreshness = { fetched_at: "", stale: false };
3996
+ try {
3997
+ const h = await loadHistory(served, { fetchFn: opts.fetchFn });
3998
+ entries = h.entries;
3999
+ historyFreshness = { fetched_at: h.fetched_at, stale: h.stale };
4000
+ } catch (err) {
4001
+ if (err.code !== "E_TERM_NOT_FOUND") throw err;
4002
+ }
4003
+ const result = courseHistory(entries, sections, code, {
4004
+ campuses: opts.campuses,
4005
+ limit: opts.limit,
4006
+ fetched_at: historyFreshness.fetched_at,
4007
+ stale: historyFreshness.stale,
4008
+ ...provenance
4009
+ });
4010
+ if (result.offerings.length > 0)
4011
+ return { ...result, ...fallback ? { fallback, warning } : {} };
4012
+ try {
4013
+ const { available } = await listTerms({ limit: 8, fetchFn: opts.fetchFn });
4014
+ const seen = /* @__PURE__ */ new Set([served]);
4015
+ for (const t of available.map((x) => x.code)) {
4016
+ if (seen.has(t)) continue;
4017
+ seen.add(t);
4018
+ try {
4019
+ const {
4020
+ entries: e2,
4021
+ fetched_at: hf,
4022
+ stale: hs
4023
+ } = await loadHistory(t, {
4024
+ fetchFn: opts.fetchFn
4025
+ });
4026
+ const probe = courseHistory(e2, [], code, {
4027
+ campuses: opts.campuses,
4028
+ limit: opts.limit,
4029
+ fetched_at: hf,
4030
+ stale: hs
4031
+ });
4032
+ if (probe.offerings.length > 0) {
4033
+ const { sections: s2, meta: m2 } = await loadTerm(t, {
4034
+ fetchFn: opts.fetchFn
4035
+ });
4036
+ const r2 = courseHistory(e2, s2, code, {
4037
+ campuses: opts.campuses,
4038
+ limit: opts.limit,
4039
+ fetched_at: hf,
4040
+ stale: hs,
4041
+ snapshot_term: t,
4042
+ enrichment: {
4043
+ term: t,
4044
+ fetched_at: m2.fetched_at,
4045
+ stale: m2.stale
4046
+ }
4047
+ });
4048
+ return {
4049
+ ...r2,
4050
+ rotation_note: `${r2.rotation_note} (Not offered in ${served}; history resolved via ${t}.)`,
4051
+ ...fallback ? { fallback, warning } : {}
4052
+ };
4053
+ }
4054
+ } catch {
4055
+ continue;
4056
+ }
4057
+ if (seen.size >= 5) break;
4058
+ }
4059
+ } catch {
4060
+ }
4061
+ return { ...result, ...fallback ? { fallback, warning } : {} };
4062
+ }
4063
+ async function defaultTerm(fetchFn) {
4064
+ const now = Date.now();
4065
+ const mem2 = getCurrentTermMem();
4066
+ if (mem2 && now - mem2.at < CURRENT_TERM_TTL_MS) {
4067
+ return mem2.value;
4068
+ }
4069
+ let staleFile = null;
4070
+ try {
4071
+ const file = await readCurrentTermFile();
4072
+ if (file) {
4073
+ if (now - file.at < CURRENT_TERM_TTL_MS) {
4074
+ setCurrentTermMem(file.value, file.at);
4075
+ return file.value;
4076
+ }
4077
+ staleFile = file.value;
4078
+ }
4079
+ } catch {
4080
+ }
4081
+ try {
4082
+ const cur = await fetchCurrentTerm({}, fetchFn);
4083
+ const value = `${cur.data.term}${cur.data.year}`;
4084
+ await writeCurrentTermFile(value);
4085
+ return value;
4086
+ } catch {
4087
+ const known = mem2?.value ?? staleFile;
4088
+ if (known) return known;
4089
+ throw Object.assign(
4090
+ new Error(
4091
+ "Cannot determine the current term: network unavailable and no cached value."
4092
+ ),
4093
+ {
4094
+ code: "E_NO_CURRENT_TERM",
4095
+ hint: "Pass an explicit term like --term FA2026, or run `5c terms` once while online."
4096
+ }
4097
+ );
4098
+ }
4099
+ }
4100
+ function suggestIds(sections, id, n = 3) {
4101
+ const parsed = parseCode(id);
4102
+ const scored = sections.map((s) => {
4103
+ let score = 0;
4104
+ if (parsed.dept && s.dept === parsed.dept) score += 3;
4105
+ if (parsed.number !== void 0 && s.number === parsed.number) score += 3;
4106
+ if (parsed.school && s.school === parsed.school) score += 2;
4107
+ return { s, score };
4108
+ });
4109
+ return scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score || a.s.id.localeCompare(b.s.id)).slice(0, n).map((x) => x.s.id);
4110
+ }
4111
+ function sortHits(hits, sort) {
4112
+ const arr = [...hits];
4113
+ if (sort === "time") {
4114
+ arr.sort(
4115
+ (a, b) => earliestStart(a.section) - earliestStart(b.section) || a.section.id.localeCompare(b.section.id)
4116
+ );
4117
+ } else if (sort === "seats") {
4118
+ arr.sort(
4119
+ (a, b) => b.section.seats.available - a.section.seats.available || a.section.id.localeCompare(b.section.id)
4120
+ );
4121
+ } else if (sort === "code") {
4122
+ arr.sort((a, b) => a.section.id.localeCompare(b.section.id));
4123
+ }
4124
+ return arr;
4125
+ }
4126
+ function earliestStart(s) {
4127
+ const starts = s.meetings.map((m) => m.startMin);
4128
+ return starts.length > 0 ? Math.min(...starts) : Number.MAX_SAFE_INTEGER;
4129
+ }
4130
+ var DEPT_NAMES = [
4131
+ ["computer science", "CSCI"],
4132
+ ["mathematics", "MATH"],
4133
+ ["economics", "ECON"],
4134
+ ["psychology", "PSYC"],
4135
+ ["physics", "PHYS"],
4136
+ ["chemistry", "CHEM"],
4137
+ ["biology", "BIOL"],
4138
+ ["english", "ENGL"],
4139
+ ["history", "HIST"],
4140
+ ["philosophy", "PHIL"],
4141
+ ["sociology", "SOC"],
4142
+ ["anthropology", "ANTH"],
4143
+ ["neuroscience", "NEUR"],
4144
+ ["astronomy", "ASTR"],
4145
+ ["geology", "GEOL"],
4146
+ ["politics", "POLI"],
4147
+ ["statistics", "MATH"],
4148
+ ["ethics", "PHIL"],
4149
+ ["arabic", "ARBC"],
4150
+ ["spanish", "SPAN"],
4151
+ ["portuguese", "PORT"],
4152
+ ["film", "MS"],
4153
+ ["media", "MS"],
4154
+ ["music", "MUS"],
4155
+ ["math", "MATH"],
4156
+ ["art", "ART"]
4157
+ ];
4158
+ function resultWillBeEmptyCodeQuery(query, hits) {
4159
+ if (hits.length > 0) return false;
4160
+ const parsed = parseCode(query);
4161
+ return !!parsed.dept && parsed.number !== void 0;
4162
+ }
4163
+ function recallSuggestions(query, filters, sections) {
4164
+ const out = [];
4165
+ const padded = ` ${query.toLowerCase().trim()} `;
4166
+ if (padded.trim().length === 0) return out;
4167
+ const asCode = parseCode(query);
4168
+ const codeShaped = !!asCode.dept && asCode.number !== void 0;
4169
+ if (!codeShaped && (!filters.departments || filters.departments.length === 0)) {
4170
+ for (const [name, code] of DEPT_NAMES) {
4171
+ if (padded.includes(` ${name} `) && sections.some((s) => s.dept === code)) {
4172
+ out.push(
4173
+ `'${query.trim()}' matches department ${code} \u2014 retry with --dept ${code} (departments:["${code}"]) for full recall.`
4174
+ );
4175
+ break;
4176
+ }
4177
+ }
4178
+ }
4179
+ if (!filters.schools || filters.schools.length === 0) {
4180
+ const mentioned = detectSchoolMention(query);
4181
+ if (mentioned) {
4182
+ out.push(
4183
+ `Query mentions a college \u2014 retry with --school ${mentioned} to narrow, or leave blank for all 5Cs.`
4184
+ );
4185
+ }
4186
+ }
4187
+ return out;
4188
+ }
4189
+
4190
+ // src/server.ts
4191
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4192
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4193
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4194
+
4195
+ // src/errors.ts
4196
+ var RETRYABLE = /* @__PURE__ */ new Set(["E_FETCH", "E_NO_CURRENT_TERM"]);
4197
+ function recoveryOf(e) {
4198
+ switch (e.code) {
4199
+ case "E_TERM_NOT_FOUND":
4200
+ case "E_BAD_TERM":
4201
+ return e.available?.length ? `Recovery: call list_terms and retry with one of ${e.available.join(", ")}, or pass allow_term_fallback: true to serve the newest.` : "Recovery: call list_terms and retry with an available term.";
4202
+ case "E_NO_CURRENT_TERM":
4203
+ return "Recovery: pass an explicit term; retry once the network is reachable.";
4204
+ case "E_NOT_FOUND":
4205
+ return "Recovery: search_classes to resolve the ID, then pass it verbatim. Loose or partial IDs are never guessed at.";
4206
+ case "E_BAD_FILTER":
4207
+ case "E_USAGE":
4208
+ return "Recovery: fix the named argument and retry; nothing was fetched.";
4209
+ default:
4210
+ return RETRYABLE.has(e.code ?? "") ? "Recovery: retry once. If it fails again, report what you have and its timestamp." : "Recovery: report the failure; do not substitute another term's data.";
4211
+ }
4212
+ }
4213
+ function toolError(err) {
4214
+ const e = err ?? {};
4215
+ const parts = [e.message ?? String(err)];
4216
+ if (e.not_found?.length) parts.push(`Unresolved: ${e.not_found.join(", ")}.`);
4217
+ if (e.hint && e.hint !== e.message) parts.push(e.hint);
4218
+ parts.push(recoveryOf(e));
4219
+ if (e.code) parts.push(`(code: ${e.code})`);
4220
+ return {
4221
+ isError: true,
4222
+ content: [{ type: "text", text: parts.join(" ") }]
4223
+ };
4224
+ }
4225
+
4226
+ // src/schemas.ts
4227
+ import { z as z2 } from "zod";
4228
+ var SCHOOLS = [
4229
+ "PO",
4230
+ "CM",
4231
+ "CMC",
4232
+ "HM",
4233
+ "SC",
4234
+ "PZ",
4235
+ "CG",
4236
+ "KS",
4237
+ "JP",
4238
+ "JT",
4239
+ "JM",
4240
+ "AF",
4241
+ "CH",
4242
+ "AA"
4243
+ ];
4244
+ var DAYS = ["M", "T", "W", "R", "F", "S", "U"];
4245
+ var SORTS = ["relevance", "time", "seats", "code"];
4246
+ var fallbackSchema = z2.object({
4247
+ requested: z2.string(),
4248
+ served: z2.string()
4249
+ }).strict();
4250
+ var termProvenanceSchema = z2.object({
4251
+ term: z2.string(),
4252
+ fetched_at: z2.string(),
4253
+ stale: z2.boolean()
4254
+ }).strict();
4255
+ var ingestFields = {
4256
+ fetched_at: z2.string().describe("When this process obtained the snapshot."),
4257
+ stale: z2.boolean().describe("Snapshot past the seat TTL; treat seats as indicative."),
4258
+ quarantined: z2.number().int().describe("Rows dropped at ingest. Nonzero means incomplete coverage."),
4259
+ duplicate_ids: z2.number().int().describe("Rows collapsed onto an existing canonical ID at ingest.")
4260
+ };
4261
+ var searchInputSchema = z2.object({
4262
+ query: z2.string().optional().describe(
4263
+ "Free text over title/description/code/instructor. Empty = browse with filters."
4264
+ ),
4265
+ term: z2.string().optional().describe(
4266
+ "Term code, e.g. 'FA2026'. Defaults to current. Call list_terms if unsure."
4267
+ ),
4268
+ schools: z2.array(z2.enum(SCHOOLS)).optional().describe(
4269
+ "Filter to colleges (5Cs + joint: KS JP JT JM AF CH AA; CG Claremont Graduate). CM is canonical for Claremont McKenna; CMC accepted as alias."
4270
+ ),
4271
+ departments: z2.array(z2.string()).optional().describe("Dept codes, e.g. ['ECON','CSCI']. Case-insensitive."),
4272
+ code: z2.string().optional().describe(
4273
+ "Course-code prefix. 'CSCI 051' matches that course; 'CSCI 05' matches the 050-059 family; 'CSCI' matches the department."
4274
+ ),
4275
+ days: z2.array(z2.enum(DAYS)).optional().describe(
4276
+ "Must meet on ALL listed days, within the whole time window. R=Thursday."
4277
+ ),
4278
+ starts_after: z2.string().optional().describe(
4279
+ "Earliest start 24h 'HH:MM', e.g. '13:00'. Applies to every meeting of a hit."
4280
+ ),
4281
+ ends_before: z2.string().optional().describe(
4282
+ "Latest end 24h 'HH:MM'. An inverted window (start after end) is an error, not an empty result."
4283
+ ),
4284
+ instructor: z2.string().optional().describe("Instructor name substring."),
4285
+ course_areas: z2.array(z2.string()).optional().describe(
4286
+ "GE/area codes as the registrar writes them, e.g. ['QR']. Codes are per-college; a code from one campus does not apply at another."
4287
+ ),
4288
+ availability: z2.enum(["any", "has_seat"]).default("any").describe(
4289
+ "has_seat = registrar status says registrable (Open/Reopened). Computed capacity is reported, not used as eligibility."
4290
+ ),
4291
+ credits_min: z2.number().min(0).max(99).optional().describe("Minimum credits; pairs with credits_max as a range."),
4292
+ credits_max: z2.number().min(0).max(99).optional().describe("Maximum credits; pairs with credits_min as a range."),
4293
+ sort: z2.enum(SORTS).default("relevance").describe(
4294
+ "Result order: relevance (default), time (earliest first), seats (most open first), code."
4295
+ ),
4296
+ explain: z2.boolean().default(false).describe("Include multi-signal match explanations per hit."),
4297
+ exclude_conflicts_with: z2.array(z2.string()).optional().describe(
4298
+ "Canonical section IDs in the searched term; overlapping hits are dropped. An unresolvable anchor is an error, never a silent no-op."
4299
+ ),
4300
+ allow_term_fallback: z2.boolean().default(false).describe(
4301
+ "When the requested term is unpublished, serve the newest available term instead and say so. Default false: an unpublished term is an error naming the available terms."
4302
+ ),
4303
+ limit: z2.number().int().min(1).max(50).default(10),
4304
+ offset: z2.number().int().min(0).default(0)
4305
+ }).strict();
4306
+ var getInputSchema = z2.object({
4307
+ ids: z2.array(z2.string()).min(1).max(20).describe("Canonical section IDs, e.g. 'CSCI 051 PO-01 FA2026'."),
4308
+ term: z2.string().optional().describe("Term hint when IDs carry no term."),
4309
+ allow_term_fallback: z2.boolean().default(false)
4310
+ }).strict();
4311
+ var checkInputSchema = z2.object({
4312
+ ids: z2.array(z2.string()).min(1).max(20).describe(
4313
+ "Section IDs to audit together. Every ID must resolve exactly; unknown IDs fail the whole call."
4314
+ ),
4315
+ term: z2.string().optional().describe("Term code, e.g. 'FA2026'."),
4316
+ passing_minutes: z2.number().int().min(0).max(60).default(10).describe("Minimum acceptable gap between consecutive classes."),
4317
+ allow_term_fallback: z2.boolean().default(false)
4318
+ }).strict();
4319
+ var termsInputSchema = z2.object({
4320
+ limit: z2.number().int().min(1).max(20).default(6).describe("Most recent N terms.")
4321
+ }).strict();
4322
+ var historyInputSchema = z2.object({
4323
+ code: z2.string().describe("Course code without section, e.g. 'ECON 101', 'CSCI 151'."),
4324
+ schools: z2.array(z2.enum(SCHOOLS)).optional().describe(
4325
+ "Filter to campuses. CM is canonical for Claremont McKenna; CMC accepted as alias."
4326
+ ),
4327
+ limit: z2.number().int().min(1).max(12).default(6),
4328
+ term: z2.string().optional().describe(
4329
+ "Term code anchoring instructor/seat enrichment, e.g. 'FA2026'. Defaults to current."
4330
+ ),
4331
+ allow_term_fallback: z2.boolean().default(false)
4332
+ }).strict();
4333
+ var meetingBlockSchema = z2.object({
4334
+ days: z2.string().describe("Merged day letters for one meeting block."),
4335
+ start: z2.string().describe("'HH:MM'"),
4336
+ end: z2.string().describe("'HH:MM'"),
4337
+ location: z2.string().nullable()
4338
+ }).strict();
4339
+ var compactHitSchema = z2.object({
4340
+ id: z2.string(),
4341
+ term: z2.string(),
4342
+ school: z2.string(),
4343
+ code: z2.string(),
4344
+ section: z2.string(),
4345
+ title: z2.string(),
4346
+ instructors: z2.array(z2.string()),
4347
+ days: z2.string().describe('Merged across meetings; "" when TBA.'),
4348
+ start: z2.string().nullable(),
4349
+ end: z2.string().nullable(),
4350
+ meetings: z2.array(meetingBlockSchema).describe("Per-block truth; the summary above may combine blocks."),
4351
+ credits: z2.number(),
4352
+ potential_error: z2.boolean().describe("Source data flagged unreliable; verify before relying on it."),
4353
+ seats_open: z2.number(),
4354
+ seats_total: z2.number(),
4355
+ status: z2.string().describe("O=Open C=Closed R=Reopened U=unknown."),
4356
+ match: z2.object({ score: z2.number(), why: z2.array(z2.string()) }).strict(),
4357
+ fetched_at: z2.string(),
4358
+ stale: z2.boolean()
4359
+ }).strict();
4360
+ var searchOutputSchema = z2.object({
4361
+ term: z2.string(),
4362
+ total_count: z2.number(),
4363
+ has_more: z2.boolean(),
4364
+ next_offset: z2.number().nullable(),
4365
+ hits: z2.array(compactHitSchema),
4366
+ hint: z2.string().nullable(),
4367
+ suggestions: z2.array(z2.string()),
4368
+ snapshot_id: z2.string().nullable().describe("Compare across pages: a change means the snapshot rotated."),
4369
+ applied_filters: z2.record(z2.string(), z2.unknown()).describe("Filters as resolved, including the serving term."),
4370
+ seats_note: z2.string(),
4371
+ ...ingestFields,
4372
+ fallback: fallbackSchema.optional(),
4373
+ warning: z2.string().optional()
4374
+ }).strict();
4375
+ var getOutputSchema = z2.object({
4376
+ classes: z2.array(sectionSchema),
4377
+ not_found: z2.array(z2.string()),
4378
+ ambiguous: z2.array(
4379
+ z2.object({ input: z2.string(), section_ids: z2.array(z2.string()) }).strict()
4380
+ ).describe("Loose inputs matching several sections; ask the user which."),
4381
+ suggestions: z2.array(
4382
+ z2.object({ input: z2.string(), ids: z2.array(z2.string()) }).strict()
4383
+ ),
4384
+ terms: z2.array(termProvenanceSchema).describe("One entry per snapshot used, in resolution order."),
4385
+ ...ingestFields,
4386
+ fallback: fallbackSchema.optional(),
4387
+ warning: z2.string().optional()
4388
+ }).strict();
4389
+ var checkOutputSchema = z2.object({
4390
+ ok: z2.boolean().describe("No hard conflicts and no closed sections."),
4391
+ complete: z2.boolean().describe(
4392
+ "False when some inputs could not be fully audited (TBA times, flagged data). ok:true never implies a complete audit."
4393
+ ),
4394
+ skipped_tba: z2.array(z2.string()).describe("Sections with unknown meeting times; not conflict-checked."),
4395
+ total_credits: z2.number(),
4396
+ class_count: z2.number(),
4397
+ conflicts: z2.array(
4398
+ z2.object({
4399
+ a: z2.string(),
4400
+ b: z2.string(),
4401
+ days: z2.array(z2.string()),
4402
+ overlap: z2.string(),
4403
+ overlap_minutes: z2.number(),
4404
+ severity: z2.enum(["hard", "tight"])
4405
+ }).strict()
4406
+ ),
4407
+ travel_warnings: z2.array(
4408
+ z2.object({
4409
+ from: z2.string(),
4410
+ to: z2.string(),
4411
+ from_school: z2.string(),
4412
+ to_school: z2.string(),
4413
+ gap_minutes: z2.number(),
4414
+ message: z2.string()
4415
+ }).strict()
4416
+ ),
4417
+ seat_problems: z2.array(
4418
+ z2.object({ id: z2.string(), reason: z2.string() }).strict()
4419
+ ),
4420
+ warnings: z2.array(z2.string()),
4421
+ weekly_grid: z2.string(),
4422
+ checked: z2.array(z2.string()).describe("Section IDs that were audited."),
4423
+ terms: z2.array(termProvenanceSchema),
4424
+ ...ingestFields,
4425
+ fallback: fallbackSchema.optional(),
4426
+ warning: z2.string().optional()
4427
+ }).strict();
4428
+ var termsOutputSchema = z2.object({
4429
+ current_term: z2.string(),
4430
+ terms: z2.array(
4431
+ z2.object({
4432
+ code: z2.string(),
4433
+ label: z2.string(),
4434
+ year: z2.number(),
4435
+ term: z2.string(),
4436
+ is_current: z2.boolean()
4437
+ }).strict()
4438
+ )
4439
+ }).strict();
4440
+ var historyOutputSchema = z2.object({
4441
+ code: z2.string(),
4442
+ offerings: z2.array(
4443
+ z2.object({
4444
+ term: z2.string(),
4445
+ instructors: z2.array(z2.string()),
4446
+ campus: z2.string(),
4447
+ seats: z2.object({ capacity: z2.number(), enrolled: z2.number() }).strict().nullable()
4448
+ }).strict()
4449
+ ),
4450
+ rotation_note: z2.string().describe("Describes the window actually observed."),
4451
+ verdict: z2.string(),
4452
+ fetched_at: z2.string().describe("Freshness of the offering-history source."),
4453
+ stale: z2.boolean(),
4454
+ snapshot_term: z2.string(),
4455
+ enrichment: z2.object({
4456
+ term: z2.string(),
4457
+ fetched_at: z2.string(),
4458
+ stale: z2.boolean()
4459
+ }).strict().nullable().optional().describe("Freshness of the sections snapshot used for seats."),
4460
+ fallback: fallbackSchema.optional(),
4461
+ warning: z2.string().optional()
4462
+ }).strict();
4463
+
4464
+ // src/text.ts
4465
+ function provenanceLines(p) {
4466
+ const out = [];
4467
+ if (p.fallback) {
4468
+ out.push(
4469
+ `Note: term ${p.fallback.requested} is not published; showing ${p.fallback.served}.`
4470
+ );
4471
+ } else if (p.warning) {
4472
+ out.push(`Note: ${p.warning}`);
4473
+ }
4474
+ const stale = p.stale ? " (STALE)" : "";
4475
+ out.push(
4476
+ `Seats as of ${p.fetched_at}${stale} \u2014 verify in CX before enrolling.`
4477
+ );
4478
+ if (p.quarantined > 0) {
4479
+ out.push(
4480
+ `Coverage warning: ${p.quarantined} row(s) were dropped at ingest \u2014 results may be incomplete.`
4481
+ );
4482
+ }
4483
+ if (p.duplicate_ids > 0) {
4484
+ out.push(
4485
+ `Coverage note: ${p.duplicate_ids} row(s) collapsed onto an existing ID at ingest (same dept/number/school/section).`
4486
+ );
4487
+ }
4488
+ return out;
4489
+ }
4490
+ function moreLine(has_more, next_offset) {
4491
+ if (!has_more || next_offset === null) return null;
4492
+ return `More results: call again with offset ${next_offset}.`;
4493
+ }
4494
+ function searchText(p) {
4495
+ const lines = [];
4496
+ if (p.total_count === 0) {
4497
+ lines.push(`No matches in ${p.term}.`);
4498
+ lines.push(`Filters applied: ${JSON.stringify(p.applied_filters)}.`);
4499
+ } else {
4500
+ lines.push(
4501
+ `${p.total_count} match(es) in ${p.term} (showing ${p.hits.length}, as of ${p.fetched_at}).`
4502
+ );
4503
+ for (const h of p.hits) {
4504
+ const seats = `${h.seats_open}/${h.seats_total} open`;
4505
+ const credits = h.credits > 0 ? `, ${h.credits} cr` : "";
4506
+ const flagged = h.potential_error ? " [source data flagged]" : "";
4507
+ lines.push(
4508
+ `- ${h.id} \u2014 ${h.title} [${h.status}${credits}, ${seats}]${flagged}`
4509
+ );
4510
+ const meets = h.meetings.length > 0 ? h.meetings.map(
4511
+ (m) => `${m.days || "TBA"} ${m.start}-${m.end}${m.location ? ` @ ${m.location}` : ""}`
4512
+ ).join("; ") : "TBA (no scheduled meeting time)";
4513
+ lines.push(` Meets: ${meets}`);
4514
+ }
4515
+ }
4516
+ if (p.hint) lines.push(p.hint);
4517
+ const more = moreLine(p.has_more, p.next_offset);
4518
+ if (more) lines.push(more);
4519
+ for (const s of p.suggestions) lines.push(`Suggestion: ${s}`);
4520
+ if (p.snapshot_id)
4521
+ lines.push(
4522
+ `Snapshot: ${p.snapshot_id} (same id across pages = same data).`
4523
+ );
4524
+ lines.push(...provenanceLines(p));
4525
+ return lines.join("\n");
4526
+ }
4527
+ function getText(p) {
4528
+ const lines = [];
4529
+ for (const amb of p.ambiguous) {
4530
+ lines.push(
4531
+ `AMBIGUOUS: '${amb.input}' matches ${amb.section_ids.length} sections. Ask the user which, or pass a full ID: ${amb.section_ids.join(" | ")}`
4532
+ );
4533
+ }
4534
+ if (p.not_found.length > 0) {
4535
+ const sug = p.suggestions.filter((s) => p.not_found.includes(s.input)).map((s) => `${s.input} \u2192 ${s.ids.join(", ")}`).join("; ");
4536
+ lines.push(
4537
+ `NOT FOUND: ${p.not_found.join(", ")}.${sug ? ` Closest: ${sug}.` : ""} Search first, then pass IDs verbatim.`
4538
+ );
4539
+ }
4540
+ for (const c of p.classes) {
4541
+ const meets = c.meetings.length > 0 ? c.meetings.map(
4542
+ (m) => `${m.days.join("")} ${m.start12}\u2013${m.end12} @ ${m.locations.join("; ") || "TBA"}`
4543
+ ).join("\n ") : "TBA";
4544
+ lines.push(
4545
+ `- ${c.id} \u2014 ${c.title} [${c.statusLabel}] ${c.credits} credit(s)`,
4546
+ ` Meets: ${meets}`,
4547
+ ` Seats: ${c.seats.available} open (${c.seats.filled}/${c.seats.total}; ${c.seats.permCount} instructor permits, not waitlist)`,
4548
+ ` Instructors: ${c.instructors.join(", ") || "staff TBD"}`,
4549
+ ` Areas: ${c.areas.join(", ") || "\u2014"} | Dates: ${c.startDate} \u2192 ${c.endDate}`,
4550
+ ` ${c.description.split("\n")[0] ?? ""}`
4551
+ );
4552
+ if (c.potentialError) {
4553
+ lines.push(` Warning: source data for ${c.id} is flagged unreliable.`);
4554
+ }
4555
+ if (p.terms.length > 1) {
4556
+ const t = p.terms.find((x) => x.term === c.term);
4557
+ if (t?.stale) lines.push(` Note: ${c.term} snapshot is stale.`);
4558
+ }
4559
+ }
4560
+ for (const t of p.terms) {
4561
+ lines.push(`${t.term}: as of ${t.fetched_at}${t.stale ? " (STALE)" : ""}`);
4562
+ }
4563
+ lines.push(...provenanceLines(p));
4564
+ return lines.join("\n");
4565
+ }
4566
+ function checkText(p) {
4567
+ const lines = [];
4568
+ const completeness = p.complete ? "audit complete" : `audit INCOMPLETE \u2014 ${p.skipped_tba.length} section(s) have unknown meeting times`;
4569
+ lines.push(
4570
+ p.ok ? `OK: no hard conflicts and no closed sections (${p.class_count} classes, ${p.total_credits} credits; ${completeness}).` : `NOT OK (${p.class_count} classes, ${p.total_credits} credits; ${completeness}).`
4571
+ );
4572
+ if (p.skipped_tba.length > 0) {
4573
+ lines.push(
4574
+ `Not checked (TBA times): ${p.skipped_tba.join(", ")}. TBA is absence of evidence, not evidence of a free slot.`
4575
+ );
4576
+ }
4577
+ if (p.conflicts.length > 0) {
4578
+ lines.push(
4579
+ `Conflicts: ${p.conflicts.map(
4580
+ (c) => `[${c.severity}] ${c.a} vs ${c.b} (${c.days.join("")} ${c.overlap})`
4581
+ ).join("; ")}.`
4582
+ );
4583
+ } else {
4584
+ lines.push("No time conflicts among the sections with known times.");
4585
+ }
4586
+ for (const t of p.travel_warnings) lines.push(`Travel: ${t.message}`);
4587
+ if (p.seat_problems.length > 0) {
4588
+ lines.push(
4589
+ `Seats: ${p.seat_problems.map((s) => `${s.id} \u2014 ${s.reason}`).join("; ")}.`
4590
+ );
4591
+ }
4592
+ for (const w of p.warnings) lines.push(`Warning: ${w}`);
4593
+ lines.push(`Checked: ${p.checked.join(", ")}`);
4594
+ lines.push(...provenanceLines(p));
4595
+ return lines.join("\n");
4596
+ }
4597
+ function termsText(p) {
4598
+ return `Current: ${p.current_term}. Terms: ${p.terms.map((t) => `${t.code} (${t.label})`).join(", ")}.`;
4599
+ }
4600
+ function historyText(p) {
4601
+ const lines = [];
4602
+ lines.push(`${p.code}: ${p.verdict}`);
4603
+ lines.push(p.rotation_note);
4604
+ if (p.offerings.length === 0) {
4605
+ lines.push("No prior offerings found in the history source.");
4606
+ } else {
4607
+ for (const o of p.offerings) {
4608
+ const seats = o.seats ? `${o.seats.enrolled}/${o.seats.capacity} enrolled` : "seats unknown";
4609
+ lines.push(
4610
+ `- ${o.term} @ ${o.campus} \u2014 ${o.instructors.join(", ") || "staff TBD"} (${seats})`
4611
+ );
4612
+ }
4613
+ }
4614
+ lines.push(`History as of ${p.fetched_at}${p.stale ? " (STALE)" : ""}.`);
4615
+ if (p.enrichment) {
4616
+ lines.push(
4617
+ `Seats enriched from ${p.enrichment.term} snapshot as of ${p.enrichment.fetched_at}${p.enrichment.stale ? " (STALE)" : ""}.`
4618
+ );
4619
+ }
4620
+ if (p.fallback) {
4621
+ lines.push(
4622
+ `Note: term ${p.fallback.requested} is not published; showing ${p.fallback.served}.`
4623
+ );
4624
+ } else if (p.warning) {
4625
+ lines.push(`Note: ${p.warning}`);
4626
+ }
4627
+ return lines.join("\n");
4628
+ }
4629
+
4630
+ // src/server.ts
4631
+ var VERSION = packageVersion();
4632
+ var SEATS_NOTE = "Seats are a snapshot; verify in CX before enrolling.";
4633
+ var READONLY = { readOnlyHint: true, openWorldHint: true };
4634
+ var MAX_BODY_BYTES = 1 * 1024 * 1024;
4635
+ function packageVersion() {
4636
+ try {
4637
+ const pkg = JSON.parse(
4638
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
4639
+ );
4640
+ return pkg.version ?? "0.0.0";
4641
+ } catch {
4642
+ return "0.0.0";
4643
+ }
4644
+ }
4645
+ var INSTRUCTIONS = `5C course search (Pomona, CM/Claremont McKenna, Harvey Mudd, Scripps, Pitzer).
4646
+ Workflow: list_terms if the term is vague -> search_classes (limit 10) -> get_classes to expand -> check_schedule to validate.
4647
+ Page with offset/next_offset/has_more (+total_count); don't widen queries. Seats are snapshots: always state fetched_at ("as of ..."). Never promise enrollment.
4648
+ Honesty rules: an unpublished term is an error, not a substitution, unless you pass allow_term_fallback. check_schedule ok:true does not mean complete:true \u2014 TBA sections are not checked. Report quarantined > 0 as incomplete coverage.`;
4649
+ function createMcpServer() {
4650
+ const server = new McpServer(
4651
+ { name: "5c-index", version: VERSION },
4652
+ { instructions: INSTRUCTIONS }
4653
+ );
4654
+ registerTools(server);
4655
+ return server;
4656
+ }
4657
+ function okPayload(payload) {
4658
+ return { structuredContent: payload };
4659
+ }
4660
+ function registerTools(server) {
4661
+ server.registerTool(
4662
+ "search_classes",
4663
+ {
4664
+ title: "Search 5C Classes",
4665
+ description: `Search current/upcoming 5C course offerings by keyword, department, campus, time, instructor, seats. USE for all discovery queries ('CS classes at Mudd Tuesday afternoons with open seats'). Do NOT use to validate a fixed schedule (use check_schedule) or for full details (use get_classes with returned IDs). Trust registrar status (O/C/R), not seat arithmetic: availability:"has_seat" means the registrar says registrable. TBA sections never match day/time filters. Unknown parameters are rejected \u2014 use availability (not open_only) and instructor singular (not instructors). Example: {"query":"microeconomics","schools":["PO","CM"],"term":"FA2026","availability":"has_seat","limit":10}.`,
4666
+ inputSchema: searchInputSchema,
4667
+ outputSchema: searchOutputSchema,
4668
+ annotations: { ...READONLY }
4669
+ },
4670
+ async (args) => {
4671
+ try {
4672
+ const credits = args.credits_min !== void 0 || args.credits_max !== void 0 ? `${args.credits_min ?? 0}-${args.credits_max ?? 999}` : void 0;
4673
+ const { result, normalized, meta, fallback, warning } = await searchClasses(args.query ?? "", {
4674
+ term: args.term,
4675
+ schools: args.schools,
4676
+ depts: args.departments,
4677
+ code: args.code,
4678
+ days: args.days?.join(""),
4679
+ after: args.starts_after,
4680
+ before: args.ends_before,
4681
+ instructor: args.instructor,
4682
+ areas: args.course_areas,
4683
+ open: args.availability === "has_seat" ? true : void 0,
4684
+ credits,
4685
+ sort: args.sort,
4686
+ explain: args.explain,
4687
+ excludeConflictsWith: args.exclude_conflicts_with,
4688
+ limit: args.limit,
4689
+ offset: args.offset,
4690
+ allowTermFallback: args.allow_term_fallback
4691
+ });
4692
+ const payload = {
4693
+ term: result.term,
4694
+ total_count: result.total,
4695
+ has_more: result.has_more,
4696
+ next_offset: result.next_offset,
4697
+ hits: result.hits,
4698
+ hint: result.hint,
4699
+ suggestions: result.suggestions,
4700
+ snapshot_id: result.snapshot_id,
4701
+ applied_filters: normalized,
4702
+ seats_note: SEATS_NOTE,
4703
+ fetched_at: meta.fetched_at,
4704
+ stale: meta.stale,
4705
+ quarantined: meta.quarantined,
4706
+ duplicate_ids: meta.duplicate_ids,
4707
+ ...fallback ? { fallback } : {},
4708
+ ...warning ? { warning } : {}
4709
+ };
4710
+ return {
4711
+ content: [{ type: "text", text: searchText(payload) }],
4712
+ ...okPayload(payload)
4713
+ };
4714
+ } catch (err) {
4715
+ return toolError(err);
4716
+ }
4717
+ }
4718
+ );
4719
+ server.registerTool(
4720
+ "get_classes",
4721
+ {
4722
+ title: "Get Class Details",
4723
+ description: 'Fetch full details for 1-20 class IDs from a prior search_classes call (description, seats, all meeting blocks, areas, dates). USE to expand hits before presenting or before check_schedule. Do NOT use for discovery \u2014 requires IDs. Unknown IDs come back in not_found with suggestions; loose IDs that match several sections come back in ambiguous (ask the user, never pick for them). Example: {"ids":["CSCI 051 PO-01 FA2026"]}.',
4724
+ inputSchema: getInputSchema,
4725
+ outputSchema: getOutputSchema,
4726
+ annotations: { ...READONLY }
4727
+ },
4728
+ async (args) => {
4729
+ try {
4730
+ const { result, meta, fallback, warning } = await getClasses(args.ids, {
4731
+ term: args.term,
4732
+ allowTermFallback: args.allow_term_fallback
4733
+ });
4734
+ const payload = {
4735
+ classes: result.classes,
4736
+ not_found: result.not_found,
4737
+ ambiguous: result.ambiguous,
4738
+ suggestions: result.suggestions,
4739
+ terms: result.terms,
4740
+ fetched_at: meta.fetched_at,
4741
+ stale: meta.stale,
4742
+ quarantined: meta.quarantined,
4743
+ duplicate_ids: meta.duplicate_ids,
4744
+ ...fallback ? { fallback } : {},
4745
+ ...warning ? { warning } : {}
4746
+ };
4747
+ const nothingResolved = payload.classes.length === 0;
4748
+ return {
4749
+ content: [{ type: "text", text: getText(payload) }],
4750
+ ...okPayload(payload),
4751
+ ...nothingResolved ? { isError: true } : {}
4752
+ };
4753
+ } catch (err) {
4754
+ return toolError(err);
4755
+ }
4756
+ }
4757
+ );
4758
+ server.registerTool(
4759
+ "check_schedule",
4760
+ {
4761
+ title: "Check Schedule Conflicts",
4762
+ description: `Audit 1-20 class IDs for time conflicts, cross-campus travel risk, credit totals, and closed sections. USE whenever the user asks 'can I take A+B?', 'does this schedule work?'. Do NOT use for finding classes. The server does all time math \u2014 never compute overlaps yourself. Read complete/skipped_tba: TBA sections are not checked, so ok:true with complete:false is a partial answer. Half-semester sections only conflict on overlapping dates. Example: {"ids":["CSCI 051 PO-01 FA2026","MATH 031 PO-02 FA2026"],"term":"FA2026"}.`,
4763
+ inputSchema: checkInputSchema,
4764
+ outputSchema: checkOutputSchema,
4765
+ annotations: { ...READONLY }
4766
+ },
4767
+ async (args) => {
4768
+ try {
4769
+ const { result, resolved, meta, terms, fallback, warning } = await checkScheduleByIds(args.ids, {
4770
+ term: args.term,
4771
+ passingMinutes: args.passing_minutes,
4772
+ allowTermFallback: args.allow_term_fallback
4773
+ });
4774
+ const payload = {
4775
+ ok: result.ok,
4776
+ complete: result.complete,
4777
+ skipped_tba: result.skipped_tba,
4778
+ total_credits: result.total_credits,
4779
+ class_count: result.class_count,
4780
+ conflicts: result.conflicts,
4781
+ travel_warnings: result.travel_warnings,
4782
+ seat_problems: result.seat_problems,
4783
+ warnings: result.warnings,
4784
+ weekly_grid: result.weekly_grid,
4785
+ checked: resolved,
4786
+ terms,
4787
+ fetched_at: meta.fetched_at,
4788
+ stale: meta.stale,
4789
+ quarantined: meta.quarantined,
4790
+ duplicate_ids: meta.duplicate_ids,
4791
+ ...fallback ? { fallback } : {},
4792
+ ...warning ? { warning } : {}
4793
+ };
4794
+ return {
4795
+ content: [{ type: "text", text: checkText(payload) }],
4796
+ ...okPayload(payload)
4797
+ };
4798
+ } catch (err) {
4799
+ return toolError(err);
4800
+ }
4801
+ }
4802
+ );
4803
+ server.registerTool(
4804
+ "list_terms",
4805
+ {
4806
+ title: "List Academic Terms",
4807
+ description: "List available term codes with labels (e.g. FA2026 = Fall 2026). USE first when the user's term is ambiguous ('next semester', 'fall') or before search_classes with an explicit term. Do NOT call repeatedly \u2014 cache the result for the session. Example: {}.",
4808
+ inputSchema: termsInputSchema,
4809
+ outputSchema: termsOutputSchema,
4810
+ annotations: { ...READONLY }
4811
+ },
4812
+ async (args) => {
4813
+ try {
4814
+ const { current, available } = await listTerms({ limit: args.limit });
4815
+ const payload = {
4816
+ current_term: current,
4817
+ terms: available
4818
+ };
4819
+ return {
4820
+ content: [{ type: "text", text: termsText(payload) }],
4821
+ ...okPayload(payload)
4822
+ };
4823
+ } catch (err) {
4824
+ return toolError(err);
4825
+ }
4826
+ }
4827
+ );
4828
+ server.registerTool(
4829
+ "get_course_history",
4830
+ {
4831
+ title: "Get Course History",
4832
+ description: `Show past offerings of a course across terms (instructors, terms offered, rotation signal). USE for 'who usually teaches X?', 'how often is X offered?', 'is it likely in spring?'. Do NOT use for current availability (use search_classes). The rotation_note names the window actually observed \u2014 never claim a pattern the note does not support. Example: {"code":"ECON 101","schools":["PO"]}.`,
4833
+ inputSchema: historyInputSchema,
4834
+ outputSchema: historyOutputSchema,
4835
+ annotations: { ...READONLY }
4836
+ },
4837
+ async (args) => {
4838
+ try {
4839
+ const h = await getCourseHistory(args.code, {
4840
+ campuses: args.schools,
4841
+ limit: args.limit,
4842
+ term: args.term,
4843
+ allowTermFallback: args.allow_term_fallback
4844
+ });
4845
+ const payload = {
4846
+ code: h.code,
4847
+ offerings: h.offerings,
4848
+ rotation_note: h.rotation_note,
4849
+ verdict: h.verdict,
4850
+ fetched_at: h.fetched_at,
4851
+ stale: h.stale,
4852
+ snapshot_term: h.snapshot_term,
4853
+ ...h.enrichment ? { enrichment: h.enrichment } : {},
4854
+ ...h.fallback ? { fallback: h.fallback } : {},
4855
+ ...h.warning ? { warning: h.warning } : {}
4856
+ };
4857
+ return {
4858
+ content: [{ type: "text", text: historyText(payload) }],
4859
+ ...okPayload(payload)
4860
+ };
4861
+ } catch (err) {
4862
+ return toolError(err);
4863
+ }
4864
+ }
4865
+ );
4866
+ }
4867
+ async function serveStdio() {
4868
+ await createMcpServer().connect(new StdioServerTransport());
4869
+ }
4870
+ async function serveHttp(host, port) {
4871
+ const httpServer = createServer(async (req, res) => {
4872
+ if (req.method !== "POST" || req.url?.split("?")[0] !== "/mcp") {
4873
+ res.writeHead(req.method === "POST" ? 404 : 405, {
4874
+ "Content-Type": "application/json"
4875
+ });
4876
+ res.end(JSON.stringify({ error: "Use POST /mcp" }));
4877
+ return;
4878
+ }
4879
+ try {
4880
+ let size = 0;
4881
+ const body = await new Promise((resolve, reject) => {
4882
+ const chunks = [];
4883
+ req.on("data", (c) => {
4884
+ size += c.length;
4885
+ if (size > MAX_BODY_BYTES) {
4886
+ reject(
4887
+ Object.assign(new Error("Request body too large"), {
4888
+ code: "E_TOO_LARGE"
4889
+ })
4890
+ );
4891
+ req.destroy();
4892
+ return;
4893
+ }
4894
+ chunks.push(c);
4895
+ });
4896
+ req.on("end", () => {
4897
+ try {
4898
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
4899
+ } catch (e) {
4900
+ reject(e);
4901
+ }
4902
+ });
4903
+ req.on("error", reject);
4904
+ });
4905
+ const transport = new StreamableHTTPServerTransport({
4906
+ sessionIdGenerator: void 0
4907
+ // stateless
4908
+ });
4909
+ await createMcpServer().connect(transport);
4910
+ await transport.handleRequest(req, res, body);
4911
+ } catch (err) {
4912
+ const tooLarge = err.code === "E_TOO_LARGE";
4913
+ console.error("MCP HTTP error:", err);
4914
+ if (!res.headersSent) {
4915
+ res.writeHead(tooLarge ? 413 : 500, {
4916
+ "Content-Type": "application/json"
4917
+ });
4918
+ res.end(
4919
+ JSON.stringify({
4920
+ error: tooLarge ? "Body too large" : "Internal error"
4921
+ })
4922
+ );
4923
+ }
4924
+ }
4925
+ });
4926
+ httpServer.listen(port, host, () => {
4927
+ const bound = httpServer.address();
4928
+ const actual = typeof bound === "object" && bound ? bound.port : port;
4929
+ console.error(
4930
+ `5c-mcp listening (stateless Streamable HTTP) on http://${host}:${actual}/mcp`
4931
+ );
4932
+ });
4933
+ }
4934
+ async function main() {
4935
+ const args = process.argv.slice(2);
4936
+ if (!args.includes("--http")) {
4937
+ await serveStdio();
4938
+ return;
4939
+ }
4940
+ const argValue = (flag) => {
4941
+ const i = args.indexOf(flag);
4942
+ return i >= 0 ? args[i + 1] : void 0;
4943
+ };
4944
+ const rawPort = argValue("--port");
4945
+ const port = rawPort !== void 0 ? Number.parseInt(rawPort, 10) : 3e3;
4946
+ const binding = Number.isFinite(port) ? port : 3e3;
4947
+ const host = argValue("--host") ?? "127.0.0.1";
4948
+ await serveHttp(host, binding);
4949
+ }
4950
+ var isDirectRun = (() => {
4951
+ const argv1 = process.argv[1];
4952
+ if (!argv1) return false;
4953
+ try {
4954
+ return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
4955
+ } catch {
4956
+ return import.meta.url === pathToFileURL(argv1).href;
4957
+ }
4958
+ })();
4959
+ if (isDirectRun) {
4960
+ main().catch((err) => {
4961
+ console.error("5c-mcp fatal:", err);
4962
+ process.exit(1);
4963
+ });
4964
+ }
4965
+ export {
4966
+ createMcpServer,
4967
+ isDirectRun
4968
+ };