@karmaniverous/jeeves-watcher 0.7.1 → 0.8.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.
@@ -149,7 +149,7 @@
149
149
  "description": "Reusable named JsonMap transformations (inline definition or .json file path resolved relative to config directory).",
150
150
  "allOf": [
151
151
  {
152
- "$ref": "#/definitions/__schema75"
152
+ "$ref": "#/definitions/__schema77"
153
153
  }
154
154
  ]
155
155
  },
@@ -157,7 +157,7 @@
157
157
  "description": "Named reusable Handlebars templates (inline strings or .hbs/.handlebars file paths).",
158
158
  "allOf": [
159
159
  {
160
- "$ref": "#/definitions/__schema76"
160
+ "$ref": "#/definitions/__schema78"
161
161
  }
162
162
  ]
163
163
  },
@@ -165,7 +165,7 @@
165
165
  "description": "Custom Handlebars helper registration.",
166
166
  "allOf": [
167
167
  {
168
- "$ref": "#/definitions/__schema77"
168
+ "$ref": "#/definitions/__schema79"
169
169
  }
170
170
  ]
171
171
  },
@@ -173,7 +173,7 @@
173
173
  "description": "Custom JsonMap lib function registration.",
174
174
  "allOf": [
175
175
  {
176
- "$ref": "#/definitions/__schema78"
176
+ "$ref": "#/definitions/__schema80"
177
177
  }
178
178
  ]
179
179
  },
@@ -181,7 +181,7 @@
181
181
  "description": "Reindex configuration.",
182
182
  "allOf": [
183
183
  {
184
- "$ref": "#/definitions/__schema79"
184
+ "$ref": "#/definitions/__schema81"
185
185
  }
186
186
  ]
187
187
  },
@@ -189,7 +189,7 @@
189
189
  "description": "Named Qdrant filter patterns for skill-activated behaviors.",
190
190
  "allOf": [
191
191
  {
192
- "$ref": "#/definitions/__schema80"
192
+ "$ref": "#/definitions/__schema82"
193
193
  }
194
194
  ]
195
195
  },
@@ -197,7 +197,7 @@
197
197
  "description": "Search configuration including score thresholds and hybrid search.",
198
198
  "allOf": [
199
199
  {
200
- "$ref": "#/definitions/__schema81"
200
+ "$ref": "#/definitions/__schema83"
201
201
  }
202
202
  ]
203
203
  },
@@ -205,7 +205,7 @@
205
205
  "description": "Logging configuration.",
206
206
  "allOf": [
207
207
  {
208
- "$ref": "#/definitions/__schema82"
208
+ "$ref": "#/definitions/__schema84"
209
209
  }
210
210
  ]
211
211
  },
@@ -213,7 +213,7 @@
213
213
  "description": "Timeout in milliseconds for graceful shutdown.",
214
214
  "allOf": [
215
215
  {
216
- "$ref": "#/definitions/__schema85"
216
+ "$ref": "#/definitions/__schema87"
217
217
  }
218
218
  ]
219
219
  },
@@ -221,7 +221,7 @@
221
221
  "description": "Maximum consecutive system-level failures before triggering fatal error. Default: Infinity.",
222
222
  "allOf": [
223
223
  {
224
- "$ref": "#/definitions/__schema86"
224
+ "$ref": "#/definitions/__schema88"
225
225
  }
226
226
  ]
227
227
  },
@@ -229,7 +229,7 @@
229
229
  "description": "Maximum backoff delay in milliseconds for system errors. Default: 60000.",
230
230
  "allOf": [
231
231
  {
232
- "$ref": "#/definitions/__schema87"
232
+ "$ref": "#/definitions/__schema89"
233
233
  }
234
234
  ]
235
235
  }
@@ -614,6 +614,9 @@
614
614
  },
615
615
  "render": {
616
616
  "$ref": "#/definitions/__schema64"
617
+ },
618
+ "renderAs": {
619
+ "$ref": "#/definitions/__schema75"
617
620
  }
618
621
  },
619
622
  "required": [
@@ -922,6 +925,18 @@
922
925
  "type": "string"
923
926
  },
924
927
  "__schema75": {
928
+ "description": "Output file extension override (without dot). Requires template or render.",
929
+ "allOf": [
930
+ {
931
+ "$ref": "#/definitions/__schema76"
932
+ }
933
+ ]
934
+ },
935
+ "__schema76": {
936
+ "type": "string",
937
+ "pattern": "^[a-z0-9]{1,10}$"
938
+ },
939
+ "__schema77": {
925
940
  "type": "object",
926
941
  "propertyNames": {
927
942
  "type": "string"
@@ -958,7 +973,7 @@
958
973
  ]
959
974
  }
960
975
  },
961
- "__schema76": {
976
+ "__schema78": {
962
977
  "type": "object",
963
978
  "propertyNames": {
964
979
  "type": "string"
@@ -985,7 +1000,7 @@
985
1000
  ]
986
1001
  }
987
1002
  },
988
- "__schema77": {
1003
+ "__schema79": {
989
1004
  "type": "object",
990
1005
  "propertyNames": {
991
1006
  "type": "string"
@@ -1005,7 +1020,7 @@
1005
1020
  ]
1006
1021
  }
1007
1022
  },
1008
- "__schema78": {
1023
+ "__schema80": {
1009
1024
  "type": "object",
1010
1025
  "propertyNames": {
1011
1026
  "type": "string"
@@ -1025,7 +1040,7 @@
1025
1040
  ]
1026
1041
  }
1027
1042
  },
1028
- "__schema79": {
1043
+ "__schema81": {
1029
1044
  "type": "object",
1030
1045
  "properties": {
1031
1046
  "callbackUrl": {
@@ -1034,14 +1049,14 @@
1034
1049
  }
1035
1050
  }
1036
1051
  },
1037
- "__schema80": {
1052
+ "__schema82": {
1038
1053
  "type": "object",
1039
1054
  "propertyNames": {
1040
1055
  "type": "string"
1041
1056
  },
1042
1057
  "additionalProperties": {}
1043
1058
  },
1044
- "__schema81": {
1059
+ "__schema83": {
1045
1060
  "type": "object",
1046
1061
  "properties": {
1047
1062
  "scoreThresholds": {
@@ -1086,14 +1101,14 @@
1086
1101
  }
1087
1102
  }
1088
1103
  },
1089
- "__schema82": {
1104
+ "__schema84": {
1090
1105
  "type": "object",
1091
1106
  "properties": {
1092
1107
  "level": {
1093
1108
  "description": "Logging level (trace, debug, info, warn, error, fatal).",
1094
1109
  "allOf": [
1095
1110
  {
1096
- "$ref": "#/definitions/__schema83"
1111
+ "$ref": "#/definitions/__schema85"
1097
1112
  }
1098
1113
  ]
1099
1114
  },
@@ -1101,25 +1116,25 @@
1101
1116
  "description": "Path to log file (logs to stdout if omitted).",
1102
1117
  "allOf": [
1103
1118
  {
1104
- "$ref": "#/definitions/__schema84"
1119
+ "$ref": "#/definitions/__schema86"
1105
1120
  }
1106
1121
  ]
1107
1122
  }
1108
1123
  }
1109
1124
  },
1110
- "__schema83": {
1125
+ "__schema85": {
1111
1126
  "type": "string"
1112
1127
  },
1113
- "__schema84": {
1128
+ "__schema86": {
1114
1129
  "type": "string"
1115
1130
  },
1116
- "__schema85": {
1131
+ "__schema87": {
1117
1132
  "type": "number"
1118
1133
  },
1119
- "__schema86": {
1134
+ "__schema88": {
1120
1135
  "type": "number"
1121
1136
  },
1122
- "__schema87": {
1137
+ "__schema89": {
1123
1138
  "type": "number"
1124
1139
  }
1125
1140
  }
@@ -1537,11 +1537,16 @@ async function applyRules(compiledRules, attributes, options = {}) {
1537
1537
  const lib = createJsonMapLib(configDir, customMapLib);
1538
1538
  let merged = {};
1539
1539
  let renderedContent = null;
1540
+ let renderAs = null;
1540
1541
  const matchedRules = [];
1541
1542
  const log = logger ?? console;
1542
1543
  for (const [, { rule, validate }] of compiledRules.entries()) {
1543
1544
  if (validate(attributes)) {
1544
1545
  matchedRules.push(rule.name);
1546
+ // Resolve renderAs (last-match-wins)
1547
+ if (rule.renderAs) {
1548
+ renderAs = rule.renderAs;
1549
+ }
1545
1550
  // Apply schema-based metadata extraction
1546
1551
  if (rule.schema && rule.schema.length > 0) {
1547
1552
  try {
@@ -1645,7 +1650,7 @@ async function applyRules(compiledRules, attributes, options = {}) {
1645
1650
  }
1646
1651
  }
1647
1652
  }
1648
- return { metadata: merged, renderedContent, matchedRules };
1653
+ return { metadata: merged, renderedContent, matchedRules, renderAs };
1649
1654
  }
1650
1655
 
1651
1656
  /**
@@ -1926,6 +1931,12 @@ const inferenceRuleSchema = z
1926
1931
  render: renderConfigSchema
1927
1932
  .optional()
1928
1933
  .describe('Declarative render configuration for frontmatter + structured Markdown output (mutually exclusive with template).'),
1934
+ /** Output file extension override (e.g. "md", "html", "txt"). Requires template or render. */
1935
+ renderAs: z
1936
+ .string()
1937
+ .regex(/^[a-z0-9]{1,10}$/, 'renderAs must be 1-10 lowercase alphanumeric characters')
1938
+ .optional()
1939
+ .describe('Output file extension override (without dot). Requires template or render.'),
1929
1940
  })
1930
1941
  .superRefine((val, ctx) => {
1931
1942
  if (val.render && val.template) {
@@ -1935,6 +1946,13 @@ const inferenceRuleSchema = z
1935
1946
  message: 'render is mutually exclusive with template',
1936
1947
  });
1937
1948
  }
1949
+ if (val.renderAs && !val.template && !val.render) {
1950
+ ctx.addIssue({
1951
+ code: 'custom',
1952
+ path: ['renderAs'],
1953
+ message: 'renderAs requires template or render',
1954
+ });
1955
+ }
1938
1956
  });
1939
1957
 
1940
1958
  /**
@@ -2711,6 +2729,107 @@ function createConfigValidateHandler(deps) {
2711
2729
  }, deps.logger, 'Config validate');
2712
2730
  }
2713
2731
 
2732
+ /**
2733
+ * @module api/handlers/facets
2734
+ * GET /search/facets route handler. Returns schema-derived facet definitions with live values.
2735
+ */
2736
+ /** Compute a simple hash of rule names + schema refs for cache invalidation. */
2737
+ function computeRulesHash(rules) {
2738
+ if (!rules)
2739
+ return '';
2740
+ return rules
2741
+ .map((r) => `${r.name}:${JSON.stringify(r.schema ?? [])}`)
2742
+ .join('|');
2743
+ }
2744
+ /**
2745
+ * Check whether a resolved property should be exposed as a facet.
2746
+ * A property is facetable if it declares `uiHint` or `enum`.
2747
+ */
2748
+ function isFacetable(prop) {
2749
+ return prop.uiHint !== undefined || prop.enum !== undefined;
2750
+ }
2751
+ /**
2752
+ * Build the schema-derived facet structure from inference rules.
2753
+ *
2754
+ * Iterates all rules, resolves their schemas via `mergeSchemas`, and extracts
2755
+ * properties that have `uiHint` or `enum` defined. Deduplicates across rules.
2756
+ */
2757
+ function buildFacetSchema(rules, mergeOptions) {
2758
+ const fields = new Map();
2759
+ for (const rule of rules ?? []) {
2760
+ if (!rule.schema?.length)
2761
+ continue;
2762
+ const resolved = mergeSchemas(rule.schema, mergeOptions);
2763
+ for (const [propName, propDef] of Object.entries(resolved.properties)) {
2764
+ if (!isFacetable(propDef))
2765
+ continue;
2766
+ const existing = fields.get(propName);
2767
+ if (existing) {
2768
+ existing.rules.push(rule.name);
2769
+ if (propDef.enum)
2770
+ existing.enumValues = propDef.enum;
2771
+ if (propDef.uiHint)
2772
+ existing.uiHint = propDef.uiHint;
2773
+ }
2774
+ else {
2775
+ fields.set(propName, {
2776
+ type: propDef.type ?? 'string',
2777
+ uiHint: propDef.uiHint ?? 'dropdown',
2778
+ enumValues: propDef.enum,
2779
+ rules: [rule.name],
2780
+ });
2781
+ }
2782
+ }
2783
+ }
2784
+ return { fields, rulesHash: computeRulesHash(rules) };
2785
+ }
2786
+ /**
2787
+ * Create the GET /search/facets route handler.
2788
+ *
2789
+ * Returns facet definitions derived from inference rule schemas, enriched
2790
+ * with live values from the ValuesManager.
2791
+ *
2792
+ * @param deps - Handler dependencies.
2793
+ * @returns Fastify route handler (plain return, compatible with `withCache`).
2794
+ */
2795
+ function createFacetsHandler(deps) {
2796
+ const { config, valuesManager, configDir } = deps;
2797
+ let cached;
2798
+ const mergeOptions = {
2799
+ globalSchemas: config.schemas,
2800
+ configDir,
2801
+ };
2802
+ return () => {
2803
+ // Rebuild schema cache if rules changed
2804
+ const currentHash = computeRulesHash(config.inferenceRules);
2805
+ if (!cached || cached.rulesHash !== currentHash) {
2806
+ cached = buildFacetSchema(config.inferenceRules, mergeOptions);
2807
+ }
2808
+ // Merge with live values
2809
+ const allValues = valuesManager.getAll();
2810
+ const facets = [];
2811
+ for (const [field, schema] of cached.fields) {
2812
+ // Collect live values from all rules that define this field
2813
+ const liveValues = new Set();
2814
+ for (const ruleName of schema.rules) {
2815
+ const fieldValues = allValues[ruleName]?.[field];
2816
+ if (fieldValues) {
2817
+ for (const v of fieldValues)
2818
+ liveValues.add(v);
2819
+ }
2820
+ }
2821
+ facets.push({
2822
+ field,
2823
+ type: schema.type,
2824
+ uiHint: schema.uiHint,
2825
+ values: schema.enumValues ?? [...liveValues].sort(),
2826
+ rules: schema.rules,
2827
+ });
2828
+ }
2829
+ return { facets };
2830
+ };
2831
+ }
2832
+
2714
2833
  /**
2715
2834
  * @module api/handlers/issues
2716
2835
  * Fastify route handler for GET /issues. Returns current processing issues.
@@ -3037,6 +3156,79 @@ function createReindexHandler(deps) {
3037
3156
  }, deps.logger, 'Reindex');
3038
3157
  }
3039
3158
 
3159
+ /**
3160
+ * @module util/isPathWatched
3161
+ * Checks whether a file path falls within the watched scope defined by watch config globs.
3162
+ */
3163
+ /**
3164
+ * Check whether a file path matches the watched scope.
3165
+ * A path is watched if it matches at least one `paths` glob
3166
+ * AND does not match any `ignored` glob. Mirrors chokidar's
3167
+ * inclusion/exclusion logic.
3168
+ *
3169
+ * @param filePath - The file path to check.
3170
+ * @param watchPaths - Glob patterns that define watched paths.
3171
+ * @param ignoredPaths - Glob patterns that exclude paths (optional).
3172
+ * @returns `true` if the file is within watched scope.
3173
+ */
3174
+ function isPathWatched(filePath, watchPaths, ignoredPaths) {
3175
+ const normalised = normalizeSlashes(filePath);
3176
+ const isIncluded = picomatch(watchPaths, { dot: true });
3177
+ if (!isIncluded(normalised))
3178
+ return false;
3179
+ if (ignoredPaths && ignoredPaths.length > 0) {
3180
+ const isExcluded = picomatch(ignoredPaths, { dot: true });
3181
+ if (isExcluded(normalised))
3182
+ return false;
3183
+ }
3184
+ return true;
3185
+ }
3186
+
3187
+ /**
3188
+ * @module api/handlers/render
3189
+ * POST /render route handler. Runs a file through the inference rule engine and returns rendered content.
3190
+ */
3191
+ /**
3192
+ * Create the POST /render route handler.
3193
+ *
3194
+ * @param deps - Handler dependencies.
3195
+ * @returns Fastify route handler.
3196
+ */
3197
+ function createRenderHandler(deps) {
3198
+ const { processor, watch, logger } = deps;
3199
+ return async (request, reply) => {
3200
+ const { path: filePath } = request.body;
3201
+ if (!filePath || typeof filePath !== 'string') {
3202
+ return reply.status(400).send({ error: 'Missing required field: path' });
3203
+ }
3204
+ // Validate path is within watched scope
3205
+ if (!isPathWatched(filePath, watch.paths, watch.ignored)) {
3206
+ return reply.status(403).send({ error: 'Path is outside watched scope' });
3207
+ }
3208
+ try {
3209
+ const result = await processor.renderFile(filePath);
3210
+ // Passthrough responses should not be cached
3211
+ if (!result.transformed) {
3212
+ void reply.header('Cache-Control', 'no-cache');
3213
+ }
3214
+ return await reply.send({
3215
+ renderAs: result.renderAs,
3216
+ content: result.content,
3217
+ rules: result.rules,
3218
+ metadata: result.metadata,
3219
+ });
3220
+ }
3221
+ catch (error) {
3222
+ const msg = error instanceof Error ? error.message : 'Unknown render error';
3223
+ if (msg.includes('ENOENT') || msg.includes('no such file')) {
3224
+ return reply.status(404).send({ error: 'File not found' });
3225
+ }
3226
+ logger.error({ filePath, error: msg }, 'Render failed');
3227
+ return reply.status(422).send({ error: msg });
3228
+ }
3229
+ };
3230
+ }
3231
+
3040
3232
  /**
3041
3233
  * @module api/handlers/rulesReapply
3042
3234
  * Fastify route handler for POST /rules/reapply.
@@ -3231,6 +3423,11 @@ function withCache(ttlMs, handler) {
3231
3423
  if (fReply.statusCode >= 400) {
3232
3424
  return result;
3233
3425
  }
3426
+ // Skip cache for responses with Cache-Control: no-cache
3427
+ const cacheControl = fReply.getHeader('Cache-Control');
3428
+ if (cacheControl === 'no-cache') {
3429
+ return result;
3430
+ }
3234
3431
  // Store in cache
3235
3432
  cache.set(key, {
3236
3433
  value: result,
@@ -3308,6 +3505,12 @@ function createApiServer(options) {
3308
3505
  reindexTracker,
3309
3506
  })));
3310
3507
  app.post('/metadata', createMetadataHandler({ processor, config, logger }));
3508
+ app.post('/render', withCache(cacheTtlMs, createRenderHandler({ processor, watch: config.watch, logger })));
3509
+ app.get('/search/facets', createFacetsHandler({
3510
+ config,
3511
+ valuesManager,
3512
+ configDir: dirname(configPath),
3513
+ }));
3311
3514
  const hybridConfig = config.search?.hybrid
3312
3515
  ? {
3313
3516
  enabled: config.search.hybrid.enabled,
@@ -3925,7 +4128,7 @@ async function buildMergedMetadata(options) {
3925
4128
  const extracted = await extractText(filePath, ext);
3926
4129
  // 2. Build attributes + apply rules
3927
4130
  const attributes = buildAttributes(filePath, stats, extracted.frontmatter, extracted.json);
3928
- const { metadata: inferred, renderedContent, matchedRules, } = await applyRules(compiledRules, attributes, {
4131
+ const { metadata: inferred, renderedContent, matchedRules, renderAs, } = await applyRules(compiledRules, attributes, {
3929
4132
  namedMaps: maps,
3930
4133
  logger,
3931
4134
  templateEngine,
@@ -3947,6 +4150,7 @@ async function buildMergedMetadata(options) {
3947
4150
  extracted,
3948
4151
  renderedContent,
3949
4152
  matchedRules,
4153
+ renderAs,
3950
4154
  };
3951
4155
  }
3952
4156
 
@@ -4162,7 +4366,7 @@ class DocumentProcessor {
4162
4366
  ...result.metadata,
4163
4367
  matched_rules: result.matchedRules,
4164
4368
  };
4165
- return { ...result, metadataWithRules };
4369
+ return { ...result, metadataWithRules, renderAs: result.renderAs };
4166
4370
  }
4167
4371
  /**
4168
4372
  * Execute an async operation with standardized file error handling.
@@ -4283,6 +4487,26 @@ class DocumentProcessor {
4283
4487
  return metadataWithRules;
4284
4488
  }, null);
4285
4489
  }
4490
+ /**
4491
+ * Render a file through the rule engine without embedding.
4492
+ * Returns rendered content, renderAs, matched rules, and metadata.
4493
+ *
4494
+ * @param filePath - The file to render.
4495
+ * @returns The render result.
4496
+ */
4497
+ async renderFile(filePath) {
4498
+ const ext = extname(filePath);
4499
+ const { renderedContent, extracted, matchedRules, metadataWithRules, renderAs, } = await this.buildMetadataWithRules(filePath);
4500
+ const content = renderedContent ?? extracted.text;
4501
+ const resolved = renderAs ?? (ext.slice(1) || 'txt');
4502
+ return {
4503
+ renderAs: resolved,
4504
+ content,
4505
+ rules: matchedRules,
4506
+ metadata: metadataWithRules,
4507
+ transformed: renderedContent !== null,
4508
+ };
4509
+ }
4286
4510
  /**
4287
4511
  * Update compiled inference rules, template engine, and custom map lib.
4288
4512
  *
package/dist/index.d.ts CHANGED
@@ -91,6 +91,7 @@ declare const inferenceRuleSchema: z.ZodObject<{
91
91
  sort: z.ZodOptional<z.ZodString>;
92
92
  }, z.core.$strip>>;
93
93
  }, z.core.$strip>>;
94
+ renderAs: z.ZodOptional<z.ZodString>;
94
95
  }, z.core.$strip>;
95
96
  /** An inference rule: JSON Schema match condition, schema array, and optional JsonMap transformation. */
96
97
  type InferenceRule = z.infer<typeof inferenceRuleSchema>;
@@ -165,6 +166,7 @@ declare const jeevesWatcherConfigSchema: z.ZodObject<{
165
166
  sort: z.ZodOptional<z.ZodString>;
166
167
  }, z.core.$strip>>;
167
168
  }, z.core.$strip>>;
169
+ renderAs: z.ZodOptional<z.ZodString>;
168
170
  }, z.core.$strip>, z.ZodString]>>>;
169
171
  maps: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodType<_karmaniverous_jsonmap.JsonMapMap, unknown, z.core.$ZodTypeInternals<_karmaniverous_jsonmap.JsonMapMap, unknown>>, z.ZodString, z.ZodObject<{
170
172
  map: z.ZodUnion<[z.ZodType<_karmaniverous_jsonmap.JsonMapMap, unknown, z.core.$ZodTypeInternals<_karmaniverous_jsonmap.JsonMapMap, unknown>>, z.ZodString]>;
@@ -580,6 +582,8 @@ interface ApplyRulesResult {
580
582
  renderedContent: string | null;
581
583
  /** Names of rules that matched. */
582
584
  matchedRules: string[];
585
+ /** The renderAs value from the last matching rule that declares it, or null. */
586
+ renderAs: string | null;
583
587
  }
584
588
  /**
585
589
  * Optional parameters for applyRules beyond the required compiledRules and attributes.
@@ -908,6 +912,26 @@ interface ProcessorConfig {
908
912
  globalSchemas?: Record<string, SchemaEntry>;
909
913
  }
910
914
 
915
+ /**
916
+ * @module processor/renderResult
917
+ * Type definition for the render endpoint result.
918
+ */
919
+ /**
920
+ * Result returned by {@link DocumentProcessorInterface.renderFile}.
921
+ */
922
+ interface RenderResult {
923
+ /** Output content type (file extension without dot). Always present. */
924
+ renderAs: string;
925
+ /** Rendered content (if a transform ran) or extracted text (passthrough). */
926
+ content: string;
927
+ /** Names of matched inference rules (diagnostic). */
928
+ rules: string[];
929
+ /** Composed embedding properties from matched rules. */
930
+ metadata: Record<string, unknown>;
931
+ /** Whether a template or render transform produced the content. */
932
+ transformed: boolean;
933
+ }
934
+
911
935
  /**
912
936
  * @module processor/types
913
937
  * Document processor interface definitions.
@@ -927,6 +951,8 @@ interface DocumentProcessorInterface {
927
951
  processMetadataUpdate(filePath: string, metadata: Record<string, unknown>): Promise<Record<string, unknown> | null>;
928
952
  /** Process a rules update for a file (rebuild merged metadata, payload update only). */
929
953
  processRulesUpdate(filePath: string): Promise<Record<string, unknown> | null>;
954
+ /** Render a file through the rule engine without embedding. */
955
+ renderFile(filePath: string): Promise<RenderResult>;
930
956
  /** Update compiled inference rules and associated engines. */
931
957
  updateRules(compiledRules: CompiledRule[], templateEngine?: TemplateEngine, customMapLib?: Record<string, (...args: unknown[]) => unknown>): void;
932
958
  }
@@ -1017,6 +1043,14 @@ declare class DocumentProcessor implements DocumentProcessorInterface {
1017
1043
  * @returns The merged metadata, or `null` if the file is not indexed.
1018
1044
  */
1019
1045
  processRulesUpdate(filePath: string): Promise<Record<string, unknown> | null>;
1046
+ /**
1047
+ * Render a file through the rule engine without embedding.
1048
+ * Returns rendered content, renderAs, matched rules, and metadata.
1049
+ *
1050
+ * @param filePath - The file to render.
1051
+ * @returns The render result.
1052
+ */
1053
+ renderFile(filePath: string): Promise<RenderResult>;
1020
1054
  /**
1021
1055
  * Update compiled inference rules, template engine, and custom map lib.
1022
1056
  *
package/dist/index.js CHANGED
@@ -892,11 +892,16 @@ async function applyRules(compiledRules, attributes, options = {}) {
892
892
  const lib = createJsonMapLib(configDir, customMapLib);
893
893
  let merged = {};
894
894
  let renderedContent = null;
895
+ let renderAs = null;
895
896
  const matchedRules = [];
896
897
  const log = logger ?? console;
897
898
  for (const [, { rule, validate }] of compiledRules.entries()) {
898
899
  if (validate(attributes)) {
899
900
  matchedRules.push(rule.name);
901
+ // Resolve renderAs (last-match-wins)
902
+ if (rule.renderAs) {
903
+ renderAs = rule.renderAs;
904
+ }
900
905
  // Apply schema-based metadata extraction
901
906
  if (rule.schema && rule.schema.length > 0) {
902
907
  try {
@@ -1000,7 +1005,7 @@ async function applyRules(compiledRules, attributes, options = {}) {
1000
1005
  }
1001
1006
  }
1002
1007
  }
1003
- return { metadata: merged, renderedContent, matchedRules };
1008
+ return { metadata: merged, renderedContent, matchedRules, renderAs };
1004
1009
  }
1005
1010
 
1006
1011
  /**
@@ -1617,6 +1622,12 @@ const inferenceRuleSchema = z
1617
1622
  render: renderConfigSchema
1618
1623
  .optional()
1619
1624
  .describe('Declarative render configuration for frontmatter + structured Markdown output (mutually exclusive with template).'),
1625
+ /** Output file extension override (e.g. "md", "html", "txt"). Requires template or render. */
1626
+ renderAs: z
1627
+ .string()
1628
+ .regex(/^[a-z0-9]{1,10}$/, 'renderAs must be 1-10 lowercase alphanumeric characters')
1629
+ .optional()
1630
+ .describe('Output file extension override (without dot). Requires template or render.'),
1620
1631
  })
1621
1632
  .superRefine((val, ctx) => {
1622
1633
  if (val.render && val.template) {
@@ -1626,6 +1637,13 @@ const inferenceRuleSchema = z
1626
1637
  message: 'render is mutually exclusive with template',
1627
1638
  });
1628
1639
  }
1640
+ if (val.renderAs && !val.template && !val.render) {
1641
+ ctx.addIssue({
1642
+ code: 'custom',
1643
+ path: ['renderAs'],
1644
+ message: 'renderAs requires template or render',
1645
+ });
1646
+ }
1629
1647
  });
1630
1648
 
1631
1649
  /**
@@ -2402,6 +2420,107 @@ function createConfigValidateHandler(deps) {
2402
2420
  }, deps.logger, 'Config validate');
2403
2421
  }
2404
2422
 
2423
+ /**
2424
+ * @module api/handlers/facets
2425
+ * GET /search/facets route handler. Returns schema-derived facet definitions with live values.
2426
+ */
2427
+ /** Compute a simple hash of rule names + schema refs for cache invalidation. */
2428
+ function computeRulesHash(rules) {
2429
+ if (!rules)
2430
+ return '';
2431
+ return rules
2432
+ .map((r) => `${r.name}:${JSON.stringify(r.schema ?? [])}`)
2433
+ .join('|');
2434
+ }
2435
+ /**
2436
+ * Check whether a resolved property should be exposed as a facet.
2437
+ * A property is facetable if it declares `uiHint` or `enum`.
2438
+ */
2439
+ function isFacetable(prop) {
2440
+ return prop.uiHint !== undefined || prop.enum !== undefined;
2441
+ }
2442
+ /**
2443
+ * Build the schema-derived facet structure from inference rules.
2444
+ *
2445
+ * Iterates all rules, resolves their schemas via `mergeSchemas`, and extracts
2446
+ * properties that have `uiHint` or `enum` defined. Deduplicates across rules.
2447
+ */
2448
+ function buildFacetSchema(rules, mergeOptions) {
2449
+ const fields = new Map();
2450
+ for (const rule of rules ?? []) {
2451
+ if (!rule.schema?.length)
2452
+ continue;
2453
+ const resolved = mergeSchemas(rule.schema, mergeOptions);
2454
+ for (const [propName, propDef] of Object.entries(resolved.properties)) {
2455
+ if (!isFacetable(propDef))
2456
+ continue;
2457
+ const existing = fields.get(propName);
2458
+ if (existing) {
2459
+ existing.rules.push(rule.name);
2460
+ if (propDef.enum)
2461
+ existing.enumValues = propDef.enum;
2462
+ if (propDef.uiHint)
2463
+ existing.uiHint = propDef.uiHint;
2464
+ }
2465
+ else {
2466
+ fields.set(propName, {
2467
+ type: propDef.type ?? 'string',
2468
+ uiHint: propDef.uiHint ?? 'dropdown',
2469
+ enumValues: propDef.enum,
2470
+ rules: [rule.name],
2471
+ });
2472
+ }
2473
+ }
2474
+ }
2475
+ return { fields, rulesHash: computeRulesHash(rules) };
2476
+ }
2477
+ /**
2478
+ * Create the GET /search/facets route handler.
2479
+ *
2480
+ * Returns facet definitions derived from inference rule schemas, enriched
2481
+ * with live values from the ValuesManager.
2482
+ *
2483
+ * @param deps - Handler dependencies.
2484
+ * @returns Fastify route handler (plain return, compatible with `withCache`).
2485
+ */
2486
+ function createFacetsHandler(deps) {
2487
+ const { config, valuesManager, configDir } = deps;
2488
+ let cached;
2489
+ const mergeOptions = {
2490
+ globalSchemas: config.schemas,
2491
+ configDir,
2492
+ };
2493
+ return () => {
2494
+ // Rebuild schema cache if rules changed
2495
+ const currentHash = computeRulesHash(config.inferenceRules);
2496
+ if (!cached || cached.rulesHash !== currentHash) {
2497
+ cached = buildFacetSchema(config.inferenceRules, mergeOptions);
2498
+ }
2499
+ // Merge with live values
2500
+ const allValues = valuesManager.getAll();
2501
+ const facets = [];
2502
+ for (const [field, schema] of cached.fields) {
2503
+ // Collect live values from all rules that define this field
2504
+ const liveValues = new Set();
2505
+ for (const ruleName of schema.rules) {
2506
+ const fieldValues = allValues[ruleName]?.[field];
2507
+ if (fieldValues) {
2508
+ for (const v of fieldValues)
2509
+ liveValues.add(v);
2510
+ }
2511
+ }
2512
+ facets.push({
2513
+ field,
2514
+ type: schema.type,
2515
+ uiHint: schema.uiHint,
2516
+ values: schema.enumValues ?? [...liveValues].sort(),
2517
+ rules: schema.rules,
2518
+ });
2519
+ }
2520
+ return { facets };
2521
+ };
2522
+ }
2523
+
2405
2524
  /**
2406
2525
  * @module api/handlers/issues
2407
2526
  * Fastify route handler for GET /issues. Returns current processing issues.
@@ -2728,6 +2847,79 @@ function createReindexHandler(deps) {
2728
2847
  }, deps.logger, 'Reindex');
2729
2848
  }
2730
2849
 
2850
+ /**
2851
+ * @module util/isPathWatched
2852
+ * Checks whether a file path falls within the watched scope defined by watch config globs.
2853
+ */
2854
+ /**
2855
+ * Check whether a file path matches the watched scope.
2856
+ * A path is watched if it matches at least one `paths` glob
2857
+ * AND does not match any `ignored` glob. Mirrors chokidar's
2858
+ * inclusion/exclusion logic.
2859
+ *
2860
+ * @param filePath - The file path to check.
2861
+ * @param watchPaths - Glob patterns that define watched paths.
2862
+ * @param ignoredPaths - Glob patterns that exclude paths (optional).
2863
+ * @returns `true` if the file is within watched scope.
2864
+ */
2865
+ function isPathWatched(filePath, watchPaths, ignoredPaths) {
2866
+ const normalised = normalizeSlashes(filePath);
2867
+ const isIncluded = picomatch(watchPaths, { dot: true });
2868
+ if (!isIncluded(normalised))
2869
+ return false;
2870
+ if (ignoredPaths && ignoredPaths.length > 0) {
2871
+ const isExcluded = picomatch(ignoredPaths, { dot: true });
2872
+ if (isExcluded(normalised))
2873
+ return false;
2874
+ }
2875
+ return true;
2876
+ }
2877
+
2878
+ /**
2879
+ * @module api/handlers/render
2880
+ * POST /render route handler. Runs a file through the inference rule engine and returns rendered content.
2881
+ */
2882
+ /**
2883
+ * Create the POST /render route handler.
2884
+ *
2885
+ * @param deps - Handler dependencies.
2886
+ * @returns Fastify route handler.
2887
+ */
2888
+ function createRenderHandler(deps) {
2889
+ const { processor, watch, logger } = deps;
2890
+ return async (request, reply) => {
2891
+ const { path: filePath } = request.body;
2892
+ if (!filePath || typeof filePath !== 'string') {
2893
+ return reply.status(400).send({ error: 'Missing required field: path' });
2894
+ }
2895
+ // Validate path is within watched scope
2896
+ if (!isPathWatched(filePath, watch.paths, watch.ignored)) {
2897
+ return reply.status(403).send({ error: 'Path is outside watched scope' });
2898
+ }
2899
+ try {
2900
+ const result = await processor.renderFile(filePath);
2901
+ // Passthrough responses should not be cached
2902
+ if (!result.transformed) {
2903
+ void reply.header('Cache-Control', 'no-cache');
2904
+ }
2905
+ return await reply.send({
2906
+ renderAs: result.renderAs,
2907
+ content: result.content,
2908
+ rules: result.rules,
2909
+ metadata: result.metadata,
2910
+ });
2911
+ }
2912
+ catch (error) {
2913
+ const msg = error instanceof Error ? error.message : 'Unknown render error';
2914
+ if (msg.includes('ENOENT') || msg.includes('no such file')) {
2915
+ return reply.status(404).send({ error: 'File not found' });
2916
+ }
2917
+ logger.error({ filePath, error: msg }, 'Render failed');
2918
+ return reply.status(422).send({ error: msg });
2919
+ }
2920
+ };
2921
+ }
2922
+
2731
2923
  /**
2732
2924
  * @module api/handlers/rulesReapply
2733
2925
  * Fastify route handler for POST /rules/reapply.
@@ -2922,6 +3114,11 @@ function withCache(ttlMs, handler) {
2922
3114
  if (fReply.statusCode >= 400) {
2923
3115
  return result;
2924
3116
  }
3117
+ // Skip cache for responses with Cache-Control: no-cache
3118
+ const cacheControl = fReply.getHeader('Cache-Control');
3119
+ if (cacheControl === 'no-cache') {
3120
+ return result;
3121
+ }
2925
3122
  // Store in cache
2926
3123
  cache.set(key, {
2927
3124
  value: result,
@@ -2999,6 +3196,12 @@ function createApiServer(options) {
2999
3196
  reindexTracker,
3000
3197
  })));
3001
3198
  app.post('/metadata', createMetadataHandler({ processor, config, logger }));
3199
+ app.post('/render', withCache(cacheTtlMs, createRenderHandler({ processor, watch: config.watch, logger })));
3200
+ app.get('/search/facets', createFacetsHandler({
3201
+ config,
3202
+ valuesManager,
3203
+ configDir: dirname(configPath),
3204
+ }));
3002
3205
  const hybridConfig = config.search?.hybrid
3003
3206
  ? {
3004
3207
  enabled: config.search.hybrid.enabled,
@@ -3903,7 +4106,7 @@ async function buildMergedMetadata(options) {
3903
4106
  const extracted = await extractText(filePath, ext);
3904
4107
  // 2. Build attributes + apply rules
3905
4108
  const attributes = buildAttributes(filePath, stats, extracted.frontmatter, extracted.json);
3906
- const { metadata: inferred, renderedContent, matchedRules, } = await applyRules(compiledRules, attributes, {
4109
+ const { metadata: inferred, renderedContent, matchedRules, renderAs, } = await applyRules(compiledRules, attributes, {
3907
4110
  namedMaps: maps,
3908
4111
  logger,
3909
4112
  templateEngine,
@@ -3925,6 +4128,7 @@ async function buildMergedMetadata(options) {
3925
4128
  extracted,
3926
4129
  renderedContent,
3927
4130
  matchedRules,
4131
+ renderAs,
3928
4132
  };
3929
4133
  }
3930
4134
 
@@ -4140,7 +4344,7 @@ class DocumentProcessor {
4140
4344
  ...result.metadata,
4141
4345
  matched_rules: result.matchedRules,
4142
4346
  };
4143
- return { ...result, metadataWithRules };
4347
+ return { ...result, metadataWithRules, renderAs: result.renderAs };
4144
4348
  }
4145
4349
  /**
4146
4350
  * Execute an async operation with standardized file error handling.
@@ -4261,6 +4465,26 @@ class DocumentProcessor {
4261
4465
  return metadataWithRules;
4262
4466
  }, null);
4263
4467
  }
4468
+ /**
4469
+ * Render a file through the rule engine without embedding.
4470
+ * Returns rendered content, renderAs, matched rules, and metadata.
4471
+ *
4472
+ * @param filePath - The file to render.
4473
+ * @returns The render result.
4474
+ */
4475
+ async renderFile(filePath) {
4476
+ const ext = extname(filePath);
4477
+ const { renderedContent, extracted, matchedRules, metadataWithRules, renderAs, } = await this.buildMetadataWithRules(filePath);
4478
+ const content = renderedContent ?? extracted.text;
4479
+ const resolved = renderAs ?? (ext.slice(1) || 'txt');
4480
+ return {
4481
+ renderAs: resolved,
4482
+ content,
4483
+ rules: matchedRules,
4484
+ metadata: metadataWithRules,
4485
+ transformed: renderedContent !== null,
4486
+ };
4487
+ }
4264
4488
  /**
4265
4489
  * Update compiled inference rules, template engine, and custom map lib.
4266
4490
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karmaniverous/jeeves-watcher",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "author": "Jason Williscroft",
5
5
  "description": "Filesystem watcher that keeps a Qdrant vector store in sync with document changes",
6
6
  "license": "BSD-3-Clause",
@@ -73,7 +73,7 @@
73
73
  "mammoth": "^1.11.0",
74
74
  "mdast-util-from-adf": "^2.2.0",
75
75
  "mdast-util-to-markdown": "^2.1.2",
76
- "picomatch": "*",
76
+ "picomatch": "^4.0.3",
77
77
  "pino": "*",
78
78
  "radash": "^12.1.1",
79
79
  "rehype-parse": "^9.0.1",
@@ -93,7 +93,7 @@
93
93
  "@types/fs-extra": "^11.0.4",
94
94
  "@types/js-yaml": "*",
95
95
  "@types/node": "^25.3.0",
96
- "@types/picomatch": "*",
96
+ "@types/picomatch": "^4.0.2",
97
97
  "@types/uuid": "*",
98
98
  "@vitest/coverage-v8": "^4.0.18",
99
99
  "auto-changelog": "^2.5.0",