High-level architectural overview of jeeves-watcher for contributors and advanced users.
embedding.provider; adding a provider is a small, self-contained code change (see Add a New Embedding Provider).
The watcher consists of several layered components:
stateDir (default .jeeves-metadata)
The system maintains three distinct stores:
<stateDir>/enrichments.sqlite, node:sqlite) — Enrichment metadata added via APIQdrant lost → Rebuild from filesystem + enrichment store:
jeeves-watcher reindex --scope full
Re-extracts every watched file, merges its enrichment from the enrichment store, re-embeds the content and upserts to Qdrant. (The CLI's default scope is rules, which only re-applies rules to points that already exist.)
Enrichment store lost → Rebuild from Qdrant:
jeeves-watcher rebuild-metadata
Scrolls Qdrant and, for every file point, writes its payload minus the reserved system fields (identity, chunk, hash, date, line and matched_rules fields) to the enrichment store. Rule-derived fields are therefore written too; source documents are not rebuilt.

Debounce: Events for the same path are collapsed until watch.debounceMs (default: 300 ms) has passed since the last one (prevents re-embedding during rapid edits).
Stability Check: Chokidar's awaitWriteFinish holds an event until the file size has been unchanged for watch.stabilityThresholdMs (default: 500 ms; prevents embedding partial writes).

The core processing flow transforms file changes into searchable vectors. The file resolver (steps 1–4) produces a document; the shared pipeline (steps 5–12) indexes it. Source documents use the source resolver and the same pipeline.
set templates and coerce to declared typesmatched_rulesdebug as Skipping empty document)content_hash, the file is skipped: nothing is embedded or writtenmatched_rules), 50 points per request
The enrichment API allows external callers to add metadata without re-embedding:
path (or a source reference source) and metadata; values that do not match the matching rules' declared property types are rejected with 400 invalid_requestmatched_rules are kept)Key principle: Enrichment updates metadata only. No text extraction, no re-embedding, no hash check. Fast metadata updates without changing vectors.

Chunk deduplication: Search returns individual chunks. Callers group by file_path (or source) to get unique documents.
Documents exceeding embedding.chunkSize (default: 1000 characters) are split into chunks.
Splitters:
.md, .markdown) — MarkdownTextSplitter (splits on heading boundaries, preserves structure)RecursiveCharacterTextSplitter (splits on \n\n, then \n, then spaces, then characters)Both splitters use:
chunkSize — max characters per chunkchunkOverlap — overlap between consecutive chunks (default: 200; helps preserve context at boundaries)Chunk Points:
Each chunk becomes a separate Qdrant point with:
pointId(filePath, chunkIndex) (deterministic UUID)file_path, matched_rules, rule and enrichment metadata, content_hash, created_at, modified_at (same across all chunks)chunk_index, total_chunks, chunk_text, line_start, line_endExample:
File with 3 chunks → 3 Qdrant points:
| Point ID | chunk_index |
total_chunks |
chunk_text |
|---|---|---|---|
uuid-0 |
0 |
3 |
"First chunk..." |
uuid-1 |
1 |
3 |
"Second chunk..." |
uuid-2 |
2 |
3 |
"Third chunk..." |
The v0.5.0 inference rules system uses declarative JSON Schemas with type coercion. When a rule matches a file, the watcher merges schema references and applies type coercion:

Key steps:
schemas collectiontypeset templates — interpolate {{...}} Handlebars expressions against file attributesWhen the config file changes, the watcher triggers a reindex whose scope is set by configWatch.reindex:

Scopes:
issues (default) — Re-process only the files and source references listed in the issues file (cheap, targeted)rules — Re-apply the rules to indexed files without re-embeddingfull — Re-process all watched files and indexed source documents (expensive, comprehensive)To turn automatic reloads off, set configWatch.enabled to false (then use POST /reindex manually).
Every processed document gets a content hash (sha256 of the text to embed) stored in the Qdrant payload.
Whenever a file is processed (initial scan, create, modify, issues/path/full reindex):
content_hash matches → skip (no embedding API call, no write)Benefits:
Note: A skipped file is not rewritten at all, so an edit that leaves the embedded text unchanged (such as a Markdown frontmatter-only edit) does not refresh rule metadata on its points. Run a rules reindex to re-apply rules without re-embedding.
Qdrant point IDs are deterministic UUIDs derived from the document's identity (see src/pointId/index.ts):
import { v5 as uuidV5 } from 'uuid';
const NAMESPACE = '6a6f686e-6761-4c74-ad6a-656576657321'; // fixed
// Files: the path is normalised, so file IDs are case-insensitive.
function pointId(filePath: string, chunkIndex: number): string {
return uuidV5(`${normalizePath(filePath)}#${chunkIndex}`, NAMESPACE);
}
// Source documents (v0.20.0+): the exact <name>://<id> reference, not normalised.
function sourcePointId(reference: string, chunkIndex: number): string {
return uuidV5(`${reference}#${chunkIndex}`, NAMESPACE);
}
Why deterministic:
Trade-off: Renaming/moving files changes their identity in the index. Acceptable — the watcher handles it transparently (with watch.moveDetection, an unlink followed by an add of the same content is treated as a move).
Filesystem events go through one debounced, concurrent queue.
Debounce: Events for one path (and priority) are collapsed: only the last event within watch.debounceMs (default: 300 ms) is processed.
Concurrency: Up to embedding.concurrency (default: 5) documents are processed at once; normal-priority events run before low-priority ones. Embedding API requests from every path (watch, source-document notifications, reindex, search) draw from one shared rate limiter (embedding.rateLimitPerMinute, counted in requests since v0.20.0).
Failures: A failing document is logged and recorded in the issues file; it does not stop the queue.
Source-document notifications (v0.20.0+) go through a separate in-memory queue keyed by reference, with the same embedding.concurrency. Pending notifications for one reference merge; a notification for a reference that is already running marks it for exactly one re-run.
Both queues end in the document processor. A file resolver stats and extracts the file and applies every matching rule; a source resolver retrieves the <name>://<id> reference through its callback and applies only the designated rule. Each produces a resolved document for the same chunk/embed/upsert pipeline. Source rules get no file reader: a map's fetchSiblings returns nothing for a source document.
On service start:
stateDirwatch.paths (and not ignored or gitignored) is queued as a create eventPoints of files deleted while the service was down are not removed at startup; run a prune reindex (jeeves-watcher reindex --scope prune) to delete them.
When configWatch.enabled is true (the default) and the service was started with a config path, the watcher monitors its own config file.
On config change:
configWatch.debounceMs (default: 1000 ms)watch config changedconfigWatch.reindex (default: issues)A reload that fails is logged (Failed to reload config) and the previous config stays in effect. api.apiKey, sources and vectorStore.payloadIndexes are read at startup only.
Enrichment lives in a SQLite database at <stateDir>/enrichments.sqlite, opened with Node's built-in node:sqlite (WAL journal, 5 s busy timeout). One table holds one row per document:
| Column | Content |
|---|---|
path |
Document key (primary key) |
metadata |
Enrichment metadata as JSON (no content/vectors) |
created_at |
ISO timestamp of the first write |
updated_at |
ISO timestamp of the last write |
Keys: File paths are normalised (lowercase, forward slashes), so lookups are case-insensitive; source references (<name>://<id>) are stored verbatim.
Merge on write: A new write merges into the existing entry; top-level keys of the new metadata replace existing ones.
The enrichment store is watcher-owned. Only the watcher process writes to it: POST /metadata, POST /rebuild-metadata, file moves (the entry follows the file) and file deletes (the entry is removed).
Enforcement: Architectural policy (single writer; no filesystem permissions).
Rules are JSON Schema match + schema arrays, evaluated in order against file attributes.
Attributes:
{
"file": {
"path": "d:/meetings/2026-02-20/transcript.md",
"directory": "d:/meetings/2026-02-20",
"filename": "transcript.md",
"extension": ".md",
"sizeBytes": 4523,
"modified": "2026-02-20T08:15:00.000Z"
},
"frontmatter": { "title": "Meeting Notes", "author": "jeeves" },
"json": { "participants": ["Alice", "Bob"] }
}
Rule example (v2):
{
"name": "meetings-classifier",
"description": "Classify meeting transcripts and notes",
"match": {
"properties": {
"file": {
"properties": {
"path": { "type": "string", "glob": "d:/meetings/**" }
}
}
}
},
"schema": [
"base",
{
"properties": {
"domain": { "type": "string", "set": "meetings" },
"title": { "type": "string", "set": "{{frontmatter.title}}" }
}
}
],
"map": "extractProject"
}
Processing order for each matching rule:
match — JSON Schema validation against file attributesset resolution — Handlebars template interpolation ({{...}}) resolves against attributes, with type coercionmap — JsonMap transformation (inline or named reference)map output overrides set output on field conflictCustom glob keyword: Picomatch glob matching registered as an ajv custom keyword. Everything else is standard JSON Schema (with the standard ajv-formats formats).
JsonMap transformations (map):
Rules can reference named maps from the top-level maps config, or include inline JsonMap definitions. The watcher provides lib functions for path manipulation and lookups: split, slice, join, toLowerCase, replace, get, lookupJson, mapLookup, fetchSiblings; custom functions can be added via mapHelpers.
See Inference Rules Guide for full details.
| Extension | Strategy |
|---|---|
.md, .markdown |
Parse YAML frontmatter (exposed as frontmatter attributes), return the body text |
.txt, .text |
Return as-is (BOM stripped) |
.json |
Return the first non-empty string among content, body, text, snippet, subject, description, summary, transcript; otherwise the serialized JSON. A JSON object is also exposed as json attributes |
.pdf |
Extract text via the unpdf library (pages joined) |
.docx |
Extract raw text via the mammoth library |
.html, .htm |
Drop script/style, take the body text via the cheerio library |
Any other extension is read as plain text. Use watch.paths globs (or watch.ignored) to keep binary files such as .png out of the index. A file whose extracted text is empty or whitespace-only is skipped.
Transient failures are retried; anything that still fails is recorded as an issue:
| Failure | Retry Policy |
|---|---|
| Embedding provider 429 (rate limit) | Every caller pauses on the shared limiter. A Retry-After up to maxBackoffMs (default 60 s) is honoured; a longer one fails fast. Without Retry-After: backoff from 1 s, doubling, capped at maxBackoffMs. Up to 5 provider calls per embed (3 for a search query) |
| Embedding provider network error or other non-2xx | 5 attempts, exponential backoff from 500 ms capped at 10 s (+ up to 20% jitter); each retry draws from the shared limiter |
| Qdrant write (upsert, delete, set-payload, delete-payload) | 5 attempts, same backoff, each retry on a fresh Qdrant client |
| Text extraction error | No retry (logged and recorded as an extraction_error issue) |
Issues file: Documents that fail are recorded in the issues file (persisted to {stateDir}/issues.json) with a canonical issue code (embedding_error, upsert_error, extraction_error, ...). Surfaced via GET /issues; re-processed by an issues reindex (the default on config reload).
On SIGTERM/SIGINT (v0.20.0+ order):
shutdownTimeoutMs, default 10 s); stopping drops work the drain did not reach and waits for in-flight runsPartially processed files are safe — on the next start the initial scan re-processes every file, and content hashing skips the ones already indexed.
On startup, if the Qdrant collection doesn't exist, the watcher creates it, then creates any missing payload indexes (configured vectorStore.payloadIndexes plus the system source/source_rule indexes):
await qdrant.createCollection(collectionName, {
vectors: {
size: dimensions, // embedding.dimensions (Gemini default: 3072)
distance: 'Cosine',
},
});
Existing collections: If the collection already exists, it is used as-is — dimensions and distance metric are not validated against config on startup.
Dimension mismatch warning: If you manually create the collection with different dimensions (e.g., 768 instead of 3072), the service will start but embedding upserts will fail with a dimension mismatch error.
Recovery: Delete the collection manually (or rename collectionName in config), then restart. The watcher will recreate the collection with correct dimensions.
Structured JSON logging via pino, at logging.level (default info):
| Level | Events (log message) |
|---|---|
info |
File processed successfully, File deleted from index, File moved in index, Metadata updated, Config reloaded, Initial scan complete, Reindex (<scope>) completed, jeeves-watcher started / stopped |
warn |
Embedding call failed; will retry, Qdrant <operation> failed; will retry, shutdown step timeouts, reserved-field warnings on reload |
error |
Failed to process file (after retries; issue recorded), Failed to reload config, Reindex failed, Watcher error |
debug |
Content unchanged, skipping, Skipping empty document, File added / File changed / File removed, Skipping gitignored file |
Log format: JSON lines on stdout, or to logging.file when set (parseable by standard log aggregators).
| Component | Library | Rationale |
|---|---|---|
| Filesystem watcher | chokidar |
Cross-platform, battle-tested (globs are matched with picomatch) |
| HTTP framework | fastify |
Lightweight, fast |
| Request validation | zod |
Shared request/response schemas in the core package |
| Text splitting | @langchain/textsplitters |
Markdown-aware + recursive splitting |
| JSON Schema validation | ajv |
Fast, extensible (custom glob keyword) |
| Embedding client | Direct HTTPS (node:https, Gemini REST API) |
No SDK bloat |
| Qdrant client | @qdrant/js-client-rest |
Official client, typed |
| Enrichment store | node:sqlite |
Built into Node.js, no native build |
| Logging | pino |
Structured JSON, low overhead |
Extractors are registered in code, not in config. Implement an extractor and add it to extractorRegistry in src/extractors/index.ts:
async function extractYaml(filePath: string): Promise<ExtractedText> {
const content = await readFile(filePath, 'utf8');
const parsed = yamlLoad(content) as Record<string, unknown>;
return {
text: JSON.stringify(parsed, null, 2),
json: parsed,
};
}
const extractorRegistry = new Map<string, Extractor>([
// ...existing entries
['.yaml', extractYaml],
]);
src/embedding/types.ts):const createMyProvider: ProviderFactory = (config, logger) => ({
dimensions: config.dimensions ?? 1024,
async embed(texts: string[]): Promise<number[][]> {
// Call your provider's API (one request per call)
},
});
embeddingProviderRegistry in src/embedding/index.ts:const embeddingProviderRegistry = new Map<string, ProviderFactory>([
['mock', createMockFromConfig],
['gemini', createGeminiProvider],
['my-provider', createMyProvider],
]);
{
"embedding": {
"provider": "my-provider",
"model": "my-model",
"apiKey": "${MY_API_KEY}"
}
}
The VCS subsystem runs alongside the embedding pipeline as an independent data flow. Both systems share watch paths but maintain separate state.
VcsManagercommitBatch() stages files, generates commit message, commitsvcs.enabled: false by default).git/ directories excludedvcs.commitMessage.apiKey, else from the local OpenClaw config); template message fallbackmaxAgeDays / maxVersions (tighter wins)git show + write, HEAD never moves backwardwatch.paths object entriesstateDir must not overlap watch paths — validated at startup when VCS is enabledEach VcsManager serializes commits within its root (single commit in-flight). The index.lock retry mechanism (4 attempts, exponential backoff) handles contention with external git processes. Squash operations that encounter a held lock abort cleanly and retry on the next cron cycle.
See the Version Control (VCS) Guide for full operational details.