@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.
- package/dist/cli.js +373 -333
- package/dist/index.d.ts +232 -262
- package/dist/index.js +317 -341
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync
|
|
1
|
+
import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, relative, sep } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import {
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* List archive snapshot files in chronological order.
|
|
@@ -110,7 +110,6 @@ function createSnapshot(metaPath, meta) {
|
|
|
110
110
|
/** Zod schema for jeeves-meta configuration. */
|
|
111
111
|
const synthConfigSchema = z.object({
|
|
112
112
|
/** Filesystem paths to watch for .meta/ directories. */
|
|
113
|
-
watchPaths: z.array(z.string()).min(1),
|
|
114
113
|
/** Watcher service base URL. */
|
|
115
114
|
watcherUrl: z.url(),
|
|
116
115
|
/** OpenClaw gateway base URL for subprocess spawning. */
|
|
@@ -143,6 +142,21 @@ const synthConfigSchema = z.object({
|
|
|
143
142
|
skipUnchanged: z.boolean().default(true),
|
|
144
143
|
/** Number of metas to synthesize per invocation. */
|
|
145
144
|
batchSize: z.number().int().min(1).default(1),
|
|
145
|
+
/**
|
|
146
|
+
* Watcher metadata properties for live .meta/meta.json files.
|
|
147
|
+
* Virtual rules use these to tag live metas; scan queries derive
|
|
148
|
+
* their filter from the first domain value.
|
|
149
|
+
*/
|
|
150
|
+
metaProperty: z
|
|
151
|
+
.object({ domains: z.array(z.string()).min(1) })
|
|
152
|
+
.default({ domains: ['meta'] }),
|
|
153
|
+
/**
|
|
154
|
+
* Watcher metadata properties for .meta/archive/** snapshots.
|
|
155
|
+
* Virtual rules use these to tag archive files.
|
|
156
|
+
*/
|
|
157
|
+
metaArchiveProperty: z
|
|
158
|
+
.object({ domains: z.array(z.string()).min(1) })
|
|
159
|
+
.default({ domains: ['meta-archive'] }),
|
|
146
160
|
});
|
|
147
161
|
|
|
148
162
|
/**
|
|
@@ -295,81 +309,100 @@ function resolveConfigPath(args) {
|
|
|
295
309
|
}
|
|
296
310
|
|
|
297
311
|
/**
|
|
298
|
-
*
|
|
312
|
+
* Normalize file paths to forward slashes for consistency with watcher-indexed paths.
|
|
299
313
|
*
|
|
300
|
-
*
|
|
314
|
+
* Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
|
|
315
|
+
* ensures all paths in the library use the same convention, regardless of
|
|
316
|
+
* the platform's native separator.
|
|
301
317
|
*
|
|
302
|
-
* @module
|
|
318
|
+
* @module normalizePath
|
|
303
319
|
*/
|
|
304
320
|
/**
|
|
305
|
-
*
|
|
321
|
+
* Normalize a file path to forward slashes.
|
|
306
322
|
*
|
|
307
|
-
* @param
|
|
308
|
-
* @returns
|
|
323
|
+
* @param p - File path (may contain backslashes).
|
|
324
|
+
* @returns Path with all backslashes replaced by forward slashes.
|
|
309
325
|
*/
|
|
310
|
-
function
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
326
|
+
function normalizePath$1(p) {
|
|
327
|
+
return p.replaceAll('\\', '/');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Paginated scan helper for exhaustive scope enumeration.
|
|
332
|
+
*
|
|
333
|
+
* @module paginatedScan
|
|
334
|
+
*/
|
|
335
|
+
/**
|
|
336
|
+
* Perform a paginated scan that follows cursor tokens until exhausted.
|
|
337
|
+
*
|
|
338
|
+
* @param watcher - WatcherClient instance.
|
|
339
|
+
* @param params - Base scan parameters (cursor is managed internally).
|
|
340
|
+
* @returns All matching files across all pages.
|
|
341
|
+
*/
|
|
342
|
+
async function paginatedScan(watcher, params) {
|
|
343
|
+
const allFiles = [];
|
|
344
|
+
let cursor;
|
|
345
|
+
do {
|
|
346
|
+
const result = await watcher.scan({ ...params, cursor });
|
|
347
|
+
allFiles.push(...result.files);
|
|
348
|
+
cursor = result.next;
|
|
349
|
+
} while (cursor);
|
|
350
|
+
return allFiles;
|
|
324
351
|
}
|
|
325
352
|
|
|
326
353
|
/**
|
|
327
|
-
*
|
|
354
|
+
* Discover .meta/ directories via watcher scan.
|
|
328
355
|
*
|
|
329
|
-
*
|
|
330
|
-
* that
|
|
356
|
+
* Replaces filesystem-based globMetas() with a watcher query
|
|
357
|
+
* that returns indexed .meta/meta.json points, filtered by domain.
|
|
331
358
|
*
|
|
332
|
-
* @module discovery/
|
|
359
|
+
* @module discovery/discoverMetas
|
|
333
360
|
*/
|
|
334
361
|
/**
|
|
335
|
-
*
|
|
362
|
+
* Build a Qdrant filter from config metaProperty.
|
|
336
363
|
*
|
|
337
|
-
* @param
|
|
338
|
-
* @returns
|
|
364
|
+
* @param config - Synth config with metaProperty.
|
|
365
|
+
* @returns Qdrant filter object for scanning live metas.
|
|
339
366
|
*/
|
|
340
|
-
function
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
for (const
|
|
370
|
-
|
|
367
|
+
function buildMetaFilter(config) {
|
|
368
|
+
return {
|
|
369
|
+
must: [
|
|
370
|
+
{
|
|
371
|
+
key: 'domains',
|
|
372
|
+
match: { value: config.metaProperty.domains[0] },
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Discover all .meta/ directories via watcher scan.
|
|
379
|
+
*
|
|
380
|
+
* Queries the watcher for indexed .meta/meta.json points using the
|
|
381
|
+
* configured domain filter. Returns deduplicated meta directory paths.
|
|
382
|
+
*
|
|
383
|
+
* @param config - Synth config (for domain filter).
|
|
384
|
+
* @param watcher - WatcherClient for scan queries.
|
|
385
|
+
* @returns Array of normalized .meta/ directory paths.
|
|
386
|
+
*/
|
|
387
|
+
async function discoverMetas(config, watcher) {
|
|
388
|
+
const filter = buildMetaFilter(config);
|
|
389
|
+
const scanFiles = await paginatedScan(watcher, {
|
|
390
|
+
filter,
|
|
391
|
+
fields: ['file_path'],
|
|
392
|
+
});
|
|
393
|
+
// Deduplicate by file_path (multi-chunk files)
|
|
394
|
+
const seen = new Set();
|
|
395
|
+
const metaPaths = [];
|
|
396
|
+
for (const sf of scanFiles) {
|
|
397
|
+
const fp = normalizePath$1(sf.file_path);
|
|
398
|
+
if (seen.has(fp))
|
|
399
|
+
continue;
|
|
400
|
+
seen.add(fp);
|
|
401
|
+
// Derive .meta/ directory from file_path (strip /meta.json)
|
|
402
|
+
const metaPath = fp.replace(/\/meta\.json$/, '');
|
|
403
|
+
metaPaths.push(metaPath);
|
|
371
404
|
}
|
|
372
|
-
return
|
|
405
|
+
return metaPaths;
|
|
373
406
|
}
|
|
374
407
|
|
|
375
408
|
/**
|
|
@@ -382,7 +415,7 @@ function globMetas(watchPaths) {
|
|
|
382
415
|
* @module discovery/ownershipTree
|
|
383
416
|
*/
|
|
384
417
|
/** Normalize path separators to forward slashes for consistent comparison. */
|
|
385
|
-
function normalizePath
|
|
418
|
+
function normalizePath(p) {
|
|
386
419
|
return p.split(sep).join('/');
|
|
387
420
|
}
|
|
388
421
|
/**
|
|
@@ -396,8 +429,8 @@ function buildOwnershipTree(metaPaths) {
|
|
|
396
429
|
// Create nodes, sorted by ownerPath length (shortest first = shallowest)
|
|
397
430
|
const sorted = [...metaPaths]
|
|
398
431
|
.map((mp) => ({
|
|
399
|
-
metaPath: normalizePath
|
|
400
|
-
ownerPath: normalizePath
|
|
432
|
+
metaPath: normalizePath(mp),
|
|
433
|
+
ownerPath: normalizePath(dirname(mp)),
|
|
401
434
|
}))
|
|
402
435
|
.sort((a, b) => a.ownerPath.length - b.ownerPath.length);
|
|
403
436
|
for (const { metaPath, ownerPath } of sorted) {
|
|
@@ -469,15 +502,6 @@ function findNode(tree, targetPath) {
|
|
|
469
502
|
function getScopePrefix(node) {
|
|
470
503
|
return node.ownerPath;
|
|
471
504
|
}
|
|
472
|
-
/**
|
|
473
|
-
* Get paths that should be excluded from the scope (child meta subtrees).
|
|
474
|
-
*
|
|
475
|
-
* @param node - The meta node to compute exclusions for.
|
|
476
|
-
* @returns Array of path prefixes to exclude from scope queries.
|
|
477
|
-
*/
|
|
478
|
-
function getScopeExclusions(node) {
|
|
479
|
-
return node.children.map((child) => child.ownerPath);
|
|
480
|
-
}
|
|
481
505
|
/**
|
|
482
506
|
* Filter a list of file paths to only those in scope for a meta node.
|
|
483
507
|
*
|
|
@@ -529,26 +553,200 @@ function computeEma(current, previous, decay = DEFAULT_DECAY) {
|
|
|
529
553
|
}
|
|
530
554
|
|
|
531
555
|
/**
|
|
532
|
-
*
|
|
556
|
+
* Shared error utilities.
|
|
533
557
|
*
|
|
534
|
-
* @module
|
|
558
|
+
* @module errors
|
|
535
559
|
*/
|
|
536
560
|
/**
|
|
537
|
-
*
|
|
561
|
+
* Wrap an unknown caught value into a SynthError.
|
|
538
562
|
*
|
|
539
|
-
* @param
|
|
540
|
-
* @param
|
|
541
|
-
* @
|
|
563
|
+
* @param step - Which synthesis step failed.
|
|
564
|
+
* @param err - The caught error value.
|
|
565
|
+
* @param code - Error classification code.
|
|
566
|
+
* @returns A structured SynthError.
|
|
542
567
|
*/
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
568
|
+
function toSynthError(step, err, code = 'FAILED') {
|
|
569
|
+
return {
|
|
570
|
+
step,
|
|
571
|
+
code,
|
|
572
|
+
message: err instanceof Error ? err.message : String(err),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* SynthExecutor implementation using the OpenClaw gateway HTTP API.
|
|
578
|
+
*
|
|
579
|
+
* Lives in the library package so both plugin and runner can import it.
|
|
580
|
+
* Spawns sub-agent sessions via the gateway, polls for completion,
|
|
581
|
+
* and extracts output text.
|
|
582
|
+
*
|
|
583
|
+
* @module executor/GatewayExecutor
|
|
584
|
+
*/
|
|
585
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
586
|
+
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
587
|
+
/** Sleep helper. */
|
|
588
|
+
function sleep$1(ms) {
|
|
589
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* SynthExecutor that spawns OpenClaw sessions via the gateway HTTP API.
|
|
593
|
+
*
|
|
594
|
+
* Used by both the OpenClaw plugin (in-process tool calls) and the
|
|
595
|
+
* runner/CLI (external invocation). Constructs from `gatewayUrl` and
|
|
596
|
+
* optional `apiKey` — typically sourced from `SynthConfig`.
|
|
597
|
+
*/
|
|
598
|
+
class GatewayExecutor {
|
|
599
|
+
gatewayUrl;
|
|
600
|
+
apiKey;
|
|
601
|
+
pollIntervalMs;
|
|
602
|
+
constructor(options = {}) {
|
|
603
|
+
this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
|
|
604
|
+
this.apiKey = options.apiKey;
|
|
605
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
606
|
+
}
|
|
607
|
+
async spawn(task, options) {
|
|
608
|
+
const timeoutMs = (options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000;
|
|
609
|
+
const deadline = Date.now() + timeoutMs;
|
|
610
|
+
const headers = {
|
|
611
|
+
'Content-Type': 'application/json',
|
|
612
|
+
};
|
|
613
|
+
if (this.apiKey) {
|
|
614
|
+
headers['Authorization'] = 'Bearer ' + this.apiKey;
|
|
615
|
+
}
|
|
616
|
+
const spawnRes = await fetch(this.gatewayUrl + '/api/sessions/spawn', {
|
|
617
|
+
method: 'POST',
|
|
618
|
+
headers,
|
|
619
|
+
body: JSON.stringify({
|
|
620
|
+
task,
|
|
621
|
+
mode: 'run',
|
|
622
|
+
model: options?.model,
|
|
623
|
+
runTimeoutSeconds: options?.timeout,
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
if (!spawnRes.ok) {
|
|
627
|
+
const text = await spawnRes.text();
|
|
628
|
+
throw new Error('Gateway spawn failed: HTTP ' +
|
|
629
|
+
spawnRes.status.toString() +
|
|
630
|
+
' - ' +
|
|
631
|
+
text);
|
|
632
|
+
}
|
|
633
|
+
const spawnData = (await spawnRes.json());
|
|
634
|
+
if (!spawnData.sessionKey) {
|
|
635
|
+
throw new Error('Gateway spawn returned no sessionKey: ' + JSON.stringify(spawnData));
|
|
636
|
+
}
|
|
637
|
+
const { sessionKey } = spawnData;
|
|
638
|
+
// Poll for completion
|
|
639
|
+
while (Date.now() < deadline) {
|
|
640
|
+
await sleep$1(this.pollIntervalMs);
|
|
641
|
+
const historyRes = await fetch(this.gatewayUrl +
|
|
642
|
+
'/api/sessions/' +
|
|
643
|
+
encodeURIComponent(sessionKey) +
|
|
644
|
+
'/history?limit=50', { headers });
|
|
645
|
+
if (!historyRes.ok)
|
|
646
|
+
continue;
|
|
647
|
+
const history = (await historyRes.json());
|
|
648
|
+
if (history.status === 'completed' || history.status === 'done') {
|
|
649
|
+
// Extract token usage from session-level or message-level usage
|
|
650
|
+
let tokens;
|
|
651
|
+
if (history.usage?.totalTokens) {
|
|
652
|
+
tokens = history.usage.totalTokens;
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
// Sum message-level usage as fallback
|
|
656
|
+
let sum = 0;
|
|
657
|
+
for (const msg of history.messages ?? []) {
|
|
658
|
+
if (msg.usage?.totalTokens)
|
|
659
|
+
sum += msg.usage.totalTokens;
|
|
660
|
+
}
|
|
661
|
+
if (sum > 0)
|
|
662
|
+
tokens = sum;
|
|
663
|
+
}
|
|
664
|
+
// Extract the last assistant message as output
|
|
665
|
+
const messages = history.messages ?? [];
|
|
666
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
667
|
+
if (messages[i].role === 'assistant' && messages[i].content) {
|
|
668
|
+
return { output: messages[i].content, tokens };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return { output: '', tokens };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* File-system lock for preventing concurrent synthesis on the same meta.
|
|
680
|
+
*
|
|
681
|
+
* Lock file: .meta/.lock containing PID + timestamp.
|
|
682
|
+
* Stale timeout: 30 minutes.
|
|
683
|
+
*
|
|
684
|
+
* @module lock
|
|
685
|
+
*/
|
|
686
|
+
const LOCK_FILE = '.lock';
|
|
687
|
+
const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
688
|
+
/**
|
|
689
|
+
* Attempt to acquire a lock on a .meta directory.
|
|
690
|
+
*
|
|
691
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
692
|
+
* @returns True if lock was acquired, false if already locked (non-stale).
|
|
693
|
+
*/
|
|
694
|
+
function acquireLock(metaPath) {
|
|
695
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
696
|
+
if (existsSync(lockPath)) {
|
|
697
|
+
try {
|
|
698
|
+
const raw = readFileSync(lockPath, 'utf8');
|
|
699
|
+
const data = JSON.parse(raw);
|
|
700
|
+
const lockAge = Date.now() - new Date(data.startedAt).getTime();
|
|
701
|
+
if (lockAge < STALE_TIMEOUT_MS) {
|
|
702
|
+
return false; // Lock is active
|
|
703
|
+
}
|
|
704
|
+
// Stale lock — fall through to overwrite
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
// Corrupt lock file — overwrite
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const lock = {
|
|
711
|
+
pid: process.pid,
|
|
712
|
+
startedAt: new Date().toISOString(),
|
|
713
|
+
};
|
|
714
|
+
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Release a lock on a .meta directory.
|
|
719
|
+
*
|
|
720
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
721
|
+
*/
|
|
722
|
+
function releaseLock(metaPath) {
|
|
723
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
724
|
+
try {
|
|
725
|
+
unlinkSync(lockPath);
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
// Already removed or never existed
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Check if a .meta directory is currently locked (non-stale).
|
|
733
|
+
*
|
|
734
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
735
|
+
* @returns True if locked and not stale.
|
|
736
|
+
*/
|
|
737
|
+
function isLocked(metaPath) {
|
|
738
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
739
|
+
if (!existsSync(lockPath))
|
|
740
|
+
return false;
|
|
741
|
+
try {
|
|
742
|
+
const raw = readFileSync(lockPath, 'utf8');
|
|
743
|
+
const data = JSON.parse(raw);
|
|
744
|
+
const lockAge = Date.now() - new Date(data.startedAt).getTime();
|
|
745
|
+
return lockAge < STALE_TIMEOUT_MS;
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return false; // Corrupt lock = not locked
|
|
749
|
+
}
|
|
552
750
|
}
|
|
553
751
|
|
|
554
752
|
/**
|
|
@@ -840,120 +1038,6 @@ function mergeAndWrite(options) {
|
|
|
840
1038
|
return result.data;
|
|
841
1039
|
}
|
|
842
1040
|
|
|
843
|
-
/**
|
|
844
|
-
* Shared error utilities.
|
|
845
|
-
*
|
|
846
|
-
* @module errors
|
|
847
|
-
*/
|
|
848
|
-
/**
|
|
849
|
-
* Wrap an unknown caught value into a SynthError.
|
|
850
|
-
*
|
|
851
|
-
* @param step - Which synthesis step failed.
|
|
852
|
-
* @param err - The caught error value.
|
|
853
|
-
* @param code - Error classification code.
|
|
854
|
-
* @returns A structured SynthError.
|
|
855
|
-
*/
|
|
856
|
-
function toSynthError(step, err, code = 'FAILED') {
|
|
857
|
-
return {
|
|
858
|
-
step,
|
|
859
|
-
code,
|
|
860
|
-
message: err instanceof Error ? err.message : String(err),
|
|
861
|
-
};
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
/**
|
|
865
|
-
* File-system lock for preventing concurrent synthesis on the same meta.
|
|
866
|
-
*
|
|
867
|
-
* Lock file: .meta/.lock containing PID + timestamp.
|
|
868
|
-
* Stale timeout: 30 minutes.
|
|
869
|
-
*
|
|
870
|
-
* @module lock
|
|
871
|
-
*/
|
|
872
|
-
const LOCK_FILE = '.lock';
|
|
873
|
-
const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
874
|
-
/**
|
|
875
|
-
* Attempt to acquire a lock on a .meta directory.
|
|
876
|
-
*
|
|
877
|
-
* @param metaPath - Absolute path to the .meta directory.
|
|
878
|
-
* @returns True if lock was acquired, false if already locked (non-stale).
|
|
879
|
-
*/
|
|
880
|
-
function acquireLock(metaPath) {
|
|
881
|
-
const lockPath = join(metaPath, LOCK_FILE);
|
|
882
|
-
if (existsSync(lockPath)) {
|
|
883
|
-
try {
|
|
884
|
-
const raw = readFileSync(lockPath, 'utf8');
|
|
885
|
-
const data = JSON.parse(raw);
|
|
886
|
-
const lockAge = Date.now() - new Date(data.startedAt).getTime();
|
|
887
|
-
if (lockAge < STALE_TIMEOUT_MS) {
|
|
888
|
-
return false; // Lock is active
|
|
889
|
-
}
|
|
890
|
-
// Stale lock — fall through to overwrite
|
|
891
|
-
}
|
|
892
|
-
catch {
|
|
893
|
-
// Corrupt lock file — overwrite
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
const lock = {
|
|
897
|
-
pid: process.pid,
|
|
898
|
-
startedAt: new Date().toISOString(),
|
|
899
|
-
};
|
|
900
|
-
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
|
|
901
|
-
return true;
|
|
902
|
-
}
|
|
903
|
-
/**
|
|
904
|
-
* Release a lock on a .meta directory.
|
|
905
|
-
*
|
|
906
|
-
* @param metaPath - Absolute path to the .meta directory.
|
|
907
|
-
*/
|
|
908
|
-
function releaseLock(metaPath) {
|
|
909
|
-
const lockPath = join(metaPath, LOCK_FILE);
|
|
910
|
-
try {
|
|
911
|
-
unlinkSync(lockPath);
|
|
912
|
-
}
|
|
913
|
-
catch {
|
|
914
|
-
// Already removed or never existed
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
/**
|
|
918
|
-
* Check if a .meta directory is currently locked (non-stale).
|
|
919
|
-
*
|
|
920
|
-
* @param metaPath - Absolute path to the .meta directory.
|
|
921
|
-
* @returns True if locked and not stale.
|
|
922
|
-
*/
|
|
923
|
-
function isLocked(metaPath) {
|
|
924
|
-
const lockPath = join(metaPath, LOCK_FILE);
|
|
925
|
-
if (!existsSync(lockPath))
|
|
926
|
-
return false;
|
|
927
|
-
try {
|
|
928
|
-
const raw = readFileSync(lockPath, 'utf8');
|
|
929
|
-
const data = JSON.parse(raw);
|
|
930
|
-
const lockAge = Date.now() - new Date(data.startedAt).getTime();
|
|
931
|
-
return lockAge < STALE_TIMEOUT_MS;
|
|
932
|
-
}
|
|
933
|
-
catch {
|
|
934
|
-
return false; // Corrupt lock = not locked
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Normalize file paths to forward slashes for consistency with watcher-indexed paths.
|
|
940
|
-
*
|
|
941
|
-
* Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
|
|
942
|
-
* ensures all paths in the library use the same convention, regardless of
|
|
943
|
-
* the platform's native separator.
|
|
944
|
-
*
|
|
945
|
-
* @module normalizePath
|
|
946
|
-
*/
|
|
947
|
-
/**
|
|
948
|
-
* Normalize a file path to forward slashes.
|
|
949
|
-
*
|
|
950
|
-
* @param p - File path (may contain backslashes).
|
|
951
|
-
* @returns Path with all backslashes replaced by forward slashes.
|
|
952
|
-
*/
|
|
953
|
-
function normalizePath(p) {
|
|
954
|
-
return p.replaceAll('\\', '/');
|
|
955
|
-
}
|
|
956
|
-
|
|
957
1041
|
/**
|
|
958
1042
|
* Select the best synthesis candidate from stale metas.
|
|
959
1043
|
*
|
|
@@ -1210,17 +1294,32 @@ function finalizeCycle(metaPath, current, config, architect, builder, critic, bu
|
|
|
1210
1294
|
* @param watcher - Watcher HTTP client.
|
|
1211
1295
|
* @returns Result indicating whether synthesis occurred.
|
|
1212
1296
|
*/
|
|
1213
|
-
async function orchestrateOnce(config, executor, watcher) {
|
|
1214
|
-
// Step 1: Discover
|
|
1215
|
-
const metaPaths =
|
|
1297
|
+
async function orchestrateOnce(config, executor, watcher, targetPath) {
|
|
1298
|
+
// Step 1: Discover via watcher scan
|
|
1299
|
+
const metaPaths = await discoverMetas(config, watcher);
|
|
1216
1300
|
if (metaPaths.length === 0)
|
|
1217
1301
|
return { synthesized: false };
|
|
1218
|
-
//
|
|
1302
|
+
// Read meta.json for each discovered meta
|
|
1219
1303
|
const metas = new Map();
|
|
1220
1304
|
for (const mp of metaPaths) {
|
|
1221
|
-
|
|
1305
|
+
const metaFilePath = join(mp, 'meta.json');
|
|
1306
|
+
try {
|
|
1307
|
+
metas.set(normalizePath$1(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
// Skip metas with unreadable meta.json
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1222
1313
|
}
|
|
1223
1314
|
const tree = buildOwnershipTree(metaPaths);
|
|
1315
|
+
// If targetPath specified, skip candidate selection — go directly to that meta
|
|
1316
|
+
let targetNode;
|
|
1317
|
+
if (targetPath) {
|
|
1318
|
+
const normalized = normalizePath$1(targetPath);
|
|
1319
|
+
targetNode = findNode(tree, normalized) ?? undefined;
|
|
1320
|
+
if (!targetNode)
|
|
1321
|
+
return { synthesized: false };
|
|
1322
|
+
}
|
|
1224
1323
|
// Steps 3-4: Staleness check + candidate selection
|
|
1225
1324
|
const candidates = [];
|
|
1226
1325
|
for (const node of tree.nodes.values()) {
|
|
@@ -1255,9 +1354,13 @@ async function orchestrateOnce(config, executor, watcher) {
|
|
|
1255
1354
|
winner = candidate;
|
|
1256
1355
|
break;
|
|
1257
1356
|
}
|
|
1258
|
-
if (!winner)
|
|
1357
|
+
if (!winner && !targetNode)
|
|
1259
1358
|
return { synthesized: false };
|
|
1260
|
-
const
|
|
1359
|
+
const node = targetNode ?? winner.node;
|
|
1360
|
+
// For targeted path, acquire lock now (candidate selection already locked for stalest)
|
|
1361
|
+
if (targetNode && !acquireLock(node.metaPath)) {
|
|
1362
|
+
return { synthesized: false };
|
|
1363
|
+
}
|
|
1261
1364
|
try {
|
|
1262
1365
|
// Re-read meta after lock (may have changed)
|
|
1263
1366
|
const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
|
|
@@ -1367,12 +1470,13 @@ async function orchestrateOnce(config, executor, watcher) {
|
|
|
1367
1470
|
* @param config - Validated synthesis config.
|
|
1368
1471
|
* @param executor - Pluggable LLM executor.
|
|
1369
1472
|
* @param watcher - Watcher HTTP client.
|
|
1473
|
+
* @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
|
|
1370
1474
|
* @returns Array of results, one per cycle attempted.
|
|
1371
1475
|
*/
|
|
1372
|
-
async function orchestrate(config, executor, watcher) {
|
|
1476
|
+
async function orchestrate(config, executor, watcher, targetPath) {
|
|
1373
1477
|
const results = [];
|
|
1374
1478
|
for (let i = 0; i < config.batchSize; i++) {
|
|
1375
|
-
const result = await orchestrateOnce(config, executor, watcher);
|
|
1479
|
+
const result = await orchestrateOnce(config, executor, watcher, targetPath);
|
|
1376
1480
|
results.push(result);
|
|
1377
1481
|
if (!result.synthesized)
|
|
1378
1482
|
break; // No more candidates
|
|
@@ -1380,134 +1484,6 @@ async function orchestrate(config, executor, watcher) {
|
|
|
1380
1484
|
return results;
|
|
1381
1485
|
}
|
|
1382
1486
|
|
|
1383
|
-
/**
|
|
1384
|
-
* Factory for creating a bound synthesis engine.
|
|
1385
|
-
*
|
|
1386
|
-
* @module engine
|
|
1387
|
-
*/
|
|
1388
|
-
/**
|
|
1389
|
-
* Create a synthesis engine with bound config, executor, and watcher client.
|
|
1390
|
-
*
|
|
1391
|
-
* @param config - Validated synthesis config.
|
|
1392
|
-
* @param executor - Pluggable LLM executor.
|
|
1393
|
-
* @param watcher - Watcher HTTP client.
|
|
1394
|
-
* @returns A bound engine instance.
|
|
1395
|
-
*/
|
|
1396
|
-
function createSynthEngine(config, executor, watcher) {
|
|
1397
|
-
return {
|
|
1398
|
-
config,
|
|
1399
|
-
synthesize() {
|
|
1400
|
-
return orchestrate(config, executor, watcher);
|
|
1401
|
-
},
|
|
1402
|
-
synthesizePath(ownerPath) {
|
|
1403
|
-
const scopedConfig = { ...config, watchPaths: [ownerPath] };
|
|
1404
|
-
return orchestrate(scopedConfig, executor, watcher);
|
|
1405
|
-
},
|
|
1406
|
-
};
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
/**
|
|
1410
|
-
* SynthExecutor implementation using the OpenClaw gateway HTTP API.
|
|
1411
|
-
*
|
|
1412
|
-
* Lives in the library package so both plugin and runner can import it.
|
|
1413
|
-
* Spawns sub-agent sessions via the gateway, polls for completion,
|
|
1414
|
-
* and extracts output text.
|
|
1415
|
-
*
|
|
1416
|
-
* @module executor/GatewayExecutor
|
|
1417
|
-
*/
|
|
1418
|
-
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
1419
|
-
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
1420
|
-
/** Sleep helper. */
|
|
1421
|
-
function sleep$1(ms) {
|
|
1422
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1423
|
-
}
|
|
1424
|
-
/**
|
|
1425
|
-
* SynthExecutor that spawns OpenClaw sessions via the gateway HTTP API.
|
|
1426
|
-
*
|
|
1427
|
-
* Used by both the OpenClaw plugin (in-process tool calls) and the
|
|
1428
|
-
* runner/CLI (external invocation). Constructs from `gatewayUrl` and
|
|
1429
|
-
* optional `apiKey` — typically sourced from `SynthConfig`.
|
|
1430
|
-
*/
|
|
1431
|
-
class GatewayExecutor {
|
|
1432
|
-
gatewayUrl;
|
|
1433
|
-
apiKey;
|
|
1434
|
-
pollIntervalMs;
|
|
1435
|
-
constructor(options = {}) {
|
|
1436
|
-
this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
|
|
1437
|
-
this.apiKey = options.apiKey;
|
|
1438
|
-
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
1439
|
-
}
|
|
1440
|
-
async spawn(task, options) {
|
|
1441
|
-
const timeoutMs = (options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000;
|
|
1442
|
-
const deadline = Date.now() + timeoutMs;
|
|
1443
|
-
const headers = {
|
|
1444
|
-
'Content-Type': 'application/json',
|
|
1445
|
-
};
|
|
1446
|
-
if (this.apiKey) {
|
|
1447
|
-
headers['Authorization'] = 'Bearer ' + this.apiKey;
|
|
1448
|
-
}
|
|
1449
|
-
const spawnRes = await fetch(this.gatewayUrl + '/api/sessions/spawn', {
|
|
1450
|
-
method: 'POST',
|
|
1451
|
-
headers,
|
|
1452
|
-
body: JSON.stringify({
|
|
1453
|
-
task,
|
|
1454
|
-
mode: 'run',
|
|
1455
|
-
model: options?.model,
|
|
1456
|
-
runTimeoutSeconds: options?.timeout,
|
|
1457
|
-
}),
|
|
1458
|
-
});
|
|
1459
|
-
if (!spawnRes.ok) {
|
|
1460
|
-
const text = await spawnRes.text();
|
|
1461
|
-
throw new Error('Gateway spawn failed: HTTP ' +
|
|
1462
|
-
spawnRes.status.toString() +
|
|
1463
|
-
' - ' +
|
|
1464
|
-
text);
|
|
1465
|
-
}
|
|
1466
|
-
const spawnData = (await spawnRes.json());
|
|
1467
|
-
if (!spawnData.sessionKey) {
|
|
1468
|
-
throw new Error('Gateway spawn returned no sessionKey: ' + JSON.stringify(spawnData));
|
|
1469
|
-
}
|
|
1470
|
-
const { sessionKey } = spawnData;
|
|
1471
|
-
// Poll for completion
|
|
1472
|
-
while (Date.now() < deadline) {
|
|
1473
|
-
await sleep$1(this.pollIntervalMs);
|
|
1474
|
-
const historyRes = await fetch(this.gatewayUrl +
|
|
1475
|
-
'/api/sessions/' +
|
|
1476
|
-
encodeURIComponent(sessionKey) +
|
|
1477
|
-
'/history?limit=50', { headers });
|
|
1478
|
-
if (!historyRes.ok)
|
|
1479
|
-
continue;
|
|
1480
|
-
const history = (await historyRes.json());
|
|
1481
|
-
if (history.status === 'completed' || history.status === 'done') {
|
|
1482
|
-
// Extract token usage from session-level or message-level usage
|
|
1483
|
-
let tokens;
|
|
1484
|
-
if (history.usage?.totalTokens) {
|
|
1485
|
-
tokens = history.usage.totalTokens;
|
|
1486
|
-
}
|
|
1487
|
-
else {
|
|
1488
|
-
// Sum message-level usage as fallback
|
|
1489
|
-
let sum = 0;
|
|
1490
|
-
for (const msg of history.messages ?? []) {
|
|
1491
|
-
if (msg.usage?.totalTokens)
|
|
1492
|
-
sum += msg.usage.totalTokens;
|
|
1493
|
-
}
|
|
1494
|
-
if (sum > 0)
|
|
1495
|
-
tokens = sum;
|
|
1496
|
-
}
|
|
1497
|
-
// Extract the last assistant message as output
|
|
1498
|
-
const messages = history.messages ?? [];
|
|
1499
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1500
|
-
if (messages[i].role === 'assistant' && messages[i].content) {
|
|
1501
|
-
return { output: messages[i].content, tokens };
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
return { output: '', tokens };
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
1487
|
/**
|
|
1512
1488
|
* HTTP implementation of the WatcherClient interface.
|
|
1513
1489
|
*
|
|
@@ -1608,4 +1584,4 @@ class HttpWatcherClient {
|
|
|
1608
1584
|
}
|
|
1609
1585
|
}
|
|
1610
1586
|
|
|
1611
|
-
export { GatewayExecutor, HttpWatcherClient, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, computeEffectiveStaleness, computeEma, computeStructureHash, createSnapshot,
|
|
1587
|
+
export { GatewayExecutor, HttpWatcherClient, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildMetaFilter, buildOwnershipTree, computeEffectiveStaleness, computeEma, computeStructureHash, createSnapshot, discoverMetas, filterInScope, findNode, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, loadSynthConfig, mergeAndWrite, metaJsonSchema, normalizePath$1 as normalizePath, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, releaseLock, resolveConfigPath, selectCandidate, synthConfigSchema, synthErrorSchema, toSynthError };
|