@karmaniverous/jeeves-watcher 0.16.3 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  import { join, dirname, resolve, relative, extname, basename, isAbsolute } from 'node:path';
2
- import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, getBindAddress, postJson, fetchJson } from '@karmaniverous/jeeves';
2
+ import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, DEFAULT_PORTS, getBindAddress, postJson, fetchJson } from '@karmaniverous/jeeves';
3
3
  import Fastify from 'fastify';
4
- import { readdir, stat, writeFile, readFile } from 'node:fs/promises';
4
+ import { readdir, stat, readFile } from 'node:fs/promises';
5
5
  import { parallel, capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radash';
6
6
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
7
7
  import ignore from 'ignore';
8
8
  import picomatch from 'picomatch';
9
- import { z, ZodError } from 'zod';
10
- import { jsonMapMapSchema, JsonMap } from '@karmaniverous/jsonmap';
11
9
  import Ajv from 'ajv';
12
10
  import addFormats from 'ajv-formats';
11
+ import { z, ZodError } from 'zod';
12
+ import { jsonMapMapSchema, JsonMap } from '@karmaniverous/jsonmap';
13
13
  import { pathToFileURL, fileURLToPath } from 'node:url';
14
14
  import Handlebars from 'handlebars';
15
15
  import dayjs from 'dayjs';
@@ -614,10 +614,10 @@ async function fireCallback(url, payload, logger) {
614
614
  function groupByRoot(filePaths, watchPaths) {
615
615
  const byRoot = {};
616
616
  for (const fp of filePaths) {
617
- const normalised = fp.replace(/\\/g, '/');
617
+ const normalised = normalizeSlashes(fp);
618
618
  let matched = false;
619
619
  for (const root of watchPaths) {
620
- const rootNorm = root.replace(/\\/g, '/').replace(/\/?\*\*.*$/, '');
620
+ const rootNorm = normalizeSlashes(root).replace(/\/?\*\*.*$/, '');
621
621
  if (normalised.startsWith(rootNorm)) {
622
622
  byRoot[rootNorm] = (byRoot[rootNorm] ?? 0) + 1;
623
623
  matched = true;
@@ -969,579 +969,135 @@ async function getPathFileList(targetPath, config, gitignoreFilter) {
969
969
  }
970
970
 
971
971
  /**
972
- * @module config/schemas/base
973
- * Base configuration schemas: watch, logging, API.
972
+ * @module rules/attributes
973
+ * Builds file attribute objects for rule matching. Pure function: derives attributes from path, stats, and extracted data.
974
974
  */
975
+ /** Derive the path-based file properties shared by all attribute builders. */
976
+ function buildFileProps(filePath) {
977
+ const normalised = normalizeSlashes(filePath);
978
+ return {
979
+ path: normalised,
980
+ directory: normalizeSlashes(dirname(normalised)),
981
+ filename: basename(normalised),
982
+ extension: extname(normalised),
983
+ };
984
+ }
975
985
  /**
976
- * Watch configuration for file system monitoring.
986
+ * Build {@link FileAttributes} from a file path and stat info.
987
+ *
988
+ * @param filePath - The file path.
989
+ * @param stats - The file stats.
990
+ * @param extractedFrontmatter - Optional extracted frontmatter.
991
+ * @param extractedJson - Optional parsed JSON content.
992
+ * @returns The constructed file attributes.
977
993
  */
978
- const watchConfigSchema = z.object({
979
- /** Glob patterns to watch. */
980
- paths: z
981
- .array(z.string())
982
- .min(1)
983
- .describe('Glob patterns for files to watch (e.g., "**/*.md"). At least one required.'),
984
- /** Glob patterns to ignore. */
985
- ignored: z
986
- .array(z.string())
987
- .optional()
988
- .describe('Glob patterns to exclude from watching (e.g., "**/node_modules/**").'),
989
- /** Polling interval in milliseconds. */
990
- pollIntervalMs: z
991
- .number()
992
- .optional()
993
- .describe('Polling interval in milliseconds when usePolling is enabled.'),
994
- /** Whether to use polling instead of native watchers. */
995
- usePolling: z
996
- .boolean()
997
- .optional()
998
- .describe('Use polling instead of native file system events (for network drives).'),
999
- /** Debounce delay in milliseconds for file change events. */
1000
- debounceMs: z
1001
- .number()
1002
- .optional()
1003
- .describe('Debounce delay in milliseconds for file change events.'),
1004
- /** Time in milliseconds a file must be stable before processing. */
1005
- stabilityThresholdMs: z
1006
- .number()
1007
- .optional()
1008
- .describe('Time in milliseconds a file must remain unchanged before processing.'),
1009
- /** Whether to respect .gitignore files when processing. */
1010
- respectGitignore: z
1011
- .boolean()
1012
- .optional()
1013
- .describe('Skip files ignored by .gitignore in git repositories. Only applies to repos with a .git directory. Default: true.'),
1014
- /** Move detection configuration for correlating unlink+add as file moves. */
1015
- moveDetection: z
1016
- .object({
1017
- /** Enable move correlation. Default: true. */
1018
- enabled: z
1019
- .boolean()
1020
- .default(true)
1021
- .describe('Enable move detection via content hash correlation.'),
1022
- /** Buffer time in ms for holding unlink events before treating as deletes. Default: 2000. */
1023
- bufferMs: z
1024
- .number()
1025
- .int()
1026
- .min(100)
1027
- .default(2000)
1028
- .describe('How long (ms) to buffer unlink events before treating as deletes.'),
1029
- })
1030
- .optional()
1031
- .describe('Move detection: correlate unlink+add events as file moves to avoid re-embedding.'),
1032
- });
994
+ function buildAttributes(filePath, stats, extractedFrontmatter, extractedJson) {
995
+ const attrs = {
996
+ file: {
997
+ ...buildFileProps(filePath),
998
+ sizeBytes: stats.size,
999
+ modified: stats.mtime.toISOString(),
1000
+ },
1001
+ };
1002
+ if (extractedFrontmatter)
1003
+ attrs.frontmatter = extractedFrontmatter;
1004
+ if (extractedJson)
1005
+ attrs.json = extractedJson;
1006
+ return attrs;
1007
+ }
1033
1008
  /**
1034
- * Configuration watch settings.
1009
+ * Build synthetic file attributes from a path string (no actual file I/O).
1010
+ * Used by API handlers that need to match rules against paths without reading files.
1011
+ *
1012
+ * @param filePath - The file path.
1013
+ * @returns Synthetic file attributes with zeroed stats.
1035
1014
  */
1036
- const configWatchConfigSchema = z.object({
1037
- /** Whether config file watching is enabled. */
1038
- enabled: z
1039
- .boolean()
1040
- .optional()
1041
- .describe('Enable automatic reloading when config file changes.'),
1042
- /** Debounce delay in milliseconds for config change events. */
1043
- debounceMs: z
1044
- .number()
1045
- .optional()
1046
- .describe('Debounce delay in milliseconds for config file change detection.'),
1047
- /** Reindex scope triggered on config change. */
1048
- reindex: z
1049
- .union([
1050
- z
1051
- .literal('issues')
1052
- .describe('Re-process only files with recorded issues.'),
1053
- z.literal('full').describe('Full reindex of all watched files.'),
1054
- z
1055
- .literal('rules')
1056
- .describe('Re-apply inference rules to existing points without re-embedding.'),
1057
- ])
1058
- .optional()
1059
- .describe('Reindex scope triggered on config change. Default: issues.'),
1060
- });
1015
+ function buildSyntheticAttributes(filePath) {
1016
+ return {
1017
+ file: {
1018
+ ...buildFileProps(filePath),
1019
+ sizeBytes: 0,
1020
+ modified: new Date(0).toISOString(),
1021
+ },
1022
+ };
1023
+ }
1024
+
1061
1025
  /**
1062
- * API server configuration.
1026
+ * @module rules/ajvSetup
1027
+ * AJV instance factory with custom glob keyword for picomatch-based pattern matching in rule schemas.
1063
1028
  */
1064
- const apiConfigSchema = z.object({
1065
- /** Host to bind to. */
1066
- host: z
1067
- .string()
1068
- .optional()
1069
- .describe('Host address for API server (e.g., "127.0.0.1", "0.0.0.0").'),
1070
- /** Port to listen on. */
1071
- port: z.number().optional().describe('Port for API server (e.g., 1936).'),
1072
- /** Read endpoint cache TTL in milliseconds. */
1073
- cacheTtlMs: z
1074
- .number()
1075
- .optional()
1076
- .describe('TTL in milliseconds for caching read-heavy endpoints (e.g., /status, /config). Default: 30000.'),
1077
- });
1078
1029
  /**
1079
- * Logging configuration.
1030
+ * Create an AJV instance with a custom `glob` format for picomatch glob matching.
1031
+ *
1032
+ * @returns The configured AJV instance.
1080
1033
  */
1081
- const loggingConfigSchema = z.object({
1082
- /** Log level. */
1083
- level: z
1084
- .string()
1085
- .optional()
1086
- .describe('Logging level (trace, debug, info, warn, error, fatal).'),
1087
- /** Log file path. */
1088
- file: z
1089
- .string()
1090
- .optional()
1091
- .describe('Path to log file (logs to stdout if omitted).'),
1092
- });
1034
+ function createRuleAjv() {
1035
+ const ajv = new Ajv({ allErrors: true, strict: false });
1036
+ addFormats(ajv);
1037
+ ajv.addKeyword({
1038
+ keyword: 'glob',
1039
+ type: 'string',
1040
+ schemaType: 'string',
1041
+ validate: (pattern, data) => picomatch.isMatch(data, pattern, { dot: true, nocase: true }),
1042
+ });
1043
+ return ajv;
1044
+ }
1093
1045
 
1094
1046
  /**
1095
- * @module config/schemas/inference
1096
- * Inference rule and schema configuration schemas.
1047
+ * @module rules/compile
1048
+ * Compiles inference rule definitions into executable AJV validators for efficient rule evaluation.
1097
1049
  */
1098
1050
  /**
1099
- * A JSON Schema property definition with optional custom keywords.
1100
- * Supports standard JSON Schema keywords plus custom `set` and `uiHint`.
1051
+ * Validate that all rule names are unique.
1052
+ * Throws if duplicate names are found.
1053
+ *
1054
+ * @param rules - The inference rule definitions.
1101
1055
  */
1102
- const propertySchemaSchema = z.record(z.string(), z.unknown());
1056
+ function validateRuleNameUniqueness(rules) {
1057
+ const names = new Set();
1058
+ const duplicates = [];
1059
+ for (const rule of rules) {
1060
+ if (names.has(rule.name)) {
1061
+ duplicates.push(rule.name);
1062
+ }
1063
+ else {
1064
+ names.add(rule.name);
1065
+ }
1066
+ }
1067
+ if (duplicates.length > 0) {
1068
+ throw new Error(`Duplicate inference rule names found: ${duplicates.join(', ')}. Rule names must be unique.`);
1069
+ }
1070
+ }
1103
1071
  /**
1104
- * A schema object: properties with JSON Schema definitions.
1072
+ * Compile an array of inference rules into executable validators.
1073
+ * Validates rule name uniqueness before compilation.
1074
+ *
1075
+ * @param rules - The inference rule definitions.
1076
+ * @returns An array of compiled rules.
1105
1077
  */
1106
- const schemaObjectSchema = z.object({
1107
- type: z
1108
- .literal('object')
1109
- .optional()
1110
- .describe('JSON Schema type (always "object" for schema definitions).'),
1111
- properties: z
1112
- .record(z.string(), propertySchemaSchema)
1113
- .optional()
1114
- .describe('Map of property names to JSON Schema property definitions.'),
1115
- });
1078
+ function compileRules(rules) {
1079
+ validateRuleNameUniqueness(rules);
1080
+ const ajv = createRuleAjv();
1081
+ return rules.map((rule) => ({
1082
+ rule,
1083
+ validate: ajv.compile({
1084
+ $id: rule.name,
1085
+ ...rule.match,
1086
+ }),
1087
+ }));
1088
+ }
1089
+
1116
1090
  /**
1117
- * Global schema entry: inline object or file path.
1091
+ * @module api/handlers/wrapHandler
1092
+ * Generic error-handling wrapper for Fastify route handlers.
1118
1093
  */
1119
- const schemaEntrySchema = z.union([
1120
- schemaObjectSchema,
1121
- z.string().describe('File path to a JSON schema file.'),
1122
- ]);
1123
1094
  /**
1124
- * Schema reference: either a named schema reference (string) or an inline schema object.
1125
- */
1126
- const schemaReferenceSchema = z.union([
1127
- z.string().describe('Named reference to a global schema.'),
1128
- schemaObjectSchema,
1129
- ]);
1130
- /** Render body section. */
1131
- const renderBodySectionSchema = z.object({
1132
- /** Key path in the template context to render. */
1133
- path: z.string().min(1).describe('Key path in template context to render.'),
1134
- /** Markdown heading level for this section (1-6). */
1135
- heading: z.number().min(1).max(6).describe('Markdown heading level (1-6).'),
1136
- /** Override heading text (default: titlecased path). */
1137
- label: z.string().optional().describe('Override heading text.'),
1138
- /** Name of a registered Handlebars helper used as a format handler. */
1139
- format: z
1140
- .string()
1141
- .optional()
1142
- .describe('Name of a registered Handlebars helper used as a format handler.'),
1143
- /** Additional args passed to the format helper. */
1144
- formatArgs: z
1145
- .array(z.unknown())
1146
- .optional()
1147
- .describe('Additional args passed to the format helper.'),
1148
- /** If true, the value at path is treated as an array and iterated. */
1149
- each: z
1150
- .boolean()
1151
- .optional()
1152
- .describe('If true, the value at path is treated as an array and iterated.'),
1153
- /** Handlebars template string for per-item heading text (used when each=true). */
1154
- headingTemplate: z
1155
- .string()
1156
- .optional()
1157
- .describe('Handlebars template string for per-item heading text (used when each=true).'),
1158
- /** Key path within each item to use as renderable content (used when each=true). */
1159
- contentPath: z
1160
- .string()
1161
- .optional()
1162
- .describe('Key path within each item to use as renderable content (used when each=true).'),
1163
- /** Key path within each item to sort by (used when each=true). */
1164
- sort: z
1165
- .string()
1166
- .optional()
1167
- .describe('Key path within each item to sort by (used when each=true).'),
1168
- });
1169
- /** Render config: YAML frontmatter + ordered body sections. */
1170
- const renderConfigSchema = z.object({
1171
- /** Keys or glob patterns to extract from context and include as YAML frontmatter. */
1172
- frontmatter: z
1173
- .array(z.string().min(1))
1174
- .describe('Keys or glob patterns to include as YAML frontmatter. ' +
1175
- 'Supports picomatch globs (e.g. "*") and "!"-prefixed exclusion patterns (e.g. "!_*"). ' +
1176
- 'Explicit names preserve declaration order; glob-matched keys are sorted alphabetically.'),
1177
- /** Ordered markdown body sections. */
1178
- body: z
1179
- .array(renderBodySectionSchema)
1180
- .describe('Ordered markdown body sections.'),
1181
- });
1182
- /**
1183
- * An inference rule that enriches document metadata.
1184
- */
1185
- const inferenceRuleSchema = z
1186
- .object({
1187
- /** Unique name for this inference rule. */
1188
- name: z
1189
- .string()
1190
- .min(1)
1191
- .describe('Unique name identifying this inference rule.'),
1192
- /** Human-readable description of what this rule does. */
1193
- description: z
1194
- .string()
1195
- .min(1)
1196
- .describe('Human-readable description of what this rule does.'),
1197
- /** JSON Schema object to match against document metadata. */
1198
- match: z
1199
- .record(z.string(), z.unknown())
1200
- .describe('JSON Schema object to match against file attributes.'),
1201
- /** Array of schema references to merge (named refs and/or inline objects). */
1202
- schema: z
1203
- .array(schemaReferenceSchema)
1204
- .optional()
1205
- .describe('Array of schema references (named schema refs or inline objects) merged left-to-right.'),
1206
- /** JsonMap transformation (inline or reference to named map). */
1207
- map: z
1208
- .union([jsonMapMapSchema, z.string()])
1209
- .optional()
1210
- .describe('JsonMap transformation (inline definition, named map reference, or .json file path).'),
1211
- /** Handlebars template (inline string, named ref, or .hbs/.handlebars file path). */
1212
- template: z
1213
- .string()
1214
- .optional()
1215
- .describe('Handlebars content template (inline string, named ref, or .hbs/.handlebars file path).'),
1216
- /** Declarative structured renderer configuration (mutually exclusive with template). */
1217
- render: renderConfigSchema
1218
- .optional()
1219
- .describe('Declarative render configuration for frontmatter + structured Markdown output (mutually exclusive with template).'),
1220
- /** Output file extension override (e.g. "md", "html", "txt"). Requires template or render. */
1221
- renderAs: z
1222
- .string()
1223
- .regex(/^[a-z0-9]{1,10}$/, 'renderAs must be 1-10 lowercase alphanumeric characters')
1224
- .optional()
1225
- .describe('Output file extension override (without dot). Requires template or render.'),
1226
- })
1227
- .superRefine((val, ctx) => {
1228
- if (val.render && val.template) {
1229
- ctx.addIssue({
1230
- code: 'custom',
1231
- path: ['render'],
1232
- message: 'render is mutually exclusive with template',
1233
- });
1234
- }
1235
- if (val.renderAs && !val.template && !val.render) {
1236
- ctx.addIssue({
1237
- code: 'custom',
1238
- path: ['renderAs'],
1239
- message: 'renderAs requires template or render',
1240
- });
1241
- }
1242
- });
1243
-
1244
- /**
1245
- * @module config/schemas/services
1246
- * Service configuration schemas: embedding and vector store.
1247
- */
1248
- /**
1249
- * Embedding model configuration.
1250
- */
1251
- const embeddingConfigSchema = z.object({
1252
- /** The embedding model provider. */
1253
- provider: z
1254
- .string()
1255
- .default('gemini')
1256
- .describe('Embedding provider name (e.g., "gemini", "openai").'),
1257
- /** The embedding model name. */
1258
- model: z
1259
- .string()
1260
- .default('gemini-embedding-001')
1261
- .describe('Embedding model identifier (e.g., "gemini-embedding-001", "text-embedding-3-small").'),
1262
- /** Maximum tokens per chunk for splitting. */
1263
- chunkSize: z
1264
- .number()
1265
- .optional()
1266
- .describe('Maximum chunk size in characters for text splitting.'),
1267
- /** Overlap between chunks in tokens. */
1268
- chunkOverlap: z
1269
- .number()
1270
- .optional()
1271
- .describe('Character overlap between consecutive chunks.'),
1272
- /** Embedding vector dimensions. */
1273
- dimensions: z
1274
- .number()
1275
- .optional()
1276
- .describe('Embedding vector dimensions (must match model output).'),
1277
- /** API key for the embedding provider. */
1278
- apiKey: z
1279
- .string()
1280
- .optional()
1281
- .describe('API key for embedding provider (supports ${ENV_VAR} substitution).'),
1282
- /** Maximum embedding requests per minute. */
1283
- rateLimitPerMinute: z
1284
- .number()
1285
- .optional()
1286
- .describe('Maximum embedding API requests per minute (rate limiting).'),
1287
- /** Maximum concurrent embedding requests. */
1288
- concurrency: z
1289
- .number()
1290
- .optional()
1291
- .describe('Maximum concurrent embedding requests.'),
1292
- });
1293
- /**
1294
- * Vector store configuration for Qdrant.
1295
- */
1296
- const vectorStoreConfigSchema = z.object({
1297
- /** Qdrant server URL. */
1298
- url: z
1299
- .string()
1300
- .describe('Qdrant server URL (e.g., "http://localhost:6333").'),
1301
- /** Qdrant collection name. */
1302
- collectionName: z
1303
- .string()
1304
- .describe('Qdrant collection name for vector storage.'),
1305
- /** Qdrant API key. */
1306
- apiKey: z
1307
- .string()
1308
- .optional()
1309
- .describe('Qdrant API key for authentication (supports ${ENV_VAR} substitution).'),
1310
- });
1311
-
1312
- /**
1313
- * @module config/schemas/root
1314
- * Root configuration schema combining all sub-schemas.
1315
- */
1316
- /**
1317
- * Top-level configuration for jeeves-watcher.
1318
- */
1319
- const jeevesWatcherConfigSchema = z.object({
1320
- /** Optional description of this watcher deployment's organizational strategy. */
1321
- description: z
1322
- .string()
1323
- .optional()
1324
- .describe("Human-readable description of this deployment's organizational strategy and content domains."),
1325
- /** Global named schema collection. */
1326
- schemas: z
1327
- .record(z.string(), schemaEntrySchema)
1328
- .optional()
1329
- .describe('Global named schema definitions (inline objects or file paths) referenced by inference rules.'),
1330
- /** File system watch configuration. */
1331
- watch: watchConfigSchema.describe('File system watch configuration.'),
1332
- /** Configuration file watch settings. */
1333
- configWatch: configWatchConfigSchema
1334
- .optional()
1335
- .describe('Configuration file watch settings.'),
1336
- /** Embedding model configuration. */
1337
- embedding: embeddingConfigSchema.describe('Embedding model configuration.'),
1338
- /** Vector store configuration. */
1339
- vectorStore: vectorStoreConfigSchema.describe('Qdrant vector store configuration.'),
1340
- /** API server configuration. */
1341
- api: apiConfigSchema.optional().describe('API server configuration.'),
1342
- /** Directory for persistent state files (issues.json, values.json, enrichments.sqlite). */
1343
- stateDir: z
1344
- .string()
1345
- .optional()
1346
- .describe('Directory for persistent state files (issues.json, values.json, enrichments.sqlite). Defaults to .jeeves-metadata.'),
1347
- /** Rules for inferring metadata from document properties (inline objects or file paths). */
1348
- inferenceRules: z
1349
- .array(z.union([inferenceRuleSchema, z.string()]))
1350
- .optional()
1351
- .describe('Rules for inferring metadata from file attributes. Each entry may be an inline rule object or a file path to a JSON rule file (resolved relative to config directory).'),
1352
- /** Reusable named JsonMap transformations (inline objects or .json file paths). */
1353
- maps: z
1354
- .record(z.string(), z.union([
1355
- jsonMapMapSchema,
1356
- z.string(),
1357
- z.object({
1358
- /** The JsonMap definition (inline object or file path). */
1359
- map: jsonMapMapSchema.or(z.string()),
1360
- /** Optional human-readable description of this map. */
1361
- description: z.string().optional(),
1362
- }),
1363
- ]))
1364
- .optional()
1365
- .describe('Reusable named JsonMap transformations (inline definition or .json file path resolved relative to config directory).'),
1366
- /** Reusable named Handlebars templates (inline strings or .hbs/.handlebars file paths). */
1367
- templates: z
1368
- .record(z.string(), z.union([
1369
- z.string(),
1370
- z.object({
1371
- /** The Handlebars template source (inline string or file path). */
1372
- template: z.string(),
1373
- /** Optional human-readable description of this template. */
1374
- description: z.string().optional(),
1375
- }),
1376
- ]))
1377
- .optional()
1378
- .describe('Named reusable Handlebars templates (inline strings or .hbs/.handlebars file paths).'),
1379
- /** Custom Handlebars helper registration. */
1380
- templateHelpers: z
1381
- .record(z.string(), z.object({
1382
- /** File path to the helper module (resolved relative to config directory). */
1383
- path: z.string(),
1384
- /** Optional human-readable description of this helper. */
1385
- description: z.string().optional(),
1386
- }))
1387
- .optional()
1388
- .describe('Custom Handlebars helper registration.'),
1389
- /** Custom JsonMap lib function registration. */
1390
- mapHelpers: z
1391
- .record(z.string(), z.object({
1392
- /** File path to the helper module (resolved relative to config directory). */
1393
- path: z.string(),
1394
- /** Optional human-readable description of this helper. */
1395
- description: z.string().optional(),
1396
- }))
1397
- .optional()
1398
- .describe('Custom JsonMap lib function registration.'),
1399
- /** Reindex configuration. */
1400
- reindex: z
1401
- .object({
1402
- /** URL to call when reindex completes. */
1403
- callbackUrl: z.url().optional(),
1404
- /** Maximum concurrent file operations during reindex. */
1405
- concurrency: z
1406
- .number()
1407
- .int()
1408
- .min(1)
1409
- .default(50)
1410
- .describe('Maximum concurrent file operations during reindex (default 50).'),
1411
- })
1412
- .optional()
1413
- .describe('Reindex configuration.'),
1414
- /** Named Qdrant filter patterns for skill-activated behaviors. */
1415
- /** Search configuration including score thresholds and hybrid search. */
1416
- search: z
1417
- .object({
1418
- /** Score thresholds for categorizing search result quality. */
1419
- scoreThresholds: z
1420
- .object({
1421
- /** Minimum score for a result to be considered a strong match. */
1422
- strong: z.number().min(-1).max(1),
1423
- /** Minimum score for a result to be considered relevant. */
1424
- relevant: z.number().min(-1).max(1),
1425
- /** Maximum score below which results are considered noise. */
1426
- noise: z.number().min(-1).max(1),
1427
- })
1428
- .optional(),
1429
- /** Hybrid search configuration combining vector and full-text search. */
1430
- hybrid: z
1431
- .object({
1432
- /** Enable hybrid search with RRF fusion. Default: false. */
1433
- enabled: z.boolean().default(false),
1434
- /** Weight for text (BM25) results in RRF fusion. Default: 0.3. */
1435
- textWeight: z.number().min(0).max(1).default(0.3),
1436
- })
1437
- .optional(),
1438
- })
1439
- .optional()
1440
- .describe('Search configuration including score thresholds and hybrid search.'),
1441
- /** Logging configuration. */
1442
- logging: loggingConfigSchema.optional().describe('Logging configuration.'),
1443
- /** Timeout in milliseconds for graceful shutdown. */
1444
- shutdownTimeoutMs: z
1445
- .number()
1446
- .optional()
1447
- .describe('Timeout in milliseconds for graceful shutdown.'),
1448
- /** Maximum consecutive system-level failures before triggering fatal error. Default: Infinity. */
1449
- maxRetries: z
1450
- .number()
1451
- .optional()
1452
- .describe('Maximum consecutive system-level failures before triggering fatal error. Default: Infinity.'),
1453
- /** Maximum backoff delay in milliseconds for system errors. Default: 60000. */
1454
- maxBackoffMs: z
1455
- .number()
1456
- .optional()
1457
- .describe('Maximum backoff delay in milliseconds for system errors. Default: 60000.'),
1458
- });
1459
-
1460
- /**
1461
- * @module api/handlers/configMerge
1462
- * Shared config merge utilities.
1463
- */
1464
- /**
1465
- * Merge inference rules by name: submitted rules replace existing by name, new ones are appended.
1466
- */
1467
- function mergeInferenceRules(existing, incoming) {
1468
- if (!incoming)
1469
- return existing ?? [];
1470
- if (!existing)
1471
- return incoming;
1472
- const merged = [...existing];
1473
- for (const rule of incoming) {
1474
- const name = rule['name'];
1475
- if (!name) {
1476
- merged.push(rule);
1477
- continue;
1478
- }
1479
- const idx = merged.findIndex((r) => r['name'] === name);
1480
- if (idx >= 0) {
1481
- merged[idx] = rule;
1482
- }
1483
- else {
1484
- merged.push(rule);
1485
- }
1486
- }
1487
- return merged;
1488
- }
1489
-
1490
- /**
1491
- * @module api/handlers/mergeAndValidate
1492
- * Shared config merge and validation logic used by both validate and apply handlers.
1493
- */
1494
- /**
1495
- * Merge a submitted partial config into the current config and validate against schema.
1496
- *
1497
- * @param currentConfig - The current running config.
1498
- * @param submittedPartial - The partial config to merge in.
1499
- * @returns The merge/validation result.
1500
- */
1501
- function mergeAndValidateConfig(currentConfig, submittedPartial) {
1502
- let candidateRaw = {
1503
- ...currentConfig,
1504
- };
1505
- if (submittedPartial) {
1506
- const mergedRules = mergeInferenceRules(candidateRaw['inferenceRules'], submittedPartial['inferenceRules']);
1507
- candidateRaw = {
1508
- ...candidateRaw,
1509
- ...submittedPartial,
1510
- inferenceRules: mergedRules,
1511
- };
1512
- }
1513
- const parseResult = jeevesWatcherConfigSchema.safeParse(candidateRaw);
1514
- const errors = [];
1515
- if (!parseResult.success) {
1516
- for (const issue of parseResult.error.issues) {
1517
- errors.push({
1518
- path: issue.path.join('.'),
1519
- message: issue.message,
1520
- });
1521
- }
1522
- return { candidateRaw, errors };
1523
- }
1524
- // Note: When accepting external config via API, string rule references are NOT resolved
1525
- // (no configDir context). API-submitted rules must always be inline objects.
1526
- // The cast is safe because API config never contains string rule refs.
1527
- return {
1528
- candidateRaw,
1529
- parsed: parseResult.data,
1530
- errors,
1531
- };
1532
- }
1533
-
1534
- /**
1535
- * @module api/handlers/wrapHandler
1536
- * Generic error-handling wrapper for Fastify route handlers.
1537
- */
1538
- /**
1539
- * Wrap a Fastify route handler with standardised error handling.
1540
- *
1541
- * @param fn - The handler function.
1542
- * @param logger - Logger instance.
1543
- * @param label - Label for error log messages.
1544
- * @returns A wrapped handler that catches errors and returns 500.
1095
+ * Wrap a Fastify route handler with standardised error handling.
1096
+ *
1097
+ * @param fn - The handler function.
1098
+ * @param logger - Logger instance.
1099
+ * @param label - Label for error log messages.
1100
+ * @returns A wrapped handler that catches errors and returns 500.
1545
1101
  */
1546
1102
  function wrapHandler(fn, logger, label) {
1547
1103
  return async (request, reply) => {
@@ -1565,467 +1121,809 @@ function wrapHandler(fn, logger, label) {
1565
1121
  }
1566
1122
 
1567
1123
  /**
1568
- * @module api/handlers/configApply
1569
- * Fastify route handler for POST /config/apply. Validates and writes config, optionally triggering reindex.
1124
+ * @module api/handlers/configMatch
1125
+ * Tests file paths against inference rules and watch scope.
1570
1126
  */
1571
1127
  /**
1572
- * Create handler for POST /config/apply.
1128
+ * Factory for POST /config/match handler.
1573
1129
  *
1574
- * @param deps - Route dependencies.
1130
+ * @param options - Handler options.
1131
+ * @returns The handler function.
1575
1132
  */
1576
- function createConfigApplyHandler(deps) {
1577
- return wrapHandler(async (request, reply) => {
1578
- const { config: submittedConfig } = request.body;
1579
- const config = deps.getConfig();
1580
- const { candidateRaw, errors } = mergeAndValidateConfig(config, submittedConfig);
1581
- if (errors.length > 0) {
1582
- return await reply.status(400).send({ valid: false, errors });
1133
+ function createConfigMatchHandler(options) {
1134
+ const { getConfig, logger } = options;
1135
+ // Cache compiled artifacts per config reference recompute only on hot-reload.
1136
+ let cachedConfig;
1137
+ let compiledRules = [];
1138
+ let watchMatcher;
1139
+ let ignoreMatcher;
1140
+ const handler = async (req, res) => {
1141
+ const body = req.body;
1142
+ if (!Array.isArray(body.paths)) {
1143
+ res.code(400).send({ error: 'Request body must include "paths" array' });
1144
+ return;
1583
1145
  }
1584
- await writeFile(deps.configPath, JSON.stringify(candidateRaw, null, 2), 'utf-8');
1585
- const reindexScope = config.configWatch?.reindex ?? 'issues';
1586
- if (deps.triggerReindex) {
1587
- deps.triggerReindex(reindexScope);
1146
+ const config = getConfig();
1147
+ if (config !== cachedConfig) {
1148
+ compiledRules = compileRules(config.inferenceRules ?? []);
1149
+ watchMatcher = picomatch(config.watch.paths, { dot: true });
1150
+ ignoreMatcher = config.watch.ignored?.length
1151
+ ? picomatch(config.watch.ignored, { dot: true })
1152
+ : undefined;
1153
+ cachedConfig = config;
1588
1154
  }
1589
- return {
1590
- applied: true,
1591
- reindexTriggered: !!deps.triggerReindex,
1592
- scope: reindexScope,
1593
- };
1594
- }, deps.logger, 'Config apply');
1155
+ const matches = body.paths.map((path) => {
1156
+ const attrs = buildSyntheticAttributes(path);
1157
+ const matchingRules = [];
1158
+ for (const compiled of compiledRules) {
1159
+ if (compiled.validate(attrs)) {
1160
+ matchingRules.push(compiled.rule.name);
1161
+ }
1162
+ }
1163
+ const normalised = attrs.file.path;
1164
+ const watched = (watchMatcher?.(normalised) ?? false) &&
1165
+ !(ignoreMatcher?.(normalised) ?? false);
1166
+ return { rules: matchingRules, watched };
1167
+ });
1168
+ const response = { matches };
1169
+ res.send(response);
1170
+ };
1171
+ return wrapHandler(handler, logger, 'Config match');
1595
1172
  }
1596
1173
 
1597
1174
  /**
1598
- * @module rules/attributes
1599
- * Builds file attribute objects for rule matching. Pure function: derives attributes from path, stats, and extracted data.
1175
+ * @module api/mergedDocument
1176
+ * Builds a merged virtual document from config, values, and issues for JSONPath querying.
1600
1177
  */
1601
1178
  /**
1602
- * Build {@link FileAttributes} from a file path and stat info.
1179
+ * Build a helper section for the merged document, injecting introspection exports per namespace.
1180
+ */
1181
+ function buildHelperSection(configHelpers, legacyExports, introspection) {
1182
+ if (!configHelpers)
1183
+ return {};
1184
+ const result = {};
1185
+ for (const [name, entry] of Object.entries(configHelpers)) {
1186
+ result[name] = {
1187
+ ...entry,
1188
+ ...(introspection?.[name]?.exports
1189
+ ? { exports: introspection[name].exports }
1190
+ : {}),
1191
+ };
1192
+ }
1193
+ if (legacyExports) {
1194
+ result['_exports'] = legacyExports;
1195
+ }
1196
+ return result;
1197
+ }
1198
+ /**
1199
+ * Safely read and parse a file reference. Returns the original string on failure.
1200
+ */
1201
+ function readFileReference(filePath) {
1202
+ try {
1203
+ const content = readFileSync(filePath, 'utf-8');
1204
+ if (filePath.endsWith('.json')) {
1205
+ return JSON.parse(content);
1206
+ }
1207
+ return content;
1208
+ }
1209
+ catch {
1210
+ return filePath;
1211
+ }
1212
+ }
1213
+ /**
1214
+ * Build a merged virtual document combining config, values, and issues.
1603
1215
  *
1604
- * @param filePath - The file path.
1605
- * @param stats - The file stats.
1606
- * @param extractedFrontmatter - Optional extracted frontmatter.
1607
- * @param extractedJson - Optional parsed JSON content.
1608
- * @returns The constructed file attributes.
1216
+ * @param options - The build options.
1217
+ * @returns The merged document object.
1609
1218
  */
1610
- function buildAttributes(filePath, stats, extractedFrontmatter, extractedJson) {
1611
- const normalised = normalizeSlashes(filePath);
1612
- const attrs = {
1613
- file: {
1614
- path: normalised,
1615
- directory: normalizeSlashes(dirname(normalised)),
1616
- filename: basename(normalised),
1617
- extension: extname(normalised),
1618
- sizeBytes: stats.size,
1619
- modified: stats.mtime.toISOString(),
1620
- },
1219
+ function buildMergedDocument(options) {
1220
+ const { config, valuesManager, issuesManager, helperExports, helperIntrospection, virtualRules, } = options;
1221
+ // Merge config rules (source: 'config') and virtual rules (source: registration key)
1222
+ const configRules = (config.inferenceRules ?? []).map((rule) => ({
1223
+ ...rule,
1224
+ source: 'config',
1225
+ values: valuesManager.getForRule(rule.name),
1226
+ }));
1227
+ const virtualRuleEntries = [];
1228
+ if (virtualRules) {
1229
+ for (const [source, rules] of Object.entries(virtualRules)) {
1230
+ for (const rule of rules) {
1231
+ const name = typeof rule.name === 'string' ? rule.name : undefined;
1232
+ virtualRuleEntries.push({
1233
+ ...rule,
1234
+ source,
1235
+ values: name ? valuesManager.getForRule(name) : {},
1236
+ });
1237
+ }
1238
+ }
1239
+ }
1240
+ const inferenceRules = [...configRules, ...virtualRuleEntries];
1241
+ const doc = {
1242
+ description: config['description'] ?? '',
1243
+ watch: config.watch,
1244
+ configWatch: config.configWatch ?? {},
1245
+ search: config.search ?? {},
1246
+ schemas: config.schemas ?? {},
1247
+ inferenceRules,
1248
+ mapHelpers: buildHelperSection(config.mapHelpers, helperExports?.mapHelpers, helperIntrospection?.mapHelpers),
1249
+ templateHelpers: buildHelperSection(config.templateHelpers, helperExports?.templateHelpers, helperIntrospection?.templateHelpers),
1250
+ maps: config.maps ?? {},
1251
+ templates: config.templates ?? {},
1252
+ issues: issuesManager.getAll(),
1621
1253
  };
1622
- if (extractedFrontmatter)
1623
- attrs.frontmatter = extractedFrontmatter;
1624
- if (extractedJson)
1625
- attrs.json = extractedJson;
1626
- return attrs;
1254
+ // Always resolve file references
1255
+ return resolveReferences(doc, ['files', 'globals']);
1627
1256
  }
1628
1257
  /**
1629
- * Build synthetic file attributes from a path string (no actual file I/O).
1630
- * Used by API handlers that need to match rules against paths without reading files.
1258
+ * Resolve file references in known config reference positions only.
1631
1259
  *
1632
- * @param filePath - The file path.
1633
- * @returns Synthetic file attributes with zeroed stats.
1260
+ * Resolves strings in:
1261
+ * - `inferenceRules[*].map` when ending in `.json` (if 'files' in resolveTypes)
1262
+ * - `maps[*]` when a string (file path) (if 'files' in resolveTypes)
1263
+ * - `templates[*]` when a string (file path) (if 'files' in resolveTypes)
1264
+ * - `inferenceRules[*].schema` named references (if 'globals' in resolveTypes)
1265
+ *
1266
+ * @param doc - The document to resolve.
1267
+ * @param resolveTypes - Which resolution types to apply.
1268
+ * @returns The resolved document.
1269
+ */
1270
+ function resolveReferences(doc, resolveTypes) {
1271
+ const resolved = { ...doc };
1272
+ // Resolve inferenceRules[*] references
1273
+ if (Array.isArray(resolved['inferenceRules'])) {
1274
+ resolved['inferenceRules'] = resolved['inferenceRules'].map((rule) => {
1275
+ let updatedRule = { ...rule };
1276
+ // Resolve map file references (if 'files' requested)
1277
+ if (resolveTypes.includes('files') &&
1278
+ typeof rule['map'] === 'string' &&
1279
+ rule['map'].endsWith('.json')) {
1280
+ updatedRule = { ...updatedRule, map: readFileReference(rule['map']) };
1281
+ }
1282
+ // Resolve schema named references (if 'globals' requested)
1283
+ if (resolveTypes.includes('globals') &&
1284
+ Array.isArray(rule['schema']) &&
1285
+ typeof resolved['schemas'] === 'object') {
1286
+ const globalSchemas = resolved['schemas'];
1287
+ const expandedSchemas = rule['schema'].map((ref) => {
1288
+ if (typeof ref === 'string' && globalSchemas[ref]) {
1289
+ return globalSchemas[ref];
1290
+ }
1291
+ return ref;
1292
+ });
1293
+ updatedRule = { ...updatedRule, schema: expandedSchemas };
1294
+ }
1295
+ return updatedRule;
1296
+ });
1297
+ }
1298
+ // Resolve maps[*] file references (if 'files' requested)
1299
+ if (resolveTypes.includes('files') &&
1300
+ resolved['maps'] &&
1301
+ typeof resolved['maps'] === 'object') {
1302
+ const maps = { ...resolved['maps'] };
1303
+ for (const [key, value] of Object.entries(maps)) {
1304
+ if (typeof value === 'string') {
1305
+ maps[key] = readFileReference(value);
1306
+ }
1307
+ }
1308
+ resolved['maps'] = maps;
1309
+ }
1310
+ // Resolve templates[*] file references (if 'files' requested)
1311
+ if (resolveTypes.includes('files') &&
1312
+ resolved['templates'] &&
1313
+ typeof resolved['templates'] === 'object') {
1314
+ const templates = {
1315
+ ...resolved['templates'],
1316
+ };
1317
+ for (const [key, value] of Object.entries(templates)) {
1318
+ if (typeof value === 'string') {
1319
+ templates[key] = readFileReference(value);
1320
+ }
1321
+ }
1322
+ resolved['templates'] = templates;
1323
+ }
1324
+ return resolved;
1325
+ }
1326
+
1327
+ /**
1328
+ * @module api/handlers/configQuery
1329
+ * Fastify route handler for GET /config. Returns the full resolved merged document,
1330
+ * optionally filtered by JSONPath via the `path` query parameter.
1331
+ *
1332
+ * Delegates JSON querying to `createConfigQueryHandler` from `@karmaniverous/jeeves` core.
1333
+ */
1334
+ /**
1335
+ * Create handler for GET /config.
1336
+ *
1337
+ * Uses `createConfigQueryHandler` from core to handle JSONPath querying.
1338
+ * Invalid JSONPath expressions are client errors and return 400.
1339
+ *
1340
+ * @param deps - Route dependencies.
1634
1341
  */
1635
- function buildSyntheticAttributes(filePath) {
1636
- const normalised = normalizeSlashes(filePath);
1637
- return {
1638
- file: {
1639
- path: normalised,
1640
- directory: normalizeSlashes(dirname(normalised)),
1641
- filename: basename(normalised),
1642
- extension: extname(normalised),
1643
- sizeBytes: 0,
1644
- modified: new Date(0).toISOString(),
1645
- },
1342
+ function createConfigQueryHandler(deps) {
1343
+ const coreHandler = createConfigQueryHandler$1(() => buildMergedDocument({
1344
+ config: deps.getConfig(),
1345
+ valuesManager: deps.valuesManager,
1346
+ issuesManager: deps.issuesManager,
1347
+ helperIntrospection: deps.helperIntrospection,
1348
+ virtualRules: deps.getVirtualRules?.(),
1349
+ }));
1350
+ return async (request, reply) => {
1351
+ try {
1352
+ const { path } = request.query;
1353
+ const result = await coreHandler({ path });
1354
+ if (result.status >= 400) {
1355
+ return await reply.status(result.status).send(result.body);
1356
+ }
1357
+ return result.body;
1358
+ }
1359
+ catch (error) {
1360
+ const err = normalizeError(error);
1361
+ deps.logger.error({ err }, 'Config query failed');
1362
+ return await reply.status(400).send({
1363
+ error: err.message || 'Query failed',
1364
+ });
1365
+ }
1646
1366
  };
1647
1367
  }
1648
1368
 
1649
1369
  /**
1650
- * @module rules/ajvSetup
1651
- * AJV instance factory with custom glob keyword for picomatch-based pattern matching in rule schemas.
1370
+ * @module api/handlers/configReindex
1371
+ * Fastify route handler for POST /reindex. Triggers an async reindex job scoped to issues, full, rules, path, or prune processing.
1652
1372
  */
1653
1373
  /**
1654
- * Create an AJV instance with a custom `glob` format for picomatch glob matching.
1374
+ * Create handler for POST /reindex.
1655
1375
  *
1656
- * @returns The configured AJV instance.
1376
+ * @param deps - Route dependencies.
1657
1377
  */
1658
- function createRuleAjv() {
1659
- const ajv = new Ajv({ allErrors: true, strict: false });
1660
- addFormats(ajv);
1661
- ajv.addKeyword({
1662
- keyword: 'glob',
1663
- type: 'string',
1664
- schemaType: 'string',
1665
- validate: (pattern, data) => picomatch.isMatch(data, pattern, { dot: true, nocase: true }),
1666
- });
1667
- return ajv;
1378
+ function createConfigReindexHandler(deps) {
1379
+ return wrapHandler(async (request, reply) => {
1380
+ const scope = request.body.scope ?? 'issues';
1381
+ const dryRun = request.body.dryRun ?? false;
1382
+ if (!VALID_REINDEX_SCOPES.includes(scope)) {
1383
+ return await reply.status(400).send({
1384
+ error: 'Invalid scope',
1385
+ message: `Scope must be one of: ${VALID_REINDEX_SCOPES.join(', ')}. Got: "${scope}"`,
1386
+ });
1387
+ }
1388
+ const validScope = scope;
1389
+ if (validScope === 'path') {
1390
+ const { path } = request.body;
1391
+ if (!path || (Array.isArray(path) && path.length === 0)) {
1392
+ return await reply.status(400).send({
1393
+ error: 'Missing path',
1394
+ message: 'The "path" field is required when scope is "path".',
1395
+ });
1396
+ }
1397
+ }
1398
+ if (validScope === 'prune' && !deps.vectorStore) {
1399
+ return await reply.status(400).send({
1400
+ error: 'Not available',
1401
+ message: 'Prune scope requires vectorStore to be configured.',
1402
+ });
1403
+ }
1404
+ const reindexDeps = {
1405
+ config: deps.getConfig(),
1406
+ processor: deps.processor,
1407
+ logger: deps.logger,
1408
+ reindexTracker: deps.reindexTracker,
1409
+ valuesManager: deps.valuesManager,
1410
+ issuesManager: deps.issuesManager,
1411
+ gitignoreFilter: deps.gitignoreFilter,
1412
+ vectorStore: deps.vectorStore,
1413
+ queue: deps.queue,
1414
+ };
1415
+ // Pass path for 'path' and 'rules' scopes
1416
+ const pathParam = validScope === 'path' || validScope === 'rules'
1417
+ ? request.body.path
1418
+ : undefined;
1419
+ if (dryRun) {
1420
+ // Dry run: compute plan synchronously and return
1421
+ const result = await executeReindex(reindexDeps, validScope, pathParam, true);
1422
+ return await reply.status(200).send({
1423
+ status: 'dry_run',
1424
+ scope,
1425
+ plan: result.plan,
1426
+ });
1427
+ }
1428
+ // Fire and forget — plan is computed inside but we need it for the response.
1429
+ // For non-prune scopes, compute plan first then execute async.
1430
+ const planResult = await executeReindex(reindexDeps, validScope, pathParam, true);
1431
+ // Now fire actual reindex async
1432
+ void executeReindex(reindexDeps, validScope, pathParam, false);
1433
+ return await reply
1434
+ .status(200)
1435
+ .send({ status: 'started', scope, plan: planResult.plan });
1436
+ }, deps.logger, 'Reindex request');
1668
1437
  }
1669
1438
 
1670
1439
  /**
1671
- * @module rules/compile
1672
- * Compiles inference rule definitions into executable AJV validators for efficient rule evaluation.
1440
+ * @module config/schemas/base
1441
+ * Base configuration schemas: watch, logging, API.
1673
1442
  */
1674
1443
  /**
1675
- * Validate that all rule names are unique.
1676
- * Throws if duplicate names are found.
1677
- *
1678
- * @param rules - The inference rule definitions.
1444
+ * Watch configuration for file system monitoring.
1679
1445
  */
1680
- function validateRuleNameUniqueness(rules) {
1681
- const names = new Set();
1682
- const duplicates = [];
1683
- for (const rule of rules) {
1684
- if (names.has(rule.name)) {
1685
- duplicates.push(rule.name);
1686
- }
1687
- else {
1688
- names.add(rule.name);
1689
- }
1690
- }
1691
- if (duplicates.length > 0) {
1692
- throw new Error(`Duplicate inference rule names found: ${duplicates.join(', ')}. Rule names must be unique.`);
1693
- }
1694
- }
1446
+ const watchConfigSchema = z.object({
1447
+ /** Glob patterns to watch. */
1448
+ paths: z
1449
+ .array(z.string())
1450
+ .min(1)
1451
+ .describe('Glob patterns for files to watch (e.g., "**/*.md"). At least one required.'),
1452
+ /** Glob patterns to ignore. */
1453
+ ignored: z
1454
+ .array(z.string())
1455
+ .optional()
1456
+ .describe('Glob patterns to exclude from watching (e.g., "**/node_modules/**").'),
1457
+ /** Polling interval in milliseconds. */
1458
+ pollIntervalMs: z
1459
+ .number()
1460
+ .optional()
1461
+ .describe('Polling interval in milliseconds when usePolling is enabled.'),
1462
+ /** Whether to use polling instead of native watchers. */
1463
+ usePolling: z
1464
+ .boolean()
1465
+ .optional()
1466
+ .describe('Use polling instead of native file system events (for network drives).'),
1467
+ /** Debounce delay in milliseconds for file change events. */
1468
+ debounceMs: z
1469
+ .number()
1470
+ .optional()
1471
+ .describe('Debounce delay in milliseconds for file change events.'),
1472
+ /** Time in milliseconds a file must be stable before processing. */
1473
+ stabilityThresholdMs: z
1474
+ .number()
1475
+ .optional()
1476
+ .describe('Time in milliseconds a file must remain unchanged before processing.'),
1477
+ /** Whether to respect .gitignore files when processing. */
1478
+ respectGitignore: z
1479
+ .boolean()
1480
+ .optional()
1481
+ .describe('Skip files ignored by .gitignore in git repositories. Only applies to repos with a .git directory. Default: true.'),
1482
+ /** Move detection configuration for correlating unlink+add as file moves. */
1483
+ moveDetection: z
1484
+ .object({
1485
+ /** Enable move correlation. Default: true. */
1486
+ enabled: z
1487
+ .boolean()
1488
+ .default(true)
1489
+ .describe('Enable move detection via content hash correlation.'),
1490
+ /** Buffer time in ms for holding unlink events before treating as deletes. Default: 2000. */
1491
+ bufferMs: z
1492
+ .number()
1493
+ .int()
1494
+ .min(100)
1495
+ .default(2000)
1496
+ .describe('How long (ms) to buffer unlink events before treating as deletes.'),
1497
+ })
1498
+ .optional()
1499
+ .describe('Move detection: correlate unlink+add events as file moves to avoid re-embedding.'),
1500
+ });
1695
1501
  /**
1696
- * Compile an array of inference rules into executable validators.
1697
- * Validates rule name uniqueness before compilation.
1698
- *
1699
- * @param rules - The inference rule definitions.
1700
- * @returns An array of compiled rules.
1502
+ * Configuration watch settings.
1701
1503
  */
1702
- function compileRules(rules) {
1703
- validateRuleNameUniqueness(rules);
1704
- const ajv = createRuleAjv();
1705
- return rules.map((rule) => ({
1706
- rule,
1707
- validate: ajv.compile({
1708
- $id: rule.name,
1709
- ...rule.match,
1710
- }),
1711
- }));
1712
- }
1504
+ const configWatchConfigSchema = z.object({
1505
+ /** Whether config file watching is enabled. */
1506
+ enabled: z
1507
+ .boolean()
1508
+ .optional()
1509
+ .describe('Enable automatic reloading when config file changes.'),
1510
+ /** Debounce delay in milliseconds for config change events. */
1511
+ debounceMs: z
1512
+ .number()
1513
+ .optional()
1514
+ .describe('Debounce delay in milliseconds for config file change detection.'),
1515
+ /** Reindex scope triggered on config change. */
1516
+ reindex: z
1517
+ .union([
1518
+ z
1519
+ .literal('issues')
1520
+ .describe('Re-process only files with recorded issues.'),
1521
+ z.literal('full').describe('Full reindex of all watched files.'),
1522
+ z
1523
+ .literal('rules')
1524
+ .describe('Re-apply inference rules to existing points without re-embedding.'),
1525
+ ])
1526
+ .optional()
1527
+ .describe('Reindex scope triggered on config change. Default: issues.'),
1528
+ });
1529
+ /**
1530
+ * API server configuration.
1531
+ */
1532
+ const apiConfigSchema = z.object({
1533
+ /** Host to bind to. */
1534
+ host: z
1535
+ .string()
1536
+ .optional()
1537
+ .describe('Host address for API server (e.g., "127.0.0.1", "0.0.0.0").'),
1538
+ /** Port to listen on. */
1539
+ port: z.number().optional().describe('Port for API server (e.g., 1936).'),
1540
+ /** Read endpoint cache TTL in milliseconds. */
1541
+ cacheTtlMs: z
1542
+ .number()
1543
+ .optional()
1544
+ .describe('TTL in milliseconds for caching read-heavy endpoints (e.g., /status, /config). Default: 30000.'),
1545
+ });
1546
+ /**
1547
+ * Logging configuration.
1548
+ */
1549
+ const loggingConfigSchema = z.object({
1550
+ /** Log level. */
1551
+ level: z
1552
+ .string()
1553
+ .optional()
1554
+ .describe('Logging level (trace, debug, info, warn, error, fatal).'),
1555
+ /** Log file path. */
1556
+ file: z
1557
+ .string()
1558
+ .optional()
1559
+ .describe('Path to log file (logs to stdout if omitted).'),
1560
+ });
1713
1561
 
1714
1562
  /**
1715
- * @module api/handlers/configMatch
1716
- * Tests file paths against inference rules and watch scope.
1717
- */
1718
- /**
1719
- * Factory for POST /config/match handler.
1720
- *
1721
- * @param options - Handler options.
1722
- * @returns The handler function.
1563
+ * @module config/schemas/inference
1564
+ * Inference rule and schema configuration schemas.
1723
1565
  */
1724
- function createConfigMatchHandler(options) {
1725
- const { getConfig, logger } = options;
1726
- // Cache compiled artifacts per config reference — recompute only on hot-reload.
1727
- let cachedConfig;
1728
- let compiledRules = [];
1729
- let watchMatcher;
1730
- let ignoreMatcher;
1731
- const handler = async (req, res) => {
1732
- const body = req.body;
1733
- if (!Array.isArray(body.paths)) {
1734
- res.code(400).send({ error: 'Request body must include "paths" array' });
1735
- return;
1736
- }
1737
- const config = getConfig();
1738
- if (config !== cachedConfig) {
1739
- compiledRules = compileRules(config.inferenceRules ?? []);
1740
- watchMatcher = picomatch(config.watch.paths, { dot: true });
1741
- ignoreMatcher = config.watch.ignored?.length
1742
- ? picomatch(config.watch.ignored, { dot: true })
1743
- : undefined;
1744
- cachedConfig = config;
1745
- }
1746
- const matches = body.paths.map((path) => {
1747
- const attrs = buildSyntheticAttributes(path);
1748
- const matchingRules = [];
1749
- for (const compiled of compiledRules) {
1750
- if (compiled.validate(attrs)) {
1751
- matchingRules.push(compiled.rule.name);
1752
- }
1753
- }
1754
- const normalised = attrs.file.path;
1755
- const watched = (watchMatcher?.(normalised) ?? false) &&
1756
- !(ignoreMatcher?.(normalised) ?? false);
1757
- return { rules: matchingRules, watched };
1758
- });
1759
- const response = { matches };
1760
- res.send(response);
1761
- };
1762
- return wrapHandler(handler, logger, 'Config match');
1763
- }
1764
-
1765
1566
  /**
1766
- * @module api/mergedDocument
1767
- * Builds a merged virtual document from config, values, and issues for JSONPath querying.
1567
+ * A JSON Schema property definition with optional custom keywords.
1568
+ * Supports standard JSON Schema keywords plus custom `set` and `uiHint`.
1768
1569
  */
1570
+ const propertySchemaSchema = z.record(z.string(), z.unknown());
1769
1571
  /**
1770
- * Build a helper section for the merged document, injecting introspection exports per namespace.
1572
+ * A schema object: properties with JSON Schema definitions.
1771
1573
  */
1772
- function buildHelperSection(configHelpers, legacyExports, introspection) {
1773
- if (!configHelpers)
1774
- return {};
1775
- const result = {};
1776
- for (const [name, entry] of Object.entries(configHelpers)) {
1777
- result[name] = {
1778
- ...entry,
1779
- ...(introspection?.[name]?.exports
1780
- ? { exports: introspection[name].exports }
1781
- : {}),
1782
- };
1783
- }
1784
- if (legacyExports) {
1785
- result['_exports'] = legacyExports;
1786
- }
1787
- return result;
1788
- }
1574
+ const schemaObjectSchema = z.object({
1575
+ type: z
1576
+ .literal('object')
1577
+ .optional()
1578
+ .describe('JSON Schema type (always "object" for schema definitions).'),
1579
+ properties: z
1580
+ .record(z.string(), propertySchemaSchema)
1581
+ .optional()
1582
+ .describe('Map of property names to JSON Schema property definitions.'),
1583
+ });
1789
1584
  /**
1790
- * Safely read and parse a file reference. Returns the original string on failure.
1585
+ * Global schema entry: inline object or file path.
1791
1586
  */
1792
- function readFileReference(filePath) {
1793
- try {
1794
- const content = readFileSync(filePath, 'utf-8');
1795
- if (filePath.endsWith('.json')) {
1796
- return JSON.parse(content);
1797
- }
1798
- return content;
1799
- }
1800
- catch {
1801
- return filePath;
1802
- }
1803
- }
1587
+ const schemaEntrySchema = z.union([
1588
+ schemaObjectSchema,
1589
+ z.string().describe('File path to a JSON schema file.'),
1590
+ ]);
1804
1591
  /**
1805
- * Build a merged virtual document combining config, values, and issues.
1806
- *
1807
- * @param options - The build options.
1808
- * @returns The merged document object.
1592
+ * Schema reference: either a named schema reference (string) or an inline schema object.
1809
1593
  */
1810
- function buildMergedDocument(options) {
1811
- const { config, valuesManager, issuesManager, helperExports, helperIntrospection, virtualRules, } = options;
1812
- // Merge config rules (source: 'config') and virtual rules (source: registration key)
1813
- const configRules = (config.inferenceRules ?? []).map((rule) => ({
1814
- ...rule,
1815
- source: 'config',
1816
- values: valuesManager.getForRule(rule.name),
1817
- }));
1818
- const virtualRuleEntries = [];
1819
- if (virtualRules) {
1820
- for (const [source, rules] of Object.entries(virtualRules)) {
1821
- for (const rule of rules) {
1822
- const name = typeof rule.name === 'string' ? rule.name : undefined;
1823
- virtualRuleEntries.push({
1824
- ...rule,
1825
- source,
1826
- values: name ? valuesManager.getForRule(name) : {},
1827
- });
1828
- }
1829
- }
1830
- }
1831
- const inferenceRules = [...configRules, ...virtualRuleEntries];
1832
- const doc = {
1833
- description: config['description'] ?? '',
1834
- watch: config.watch,
1835
- configWatch: config.configWatch ?? {},
1836
- search: config.search ?? {},
1837
- schemas: config.schemas ?? {},
1838
- inferenceRules,
1839
- mapHelpers: buildHelperSection(config.mapHelpers, helperExports?.mapHelpers, helperIntrospection?.mapHelpers),
1840
- templateHelpers: buildHelperSection(config.templateHelpers, helperExports?.templateHelpers, helperIntrospection?.templateHelpers),
1841
- maps: config.maps ?? {},
1842
- templates: config.templates ?? {},
1843
- issues: issuesManager.getAll(),
1844
- };
1845
- // Always resolve file references
1846
- return resolveReferences(doc, ['files', 'globals']);
1847
- }
1594
+ const schemaReferenceSchema = z.union([
1595
+ z.string().describe('Named reference to a global schema.'),
1596
+ schemaObjectSchema,
1597
+ ]);
1598
+ /** Render body section. */
1599
+ const renderBodySectionSchema = z.object({
1600
+ /** Key path in the template context to render. */
1601
+ path: z.string().min(1).describe('Key path in template context to render.'),
1602
+ /** Markdown heading level for this section (1-6). */
1603
+ heading: z.number().min(1).max(6).describe('Markdown heading level (1-6).'),
1604
+ /** Override heading text (default: titlecased path). */
1605
+ label: z.string().optional().describe('Override heading text.'),
1606
+ /** Name of a registered Handlebars helper used as a format handler. */
1607
+ format: z
1608
+ .string()
1609
+ .optional()
1610
+ .describe('Name of a registered Handlebars helper used as a format handler.'),
1611
+ /** Additional args passed to the format helper. */
1612
+ formatArgs: z
1613
+ .array(z.unknown())
1614
+ .optional()
1615
+ .describe('Additional args passed to the format helper.'),
1616
+ /** If true, the value at path is treated as an array and iterated. */
1617
+ each: z
1618
+ .boolean()
1619
+ .optional()
1620
+ .describe('If true, the value at path is treated as an array and iterated.'),
1621
+ /** Handlebars template string for per-item heading text (used when each=true). */
1622
+ headingTemplate: z
1623
+ .string()
1624
+ .optional()
1625
+ .describe('Handlebars template string for per-item heading text (used when each=true).'),
1626
+ /** Key path within each item to use as renderable content (used when each=true). */
1627
+ contentPath: z
1628
+ .string()
1629
+ .optional()
1630
+ .describe('Key path within each item to use as renderable content (used when each=true).'),
1631
+ /** Key path within each item to sort by (used when each=true). */
1632
+ sort: z
1633
+ .string()
1634
+ .optional()
1635
+ .describe('Key path within each item to sort by (used when each=true).'),
1636
+ });
1637
+ /** Render config: YAML frontmatter + ordered body sections. */
1638
+ const renderConfigSchema = z.object({
1639
+ /** Keys or glob patterns to extract from context and include as YAML frontmatter. */
1640
+ frontmatter: z
1641
+ .array(z.string().min(1))
1642
+ .describe('Keys or glob patterns to include as YAML frontmatter. ' +
1643
+ 'Supports picomatch globs (e.g. "*") and "!"-prefixed exclusion patterns (e.g. "!_*"). ' +
1644
+ 'Explicit names preserve declaration order; glob-matched keys are sorted alphabetically.'),
1645
+ /** Ordered markdown body sections. */
1646
+ body: z
1647
+ .array(renderBodySectionSchema)
1648
+ .describe('Ordered markdown body sections.'),
1649
+ });
1848
1650
  /**
1849
- * Resolve file references in known config reference positions only.
1850
- *
1851
- * Resolves strings in:
1852
- * - `inferenceRules[*].map` when ending in `.json` (if 'files' in resolveTypes)
1853
- * - `maps[*]` when a string (file path) (if 'files' in resolveTypes)
1854
- * - `templates[*]` when a string (file path) (if 'files' in resolveTypes)
1855
- * - `inferenceRules[*].schema` named references (if 'globals' in resolveTypes)
1856
- *
1857
- * @param doc - The document to resolve.
1858
- * @param resolveTypes - Which resolution types to apply.
1859
- * @returns The resolved document.
1651
+ * An inference rule that enriches document metadata.
1860
1652
  */
1861
- function resolveReferences(doc, resolveTypes) {
1862
- const resolved = { ...doc };
1863
- // Resolve inferenceRules[*] references
1864
- if (Array.isArray(resolved['inferenceRules'])) {
1865
- resolved['inferenceRules'] = resolved['inferenceRules'].map((rule) => {
1866
- let updatedRule = { ...rule };
1867
- // Resolve map file references (if 'files' requested)
1868
- if (resolveTypes.includes('files') &&
1869
- typeof rule['map'] === 'string' &&
1870
- rule['map'].endsWith('.json')) {
1871
- updatedRule = { ...updatedRule, map: readFileReference(rule['map']) };
1872
- }
1873
- // Resolve schema named references (if 'globals' requested)
1874
- if (resolveTypes.includes('globals') &&
1875
- Array.isArray(rule['schema']) &&
1876
- typeof resolved['schemas'] === 'object') {
1877
- const globalSchemas = resolved['schemas'];
1878
- const expandedSchemas = rule['schema'].map((ref) => {
1879
- if (typeof ref === 'string' && globalSchemas[ref]) {
1880
- return globalSchemas[ref];
1881
- }
1882
- return ref;
1883
- });
1884
- updatedRule = { ...updatedRule, schema: expandedSchemas };
1885
- }
1886
- return updatedRule;
1653
+ const inferenceRuleSchema = z
1654
+ .object({
1655
+ /** Unique name for this inference rule. */
1656
+ name: z
1657
+ .string()
1658
+ .min(1)
1659
+ .describe('Unique name identifying this inference rule.'),
1660
+ /** Human-readable description of what this rule does. */
1661
+ description: z
1662
+ .string()
1663
+ .min(1)
1664
+ .describe('Human-readable description of what this rule does.'),
1665
+ /** JSON Schema object to match against document metadata. */
1666
+ match: z
1667
+ .record(z.string(), z.unknown())
1668
+ .describe('JSON Schema object to match against file attributes.'),
1669
+ /** Array of schema references to merge (named refs and/or inline objects). */
1670
+ schema: z
1671
+ .array(schemaReferenceSchema)
1672
+ .optional()
1673
+ .describe('Array of schema references (named schema refs or inline objects) merged left-to-right.'),
1674
+ /** JsonMap transformation (inline or reference to named map). */
1675
+ map: z
1676
+ .union([jsonMapMapSchema, z.string()])
1677
+ .optional()
1678
+ .describe('JsonMap transformation (inline definition, named map reference, or .json file path).'),
1679
+ /** Handlebars template (inline string, named ref, or .hbs/.handlebars file path). */
1680
+ template: z
1681
+ .string()
1682
+ .optional()
1683
+ .describe('Handlebars content template (inline string, named ref, or .hbs/.handlebars file path).'),
1684
+ /** Declarative structured renderer configuration (mutually exclusive with template). */
1685
+ render: renderConfigSchema
1686
+ .optional()
1687
+ .describe('Declarative render configuration for frontmatter + structured Markdown output (mutually exclusive with template).'),
1688
+ /** Output file extension override (e.g. "md", "html", "txt"). Requires template or render. */
1689
+ renderAs: z
1690
+ .string()
1691
+ .regex(/^[a-z0-9]{1,10}$/, 'renderAs must be 1-10 lowercase alphanumeric characters')
1692
+ .optional()
1693
+ .describe('Output file extension override (without dot). Requires template or render.'),
1694
+ })
1695
+ .superRefine((val, ctx) => {
1696
+ if (val.render && val.template) {
1697
+ ctx.addIssue({
1698
+ code: 'custom',
1699
+ path: ['render'],
1700
+ message: 'render is mutually exclusive with template',
1887
1701
  });
1888
1702
  }
1889
- // Resolve maps[*] file references (if 'files' requested)
1890
- if (resolveTypes.includes('files') &&
1891
- resolved['maps'] &&
1892
- typeof resolved['maps'] === 'object') {
1893
- const maps = { ...resolved['maps'] };
1894
- for (const [key, value] of Object.entries(maps)) {
1895
- if (typeof value === 'string') {
1896
- maps[key] = readFileReference(value);
1897
- }
1898
- }
1899
- resolved['maps'] = maps;
1900
- }
1901
- // Resolve templates[*] file references (if 'files' requested)
1902
- if (resolveTypes.includes('files') &&
1903
- resolved['templates'] &&
1904
- typeof resolved['templates'] === 'object') {
1905
- const templates = {
1906
- ...resolved['templates'],
1907
- };
1908
- for (const [key, value] of Object.entries(templates)) {
1909
- if (typeof value === 'string') {
1910
- templates[key] = readFileReference(value);
1911
- }
1912
- }
1913
- resolved['templates'] = templates;
1703
+ if (val.renderAs && !val.template && !val.render) {
1704
+ ctx.addIssue({
1705
+ code: 'custom',
1706
+ path: ['renderAs'],
1707
+ message: 'renderAs requires template or render',
1708
+ });
1914
1709
  }
1915
- return resolved;
1916
- }
1710
+ });
1917
1711
 
1918
1712
  /**
1919
- * @module api/handlers/configQuery
1920
- * Fastify route handler for GET /config. Returns the full resolved merged document,
1921
- * optionally filtered by JSONPath via the `path` query parameter.
1922
- *
1923
- * Delegates JSON querying to `createConfigQueryHandler` from `@karmaniverous/jeeves` core.
1713
+ * @module config/schemas/services
1714
+ * Service configuration schemas: embedding and vector store.
1924
1715
  */
1925
1716
  /**
1926
- * Create handler for GET /config.
1927
- *
1928
- * Uses `createConfigQueryHandler` from core to handle JSONPath querying.
1929
- * Invalid JSONPath expressions are client errors and return 400.
1930
- *
1931
- * @param deps - Route dependencies.
1717
+ * Embedding model configuration.
1932
1718
  */
1933
- function createConfigQueryHandler(deps) {
1934
- const coreHandler = createConfigQueryHandler$1(() => buildMergedDocument({
1935
- config: deps.getConfig(),
1936
- valuesManager: deps.valuesManager,
1937
- issuesManager: deps.issuesManager,
1938
- helperIntrospection: deps.helperIntrospection,
1939
- virtualRules: deps.getVirtualRules?.(),
1940
- }));
1941
- return async (request, reply) => {
1942
- try {
1943
- const { path } = request.query;
1944
- const result = await coreHandler({ path });
1945
- if (result.status >= 400) {
1946
- return await reply.status(result.status).send(result.body);
1947
- }
1948
- return result.body;
1949
- }
1950
- catch (error) {
1951
- const err = normalizeError(error);
1952
- deps.logger.error({ err }, 'Config query failed');
1953
- return await reply.status(400).send({
1954
- error: err.message || 'Query failed',
1955
- });
1956
- }
1957
- };
1958
- }
1719
+ const embeddingConfigSchema = z.object({
1720
+ /** The embedding model provider. */
1721
+ provider: z
1722
+ .string()
1723
+ .default('gemini')
1724
+ .describe('Embedding provider name (e.g., "gemini", "openai").'),
1725
+ /** The embedding model name. */
1726
+ model: z
1727
+ .string()
1728
+ .default('gemini-embedding-001')
1729
+ .describe('Embedding model identifier (e.g., "gemini-embedding-001", "text-embedding-3-small").'),
1730
+ /** Maximum tokens per chunk for splitting. */
1731
+ chunkSize: z
1732
+ .number()
1733
+ .optional()
1734
+ .describe('Maximum chunk size in characters for text splitting.'),
1735
+ /** Overlap between chunks in tokens. */
1736
+ chunkOverlap: z
1737
+ .number()
1738
+ .optional()
1739
+ .describe('Character overlap between consecutive chunks.'),
1740
+ /** Embedding vector dimensions. */
1741
+ dimensions: z
1742
+ .number()
1743
+ .optional()
1744
+ .describe('Embedding vector dimensions (must match model output).'),
1745
+ /** API key for the embedding provider. */
1746
+ apiKey: z
1747
+ .string()
1748
+ .optional()
1749
+ .describe('API key for embedding provider (supports ${ENV_VAR} substitution).'),
1750
+ /** Maximum embedding requests per minute. */
1751
+ rateLimitPerMinute: z
1752
+ .number()
1753
+ .optional()
1754
+ .describe('Maximum embedding API requests per minute (rate limiting).'),
1755
+ /** Maximum concurrent embedding requests. */
1756
+ concurrency: z
1757
+ .number()
1758
+ .optional()
1759
+ .describe('Maximum concurrent embedding requests.'),
1760
+ });
1761
+ /**
1762
+ * Vector store configuration for Qdrant.
1763
+ */
1764
+ const vectorStoreConfigSchema = z.object({
1765
+ /** Qdrant server URL. */
1766
+ url: z
1767
+ .string()
1768
+ .describe('Qdrant server URL (e.g., "http://localhost:6333").'),
1769
+ /** Qdrant collection name. */
1770
+ collectionName: z
1771
+ .string()
1772
+ .describe('Qdrant collection name for vector storage.'),
1773
+ /** Qdrant API key. */
1774
+ apiKey: z
1775
+ .string()
1776
+ .optional()
1777
+ .describe('Qdrant API key for authentication (supports ${ENV_VAR} substitution).'),
1778
+ });
1959
1779
 
1960
1780
  /**
1961
- * @module api/handlers/configReindex
1962
- * Fastify route handler for POST /reindex. Triggers an async reindex job scoped to issues, full, rules, path, or prune processing.
1781
+ * @module config/schemas/root
1782
+ * Root configuration schema combining all sub-schemas.
1963
1783
  */
1964
1784
  /**
1965
- * Create handler for POST /reindex.
1966
- *
1967
- * @param deps - Route dependencies.
1785
+ * Top-level configuration for jeeves-watcher.
1968
1786
  */
1969
- function createConfigReindexHandler(deps) {
1970
- return wrapHandler(async (request, reply) => {
1971
- const scope = request.body.scope ?? 'issues';
1972
- const dryRun = request.body.dryRun ?? false;
1973
- if (!VALID_REINDEX_SCOPES.includes(scope)) {
1974
- return await reply.status(400).send({
1975
- error: 'Invalid scope',
1976
- message: `Scope must be one of: ${VALID_REINDEX_SCOPES.join(', ')}. Got: "${scope}"`,
1977
- });
1978
- }
1979
- const validScope = scope;
1980
- if (validScope === 'path') {
1981
- const { path } = request.body;
1982
- if (!path || (Array.isArray(path) && path.length === 0)) {
1983
- return await reply.status(400).send({
1984
- error: 'Missing path',
1985
- message: 'The "path" field is required when scope is "path".',
1986
- });
1987
- }
1988
- }
1989
- if (validScope === 'prune' && !deps.vectorStore) {
1990
- return await reply.status(400).send({
1991
- error: 'Not available',
1992
- message: 'Prune scope requires vectorStore to be configured.',
1993
- });
1994
- }
1995
- const reindexDeps = {
1996
- config: deps.getConfig(),
1997
- processor: deps.processor,
1998
- logger: deps.logger,
1999
- reindexTracker: deps.reindexTracker,
2000
- valuesManager: deps.valuesManager,
2001
- issuesManager: deps.issuesManager,
2002
- gitignoreFilter: deps.gitignoreFilter,
2003
- vectorStore: deps.vectorStore,
2004
- queue: deps.queue,
2005
- };
2006
- // Pass path for 'path' and 'rules' scopes
2007
- const pathParam = validScope === 'path' || validScope === 'rules'
2008
- ? request.body.path
2009
- : undefined;
2010
- if (dryRun) {
2011
- // Dry run: compute plan synchronously and return
2012
- const result = await executeReindex(reindexDeps, validScope, pathParam, true);
2013
- return await reply.status(200).send({
2014
- status: 'dry_run',
2015
- scope,
2016
- plan: result.plan,
2017
- });
2018
- }
2019
- // Fire and forget — plan is computed inside but we need it for the response.
2020
- // For non-prune scopes, compute plan first then execute async.
2021
- const planResult = await executeReindex(reindexDeps, validScope, pathParam, true);
2022
- // Now fire actual reindex async
2023
- void executeReindex(reindexDeps, validScope, pathParam, false);
2024
- return await reply
2025
- .status(200)
2026
- .send({ status: 'started', scope, plan: planResult.plan });
2027
- }, deps.logger, 'Reindex request');
2028
- }
1787
+ const jeevesWatcherConfigSchema = z.object({
1788
+ /** Optional description of this watcher deployment's organizational strategy. */
1789
+ description: z
1790
+ .string()
1791
+ .optional()
1792
+ .describe("Human-readable description of this deployment's organizational strategy and content domains."),
1793
+ /** Global named schema collection. */
1794
+ schemas: z
1795
+ .record(z.string(), schemaEntrySchema)
1796
+ .optional()
1797
+ .describe('Global named schema definitions (inline objects or file paths) referenced by inference rules.'),
1798
+ /** File system watch configuration. */
1799
+ watch: watchConfigSchema.describe('File system watch configuration.'),
1800
+ /** Configuration file watch settings. */
1801
+ configWatch: configWatchConfigSchema
1802
+ .optional()
1803
+ .describe('Configuration file watch settings.'),
1804
+ /** Embedding model configuration. */
1805
+ embedding: embeddingConfigSchema.describe('Embedding model configuration.'),
1806
+ /** Vector store configuration. */
1807
+ vectorStore: vectorStoreConfigSchema.describe('Qdrant vector store configuration.'),
1808
+ /** API server configuration. */
1809
+ api: apiConfigSchema.optional().describe('API server configuration.'),
1810
+ /** Directory for persistent state files (issues.json, values.json, enrichments.sqlite). */
1811
+ stateDir: z
1812
+ .string()
1813
+ .optional()
1814
+ .describe('Directory for persistent state files (issues.json, values.json, enrichments.sqlite). Defaults to .jeeves-metadata.'),
1815
+ /** Rules for inferring metadata from document properties (inline objects or file paths). */
1816
+ inferenceRules: z
1817
+ .array(z.union([inferenceRuleSchema, z.string()]))
1818
+ .optional()
1819
+ .describe('Rules for inferring metadata from file attributes. Each entry may be an inline rule object or a file path to a JSON rule file (resolved relative to config directory).'),
1820
+ /** Reusable named JsonMap transformations (inline objects or .json file paths). */
1821
+ maps: z
1822
+ .record(z.string(), z.union([
1823
+ jsonMapMapSchema,
1824
+ z.string(),
1825
+ z.object({
1826
+ /** The JsonMap definition (inline object or file path). */
1827
+ map: jsonMapMapSchema.or(z.string()),
1828
+ /** Optional human-readable description of this map. */
1829
+ description: z.string().optional(),
1830
+ }),
1831
+ ]))
1832
+ .optional()
1833
+ .describe('Reusable named JsonMap transformations (inline definition or .json file path resolved relative to config directory).'),
1834
+ /** Reusable named Handlebars templates (inline strings or .hbs/.handlebars file paths). */
1835
+ templates: z
1836
+ .record(z.string(), z.union([
1837
+ z.string(),
1838
+ z.object({
1839
+ /** The Handlebars template source (inline string or file path). */
1840
+ template: z.string(),
1841
+ /** Optional human-readable description of this template. */
1842
+ description: z.string().optional(),
1843
+ }),
1844
+ ]))
1845
+ .optional()
1846
+ .describe('Named reusable Handlebars templates (inline strings or .hbs/.handlebars file paths).'),
1847
+ /** Custom Handlebars helper registration. */
1848
+ templateHelpers: z
1849
+ .record(z.string(), z.object({
1850
+ /** File path to the helper module (resolved relative to config directory). */
1851
+ path: z.string(),
1852
+ /** Optional human-readable description of this helper. */
1853
+ description: z.string().optional(),
1854
+ }))
1855
+ .optional()
1856
+ .describe('Custom Handlebars helper registration.'),
1857
+ /** Custom JsonMap lib function registration. */
1858
+ mapHelpers: z
1859
+ .record(z.string(), z.object({
1860
+ /** File path to the helper module (resolved relative to config directory). */
1861
+ path: z.string(),
1862
+ /** Optional human-readable description of this helper. */
1863
+ description: z.string().optional(),
1864
+ }))
1865
+ .optional()
1866
+ .describe('Custom JsonMap lib function registration.'),
1867
+ /** Reindex configuration. */
1868
+ reindex: z
1869
+ .object({
1870
+ /** URL to call when reindex completes. */
1871
+ callbackUrl: z.url().optional(),
1872
+ /** Maximum concurrent file operations during reindex. */
1873
+ concurrency: z
1874
+ .number()
1875
+ .int()
1876
+ .min(1)
1877
+ .default(50)
1878
+ .describe('Maximum concurrent file operations during reindex (default 50).'),
1879
+ })
1880
+ .optional()
1881
+ .describe('Reindex configuration.'),
1882
+ /** Named Qdrant filter patterns for skill-activated behaviors. */
1883
+ /** Search configuration including score thresholds and hybrid search. */
1884
+ search: z
1885
+ .object({
1886
+ /** Score thresholds for categorizing search result quality. */
1887
+ scoreThresholds: z
1888
+ .object({
1889
+ /** Minimum score for a result to be considered a strong match. */
1890
+ strong: z.number().min(-1).max(1),
1891
+ /** Minimum score for a result to be considered relevant. */
1892
+ relevant: z.number().min(-1).max(1),
1893
+ /** Maximum score below which results are considered noise. */
1894
+ noise: z.number().min(-1).max(1),
1895
+ })
1896
+ .optional(),
1897
+ /** Hybrid search configuration combining vector and full-text search. */
1898
+ hybrid: z
1899
+ .object({
1900
+ /** Enable hybrid search with RRF fusion. Default: false. */
1901
+ enabled: z.boolean().default(false),
1902
+ /** Weight for text (BM25) results in RRF fusion. Default: 0.3. */
1903
+ textWeight: z.number().min(0).max(1).default(0.3),
1904
+ })
1905
+ .optional(),
1906
+ })
1907
+ .optional()
1908
+ .describe('Search configuration including score thresholds and hybrid search.'),
1909
+ /** Logging configuration. */
1910
+ logging: loggingConfigSchema.optional().describe('Logging configuration.'),
1911
+ /** Timeout in milliseconds for graceful shutdown. */
1912
+ shutdownTimeoutMs: z
1913
+ .number()
1914
+ .optional()
1915
+ .describe('Timeout in milliseconds for graceful shutdown.'),
1916
+ /** Maximum consecutive system-level failures before triggering fatal error. Default: Infinity. */
1917
+ maxRetries: z
1918
+ .number()
1919
+ .optional()
1920
+ .describe('Maximum consecutive system-level failures before triggering fatal error. Default: Infinity.'),
1921
+ /** Maximum backoff delay in milliseconds for system errors. Default: 60000. */
1922
+ maxBackoffMs: z
1923
+ .number()
1924
+ .optional()
1925
+ .describe('Maximum backoff delay in milliseconds for system errors. Default: 60000.'),
1926
+ });
2029
1927
 
2030
1928
  /**
2031
1929
  * @module api/handlers/configSchema
@@ -2610,9 +2508,10 @@ function renderDoc(context, config, hbs) {
2610
2508
  *
2611
2509
  * @param configDir - Optional config directory for resolving relative file paths in lookups.
2612
2510
  * @param customLib - Optional custom lib functions to merge.
2511
+ * @param extractText - Optional callback to extract text from a file path. Returns extracted text or undefined on failure.
2613
2512
  * @returns The lib object.
2614
2513
  */
2615
- function createJsonMapLib(configDir, customLib) {
2514
+ function createJsonMapLib(configDir, customLib, extractText) {
2616
2515
  // Cache loaded JSON files within a single applyRules invocation.
2617
2516
  const jsonCache = new Map();
2618
2517
  const loadJson = (filePath) => {
@@ -2683,6 +2582,66 @@ function createJsonMapLib(configDir, customLib) {
2683
2582
  }
2684
2583
  return results;
2685
2584
  },
2585
+ /**
2586
+ * Retrieve extracted text from neighboring files in the same directory.
2587
+ *
2588
+ * @param filePath - The current file path.
2589
+ * @param options - Optional windowing and sort options.
2590
+ * @returns Flat array of extracted text from siblings, in sort order.
2591
+ */
2592
+ fetchSiblings: (filePath, options) => {
2593
+ if (!extractText)
2594
+ return [];
2595
+ const { before = 3, after = 1, sort = 'name' } = options ?? {};
2596
+ const dir = dirname(filePath);
2597
+ const ext = extname(filePath);
2598
+ // List all files in the directory with the same extension
2599
+ let entries;
2600
+ try {
2601
+ entries = readdirSync(dir).filter((name) => extname(name) === ext);
2602
+ }
2603
+ catch {
2604
+ return [];
2605
+ }
2606
+ // Sort by filename (default) or mtime
2607
+ if (sort === 'mtime') {
2608
+ entries.sort((a, b) => {
2609
+ try {
2610
+ const aStat = statSync(join(dir, a));
2611
+ const bStat = statSync(join(dir, b));
2612
+ return aStat.mtimeMs - bStat.mtimeMs;
2613
+ }
2614
+ catch {
2615
+ return 0;
2616
+ }
2617
+ });
2618
+ }
2619
+ else {
2620
+ entries.sort();
2621
+ }
2622
+ // Find current file's position
2623
+ const currentName = basename(filePath);
2624
+ const currentIndex = entries.indexOf(currentName);
2625
+ if (currentIndex === -1)
2626
+ return [];
2627
+ // Slice before/after window (excluding current file)
2628
+ const beforeEntries = entries.slice(Math.max(0, currentIndex - before), currentIndex);
2629
+ const afterEntries = entries.slice(currentIndex + 1, currentIndex + 1 + after);
2630
+ const window = [...beforeEntries, ...afterEntries];
2631
+ // Extract text from each sibling, silently skipping failures
2632
+ const results = [];
2633
+ for (const name of window) {
2634
+ try {
2635
+ const text = extractText(join(dir, name));
2636
+ if (text !== undefined)
2637
+ results.push(text);
2638
+ }
2639
+ catch {
2640
+ // Silently skip files that fail extraction
2641
+ }
2642
+ }
2643
+ return results;
2644
+ },
2686
2645
  ...customLib,
2687
2646
  };
2688
2647
  }
@@ -2703,9 +2662,12 @@ function coerceType(value, type) {
2703
2662
  if (value === null || value === undefined) {
2704
2663
  return undefined;
2705
2664
  }
2665
+ // Empty strings are only valid for the 'string' type
2666
+ if (value === '' && type !== 'string') {
2667
+ return undefined;
2668
+ }
2706
2669
  switch (type) {
2707
2670
  case 'string': {
2708
- // Empty strings are valid for string type
2709
2671
  if (typeof value === 'string')
2710
2672
  return value;
2711
2673
  if (typeof value === 'number' || typeof value === 'boolean') {
@@ -2719,9 +2681,6 @@ function coerceType(value, type) {
2719
2681
  return undefined;
2720
2682
  }
2721
2683
  case 'integer': {
2722
- // Empty strings are not valid integers
2723
- if (value === '')
2724
- return undefined;
2725
2684
  if (typeof value === 'string') {
2726
2685
  // For strings, check that parsing yields an integer that matches the trimmed input
2727
2686
  const trimmed = value.trim();
@@ -2737,16 +2696,10 @@ function coerceType(value, type) {
2737
2696
  return undefined;
2738
2697
  }
2739
2698
  case 'number': {
2740
- // Empty strings are not valid numbers
2741
- if (value === '')
2742
- return undefined;
2743
2699
  const num = typeof value === 'string' ? parseFloat(value) : Number(value);
2744
2700
  return Number.isFinite(num) ? num : undefined;
2745
2701
  }
2746
2702
  case 'boolean': {
2747
- // Empty strings are not valid booleans
2748
- if (value === '')
2749
- return undefined;
2750
2703
  if (typeof value === 'boolean')
2751
2704
  return value;
2752
2705
  if (typeof value === 'string') {
@@ -2759,9 +2712,6 @@ function coerceType(value, type) {
2759
2712
  return undefined;
2760
2713
  }
2761
2714
  case 'array': {
2762
- // Empty strings are not valid arrays
2763
- if (value === '')
2764
- return undefined;
2765
2715
  if (Array.isArray(value))
2766
2716
  return value;
2767
2717
  if (typeof value === 'string') {
@@ -2777,9 +2727,6 @@ function coerceType(value, type) {
2777
2727
  return undefined;
2778
2728
  }
2779
2729
  case 'object': {
2780
- // Empty strings are not valid objects
2781
- if (value === '')
2782
- return undefined;
2783
2730
  if (typeof value === 'string') {
2784
2731
  try {
2785
2732
  const parsed = JSON.parse(value);
@@ -3011,11 +2958,11 @@ async function loadCustomMapHelpers(helpers, configDir) {
3011
2958
  * @returns The merged metadata and optional rendered content.
3012
2959
  */
3013
2960
  async function applyRules(compiledRules, attributes, options = {}) {
3014
- const { namedMaps, logger, templateEngine, configDir, customMapLib, globalSchemas, } = options;
2961
+ const { namedMaps, logger, templateEngine, configDir, customMapLib, globalSchemas, extractText, } = options;
3015
2962
  const hbs = templateEngine?.hbs ?? createHandlebarsInstance();
3016
2963
  // JsonMap's type definitions expect a generic JsonMapLib shape with unary functions.
3017
2964
  // Our helper functions accept multiple args, which JsonMap supports at runtime.
3018
- const lib = createJsonMapLib(configDir, customMapLib);
2965
+ const lib = createJsonMapLib(configDir, customMapLib, extractText);
3019
2966
  let merged = {};
3020
2967
  let renderedContent = null;
3021
2968
  let renderAs = null;
@@ -3135,6 +3082,80 @@ async function applyRules(compiledRules, attributes, options = {}) {
3135
3082
  return { metadata: merged, renderedContent, matchedRules, renderAs };
3136
3083
  }
3137
3084
 
3085
+ /**
3086
+ * @module api/handlers/configMerge
3087
+ * Shared config merge utilities.
3088
+ */
3089
+ /**
3090
+ * Merge inference rules by name: submitted rules replace existing by name, new ones are appended.
3091
+ */
3092
+ function mergeInferenceRules(existing, incoming) {
3093
+ if (!incoming)
3094
+ return existing ?? [];
3095
+ if (!existing)
3096
+ return incoming;
3097
+ const merged = [...existing];
3098
+ for (const rule of incoming) {
3099
+ const name = rule['name'];
3100
+ if (!name) {
3101
+ merged.push(rule);
3102
+ continue;
3103
+ }
3104
+ const idx = merged.findIndex((r) => r['name'] === name);
3105
+ if (idx >= 0) {
3106
+ merged[idx] = rule;
3107
+ }
3108
+ else {
3109
+ merged.push(rule);
3110
+ }
3111
+ }
3112
+ return merged;
3113
+ }
3114
+
3115
+ /**
3116
+ * @module api/handlers/mergeAndValidate
3117
+ * Shared config merge and validation logic used by both validate and apply handlers.
3118
+ */
3119
+ /**
3120
+ * Merge a submitted partial config into the current config and validate against schema.
3121
+ *
3122
+ * @param currentConfig - The current running config.
3123
+ * @param submittedPartial - The partial config to merge in.
3124
+ * @returns The merge/validation result.
3125
+ */
3126
+ function mergeAndValidateConfig(currentConfig, submittedPartial) {
3127
+ let candidateRaw = {
3128
+ ...currentConfig,
3129
+ };
3130
+ if (submittedPartial) {
3131
+ const mergedRules = mergeInferenceRules(candidateRaw['inferenceRules'], submittedPartial['inferenceRules']);
3132
+ candidateRaw = {
3133
+ ...candidateRaw,
3134
+ ...submittedPartial,
3135
+ inferenceRules: mergedRules,
3136
+ };
3137
+ }
3138
+ const parseResult = jeevesWatcherConfigSchema.safeParse(candidateRaw);
3139
+ const errors = [];
3140
+ if (!parseResult.success) {
3141
+ for (const issue of parseResult.error.issues) {
3142
+ errors.push({
3143
+ path: issue.path.join('.'),
3144
+ message: issue.message,
3145
+ });
3146
+ }
3147
+ return { candidateRaw, errors };
3148
+ }
3149
+ // Note: When accepting external config via API, string rule references are NOT resolved
3150
+ // (no configDir context). API-submitted rules must always be inline objects.
3151
+ // The cast is safe because API config never contains string rule refs.
3152
+ return {
3153
+ candidateRaw,
3154
+ parsed: parseResult.data,
3155
+ errors,
3156
+ };
3157
+ }
3158
+
3138
3159
  /**
3139
3160
  * @module api/handlers/configValidate
3140
3161
  * Fastify route handler for POST /config/validate. Validates config against schema with optional test paths.
@@ -3651,11 +3672,11 @@ function createRenderHandler(deps) {
3651
3672
  });
3652
3673
  }
3653
3674
  catch (error) {
3654
- const msg = error instanceof Error ? error.message : 'Unknown render error';
3675
+ const { message: msg } = normalizeError(error);
3655
3676
  if (msg.includes('ENOENT') || msg.includes('no such file')) {
3656
3677
  return reply.status(404).send({ error: 'File not found' });
3657
3678
  }
3658
- logger.error({ filePath, error: msg }, 'Render failed');
3679
+ logger.error({ filePath, err: normalizeError(error) }, 'Render failed');
3659
3680
  return reply.status(422).send({ error: msg });
3660
3681
  }
3661
3682
  };
@@ -3715,7 +3736,7 @@ function createRulesReapplyHandler(deps) {
3715
3736
  * Create handler for POST /rules/register.
3716
3737
  */
3717
3738
  function createRulesRegisterHandler(deps) {
3718
- return wrapHandler(async (request) => {
3739
+ return wrapHandler((request) => {
3719
3740
  const { source, rules } = request.body;
3720
3741
  if (!source || typeof source !== 'string') {
3721
3742
  throw new Error('Missing required field: source');
@@ -3726,11 +3747,11 @@ function createRulesRegisterHandler(deps) {
3726
3747
  deps.virtualRuleStore.register(source, rules);
3727
3748
  deps.onRulesChanged();
3728
3749
  deps.logger.info({ source, ruleCount: rules.length }, 'Virtual rules registered');
3729
- return await Promise.resolve({
3750
+ return {
3730
3751
  source,
3731
3752
  registered: rules.length,
3732
3753
  totalVirtualRules: deps.virtualRuleStore.size,
3733
- });
3754
+ };
3734
3755
  }, deps.logger, 'RulesRegister');
3735
3756
  }
3736
3757
 
@@ -3755,16 +3776,16 @@ function unregisterSource(deps, source) {
3755
3776
  * Create handler for DELETE /rules/unregister (body-based).
3756
3777
  */
3757
3778
  function createRulesUnregisterHandler(deps) {
3758
- return wrapHandler(async (request) => {
3759
- return Promise.resolve(unregisterSource(deps, request.body.source));
3779
+ return wrapHandler((request) => {
3780
+ return unregisterSource(deps, request.body.source);
3760
3781
  }, deps.logger, 'RulesUnregister');
3761
3782
  }
3762
3783
  /**
3763
3784
  * Create handler for DELETE /rules/unregister/:source (param-based).
3764
3785
  */
3765
3786
  function createRulesUnregisterParamHandler(deps) {
3766
- return wrapHandler(async (request) => {
3767
- return Promise.resolve(unregisterSource(deps, request.params.source));
3787
+ return wrapHandler((request) => {
3788
+ return unregisterSource(deps, request.params.source);
3768
3789
  }, deps.logger, 'RulesUnregister');
3769
3790
  }
3770
3791
 
@@ -4444,7 +4465,7 @@ class InitialScanTracker {
4444
4465
  * @returns A configured Fastify instance.
4445
4466
  */
4446
4467
  function createApiServer(options) {
4447
- const { processor, vectorStore, embeddingProvider, queue, logger, config, issuesManager, valuesManager, configPath, helperIntrospection, virtualRuleStore, gitignoreFilter, version, initialScanTracker, } = options;
4468
+ const { descriptor, processor, vectorStore, embeddingProvider, queue, logger, config, issuesManager, valuesManager, configPath, helperIntrospection, virtualRuleStore, gitignoreFilter, version, initialScanTracker, } = options;
4448
4469
  const getConfig = options.getConfig ?? (() => config);
4449
4470
  const getFileSystemWatcher = options.getFileSystemWatcher ?? (() => options.fileSystemWatcher);
4450
4471
  const reindexTracker = options.reindexTracker ?? new ReindexTracker();
@@ -4559,13 +4580,20 @@ function createApiServer(options) {
4559
4580
  logger,
4560
4581
  configDir: dirname(configPath),
4561
4582
  }));
4562
- app.post('/config/apply', createConfigApplyHandler({
4563
- getConfig,
4564
- configPath,
4565
- reindexTracker,
4566
- logger,
4567
- triggerReindex,
4568
- }));
4583
+ const coreConfigApplyHandler = createConfigApplyHandler({
4584
+ ...descriptor,
4585
+ onConfigApply: () => {
4586
+ const cfg = getConfig();
4587
+ const reindexScope = cfg.configWatch?.reindex ?? 'issues';
4588
+ triggerReindex(reindexScope);
4589
+ return Promise.resolve();
4590
+ },
4591
+ });
4592
+ app.post('/config/apply', async (request, reply) => {
4593
+ const { patch, replace } = request.body;
4594
+ const result = await coreConfigApplyHandler({ patch, replace });
4595
+ return reply.status(result.status).send(result.body);
4596
+ });
4569
4597
  // Virtual rules and points deletion routes
4570
4598
  if (virtualRuleStore) {
4571
4599
  const onRulesChanged = createOnRulesChanged({
@@ -4613,7 +4641,7 @@ function createApiServer(options) {
4613
4641
  * @returns The normalized path string.
4614
4642
  */
4615
4643
  function normalizePath(filePath, stripDriveLetter = false) {
4616
- let result = filePath.replace(/\\/g, '/').toLowerCase();
4644
+ let result = normalizeSlashes(filePath).toLowerCase();
4617
4645
  if (stripDriveLetter) {
4618
4646
  result = result.replace(/^([a-z]):/, (_m, letter) => letter);
4619
4647
  }
@@ -5214,7 +5242,7 @@ const CONFIG_WATCH_DEFAULTS = {
5214
5242
  /** Default API values. */
5215
5243
  const API_DEFAULTS = {
5216
5244
  host: '127.0.0.1',
5217
- port: 1936,
5245
+ port: DEFAULT_PORTS.watcher,
5218
5246
  cacheTtlMs: 30000,
5219
5247
  };
5220
5248
  /** Default logging values. */
@@ -5682,6 +5710,7 @@ function extractMarkdownFrontmatter(markdown) {
5682
5710
  : undefined;
5683
5711
  return { frontmatter, body };
5684
5712
  }
5713
+ /** Well-known JSON fields that contain meaningful text content. */
5685
5714
  const JSON_TEXT_FIELDS = [
5686
5715
  'content',
5687
5716
  'body',
@@ -5782,6 +5811,25 @@ async function extractText(filePath, extension, additionalExtractors) {
5782
5811
  * @module processor/buildMetadata
5783
5812
  * Builds merged metadata from file content, inference rules, and enrichment store. I/O: reads files, extracts text, queries SQLite enrichment.
5784
5813
  */
5814
+ /**
5815
+ * Synchronously extract text from a file. Returns undefined on failure.
5816
+ * Used by fetchSiblings in JsonMap lib for sibling context extraction.
5817
+ */
5818
+ function syncExtractText(filePath) {
5819
+ try {
5820
+ const raw = readFileSync(filePath, 'utf-8').replace(/^\uFEFF/, '');
5821
+ const ext = extname(filePath).toLowerCase();
5822
+ if (ext === '.json') {
5823
+ const parsed = JSON.parse(raw);
5824
+ return extractJsonText(parsed);
5825
+ }
5826
+ // All other supported text formats: return raw content
5827
+ return raw;
5828
+ }
5829
+ catch {
5830
+ return undefined;
5831
+ }
5832
+ }
5785
5833
  /**
5786
5834
  * Build merged metadata for a file by applying inference rules and merging with enrichment metadata.
5787
5835
  *
@@ -5803,6 +5851,7 @@ async function buildMergedMetadata(options) {
5803
5851
  configDir,
5804
5852
  customMapLib,
5805
5853
  globalSchemas,
5854
+ extractText: syncExtractText,
5806
5855
  });
5807
5856
  // 3. Read enrichment metadata from store (composable merge)
5808
5857
  const enrichment = enrichmentStore?.get(filePath) ?? null;
@@ -7701,7 +7750,7 @@ function installShutdownHandlers(stop) {
7701
7750
  * When provided, the file is loaded as-is (no migration).
7702
7751
  * @returns The running JeevesWatcher instance.
7703
7752
  */
7704
- async function startFromConfig(configPath) {
7753
+ async function startFromConfig(configPath, descriptor) {
7705
7754
  let resolvedPath = configPath;
7706
7755
  // Auto-migrate only when no explicit path was given.
7707
7756
  if (!configPath) {
@@ -7724,7 +7773,10 @@ async function startFromConfig(configPath) {
7724
7773
  }
7725
7774
  }
7726
7775
  const config = await loadConfig(resolvedPath);
7727
- const app = new JeevesWatcher(config, resolvedPath);
7776
+ // Dynamic import breaks the circular dependency between this module
7777
+ // and app/index.ts (which defines JeevesWatcher and re-exports startFromConfig).
7778
+ const { JeevesWatcher } = await Promise.resolve().then(function () { return index; });
7779
+ const app = new JeevesWatcher(config, resolvedPath, descriptor);
7728
7780
  installShutdownHandlers(() => app.stop());
7729
7781
  await app.start();
7730
7782
  return app;
@@ -7740,6 +7792,7 @@ async function startFromConfig(configPath) {
7740
7792
  class JeevesWatcher {
7741
7793
  config;
7742
7794
  configPath;
7795
+ descriptor;
7743
7796
  factories;
7744
7797
  runtimeOptions;
7745
7798
  logger;
@@ -7760,9 +7813,13 @@ class JeevesWatcher {
7760
7813
  initialScanTracker;
7761
7814
  version;
7762
7815
  /** Create a new JeevesWatcher instance. */
7763
- constructor(config, configPath, factories = {}, runtimeOptions = {}) {
7816
+ constructor(config, configPath, descriptor, factories = {}, runtimeOptions = {}) {
7817
+ if (!descriptor) {
7818
+ throw new Error('JeevesWatcher requires a component descriptor. Pass watcherDescriptor from the descriptor module.');
7819
+ }
7764
7820
  this.config = config;
7765
7821
  this.configPath = configPath;
7822
+ this.descriptor = descriptor;
7766
7823
  this.factories = { ...defaultFactories, ...factories };
7767
7824
  this.runtimeOptions = runtimeOptions;
7768
7825
  this.virtualRuleStore = new VirtualRuleStore();
@@ -7852,6 +7909,7 @@ class JeevesWatcher {
7852
7909
  }
7853
7910
  async startApiServer() {
7854
7911
  const server = this.factories.createApiServer({
7912
+ descriptor: this.descriptor,
7855
7913
  processor: this.processor,
7856
7914
  vectorStore: this.vectorStore,
7857
7915
  embeddingProvider: this.embeddingProvider,
@@ -7873,7 +7931,7 @@ class JeevesWatcher {
7873
7931
  });
7874
7932
  await server.listen({
7875
7933
  host: this.config.api?.host ?? getBindAddress('watcher'),
7876
- port: this.config.api?.port ?? 1936,
7934
+ port: this.config.api?.port ?? DEFAULT_PORTS.watcher,
7877
7935
  });
7878
7936
  return server;
7879
7937
  }
@@ -7931,12 +7989,18 @@ class JeevesWatcher {
7931
7989
  }
7932
7990
  }
7933
7991
 
7992
+ var index = /*#__PURE__*/Object.freeze({
7993
+ __proto__: null,
7994
+ JeevesWatcher: JeevesWatcher,
7995
+ startFromConfig: startFromConfig
7996
+ });
7997
+
7934
7998
  /**
7935
7999
  * @module cli/jeeves-watcher/customCommands
7936
8000
  * Domain-specific CLI commands registered via the descriptor's customCliCommands.
7937
8001
  * Uses fetchJson/postJson from core instead of hand-rolled API helpers.
7938
8002
  */
7939
- const DEFAULT_PORT = '1936';
8003
+ const DEFAULT_PORT = String(DEFAULT_PORTS.watcher);
7940
8004
  const DEFAULT_HOST = '127.0.0.1';
7941
8005
  /** Build the API base URL from host and port options. */
7942
8006
  function baseUrl(opts) {
@@ -8032,8 +8096,8 @@ function registerCustomCommands(program) {
8032
8096
  .option('-s, --scope <scope>', 'Reindex scope (issues|full|rules|path|prune)', 'rules')
8033
8097
  .option('-t, --path <paths...>', 'Target path(s) for path or rules scope')).action(async (opts) => {
8034
8098
  await handleErrors(async () => {
8035
- if (!VALID_SCOPES.includes(opts.scope)) {
8036
- console.error(`Invalid scope "${opts.scope}". Must be one of: ${VALID_SCOPES.join(', ')}`);
8099
+ if (!VALID_REINDEX_SCOPES.includes(opts.scope)) {
8100
+ console.error(`Invalid scope "${opts.scope}". Must be one of: ${VALID_REINDEX_SCOPES.join(', ')}`);
8037
8101
  process.exitCode = 1;
8038
8102
  return;
8039
8103
  }
@@ -8091,8 +8155,6 @@ function registerCustomCommands(program) {
8091
8155
  })();
8092
8156
  });
8093
8157
  }
8094
- // --- shared constants and helpers ---
8095
- const VALID_SCOPES = ['issues', 'full', 'rules', 'path', 'prune'];
8096
8158
  /** Parse --json and --key options into a metadata object. Returns null on validation failure. */
8097
8159
  function parseMetadataArgs(json, keys) {
8098
8160
  let metadata = {};
@@ -8176,16 +8238,14 @@ const watcherDescriptor = {
8176
8238
  version,
8177
8239
  servicePackage: '@karmaniverous/jeeves-watcher',
8178
8240
  pluginPackage: '@karmaniverous/jeeves-watcher-openclaw',
8179
- defaultPort: 1936,
8241
+ defaultPort: DEFAULT_PORTS.watcher,
8180
8242
  // Config
8181
8243
  configSchema: jeevesWatcherConfigSchema,
8182
8244
  configFileName: 'config.json',
8183
8245
  initTemplate: () => ({ ...INIT_CONFIG_TEMPLATE }),
8184
- // Service behavior — onConfigApply wired at the Fastify layer (api/index.ts)
8185
- // where it has access to the live reindex tracker and config getter.
8186
- onConfigApply: async (config) => {
8187
- await Promise.resolve();
8188
- },
8246
+ // onConfigApply is overridden in createApiServer (api/index.ts) via the
8247
+ // descriptor passed as a dependency, where it has access to the live
8248
+ // reindex tracker and config getter.
8189
8249
  customMerge: (target, source) => {
8190
8250
  const mergedRules = mergeInferenceRules(target['inferenceRules'], source['inferenceRules']);
8191
8251
  return {
@@ -8202,7 +8262,7 @@ const watcherDescriptor = {
8202
8262
  configPath,
8203
8263
  ],
8204
8264
  run: async (configPath) => {
8205
- await startFromConfig(configPath);
8265
+ await startFromConfig(configPath, watcherDescriptor);
8206
8266
  },
8207
8267
  // Content — generateToolsContent is wired in the plugin package
8208
8268
  // (watcherComponent.ts) where it has access to the API URL for menu generation.