@karmaniverous/jeeves-meta 0.12.1 → 0.12.2

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
@@ -13,9 +13,9 @@ import require$$2 from 'events';
13
13
  import vm from 'vm';
14
14
  import * as commander from 'commander';
15
15
  import { tmpdir } from 'node:os';
16
- import 'node:child_process';
17
16
  import { z } from 'zod';
18
- import { createHash, randomUUID } from 'node:crypto';
17
+ import 'node:child_process';
18
+ import { randomUUID, createHash } from 'node:crypto';
19
19
  import pino from 'pino';
20
20
  import Handlebars from 'handlebars';
21
21
  import { Cron } from 'croner';
@@ -7684,12 +7684,17 @@ const jeevesComponentDescriptorSchema = z.object({
7684
7684
  .optional(),
7685
7685
  /**
7686
7686
  * Returns command + args for launching the service process.
7687
- * Consumed by `start` CLI command and `service install`.
7687
+ * Consumed by `service install`.
7688
7688
  */
7689
7689
  startCommand: z.function({
7690
7690
  input: [z.string()],
7691
7691
  output: z.array(z.string()),
7692
7692
  }),
7693
+ /** In-process service entry point for the CLI `start` command. */
7694
+ run: z.function({
7695
+ input: [z.string()],
7696
+ output: z.promise(z.void()),
7697
+ }),
7693
7698
  /** TOOLS.md section name (e.g., 'Watcher'). */
7694
7699
  sectionId: z.string().min(1, 'sectionId must be a non-empty string'),
7695
7700
  /** Refresh interval in seconds (must be a prime number). */
@@ -8265,49 +8270,6 @@ function loadServiceConfig(configPath) {
8265
8270
  return serviceConfigSchema.parse(raw);
8266
8271
  }
8267
8272
 
8268
- /**
8269
- * Jeeves component descriptor for jeeves-meta.
8270
- *
8271
- * Single source of truth consumed by the service CLI, plugin writer, and
8272
- * config-apply pipeline.
8273
- *
8274
- * @module descriptor
8275
- */
8276
- /**
8277
- * Parsed jeeves-meta component descriptor.
8278
- */
8279
- const metaDescriptor = jeevesComponentDescriptorSchema.parse({
8280
- name: 'meta',
8281
- version: SERVICE_VERSION,
8282
- servicePackage: '@karmaniverous/jeeves-meta',
8283
- pluginPackage: '@karmaniverous/jeeves-meta-openclaw',
8284
- defaultPort: 1938,
8285
- // The runtime Zod custom validator only checks for a .parse() method.
8286
- // Use unknown cast to bridge the Zod v4 (service) → v3 (core SDK) type gap.
8287
- configSchema: serviceConfigSchema,
8288
- configFileName: 'config.json',
8289
- initTemplate: () => serviceConfigSchema.parse({
8290
- watcherUrl: 'http://127.0.0.1:1936',
8291
- }),
8292
- onConfigApply: (merged) => {
8293
- const parsed = serviceConfigSchema.parse(merged);
8294
- applyHotReloadedConfig(parsed);
8295
- return Promise.resolve();
8296
- },
8297
- startCommand: (configPath) => [
8298
- 'node',
8299
- 'dist/cli/jeeves-meta/index.js',
8300
- 'start',
8301
- '-c',
8302
- configPath,
8303
- ],
8304
- sectionId: 'Meta',
8305
- refreshIntervalSeconds: 73,
8306
- generateToolsContent: () => '',
8307
- dependencies: { hard: ['watcher'], soft: [] },
8308
- customCliCommands: registerCustomCliCommands,
8309
- });
8310
-
8311
8273
  /**
8312
8274
  * Compute summary statistics from an array of MetaEntry objects.
8313
8275
  *
@@ -8862,67 +8824,6 @@ function getDeltaFiles(generatedAt, scopeFiles) {
8862
8824
  return filterModifiedAfter(scopeFiles, new Date(generatedAt).getTime());
8863
8825
  }
8864
8826
 
8865
- /**
8866
- * Exponential moving average helper for token tracking.
8867
- *
8868
- * @module ema
8869
- */
8870
- const DEFAULT_DECAY = 0.3;
8871
- /**
8872
- * Compute exponential moving average.
8873
- *
8874
- * @param current - New observation.
8875
- * @param previous - Previous EMA value, or undefined for first observation.
8876
- * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
8877
- * @returns Updated EMA.
8878
- */
8879
- function computeEma(current, previous, decay = DEFAULT_DECAY) {
8880
- if (previous === undefined)
8881
- return current;
8882
- return decay * current + (1 - decay) * previous;
8883
- }
8884
-
8885
- /**
8886
- * Shared error utilities.
8887
- *
8888
- * @module errors
8889
- */
8890
- /**
8891
- * Wrap an unknown caught value into a MetaError.
8892
- *
8893
- * @param step - Which synthesis step failed.
8894
- * @param err - The caught error value.
8895
- * @param code - Error classification code.
8896
- * @returns A structured MetaError.
8897
- */
8898
- function toMetaError(step, err, code = 'FAILED') {
8899
- return {
8900
- step,
8901
- code,
8902
- message: err instanceof Error ? err.message : String(err),
8903
- };
8904
- }
8905
-
8906
- /**
8907
- * Compute a structure hash from a sorted file listing.
8908
- *
8909
- * Used to detect when directory structure changes, triggering
8910
- * an architect re-run.
8911
- *
8912
- * @module structureHash
8913
- */
8914
- /**
8915
- * Compute a SHA-256 hash of a sorted file listing.
8916
- *
8917
- * @param filePaths - Array of file paths in scope.
8918
- * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
8919
- */
8920
- function computeStructureHash(filePaths) {
8921
- const sorted = [...filePaths].sort();
8922
- const content = sorted.join('\n');
8923
- return createHash('sha256').update(content).digest('hex');
8924
- }
8925
-
8926
8827
  /** Sleep for a given number of milliseconds. */
8927
8828
  function sleep(ms) {
8928
8829
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -9460,6 +9361,26 @@ function buildCriticTask(ctx, meta, config) {
9460
9361
  return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
9461
9362
  }
9462
9363
 
9364
+ /**
9365
+ * Exponential moving average helper for token tracking.
9366
+ *
9367
+ * @module ema
9368
+ */
9369
+ const DEFAULT_DECAY = 0.3;
9370
+ /**
9371
+ * Compute exponential moving average.
9372
+ *
9373
+ * @param current - New observation.
9374
+ * @param previous - Previous EMA value, or undefined for first observation.
9375
+ * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
9376
+ * @returns Updated EMA.
9377
+ */
9378
+ function computeEma(current, previous, decay = DEFAULT_DECAY) {
9379
+ if (previous === undefined)
9380
+ return current;
9381
+ return decay * current + (1 - decay) * previous;
9382
+ }
9383
+
9463
9384
  /**
9464
9385
  * Structured error from a synthesis step failure.
9465
9386
  *
@@ -9890,6 +9811,47 @@ function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
9890
9811
  return Math.min(1, (stalenessSeconds * depthFactor * emphasis) / (30 * 86400));
9891
9812
  }
9892
9813
 
9814
+ /**
9815
+ * Shared error utilities.
9816
+ *
9817
+ * @module errors
9818
+ */
9819
+ /**
9820
+ * Wrap an unknown caught value into a MetaError.
9821
+ *
9822
+ * @param step - Which synthesis step failed.
9823
+ * @param err - The caught error value.
9824
+ * @param code - Error classification code.
9825
+ * @returns A structured MetaError.
9826
+ */
9827
+ function toMetaError(step, err, code = 'FAILED') {
9828
+ return {
9829
+ step,
9830
+ code,
9831
+ message: err instanceof Error ? err.message : String(err),
9832
+ };
9833
+ }
9834
+
9835
+ /**
9836
+ * Compute a structure hash from a sorted file listing.
9837
+ *
9838
+ * Used to detect when directory structure changes, triggering
9839
+ * an architect re-run.
9840
+ *
9841
+ * @module structureHash
9842
+ */
9843
+ /**
9844
+ * Compute a SHA-256 hash of a sorted file listing.
9845
+ *
9846
+ * @param filePaths - Array of file paths in scope.
9847
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
9848
+ */
9849
+ function computeStructureHash(filePaths) {
9850
+ const sorted = [...filePaths].sort();
9851
+ const content = sorted.join('\n');
9852
+ return createHash('sha256').update(content).digest('hex');
9853
+ }
9854
+
9893
9855
  /**
9894
9856
  * Lock-staged cycle finalization: write to .lock, copy to meta.json, archive, prune.
9895
9857
  *
@@ -10516,309 +10478,35 @@ class ProgressReporter {
10516
10478
  }
10517
10479
 
10518
10480
  /**
10519
- * Core seed logic create a .meta/ directory with initial meta.json.
10520
- *
10521
- * Shared between the POST /seed route handler and the auto-seed pass.
10522
- *
10523
- * @module seed/createMeta
10524
- */
10525
- /**
10526
- * Create a .meta/ directory with an initial meta.json.
10527
- *
10528
- * Does NOT check for existing .meta/ — caller is responsible for that guard.
10529
- *
10530
- * @param ownerPath - The owner directory path.
10531
- * @param options - Optional cross-refs and steering prompt.
10532
- * @returns The meta directory path and generated ID.
10533
- */
10534
- async function createMeta(ownerPath, options) {
10535
- const metaDir = resolveMetaDir(ownerPath);
10536
- await mkdir(metaDir, { recursive: true });
10537
- const _id = randomUUID();
10538
- const metaJson = { _id };
10539
- if (options?.crossRefs !== undefined)
10540
- metaJson._crossRefs = options.crossRefs;
10541
- if (options?.steer !== undefined)
10542
- metaJson._steer = options.steer;
10543
- const metaJsonPath = join(metaDir, 'meta.json');
10544
- await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
10545
- return { metaDir, _id };
10546
- }
10547
- /**
10548
- * Check if a .meta/ directory already exists for an owner path.
10549
- *
10550
- * @param ownerPath - The owner directory path.
10551
- * @returns True if .meta/ already exists.
10552
- */
10553
- function metaExists(ownerPath) {
10554
- return existsSync(resolveMetaDir(ownerPath));
10555
- }
10556
-
10557
- /**
10558
- * Auto-seed pass — scan for directories matching policy rules and seed them.
10559
- *
10560
- * Runs before discovery in each scheduler tick. For each auto-seed rule,
10561
- * walks matching directories via the watcher and creates .meta/ directories
10562
- * for those that don't already have one.
10481
+ * Single-threaded synthesis queue with priority support and deduplication.
10563
10482
  *
10564
- * Rules are processed in array order; last match wins for steer/crossRefs.
10483
+ * The scheduler enqueues the stalest candidate each tick. HTTP-triggered
10484
+ * synthesis requests get priority (inserted at front). A path appears at
10485
+ * most once in the queue; re-triggering returns the current position.
10565
10486
  *
10566
- * @module seed/autoSeed
10487
+ * @module queue
10567
10488
  */
10489
+ const DEPTH_WARNING_THRESHOLD = 3;
10568
10490
  /**
10569
- * Extract parent directory paths from watcher walk results.
10491
+ * Single-threaded synthesis queue.
10570
10492
  *
10571
- * Walk returns file paths; we need the unique set of immediate parent
10572
- * directories that could be owners.
10493
+ * Only one synthesis runs at a time. Priority items are inserted at the
10494
+ * front of the queue. Duplicate paths are rejected with their current
10495
+ * position returned.
10573
10496
  */
10574
- function extractDirectories(filePaths) {
10575
- const dirs = new Set();
10576
- for (const fp of filePaths) {
10577
- const dir = posix.dirname(fp);
10578
- if (dir !== '.' && dir !== '/') {
10579
- dirs.add(dir);
10580
- }
10581
- }
10582
- return [...dirs];
10583
- }
10584
- /**
10585
- * Run the auto-seed pass: apply policy rules and create missing metas.
10586
- *
10587
- * @param rules - Auto-seed policy rules from config.
10588
- * @param watcher - Watcher client for filesystem enumeration.
10589
- * @param logger - Logger for reporting seed actions.
10590
- * @returns Summary of what was seeded.
10591
- */
10592
- async function autoSeedPass(rules, watcher, logger) {
10593
- if (rules.length === 0)
10594
- return { seeded: 0, paths: [] };
10595
- // Build a map of ownerPath → effective options (last match wins)
10596
- const candidates = new Map();
10597
- for (const rule of rules) {
10598
- const files = await watcher.walk([rule.match]);
10599
- const dirs = extractDirectories(files);
10600
- for (const dir of dirs) {
10601
- candidates.set(dir, {
10602
- steer: rule.steer,
10603
- crossRefs: rule.crossRefs,
10604
- });
10605
- }
10606
- }
10607
- // Filter out paths that already have .meta/meta.json
10608
- const toSeed = [];
10609
- for (const [path, opts] of candidates) {
10610
- if (!metaExists(path)) {
10611
- toSeed.push({ path, ...opts });
10612
- }
10613
- }
10614
- // Seed remaining
10615
- const seededPaths = [];
10616
- for (const candidate of toSeed) {
10617
- try {
10618
- await createMeta(candidate.path, {
10619
- steer: candidate.steer,
10620
- crossRefs: candidate.crossRefs,
10621
- });
10622
- seededPaths.push(candidate.path);
10623
- logger?.info({ path: candidate.path }, 'auto-seeded meta');
10624
- }
10625
- catch (err) {
10626
- logger?.warn({
10627
- path: candidate.path,
10628
- err: err instanceof Error ? err.message : String(err),
10629
- }, 'auto-seed failed for path');
10630
- }
10631
- }
10632
- return { seeded: seededPaths.length, paths: seededPaths };
10633
- }
10634
-
10635
- /**
10636
- * Croner-based scheduler that discovers the stalest meta candidate each tick
10637
- * and enqueues it for synthesis.
10638
- *
10639
- * @module scheduler
10640
- */
10641
- const MAX_BACKOFF_MULTIPLIER = 4;
10642
- /**
10643
- * Periodic scheduler that discovers stale meta candidates and enqueues them.
10644
- *
10645
- * Supports adaptive backoff when no candidates are found and hot-reloadable
10646
- * cron expressions via {@link Scheduler.updateSchedule}.
10647
- */
10648
- class Scheduler {
10649
- job = null;
10650
- backoffMultiplier = 1;
10651
- tickCount = 0;
10652
- config;
10653
- queue;
10654
- logger;
10655
- watcher;
10656
- registrar = null;
10657
- currentExpression;
10658
- constructor(config, queue, logger, watcher) {
10659
- this.config = config;
10660
- this.queue = queue;
10661
- this.logger = logger;
10662
- this.watcher = watcher;
10663
- this.currentExpression = config.schedule;
10664
- }
10665
- /** Set the rule registrar for watcher restart detection. */
10666
- setRegistrar(registrar) {
10667
- this.registrar = registrar;
10668
- }
10669
- /** Start the cron job. */
10670
- start() {
10671
- if (this.job)
10672
- return;
10673
- this.job = new Cron(this.currentExpression, () => {
10674
- void this.tick();
10675
- });
10676
- this.logger.info({ schedule: this.currentExpression }, 'Scheduler started');
10677
- }
10678
- /** Stop the cron job. */
10679
- stop() {
10680
- if (!this.job)
10681
- return;
10682
- this.job.stop();
10683
- this.job = null;
10684
- this.backoffMultiplier = 1;
10685
- this.logger.info('Scheduler stopped');
10686
- }
10687
- /** Hot-reload the cron schedule expression. */
10688
- updateSchedule(expression) {
10689
- this.currentExpression = expression;
10690
- if (this.job) {
10691
- this.job.stop();
10692
- this.job = new Cron(expression, () => {
10693
- void this.tick();
10694
- });
10695
- this.logger.info({ schedule: expression }, 'Schedule updated');
10696
- }
10697
- }
10698
- /** Reset backoff multiplier (call after successful synthesis). */
10699
- resetBackoff() {
10700
- if (this.backoffMultiplier > 1) {
10701
- this.logger.debug('Backoff reset after successful synthesis');
10702
- }
10703
- this.backoffMultiplier = 1;
10704
- }
10705
- /** Whether the scheduler is currently running. */
10706
- get isRunning() {
10707
- return this.job !== null;
10708
- }
10709
- /** Next scheduled tick time, or null if not running. */
10710
- get nextRunAt() {
10711
- if (!this.job)
10712
- return null;
10713
- return this.job.nextRun() ?? null;
10714
- }
10715
- /**
10716
- * Single tick: discover stalest candidate and enqueue it.
10717
- *
10718
- * Skips if the queue is currently processing. Applies adaptive backoff
10719
- * when no candidates are found.
10720
- */
10721
- async tick() {
10722
- this.tickCount++;
10723
- // Apply backoff: skip ticks when backing off
10724
- if (this.backoffMultiplier > 1 &&
10725
- this.tickCount % this.backoffMultiplier !== 0) {
10726
- this.logger.trace({
10727
- backoffMultiplier: this.backoffMultiplier,
10728
- tickCount: this.tickCount,
10729
- }, 'Skipping tick (backoff)');
10730
- return;
10731
- }
10732
- // Auto-seed pass: create .meta/ for matching directories
10733
- if (this.config.autoSeed.length > 0) {
10734
- try {
10735
- const result = await autoSeedPass(this.config.autoSeed, this.watcher, this.logger);
10736
- if (result.seeded > 0) {
10737
- this.logger.info({ seeded: result.seeded }, 'Auto-seed pass completed');
10738
- }
10739
- }
10740
- catch (err) {
10741
- this.logger.warn({ err }, 'Auto-seed pass failed');
10742
- }
10743
- }
10744
- const candidate = await this.discoverStalest();
10745
- if (!candidate) {
10746
- this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
10747
- this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No stale candidates found, increasing backoff');
10748
- return;
10749
- }
10750
- this.queue.enqueue(candidate);
10751
- this.logger.info({ path: candidate }, 'Enqueued stale candidate');
10752
- // Opportunistic watcher restart detection
10753
- if (this.registrar) {
10754
- try {
10755
- const statusRes = await fetch(new URL('/status', this.config.watcherUrl), {
10756
- signal: AbortSignal.timeout(3000),
10757
- });
10758
- if (statusRes.ok) {
10759
- const status = (await statusRes.json());
10760
- if (typeof status.uptime === 'number') {
10761
- await this.registrar.checkAndReregister(status.uptime);
10762
- }
10763
- }
10764
- }
10765
- catch {
10766
- // Watcher unreachable — skip uptime check
10767
- }
10768
- }
10769
- }
10770
- /**
10771
- * Discover the stalest meta candidate via watcher.
10772
- */
10773
- async discoverStalest() {
10774
- try {
10775
- const result = await listMetas(this.config, this.watcher);
10776
- const stale = result.entries
10777
- .filter((e) => e.stalenessSeconds > 0)
10778
- .map((e) => ({
10779
- node: e.node,
10780
- meta: e.meta,
10781
- actualStaleness: e.stalenessSeconds,
10782
- }));
10783
- return discoverStalestPath(stale, this.config.depthWeight);
10784
- }
10785
- catch (err) {
10786
- this.logger.warn({ err }, 'Failed to discover stalest candidate');
10787
- return null;
10788
- }
10789
- }
10790
- }
10791
-
10792
- /**
10793
- * Single-threaded synthesis queue with priority support and deduplication.
10794
- *
10795
- * The scheduler enqueues the stalest candidate each tick. HTTP-triggered
10796
- * synthesis requests get priority (inserted at front). A path appears at
10797
- * most once in the queue; re-triggering returns the current position.
10798
- *
10799
- * @module queue
10800
- */
10801
- const DEPTH_WARNING_THRESHOLD = 3;
10802
- /**
10803
- * Single-threaded synthesis queue.
10804
- *
10805
- * Only one synthesis runs at a time. Priority items are inserted at the
10806
- * front of the queue. Duplicate paths are rejected with their current
10807
- * position returned.
10808
- */
10809
- class SynthesisQueue {
10810
- queue = [];
10811
- currentItem = null;
10812
- processing = false;
10813
- logger;
10814
- onEnqueueCallback = null;
10815
- /**
10816
- * Create a new SynthesisQueue.
10817
- *
10818
- * @param logger - Pino logger instance.
10819
- */
10820
- constructor(logger) {
10821
- this.logger = logger;
10497
+ class SynthesisQueue {
10498
+ queue = [];
10499
+ currentItem = null;
10500
+ processing = false;
10501
+ logger;
10502
+ onEnqueueCallback = null;
10503
+ /**
10504
+ * Create a new SynthesisQueue.
10505
+ *
10506
+ * @param logger - Pino logger instance.
10507
+ */
10508
+ constructor(logger) {
10509
+ this.logger = logger;
10822
10510
  }
10823
10511
  /**
10824
10512
  * Set a callback to invoke when a new (non-duplicate) item is enqueued.
@@ -10973,241 +10661,855 @@ class SynthesisQueue {
10973
10661
  }
10974
10662
 
10975
10663
  /**
10976
- * GET /config query service configuration with optional JSONPath.
10664
+ * Periodic watcher health check for rule registration resilience.
10977
10665
  *
10978
- * Replaces the old GET /config/validate endpoint with the core SDK's
10979
- * `createConfigQueryHandler()` for JSONPath support.
10666
+ * Pings watcher `/status` on a configurable interval, detects restarts
10667
+ * (uptime decrease), and re-registers virtual rules automatically.
10668
+ * Independent of the synthesis scheduler.
10980
10669
  *
10981
- * @module routes/config
10670
+ * @module rules/healthCheck
10982
10671
  */
10983
- /** Return a sanitized copy of the config (redact gatewayApiKey). */
10984
- function sanitizeConfig(config) {
10985
- return {
10986
- ...config,
10987
- gatewayApiKey: config.gatewayApiKey ? '[REDACTED]' : undefined,
10988
- };
10989
- }
10990
- function registerConfigRoute(app, deps) {
10991
- const configHandler = createConfigQueryHandler(() => sanitizeConfig(deps.config));
10992
- app.get('/config', async (request, reply) => {
10993
- const { path } = request.query;
10994
- const result = await configHandler({ path });
10995
- return reply.status(result.status).send(result.body);
10996
- });
10997
- }
10998
-
10999
- /**
11000
- * POST /config/apply — apply a config patch via the core SDK handler.
11001
- *
11002
- * @module routes/configApply
11003
- */
11004
- /** Register the POST /config/apply route. */
11005
- function registerConfigApplyRoute(app) {
11006
- const handler = createConfigApplyHandler(metaDescriptor);
11007
- app.post('/config/apply', async (request, reply) => {
11008
- const result = await handler(request.body);
11009
- return reply.status(result.status).send(result.body);
11010
- });
11011
- }
11012
-
11013
10672
  /**
11014
- * GET /metas list metas with optional filters.
11015
- * GET /metas/:path — single meta detail.
10673
+ * Manages the periodic watcher health check loop.
11016
10674
  *
11017
- * @module routes/metas
10675
+ * Starts a `setInterval` that pings the watcher and delegates
10676
+ * restart detection to `RuleRegistrar.checkAndReregister()`.
11018
10677
  */
11019
- const metasQuerySchema = z.object({
11020
- pathPrefix: z.string().optional(),
11021
- hasError: z
11022
- .enum(['true', 'false'])
11023
- .transform((v) => v === 'true')
11024
- .optional(),
11025
- staleHours: z
11026
- .string()
11027
- .transform(Number)
11028
- .pipe(z.number().positive())
11029
- .optional(),
11030
- neverSynthesized: z
11031
- .enum(['true', 'false'])
11032
- .transform((v) => v === 'true')
11033
- .optional(),
11034
- locked: z
11035
- .enum(['true', 'false'])
11036
- .transform((v) => v === 'true')
11037
- .optional(),
11038
- fields: z.string().optional(),
11039
- });
11040
- const metaDetailQuerySchema = z.object({
11041
- fields: z.string().optional(),
11042
- includeArchive: z
11043
- .union([
11044
- z.enum(['true', 'false']).transform((v) => v === 'true'),
11045
- z.string().transform(Number).pipe(z.number().int().nonnegative()),
11046
- ])
11047
- .optional(),
11048
- });
11049
- function registerMetasRoutes(app, deps) {
11050
- app.get('/metas', async (request) => {
11051
- const query = metasQuerySchema.parse(request.query);
11052
- const { config, watcher } = deps;
11053
- const result = await listMetas(config, watcher);
11054
- let entries = result.entries;
11055
- // Apply filters
11056
- if (query.pathPrefix) {
11057
- entries = entries.filter((e) => e.path.includes(query.pathPrefix));
11058
- }
11059
- if (query.hasError !== undefined) {
11060
- entries = entries.filter((e) => e.hasError === query.hasError);
11061
- }
11062
- if (query.neverSynthesized !== undefined) {
11063
- entries = entries.filter((e) => (e.lastSynthesized === null) === query.neverSynthesized);
10678
+ class WatcherHealthCheck {
10679
+ watcherUrl;
10680
+ intervalMs;
10681
+ registrar;
10682
+ logger;
10683
+ handle = null;
10684
+ constructor(opts) {
10685
+ this.watcherUrl = opts.watcherUrl.replace(/\/+$/, '');
10686
+ this.intervalMs = opts.intervalMs;
10687
+ this.registrar = opts.registrar;
10688
+ this.logger = opts.logger;
10689
+ }
10690
+ /** Start the periodic health check. No-op if intervalMs is 0. */
10691
+ start() {
10692
+ if (this.intervalMs <= 0) {
10693
+ this.logger.info('Watcher health check disabled (interval = 0)');
10694
+ return;
11064
10695
  }
11065
- if (query.locked !== undefined) {
11066
- entries = entries.filter((e) => e.locked === query.locked);
10696
+ this.handle = setInterval(() => {
10697
+ void this.check();
10698
+ }, this.intervalMs);
10699
+ // Don't prevent process exit
10700
+ if (typeof this.handle === 'object' && 'unref' in this.handle) {
10701
+ this.handle.unref();
11067
10702
  }
11068
- if (typeof query.staleHours === 'number') {
11069
- entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
10703
+ this.logger.info({ intervalMs: this.intervalMs }, 'Watcher health check started');
10704
+ }
10705
+ /** Stop the periodic health check. */
10706
+ stop() {
10707
+ if (this.handle) {
10708
+ clearInterval(this.handle);
10709
+ this.handle = null;
11070
10710
  }
11071
- // Summary (computed from filtered entries)
11072
- const summary = computeSummary(entries, config.depthWeight);
11073
- // Field projection
11074
- const fieldList = query.fields?.split(',');
11075
- const defaultFields = [
11076
- 'path',
11077
- 'depth',
11078
- 'emphasis',
11079
- 'stalenessSeconds',
11080
- 'lastSynthesized',
11081
- 'hasError',
11082
- 'locked',
11083
- 'architectTokens',
11084
- 'builderTokens',
11085
- 'criticTokens',
11086
- ];
11087
- const projectedFields = fieldList ?? defaultFields;
11088
- const metas = entries.map((e) => {
11089
- const full = {
11090
- path: e.path,
11091
- depth: e.depth,
11092
- emphasis: e.emphasis,
11093
- stalenessSeconds: e.stalenessSeconds === Infinity
11094
- ? null
11095
- : Math.round(e.stalenessSeconds),
11096
- lastSynthesized: e.lastSynthesized,
11097
- hasError: e.hasError,
11098
- locked: e.locked,
11099
- architectTokens: e.architectTokens,
11100
- builderTokens: e.builderTokens,
11101
- criticTokens: e.criticTokens,
11102
- };
11103
- const projected = {};
11104
- for (const f of projectedFields) {
11105
- if (f in full)
11106
- projected[f] = full[f];
11107
- }
11108
- return projected;
11109
- });
11110
- return { summary, metas };
11111
- });
11112
- app.get('/metas/:path', async (request, reply) => {
11113
- const query = metaDetailQuerySchema.parse(request.query);
11114
- const { config, watcher } = deps;
11115
- const targetPath = normalizePath(decodeURIComponent(request.params.path));
11116
- const result = await listMetas(config, watcher);
11117
- const targetNode = findNode(result.tree, targetPath);
11118
- if (!targetNode) {
11119
- return reply.status(404).send({
11120
- error: 'NOT_FOUND',
11121
- message: 'Meta path not found: ' + targetPath,
10711
+ }
10712
+ /** Single health check iteration. */
10713
+ async check() {
10714
+ try {
10715
+ const res = await fetch(this.watcherUrl + '/status', {
10716
+ signal: AbortSignal.timeout(5000),
11122
10717
  });
11123
- }
11124
- const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
11125
- // Field projection
11126
- const defaultExclude = new Set([
11127
- '_architect',
11128
- '_builder',
11129
- '_critic',
11130
- '_content',
11131
- '_feedback',
11132
- ]);
11133
- const fieldList = query.fields?.split(',');
11134
- const projectMeta = (m) => {
11135
- if (fieldList) {
11136
- const r = {};
11137
- for (const f of fieldList)
11138
- r[f] = m[f];
11139
- return r;
10718
+ if (!res.ok) {
10719
+ this.logger.warn({ status: res.status }, 'Watcher health check: non-OK response');
10720
+ return;
11140
10721
  }
11141
- const r = {};
11142
- for (const [k, v] of Object.entries(m)) {
11143
- if (!defaultExclude.has(k))
11144
- r[k] = v;
10722
+ const data = (await res.json());
10723
+ // If rules were never successfully registered (startup failure),
10724
+ // attempt registration now that the watcher is reachable.
10725
+ if (!this.registrar.isRegistered) {
10726
+ this.logger.info('Rules not registered — attempting registration');
10727
+ await this.registrar.register();
11145
10728
  }
11146
- return r;
11147
- };
11148
- // Compute scope
11149
- const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
11150
- // Compute staleness
11151
- const metaTyped = meta;
11152
- const staleSeconds = metaTyped._generatedAt
11153
- ? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
11154
- : null;
11155
- const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
11156
- const response = {
11157
- path: targetNode.metaPath,
11158
- meta: projectMeta(meta),
11159
- scope: {
11160
- ownedFiles: scopeFiles.length,
11161
- childMetas: targetNode.children.length,
11162
- totalFiles: allFiles.length,
11163
- },
11164
- staleness: {
11165
- seconds: staleSeconds,
11166
- score: Math.round(score * 100) / 100,
11167
- },
11168
- };
11169
- // Cross-refs status
11170
- const crossRefsRaw = meta._crossRefs;
11171
- if (Array.isArray(crossRefsRaw) && crossRefsRaw.length > 0) {
11172
- response.crossRefs = await Promise.all(crossRefsRaw.map(async (refPath) => {
11173
- const rp = String(refPath);
11174
- const refMetaFile = join(rp, '.meta', 'meta.json');
11175
- try {
11176
- const refMeta = JSON.parse(await readFile(refMetaFile, 'utf8'));
11177
- return {
11178
- path: rp,
11179
- status: 'resolved',
11180
- hasContent: Boolean(refMeta._content),
11181
- };
11182
- }
11183
- catch {
11184
- return { path: rp, status: 'missing' };
11185
- }
11186
- }));
10729
+ await this.registrar.checkAndReregister(data.uptime);
11187
10730
  }
11188
- // Archive
11189
- if (query.includeArchive) {
11190
- const limit = typeof query.includeArchive === 'number'
11191
- ? query.includeArchive
11192
- : undefined;
11193
- try {
11194
- response.archive = await readArchiveFromWatcher(watcher, targetNode.metaPath, config.metaArchiveProperty, limit, projectMeta);
11195
- }
11196
- catch {
11197
- response.archive = await readArchiveFromDisk(targetNode.metaPath, limit, projectMeta);
11198
- }
10731
+ catch (err) {
10732
+ this.logger.debug({ err }, 'Watcher health check: unreachable (expected during startup)');
11199
10733
  }
11200
- return response;
11201
- });
10734
+ }
11202
10735
  }
11203
10736
 
11204
10737
  /**
11205
- * GET /preview dry-run synthesis preview.
10738
+ * Virtual rule registration with jeeves-watcher.
11206
10739
  *
11207
- * @module routes/preview
10740
+ * Service registers inference rules at startup (with retry) and
10741
+ * re-registers opportunistically when watcher restart is detected.
10742
+ *
10743
+ * @module rules
11208
10744
  */
11209
- function registerPreviewRoute(app, deps) {
11210
- app.get('/preview', async (request, reply) => {
10745
+ const SOURCE = 'jeeves-meta';
10746
+ const MAX_RETRIES = 10;
10747
+ const RETRY_BASE_MS = 2000;
10748
+ /**
10749
+ * Convert a `Record<string, unknown>` config property into watcher
10750
+ * schema `set` directives: `{ key: { set: value } }` per entry.
10751
+ */
10752
+ function toSchemaSetDirectives(props) {
10753
+ return Object.fromEntries(Object.entries(props).map(([k, v]) => [k, { set: v }]));
10754
+ }
10755
+ /** Build the three virtual rule definitions. */
10756
+ function buildMetaRules(config) {
10757
+ return [
10758
+ {
10759
+ name: 'meta-current',
10760
+ description: 'Live jeeves-meta .meta/meta.json files',
10761
+ match: {
10762
+ properties: {
10763
+ file: {
10764
+ properties: {
10765
+ path: { type: 'string', glob: '**/.meta/meta.json' },
10766
+ },
10767
+ },
10768
+ },
10769
+ },
10770
+ schema: [
10771
+ 'base',
10772
+ {
10773
+ properties: {
10774
+ ...toSchemaSetDirectives(config.metaProperty),
10775
+ meta_id: { type: 'string', set: '{{json._id}}' },
10776
+ meta_steer: { type: 'string', set: '{{json._steer}}' },
10777
+ meta_depth: { type: 'number', set: '{{json._depth}}' },
10778
+ meta_emphasis: { type: 'number', set: '{{json._emphasis}}' },
10779
+ meta_synthesis_count: {
10780
+ type: 'integer',
10781
+ set: '{{json._synthesisCount}}',
10782
+ },
10783
+ meta_structure_hash: {
10784
+ type: 'string',
10785
+ set: '{{json._structureHash}}',
10786
+ },
10787
+ meta_architect_tokens: {
10788
+ type: 'integer',
10789
+ set: '{{json._architectTokens}}',
10790
+ },
10791
+ meta_builder_tokens: {
10792
+ type: 'integer',
10793
+ set: '{{json._builderTokens}}',
10794
+ },
10795
+ meta_critic_tokens: {
10796
+ type: 'integer',
10797
+ set: '{{json._criticTokens}}',
10798
+ },
10799
+ meta_error_step: {
10800
+ type: 'string',
10801
+ set: '{{json._error.step}}',
10802
+ },
10803
+ generated_at: {
10804
+ type: 'string',
10805
+ set: '{{json._generatedAt}}',
10806
+ },
10807
+ generated_at_unix: {
10808
+ type: 'integer',
10809
+ set: '{{toUnix json._generatedAt}}',
10810
+ },
10811
+ has_error: {
10812
+ type: 'boolean',
10813
+ set: '{{#if json._error}}true{{else}}false{{/if}}',
10814
+ },
10815
+ },
10816
+ },
10817
+ ],
10818
+ render: {
10819
+ frontmatter: [
10820
+ 'meta_id',
10821
+ 'generated_at',
10822
+ '*',
10823
+ '!_*',
10824
+ '!json',
10825
+ '!file',
10826
+ '!has_error',
10827
+ ],
10828
+ body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
10829
+ },
10830
+ renderAs: 'md',
10831
+ },
10832
+ {
10833
+ name: 'meta-archive',
10834
+ description: 'Archived jeeves-meta .meta/archive snapshots',
10835
+ match: {
10836
+ properties: {
10837
+ file: {
10838
+ properties: {
10839
+ path: { type: 'string', glob: '**/.meta/archive/*.json' },
10840
+ },
10841
+ },
10842
+ },
10843
+ },
10844
+ schema: [
10845
+ 'base',
10846
+ {
10847
+ properties: {
10848
+ ...toSchemaSetDirectives(config.metaArchiveProperty),
10849
+ meta_id: { type: 'string', set: '{{json._id}}' },
10850
+ archived: { type: 'boolean', set: 'true' },
10851
+ archived_at: { type: 'string', set: '{{json._archivedAt}}' },
10852
+ },
10853
+ },
10854
+ ],
10855
+ render: {
10856
+ frontmatter: ['meta_id', 'archived', 'archived_at'],
10857
+ body: [
10858
+ {
10859
+ path: 'json._content',
10860
+ heading: 1,
10861
+ label: 'Synthesis (archived)',
10862
+ },
10863
+ ],
10864
+ },
10865
+ renderAs: 'md',
10866
+ },
10867
+ {
10868
+ name: 'meta-config',
10869
+ description: 'jeeves-meta configuration file',
10870
+ match: {
10871
+ properties: {
10872
+ file: {
10873
+ properties: {
10874
+ path: { type: 'string', glob: '**/jeeves-meta.config.json' },
10875
+ },
10876
+ },
10877
+ },
10878
+ },
10879
+ schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
10880
+ render: {
10881
+ frontmatter: [
10882
+ 'watcherUrl',
10883
+ 'gatewayUrl',
10884
+ 'architectEvery',
10885
+ 'depthWeight',
10886
+ 'maxArchive',
10887
+ 'maxLines',
10888
+ ],
10889
+ body: [
10890
+ {
10891
+ path: 'json.defaultArchitect',
10892
+ heading: 2,
10893
+ label: 'Default Architect Prompt',
10894
+ },
10895
+ {
10896
+ path: 'json.defaultCritic',
10897
+ heading: 2,
10898
+ label: 'Default Critic Prompt',
10899
+ },
10900
+ ],
10901
+ },
10902
+ renderAs: 'md',
10903
+ },
10904
+ ];
10905
+ }
10906
+ /**
10907
+ * Manages virtual rule registration with watcher.
10908
+ *
10909
+ * - Registers at startup with exponential retry
10910
+ * - Tracks watcher uptime for restart detection
10911
+ * - Re-registers opportunistically when uptime decreases
10912
+ */
10913
+ class RuleRegistrar {
10914
+ config;
10915
+ logger;
10916
+ watcherClient;
10917
+ lastWatcherUptime = null;
10918
+ registered = false;
10919
+ constructor(config, logger, watcher) {
10920
+ this.config = config;
10921
+ this.logger = logger;
10922
+ this.watcherClient = watcher;
10923
+ }
10924
+ /** Whether rules have been successfully registered. */
10925
+ get isRegistered() {
10926
+ return this.registered;
10927
+ }
10928
+ /**
10929
+ * Register rules with watcher. Retries with exponential backoff.
10930
+ * Non-blocking — logs errors but never throws.
10931
+ */
10932
+ async register() {
10933
+ const rules = buildMetaRules(this.config);
10934
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
10935
+ try {
10936
+ await this.watcherClient.registerRules(SOURCE, rules);
10937
+ this.registered = true;
10938
+ this.logger.info('Virtual rules registered with watcher');
10939
+ return;
10940
+ }
10941
+ catch (err) {
10942
+ const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
10943
+ this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
10944
+ await new Promise((r) => setTimeout(r, delayMs));
10945
+ }
10946
+ }
10947
+ this.logger.error('Rule registration failed after max retries — service degraded');
10948
+ }
10949
+ /**
10950
+ * Check watcher uptime and re-register if it decreased (restart detected).
10951
+ *
10952
+ * @param currentUptime - Current watcher uptime in seconds.
10953
+ */
10954
+ async checkAndReregister(currentUptime) {
10955
+ if (this.lastWatcherUptime !== null &&
10956
+ currentUptime < this.lastWatcherUptime) {
10957
+ this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
10958
+ this.registered = false;
10959
+ await this.register();
10960
+ }
10961
+ this.lastWatcherUptime = currentUptime;
10962
+ }
10963
+ }
10964
+
10965
+ /**
10966
+ * Post-registration verification of virtual rule application.
10967
+ *
10968
+ * After rules are registered with the watcher, verifies that .meta/meta.json
10969
+ * files are discoverable via watcher walk (which depends on virtual rules
10970
+ * being applied). Logs a warning if expected metas are not found.
10971
+ *
10972
+ * @module rules/verify
10973
+ */
10974
+ /**
10975
+ * Verify that virtual rules are applied to indexed .meta/meta.json files.
10976
+ *
10977
+ * Runs a discovery pass and logs the result. If no metas are found but
10978
+ * the filesystem likely has some, logs a warning suggesting reindex.
10979
+ *
10980
+ * @param watcher - WatcherClient for discovery.
10981
+ * @param logger - Logger for reporting results.
10982
+ * @returns Number of metas discovered.
10983
+ */
10984
+ async function verifyRuleApplication(watcher, logger) {
10985
+ try {
10986
+ const metaPaths = await discoverMetas(watcher);
10987
+ if (metaPaths.length === 0) {
10988
+ logger.warn({ count: 0 }, 'Post-registration verification: no .meta/meta.json files found via watcher walk. ' +
10989
+ 'Virtual rules may not be applied to indexed files. ' +
10990
+ 'If metas exist, a path-scoped reindex may be needed.');
10991
+ }
10992
+ else {
10993
+ logger.info({ count: metaPaths.length }, 'Post-registration verification: metas discoverable');
10994
+ }
10995
+ return metaPaths.length;
10996
+ }
10997
+ catch (err) {
10998
+ logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'Post-registration verification failed (watcher may be unavailable)');
10999
+ return 0;
11000
+ }
11001
+ }
11002
+
11003
+ /**
11004
+ * Core seed logic — create a .meta/ directory with initial meta.json.
11005
+ *
11006
+ * Shared between the POST /seed route handler and the auto-seed pass.
11007
+ *
11008
+ * @module seed/createMeta
11009
+ */
11010
+ /**
11011
+ * Create a .meta/ directory with an initial meta.json.
11012
+ *
11013
+ * Does NOT check for existing .meta/ — caller is responsible for that guard.
11014
+ *
11015
+ * @param ownerPath - The owner directory path.
11016
+ * @param options - Optional cross-refs and steering prompt.
11017
+ * @returns The meta directory path and generated ID.
11018
+ */
11019
+ async function createMeta(ownerPath, options) {
11020
+ const metaDir = resolveMetaDir(ownerPath);
11021
+ await mkdir(metaDir, { recursive: true });
11022
+ const _id = randomUUID();
11023
+ const metaJson = { _id };
11024
+ if (options?.crossRefs !== undefined)
11025
+ metaJson._crossRefs = options.crossRefs;
11026
+ if (options?.steer !== undefined)
11027
+ metaJson._steer = options.steer;
11028
+ const metaJsonPath = join(metaDir, 'meta.json');
11029
+ await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
11030
+ return { metaDir, _id };
11031
+ }
11032
+ /**
11033
+ * Check if a .meta/ directory already exists for an owner path.
11034
+ *
11035
+ * @param ownerPath - The owner directory path.
11036
+ * @returns True if .meta/ already exists.
11037
+ */
11038
+ function metaExists(ownerPath) {
11039
+ return existsSync(resolveMetaDir(ownerPath));
11040
+ }
11041
+
11042
+ /**
11043
+ * Auto-seed pass — scan for directories matching policy rules and seed them.
11044
+ *
11045
+ * Runs before discovery in each scheduler tick. For each auto-seed rule,
11046
+ * walks matching directories via the watcher and creates .meta/ directories
11047
+ * for those that don't already have one.
11048
+ *
11049
+ * Rules are processed in array order; last match wins for steer/crossRefs.
11050
+ *
11051
+ * @module seed/autoSeed
11052
+ */
11053
+ /**
11054
+ * Extract parent directory paths from watcher walk results.
11055
+ *
11056
+ * Walk returns file paths; we need the unique set of immediate parent
11057
+ * directories that could be owners.
11058
+ */
11059
+ function extractDirectories(filePaths) {
11060
+ const dirs = new Set();
11061
+ for (const fp of filePaths) {
11062
+ const dir = posix.dirname(fp);
11063
+ if (dir !== '.' && dir !== '/') {
11064
+ dirs.add(dir);
11065
+ }
11066
+ }
11067
+ return [...dirs];
11068
+ }
11069
+ /**
11070
+ * Run the auto-seed pass: apply policy rules and create missing metas.
11071
+ *
11072
+ * @param rules - Auto-seed policy rules from config.
11073
+ * @param watcher - Watcher client for filesystem enumeration.
11074
+ * @param logger - Logger for reporting seed actions.
11075
+ * @returns Summary of what was seeded.
11076
+ */
11077
+ async function autoSeedPass(rules, watcher, logger) {
11078
+ if (rules.length === 0)
11079
+ return { seeded: 0, paths: [] };
11080
+ // Build a map of ownerPath → effective options (last match wins)
11081
+ const candidates = new Map();
11082
+ for (const rule of rules) {
11083
+ const files = await watcher.walk([rule.match]);
11084
+ const dirs = extractDirectories(files);
11085
+ for (const dir of dirs) {
11086
+ candidates.set(dir, {
11087
+ steer: rule.steer,
11088
+ crossRefs: rule.crossRefs,
11089
+ });
11090
+ }
11091
+ }
11092
+ // Filter out paths that already have .meta/meta.json
11093
+ const toSeed = [];
11094
+ for (const [path, opts] of candidates) {
11095
+ if (!metaExists(path)) {
11096
+ toSeed.push({ path, ...opts });
11097
+ }
11098
+ }
11099
+ // Seed remaining
11100
+ const seededPaths = [];
11101
+ for (const candidate of toSeed) {
11102
+ try {
11103
+ await createMeta(candidate.path, {
11104
+ steer: candidate.steer,
11105
+ crossRefs: candidate.crossRefs,
11106
+ });
11107
+ seededPaths.push(candidate.path);
11108
+ logger?.info({ path: candidate.path }, 'auto-seeded meta');
11109
+ }
11110
+ catch (err) {
11111
+ logger?.warn({
11112
+ path: candidate.path,
11113
+ err: err instanceof Error ? err.message : String(err),
11114
+ }, 'auto-seed failed for path');
11115
+ }
11116
+ }
11117
+ return { seeded: seededPaths.length, paths: seededPaths };
11118
+ }
11119
+
11120
+ /**
11121
+ * Croner-based scheduler that discovers the stalest meta candidate each tick
11122
+ * and enqueues it for synthesis.
11123
+ *
11124
+ * @module scheduler
11125
+ */
11126
+ const MAX_BACKOFF_MULTIPLIER = 4;
11127
+ /**
11128
+ * Periodic scheduler that discovers stale meta candidates and enqueues them.
11129
+ *
11130
+ * Supports adaptive backoff when no candidates are found and hot-reloadable
11131
+ * cron expressions via {@link Scheduler.updateSchedule}.
11132
+ */
11133
+ class Scheduler {
11134
+ job = null;
11135
+ backoffMultiplier = 1;
11136
+ tickCount = 0;
11137
+ config;
11138
+ queue;
11139
+ logger;
11140
+ watcher;
11141
+ registrar = null;
11142
+ currentExpression;
11143
+ constructor(config, queue, logger, watcher) {
11144
+ this.config = config;
11145
+ this.queue = queue;
11146
+ this.logger = logger;
11147
+ this.watcher = watcher;
11148
+ this.currentExpression = config.schedule;
11149
+ }
11150
+ /** Set the rule registrar for watcher restart detection. */
11151
+ setRegistrar(registrar) {
11152
+ this.registrar = registrar;
11153
+ }
11154
+ /** Start the cron job. */
11155
+ start() {
11156
+ if (this.job)
11157
+ return;
11158
+ this.job = new Cron(this.currentExpression, () => {
11159
+ void this.tick();
11160
+ });
11161
+ this.logger.info({ schedule: this.currentExpression }, 'Scheduler started');
11162
+ }
11163
+ /** Stop the cron job. */
11164
+ stop() {
11165
+ if (!this.job)
11166
+ return;
11167
+ this.job.stop();
11168
+ this.job = null;
11169
+ this.backoffMultiplier = 1;
11170
+ this.logger.info('Scheduler stopped');
11171
+ }
11172
+ /** Hot-reload the cron schedule expression. */
11173
+ updateSchedule(expression) {
11174
+ this.currentExpression = expression;
11175
+ if (this.job) {
11176
+ this.job.stop();
11177
+ this.job = new Cron(expression, () => {
11178
+ void this.tick();
11179
+ });
11180
+ this.logger.info({ schedule: expression }, 'Schedule updated');
11181
+ }
11182
+ }
11183
+ /** Reset backoff multiplier (call after successful synthesis). */
11184
+ resetBackoff() {
11185
+ if (this.backoffMultiplier > 1) {
11186
+ this.logger.debug('Backoff reset after successful synthesis');
11187
+ }
11188
+ this.backoffMultiplier = 1;
11189
+ }
11190
+ /** Whether the scheduler is currently running. */
11191
+ get isRunning() {
11192
+ return this.job !== null;
11193
+ }
11194
+ /** Next scheduled tick time, or null if not running. */
11195
+ get nextRunAt() {
11196
+ if (!this.job)
11197
+ return null;
11198
+ return this.job.nextRun() ?? null;
11199
+ }
11200
+ /**
11201
+ * Single tick: discover stalest candidate and enqueue it.
11202
+ *
11203
+ * Skips if the queue is currently processing. Applies adaptive backoff
11204
+ * when no candidates are found.
11205
+ */
11206
+ async tick() {
11207
+ this.tickCount++;
11208
+ // Apply backoff: skip ticks when backing off
11209
+ if (this.backoffMultiplier > 1 &&
11210
+ this.tickCount % this.backoffMultiplier !== 0) {
11211
+ this.logger.trace({
11212
+ backoffMultiplier: this.backoffMultiplier,
11213
+ tickCount: this.tickCount,
11214
+ }, 'Skipping tick (backoff)');
11215
+ return;
11216
+ }
11217
+ // Auto-seed pass: create .meta/ for matching directories
11218
+ if (this.config.autoSeed.length > 0) {
11219
+ try {
11220
+ const result = await autoSeedPass(this.config.autoSeed, this.watcher, this.logger);
11221
+ if (result.seeded > 0) {
11222
+ this.logger.info({ seeded: result.seeded }, 'Auto-seed pass completed');
11223
+ }
11224
+ }
11225
+ catch (err) {
11226
+ this.logger.warn({ err }, 'Auto-seed pass failed');
11227
+ }
11228
+ }
11229
+ const candidate = await this.discoverStalest();
11230
+ if (!candidate) {
11231
+ this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
11232
+ this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No stale candidates found, increasing backoff');
11233
+ return;
11234
+ }
11235
+ this.queue.enqueue(candidate);
11236
+ this.logger.info({ path: candidate }, 'Enqueued stale candidate');
11237
+ // Opportunistic watcher restart detection
11238
+ if (this.registrar) {
11239
+ try {
11240
+ const statusRes = await fetch(new URL('/status', this.config.watcherUrl), {
11241
+ signal: AbortSignal.timeout(3000),
11242
+ });
11243
+ if (statusRes.ok) {
11244
+ const status = (await statusRes.json());
11245
+ if (typeof status.uptime === 'number') {
11246
+ await this.registrar.checkAndReregister(status.uptime);
11247
+ }
11248
+ }
11249
+ }
11250
+ catch {
11251
+ // Watcher unreachable — skip uptime check
11252
+ }
11253
+ }
11254
+ }
11255
+ /**
11256
+ * Discover the stalest meta candidate via watcher.
11257
+ */
11258
+ async discoverStalest() {
11259
+ try {
11260
+ const result = await listMetas(this.config, this.watcher);
11261
+ const stale = result.entries
11262
+ .filter((e) => e.stalenessSeconds > 0)
11263
+ .map((e) => ({
11264
+ node: e.node,
11265
+ meta: e.meta,
11266
+ actualStaleness: e.stalenessSeconds,
11267
+ }));
11268
+ return discoverStalestPath(stale, this.config.depthWeight);
11269
+ }
11270
+ catch (err) {
11271
+ this.logger.warn({ err }, 'Failed to discover stalest candidate');
11272
+ return null;
11273
+ }
11274
+ }
11275
+ }
11276
+
11277
+ /**
11278
+ * GET /config — query service configuration with optional JSONPath.
11279
+ *
11280
+ * Replaces the old GET /config/validate endpoint with the core SDK's
11281
+ * `createConfigQueryHandler()` for JSONPath support.
11282
+ *
11283
+ * @module routes/config
11284
+ */
11285
+ /** Return a sanitized copy of the config (redact gatewayApiKey). */
11286
+ function sanitizeConfig(config) {
11287
+ return {
11288
+ ...config,
11289
+ gatewayApiKey: config.gatewayApiKey ? '[REDACTED]' : undefined,
11290
+ };
11291
+ }
11292
+ function registerConfigRoute(app, deps) {
11293
+ const configHandler = createConfigQueryHandler(() => sanitizeConfig(deps.config));
11294
+ app.get('/config', async (request, reply) => {
11295
+ const { path } = request.query;
11296
+ const result = await configHandler({ path });
11297
+ return reply.status(result.status).send(result.body);
11298
+ });
11299
+ }
11300
+
11301
+ /**
11302
+ * POST /config/apply — apply a config patch via the core SDK handler.
11303
+ *
11304
+ * @module routes/configApply
11305
+ */
11306
+ /** Register the POST /config/apply route. */
11307
+ function registerConfigApplyRoute(app) {
11308
+ const handler = createConfigApplyHandler(metaDescriptor);
11309
+ app.post('/config/apply', async (request, reply) => {
11310
+ const result = await handler(request.body);
11311
+ return reply.status(result.status).send(result.body);
11312
+ });
11313
+ }
11314
+
11315
+ /**
11316
+ * GET /metas — list metas with optional filters.
11317
+ * GET /metas/:path — single meta detail.
11318
+ *
11319
+ * @module routes/metas
11320
+ */
11321
+ const metasQuerySchema = z.object({
11322
+ pathPrefix: z.string().optional(),
11323
+ hasError: z
11324
+ .enum(['true', 'false'])
11325
+ .transform((v) => v === 'true')
11326
+ .optional(),
11327
+ staleHours: z
11328
+ .string()
11329
+ .transform(Number)
11330
+ .pipe(z.number().positive())
11331
+ .optional(),
11332
+ neverSynthesized: z
11333
+ .enum(['true', 'false'])
11334
+ .transform((v) => v === 'true')
11335
+ .optional(),
11336
+ locked: z
11337
+ .enum(['true', 'false'])
11338
+ .transform((v) => v === 'true')
11339
+ .optional(),
11340
+ fields: z.string().optional(),
11341
+ });
11342
+ const metaDetailQuerySchema = z.object({
11343
+ fields: z.string().optional(),
11344
+ includeArchive: z
11345
+ .union([
11346
+ z.enum(['true', 'false']).transform((v) => v === 'true'),
11347
+ z.string().transform(Number).pipe(z.number().int().nonnegative()),
11348
+ ])
11349
+ .optional(),
11350
+ });
11351
+ function registerMetasRoutes(app, deps) {
11352
+ app.get('/metas', async (request) => {
11353
+ const query = metasQuerySchema.parse(request.query);
11354
+ const { config, watcher } = deps;
11355
+ const result = await listMetas(config, watcher);
11356
+ let entries = result.entries;
11357
+ // Apply filters
11358
+ if (query.pathPrefix) {
11359
+ entries = entries.filter((e) => e.path.includes(query.pathPrefix));
11360
+ }
11361
+ if (query.hasError !== undefined) {
11362
+ entries = entries.filter((e) => e.hasError === query.hasError);
11363
+ }
11364
+ if (query.neverSynthesized !== undefined) {
11365
+ entries = entries.filter((e) => (e.lastSynthesized === null) === query.neverSynthesized);
11366
+ }
11367
+ if (query.locked !== undefined) {
11368
+ entries = entries.filter((e) => e.locked === query.locked);
11369
+ }
11370
+ if (typeof query.staleHours === 'number') {
11371
+ entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
11372
+ }
11373
+ // Summary (computed from filtered entries)
11374
+ const summary = computeSummary(entries, config.depthWeight);
11375
+ // Field projection
11376
+ const fieldList = query.fields?.split(',');
11377
+ const defaultFields = [
11378
+ 'path',
11379
+ 'depth',
11380
+ 'emphasis',
11381
+ 'stalenessSeconds',
11382
+ 'lastSynthesized',
11383
+ 'hasError',
11384
+ 'locked',
11385
+ 'architectTokens',
11386
+ 'builderTokens',
11387
+ 'criticTokens',
11388
+ ];
11389
+ const projectedFields = fieldList ?? defaultFields;
11390
+ const metas = entries.map((e) => {
11391
+ const full = {
11392
+ path: e.path,
11393
+ depth: e.depth,
11394
+ emphasis: e.emphasis,
11395
+ stalenessSeconds: e.stalenessSeconds === Infinity
11396
+ ? null
11397
+ : Math.round(e.stalenessSeconds),
11398
+ lastSynthesized: e.lastSynthesized,
11399
+ hasError: e.hasError,
11400
+ locked: e.locked,
11401
+ architectTokens: e.architectTokens,
11402
+ builderTokens: e.builderTokens,
11403
+ criticTokens: e.criticTokens,
11404
+ };
11405
+ const projected = {};
11406
+ for (const f of projectedFields) {
11407
+ if (f in full)
11408
+ projected[f] = full[f];
11409
+ }
11410
+ return projected;
11411
+ });
11412
+ return { summary, metas };
11413
+ });
11414
+ app.get('/metas/:path', async (request, reply) => {
11415
+ const query = metaDetailQuerySchema.parse(request.query);
11416
+ const { config, watcher } = deps;
11417
+ const targetPath = normalizePath(decodeURIComponent(request.params.path));
11418
+ const result = await listMetas(config, watcher);
11419
+ const targetNode = findNode(result.tree, targetPath);
11420
+ if (!targetNode) {
11421
+ return reply.status(404).send({
11422
+ error: 'NOT_FOUND',
11423
+ message: 'Meta path not found: ' + targetPath,
11424
+ });
11425
+ }
11426
+ const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
11427
+ // Field projection
11428
+ const defaultExclude = new Set([
11429
+ '_architect',
11430
+ '_builder',
11431
+ '_critic',
11432
+ '_content',
11433
+ '_feedback',
11434
+ ]);
11435
+ const fieldList = query.fields?.split(',');
11436
+ const projectMeta = (m) => {
11437
+ if (fieldList) {
11438
+ const r = {};
11439
+ for (const f of fieldList)
11440
+ r[f] = m[f];
11441
+ return r;
11442
+ }
11443
+ const r = {};
11444
+ for (const [k, v] of Object.entries(m)) {
11445
+ if (!defaultExclude.has(k))
11446
+ r[k] = v;
11447
+ }
11448
+ return r;
11449
+ };
11450
+ // Compute scope
11451
+ const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
11452
+ // Compute staleness
11453
+ const metaTyped = meta;
11454
+ const staleSeconds = metaTyped._generatedAt
11455
+ ? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
11456
+ : null;
11457
+ const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
11458
+ const response = {
11459
+ path: targetNode.metaPath,
11460
+ meta: projectMeta(meta),
11461
+ scope: {
11462
+ ownedFiles: scopeFiles.length,
11463
+ childMetas: targetNode.children.length,
11464
+ totalFiles: allFiles.length,
11465
+ },
11466
+ staleness: {
11467
+ seconds: staleSeconds,
11468
+ score: Math.round(score * 100) / 100,
11469
+ },
11470
+ };
11471
+ // Cross-refs status
11472
+ const crossRefsRaw = meta._crossRefs;
11473
+ if (Array.isArray(crossRefsRaw) && crossRefsRaw.length > 0) {
11474
+ response.crossRefs = await Promise.all(crossRefsRaw.map(async (refPath) => {
11475
+ const rp = String(refPath);
11476
+ const refMetaFile = join(rp, '.meta', 'meta.json');
11477
+ try {
11478
+ const refMeta = JSON.parse(await readFile(refMetaFile, 'utf8'));
11479
+ return {
11480
+ path: rp,
11481
+ status: 'resolved',
11482
+ hasContent: Boolean(refMeta._content),
11483
+ };
11484
+ }
11485
+ catch {
11486
+ return { path: rp, status: 'missing' };
11487
+ }
11488
+ }));
11489
+ }
11490
+ // Archive
11491
+ if (query.includeArchive) {
11492
+ const limit = typeof query.includeArchive === 'number'
11493
+ ? query.includeArchive
11494
+ : undefined;
11495
+ try {
11496
+ response.archive = await readArchiveFromWatcher(watcher, targetNode.metaPath, config.metaArchiveProperty, limit, projectMeta);
11497
+ }
11498
+ catch {
11499
+ response.archive = await readArchiveFromDisk(targetNode.metaPath, limit, projectMeta);
11500
+ }
11501
+ }
11502
+ return response;
11503
+ });
11504
+ }
11505
+
11506
+ /**
11507
+ * GET /preview — dry-run synthesis preview.
11508
+ *
11509
+ * @module routes/preview
11510
+ */
11511
+ function registerPreviewRoute(app, deps) {
11512
+ app.get('/preview', async (request, reply) => {
11211
11513
  const { config, watcher } = deps;
11212
11514
  const query = request.query;
11213
11515
  let result;
@@ -11375,466 +11677,200 @@ function registerSeedRoute(app, deps) {
11375
11677
  *
11376
11678
  * Uses the core SDK's `createStatusHandler` factory with a custom
11377
11679
  * `getHealth` callback that preserves all existing health details.
11378
- *
11379
- * @module routes/status
11380
- */
11381
- async function checkDependency(url, path) {
11382
- const checkedAt = new Date().toISOString();
11383
- try {
11384
- const res = await fetch(new URL(path, url), {
11385
- signal: AbortSignal.timeout(3000),
11386
- });
11387
- return { url, status: res.ok ? 'ok' : 'error', checkedAt };
11388
- }
11389
- catch {
11390
- return { url, status: 'unreachable', checkedAt };
11391
- }
11392
- }
11393
- /** Check watcher, surfacing initialScan.active as indexing state. */
11394
- async function checkWatcher(url) {
11395
- const checkedAt = new Date().toISOString();
11396
- try {
11397
- const res = await fetch(new URL('/status', url), {
11398
- signal: AbortSignal.timeout(3000),
11399
- });
11400
- if (!res.ok)
11401
- return { url, status: 'error', checkedAt };
11402
- const data = (await res.json());
11403
- const indexing = data.initialScan?.active === true;
11404
- return {
11405
- url,
11406
- status: indexing ? 'indexing' : 'ok',
11407
- checkedAt,
11408
- indexing,
11409
- };
11410
- }
11411
- catch {
11412
- return { url, status: 'unreachable', checkedAt };
11413
- }
11414
- }
11415
- function registerStatusRoute(app, deps) {
11416
- const statusHandler = createStatusHandler({
11417
- name: SERVICE_NAME,
11418
- version: SERVICE_VERSION,
11419
- getHealth: async () => {
11420
- const { config, queue, scheduler, stats } = deps;
11421
- // On-demand dependency checks
11422
- const [watcherHealth, gatewayHealth] = await Promise.all([
11423
- checkWatcher(config.watcherUrl),
11424
- checkDependency(config.gatewayUrl, '/status'),
11425
- ]);
11426
- return {
11427
- currentTarget: queue.current?.path ?? null,
11428
- queue: queue.getState(),
11429
- stats: {
11430
- totalSyntheses: stats.totalSyntheses,
11431
- totalTokens: stats.totalTokens,
11432
- totalErrors: stats.totalErrors,
11433
- lastCycleDurationMs: stats.lastCycleDurationMs,
11434
- lastCycleAt: stats.lastCycleAt,
11435
- },
11436
- schedule: {
11437
- expression: config.schedule,
11438
- nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
11439
- },
11440
- dependencies: {
11441
- watcher: {
11442
- ...watcherHealth,
11443
- rulesRegistered: deps.registrar?.isRegistered ?? false,
11444
- },
11445
- gateway: gatewayHealth,
11446
- },
11447
- };
11448
- },
11449
- });
11450
- app.get('/status', async (_request, reply) => {
11451
- const result = await statusHandler();
11452
- return reply.status(result.status).send(result.body);
11453
- });
11454
- }
11455
-
11456
- /**
11457
- * POST /synthesize route handler.
11458
- *
11459
- * @module routes/synthesize
11460
- */
11461
- const synthesizeBodySchema = z.object({
11462
- path: z.string().optional(),
11463
- });
11464
- /** Register the POST /synthesize route. */
11465
- function registerSynthesizeRoute(app, deps) {
11466
- app.post('/synthesize', async (request, reply) => {
11467
- const body = synthesizeBodySchema.parse(request.body);
11468
- const { config, watcher, queue } = deps;
11469
- let targetPath;
11470
- if (body.path) {
11471
- targetPath = body.path;
11472
- }
11473
- else {
11474
- // Discover stalest candidate
11475
- let result;
11476
- try {
11477
- result = await listMetas(config, watcher);
11478
- }
11479
- catch {
11480
- return reply.status(503).send({
11481
- error: 'SERVICE_UNAVAILABLE',
11482
- message: 'Watcher unreachable — cannot discover candidates',
11483
- });
11484
- }
11485
- const stale = result.entries
11486
- .filter((e) => e.stalenessSeconds > 0)
11487
- .map((e) => ({
11488
- node: e.node,
11489
- meta: e.meta,
11490
- actualStaleness: e.stalenessSeconds,
11491
- }));
11492
- const stalest = discoverStalestPath(stale, config.depthWeight);
11493
- if (!stalest) {
11494
- return reply.code(200).send({
11495
- status: 'skipped',
11496
- message: 'No stale metas found. Nothing to synthesize.',
11497
- });
11498
- }
11499
- targetPath = stalest;
11500
- }
11501
- const result = queue.enqueue(targetPath, body.path !== undefined);
11502
- return reply.code(202).send({
11503
- status: 'accepted',
11504
- path: targetPath,
11505
- queuePosition: result.position,
11506
- alreadyQueued: result.alreadyQueued,
11507
- });
11508
- });
11509
- }
11510
-
11511
- /**
11512
- * POST /unlock — remove .lock from a .meta/ directory.
11513
- *
11514
- * @module routes/unlock
11515
- */
11516
- const unlockBodySchema = z.object({
11517
- path: z.string().min(1),
11518
- });
11519
- function registerUnlockRoute(app, deps) {
11520
- app.post('/unlock', (request, reply) => {
11521
- const body = unlockBodySchema.parse(request.body);
11522
- const metaDir = resolveMetaDir(body.path);
11523
- const lockPath = join(metaDir, '.lock');
11524
- if (!existsSync(lockPath)) {
11525
- return reply.status(409).send({
11526
- error: 'ALREADY_UNLOCKED',
11527
- message: `No lock file at ${body.path} (already unlocked)`,
11528
- });
11529
- }
11530
- deps.logger.info({ lockPath }, 'removing lock file');
11531
- unlinkSync(lockPath);
11532
- return reply.status(200).send({
11533
- status: 'unlocked',
11534
- path: body.path,
11535
- });
11536
- });
11537
- }
11538
-
11539
- /**
11540
- * Route registration for jeeves-meta service.
11541
- *
11542
- * @module routes
11543
- */
11544
- /** Register all HTTP routes on the Fastify instance. */
11545
- function registerRoutes(app, deps) {
11546
- // Global error handler for validation + watcher errors
11547
- app.setErrorHandler((error, _request, reply) => {
11548
- if (error.validation) {
11549
- return reply
11550
- .status(400)
11551
- .send({ error: 'BAD_REQUEST', message: error.message });
11552
- }
11553
- if (error.statusCode === 404) {
11554
- return reply
11555
- .status(404)
11556
- .send({ error: 'NOT_FOUND', message: error.message });
11557
- }
11558
- deps.logger.error(error, 'Unhandled route error');
11559
- return reply
11560
- .status(500)
11561
- .send({ error: 'INTERNAL_ERROR', message: error.message });
11562
- });
11563
- registerStatusRoute(app, deps);
11564
- registerMetasRoutes(app, deps);
11565
- registerSynthesizeRoute(app, deps);
11566
- registerPreviewRoute(app, deps);
11567
- registerSeedRoute(app, deps);
11568
- registerUnlockRoute(app, deps);
11569
- registerConfigRoute(app, deps);
11570
- registerConfigApplyRoute(app);
11571
- registerQueueRoutes(app, deps);
11572
- }
11573
-
11574
- /**
11575
- * Virtual rule registration with jeeves-watcher.
11576
- *
11577
- * Service registers inference rules at startup (with retry) and
11578
- * re-registers opportunistically when watcher restart is detected.
11579
- *
11580
- * @module rules
11581
- */
11582
- const SOURCE = 'jeeves-meta';
11583
- const MAX_RETRIES = 10;
11584
- const RETRY_BASE_MS = 2000;
11585
- /**
11586
- * Convert a `Record<string, unknown>` config property into watcher
11587
- * schema `set` directives: `{ key: { set: value } }` per entry.
11680
+ *
11681
+ * @module routes/status
11588
11682
  */
11589
- function toSchemaSetDirectives(props) {
11590
- return Object.fromEntries(Object.entries(props).map(([k, v]) => [k, { set: v }]));
11683
+ async function checkDependency(url, path) {
11684
+ const checkedAt = new Date().toISOString();
11685
+ try {
11686
+ const res = await fetch(new URL(path, url), {
11687
+ signal: AbortSignal.timeout(3000),
11688
+ });
11689
+ return { url, status: res.ok ? 'ok' : 'error', checkedAt };
11690
+ }
11691
+ catch {
11692
+ return { url, status: 'unreachable', checkedAt };
11693
+ }
11591
11694
  }
11592
- /** Build the three virtual rule definitions. */
11593
- function buildMetaRules(config) {
11594
- return [
11595
- {
11596
- name: 'meta-current',
11597
- description: 'Live jeeves-meta .meta/meta.json files',
11598
- match: {
11599
- properties: {
11600
- file: {
11601
- properties: {
11602
- path: { type: 'string', glob: '**/.meta/meta.json' },
11603
- },
11604
- },
11605
- },
11606
- },
11607
- schema: [
11608
- 'base',
11609
- {
11610
- properties: {
11611
- ...toSchemaSetDirectives(config.metaProperty),
11612
- meta_id: { type: 'string', set: '{{json._id}}' },
11613
- meta_steer: { type: 'string', set: '{{json._steer}}' },
11614
- meta_depth: { type: 'number', set: '{{json._depth}}' },
11615
- meta_emphasis: { type: 'number', set: '{{json._emphasis}}' },
11616
- meta_synthesis_count: {
11617
- type: 'integer',
11618
- set: '{{json._synthesisCount}}',
11619
- },
11620
- meta_structure_hash: {
11621
- type: 'string',
11622
- set: '{{json._structureHash}}',
11623
- },
11624
- meta_architect_tokens: {
11625
- type: 'integer',
11626
- set: '{{json._architectTokens}}',
11627
- },
11628
- meta_builder_tokens: {
11629
- type: 'integer',
11630
- set: '{{json._builderTokens}}',
11631
- },
11632
- meta_critic_tokens: {
11633
- type: 'integer',
11634
- set: '{{json._criticTokens}}',
11635
- },
11636
- meta_error_step: {
11637
- type: 'string',
11638
- set: '{{json._error.step}}',
11639
- },
11640
- generated_at: {
11641
- type: 'string',
11642
- set: '{{json._generatedAt}}',
11643
- },
11644
- generated_at_unix: {
11645
- type: 'integer',
11646
- set: '{{toUnix json._generatedAt}}',
11647
- },
11648
- has_error: {
11649
- type: 'boolean',
11650
- set: '{{#if json._error}}true{{else}}false{{/if}}',
11651
- },
11652
- },
11653
- },
11654
- ],
11655
- render: {
11656
- frontmatter: [
11657
- 'meta_id',
11658
- 'generated_at',
11659
- '*',
11660
- '!_*',
11661
- '!json',
11662
- '!file',
11663
- '!has_error',
11664
- ],
11665
- body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
11666
- },
11667
- renderAs: 'md',
11668
- },
11669
- {
11670
- name: 'meta-archive',
11671
- description: 'Archived jeeves-meta .meta/archive snapshots',
11672
- match: {
11673
- properties: {
11674
- file: {
11675
- properties: {
11676
- path: { type: 'string', glob: '**/.meta/archive/*.json' },
11677
- },
11678
- },
11695
+ /** Check watcher, surfacing initialScan.active as indexing state. */
11696
+ async function checkWatcher(url) {
11697
+ const checkedAt = new Date().toISOString();
11698
+ try {
11699
+ const res = await fetch(new URL('/status', url), {
11700
+ signal: AbortSignal.timeout(3000),
11701
+ });
11702
+ if (!res.ok)
11703
+ return { url, status: 'error', checkedAt };
11704
+ const data = (await res.json());
11705
+ const indexing = data.initialScan?.active === true;
11706
+ return {
11707
+ url,
11708
+ status: indexing ? 'indexing' : 'ok',
11709
+ checkedAt,
11710
+ indexing,
11711
+ };
11712
+ }
11713
+ catch {
11714
+ return { url, status: 'unreachable', checkedAt };
11715
+ }
11716
+ }
11717
+ function registerStatusRoute(app, deps) {
11718
+ const statusHandler = createStatusHandler({
11719
+ name: SERVICE_NAME,
11720
+ version: SERVICE_VERSION,
11721
+ getHealth: async () => {
11722
+ const { config, queue, scheduler, stats } = deps;
11723
+ // On-demand dependency checks
11724
+ const [watcherHealth, gatewayHealth] = await Promise.all([
11725
+ checkWatcher(config.watcherUrl),
11726
+ checkDependency(config.gatewayUrl, '/status'),
11727
+ ]);
11728
+ return {
11729
+ currentTarget: queue.current?.path ?? null,
11730
+ queue: queue.getState(),
11731
+ stats: {
11732
+ totalSyntheses: stats.totalSyntheses,
11733
+ totalTokens: stats.totalTokens,
11734
+ totalErrors: stats.totalErrors,
11735
+ lastCycleDurationMs: stats.lastCycleDurationMs,
11736
+ lastCycleAt: stats.lastCycleAt,
11679
11737
  },
11680
- },
11681
- schema: [
11682
- 'base',
11683
- {
11684
- properties: {
11685
- ...toSchemaSetDirectives(config.metaArchiveProperty),
11686
- meta_id: { type: 'string', set: '{{json._id}}' },
11687
- archived: { type: 'boolean', set: 'true' },
11688
- archived_at: { type: 'string', set: '{{json._archivedAt}}' },
11689
- },
11738
+ schedule: {
11739
+ expression: config.schedule,
11740
+ nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
11690
11741
  },
11691
- ],
11692
- render: {
11693
- frontmatter: ['meta_id', 'archived', 'archived_at'],
11694
- body: [
11695
- {
11696
- path: 'json._content',
11697
- heading: 1,
11698
- label: 'Synthesis (archived)',
11699
- },
11700
- ],
11701
- },
11702
- renderAs: 'md',
11703
- },
11704
- {
11705
- name: 'meta-config',
11706
- description: 'jeeves-meta configuration file',
11707
- match: {
11708
- properties: {
11709
- file: {
11710
- properties: {
11711
- path: { type: 'string', glob: '**/jeeves-meta.config.json' },
11712
- },
11742
+ dependencies: {
11743
+ watcher: {
11744
+ ...watcherHealth,
11745
+ rulesRegistered: deps.registrar?.isRegistered ?? false,
11713
11746
  },
11747
+ gateway: gatewayHealth,
11714
11748
  },
11715
- },
11716
- schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
11717
- render: {
11718
- frontmatter: [
11719
- 'watcherUrl',
11720
- 'gatewayUrl',
11721
- 'architectEvery',
11722
- 'depthWeight',
11723
- 'maxArchive',
11724
- 'maxLines',
11725
- ],
11726
- body: [
11727
- {
11728
- path: 'json.defaultArchitect',
11729
- heading: 2,
11730
- label: 'Default Architect Prompt',
11731
- },
11732
- {
11733
- path: 'json.defaultCritic',
11734
- heading: 2,
11735
- label: 'Default Critic Prompt',
11736
- },
11737
- ],
11738
- },
11739
- renderAs: 'md',
11749
+ };
11740
11750
  },
11741
- ];
11751
+ });
11752
+ app.get('/status', async (_request, reply) => {
11753
+ const result = await statusHandler();
11754
+ return reply.status(result.status).send(result.body);
11755
+ });
11742
11756
  }
11757
+
11743
11758
  /**
11744
- * Manages virtual rule registration with watcher.
11759
+ * POST /synthesize route handler.
11745
11760
  *
11746
- * - Registers at startup with exponential retry
11747
- * - Tracks watcher uptime for restart detection
11748
- * - Re-registers opportunistically when uptime decreases
11761
+ * @module routes/synthesize
11749
11762
  */
11750
- class RuleRegistrar {
11751
- config;
11752
- logger;
11753
- watcherClient;
11754
- lastWatcherUptime = null;
11755
- registered = false;
11756
- constructor(config, logger, watcher) {
11757
- this.config = config;
11758
- this.logger = logger;
11759
- this.watcherClient = watcher;
11760
- }
11761
- /** Whether rules have been successfully registered. */
11762
- get isRegistered() {
11763
- return this.registered;
11764
- }
11765
- /**
11766
- * Register rules with watcher. Retries with exponential backoff.
11767
- * Non-blocking — logs errors but never throws.
11768
- */
11769
- async register() {
11770
- const rules = buildMetaRules(this.config);
11771
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
11763
+ const synthesizeBodySchema = z.object({
11764
+ path: z.string().optional(),
11765
+ });
11766
+ /** Register the POST /synthesize route. */
11767
+ function registerSynthesizeRoute(app, deps) {
11768
+ app.post('/synthesize', async (request, reply) => {
11769
+ const body = synthesizeBodySchema.parse(request.body);
11770
+ const { config, watcher, queue } = deps;
11771
+ let targetPath;
11772
+ if (body.path) {
11773
+ targetPath = body.path;
11774
+ }
11775
+ else {
11776
+ // Discover stalest candidate
11777
+ let result;
11772
11778
  try {
11773
- await this.watcherClient.registerRules(SOURCE, rules);
11774
- this.registered = true;
11775
- this.logger.info('Virtual rules registered with watcher');
11776
- return;
11779
+ result = await listMetas(config, watcher);
11777
11780
  }
11778
- catch (err) {
11779
- const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
11780
- this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
11781
- await new Promise((r) => setTimeout(r, delayMs));
11781
+ catch {
11782
+ return reply.status(503).send({
11783
+ error: 'SERVICE_UNAVAILABLE',
11784
+ message: 'Watcher unreachable cannot discover candidates',
11785
+ });
11782
11786
  }
11787
+ const stale = result.entries
11788
+ .filter((e) => e.stalenessSeconds > 0)
11789
+ .map((e) => ({
11790
+ node: e.node,
11791
+ meta: e.meta,
11792
+ actualStaleness: e.stalenessSeconds,
11793
+ }));
11794
+ const stalest = discoverStalestPath(stale, config.depthWeight);
11795
+ if (!stalest) {
11796
+ return reply.code(200).send({
11797
+ status: 'skipped',
11798
+ message: 'No stale metas found. Nothing to synthesize.',
11799
+ });
11800
+ }
11801
+ targetPath = stalest;
11783
11802
  }
11784
- this.logger.error('Rule registration failed after max retries — service degraded');
11785
- }
11786
- /**
11787
- * Check watcher uptime and re-register if it decreased (restart detected).
11788
- *
11789
- * @param currentUptime - Current watcher uptime in seconds.
11790
- */
11791
- async checkAndReregister(currentUptime) {
11792
- if (this.lastWatcherUptime !== null &&
11793
- currentUptime < this.lastWatcherUptime) {
11794
- this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
11795
- this.registered = false;
11796
- await this.register();
11797
- }
11798
- this.lastWatcherUptime = currentUptime;
11799
- }
11803
+ const result = queue.enqueue(targetPath, body.path !== undefined);
11804
+ return reply.code(202).send({
11805
+ status: 'accepted',
11806
+ path: targetPath,
11807
+ queuePosition: result.position,
11808
+ alreadyQueued: result.alreadyQueued,
11809
+ });
11810
+ });
11800
11811
  }
11801
11812
 
11802
11813
  /**
11803
- * Post-registration verification of virtual rule application.
11804
- *
11805
- * After rules are registered with the watcher, verifies that .meta/meta.json
11806
- * files are discoverable via watcher walk (which depends on virtual rules
11807
- * being applied). Logs a warning if expected metas are not found.
11814
+ * POST /unlock remove .lock from a .meta/ directory.
11808
11815
  *
11809
- * @module rules/verify
11816
+ * @module routes/unlock
11810
11817
  */
11818
+ const unlockBodySchema = z.object({
11819
+ path: z.string().min(1),
11820
+ });
11821
+ function registerUnlockRoute(app, deps) {
11822
+ app.post('/unlock', (request, reply) => {
11823
+ const body = unlockBodySchema.parse(request.body);
11824
+ const metaDir = resolveMetaDir(body.path);
11825
+ const lockPath = join(metaDir, '.lock');
11826
+ if (!existsSync(lockPath)) {
11827
+ return reply.status(409).send({
11828
+ error: 'ALREADY_UNLOCKED',
11829
+ message: `No lock file at ${body.path} (already unlocked)`,
11830
+ });
11831
+ }
11832
+ deps.logger.info({ lockPath }, 'removing lock file');
11833
+ unlinkSync(lockPath);
11834
+ return reply.status(200).send({
11835
+ status: 'unlocked',
11836
+ path: body.path,
11837
+ });
11838
+ });
11839
+ }
11840
+
11811
11841
  /**
11812
- * Verify that virtual rules are applied to indexed .meta/meta.json files.
11813
- *
11814
- * Runs a discovery pass and logs the result. If no metas are found but
11815
- * the filesystem likely has some, logs a warning suggesting reindex.
11842
+ * Route registration for jeeves-meta service.
11816
11843
  *
11817
- * @param watcher - WatcherClient for discovery.
11818
- * @param logger - Logger for reporting results.
11819
- * @returns Number of metas discovered.
11844
+ * @module routes
11820
11845
  */
11821
- async function verifyRuleApplication(watcher, logger) {
11822
- try {
11823
- const metaPaths = await discoverMetas(watcher);
11824
- if (metaPaths.length === 0) {
11825
- logger.warn({ count: 0 }, 'Post-registration verification: no .meta/meta.json files found via watcher walk. ' +
11826
- 'Virtual rules may not be applied to indexed files. ' +
11827
- 'If metas exist, a path-scoped reindex may be needed.');
11846
+ /** Register all HTTP routes on the Fastify instance. */
11847
+ function registerRoutes(app, deps) {
11848
+ // Global error handler for validation + watcher errors
11849
+ app.setErrorHandler((error, _request, reply) => {
11850
+ if (error.validation) {
11851
+ return reply
11852
+ .status(400)
11853
+ .send({ error: 'BAD_REQUEST', message: error.message });
11828
11854
  }
11829
- else {
11830
- logger.info({ count: metaPaths.length }, 'Post-registration verification: metas discoverable');
11855
+ if (error.statusCode === 404) {
11856
+ return reply
11857
+ .status(404)
11858
+ .send({ error: 'NOT_FOUND', message: error.message });
11831
11859
  }
11832
- return metaPaths.length;
11833
- }
11834
- catch (err) {
11835
- logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'Post-registration verification failed (watcher may be unavailable)');
11836
- return 0;
11837
- }
11860
+ deps.logger.error(error, 'Unhandled route error');
11861
+ return reply
11862
+ .status(500)
11863
+ .send({ error: 'INTERNAL_ERROR', message: error.message });
11864
+ });
11865
+ registerStatusRoute(app, deps);
11866
+ registerMetasRoutes(app, deps);
11867
+ registerSynthesizeRoute(app, deps);
11868
+ registerPreviewRoute(app, deps);
11869
+ registerSeedRoute(app, deps);
11870
+ registerUnlockRoute(app, deps);
11871
+ registerConfigRoute(app, deps);
11872
+ registerConfigApplyRoute(app);
11873
+ registerQueueRoutes(app, deps);
11838
11874
  }
11839
11875
 
11840
11876
  /**
@@ -11994,80 +12030,6 @@ class HttpWatcherClient {
11994
12030
  }
11995
12031
  }
11996
12032
 
11997
- /**
11998
- * Periodic watcher health check for rule registration resilience.
11999
- *
12000
- * Pings watcher `/status` on a configurable interval, detects restarts
12001
- * (uptime decrease), and re-registers virtual rules automatically.
12002
- * Independent of the synthesis scheduler.
12003
- *
12004
- * @module rules/healthCheck
12005
- */
12006
- /**
12007
- * Manages the periodic watcher health check loop.
12008
- *
12009
- * Starts a `setInterval` that pings the watcher and delegates
12010
- * restart detection to `RuleRegistrar.checkAndReregister()`.
12011
- */
12012
- class WatcherHealthCheck {
12013
- watcherUrl;
12014
- intervalMs;
12015
- registrar;
12016
- logger;
12017
- handle = null;
12018
- constructor(opts) {
12019
- this.watcherUrl = opts.watcherUrl.replace(/\/+$/, '');
12020
- this.intervalMs = opts.intervalMs;
12021
- this.registrar = opts.registrar;
12022
- this.logger = opts.logger;
12023
- }
12024
- /** Start the periodic health check. No-op if intervalMs is 0. */
12025
- start() {
12026
- if (this.intervalMs <= 0) {
12027
- this.logger.info('Watcher health check disabled (interval = 0)');
12028
- return;
12029
- }
12030
- this.handle = setInterval(() => {
12031
- void this.check();
12032
- }, this.intervalMs);
12033
- // Don't prevent process exit
12034
- if (typeof this.handle === 'object' && 'unref' in this.handle) {
12035
- this.handle.unref();
12036
- }
12037
- this.logger.info({ intervalMs: this.intervalMs }, 'Watcher health check started');
12038
- }
12039
- /** Stop the periodic health check. */
12040
- stop() {
12041
- if (this.handle) {
12042
- clearInterval(this.handle);
12043
- this.handle = null;
12044
- }
12045
- }
12046
- /** Single health check iteration. */
12047
- async check() {
12048
- try {
12049
- const res = await fetch(this.watcherUrl + '/status', {
12050
- signal: AbortSignal.timeout(5000),
12051
- });
12052
- if (!res.ok) {
12053
- this.logger.warn({ status: res.status }, 'Watcher health check: non-OK response');
12054
- return;
12055
- }
12056
- const data = (await res.json());
12057
- // If rules were never successfully registered (startup failure),
12058
- // attempt registration now that the watcher is reachable.
12059
- if (!this.registrar.isRegistered) {
12060
- this.logger.info('Rules not registered — attempting registration');
12061
- await this.registrar.register();
12062
- }
12063
- await this.registrar.checkAndReregister(data.uptime);
12064
- }
12065
- catch (err) {
12066
- this.logger.debug({ err }, 'Watcher health check: unreachable (expected during startup)');
12067
- }
12068
- }
12069
- }
12070
-
12071
12033
  /**
12072
12034
  * Service bootstrap — wire up all components and start listening.
12073
12035
  *
@@ -12251,4 +12213,51 @@ async function startService(config, configPath) {
12251
12213
  logger.info('Service fully initialized');
12252
12214
  }
12253
12215
 
12216
+ /**
12217
+ * Jeeves component descriptor for jeeves-meta.
12218
+ *
12219
+ * Single source of truth consumed by the service CLI, plugin writer, and
12220
+ * config-apply pipeline.
12221
+ *
12222
+ * @module descriptor
12223
+ */
12224
+ /**
12225
+ * Parsed jeeves-meta component descriptor.
12226
+ */
12227
+ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
12228
+ name: 'meta',
12229
+ version: SERVICE_VERSION,
12230
+ servicePackage: '@karmaniverous/jeeves-meta',
12231
+ pluginPackage: '@karmaniverous/jeeves-meta-openclaw',
12232
+ defaultPort: 1938,
12233
+ // The runtime Zod custom validator only checks for a .parse() method.
12234
+ // Use unknown cast to bridge the Zod v4 (service) → v3 (core SDK) type gap.
12235
+ configSchema: serviceConfigSchema,
12236
+ configFileName: 'config.json',
12237
+ initTemplate: () => serviceConfigSchema.parse({
12238
+ watcherUrl: 'http://127.0.0.1:1936',
12239
+ }),
12240
+ onConfigApply: (merged) => {
12241
+ const parsed = serviceConfigSchema.parse(merged);
12242
+ applyHotReloadedConfig(parsed);
12243
+ return Promise.resolve();
12244
+ },
12245
+ run: async (configPath) => {
12246
+ const config = loadServiceConfig(configPath);
12247
+ await startService(config, configPath);
12248
+ },
12249
+ startCommand: (configPath) => [
12250
+ 'node',
12251
+ 'dist/cli/jeeves-meta/index.js',
12252
+ 'start',
12253
+ '-c',
12254
+ configPath,
12255
+ ],
12256
+ sectionId: 'Meta',
12257
+ refreshIntervalSeconds: 73,
12258
+ generateToolsContent: () => '',
12259
+ dependencies: { hard: ['watcher'], soft: [] },
12260
+ customCliCommands: registerCustomCliCommands,
12261
+ });
12262
+
12254
12263
  export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaDescriptor, metaErrorSchema, metaJsonSchema, migrateConfigPath, normalizePath, orchestrate, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError, verifyRuleApplication };