@karmaniverous/jeeves-watcher 0.6.9 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
+ import { resolve, extname, basename, dirname, isAbsolute, join, relative } from 'node:path';
1
2
  import Fastify from 'fastify';
2
3
  import { readFileSync, statSync, existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
3
- import { resolve, extname, basename, dirname, join, relative } from 'node:path';
4
4
  import { JsonMap, jsonMapMapSchema } from '@karmaniverous/jsonmap';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import Handlebars from 'handlebars';
@@ -11,6 +11,7 @@ import { toMarkdown } from 'mdast-util-to-markdown';
11
11
  import { capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radash';
12
12
  import rehypeParse from 'rehype-parse';
13
13
  import { unified } from 'unified';
14
+ import yaml from 'js-yaml';
14
15
  import Ajv from 'ajv';
15
16
  import addFormats from 'ajv-formats';
16
17
  import picomatch from 'picomatch';
@@ -18,13 +19,13 @@ import { readdir, stat, writeFile, rm, readFile, mkdir } from 'node:fs/promises'
18
19
  import { z, ZodError } from 'zod';
19
20
  import { JSONPath } from 'jsonpath-plus';
20
21
  import { createHash } from 'node:crypto';
22
+ import crypto from 'crypto';
21
23
  import chokidar from 'chokidar';
22
24
  import { cosmiconfig } from 'cosmiconfig';
23
25
  import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai';
24
26
  import pino from 'pino';
25
27
  import { v5 } from 'uuid';
26
28
  import * as cheerio from 'cheerio';
27
- import yaml from 'js-yaml';
28
29
  import mammoth from 'mammoth';
29
30
  import { MarkdownTextSplitter, RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
30
31
  import { QdrantClient } from '@qdrant/js-client-rest';
@@ -334,6 +335,146 @@ async function buildTemplateEngine(rules, namedTemplates, templateHelpers, confi
334
335
  return engine;
335
336
  }
336
337
 
338
+ /**
339
+ * @module templates/rebaseHeadings
340
+ * Rebase Markdown heading levels so the minimum heading depth starts at baseHeading+1.
341
+ */
342
+ /**
343
+ * Rebase Markdown headings.
344
+ *
345
+ * Finds all ATX headings (lines starting with one or more '#'),
346
+ * computes the minimum heading level present, and shifts all headings
347
+ * so that the minimum becomes baseHeading+1.
348
+ */
349
+ function rebaseHeadings(markdown, baseHeading) {
350
+ if (!markdown.trim())
351
+ return markdown;
352
+ const lines = markdown.split(/\r?\n/);
353
+ const headingLevels = [];
354
+ for (const line of lines) {
355
+ const m = line.match(/^(#{1,6})\s+/);
356
+ if (m)
357
+ headingLevels.push(m[1].length);
358
+ }
359
+ if (headingLevels.length === 0)
360
+ return markdown;
361
+ const minLevel = Math.min(...headingLevels);
362
+ const targetMin = Math.min(6, Math.max(1, baseHeading + 1));
363
+ const delta = targetMin - minLevel;
364
+ if (delta === 0)
365
+ return markdown;
366
+ const rebased = lines.map((line) => {
367
+ const m = line.match(/^(#{1,6})(\s+.*)$/);
368
+ if (!m)
369
+ return line;
370
+ const level = m[1].length;
371
+ const newLevel = Math.min(6, Math.max(1, level + delta));
372
+ return `${'#'.repeat(newLevel)}${m[2]}`;
373
+ });
374
+ return rebased.join('\n');
375
+ }
376
+
377
+ /**
378
+ * @module templates/renderDoc
379
+ * Declarative renderer for YAML frontmatter + structured Markdown body.
380
+ */
381
+ function renderSectionHeading(section, hbs, context = {}) {
382
+ const level = Math.min(6, Math.max(1, section.heading));
383
+ const label = section.label
384
+ ? hbs.compile(section.label)(context)
385
+ : title(section.path);
386
+ return `${'#'.repeat(level)} ${label}`;
387
+ }
388
+ function callFormatHelper(hbs, helperName, value, args) {
389
+ const helper = hbs.helpers[helperName];
390
+ if (typeof helper !== 'function') {
391
+ throw new Error(`Format helper not found: ${helperName}`);
392
+ }
393
+ // Handlebars helpers may return SafeString or other values; coerce to string
394
+ const safeArgs = args ?? [];
395
+ const result = helper(value, ...safeArgs);
396
+ return typeof result === 'string' ? result : String(result);
397
+ }
398
+ function renderValueAsMarkdown(hbs, section, value) {
399
+ if (value === null || value === undefined)
400
+ return '';
401
+ if (section.format) {
402
+ const md = callFormatHelper(hbs, section.format, value, section.formatArgs);
403
+ return rebaseHeadings(md, section.heading);
404
+ }
405
+ const strValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
406
+ return hbs.Utils.escapeExpression(strValue);
407
+ }
408
+ function renderEach(hbs, section, value) {
409
+ if (!Array.isArray(value))
410
+ return '';
411
+ const items = [...value];
412
+ if (section.sort) {
413
+ items.sort((a, b) => {
414
+ const av = get(a, section.sort);
415
+ const bv = get(b, section.sort);
416
+ const aStr = typeof av === 'string' ? av : JSON.stringify(av ?? '');
417
+ const bStr = typeof bv === 'string' ? bv : JSON.stringify(bv ?? '');
418
+ return aStr.localeCompare(bStr);
419
+ });
420
+ }
421
+ const subHeadingLevel = Math.min(6, section.heading + 1);
422
+ const headingTpl = section.headingTemplate
423
+ ? hbs.compile(section.headingTemplate)
424
+ : undefined;
425
+ const parts = [];
426
+ for (const item of items) {
427
+ const headingText = headingTpl
428
+ ? headingTpl(item)
429
+ : '';
430
+ if (headingText) {
431
+ parts.push(`${'#'.repeat(subHeadingLevel)} ${headingText}`);
432
+ }
433
+ const contentVal = section.contentPath
434
+ ? get(item, section.contentPath)
435
+ : item;
436
+ const md = renderValueAsMarkdown(hbs, { ...section, heading: subHeadingLevel }, contentVal);
437
+ if (md.trim())
438
+ parts.push(md.trim());
439
+ }
440
+ return parts.join('\n\n');
441
+ }
442
+ /**
443
+ * Render a document according to a RenderConfig.
444
+ */
445
+ function renderDoc(context, config, hbs) {
446
+ const parts = [];
447
+ // Frontmatter
448
+ const fmObj = {};
449
+ for (const key of config.frontmatter) {
450
+ const v = get(context, key);
451
+ if (v !== undefined) {
452
+ fmObj[key] = v;
453
+ }
454
+ }
455
+ if (Object.keys(fmObj).length > 0) {
456
+ parts.push('---');
457
+ parts.push(yaml.dump(fmObj, { skipInvalid: true }).trim());
458
+ parts.push('---');
459
+ parts.push('');
460
+ }
461
+ // Body
462
+ for (const section of config.body) {
463
+ const heading = renderSectionHeading(section, hbs, context);
464
+ parts.push(heading);
465
+ parts.push('');
466
+ const v = get(context, section.path);
467
+ const body = section.each
468
+ ? renderEach(hbs, section, v)
469
+ : renderValueAsMarkdown(hbs, section, v);
470
+ if (body.trim()) {
471
+ parts.push(body.trim());
472
+ }
473
+ parts.push('');
474
+ }
475
+ return parts.join('\n').trim() + '\n';
476
+ }
477
+
337
478
  /**
338
479
  * @module util/normalizeError
339
480
  *
@@ -820,15 +961,30 @@ async function applyRules(compiledRules, attributes, options = {}) {
820
961
  log.warn(`JsonMap transformation failed: ${normalizeError(error).message}`);
821
962
  }
822
963
  }
964
+ // Build template context: attributes (with json spread at top) + map output
965
+ const context = {
966
+ ...(attributes.json ?? {}),
967
+ ...attributes,
968
+ ...merged,
969
+ };
970
+ // Render via renderDoc if present
971
+ if (rule.render) {
972
+ try {
973
+ const result = renderDoc(context, rule.render, hbs);
974
+ if (result && result.trim()) {
975
+ renderedContent = result;
976
+ }
977
+ else {
978
+ log.warn(`renderDoc for rule "${rule.name}" rendered empty output. Falling back to raw content.`);
979
+ }
980
+ }
981
+ catch (error) {
982
+ log.warn(`renderDoc failed for rule "${rule.name}": ${normalizeError(error).message}. Falling back to raw content.`);
983
+ }
984
+ }
823
985
  // Render template if present
824
986
  if (rule.template && templateEngine) {
825
987
  const templateKey = rule.name;
826
- // Build template context: attributes (with json spread at top) + map output
827
- const context = {
828
- ...(attributes.json ?? {}),
829
- ...attributes,
830
- ...merged,
831
- };
832
988
  try {
833
989
  const result = templateEngine.render(templateKey, context);
834
990
  if (result && result.trim()) {
@@ -894,6 +1050,26 @@ function buildAttributes(filePath, stats, extractedFrontmatter, extractedJson) {
894
1050
  attrs.json = extractedJson;
895
1051
  return attrs;
896
1052
  }
1053
+ /**
1054
+ * Build synthetic file attributes from a path string (no actual file I/O).
1055
+ * Used by API handlers that need to match rules against paths without reading files.
1056
+ *
1057
+ * @param filePath - The file path.
1058
+ * @returns Synthetic file attributes with zeroed stats.
1059
+ */
1060
+ function buildSyntheticAttributes(filePath) {
1061
+ const normalised = normalizeSlashes(filePath);
1062
+ return {
1063
+ file: {
1064
+ path: normalised,
1065
+ directory: normalizeSlashes(dirname(normalised)),
1066
+ filename: basename(normalised),
1067
+ extension: extname(normalised),
1068
+ sizeBytes: 0,
1069
+ modified: new Date(0).toISOString(),
1070
+ },
1071
+ };
1072
+ }
897
1073
 
898
1074
  /**
899
1075
  * @module rules/ajvSetup
@@ -961,8 +1137,14 @@ function compileRules(rules) {
961
1137
  }
962
1138
 
963
1139
  /**
964
- * @module util/retry
965
- * Small async retry helper with exponential backoff. Side effects: sleeps between attempts; can invoke onRetry callback for logging.
1140
+ * @module util/sleep
1141
+ * Abort-aware async sleep.
1142
+ */
1143
+ /**
1144
+ * Sleep for a given duration with optional abort support.
1145
+ *
1146
+ * @param ms - Duration in milliseconds.
1147
+ * @param signal - Optional abort signal.
966
1148
  */
967
1149
  function sleep(ms, signal) {
968
1150
  if (ms <= 0)
@@ -974,7 +1156,7 @@ function sleep(ms, signal) {
974
1156
  }, ms);
975
1157
  const onAbort = () => {
976
1158
  cleanup();
977
- reject(new Error('Retry sleep aborted'));
1159
+ reject(new Error('Sleep aborted'));
978
1160
  };
979
1161
  const cleanup = () => {
980
1162
  clearTimeout(timer);
@@ -990,6 +1172,11 @@ function sleep(ms, signal) {
990
1172
  }
991
1173
  });
992
1174
  }
1175
+
1176
+ /**
1177
+ * @module util/retry
1178
+ * Small async retry helper with exponential backoff. Side effects: sleeps between attempts; can invoke onRetry callback for logging.
1179
+ */
993
1180
  function computeDelayMs(attempt, baseDelayMs, maxDelayMs, jitter = 0) {
994
1181
  const exp = Math.max(0, attempt - 1);
995
1182
  const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** exp);
@@ -1283,7 +1470,12 @@ const apiConfigSchema = z.object({
1283
1470
  .optional()
1284
1471
  .describe('Host address for API server (e.g., "127.0.0.1", "0.0.0.0").'),
1285
1472
  /** Port to listen on. */
1286
- port: z.number().optional().describe('Port for API server (e.g., 3456).'),
1473
+ port: z.number().optional().describe('Port for API server (e.g., 1936).'),
1474
+ /** Read endpoint cache TTL in milliseconds. */
1475
+ cacheTtlMs: z
1476
+ .number()
1477
+ .optional()
1478
+ .describe('TTL in milliseconds for caching read-heavy endpoints (e.g., /status, /config/query). Default: 30000.'),
1287
1479
  });
1288
1480
  /**
1289
1481
  * Logging configuration.
@@ -1337,10 +1529,61 @@ const schemaReferenceSchema = z.union([
1337
1529
  z.string().describe('Named reference to a global schema.'),
1338
1530
  schemaObjectSchema,
1339
1531
  ]);
1532
+ /** Render body section. */
1533
+ const renderBodySectionSchema = z.object({
1534
+ /** Key path in the template context to render. */
1535
+ path: z.string().min(1).describe('Key path in template context to render.'),
1536
+ /** Markdown heading level for this section (1-6). */
1537
+ heading: z.number().min(1).max(6).describe('Markdown heading level (1-6).'),
1538
+ /** Override heading text (default: titlecased path). */
1539
+ label: z.string().optional().describe('Override heading text.'),
1540
+ /** Name of a registered Handlebars helper used as a format handler. */
1541
+ format: z
1542
+ .string()
1543
+ .optional()
1544
+ .describe('Name of a registered Handlebars helper used as a format handler.'),
1545
+ /** Additional args passed to the format helper. */
1546
+ formatArgs: z
1547
+ .array(z.unknown())
1548
+ .optional()
1549
+ .describe('Additional args passed to the format helper.'),
1550
+ /** If true, the value at path is treated as an array and iterated. */
1551
+ each: z
1552
+ .boolean()
1553
+ .optional()
1554
+ .describe('If true, the value at path is treated as an array and iterated.'),
1555
+ /** Handlebars template string for per-item heading text (used when each=true). */
1556
+ headingTemplate: z
1557
+ .string()
1558
+ .optional()
1559
+ .describe('Handlebars template string for per-item heading text (used when each=true).'),
1560
+ /** Key path within each item to use as renderable content (used when each=true). */
1561
+ contentPath: z
1562
+ .string()
1563
+ .optional()
1564
+ .describe('Key path within each item to use as renderable content (used when each=true).'),
1565
+ /** Key path within each item to sort by (used when each=true). */
1566
+ sort: z
1567
+ .string()
1568
+ .optional()
1569
+ .describe('Key path within each item to sort by (used when each=true).'),
1570
+ });
1571
+ /** Render config: YAML frontmatter + ordered body sections. */
1572
+ const renderConfigSchema = z.object({
1573
+ /** Keys to extract from context and include as YAML frontmatter. */
1574
+ frontmatter: z
1575
+ .array(z.string().min(1))
1576
+ .describe('Keys to extract from context and include as YAML frontmatter.'),
1577
+ /** Ordered markdown body sections. */
1578
+ body: z
1579
+ .array(renderBodySectionSchema)
1580
+ .describe('Ordered markdown body sections.'),
1581
+ });
1340
1582
  /**
1341
1583
  * An inference rule that enriches document metadata.
1342
1584
  */
1343
- const inferenceRuleSchema = z.object({
1585
+ const inferenceRuleSchema = z
1586
+ .object({
1344
1587
  /** Unique name for this inference rule. */
1345
1588
  name: z
1346
1589
  .string()
@@ -1370,6 +1613,19 @@ const inferenceRuleSchema = z.object({
1370
1613
  .string()
1371
1614
  .optional()
1372
1615
  .describe('Handlebars content template (inline string, named ref, or .hbs/.handlebars file path).'),
1616
+ /** Declarative structured renderer configuration (mutually exclusive with template). */
1617
+ render: renderConfigSchema
1618
+ .optional()
1619
+ .describe('Declarative render configuration for frontmatter + structured Markdown output (mutually exclusive with template).'),
1620
+ })
1621
+ .superRefine((val, ctx) => {
1622
+ if (val.render && val.template) {
1623
+ ctx.addIssue({
1624
+ code: 'custom',
1625
+ path: ['render'],
1626
+ message: 'render is mutually exclusive with template',
1627
+ });
1628
+ }
1373
1629
  });
1374
1630
 
1375
1631
  /**
@@ -1753,17 +2009,7 @@ function createConfigMatchHandler(options) {
1753
2009
  return;
1754
2010
  }
1755
2011
  const matches = body.paths.map((path) => {
1756
- const normalised = normalizeSlashes(path);
1757
- const attrs = {
1758
- file: {
1759
- path: normalised,
1760
- directory: normalizeSlashes(dirname(normalised)),
1761
- filename: basename(normalised),
1762
- extension: extname(normalised),
1763
- sizeBytes: 0,
1764
- modified: new Date(0).toISOString(),
1765
- },
1766
- };
2012
+ const attrs = buildSyntheticAttributes(path);
1767
2013
  // Find matching rules
1768
2014
  const matchingRules = [];
1769
2015
  for (const compiled of compiledRules) {
@@ -1772,6 +2018,7 @@ function createConfigMatchHandler(options) {
1772
2018
  }
1773
2019
  }
1774
2020
  // Check watch scope: matches watch paths and not in ignored
2021
+ const normalised = attrs.file.path;
1775
2022
  const watched = watchMatcher(normalised) && !ignoreMatcher?.(normalised);
1776
2023
  return { rules: matchingRules, watched };
1777
2024
  });
@@ -1834,6 +2081,8 @@ function buildMergedDocument(options) {
1834
2081
  }));
1835
2082
  return {
1836
2083
  description: config['description'] ?? '',
2084
+ watch: config.watch,
2085
+ configWatch: config.configWatch ?? {},
1837
2086
  search: config.search ?? {},
1838
2087
  schemas: config.schemas ?? {},
1839
2088
  inferenceRules,
@@ -1923,6 +2172,9 @@ function resolveReferences(doc, resolveTypes) {
1923
2172
  /**
1924
2173
  * Create handler for POST /config/query.
1925
2174
  *
2175
+ * Uses direct error handling (returns 400) rather than wrapHandler (which returns 500),
2176
+ * because invalid JSONPath expressions are client errors, not server errors.
2177
+ *
1926
2178
  * @param deps - Route dependencies.
1927
2179
  */
1928
2180
  function createConfigQueryHandler(deps) {
@@ -2038,7 +2290,7 @@ function createConfigSchemaHandler() {
2038
2290
  /**
2039
2291
  * Validate helper file references (mapHelpers, templateHelpers).
2040
2292
  */
2041
- function validateHelperFiles(config) {
2293
+ function validateHelperFiles(config, configDir) {
2042
2294
  const errors = [];
2043
2295
  for (const section of ['mapHelpers', 'templateHelpers']) {
2044
2296
  const helpers = config[section];
@@ -2047,15 +2299,18 @@ function validateHelperFiles(config) {
2047
2299
  for (const [name, helper] of Object.entries(helpers)) {
2048
2300
  if (!helper.path)
2049
2301
  continue;
2050
- if (!existsSync(helper.path)) {
2302
+ const resolvedPath = isAbsolute(helper.path)
2303
+ ? helper.path
2304
+ : resolve(configDir, helper.path);
2305
+ if (!existsSync(resolvedPath)) {
2051
2306
  errors.push({
2052
2307
  path: `${section}.${name}.path`,
2053
- message: `File not found: ${helper.path}`,
2308
+ message: `File not found: ${resolvedPath}`,
2054
2309
  });
2055
2310
  continue;
2056
2311
  }
2057
2312
  try {
2058
- readFileSync(helper.path, 'utf-8');
2313
+ readFileSync(resolvedPath, 'utf-8');
2059
2314
  }
2060
2315
  catch (err) {
2061
2316
  errors.push({
@@ -2108,7 +2363,7 @@ function createConfigValidateHandler(deps) {
2108
2363
  if (errors.length > 0) {
2109
2364
  return { valid: false, errors };
2110
2365
  }
2111
- const helperErrors = validateHelperFiles(candidateRaw);
2366
+ const helperErrors = validateHelperFiles(candidateRaw, deps.configDir);
2112
2367
  if (helperErrors.length > 0) {
2113
2368
  return { valid: false, errors: helperErrors };
2114
2369
  }
@@ -2212,17 +2467,7 @@ function getValueType(value) {
2212
2467
  */
2213
2468
  function validateMetadataPayload(config, path, metadata) {
2214
2469
  const compiled = compileRules(config.inferenceRules ?? []);
2215
- const normalised = normalizeSlashes(path);
2216
- const attrs = {
2217
- file: {
2218
- path: normalised,
2219
- directory: normalizeSlashes(dirname(normalised)),
2220
- filename: basename(normalised),
2221
- extension: extname(normalised),
2222
- sizeBytes: 0,
2223
- modified: new Date(0).toISOString(),
2224
- },
2225
- };
2470
+ const attrs = buildSyntheticAttributes(path);
2226
2471
  const matched = compiled.filter((r) => r.validate(attrs));
2227
2472
  const matchedNames = matched.map((m) => m.rule.name);
2228
2473
  const schemaRefs = matched.flatMap((m) => m.rule.schema ?? []);
@@ -2496,7 +2741,6 @@ function createReindexHandler(deps) {
2496
2741
  */
2497
2742
  function createRulesReapplyHandler(deps) {
2498
2743
  return wrapHandler(async (request) => {
2499
- await Promise.resolve();
2500
2744
  const { globs } = request.body;
2501
2745
  if (!Array.isArray(globs) || globs.length === 0) {
2502
2746
  throw new Error('Missing required field: globs (non-empty string array)');
@@ -2539,7 +2783,6 @@ function createRulesReapplyHandler(deps) {
2539
2783
  */
2540
2784
  function createRulesRegisterHandler(deps) {
2541
2785
  return wrapHandler(async (request) => {
2542
- await Promise.resolve();
2543
2786
  const { source, rules } = request.body;
2544
2787
  if (!source || typeof source !== 'string') {
2545
2788
  throw new Error('Missing required field: source');
@@ -2550,11 +2793,11 @@ function createRulesRegisterHandler(deps) {
2550
2793
  deps.virtualRuleStore.register(source, rules);
2551
2794
  deps.onRulesChanged();
2552
2795
  deps.logger.info({ source, ruleCount: rules.length }, 'Virtual rules registered');
2553
- return {
2796
+ return await Promise.resolve({
2554
2797
  source,
2555
2798
  registered: rules.length,
2556
2799
  totalVirtualRules: deps.virtualRuleStore.size,
2557
- };
2800
+ });
2558
2801
  }, deps.logger, 'RulesRegister');
2559
2802
  }
2560
2803
 
@@ -2562,21 +2805,25 @@ function createRulesRegisterHandler(deps) {
2562
2805
  * @module api/handlers/rulesUnregister
2563
2806
  * Fastify route handler for DELETE /rules/unregister.
2564
2807
  */
2808
+ /**
2809
+ * Core unregister logic shared by body and param handlers.
2810
+ */
2811
+ function unregisterSource(deps, source) {
2812
+ if (!source || typeof source !== 'string') {
2813
+ throw new Error('Missing required field: source');
2814
+ }
2815
+ const removed = deps.virtualRuleStore.unregister(source);
2816
+ if (removed)
2817
+ deps.onRulesChanged();
2818
+ deps.logger.info({ source, removed }, 'Virtual rules unregister');
2819
+ return { source, removed };
2820
+ }
2565
2821
  /**
2566
2822
  * Create handler for DELETE /rules/unregister (body-based).
2567
2823
  */
2568
2824
  function createRulesUnregisterHandler(deps) {
2569
2825
  return wrapHandler(async (request) => {
2570
- await Promise.resolve();
2571
- const { source } = request.body;
2572
- if (!source || typeof source !== 'string') {
2573
- throw new Error('Missing required field: source');
2574
- }
2575
- const removed = deps.virtualRuleStore.unregister(source);
2576
- if (removed)
2577
- deps.onRulesChanged();
2578
- deps.logger.info({ source, removed }, 'Virtual rules unregister');
2579
- return { source, removed };
2826
+ return Promise.resolve(unregisterSource(deps, request.body.source));
2580
2827
  }, deps.logger, 'RulesUnregister');
2581
2828
  }
2582
2829
  /**
@@ -2584,16 +2831,7 @@ function createRulesUnregisterHandler(deps) {
2584
2831
  */
2585
2832
  function createRulesUnregisterParamHandler(deps) {
2586
2833
  return wrapHandler(async (request) => {
2587
- await Promise.resolve();
2588
- const { source } = request.params;
2589
- if (!source || typeof source !== 'string') {
2590
- throw new Error('Missing required param: source');
2591
- }
2592
- const removed = deps.virtualRuleStore.unregister(source);
2593
- if (removed)
2594
- deps.onRulesChanged();
2595
- deps.logger.info({ source, removed }, 'Virtual rules unregister');
2596
- return { source, removed };
2834
+ return Promise.resolve(unregisterSource(deps, request.params.source));
2597
2835
  }, deps.logger, 'RulesUnregister');
2598
2836
  }
2599
2837
 
@@ -2642,6 +2880,57 @@ function createStatusHandler(deps) {
2642
2880
  };
2643
2881
  }
2644
2882
 
2883
+ /**
2884
+ * In-memory response cache
2885
+ */
2886
+ const cache = new Map();
2887
+ /**
2888
+ * Generates a deterministic hash for an object
2889
+ */
2890
+ function hashObject(obj) {
2891
+ if (obj === undefined)
2892
+ return 'undefined';
2893
+ if (obj === null)
2894
+ return 'null';
2895
+ const str = typeof obj === 'string' ? obj : JSON.stringify(obj);
2896
+ return crypto.createHash('sha256').update(str).digest('hex');
2897
+ }
2898
+ /**
2899
+ * Higher-order function to wrap Fastify route handlers with an in-memory TTL cache.
2900
+ * Uses request method, URL, and body hash as the cache key.
2901
+ *
2902
+ * @param ttlMs - Time to live in milliseconds
2903
+ * @param handler - The original route handler
2904
+ * @returns A new route handler that implements caching
2905
+ */
2906
+ function withCache(ttlMs, handler) {
2907
+ return async (req, reply) => {
2908
+ const fReq = req;
2909
+ const fReply = reply;
2910
+ // Generate deterministic cache key: METHOD:URL:BODY_HASH
2911
+ const bodyHash = hashObject(fReq.body);
2912
+ const key = fReq.method + ':' + fReq.url + ':' + bodyHash;
2913
+ // Check cache
2914
+ const now = Date.now();
2915
+ const entry = cache.get(key);
2916
+ if (entry && entry.expiresAt > now) {
2917
+ return entry.value;
2918
+ }
2919
+ // Cache miss - call handler
2920
+ const result = await handler(req, reply);
2921
+ // Don't cache errors (Fastify reply properties might indicate error)
2922
+ if (fReply.statusCode >= 400) {
2923
+ return result;
2924
+ }
2925
+ // Store in cache
2926
+ cache.set(key, {
2927
+ value: result,
2928
+ expiresAt: now + ttlMs,
2929
+ });
2930
+ return result;
2931
+ };
2932
+ }
2933
+
2645
2934
  /**
2646
2935
  * @module api/ReindexTracker
2647
2936
  * Tracks reindex operation state for status reporting. Single instance shared across handlers.
@@ -2703,11 +2992,12 @@ function createApiServer(options) {
2703
2992
  issuesManager,
2704
2993
  }, scope);
2705
2994
  };
2706
- app.get('/status', createStatusHandler({
2995
+ const cacheTtlMs = config.api?.cacheTtlMs ?? 30000;
2996
+ app.get('/status', withCache(cacheTtlMs, createStatusHandler({
2707
2997
  vectorStore,
2708
2998
  collectionName: config.vectorStore.collectionName,
2709
2999
  reindexTracker,
2710
- }));
3000
+ })));
2711
3001
  app.post('/metadata', createMetadataHandler({ processor, config, logger }));
2712
3002
  const hybridConfig = config.search?.hybrid
2713
3003
  ? {
@@ -2728,10 +3018,10 @@ function createApiServer(options) {
2728
3018
  logger,
2729
3019
  }));
2730
3020
  app.post('/config-reindex', createConfigReindexHandler({ config, processor, logger, reindexTracker }));
2731
- app.get('/issues', createIssuesHandler({ issuesManager }));
2732
- app.get('/config/schema', createConfigSchemaHandler());
3021
+ app.get('/issues', withCache(cacheTtlMs, createIssuesHandler({ issuesManager })));
3022
+ app.get('/config/schema', withCache(cacheTtlMs, createConfigSchemaHandler()));
2733
3023
  app.post('/config/match', createConfigMatchHandler({ config, logger }));
2734
- app.post('/config/query', createConfigQueryHandler({
3024
+ app.post('/config/query', withCache(cacheTtlMs, createConfigQueryHandler({
2735
3025
  config,
2736
3026
  valuesManager,
2737
3027
  issuesManager,
@@ -2740,8 +3030,12 @@ function createApiServer(options) {
2740
3030
  getVirtualRules: virtualRuleStore
2741
3031
  ? () => virtualRuleStore.getAll()
2742
3032
  : undefined,
3033
+ })));
3034
+ app.post('/config/validate', createConfigValidateHandler({
3035
+ config,
3036
+ logger,
3037
+ configDir: dirname(configPath),
2743
3038
  }));
2744
- app.post('/config/validate', createConfigValidateHandler({ config, logger }));
2745
3039
  app.post('/config/apply', createConfigApplyHandler({
2746
3040
  config,
2747
3041
  configPath,
@@ -3101,6 +3395,7 @@ const CONFIG_WATCH_DEFAULTS = {
3101
3395
  const API_DEFAULTS = {
3102
3396
  host: '127.0.0.1',
3103
3397
  port: 1936,
3398
+ cacheTtlMs: 30000,
3104
3399
  };
3105
3400
  /** Default logging values. */
3106
3401
  const LOGGING_DEFAULTS = {
@@ -3579,13 +3874,10 @@ const extractorRegistry = new Map([
3579
3874
  * @returns Extracted text and optional structured data.
3580
3875
  */
3581
3876
  async function extractText(filePath, extension, additionalExtractors) {
3582
- // Merge additional extractors with built-in registry
3583
- const registry = new Map(extractorRegistry);
3584
- if (additionalExtractors) {
3585
- for (const [ext, extractor] of additionalExtractors) {
3586
- registry.set(ext, extractor);
3587
- }
3588
- }
3877
+ // Use base registry directly unless additional extractors provided
3878
+ const registry = additionalExtractors
3879
+ ? new Map([...extractorRegistry, ...additionalExtractors])
3880
+ : extractorRegistry;
3589
3881
  const extractor = registry.get(extension.toLowerCase());
3590
3882
  if (extractor)
3591
3883
  return extractor(filePath);
@@ -4671,28 +4963,7 @@ class SystemHealth {
4671
4963
  if (delay <= 0)
4672
4964
  return;
4673
4965
  this.logger.warn({ delayMs: delay, consecutiveFailures: this.consecutiveFailures }, 'Backing off before next attempt');
4674
- await new Promise((resolve, reject) => {
4675
- const timer = setTimeout(() => {
4676
- cleanup();
4677
- resolve();
4678
- }, delay);
4679
- const onAbort = () => {
4680
- cleanup();
4681
- reject(new Error('Backoff aborted'));
4682
- };
4683
- const cleanup = () => {
4684
- clearTimeout(timer);
4685
- if (signal)
4686
- signal.removeEventListener('abort', onAbort);
4687
- };
4688
- if (signal) {
4689
- if (signal.aborted) {
4690
- onAbort();
4691
- return;
4692
- }
4693
- signal.addEventListener('abort', onAbort, { once: true });
4694
- }
4695
- });
4966
+ await sleep(delay, signal);
4696
4967
  }
4697
4968
  /** Current consecutive failure count. */
4698
4969
  get failures() {
@@ -5520,7 +5791,7 @@ class JeevesWatcher {
5520
5791
  });
5521
5792
  await server.listen({
5522
5793
  host: this.config.api?.host ?? '127.0.0.1',
5523
- port: this.config.api?.port ?? 3456,
5794
+ port: this.config.api?.port ?? 1936,
5524
5795
  });
5525
5796
  return server;
5526
5797
  }