@karmaniverous/jeeves-meta 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3799 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, copyFileSync, watchFile } from 'node:fs';
4
+ import { dirname, join, relative } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import pino from 'pino';
8
+ import { Cron } from 'croner';
9
+ import Fastify from 'fastify';
10
+
11
+ /**
12
+ * Zod schema for jeeves-meta service configuration.
13
+ *
14
+ * The service config is a strict superset of the core (library-compatible) meta config.
15
+ *
16
+ * @module schema/config
17
+ */
18
+ /** Zod schema for the core (library-compatible) meta configuration. */
19
+ const metaConfigSchema = z.object({
20
+ /** Watcher service base URL. */
21
+ watcherUrl: z.url(),
22
+ /** OpenClaw gateway base URL for subprocess spawning. */
23
+ gatewayUrl: z.url().default('http://127.0.0.1:18789'),
24
+ /** Optional API key for gateway authentication. */
25
+ gatewayApiKey: z.string().optional(),
26
+ /** Run architect every N cycles (per meta). */
27
+ architectEvery: z.number().int().min(1).default(10),
28
+ /** Exponent for depth weighting in staleness formula. */
29
+ depthWeight: z.number().min(0).default(0.5),
30
+ /** Maximum archive snapshots to retain per meta. */
31
+ maxArchive: z.number().int().min(1).default(20),
32
+ /** Maximum lines of context to include in subprocess prompts. */
33
+ maxLines: z.number().int().min(50).default(500),
34
+ /** Architect subprocess timeout in seconds. */
35
+ architectTimeout: z.number().int().min(30).default(120),
36
+ /** Builder subprocess timeout in seconds. */
37
+ builderTimeout: z.number().int().min(60).default(600),
38
+ /** Critic subprocess timeout in seconds. */
39
+ criticTimeout: z.number().int().min(30).default(300),
40
+ /** Thinking level for spawned synthesis sessions. */
41
+ thinking: z.string().default('low'),
42
+ /** Resolved architect system prompt text. */
43
+ defaultArchitect: z.string(),
44
+ /** Resolved critic system prompt text. */
45
+ defaultCritic: z.string(),
46
+ /** Skip unchanged candidates, bump _generatedAt. */
47
+ skipUnchanged: z.boolean().default(true),
48
+ /** Watcher metadata properties applied to live .meta/meta.json files. */
49
+ metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
50
+ /** Watcher metadata properties applied to archive snapshots. */
51
+ metaArchiveProperty: z
52
+ .record(z.string(), z.unknown())
53
+ .default({ _meta: 'archive' }),
54
+ });
55
+ /** Zod schema for logging configuration. */
56
+ const loggingSchema = z.object({
57
+ /** Log level. */
58
+ level: z.string().default('info'),
59
+ /** Optional file path for log output. */
60
+ file: z.string().optional(),
61
+ });
62
+ /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
63
+ const serviceConfigSchema = metaConfigSchema.extend({
64
+ /** HTTP port for the service (default: 1938). */
65
+ port: z.number().int().min(1).max(65535).default(1938),
66
+ /** Cron schedule for synthesis cycles (default: every 30 min). */
67
+ schedule: z.string().default('*/30 * * * *'),
68
+ /** Optional channel identifier for reporting. */
69
+ reportChannel: z.string().optional(),
70
+ /** Logging configuration. */
71
+ logging: loggingSchema.default(() => loggingSchema.parse({})),
72
+ });
73
+
74
+ /**
75
+ * Load and resolve jeeves-meta service config.
76
+ *
77
+ * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
78
+ *
79
+ * @module configLoader
80
+ */
81
+ /**
82
+ * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
83
+ *
84
+ * @param value - Arbitrary JSON-compatible value.
85
+ * @returns Value with env-var placeholders resolved.
86
+ */
87
+ function substituteEnvVars(value) {
88
+ if (typeof value === 'string') {
89
+ return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
90
+ const envVal = process.env[name];
91
+ if (envVal === undefined) {
92
+ throw new Error(`Environment variable ${name} is not set`);
93
+ }
94
+ return envVal;
95
+ });
96
+ }
97
+ if (Array.isArray(value)) {
98
+ return value.map(substituteEnvVars);
99
+ }
100
+ if (value !== null && typeof value === 'object') {
101
+ const result = {};
102
+ for (const [key, val] of Object.entries(value)) {
103
+ result[key] = substituteEnvVars(val);
104
+ }
105
+ return result;
106
+ }
107
+ return value;
108
+ }
109
+ /**
110
+ * Resolve \@file: references in a config value.
111
+ *
112
+ * @param value - String value that may start with "\@file:".
113
+ * @param baseDir - Base directory for resolving relative paths.
114
+ * @returns The resolved string (file contents or original value).
115
+ */
116
+ function resolveFileRef(value, baseDir) {
117
+ if (!value.startsWith('@file:'))
118
+ return value;
119
+ const filePath = join(baseDir, value.slice(6));
120
+ return readFileSync(filePath, 'utf8');
121
+ }
122
+ /**
123
+ * Resolve config path from --config flag or JEEVES_META_CONFIG env var.
124
+ *
125
+ * @param args - CLI arguments (process.argv.slice(2)).
126
+ * @returns Resolved config path.
127
+ * @throws If no config path found.
128
+ */
129
+ function resolveConfigPath(args) {
130
+ let configIdx = args.indexOf('--config');
131
+ if (configIdx === -1)
132
+ configIdx = args.indexOf('-c');
133
+ if (configIdx !== -1 && args[configIdx + 1]) {
134
+ return args[configIdx + 1];
135
+ }
136
+ const envPath = process.env['JEEVES_META_CONFIG'];
137
+ if (envPath)
138
+ return envPath;
139
+ throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
140
+ }
141
+ /**
142
+ * Load service config from a JSON file.
143
+ *
144
+ * Resolves \@file: references for defaultArchitect and defaultCritic,
145
+ * and substitutes environment-variable placeholders throughout.
146
+ *
147
+ * @param configPath - Path to config JSON file.
148
+ * @returns Validated ServiceConfig.
149
+ */
150
+ function loadServiceConfig(configPath) {
151
+ const rawText = readFileSync(configPath, 'utf8');
152
+ const raw = substituteEnvVars(JSON.parse(rawText));
153
+ const baseDir = dirname(configPath);
154
+ if (typeof raw['defaultArchitect'] === 'string') {
155
+ raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
156
+ }
157
+ if (typeof raw['defaultCritic'] === 'string') {
158
+ raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
159
+ }
160
+ return serviceConfigSchema.parse(raw);
161
+ }
162
+
163
+ var configLoader = /*#__PURE__*/Object.freeze({
164
+ __proto__: null,
165
+ loadServiceConfig: loadServiceConfig,
166
+ resolveConfigPath: resolveConfigPath
167
+ });
168
+
169
+ /**
170
+ * List archive snapshot files in chronological order.
171
+ *
172
+ * @module archive/listArchive
173
+ */
174
+ /**
175
+ * List archive .json files sorted chronologically (oldest first).
176
+ *
177
+ * @param metaPath - Absolute path to the .meta directory.
178
+ * @returns Array of absolute paths to archive files, or empty if none.
179
+ */
180
+ function listArchiveFiles(metaPath) {
181
+ const archiveDir = join(metaPath, 'archive');
182
+ try {
183
+ return readdirSync(archiveDir)
184
+ .filter((f) => f.endsWith('.json'))
185
+ .sort()
186
+ .map((f) => join(archiveDir, f));
187
+ }
188
+ catch {
189
+ return [];
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Prune old archive snapshots beyond maxArchive.
195
+ *
196
+ * @module archive/prune
197
+ */
198
+ /**
199
+ * Prune archive directory to keep at most maxArchive snapshots.
200
+ * Removes the oldest files.
201
+ *
202
+ * @param metaPath - Absolute path to the .meta directory.
203
+ * @param maxArchive - Maximum snapshots to retain.
204
+ * @returns Number of files pruned.
205
+ */
206
+ function pruneArchive(metaPath, maxArchive) {
207
+ const files = listArchiveFiles(metaPath);
208
+ const toRemove = files.length - maxArchive;
209
+ if (toRemove <= 0)
210
+ return 0;
211
+ for (let i = 0; i < toRemove; i++) {
212
+ unlinkSync(files[i]);
213
+ }
214
+ return toRemove;
215
+ }
216
+
217
+ /**
218
+ * Read the latest archive snapshot for steer change detection.
219
+ *
220
+ * @module archive/readLatest
221
+ */
222
+ /**
223
+ * Read the most recent archive snapshot.
224
+ *
225
+ * @param metaPath - Absolute path to the .meta directory.
226
+ * @returns The latest archived meta, or null if no archives exist.
227
+ */
228
+ function readLatestArchive(metaPath) {
229
+ const files = listArchiveFiles(metaPath);
230
+ if (files.length === 0)
231
+ return null;
232
+ const raw = readFileSync(files[files.length - 1], 'utf8');
233
+ return JSON.parse(raw);
234
+ }
235
+
236
+ /**
237
+ * Create archive snapshots of meta.json.
238
+ *
239
+ * Copies current meta.json to archive/\{ISO-timestamp\}.json with
240
+ * _archived: true and _archivedAt added.
241
+ *
242
+ * @module archive/snapshot
243
+ */
244
+ /**
245
+ * Create an archive snapshot of the current meta.json.
246
+ *
247
+ * @param metaPath - Absolute path to the .meta directory.
248
+ * @param meta - Current meta.json content.
249
+ * @returns The archive file path.
250
+ */
251
+ function createSnapshot(metaPath, meta) {
252
+ const archiveDir = join(metaPath, 'archive');
253
+ mkdirSync(archiveDir, { recursive: true });
254
+ const now = new Date().toISOString().replace(/[:.]/g, '-');
255
+ const archiveFile = join(archiveDir, now + '.json');
256
+ const archived = {
257
+ ...meta,
258
+ _archived: true,
259
+ _archivedAt: new Date().toISOString(),
260
+ };
261
+ writeFileSync(archiveFile, JSON.stringify(archived, null, 2) + '\n');
262
+ return archiveFile;
263
+ }
264
+
265
+ /**
266
+ * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
267
+ *
268
+ * Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
269
+ * ensures all paths in the library use the same convention, regardless of
270
+ * the platform's native separator.
271
+ *
272
+ * @module normalizePath
273
+ */
274
+ /**
275
+ * Normalize a file path to forward slashes.
276
+ *
277
+ * @param p - File path (may contain backslashes).
278
+ * @returns Path with all backslashes replaced by forward slashes.
279
+ */
280
+ function normalizePath(p) {
281
+ return p.replaceAll('\\', '/');
282
+ }
283
+
284
+ /**
285
+ * Paginated scan helper for exhaustive scope enumeration.
286
+ *
287
+ * @module paginatedScan
288
+ */
289
+ /**
290
+ * Perform a paginated scan that follows cursor tokens until exhausted.
291
+ *
292
+ * @param watcher - WatcherClient instance.
293
+ * @param params - Base scan parameters (cursor is managed internally).
294
+ * @returns All matching files across all pages.
295
+ */
296
+ async function paginatedScan(watcher, params) {
297
+ const allFiles = [];
298
+ let cursor;
299
+ do {
300
+ const result = await watcher.scan({ ...params, cursor });
301
+ allFiles.push(...result.files);
302
+ cursor = result.next;
303
+ } while (cursor);
304
+ return allFiles;
305
+ }
306
+
307
+ /**
308
+ * Discover .meta/ directories via watcher scan.
309
+ *
310
+ * Replaces filesystem-based globMetas() with a watcher query
311
+ * that returns indexed .meta/meta.json points, filtered by domain.
312
+ *
313
+ * @module discovery/discoverMetas
314
+ */
315
+ /**
316
+ * Build a single Qdrant filter clause from a key-value pair.
317
+ *
318
+ * Arrays use `match.value` on the first element (Qdrant array membership).
319
+ * Scalars (string, number, boolean) use `match.value` directly.
320
+ * Objects and other non-filterable types are skipped with a warning.
321
+ */
322
+ function buildMatchClause(key, value) {
323
+ if (Array.isArray(value)) {
324
+ if (value.length === 0)
325
+ return null;
326
+ return { key, match: { value: value[0] } };
327
+ }
328
+ if (typeof value === 'string' ||
329
+ typeof value === 'number' ||
330
+ typeof value === 'boolean') {
331
+ return { key, match: { value } };
332
+ }
333
+ // Non-filterable value (object, null, etc.) — valid for tagging but
334
+ // cannot be expressed as a Qdrant match clause.
335
+ return null;
336
+ }
337
+ /**
338
+ * Build a Qdrant filter from config metaProperty.
339
+ *
340
+ * Iterates all key-value pairs in `metaProperty` (a generic record)
341
+ * to construct `must` clauses. Always appends `file_path: meta.json`
342
+ * for deduplication.
343
+ *
344
+ * @param config - Meta config with metaProperty.
345
+ * @returns Qdrant filter object for scanning live metas.
346
+ */
347
+ function buildMetaFilter(config) {
348
+ const must = [];
349
+ for (const [key, value] of Object.entries(config.metaProperty)) {
350
+ const clause = buildMatchClause(key, value);
351
+ if (clause)
352
+ must.push(clause);
353
+ }
354
+ must.push({
355
+ key: 'file_path',
356
+ match: { text: '.meta/meta.json' },
357
+ });
358
+ return { must };
359
+ }
360
+ /**
361
+ * Discover all .meta/ directories via watcher scan.
362
+ *
363
+ * Queries the watcher for indexed .meta/meta.json points using the
364
+ * configured domain filter. Returns deduplicated meta directory paths.
365
+ *
366
+ * @param config - Meta config (for domain filter).
367
+ * @param watcher - WatcherClient for scan queries.
368
+ * @returns Array of normalized .meta/ directory paths.
369
+ */
370
+ async function discoverMetas(config, watcher) {
371
+ const filter = buildMetaFilter(config);
372
+ const scanFiles = await paginatedScan(watcher, {
373
+ filter,
374
+ fields: ['file_path'],
375
+ });
376
+ // Deduplicate by .meta/ directory path (handles multi-chunk files)
377
+ const seen = new Set();
378
+ const metaPaths = [];
379
+ for (const sf of scanFiles) {
380
+ const fp = normalizePath(sf.file_path);
381
+ // Derive .meta/ directory from file_path (strip /meta.json)
382
+ const metaPath = fp.replace(/\/meta\.json$/, '');
383
+ if (seen.has(metaPath))
384
+ continue;
385
+ seen.add(metaPath);
386
+ metaPaths.push(metaPath);
387
+ }
388
+ return metaPaths;
389
+ }
390
+
391
+ /**
392
+ * File-system lock for preventing concurrent synthesis on the same meta.
393
+ *
394
+ * Lock file: .meta/.lock containing `_lockPid` + `_lockStartedAt` (underscore-prefixed
395
+ * reserved keys, consistent with meta.json conventions).
396
+ * Stale timeout: 30 minutes.
397
+ *
398
+ * @module lock
399
+ */
400
+ const LOCK_FILE = '.lock';
401
+ /**
402
+ * Resolve a path to a .meta directory.
403
+ *
404
+ * If the path already ends with '.meta', returns it as-is.
405
+ * Otherwise, appends '.meta' as a subdirectory.
406
+ *
407
+ * @param inputPath - Path that may or may not end with '.meta'.
408
+ * @returns The resolved .meta directory path.
409
+ */
410
+ function resolveMetaDir(inputPath) {
411
+ return inputPath.endsWith('.meta') ? inputPath : join(inputPath, '.meta');
412
+ }
413
+ const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
414
+ /**
415
+ * Read and classify the state of a .meta/.lock file.
416
+ *
417
+ * @param metaPath - Absolute path to the .meta directory.
418
+ * @returns Parsed lock state.
419
+ */
420
+ function readLockState(metaPath) {
421
+ const lockPath = join(metaPath, LOCK_FILE);
422
+ if (!existsSync(lockPath)) {
423
+ return { exists: false, staged: false, active: false, data: null };
424
+ }
425
+ try {
426
+ const raw = readFileSync(lockPath, 'utf8');
427
+ const data = JSON.parse(raw);
428
+ if ('_id' in data) {
429
+ return { exists: true, staged: true, active: false, data };
430
+ }
431
+ const startedAt = data._lockStartedAt;
432
+ if (startedAt) {
433
+ const lockAge = Date.now() - new Date(startedAt).getTime();
434
+ return {
435
+ exists: true,
436
+ staged: false,
437
+ active: lockAge < STALE_TIMEOUT_MS,
438
+ data,
439
+ };
440
+ }
441
+ return { exists: true, staged: false, active: false, data };
442
+ }
443
+ catch {
444
+ return { exists: true, staged: false, active: false, data: null };
445
+ }
446
+ }
447
+ /**
448
+ * Attempt to acquire a lock on a .meta directory.
449
+ *
450
+ * @param metaPath - Absolute path to the .meta directory.
451
+ * @returns True if lock was acquired, false if already locked (non-stale).
452
+ */
453
+ function acquireLock(metaPath) {
454
+ const state = readLockState(metaPath);
455
+ // Active non-stale lock — cannot acquire
456
+ if (state.active)
457
+ return false;
458
+ // Staged, stale, corrupt, or missing — safe to (over)write
459
+ const lockPath = join(metaPath, LOCK_FILE);
460
+ const lock = {
461
+ _lockPid: process.pid,
462
+ _lockStartedAt: new Date().toISOString(),
463
+ };
464
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
465
+ return true;
466
+ }
467
+ /**
468
+ * Release a lock on a .meta directory.
469
+ *
470
+ * @param metaPath - Absolute path to the .meta directory.
471
+ */
472
+ function releaseLock(metaPath) {
473
+ const lockPath = join(metaPath, LOCK_FILE);
474
+ try {
475
+ unlinkSync(lockPath);
476
+ }
477
+ catch {
478
+ // Already removed or never existed
479
+ }
480
+ }
481
+ /**
482
+ * Check if a .meta directory is currently locked (non-stale).
483
+ *
484
+ * @param metaPath - Absolute path to the .meta directory.
485
+ * @returns True if locked and not stale.
486
+ */
487
+ function isLocked(metaPath) {
488
+ return readLockState(metaPath).active;
489
+ }
490
+ /**
491
+ * Clean up stale lock files on startup.
492
+ *
493
+ * For each .meta directory found via the provided paths:
494
+ * - If lock contains PID-only data (synthesis incomplete), delete it.
495
+ * - If lock contains staged result (_id present), log warning and delete.
496
+ *
497
+ * @param metaPaths - Array of .meta directory paths to check.
498
+ * @param logger - Optional logger for warnings.
499
+ */
500
+ function cleanupStaleLocks(metaPaths, logger) {
501
+ for (const metaPath of metaPaths) {
502
+ const state = readLockState(metaPath);
503
+ if (!state.exists)
504
+ continue;
505
+ const lockPath = join(metaPath, LOCK_FILE);
506
+ if (state.staged) {
507
+ logger?.warn({ metaPath }, 'Found staged synthesis result in lock file from previous crash — deleting (conservative: not auto-finalizing)');
508
+ }
509
+ else {
510
+ logger?.warn({ metaPath }, 'Found stale lock file from previous crash — deleting');
511
+ }
512
+ try {
513
+ unlinkSync(lockPath);
514
+ }
515
+ catch {
516
+ // Already gone
517
+ }
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Build the ownership tree from discovered .meta/ paths.
523
+ *
524
+ * Each .meta/ directory owns its parent directory and all descendants,
525
+ * except subtrees that contain their own .meta/. For those subtrees,
526
+ * the parent meta consumes the child meta's synthesis output.
527
+ *
528
+ * @module discovery/ownershipTree
529
+ */
530
+ /**
531
+ * Build an ownership tree from an array of .meta/ directory paths.
532
+ *
533
+ * @param metaPaths - Absolute paths to .meta/ directories.
534
+ * @returns The ownership tree with parent/child relationships.
535
+ */
536
+ function buildOwnershipTree(metaPaths) {
537
+ const nodes = new Map();
538
+ // Create nodes, sorted by ownerPath length (shortest first = shallowest)
539
+ const sorted = [...metaPaths]
540
+ .map((mp) => ({
541
+ metaPath: normalizePath(mp),
542
+ ownerPath: normalizePath(dirname(mp)),
543
+ }))
544
+ .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
545
+ for (const { metaPath, ownerPath } of sorted) {
546
+ nodes.set(metaPath, {
547
+ metaPath,
548
+ ownerPath,
549
+ treeDepth: 0,
550
+ children: [],
551
+ parent: null,
552
+ });
553
+ }
554
+ const roots = [];
555
+ // For each node, find its closest ancestor meta
556
+ for (const node of nodes.values()) {
557
+ let bestParent = null;
558
+ let bestParentLen = -1;
559
+ for (const candidate of nodes.values()) {
560
+ if (candidate === node)
561
+ continue;
562
+ // Check if node's ownerPath is under candidate's ownerPath
563
+ const rel = relative(candidate.ownerPath, node.ownerPath);
564
+ if (rel.startsWith('..') || rel === '')
565
+ continue;
566
+ // candidate.ownerPath is an ancestor of node.ownerPath
567
+ if (candidate.ownerPath.length > bestParentLen) {
568
+ bestParent = candidate;
569
+ bestParentLen = candidate.ownerPath.length;
570
+ }
571
+ }
572
+ if (bestParent) {
573
+ node.parent = bestParent;
574
+ node.treeDepth = bestParent.treeDepth + 1;
575
+ bestParent.children.push(node);
576
+ }
577
+ else {
578
+ roots.push(node);
579
+ }
580
+ }
581
+ return { nodes, roots };
582
+ }
583
+ /**
584
+ * Find a node in the ownership tree by meta path or owner path.
585
+ *
586
+ * @param tree - The ownership tree to search.
587
+ * @param targetPath - Path to search for (meta path or owner path).
588
+ * @returns The matching node, or undefined if not found.
589
+ */
590
+ function findNode(tree, targetPath) {
591
+ return Array.from(tree.nodes.values()).find((n) => n.metaPath === targetPath || n.ownerPath === targetPath);
592
+ }
593
+
594
+ /**
595
+ * Unified meta listing: scan, dedup, enrich.
596
+ *
597
+ * Single source of truth for all consumers that need a list of metas
598
+ * with enriched metadata. Replaces duplicated scan+dedup logic in
599
+ * plugin tools, CLI, and prompt injection.
600
+ *
601
+ * @module discovery/listMetas
602
+ */
603
+ /**
604
+ * Discover, deduplicate, and enrich all metas.
605
+ *
606
+ * This is the single consolidated function that replaces all duplicated
607
+ * scan+dedup+enrich logic across the codebase. All enrichment comes from
608
+ * reading meta.json on disk (the canonical source).
609
+ *
610
+ * @param config - Validated synthesis config.
611
+ * @param watcher - Watcher HTTP client for discovery.
612
+ * @returns Enriched meta list with summary statistics and ownership tree.
613
+ */
614
+ async function listMetas(config, watcher) {
615
+ // Step 1: Discover deduplicated meta paths via watcher scan
616
+ const metaPaths = await discoverMetas(config, watcher);
617
+ // Step 2: Build ownership tree
618
+ const tree = buildOwnershipTree(metaPaths);
619
+ // Step 3: Read and enrich each meta from disk
620
+ const entries = [];
621
+ let staleCount = 0;
622
+ let errorCount = 0;
623
+ let lockedCount = 0;
624
+ let neverSynthesizedCount = 0;
625
+ let totalArchTokens = 0;
626
+ let totalBuilderTokens = 0;
627
+ let totalCriticTokens = 0;
628
+ let lastSynthPath = null;
629
+ let lastSynthAt = null;
630
+ let stalestPath = null;
631
+ let stalestEffective = -1;
632
+ for (const node of tree.nodes.values()) {
633
+ let meta;
634
+ try {
635
+ meta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
636
+ }
637
+ catch {
638
+ // Skip unreadable metas
639
+ continue;
640
+ }
641
+ const depth = meta._depth ?? node.treeDepth;
642
+ const emphasis = meta._emphasis ?? 1;
643
+ const hasError = Boolean(meta._error);
644
+ const locked = isLocked(normalizePath(node.metaPath));
645
+ const neverSynth = !meta._generatedAt;
646
+ // Compute staleness
647
+ let stalenessSeconds;
648
+ if (neverSynth) {
649
+ stalenessSeconds = Infinity;
650
+ }
651
+ else {
652
+ const genAt = new Date(meta._generatedAt).getTime();
653
+ stalenessSeconds = Math.max(0, Math.floor((Date.now() - genAt) / 1000));
654
+ }
655
+ // Tokens
656
+ const archTokens = meta._architectTokens ?? 0;
657
+ const buildTokens = meta._builderTokens ?? 0;
658
+ const critTokens = meta._criticTokens ?? 0;
659
+ // Accumulate summary stats
660
+ if (stalenessSeconds > 0)
661
+ staleCount++;
662
+ if (hasError)
663
+ errorCount++;
664
+ if (locked)
665
+ lockedCount++;
666
+ if (neverSynth)
667
+ neverSynthesizedCount++;
668
+ totalArchTokens += archTokens;
669
+ totalBuilderTokens += buildTokens;
670
+ totalCriticTokens += critTokens;
671
+ // Track last synthesized
672
+ if (meta._generatedAt) {
673
+ if (!lastSynthAt || meta._generatedAt > lastSynthAt) {
674
+ lastSynthAt = meta._generatedAt;
675
+ lastSynthPath = node.metaPath;
676
+ }
677
+ }
678
+ // Track stalest (effective staleness for scheduling)
679
+ const depthFactor = Math.pow(1 + config.depthWeight, depth);
680
+ const effectiveStaleness = (stalenessSeconds === Infinity
681
+ ? Number.MAX_SAFE_INTEGER
682
+ : stalenessSeconds) *
683
+ depthFactor *
684
+ emphasis;
685
+ if (effectiveStaleness > stalestEffective) {
686
+ stalestEffective = effectiveStaleness;
687
+ stalestPath = node.metaPath;
688
+ }
689
+ entries.push({
690
+ path: node.metaPath,
691
+ depth,
692
+ emphasis,
693
+ stalenessSeconds,
694
+ lastSynthesized: meta._generatedAt ?? null,
695
+ hasError,
696
+ locked,
697
+ architectTokens: archTokens > 0 ? archTokens : null,
698
+ builderTokens: buildTokens > 0 ? buildTokens : null,
699
+ criticTokens: critTokens > 0 ? critTokens : null,
700
+ children: node.children.length,
701
+ node,
702
+ meta,
703
+ });
704
+ }
705
+ return {
706
+ summary: {
707
+ total: entries.length,
708
+ stale: staleCount,
709
+ errors: errorCount,
710
+ locked: lockedCount,
711
+ neverSynthesized: neverSynthesizedCount,
712
+ tokens: {
713
+ architect: totalArchTokens,
714
+ builder: totalBuilderTokens,
715
+ critic: totalCriticTokens,
716
+ },
717
+ stalestPath,
718
+ lastSynthesizedPath: lastSynthPath,
719
+ lastSynthesizedAt: lastSynthAt,
720
+ },
721
+ entries,
722
+ tree,
723
+ };
724
+ }
725
+
726
+ /**
727
+ * Compute the file scope owned by a meta node.
728
+ *
729
+ * A meta owns: parent dir + all descendants, minus child .meta/ subtrees.
730
+ * For child subtrees, it consumes the child's .meta/meta.json as a rollup input.
731
+ *
732
+ * @module discovery/scope
733
+ */
734
+ /**
735
+ * Get the scope path prefix for a meta node.
736
+ *
737
+ * This is the ownerPath — all files under this path are in scope,
738
+ * except subtrees owned by child metas.
739
+ *
740
+ * @param node - The meta node to compute scope for.
741
+ * @returns The scope path prefix.
742
+ */
743
+ function getScopePrefix(node) {
744
+ return node.ownerPath;
745
+ }
746
+ /**
747
+ * Filter a list of file paths to only those in scope for a meta node.
748
+ *
749
+ * Includes files under ownerPath, excludes files under child meta ownerPaths,
750
+ * but includes child .meta/meta.json files as rollup inputs.
751
+ *
752
+ * @param node - The meta node.
753
+ * @param files - Array of file paths to filter.
754
+ * @returns Filtered array of in-scope file paths.
755
+ */
756
+ function filterInScope(node, files) {
757
+ const prefix = node.ownerPath + '/';
758
+ const exclusions = node.children.map((c) => c.ownerPath + '/');
759
+ const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
760
+ return files.filter((f) => {
761
+ const normalized = f.split('\\').join('/');
762
+ // Must be under ownerPath
763
+ if (!normalized.startsWith(prefix) && normalized !== node.ownerPath)
764
+ return false;
765
+ // Check if under a child meta's subtree
766
+ for (const excl of exclusions) {
767
+ if (normalized.startsWith(excl)) {
768
+ // Exception: child meta.json files are included as rollup inputs
769
+ return childMetaJsons.has(normalized);
770
+ }
771
+ }
772
+ return true;
773
+ });
774
+ }
775
+ /**
776
+ * Get all files in scope for a meta node via watcher scan.
777
+ *
778
+ * Scans the owner path prefix and filters out child meta subtrees,
779
+ * keeping only files directly owned by this meta.
780
+ *
781
+ * @param node - The meta node.
782
+ * @param watcher - WatcherClient for scan queries.
783
+ * @returns Array of in-scope file paths.
784
+ */
785
+ async function getScopeFiles(node, watcher) {
786
+ const allScanFiles = await paginatedScan(watcher, {
787
+ pathPrefix: node.ownerPath,
788
+ });
789
+ const allFiles = allScanFiles.map((f) => f.file_path);
790
+ return {
791
+ scopeFiles: filterInScope(node, allFiles),
792
+ allFiles,
793
+ };
794
+ }
795
+ /**
796
+ * Get files modified since a given timestamp within a meta node's scope.
797
+ *
798
+ * If no generatedAt is provided (first run), returns all scope files.
799
+ *
800
+ * @param node - The meta node.
801
+ * @param watcher - WatcherClient for scan queries.
802
+ * @param generatedAt - ISO timestamp of last synthesis, or null/undefined for first run.
803
+ * @param scopeFiles - Pre-computed scope files (used as fallback for first run).
804
+ * @returns Array of modified in-scope file paths.
805
+ */
806
+ async function getDeltaFiles(node, watcher, generatedAt, scopeFiles) {
807
+ if (!generatedAt)
808
+ return scopeFiles;
809
+ const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
810
+ const deltaScanFiles = await paginatedScan(watcher, {
811
+ pathPrefix: node.ownerPath,
812
+ modifiedAfter,
813
+ });
814
+ return filterInScope(node, deltaScanFiles.map((f) => f.file_path));
815
+ }
816
+
817
+ /**
818
+ * Exponential moving average helper for token tracking.
819
+ *
820
+ * @module ema
821
+ */
822
+ const DEFAULT_DECAY = 0.3;
823
+ /**
824
+ * Compute exponential moving average.
825
+ *
826
+ * @param current - New observation.
827
+ * @param previous - Previous EMA value, or undefined for first observation.
828
+ * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
829
+ * @returns Updated EMA.
830
+ */
831
+ function computeEma(current, previous, decay = DEFAULT_DECAY) {
832
+ if (previous === undefined)
833
+ return current;
834
+ return decay * current + (1 - decay) * previous;
835
+ }
836
+
837
+ /**
838
+ * Shared error utilities.
839
+ *
840
+ * @module errors
841
+ */
842
+ /**
843
+ * Wrap an unknown caught value into a MetaError.
844
+ *
845
+ * @param step - Which synthesis step failed.
846
+ * @param err - The caught error value.
847
+ * @param code - Error classification code.
848
+ * @returns A structured MetaError.
849
+ */
850
+ function toMetaError(step, err, code = 'FAILED') {
851
+ return {
852
+ step,
853
+ code,
854
+ message: err instanceof Error ? err.message : String(err),
855
+ };
856
+ }
857
+
858
+ /**
859
+ * Compute a structure hash from a sorted file listing.
860
+ *
861
+ * Used to detect when directory structure changes, triggering
862
+ * an architect re-run.
863
+ *
864
+ * @module structureHash
865
+ */
866
+ /**
867
+ * Compute a SHA-256 hash of a sorted file listing.
868
+ *
869
+ * @param filePaths - Array of file paths in scope.
870
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
871
+ */
872
+ function computeStructureHash(filePaths) {
873
+ const sorted = [...filePaths].sort();
874
+ const content = sorted.join('\n');
875
+ return createHash('sha256').update(content).digest('hex');
876
+ }
877
+
878
+ /** Sleep for a given number of milliseconds. */
879
+ function sleep(ms) {
880
+ return new Promise((resolve) => setTimeout(resolve, ms));
881
+ }
882
+
883
+ /**
884
+ * MetaExecutor implementation using the OpenClaw gateway HTTP API.
885
+ *
886
+ * Lives in the library package so both plugin and runner can import it.
887
+ * Spawns sub-agent sessions via the gateway's `/tools/invoke` endpoint,
888
+ * polls for completion, and extracts output text.
889
+ *
890
+ * @module executor/GatewayExecutor
891
+ */
892
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
893
+ const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
894
+ /**
895
+ * MetaExecutor that spawns OpenClaw sessions via the gateway's
896
+ * `/tools/invoke` endpoint.
897
+ *
898
+ * Used by both the OpenClaw plugin (in-process tool calls) and the
899
+ * runner/CLI (external invocation). Constructs from `gatewayUrl` and
900
+ * optional `apiKey` — typically sourced from `MetaConfig`.
901
+ */
902
+ class GatewayExecutor {
903
+ gatewayUrl;
904
+ apiKey;
905
+ pollIntervalMs;
906
+ constructor(options = {}) {
907
+ this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
908
+ this.apiKey = options.apiKey;
909
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
910
+ }
911
+ /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
912
+ async invoke(tool, args) {
913
+ const headers = {
914
+ 'Content-Type': 'application/json',
915
+ };
916
+ if (this.apiKey) {
917
+ headers['Authorization'] = 'Bearer ' + this.apiKey;
918
+ }
919
+ const res = await fetch(this.gatewayUrl + '/tools/invoke', {
920
+ method: 'POST',
921
+ headers,
922
+ body: JSON.stringify({ tool, args }),
923
+ });
924
+ if (!res.ok) {
925
+ const text = await res.text();
926
+ throw new Error(`Gateway ${tool} failed: HTTP ${res.status.toString()} - ${text}`);
927
+ }
928
+ const data = (await res.json());
929
+ if (data.ok === false || data.error) {
930
+ throw new Error(`Gateway ${tool} error: ${data.error?.message ?? JSON.stringify(data)}`);
931
+ }
932
+ return data;
933
+ }
934
+ async spawn(task, options) {
935
+ const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000;
936
+ const timeoutMs = timeoutSeconds * 1000;
937
+ const deadline = Date.now() + timeoutMs;
938
+ // Step 1: Spawn the sub-agent session
939
+ const spawnResult = await this.invoke('sessions_spawn', {
940
+ task,
941
+ label: options?.label ?? 'jeeves-meta-synthesis',
942
+ runTimeoutSeconds: timeoutSeconds,
943
+ ...(options?.thinking ? { thinking: options.thinking } : {}),
944
+ ...(options?.model ? { model: options.model } : {}),
945
+ });
946
+ const details = (spawnResult.result?.details ?? spawnResult.result);
947
+ const sessionKey = details?.childSessionKey ?? details?.sessionKey;
948
+ if (typeof sessionKey !== 'string' || !sessionKey) {
949
+ throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
950
+ JSON.stringify(spawnResult));
951
+ }
952
+ // Step 2: Poll for completion via sessions_history
953
+ await sleep(3000);
954
+ while (Date.now() < deadline) {
955
+ try {
956
+ const historyResult = await this.invoke('sessions_history', {
957
+ sessionKey,
958
+ limit: 5,
959
+ includeTools: false,
960
+ });
961
+ const messages = historyResult.result?.details?.messages ??
962
+ historyResult.result?.messages ??
963
+ [];
964
+ const msgArray = messages;
965
+ if (msgArray.length > 0) {
966
+ const lastMsg = msgArray[msgArray.length - 1];
967
+ // Complete when last message is assistant with a terminal stop reason
968
+ if (lastMsg.role === 'assistant' &&
969
+ lastMsg.stopReason &&
970
+ lastMsg.stopReason !== 'toolUse' &&
971
+ lastMsg.stopReason !== 'error') {
972
+ // Sum token usage from all messages
973
+ let tokens;
974
+ let sum = 0;
975
+ for (const msg of msgArray) {
976
+ if (msg.usage?.totalTokens)
977
+ sum += msg.usage.totalTokens;
978
+ }
979
+ if (sum > 0)
980
+ tokens = sum;
981
+ // Find the last assistant message with content
982
+ for (let i = msgArray.length - 1; i >= 0; i--) {
983
+ if (msgArray[i].role === 'assistant' && msgArray[i].content) {
984
+ return { output: msgArray[i].content, tokens };
985
+ }
986
+ }
987
+ return { output: '', tokens };
988
+ }
989
+ }
990
+ }
991
+ catch {
992
+ // Transient poll failure — keep trying
993
+ }
994
+ await sleep(this.pollIntervalMs);
995
+ }
996
+ throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Pino logger factory.
1002
+ *
1003
+ * @module logger
1004
+ */
1005
+ /**
1006
+ * Create a pino logger instance.
1007
+ *
1008
+ * @param config - Optional logger configuration.
1009
+ * @returns Configured pino logger.
1010
+ */
1011
+ function createLogger(config) {
1012
+ const level = config?.level ?? 'info';
1013
+ if (config?.file) {
1014
+ const transport = pino.transport({
1015
+ target: 'pino/file',
1016
+ options: { destination: config.file, mkdir: true },
1017
+ });
1018
+ return pino({ level }, transport);
1019
+ }
1020
+ return pino({ level });
1021
+ }
1022
+
1023
+ /**
1024
+ * Build the MetaContext for a synthesis cycle.
1025
+ *
1026
+ * Computes shared inputs once: scope files, delta files, child meta outputs,
1027
+ * previous content/feedback, steer, and archive paths.
1028
+ *
1029
+ * @module orchestrator/contextPackage
1030
+ */
1031
+ /**
1032
+ * Condense a file list into glob-like summaries.
1033
+ * Groups by directory + extension pattern.
1034
+ *
1035
+ * @param files - Array of file paths.
1036
+ * @param maxIndividual - Show individual files up to this count.
1037
+ * @returns Condensed summary string.
1038
+ */
1039
+ function condenseScopeFiles(files, maxIndividual = 30) {
1040
+ if (files.length <= maxIndividual)
1041
+ return files.join('\n');
1042
+ // Group by dir + extension
1043
+ const groups = new Map();
1044
+ for (const f of files) {
1045
+ const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
1046
+ const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
1047
+ const key = dir + '*' + ext;
1048
+ groups.set(key, (groups.get(key) ?? 0) + 1);
1049
+ }
1050
+ // Sort by count descending
1051
+ const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
1052
+ return sorted
1053
+ .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
1054
+ .join('\n');
1055
+ }
1056
+ /**
1057
+ * Build the context package for a synthesis cycle.
1058
+ *
1059
+ * @param node - The meta node being synthesized.
1060
+ * @param meta - Current meta.json content.
1061
+ * @param watcher - WatcherClient for scope enumeration.
1062
+ * @returns The computed context package.
1063
+ */
1064
+ async function buildContextPackage(node, meta, watcher) {
1065
+ // Scope and delta files via watcher scan
1066
+ const { scopeFiles } = await getScopeFiles(node, watcher);
1067
+ const deltaFiles = await getDeltaFiles(node, watcher, meta._generatedAt, scopeFiles);
1068
+ // Child meta outputs
1069
+ const childMetas = {};
1070
+ for (const child of node.children) {
1071
+ const childMetaFile = join(child.metaPath, 'meta.json');
1072
+ try {
1073
+ const raw = readFileSync(childMetaFile, 'utf8');
1074
+ const childMeta = JSON.parse(raw);
1075
+ childMetas[child.ownerPath] = childMeta._content ?? null;
1076
+ }
1077
+ catch {
1078
+ childMetas[child.ownerPath] = null;
1079
+ }
1080
+ }
1081
+ // Archive paths
1082
+ const archives = listArchiveFiles(node.metaPath);
1083
+ return {
1084
+ path: node.metaPath,
1085
+ scopeFiles,
1086
+ deltaFiles,
1087
+ childMetas,
1088
+ previousContent: meta._content ?? null,
1089
+ previousFeedback: meta._feedback ?? null,
1090
+ steer: meta._steer ?? null,
1091
+ archives,
1092
+ };
1093
+ }
1094
+
1095
+ /**
1096
+ * Build task prompts for each synthesis step.
1097
+ *
1098
+ * @module orchestrator/buildTask
1099
+ */
1100
+ /** Append optional context sections shared across all step prompts. */
1101
+ function appendSharedSections(sections, ctx, options) {
1102
+ const opts = {
1103
+ includeSteer: true,
1104
+ includePreviousContent: true,
1105
+ includePreviousFeedback: true,
1106
+ feedbackHeading: '## PREVIOUS FEEDBACK',
1107
+ includeChildMetas: true,
1108
+ ...options,
1109
+ };
1110
+ if (opts.includeSteer && ctx.steer) {
1111
+ sections.push('', '## STEERING PROMPT', ctx.steer);
1112
+ }
1113
+ if (opts.includePreviousContent && ctx.previousContent) {
1114
+ sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
1115
+ }
1116
+ if (opts.includePreviousFeedback && ctx.previousFeedback) {
1117
+ sections.push('', opts.feedbackHeading, ctx.previousFeedback);
1118
+ }
1119
+ if (opts.includeChildMetas && Object.keys(ctx.childMetas).length > 0) {
1120
+ sections.push('', '## CHILD META OUTPUTS');
1121
+ for (const [childPath, content] of Object.entries(ctx.childMetas)) {
1122
+ sections.push(`### ${childPath}`, typeof content === 'string' ? content : '(not yet synthesized)');
1123
+ }
1124
+ }
1125
+ }
1126
+ /**
1127
+ * Build the architect task prompt.
1128
+ *
1129
+ * @param ctx - Synthesis context.
1130
+ * @param meta - Current meta.json.
1131
+ * @param config - Synthesis config.
1132
+ * @returns The architect task prompt string.
1133
+ */
1134
+ function buildArchitectTask(ctx, meta, config) {
1135
+ const sections = [
1136
+ meta._architect ?? config.defaultArchitect,
1137
+ '',
1138
+ '## SCOPE',
1139
+ `Path: ${ctx.path}`,
1140
+ `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
1141
+ `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
1142
+ '',
1143
+ '### File listing (scope)',
1144
+ condenseScopeFiles(ctx.scopeFiles),
1145
+ ];
1146
+ // Inject previous _builder so architect can see its own prior output
1147
+ if (meta._builder) {
1148
+ sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
1149
+ }
1150
+ appendSharedSections(sections, ctx);
1151
+ if (ctx.archives.length > 0) {
1152
+ sections.push('', '## ARCHIVE HISTORY', `${ctx.archives.length.toString()} previous synthesis snapshots available in .meta/archive/.`, 'Review these to understand how the synthesis has evolved over time.');
1153
+ }
1154
+ return sections.join('\n');
1155
+ }
1156
+ /**
1157
+ * Build the builder task prompt.
1158
+ *
1159
+ * @param ctx - Synthesis context.
1160
+ * @param meta - Current meta.json.
1161
+ * @param config - Synthesis config.
1162
+ * @returns The builder task prompt string.
1163
+ */
1164
+ function buildBuilderTask(ctx, meta, config) {
1165
+ const sections = [
1166
+ '## TASK BRIEF (from Architect)',
1167
+ meta._builder ?? '(No architect brief available)',
1168
+ '',
1169
+ '## SCOPE',
1170
+ `Path: ${ctx.path}`,
1171
+ `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
1172
+ ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
1173
+ ];
1174
+ appendSharedSections(sections, ctx, {
1175
+ includeSteer: false,
1176
+ feedbackHeading: '## FEEDBACK FROM CRITIC',
1177
+ });
1178
+ sections.push('', '## OUTPUT FORMAT', 'Return a JSON object with:', '- "_content": Markdown narrative synthesis (required)', '- Any additional structured fields as non-underscore keys');
1179
+ return sections.join('\n');
1180
+ }
1181
+ /**
1182
+ * Build the critic task prompt.
1183
+ *
1184
+ * @param ctx - Synthesis context.
1185
+ * @param meta - Current meta.json (with _content already set by builder).
1186
+ * @param config - Synthesis config.
1187
+ * @returns The critic task prompt string.
1188
+ */
1189
+ function buildCriticTask(ctx, meta, config) {
1190
+ const sections = [
1191
+ meta._critic ?? config.defaultCritic,
1192
+ '',
1193
+ '## SYNTHESIS TO EVALUATE',
1194
+ meta._content ?? '(No content produced)',
1195
+ '',
1196
+ '## SCOPE',
1197
+ `Path: ${ctx.path}`,
1198
+ `Files in scope: ${ctx.scopeFiles.length.toString()}`,
1199
+ ];
1200
+ appendSharedSections(sections, ctx, {
1201
+ includePreviousContent: false,
1202
+ feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
1203
+ includeChildMetas: false,
1204
+ });
1205
+ sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
1206
+ return sections.join('\n');
1207
+ }
1208
+
1209
+ /**
1210
+ * Structured error from a synthesis step failure.
1211
+ *
1212
+ * @module schema/error
1213
+ */
1214
+ /** Zod schema for synthesis step errors. */
1215
+ const metaErrorSchema = z.object({
1216
+ /** Which step failed: 'architect', 'builder', or 'critic'. */
1217
+ step: z.enum(['architect', 'builder', 'critic']),
1218
+ /** Error classification code. */
1219
+ code: z.string(),
1220
+ /** Human-readable error message. */
1221
+ message: z.string(),
1222
+ });
1223
+
1224
+ /**
1225
+ * Zod schema for .meta/meta.json files.
1226
+ *
1227
+ * Reserved properties are underscore-prefixed and engine-managed.
1228
+ * All other keys are open schema (builder output).
1229
+ *
1230
+ * @module schema/meta
1231
+ */
1232
+ /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
1233
+ const metaJsonSchema = z
1234
+ .object({
1235
+ /** Stable identity. Generated on first synthesis, never changes. */
1236
+ _id: z.uuid(),
1237
+ /** Human-provided steering prompt. Optional. */
1238
+ _steer: z.string().optional(),
1239
+ /** Architect system prompt used this turn. Defaults from config. */
1240
+ _architect: z.string().optional(),
1241
+ /**
1242
+ * Task brief generated by the architect. Cached and reused across cycles;
1243
+ * regenerated only when triggered.
1244
+ */
1245
+ _builder: z.string().optional(),
1246
+ /** Critic system prompt used this turn. Defaults from config. */
1247
+ _critic: z.string().optional(),
1248
+ /** Timestamp of last synthesis. ISO 8601. */
1249
+ _generatedAt: z.iso.datetime().optional(),
1250
+ /** Narrative synthesis output. Rendered by watcher for embedding. */
1251
+ _content: z.string().optional(),
1252
+ /**
1253
+ * Hash of sorted file listing in scope. Detects directory structure
1254
+ * changes that trigger an architect re-run.
1255
+ */
1256
+ _structureHash: z.string().optional(),
1257
+ /**
1258
+ * Cycles since last architect run. Reset to 0 when architect runs.
1259
+ * Used with architectEvery to trigger periodic re-prompting.
1260
+ */
1261
+ _synthesisCount: z.number().int().min(0).optional(),
1262
+ /** Critic evaluation of the last synthesis. */
1263
+ _feedback: z.string().optional(),
1264
+ /**
1265
+ * Present and true on archive snapshots. Distinguishes live vs. archived
1266
+ * metas.
1267
+ */
1268
+ _archived: z.boolean().optional(),
1269
+ /** Timestamp when this snapshot was archived. ISO 8601. */
1270
+ _archivedAt: z.iso.datetime().optional(),
1271
+ /**
1272
+ * Scheduling priority. Higher = updates more often. Negative allowed;
1273
+ * normalized to min 0 at scheduling time.
1274
+ */
1275
+ _depth: z.number().optional(),
1276
+ /**
1277
+ * Emphasis multiplier for depth weighting in scheduling.
1278
+ * Default 1. Higher values increase this meta's scheduling priority
1279
+ * relative to its depth. Set to 0.5 to halve the depth effect,
1280
+ * 2 to double it, 0 to ignore depth entirely for this meta.
1281
+ */
1282
+ _emphasis: z.number().min(0).optional(),
1283
+ /** Token count from last architect subprocess call. */
1284
+ _architectTokens: z.number().int().optional(),
1285
+ /** Token count from last builder subprocess call. */
1286
+ _builderTokens: z.number().int().optional(),
1287
+ /** Token count from last critic subprocess call. */
1288
+ _criticTokens: z.number().int().optional(),
1289
+ /** Exponential moving average of architect token usage (decay 0.3). */
1290
+ _architectTokensAvg: z.number().optional(),
1291
+ /** Exponential moving average of builder token usage (decay 0.3). */
1292
+ _builderTokensAvg: z.number().optional(),
1293
+ /** Exponential moving average of critic token usage (decay 0.3). */
1294
+ _criticTokensAvg: z.number().optional(),
1295
+ /**
1296
+ * Structured error from last cycle. Present when a step failed.
1297
+ * Cleared on successful cycle.
1298
+ */
1299
+ _error: metaErrorSchema.optional(),
1300
+ })
1301
+ .loose();
1302
+
1303
+ /**
1304
+ * Merge synthesis results into meta.json.
1305
+ *
1306
+ * Preserves human-set fields (_id, _steer, _depth).
1307
+ * Writes engine fields (_generatedAt, _structureHash, etc.).
1308
+ * Validates against schema before writing.
1309
+ *
1310
+ * @module orchestrator/merge
1311
+ */
1312
+ /**
1313
+ * Merge results into meta.json and write atomically.
1314
+ *
1315
+ * @param options - Merge options.
1316
+ * @returns The updated MetaJson.
1317
+ * @throws If validation fails (malformed output).
1318
+ */
1319
+ function mergeAndWrite(options) {
1320
+ const merged = {
1321
+ // Preserve human-set fields
1322
+ _id: options.current._id,
1323
+ _steer: options.current._steer,
1324
+ _depth: options.current._depth,
1325
+ _emphasis: options.current._emphasis,
1326
+ // Engine fields
1327
+ _architect: options.architect,
1328
+ _builder: options.builder,
1329
+ _critic: options.critic,
1330
+ _generatedAt: new Date().toISOString(),
1331
+ _structureHash: options.structureHash,
1332
+ _synthesisCount: options.synthesisCount,
1333
+ // Token tracking
1334
+ _architectTokens: options.architectTokens,
1335
+ _builderTokens: options.builderTokens,
1336
+ _criticTokens: options.criticTokens,
1337
+ _architectTokensAvg: options.architectTokens !== undefined
1338
+ ? computeEma(options.architectTokens, options.current._architectTokensAvg)
1339
+ : options.current._architectTokensAvg,
1340
+ _builderTokensAvg: options.builderTokens !== undefined
1341
+ ? computeEma(options.builderTokens, options.current._builderTokensAvg)
1342
+ : options.current._builderTokensAvg,
1343
+ _criticTokensAvg: options.criticTokens !== undefined
1344
+ ? computeEma(options.criticTokens, options.current._criticTokensAvg)
1345
+ : options.current._criticTokensAvg,
1346
+ // Content from builder
1347
+ _content: options.builderOutput?.content ?? options.current._content,
1348
+ // Feedback from critic
1349
+ _feedback: options.feedback ?? options.current._feedback,
1350
+ // Error handling
1351
+ _error: options.error ?? undefined,
1352
+ // Spread structured fields from builder
1353
+ ...options.builderOutput?.fields,
1354
+ };
1355
+ // Clean up undefined optional fields
1356
+ if (merged._steer === undefined)
1357
+ delete merged._steer;
1358
+ if (merged._depth === undefined)
1359
+ delete merged._depth;
1360
+ if (merged._emphasis === undefined)
1361
+ delete merged._emphasis;
1362
+ if (merged._architectTokens === undefined)
1363
+ delete merged._architectTokens;
1364
+ if (merged._builderTokens === undefined)
1365
+ delete merged._builderTokens;
1366
+ if (merged._criticTokens === undefined)
1367
+ delete merged._criticTokens;
1368
+ if (merged._architectTokensAvg === undefined)
1369
+ delete merged._architectTokensAvg;
1370
+ if (merged._builderTokensAvg === undefined)
1371
+ delete merged._builderTokensAvg;
1372
+ if (merged._criticTokensAvg === undefined)
1373
+ delete merged._criticTokensAvg;
1374
+ if (merged._error === undefined)
1375
+ delete merged._error;
1376
+ if (merged._content === undefined)
1377
+ delete merged._content;
1378
+ if (merged._feedback === undefined)
1379
+ delete merged._feedback;
1380
+ // Validate
1381
+ const result = metaJsonSchema.safeParse(merged);
1382
+ if (!result.success) {
1383
+ throw new Error(`Meta validation failed: ${result.error.message}`);
1384
+ }
1385
+ // Write to specified path (lock staging) or default meta.json
1386
+ const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
1387
+ writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
1388
+ return result.data;
1389
+ }
1390
+
1391
+ /**
1392
+ * Weighted staleness formula for candidate selection.
1393
+ *
1394
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1395
+ *
1396
+ * @module scheduling/weightedFormula
1397
+ */
1398
+ /**
1399
+ * Compute effective staleness for a set of candidates.
1400
+ *
1401
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
1402
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1403
+ *
1404
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
1405
+ * metas to tune how much their tree position affects scheduling.
1406
+ *
1407
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
1408
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
1409
+ * @returns Same array with effectiveStaleness computed.
1410
+ */
1411
+ function computeEffectiveStaleness(candidates, depthWeight) {
1412
+ if (candidates.length === 0)
1413
+ return [];
1414
+ // Get depth for each candidate: use _depth override or tree depth
1415
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
1416
+ // Normalize: shift so minimum becomes 0
1417
+ const minDepth = Math.min(...depths);
1418
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
1419
+ return candidates.map((c, i) => {
1420
+ const emphasis = c.meta._emphasis ?? 1;
1421
+ return {
1422
+ ...c,
1423
+ effectiveStaleness: c.actualStaleness *
1424
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
1425
+ };
1426
+ });
1427
+ }
1428
+
1429
+ /**
1430
+ * Select the best synthesis candidate from stale metas.
1431
+ *
1432
+ * Picks the meta with highest effective staleness.
1433
+ *
1434
+ * @module scheduling/selectCandidate
1435
+ */
1436
+ /**
1437
+ * Select the candidate with the highest effective staleness.
1438
+ *
1439
+ * @param candidates - Array of candidates with computed effective staleness.
1440
+ * @returns The winning candidate, or null if no candidates.
1441
+ */
1442
+ function selectCandidate(candidates) {
1443
+ if (candidates.length === 0)
1444
+ return null;
1445
+ let best = candidates[0];
1446
+ for (let i = 1; i < candidates.length; i++) {
1447
+ if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
1448
+ best = candidates[i];
1449
+ }
1450
+ }
1451
+ return best;
1452
+ }
1453
+ /**
1454
+ * Extract stale candidates from a list and return the stalest path.
1455
+ *
1456
+ * Consolidates the repeated pattern of:
1457
+ * filter → computeEffectiveStaleness → selectCandidate → return path
1458
+ *
1459
+ * @param candidates - Array with node, meta, and stalenessSeconds.
1460
+ * @param depthWeight - Depth weighting exponent from config.
1461
+ * @returns The stalest candidate's metaPath, or null if none are stale.
1462
+ */
1463
+ function discoverStalestPath(candidates, depthWeight) {
1464
+ const weighted = computeEffectiveStaleness(candidates, depthWeight);
1465
+ const winner = selectCandidate(weighted);
1466
+ return winner?.node.metaPath ?? null;
1467
+ }
1468
+
1469
+ /**
1470
+ * Staleness detection via watcher scan.
1471
+ *
1472
+ * A meta is stale when any file in its scope was modified after _generatedAt.
1473
+ *
1474
+ * @module scheduling/staleness
1475
+ */
1476
+ /**
1477
+ * Check if a meta is stale by querying the watcher for modified files.
1478
+ *
1479
+ * @param scopePrefix - Path prefix for this meta's scope.
1480
+ * @param meta - Current meta.json content.
1481
+ * @param watcher - WatcherClient instance.
1482
+ * @returns True if any file in scope was modified after _generatedAt.
1483
+ */
1484
+ async function isStale(scopePrefix, meta, watcher) {
1485
+ if (!meta._generatedAt)
1486
+ return true; // Never synthesized = stale
1487
+ const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
1488
+ const result = await watcher.scan({
1489
+ pathPrefix: scopePrefix,
1490
+ modifiedAfter: generatedAtUnix,
1491
+ limit: 1,
1492
+ });
1493
+ return result.files.length > 0;
1494
+ }
1495
+ /**
1496
+ * Compute actual staleness in seconds (now minus _generatedAt).
1497
+ *
1498
+ * @param meta - Current meta.json content.
1499
+ * @returns Staleness in seconds, or Infinity if never synthesized.
1500
+ */
1501
+ function actualStaleness(meta) {
1502
+ if (!meta._generatedAt)
1503
+ return Infinity;
1504
+ const generatedMs = new Date(meta._generatedAt).getTime();
1505
+ return (Date.now() - generatedMs) / 1000;
1506
+ }
1507
+ /**
1508
+ * Check whether the architect step should be triggered.
1509
+ *
1510
+ * @param meta - Current meta.json.
1511
+ * @param structureChanged - Whether the structure hash changed.
1512
+ * @param steerChanged - Whether the steer directive changed.
1513
+ * @param architectEvery - Config: run architect every N cycles.
1514
+ * @returns True if the architect step should run.
1515
+ */
1516
+ function isArchitectTriggered(meta, structureChanged, steerChanged, architectEvery) {
1517
+ return (!meta._builder ||
1518
+ structureChanged ||
1519
+ steerChanged ||
1520
+ (meta._synthesisCount ?? 0) >= architectEvery);
1521
+ }
1522
+ /**
1523
+ * Detect whether the steer directive changed since the last archive.
1524
+ *
1525
+ * @param currentSteer - Current _steer value (or undefined).
1526
+ * @param archiveSteer - Archive _steer value (or undefined).
1527
+ * @param hasArchive - Whether an archive snapshot exists.
1528
+ * @returns True if steer changed.
1529
+ */
1530
+ function hasSteerChanged(currentSteer, archiveSteer, hasArchive) {
1531
+ if (!hasArchive)
1532
+ return Boolean(currentSteer);
1533
+ return currentSteer !== archiveSteer;
1534
+ }
1535
+ /**
1536
+ * Compute a normalized staleness score (0–1) for display purposes.
1537
+ *
1538
+ * Uses the same depth/emphasis weighting as candidate selection,
1539
+ * normalized to a 30-day window.
1540
+ *
1541
+ * @param stalenessSeconds - Raw staleness in seconds (null = never synthesized).
1542
+ * @param depth - Meta tree depth.
1543
+ * @param emphasis - Scheduling emphasis multiplier.
1544
+ * @param depthWeight - Depth weighting exponent from config.
1545
+ * @returns Normalized score between 0 and 1.
1546
+ */
1547
+ function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
1548
+ if (stalenessSeconds === null)
1549
+ return 1;
1550
+ const depthFactor = Math.pow(1 + depthWeight, depth);
1551
+ return Math.min(1, (stalenessSeconds * depthFactor * emphasis) / (30 * 86400));
1552
+ }
1553
+
1554
+ /**
1555
+ * Parse subprocess outputs for each synthesis step.
1556
+ *
1557
+ * - Architect: returns text \> _builder
1558
+ * - Builder: returns JSON \> _content + structured fields
1559
+ * - Critic: returns text \> _feedback
1560
+ *
1561
+ * @module orchestrator/parseOutput
1562
+ */
1563
+ /**
1564
+ * Parse architect output. The architect returns a task brief as text.
1565
+ *
1566
+ * @param output - Raw subprocess output.
1567
+ * @returns The task brief string.
1568
+ */
1569
+ function parseArchitectOutput(output) {
1570
+ return output.trim();
1571
+ }
1572
+ /**
1573
+ * Parse builder output. The builder returns JSON with _content and optional fields.
1574
+ *
1575
+ * Attempts JSON parse first. If that fails, treats the entire output as _content.
1576
+ *
1577
+ * @param output - Raw subprocess output.
1578
+ * @returns Parsed builder output with content and structured fields.
1579
+ */
1580
+ function parseBuilderOutput(output) {
1581
+ const trimmed = output.trim();
1582
+ // Try to extract JSON from the output (may be wrapped in markdown code fences)
1583
+ let jsonStr = trimmed;
1584
+ const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
1585
+ if (fenceMatch) {
1586
+ jsonStr = fenceMatch[1].trim();
1587
+ }
1588
+ try {
1589
+ const parsed = JSON.parse(jsonStr);
1590
+ // Extract _content
1591
+ const content = typeof parsed._content === 'string'
1592
+ ? parsed._content
1593
+ : typeof parsed.content === 'string'
1594
+ ? parsed.content
1595
+ : trimmed;
1596
+ // Extract non-underscore fields
1597
+ const fields = {};
1598
+ for (const [key, value] of Object.entries(parsed)) {
1599
+ if (!key.startsWith('_') && key !== 'content') {
1600
+ fields[key] = value;
1601
+ }
1602
+ }
1603
+ return { content, fields };
1604
+ }
1605
+ catch {
1606
+ // Not valid JSON — treat entire output as content
1607
+ return { content: trimmed, fields: {} };
1608
+ }
1609
+ }
1610
+ /**
1611
+ * Parse critic output. The critic returns evaluation text.
1612
+ *
1613
+ * @param output - Raw subprocess output.
1614
+ * @returns The feedback string.
1615
+ */
1616
+ function parseCriticOutput(output) {
1617
+ return output.trim();
1618
+ }
1619
+
1620
+ /**
1621
+ * Main orchestration function — the 13-step synthesis cycle.
1622
+ *
1623
+ * Wires together discovery, scheduling, archiving, executor calls,
1624
+ * and merge/write-back.
1625
+ *
1626
+ * @module orchestrator/orchestrate
1627
+ */
1628
+ /** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
1629
+ function finalizeCycle(opts) {
1630
+ const lockPath = join(opts.metaPath, '.lock');
1631
+ const metaJsonPath = join(opts.metaPath, 'meta.json');
1632
+ // Stage: write merged result to .lock
1633
+ const updated = mergeAndWrite({
1634
+ metaPath: opts.metaPath,
1635
+ current: opts.current,
1636
+ architect: opts.architect,
1637
+ builder: opts.builder,
1638
+ critic: opts.critic,
1639
+ builderOutput: opts.builderOutput,
1640
+ feedback: opts.feedback,
1641
+ structureHash: opts.structureHash,
1642
+ synthesisCount: opts.synthesisCount,
1643
+ error: opts.error,
1644
+ architectTokens: opts.architectTokens,
1645
+ builderTokens: opts.builderTokens,
1646
+ criticTokens: opts.criticTokens,
1647
+ outputPath: lockPath,
1648
+ });
1649
+ // Commit: copy .lock → meta.json
1650
+ copyFileSync(lockPath, metaJsonPath);
1651
+ // Archive + prune from the committed meta.json
1652
+ createSnapshot(opts.metaPath, updated);
1653
+ pruneArchive(opts.metaPath, opts.config.maxArchive);
1654
+ // .lock is cleaned up by the finally block (releaseLock)
1655
+ return updated;
1656
+ }
1657
+ /**
1658
+ * Run a single synthesis cycle.
1659
+ *
1660
+ * Discovers all metas, selects the stalest candidate, and runs the
1661
+ * three-step synthesis (architect, builder, critic).
1662
+ *
1663
+ * @param config - Validated synthesis config.
1664
+ * @param executor - Pluggable LLM executor.
1665
+ * @param watcher - Watcher HTTP client.
1666
+ * @returns Result indicating whether synthesis occurred.
1667
+ */
1668
+ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress) {
1669
+ // Step 1: Discover via watcher scan
1670
+ const metaPaths = await discoverMetas(config, watcher);
1671
+ if (metaPaths.length === 0)
1672
+ return { synthesized: false };
1673
+ // Read meta.json for each discovered meta
1674
+ const metas = new Map();
1675
+ for (const mp of metaPaths) {
1676
+ const metaFilePath = join(mp, 'meta.json');
1677
+ try {
1678
+ metas.set(normalizePath(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
1679
+ }
1680
+ catch {
1681
+ // Skip metas with unreadable meta.json
1682
+ continue;
1683
+ }
1684
+ }
1685
+ // Only build tree from paths with readable meta.json (excludes orphaned/deleted entries)
1686
+ const validPaths = metaPaths.filter((mp) => metas.has(normalizePath(mp)));
1687
+ if (validPaths.length === 0)
1688
+ return { synthesized: false };
1689
+ const tree = buildOwnershipTree(validPaths);
1690
+ // If targetPath specified, skip candidate selection — go directly to that meta
1691
+ let targetNode;
1692
+ if (targetPath) {
1693
+ const normalized = normalizePath(targetPath);
1694
+ targetNode = findNode(tree, normalized) ?? undefined;
1695
+ if (!targetNode)
1696
+ return { synthesized: false };
1697
+ }
1698
+ // Steps 3-4: Staleness check + candidate selection
1699
+ const candidates = [];
1700
+ for (const node of tree.nodes.values()) {
1701
+ const meta = metas.get(node.metaPath);
1702
+ if (!meta)
1703
+ continue; // Node not in metas map (e.g. unreadable meta.json)
1704
+ const staleness = actualStaleness(meta);
1705
+ if (staleness > 0) {
1706
+ candidates.push({ node, meta, actualStaleness: staleness });
1707
+ }
1708
+ }
1709
+ const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
1710
+ // Sort by effective staleness descending
1711
+ const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
1712
+ if (ranked.length === 0)
1713
+ return { synthesized: false };
1714
+ // Find the first candidate with actual changes (if skipUnchanged)
1715
+ let winner = null;
1716
+ for (const candidate of ranked) {
1717
+ if (!acquireLock(candidate.node.metaPath))
1718
+ continue;
1719
+ const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
1720
+ if (!verifiedStale && candidate.meta._generatedAt) {
1721
+ // Bump _generatedAt so it doesn't win next cycle
1722
+ const metaFilePath = join(candidate.node.metaPath, 'meta.json');
1723
+ const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
1724
+ freshMeta._generatedAt = new Date().toISOString();
1725
+ writeFileSync(metaFilePath, JSON.stringify(freshMeta, null, 2));
1726
+ releaseLock(candidate.node.metaPath);
1727
+ if (config.skipUnchanged)
1728
+ continue;
1729
+ return { synthesized: false };
1730
+ }
1731
+ winner = candidate;
1732
+ break;
1733
+ }
1734
+ if (!winner && !targetNode)
1735
+ return { synthesized: false };
1736
+ const node = targetNode ?? winner.node;
1737
+ // For targeted path, acquire lock now (candidate selection already locked for stalest)
1738
+ if (targetNode && !acquireLock(node.metaPath)) {
1739
+ return { synthesized: false };
1740
+ }
1741
+ try {
1742
+ // Re-read meta after lock (may have changed)
1743
+ const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
1744
+ const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1745
+ const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1746
+ // Step 5-6: Steer change detection
1747
+ const latestArchive = readLatestArchive(node.metaPath);
1748
+ const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1749
+ // Step 7: Compute context (includes scope files and delta files)
1750
+ const ctx = await buildContextPackage(node, currentMeta, watcher);
1751
+ // Step 5 (deferred): Structure hash from context scope files
1752
+ const newStructureHash = computeStructureHash(ctx.scopeFiles);
1753
+ const structureChanged = newStructureHash !== currentMeta._structureHash;
1754
+ // Step 8: Architect (conditional)
1755
+ const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
1756
+ let builderBrief = currentMeta._builder ?? '';
1757
+ let synthesisCount = currentMeta._synthesisCount ?? 0;
1758
+ let stepError = null;
1759
+ let architectTokens;
1760
+ let builderTokens;
1761
+ let criticTokens;
1762
+ if (architectTriggered) {
1763
+ try {
1764
+ await onProgress?.({
1765
+ type: 'phase_start',
1766
+ metaPath: node.metaPath,
1767
+ phase: 'architect',
1768
+ });
1769
+ const phaseStart = Date.now();
1770
+ const architectTask = buildArchitectTask(ctx, currentMeta, config);
1771
+ const architectResult = await executor.spawn(architectTask, {
1772
+ thinking: config.thinking,
1773
+ timeout: config.architectTimeout,
1774
+ });
1775
+ builderBrief = parseArchitectOutput(architectResult.output);
1776
+ architectTokens = architectResult.tokens;
1777
+ synthesisCount = 0;
1778
+ await onProgress?.({
1779
+ type: 'phase_complete',
1780
+ metaPath: node.metaPath,
1781
+ phase: 'architect',
1782
+ tokens: architectTokens,
1783
+ durationMs: Date.now() - phaseStart,
1784
+ });
1785
+ }
1786
+ catch (err) {
1787
+ stepError = toMetaError('architect', err);
1788
+ if (!currentMeta._builder) {
1789
+ // No cached builder — cycle fails
1790
+ finalizeCycle({
1791
+ metaPath: node.metaPath,
1792
+ current: currentMeta,
1793
+ config,
1794
+ architect: architectPrompt,
1795
+ builder: '',
1796
+ critic: criticPrompt,
1797
+ builderOutput: null,
1798
+ feedback: null,
1799
+ structureHash: newStructureHash,
1800
+ synthesisCount,
1801
+ error: stepError,
1802
+ architectTokens,
1803
+ });
1804
+ return {
1805
+ synthesized: true,
1806
+ metaPath: node.metaPath,
1807
+ error: stepError,
1808
+ };
1809
+ }
1810
+ // Has cached builder — continue with existing
1811
+ }
1812
+ }
1813
+ // Step 9: Builder
1814
+ const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1815
+ let builderOutput = null;
1816
+ try {
1817
+ await onProgress?.({
1818
+ type: 'phase_start',
1819
+ metaPath: node.metaPath,
1820
+ phase: 'builder',
1821
+ });
1822
+ const builderStart = Date.now();
1823
+ const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1824
+ const builderResult = await executor.spawn(builderTask, {
1825
+ thinking: config.thinking,
1826
+ timeout: config.builderTimeout,
1827
+ });
1828
+ builderOutput = parseBuilderOutput(builderResult.output);
1829
+ builderTokens = builderResult.tokens;
1830
+ synthesisCount++;
1831
+ await onProgress?.({
1832
+ type: 'phase_complete',
1833
+ metaPath: node.metaPath,
1834
+ phase: 'builder',
1835
+ tokens: builderTokens,
1836
+ durationMs: Date.now() - builderStart,
1837
+ });
1838
+ }
1839
+ catch (err) {
1840
+ stepError = toMetaError('builder', err);
1841
+ return { synthesized: true, metaPath: node.metaPath, error: stepError };
1842
+ }
1843
+ // Step 10: Critic
1844
+ const metaForCritic = {
1845
+ ...currentMeta,
1846
+ _content: builderOutput.content,
1847
+ };
1848
+ let feedback = null;
1849
+ try {
1850
+ await onProgress?.({
1851
+ type: 'phase_start',
1852
+ metaPath: node.metaPath,
1853
+ phase: 'critic',
1854
+ });
1855
+ const criticStart = Date.now();
1856
+ const criticTask = buildCriticTask(ctx, metaForCritic, config);
1857
+ const criticResult = await executor.spawn(criticTask, {
1858
+ thinking: config.thinking,
1859
+ timeout: config.criticTimeout,
1860
+ });
1861
+ feedback = parseCriticOutput(criticResult.output);
1862
+ criticTokens = criticResult.tokens;
1863
+ stepError = null; // Clear any architect error on full success
1864
+ await onProgress?.({
1865
+ type: 'phase_complete',
1866
+ metaPath: node.metaPath,
1867
+ phase: 'critic',
1868
+ tokens: criticTokens,
1869
+ durationMs: Date.now() - criticStart,
1870
+ });
1871
+ }
1872
+ catch (err) {
1873
+ stepError = stepError ?? toMetaError('critic', err);
1874
+ }
1875
+ // Steps 11-12: Merge, archive, prune
1876
+ finalizeCycle({
1877
+ metaPath: node.metaPath,
1878
+ current: currentMeta,
1879
+ config,
1880
+ architect: architectPrompt,
1881
+ builder: builderBrief,
1882
+ critic: criticPrompt,
1883
+ builderOutput,
1884
+ feedback,
1885
+ structureHash: newStructureHash,
1886
+ synthesisCount,
1887
+ error: stepError,
1888
+ architectTokens,
1889
+ builderTokens,
1890
+ criticTokens,
1891
+ });
1892
+ return {
1893
+ synthesized: true,
1894
+ metaPath: node.metaPath,
1895
+ error: stepError ?? undefined,
1896
+ };
1897
+ }
1898
+ finally {
1899
+ // Step 13: Release lock
1900
+ releaseLock(node.metaPath);
1901
+ }
1902
+ }
1903
+ /**
1904
+ * Run a single synthesis cycle.
1905
+ *
1906
+ * Selects the stalest candidate (or a specific target) and runs the
1907
+ * full architect/builder/critic pipeline.
1908
+ *
1909
+ * @param config - Validated synthesis config.
1910
+ * @param executor - Pluggable LLM executor.
1911
+ * @param watcher - Watcher HTTP client.
1912
+ * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
1913
+ * @returns Array with a single result.
1914
+ */
1915
+ async function orchestrate(config, executor, watcher, targetPath, onProgress) {
1916
+ const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
1917
+ return [result];
1918
+ }
1919
+
1920
+ /**
1921
+ * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
1922
+ *
1923
+ * @module progress
1924
+ */
1925
+ function formatSeconds(durationMs) {
1926
+ const seconds = durationMs / 1000;
1927
+ return seconds.toFixed(1) + 's';
1928
+ }
1929
+ function titleCasePhase(phase) {
1930
+ return phase.charAt(0).toUpperCase() + phase.slice(1);
1931
+ }
1932
+ function formatProgressEvent(event) {
1933
+ switch (event.type) {
1934
+ case 'synthesis_start':
1935
+ return `🔬 Started meta synthesis: ${event.metaPath}`;
1936
+ case 'phase_start': {
1937
+ if (!event.phase) {
1938
+ return ` ⚙️ Phase started: ${event.metaPath}`;
1939
+ }
1940
+ return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
1941
+ }
1942
+ case 'phase_complete': {
1943
+ const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
1944
+ const tokens = event.tokens ?? 0;
1945
+ const duration = event.durationMs !== undefined
1946
+ ? formatSeconds(event.durationMs)
1947
+ : '0.0s';
1948
+ return ` ✅ ${phase} phase complete (${String(tokens)} tokens / ${duration})`;
1949
+ }
1950
+ case 'synthesis_complete': {
1951
+ const tokens = event.tokens ?? 0;
1952
+ const duration = event.durationMs !== undefined
1953
+ ? formatSeconds(event.durationMs)
1954
+ : '0.0s';
1955
+ return `✅ Completed: ${event.metaPath} (${String(tokens)} tokens / ${duration})`;
1956
+ }
1957
+ case 'error': {
1958
+ const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
1959
+ const error = event.error ?? 'Unknown error';
1960
+ return `❌ Synthesis failed at ${phase}phase: ${event.metaPath}\n Error: ${error}`;
1961
+ }
1962
+ default: {
1963
+ return 'Unknown progress event';
1964
+ }
1965
+ }
1966
+ }
1967
+ class ProgressReporter {
1968
+ config;
1969
+ logger;
1970
+ constructor(config, logger) {
1971
+ this.config = config;
1972
+ this.logger = logger;
1973
+ }
1974
+ async report(event) {
1975
+ const target = this.config.reportChannel;
1976
+ if (!target)
1977
+ return;
1978
+ const message = formatProgressEvent(event);
1979
+ const url = new URL('/tools/invoke', this.config.gatewayUrl);
1980
+ const payload = {
1981
+ tool: 'message',
1982
+ args: {
1983
+ action: 'send',
1984
+ target,
1985
+ message,
1986
+ },
1987
+ };
1988
+ try {
1989
+ const res = await fetch(url, {
1990
+ method: 'POST',
1991
+ headers: {
1992
+ 'content-type': 'application/json',
1993
+ ...(this.config.gatewayApiKey
1994
+ ? { authorization: `Bearer ${this.config.gatewayApiKey}` }
1995
+ : {}),
1996
+ },
1997
+ body: JSON.stringify(payload),
1998
+ });
1999
+ if (!res.ok) {
2000
+ const text = await res.text().catch(() => '');
2001
+ this.logger.warn({ status: res.status, statusText: res.statusText, body: text }, 'Progress reporting failed');
2002
+ }
2003
+ }
2004
+ catch (err) {
2005
+ this.logger.warn({ err }, 'Progress reporting threw');
2006
+ }
2007
+ }
2008
+ }
2009
+
2010
+ /**
2011
+ * Croner-based scheduler that discovers the stalest meta candidate each tick
2012
+ * and enqueues it for synthesis.
2013
+ *
2014
+ * @module scheduler
2015
+ */
2016
+ const MAX_BACKOFF_MULTIPLIER = 4;
2017
+ /**
2018
+ * Periodic scheduler that discovers stale meta candidates and enqueues them.
2019
+ *
2020
+ * Supports adaptive backoff when no candidates are found and hot-reloadable
2021
+ * cron expressions via {@link Scheduler.updateSchedule}.
2022
+ */
2023
+ class Scheduler {
2024
+ job = null;
2025
+ backoffMultiplier = 1;
2026
+ tickCount = 0;
2027
+ config;
2028
+ queue;
2029
+ logger;
2030
+ watcher;
2031
+ registrar = null;
2032
+ currentExpression;
2033
+ constructor(config, queue, logger, watcher) {
2034
+ this.config = config;
2035
+ this.queue = queue;
2036
+ this.logger = logger;
2037
+ this.watcher = watcher;
2038
+ this.currentExpression = config.schedule;
2039
+ }
2040
+ /** Set the rule registrar for watcher restart detection. */
2041
+ setRegistrar(registrar) {
2042
+ this.registrar = registrar;
2043
+ }
2044
+ /** Start the cron job. */
2045
+ start() {
2046
+ if (this.job)
2047
+ return;
2048
+ this.job = new Cron(this.currentExpression, () => {
2049
+ void this.tick();
2050
+ });
2051
+ this.logger.info({ schedule: this.currentExpression }, 'Scheduler started');
2052
+ }
2053
+ /** Stop the cron job. */
2054
+ stop() {
2055
+ if (!this.job)
2056
+ return;
2057
+ this.job.stop();
2058
+ this.job = null;
2059
+ this.backoffMultiplier = 1;
2060
+ this.logger.info('Scheduler stopped');
2061
+ }
2062
+ /** Hot-reload the cron schedule expression. */
2063
+ updateSchedule(expression) {
2064
+ this.currentExpression = expression;
2065
+ if (this.job) {
2066
+ this.job.stop();
2067
+ this.job = new Cron(expression, () => {
2068
+ void this.tick();
2069
+ });
2070
+ this.logger.info({ schedule: expression }, 'Schedule updated');
2071
+ }
2072
+ }
2073
+ /** Reset backoff multiplier (call after successful synthesis). */
2074
+ resetBackoff() {
2075
+ if (this.backoffMultiplier > 1) {
2076
+ this.logger.debug('Backoff reset after successful synthesis');
2077
+ }
2078
+ this.backoffMultiplier = 1;
2079
+ }
2080
+ /** Whether the scheduler is currently running. */
2081
+ get isRunning() {
2082
+ return this.job !== null;
2083
+ }
2084
+ /** Next scheduled tick time, or null if not running. */
2085
+ get nextRunAt() {
2086
+ if (!this.job)
2087
+ return null;
2088
+ return this.job.nextRun() ?? null;
2089
+ }
2090
+ /**
2091
+ * Single tick: discover stalest candidate and enqueue it.
2092
+ *
2093
+ * Skips if the queue is currently processing. Applies adaptive backoff
2094
+ * when no candidates are found.
2095
+ */
2096
+ async tick() {
2097
+ this.tickCount++;
2098
+ // Apply backoff: skip ticks when backing off
2099
+ if (this.backoffMultiplier > 1 &&
2100
+ this.tickCount % this.backoffMultiplier !== 0) {
2101
+ this.logger.trace({
2102
+ backoffMultiplier: this.backoffMultiplier,
2103
+ tickCount: this.tickCount,
2104
+ }, 'Skipping tick (backoff)');
2105
+ return;
2106
+ }
2107
+ const candidate = await this.discoverStalest();
2108
+ if (!candidate) {
2109
+ this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
2110
+ this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No stale candidates found, increasing backoff');
2111
+ return;
2112
+ }
2113
+ this.queue.enqueue(candidate);
2114
+ this.logger.info({ path: candidate }, 'Enqueued stale candidate');
2115
+ // Opportunistic watcher restart detection
2116
+ if (this.registrar) {
2117
+ try {
2118
+ const statusRes = await fetch(new URL('/status', this.config.watcherUrl), {
2119
+ signal: AbortSignal.timeout(3000),
2120
+ });
2121
+ if (statusRes.ok) {
2122
+ const status = (await statusRes.json());
2123
+ if (typeof status.uptime === 'number') {
2124
+ await this.registrar.checkAndReregister(status.uptime);
2125
+ }
2126
+ }
2127
+ }
2128
+ catch {
2129
+ // Watcher unreachable — skip uptime check
2130
+ }
2131
+ }
2132
+ }
2133
+ /**
2134
+ * Discover the stalest meta candidate via watcher.
2135
+ */
2136
+ async discoverStalest() {
2137
+ try {
2138
+ const result = await listMetas(this.config, this.watcher);
2139
+ const stale = result.entries
2140
+ .filter((e) => e.stalenessSeconds > 0)
2141
+ .map((e) => ({
2142
+ node: e.node,
2143
+ meta: e.meta,
2144
+ actualStaleness: e.stalenessSeconds,
2145
+ }));
2146
+ return discoverStalestPath(stale, this.config.depthWeight);
2147
+ }
2148
+ catch (err) {
2149
+ this.logger.warn({ err }, 'Failed to discover stalest candidate');
2150
+ return null;
2151
+ }
2152
+ }
2153
+ }
2154
+
2155
+ /**
2156
+ * Single-threaded synthesis queue with priority support and deduplication.
2157
+ *
2158
+ * The scheduler enqueues the stalest candidate each tick. HTTP-triggered
2159
+ * synthesis requests get priority (inserted at front). A path appears at
2160
+ * most once in the queue; re-triggering returns the current position.
2161
+ *
2162
+ * @module queue
2163
+ */
2164
+ const DEPTH_WARNING_THRESHOLD = 3;
2165
+ /**
2166
+ * Single-threaded synthesis queue.
2167
+ *
2168
+ * Only one synthesis runs at a time. Priority items are inserted at the
2169
+ * front of the queue. Duplicate paths are rejected with their current
2170
+ * position returned.
2171
+ */
2172
+ class SynthesisQueue {
2173
+ queue = [];
2174
+ currentItem = null;
2175
+ processing = false;
2176
+ logger;
2177
+ onEnqueueCallback = null;
2178
+ /**
2179
+ * Create a new SynthesisQueue.
2180
+ *
2181
+ * @param logger - Pino logger instance.
2182
+ */
2183
+ constructor(logger) {
2184
+ this.logger = logger;
2185
+ }
2186
+ /**
2187
+ * Set a callback to invoke when a new (non-duplicate) item is enqueued.
2188
+ */
2189
+ onEnqueue(callback) {
2190
+ this.onEnqueueCallback = callback;
2191
+ }
2192
+ /**
2193
+ * Add a path to the synthesis queue.
2194
+ *
2195
+ * @param path - Meta path to synthesize.
2196
+ * @param priority - If true, insert at front of queue.
2197
+ * @returns Position and whether the path was already queued.
2198
+ */
2199
+ enqueue(path, priority = false) {
2200
+ // Check if currently being synthesized.
2201
+ if (this.currentItem?.path === path) {
2202
+ return { position: 0, alreadyQueued: true };
2203
+ }
2204
+ // Check if already in queue.
2205
+ const existingIndex = this.queue.findIndex((item) => item.path === path);
2206
+ if (existingIndex !== -1) {
2207
+ return { position: existingIndex, alreadyQueued: true };
2208
+ }
2209
+ const item = {
2210
+ path,
2211
+ priority,
2212
+ enqueuedAt: new Date().toISOString(),
2213
+ };
2214
+ if (priority) {
2215
+ this.queue.unshift(item);
2216
+ }
2217
+ else {
2218
+ this.queue.push(item);
2219
+ }
2220
+ if (this.queue.length > DEPTH_WARNING_THRESHOLD) {
2221
+ this.logger.warn({ depth: this.queue.length }, 'Queue depth exceeds threshold');
2222
+ }
2223
+ const position = this.queue.findIndex((i) => i.path === path);
2224
+ this.onEnqueueCallback?.();
2225
+ return { position, alreadyQueued: false };
2226
+ }
2227
+ /**
2228
+ * Remove and return the next item from the queue.
2229
+ *
2230
+ * @returns The next QueueItem, or undefined if the queue is empty.
2231
+ */
2232
+ dequeue() {
2233
+ const item = this.queue.shift();
2234
+ if (item) {
2235
+ this.currentItem = item;
2236
+ }
2237
+ return item;
2238
+ }
2239
+ /** Mark the currently-running synthesis as complete. */
2240
+ complete() {
2241
+ this.currentItem = null;
2242
+ }
2243
+ /** Number of items waiting in the queue (excludes current). */
2244
+ get depth() {
2245
+ return this.queue.length;
2246
+ }
2247
+ /** The item currently being synthesized, or null. */
2248
+ get current() {
2249
+ return this.currentItem;
2250
+ }
2251
+ /** A shallow copy of the queued items. */
2252
+ get items() {
2253
+ return [...this.queue];
2254
+ }
2255
+ /**
2256
+ * Check whether a path is in the queue or currently being synthesized.
2257
+ *
2258
+ * @param path - Meta path to look up.
2259
+ * @returns True if the path is queued or currently running.
2260
+ */
2261
+ has(path) {
2262
+ if (this.currentItem?.path === path)
2263
+ return true;
2264
+ return this.queue.some((item) => item.path === path);
2265
+ }
2266
+ /**
2267
+ * Get the 0-indexed position of a path in the queue.
2268
+ *
2269
+ * @param path - Meta path to look up.
2270
+ * @returns Position index, or null if not found in the queue.
2271
+ */
2272
+ getPosition(path) {
2273
+ const index = this.queue.findIndex((item) => item.path === path);
2274
+ return index === -1 ? null : index;
2275
+ }
2276
+ /**
2277
+ * Return a snapshot of queue state for the /status endpoint.
2278
+ *
2279
+ * @returns Queue depth and item list.
2280
+ */
2281
+ getState() {
2282
+ return {
2283
+ depth: this.queue.length,
2284
+ items: this.queue.map((item) => ({
2285
+ path: item.path,
2286
+ priority: item.priority,
2287
+ enqueuedAt: item.enqueuedAt,
2288
+ })),
2289
+ };
2290
+ }
2291
+ /**
2292
+ * Process queued items one at a time until the queue is empty.
2293
+ *
2294
+ * Re-entry is prevented: if already processing, the call returns
2295
+ * immediately. Errors are logged and do not block subsequent items.
2296
+ *
2297
+ * @param synthesizeFn - Async function that performs synthesis for a path.
2298
+ */
2299
+ async processQueue(synthesizeFn) {
2300
+ if (this.processing)
2301
+ return;
2302
+ this.processing = true;
2303
+ try {
2304
+ let item = this.dequeue();
2305
+ while (item) {
2306
+ try {
2307
+ await synthesizeFn(item.path);
2308
+ }
2309
+ catch (err) {
2310
+ this.logger.error({ path: item.path, err }, 'Synthesis failed');
2311
+ }
2312
+ this.complete();
2313
+ item = this.dequeue();
2314
+ }
2315
+ }
2316
+ finally {
2317
+ this.processing = false;
2318
+ }
2319
+ }
2320
+ }
2321
+
2322
+ /**
2323
+ * GET /config/validate — return sanitized service configuration.
2324
+ *
2325
+ * @module routes/configValidate
2326
+ */
2327
+ function registerConfigValidateRoute(app, deps) {
2328
+ app.get('/config/validate', () => {
2329
+ const sanitized = {
2330
+ ...deps.config,
2331
+ gatewayApiKey: deps.config.gatewayApiKey ? '[REDACTED]' : undefined,
2332
+ };
2333
+ return sanitized;
2334
+ });
2335
+ }
2336
+
2337
+ /**
2338
+ * GET /metas — list metas with optional filters.
2339
+ * GET /metas/:path — single meta detail.
2340
+ *
2341
+ * @module routes/metas
2342
+ */
2343
+ const metasQuerySchema = z.object({
2344
+ pathPrefix: z.string().optional(),
2345
+ hasError: z
2346
+ .enum(['true', 'false'])
2347
+ .transform((v) => v === 'true')
2348
+ .optional(),
2349
+ staleHours: z
2350
+ .string()
2351
+ .transform(Number)
2352
+ .pipe(z.number().positive())
2353
+ .optional(),
2354
+ neverSynthesized: z
2355
+ .enum(['true', 'false'])
2356
+ .transform((v) => v === 'true')
2357
+ .optional(),
2358
+ locked: z
2359
+ .enum(['true', 'false'])
2360
+ .transform((v) => v === 'true')
2361
+ .optional(),
2362
+ fields: z.string().optional(),
2363
+ });
2364
+ const metaDetailQuerySchema = z.object({
2365
+ fields: z.string().optional(),
2366
+ includeArchive: z
2367
+ .union([
2368
+ z.enum(['true', 'false']).transform((v) => v === 'true'),
2369
+ z.string().transform(Number).pipe(z.number().int().nonnegative()),
2370
+ ])
2371
+ .optional(),
2372
+ });
2373
+ /** Compute summary stats from a filtered set of MetaEntries. */
2374
+ function computeFilteredSummary(entries) {
2375
+ let staleCount = 0;
2376
+ let errorCount = 0;
2377
+ let neverSynthCount = 0;
2378
+ let stalestPath = null;
2379
+ let stalestSeconds = -1;
2380
+ let lastSynthesizedPath = null;
2381
+ let lastSynthesizedAt = null;
2382
+ let totalArchitectTokens = 0;
2383
+ let totalBuilderTokens = 0;
2384
+ let totalCriticTokens = 0;
2385
+ for (const e of entries) {
2386
+ if (e.stalenessSeconds > 0)
2387
+ staleCount++;
2388
+ if (e.hasError)
2389
+ errorCount++;
2390
+ if (e.stalenessSeconds === Infinity)
2391
+ neverSynthCount++;
2392
+ if (e.stalenessSeconds > stalestSeconds) {
2393
+ stalestSeconds = e.stalenessSeconds;
2394
+ stalestPath = e.path;
2395
+ }
2396
+ if (e.lastSynthesized &&
2397
+ (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
2398
+ lastSynthesizedAt = e.lastSynthesized;
2399
+ lastSynthesizedPath = e.path;
2400
+ }
2401
+ totalArchitectTokens += e.architectTokens ?? 0;
2402
+ totalBuilderTokens += e.builderTokens ?? 0;
2403
+ totalCriticTokens += e.criticTokens ?? 0;
2404
+ }
2405
+ return {
2406
+ total: entries.length,
2407
+ stale: staleCount,
2408
+ errors: errorCount,
2409
+ neverSynthesized: neverSynthCount,
2410
+ stalestPath,
2411
+ lastSynthesizedPath,
2412
+ lastSynthesizedAt,
2413
+ tokens: {
2414
+ architect: totalArchitectTokens,
2415
+ builder: totalBuilderTokens,
2416
+ critic: totalCriticTokens,
2417
+ },
2418
+ };
2419
+ }
2420
+ function registerMetasRoutes(app, deps) {
2421
+ app.get('/metas', async (request) => {
2422
+ const query = metasQuerySchema.parse(request.query);
2423
+ const { config, watcher } = deps;
2424
+ const result = await listMetas(config, watcher);
2425
+ let entries = result.entries;
2426
+ // Apply filters
2427
+ if (query.pathPrefix) {
2428
+ entries = entries.filter((e) => e.path.includes(query.pathPrefix));
2429
+ }
2430
+ if (query.hasError !== undefined) {
2431
+ entries = entries.filter((e) => e.hasError === query.hasError);
2432
+ }
2433
+ if (query.neverSynthesized !== undefined) {
2434
+ entries = entries.filter((e) => (e.stalenessSeconds === Infinity) === query.neverSynthesized);
2435
+ }
2436
+ if (query.locked !== undefined) {
2437
+ entries = entries.filter((e) => e.locked === query.locked);
2438
+ }
2439
+ if (typeof query.staleHours === 'number') {
2440
+ entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
2441
+ }
2442
+ // Summary (computed from filtered entries)
2443
+ const summary = computeFilteredSummary(entries);
2444
+ // Field projection
2445
+ const fieldList = query.fields?.split(',');
2446
+ const defaultFields = [
2447
+ 'path',
2448
+ 'depth',
2449
+ 'emphasis',
2450
+ 'stalenessSeconds',
2451
+ 'lastSynthesized',
2452
+ 'hasError',
2453
+ 'locked',
2454
+ 'architectTokens',
2455
+ 'builderTokens',
2456
+ 'criticTokens',
2457
+ ];
2458
+ const projectedFields = fieldList ?? defaultFields;
2459
+ const metas = entries.map((e) => {
2460
+ const full = {
2461
+ path: e.path,
2462
+ depth: e.depth,
2463
+ emphasis: e.emphasis,
2464
+ stalenessSeconds: e.stalenessSeconds === Infinity
2465
+ ? null
2466
+ : Math.round(e.stalenessSeconds),
2467
+ lastSynthesized: e.lastSynthesized,
2468
+ hasError: e.hasError,
2469
+ locked: e.locked,
2470
+ architectTokens: e.architectTokens,
2471
+ builderTokens: e.builderTokens,
2472
+ criticTokens: e.criticTokens,
2473
+ };
2474
+ const projected = {};
2475
+ for (const f of projectedFields) {
2476
+ if (f in full)
2477
+ projected[f] = full[f];
2478
+ }
2479
+ return projected;
2480
+ });
2481
+ return { summary, metas };
2482
+ });
2483
+ app.get('/metas/:path', async (request, reply) => {
2484
+ const query = metaDetailQuerySchema.parse(request.query);
2485
+ const { config, watcher } = deps;
2486
+ const targetPath = normalizePath(decodeURIComponent(request.params.path));
2487
+ const result = await listMetas(config, watcher);
2488
+ const targetNode = findNode(result.tree, targetPath);
2489
+ if (!targetNode) {
2490
+ return reply.status(404).send({
2491
+ error: 'NOT_FOUND',
2492
+ message: 'Meta path not found: ' + targetPath,
2493
+ });
2494
+ }
2495
+ const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2496
+ // Field projection
2497
+ const defaultExclude = new Set([
2498
+ '_architect',
2499
+ '_builder',
2500
+ '_critic',
2501
+ '_content',
2502
+ '_feedback',
2503
+ ]);
2504
+ const fieldList = query.fields?.split(',');
2505
+ const projectMeta = (m) => {
2506
+ if (fieldList) {
2507
+ const r = {};
2508
+ for (const f of fieldList)
2509
+ r[f] = m[f];
2510
+ return r;
2511
+ }
2512
+ const r = {};
2513
+ for (const [k, v] of Object.entries(m)) {
2514
+ if (!defaultExclude.has(k))
2515
+ r[k] = v;
2516
+ }
2517
+ return r;
2518
+ };
2519
+ // Compute scope
2520
+ const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
2521
+ // Compute staleness
2522
+ const metaTyped = meta;
2523
+ const staleSeconds = metaTyped._generatedAt
2524
+ ? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
2525
+ : null;
2526
+ const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
2527
+ const response = {
2528
+ path: targetNode.metaPath,
2529
+ meta: projectMeta(meta),
2530
+ scope: {
2531
+ ownedFiles: scopeFiles.length,
2532
+ childMetas: targetNode.children.length,
2533
+ totalFiles: allFiles.length,
2534
+ },
2535
+ staleness: {
2536
+ seconds: staleSeconds,
2537
+ score: Math.round(score * 100) / 100,
2538
+ },
2539
+ };
2540
+ // Archive
2541
+ if (query.includeArchive) {
2542
+ const archiveFiles = listArchiveFiles(targetNode.metaPath);
2543
+ const limit = typeof query.includeArchive === 'number'
2544
+ ? query.includeArchive
2545
+ : archiveFiles.length;
2546
+ const selected = archiveFiles.slice(-limit).reverse();
2547
+ response.archive = selected.map((af) => {
2548
+ const raw = readFileSync(af, 'utf8');
2549
+ return projectMeta(JSON.parse(raw));
2550
+ });
2551
+ }
2552
+ return response;
2553
+ });
2554
+ }
2555
+
2556
+ /**
2557
+ * GET /preview — dry-run synthesis preview.
2558
+ *
2559
+ * @module routes/preview
2560
+ */
2561
+ function registerPreviewRoute(app, deps) {
2562
+ app.get('/preview', async (request, reply) => {
2563
+ const { config, watcher } = deps;
2564
+ const query = request.query;
2565
+ let result;
2566
+ try {
2567
+ result = await listMetas(config, watcher);
2568
+ }
2569
+ catch {
2570
+ return reply.status(503).send({
2571
+ error: 'SERVICE_UNAVAILABLE',
2572
+ message: 'Watcher unreachable — cannot compute preview',
2573
+ });
2574
+ }
2575
+ let targetNode;
2576
+ if (query.path) {
2577
+ const normalized = normalizePath(query.path);
2578
+ targetNode = findNode(result.tree, normalized);
2579
+ if (!targetNode) {
2580
+ return {
2581
+ error: 'NOT_FOUND',
2582
+ message: 'Meta path not found: ' + query.path,
2583
+ };
2584
+ }
2585
+ }
2586
+ else {
2587
+ // Select stalest candidate
2588
+ const stale = result.entries
2589
+ .filter((e) => e.stalenessSeconds > 0)
2590
+ .map((e) => ({
2591
+ node: e.node,
2592
+ meta: e.meta,
2593
+ actualStaleness: e.stalenessSeconds,
2594
+ }));
2595
+ const stalestPath = discoverStalestPath(stale, config.depthWeight);
2596
+ if (!stalestPath) {
2597
+ return { message: 'No stale metas found. Nothing to synthesize.' };
2598
+ }
2599
+ targetNode = findNode(result.tree, stalestPath);
2600
+ }
2601
+ const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2602
+ // Scope files
2603
+ const { scopeFiles } = await getScopeFiles(targetNode, watcher);
2604
+ const structureHash = computeStructureHash(scopeFiles);
2605
+ const structureChanged = structureHash !== meta._structureHash;
2606
+ const latestArchive = readLatestArchive(targetNode.metaPath);
2607
+ const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2608
+ const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
2609
+ // Delta files
2610
+ const deltaFiles = await getDeltaFiles(targetNode, watcher, meta._generatedAt, scopeFiles);
2611
+ // EMA token estimates
2612
+ const estimatedTokens = {
2613
+ architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
2614
+ builder: meta._builderTokensAvg ?? meta._builderTokens ?? 0,
2615
+ critic: meta._criticTokensAvg ?? meta._criticTokens ?? 0,
2616
+ };
2617
+ // Compute staleness
2618
+ const stalenessSeconds = meta._generatedAt
2619
+ ? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
2620
+ : null;
2621
+ const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
2622
+ return {
2623
+ path: targetNode.metaPath,
2624
+ staleness: {
2625
+ seconds: stalenessSeconds,
2626
+ score: Math.round(stalenessScore * 100) / 100,
2627
+ },
2628
+ architectWillRun: architectTriggered,
2629
+ architectReason: [
2630
+ ...(!meta._builder ? ['no cached builder (first run)'] : []),
2631
+ ...(structureChanged ? ['structure changed'] : []),
2632
+ ...(steerChanged ? ['steer changed'] : []),
2633
+ ...((meta._synthesisCount ?? 0) >= config.architectEvery
2634
+ ? ['periodic refresh']
2635
+ : []),
2636
+ ].join(', ') || 'not triggered',
2637
+ scope: {
2638
+ ownedFiles: scopeFiles.length,
2639
+ childMetas: targetNode.children.length,
2640
+ deltaFiles: deltaFiles
2641
+ .slice(0, 50)
2642
+ .map((f) => ({ path: f, action: 'modified' })),
2643
+ deltaCount: deltaFiles.length,
2644
+ },
2645
+ estimatedTokens,
2646
+ };
2647
+ });
2648
+ }
2649
+
2650
+ /**
2651
+ * POST /seed — create a .meta/ directory with an empty meta.json.
2652
+ *
2653
+ * @module routes/seed
2654
+ */
2655
+ const seedBodySchema = z.object({
2656
+ path: z.string().min(1),
2657
+ });
2658
+ function registerSeedRoute(app, deps) {
2659
+ app.post('/seed', (request, reply) => {
2660
+ const body = seedBodySchema.parse(request.body);
2661
+ const metaDir = resolveMetaDir(body.path);
2662
+ if (existsSync(metaDir)) {
2663
+ return reply.status(409).send({
2664
+ error: 'CONFLICT',
2665
+ message: `.meta directory already exists at ${body.path}`,
2666
+ });
2667
+ }
2668
+ deps.logger.info({ metaDir }, 'creating .meta directory');
2669
+ mkdirSync(metaDir, { recursive: true });
2670
+ const metaJson = { _id: randomUUID() };
2671
+ const metaJsonPath = join(metaDir, 'meta.json');
2672
+ deps.logger.info({ metaJsonPath }, 'writing meta.json');
2673
+ writeFileSync(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
2674
+ return reply.status(201).send({
2675
+ status: 'created',
2676
+ path: body.path,
2677
+ metaDir,
2678
+ _id: metaJson._id,
2679
+ });
2680
+ });
2681
+ }
2682
+
2683
+ /**
2684
+ * GET /status — service health and status overview.
2685
+ *
2686
+ * On-demand dependency health checks (lightweight ping).
2687
+ *
2688
+ * @module routes/status
2689
+ */
2690
+ async function checkDependency(url, path) {
2691
+ const checkedAt = new Date().toISOString();
2692
+ try {
2693
+ const res = await fetch(new URL(path, url), {
2694
+ signal: AbortSignal.timeout(3000),
2695
+ });
2696
+ return { url, status: res.ok ? 'ok' : 'error', checkedAt };
2697
+ }
2698
+ catch {
2699
+ return { url, status: 'unreachable', checkedAt };
2700
+ }
2701
+ }
2702
+ function registerStatusRoute(app, deps) {
2703
+ app.get('/status', async () => {
2704
+ const { config, queue, scheduler, stats, watcher } = deps;
2705
+ // On-demand dependency checks
2706
+ const [watcherHealth, gatewayHealth] = await Promise.all([
2707
+ checkDependency(config.watcherUrl, '/status'),
2708
+ checkDependency(config.gatewayUrl, '/api/status'),
2709
+ ]);
2710
+ const degraded = watcherHealth.status !== 'ok' || gatewayHealth.status !== 'ok';
2711
+ // Determine status
2712
+ let status;
2713
+ if (deps.shuttingDown) {
2714
+ status = 'stopping';
2715
+ }
2716
+ else if (queue.current) {
2717
+ status = 'synthesizing';
2718
+ }
2719
+ else if (degraded) {
2720
+ status = 'degraded';
2721
+ }
2722
+ else {
2723
+ status = 'idle';
2724
+ }
2725
+ // Metas summary from listMetas (already computed)
2726
+ let metasSummary = { total: 0, stale: 0, errors: 0, neverSynthesized: 0 };
2727
+ try {
2728
+ const result = await listMetas(config, watcher);
2729
+ metasSummary = {
2730
+ total: result.summary.total,
2731
+ stale: result.summary.stale,
2732
+ errors: result.summary.errors,
2733
+ neverSynthesized: result.summary.neverSynthesized,
2734
+ };
2735
+ }
2736
+ catch {
2737
+ // Watcher unreachable — leave zeros
2738
+ }
2739
+ return {
2740
+ service: 'jeeves-meta',
2741
+ version: '0.4.0',
2742
+ uptime: process.uptime(),
2743
+ status,
2744
+ currentTarget: queue.current?.path ?? null,
2745
+ queue: queue.getState(),
2746
+ stats: {
2747
+ totalSyntheses: stats.totalSyntheses,
2748
+ totalTokens: stats.totalTokens,
2749
+ totalErrors: stats.totalErrors,
2750
+ lastCycleDurationMs: stats.lastCycleDurationMs,
2751
+ lastCycleAt: stats.lastCycleAt,
2752
+ },
2753
+ schedule: {
2754
+ expression: config.schedule,
2755
+ nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
2756
+ },
2757
+ dependencies: {
2758
+ watcher: watcherHealth,
2759
+ gateway: gatewayHealth,
2760
+ },
2761
+ metas: metasSummary,
2762
+ };
2763
+ });
2764
+ }
2765
+
2766
+ /**
2767
+ * POST /synthesize route handler.
2768
+ *
2769
+ * @module routes/synthesize
2770
+ */
2771
+ const synthesizeBodySchema = z.object({
2772
+ path: z.string().optional(),
2773
+ });
2774
+ /** Register the POST /synthesize route. */
2775
+ function registerSynthesizeRoute(app, deps) {
2776
+ app.post('/synthesize', async (request, reply) => {
2777
+ const body = synthesizeBodySchema.parse(request.body);
2778
+ const { config, watcher, queue } = deps;
2779
+ let targetPath;
2780
+ if (body.path) {
2781
+ targetPath = body.path;
2782
+ }
2783
+ else {
2784
+ // Discover stalest candidate
2785
+ let result;
2786
+ try {
2787
+ result = await listMetas(config, watcher);
2788
+ }
2789
+ catch {
2790
+ return reply.status(503).send({
2791
+ error: 'SERVICE_UNAVAILABLE',
2792
+ message: 'Watcher unreachable — cannot discover candidates',
2793
+ });
2794
+ }
2795
+ const stale = result.entries
2796
+ .filter((e) => e.stalenessSeconds > 0)
2797
+ .map((e) => ({
2798
+ node: e.node,
2799
+ meta: e.meta,
2800
+ actualStaleness: e.stalenessSeconds,
2801
+ }));
2802
+ const stalest = discoverStalestPath(stale, config.depthWeight);
2803
+ if (!stalest) {
2804
+ return reply.code(200).send({
2805
+ status: 'skipped',
2806
+ message: 'No stale metas found. Nothing to synthesize.',
2807
+ });
2808
+ }
2809
+ targetPath = stalest;
2810
+ }
2811
+ const result = queue.enqueue(targetPath, body.path !== undefined);
2812
+ return reply.code(202).send({
2813
+ status: 'accepted',
2814
+ path: targetPath,
2815
+ queuePosition: result.position,
2816
+ alreadyQueued: result.alreadyQueued,
2817
+ });
2818
+ });
2819
+ }
2820
+
2821
+ /**
2822
+ * POST /unlock — remove .lock from a .meta/ directory.
2823
+ *
2824
+ * @module routes/unlock
2825
+ */
2826
+ const unlockBodySchema = z.object({
2827
+ path: z.string().min(1),
2828
+ });
2829
+ function registerUnlockRoute(app, deps) {
2830
+ app.post('/unlock', (request, reply) => {
2831
+ const body = unlockBodySchema.parse(request.body);
2832
+ const metaDir = resolveMetaDir(body.path);
2833
+ const lockPath = join(metaDir, '.lock');
2834
+ if (!existsSync(lockPath)) {
2835
+ return reply.status(409).send({
2836
+ error: 'ALREADY_UNLOCKED',
2837
+ message: `No lock file at ${body.path} (already unlocked)`,
2838
+ });
2839
+ }
2840
+ deps.logger.info({ lockPath }, 'removing lock file');
2841
+ unlinkSync(lockPath);
2842
+ return reply.status(200).send({
2843
+ status: 'unlocked',
2844
+ path: body.path,
2845
+ });
2846
+ });
2847
+ }
2848
+
2849
+ /**
2850
+ * Route registration for jeeves-meta service.
2851
+ *
2852
+ * @module routes
2853
+ */
2854
+ /** Register all HTTP routes on the Fastify instance. */
2855
+ function registerRoutes(app, deps) {
2856
+ // Global error handler for validation + watcher errors
2857
+ app.setErrorHandler((error, _request, reply) => {
2858
+ if (error.validation) {
2859
+ return reply
2860
+ .status(400)
2861
+ .send({ error: 'BAD_REQUEST', message: error.message });
2862
+ }
2863
+ if (error.statusCode === 404) {
2864
+ return reply
2865
+ .status(404)
2866
+ .send({ error: 'NOT_FOUND', message: error.message });
2867
+ }
2868
+ deps.logger.error(error, 'Unhandled route error');
2869
+ return reply
2870
+ .status(500)
2871
+ .send({ error: 'INTERNAL_ERROR', message: error.message });
2872
+ });
2873
+ registerStatusRoute(app, deps);
2874
+ registerMetasRoutes(app, deps);
2875
+ registerSynthesizeRoute(app, deps);
2876
+ registerPreviewRoute(app, deps);
2877
+ registerSeedRoute(app, deps);
2878
+ registerUnlockRoute(app, deps);
2879
+ registerConfigValidateRoute(app, deps);
2880
+ }
2881
+
2882
+ /**
2883
+ * Virtual rule registration with jeeves-watcher.
2884
+ *
2885
+ * Service registers inference rules at startup (with retry) and
2886
+ * re-registers opportunistically when watcher restart is detected.
2887
+ *
2888
+ * @module rules
2889
+ */
2890
+ const SOURCE = 'jeeves-meta';
2891
+ const MAX_RETRIES = 10;
2892
+ const RETRY_BASE_MS = 2000;
2893
+ /**
2894
+ * Convert a `Record<string, unknown>` config property into watcher
2895
+ * schema `set` directives: `{ key: { set: value } }` per entry.
2896
+ */
2897
+ function toSchemaSetDirectives(props) {
2898
+ return Object.fromEntries(Object.entries(props).map(([k, v]) => [k, { set: v }]));
2899
+ }
2900
+ /** Build the three virtual rule definitions. */
2901
+ function buildMetaRules(config) {
2902
+ return [
2903
+ {
2904
+ name: 'meta-current',
2905
+ description: 'Live jeeves-meta .meta/meta.json files',
2906
+ match: {
2907
+ properties: {
2908
+ file: {
2909
+ properties: {
2910
+ path: { type: 'string', glob: '**/.meta/meta.json' },
2911
+ },
2912
+ },
2913
+ },
2914
+ },
2915
+ schema: [
2916
+ 'base',
2917
+ {
2918
+ properties: {
2919
+ ...toSchemaSetDirectives(config.metaProperty),
2920
+ meta_id: { type: 'string', set: '{{json._id}}' },
2921
+ meta_steer: { type: 'string', set: '{{json._steer}}' },
2922
+ meta_depth: { type: 'number', set: '{{json._depth}}' },
2923
+ meta_emphasis: { type: 'number', set: '{{json._emphasis}}' },
2924
+ meta_synthesis_count: {
2925
+ type: 'integer',
2926
+ set: '{{json._synthesisCount}}',
2927
+ },
2928
+ meta_structure_hash: {
2929
+ type: 'string',
2930
+ set: '{{json._structureHash}}',
2931
+ },
2932
+ meta_architect_tokens: {
2933
+ type: 'integer',
2934
+ set: '{{json._architectTokens}}',
2935
+ },
2936
+ meta_builder_tokens: {
2937
+ type: 'integer',
2938
+ set: '{{json._builderTokens}}',
2939
+ },
2940
+ meta_critic_tokens: {
2941
+ type: 'integer',
2942
+ set: '{{json._criticTokens}}',
2943
+ },
2944
+ meta_error_step: {
2945
+ type: 'string',
2946
+ set: '{{json._error.step}}',
2947
+ },
2948
+ generated_at_unix: {
2949
+ type: 'integer',
2950
+ set: '{{toUnix json._generatedAt}}',
2951
+ },
2952
+ has_error: {
2953
+ type: 'boolean',
2954
+ set: '{{#if json._error}}true{{else}}false{{/if}}',
2955
+ },
2956
+ },
2957
+ },
2958
+ ],
2959
+ render: {
2960
+ frontmatter: [
2961
+ 'meta_id',
2962
+ 'meta_steer',
2963
+ 'generated_at_unix',
2964
+ 'meta_depth',
2965
+ 'meta_emphasis',
2966
+ 'meta_architect_tokens',
2967
+ 'meta_builder_tokens',
2968
+ 'meta_critic_tokens',
2969
+ ],
2970
+ body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
2971
+ },
2972
+ renderAs: 'md',
2973
+ },
2974
+ {
2975
+ name: 'meta-archive',
2976
+ description: 'Archived jeeves-meta .meta/archive snapshots',
2977
+ match: {
2978
+ properties: {
2979
+ file: {
2980
+ properties: {
2981
+ path: { type: 'string', glob: '**/.meta/archive/*.json' },
2982
+ },
2983
+ },
2984
+ },
2985
+ },
2986
+ schema: [
2987
+ 'base',
2988
+ {
2989
+ properties: {
2990
+ ...toSchemaSetDirectives(config.metaArchiveProperty),
2991
+ meta_id: { type: 'string', set: '{{json._id}}' },
2992
+ archived: { type: 'boolean', set: 'true' },
2993
+ archived_at: { type: 'string', set: '{{json._archivedAt}}' },
2994
+ },
2995
+ },
2996
+ ],
2997
+ render: {
2998
+ frontmatter: ['meta_id', 'archived', 'archived_at'],
2999
+ body: [
3000
+ {
3001
+ path: 'json._content',
3002
+ heading: 1,
3003
+ label: 'Synthesis (archived)',
3004
+ },
3005
+ ],
3006
+ },
3007
+ renderAs: 'md',
3008
+ },
3009
+ {
3010
+ name: 'meta-config',
3011
+ description: 'jeeves-meta configuration file',
3012
+ match: {
3013
+ properties: {
3014
+ file: {
3015
+ properties: {
3016
+ path: { type: 'string', glob: '**/jeeves-meta.config.json' },
3017
+ },
3018
+ },
3019
+ },
3020
+ },
3021
+ schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
3022
+ render: {
3023
+ frontmatter: [
3024
+ 'watcherUrl',
3025
+ 'gatewayUrl',
3026
+ 'architectEvery',
3027
+ 'depthWeight',
3028
+ 'maxArchive',
3029
+ 'maxLines',
3030
+ ],
3031
+ body: [
3032
+ {
3033
+ path: 'json.defaultArchitect',
3034
+ heading: 2,
3035
+ label: 'Default Architect Prompt',
3036
+ },
3037
+ {
3038
+ path: 'json.defaultCritic',
3039
+ heading: 2,
3040
+ label: 'Default Critic Prompt',
3041
+ },
3042
+ ],
3043
+ },
3044
+ renderAs: 'md',
3045
+ },
3046
+ ];
3047
+ }
3048
+ /**
3049
+ * Manages virtual rule registration with watcher.
3050
+ *
3051
+ * - Registers at startup with exponential retry
3052
+ * - Tracks watcher uptime for restart detection
3053
+ * - Re-registers opportunistically when uptime decreases
3054
+ */
3055
+ class RuleRegistrar {
3056
+ config;
3057
+ logger;
3058
+ watcherClient;
3059
+ lastWatcherUptime = null;
3060
+ registered = false;
3061
+ constructor(config, logger, watcher) {
3062
+ this.config = config;
3063
+ this.logger = logger;
3064
+ this.watcherClient = watcher;
3065
+ }
3066
+ /** Whether rules have been successfully registered. */
3067
+ get isRegistered() {
3068
+ return this.registered;
3069
+ }
3070
+ /**
3071
+ * Register rules with watcher. Retries with exponential backoff.
3072
+ * Non-blocking — logs errors but never throws.
3073
+ */
3074
+ async register() {
3075
+ const rules = buildMetaRules(this.config);
3076
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
3077
+ try {
3078
+ await this.watcherClient.registerRules(SOURCE, rules);
3079
+ this.registered = true;
3080
+ this.logger.info('Virtual rules registered with watcher');
3081
+ return;
3082
+ }
3083
+ catch (err) {
3084
+ const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
3085
+ this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
3086
+ await new Promise((r) => setTimeout(r, delayMs));
3087
+ }
3088
+ }
3089
+ this.logger.error('Rule registration failed after max retries — service degraded');
3090
+ }
3091
+ /**
3092
+ * Check watcher uptime and re-register if it decreased (restart detected).
3093
+ *
3094
+ * @param currentUptime - Current watcher uptime in seconds.
3095
+ */
3096
+ async checkAndReregister(currentUptime) {
3097
+ if (this.lastWatcherUptime !== null &&
3098
+ currentUptime < this.lastWatcherUptime) {
3099
+ this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
3100
+ this.registered = false;
3101
+ await this.register();
3102
+ }
3103
+ this.lastWatcherUptime = currentUptime;
3104
+ }
3105
+ }
3106
+
3107
+ /**
3108
+ * Minimal Fastify HTTP server for jeeves-meta service.
3109
+ *
3110
+ * @module server
3111
+ */
3112
+ /**
3113
+ * Create and configure the Fastify server.
3114
+ *
3115
+ * @param options - Server creation options.
3116
+ * @returns Configured Fastify instance (not yet listening).
3117
+ */
3118
+ function createServer(options) {
3119
+ const app = Fastify({ logger: options.logger });
3120
+ registerRoutes(app, {
3121
+ config: options.config,
3122
+ logger: options.logger,
3123
+ queue: options.queue,
3124
+ watcher: options.watcher,
3125
+ scheduler: options.scheduler,
3126
+ stats: options.stats,
3127
+ });
3128
+ return app;
3129
+ }
3130
+
3131
+ /**
3132
+ * Graceful shutdown handler.
3133
+ *
3134
+ * On SIGTERM/SIGINT: stops scheduler, drains queue, cleans up locks.
3135
+ *
3136
+ * @module shutdown
3137
+ */
3138
+ /**
3139
+ * Register shutdown handlers for SIGTERM and SIGINT.
3140
+ *
3141
+ * Flow:
3142
+ * 1. Stop scheduler (no new ticks)
3143
+ * 2. If synthesis in progress, release its lock
3144
+ * 3. Close Fastify server
3145
+ * 4. Exit
3146
+ */
3147
+ function registerShutdownHandlers(deps) {
3148
+ let shuttingDown = false;
3149
+ const shutdown = async (signal) => {
3150
+ if (shuttingDown)
3151
+ return;
3152
+ shuttingDown = true;
3153
+ deps.logger.info({ signal }, 'Shutdown signal received');
3154
+ // Signal stopping state to /status
3155
+ if (deps.routeDeps) {
3156
+ deps.routeDeps.shuttingDown = true;
3157
+ }
3158
+ // 1. Stop scheduler
3159
+ if (deps.scheduler) {
3160
+ deps.scheduler.stop();
3161
+ deps.logger.info('Scheduler stopped');
3162
+ }
3163
+ // 2. Release lock for in-progress synthesis
3164
+ const current = deps.queue.current;
3165
+ if (current) {
3166
+ try {
3167
+ releaseLock(current.path);
3168
+ deps.logger.info({ path: current.path }, 'Released lock for in-progress synthesis');
3169
+ }
3170
+ catch {
3171
+ deps.logger.warn({ path: current.path }, 'Failed to release lock during shutdown');
3172
+ }
3173
+ }
3174
+ // 3. Close server
3175
+ try {
3176
+ await deps.server.close();
3177
+ deps.logger.info('HTTP server closed');
3178
+ }
3179
+ catch (err) {
3180
+ deps.logger.error(err, 'Error closing HTTP server');
3181
+ }
3182
+ process.exit(0);
3183
+ };
3184
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
3185
+ process.on('SIGINT', () => void shutdown('SIGINT'));
3186
+ }
3187
+
3188
+ /**
3189
+ * HTTP implementation of the WatcherClient interface.
3190
+ *
3191
+ * Talks to jeeves-watcher's POST /scan and POST /rules endpoints
3192
+ * with retry and exponential backoff.
3193
+ *
3194
+ * @module watcher-client/HttpWatcherClient
3195
+ */
3196
+ /** Default retry configuration. */
3197
+ const DEFAULT_MAX_RETRIES = 3;
3198
+ const DEFAULT_BACKOFF_BASE_MS = 1000;
3199
+ const DEFAULT_BACKOFF_FACTOR = 4;
3200
+ /** Check if an error is transient (worth retrying). */
3201
+ function isTransient(status) {
3202
+ return status >= 500 || status === 408 || status === 429;
3203
+ }
3204
+ /**
3205
+ * HTTP-based WatcherClient implementation with retry.
3206
+ */
3207
+ class HttpWatcherClient {
3208
+ baseUrl;
3209
+ maxRetries;
3210
+ backoffBaseMs;
3211
+ backoffFactor;
3212
+ constructor(options) {
3213
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
3214
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
3215
+ this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
3216
+ this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
3217
+ }
3218
+ /** POST JSON with retry. */
3219
+ async post(endpoint, body) {
3220
+ const url = this.baseUrl + endpoint;
3221
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
3222
+ const res = await fetch(url, {
3223
+ method: 'POST',
3224
+ headers: { 'Content-Type': 'application/json' },
3225
+ body: JSON.stringify(body),
3226
+ });
3227
+ if (res.ok) {
3228
+ return res.json();
3229
+ }
3230
+ if (!isTransient(res.status) || attempt === this.maxRetries) {
3231
+ const text = await res.text();
3232
+ throw new Error(`Watcher ${endpoint} failed: HTTP ${res.status.toString()} - ${text}`);
3233
+ }
3234
+ // Exponential backoff
3235
+ const delayMs = this.backoffBaseMs * Math.pow(this.backoffFactor, attempt);
3236
+ await sleep(delayMs);
3237
+ }
3238
+ // Unreachable, but TypeScript needs it
3239
+ throw new Error('Retry exhausted');
3240
+ }
3241
+ async scan(params) {
3242
+ // Build Qdrant filter: merge explicit filter with pathPrefix/modifiedAfter
3243
+ const mustClauses = [];
3244
+ // Carry over any existing 'must' clauses from the provided filter
3245
+ if (params.filter) {
3246
+ const existing = params.filter.must;
3247
+ if (Array.isArray(existing)) {
3248
+ mustClauses.push(...existing);
3249
+ }
3250
+ }
3251
+ // Translate pathPrefix into a Qdrant text match on file_path
3252
+ if (params.pathPrefix !== undefined) {
3253
+ mustClauses.push({
3254
+ key: 'file_path',
3255
+ match: { text: params.pathPrefix },
3256
+ });
3257
+ }
3258
+ // Translate modifiedAfter into a Qdrant range filter on modified_at
3259
+ if (params.modifiedAfter !== undefined) {
3260
+ mustClauses.push({
3261
+ key: 'modified_at',
3262
+ range: { gt: params.modifiedAfter },
3263
+ });
3264
+ }
3265
+ const filter = { must: mustClauses };
3266
+ const body = { filter };
3267
+ if (params.fields !== undefined) {
3268
+ body.fields = params.fields;
3269
+ }
3270
+ if (params.limit !== undefined) {
3271
+ body.limit = params.limit;
3272
+ }
3273
+ if (params.cursor !== undefined) {
3274
+ body.cursor = params.cursor;
3275
+ }
3276
+ const raw = (await this.post('/scan', body));
3277
+ // jeeves-watcher returns { points, cursor }; map to ScanResponse.
3278
+ const points = (raw.points ?? raw.files ?? []);
3279
+ const next = (raw.cursor ?? raw.next);
3280
+ const files = points.map((p) => {
3281
+ const payload = (p.payload ?? p);
3282
+ return {
3283
+ file_path: (payload.file_path ?? payload.path ?? ''),
3284
+ modified_at: (payload.modified_at ?? payload.mtime ?? 0),
3285
+ content_hash: (payload.content_hash ?? ''),
3286
+ ...payload,
3287
+ };
3288
+ });
3289
+ return { files, next: next ?? undefined };
3290
+ }
3291
+ async registerRules(source, rules) {
3292
+ await this.post('/rules/register', { source, rules });
3293
+ }
3294
+ async unregisterRules(source) {
3295
+ await this.post('/rules/unregister', { source });
3296
+ }
3297
+ }
3298
+
3299
+ /**
3300
+ * Jeeves Meta Service — knowledge synthesis HTTP service for the Jeeves platform.
3301
+ *
3302
+ * @packageDocumentation
3303
+ */
3304
+ // ── Archive ──
3305
+ /**
3306
+ * Bootstrap the service: create logger, build server, start listening,
3307
+ * wire scheduler, queue processing, rule registration, config hot-reload,
3308
+ * startup lock cleanup, and shutdown.
3309
+ *
3310
+ * @param config - Validated service configuration.
3311
+ * @param configPath - Optional path to config file for hot-reload.
3312
+ */
3313
+ async function startService(config, configPath) {
3314
+ const logger = createLogger({
3315
+ level: config.logging.level,
3316
+ file: config.logging.file,
3317
+ });
3318
+ // Wire synthesis executor + watcher
3319
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
3320
+ const executor = new GatewayExecutor({
3321
+ gatewayUrl: config.gatewayUrl,
3322
+ apiKey: config.gatewayApiKey,
3323
+ });
3324
+ // Runtime stats (mutable, shared with routes)
3325
+ const stats = {
3326
+ totalSyntheses: 0,
3327
+ totalTokens: 0,
3328
+ totalErrors: 0,
3329
+ lastCycleDurationMs: null,
3330
+ lastCycleAt: null,
3331
+ };
3332
+ const queue = new SynthesisQueue(logger);
3333
+ // Scheduler (needs watcher for discovery)
3334
+ const scheduler = new Scheduler(config, queue, logger, watcher);
3335
+ const routeDeps = {
3336
+ config,
3337
+ logger,
3338
+ queue,
3339
+ watcher,
3340
+ scheduler,
3341
+ stats,
3342
+ };
3343
+ const server = createServer({
3344
+ logger,
3345
+ config,
3346
+ queue,
3347
+ watcher,
3348
+ scheduler,
3349
+ stats,
3350
+ });
3351
+ // Start HTTP server
3352
+ try {
3353
+ await server.listen({ port: config.port, host: '0.0.0.0' });
3354
+ logger.info({ port: config.port }, 'Service listening');
3355
+ }
3356
+ catch (err) {
3357
+ logger.error(err, 'Failed to start service');
3358
+ process.exit(1);
3359
+ }
3360
+ // Progress reporter — uses shared config reference so hot-reload propagates
3361
+ const progress = new ProgressReporter(config, logger);
3362
+ // Wire queue processing — synthesize one meta per dequeue
3363
+ const synthesizeFn = async (path) => {
3364
+ const startMs = Date.now();
3365
+ let cycleTokens = 0;
3366
+ await progress.report({
3367
+ type: 'synthesis_start',
3368
+ metaPath: path,
3369
+ });
3370
+ try {
3371
+ const results = await orchestrate(config, executor, watcher, path, async (evt) => {
3372
+ // Track token stats from phase completions
3373
+ if (evt.type === 'phase_complete' && evt.tokens) {
3374
+ stats.totalTokens += evt.tokens;
3375
+ cycleTokens += evt.tokens;
3376
+ }
3377
+ await progress.report(evt);
3378
+ });
3379
+ // orchestrate() always returns exactly one result
3380
+ const result = results[0];
3381
+ const durationMs = Date.now() - startMs;
3382
+ // Update stats
3383
+ stats.totalSyntheses++;
3384
+ stats.lastCycleDurationMs = durationMs;
3385
+ stats.lastCycleAt = new Date().toISOString();
3386
+ if (result.error) {
3387
+ stats.totalErrors++;
3388
+ await progress.report({
3389
+ type: 'error',
3390
+ metaPath: path,
3391
+ error: result.error.message,
3392
+ });
3393
+ }
3394
+ else {
3395
+ scheduler.resetBackoff();
3396
+ await progress.report({
3397
+ type: 'synthesis_complete',
3398
+ metaPath: path,
3399
+ tokens: cycleTokens,
3400
+ durationMs,
3401
+ });
3402
+ }
3403
+ }
3404
+ catch (err) {
3405
+ stats.totalErrors++;
3406
+ const message = err instanceof Error ? err.message : String(err);
3407
+ await progress.report({
3408
+ type: 'error',
3409
+ metaPath: path,
3410
+ error: message,
3411
+ });
3412
+ throw err;
3413
+ }
3414
+ };
3415
+ // Auto-process queue when new items arrive
3416
+ queue.onEnqueue(() => {
3417
+ void queue.processQueue(synthesizeFn);
3418
+ });
3419
+ // Startup: clean stale locks (gap #16)
3420
+ try {
3421
+ const metaResult = await listMetas(config, watcher);
3422
+ const metaPaths = metaResult.entries.map((e) => e.node.metaPath);
3423
+ cleanupStaleLocks(metaPaths, logger);
3424
+ }
3425
+ catch (err) {
3426
+ logger.warn({ err }, 'Could not clean stale locks (watcher may be down)');
3427
+ }
3428
+ // Start scheduler
3429
+ scheduler.start();
3430
+ // Rule registration (fire-and-forget with retries)
3431
+ const registrar = new RuleRegistrar(config, logger, watcher);
3432
+ scheduler.setRegistrar(registrar);
3433
+ void registrar.register();
3434
+ // Config hot-reload (gap #12)
3435
+ if (configPath) {
3436
+ watchFile(configPath, { interval: 5000 }, () => {
3437
+ try {
3438
+ const newConfig = loadServiceConfig(configPath);
3439
+ // Hot-reloadable fields: schedule, reportChannel, logging level
3440
+ if (newConfig.schedule !== config.schedule) {
3441
+ scheduler.updateSchedule(newConfig.schedule);
3442
+ logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
3443
+ }
3444
+ if (newConfig.reportChannel !== config.reportChannel) {
3445
+ // Mutate shared config reference for progress reporter
3446
+ config.reportChannel =
3447
+ newConfig.reportChannel;
3448
+ logger.info({ reportChannel: newConfig.reportChannel }, 'reportChannel hot-reloaded');
3449
+ }
3450
+ if (newConfig.logging.level !== config.logging.level) {
3451
+ logger.level = newConfig.logging.level;
3452
+ logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
3453
+ }
3454
+ }
3455
+ catch (err) {
3456
+ logger.warn({ err }, 'Config hot-reload failed');
3457
+ }
3458
+ });
3459
+ }
3460
+ // Shutdown handlers
3461
+ registerShutdownHandlers({
3462
+ server,
3463
+ scheduler,
3464
+ queue,
3465
+ logger,
3466
+ routeDeps,
3467
+ });
3468
+ logger.info('Service fully initialized');
3469
+ }
3470
+
3471
+ /**
3472
+ * Commander CLI for jeeves-meta service.
3473
+ *
3474
+ * @module cli
3475
+ */
3476
+ const program = new Command();
3477
+ program.name('jeeves-meta').description('Jeeves Meta synthesis service');
3478
+ // ─── start ──────────────────────────────────────────────────────────
3479
+ program
3480
+ .command('start')
3481
+ .description('Start the HTTP service')
3482
+ .requiredOption('-c, --config <path>', 'Path to config JSON file')
3483
+ .action(async (opts) => {
3484
+ const configPath = resolveConfigPath(['-c', opts.config]);
3485
+ const config = loadServiceConfig(configPath);
3486
+ await startService(config, configPath);
3487
+ });
3488
+ // ─── API client helpers ─────────────────────────────────────────────
3489
+ function apiUrl(port, path) {
3490
+ return `http://127.0.0.1:${String(port)}${path}`;
3491
+ }
3492
+ async function apiGet(port, path) {
3493
+ const res = await fetch(apiUrl(port, path));
3494
+ if (!res.ok) {
3495
+ const text = await res.text();
3496
+ throw new Error(`${String(res.status)} ${res.statusText}: ${text}`);
3497
+ }
3498
+ return res.json();
3499
+ }
3500
+ async function apiPost(port, path, body) {
3501
+ const res = await fetch(apiUrl(port, path), {
3502
+ method: 'POST',
3503
+ headers: { 'content-type': 'application/json' },
3504
+ body: body !== undefined ? JSON.stringify(body) : undefined,
3505
+ });
3506
+ if (!res.ok) {
3507
+ const text = await res.text();
3508
+ throw new Error(`${String(res.status)} ${res.statusText}: ${text}`);
3509
+ }
3510
+ return res.json();
3511
+ }
3512
+ // ─── status ─────────────────────────────────────────────────────────
3513
+ program
3514
+ .command('status')
3515
+ .description('Show service status')
3516
+ .option('-p, --port <port>', 'Service port', '1938')
3517
+ .action(async (opts) => {
3518
+ try {
3519
+ const data = await apiGet(parseInt(opts.port, 10), '/status');
3520
+ console.log(JSON.stringify(data, null, 2));
3521
+ }
3522
+ catch (err) {
3523
+ console.error('Service unreachable:', err.message);
3524
+ process.exit(1);
3525
+ }
3526
+ });
3527
+ // ─── list ───────────────────────────────────────────────────────────
3528
+ program
3529
+ .command('list')
3530
+ .description('List all discovered meta entities')
3531
+ .option('-p, --port <port>', 'Service port', '1938')
3532
+ .action(async (opts) => {
3533
+ try {
3534
+ const data = await apiGet(parseInt(opts.port, 10), '/metas');
3535
+ console.log(JSON.stringify(data, null, 2));
3536
+ }
3537
+ catch (err) {
3538
+ console.error('Error:', err.message);
3539
+ process.exit(1);
3540
+ }
3541
+ });
3542
+ // ─── detail ─────────────────────────────────────────────────────────
3543
+ program
3544
+ .command('detail <path>')
3545
+ .description('Show full detail for a single meta entity')
3546
+ .option('-p, --port <port>', 'Service port', '1938')
3547
+ .action(async (metaPath, opts) => {
3548
+ try {
3549
+ const encoded = encodeURIComponent(metaPath);
3550
+ const data = await apiGet(parseInt(opts.port, 10), `/metas/${encoded}`);
3551
+ console.log(JSON.stringify(data, null, 2));
3552
+ }
3553
+ catch (err) {
3554
+ console.error('Error:', err.message);
3555
+ process.exit(1);
3556
+ }
3557
+ });
3558
+ // ─── preview ────────────────────────────────────────────────────────
3559
+ program
3560
+ .command('preview')
3561
+ .description('Dry-run: preview inputs for next synthesis cycle')
3562
+ .option('-p, --port <port>', 'Service port', '1938')
3563
+ .option('--path <path>', 'Specific meta path to preview')
3564
+ .action(async (opts) => {
3565
+ try {
3566
+ const qs = opts.path ? '?path=' + encodeURIComponent(opts.path) : '';
3567
+ const data = await apiGet(parseInt(opts.port, 10), '/preview' + qs);
3568
+ console.log(JSON.stringify(data, null, 2));
3569
+ }
3570
+ catch (err) {
3571
+ console.error('Error:', err.message);
3572
+ process.exit(1);
3573
+ }
3574
+ });
3575
+ // ─── synthesize ─────────────────────────────────────────────────────
3576
+ program
3577
+ .command('synthesize')
3578
+ .description('Trigger synthesis (enqueues work)')
3579
+ .option('-p, --port <port>', 'Service port', '1938')
3580
+ .option('--path <path>', 'Specific meta path to synthesize')
3581
+ .action(async (opts) => {
3582
+ try {
3583
+ const body = opts.path ? { path: opts.path } : {};
3584
+ const data = await apiPost(parseInt(opts.port, 10), '/synthesize', body);
3585
+ console.log(JSON.stringify(data, null, 2));
3586
+ }
3587
+ catch (err) {
3588
+ console.error('Error:', err.message);
3589
+ process.exit(1);
3590
+ }
3591
+ });
3592
+ // ─── seed ───────────────────────────────────────────────────────────
3593
+ program
3594
+ .command('seed <path>')
3595
+ .description('Create .meta/ directory + meta.json for a path')
3596
+ .option('-p, --port <port>', 'Service port', '1938')
3597
+ .action(async (metaPath, opts) => {
3598
+ try {
3599
+ const data = await apiPost(parseInt(opts.port, 10), '/seed', {
3600
+ path: metaPath,
3601
+ });
3602
+ console.log(JSON.stringify(data, null, 2));
3603
+ }
3604
+ catch (err) {
3605
+ console.error('Error:', err.message);
3606
+ process.exit(1);
3607
+ }
3608
+ });
3609
+ // ─── unlock ─────────────────────────────────────────────────────────
3610
+ program
3611
+ .command('unlock <path>')
3612
+ .description('Remove .lock file from a meta entity')
3613
+ .option('-p, --port <port>', 'Service port', '1938')
3614
+ .action(async (metaPath, opts) => {
3615
+ try {
3616
+ const data = await apiPost(parseInt(opts.port, 10), '/unlock', {
3617
+ path: metaPath,
3618
+ });
3619
+ console.log(JSON.stringify(data, null, 2));
3620
+ }
3621
+ catch (err) {
3622
+ console.error('Error:', err.message);
3623
+ process.exit(1);
3624
+ }
3625
+ });
3626
+ // ─── validate ───────────────────────────────────────────────────────
3627
+ program
3628
+ .command('validate')
3629
+ .description('Validate current or candidate config')
3630
+ .option('-p, --port <port>', 'Service port', '1938')
3631
+ .option('-c, --config <path>', 'Validate a candidate config file locally')
3632
+ .action(async (opts) => {
3633
+ try {
3634
+ if (opts.config) {
3635
+ // Local validation — parse candidate file through Zod schema
3636
+ const { loadServiceConfig } = await Promise.resolve().then(function () { return configLoader; });
3637
+ const configPath = opts.config;
3638
+ const config = loadServiceConfig(configPath);
3639
+ const sanitized = {
3640
+ ...config,
3641
+ gatewayApiKey: config.gatewayApiKey ? '[REDACTED]' : undefined,
3642
+ };
3643
+ console.log(JSON.stringify(sanitized, null, 2));
3644
+ }
3645
+ else {
3646
+ // Remote — query running service
3647
+ const data = await apiGet(parseInt(opts.port, 10), '/config/validate');
3648
+ console.log(JSON.stringify(data, null, 2));
3649
+ }
3650
+ }
3651
+ catch (err) {
3652
+ console.error('Error:', err.message);
3653
+ process.exit(1);
3654
+ }
3655
+ });
3656
+ // ─── service install/uninstall ──────────────────────────────────────
3657
+ const service = program
3658
+ .command('service')
3659
+ .description('Generate service install/uninstall instructions');
3660
+ service.addCommand(new Command('install')
3661
+ .description('Print install instructions for a system service')
3662
+ .option('-c, --config <path>', 'Path to configuration file')
3663
+ .option('-n, --name <name>', 'Service name', 'JeevesMeta')
3664
+ .action((options) => {
3665
+ const { name } = options;
3666
+ const configFlag = options.config ? ` -c "${options.config}"` : '';
3667
+ if (process.platform === 'win32') {
3668
+ console.log('# NSSM install (Windows)');
3669
+ console.log(` nssm install ${name} node "%APPDATA%\\npm\\node_modules\\@karmaniverous\\jeeves-meta\\dist\\cli\\jeeves-meta\\index.js" start${configFlag}`);
3670
+ console.log(` nssm set ${name} AppDirectory "%CD%"`);
3671
+ console.log(` nssm set ${name} DisplayName "Jeeves Meta"`);
3672
+ console.log(` nssm set ${name} Description "Meta synthesis service"`);
3673
+ console.log(` nssm set ${name} Start SERVICE_AUTO_START`);
3674
+ console.log(` nssm start ${name}`);
3675
+ return;
3676
+ }
3677
+ if (process.platform === 'darwin') {
3678
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
3679
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3680
+ <plist version="1.0">
3681
+ <dict>
3682
+ <key>Label</key><string>com.jeeves.meta</string>
3683
+ <key>ProgramArguments</key>
3684
+ <array>
3685
+ <string>/usr/local/bin/jeeves-meta</string>
3686
+ <string>start</string>${options.config ? `\n <string>-c</string>\n <string>${options.config}</string>` : ''}
3687
+ </array>
3688
+ <key>RunAtLoad</key><true/>
3689
+ <key>KeepAlive</key><true/>
3690
+ <key>StandardOutPath</key><string>/tmp/${name}.stdout.log</string>
3691
+ <key>StandardErrorPath</key><string>/tmp/${name}.stderr.log</string>
3692
+ </dict>
3693
+ </plist>`;
3694
+ console.log('# launchd plist (macOS)');
3695
+ console.log(`# ~/Library/LaunchAgents/com.jeeves.meta.plist`);
3696
+ console.log(plist);
3697
+ console.log();
3698
+ console.log('# install');
3699
+ console.log(` launchctl load ~/Library/LaunchAgents/com.jeeves.meta.plist`);
3700
+ return;
3701
+ }
3702
+ // Linux (systemd)
3703
+ const unit = [
3704
+ '[Unit]',
3705
+ 'Description=Jeeves Meta - Synthesis Service',
3706
+ 'After=network.target',
3707
+ '',
3708
+ '[Service]',
3709
+ 'Type=simple',
3710
+ 'WorkingDirectory=%h',
3711
+ `ExecStart=/usr/bin/env jeeves-meta start${configFlag}`,
3712
+ 'Restart=on-failure',
3713
+ '',
3714
+ '[Install]',
3715
+ 'WantedBy=default.target',
3716
+ ].join('\n');
3717
+ console.log('# systemd unit file (Linux)');
3718
+ console.log(`# ~/.config/systemd/user/${name}.service`);
3719
+ console.log(unit);
3720
+ console.log();
3721
+ console.log('# install');
3722
+ console.log(` systemctl --user daemon-reload`);
3723
+ console.log(` systemctl --user enable --now ${name}.service`);
3724
+ }));
3725
+ // start command (prints OS-specific start instructions)
3726
+ service.addCommand(new Command('start')
3727
+ .description('Print start instructions for the installed service')
3728
+ .option('-n, --name <name>', 'Service name', 'JeevesMeta')
3729
+ .action((options) => {
3730
+ const { name } = options;
3731
+ if (process.platform === 'win32') {
3732
+ console.log('# NSSM start (Windows)');
3733
+ console.log(` nssm start ${name}`);
3734
+ return;
3735
+ }
3736
+ if (process.platform === 'darwin') {
3737
+ console.log('# launchd start (macOS)');
3738
+ console.log(` launchctl load ~/Library/LaunchAgents/com.jeeves.meta.plist`);
3739
+ return;
3740
+ }
3741
+ console.log('# systemd start (Linux)');
3742
+ console.log(` systemctl --user start ${name}.service`);
3743
+ }));
3744
+ // stop command
3745
+ service.addCommand(new Command('stop')
3746
+ .description('Stop the running service')
3747
+ .option('-n, --name <name>', 'Service name', 'JeevesMeta')
3748
+ .action((options) => {
3749
+ const { name } = options;
3750
+ if (process.platform === 'win32') {
3751
+ console.log('# NSSM stop (Windows)');
3752
+ console.log(` nssm stop ${name}`);
3753
+ return;
3754
+ }
3755
+ if (process.platform === 'darwin') {
3756
+ console.log('# launchd stop (macOS)');
3757
+ console.log(` launchctl unload ~/Library/LaunchAgents/com.jeeves.meta.plist`);
3758
+ return;
3759
+ }
3760
+ console.log('# systemd stop (Linux)');
3761
+ console.log(` systemctl --user stop ${name}.service`);
3762
+ }));
3763
+ // status command (service subcommand — queries HTTP API)
3764
+ service.addCommand(new Command('status')
3765
+ .description('Show service status via HTTP API')
3766
+ .option('-p, --port <port>', 'Service port', '1938')
3767
+ .action(async (opts) => {
3768
+ try {
3769
+ const data = await apiGet(parseInt(opts.port, 10), '/status');
3770
+ console.log(JSON.stringify(data, null, 2));
3771
+ }
3772
+ catch (err) {
3773
+ console.error('Service unreachable:', err.message);
3774
+ process.exit(1);
3775
+ }
3776
+ }));
3777
+ service.addCommand(new Command('remove')
3778
+ .description('Print remove instructions for a system service')
3779
+ .option('-n, --name <name>', 'Service name', 'JeevesMeta')
3780
+ .action((options) => {
3781
+ const { name } = options;
3782
+ if (process.platform === 'win32') {
3783
+ console.log('# NSSM remove (Windows)');
3784
+ console.log(` nssm stop ${name}`);
3785
+ console.log(` nssm remove ${name} confirm`);
3786
+ return;
3787
+ }
3788
+ if (process.platform === 'darwin') {
3789
+ console.log('# launchd remove (macOS)');
3790
+ console.log(` launchctl unload ~/Library/LaunchAgents/com.jeeves.meta.plist`);
3791
+ console.log(` rm ~/Library/LaunchAgents/com.jeeves.meta.plist`);
3792
+ return;
3793
+ }
3794
+ console.log('# systemd remove (Linux)');
3795
+ console.log(` systemctl --user disable --now ${name}.service`);
3796
+ console.log(`# rm ~/.config/systemd/user/${name}.service`);
3797
+ console.log(` systemctl --user daemon-reload`);
3798
+ }));
3799
+ program.parse();