@karmaniverous/jeeves-watcher-openclaw 0.17.0 → 0.18.0-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,4 +1,5 @@
1
- import { DEFAULT_PORTS, resolvePluginSetting, resolveOptionalPluginSetting, ok, connectionFail, fail, init, resolveWorkspacePath, registerPromptContext, jeevesComponentDescriptorSchema, getPackageVersion, createPluginToolset } from '@karmaniverous/jeeves';
1
+ import { DEFAULT_PORTS, resolveOptionalPluginSetting, resolvePluginSetting, createPluginToolset, fail, init, resolveWorkspacePath, registerPromptContext, jeevesComponentDescriptorSchema, getPackageVersion } from '@karmaniverous/jeeves';
2
+ import { defineCatalog, standardEntries, toToolName } from '@karmaniverous/jeeves/catalog';
2
3
  import 'node:crypto';
3
4
 
4
5
  function getEnumValues$1(entries) {
@@ -28855,7 +28856,7 @@ function requireExactlyOneTarget(val, ctx) {
28855
28856
  * Request schemas of the administrative endpoints: POST /points/delete, POST /rules/register, DELETE /rules/unregister, POST /rules/reapply. The service parses their bodies with these, like every other route. No I/O.
28856
28857
  */
28857
28858
  /** POST /points/delete request body. */
28858
- object$1({
28859
+ const pointsDeleteRequestSchema = object$1({
28859
28860
  /** Native Qdrant filter selecting the points to delete. */
28860
28861
  filter: qdrantFilter(FILTER_REQUIRED_MESSAGE),
28861
28862
  });
@@ -28864,7 +28865,7 @@ const ruleSourceSchema = string$2({ error: 'Missing required field: source' })
28864
28865
  .min(1, { error: 'Missing required field: source' });
28865
28866
  const RULE_NAME_REQUIRED = 'Every rule must be an object with a name';
28866
28867
  /** POST /rules/register request body. */
28867
- object$1({
28868
+ const rulesRegisterRequestSchema = object$1({
28868
28869
  /** Registering source (e.g. a plugin name). */
28869
28870
  source: ruleSourceSchema,
28870
28871
  /** Inference rules; each needs a non-empty `name` (the rest is validated on registration). */
@@ -28874,12 +28875,12 @@ object$1({
28874
28875
  }, { error: RULE_NAME_REQUIRED }), { error: 'Missing required field: rules (array)' }),
28875
28876
  });
28876
28877
  /** DELETE /rules/unregister request body. */
28877
- object$1({
28878
+ const rulesUnregisterRequestSchema = object$1({
28878
28879
  /** Source whose rules are removed. */
28879
28880
  source: ruleSourceSchema,
28880
28881
  });
28881
28882
  /** POST /rules/reapply request body. */
28882
- object$1({
28883
+ const rulesReapplyRequestSchema = object$1({
28883
28884
  /** Globs selecting the indexed files to re-apply rules to. */
28884
28885
  globs: array$1(string$2().min(1), {
28885
28886
  error: 'Missing required field: globs (non-empty string array)',
@@ -28888,16 +28889,151 @@ object$1({
28888
28889
  error: 'Missing required field: globs (non-empty string array)',
28889
28890
  }),
28890
28891
  });
28892
+ /** POST /points/delete response body: how many points were deleted. */
28893
+ const pointsDeleteResponseSchema = object$1({
28894
+ deleted: number$2().int().nonnegative(),
28895
+ });
28896
+ /** POST /rules/register response body. */
28897
+ const rulesRegisterResponseSchema = object$1({
28898
+ source: string$2(),
28899
+ registered: number$2().int().nonnegative(),
28900
+ totalVirtualRules: number$2().int().nonnegative(),
28901
+ });
28902
+ /** Response body of both rule unregister routes. */
28903
+ const rulesUnregisterResponseSchema = object$1({
28904
+ source: string$2(),
28905
+ removed: boolean$2(),
28906
+ });
28907
+ /** Path parameters of DELETE /rules/unregister/:source. */
28908
+ const rulesUnregisterParamsSchema = object$1({
28909
+ source: ruleSourceSchema,
28910
+ });
28911
+ /** POST /rules/reapply response body. */
28912
+ const rulesReapplyResponseSchema = object$1({
28913
+ matched: number$2().int().nonnegative(),
28914
+ updated: number$2().int().nonnegative(),
28915
+ });
28916
+ /** POST /rebuild-metadata request body: takes no fields. */
28917
+ const rebuildMetadataRequestSchema = object$1({});
28918
+ /** POST /rebuild-metadata response body. */
28919
+ const rebuildMetadataResponseSchema = object$1({ ok: literal(true) });
28920
+
28921
+ /**
28922
+ * @module api/changeSource
28923
+ * Zod schema and type of `GET /status` `health.changeSource` (D133, D157): the change source and reconciliation counters. No I/O.
28924
+ */
28925
+ /**
28926
+ * Why a process exited for a service-manager restart (D196): a watch config
28927
+ * change that needs a watcher rebuild, an error on a live parcel
28928
+ * subscription, or a watched root gone. Windows parcel only.
28929
+ */
28930
+ const restartCauseSchema = _enum$1(['watch-config-changed', 'subscription-error', 'root-gone'])
28931
+ .describe('Why the previous process exited for a service-manager restart (D196).');
28932
+ /**
28933
+ * Change-source health in `GET /status` `health.changeSource` (D133, D157):
28934
+ * counts and timestamps only, never paths (D89).
28935
+ */
28936
+ const changeSourceHealthSchema = object$1({
28937
+ /** The running change source. */
28938
+ source: _enum$1(['chokidar', 'parcel'])
28939
+ .describe('The running change source.'),
28940
+ /** Subscriptions re-created after an error since start (D131). */
28941
+ resubscribes: number$2()
28942
+ .describe('Subscriptions re-created after an error since start (D131); scheduled renewals are not counted. Always 0 under chokidar.'),
28943
+ /** Roots currently unavailable (D109). */
28944
+ unavailableRoots: number$2()
28945
+ .describe('Roots currently marked unavailable (D109).'),
28946
+ /** Synthetic events reconciliation has emitted since start (D90). */
28947
+ reconcileEvents: number$2()
28948
+ .describe('Synthetic events reconciliation has emitted since start (D90): a rising value means the source is missing events.'),
28949
+ /** Reconciles that errored and emitted nothing since start (D90). */
28950
+ reconcileErrors: number$2()
28951
+ .describe('Reconciles that errored (walk failure, unreachable root, empty root walk against a non-empty inventory) and emitted nothing, since start (D90).'),
28952
+ /** End of the most recent reconcile. */
28953
+ lastReconcileAt: string$2()
28954
+ .nullable()
28955
+ .describe('End of the most recent reconcile (ISO), null before the first.'),
28956
+ /** Duration of the most recent reconcile (walk and compare). */
28957
+ lastReconcileDurationMs: number$2()
28958
+ .nullable()
28959
+ .describe('Duration of the most recent reconcile, walk and compare (ms), null before the first.'),
28960
+ /** Paths in the stability ticker's pending set (D128). */
28961
+ pendingStability: number$2()
28962
+ .describe("Paths in the stability ticker's pending set (D128); a backlog that keeps growing means the ticker is falling behind."),
28963
+ /** Duration of the most recent subscription renewal (D129). */
28964
+ lastRenewDurationMs: number$2()
28965
+ .nullable()
28966
+ .describe('Duration of the most recent subscription renewal (ms, D129); null until the first, and always null under chokidar and on Windows.'),
28967
+ /** Renewals that forced a source rebuild since start (D165). */
28968
+ renewFailures: number$2()
28969
+ .describe('Renewals whose unsubscribe rejected and forced a source rebuild, plus rejections during a rebuild, since start (D165, D176). Always 0 under chokidar.'),
28970
+ /** Why the previous process exited for a restart (D196). */
28971
+ lastRestartCause: restartCauseSchema
28972
+ .nullable()
28973
+ .describe('Why the previous process exited for a service-manager restart (D196: on Windows parcel never unsubscribes, so a watcher rebuild or a subscription recovery restarts the process); null when this process did not start after such an exit.'),
28974
+ /** When the previous process asked for that restart. */
28975
+ lastRestartAt: string$2()
28976
+ .nullable()
28977
+ .describe('When the previous process asked for that restart (ISO); null with lastRestartCause.'),
28978
+ })
28979
+ .describe('Change-source health (D133, D157, D196).');
28980
+
28981
+ /**
28982
+ * @module api/configRoutes
28983
+ * Request and response schemas for the config inspection routes: `GET /config/schema`, `POST /config/match` and `POST /config/validate`. Envelopes are closed objects; user content (the JSON Schema document, test-path metadata, a candidate config) is an open record (spec D51). No I/O.
28984
+ */
28985
+ /** A config location and what is wrong (or notable) there. */
28986
+ const configIssueSchema = object$1({
28987
+ path: string$2(),
28988
+ message: string$2(),
28989
+ });
28990
+ /** Response body of `GET /config/schema`: the merged config JSON Schema document. */
28991
+ const getConfigSchemaResponseSchema = record$1(string$2(), unknown$1())
28992
+ .describe('JSON Schema (draft 2020-12) for the watcher config.');
28993
+ /** Request body of `POST /config/match`. */
28994
+ const matchConfigRequestSchema = object$1({
28995
+ paths: array$1(string$2(), { error: 'Request body must include "paths" array' })
28996
+ .describe('File paths to test against the watch scope and rules.'),
28997
+ });
28998
+ /** Response body of `POST /config/match`: one entry per requested path, in order. */
28999
+ const matchConfigResponseSchema = object$1({
29000
+ matches: array$1(object$1({
29001
+ rules: array$1(string$2()),
29002
+ watched: boolean$2(),
29003
+ })),
29004
+ });
29005
+ /** Request body of `POST /config/validate`. */
29006
+ const validateConfigRequestSchema = object$1({
29007
+ config: record$1(string$2(), unknown$1())
29008
+ .optional()
29009
+ .describe('Partial config merged over the current one; omit to check it.'),
29010
+ testPaths: array$1(string$2())
29011
+ .optional()
29012
+ .describe('Paths to run through the candidate config.'),
29013
+ });
29014
+ /** Response body of `POST /config/validate`. */
29015
+ const validateConfigResponseSchema = object$1({
29016
+ valid: boolean$2(),
29017
+ errors: array$1(configIssueSchema).optional(),
29018
+ warnings: array$1(configIssueSchema).optional(),
29019
+ testResults: array$1(object$1({
29020
+ path: string$2(),
29021
+ matchedRules: array$1(string$2()),
29022
+ metadata: record$1(string$2(), unknown$1()),
29023
+ error: string$2().optional(),
29024
+ }))
29025
+ .optional(),
29026
+ });
28891
29027
 
28892
29028
  /**
28893
29029
  * @module api/documentOps
28894
29030
  * Zod request/response schemas for single-document operations POST /render and POST /metadata (path or source target). No I/O.
28895
29031
  */
28896
29032
  /** Request body for `POST /render`: exactly one of `path` or `source`. */
28897
- object$1(documentTargetShape)
29033
+ const renderRequestSchema = object$1(documentTargetShape)
28898
29034
  .superRefine(requireExactlyOneTarget);
28899
29035
  /** Response body for `POST /render`. */
28900
- object$1({
29036
+ const renderResponseSchema = object$1({
28901
29037
  /** Output format (file extension without dot). */
28902
29038
  renderAs: string$2(),
28903
29039
  /** Rendered (or passthrough) content. */
@@ -28908,7 +29044,7 @@ object$1({
28908
29044
  metadata: record$1(string$2(), unknown$1()),
28909
29045
  });
28910
29046
  /** Request body for `POST /metadata`: exactly one of `path` or `source`, plus metadata. */
28911
- object$1({
29047
+ const metadataRequestSchema = object$1({
28912
29048
  ...documentTargetShape,
28913
29049
  /** Enrichment metadata to merge into the document's points. */
28914
29050
  metadata: record$1(string$2(), unknown$1(), {
@@ -28917,13 +29053,55 @@ object$1({
28917
29053
  })
28918
29054
  .superRefine(requireExactlyOneTarget);
28919
29055
  /** Response body for `POST /metadata`. */
28920
- object$1({
29056
+ const metadataResponseSchema = object$1({
28921
29057
  /** Always true on success. */
28922
29058
  ok: literal(true),
28923
29059
  /** Rules whose schemas validated the metadata. */
28924
29060
  matched_rules: array$1(string$2()),
28925
29061
  });
28926
29062
 
29063
+ /**
29064
+ * @module api/indexCheck
29065
+ * Zod schema and type of `GET /status` `health.indexCheck` (D197): the state and counts of the index check that runs once after the start walk. No I/O.
29066
+ */
29067
+ /**
29068
+ * State of the index check at start (D197): `pending` until the start walk
29069
+ * completes, `running`, then `done` or `failed` (the index could not be
29070
+ * listed, or the watcher stopped or was rebuilt first; nothing more is
29071
+ * deleted); `off` when `watch.indexCheckOnStart` is `false`.
29072
+ */
29073
+ const indexCheckStateSchema = _enum$1(['pending', 'running', 'done', 'failed', 'off'])
29074
+ .describe('Index check state: pending (the start walk is still running), running, done, failed (the index could not be listed, or the watcher stopped or was rebuilt first; nothing more is deleted) or off (watch.indexCheckOnStart is false).');
29075
+ /**
29076
+ * Index-check health in `GET /status` `health.indexCheck` (D197): counts
29077
+ * and timestamps only, never paths (D89).
29078
+ */
29079
+ const indexCheckHealthSchema = object$1({
29080
+ /** Where the check is. */
29081
+ state: indexCheckStateSchema,
29082
+ /** When it ended. */
29083
+ at: string$2()
29084
+ .nullable()
29085
+ .describe('When the check ended (ISO), null until it is done or failed.'),
29086
+ /** How long it took. */
29087
+ durationMs: number$2()
29088
+ .nullable()
29089
+ .describe('How long the check took, listing and deletes (ms), null until it is done or failed.'),
29090
+ /** File documents compared with the inventory. */
29091
+ checked: number$2()
29092
+ .describe('Indexed file documents compared with the inventory, under the roots that were checked.'),
29093
+ /** Documents deleted. */
29094
+ removed: number$2()
29095
+ .describe('Indexed file documents deleted because their file is gone (deleted or moved away while the service was down).'),
29096
+ /** Roots not checked. */
29097
+ skippedRoots: number$2()
29098
+ .describe('Watch roots not checked: unavailable, gone, unwatched, or whose start walk failed.'),
29099
+ /** Roots over the cap. */
29100
+ capped: number$2()
29101
+ .describe('Watch roots where more than 10% of the indexed documents (once more than 20 are missing) or more than 1,000 were missing from the inventory: nothing was deleted there; run reindex prune deliberately.'),
29102
+ })
29103
+ .describe('Index check at start (D197).');
29104
+
28927
29105
  /**
28928
29106
  * @module api/monitoring
28929
29107
  * Zod response schemas for the read-only monitoring endpoints GET /status and GET /issues, including the issue record. No I/O.
@@ -28941,11 +29119,125 @@ object$1({
28941
29119
  /** Component health: collection, reindex and initial-scan state. */
28942
29120
  health: record$1(string$2(), unknown$1()),
28943
29121
  });
29122
+ /** Reindex state in `GET /status` `health.reindex`. */
29123
+ const reindexStatusSchema = object$1({
29124
+ /** Whether a reindex is in progress. */
29125
+ active: boolean$2(),
29126
+ /** Active reindex scope (while active). */
29127
+ scope: string$2().optional(),
29128
+ /** ISO 8601 start time (while active). */
29129
+ startedAt: string$2().optional(),
29130
+ /** Files processed so far (while active). */
29131
+ filesProcessed: number$2().optional(),
29132
+ /** Files to process (while active). */
29133
+ totalFiles: number$2().optional(),
29134
+ });
29135
+ /** Initial filesystem scan state in `GET /status` `health.initialScan`. */
29136
+ const initialScanStatusSchema = object$1({
29137
+ /** Whether the initial scan is in progress. */
29138
+ active: boolean$2(),
29139
+ /** Files matched by the watch globs. */
29140
+ filesMatched: number$2().optional(),
29141
+ /** Files enqueued so far. */
29142
+ filesEnqueued: number$2().optional(),
29143
+ /** ISO 8601 start time. */
29144
+ startedAt: string$2().optional(),
29145
+ /** ISO 8601 completion time. */
29146
+ completedAt: string$2().optional(),
29147
+ /** Scan duration in milliseconds. */
29148
+ durationMs: number$2().optional(),
29149
+ });
29150
+ /** VCS state in `GET /status` `health.vcs` (D86): roots, backlog, tripped breakers. */
29151
+ const vcsHealthSchema = object$1({
29152
+ /** VCS-enabled roots. */
29153
+ roots: number$2(),
29154
+ /** Files queued or in flight, not yet committed, across roots. */
29155
+ pending: number$2(),
29156
+ /** Roots whose commit circuit breaker is tripped. */
29157
+ trippedRoots: number$2(),
29158
+ /** ISO 8601 time the longest-tripped breaker tripped, or null. */
29159
+ oldestTrippedAt: string$2().nullable(),
29160
+ });
29161
+ /**
29162
+ * State of one system payload index (D74, D154, D155): `building` until its
29163
+ * background creation returns, then `ready`; `conflict` when the field
29164
+ * already has another index type (left alone); `failed` when creation
29165
+ * failed (retried at the next start).
29166
+ */
29167
+ const systemIndexStateSchema = _enum$1(['building', 'ready', 'conflict', 'failed'])
29168
+ .describe('System payload index state: building (background creation running), ready, conflict (the field has another index type; left alone) or failed (creation failed; retried at the next start).');
29169
+ /** System payload index states by field, in `health.collection.systemIndexes`. */
29170
+ const systemIndexesSchema = record$1(string$2(), systemIndexStateSchema)
29171
+ .describe('System payload indexes the service creates whatever payloadIndexes says (chunk_index integer, file_path and matched_rules keyword), by field.');
29172
+ /**
29173
+ * Embedding health in `GET /status` `health.embedding` (D88, D89): state and
29174
+ * timestamps with error codes only, never provider text or paths.
29175
+ */
29176
+ const embeddingHealthSchema = object$1({
29177
+ /** `ok`, or `failing` (3+ failures and 60 s without a success). */
29178
+ state: _enum$1(['ok', 'failing'])
29179
+ .describe('ok, or failing: at least 3 embedding failures since the last success and 60 s without one. The first success returns it to ok.'),
29180
+ /** Last successful call. */
29181
+ lastSuccessAt: string$2()
29182
+ .nullable()
29183
+ .describe('Last successful embedding call (ISO), null before the first.'),
29184
+ /** Most recent error. */
29185
+ lastError: object$1({
29186
+ at: string$2().describe('When (ISO).'),
29187
+ code: string$2()
29188
+ .describe('rate_limited, timeout, http_<status>, network or error (no provider text).'),
29189
+ })
29190
+ .nullable()
29191
+ .describe('Most recent embedding error, null if none yet.'),
29192
+ /** When the state turned failing. */
29193
+ failingSince: string$2()
29194
+ .nullable()
29195
+ .describe('When the state turned failing (ISO), null while ok.'),
29196
+ /** End of the stored provider back-off. */
29197
+ backoffUntil: string$2()
29198
+ .nullable()
29199
+ .describe('End of the stored provider back-off (ISO, at most 5 minutes ahead), null if none.'),
29200
+ })
29201
+ .describe('Embedding provider health (D88, D89).');
29202
+ /**
29203
+ * `GET /status` `health`: collection, reindex and initial-scan state, or
29204
+ * `{ error }` when the vector store is unreachable (status `degraded`).
29205
+ * Loose and optional throughout, so new health keys pass through.
29206
+ */
29207
+ const watcherHealthSchema = looseObject({
29208
+ /** Vector store collection. */
29209
+ collection: object$1({
29210
+ /** Collection name. */
29211
+ name: string$2(),
29212
+ /** Indexed points. */
29213
+ pointCount: number$2(),
29214
+ /** Vector dimensions. */
29215
+ dimensions: number$2(),
29216
+ /** System payload index states (D74, D154, D155). */
29217
+ systemIndexes: systemIndexesSchema.optional(),
29218
+ })
29219
+ .optional(),
29220
+ /** Reindex state. */
29221
+ reindex: reindexStatusSchema.optional(),
29222
+ /** Initial scan state (once the scan tracker exists). */
29223
+ initialScan: initialScanStatusSchema.optional(),
29224
+ /** VCS state (when VCS is enabled on any root). */
29225
+ vcs: vcsHealthSchema.optional(),
29226
+ /** Embedding health (D88, D89). */
29227
+ embedding: embeddingHealthSchema.optional(),
29228
+ /** Change-source health (D133, D157). */
29229
+ changeSource: changeSourceHealthSchema.optional(),
29230
+ /** Index check at start (D197). */
29231
+ indexCheck: indexCheckHealthSchema.optional(),
29232
+ /** Why health could not be read (degraded). */
29233
+ error: string$2().optional(),
29234
+ });
28944
29235
  /**
28945
29236
  * Canonical issue codes, in one list (spec Decision 16.4).
28946
29237
  *
28947
29238
  * `type_collision` and `interpolation_error` predate 0.20.0; the rest cover
28948
- * processing failures (#255) and source documents.
29239
+ * processing failures (#255) and source documents; `too_large` marks a
29240
+ * document over a `processing` ceiling (D44).
28949
29241
  */
28950
29242
  const ISSUE_TYPES = [
28951
29243
  'type_collision',
@@ -28958,10 +29250,11 @@ const ISSUE_TYPES = [
28958
29250
  'no_content',
28959
29251
  'source_unavailable',
28960
29252
  'unsupported_content_type',
29253
+ 'too_large',
28961
29254
  ];
28962
29255
  /** Schema for the canonical issue code carried by every issue record. */
28963
29256
  const issueTypeSchema = _enum$1(ISSUE_TYPES)
28964
- .describe('Issue code: type_collision (conflicting metadata types), interpolation_error (template rendering failure), extraction_error (reading, rule application or chunking failed), embedding_error (embedding provider failed), upsert_error (vector store write failed), match_failed (designated rule did not match), rule_missing (designated rule not found), no_content (nothing to embed), source_unavailable (source callback failed), unsupported_content_type (callback returned a non-JSON body).');
29257
+ .describe('Issue code: type_collision (conflicting metadata types), interpolation_error (template rendering failure), extraction_error (reading, rule application or chunking failed), embedding_error (embedding provider failed), upsert_error (vector store write failed), match_failed (designated rule did not match), rule_missing (designated rule not found), no_content (nothing to embed), source_unavailable (source callback failed), unsupported_content_type (callback returned a non-JSON body), too_large (over a processing ceiling; its points were deleted).');
28965
29258
  /** Schema for a single issue record tracking a processing failure. */
28966
29259
  const issueRecordSchema = object$1({
28967
29260
  /** Canonical issue code. */
@@ -28987,7 +29280,7 @@ const issueRecordSchema = object$1({
28987
29280
  timestamp: union$1([number$2(), string$2()]),
28988
29281
  });
28989
29282
  /** Response body for `GET /issues`: issue records keyed by document key. */
28990
- object$1({
29283
+ const issuesResponseSchema = object$1({
28991
29284
  /** Number of documents with issues. */
28992
29285
  count: number$2().int().nonnegative(),
28993
29286
  /** Issue records keyed by document key (file path or source reference). */
@@ -29035,7 +29328,7 @@ object$1({
29035
29328
  * `rejected`) instead of the whole request. Shape errors (count, missing or
29036
29329
  * empty `rule`, unknown keys) still fail the request with `400`.
29037
29330
  */
29038
- object$1({
29331
+ const documentsEnvelopeSchema = object$1({
29039
29332
  /** Documents to (re)index: 1 to {@link MAX_DOCUMENTS_PER_REQUEST}. */
29040
29333
  documents: documentsArray(documentNotificationSchema.extend({
29041
29334
  /** Reference as submitted; validated per document. */
@@ -29057,16 +29350,14 @@ const documentRejectionCodeSchema = _enum$1([
29057
29350
  'queue_stopped',
29058
29351
  ]);
29059
29352
  /**
29060
- * Query string for `POST /documents`. `wait` accepts a boolean or the strings
29061
- * `true`/`false`/`1`/`0`.
29353
+ * Query string for `POST /documents`. `wait` accepts the strings
29354
+ * `true`/`false`/`1`/`0` (a query string never carries a JSON boolean).
29062
29355
  */
29063
- object$1({
29356
+ const documentsQuerySchema = object$1({
29064
29357
  /** Process synchronously and return per-document results. */
29065
- wait: union$1([
29066
- boolean$2(),
29067
- stringbool({ truthy: ['true', '1'], falsy: ['false', '0'] }),
29068
- ])
29069
- .optional(),
29358
+ wait: stringbool({ truthy: ['true', '1'], falsy: ['false', '0'] })
29359
+ .optional()
29360
+ .describe('Process synchronously and answer 200 with per-document results: true or 1 (false or 0, the default, answers 202).'),
29070
29361
  });
29071
29362
  /** Why a notification was rejected: a typed code plus a message. */
29072
29363
  const documentRejectionErrorSchema = documentErrorSchema
@@ -29083,7 +29374,7 @@ const documentRejectionSchema = object$1({
29083
29374
  error: documentRejectionErrorSchema,
29084
29375
  });
29085
29376
  /** `202` response body for `POST /documents` (asynchronous, the default). */
29086
- object$1({
29377
+ const documentsAcceptedResponseSchema = object$1({
29087
29378
  /** References enqueued for processing. */
29088
29379
  accepted: array$1(string$2()),
29089
29380
  /** Notifications rejected by synchronous validation. */
@@ -29124,7 +29415,7 @@ const documentResultSchema = object$1({
29124
29415
  error: documentFailureSchema.optional(),
29125
29416
  });
29126
29417
  /** `200` response body for `POST /documents?wait=true`. */
29127
- object$1({
29418
+ const documentsResultsResponseSchema = object$1({
29128
29419
  /** One result per submitted document. */
29129
29420
  results: array$1(documentResultSchema),
29130
29421
  });
@@ -29133,21 +29424,31 @@ object$1({
29133
29424
  * Qdrant filter with at least one condition (a condition-free filter such as
29134
29425
  * `{}` or `{ must: [] }` is rejected). The server constrains the filter to
29135
29426
  * points carrying `source` and no `file_path`.
29427
+ *
29428
+ * One strict object with both selectors optional and a refinement requiring
29429
+ * exactly one, because a catalog body must be a single Zod object (spec
29430
+ * Design 3). It accepts exactly the requests 0.20.0's two-shape union did.
29136
29431
  */
29137
- union$1([
29138
- strictObject({
29139
- /** References of the documents to delete: 1 to {@link MAX_DELETE_SOURCES_PER_REQUEST}. */
29140
- sources: array$1(sourceReferenceSchema)
29141
- .min(1, 'sources must contain at least one reference')
29142
- .max(MAX_DELETE_SOURCES_PER_REQUEST, `sources must contain at most ${String(MAX_DELETE_SOURCES_PER_REQUEST)} references`),
29143
- }),
29144
- strictObject({
29145
- /** Qdrant filter selecting the documents to delete (at least one condition). */
29146
- filter: nonEmptyQdrantFilterSchema,
29147
- }),
29148
- ]);
29432
+ const documentsDeleteRequestSchema = strictObject({
29433
+ /** References of the documents to delete: 1 to {@link MAX_DELETE_SOURCES_PER_REQUEST}. */
29434
+ sources: array$1(sourceReferenceSchema)
29435
+ .min(1, 'sources must contain at least one reference')
29436
+ .max(MAX_DELETE_SOURCES_PER_REQUEST, `sources must contain at most ${String(MAX_DELETE_SOURCES_PER_REQUEST)} references`)
29437
+ .optional(),
29438
+ /** Qdrant filter selecting the documents to delete (at least one condition). */
29439
+ filter: nonEmptyQdrantFilterSchema.optional(),
29440
+ })
29441
+ .superRefine((val, ctx) => {
29442
+ if ((val.sources === undefined) === (val.filter === undefined)) {
29443
+ ctx.addIssue({
29444
+ code: 'custom',
29445
+ path: [],
29446
+ message: 'Exactly one of sources or filter is required',
29447
+ });
29448
+ }
29449
+ });
29149
29450
  /** Response body for `POST /documents/delete`. */
29150
- object$1({
29451
+ const documentsDeleteResponseSchema = object$1({
29151
29452
  /** Number of documents (not points) removed. */
29152
29453
  deleted: number$2().int().nonnegative(),
29153
29454
  });
@@ -29168,7 +29469,7 @@ object$1({
29168
29469
  * or wrong.
29169
29470
  * - `path_not_watched` (403): the file path is outside the watched scope.
29170
29471
  * - `not_found` (404): no such route, file, VCS root, commit content or
29171
- * `.gitignore` entry.
29472
+ * exclude pattern.
29172
29473
  * - `source_not_indexed` (404): no stored point for the source reference, so
29173
29474
  * no designated rule is known.
29174
29475
  * - `source_not_found` (404): the source callback answered `404`.
@@ -29176,9 +29477,13 @@ object$1({
29176
29477
  * body.
29177
29478
  * - `pipeline_error` (422): rendering or extraction failed; for a source
29178
29479
  * document `issueType` names the failing stage.
29480
+ * - `confirm_required` (409): `POST /vcs/exclude` would untrack more than
29481
+ * 100 files; nothing was written, and `untracked` holds the count. Repeat
29482
+ * with `confirm: true`.
29179
29483
  * - `source_unavailable` (502): the source callback failed.
29180
29484
  * - `not_available` (503): the feature is not available on this instance
29181
- * (no source support, no filesystem watcher). A prune reindex
29485
+ * (no source support, no filesystem watcher), or `POST /vcs/exclude`
29486
+ * waited 30 s for the root's git lock (`Retry-After: 30`). A prune reindex
29182
29487
  * (`POST /reindex`) without a vector store keeps its 0.19 status `400`.
29183
29488
  * - `scan_in_progress` (503): the initial filesystem scan is still running;
29184
29489
  * retry later.
@@ -29195,6 +29500,7 @@ const API_ERROR_CODES = [
29195
29500
  'source_not_found',
29196
29501
  'unsupported_content_type',
29197
29502
  'pipeline_error',
29503
+ 'confirm_required',
29198
29504
  'source_unavailable',
29199
29505
  'not_available',
29200
29506
  'scan_in_progress',
@@ -29218,6 +29524,96 @@ apiErrorResponseSchema.extend({
29218
29524
  /** Issue type of the pipeline failure behind a source render error. */
29219
29525
  issueType: issueTypeSchema.optional(),
29220
29526
  });
29527
+
29528
+ /**
29529
+ * @module api/facets
29530
+ * Zod schemas for the facet endpoints: `GET /search/facets` (definitions, optionally scoped by `rules`) and `POST /search/facets/counts` (per-field value counts or ranges for a filter; spec Design 12). No I/O.
29531
+ */
29532
+ /** Most fields one `POST /search/facets/counts` request may name. */
29533
+ const COUNT_FACETS_MAX_FIELDS = 20;
29534
+ /** Values per counted field when `limit` is omitted. */
29535
+ const COUNT_FACETS_DEFAULT_LIMIT = 100;
29536
+ /** Largest `limit` of `POST /search/facets/counts`. */
29537
+ const COUNT_FACETS_MAX_LIMIT = 1000;
29538
+ /** Query for `GET /search/facets`. */
29539
+ const listFacetsQuerySchema = object$1({
29540
+ /** Comma-separated rule names that scope the list. */
29541
+ rules: string$2()
29542
+ .optional()
29543
+ .describe('Comma-separated rule names (configured or registered at runtime): only fields those rules declare, with values recorded for their documents. Unknown names answer 400. Without it, every configured rule.'),
29544
+ });
29545
+ /** Response body for `GET /search/facets`: the facetable fields and their values. */
29546
+ const listFacetsResponseSchema = object$1({
29547
+ facets: array$1(object$1({
29548
+ field: string$2(),
29549
+ type: string$2(),
29550
+ uiHint: string$2(),
29551
+ values: array$1(unknown$1()),
29552
+ rules: array$1(string$2()),
29553
+ })),
29554
+ });
29555
+ const FIELDS_MESSAGE = `fields must name 1 to ${String(COUNT_FACETS_MAX_FIELDS)} fields`;
29556
+ const LIMIT_MESSAGE = `limit must be between 1 and ${String(COUNT_FACETS_MAX_LIMIT)}`;
29557
+ /**
29558
+ * Request body for `POST /search/facets/counts`. Unknown keys are rejected,
29559
+ * so a misspelt option fails with `400` instead of being ignored.
29560
+ */
29561
+ const countFacetsRequestSchema = strictObject({
29562
+ /** Qdrant filter scoping the counts (e.g. a tenant condition). */
29563
+ filter: qdrantFilterSchema
29564
+ .optional()
29565
+ .describe('Qdrant filter scoping the counts, as for search (e.g. a tenant condition).'),
29566
+ /** Rule names: only documents of at least one of them are counted. */
29567
+ rules: array$1(string$2().min(1, 'rule names must be non-empty'))
29568
+ .min(1, 'rules must name at least one rule')
29569
+ .optional()
29570
+ .describe('Rule names (configured or registered at runtime): only documents that matched at least one of them are counted, each once. Unknown names answer 400.'),
29571
+ /** Fields to count (each needs a payload index). */
29572
+ fields: array$1(string$2().min(1, 'field names must be non-empty'), {
29573
+ error: FIELDS_MESSAGE,
29574
+ })
29575
+ .min(1, FIELDS_MESSAGE)
29576
+ .max(COUNT_FACETS_MAX_FIELDS, FIELDS_MESSAGE)
29577
+ .describe(`Payload fields to count (1 to ${String(COUNT_FACETS_MAX_FIELDS)}). Each needs a payload index: keyword, integer or bool for value counts; integer, float or datetime for a range.`),
29578
+ /** Most values returned per counted field. */
29579
+ limit: number$2({ error: LIMIT_MESSAGE })
29580
+ .int('limit must be an integer')
29581
+ .min(1, LIMIT_MESSAGE)
29582
+ .max(COUNT_FACETS_MAX_LIMIT, LIMIT_MESSAGE)
29583
+ .optional()
29584
+ .describe(`Most values per counted field, highest counts first (default ${String(COUNT_FACETS_DEFAULT_LIMIT)}, max ${String(COUNT_FACETS_MAX_LIMIT)}).`),
29585
+ });
29586
+ /** One counted value: how many documents hold it. */
29587
+ const facetValueCountSchema = object$1({
29588
+ /** The payload value. */
29589
+ value: union$1([string$2(), number$2(), boolean$2()]),
29590
+ /** Documents (not chunks) holding the value. */
29591
+ count: number$2().int().nonnegative(),
29592
+ });
29593
+ /** A bound of a range: a number, a date string, or null when nothing matched. */
29594
+ const rangeBoundSchema = union$1([number$2(), string$2(), _null$3()]);
29595
+ /** Range of a numeric or date field over the matching documents. */
29596
+ const facetRangeSchema = object$1({
29597
+ /** Smallest value, or null when no matching document holds the field. */
29598
+ min: rangeBoundSchema,
29599
+ /** Largest value, or null when no matching document holds the field. */
29600
+ max: rangeBoundSchema,
29601
+ });
29602
+ /** Response body for `POST /search/facets/counts`. */
29603
+ const countFacetsResponseSchema = object$1({
29604
+ /** Per field: value counts (highest first), or a range. */
29605
+ counts: record$1(string$2(), union$1([array$1(facetValueCountSchema), facetRangeSchema]))
29606
+ .describe('Per requested field: value counts (documents, highest first), or { min, max } for a numeric or date field.'),
29607
+ });
29608
+
29609
+ /**
29610
+ * @module api/query
29611
+ * Zod request/response schemas for the read endpoints POST /search, POST /scan and POST /walk. No I/O.
29612
+ */
29613
+ /** Results `POST /search` returns when `limit` is omitted. */
29614
+ const SEARCH_DEFAULT_LIMIT = 10;
29615
+ /** Page size of `POST /scan` when `limit` is omitted. */
29616
+ const SCAN_DEFAULT_LIMIT = 100;
29221
29617
  /** Largest `POST /scan` page size. */
29222
29618
  const SCAN_MAX_LIMIT = 1000;
29223
29619
  /**
@@ -29225,18 +29621,28 @@ const SCAN_MAX_LIMIT = 1000;
29225
29621
  * unsupported option (e.g. `collapse`) fails with `400` instead of being
29226
29622
  * silently ignored.
29227
29623
  */
29228
- strictObject({
29624
+ const searchRequestSchema = strictObject({
29229
29625
  /** Natural-language query text to embed. */
29230
- query: string$2().min(1, 'query must be a non-empty string'),
29626
+ query: string$2()
29627
+ .min(1, 'query must be a non-empty string')
29628
+ .describe('Search query text.'),
29231
29629
  /** Maximum results to return. Default: {@link SEARCH_DEFAULT_LIMIT}. */
29232
- limit: number$2().int().positive().optional(),
29630
+ limit: number$2()
29631
+ .int()
29632
+ .positive()
29633
+ .optional()
29634
+ .describe(`Max results (default ${String(SEARCH_DEFAULT_LIMIT)}).`),
29233
29635
  /** Number of results to skip (pagination). */
29234
- offset: number$2().int().nonnegative().optional(),
29636
+ offset: number$2()
29637
+ .int()
29638
+ .nonnegative()
29639
+ .optional()
29640
+ .describe('Number of results to skip for pagination.'),
29235
29641
  /** Optional Qdrant filter. */
29236
- filter: qdrantFilterSchema.optional(),
29642
+ filter: qdrantFilterSchema.optional().describe('Qdrant filter object.'),
29237
29643
  });
29238
29644
  /** A single search hit. */
29239
- object$1({
29645
+ const searchResultSchema = object$1({
29240
29646
  /** Point ID. */
29241
29647
  id: string$2(),
29242
29648
  /** Similarity (or fused) score. */
@@ -29244,23 +29650,32 @@ object$1({
29244
29650
  /** Point payload (system fields plus rule metadata). */
29245
29651
  payload: record$1(string$2(), unknown$1()),
29246
29652
  });
29653
+ /** Response body for `POST /search`: hits ordered by score. */
29654
+ const searchResponseSchema = array$1(searchResultSchema);
29247
29655
  const SCAN_LIMIT_MESSAGE = `limit must be between 1 and ${String(SCAN_MAX_LIMIT)}`;
29248
29656
  /** Request body for `POST /scan`. */
29249
- object$1({
29657
+ const scanRequestSchema = object$1({
29250
29658
  /** Qdrant filter (required). */
29251
- filter: qdrantFilter(FILTER_REQUIRED_MESSAGE),
29659
+ filter: qdrantFilter(FILTER_REQUIRED_MESSAGE).describe('Qdrant filter object (required).'),
29252
29660
  /** Page size (1 to {@link SCAN_MAX_LIMIT}). Default: {@link SCAN_DEFAULT_LIMIT}. */
29253
29661
  limit: number$2({ error: SCAN_LIMIT_MESSAGE })
29254
29662
  .int('limit must be an integer')
29255
29663
  .min(1, SCAN_LIMIT_MESSAGE)
29256
29664
  .max(SCAN_MAX_LIMIT, SCAN_LIMIT_MESSAGE)
29257
- .optional(),
29665
+ .optional()
29666
+ .describe(`Page size (default ${String(SCAN_DEFAULT_LIMIT)}, max ${String(SCAN_MAX_LIMIT)}).`),
29258
29667
  /** Opaque cursor from a previous page. */
29259
- cursor: union$1([string$2(), number$2()]).optional(),
29668
+ cursor: union$1([string$2(), number$2()])
29669
+ .optional()
29670
+ .describe('Opaque cursor from previous response for pagination.'),
29260
29671
  /** Payload fields to return (projection). */
29261
- fields: array$1(string$2()).optional(),
29672
+ fields: array$1(string$2())
29673
+ .optional()
29674
+ .describe('Payload fields to return (projection).'),
29262
29675
  /** Return only `{ count }`. */
29263
- countOnly: boolean$2().optional(),
29676
+ countOnly: boolean$2()
29677
+ .optional()
29678
+ .describe('If true, return { count } instead of points.'),
29264
29679
  });
29265
29680
  /** A scrolled point (one entry of the `POST /scan` `points` page). */
29266
29681
  const scanPointSchema = object$1({
@@ -29270,7 +29685,7 @@ const scanPointSchema = object$1({
29270
29685
  payload: record$1(string$2(), unknown$1()),
29271
29686
  });
29272
29687
  /** Response body for `POST /scan`: a page of points, or a count when `countOnly`. */
29273
- union$1([
29688
+ const scanResponseSchema = union$1([
29274
29689
  object$1({
29275
29690
  /** Matched points. */
29276
29691
  points: array$1(scanPointSchema),
@@ -29282,6 +29697,18 @@ union$1([
29282
29697
  count: number$2().int().nonnegative(),
29283
29698
  }),
29284
29699
  ]);
29700
+ /** Request body for `POST /walk`. */
29701
+ const walkRequestSchema = object$1({
29702
+ globs: array$1(string$2())
29703
+ .min(1)
29704
+ .describe('Globs matched against the watched files.'),
29705
+ });
29706
+ /** Response body for `POST /walk`. */
29707
+ const walkResponseSchema = object$1({
29708
+ paths: array$1(string$2()),
29709
+ matchedCount: number$2().int().nonnegative(),
29710
+ scannedRoots: array$1(string$2()),
29711
+ });
29285
29712
 
29286
29713
  /**
29287
29714
  * @module api/reindex
@@ -29322,15 +29749,19 @@ const sourceTargetsSchema = union$1([
29322
29749
  * or `source`; `rules` optionally narrows to `path`; `source` is only valid
29323
29750
  * with scope `path`.
29324
29751
  */
29325
- object$1({
29752
+ const reindexRequestSchema = object$1({
29326
29753
  /** Reindex scope. Default: `issues`. */
29327
- scope: reindexScopeSchema.optional(),
29754
+ scope: reindexScopeSchema
29755
+ .optional()
29756
+ .describe('Reindex scope: "issues" re-processes documents that failed; "rules" re-applies inference rules without re-embedding; "full" re-embeds everything; "path" reindexes the given files or directories (`path`) or source references (`source`); "prune" deletes points for files no longer in watch scope.'),
29328
29757
  /** File path(s) or globs for scopes `path` and `rules`. */
29329
29758
  path: pathTargetsSchema.optional(),
29330
29759
  /** Source reference(s) for scope `path`. */
29331
29760
  source: sourceTargetsSchema.optional(),
29332
29761
  /** Compute and return the plan without executing. */
29333
- dryRun: boolean$2().optional(),
29762
+ dryRun: boolean$2()
29763
+ .optional()
29764
+ .describe('Return the blast-area plan (counts by root) without running the reindex.'),
29334
29765
  })
29335
29766
  .superRefine((val, ctx) => {
29336
29767
  if (val.scope === 'path') {
@@ -29364,7 +29795,7 @@ const reindexPlanSchema = object$1({
29364
29795
  incomplete: boolean$2().optional(),
29365
29796
  });
29366
29797
  /** Response body for `POST /reindex`: started (async) or dry-run plan. */
29367
- object$1({
29798
+ const reindexResponseSchema = object$1({
29368
29799
  /** `started` (executing asynchronously) or `dry_run` (plan only). */
29369
29800
  status: _enum$1(['started', 'dry_run']),
29370
29801
  /** The scope requested. */
@@ -29374,338 +29805,470 @@ object$1({
29374
29805
  });
29375
29806
 
29376
29807
  /**
29377
- * @module constants
29378
- * Shared identity constants for the jeeves-watcher ecosystem.
29379
- * Consumed by both the service and plugin packages.
29808
+ * @module api/vcs
29809
+ * Zod request schemas for the version-history endpoints in the endpoint catalog (`GET /vcs/show`). No I/O.
29380
29810
  */
29381
- /** Component name shared across service and plugin descriptors. */
29382
- const COMPONENT_NAME = 'watcher';
29383
- /** npm package name for the service. */
29384
- const SERVICE_PACKAGE = '@karmaniverous/jeeves-watcher';
29385
- /** npm package name for the plugin. */
29386
- const PLUGIN_PACKAGE = '@karmaniverous/jeeves-watcher-openclaw';
29387
- /** Default watcher API port. */
29388
- const DEFAULT_PORT = DEFAULT_PORTS.watcher;
29389
- /** Default API values. */
29390
- ({
29391
- port: DEFAULT_PORTS.watcher});
29392
- /** Default reindex values. */
29393
- const REINDEX_DEFAULTS = {
29394
- /** Files (and source documents, and stored-rule lookups) in flight at once. */
29395
- concurrency: 50,
29396
- };
29811
+ /**
29812
+ * A required, non-empty string whose missing or empty value is reported as
29813
+ * `error` (a schema-level `error` also words the schema's own checks, here
29814
+ * `min(1)`).
29815
+ *
29816
+ * @param error - Message for a missing, non-string or empty value.
29817
+ * @param description - Field description (OpenAPI and tool text).
29818
+ * @returns The field schema.
29819
+ */
29820
+ function requiredString(error, description) {
29821
+ return string$2({ error }).min(1).describe(description);
29822
+ }
29823
+ /**
29824
+ * A required, non-empty query parameter, reported as
29825
+ * `Missing required query parameter: <name>`.
29826
+ *
29827
+ * @param name - Parameter name.
29828
+ * @param description - Parameter description (OpenAPI and tool text).
29829
+ * @returns The parameter schema.
29830
+ */
29831
+ function requiredQueryParam(name, description) {
29832
+ return requiredString(`Missing required query parameter: ${name}`, description);
29833
+ }
29834
+ /** Query string of `GET /vcs/show`. */
29835
+ const vcsShowQuerySchema = object$1({
29836
+ /** Absolute path of the file inside a VCS-enabled watch root. */
29837
+ path: requiredQueryParam('path', 'File path to retrieve.'),
29838
+ /** Version identifier (a commit). */
29839
+ commit: requiredQueryParam('commit', 'Version identifier.'),
29840
+ });
29841
+ /** Response body of `GET /vcs/status`. */
29842
+ const vcsStatusResponseSchema = object$1({
29843
+ enabled: boolean$2(),
29844
+ roots: array$1(object$1({
29845
+ path: string$2(),
29846
+ tracked: number$2().int().nonnegative(),
29847
+ lastCommit: object$1({
29848
+ hash: string$2(),
29849
+ message: string$2(),
29850
+ timestamp: string$2(),
29851
+ })
29852
+ .nullable(),
29853
+ remoteUrl: string$2().nullable(),
29854
+ lastPush: string$2().nullable(),
29855
+ pushErrors: array$1(object$1({
29856
+ /** ISO 8601 time of the failed push. */
29857
+ timestamp: string$2(),
29858
+ /** Why the push failed (credentials redacted). */
29859
+ message: string$2(),
29860
+ })),
29861
+ /** The root's commit circuit breaker (null without a manager). */
29862
+ breaker: object$1({
29863
+ /** Consecutive commit failures since the last success. */
29864
+ consecutiveFailures: number$2().int().nonnegative(),
29865
+ /** Whether the breaker is tripped. */
29866
+ tripped: boolean$2(),
29867
+ /** ISO 8601 time of the current trip, or null. */
29868
+ trippedAt: string$2().nullable(),
29869
+ /** Message of the most recent failure, or null. */
29870
+ lastError: string$2().nullable(),
29871
+ /** Files queued or in flight, not yet committed. */
29872
+ pendingCount: number$2().int().nonnegative(),
29873
+ })
29874
+ .nullable(),
29875
+ })),
29876
+ });
29877
+ /** Query string of `GET /vcs/history`. */
29878
+ const vcsHistoryQuerySchema = object$1({
29879
+ glob: requiredQueryParam('glob', 'Glob inside a VCS-enabled watch root.'),
29880
+ since: string$2()
29881
+ .optional()
29882
+ .describe('Only commits after this date (any date git log accepts).'),
29883
+ until: string$2()
29884
+ .optional()
29885
+ .describe('Only commits before this date (any date git log accepts).'),
29886
+ limit: string$2().optional().describe('Most commits to return (default 20).'),
29887
+ });
29888
+ /** Response body of `GET /vcs/history`: commits touching the glob, newest first. */
29889
+ const vcsHistoryResponseSchema = array$1(object$1({
29890
+ commit: string$2(),
29891
+ message: string$2(),
29892
+ timestamp: string$2(),
29893
+ files: array$1(string$2()),
29894
+ }));
29895
+ /** Query string of `GET /vcs/diff`. */
29896
+ const vcsDiffQuerySchema = object$1({
29897
+ glob: requiredQueryParam('glob', 'Glob inside a VCS-enabled watch root.'),
29898
+ commit: requiredQueryParam('commit', 'Version to diff from.'),
29899
+ commitEnd: string$2()
29900
+ .optional()
29901
+ .describe('Version to diff to (default: the working tree).'),
29902
+ });
29903
+ /** Response body of `GET /vcs/diff`: a unified diff, `text/plain`. */
29904
+ const vcsDiffResponseSchema = string$2();
29905
+ /** Query string of `GET /vcs/check-exclusion`. */
29906
+ const vcsCheckExclusionQuerySchema = object$1({
29907
+ path: requiredQueryParam('path', 'Path inside a VCS-enabled watch root.'),
29908
+ });
29909
+ /**
29910
+ * Response body of `GET /vcs/check-exclusion` (D82): whether git ignores the
29911
+ * path and, when it does, the matching pattern, the file holding it
29912
+ * (`.git/info/exclude` for a VCS-only exclusion) and its line number.
29913
+ */
29914
+ const vcsCheckExclusionResponseSchema = object$1({
29915
+ /** Whether git ignores the path (a tracked path never is). */
29916
+ excluded: boolean$2(),
29917
+ /** The matching pattern. */
29918
+ rule: string$2().optional(),
29919
+ /** The file holding the pattern, as git names it. */
29920
+ source: string$2().optional(),
29921
+ /** The pattern's 1-based line number in `source`. */
29922
+ line: number$2().int().positive().optional(),
29923
+ });
29924
+ /** Request body of `POST /vcs/revert`. */
29925
+ const vcsRevertRequestSchema = object$1({
29926
+ glob: requiredString('Missing required body parameters: glob, commit', 'Glob of files to restore.'),
29927
+ commit: requiredString('Missing required body parameters: glob, commit', 'Version to restore from.'),
29928
+ existingOnly: boolean$2()
29929
+ .optional()
29930
+ .describe('Restore only files that exist in the working tree.'),
29931
+ });
29932
+ /** Response body of `POST /vcs/revert`. */
29933
+ const vcsRevertResponseSchema = object$1({
29934
+ restored: number$2().int().nonnegative(),
29935
+ files: array$1(string$2()),
29936
+ });
29937
+ /** Most files `POST /vcs/exclude` untracks without `confirm: true` (D103). */
29938
+ const VCS_EXCLUDE_CONFIRM_THRESHOLD = 100;
29939
+ /** Request body of `POST /vcs/exclude` (Design 5, D81, D103). */
29940
+ const vcsExcludeRequestSchema = object$1({
29941
+ glob: requiredString('Missing required body parameter: glob', "One gitignore pattern, absolute (under a VCS root) or relative to `root`. It is written root-anchored (`/` + the root-relative pattern) to the root's `.git/info/exclude`. Brackets are a character class: escape them to name a file literally (`Report \\[draft\\].docx`). A newline, a leading `!` or `#`, braces, or a pattern matching the whole root is refused."),
29942
+ remove: boolean$2()
29943
+ .optional()
29944
+ .describe('Remove the pattern instead of adding it (from `.git/info/exclude`, else from the `.gitignore` a 0.20.0 exclude wrote). Files are not re-added; their next change is versioned again.'),
29945
+ root: string$2()
29946
+ .optional()
29947
+ .describe('VCS root to write under (default: the root containing the glob); a glob outside it is refused.'),
29948
+ confirm: boolean$2()
29949
+ .optional()
29950
+ .describe(`Required (true) when adding the pattern would untrack more than ${String(VCS_EXCLUDE_CONFIRM_THRESHOLD)} files.`),
29951
+ });
29952
+ /** Response body of `POST /vcs/exclude`. */
29953
+ const vcsExcludeResponseSchema = object$1({
29954
+ ok: literal(true),
29955
+ /** The file written: the root's `.git/info/exclude`, or the `.gitignore` a removed 0.20.0 pattern was in. */
29956
+ file: string$2(),
29957
+ action: _enum$1(['added', 'removed']),
29958
+ /** Paths removed from the index (in HEAD or staged); 0 for `removed`. */
29959
+ untracked: number$2().int().nonnegative(),
29960
+ });
29397
29961
 
29398
29962
  /**
29399
- * @module endpoints
29400
- * Declarative catalog of every jeeves-watcher HTTP endpoint (name, method, path, description) plus a typed lookup; the single source the service, plugin and SDK derive routes from. Pure.
29963
+ * @module catalog/routes
29964
+ * Shared parts of the watcher catalog entries: the bearer auth every entry but `status` takes, and the `route`/`vcsRoute` helpers giving an entry its name, method, path, description and auth. No I/O.
29401
29965
  */
29966
+ /** Bearer-key auth (`api.apiKey`) required by every entry except `status`. */
29967
+ const bearer = { schemes: [{ in: 'bearer' }] };
29402
29968
  /**
29403
- * Canonical endpoint catalog for the jeeves-watcher API.
29969
+ * Name, method, path, description and auth of a bearer-protected entry.
29404
29970
  *
29405
- * Every entry describes a single HTTP endpoint exposed by the service.
29406
- * Route handlers, plugin tools, and HTTP clients should reference these
29407
- * descriptors rather than hard-coding paths and descriptions.
29971
+ * @param name - Entry name: verb-first (D77), the operation's one name.
29972
+ * @param method - HTTP method.
29973
+ * @param path - Route path.
29974
+ * @param description - What the operation does.
29408
29975
  */
29409
- const WATCHER_ENDPOINTS = [
29410
- {
29411
- name: 'status',
29412
- method: 'GET',
29413
- path: '/status',
29414
- description: 'Service health and status overview.',
29415
- },
29416
- {
29417
- name: 'metadata',
29418
- method: 'POST',
29419
- path: '/metadata',
29420
- description: 'Set or update metadata on a document by file path or source reference.',
29421
- },
29976
+ function route(name, method, path, description) {
29977
+ return { name, method, path, description, auth: bearer };
29978
+ }
29979
+ /**
29980
+ * Description suffix of every entry the service serves only while version
29981
+ * control is on (spec Design 3): with VCS off, the route answers the
29982
+ * not-found 404 an unregistered route sends.
29983
+ */
29984
+ const VCS_DISABLED_NOTE = 'Answers 404 `not_found` when version control is disabled (`vcs.enabled: false`).';
29985
+ /**
29986
+ * {@link route} for a version-control entry: its description ends with
29987
+ * {@link VCS_DISABLED_NOTE}.
29988
+ *
29989
+ * @param name - Entry name.
29990
+ * @param method - HTTP method.
29991
+ * @param path - Route path.
29992
+ * @param description - What the operation does.
29993
+ */
29994
+ function vcsRoute(name, method, path, description) {
29995
+ return route(name, method, path, `${description} ${VCS_DISABLED_NOTE}`);
29996
+ }
29997
+
29998
+ /**
29999
+ * @module catalog/destructiveEntries
30000
+ * The watcher catalog's `destructive` entries, in catalog order (spec Design 3 kinds). No I/O.
30001
+ */
30002
+ /** The `destructive` entries. */
30003
+ const DESTRUCTIVE_ENTRIES = [
29422
30004
  {
29423
- name: 'render',
29424
- method: 'POST',
29425
- path: '/render',
29426
- description: 'Render a document (file path, or source reference retrieved through its callback) through the template pipeline.',
30005
+ ...route('deleteDocuments', 'POST', '/documents/delete', 'Delete source documents by reference or by Qdrant filter (at least one condition; constrained to points carrying source and no file_path). The route waits at most 10 s for each document lock; if one stays busy it answers 503 `not_available` with `Retry-After: 10`, and documents already deleted stay deleted (retry to finish).'),
30006
+ body: documentsDeleteRequestSchema,
30007
+ response: documentsDeleteResponseSchema,
30008
+ kind: 'destructive',
29427
30009
  },
29428
30010
  {
29429
- name: 'searchFacets',
29430
- method: 'GET',
29431
- path: '/search/facets',
29432
- description: 'List available search facets and their values.',
30011
+ ...route('deletePoints', 'POST', '/points/delete', 'Delete points from the vector store by filter.'),
30012
+ body: pointsDeleteRequestSchema,
30013
+ response: pointsDeleteResponseSchema,
30014
+ kind: 'destructive',
29433
30015
  },
29434
30016
  {
29435
- name: 'scan',
29436
- method: 'POST',
29437
- path: '/scan',
29438
- description: 'Filter-only point query without vector search. Returns metadata for points matching a Qdrant filter.',
30017
+ ...route('unregisterRules', 'DELETE', '/rules/unregister', 'Unregister external inference rules.'),
30018
+ body: rulesUnregisterRequestSchema,
30019
+ response: rulesUnregisterResponseSchema,
30020
+ kind: 'destructive',
29439
30021
  },
29440
30022
  {
29441
- name: 'walk',
29442
- method: 'POST',
29443
- path: '/walk',
29444
- description: 'Walk watched filesystem paths with glob intersection. Returns matching file paths.',
30023
+ ...route('unregisterRulesBySource', 'DELETE', '/rules/unregister/:source', 'Unregister all external inference rules from a source.'),
30024
+ params: rulesUnregisterParamsSchema,
30025
+ response: rulesUnregisterResponseSchema,
30026
+ kind: 'destructive',
29445
30027
  },
29446
30028
  {
29447
- name: 'search',
29448
- method: 'POST',
29449
- path: '/search',
29450
- description: 'Semantic search over indexed documents. Supports Qdrant filters.',
30029
+ ...vcsRoute('revertVcs', 'POST', '/vcs/revert', 'Restore files from a historical commit (forward-only reversion).'),
30030
+ body: vcsRevertRequestSchema,
30031
+ response: vcsRevertResponseSchema,
30032
+ kind: 'destructive',
29451
30033
  },
29452
30034
  {
29453
- name: 'rebuildMetadata',
29454
- method: 'POST',
29455
- path: '/rebuild-metadata',
29456
- description: 'Rebuild the enrichment store from vector point payloads (file points only).',
30035
+ // D103: it untracks files, ending their history.
30036
+ ...vcsRoute('excludeVcsPath', 'POST', '/vcs/exclude', 'Exclude a path from version history (VCS-only: it stays in search) or remove the exclusion. The glob is a gitignore pattern, so brackets are a character class: escape them to name a file literally (`Report \\[draft\\].docx`). Adding a pattern also untracks the tracked files it matches, in one commit (history stops for them; the files stay on disk and in search); `remove` does not restore that history. More than ' +
30037
+ String(VCS_EXCLUDE_CONFIRM_THRESHOLD) +
30038
+ " files to untrack need `confirm: true` (else 409 `confirm_required` with `untracked`, nothing written). The route waits at most 30 s for the root's git lock, then answers 503 `not_available` with `Retry-After: 30`, nothing written. A glob outside an explicit `root` is 400; a glob under no VCS root is 404."),
30039
+ body: vcsExcludeRequestSchema,
30040
+ response: vcsExcludeResponseSchema,
30041
+ kind: 'destructive',
29457
30042
  },
30043
+ ];
30044
+
30045
+ /**
30046
+ * @module catalog/readEntries
30047
+ * The watcher catalog's `read` entries, in catalog order (spec Design 3 kinds). No I/O.
30048
+ */
30049
+ /** The `read` entries. */
30050
+ const READ_ENTRIES = [
29458
30051
  {
29459
- name: 'reindex',
29460
- method: 'POST',
29461
- path: '/reindex',
29462
- description: 'Trigger a scoped reindex of watched files (scope path also accepts source references).',
30052
+ ...route('getConfigSchema', 'GET', '/config/schema', 'Get the JSON Schema for the configuration file.'),
30053
+ response: getConfigSchemaResponseSchema,
30054
+ kind: 'read',
29463
30055
  },
29464
30056
  {
29465
- name: 'documents',
29466
- method: 'POST',
29467
- path: '/documents',
29468
- description: 'Notify changed source documents ({ source, rule }); queued and coalesced (202), or processed synchronously with ?wait=true.',
30057
+ ...route('matchConfig', 'POST', '/config/match', 'Test file paths against config inference rules.'),
30058
+ body: matchConfigRequestSchema,
30059
+ response: matchConfigResponseSchema,
30060
+ kind: 'read',
29469
30061
  },
29470
30062
  {
29471
- name: 'documentsDelete',
29472
- method: 'POST',
29473
- path: '/documents/delete',
29474
- description: 'Delete source documents by reference or by Qdrant filter (at least one condition; constrained to points carrying source and no file_path).',
30063
+ ...route('validateConfig', 'POST', '/config/validate', 'Validate a candidate config. Optionally test file paths against the config.'),
30064
+ body: validateConfigRequestSchema,
30065
+ response: validateConfigResponseSchema,
30066
+ kind: 'read',
29475
30067
  },
29476
30068
  {
29477
- name: 'issues',
29478
- method: 'GET',
29479
- path: '/issues',
29480
- description: 'Get runtime processing failures. Shows the documents (file path or source reference) that failed processing and why.',
30069
+ ...route('search', 'POST', '/search', 'Semantic search over indexed documents. Supports Qdrant filters.'),
30070
+ body: searchRequestSchema,
30071
+ response: searchResponseSchema,
30072
+ kind: 'read',
29481
30073
  },
29482
30074
  {
29483
- name: 'configSchema',
29484
- method: 'GET',
29485
- path: '/config/schema',
29486
- description: 'Get the JSON Schema for the configuration file.',
30075
+ ...route('scan', 'POST', '/scan', 'Filter-only point query without vector search. Returns metadata for points matching a Qdrant filter.'),
30076
+ body: scanRequestSchema,
30077
+ response: scanResponseSchema,
30078
+ kind: 'read',
29487
30079
  },
29488
30080
  {
29489
- name: 'configMatch',
29490
- method: 'POST',
29491
- path: '/config/match',
29492
- description: 'Test file paths against config inference rules.',
30081
+ ...route('listFacets', 'GET', '/search/facets', 'List available search facets and their values: every field a rule schema gives a uiHint or enum, with its declared or recorded values. With `rules`, only the fields and values of those rules.'),
30082
+ query: listFacetsQuerySchema,
30083
+ response: listFacetsResponseSchema,
30084
+ kind: 'read',
29493
30085
  },
29494
30086
  {
29495
- name: 'config',
29496
- method: 'GET',
29497
- path: '/config',
29498
- description: 'Query service configuration with optional JSONPath.',
30087
+ ...route('countFacets', 'POST', '/search/facets/counts', 'Count the values of payload fields over the documents matching a filter (each document once, not each chunk), or give { min, max } for a numeric or date field. Counts are approximate (Qdrant facet estimates); ranges are exact. `rules` keeps only documents of those rules. Each field needs a payload index (else 400). Fields are counted 4 at a time within a 10 s budget; past it the route answers 503 `not_available` naming the fields not counted. It also answers 503 until the `chunk_index` system index is ready, and with `rules` until `matched_rules` is.'),
30088
+ body: countFacetsRequestSchema,
30089
+ response: countFacetsResponseSchema,
30090
+ kind: 'read',
29499
30091
  },
29500
30092
  {
29501
- name: 'configValidate',
29502
- method: 'POST',
29503
- path: '/config/validate',
29504
- description: 'Validate a candidate config. Optionally test file paths against the config.',
30093
+ ...route('render', 'POST', '/render', 'Render a document (file path, or source reference retrieved through its callback) through the template pipeline.'),
30094
+ body: renderRequestSchema,
30095
+ response: renderResponseSchema,
30096
+ kind: 'read',
29505
30097
  },
29506
30098
  {
29507
- name: 'configApply',
29508
- method: 'POST',
29509
- path: '/config/apply',
29510
- description: 'Apply a configuration patch.',
30099
+ ...route('walk', 'POST', '/walk', 'Walk watched filesystem paths with glob intersection. Returns matching file paths.'),
30100
+ body: walkRequestSchema,
30101
+ response: walkResponseSchema,
30102
+ kind: 'read',
29511
30103
  },
29512
30104
  {
29513
- name: 'rulesRegister',
29514
- method: 'POST',
29515
- path: '/rules/register',
29516
- description: 'Register external inference rules.',
30105
+ ...route('listIssues', 'GET', '/issues', 'Get runtime processing failures. Shows the documents (file path or source reference) that failed processing and why.'),
30106
+ response: issuesResponseSchema,
30107
+ kind: 'read',
29517
30108
  },
29518
30109
  {
29519
- name: 'rulesUnregister',
29520
- method: 'DELETE',
29521
- path: '/rules/unregister',
29522
- description: 'Unregister external inference rules.',
30110
+ ...vcsRoute('getVcsStatus', 'GET', '/vcs/status', 'VCS state for all roots: enabled state, tracked files, last commit, remote status, commit circuit breaker state.'),
30111
+ response: vcsStatusResponseSchema,
30112
+ kind: 'read',
29523
30113
  },
29524
30114
  {
29525
- name: 'rulesUnregisterBySource',
29526
- method: 'DELETE',
29527
- path: '/rules/unregister/:source',
29528
- description: 'Unregister all external inference rules from a source.',
30115
+ ...vcsRoute('listVcsHistory', 'GET', '/vcs/history', 'Query change history by path or glob with optional date range.'),
30116
+ query: vcsHistoryQuerySchema,
30117
+ response: vcsHistoryResponseSchema,
30118
+ kind: 'read',
29529
30119
  },
29530
30120
  {
29531
- name: 'pointsDelete',
29532
- method: 'POST',
29533
- path: '/points/delete',
29534
- description: 'Delete points from the vector store by filter.',
30121
+ ...vcsRoute('showVcsFile', 'GET', '/vcs/show', 'Retrieve file content at a specific version. The reply is the raw file content; its content-type follows the file extension.'),
30122
+ query: vcsShowQuerySchema,
30123
+ response: string$2(),
30124
+ contentType: 'text/plain',
30125
+ kind: 'read',
29535
30126
  },
29536
30127
  {
29537
- name: 'rulesReapply',
29538
- method: 'POST',
29539
- path: '/rules/reapply',
29540
- description: 'Re-apply inference rules to existing vector points.',
30128
+ ...vcsRoute('diffVcs', 'GET', '/vcs/diff', 'Show what changed between two versions, or between a version and current. The reply is a unified diff.'),
30129
+ query: vcsDiffQuerySchema,
30130
+ response: vcsDiffResponseSchema,
30131
+ contentType: 'text/plain',
30132
+ kind: 'read',
29541
30133
  },
29542
30134
  {
29543
- name: 'vcsStatus',
29544
- method: 'GET',
29545
- path: '/vcs/status',
29546
- description: 'VCS state for all roots: enabled state, tracked files, last commit, remote status, commit circuit breaker state.',
30135
+ ...vcsRoute('checkVcsExclusion', 'GET', '/vcs/check-exclusion', 'Check whether a path is excluded from version tracking and why.'),
30136
+ query: vcsCheckExclusionQuerySchema,
30137
+ response: vcsCheckExclusionResponseSchema,
30138
+ kind: 'read',
29547
30139
  },
30140
+ ];
30141
+
30142
+ /**
30143
+ * @module catalog/writeEntries
30144
+ * The watcher catalog's `write` entries, in catalog order (spec Design 3 kinds). No I/O.
30145
+ */
30146
+ /** The `write` entries. */
30147
+ const WRITE_ENTRIES = [
29548
30148
  {
29549
- name: 'vcsHistory',
29550
- method: 'GET',
29551
- path: '/vcs/history',
29552
- description: 'Query change history by path or glob with optional date range.',
30149
+ ...route('setMetadata', 'POST', '/metadata', 'Set or update metadata on a document by file path or source reference. While the document is being processed the route waits at most 10 s for its lock, then answers 503 `not_available` with `Retry-After: 10`, nothing changed (retry).'),
30150
+ body: metadataRequestSchema,
30151
+ response: metadataResponseSchema,
30152
+ kind: 'write',
29553
30153
  },
29554
30154
  {
29555
- name: 'vcsShow',
29556
- method: 'GET',
29557
- path: '/vcs/show',
29558
- description: 'Retrieve file content at a specific version.',
30155
+ ...route('reindex', 'POST', '/reindex', 'Trigger a scoped reindex of watched files (scope path also accepts source references).'),
30156
+ body: reindexRequestSchema,
30157
+ response: reindexResponseSchema,
30158
+ kind: 'write',
29559
30159
  },
29560
30160
  {
29561
- name: 'vcsDiff',
29562
- method: 'GET',
29563
- path: '/vcs/diff',
29564
- description: 'Show what changed between two versions, or between a version and current.',
30161
+ ...route('notifyDocuments', 'POST', '/documents', 'Notify changed source documents ({ source, rule }); queued and coalesced (202), or processed synchronously with ?wait=true. Answers 202 with accepted and rejected references, or with `wait=true` 200 with per-document results.'),
30162
+ query: documentsQuerySchema,
30163
+ body: documentsEnvelopeSchema,
30164
+ response: union$1([
30165
+ documentsAcceptedResponseSchema,
30166
+ documentsResultsResponseSchema,
30167
+ ]),
30168
+ kind: 'write',
29565
30169
  },
29566
30170
  {
29567
- name: 'vcsCheckExclusion',
29568
- method: 'GET',
29569
- path: '/vcs/check-exclusion',
29570
- description: 'Check whether a path is excluded from version tracking and why.',
30171
+ ...route('registerRules', 'POST', '/rules/register', 'Register external inference rules.'),
30172
+ body: rulesRegisterRequestSchema,
30173
+ response: rulesRegisterResponseSchema,
30174
+ kind: 'write',
29571
30175
  },
29572
30176
  {
29573
- name: 'vcsRevert',
29574
- method: 'POST',
29575
- path: '/vcs/revert',
29576
- description: 'Restore files from a historical commit (forward-only reversion).',
30177
+ ...route('reapplyRules', 'POST', '/rules/reapply', 'Re-apply inference rules to existing vector points. The route waits at most 10 s for each document lock; if one stays busy it answers 503 `not_available` with `Retry-After: 10`, and documents already updated keep their update (retry to finish).'),
30178
+ body: rulesReapplyRequestSchema,
30179
+ response: rulesReapplyResponseSchema,
30180
+ kind: 'write',
29577
30181
  },
29578
30182
  {
29579
- name: 'vcsExclude',
29580
- method: 'POST',
29581
- path: '/vcs/exclude',
29582
- description: 'Manage .gitignore entries for version tracking exclusion.',
30183
+ ...route('rebuildMetadata', 'POST', '/rebuild-metadata', 'Rebuild the enrichment store from vector point payloads (file points only).'),
30184
+ body: rebuildMetadataRequestSchema,
30185
+ response: rebuildMetadataResponseSchema,
30186
+ kind: 'write',
29583
30187
  },
29584
30188
  ];
29585
- /**
29586
- * Look up an endpoint descriptor by name.
29587
- *
29588
- * @param name - The endpoint identifier.
29589
- * @returns The matching {@link EndpointDescriptor}.
29590
- */
29591
- function getEndpoint(name) {
29592
- const ep = WATCHER_ENDPOINTS.find((e) => e.name === name);
29593
- if (!ep)
29594
- throw new Error(`Unknown endpoint: ${name}`);
29595
- return ep;
29596
- }
29597
30189
 
29598
30190
  /**
29599
- * @module httpContract
29600
- * The one HTTP client contract shared by the SDK, the CLI and the plugin: the API key env var, bearer header construction and the mapping of a non-2xx response (status + raw body) to `{ status, code, message, details }`. Pure.
29601
- */
29602
- /** Environment variable holding the watcher API bearer key for clients (CLI, plugin). */
29603
- const API_KEY_ENV_VAR = 'JEEVES_WATCHER_API_KEY';
29604
- /**
29605
- * Request headers carrying the bearer key.
30191
+ * @module catalog
30192
+ * The watcher's endpoint catalog (`WATCHER_CATALOG`): one Zod-described entry per route, from which the service's routes and validation, the OpenAPI document, the SDK client, the CLI and the plugin's tools are derived. No I/O.
29606
30193
  *
29607
- * @param apiKey - Bearer key; `undefined` means no key.
29608
- * @returns `{ Authorization: 'Bearer <apiKey>' }`, or `{}` without a key.
30194
+ * @remarks
30195
+ * Holds every route (30 entries; spec Design 3, `countFacets` from Design
30196
+ * 12), each with its `kind`, listed by kind in `catalog/`. Entry
30197
+ * names are verb-first (D77, Design 14), and each is its operation's one
30198
+ * name: catalog entry, SDK method, OpenAPI `operationId` and, through
30199
+ * `toToolName`, plugin tool name. The catalog is the only route list
30200
+ * (D41).
29609
30201
  */
29610
- function bearerHeaders(apiKey) {
29611
- return apiKey === undefined ? {} : { Authorization: `Bearer ${apiKey}` };
29612
- }
30202
+ /** The watcher endpoint catalog. */
30203
+ const WATCHER_CATALOG = defineCatalog([
30204
+ ...standardEntries('watcher', {
30205
+ auth: { config: bearer },
30206
+ healthSchema: watcherHealthSchema,
30207
+ }),
30208
+ ...READ_ENTRIES,
30209
+ ...WRITE_ENTRIES,
30210
+ ...DESTRUCTIVE_ENTRIES,
30211
+ ]);
30212
+
29613
30213
  /**
29614
- * Parse text as JSON, returning `undefined` when it is empty or not JSON.
29615
- *
29616
- * @param text - Raw response body.
29617
- * @returns The parsed value, or `undefined`.
30214
+ * @module constants
30215
+ * Shared identity constants for the jeeves-watcher ecosystem.
30216
+ * Consumed by both the service and plugin packages.
29618
30217
  */
29619
- function tryParseJson(text) {
29620
- if (text.length === 0)
29621
- return undefined;
29622
- try {
29623
- return JSON.parse(text);
29624
- }
29625
- catch {
29626
- return undefined;
29627
- }
29628
- }
29629
- const stringField = (body, key) => {
29630
- if (typeof body !== 'object' || body === null)
29631
- return undefined;
29632
- const value = body[key];
29633
- return typeof value === 'string' && value.length > 0 ? value : undefined;
30218
+ /** Component name shared across service and plugin descriptors. */
30219
+ const COMPONENT_NAME = 'watcher';
30220
+ /** npm package name for the service. */
30221
+ const SERVICE_PACKAGE = '@karmaniverous/jeeves-watcher';
30222
+ /** npm package name for the plugin. */
30223
+ const PLUGIN_PACKAGE = '@karmaniverous/jeeves-watcher-openclaw';
30224
+ /** Default watcher API port. */
30225
+ const DEFAULT_PORT = DEFAULT_PORTS.watcher;
30226
+ /** Default API values. */
30227
+ ({
30228
+ port: DEFAULT_PORTS.watcher});
30229
+ /** Default reindex values. */
30230
+ const REINDEX_DEFAULTS = {
30231
+ /** Files (and source documents, and stored-rule lookups) in flight at once. */
30232
+ concurrency: 50,
29634
30233
  };
29635
30234
  /**
29636
- * Code and message of a 0.20.0+ error body: `code` is one of the canonical
29637
- * API error codes and `error` is the human-readable text (`message`, when
29638
- * present and different, is appended).
29639
- *
29640
- * @param body - Parsed response body.
29641
- * @returns Code and message, or `undefined` for any other body.
30235
+ * Default processing ceilings (D44, D64): from the 2026-10-31 corpus survey,
30236
+ * which only the single largest document exceeded.
29642
30237
  */
29643
- function canonical(body) {
29644
- const code = apiErrorCodeSchema.safeParse(stringField(body, 'code'));
29645
- const error = stringField(body, 'error');
29646
- if (!code.success || error === undefined)
29647
- return undefined;
29648
- const extra = stringField(body, 'message');
29649
- return {
29650
- code: code.data,
29651
- message: extra !== undefined && extra !== error ? `${error}: ${extra}` : error,
29652
- };
29653
- }
29654
- /**
29655
- * Map a non-2xx response to its code, message and details.
29656
- *
29657
- * - Canonical body (`code` is an `ApiErrorCode`, `error` is a string): `code`
29658
- * is `body.code`; `message` is `body.error`, plus `: <body.message>` when
29659
- * present.
29660
- * - Otherwise (older servers, proxies): `code` is `body.code`, else
29661
- * `body.error`, else `http_<status>`; `message` is `body.message`, else
29662
- * `body.error`, else `HTTP <status> <statusText>`.
29663
- * - `details`: `body.details` when present, else the parsed body (or raw
29664
- * text).
29665
- *
29666
- * @param status - HTTP status.
29667
- * @param statusText - HTTP status text.
29668
- * @param text - Raw response body.
29669
- * @returns The mapped error parts.
29670
- */
29671
- function httpError(status, statusText, text) {
29672
- const body = tryParseJson(text);
29673
- const fallbackMessage = `HTTP ${String(status)}${statusText ? ` ${statusText}` : ''}`.trim();
29674
- const hasDetails = typeof body === 'object' && body !== null && 'details' in body;
29675
- const details = hasDetails
29676
- ? body.details
29677
- : (body ?? (text.length > 0 ? text : undefined));
29678
- const mapped = canonical(body) ?? {
29679
- code: stringField(body, 'code') ??
29680
- stringField(body, 'error') ??
29681
- `http_${String(status)}`,
29682
- message: stringField(body, 'message') ??
29683
- stringField(body, 'error') ??
29684
- fallbackMessage,
29685
- };
29686
- return { status, ...mapped, details };
29687
- }
30238
+ const PROCESSING_DEFAULTS = {
30239
+ /** 64 MiB, checked with `stat` before extraction. */
30240
+ maxFileBytes: '64m',
30241
+ maxChunksPerFile: 5000,
30242
+ /** 8 MiB of extracted text (UTF-8 bytes). */
30243
+ maxTextBytes: '8m',
30244
+ };
30245
+
29688
30246
  /**
29689
- * One-line text for a mapped error, as the CLI and plugin report it.
29690
- *
29691
- * @param info - The mapped error.
29692
- * @returns `HTTP <status> <code>: <message>`.
30247
+ * @module httpContract
30248
+ * The HTTP client constant the CLI and the plugin share: the environment variable holding the watcher API bearer key. Request building and error mapping live in jeeves's catalog client and the SDK's `WatcherError` (spec #281 D36, D53). Pure.
29693
30249
  */
29694
- function formatHttpError(info) {
29695
- return `HTTP ${String(info.status)} ${info.code}: ${info.message}`;
29696
- }
30250
+ /** Environment variable holding the watcher API bearer key for clients (CLI, plugin). */
30251
+ const API_KEY_ENV_VAR = 'JEEVES_WATCHER_API_KEY';
30252
+
29697
30253
  /**
29698
- * Error text for a `401` response. Never contains the key.
30254
+ * @module promptRules
30255
+ * Always-in-context watcher rules (descriptor `promptRules`), injected by the plugin through `before_prompt_build`. Static text; no side effects.
29699
30256
  *
29700
- * @param keySent - Whether the request carried a bearer key.
29701
- * @param keySources - Where this client reads its key from (e.g. `--api-key or the JEEVES_WATCHER_API_KEY env var`).
29702
- * @returns Guidance on supplying (or checking) the key.
30257
+ * @remarks
30258
+ * Live numbers (point counts, configured score thresholds, VCS roots) are
30259
+ * served by `watcher_status` and `watcher_config`, and detail lives in the
30260
+ * skill ({@link WATCHER_SKILL}).
29703
30261
  */
29704
- function unauthorizedMessage(keySent, keySources) {
29705
- return keySent
29706
- ? `HTTP 401 unauthorized: the watcher API rejected the API key. Check ${keySources} against the service's api.apiKey.`
29707
- : `HTTP 401 unauthorized: the watcher API requires an API key. Provide it via ${keySources}.`;
29708
- }
30262
+ /** Static watcher rules the agent must see on every turn. */
30263
+ const WATCHER_PROMPT_RULES = [
30264
+ '## Watcher',
30265
+ 'This environment includes a semantic search index over the document archive (`watcher_search`).',
30266
+ '- **Escalation rule:** Use `memory_search` for personal operational notes, decisions, and rules. Escalate to `watcher_search` when memory is thin, or when searching the broader archive (tickets, docs, code). ALWAYS use `watcher_search` BEFORE filesystem commands (exec, grep) when looking for information in indexed paths.',
30267
+ '- **Scan-first rule:** For structural queries (file enumeration, staleness checks, domain listing, counts), use `watcher_scan` instead of `watcher_search`. Scan does not use embeddings and does not accept a query string.',
30268
+ '- **Search-first rule:** When finding, reading, or modifying files in indexed paths, run `watcher_search` FIRST, even if you already know the file path. Direct filesystem access is for acting on search results, not bypassing them.',
30269
+ '- **Scores:** strong / relevant / noise thresholds apply only when configured at `$.search.scoreThresholds` (query with `watcher_config`); there are no defaults. When configured, discard noise; if all results are noise, broaden the query.',
30270
+ '- **Inventory on demand (`watcher_config`):** inference rules `$.inferenceRules[*]`, watched paths `$.watch.paths[*]`, ignored paths `$.watch.ignored[*]`. Version history: `watcher_list_vcs_history`, `watcher_diff_vcs`, `watcher_show_vcs_file`. See the jeeves-watcher skill for detail.',
30271
+ ].join('\n');
29709
30272
 
29710
30273
  /**
29711
30274
  * @module reservedFields
@@ -29758,6 +30321,19 @@ const SYSTEM_PAYLOAD_INDEX_FIELDS = [
29758
30321
  ];
29759
30322
  new Set(RESERVED_PAYLOAD_FIELDS);
29760
30323
 
30324
+ /**
30325
+ * @module schemas/byteSize
30326
+ * Byte-size settings (Design 10): a number of bytes, or a string with a `k`, `m` or `g` suffix read as binary units (`10m` = 10,485,760 bytes). One schema and one parser for every byte-size key.
30327
+ */
30328
+ /** A byte-size string: digits (optionally with a decimal part) and an optional k, m or g suffix. */
30329
+ const BYTE_SIZE_PATTERN = /^\s*(\d+(?:\.\d+)?)\s*([kmg]?)\s*$/i;
30330
+ /** A byte-size setting: bytes, or a string such as `10m` (binary units). */
30331
+ const byteSizeSchema = union$1([
30332
+ number$2().int().min(0),
30333
+ string$2()
30334
+ .regex(BYTE_SIZE_PATTERN, 'Expected a byte size: a number, or a number with a k, m or g suffix (binary units)'),
30335
+ ]);
30336
+
29761
30337
  /**
29762
30338
  * @module schemas/vcs
29763
30339
  * VCS (version control system) configuration schema for git-backed content versioning.
@@ -29878,11 +30454,19 @@ const vcsConfigSchema = object$1({
29878
30454
  .min(1000)
29879
30455
  .default(300000)
29880
30456
  .describe('Circuit breaker cooldown in ms before a half-open recovery attempt. Default: 300000 (5 minutes).'),
29881
- /** Git branch name for VCS operations. SquashManager and startup recovery use this instead of dynamic detection. */
30457
+ /** Git branch name for VCS operations. Squash and the startup branch check use this instead of dynamic detection. */
29882
30458
  branch: string$2()
29883
30459
  .min(1)
29884
30460
  .default('master')
29885
30461
  .describe('Git branch name for VCS operations. Default: "master".'),
30462
+ /**
30463
+ * Size cap for versioned files (D80). A new file over it is not staged;
30464
+ * a tracked file that grows past it is untracked in its own commit.
30465
+ * `0` disables the cap. Read at each staging (no restart needed).
30466
+ */
30467
+ maxFileBytes: byteSizeSchema
30468
+ .optional()
30469
+ .describe("Size cap for versioned files: bytes, or a string with a k, m or g suffix (binary units). A new file over it is not staged; a tracked file that grows past it is untracked (its history ends at its last committed version). 0 disables the cap. Default: 100000000 (100 MB, under GitHub's 100 MiB file limit)."),
29886
30470
  });
29887
30471
  /**
29888
30472
  * Per-root VCS overrides extending the root-level VCS config.
@@ -29935,6 +30519,10 @@ const watchConfigSchema = object$1({
29935
30519
  usePolling: boolean$2()
29936
30520
  .optional()
29937
30521
  .describe('Use polling instead of native file system events (for network drives).'),
30522
+ /** The local change source (D125); `usePolling: true` forces chokidar. */
30523
+ changeSource: _enum$1(['parcel', 'chokidar'])
30524
+ .optional()
30525
+ .describe('Local change source: "parcel" (@parcel/watcher, one native subscription per watch root; the default) or "chokidar" (the manual switch). usePolling: true forces chokidar. A reload that changes it rebuilds the watcher; if @parcel/watcher cannot load, the service refuses to start (or a reload refuses the rebuild) naming this switch.'),
29938
30526
  /** Debounce delay in milliseconds for file change events. */
29939
30527
  debounceMs: number$2()
29940
30528
  .optional()
@@ -29943,6 +30531,16 @@ const watchConfigSchema = object$1({
29943
30531
  stabilityThresholdMs: number$2()
29944
30532
  .optional()
29945
30533
  .describe('Time in milliseconds a file must remain unchanged before processing.'),
30534
+ /** Periodic reconciliation interval (D111); 0 disables it. */
30535
+ reconcileIntervalMs: number$2()
30536
+ .int()
30537
+ .min(0)
30538
+ .optional()
30539
+ .describe('Interval in milliseconds between periodic reconciliations of each watch root against the inventory (default 900000, 15 minutes); 0 disables them. A reload changes it without rebuilding the watcher.'),
30540
+ /** Index check at start (D197). */
30541
+ indexCheckOnStart: boolean$2()
30542
+ .optional()
30543
+ .describe('After the start walk, delete the indexed file documents under each available watch root whose files are gone (deleted or moved away while the service was down). Default: true. A root where more than 10% of its indexed documents (once more than 20 are missing) or more than 1,000 are missing is left alone (run reindex prune deliberately). Read at start.'),
29946
30544
  /** Whether to respect .gitignore files when processing. */
29947
30545
  respectGitignore: boolean$2()
29948
30546
  .optional()
@@ -30000,6 +30598,25 @@ const apiConfigSchema = object$1({
30000
30598
  .optional()
30001
30599
  .describe('Bearer API key required on every route except GET /status (supports ${ENV_VAR} substitution). Must be non-empty. Unset: no authentication. Read at startup only.'),
30002
30600
  });
30601
+ /**
30602
+ * Log file rotation (D171). Every key falls back to its default.
30603
+ */
30604
+ const logRotationConfigSchema = object$1({
30605
+ /** Rotate when the file reaches this size. */
30606
+ maxSize: byteSizeSchema
30607
+ .optional()
30608
+ .describe('Rotate when the log file reaches this size: bytes, or a string with a k, m or g suffix (binary units). Default: "10m".'),
30609
+ /** Rotate on this schedule too. */
30610
+ frequency: union$1([_enum$1(['daily', 'hourly']), number$2().int().positive()])
30611
+ .optional()
30612
+ .describe('Also rotate "daily", "hourly", or every N milliseconds. Default: "daily".'),
30613
+ /** Rotated files kept in addition to the active one (up to N + 1 files, D184). */
30614
+ maxFiles: number$2()
30615
+ .int()
30616
+ .min(1)
30617
+ .optional()
30618
+ .describe('Number of rotated log files kept in addition to the active one, so up to maxFiles + 1 files remain. Default: 14.'),
30619
+ });
30003
30620
  /**
30004
30621
  * Logging configuration.
30005
30622
  */
@@ -30012,6 +30629,10 @@ const loggingConfigSchema = object$1({
30012
30629
  file: string$2()
30013
30630
  .optional()
30014
30631
  .describe('Path to log file (logs to stdout if omitted).'),
30632
+ /** Rotation of the log file (D171). */
30633
+ rotation: union$1([literal(false), logRotationConfigSchema])
30634
+ .optional()
30635
+ .describe('Rotation of logging.file: false to disable, or { maxSize, frequency, maxFiles }. Default when logging.file is set: { maxSize: "10m", frequency: "daily", maxFiles: 14 }.'),
30015
30636
  });
30016
30637
 
30017
30638
  /**
@@ -30244,6 +30865,28 @@ const payloadIndexesSchema = array$1(payloadIndexSchema)
30244
30865
  });
30245
30866
  });
30246
30867
 
30868
+ /**
30869
+ * @module schemas/processing
30870
+ * `processing` config: the per-document ceilings (file size before extraction, extracted text bytes, chunk count) past which a document is `too_large` (D44, D64).
30871
+ */
30872
+ /** Processing ceilings, read per document (a reload applies to the next one). */
30873
+ const processingConfigSchema = object$1({
30874
+ /** File size cap, checked with `stat` before extraction (files only). */
30875
+ maxFileBytes: byteSizeSchema
30876
+ .optional()
30877
+ .describe(`Largest file processed, checked with stat before extraction (files only; source documents keep the 10 MiB response cap): bytes, or a string with a k, m or g suffix (binary units). A larger file has its points deleted and a too_large issue recorded. Default: '${PROCESSING_DEFAULTS.maxFileBytes}'.`),
30878
+ /** Most chunks one document may split into. */
30879
+ maxChunksPerFile: number$2()
30880
+ .int()
30881
+ .min(1)
30882
+ .optional()
30883
+ .describe(`Most chunks one document (file or source) may split into; more has its points deleted and a too_large issue recorded. Default: ${String(PROCESSING_DEFAULTS.maxChunksPerFile)}.`),
30884
+ /** Extracted text cap (UTF-8 bytes). */
30885
+ maxTextBytes: byteSizeSchema
30886
+ .optional()
30887
+ .describe(`Largest extracted text of one document (file or source), in UTF-8 bytes: bytes, or a string with a k, m or g suffix (binary units). More has its points deleted and a too_large issue recorded. Default: '${PROCESSING_DEFAULTS.maxTextBytes}'.`),
30888
+ });
30889
+
30247
30890
  /**
30248
30891
  * @module schemas/services
30249
30892
  * Service configuration schemas: embedding and vector store.
@@ -30284,6 +30927,10 @@ const embeddingConfigSchema = object$1({
30284
30927
  concurrency: number$2()
30285
30928
  .optional()
30286
30929
  .describe('Maximum concurrent embedding requests.'),
30930
+ /** URL POSTed when embedding health changes state (unsigned body). */
30931
+ alertUrl: url()
30932
+ .optional()
30933
+ .describe('URL that receives a POST when embedding health turns failing (embedding_failed) or recovers (embedding_recovered). The body is unsigned and sent without credentials: use a private endpoint. Read at each transition.'),
30287
30934
  });
30288
30935
  /**
30289
30936
  * Vector store configuration for Qdrant.
@@ -30424,6 +31071,10 @@ object$1({
30424
31071
  vcs: vcsConfigSchema
30425
31072
  .optional()
30426
31073
  .describe('VCS configuration for git-backed version control of watched content.'),
31074
+ /** Processing ceilings (D44, D64). */
31075
+ processing: processingConfigSchema
31076
+ .optional()
31077
+ .describe('Processing ceilings: a document over one has its points deleted and a too_large issue recorded. Read per document.'),
30427
31078
  /** Named sources for non-file documents (callback retrieval). */
30428
31079
  sources: sourcesConfigSchema.optional(),
30429
31080
  /** Logging configuration. */
@@ -30442,6 +31093,13 @@ object$1({
30442
31093
  .describe('Maximum backoff delay in milliseconds for system errors. Default: 60000.'),
30443
31094
  });
30444
31095
 
31096
+ /**
31097
+ * @module skill.generated
31098
+ * The jeeves-watcher skill (descriptor `skill`), compiled from `skills/jeeves-watcher/SKILL.md` by `npm run skill`. Generated; do not edit.
31099
+ */
31100
+ /** Contents of the jeeves-watcher `SKILL.md`. */
31101
+ const WATCHER_SKILL = '---\nname: jeeves-watcher\ndescription: >\n Semantic search of the indexed archive: recall prior context, find documents, scan, enrich metadata, watcher config, reindex, indexing issues, file history.\n---\n\n# jeeves-watcher — Search, Discovery & Administration\n\n## Service Architecture\n\nThe watcher is an HTTP API running as a background service (typically NSSM on Windows, systemd on Linux).\n\n**Default port:** 1936 (configurable via `api.port` in watcher config) **Non-default port:** If the watcher runs on a different port, the user must set `plugins.entries.jeeves-watcher-openclaw.config.apiUrl` in `openclaw.json`. The plugin cannot auto-discover a non-default port.\n\n**API key:** If the watcher sets `api.apiKey`, every route except `GET /status` and `GET /openapi.json` needs `Authorization: Bearer <key>`. The tools send it from `plugins.entries.jeeves-watcher-openclaw.config.apiKey` (or the `JEEVES_WATCHER_API_KEY` env var). From `watcher_config`, `watcher_config_apply`, `watcher_search`, `watcher_scan` and `watcher_show_vcs_file`, a missing or wrong key comes back as `HTTP 401: {"error":"Unauthorized","code":"unauthorized"}` (see [Error Handling](#error-handling) for the fix). From the other tools it reads `HTTP 401 unauthorized: the watcher API requires an API key. Provide it via plugins.entries.jeeves-watcher-openclaw.config.apiKey or the JEEVES_WATCHER_API_KEY env var.` (or, when a key was sent, `... rejected the API key. Check ... against the service\'s api.apiKey.`). When calling the API directly, add the header yourself. Never echo the key.\n\n**Tool errors:** Tool error text is prefixed `Error: `. Every `watcher_*` tool is built from the watcher\'s endpoint catalog and reports a non-2xx response as `HTTP <status>: <body>`, with the server\'s JSON body verbatim: read its `code` (and `details` or `issues`) there, e.g. `HTTP 400: {"error":"Validation failed","code":"invalid_request",...}` or `HTTP 503: {"error":"Scan in progress","code":"scan_in_progress"}`. Branch on the code, not the message. The tools reject parameters that don\'t match their schema before sending anything. Each call waits up to 120 s; a timeout does not cancel a state change on the service, so check the outcome (e.g. `watcher_check_vcs_exclusion` after `watcher_exclude_vcs_path`) before retrying. An unreachable service reads `Service not reachable at <url>.` with a hint to set `apiUrl`. (`watcher_service` comes from the shared Jeeves core toolset and words its own errors.)\n\n**Health check:** `GET /status` returns `name`, `version`, `uptime`, `status` (`healthy`/`degraded`/`unhealthy`), and a `health` object containing `collection` (`name`, `pointCount`, `dimensions`, `systemIndexes`), `reindex` progress, `initialScan` progress, `embedding` (provider health), and (when version tracking is on) `vcs` (`roots`, `pending`, `trippedRoots`, `oldestTrippedAt`). `status` is `degraded` (still HTTP 200) when embeddings are failing or a version-control root has tripped.\n\n**Mental model:** The `watcher_*` tools are thin HTTP wrappers. Each tool call translates to an HTTP request to the watcher API. When tools are available, use them. When they\'re not (e.g., different session, plugin not loaded), you can hit the API directly. Replace `<PORT>` below with the configured port (default 1936; check `plugins.entries.jeeves-watcher-openclaw.config.apiUrl` in `openclaw.json` if overridden), and add `-H "Authorization: Bearer <key>"` when the service sets `api.apiKey`:\n\n```\n# Health check\ncurl http://127.0.0.1:<PORT>/status\n\n# Search\ncurl -X POST http://127.0.0.1:<PORT>/search \\\n -H "Content-Type: application/json" \\\n -d \'{"query": "search text", "limit": 5}\'\n\n# Query config\ncurl http://127.0.0.1:<PORT>/config\n```\n\n**Key endpoints:**\n\n| Endpoint | Method | Purpose |\n| --- | --- | --- |\n| `/status` | GET | Health check, uptime, collection stats |\n| `/openapi.json` | GET | OpenAPI 3.1 document of the catalog routes (no key needed) |\n| `/search` | POST | Semantic search (main query interface) |\n| `/config` | GET | Public resolved config (allow-listed keys plus `issues`); optional `?path=<jsonpath>` filter |\n| `/config/validate` | POST | Validate candidate config |\n| `/config/apply` | POST | Apply config changes |\n| `/reindex` | POST | Trigger reindex |\n| `/metadata` | POST | Enrich document metadata (file path or source reference) |\n| `/scan` | POST | Filter-only point query (no embeddings) |\n| `/walk` | POST | Watched-file listing with glob intersection |\n| `/issues` | GET | Runtime processing failures (file paths and source references) |\n| `/documents` | POST | Notify changed source documents (`{ source, rule }`) |\n| `/documents/delete` | POST | Delete source documents by reference or filter |\n| `/rules/register` | POST | Register virtual inference rules (auto-triggers rules reindex) |\n| `/rules/unregister` | DELETE | Remove virtual rules by source (`{ source }` body) |\n| `/rules/unregister/:source` | DELETE | Remove virtual rules by source (parameterized) |\n| `/rules/reapply` | POST | Re-apply rules to indexed files matching `globs` |\n| `/points/delete` | POST | Delete points matching a Qdrant filter |\n| `/render` | POST | Render a document (file path or source reference) through its rule |\n| `/search/facets` | GET | Get search facet definitions and values (`?rules=a,b` scopes them to those rules) |\n| `/search/facets/counts` | POST | Count values of indexed fields over a filter, each document once; counts are approximate (`{ fields, filter?, rules?, limit? }`); `{ min, max }` for number/date fields; `503 not_available` while its system indexes build or past its 10 s budget |\n| `/rebuild-metadata` | POST | Rebuild the enrichment store from Qdrant file point payloads |\n| `/config/schema` | GET | Get config JSON schema |\n| `/config/match` | POST | Test file paths against inference rules and watch scope |\n| `/vcs/*` | GET/POST | Version tracking (`status`, `history`, `show`, `diff`, `check-exclusion`, `revert`, `exclude`) |\n\n**If the watcher is unreachable:** Check the service status (`nssm status jeeves-watcher` on Windows), check the configured port in the watcher config file, and check logs for startup errors.\n\n## Theory of Operation\n\nYou have access to a **semantic archive** of your human\'s working world. Documents, messages, tickets, notes, code, and other artifacts are indexed, chunked, embedded, and searchable. This is your long-term recall for anything beyond the current conversation.\n\n**When to reach for the watcher:**\n\n- **Someone asks about something that happened.** A meeting, a decision, a conversation, a ticket, a message. You weren\'t there, but the archive was. Search it.\n- **You need context you don\'t have.** Before asking the human "what\'s the status of X?", search for X. The answer is probably already indexed.\n- **You\'re working on something and need background.** Prior discussions, related records, relevant documents. Search by topic, filter by domain.\n- **You need to verify something.** Don\'t guess from stale memory. Search for the current state.\n- **You want to connect dots across domains.** The same topic might appear in multiple domains. A single semantic search surfaces all of them.\n\n**When NOT to use it:**\n\n- You already have the information in the current conversation\n- The question is about general knowledge, not the human\'s specific context\n- The watcher is unreachable (fall back to filesystem browsing)\n\n**The principle:** Memory-core is your curated highlights. The watcher archive is your perfect recall. Use memory first for speed and signal, but never let its narrow scope be the ceiling of what you can remember.\n\n## ⚠️ Embedding Cost — Hard Behavioral Gate\n\n**The watcher embeds every file it sees.** Embedding is the most expensive operation in the pipeline — each file triggers a Gemini API call. The index may contain hundreds of thousands of points. Any action that causes the watcher to process a large number of files has real, potentially significant cost.\n\n**STOP and ask for a human decision before:**\n\n- **Renaming, moving, or reorganizing watched directories.** The watcher has **move detection** enabled by default (`watch.moveDetection.enabled: true`). When a file is moved, the change source reports an unlink and an add; the `MoveCorrelator` buffers the unlink, matches it with the add via content hash, and processes it as a zero-embedding move (point ID remap, metadata re-inference, enrichment migration). Under the default parcel source, a renamed or moved directory is paired as a whole (at least 90% of its files at the same relative path and size) and each file becomes such a move. **No re-embedding cost for moves.** However, if a rename is blocked (e.g., by a file lock from the watcher or another service), **do NOT work around it by creating new directories and copying/moving files**. That creates duplicate embeddings at the new paths while the old paths still exist. Instead: stop the blocking service, perform the rename, restart.\n- **Changing `watch.paths` to add large directory trees.** Adding a new watch root triggers initial indexing of every matching file under it.\n- **Running `scope: "full"` reindex.** Every watched file is re-extracted (a file whose text to embed changed, or that was never indexed, is re-embedded) and every indexed source document is retrieved through its callback and always re-embedded. Use `scope: "rules"` for inference rule changes (no file re-embedding; only metadata reapplication).\n- **Any bulk file operation** (mass copy, mass move, template-based file generation) under watched paths.\n\n**The right instinct when blocked:** If a filesystem operation fails because a service has a lock, the correct response is to stop the service, do the operation, and restart — NOT to find a creative workaround. Creative workarounds in the presence of a live watcher are how you accidentally trigger re-embedding of tens of thousands of files.\n\n**Cost context:** A single file embedding costs a Gemini API call. 1,000 unnecessary embeddings is a noticeable cost. 100,000 unnecessary embeddings (e.g., copying an entire domain directory to a new path) is a billing event worth flagging.\n\n## Plugin Installation\n\nThe plugin is a standard OpenClaw plugin. On a Jeeves box, `jeeves install watcher --config-root <path>` installs it and writes its config; otherwise use `openclaw plugins install npm:@karmaniverous/jeeves-watcher-openclaw@<version> --pin --accept-capabilities` and set `plugins.entries.jeeves-watcher-openclaw.config` yourself. Restart the gateway to load it.\n\n`configRoot` has no default. Only `watcher_service` with `action: "install"` needs it; other `watcher_service` actions and all HTTP API tools work without it. If `watcher_service install` returns "configRoot not configured", set `configRoot` in the plugin config or the `JEEVES_CONFIG_ROOT` env var.\n\n## Quick Start (Existing Deployment)\n\nIf the watcher service is already running and healthy:\n\n1. **Search** — use `watcher_search` with a natural language query and optional metadata filters\n2. **Read source** — use `read` (standard file read) with `file_path` from search results for full document content. Source documents (points carrying `source`, such as `app://chat/thread/42`, and no `file_path`) are not files: their content comes from the source\'s callback (`POST /render` with `{ "source": "<ref>" }`).\n\n## Bootstrap (First-Time Setup)\n\nWhen the plugin loads and the watcher service is NOT yet set up, drive the entire setup proactively. The user should be able to install the plugin with nothing else in place and the bootstrap process gets them to a working system.\n\n**The agent drives this process.** Don\'t hand the user CLI commands and wait. Check each prerequisite, explain what\'s needed, execute what you can, and prompt the user only for decisions that require human judgment.\n\n### Step 1: Check Node.js\n\nVerify Node.js is installed and version ≥ 24.22.2:\n\n```bash\nnode --version\n```\n\nIf missing or too old, guide the user to install Node.js 24.22.2 or later from https://nodejs.org or via their package manager.\n\n### Step 2: Install Qdrant\n\nCheck if Qdrant is already running:\n\n```bash\ncurl -s http://localhost:6333/healthz\n```\n\nIf not running, install it. **Prefer native installation** (especially on cloud instances where Docker may not be available):\n\n**Linux (recommended for servers):**\n\n```bash\n# Download and install binary\ncurl -L https://github.com/qdrant/qdrant/releases/latest/download/qdrant-x86_64-unknown-linux-musl.tar.gz -o /tmp/qdrant.tar.gz\nsudo tar xzf /tmp/qdrant.tar.gz -C /usr/local/bin/\n\n# Create qdrant user and directories\nsudo useradd -r -s /bin/false qdrant\nsudo mkdir -p /var/lib/qdrant/storage /var/lib/qdrant/snapshots /etc/qdrant\nsudo chown -R qdrant:qdrant /var/lib/qdrant\n\n# Create config\nsudo tee /etc/qdrant/config.yaml > /dev/null <<EOF\nstorage:\n storage_path: /var/lib/qdrant/storage\n snapshots_path: /var/lib/qdrant/snapshots\nservice:\n host: 0.0.0.0\n http_port: 6333\n grpc_port: 6334\nEOF\n\n# Create systemd service\nsudo tee /etc/systemd/system/qdrant.service > /dev/null <<EOF\n[Unit]\nDescription=Qdrant Vector Database\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/local/bin/qdrant --config-path /etc/qdrant/config.yaml\nWorkingDirectory=/var/lib/qdrant\nRestart=always\nUser=qdrant\n\n[Install]\nWantedBy=multi-user.target\nEOF\nsudo systemctl daemon-reload\nsudo systemctl enable --now qdrant\n```\n\n**Windows:**\n\n```powershell\n# Download from GitHub releases page\n# https://github.com/qdrant/qdrant/releases\n# Extract and run, or register as NSSM service:\nnssm install Qdrant <path-to-qdrant.exe>\nnssm start Qdrant\n```\n\n**Docker (fallback, if available):**\n\n```bash\ndocker run -d -p 6333:6333 -v qdrant_data:/qdrant/storage qdrant/qdrant\n```\n\nAfter installation, verify:\n\n```bash\ncurl -s http://localhost:6333/healthz\n```\n\n### Step 3: Install Watcher Service\n\nInstall the watcher CLI globally:\n\n```bash\nnpm install -g @karmaniverous/jeeves-watcher\n```\n\nVerify:\n\n```bash\njeeves-watcher --version\n```\n\n### Step 4: Set Up Embedding Provider\n\nThe watcher uses Google Gemini for embeddings by default (`gemini-embedding-001`, 3072 dimensions).\n\nCheck for an existing API key:\n\n```bash\necho $GOOGLE_API_KEY # Linux/Mac\necho %GOOGLE_API_KEY% # Windows cmd\n$env:GOOGLE_API_KEY # PowerShell\n```\n\nIf not set, guide the user:\n\n1. Go to https://aistudio.google.com/apikey\n2. Create an API key (free tier supports 1,000 embedding requests/minute)\n3. Set it as a persistent environment variable:\n - **Linux:** Add `export GOOGLE_API_KEY=<key>` to `~/.bashrc` or `~/.profile`\n - **Windows:** `setx GOOGLE_API_KEY "<key>"` (new shell sessions only) or set via System Properties → Environment Variables\n - **macOS:** Add to `~/.zshrc` or use `launchctl setenv`\n\nVerify the key works by testing a Gemini API call:\n\n```bash\ncurl -s "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=$GOOGLE_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"models/gemini-embedding-001","content":{"parts":[{"text":"test"}]}}\'\n```\n\nA successful response contains an `embedding.values` array.\n\n### Step 5: Author Initial Config\n\nAsk the user these questions:\n\n- **What directories should the watcher index?** (e.g., `~/documents`, `~/projects`, a workspace path)\n- **What types of files matter?** (helps determine file extensions for watch globs)\n- **Are there directories to exclude?** (node_modules, .git, build outputs, etc.)\n\nThen generate a starter config file. Example minimal config:\n\n```json\n{\n "description": "Personal knowledge base indexing",\n "api": { "port": 1936 },\n "watch": {\n "paths": ["/home/user/documents/**/*.{md,txt,json,pdf,html,docx}"],\n "ignored": ["**/node_modules/**", "**/.git/**", "**/dist/**"]\n },\n "embedding": {\n "provider": "gemini",\n "model": "gemini-embedding-001",\n "dimensions": 3072,\n "apiKey": "${GOOGLE_API_KEY}",\n "chunkSize": 1000,\n "chunkOverlap": 200,\n "rateLimitPerMinute": 1000,\n "concurrency": 5\n },\n "vectorStore": {\n "url": "http://localhost:6333",\n "collectionName": "jeeves_archive"\n },\n "search": {\n "scoreThresholds": { "strong": 0.75, "relevant": 0.5, "noise": 0.25 },\n "hybrid": { "enabled": true }\n },\n "logging": { "level": "info" },\n "inferenceRules": []\n}\n```\n\nWrite the config to a sensible location (e.g., `~/.config/jeeves-watcher.config.json` on Linux, or alongside the user\'s workspace). Validate it against the config schema (offline; no running service needed) with:\n\n```bash\njeeves-watcher config validate -c <config-path>\n```\n\n### Step 6: Register and Start as a Service\n\n**The watcher should run as a persistent service, not a foreground process.**\n\n**Linux (systemd):**\n\n```bash\nsudo tee /etc/systemd/system/jeeves-watcher.service > /dev/null <<EOF\n[Unit]\nDescription=Jeeves Watcher - Filesystem Indexing Service\nAfter=network.target qdrant.service\n\n[Service]\nType=simple\nExecStart=$(which jeeves-watcher) start -c <config-path>\nWorkingDirectory=%h\nRestart=always\nEnvironment=GOOGLE_API_KEY=<key>\nUser=$USER\n\n[Install]\nWantedBy=multi-user.target\nEOF\nsudo systemctl enable --now jeeves-watcher\n```\n\n**Windows (NSSM):**\n\n```powershell\njeeves-watcher service install -c <config-path>\n# Or manually:\nnssm install jeeves-watcher (Get-Command jeeves-watcher).Source start -c <config-path>\nnssm set jeeves-watcher AppEnvironmentExtra GOOGLE_API_KEY=<key>\nnssm start jeeves-watcher\n```\n\nVerify the service started:\n\n```bash\ncurl -s http://127.0.0.1:1936/status\n```\n\n### Step 7: Verify Health\n\nCall `watcher_status` (or `curl http://127.0.0.1:1936/status`). Confirm:\n\n- Service is running\n- Qdrant collection exists with expected dimensions (3072)\n- Point count is increasing (initial indexing in progress)\n\nIf the point count is 0 after a minute, check `watcher_list_issues` for processing failures.\n\n### Step 8: Orientation\n\nOnce health is confirmed and initial indexing has started:\n\n1. Query `$.[\'description\',\'search\']` for the deployment\'s organizational strategy and score thresholds.\n2. Query `$.inferenceRules[*].[\'name\',\'description\']` for available record types.\n3. Report to the user: how many points indexed so far, which domains are available, estimated time to complete initial indexing (based on file count and embedding rate).\n\n### On Subsequent Sessions\n\nOn sessions after bootstrap is complete:\n\n1. Call `watcher_status` silently.\n2. Run the orientation queries silently.\n3. Only report if something changed (service down, point count dropped significantly, new domains appeared).\n\n**Key principle:** The agent drives discovery. The user shouldn\'t have to explain their archive to you — the archive explains itself through its config.\n\n## Tools\n\nThe plugin registers 18 tools: `watcher_status`, `watcher_config`, `watcher_config_apply`, `watcher_service`, `watcher_search`, `watcher_set_metadata`, `watcher_validate_config`, `watcher_reindex`, `watcher_scan`, `watcher_list_issues`, `watcher_walk`, and the seven version-control tools (see [Version Tracking](#version-tracking)).\n\n### `watcher_search`\n\nSemantic search over indexed documents (`POST /search`).\n\n- `query` (string, required) — natural language search query (non-empty)\n- `limit` (number, optional) — max results (positive integer), default 10\n- `offset` (number, optional) — skip N results for pagination (non-negative integer)\n- `filter` (object, optional) — Qdrant filter for metadata filtering\n\nReturns an array of `{ id, score, payload }` ordered by score (see [Search Result Shape](#search-result-shape)). A filter Qdrant rejects is `400 invalid_request`; an exhausted embedding budget is `503 embedding_unavailable`.\n\n### `watcher_set_metadata`\n\nSet or update metadata on a document (`POST /metadata`).\n\n- `path` (string, required) — file path of the document (absolute or watched path)\n- `metadata` (object, required) — key-value metadata to merge\n\nReturns `{ ok: true, matched_rules }`. If the document is busy (being indexed) for 10 s, the tool reports `503 not_available` with `Retry-After: 10` and changes nothing; retry after 10 s. The tool addresses files only; to enrich a source document, call `POST /metadata` directly with `{ "source": "<name>://<id>", "metadata": { ... } }` (exactly one of `path` or `source`).\n\n### `watcher_status`\n\nService health check (`GET /status`, never needs the API key). Returns `name`, `version`, `uptime`, `status`, and a `health` object: `collection` (`name`, `pointCount`, `dimensions`, and `systemIndexes`: the state of each index the service creates itself, e.g. `{ "chunk_index": "ready", "file_path": "ready", "matched_rules": "building" }`; `building`, `ready`, `conflict` or `failed`), `embedding` (`state` `ok` or `failing`, `lastSuccessAt`, `lastError` `{ at, code }`, `failingSince`, `backoffUntil`; codes only, never provider text), `reindex` (`active`; while active also `scope`, `startedAt`, `filesProcessed`, `totalFiles`), `initialScan`, and `vcs` when version tracking is on: `roots`, `pending` (changes not yet committed), `trippedRoots` (roots whose commits keep failing) and `oldestTrippedAt`.\n\nAfter a service restart, the `health.initialScan` field shows scan progress:\n\n- `active: true` — filesystem walk in progress; `filesMatched` and `filesEnqueued` grow until the initial walk (or chokidar\'s scan) completes\n- `active: false` with `completedAt`/`durationMs` — scan finished\n\nUse this to determine if the service is still initializing after a restart.\n\n### `watcher_config`\n\nQuery the effective runtime config via JSONPath (`GET /config`). Returns the full resolved merged document when no path is provided.\n\n- `path` (string, optional) — JSONPath expression\n\nThe document holds only the allow-listed keys `description`, `watch`, `configWatch`, `search`, `schemas`, `inferenceRules`, `mapHelpers`, `templateHelpers`, `maps`, `templates`, plus `issues` (the current issues). `api`, `sources`, `embedding`, `vectorStore` (and their keys) are never included. Each rule carries `source` (`config`, or the registering source of a virtual rule) and `values` (the runtime values index). File references (`map`, `maps`, `templates`) and named schema references are always resolved. An invalid JSONPath is `400 invalid_request`.\n\n### `watcher_validate_config`\n\nValidate config and optionally test file paths (`POST /config/validate`).\n\n- `config` (object, optional) — candidate config (partial or full). Omit to validate current config.\n- `testPaths` (string[], optional) — file paths to test against the config\n\nPartial configs are merged into the running config shallowly: each top-level key replaces the running value, except `inferenceRules`, which merge by rule name. This differs from `watcher_config_apply` (deep merge), so pass a whole top-level object (e.g. all of `watch`) when validating a change inside it. Returns `{ valid: false, errors: [{ path, message }] }`, or `{ valid: true, warnings?, testResults? }`, where each test result is `{ path, matchedRules, metadata, error? }` (`error: "File not found"` when the path cannot be read). Validation problems come back in a `200` body, so this is the place to see them in full.\n\n### `watcher_config_apply`\n\nApply config changes (`POST /config/apply`, sent as `{ patch: config }`).\n\n- `config` (object, required) — full or partial config to apply\n- `replace` (boolean, optional) — replace the whole config with `config` instead of merging\n\nPartial configs are deep-merged into the config file the service runs from (objects recursively, arrays replaced, `inferenceRules` by rule name), so `{}` or a single key is valid. The merged result is validated and written to disk, then the `configWatch.reindex` scope (default `issues`) runs; that reindex covers files only. With `configWatch.enabled` (default `true`) the service picks up the written file without a restart, including new/removed watch paths (the filesystem watcher is rebuilt), inference rule changes, move detection settings, and gitignore filter updates.\n\nReturns `{ applied: true, config }`. `config` is redacted through the same allow-list as `GET /config`: `api`, `sources` and provider keys are never echoed. When the file was written but the post-write apply step failed, the body also carries `warning` (a string describing the failure). An invalid merged config is a tool error reading `HTTP 400: {"error":"Config validation failed","code":"invalid_request","issues":[...]}`, so run `watcher_validate_config` first. A config file the service cannot read or write is `HTTP 500` with the generic `internal_error` body, the cause in the service log.\n\n### `watcher_reindex`\n\nTrigger a reindex operation (`POST /reindex`). Non-prune scopes return a `plan` object showing the blast area; the live run reuses the same targets. A live prune (non-dry-run) returns immediately without a plan.\n\n**Parameters:**\n\n- `scope` (string, optional) — Reindex scope. The tool sends `"rules"` when omitted (the HTTP API\'s own default is `"issues"`). One of:\n - `"rules"` — Re-apply inference rules to every watched file (no re-embedding; changed values written, keys the rules no longer project removed). With `path`, only files matching those globs. Without `path`, every indexed source document is also retrieved through its callback and its stored rule re-applied: unchanged content gets a metadata-only update, and a document is re-embedded only if its rendered text changed.\n - `"full"` — Re-extract and re-process every watched file (a file is re-embedded when the text to embed changed or it was never indexed), and retrieve and always re-embed every indexed source document. Clears the runtime values index first.\n - `"issues"` — Re-process the documents in `watcher_list_issues`: failed files, and failed source references (with their stored rule, or the `rule` recorded on the issue when the document was never indexed).\n - `"path"` — Re-process a specific file or all files under a directory. Requires `path`. Each target must be watched and not gitignored (`403 path_not_watched`) and must exist (`404 not_found`). Files only.\n - `"prune"` — Delete Qdrant points for files no longer in watch scope (removed paths, gitignored files, deleted files). No re-embedding. Pure cleanup. Never selects source points (`source` set, no `file_path`).\n- `path` (string | string[], optional) — Target file(s) or directories for `"path"` (required there), or globs narrowing `"rules"`. Ignored by other scopes.\n- `dryRun` (boolean, optional) — When `true`, compute and return the plan without executing.\n\nThe tool has no `source` parameter. To re-process specific source references, call `POST /reindex` directly with `{ "scope": "path", "source": ["<name>://<id>", ...] }` (at most 1000): they are enqueued on the notify queue, are not tracked by `GET /status`, and send no completion callback. A reindex triggered by a config change (`watcher_config_apply`, config-file reload, rule registration) covers files only.\n\n**Response (normal):**\n\n```json\n{\n "status": "started",\n "scope": "rules",\n "plan": {\n "total": 148000,\n "toProcess": 148000,\n "toDelete": 0,\n "byRoot": { "/data": 95000, "/srv/config": 3000 }\n }\n}\n```\n\n**Response (dryRun):**\n\n```json\n{\n "status": "dry_run",\n "scope": "prune",\n "plan": {\n "total": 562000,\n "toProcess": 0,\n "toDelete": 2300,\n "byRoot": { "/srv/app/node_modules": 1800, "/srv/app/.cache": 500 }\n }\n}\n```\n\n**Plan fields:**\n\n- `total` — Points scrolled (prune), or files plus source documents covered (other scopes).\n- `toProcess` — Files plus source documents to process (0 for prune).\n- `toDelete` — Points to delete (prune only, 0 for others).\n- `byRoot` — Files grouped by watch root (for prune: orphaned files), plus one entry per source name for source documents. Shows where the impact concentrates.\n- `incomplete` — `true` when the prune scroll failed part-way and the plan is partial.\n\n**Progress:** a live run (every scope except `path` with `source`) is tracked in `watcher_status` under `health.reindex` (`filesProcessed` / `totalFiles`, counting files plus source documents). A failing file or source document is logged and recorded; the rest of the run continues.\n\n**Guidance:**\n\n- Use `dryRun: true` before any large-blast operation to preview impact.\n- `prune` is safe — it only deletes orphaned file points, never re-embeds. Use after changing watch paths, fixing gitignore, or cleaning up stale data.\n- `prune` is NOT triggered by config-watch auto-reindex (too dangerous for auto-trigger).\n\n### `watcher_scan`\n\nFilter-only point query without vector search (`POST /scan`). Use for structural queries where the question has no semantic dimension.\n\n- `filter` (object, required) — Qdrant filter object. Required to prevent accidental full-collection scans.\n- `limit` (number, optional) — page size (integer 1 to 1000), default 100\n- `cursor` (string or number, optional) — opaque cursor from previous response for pagination\n- `fields` (string[], optional) — payload fields to return (projection)\n- `countOnly` (boolean, optional) — if true, return `{ count }` instead of points\n\n**Response (normal):**\n\n```json\n{\n "points": [\n {\n "id": "uuid",\n "payload": { "file_path": "/data/tickets/PROJ-123.json" }\n }\n ],\n "cursor": "opaque string, number or null"\n}\n```\n\n**Response (countOnly):**\n\n```json\n{ "count": 1234 }\n```\n\n**Key differences from `watcher_search`:**\n\n- No `query` parameter — does NOT use embeddings\n- No `score` field — results are unranked filter matches\n- Cursor-based pagination (not offset-based); `cursor` is `null` on the last page\n- Zero cost per call beyond Qdrant\'s filtered scroll\n\n**Pagination pattern:**\n\n```\nlet cursor = undefined;\ndo {\n const result = await watcher_scan({ filter, limit: 100, cursor });\n // process result.points\n cursor = result.cursor;\n} while (cursor);\n```\n\n### `watcher_service`\n\nManage the watcher background service (install, uninstall, start, stop, restart, check status). No HTTP call.\n\n- `action` (string, required) — one of: `install`, `uninstall`, `start`, `stop`, `restart`, `status`\n\nReturns the service manager\'s response. On Windows uses NSSM, on Linux systemd, on macOS launchd. Only `install` needs `configRoot` (see [Plugin Installation](#plugin-installation)).\n\n### `watcher_list_issues`\n\nGet runtime processing failures (`GET /issues`). Returns `{ count, issues: { [key]: IssueRecord[] } }`: `count` is the number of documents with issues, and each key is a file path or a source reference (`<name>://<id>`). Each `IssueRecord` has `type` (one of the [issue codes](#error-categories)), `message`, `timestamp`, and optionally `operation`, `property`, `rule`, `rules`, `types`, `details`.\n\n### `watcher_walk`\n\nList watched files matching globs (`POST /walk`). Returns matching file paths from the watcher\'s in-memory list of watched files across all configured watch roots.\n\n- `globs` (string[], required) — glob patterns to intersect with watch paths (non-empty; matched case-insensitively, dotfiles included)\n\n**Response:**\n\n```json\n{\n "paths": ["/data/foo/.meta/meta.json", "/data/bar/.meta/meta.json"],\n "matchedCount": 2,\n "scannedRoots": ["/data", "/srv/config"]\n}\n```\n\n**Use cases:**\n\n- Discover files matching a pattern across all watched directories (e.g., `["**/.meta/meta.json"]`)\n- Enumerate files before rule registration to understand scope\n- Find files that aren\'t yet indexed (no Qdrant dependency)\n\n**Key differences from `watcher_scan`:**\n\n- Lists the files the filesystem watcher tracks, not the Qdrant index\n- No embedding or indexing required, but the initial filesystem scan must be complete: until then the tool returns `HTTP 503 scan_in_progress` (check `health.initialScan` in `watcher_status`)\n- Returns file paths only (no metadata, no vectors)\n- Applies `watch.ignored` and gitignore filtering automatically\n\n## Query Planning: Scan vs Search\n\n**Decision rule:** If the query has no semantic/natural-language dimension, use `watcher_scan`. If you need meaning-based similarity, use `watcher_search`.\n\n| Use `watcher_scan` | Use `watcher_search` |\n| --- | --- |\n| "List all files in domain X" | "Find documents about authentication" |\n| "Files modified after timestamp T" | "What discusses rate limiting?" |\n| "Enumerate paths under prefix P" | "Prior conversations about deployment" |\n| "Count files matching a condition" | "Related tickets to this issue" |\n| "Staleness detection / delta computation" | "What happened in last week\'s meetings?" |\n\n**Scan-specific filter examples:**\n\n**Domain enumeration:**\n\n```json\n{ "must": [{ "key": "domains", "match": { "value": "email" } }] }\n```\n\n**Modified after timestamp:**\n\n```json\n{ "must": [{ "key": "modified_at", "range": { "gte": 1772800000 } }] }\n```\n\n**Path prefix matching:**\n\n```json\n{ "must": [{ "key": "file_path", "match": { "text": "/data/tickets" } }] }\n```\n\n**Count files in a domain (no point data transferred):**\n\n```\nwatcher_scan: filter={"must":[{"key":"domains","match":{"value":"github"}}]}, countOnly=true\n```\n\n---\n\n## Qdrant Filter Syntax\n\nFilters use Qdrant\'s native JSON filter format, passed as the `filter` parameter to `watcher_search`.\n\n### Basic Patterns\n\n**Match exact value:**\n\n```json\n{ "must": [{ "key": "domain", "match": { "value": "email" } }] }\n```\n\n**Match text (full-text search within field):**\n\n```json\n{ "must": [{ "key": "chunk_text", "match": { "text": "authentication" } }] }\n```\n\n**Combine conditions (AND):**\n\n```json\n{\n "must": [\n { "key": "domain", "match": { "value": "jira" } },\n { "key": "status", "match": { "value": "In Progress" } }\n ]\n}\n```\n\n**Exclude (NOT):**\n\n```json\n{\n "must_not": [{ "key": "domain", "match": { "value": "repos" } }]\n}\n```\n\n**Any of (OR):**\n\n```json\n{\n "should": [\n { "key": "domain", "match": { "value": "email" } },\n { "key": "domain", "match": { "value": "slack" } }\n ]\n}\n```\n\n**Nested (combine AND + NOT):**\n\n```json\n{\n "must": [{ "key": "domain", "match": { "value": "jira" } }],\n "must_not": [{ "key": "status", "match": { "value": "Done" } }]\n}\n```\n\n### Key Differences\n\n- `match.value` — exact match (case-sensitive, for keyword fields like `domain`, `status`)\n- `match.text` — full-text match (for text fields like `chunk_text`)\n\n## Search Result Shape\n\nEach result from `watcher_search` contains:\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` | string | Qdrant point ID |\n| `score` | number | Similarity (or hybrid fused) score; higher = more relevant |\n| `payload.file_path` | string | Normalized file path (file points only) |\n| `payload.source` | string | Source reference `<name>://<id>` (source points only) |\n| `payload.source_rule` | string | Designated rule of a source document (source points only) |\n| `payload.chunk_text` | string | The matched text chunk |\n| `payload.chunk_index` | number | Zero-based chunk position within the document |\n| `payload.total_chunks` | number | Total chunks for this document |\n| `payload.line_start` | number | 1-indexed line where the chunk starts |\n| `payload.line_end` | number | 1-indexed line where the chunk ends |\n| `payload.created_at` | number | Creation timestamp (unix seconds) |\n| `payload.modified_at` | number | Modification timestamp (unix seconds) |\n| `payload.content_hash` | string | SHA-256 hash of the embedded text |\n| `payload.matched_rules` | string[] | Names of inference rules that matched |\n\n`file_path` is not always present: a source point (a document indexed through a source callback) carries a non-empty `source` and no `file_path`. A file point may also carry a `source` written by its own rules; it is still a file point. Additional metadata fields depend on the deployment\'s inference rules (e.g., `domain`, `status`, `author`). Use `watcher_config` to discover available fields.\n\n## Query Planning (Per Search Task)\n\nIdentify relevant rule(s) from the orientation model, then retrieve their schemas:\n\n**Retrieve complete schema for a rule:**\n\n```\nwatcher_config: path="$.inferenceRules[?(@.name==\'jira-issue\')].schema"\n```\n\nReturns the rule\'s schema array with named schema references and file references already resolved: properties, types, `set` expressions, `uiHint`, `enum`, etc.\n\n**For select/multiselect fields without `enum` in schema:**\n\n```\nwatcher_config: path="$.inferenceRules[?(@.name==\'jira-issue\')].values.status"\n```\n\nRetrieves valid filter values from the runtime values index (distinct values accumulated during embedding).\n\n**When search results span multiple rules** (indicated by `matched_rules` on results): query each unique rule\'s schema separately and merge mentally. Most result sets share the same rule combination, so this is typically one or two queries, not one per result.\n\n---\n\n## uiHint → Filter Mapping\n\nUse `uiHint` to determine filter construction strategy. **This table is explicit, not intuited:**\n\n| `uiHint` | Qdrant filter | Notes |\n| --- | --- | --- |\n| `text` | `{ "key": "<field>", "match": { "text": "<value>" } }` | Substring/keyword match |\n| `select` | `{ "key": "<field>", "match": { "value": "<enum_value>" } }` | Exact match; use `enum` values from schema or runtime values index |\n| `multiselect` | `{ "key": "<field>", "match": { "value": "<enum_value>" } }` | Any-element match on array field; use `enum` or runtime values index |\n| `date` | `{ "key": "<field>", "range": { "gte": <unix_ts>, "lt": <unix_ts> } }` | Range filter against integer fields holding Unix timestamps (seconds). Source dates should be normalized in config via `{{toUnix ...}}` in `set` expressions. Either bound optional for open-ended ranges (e.g., "after January" → `gte` only) |\n| `number` | `{ "key": "<field>", "range": { "gte": <n>, "lte": <n> } }` | Either bound optional for open-ended ranges |\n| `check` | `{ "key": "<field>", "match": { "value": true } }` | Boolean match |\n| _(absent)_ | Do not use in filters | Internal bookkeeping field, not intended for search |\n\n**Fallback:** If a `select`/`multiselect` field has neither `enum` in schema nor values in the index, treat it as `text` (substring match instead of exact match).\n\n---\n\n## Qdrant Filter Combinators\n\nCompose individual field conditions into complex queries using three combinators:\n\n| Combinator | Semantics | Use case |\n| --- | --- | --- |\n| `must` | AND — all conditions required | Intersecting constraints (domain + date range + assignee) |\n| `should` | OR — at least one must match | Alternative values, fuzzy criteria ("assigned to X or Y") |\n| `must_not` | Exclusion — any match triggers exclude | Filtering out noise (exclude Done, exclude codebase domain) |\n\n**Combinators nest arbitrarily for complex boolean logic:**\n\n```json\n{\n "must": [\n { "key": "domain", "match": { "value": "jira" } },\n { "key": "created", "range": { "gte": 1735689600 } }\n ],\n "should": [\n { "key": "assignee", "match": { "value": "Jane Doe" } },\n { "key": "assignee", "match": { "value": null } }\n ],\n "must_not": [{ "key": "status", "match": { "value": "Done" } }]\n}\n```\n\nA consuming UI will necessarily compose simple single-field filters. The assistant can compose deeply complex queries combining multiple fields, nested boolean logic, and open-ended ranges to precisely target what it needs.\n\n---\n\n## Search Execution\n\n**Plain semantic search is valid and often sufficient.** Not every query needs metadata filters. When the user\'s question is broad or exploratory, a natural language query with no filter object is the right starting point. Add filters to narrow, not as a default.\n\n**Result limit guidance:**\n\n- Default: 10 results\n- Broad discovery / exploratory: 20–30, apply score threshold cutoff from config\n- Targeted retrieval with tight filters: 5\n- Cross-domain sweep: 15–20, no domain filter, use score to separate signal from noise\n\n---\n\n## Post-Processing Guidance\n\n### Score Interpretation\n\nUse `scoreThresholds` from config (queried during orientation). Values are deployment-specific, constrained to [-1, 1]:\n\n- `strong` — minimum score for a strong match. **Action:** High confidence. Use these results directly.\n- `relevant` — minimum score for relevance. **Action:** Likely useful but verify context before relying on them.\n- `noise` — maximum score below which results are noise. **Action:** Discard. If all results fall below this threshold, broaden your query or try different terms.\n\n### Chunk Grouping\n\nMultiple results with the same `file_path` (or, for source documents, the same `source`) are chunks of one document. Read the full file for complete context.\n\n### Schema Lookup\n\nUse `matched_rules` on results to look up applicable schemas for metadata interpretation:\n\n```\nwatcher_config: path="$.inferenceRules[?(@.name==\'jira-issue\')].schema"\n```\n\n### Full Context\n\nSearch gives you chunks; use `read` with `file_path` for the complete document. A source document has no file: render it with `POST /render` and `{ "source": "<ref>" }`, which retrieves it through its callback.\n\n---\n\n## Path Testing\n\nWhen uncertain whether a file would be indexed, test it against the rules. `watcher_validate_config` with `testPaths` (and no `config`) runs the current rules against each path and returns `{ path, matchedRules, metadata }` per path (`error: "File not found"` when the file cannot be read):\n\n```\nwatcher_validate_config: testPaths=["/data/tickets/PROJ-123.json"]\n```\n\nOr call `POST /config/match` directly with `{ "paths": [...] }`. It returns `{ matches: [{ rules, watched }] }`, one entry per path, without reading the files:\n\n- `rules` lists the matching rule names; an empty array means no inference rules match\n- `watched: false` means the path falls outside watch paths or is excluded by ignore patterns\n\n---\n\n## Config Authoring\n\n### Rule Structure\n\nEach inference rule has:\n\n- `name` (required) — unique identifier\n- `description` (required) — human-readable purpose\n- `match` (required) — JSON Schema matched against file attributes (e.g. a picomatch `glob` on `file.path`)\n- `schema` (optional) — array of named schema references and/or inline schema objects with `set` templates\n- `map` (optional) — JsonMap transform (inline, named map, or `.json` file path)\n- `template` (optional) — Handlebars template (inline, named template, or `.hbs`/`.handlebars` file path)\n- `render` (optional) — declarative frontmatter + Markdown renderer (mutually exclusive with `template`)\n- `renderAs` (optional) — output extension (1-10 lowercase alphanumerics); requires `template` or `render`\n\n### Authoring Notes\n\n- **`maps` entries are bare strings or inline maps.** A string value is a file path, loaded and parsed as a JsonMap at startup. A `{ path, description }` object is kept as-is and never loaded from the file, so it silently does nothing useful. (`mapHelpers` and `templateHelpers` are different: `{ path, description }` objects are their documented form.)\n- **Metadata is derived with `set`, not `attributes`.** Each schema property\'s `set` template produces its value. There is no `attributes` key in the config schema; older configs and examples that use one need rewriting.\n- **`render.frontmatter` accepts picomatch globs and `!` exclusions** alongside explicit keys, e.g. `["meta_id", "*", "!_*", "!chunk_*"]`. Explicit names keep their listed order; keys matched by a glob follow, sorted alphabetically; a key matching an exclusion is dropped.\n- **Virtual rules have a startup window.** Virtual rules are registered at runtime by other services (`POST /rules/register`, e.g. jeeves-meta), so they are absent during the initial file scan after a watcher start. Files indexed in that window lack virtual-rule metadata until the registration\'s automatic rules reindex runs, or until `POST /rules/reapply` covers them. If metadata from a virtual rule is missing right after a restart, check whether its service has registered yet.\n\n### Config Workflow\n\n1. Edit config (or build partial config object)\n2. Validate: `watcher_validate_config` with optional `testPaths` for dry-run preview\n3. Apply: `watcher_config_apply` — validates, writes, triggers the `configWatch.reindex` reindex\n4. Monitor: `watcher_list_issues` for runtime processing failures\n\n### Reindex Concurrency\n\nReindex operations process files and source documents concurrently using `reindex.concurrency` (default 50). This applies to every scope, to issue-source discovery, and to the stored-rule lookups of `POST /reindex` with `source`. The watcher\'s incremental processing (file events and source notifications) uses a separate `embedding.concurrency` setting.\n\nProgress is reported via `watcher_status` (`health.reindex.filesProcessed` / `health.reindex.totalFiles`, counting files plus source documents).\n\n### When to Reindex\n\n- **Rules scope** (`"rules"`): Changed rule matching patterns, set expressions, schema mappings. No file re-embedding needed.\n- **Full scope** (`"full"`): Added watch paths, broad schema restructuring, or source documents whose vectors must be rebuilt. Re-extracts every file (re-embedding the ones whose text to embed changed) and re-embeds every source document.\n- **Issues scope** (`"issues"`): After fixing the root cause of processing failures (permissions, encoding, file format, a source outage). Re-processes only the failed files and source documents.\n- **Path scope** (`"path"`): Edited files in a specific directory, or a single file looks stale, and you want them re-processed without a full reindex. A file whose text to embed is unchanged is not re-embedded.\n- **Prune scope** (`"prune"`): After changing `watch.paths`, adding gitignore rules, or discovering stale/orphaned points (e.g., indexed `node_modules`). Deletes points for out-of-scope or deleted files. Always `dryRun: true` first to preview.\n\n---\n\n## Diagnostics\n\n### Escalation Path\n\n1. `watcher_status` — is the service healthy? Is a reindex running? Is the initial scan still active?\n2. `watcher_list_issues` — which documents are failing and why?\n3. `watcher_config` with `$.issues` — the same issue records via JSONPath\n4. Check logs at the configured log path\n\n### Error Categories\n\nEvery issue record\'s `type` is one of these codes:\n\n- `type_collision` — conflicting metadata types across rules (includes `property`, `rules[]`, `types[]`)\n- `interpolation_error` — template or `set` expression rendering failed (includes `property`, `rule`; `details` holds per-step failures)\n- `extraction_error` — reading, rule application or chunking failed\n- `embedding_error` — the embedding provider failed\n- `upsert_error` — the vector store write failed\n- `match_failed` — a source document\'s designated rule did not match (`details` holds the AJV errors)\n- `rule_missing` — a source document\'s designated rule was not found\n- `no_content` — nothing to embed\n- `source_unavailable` — the source callback failed\n- `unsupported_content_type` — the source callback returned a non-JSON body\n- `too_large` — the document is over a `processing` ceiling (`maxFileBytes`, `maxChunksPerFile` or `maxTextBytes`); it is not embedded and its points are deleted. Raise the ceiling and run `reindex` with scope `issues` to restore it\n\nSource-document issues always carry `rule` (the designated rule of the failed run).\n\n**Issues are self-healing:** resolved on successful re-process. The issues file always represents the current set of unresolved problems: a live todo list.\n\n### Change Source\n\n`watcher_status` reports the change source under `health.changeSource` (`@parcel/watcher` by default, chokidar on request or when polling):\n\n- **Service refuses to start, log names `@parcel/watcher failed to load`:** the platform binary is missing or broken. Reinstall without `--omit=optional`, or set `watch.changeSource: chokidar` (the manual switch; a reload rebuilds the watcher with one full initial scan).\n- **Rising `reconcileEvents`:** the source is missing events and reconciliation is finding them. **Rising `resubscribes`:** subscriptions keep failing (Linux). **Rising `renewFailures`:** a Linux renewal could not unsubscribe; restart the service. **`unavailableRoots` above 0:** a watch root is missing or unreadable (an unmounted share); its files keep their points until it returns. **High `pendingStability`, or a long `lastReconcileDurationMs` / `lastRenewDurationMs`:** a large write burst or a slow disk; a long renewal pauses event handling for every root.\n- **Linux, a file in a moved-in or renamed folder is indexed late:** changes inside folders nested in a newly created or moved-in tree, or in a directory renamed within a root, are missed until the root\'s subscription is renewed at the next periodic reconcile, up to `watch.reconcileIntervalMs` (15 minutes by default) late. Expected; shorten the interval if the latency matters. The files moved in with the tree are indexed at once.\n- **Linux, events lost under a write burst with nothing in the log:** parcel cannot detect an inotify queue overflow. Check `sysctl fs.inotify.max_queued_events` (recommended 1048576) and `fs.inotify.max_user_watches` (the start log warns at 90%); the periodic reconcile finds what was lost (`reconcileEvents` rises).\n- **Windows, files in deep folders indexed only by reconciliation:** expected. Under parcel, creating or changing a file whose path is 260 characters or more produces no change event at either `LongPathsEnabled` setting, so it is indexed at the next periodic reconcile, up to `watch.reconcileIntervalMs` late. Nothing to fix; check that `watch.reconcileIntervalMs` is not 0. (`LongPathsEnabled = 1` is still recommended for other tools; it is a host change for the owner.)\n- **Windows, `lastRestartCause` set:** under parcel the service never unsubscribes on Windows (`@parcel/watcher` 2.6.0 crashes the process there), so a reload that rebuilds the watcher, a subscription error or a vanished watch root exits with code 75 and the service manager restarts it. `watch-config-changed`, `subscription-error` or `root-gone` says why, `lastRestartAt` when. If the service stays down after such an exit, its service manager is not restarting it on exit (NSSM: `AppExit Default Restart`).\n\n### Index Check at Start\n\nAfter the start walk the service deletes the indexed documents of files gone from disk (deleted or moved while it was down). `watcher_status` reports it under `health.indexCheck` (`state`, `at`, `durationMs`, `checked`, `removed`, `skippedRoots`, `capped`):\n\n- **`capped` above 0:** under some watch root more than 10% of the indexed documents (once more than 20 are missing) or more than 1,000 were missing from disk or filters, so nothing was deleted there; the log names the root. Run `watcher_reindex` with scope `prune` and `dryRun: true`, check the plan, then run it.\n- **`state: failed`:** the index could not be listed (Qdrant down), or the service stopped first; it runs again at the next start.\n- **`skippedRoots` above 0:** a root was unavailable, unwatched, gone, or its start walk failed; its deletions wait for the next start or a `prune`.\n- **Files that still exist but are now ignored** keep their points: that is `prune`\'s job. `watch.indexCheckOnStart: false` turns the check off.\n\n---\n\n## Helper Management\n\nHelpers use namespace prefixing: config key becomes prefix. A helper named `slack` exports `slack_extractParticipants`.\n\nEnumerate loaded helpers:\n\n```\n$.mapHelpers — JsonMap helper namespaces with exports\n$.templateHelpers — Handlebars helper namespaces with exports\n```\n\n### Built-in JsonMap Helpers\n\nThe following helpers are available in every JsonMap `lib` context without any helper config:\n\n#### `fetchSiblings(filePath, options?)`\n\nRetrieve extracted text from neighboring files in the same directory with the same extension. Useful for contextual embedding — e.g., injecting surrounding email messages into a thread member\'s embedding for better semantic search.\n\n**Parameters:**\n\n- `filePath` (string) — the current file path (in a rule\'s `map`, `$.input.file.path`)\n- `options` (object, optional; JsonMap `params` are path strings, so a call from a config `map` uses the defaults):\n - `before` (number, default 3) — number of preceding siblings to include\n - `after` (number, default 1) — number of following siblings to include\n - `sort` (`"name"` | `"mtime"`, default `"name"`) — sort order for determining neighbor position\n\n**Returns:** `string[]` — extracted text from sibling files, in sort order. Files that fail extraction are silently skipped. For a source document (a source rule\'s `map`) there is no file reader, so it returns `[]`.\n\n**Example** (in a rule\'s `map`):\n\n```json\n{\n "context": {\n "$": { "method": "$.lib.fetchSiblings", "params": "$.input.file.path" }\n }\n}\n```\n\n---\n\n## Enrichment\n\nUse `watcher_set_metadata` to tag documents after analysis (e.g., `reviewed: true`, project labels).\n\n**Enrichments are durable.** Stored in a SQLite database (`<stateDir>/enrichments.sqlite`, default `stateDir` `.jeeves-metadata`), enrichments survive full reindexes. Enriching a file that is not indexed yet stores the enrichment, which applies once the file is indexed. When the watcher re-processes a file, enrichments are merged with inference rule output using composable semantics: scalar fields overwrite, array fields union+deduplicate.\n\n**Metadata is validated against the file\'s matched rule schemas** (for a source document, its designated rule\'s schema). Only declared property types are checked; unknown properties and `null` values pass. The tool reports a failure as `HTTP 400 invalid_request: Validation failed`; the structured `details` are in the HTTP response body:\n\n```json\n{\n "code": "invalid_request",\n "error": "Validation failed",\n "details": [\n {\n "property": "priority",\n "expected": "string",\n "received": "number",\n "rule": "jira-issue",\n "message": "Property \'priority\' is declared as string in jira-issue schema, received number"\n }\n ]\n}\n```\n\n---\n\n## Version Tracking\n\nThe watcher includes built-in history and undo for your watched files. When enabled, every change to a watched file is automatically versioned. You can view the history of any file or folder, see exactly what changed between versions, retrieve old versions, and undo changes — all through the version-control tools below.\n\n### Tools\n\n| Tool | Purpose |\n| --- | --- |\n| `watcher_get_vcs_status` | Check version tracking health: enabled state, tracked roots, remote sync status |\n| `watcher_list_vcs_history` | View change history for a file or folder, with optional date range and limit |\n| `watcher_show_vcs_file` | Retrieve the content of a file at a specific past version |\n| `watcher_diff_vcs` | Show what changed between two versions, or between a past version and now |\n| `watcher_revert_vcs` | Undo changes by restoring files to a specific past version |\n| `watcher_exclude_vcs_path` | Exclude a path from version history (it stays in search), or remove the exclusion; untracks matching files |\n| `watcher_check_vcs_exclusion` | Check whether git ignores a path and why (rule, file, line) |\n\n### Usage Patterns\n\n**View recent changes to a file:**\n\n```\nwatcher_list_vcs_history: glob="/data/tickets/PROJ-123.json", limit=10\n```\n\n**See what changed between two versions:**\n\n```\nwatcher_diff_vcs: glob="/data/tickets/PROJ-123.json", commit="abc1234"\n```\n\n**Restore a file to a previous version:**\n\n```\nwatcher_revert_vcs: glob="/data/tickets/PROJ-123.json", commit="abc1234"\n```\n\n**View old content without restoring:**\n\n```\nwatcher_show_vcs_file: path="/data/tickets/PROJ-123.json", commit="abc1234"\n```\n\n**Exclude files that don\'t need versioning** (e.g., Jira issues that are read-only syncs):\n\n```\nwatcher_exclude_vcs_path: glob="/data/tickets/**/*.json"\n```\n\n**Check if a file is excluded and why:**\n\n```\nwatcher_check_vcs_exclusion: path="/data/tickets/PROJ-123.json"\n```\n\n### Enabling Version Tracking\n\nVersion tracking is controlled by the `vcs.enabled` config setting. When set to `true`, the watcher automatically versions all watched files. No additional setup is required — just enable it and history starts accumulating.\n\nSquash retention activates by default when VCS is enabled, even without explicit `retention` config. The defaults are: 30 days max age, 100 versions max, and a daily midnight cron schedule (`0 0 * * *`). These defaults prevent unbounded commit history growth.\n\n### Excluding Paths\n\nNot all watched files need version history. For example, if Jira issues are synced read-only, there\'s nothing to "undo." Use `watcher_exclude_vcs_path` to stop tracking specific paths.\n\nThe exclusion is VCS-only: the pattern goes to the root\'s `.git/info/exclude`, so the files stop being versioned but stay indexed and searchable.\n\n- **Destructive.** Adding a pattern untracks the tracked files it matches, in one commit: their history ends at their last committed version (the files stay on disk). `remove: true` deletes the pattern but does not restore history; the files are versioned again from their next change.\n- **One gitignore pattern, root-anchored.** The pattern is written as `/` plus the glob relative to the root, so `/data/tickets/*.json` (root `/data`) matches only directly under `tickets/`; use `**/` for any depth. Pass `root` to give a root-relative glob (`tickets/**/*.json`, without a leading `/`: with `root`, a glob starting with `/` is a filesystem path and must lie under the root).\n- **Brackets are a character class.** `Report [draft].docx` matches `Report d.docx`; escape them to name the file: `Report \\[draft\\].docx`.\n- **Refused (`400`):** a newline, a leading `!` or `#`, braces, a pattern matching the whole root (`*`, `**`, `**/*`, ...), or a glob outside the given `root`.\n- **More than 100 files** to untrack need `confirm: true`; without it the tool reports `409 confirm_required` with the count and changes nothing. Check the count with the user before confirming.\n- **Busy root.** If version control is busy on the root for 30 s the tool reports `503 not_available`; retry after 30 s.\n- `watcher_check_vcs_exclusion` reports `excluded: false` for a tracked file even when a human `.gitignore` matches it (git never reports tracked paths as ignored); exclude it to untrack it.\n\n### Score Interpretation\n\nVCS tools do not use vector search. There are no relevance scores to interpret — results are exact history lookups, diffs, and file retrievals.\n\n---\n\n## Error Handling\n\nIf the watcher is unreachable:\n\n- Inform the user that semantic search is temporarily unavailable\n- Fall back to direct `read` for known file paths\n- Do not retry silently in a loop\n\nIf a tool fails with `HTTP 401` (`unauthorized`):\n\n- The watcher sets `api.apiKey` and the plugin sent no key, or a different one.\n- Ask the user to set `plugins.entries.jeeves-watcher-openclaw.config.apiKey` in `openclaw.json` (or the `JEEVES_WATCHER_API_KEY` environment variable of the gateway) to the service\'s `api.apiKey`. The tools read it on every call, so no restart is needed.\n- Never ask for, print or guess the key. `GET /status` and `GET /openapi.json` never need it.\n\nIf a tool or call fails with `HTTP 503` (`not_available`) and a `Retry-After` header:\n\n- `POST /metadata` (`watcher_set_metadata`), `POST /rules/reapply` and `POST /documents/delete` wait at most 10 s for a document that is being indexed, then answer `503` with `Retry-After: 10`. A single-document request changed nothing; a multi-document request kept the documents it already changed. All three are idempotent: retry the same request after 10 s.\n- `watcher_exclude_vcs_path` waits 30 s for a busy version-control root.\n\nIf a tool fails with `HTTP 500: {"error":"Response validation failed","code":"internal_error"}`, the watcher produced a response that doesn\'t match its own schema: a server bug. Report it with the request you made; retrying won\'t help.\n\nIf tools are unavailable (plugin not loaded in this session):\n\n- The watcher API is still accessible via direct HTTP calls\n- Use `exec` to call the endpoints listed in Service Architecture\n- Default: `http://127.0.0.1:1936`\n\n**CLI Fallbacks:**\n\n- `jeeves-watcher status [-p <port>]` — check if the service is running (`GET /status`)\n- `jeeves-watcher config validate -c <path>` — validate a config file against the schema (offline)\n- Restart via NSSM (Windows) or systemctl (Linux)\n\n---\n\n## References\n\n- [JSONPath Plus documentation](https://www.npmjs.com/package/jsonpath-plus) for JSONPath syntax\n- [Qdrant filtering documentation](https://qdrant.tech/documentation/concepts/filtering/) for advanced query patterns and search response format\n';
31102
+
30445
31103
  /**
30446
31104
  * @module plugin/constants
30447
31105
  * Shared constants for the OpenClaw plugin package.
@@ -30468,9 +31126,9 @@ const CONFIG_ROOT_UNSET_WARNING = `[${PLUGIN_ID}] configRoot not configured yet
30468
31126
  *
30469
31127
  * @remarks
30470
31128
  * Only watcher-specific resolution logic (API URL, API key, config root)
30471
- * lives here. Core SDK types and utilities (`PluginApi`, `ToolResult`, `ok`,
30472
- * `connectionFail`) should be imported directly from `@karmaniverous/jeeves`;
30473
- * watcher API requests go through `requestJson` (`./http.js`).
31129
+ * lives here. Core SDK types and utilities should be imported directly from
31130
+ * `@karmaniverous/jeeves`; every watcher API request goes through core's
31131
+ * catalog toolset (`catalogTools.ts`).
30474
31132
  *
30475
31133
  * @module plugin/helpers
30476
31134
  */
@@ -30526,162 +31184,132 @@ function getConfigRoot(api) {
30526
31184
  }
30527
31185
 
30528
31186
  /**
30529
- * @module plugin/http
30530
- * The plugin's single HTTP helper for watcher API calls: JSON GET/POST via `fetch` with the core bearer header and the core error mapping (errors carry the server `code`; 401 adds key guidance). Never logs the key.
31187
+ * @module plugin/catalogTools
31188
+ * Every watcher tool, built from `WATCHER_CATALOG` by core's toolset (D38): the four standard tools (`watcher_status`, `watcher_service`, `watcher_config`, `watcher_config_apply`) and the 14 domain tools, all sending the bearer key where their entry requires it; `registerCatalogTool` registers one of them as an optional tool.
31189
+ *
31190
+ * @remarks
31191
+ * Each tool is named `watcher_` plus its entry's verb-first name in
31192
+ * snake_case (D77, `toToolName`). Parameters come from the entry's request
31193
+ * schemas; descriptions keep the 0.17.0 wording where it is still true
31194
+ * (`WATCHER_TOOL_SELECTION`). `watcher_search` and `watcher_scan` are
31195
+ * `catalogMode: 'direct-only'`, so OpenClaw Tool Search keeps them
31196
+ * model-visible: semantic search must be a direct tool or the model reaches
31197
+ * for grep first. Every call runs on core's 120 s default timeout
31198
+ * (`DEFAULT_FETCH_TIMEOUT_MS`, D137), the status probe on 5 s. Errors are
31199
+ * reported as core's `HTTP <status>: <body>`; API-key guidance lives in the
31200
+ * skill's troubleshooting section (spec #281 D31).
30531
31201
  */
30532
- /** Where the plugin reads its API key from (named in 401 guidance). */
30533
- const PLUGIN_KEY_SOURCES = `plugins.entries.${PLUGIN_ID}.config.apiKey or the ${API_KEY_ENV_VAR} env var`;
31202
+ /** `watcher_exclude_vcs_path` description (D103, D137). */
31203
+ const VCS_EXCLUDE_DESCRIPTION = `Exclude a path from version history (VCS-only: it stays in search), or remove the exclusion. Adding untracks the files the pattern matches, ending their history; more than ${String(VCS_EXCLUDE_CONFIRM_THRESHOLD)} files need confirm: true. A timeout does not cancel the exclusion: check the outcome with watcher_check_vcs_exclusion before retrying.`;
31204
+ /** `watcher_check_vcs_exclusion` description (0.21.0, spec B4). */
31205
+ const VCS_CHECK_DESCRIPTION = 'Check whether git ignores a path and why: the rule, the file holding it and its line';
31206
+ /** The scope `watcher_reindex` sends when the caller omits one: 0.17.0's default (owner ruling, 2026-11-04). */
31207
+ const REINDEX_DEFAULT_SCOPE = 'rules';
31208
+ /** `watcher_reindex` description. */
31209
+ const REINDEX_DESCRIPTION = `Trigger a reindex of the watched files. Without scope, re-applies inference rules (scope "${REINDEX_DEFAULT_SCOPE}"); the HTTP API's own default is "issues".`;
30534
31210
  /**
30535
- * Build the `fetch` init for a request. With no key and no body this is
30536
- * `undefined`, exactly as an unauthenticated GET has always been sent.
30537
- *
30538
- * @param body - JSON body; `undefined` for a GET.
30539
- * @param apiKey - Bearer key, if any.
30540
- * @returns The request init.
31211
+ * The 14 domain tools, in their 0.17.0 registration order, by catalog entry.
31212
+ * Descriptions keep the 0.17.0 text unless it changed with the tool.
30541
31213
  */
30542
- function buildInit(body, apiKey) {
30543
- const auth = bearerHeaders(apiKey);
30544
- if (body === undefined) {
30545
- return apiKey === undefined ? undefined : { headers: auth };
30546
- }
30547
- return {
30548
- method: 'POST',
30549
- headers: { 'Content-Type': 'application/json', ...auth },
30550
- body: JSON.stringify(body),
30551
- };
30552
- }
31214
+ const WATCHER_TOOL_SELECTION = {
31215
+ search: {
31216
+ description: 'Semantic search over indexed documents. Supports Qdrant filters. This is a direct tool: call it directly, never through tool_call.',
31217
+ catalogMode: 'direct-only',
31218
+ },
31219
+ setMetadata: {
31220
+ description: 'Set or update metadata on a document by file path or source reference.',
31221
+ },
31222
+ validateConfig: {
31223
+ description: 'Validate a candidate config (or current config if omitted). Optionally test file paths against the config to preview rule matching and metadata output.',
31224
+ },
31225
+ reindex: { description: REINDEX_DESCRIPTION },
31226
+ scan: {
31227
+ description: 'Filter-only point query without vector search. Returns metadata for points matching a Qdrant filter. Use for structural queries: file enumeration, staleness checks, delta computation. Use watcher_search for semantic/similarity queries. This is a direct tool: call it directly, never through tool_call.',
31228
+ catalogMode: 'direct-only',
31229
+ },
31230
+ listIssues: {
31231
+ description: 'Get runtime processing failures. Shows the documents (file path or source reference) that failed processing and why.',
31232
+ },
31233
+ walk: {
31234
+ description: 'Walk watched filesystem paths with glob intersection. Returns matching file paths from all configured watch roots, applying watch.ignored and gitignore filtering.',
31235
+ },
31236
+ getVcsStatus: {
31237
+ description: 'Get version tracking health: enabled state, tracked roots, remote status, last activity',
31238
+ },
31239
+ listVcsHistory: {
31240
+ description: 'Query change history by path or glob with optional date range',
31241
+ },
31242
+ showVcsFile: { description: 'Retrieve file content at a specific version' },
31243
+ diffVcs: {
31244
+ description: 'Show what changed between two versions, or between a version and current',
31245
+ },
31246
+ revertVcs: {
31247
+ description: 'Undo changes by restoring files to a specific version',
31248
+ },
31249
+ excludeVcsPath: { description: VCS_EXCLUDE_DESCRIPTION },
31250
+ checkVcsExclusion: { description: VCS_CHECK_DESCRIPTION },
31251
+ };
31252
+ /** The standard tools, in their 0.17.0 registration order. */
31253
+ const STANDARD_TOOLS = [
31254
+ 'watcher_status',
31255
+ 'watcher_service',
31256
+ 'watcher_config',
31257
+ 'watcher_config_apply',
31258
+ ];
31259
+ /** Every tool the plugin registers, in registration order (0.17.0 order). */
31260
+ const WATCHER_TOOL_NAMES = [
31261
+ ...STANDARD_TOOLS,
31262
+ ...Object.keys(WATCHER_TOOL_SELECTION).map((entry) => toToolName('watcher', entry)),
31263
+ ];
30553
31264
  /**
30554
- * Call the watcher API and return the parsed JSON body.
31265
+ * Core's toolset for the watcher: the four standard tools and the 14 domain
31266
+ * tools. The base URL and the bearer key are resolved from `api` on every
31267
+ * call.
30555
31268
  *
30556
- * @param url - Full request URL.
30557
- * @param body - JSON body; `undefined` sends a GET, anything else a POST.
30558
- * @param apiKey - Bearer key; `undefined` sends no `Authorization` header.
30559
- * @returns Parsed JSON response body.
30560
- * @throws On `401` with core `unauthorizedMessage` (naming
30561
- * {@link PLUGIN_KEY_SOURCES}); on any other non-OK status with
30562
- * `HTTP <status> <code>: <message>` (core `httpError`); network errors
30563
- * propagate unchanged.
31269
+ * @param api - Plugin API (config source).
31270
+ * @param component - Plugin-side component descriptor.
31271
+ * @returns The tools, keyed by name.
30564
31272
  */
30565
- async function requestJson(url, body, apiKey) {
30566
- const res = await fetch(url, buildInit(body, apiKey));
30567
- if (res.status === 401) {
30568
- throw new Error(unauthorizedMessage(apiKey !== undefined, PLUGIN_KEY_SOURCES));
30569
- }
30570
- if (!res.ok) {
30571
- const info = httpError(res.status, res.statusText, await res.text());
30572
- throw new Error(formatHttpError(info));
30573
- }
30574
- return res.json();
31273
+ function createCatalogTools(api, component) {
31274
+ const auth = { bearer: () => getApiKey(api) };
31275
+ const tools = createPluginToolset(component, {
31276
+ apiUrl: () => getApiUrl(api),
31277
+ catalog: WATCHER_CATALOG,
31278
+ auth,
31279
+ tools: WATCHER_TOOL_SELECTION,
31280
+ });
31281
+ return new Map(tools.map((tool) => [
31282
+ tool.name,
31283
+ tool.name === 'watcher_reindex' ? withDefaultScope(tool) : tool,
31284
+ ]));
30575
31285
  }
30576
-
30577
- /**
30578
- * @module plugin/apiTool
30579
- * Generic HTTP-backed watcher tool registration: config shape, request helpers, and ok/connectionFail wrapping. Requests carry the bearer key (if configured) via `requestJson`.
30580
- */
30581
31286
  /**
30582
- * Register a single API tool with standard try/catch + ok/connectionFail.
31287
+ * `watcher_reindex` with 0.17.0's default scope: a call without `scope` sends
31288
+ * {@link REINDEX_DEFAULT_SCOPE}, so agents see no change from 0.17.0.
30583
31289
  *
30584
- * @remarks
30585
- * The bearer key is resolved from `api` ({@link getApiKey}) on every call,
30586
- * so a key set after registration takes effect without re-registering.
31290
+ * @param tool - The catalog-built reindex tool.
31291
+ * @returns The same tool, defaulting `scope`.
30587
31292
  */
30588
- function registerApiTool(api, baseUrlSource, config) {
30589
- const tool = {
30590
- name: config.name,
30591
- description: config.description,
30592
- parameters: config.parameters,
30593
- ...(config.catalogMode ? { catalogMode: config.catalogMode } : {}),
30594
- execute: async (_id, params) => {
30595
- const baseUrl = typeof baseUrlSource === 'function' ? baseUrlSource() : baseUrlSource;
30596
- try {
30597
- const [endpoint, body] = config.buildRequest(params);
30598
- const data = await requestJson(`${baseUrl}${endpoint}`, body, getApiKey(api));
30599
- return ok(data);
30600
- }
30601
- catch (error) {
30602
- return connectionFail(error, baseUrl, PLUGIN_ID);
30603
- }
30604
- },
31293
+ function withDefaultScope(tool) {
31294
+ return {
31295
+ ...tool,
31296
+ execute: (id, params) => tool.execute(id, { scope: REINDEX_DEFAULT_SCOPE, ...params }),
30605
31297
  };
30606
- api.registerTool(tool, { optional: true });
30607
31298
  }
30608
- /** Build a query string from defined params. */
30609
- function buildQuery(params, keys) {
30610
- const parts = [];
30611
- for (const key of keys) {
30612
- const val = params[key];
30613
- if (val !== undefined) {
30614
- const s = typeof val === 'string' ? val : JSON.stringify(val);
30615
- parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(s)}`);
30616
- }
30617
- }
30618
- return parts.length > 0 ? `?${parts.join('&')}` : '';
30619
- }
30620
- /** Pick defined keys from params into a body object. */
30621
- function pickDefined(params, keys) {
30622
- const body = {};
30623
- for (const key of keys) {
30624
- if (params[key] !== undefined)
30625
- body[key] = params[key];
30626
- }
30627
- return body;
30628
- }
30629
-
30630
31299
  /**
30631
- * @module plugin/configTools
30632
- * Watcher-owned `watcher_config` and `watcher_config_apply` tools: same names, parameters and requests as the core toolset's, but sent through the plugin HTTP helper so they carry the bearer key.
31300
+ * Register one catalog-built tool as an optional tool.
30633
31301
  *
30634
- * @remarks
30635
- * Core's `createPluginToolset` cannot attach request headers, so its two
30636
- * authenticated HTTP tools are replaced by these (see {@link CORE_TOOLS_REPLACED}).
30637
- * `watcher_status` stays with core: `GET /status` is always open.
31302
+ * @param api - Plugin API (tool registration target).
31303
+ * @param catalogTools - Catalog-built tools, by name ({@link createCatalogTools}).
31304
+ * @param name - Tool name.
31305
+ * @throws When no tool of that name was built.
30638
31306
  */
30639
- /** Core toolset tools that this module replaces. */
30640
- const CORE_TOOLS_REPLACED = new Set([
30641
- 'watcher_config',
30642
- 'watcher_config_apply',
30643
- ]);
30644
- /** `watcher_config` and `watcher_config_apply`, as API tool configs. */
30645
- const configToolConfigs = [
30646
- {
30647
- name: 'watcher_config',
30648
- description: 'Query watcher running configuration. Optional JSONPath filter.',
30649
- parameters: {
30650
- type: 'object',
30651
- properties: {
30652
- path: {
30653
- type: 'string',
30654
- description: 'JSONPath expression (optional)',
30655
- },
30656
- },
30657
- },
30658
- buildRequest: (params) => {
30659
- const path = params.path;
30660
- const base = getEndpoint('config').path;
30661
- return [path ? `${base}?path=${encodeURIComponent(path)}` : base];
30662
- },
30663
- },
30664
- {
30665
- name: 'watcher_config_apply',
30666
- description: 'Apply a config patch to the running watcher service.',
30667
- parameters: {
30668
- type: 'object',
30669
- properties: {
30670
- config: {
30671
- type: 'object',
30672
- description: 'Config patch to apply',
30673
- },
30674
- },
30675
- required: ['config'],
30676
- },
30677
- buildRequest: (params) => {
30678
- const config = params.config;
30679
- if (!config)
30680
- throw new Error('Missing required parameter: config');
30681
- return [getEndpoint('configApply').path, { patch: config }];
30682
- },
30683
- },
30684
- ];
31307
+ function registerCatalogTool(api, catalogTools, name) {
31308
+ const tool = catalogTools.get(name);
31309
+ if (!tool)
31310
+ throw new Error(`Catalog tool not built: ${name}`);
31311
+ api.registerTool(tool, { optional: true });
31312
+ }
30685
31313
 
30686
31314
  /**
30687
31315
  * @module plugin/toolGating
@@ -30693,19 +31321,17 @@ const configToolConfigs = [
30693
31321
  * that only call the service HTTP API keep working without it.
30694
31322
  *
30695
31323
  * Audit of the tools the plugin registers:
30696
- * - `watcher_status` (core standard toolset, `createPluginToolset`): HTTP
30697
- * only (`{apiUrl}/status`, resolved per call).
30698
- * - `watcher_config`, `watcher_config_apply`: implemented by the plugin
30699
- * (`configTools.ts`, replacing the core ones so the request carries the
30700
- * `Authorization: Bearer` key); HTTP only.
31324
+ * - `watcher_status`, `watcher_config`, `watcher_config_apply` (core
31325
+ * toolset from `WATCHER_CATALOG`, `catalogTools.ts`): HTTP only
31326
+ * (`{apiUrl}` resolved per call; the bearer key sent where the entry
31327
+ * requires it).
30701
31328
  * - `watcher_service` (core standard toolset): only `install` reads
30702
31329
  * `configRoot` (`createServiceManager` → `resolveConfigFilePath` →
30703
31330
  * `getComponentConfigDir`); the other actions query or drive the OS
30704
31331
  * service manager by service name.
30705
31332
  *
30706
- * The 14 domain tools (7 in `watcherTools.ts`, 7 VCS tools in
30707
- * `vcsTools.ts`) call the service API via `registerApiTool` →
30708
- * `requestJson` and are never gated.
31333
+ * The 14 domain tools come from the same core toolset (D38): HTTP only,
31334
+ * never gated.
30709
31335
  */
30710
31336
  /**
30711
31337
  * Tools whose implementation reads `configRoot`, keyed by tool name. A tool
@@ -30802,31 +31428,25 @@ function withGuardedTools(api, ensureCore, readers = CONFIG_ROOT_READERS) {
30802
31428
 
30803
31429
  /**
30804
31430
  * @module plugin/promptContext
30805
- * Always-in-context watcher rules, injected via `before_prompt_build`.
31431
+ * Injects the descriptor's always-in-context rules (`promptRules`, from watcher-core) via `before_prompt_build`.
30806
31432
  *
30807
31433
  * @remarks
30808
31434
  * Replaces the v0.x TOOLS.md `## Watcher` section that core's
30809
- * `ComponentWriter` wrote on a timer. The rules are static; live numbers
30810
- * (point counts, configured score thresholds, VCS roots) are served by
30811
- * `watcher_status` and `watcher_config`, and detail lives in the skill.
31435
+ * `ComponentWriter` wrote on a timer. Core registers nothing itself: the
31436
+ * plugin passes the descriptor's `promptRules` to `registerPromptContext`.
30812
31437
  */
30813
- /** Static watcher rules the agent must see on every turn. */
30814
- const WATCHER_PROMPT_CONTEXT = [
30815
- '## Watcher',
30816
- 'This environment includes a semantic search index over the document archive (`watcher_search`).',
30817
- '- **Escalation rule:** Use `memory_search` for personal operational notes, decisions, and rules. Escalate to `watcher_search` when memory is thin, or when searching the broader archive (tickets, docs, code). ALWAYS use `watcher_search` BEFORE filesystem commands (exec, grep) when looking for information in indexed paths.',
30818
- '- **Scan-first rule:** For structural queries (file enumeration, staleness checks, domain listing, counts), use `watcher_scan` instead of `watcher_search`. Scan does not use embeddings and does not accept a query string.',
30819
- '- **Search-first rule:** When finding, reading, or modifying files in indexed paths, run `watcher_search` FIRST, even if you already know the file path. Direct filesystem access is for acting on search results, not bypassing them.',
30820
- '- **Scores:** strong / relevant / noise thresholds apply only when configured at `$.search.scoreThresholds` (query with `watcher_config`); there are no defaults. When configured, discard noise; if all results are noise, broaden the query.',
30821
- '- **Inventory on demand (`watcher_config`):** inference rules `$.inferenceRules[*]`, watched paths `$.watch.paths[*]`, ignored paths `$.watch.ignored[*]`. Version history: `watcher_vcs_*` tools. See the jeeves-watcher skill for detail.',
30822
- ].join('\n');
30823
31438
  /**
30824
- * Register the watcher rules with OpenClaw's `before_prompt_build` hook.
31439
+ * Register the component's prompt rules with OpenClaw's `before_prompt_build` hook.
30825
31440
  *
30826
- * @returns `true` when the hook was registered.
31441
+ * @param api - Plugin API.
31442
+ * @param component - Descriptor carrying `promptRules`.
31443
+ * @returns `true` when the hook was registered; `false` when the host has no
31444
+ * `api.on` or the descriptor has no rules.
30827
31445
  */
30828
- function registerWatcherPromptContext(api) {
30829
- return registerPromptContext(api, { content: WATCHER_PROMPT_CONTEXT });
31446
+ function registerWatcherPromptContext(api, component) {
31447
+ if (!component.promptRules)
31448
+ return false;
31449
+ return registerPromptContext(api, { content: component.promptRules });
30830
31450
  }
30831
31451
 
30832
31452
  /**
@@ -30834,10 +31454,9 @@ function registerWatcherPromptContext(api) {
30834
31454
  * Jeeves component descriptor for the watcher OpenClaw plugin.
30835
31455
  *
30836
31456
  * @remarks
30837
- * Feeds `createPluginToolset()`, of which the plugin registers
30838
- * `watcher_status` and `watcher_service`; its `watcher_config` and
30839
- * `watcher_config_apply` are replaced by the plugin's own (`configTools.ts`,
30840
- * which send the API key). The service descriptor
31457
+ * Feeds `createPluginToolset()` (with `WATCHER_CATALOG`, `catalogTools.ts`),
31458
+ * which builds the four standard tools and the catalog-built domain tools.
31459
+ * The service descriptor
30841
31460
  * (`packages/service/src/descriptor.ts`) is the canonical source for the
30842
31461
  * config schema and CLI commands.
30843
31462
  */
@@ -30859,6 +31478,9 @@ function createWatcherComponent(pluginVersion) {
30859
31478
  configSchema: jeevesComponentDescriptorSchema.shape.name,
30860
31479
  configFileName: 'config.json',
30861
31480
  initTemplate: () => ({}),
31481
+ // Injected by the plugin (prompt rules) and written by its build (skill).
31482
+ promptRules: WATCHER_PROMPT_RULES,
31483
+ skill: WATCHER_SKILL,
30862
31484
  startCommand: (configPath) => [
30863
31485
  'jeeves-watcher',
30864
31486
  'start',
@@ -30872,362 +31494,6 @@ function createWatcherComponent(pluginVersion) {
30872
31494
  };
30873
31495
  }
30874
31496
 
30875
- /**
30876
- * @module plugin/vcsTools
30877
- * Version-tracking (watcher_vcs_*) tool configs for the OpenClaw plugin; each maps tool params to a watcher VCS endpoint.
30878
- */
30879
- /** The 7 watcher_vcs_* tool configs. */
30880
- const vcsToolConfigs = [
30881
- {
30882
- name: 'watcher_vcs_status',
30883
- description: 'Get version tracking health: enabled state, tracked roots, remote status, last activity',
30884
- parameters: { type: 'object', properties: {} },
30885
- buildRequest: () => [getEndpoint('vcsStatus').path],
30886
- },
30887
- {
30888
- name: 'watcher_vcs_history',
30889
- description: 'Query change history by path or glob with optional date range',
30890
- parameters: {
30891
- type: 'object',
30892
- required: ['glob'],
30893
- properties: {
30894
- glob: {
30895
- type: 'string',
30896
- description: 'Path or glob pattern to query history for.',
30897
- },
30898
- since: {
30899
- type: 'string',
30900
- description: 'Start date (ISO 8601 or git date string).',
30901
- },
30902
- until: {
30903
- type: 'string',
30904
- description: 'End date (ISO 8601 or git date string).',
30905
- },
30906
- limit: {
30907
- type: 'number',
30908
- description: 'Maximum number of history entries to return.',
30909
- },
30910
- },
30911
- },
30912
- buildRequest: (params) => [
30913
- `${getEndpoint('vcsHistory').path}${buildQuery(params, ['glob', 'since', 'until', 'limit'])}`,
30914
- ],
30915
- },
30916
- {
30917
- name: 'watcher_vcs_show',
30918
- description: 'Retrieve file content at a specific version',
30919
- parameters: {
30920
- type: 'object',
30921
- required: ['path', 'commit'],
30922
- properties: {
30923
- path: {
30924
- type: 'string',
30925
- description: 'File path to retrieve.',
30926
- },
30927
- commit: {
30928
- type: 'string',
30929
- description: 'Version identifier.',
30930
- },
30931
- },
30932
- },
30933
- buildRequest: (params) => [
30934
- `${getEndpoint('vcsShow').path}${buildQuery(params, ['path', 'commit'])}`,
30935
- ],
30936
- },
30937
- {
30938
- name: 'watcher_vcs_diff',
30939
- description: 'Show what changed between two versions, or between a version and current',
30940
- parameters: {
30941
- type: 'object',
30942
- required: ['glob', 'commit'],
30943
- properties: {
30944
- glob: {
30945
- type: 'string',
30946
- description: 'Path or glob pattern to diff.',
30947
- },
30948
- commit: {
30949
- type: 'string',
30950
- description: 'Start version identifier.',
30951
- },
30952
- commitEnd: {
30953
- type: 'string',
30954
- description: 'End version identifier (defaults to current if omitted).',
30955
- },
30956
- },
30957
- },
30958
- buildRequest: (params) => [
30959
- `${getEndpoint('vcsDiff').path}${buildQuery(params, ['glob', 'commit', 'commitEnd'])}`,
30960
- ],
30961
- },
30962
- {
30963
- name: 'watcher_vcs_revert',
30964
- description: 'Undo changes by restoring files to a specific version',
30965
- parameters: {
30966
- type: 'object',
30967
- required: ['glob', 'commit'],
30968
- properties: {
30969
- glob: {
30970
- type: 'string',
30971
- description: 'Path or glob pattern to revert.',
30972
- },
30973
- commit: {
30974
- type: 'string',
30975
- description: 'Version to restore files to.',
30976
- },
30977
- existingOnly: {
30978
- type: 'boolean',
30979
- description: 'When true, only revert files that currently exist (skip deleted files).',
30980
- },
30981
- },
30982
- },
30983
- buildRequest: (params) => {
30984
- const body = pickDefined(params, ['glob', 'commit', 'existingOnly']);
30985
- return [getEndpoint('vcsRevert').path, body];
30986
- },
30987
- },
30988
- {
30989
- name: 'watcher_vcs_exclude',
30990
- description: 'Exclude or re-include paths from version tracking',
30991
- parameters: {
30992
- type: 'object',
30993
- required: ['glob'],
30994
- properties: {
30995
- glob: {
30996
- type: 'string',
30997
- description: 'Glob pattern to exclude or re-include.',
30998
- },
30999
- root: {
31000
- type: 'string',
31001
- description: 'Tracked root to target (defaults to auto-detect).',
31002
- },
31003
- remove: {
31004
- type: 'boolean',
31005
- description: 'When true, remove the exclusion rule (re-include the path).',
31006
- },
31007
- },
31008
- },
31009
- buildRequest: (params) => {
31010
- const body = pickDefined(params, ['glob', 'root', 'remove']);
31011
- return [getEndpoint('vcsExclude').path, body];
31012
- },
31013
- },
31014
- {
31015
- name: 'watcher_vcs_check',
31016
- description: 'Check whether a path is excluded from version tracking and why',
31017
- parameters: {
31018
- type: 'object',
31019
- required: ['path'],
31020
- properties: {
31021
- path: {
31022
- type: 'string',
31023
- description: 'File path to check exclusion status for.',
31024
- },
31025
- },
31026
- },
31027
- buildRequest: (params) => [
31028
- `${getEndpoint('vcsCheckExclusion').path}${buildQuery(params, ['path'])}`,
31029
- ],
31030
- },
31031
- ];
31032
-
31033
- /**
31034
- * @module plugin/watcherTools
31035
- * Domain-specific watcher tool registrations (14 tools) for the OpenClaw plugin.
31036
- */
31037
- /**
31038
- * Register the 14 domain-specific watcher_* tools with the OpenClaw plugin API.
31039
- *
31040
- * @remarks
31041
- * `watcher_search` and `watcher_scan` are `catalogMode: 'direct-only'` so
31042
- * OpenClaw Tool Search keeps them model-visible: semantic search must be a
31043
- * direct tool or the model reaches for grep first. All other tools remain
31044
- * catalog-eligible.
31045
- *
31046
- * @param api - Plugin API.
31047
- * @param baseUrl - API base URL, or a getter resolved on every call (so a
31048
- * changed `apiUrl` takes effect without re-registering, as for the config
31049
- * tools).
31050
- */
31051
- function registerWatcherTools(api, baseUrl) {
31052
- const tools = [
31053
- {
31054
- name: 'watcher_search',
31055
- description: 'Semantic search over indexed documents. Supports Qdrant filters. This is a direct tool: call it directly, never through tool_call.',
31056
- catalogMode: 'direct-only',
31057
- parameters: {
31058
- type: 'object',
31059
- required: ['query'],
31060
- properties: {
31061
- query: { type: 'string', description: 'Search query text.' },
31062
- limit: { type: 'number', description: 'Max results (default 10).' },
31063
- offset: {
31064
- type: 'number',
31065
- description: 'Number of results to skip for pagination.',
31066
- },
31067
- filter: { type: 'object', description: 'Qdrant filter object.' },
31068
- },
31069
- },
31070
- buildRequest: (params) => {
31071
- const body = pickDefined(params, [
31072
- 'query',
31073
- 'limit',
31074
- 'offset',
31075
- 'filter',
31076
- ]);
31077
- return [getEndpoint('search').path, body];
31078
- },
31079
- },
31080
- {
31081
- name: 'watcher_enrich',
31082
- description: 'Set or update metadata on a document by file path.',
31083
- parameters: {
31084
- type: 'object',
31085
- required: ['path', 'metadata'],
31086
- properties: {
31087
- path: {
31088
- type: 'string',
31089
- description: 'Relative file path of the document.',
31090
- },
31091
- metadata: {
31092
- type: 'object',
31093
- description: 'Key-value metadata to set on the document.',
31094
- },
31095
- },
31096
- },
31097
- buildRequest: (params) => [
31098
- getEndpoint('metadata').path,
31099
- { path: params.path, metadata: params.metadata },
31100
- ],
31101
- },
31102
- {
31103
- name: 'watcher_validate',
31104
- description: 'Validate a candidate config (or current config if omitted). Optionally test file paths against the config to preview rule matching and metadata output.',
31105
- parameters: {
31106
- type: 'object',
31107
- properties: {
31108
- config: {
31109
- type: 'object',
31110
- description: 'Candidate config (partial or full). Omit to validate current config.',
31111
- },
31112
- testPaths: {
31113
- type: 'array',
31114
- items: { type: 'string' },
31115
- description: 'File paths to test against the config for dry-run preview.',
31116
- },
31117
- },
31118
- },
31119
- buildRequest: (params) => {
31120
- const body = pickDefined(params, ['config', 'testPaths']);
31121
- return [getEndpoint('configValidate').path, body];
31122
- },
31123
- },
31124
- {
31125
- name: 'watcher_reindex',
31126
- description: 'Trigger a reindex of the watched files.',
31127
- parameters: {
31128
- type: 'object',
31129
- properties: {
31130
- scope: {
31131
- type: 'string',
31132
- enum: ['rules', 'full', 'issues', 'path', 'prune'],
31133
- description: 'Reindex scope: "rules" (default) re-applies inference rules; "full" re-embeds everything; "issues" re-processes files with errors; "path" reindexes a specific file or directory (requires path parameter); "prune" deletes points for files no longer in watch scope.',
31134
- },
31135
- path: {
31136
- oneOf: [
31137
- { type: 'string' },
31138
- { type: 'array', items: { type: 'string' } },
31139
- ],
31140
- description: 'Target file or directory path (required when scope is "path"). Accepts a single path or array of paths.',
31141
- },
31142
- dryRun: {
31143
- type: 'boolean',
31144
- description: 'When true, compute and return the blast area plan without executing. Returns counts by root showing impact.',
31145
- },
31146
- },
31147
- },
31148
- buildRequest: (params) => [
31149
- getEndpoint('reindex').path,
31150
- {
31151
- scope: params.scope ?? 'rules',
31152
- ...(params.path ? { path: params.path } : {}),
31153
- ...(params.dryRun ? { dryRun: true } : {}),
31154
- },
31155
- ],
31156
- },
31157
- {
31158
- name: 'watcher_scan',
31159
- description: 'Filter-only point query without vector search. Returns metadata for points matching a Qdrant filter. Use for structural queries: file enumeration, staleness checks, delta computation. Use watcher_search for semantic/similarity queries. This is a direct tool: call it directly, never through tool_call.',
31160
- catalogMode: 'direct-only',
31161
- parameters: {
31162
- type: 'object',
31163
- required: ['filter'],
31164
- properties: {
31165
- filter: {
31166
- type: 'object',
31167
- description: 'Qdrant filter object (required).',
31168
- },
31169
- limit: {
31170
- type: 'number',
31171
- description: 'Page size (default 100, max 1000).',
31172
- },
31173
- cursor: {
31174
- type: 'string',
31175
- description: 'Opaque cursor from previous response for pagination.',
31176
- },
31177
- fields: {
31178
- type: 'array',
31179
- items: { type: 'string' },
31180
- description: 'Payload fields to return (projection).',
31181
- },
31182
- countOnly: {
31183
- type: 'boolean',
31184
- description: 'If true, return { count } instead of points.',
31185
- },
31186
- },
31187
- },
31188
- buildRequest: (params) => {
31189
- const body = pickDefined(params, [
31190
- 'filter',
31191
- 'limit',
31192
- 'cursor',
31193
- 'fields',
31194
- 'countOnly',
31195
- ]);
31196
- return [getEndpoint('scan').path, body];
31197
- },
31198
- },
31199
- {
31200
- name: 'watcher_issues',
31201
- description: 'Get runtime processing failures. Shows the documents (file path or source reference) that failed processing and why.',
31202
- parameters: { type: 'object', properties: {} },
31203
- buildRequest: () => [getEndpoint('issues').path],
31204
- },
31205
- {
31206
- name: 'watcher_walk',
31207
- description: 'Walk watched filesystem paths with glob intersection. Returns matching file paths from all configured watch roots, applying watch.ignored and gitignore filtering.',
31208
- parameters: {
31209
- type: 'object',
31210
- required: ['globs'],
31211
- properties: {
31212
- globs: {
31213
- type: 'array',
31214
- items: { type: 'string' },
31215
- description: 'Glob patterns to intersect with watch paths (e.g., ["**/.meta/meta.json"]).',
31216
- },
31217
- },
31218
- },
31219
- buildRequest: (params) => [
31220
- getEndpoint('walk').path,
31221
- { globs: params.globs },
31222
- ],
31223
- },
31224
- ...vcsToolConfigs,
31225
- ];
31226
- for (const tool of tools) {
31227
- registerApiTool(api, baseUrl, tool);
31228
- }
31229
- }
31230
-
31231
31497
  /**
31232
31498
  * @module plugin
31233
31499
  * OpenClaw plugin entry point. Registers the jeeves-watcher tools and the
@@ -31245,22 +31511,16 @@ const PLUGIN_VERSION = getPackageVersion(import.meta.url);
31245
31511
  function register(api) {
31246
31512
  warnIfConfigRootUnset(api);
31247
31513
  const toolApi = withGuardedTools(api, createLazyCore(api));
31248
- // 4 standard tools: watcher_status and watcher_service from the core
31249
- // factory; watcher_config and watcher_config_apply re-implemented here so
31250
- // they send the bearer key (core cannot attach headers). `apiUrl` is
31251
- // resolved lazily on every call by every tool (defaultPort is only core's
31252
- // fallback).
31253
- for (const tool of createPluginToolset(createWatcherComponent(PLUGIN_VERSION), { apiUrl: () => getApiUrl(api) })) {
31254
- if (!CORE_TOOLS_REPLACED.has(tool.name)) {
31255
- toolApi.registerTool(tool, { optional: true });
31256
- }
31257
- }
31258
- for (const config of configToolConfigs) {
31259
- registerApiTool(toolApi, () => getApiUrl(api), config);
31260
- }
31261
- // 14 domain-specific tools (search, enrich, scan, vcs, ...).
31262
- registerWatcherTools(toolApi, () => getApiUrl(api));
31263
- registerWatcherPromptContext(api);
31514
+ const component = createWatcherComponent(PLUGIN_VERSION);
31515
+ // Every tool comes from core's toolset over WATCHER_CATALOG (D38): the 4
31516
+ // standard tools and the 14 domain tools, in their 0.17.0 order. Each
31517
+ // resolves the base URL (and, where its entry requires it, the bearer key)
31518
+ // on every call.
31519
+ const catalogTools = createCatalogTools(api, component);
31520
+ for (const name of WATCHER_TOOL_NAMES) {
31521
+ registerCatalogTool(toolApi, catalogTools, name);
31522
+ }
31523
+ registerWatcherPromptContext(api, component);
31264
31524
  }
31265
31525
 
31266
31526
  export { register as default };