@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/config.schema.json +243 -78
- package/dist/cli/jeeves-watcher/index.js +370 -96
- package/dist/index.d.ts +30 -0
- package/dist/index.js +371 -100
- package/package.json +1 -1
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/
|
|
965
|
-
*
|
|
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('
|
|
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.,
|
|
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
|
|
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
|
|
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
|
-
|
|
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: ${
|
|
2308
|
+
message: `File not found: ${resolvedPath}`,
|
|
2054
2309
|
});
|
|
2055
2310
|
continue;
|
|
2056
2311
|
}
|
|
2057
2312
|
try {
|
|
2058
|
-
readFileSync(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3583
|
-
const registry =
|
|
3584
|
-
|
|
3585
|
-
|
|
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
|
|
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 ??
|
|
5794
|
+
port: this.config.api?.port ?? 1936,
|
|
5524
5795
|
});
|
|
5525
5796
|
return server;
|
|
5526
5797
|
}
|