@karmaniverous/jeeves-watcher 0.6.8 → 0.7.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.
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from 'node:module';
3
3
  import { Command } from '@commander-js/extra-typings';
4
4
  import { readdir, stat, writeFile, rm, readFile, mkdir } from 'node:fs/promises';
5
- import { resolve, dirname, join, extname, basename, relative } from 'node:path';
5
+ import { resolve, dirname, join, extname, basename, isAbsolute, relative } from 'node:path';
6
6
  import picomatch from 'picomatch';
7
7
  import { mkdirSync, existsSync, readFileSync, writeFileSync, statSync, readdirSync } from 'node:fs';
8
8
  import { z, ZodError } from 'zod';
@@ -20,14 +20,15 @@ import { toMarkdown } from 'mdast-util-to-markdown';
20
20
  import { capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radash';
21
21
  import rehypeParse from 'rehype-parse';
22
22
  import { unified } from 'unified';
23
+ import yaml from 'js-yaml';
23
24
  import { JSONPath } from 'jsonpath-plus';
24
25
  import { createHash } from 'node:crypto';
26
+ import crypto from 'crypto';
25
27
  import { cosmiconfig } from 'cosmiconfig';
26
28
  import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai';
27
29
  import pino from 'pino';
28
30
  import { v5 } from 'uuid';
29
31
  import * as cheerio from 'cheerio';
30
- import yaml from 'js-yaml';
31
32
  import mammoth from 'mammoth';
32
33
  import { MarkdownTextSplitter, RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
33
34
  import { QdrantClient } from '@qdrant/js-client-rest';
@@ -59,8 +60,14 @@ function normalizeError(error) {
59
60
  }
60
61
 
61
62
  /**
62
- * @module util/retry
63
- * Small async retry helper with exponential backoff. Side effects: sleeps between attempts; can invoke onRetry callback for logging.
63
+ * @module util/sleep
64
+ * Abort-aware async sleep.
65
+ */
66
+ /**
67
+ * Sleep for a given duration with optional abort support.
68
+ *
69
+ * @param ms - Duration in milliseconds.
70
+ * @param signal - Optional abort signal.
64
71
  */
65
72
  function sleep(ms, signal) {
66
73
  if (ms <= 0)
@@ -72,7 +79,7 @@ function sleep(ms, signal) {
72
79
  }, ms);
73
80
  const onAbort = () => {
74
81
  cleanup();
75
- reject(new Error('Retry sleep aborted'));
82
+ reject(new Error('Sleep aborted'));
76
83
  };
77
84
  const cleanup = () => {
78
85
  clearTimeout(timer);
@@ -88,6 +95,11 @@ function sleep(ms, signal) {
88
95
  }
89
96
  });
90
97
  }
98
+
99
+ /**
100
+ * @module util/retry
101
+ * Small async retry helper with exponential backoff. Side effects: sleeps between attempts; can invoke onRetry callback for logging.
102
+ */
91
103
  function computeDelayMs(attempt, baseDelayMs, maxDelayMs, jitter = 0) {
92
104
  const exp = Math.max(0, attempt - 1);
93
105
  const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** exp);
@@ -993,6 +1005,146 @@ async function buildTemplateEngine(rules, namedTemplates, templateHelpers, confi
993
1005
  return engine;
994
1006
  }
995
1007
 
1008
+ /**
1009
+ * @module templates/rebaseHeadings
1010
+ * Rebase Markdown heading levels so the minimum heading depth starts at baseHeading+1.
1011
+ */
1012
+ /**
1013
+ * Rebase Markdown headings.
1014
+ *
1015
+ * Finds all ATX headings (lines starting with one or more '#'),
1016
+ * computes the minimum heading level present, and shifts all headings
1017
+ * so that the minimum becomes baseHeading+1.
1018
+ */
1019
+ function rebaseHeadings(markdown, baseHeading) {
1020
+ if (!markdown.trim())
1021
+ return markdown;
1022
+ const lines = markdown.split(/\r?\n/);
1023
+ const headingLevels = [];
1024
+ for (const line of lines) {
1025
+ const m = line.match(/^(#{1,6})\s+/);
1026
+ if (m)
1027
+ headingLevels.push(m[1].length);
1028
+ }
1029
+ if (headingLevels.length === 0)
1030
+ return markdown;
1031
+ const minLevel = Math.min(...headingLevels);
1032
+ const targetMin = Math.min(6, Math.max(1, baseHeading + 1));
1033
+ const delta = targetMin - minLevel;
1034
+ if (delta === 0)
1035
+ return markdown;
1036
+ const rebased = lines.map((line) => {
1037
+ const m = line.match(/^(#{1,6})(\s+.*)$/);
1038
+ if (!m)
1039
+ return line;
1040
+ const level = m[1].length;
1041
+ const newLevel = Math.min(6, Math.max(1, level + delta));
1042
+ return `${'#'.repeat(newLevel)}${m[2]}`;
1043
+ });
1044
+ return rebased.join('\n');
1045
+ }
1046
+
1047
+ /**
1048
+ * @module templates/renderDoc
1049
+ * Declarative renderer for YAML frontmatter + structured Markdown body.
1050
+ */
1051
+ function renderSectionHeading(section, hbs, context = {}) {
1052
+ const level = Math.min(6, Math.max(1, section.heading));
1053
+ const label = section.label
1054
+ ? hbs.compile(section.label)(context)
1055
+ : title(section.path);
1056
+ return `${'#'.repeat(level)} ${label}`;
1057
+ }
1058
+ function callFormatHelper(hbs, helperName, value, args) {
1059
+ const helper = hbs.helpers[helperName];
1060
+ if (typeof helper !== 'function') {
1061
+ throw new Error(`Format helper not found: ${helperName}`);
1062
+ }
1063
+ // Handlebars helpers may return SafeString or other values; coerce to string
1064
+ const safeArgs = args ?? [];
1065
+ const result = helper(value, ...safeArgs);
1066
+ return typeof result === 'string' ? result : String(result);
1067
+ }
1068
+ function renderValueAsMarkdown(hbs, section, value) {
1069
+ if (value === null || value === undefined)
1070
+ return '';
1071
+ if (section.format) {
1072
+ const md = callFormatHelper(hbs, section.format, value, section.formatArgs);
1073
+ return rebaseHeadings(md, section.heading);
1074
+ }
1075
+ const strValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
1076
+ return hbs.Utils.escapeExpression(strValue);
1077
+ }
1078
+ function renderEach(hbs, section, value) {
1079
+ if (!Array.isArray(value))
1080
+ return '';
1081
+ const items = [...value];
1082
+ if (section.sort) {
1083
+ items.sort((a, b) => {
1084
+ const av = get(a, section.sort);
1085
+ const bv = get(b, section.sort);
1086
+ const aStr = typeof av === 'string' ? av : JSON.stringify(av ?? '');
1087
+ const bStr = typeof bv === 'string' ? bv : JSON.stringify(bv ?? '');
1088
+ return aStr.localeCompare(bStr);
1089
+ });
1090
+ }
1091
+ const subHeadingLevel = Math.min(6, section.heading + 1);
1092
+ const headingTpl = section.headingTemplate
1093
+ ? hbs.compile(section.headingTemplate)
1094
+ : undefined;
1095
+ const parts = [];
1096
+ for (const item of items) {
1097
+ const headingText = headingTpl
1098
+ ? headingTpl(item)
1099
+ : '';
1100
+ if (headingText) {
1101
+ parts.push(`${'#'.repeat(subHeadingLevel)} ${headingText}`);
1102
+ }
1103
+ const contentVal = section.contentPath
1104
+ ? get(item, section.contentPath)
1105
+ : item;
1106
+ const md = renderValueAsMarkdown(hbs, { ...section, heading: subHeadingLevel }, contentVal);
1107
+ if (md.trim())
1108
+ parts.push(md.trim());
1109
+ }
1110
+ return parts.join('\n\n');
1111
+ }
1112
+ /**
1113
+ * Render a document according to a RenderConfig.
1114
+ */
1115
+ function renderDoc(context, config, hbs) {
1116
+ const parts = [];
1117
+ // Frontmatter
1118
+ const fmObj = {};
1119
+ for (const key of config.frontmatter) {
1120
+ const v = get(context, key);
1121
+ if (v !== undefined) {
1122
+ fmObj[key] = v;
1123
+ }
1124
+ }
1125
+ if (Object.keys(fmObj).length > 0) {
1126
+ parts.push('---');
1127
+ parts.push(yaml.dump(fmObj, { skipInvalid: true }).trim());
1128
+ parts.push('---');
1129
+ parts.push('');
1130
+ }
1131
+ // Body
1132
+ for (const section of config.body) {
1133
+ const heading = renderSectionHeading(section, hbs, context);
1134
+ parts.push(heading);
1135
+ parts.push('');
1136
+ const v = get(context, section.path);
1137
+ const body = section.each
1138
+ ? renderEach(hbs, section, v)
1139
+ : renderValueAsMarkdown(hbs, section, v);
1140
+ if (body.trim()) {
1141
+ parts.push(body.trim());
1142
+ }
1143
+ parts.push('');
1144
+ }
1145
+ return parts.join('\n').trim() + '\n';
1146
+ }
1147
+
996
1148
  /**
997
1149
  * @module rules/jsonMapLib
998
1150
  * Creates the lib object for JsonMap transformations.
@@ -1454,15 +1606,30 @@ async function applyRules(compiledRules, attributes, options = {}) {
1454
1606
  log.warn(`JsonMap transformation failed: ${normalizeError(error).message}`);
1455
1607
  }
1456
1608
  }
1609
+ // Build template context: attributes (with json spread at top) + map output
1610
+ const context = {
1611
+ ...(attributes.json ?? {}),
1612
+ ...attributes,
1613
+ ...merged,
1614
+ };
1615
+ // Render via renderDoc if present
1616
+ if (rule.render) {
1617
+ try {
1618
+ const result = renderDoc(context, rule.render, hbs);
1619
+ if (result && result.trim()) {
1620
+ renderedContent = result;
1621
+ }
1622
+ else {
1623
+ log.warn(`renderDoc for rule "${rule.name}" rendered empty output. Falling back to raw content.`);
1624
+ }
1625
+ }
1626
+ catch (error) {
1627
+ log.warn(`renderDoc failed for rule "${rule.name}": ${normalizeError(error).message}. Falling back to raw content.`);
1628
+ }
1629
+ }
1457
1630
  // Render template if present
1458
1631
  if (rule.template && templateEngine) {
1459
1632
  const templateKey = rule.name;
1460
- // Build template context: attributes (with json spread at top) + map output
1461
- const context = {
1462
- ...(attributes.json ?? {}),
1463
- ...attributes,
1464
- ...merged,
1465
- };
1466
1633
  try {
1467
1634
  const result = templateEngine.render(templateKey, context);
1468
1635
  if (result && result.trim()) {
@@ -1512,6 +1679,26 @@ function buildAttributes(filePath, stats, extractedFrontmatter, extractedJson) {
1512
1679
  attrs.json = extractedJson;
1513
1680
  return attrs;
1514
1681
  }
1682
+ /**
1683
+ * Build synthetic file attributes from a path string (no actual file I/O).
1684
+ * Used by API handlers that need to match rules against paths without reading files.
1685
+ *
1686
+ * @param filePath - The file path.
1687
+ * @returns Synthetic file attributes with zeroed stats.
1688
+ */
1689
+ function buildSyntheticAttributes(filePath) {
1690
+ const normalised = normalizeSlashes(filePath);
1691
+ return {
1692
+ file: {
1693
+ path: normalised,
1694
+ directory: normalizeSlashes(dirname(normalised)),
1695
+ filename: basename(normalised),
1696
+ extension: extname(normalised),
1697
+ sizeBytes: 0,
1698
+ modified: new Date(0).toISOString(),
1699
+ },
1700
+ };
1701
+ }
1515
1702
 
1516
1703
  /**
1517
1704
  * @module config/schemas/base
@@ -1592,7 +1779,12 @@ const apiConfigSchema = z.object({
1592
1779
  .optional()
1593
1780
  .describe('Host address for API server (e.g., "127.0.0.1", "0.0.0.0").'),
1594
1781
  /** Port to listen on. */
1595
- port: z.number().optional().describe('Port for API server (e.g., 3456).'),
1782
+ port: z.number().optional().describe('Port for API server (e.g., 1936).'),
1783
+ /** Read endpoint cache TTL in milliseconds. */
1784
+ cacheTtlMs: z
1785
+ .number()
1786
+ .optional()
1787
+ .describe('TTL in milliseconds for caching read-heavy endpoints (e.g., /status, /config/query). Default: 30000.'),
1596
1788
  });
1597
1789
  /**
1598
1790
  * Logging configuration.
@@ -1646,10 +1838,61 @@ const schemaReferenceSchema = z.union([
1646
1838
  z.string().describe('Named reference to a global schema.'),
1647
1839
  schemaObjectSchema,
1648
1840
  ]);
1841
+ /** Render body section. */
1842
+ const renderBodySectionSchema = z.object({
1843
+ /** Key path in the template context to render. */
1844
+ path: z.string().min(1).describe('Key path in template context to render.'),
1845
+ /** Markdown heading level for this section (1-6). */
1846
+ heading: z.number().min(1).max(6).describe('Markdown heading level (1-6).'),
1847
+ /** Override heading text (default: titlecased path). */
1848
+ label: z.string().optional().describe('Override heading text.'),
1849
+ /** Name of a registered Handlebars helper used as a format handler. */
1850
+ format: z
1851
+ .string()
1852
+ .optional()
1853
+ .describe('Name of a registered Handlebars helper used as a format handler.'),
1854
+ /** Additional args passed to the format helper. */
1855
+ formatArgs: z
1856
+ .array(z.unknown())
1857
+ .optional()
1858
+ .describe('Additional args passed to the format helper.'),
1859
+ /** If true, the value at path is treated as an array and iterated. */
1860
+ each: z
1861
+ .boolean()
1862
+ .optional()
1863
+ .describe('If true, the value at path is treated as an array and iterated.'),
1864
+ /** Handlebars template string for per-item heading text (used when each=true). */
1865
+ headingTemplate: z
1866
+ .string()
1867
+ .optional()
1868
+ .describe('Handlebars template string for per-item heading text (used when each=true).'),
1869
+ /** Key path within each item to use as renderable content (used when each=true). */
1870
+ contentPath: z
1871
+ .string()
1872
+ .optional()
1873
+ .describe('Key path within each item to use as renderable content (used when each=true).'),
1874
+ /** Key path within each item to sort by (used when each=true). */
1875
+ sort: z
1876
+ .string()
1877
+ .optional()
1878
+ .describe('Key path within each item to sort by (used when each=true).'),
1879
+ });
1880
+ /** Render config: YAML frontmatter + ordered body sections. */
1881
+ const renderConfigSchema = z.object({
1882
+ /** Keys to extract from context and include as YAML frontmatter. */
1883
+ frontmatter: z
1884
+ .array(z.string().min(1))
1885
+ .describe('Keys to extract from context and include as YAML frontmatter.'),
1886
+ /** Ordered markdown body sections. */
1887
+ body: z
1888
+ .array(renderBodySectionSchema)
1889
+ .describe('Ordered markdown body sections.'),
1890
+ });
1649
1891
  /**
1650
1892
  * An inference rule that enriches document metadata.
1651
1893
  */
1652
- const inferenceRuleSchema = z.object({
1894
+ const inferenceRuleSchema = z
1895
+ .object({
1653
1896
  /** Unique name for this inference rule. */
1654
1897
  name: z
1655
1898
  .string()
@@ -1679,6 +1922,19 @@ const inferenceRuleSchema = z.object({
1679
1922
  .string()
1680
1923
  .optional()
1681
1924
  .describe('Handlebars content template (inline string, named ref, or .hbs/.handlebars file path).'),
1925
+ /** Declarative structured renderer configuration (mutually exclusive with template). */
1926
+ render: renderConfigSchema
1927
+ .optional()
1928
+ .describe('Declarative render configuration for frontmatter + structured Markdown output (mutually exclusive with template).'),
1929
+ })
1930
+ .superRefine((val, ctx) => {
1931
+ if (val.render && val.template) {
1932
+ ctx.addIssue({
1933
+ code: 'custom',
1934
+ path: ['render'],
1935
+ message: 'render is mutually exclusive with template',
1936
+ });
1937
+ }
1682
1938
  });
1683
1939
 
1684
1940
  /**
@@ -1993,11 +2249,16 @@ function mergeAndValidateConfig(currentConfig, submittedPartial) {
1993
2249
  function wrapHandler(fn, logger, label) {
1994
2250
  return async (request, reply) => {
1995
2251
  try {
1996
- return await fn(request, reply);
2252
+ const result = await fn(request, reply);
2253
+ if (!reply.sent) {
2254
+ void reply.send(result);
2255
+ }
1997
2256
  }
1998
2257
  catch (error) {
1999
2258
  logger.error({ err: normalizeError(error) }, `${label} failed`);
2000
- return reply.status(500).send({ error: 'Internal server error' });
2259
+ if (!reply.sent) {
2260
+ void reply.status(500).send({ error: 'Internal server error' });
2261
+ }
2001
2262
  }
2002
2263
  };
2003
2264
  }
@@ -2057,17 +2318,7 @@ function createConfigMatchHandler(options) {
2057
2318
  return;
2058
2319
  }
2059
2320
  const matches = body.paths.map((path) => {
2060
- const normalised = normalizeSlashes(path);
2061
- const attrs = {
2062
- file: {
2063
- path: normalised,
2064
- directory: normalizeSlashes(dirname(normalised)),
2065
- filename: basename(normalised),
2066
- extension: extname(normalised),
2067
- sizeBytes: 0,
2068
- modified: new Date(0).toISOString(),
2069
- },
2070
- };
2321
+ const attrs = buildSyntheticAttributes(path);
2071
2322
  // Find matching rules
2072
2323
  const matchingRules = [];
2073
2324
  for (const compiled of compiledRules) {
@@ -2076,6 +2327,7 @@ function createConfigMatchHandler(options) {
2076
2327
  }
2077
2328
  }
2078
2329
  // Check watch scope: matches watch paths and not in ignored
2330
+ const normalised = attrs.file.path;
2079
2331
  const watched = watchMatcher(normalised) && !ignoreMatcher?.(normalised);
2080
2332
  return { rules: matchingRules, watched };
2081
2333
  });
@@ -2227,6 +2479,9 @@ function resolveReferences(doc, resolveTypes) {
2227
2479
  /**
2228
2480
  * Create handler for POST /config/query.
2229
2481
  *
2482
+ * Uses direct error handling (returns 400) rather than wrapHandler (which returns 500),
2483
+ * because invalid JSONPath expressions are client errors, not server errors.
2484
+ *
2230
2485
  * @param deps - Route dependencies.
2231
2486
  */
2232
2487
  function createConfigQueryHandler(deps) {
@@ -2342,7 +2597,7 @@ function createConfigSchemaHandler() {
2342
2597
  /**
2343
2598
  * Validate helper file references (mapHelpers, templateHelpers).
2344
2599
  */
2345
- function validateHelperFiles(config) {
2600
+ function validateHelperFiles(config, configDir) {
2346
2601
  const errors = [];
2347
2602
  for (const section of ['mapHelpers', 'templateHelpers']) {
2348
2603
  const helpers = config[section];
@@ -2351,15 +2606,18 @@ function validateHelperFiles(config) {
2351
2606
  for (const [name, helper] of Object.entries(helpers)) {
2352
2607
  if (!helper.path)
2353
2608
  continue;
2354
- if (!existsSync(helper.path)) {
2609
+ const resolvedPath = isAbsolute(helper.path)
2610
+ ? helper.path
2611
+ : resolve(configDir, helper.path);
2612
+ if (!existsSync(resolvedPath)) {
2355
2613
  errors.push({
2356
2614
  path: `${section}.${name}.path`,
2357
- message: `File not found: ${helper.path}`,
2615
+ message: `File not found: ${resolvedPath}`,
2358
2616
  });
2359
2617
  continue;
2360
2618
  }
2361
2619
  try {
2362
- readFileSync(helper.path, 'utf-8');
2620
+ readFileSync(resolvedPath, 'utf-8');
2363
2621
  }
2364
2622
  catch (err) {
2365
2623
  errors.push({
@@ -2412,7 +2670,7 @@ function createConfigValidateHandler(deps) {
2412
2670
  if (errors.length > 0) {
2413
2671
  return { valid: false, errors };
2414
2672
  }
2415
- const helperErrors = validateHelperFiles(candidateRaw);
2673
+ const helperErrors = validateHelperFiles(candidateRaw, deps.configDir);
2416
2674
  if (helperErrors.length > 0) {
2417
2675
  return { valid: false, errors: helperErrors };
2418
2676
  }
@@ -2516,17 +2774,7 @@ function getValueType(value) {
2516
2774
  */
2517
2775
  function validateMetadataPayload(config, path, metadata) {
2518
2776
  const compiled = compileRules(config.inferenceRules ?? []);
2519
- const normalised = normalizeSlashes(path);
2520
- const attrs = {
2521
- file: {
2522
- path: normalised,
2523
- directory: normalizeSlashes(dirname(normalised)),
2524
- filename: basename(normalised),
2525
- extension: extname(normalised),
2526
- sizeBytes: 0,
2527
- modified: new Date(0).toISOString(),
2528
- },
2529
- };
2777
+ const attrs = buildSyntheticAttributes(path);
2530
2778
  const matched = compiled.filter((r) => r.validate(attrs));
2531
2779
  const matchedNames = matched.map((m) => m.rule.name);
2532
2780
  const schemaRefs = matched.flatMap((m) => m.rule.schema ?? []);
@@ -2800,7 +3048,6 @@ function createReindexHandler(deps) {
2800
3048
  */
2801
3049
  function createRulesReapplyHandler(deps) {
2802
3050
  return wrapHandler(async (request) => {
2803
- await Promise.resolve();
2804
3051
  const { globs } = request.body;
2805
3052
  if (!Array.isArray(globs) || globs.length === 0) {
2806
3053
  throw new Error('Missing required field: globs (non-empty string array)');
@@ -2843,7 +3090,6 @@ function createRulesReapplyHandler(deps) {
2843
3090
  */
2844
3091
  function createRulesRegisterHandler(deps) {
2845
3092
  return wrapHandler(async (request) => {
2846
- await Promise.resolve();
2847
3093
  const { source, rules } = request.body;
2848
3094
  if (!source || typeof source !== 'string') {
2849
3095
  throw new Error('Missing required field: source');
@@ -2854,11 +3100,11 @@ function createRulesRegisterHandler(deps) {
2854
3100
  deps.virtualRuleStore.register(source, rules);
2855
3101
  deps.onRulesChanged();
2856
3102
  deps.logger.info({ source, ruleCount: rules.length }, 'Virtual rules registered');
2857
- return {
3103
+ return await Promise.resolve({
2858
3104
  source,
2859
3105
  registered: rules.length,
2860
3106
  totalVirtualRules: deps.virtualRuleStore.size,
2861
- };
3107
+ });
2862
3108
  }, deps.logger, 'RulesRegister');
2863
3109
  }
2864
3110
 
@@ -2866,21 +3112,25 @@ function createRulesRegisterHandler(deps) {
2866
3112
  * @module api/handlers/rulesUnregister
2867
3113
  * Fastify route handler for DELETE /rules/unregister.
2868
3114
  */
3115
+ /**
3116
+ * Core unregister logic shared by body and param handlers.
3117
+ */
3118
+ function unregisterSource(deps, source) {
3119
+ if (!source || typeof source !== 'string') {
3120
+ throw new Error('Missing required field: source');
3121
+ }
3122
+ const removed = deps.virtualRuleStore.unregister(source);
3123
+ if (removed)
3124
+ deps.onRulesChanged();
3125
+ deps.logger.info({ source, removed }, 'Virtual rules unregister');
3126
+ return { source, removed };
3127
+ }
2869
3128
  /**
2870
3129
  * Create handler for DELETE /rules/unregister (body-based).
2871
3130
  */
2872
3131
  function createRulesUnregisterHandler(deps) {
2873
3132
  return wrapHandler(async (request) => {
2874
- await Promise.resolve();
2875
- const { source } = request.body;
2876
- if (!source || typeof source !== 'string') {
2877
- throw new Error('Missing required field: source');
2878
- }
2879
- const removed = deps.virtualRuleStore.unregister(source);
2880
- if (removed)
2881
- deps.onRulesChanged();
2882
- deps.logger.info({ source, removed }, 'Virtual rules unregister');
2883
- return { source, removed };
3133
+ return Promise.resolve(unregisterSource(deps, request.body.source));
2884
3134
  }, deps.logger, 'RulesUnregister');
2885
3135
  }
2886
3136
  /**
@@ -2888,16 +3138,7 @@ function createRulesUnregisterHandler(deps) {
2888
3138
  */
2889
3139
  function createRulesUnregisterParamHandler(deps) {
2890
3140
  return wrapHandler(async (request) => {
2891
- await Promise.resolve();
2892
- const { source } = request.params;
2893
- if (!source || typeof source !== 'string') {
2894
- throw new Error('Missing required param: source');
2895
- }
2896
- const removed = deps.virtualRuleStore.unregister(source);
2897
- if (removed)
2898
- deps.onRulesChanged();
2899
- deps.logger.info({ source, removed }, 'Virtual rules unregister');
2900
- return { source, removed };
3141
+ return Promise.resolve(unregisterSource(deps, request.params.source));
2901
3142
  }, deps.logger, 'RulesUnregister');
2902
3143
  }
2903
3144
 
@@ -2946,6 +3187,55 @@ function createStatusHandler(deps) {
2946
3187
  };
2947
3188
  }
2948
3189
 
3190
+ /**
3191
+ * In-memory response cache
3192
+ */
3193
+ const cache = new Map();
3194
+ /**
3195
+ * Generates a deterministic hash for an object
3196
+ */
3197
+ function hashObject(obj) {
3198
+ if (obj === undefined)
3199
+ return 'undefined';
3200
+ if (obj === null)
3201
+ return 'null';
3202
+ const str = typeof obj === 'string' ? obj : JSON.stringify(obj);
3203
+ return crypto.createHash('sha256').update(str).digest('hex');
3204
+ }
3205
+ /**
3206
+ * Higher-order function to wrap Fastify route handlers with an in-memory TTL cache.
3207
+ * Uses request method, URL, and body hash as the cache key.
3208
+ *
3209
+ * @param ttlMs - Time to live in milliseconds
3210
+ * @param handler - The original route handler
3211
+ * @returns A new route handler that implements caching
3212
+ */
3213
+ function withCache(ttlMs, handler) {
3214
+ return async (req, reply) => {
3215
+ // Generate deterministic cache key: METHOD:URL:BODY_HASH
3216
+ const bodyHash = hashObject(req.body);
3217
+ const key = `${req.method}:${req.url}:${bodyHash}`;
3218
+ // Check cache
3219
+ const now = Date.now();
3220
+ const entry = cache.get(key);
3221
+ if (entry && entry.expiresAt > now) {
3222
+ return entry.value;
3223
+ }
3224
+ // Cache miss - call handler
3225
+ const result = await handler(req, reply);
3226
+ // Don't cache errors (Fastify reply properties might indicate error)
3227
+ if (reply.statusCode >= 400) {
3228
+ return result;
3229
+ }
3230
+ // Store in cache
3231
+ cache.set(key, {
3232
+ value: result,
3233
+ expiresAt: now + ttlMs,
3234
+ });
3235
+ return result;
3236
+ };
3237
+ }
3238
+
2949
3239
  /**
2950
3240
  * @module api/ReindexTracker
2951
3241
  * Tracks reindex operation state for status reporting. Single instance shared across handlers.
@@ -3007,11 +3297,12 @@ function createApiServer(options) {
3007
3297
  issuesManager,
3008
3298
  }, scope);
3009
3299
  };
3010
- app.get('/status', createStatusHandler({
3300
+ const cacheTtlMs = config.api?.cacheTtlMs ?? 30000;
3301
+ app.get('/status', withCache(cacheTtlMs, createStatusHandler({
3011
3302
  vectorStore,
3012
3303
  collectionName: config.vectorStore.collectionName,
3013
3304
  reindexTracker,
3014
- }));
3305
+ })));
3015
3306
  app.post('/metadata', createMetadataHandler({ processor, config, logger }));
3016
3307
  const hybridConfig = config.search?.hybrid
3017
3308
  ? {
@@ -3032,10 +3323,10 @@ function createApiServer(options) {
3032
3323
  logger,
3033
3324
  }));
3034
3325
  app.post('/config-reindex', createConfigReindexHandler({ config, processor, logger, reindexTracker }));
3035
- app.get('/issues', createIssuesHandler({ issuesManager }));
3036
- app.get('/config/schema', createConfigSchemaHandler());
3326
+ app.get('/issues', withCache(cacheTtlMs, createIssuesHandler({ issuesManager })));
3327
+ app.get('/config/schema', withCache(cacheTtlMs, createConfigSchemaHandler()));
3037
3328
  app.post('/config/match', createConfigMatchHandler({ config, logger }));
3038
- app.post('/config/query', createConfigQueryHandler({
3329
+ app.post('/config/query', withCache(cacheTtlMs, createConfigQueryHandler({
3039
3330
  config,
3040
3331
  valuesManager,
3041
3332
  issuesManager,
@@ -3044,8 +3335,12 @@ function createApiServer(options) {
3044
3335
  getVirtualRules: virtualRuleStore
3045
3336
  ? () => virtualRuleStore.getAll()
3046
3337
  : undefined,
3338
+ })));
3339
+ app.post('/config/validate', createConfigValidateHandler({
3340
+ config,
3341
+ logger,
3342
+ configDir: dirname(configPath),
3047
3343
  }));
3048
- app.post('/config/validate', createConfigValidateHandler({ config, logger }));
3049
3344
  app.post('/config/apply', createConfigApplyHandler({
3050
3345
  config,
3051
3346
  configPath,
@@ -3098,7 +3393,8 @@ const CONFIG_WATCH_DEFAULTS = {
3098
3393
  /** Default API values. */
3099
3394
  const API_DEFAULTS = {
3100
3395
  host: '127.0.0.1',
3101
- port: 3456,
3396
+ port: 1936,
3397
+ cacheTtlMs: 30000,
3102
3398
  };
3103
3399
  /** Default logging values. */
3104
3400
  const LOGGING_DEFAULTS = {
@@ -3598,8 +3894,8 @@ const extractorRegistry = new Map([
3598
3894
  * @returns Extracted text and optional structured data.
3599
3895
  */
3600
3896
  async function extractText(filePath, extension, additionalExtractors) {
3601
- // Merge additional extractors with built-in registry
3602
- const registry = new Map(extractorRegistry);
3897
+ // Use base registry directly unless additional extractors provided
3898
+ const registry = extractorRegistry;
3603
3899
  const extractor = registry.get(extension.toLowerCase());
3604
3900
  if (extractor)
3605
3901
  return extractor(filePath);
@@ -4685,28 +4981,7 @@ class SystemHealth {
4685
4981
  if (delay <= 0)
4686
4982
  return;
4687
4983
  this.logger.warn({ delayMs: delay, consecutiveFailures: this.consecutiveFailures }, 'Backing off before next attempt');
4688
- await new Promise((resolve, reject) => {
4689
- const timer = setTimeout(() => {
4690
- cleanup();
4691
- resolve();
4692
- }, delay);
4693
- const onAbort = () => {
4694
- cleanup();
4695
- reject(new Error('Backoff aborted'));
4696
- };
4697
- const cleanup = () => {
4698
- clearTimeout(timer);
4699
- if (signal)
4700
- signal.removeEventListener('abort', onAbort);
4701
- };
4702
- if (signal) {
4703
- if (signal.aborted) {
4704
- onAbort();
4705
- return;
4706
- }
4707
- signal.addEventListener('abort', onAbort, { once: true });
4708
- }
4709
- });
4984
+ await sleep(delay, signal);
4710
4985
  }
4711
4986
  /** Current consecutive failure count. */
4712
4987
  get failures() {
@@ -5534,7 +5809,7 @@ class JeevesWatcher {
5534
5809
  });
5535
5810
  await server.listen({
5536
5811
  host: this.config.api?.host ?? '127.0.0.1',
5537
- port: this.config.api?.port ?? 3456,
5812
+ port: this.config.api?.port ?? 1936,
5538
5813
  });
5539
5814
  return server;
5540
5815
  }
@@ -5684,7 +5959,7 @@ async function runApiCommand(options) {
5684
5959
  /** Default API host for CLI commands. */
5685
5960
  const DEFAULT_HOST = '127.0.0.1';
5686
5961
  /** Default API port for CLI commands. */
5687
- const DEFAULT_PORT = '3456';
5962
+ const DEFAULT_PORT = '1936';
5688
5963
 
5689
5964
  /**
5690
5965
  * @module cli/jeeves-watcher/withApiOptions
@@ -6102,7 +6377,7 @@ cli
6102
6377
  console.log(` Watch paths: ${config.watch.paths.join(', ')}`);
6103
6378
  console.log(` Embedding: ${config.embedding.provider}/${config.embedding.model}`);
6104
6379
  console.log(` Vector store: ${config.vectorStore.url} (${config.vectorStore.collectionName})`);
6105
- console.log(` API: ${config.api?.host ?? '127.0.0.1'}:${String(config.api?.port ?? 3456)}`);
6380
+ console.log(` API: ${config.api?.host ?? '127.0.0.1'}:${String(config.api?.port ?? 1936)}`);
6106
6381
  }
6107
6382
  catch (error) {
6108
6383
  console.error('Config invalid:', error);