@karmaniverous/jeeves-meta 0.2.2 → 0.3.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.
Files changed (4) hide show
  1. package/dist/cli.js +373 -333
  2. package/dist/index.d.ts +232 -262
  3. package/dist/index.js +317 -341
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
2
+ import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { dirname, join, relative, sep, resolve } from 'node:path';
4
4
  import { z } from 'zod';
5
- import { randomUUID, createHash } from 'node:crypto';
5
+ import { createHash } from 'node:crypto';
6
6
 
7
7
  /**
8
8
  * Zod schema for jeeves-meta configuration.
@@ -15,7 +15,6 @@ import { randomUUID, createHash } from 'node:crypto';
15
15
  /** Zod schema for jeeves-meta configuration. */
16
16
  const synthConfigSchema = z.object({
17
17
  /** Filesystem paths to watch for .meta/ directories. */
18
- watchPaths: z.array(z.string()).min(1),
19
18
  /** Watcher service base URL. */
20
19
  watcherUrl: z.url(),
21
20
  /** OpenClaw gateway base URL for subprocess spawning. */
@@ -48,6 +47,21 @@ const synthConfigSchema = z.object({
48
47
  skipUnchanged: z.boolean().default(true),
49
48
  /** Number of metas to synthesize per invocation. */
50
49
  batchSize: z.number().int().min(1).default(1),
50
+ /**
51
+ * Watcher metadata properties for live .meta/meta.json files.
52
+ * Virtual rules use these to tag live metas; scan queries derive
53
+ * their filter from the first domain value.
54
+ */
55
+ metaProperty: z
56
+ .object({ domains: z.array(z.string()).min(1) })
57
+ .default({ domains: ['meta'] }),
58
+ /**
59
+ * Watcher metadata properties for .meta/archive/** snapshots.
60
+ * Virtual rules use these to tag archive files.
61
+ */
62
+ metaArchiveProperty: z
63
+ .object({ domains: z.array(z.string()).min(1) })
64
+ .default({ domains: ['meta-archive'] }),
51
65
  });
52
66
 
53
67
  /**
@@ -310,81 +324,100 @@ var index$1 = /*#__PURE__*/Object.freeze({
310
324
  });
311
325
 
312
326
  /**
313
- * Ensure meta.json exists in each .meta/ directory.
327
+ * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
314
328
  *
315
- * If meta.json is missing, creates it with a generated UUID.
329
+ * Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
330
+ * ensures all paths in the library use the same convention, regardless of
331
+ * the platform's native separator.
316
332
  *
317
- * @module discovery/ensureMetaJson
333
+ * @module normalizePath
318
334
  */
319
335
  /**
320
- * Ensure meta.json exists at the given .meta/ path.
336
+ * Normalize a file path to forward slashes.
321
337
  *
322
- * @param metaPath - Absolute path to a .meta/ directory.
323
- * @returns The meta.json content (existing or newly created).
338
+ * @param p - File path (may contain backslashes).
339
+ * @returns Path with all backslashes replaced by forward slashes.
324
340
  */
325
- function ensureMetaJson(metaPath) {
326
- const filePath = join(metaPath, 'meta.json');
327
- if (existsSync(filePath)) {
328
- const raw = readFileSync(filePath, 'utf8');
329
- return JSON.parse(raw);
330
- }
331
- // Create the archive subdirectory while we're at it
332
- const archivePath = join(metaPath, 'archive');
333
- if (!existsSync(archivePath)) {
334
- mkdirSync(archivePath, { recursive: true });
335
- }
336
- const meta = { _id: randomUUID() };
337
- writeFileSync(filePath, JSON.stringify(meta, null, 2) + '\n');
338
- return meta;
341
+ function normalizePath$1(p) {
342
+ return p.replaceAll('\\', '/');
339
343
  }
340
344
 
341
345
  /**
342
- * Glob watchPaths for .meta/ directories.
346
+ * Paginated scan helper for exhaustive scope enumeration.
343
347
  *
344
- * Walks each watchPath recursively, collecting directories named '.meta'
345
- * that contain (or will contain) a meta.json file.
348
+ * @module paginatedScan
349
+ */
350
+ /**
351
+ * Perform a paginated scan that follows cursor tokens until exhausted.
346
352
  *
347
- * @module discovery/globMetas
353
+ * @param watcher - WatcherClient instance.
354
+ * @param params - Base scan parameters (cursor is managed internally).
355
+ * @returns All matching files across all pages.
348
356
  */
357
+ async function paginatedScan(watcher, params) {
358
+ const allFiles = [];
359
+ let cursor;
360
+ do {
361
+ const result = await watcher.scan({ ...params, cursor });
362
+ allFiles.push(...result.files);
363
+ cursor = result.next;
364
+ } while (cursor);
365
+ return allFiles;
366
+ }
367
+
349
368
  /**
350
- * Recursively find all .meta/ directories under the given paths.
369
+ * Discover .meta/ directories via watcher scan.
370
+ *
371
+ * Replaces filesystem-based globMetas() with a watcher query
372
+ * that returns indexed .meta/meta.json points, filtered by domain.
351
373
  *
352
- * @param watchPaths - Root directories to search.
353
- * @returns Array of absolute paths to .meta/ directories.
374
+ * @module discovery/discoverMetas
354
375
  */
355
- function globMetas(watchPaths) {
356
- const results = [];
357
- function walk(dir) {
358
- let entries;
359
- try {
360
- entries = readdirSync(dir);
361
- }
362
- catch {
363
- return; // Skip unreadable directories
364
- }
365
- for (const entry of entries) {
366
- const full = join(dir, entry);
367
- let stat;
368
- try {
369
- stat = statSync(full);
370
- }
371
- catch {
372
- continue;
373
- }
374
- if (!stat.isDirectory())
375
- continue;
376
- if (entry === '.meta') {
377
- results.push(full);
378
- }
379
- else {
380
- walk(full);
381
- }
382
- }
383
- }
384
- for (const wp of watchPaths) {
385
- walk(wp);
376
+ /**
377
+ * Build a Qdrant filter from config metaProperty.
378
+ *
379
+ * @param config - Synth config with metaProperty.
380
+ * @returns Qdrant filter object for scanning live metas.
381
+ */
382
+ function buildMetaFilter(config) {
383
+ return {
384
+ must: [
385
+ {
386
+ key: 'domains',
387
+ match: { value: config.metaProperty.domains[0] },
388
+ },
389
+ ],
390
+ };
391
+ }
392
+ /**
393
+ * Discover all .meta/ directories via watcher scan.
394
+ *
395
+ * Queries the watcher for indexed .meta/meta.json points using the
396
+ * configured domain filter. Returns deduplicated meta directory paths.
397
+ *
398
+ * @param config - Synth config (for domain filter).
399
+ * @param watcher - WatcherClient for scan queries.
400
+ * @returns Array of normalized .meta/ directory paths.
401
+ */
402
+ async function discoverMetas(config, watcher) {
403
+ const filter = buildMetaFilter(config);
404
+ const scanFiles = await paginatedScan(watcher, {
405
+ filter,
406
+ fields: ['file_path'],
407
+ });
408
+ // Deduplicate by file_path (multi-chunk files)
409
+ const seen = new Set();
410
+ const metaPaths = [];
411
+ for (const sf of scanFiles) {
412
+ const fp = normalizePath$1(sf.file_path);
413
+ if (seen.has(fp))
414
+ continue;
415
+ seen.add(fp);
416
+ // Derive .meta/ directory from file_path (strip /meta.json)
417
+ const metaPath = fp.replace(/\/meta\.json$/, '');
418
+ metaPaths.push(metaPath);
386
419
  }
387
- return results;
420
+ return metaPaths;
388
421
  }
389
422
 
390
423
  /**
@@ -397,7 +430,7 @@ function globMetas(watchPaths) {
397
430
  * @module discovery/ownershipTree
398
431
  */
399
432
  /** Normalize path separators to forward slashes for consistent comparison. */
400
- function normalizePath$1(p) {
433
+ function normalizePath(p) {
401
434
  return p.split(sep).join('/');
402
435
  }
403
436
  /**
@@ -411,8 +444,8 @@ function buildOwnershipTree(metaPaths) {
411
444
  // Create nodes, sorted by ownerPath length (shortest first = shallowest)
412
445
  const sorted = [...metaPaths]
413
446
  .map((mp) => ({
414
- metaPath: normalizePath$1(mp),
415
- ownerPath: normalizePath$1(dirname(mp)),
447
+ metaPath: normalizePath(mp),
448
+ ownerPath: normalizePath(dirname(mp)),
416
449
  }))
417
450
  .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
418
451
  for (const { metaPath, ownerPath } of sorted) {
@@ -535,26 +568,200 @@ function computeEma(current, previous, decay = DEFAULT_DECAY) {
535
568
  }
536
569
 
537
570
  /**
538
- * Paginated scan helper for exhaustive scope enumeration.
571
+ * Shared error utilities.
539
572
  *
540
- * @module paginatedScan
573
+ * @module errors
541
574
  */
542
575
  /**
543
- * Perform a paginated scan that follows cursor tokens until exhausted.
576
+ * Wrap an unknown caught value into a SynthError.
544
577
  *
545
- * @param watcher - WatcherClient instance.
546
- * @param params - Base scan parameters (cursor is managed internally).
547
- * @returns All matching files across all pages.
578
+ * @param step - Which synthesis step failed.
579
+ * @param err - The caught error value.
580
+ * @param code - Error classification code.
581
+ * @returns A structured SynthError.
548
582
  */
549
- async function paginatedScan(watcher, params) {
550
- const allFiles = [];
551
- let cursor;
552
- do {
553
- const result = await watcher.scan({ ...params, cursor });
554
- allFiles.push(...result.files);
555
- cursor = result.next;
556
- } while (cursor);
557
- return allFiles;
583
+ function toSynthError(step, err, code = 'FAILED') {
584
+ return {
585
+ step,
586
+ code,
587
+ message: err instanceof Error ? err.message : String(err),
588
+ };
589
+ }
590
+
591
+ /**
592
+ * SynthExecutor implementation using the OpenClaw gateway HTTP API.
593
+ *
594
+ * Lives in the library package so both plugin and runner can import it.
595
+ * Spawns sub-agent sessions via the gateway, polls for completion,
596
+ * and extracts output text.
597
+ *
598
+ * @module executor/GatewayExecutor
599
+ */
600
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
601
+ const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
602
+ /** Sleep helper. */
603
+ function sleep$1(ms) {
604
+ return new Promise((resolve) => setTimeout(resolve, ms));
605
+ }
606
+ /**
607
+ * SynthExecutor that spawns OpenClaw sessions via the gateway HTTP API.
608
+ *
609
+ * Used by both the OpenClaw plugin (in-process tool calls) and the
610
+ * runner/CLI (external invocation). Constructs from `gatewayUrl` and
611
+ * optional `apiKey` — typically sourced from `SynthConfig`.
612
+ */
613
+ class GatewayExecutor {
614
+ gatewayUrl;
615
+ apiKey;
616
+ pollIntervalMs;
617
+ constructor(options = {}) {
618
+ this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
619
+ this.apiKey = options.apiKey;
620
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
621
+ }
622
+ async spawn(task, options) {
623
+ const timeoutMs = (options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000;
624
+ const deadline = Date.now() + timeoutMs;
625
+ const headers = {
626
+ 'Content-Type': 'application/json',
627
+ };
628
+ if (this.apiKey) {
629
+ headers['Authorization'] = 'Bearer ' + this.apiKey;
630
+ }
631
+ const spawnRes = await fetch(this.gatewayUrl + '/api/sessions/spawn', {
632
+ method: 'POST',
633
+ headers,
634
+ body: JSON.stringify({
635
+ task,
636
+ mode: 'run',
637
+ model: options?.model,
638
+ runTimeoutSeconds: options?.timeout,
639
+ }),
640
+ });
641
+ if (!spawnRes.ok) {
642
+ const text = await spawnRes.text();
643
+ throw new Error('Gateway spawn failed: HTTP ' +
644
+ spawnRes.status.toString() +
645
+ ' - ' +
646
+ text);
647
+ }
648
+ const spawnData = (await spawnRes.json());
649
+ if (!spawnData.sessionKey) {
650
+ throw new Error('Gateway spawn returned no sessionKey: ' + JSON.stringify(spawnData));
651
+ }
652
+ const { sessionKey } = spawnData;
653
+ // Poll for completion
654
+ while (Date.now() < deadline) {
655
+ await sleep$1(this.pollIntervalMs);
656
+ const historyRes = await fetch(this.gatewayUrl +
657
+ '/api/sessions/' +
658
+ encodeURIComponent(sessionKey) +
659
+ '/history?limit=50', { headers });
660
+ if (!historyRes.ok)
661
+ continue;
662
+ const history = (await historyRes.json());
663
+ if (history.status === 'completed' || history.status === 'done') {
664
+ // Extract token usage from session-level or message-level usage
665
+ let tokens;
666
+ if (history.usage?.totalTokens) {
667
+ tokens = history.usage.totalTokens;
668
+ }
669
+ else {
670
+ // Sum message-level usage as fallback
671
+ let sum = 0;
672
+ for (const msg of history.messages ?? []) {
673
+ if (msg.usage?.totalTokens)
674
+ sum += msg.usage.totalTokens;
675
+ }
676
+ if (sum > 0)
677
+ tokens = sum;
678
+ }
679
+ // Extract the last assistant message as output
680
+ const messages = history.messages ?? [];
681
+ for (let i = messages.length - 1; i >= 0; i--) {
682
+ if (messages[i].role === 'assistant' && messages[i].content) {
683
+ return { output: messages[i].content, tokens };
684
+ }
685
+ }
686
+ return { output: '', tokens };
687
+ }
688
+ }
689
+ throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
690
+ }
691
+ }
692
+
693
+ /**
694
+ * File-system lock for preventing concurrent synthesis on the same meta.
695
+ *
696
+ * Lock file: .meta/.lock containing PID + timestamp.
697
+ * Stale timeout: 30 minutes.
698
+ *
699
+ * @module lock
700
+ */
701
+ const LOCK_FILE = '.lock';
702
+ const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
703
+ /**
704
+ * Attempt to acquire a lock on a .meta directory.
705
+ *
706
+ * @param metaPath - Absolute path to the .meta directory.
707
+ * @returns True if lock was acquired, false if already locked (non-stale).
708
+ */
709
+ function acquireLock(metaPath) {
710
+ const lockPath = join(metaPath, LOCK_FILE);
711
+ if (existsSync(lockPath)) {
712
+ try {
713
+ const raw = readFileSync(lockPath, 'utf8');
714
+ const data = JSON.parse(raw);
715
+ const lockAge = Date.now() - new Date(data.startedAt).getTime();
716
+ if (lockAge < STALE_TIMEOUT_MS) {
717
+ return false; // Lock is active
718
+ }
719
+ // Stale lock — fall through to overwrite
720
+ }
721
+ catch {
722
+ // Corrupt lock file — overwrite
723
+ }
724
+ }
725
+ const lock = {
726
+ pid: process.pid,
727
+ startedAt: new Date().toISOString(),
728
+ };
729
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
730
+ return true;
731
+ }
732
+ /**
733
+ * Release a lock on a .meta directory.
734
+ *
735
+ * @param metaPath - Absolute path to the .meta directory.
736
+ */
737
+ function releaseLock(metaPath) {
738
+ const lockPath = join(metaPath, LOCK_FILE);
739
+ try {
740
+ unlinkSync(lockPath);
741
+ }
742
+ catch {
743
+ // Already removed or never existed
744
+ }
745
+ }
746
+ /**
747
+ * Check if a .meta directory is currently locked (non-stale).
748
+ *
749
+ * @param metaPath - Absolute path to the .meta directory.
750
+ * @returns True if locked and not stale.
751
+ */
752
+ function isLocked(metaPath) {
753
+ const lockPath = join(metaPath, LOCK_FILE);
754
+ if (!existsSync(lockPath))
755
+ return false;
756
+ try {
757
+ const raw = readFileSync(lockPath, 'utf8');
758
+ const data = JSON.parse(raw);
759
+ const lockAge = Date.now() - new Date(data.startedAt).getTime();
760
+ return lockAge < STALE_TIMEOUT_MS;
761
+ }
762
+ catch {
763
+ return false; // Corrupt lock = not locked
764
+ }
558
765
  }
559
766
 
560
767
  /**
@@ -846,120 +1053,6 @@ function mergeAndWrite(options) {
846
1053
  return result.data;
847
1054
  }
848
1055
 
849
- /**
850
- * Shared error utilities.
851
- *
852
- * @module errors
853
- */
854
- /**
855
- * Wrap an unknown caught value into a SynthError.
856
- *
857
- * @param step - Which synthesis step failed.
858
- * @param err - The caught error value.
859
- * @param code - Error classification code.
860
- * @returns A structured SynthError.
861
- */
862
- function toSynthError(step, err, code = 'FAILED') {
863
- return {
864
- step,
865
- code,
866
- message: err instanceof Error ? err.message : String(err),
867
- };
868
- }
869
-
870
- /**
871
- * File-system lock for preventing concurrent synthesis on the same meta.
872
- *
873
- * Lock file: .meta/.lock containing PID + timestamp.
874
- * Stale timeout: 30 minutes.
875
- *
876
- * @module lock
877
- */
878
- const LOCK_FILE = '.lock';
879
- const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
880
- /**
881
- * Attempt to acquire a lock on a .meta directory.
882
- *
883
- * @param metaPath - Absolute path to the .meta directory.
884
- * @returns True if lock was acquired, false if already locked (non-stale).
885
- */
886
- function acquireLock(metaPath) {
887
- const lockPath = join(metaPath, LOCK_FILE);
888
- if (existsSync(lockPath)) {
889
- try {
890
- const raw = readFileSync(lockPath, 'utf8');
891
- const data = JSON.parse(raw);
892
- const lockAge = Date.now() - new Date(data.startedAt).getTime();
893
- if (lockAge < STALE_TIMEOUT_MS) {
894
- return false; // Lock is active
895
- }
896
- // Stale lock — fall through to overwrite
897
- }
898
- catch {
899
- // Corrupt lock file — overwrite
900
- }
901
- }
902
- const lock = {
903
- pid: process.pid,
904
- startedAt: new Date().toISOString(),
905
- };
906
- writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
907
- return true;
908
- }
909
- /**
910
- * Release a lock on a .meta directory.
911
- *
912
- * @param metaPath - Absolute path to the .meta directory.
913
- */
914
- function releaseLock(metaPath) {
915
- const lockPath = join(metaPath, LOCK_FILE);
916
- try {
917
- unlinkSync(lockPath);
918
- }
919
- catch {
920
- // Already removed or never existed
921
- }
922
- }
923
- /**
924
- * Check if a .meta directory is currently locked (non-stale).
925
- *
926
- * @param metaPath - Absolute path to the .meta directory.
927
- * @returns True if locked and not stale.
928
- */
929
- function isLocked(metaPath) {
930
- const lockPath = join(metaPath, LOCK_FILE);
931
- if (!existsSync(lockPath))
932
- return false;
933
- try {
934
- const raw = readFileSync(lockPath, 'utf8');
935
- const data = JSON.parse(raw);
936
- const lockAge = Date.now() - new Date(data.startedAt).getTime();
937
- return lockAge < STALE_TIMEOUT_MS;
938
- }
939
- catch {
940
- return false; // Corrupt lock = not locked
941
- }
942
- }
943
-
944
- /**
945
- * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
946
- *
947
- * Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
948
- * ensures all paths in the library use the same convention, regardless of
949
- * the platform's native separator.
950
- *
951
- * @module normalizePath
952
- */
953
- /**
954
- * Normalize a file path to forward slashes.
955
- *
956
- * @param p - File path (may contain backslashes).
957
- * @returns Path with all backslashes replaced by forward slashes.
958
- */
959
- function normalizePath(p) {
960
- return p.replaceAll('\\', '/');
961
- }
962
-
963
1056
  /**
964
1057
  * Select the best synthesis candidate from stale metas.
965
1058
  *
@@ -1216,17 +1309,32 @@ function finalizeCycle(metaPath, current, config, architect, builder, critic, bu
1216
1309
  * @param watcher - Watcher HTTP client.
1217
1310
  * @returns Result indicating whether synthesis occurred.
1218
1311
  */
1219
- async function orchestrateOnce(config, executor, watcher) {
1220
- // Step 1: Discover
1221
- const metaPaths = globMetas(config.watchPaths);
1312
+ async function orchestrateOnce(config, executor, watcher, targetPath) {
1313
+ // Step 1: Discover via watcher scan
1314
+ const metaPaths = await discoverMetas(config, watcher);
1222
1315
  if (metaPaths.length === 0)
1223
1316
  return { synthesized: false };
1224
- // Ensure all meta.json files exist
1317
+ // Read meta.json for each discovered meta
1225
1318
  const metas = new Map();
1226
1319
  for (const mp of metaPaths) {
1227
- metas.set(normalizePath(mp), ensureMetaJson(mp));
1320
+ const metaFilePath = join(mp, 'meta.json');
1321
+ try {
1322
+ metas.set(normalizePath$1(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
1323
+ }
1324
+ catch {
1325
+ // Skip metas with unreadable meta.json
1326
+ continue;
1327
+ }
1228
1328
  }
1229
1329
  const tree = buildOwnershipTree(metaPaths);
1330
+ // If targetPath specified, skip candidate selection — go directly to that meta
1331
+ let targetNode;
1332
+ if (targetPath) {
1333
+ const normalized = normalizePath$1(targetPath);
1334
+ targetNode = findNode(tree, normalized) ?? undefined;
1335
+ if (!targetNode)
1336
+ return { synthesized: false };
1337
+ }
1230
1338
  // Steps 3-4: Staleness check + candidate selection
1231
1339
  const candidates = [];
1232
1340
  for (const node of tree.nodes.values()) {
@@ -1261,9 +1369,13 @@ async function orchestrateOnce(config, executor, watcher) {
1261
1369
  winner = candidate;
1262
1370
  break;
1263
1371
  }
1264
- if (!winner)
1372
+ if (!winner && !targetNode)
1373
+ return { synthesized: false };
1374
+ const node = targetNode ?? winner.node;
1375
+ // For targeted path, acquire lock now (candidate selection already locked for stalest)
1376
+ if (targetNode && !acquireLock(node.metaPath)) {
1265
1377
  return { synthesized: false };
1266
- const { node } = winner;
1378
+ }
1267
1379
  try {
1268
1380
  // Re-read meta after lock (may have changed)
1269
1381
  const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
@@ -1373,12 +1485,13 @@ async function orchestrateOnce(config, executor, watcher) {
1373
1485
  * @param config - Validated synthesis config.
1374
1486
  * @param executor - Pluggable LLM executor.
1375
1487
  * @param watcher - Watcher HTTP client.
1488
+ * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
1376
1489
  * @returns Array of results, one per cycle attempted.
1377
1490
  */
1378
- async function orchestrate(config, executor, watcher) {
1491
+ async function orchestrate(config, executor, watcher, targetPath) {
1379
1492
  const results = [];
1380
1493
  for (let i = 0; i < config.batchSize; i++) {
1381
- const result = await orchestrateOnce(config, executor, watcher);
1494
+ const result = await orchestrateOnce(config, executor, watcher, targetPath);
1382
1495
  results.push(result);
1383
1496
  if (!result.synthesized)
1384
1497
  break; // No more candidates
@@ -1386,108 +1499,6 @@ async function orchestrate(config, executor, watcher) {
1386
1499
  return results;
1387
1500
  }
1388
1501
 
1389
- /**
1390
- * SynthExecutor implementation using the OpenClaw gateway HTTP API.
1391
- *
1392
- * Lives in the library package so both plugin and runner can import it.
1393
- * Spawns sub-agent sessions via the gateway, polls for completion,
1394
- * and extracts output text.
1395
- *
1396
- * @module executor/GatewayExecutor
1397
- */
1398
- const DEFAULT_POLL_INTERVAL_MS = 5000;
1399
- const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
1400
- /** Sleep helper. */
1401
- function sleep$1(ms) {
1402
- return new Promise((resolve) => setTimeout(resolve, ms));
1403
- }
1404
- /**
1405
- * SynthExecutor that spawns OpenClaw sessions via the gateway HTTP API.
1406
- *
1407
- * Used by both the OpenClaw plugin (in-process tool calls) and the
1408
- * runner/CLI (external invocation). Constructs from `gatewayUrl` and
1409
- * optional `apiKey` — typically sourced from `SynthConfig`.
1410
- */
1411
- class GatewayExecutor {
1412
- gatewayUrl;
1413
- apiKey;
1414
- pollIntervalMs;
1415
- constructor(options = {}) {
1416
- this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
1417
- this.apiKey = options.apiKey;
1418
- this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1419
- }
1420
- async spawn(task, options) {
1421
- const timeoutMs = (options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000;
1422
- const deadline = Date.now() + timeoutMs;
1423
- const headers = {
1424
- 'Content-Type': 'application/json',
1425
- };
1426
- if (this.apiKey) {
1427
- headers['Authorization'] = 'Bearer ' + this.apiKey;
1428
- }
1429
- const spawnRes = await fetch(this.gatewayUrl + '/api/sessions/spawn', {
1430
- method: 'POST',
1431
- headers,
1432
- body: JSON.stringify({
1433
- task,
1434
- mode: 'run',
1435
- model: options?.model,
1436
- runTimeoutSeconds: options?.timeout,
1437
- }),
1438
- });
1439
- if (!spawnRes.ok) {
1440
- const text = await spawnRes.text();
1441
- throw new Error('Gateway spawn failed: HTTP ' +
1442
- spawnRes.status.toString() +
1443
- ' - ' +
1444
- text);
1445
- }
1446
- const spawnData = (await spawnRes.json());
1447
- if (!spawnData.sessionKey) {
1448
- throw new Error('Gateway spawn returned no sessionKey: ' + JSON.stringify(spawnData));
1449
- }
1450
- const { sessionKey } = spawnData;
1451
- // Poll for completion
1452
- while (Date.now() < deadline) {
1453
- await sleep$1(this.pollIntervalMs);
1454
- const historyRes = await fetch(this.gatewayUrl +
1455
- '/api/sessions/' +
1456
- encodeURIComponent(sessionKey) +
1457
- '/history?limit=50', { headers });
1458
- if (!historyRes.ok)
1459
- continue;
1460
- const history = (await historyRes.json());
1461
- if (history.status === 'completed' || history.status === 'done') {
1462
- // Extract token usage from session-level or message-level usage
1463
- let tokens;
1464
- if (history.usage?.totalTokens) {
1465
- tokens = history.usage.totalTokens;
1466
- }
1467
- else {
1468
- // Sum message-level usage as fallback
1469
- let sum = 0;
1470
- for (const msg of history.messages ?? []) {
1471
- if (msg.usage?.totalTokens)
1472
- sum += msg.usage.totalTokens;
1473
- }
1474
- if (sum > 0)
1475
- tokens = sum;
1476
- }
1477
- // Extract the last assistant message as output
1478
- const messages = history.messages ?? [];
1479
- for (let i = messages.length - 1; i >= 0; i--) {
1480
- if (messages[i].role === 'assistant' && messages[i].content) {
1481
- return { output: messages[i].content, tokens };
1482
- }
1483
- }
1484
- return { output: '', tokens };
1485
- }
1486
- }
1487
- throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
1488
- }
1489
- }
1490
-
1491
1502
  /**
1492
1503
  * HTTP implementation of the WatcherClient interface.
1493
1504
  *
@@ -1604,16 +1615,16 @@ var index = /*#__PURE__*/Object.freeze({
1604
1615
  buildBuilderTask: buildBuilderTask,
1605
1616
  buildContextPackage: buildContextPackage,
1606
1617
  buildCriticTask: buildCriticTask,
1618
+ buildMetaFilter: buildMetaFilter,
1607
1619
  buildOwnershipTree: buildOwnershipTree,
1608
1620
  computeEffectiveStaleness: computeEffectiveStaleness,
1609
1621
  computeEma: computeEma,
1610
1622
  computeStructureHash: computeStructureHash,
1611
1623
  createSnapshot: createSnapshot,
1612
- ensureMetaJson: ensureMetaJson,
1624
+ discoverMetas: discoverMetas,
1613
1625
  filterInScope: filterInScope,
1614
1626
  findNode: findNode,
1615
1627
  getScopePrefix: getScopePrefix,
1616
- globMetas: globMetas,
1617
1628
  hasSteerChanged: hasSteerChanged,
1618
1629
  isArchitectTriggered: isArchitectTriggered,
1619
1630
  isLocked: isLocked,
@@ -1622,7 +1633,7 @@ var index = /*#__PURE__*/Object.freeze({
1622
1633
  loadSynthConfig: loadSynthConfig,
1623
1634
  mergeAndWrite: mergeAndWrite,
1624
1635
  metaJsonSchema: metaJsonSchema,
1625
- normalizePath: normalizePath,
1636
+ normalizePath: normalizePath$1,
1626
1637
  orchestrate: orchestrate,
1627
1638
  paginatedScan: paginatedScan,
1628
1639
  parseArchitectOutput: parseArchitectOutput,
@@ -1648,6 +1659,10 @@ var index = /*#__PURE__*/Object.freeze({
1648
1659
  *
1649
1660
  * @module cli
1650
1661
  */
1662
+ /** Read and parse a meta.json file with proper typing. */
1663
+ function readMeta(metaPath) {
1664
+ return JSON.parse(readFileSync(join(metaPath, 'meta.json'), 'utf8'));
1665
+ }
1651
1666
  const args = process.argv.slice(2);
1652
1667
  const command = args.find((a) => !a.startsWith('-'));
1653
1668
  const jsonOutput = args.includes('--json');
@@ -1683,8 +1698,9 @@ function output(data) {
1683
1698
  console.log(JSON.stringify(data, null, 2));
1684
1699
  }
1685
1700
  }
1686
- function runStatus(config) {
1687
- const metaPaths = globMetas(config.watchPaths);
1701
+ async function runStatus(config) {
1702
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1703
+ const metaPaths = await discoverMetas(config, watcher);
1688
1704
  const tree = buildOwnershipTree(metaPaths);
1689
1705
  let stale = 0;
1690
1706
  let errors = 0;
@@ -1694,13 +1710,19 @@ function runStatus(config) {
1694
1710
  let buildTokens = 0;
1695
1711
  let critTokens = 0;
1696
1712
  for (const node of tree.nodes.values()) {
1697
- const meta = ensureMetaJson(node.metaPath);
1713
+ let meta;
1714
+ try {
1715
+ meta = readMeta(node.metaPath);
1716
+ }
1717
+ catch {
1718
+ continue;
1719
+ }
1698
1720
  const s = actualStaleness(meta);
1699
1721
  if (s > 0)
1700
1722
  stale++;
1701
1723
  if (meta._error)
1702
1724
  errors++;
1703
- if (isLocked(normalizePath(node.metaPath)))
1725
+ if (isLocked(normalizePath$1(node.metaPath)))
1704
1726
  locked++;
1705
1727
  if (!meta._generatedAt)
1706
1728
  neverSynth++;
@@ -1720,19 +1742,26 @@ function runStatus(config) {
1720
1742
  tokens: { architect: archTokens, builder: buildTokens, critic: critTokens },
1721
1743
  });
1722
1744
  }
1723
- function runList(config) {
1745
+ async function runList(config) {
1724
1746
  const prefix = getArg('--prefix');
1725
1747
  const filter = getArg('--filter');
1726
- const metaPaths = globMetas(config.watchPaths);
1748
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1749
+ const metaPaths = await discoverMetas(config, watcher);
1727
1750
  const tree = buildOwnershipTree(metaPaths);
1728
1751
  const rows = [];
1729
1752
  for (const node of tree.nodes.values()) {
1730
1753
  if (prefix && !node.metaPath.includes(prefix))
1731
1754
  continue;
1732
- const meta = ensureMetaJson(node.metaPath);
1755
+ let meta;
1756
+ try {
1757
+ meta = readMeta(node.metaPath);
1758
+ }
1759
+ catch {
1760
+ continue;
1761
+ }
1733
1762
  const s = actualStaleness(meta);
1734
1763
  const hasError = Boolean(meta._error);
1735
- const isLockedNow = isLocked(normalizePath(node.metaPath));
1764
+ const isLockedNow = isLocked(normalizePath$1(node.metaPath));
1736
1765
  if (filter === 'hasError' && !hasError)
1737
1766
  continue;
1738
1767
  if (filter === 'stale' && s <= 0)
@@ -1759,15 +1788,16 @@ async function runDetail(config) {
1759
1788
  process.exit(1);
1760
1789
  }
1761
1790
  const archiveArg = getArg('--archive');
1762
- const metaPaths = globMetas(config.watchPaths);
1791
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1792
+ const metaPaths = await discoverMetas(config, watcher);
1763
1793
  const tree = buildOwnershipTree(metaPaths);
1764
- const normalized = normalizePath(targetPath);
1794
+ const normalized = normalizePath$1(targetPath);
1765
1795
  const node = findNode(tree, normalized);
1766
1796
  if (!node) {
1767
1797
  console.error('Meta not found: ' + targetPath);
1768
1798
  process.exit(1);
1769
1799
  }
1770
- const meta = ensureMetaJson(node.metaPath);
1800
+ const meta = readMeta(node.metaPath);
1771
1801
  const result = { meta };
1772
1802
  if (archiveArg) {
1773
1803
  const { listArchiveFiles } = await Promise.resolve().then(function () { return index$1; });
@@ -1784,12 +1814,12 @@ async function runDetail(config) {
1784
1814
  async function runPreview(config) {
1785
1815
  const targetPath = getArg('--path');
1786
1816
  const { filterInScope, paginatedScan, readLatestArchive, computeStructureHash, selectCandidate, } = await Promise.resolve().then(function () { return index; });
1787
- const metaPaths = globMetas(config.watchPaths);
1788
- const tree = buildOwnershipTree(metaPaths);
1789
1817
  const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1818
+ const metaPaths = await discoverMetas(config, watcher);
1819
+ const tree = buildOwnershipTree(metaPaths);
1790
1820
  let targetNode;
1791
1821
  if (targetPath) {
1792
- const normalized = normalizePath(targetPath);
1822
+ const normalized = normalizePath$1(targetPath);
1793
1823
  targetNode = findNode(tree, normalized);
1794
1824
  if (!targetNode) {
1795
1825
  console.error('Meta not found: ' + targetPath);
@@ -1799,7 +1829,13 @@ async function runPreview(config) {
1799
1829
  else {
1800
1830
  const candidates = [];
1801
1831
  for (const node of tree.nodes.values()) {
1802
- const meta = ensureMetaJson(node.metaPath);
1832
+ let meta;
1833
+ try {
1834
+ meta = readMeta(node.metaPath);
1835
+ }
1836
+ catch {
1837
+ continue;
1838
+ }
1803
1839
  const s = actualStaleness(meta);
1804
1840
  if (s > 0)
1805
1841
  candidates.push({ node, meta, actualStaleness: s });
@@ -1812,7 +1848,7 @@ async function runPreview(config) {
1812
1848
  }
1813
1849
  targetNode = winner.node;
1814
1850
  }
1815
- const meta = ensureMetaJson(targetNode.metaPath);
1851
+ const meta = readMeta(targetNode.metaPath);
1816
1852
  const allFiles = await paginatedScan(watcher, {
1817
1853
  pathPrefix: targetNode.ownerPath,
1818
1854
  });
@@ -1840,9 +1876,6 @@ async function runSynthesize(config) {
1840
1876
  const batchArg = getArg('--batch');
1841
1877
  const effectiveConfig = {
1842
1878
  ...config,
1843
- ...(targetPath
1844
- ? { watchPaths: [targetPath.replace(/[/\\]\.meta[/\\]?$/, '')] }
1845
- : {}),
1846
1879
  ...(batchArg ? { batchSize: parseInt(batchArg, 10) } : {}),
1847
1880
  };
1848
1881
  const executor = new GatewayExecutor({
@@ -1850,7 +1883,7 @@ async function runSynthesize(config) {
1850
1883
  apiKey: config.gatewayApiKey,
1851
1884
  });
1852
1885
  const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1853
- const results = await orchestrate(effectiveConfig, executor, watcher);
1886
+ const results = await orchestrate(effectiveConfig, executor, watcher, targetPath ?? undefined);
1854
1887
  const synthesized = results.filter((r) => r.synthesized);
1855
1888
  output({
1856
1889
  synthesizedCount: synthesized.length,
@@ -1921,9 +1954,16 @@ async function runValidate(config) {
1921
1954
  catch {
1922
1955
  checks.gateway = 'UNREACHABLE (' + config.gatewayUrl + ')';
1923
1956
  }
1924
- // Check watch paths
1925
- const metaPaths = globMetas(config.watchPaths);
1926
- checks.metas = String(metaPaths.length) + ' .meta/ directories found';
1957
+ // Check meta discovery via watcher
1958
+ try {
1959
+ const watcherClient = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1960
+ const metaPaths = await discoverMetas(config, watcherClient);
1961
+ checks.metas =
1962
+ String(metaPaths.length) + ' .meta/ entities discovered via watcher';
1963
+ }
1964
+ catch {
1965
+ checks.metas = 'FAILED — could not discover metas (watcher may be down)';
1966
+ }
1927
1967
  output({ config: 'valid', checks });
1928
1968
  }
1929
1969
  function runConfigShow(config) {
@@ -1983,10 +2023,10 @@ async function main() {
1983
2023
  }
1984
2024
  switch (command) {
1985
2025
  case 'status':
1986
- runStatus(config);
2026
+ await runStatus(config);
1987
2027
  break;
1988
2028
  case 'list':
1989
- runList(config);
2029
+ await runList(config);
1990
2030
  break;
1991
2031
  case 'detail':
1992
2032
  await runDetail(config);