@karmaniverous/jeeves-meta 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/jeeves-meta/index.js +570 -336
- package/dist/index.d.ts +109 -53
- package/dist/index.js +564 -326
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, copyFileSync, watchFile } from 'node:fs';
|
|
2
|
-
import { join, dirname, relative } from 'node:path';
|
|
1
|
+
import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
|
|
2
|
+
import { join, dirname, resolve, relative } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
5
7
|
import pino from 'pino';
|
|
6
8
|
import { Cron } from 'croner';
|
|
7
9
|
import Fastify from 'fastify';
|
|
@@ -102,6 +104,42 @@ function createSnapshot(metaPath, meta) {
|
|
|
102
104
|
return archiveFile;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Shared constants for the jeeves-meta service package.
|
|
109
|
+
*
|
|
110
|
+
* @module constants
|
|
111
|
+
*/
|
|
112
|
+
/** Default HTTP port for the jeeves-meta service. */
|
|
113
|
+
const DEFAULT_PORT = 1938;
|
|
114
|
+
/** Default port as a string (for Commander CLI defaults). */
|
|
115
|
+
const DEFAULT_PORT_STR = String(DEFAULT_PORT);
|
|
116
|
+
/** Service name identifier. */
|
|
117
|
+
const SERVICE_NAME = 'jeeves-meta';
|
|
118
|
+
/** Service version, read from package.json at startup. */
|
|
119
|
+
const SERVICE_VERSION = (() => {
|
|
120
|
+
try {
|
|
121
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
122
|
+
// Walk up to find package.json (works from src/ or dist/)
|
|
123
|
+
for (const candidate of [
|
|
124
|
+
resolve(dir, '..', 'package.json'),
|
|
125
|
+
resolve(dir, '..', '..', 'package.json'),
|
|
126
|
+
]) {
|
|
127
|
+
try {
|
|
128
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
129
|
+
if (pkg.version)
|
|
130
|
+
return pkg.version;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// try next candidate
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return 'unknown';
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return 'unknown';
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
|
|
105
143
|
/**
|
|
106
144
|
* Zod schema for jeeves-meta service configuration.
|
|
107
145
|
*
|
|
@@ -285,14 +323,29 @@ function normalizePath(p) {
|
|
|
285
323
|
* @param params - Base scan parameters (cursor is managed internally).
|
|
286
324
|
* @returns All matching files across all pages.
|
|
287
325
|
*/
|
|
288
|
-
async function paginatedScan(watcher, params) {
|
|
326
|
+
async function paginatedScan(watcher, params, logger) {
|
|
289
327
|
const allFiles = [];
|
|
290
328
|
let cursor;
|
|
329
|
+
let pageCount = 0;
|
|
330
|
+
const start = Date.now();
|
|
291
331
|
do {
|
|
332
|
+
const pageStart = Date.now();
|
|
292
333
|
const result = await watcher.scan({ ...params, cursor });
|
|
293
334
|
allFiles.push(...result.files);
|
|
335
|
+
pageCount++;
|
|
336
|
+
logger?.debug({
|
|
337
|
+
page: pageCount,
|
|
338
|
+
files: result.files.length,
|
|
339
|
+
pageMs: Date.now() - pageStart,
|
|
340
|
+
hasNext: Boolean(result.next),
|
|
341
|
+
}, 'paginatedScan page');
|
|
294
342
|
cursor = result.next;
|
|
295
343
|
} while (cursor);
|
|
344
|
+
logger?.debug({
|
|
345
|
+
pages: pageCount,
|
|
346
|
+
totalFiles: allFiles.length,
|
|
347
|
+
totalMs: Date.now() - start,
|
|
348
|
+
}, 'paginatedScan complete');
|
|
296
349
|
return allFiles;
|
|
297
350
|
}
|
|
298
351
|
|
|
@@ -359,12 +412,9 @@ function buildMetaFilter(config) {
|
|
|
359
412
|
* @param watcher - WatcherClient for scan queries.
|
|
360
413
|
* @returns Array of normalized .meta/ directory paths.
|
|
361
414
|
*/
|
|
362
|
-
async function discoverMetas(config, watcher) {
|
|
415
|
+
async function discoverMetas(config, watcher, logger) {
|
|
363
416
|
const filter = buildMetaFilter(config);
|
|
364
|
-
const scanFiles = await paginatedScan(watcher, {
|
|
365
|
-
filter,
|
|
366
|
-
fields: ['file_path'],
|
|
367
|
-
});
|
|
417
|
+
const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
|
|
368
418
|
// Deduplicate by .meta/ directory path (handles multi-chunk files)
|
|
369
419
|
const seen = new Set();
|
|
370
420
|
const metaPaths = [];
|
|
@@ -592,6 +642,8 @@ function findNode(tree, targetPath) {
|
|
|
592
642
|
*
|
|
593
643
|
* @module discovery/listMetas
|
|
594
644
|
*/
|
|
645
|
+
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
646
|
+
const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
|
|
595
647
|
/**
|
|
596
648
|
* Discover, deduplicate, and enrich all metas.
|
|
597
649
|
*
|
|
@@ -603,9 +655,9 @@ function findNode(tree, targetPath) {
|
|
|
603
655
|
* @param watcher - Watcher HTTP client for discovery.
|
|
604
656
|
* @returns Enriched meta list with summary statistics and ownership tree.
|
|
605
657
|
*/
|
|
606
|
-
async function listMetas(config, watcher) {
|
|
658
|
+
async function listMetas(config, watcher, logger) {
|
|
607
659
|
// Step 1: Discover deduplicated meta paths via watcher scan
|
|
608
|
-
const metaPaths = await discoverMetas(config, watcher);
|
|
660
|
+
const metaPaths = await discoverMetas(config, watcher, logger);
|
|
609
661
|
// Step 2: Build ownership tree
|
|
610
662
|
const tree = buildOwnershipTree(metaPaths);
|
|
611
663
|
// Step 3: Read and enrich each meta from disk
|
|
@@ -638,7 +690,7 @@ async function listMetas(config, watcher) {
|
|
|
638
690
|
// Compute staleness
|
|
639
691
|
let stalenessSeconds;
|
|
640
692
|
if (neverSynth) {
|
|
641
|
-
stalenessSeconds =
|
|
693
|
+
stalenessSeconds = MAX_STALENESS_SECONDS$1;
|
|
642
694
|
}
|
|
643
695
|
else {
|
|
644
696
|
const genAt = new Date(meta._generatedAt).getTime();
|
|
@@ -669,11 +721,7 @@ async function listMetas(config, watcher) {
|
|
|
669
721
|
}
|
|
670
722
|
// Track stalest (effective staleness for scheduling)
|
|
671
723
|
const depthFactor = Math.pow(1 + config.depthWeight, depth);
|
|
672
|
-
const effectiveStaleness =
|
|
673
|
-
? Number.MAX_SAFE_INTEGER
|
|
674
|
-
: stalenessSeconds) *
|
|
675
|
-
depthFactor *
|
|
676
|
-
emphasis;
|
|
724
|
+
const effectiveStaleness = stalenessSeconds * depthFactor * emphasis;
|
|
677
725
|
if (effectiveStaleness > stalestEffective) {
|
|
678
726
|
stalestEffective = effectiveStaleness;
|
|
679
727
|
stalestPath = node.metaPath;
|
|
@@ -715,22 +763,83 @@ async function listMetas(config, watcher) {
|
|
|
715
763
|
};
|
|
716
764
|
}
|
|
717
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Recursive filesystem walker for file enumeration.
|
|
768
|
+
*
|
|
769
|
+
* Replaces paginated watcher scans for scope/delta/staleness checks.
|
|
770
|
+
* Returns normalized forward-slash paths.
|
|
771
|
+
*
|
|
772
|
+
* @module walkFiles
|
|
773
|
+
*/
|
|
774
|
+
/** Default directory names to always skip. */
|
|
775
|
+
const DEFAULT_SKIP = new Set([
|
|
776
|
+
'node_modules',
|
|
777
|
+
'.git',
|
|
778
|
+
'.rollup.cache',
|
|
779
|
+
'dist',
|
|
780
|
+
'Thumbs.db',
|
|
781
|
+
]);
|
|
782
|
+
/**
|
|
783
|
+
* Recursively walk a directory and return all file paths.
|
|
784
|
+
*
|
|
785
|
+
* @param root - Root directory to walk.
|
|
786
|
+
* @param options - Walk options.
|
|
787
|
+
* @returns Array of normalized file paths.
|
|
788
|
+
*/
|
|
789
|
+
function walkFiles(root, options) {
|
|
790
|
+
const exclude = new Set([...DEFAULT_SKIP, ...(options?.exclude ?? [])]);
|
|
791
|
+
const modifiedAfter = options?.modifiedAfter;
|
|
792
|
+
const maxDepth = options?.maxDepth ?? 50;
|
|
793
|
+
const results = [];
|
|
794
|
+
function walk(dir, depth) {
|
|
795
|
+
if (depth > maxDepth)
|
|
796
|
+
return;
|
|
797
|
+
let entries;
|
|
798
|
+
try {
|
|
799
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return; // Permission errors, missing dirs — skip
|
|
803
|
+
}
|
|
804
|
+
for (const entry of entries) {
|
|
805
|
+
if (exclude.has(entry.name))
|
|
806
|
+
continue;
|
|
807
|
+
const fullPath = join(dir, entry.name);
|
|
808
|
+
if (entry.isDirectory()) {
|
|
809
|
+
walk(fullPath, depth + 1);
|
|
810
|
+
}
|
|
811
|
+
else if (entry.isFile()) {
|
|
812
|
+
if (modifiedAfter !== undefined) {
|
|
813
|
+
try {
|
|
814
|
+
const stat = statSync(fullPath);
|
|
815
|
+
if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
results.push(normalizePath(fullPath));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
walk(root, 0);
|
|
827
|
+
return results;
|
|
828
|
+
}
|
|
829
|
+
|
|
718
830
|
/**
|
|
719
831
|
* Compute the file scope owned by a meta node.
|
|
720
832
|
*
|
|
721
|
-
* A meta owns: parent dir + all descendants, minus
|
|
722
|
-
*
|
|
833
|
+
* A meta owns: parent dir + all descendants, minus:
|
|
834
|
+
* - Its own .meta/ subtree (outputs, not inputs)
|
|
835
|
+
* - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
|
|
836
|
+
*
|
|
837
|
+
* Uses filesystem walks instead of watcher scans for performance.
|
|
723
838
|
*
|
|
724
839
|
* @module discovery/scope
|
|
725
840
|
*/
|
|
726
841
|
/**
|
|
727
842
|
* Get the scope path prefix for a meta node.
|
|
728
|
-
*
|
|
729
|
-
* This is the ownerPath — all files under this path are in scope,
|
|
730
|
-
* except subtrees owned by child metas.
|
|
731
|
-
*
|
|
732
|
-
* @param node - The meta node to compute scope for.
|
|
733
|
-
* @returns The scope path prefix.
|
|
734
843
|
*/
|
|
735
844
|
function getScopePrefix(node) {
|
|
736
845
|
return node.ownerPath;
|
|
@@ -738,47 +847,39 @@ function getScopePrefix(node) {
|
|
|
738
847
|
/**
|
|
739
848
|
* Filter a list of file paths to only those in scope for a meta node.
|
|
740
849
|
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
850
|
+
* Excludes:
|
|
851
|
+
* - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
|
|
852
|
+
* - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
|
|
743
853
|
*
|
|
744
|
-
*
|
|
745
|
-
* @param files - Array of file paths to filter.
|
|
746
|
-
* @returns Filtered array of in-scope file paths.
|
|
854
|
+
* walkFiles already returns normalized forward-slash paths.
|
|
747
855
|
*/
|
|
748
856
|
function filterInScope(node, files) {
|
|
749
857
|
const prefix = node.ownerPath + '/';
|
|
858
|
+
const ownMetaPrefix = node.metaPath + '/';
|
|
750
859
|
const exclusions = node.children.map((c) => c.ownerPath + '/');
|
|
751
860
|
const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
|
|
752
861
|
return files.filter((f) => {
|
|
753
|
-
const normalized = f.split('\\').join('/');
|
|
754
862
|
// Must be under ownerPath
|
|
755
|
-
if (!
|
|
863
|
+
if (!f.startsWith(prefix) && f !== node.ownerPath)
|
|
864
|
+
return false;
|
|
865
|
+
// Exclude own .meta/ subtree (outputs are not inputs)
|
|
866
|
+
if (f.startsWith(ownMetaPrefix))
|
|
756
867
|
return false;
|
|
757
868
|
// Check if under a child meta's subtree
|
|
758
869
|
for (const excl of exclusions) {
|
|
759
|
-
if (
|
|
870
|
+
if (f.startsWith(excl)) {
|
|
760
871
|
// Exception: child meta.json files are included as rollup inputs
|
|
761
|
-
return childMetaJsons.has(
|
|
872
|
+
return childMetaJsons.has(f);
|
|
762
873
|
}
|
|
763
874
|
}
|
|
764
875
|
return true;
|
|
765
876
|
});
|
|
766
877
|
}
|
|
767
878
|
/**
|
|
768
|
-
* Get all files in scope for a meta node via
|
|
769
|
-
*
|
|
770
|
-
* Scans the owner path prefix and filters out child meta subtrees,
|
|
771
|
-
* keeping only files directly owned by this meta.
|
|
772
|
-
*
|
|
773
|
-
* @param node - The meta node.
|
|
774
|
-
* @param watcher - WatcherClient for scan queries.
|
|
775
|
-
* @returns Array of in-scope file paths.
|
|
879
|
+
* Get all files in scope for a meta node via filesystem walk.
|
|
776
880
|
*/
|
|
777
|
-
|
|
778
|
-
const
|
|
779
|
-
pathPrefix: node.ownerPath,
|
|
780
|
-
});
|
|
781
|
-
const allFiles = allScanFiles.map((f) => f.file_path);
|
|
881
|
+
function getScopeFiles(node) {
|
|
882
|
+
const allFiles = walkFiles(node.ownerPath);
|
|
782
883
|
return {
|
|
783
884
|
scopeFiles: filterInScope(node, allFiles),
|
|
784
885
|
allFiles,
|
|
@@ -788,22 +889,13 @@ async function getScopeFiles(node, watcher) {
|
|
|
788
889
|
* Get files modified since a given timestamp within a meta node's scope.
|
|
789
890
|
*
|
|
790
891
|
* If no generatedAt is provided (first run), returns all scope files.
|
|
791
|
-
*
|
|
792
|
-
* @param node - The meta node.
|
|
793
|
-
* @param watcher - WatcherClient for scan queries.
|
|
794
|
-
* @param generatedAt - ISO timestamp of last synthesis, or null/undefined for first run.
|
|
795
|
-
* @param scopeFiles - Pre-computed scope files (used as fallback for first run).
|
|
796
|
-
* @returns Array of modified in-scope file paths.
|
|
797
892
|
*/
|
|
798
|
-
|
|
893
|
+
function getDeltaFiles(node, generatedAt, scopeFiles) {
|
|
799
894
|
if (!generatedAt)
|
|
800
895
|
return scopeFiles;
|
|
801
896
|
const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
modifiedAfter,
|
|
805
|
-
});
|
|
806
|
-
return filterInScope(node, deltaScanFiles.map((f) => f.file_path));
|
|
897
|
+
const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
|
|
898
|
+
return filterInScope(node, deltaFiles);
|
|
807
899
|
}
|
|
808
900
|
|
|
809
901
|
/**
|
|
@@ -882,7 +974,7 @@ function sleep(ms) {
|
|
|
882
974
|
* @module executor/GatewayExecutor
|
|
883
975
|
*/
|
|
884
976
|
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
885
|
-
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
977
|
+
const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
|
|
886
978
|
/**
|
|
887
979
|
* MetaExecutor that spawns OpenClaw sessions via the gateway's
|
|
888
980
|
* `/tools/invoke` endpoint.
|
|
@@ -895,10 +987,12 @@ class GatewayExecutor {
|
|
|
895
987
|
gatewayUrl;
|
|
896
988
|
apiKey;
|
|
897
989
|
pollIntervalMs;
|
|
990
|
+
workspaceDir;
|
|
898
991
|
constructor(options = {}) {
|
|
899
992
|
this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
|
|
900
993
|
this.apiKey = options.apiKey;
|
|
901
994
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
995
|
+
this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
|
|
902
996
|
}
|
|
903
997
|
/** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
|
|
904
998
|
async invoke(tool, args) {
|
|
@@ -923,13 +1017,44 @@ class GatewayExecutor {
|
|
|
923
1017
|
}
|
|
924
1018
|
return data;
|
|
925
1019
|
}
|
|
1020
|
+
/** Look up totalTokens for a session via sessions_list. */
|
|
1021
|
+
async getSessionTokens(sessionKey) {
|
|
1022
|
+
try {
|
|
1023
|
+
const result = await this.invoke('sessions_list', {
|
|
1024
|
+
limit: 20,
|
|
1025
|
+
messageLimit: 0,
|
|
1026
|
+
});
|
|
1027
|
+
const sessions = (result.result?.details?.sessions ??
|
|
1028
|
+
result.result?.sessions ??
|
|
1029
|
+
[]);
|
|
1030
|
+
const match = sessions.find((s) => s.key === sessionKey);
|
|
1031
|
+
return match?.totalTokens ?? undefined;
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
926
1037
|
async spawn(task, options) {
|
|
927
|
-
const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000;
|
|
1038
|
+
const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
|
|
928
1039
|
const timeoutMs = timeoutSeconds * 1000;
|
|
929
1040
|
const deadline = Date.now() + timeoutMs;
|
|
1041
|
+
// Ensure workspace dir exists
|
|
1042
|
+
if (!existsSync(this.workspaceDir)) {
|
|
1043
|
+
mkdirSync(this.workspaceDir, { recursive: true });
|
|
1044
|
+
}
|
|
1045
|
+
// Generate unique output path for file-based output
|
|
1046
|
+
const outputId = randomUUID();
|
|
1047
|
+
const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
|
|
1048
|
+
// Append file output instruction to the task
|
|
1049
|
+
const taskWithOutput = task +
|
|
1050
|
+
'\n\n## OUTPUT DELIVERY\n\n' +
|
|
1051
|
+
'Write your complete output to a file using the Write tool at:\n' +
|
|
1052
|
+
outputPath +
|
|
1053
|
+
'\n\n' +
|
|
1054
|
+
'Reply with ONLY the file path you wrote to. No other text.';
|
|
930
1055
|
// Step 1: Spawn the sub-agent session
|
|
931
1056
|
const spawnResult = await this.invoke('sessions_spawn', {
|
|
932
|
-
task,
|
|
1057
|
+
task: taskWithOutput,
|
|
933
1058
|
label: options?.label ?? 'jeeves-meta-synthesis',
|
|
934
1059
|
runTimeoutSeconds: timeoutSeconds,
|
|
935
1060
|
...(options?.thinking ? { thinking: options.thinking } : {}),
|
|
@@ -961,19 +1086,37 @@ class GatewayExecutor {
|
|
|
961
1086
|
lastMsg.stopReason &&
|
|
962
1087
|
lastMsg.stopReason !== 'toolUse' &&
|
|
963
1088
|
lastMsg.stopReason !== 'error') {
|
|
964
|
-
//
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1089
|
+
// Fetch token usage from session metadata
|
|
1090
|
+
const tokens = await this.getSessionTokens(sessionKey);
|
|
1091
|
+
// Read output from file (sub-agent wrote it via Write tool)
|
|
1092
|
+
if (existsSync(outputPath)) {
|
|
1093
|
+
try {
|
|
1094
|
+
const output = readFileSync(outputPath, 'utf8');
|
|
1095
|
+
return { output, tokens };
|
|
1096
|
+
}
|
|
1097
|
+
finally {
|
|
1098
|
+
try {
|
|
1099
|
+
unlinkSync(outputPath);
|
|
1100
|
+
}
|
|
1101
|
+
catch {
|
|
1102
|
+
/* cleanup best-effort */
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
970
1105
|
}
|
|
971
|
-
if
|
|
972
|
-
tokens = sum;
|
|
973
|
-
// Find the last assistant message with content
|
|
1106
|
+
// Fallback: extract from message content if file wasn't written
|
|
974
1107
|
for (let i = msgArray.length - 1; i >= 0; i--) {
|
|
975
|
-
|
|
976
|
-
|
|
1108
|
+
const msg = msgArray[i];
|
|
1109
|
+
if (msg.role === 'assistant' && msg.content) {
|
|
1110
|
+
const text = typeof msg.content === 'string'
|
|
1111
|
+
? msg.content
|
|
1112
|
+
: Array.isArray(msg.content)
|
|
1113
|
+
? msg.content
|
|
1114
|
+
.filter((b) => b.type === 'text' && b.text)
|
|
1115
|
+
.map((b) => b.text)
|
|
1116
|
+
.join('\n')
|
|
1117
|
+
: '';
|
|
1118
|
+
if (text)
|
|
1119
|
+
return { output: text, tokens };
|
|
977
1120
|
}
|
|
978
1121
|
}
|
|
979
1122
|
return { output: '', tokens };
|
|
@@ -1053,10 +1196,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
|
|
|
1053
1196
|
* @param watcher - WatcherClient for scope enumeration.
|
|
1054
1197
|
* @returns The computed context package.
|
|
1055
1198
|
*/
|
|
1056
|
-
|
|
1199
|
+
function buildContextPackage(node, meta) {
|
|
1057
1200
|
// Scope and delta files via watcher scan
|
|
1058
|
-
const { scopeFiles } =
|
|
1059
|
-
const deltaFiles =
|
|
1201
|
+
const { scopeFiles } = getScopeFiles(node);
|
|
1202
|
+
const deltaFiles = getDeltaFiles(node, meta._generatedAt, scopeFiles);
|
|
1060
1203
|
// Child meta outputs
|
|
1061
1204
|
const childMetas = {};
|
|
1062
1205
|
for (const child of node.children) {
|
|
@@ -1167,7 +1310,7 @@ function buildBuilderTask(ctx, meta, config) {
|
|
|
1167
1310
|
includeSteer: false,
|
|
1168
1311
|
feedbackHeading: '## FEEDBACK FROM CRITIC',
|
|
1169
1312
|
});
|
|
1170
|
-
sections.push('', '## OUTPUT FORMAT', '
|
|
1313
|
+
sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.');
|
|
1171
1314
|
return sections.join('\n');
|
|
1172
1315
|
}
|
|
1173
1316
|
/**
|
|
@@ -1473,28 +1616,33 @@ function discoverStalestPath(candidates, depthWeight) {
|
|
|
1473
1616
|
* @param watcher - WatcherClient instance.
|
|
1474
1617
|
* @returns True if any file in scope was modified after _generatedAt.
|
|
1475
1618
|
*/
|
|
1476
|
-
|
|
1619
|
+
function isStale(scopePrefix, meta) {
|
|
1477
1620
|
if (!meta._generatedAt)
|
|
1478
1621
|
return true; // Never synthesized = stale
|
|
1479
1622
|
const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
|
|
1480
|
-
const
|
|
1481
|
-
pathPrefix: scopePrefix,
|
|
1623
|
+
const modified = walkFiles(scopePrefix, {
|
|
1482
1624
|
modifiedAfter: generatedAtUnix,
|
|
1483
|
-
|
|
1625
|
+
maxDepth: 1,
|
|
1484
1626
|
});
|
|
1485
|
-
return
|
|
1627
|
+
return modified.length > 0;
|
|
1486
1628
|
}
|
|
1629
|
+
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
1630
|
+
const MAX_STALENESS_SECONDS = 365 * 86_400;
|
|
1487
1631
|
/**
|
|
1488
1632
|
* Compute actual staleness in seconds (now minus _generatedAt).
|
|
1489
1633
|
*
|
|
1634
|
+
* Never-synthesized metas are capped at {@link MAX_STALENESS_SECONDS}
|
|
1635
|
+
* (1 year) so that depth weighting can differentiate them. Without
|
|
1636
|
+
* bounding, `Infinity * depthFactor` = `Infinity` for all depths.
|
|
1637
|
+
*
|
|
1490
1638
|
* @param meta - Current meta.json content.
|
|
1491
|
-
* @returns Staleness in seconds,
|
|
1639
|
+
* @returns Staleness in seconds, capped at 1 year for never-synthesized metas.
|
|
1492
1640
|
*/
|
|
1493
1641
|
function actualStaleness(meta) {
|
|
1494
1642
|
if (!meta._generatedAt)
|
|
1495
|
-
return
|
|
1643
|
+
return MAX_STALENESS_SECONDS;
|
|
1496
1644
|
const generatedMs = new Date(meta._generatedAt).getTime();
|
|
1497
|
-
return (Date.now() - generatedMs) / 1000;
|
|
1645
|
+
return Math.min((Date.now() - generatedMs) / 1000, MAX_STALENESS_SECONDS);
|
|
1498
1646
|
}
|
|
1499
1647
|
/**
|
|
1500
1648
|
* Check whether the architect step should be triggered.
|
|
@@ -1571,20 +1719,50 @@ function parseArchitectOutput(output) {
|
|
|
1571
1719
|
*/
|
|
1572
1720
|
function parseBuilderOutput(output) {
|
|
1573
1721
|
const trimmed = output.trim();
|
|
1574
|
-
// Try to
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1722
|
+
// Strategy 1: Try to parse the entire output as JSON directly
|
|
1723
|
+
const direct = tryParseJson(trimmed);
|
|
1724
|
+
if (direct)
|
|
1725
|
+
return direct;
|
|
1726
|
+
// Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
|
|
1727
|
+
const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
|
|
1728
|
+
const fenceMatches = [];
|
|
1729
|
+
let match;
|
|
1730
|
+
while ((match = fencePattern.exec(trimmed)) !== null) {
|
|
1731
|
+
fenceMatches.push(match[1].trim());
|
|
1732
|
+
}
|
|
1733
|
+
// Try last fence first (most likely to be the actual output)
|
|
1734
|
+
for (let i = fenceMatches.length - 1; i >= 0; i--) {
|
|
1735
|
+
const result = tryParseJson(fenceMatches[i]);
|
|
1736
|
+
if (result)
|
|
1737
|
+
return result;
|
|
1738
|
+
}
|
|
1739
|
+
// Strategy 3: Find outermost { ... } braces
|
|
1740
|
+
const firstBrace = trimmed.indexOf('{');
|
|
1741
|
+
const lastBrace = trimmed.lastIndexOf('}');
|
|
1742
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
1743
|
+
const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
|
|
1744
|
+
if (result)
|
|
1745
|
+
return result;
|
|
1746
|
+
}
|
|
1747
|
+
// Fallback: treat entire output as content
|
|
1748
|
+
return { content: trimmed, fields: {} };
|
|
1749
|
+
}
|
|
1750
|
+
/** Try to parse a string as JSON and extract builder output fields. */
|
|
1751
|
+
function tryParseJson(str) {
|
|
1580
1752
|
try {
|
|
1581
|
-
const
|
|
1753
|
+
const raw = JSON.parse(str);
|
|
1754
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
const parsed = raw;
|
|
1582
1758
|
// Extract _content
|
|
1583
|
-
const content = typeof parsed
|
|
1584
|
-
? parsed
|
|
1585
|
-
: typeof parsed
|
|
1586
|
-
? parsed
|
|
1587
|
-
:
|
|
1759
|
+
const content = typeof parsed['_content'] === 'string'
|
|
1760
|
+
? parsed['_content']
|
|
1761
|
+
: typeof parsed['content'] === 'string'
|
|
1762
|
+
? parsed['content']
|
|
1763
|
+
: null;
|
|
1764
|
+
if (content === null)
|
|
1765
|
+
return null;
|
|
1588
1766
|
// Extract non-underscore fields
|
|
1589
1767
|
const fields = {};
|
|
1590
1768
|
for (const [key, value] of Object.entries(parsed)) {
|
|
@@ -1595,8 +1773,7 @@ function parseBuilderOutput(output) {
|
|
|
1595
1773
|
return { content, fields };
|
|
1596
1774
|
}
|
|
1597
1775
|
catch {
|
|
1598
|
-
|
|
1599
|
-
return { content: trimmed, fields: {} };
|
|
1776
|
+
return null;
|
|
1600
1777
|
}
|
|
1601
1778
|
}
|
|
1602
1779
|
/**
|
|
@@ -1657,9 +1834,243 @@ function finalizeCycle(opts) {
|
|
|
1657
1834
|
* @param watcher - Watcher HTTP client.
|
|
1658
1835
|
* @returns Result indicating whether synthesis occurred.
|
|
1659
1836
|
*/
|
|
1660
|
-
|
|
1837
|
+
/**
|
|
1838
|
+
* Build a minimal MetaNode from the filesystem for a known meta path.
|
|
1839
|
+
* Discovers immediate child .meta/ dirs without a full watcher scan.
|
|
1840
|
+
*/
|
|
1841
|
+
function buildMinimalNode(metaPath) {
|
|
1842
|
+
const normalized = normalizePath(metaPath);
|
|
1843
|
+
const ownerPath = normalizePath(dirname(metaPath));
|
|
1844
|
+
// Find child .meta/ directories by scanning the owner directory
|
|
1845
|
+
const children = [];
|
|
1846
|
+
function findChildMetas(dir, depth) {
|
|
1847
|
+
if (depth > 10)
|
|
1848
|
+
return; // Safety limit
|
|
1849
|
+
try {
|
|
1850
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1851
|
+
for (const entry of entries) {
|
|
1852
|
+
if (!entry.isDirectory())
|
|
1853
|
+
continue;
|
|
1854
|
+
const fullPath = normalizePath(join(dir, entry.name));
|
|
1855
|
+
if (entry.name === '.meta' && fullPath !== normalized) {
|
|
1856
|
+
// Found a child .meta — check it has meta.json
|
|
1857
|
+
if (existsSync(join(fullPath, 'meta.json'))) {
|
|
1858
|
+
children.push({
|
|
1859
|
+
metaPath: fullPath,
|
|
1860
|
+
ownerPath: normalizePath(dirname(fullPath)),
|
|
1861
|
+
treeDepth: 1, // Relative to target
|
|
1862
|
+
children: [],
|
|
1863
|
+
parent: null, // Set below
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
// Don't recurse into .meta dirs
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
if (entry.name === 'node_modules' ||
|
|
1870
|
+
entry.name === '.git' ||
|
|
1871
|
+
entry.name === 'archive')
|
|
1872
|
+
continue;
|
|
1873
|
+
findChildMetas(fullPath, depth + 1);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
catch {
|
|
1877
|
+
// Permission errors, etc — skip
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
findChildMetas(ownerPath, 0);
|
|
1881
|
+
const node = {
|
|
1882
|
+
metaPath: normalized,
|
|
1883
|
+
ownerPath,
|
|
1884
|
+
treeDepth: 0,
|
|
1885
|
+
children,
|
|
1886
|
+
parent: null,
|
|
1887
|
+
};
|
|
1888
|
+
// Wire parent references
|
|
1889
|
+
for (const child of children) {
|
|
1890
|
+
child.parent = node;
|
|
1891
|
+
}
|
|
1892
|
+
return node;
|
|
1893
|
+
}
|
|
1894
|
+
/** Run the architect/builder/critic pipeline on a single node. */
|
|
1895
|
+
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
|
|
1896
|
+
const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
|
|
1897
|
+
const criticPrompt = currentMeta._critic ?? config.defaultCritic;
|
|
1898
|
+
// Step 5-6: Steer change detection
|
|
1899
|
+
const latestArchive = readLatestArchive(node.metaPath);
|
|
1900
|
+
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
1901
|
+
// Step 7: Compute context (includes scope files and delta files)
|
|
1902
|
+
const ctx = buildContextPackage(node, currentMeta);
|
|
1903
|
+
// Step 5 (deferred): Structure hash from context scope files
|
|
1904
|
+
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
1905
|
+
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
1906
|
+
// Step 8: Architect (conditional)
|
|
1907
|
+
const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
|
|
1908
|
+
let builderBrief = currentMeta._builder ?? '';
|
|
1909
|
+
let synthesisCount = currentMeta._synthesisCount ?? 0;
|
|
1910
|
+
let stepError = null;
|
|
1911
|
+
let architectTokens;
|
|
1912
|
+
let builderTokens;
|
|
1913
|
+
let criticTokens;
|
|
1914
|
+
if (architectTriggered) {
|
|
1915
|
+
try {
|
|
1916
|
+
await onProgress?.({
|
|
1917
|
+
type: 'phase_start',
|
|
1918
|
+
path: node.ownerPath,
|
|
1919
|
+
phase: 'architect',
|
|
1920
|
+
});
|
|
1921
|
+
const phaseStart = Date.now();
|
|
1922
|
+
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
1923
|
+
const architectResult = await executor.spawn(architectTask, {
|
|
1924
|
+
thinking: config.thinking,
|
|
1925
|
+
timeout: config.architectTimeout,
|
|
1926
|
+
});
|
|
1927
|
+
builderBrief = parseArchitectOutput(architectResult.output);
|
|
1928
|
+
architectTokens = architectResult.tokens;
|
|
1929
|
+
synthesisCount = 0;
|
|
1930
|
+
await onProgress?.({
|
|
1931
|
+
type: 'phase_complete',
|
|
1932
|
+
path: node.ownerPath,
|
|
1933
|
+
phase: 'architect',
|
|
1934
|
+
tokens: architectTokens,
|
|
1935
|
+
durationMs: Date.now() - phaseStart,
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
catch (err) {
|
|
1939
|
+
stepError = toMetaError('architect', err);
|
|
1940
|
+
if (!currentMeta._builder) {
|
|
1941
|
+
// No cached builder — cycle fails
|
|
1942
|
+
finalizeCycle({
|
|
1943
|
+
metaPath: node.metaPath,
|
|
1944
|
+
current: currentMeta,
|
|
1945
|
+
config,
|
|
1946
|
+
architect: architectPrompt,
|
|
1947
|
+
builder: '',
|
|
1948
|
+
critic: criticPrompt,
|
|
1949
|
+
builderOutput: null,
|
|
1950
|
+
feedback: null,
|
|
1951
|
+
structureHash: newStructureHash,
|
|
1952
|
+
synthesisCount,
|
|
1953
|
+
error: stepError,
|
|
1954
|
+
architectTokens,
|
|
1955
|
+
});
|
|
1956
|
+
return {
|
|
1957
|
+
synthesized: true,
|
|
1958
|
+
metaPath: node.metaPath,
|
|
1959
|
+
error: stepError,
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
// Has cached builder — continue with existing
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
// Step 9: Builder
|
|
1966
|
+
const metaForBuilder = { ...currentMeta, _builder: builderBrief };
|
|
1967
|
+
let builderOutput = null;
|
|
1968
|
+
try {
|
|
1969
|
+
await onProgress?.({
|
|
1970
|
+
type: 'phase_start',
|
|
1971
|
+
path: node.ownerPath,
|
|
1972
|
+
phase: 'builder',
|
|
1973
|
+
});
|
|
1974
|
+
const builderStart = Date.now();
|
|
1975
|
+
const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
|
|
1976
|
+
const builderResult = await executor.spawn(builderTask, {
|
|
1977
|
+
thinking: config.thinking,
|
|
1978
|
+
timeout: config.builderTimeout,
|
|
1979
|
+
});
|
|
1980
|
+
builderOutput = parseBuilderOutput(builderResult.output);
|
|
1981
|
+
builderTokens = builderResult.tokens;
|
|
1982
|
+
synthesisCount++;
|
|
1983
|
+
await onProgress?.({
|
|
1984
|
+
type: 'phase_complete',
|
|
1985
|
+
path: node.ownerPath,
|
|
1986
|
+
phase: 'builder',
|
|
1987
|
+
tokens: builderTokens,
|
|
1988
|
+
durationMs: Date.now() - builderStart,
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
catch (err) {
|
|
1992
|
+
stepError = toMetaError('builder', err);
|
|
1993
|
+
return { synthesized: true, metaPath: node.metaPath, error: stepError };
|
|
1994
|
+
}
|
|
1995
|
+
// Step 10: Critic
|
|
1996
|
+
const metaForCritic = {
|
|
1997
|
+
...currentMeta,
|
|
1998
|
+
_content: builderOutput.content,
|
|
1999
|
+
};
|
|
2000
|
+
let feedback = null;
|
|
2001
|
+
try {
|
|
2002
|
+
await onProgress?.({
|
|
2003
|
+
type: 'phase_start',
|
|
2004
|
+
path: node.ownerPath,
|
|
2005
|
+
phase: 'critic',
|
|
2006
|
+
});
|
|
2007
|
+
const criticStart = Date.now();
|
|
2008
|
+
const criticTask = buildCriticTask(ctx, metaForCritic, config);
|
|
2009
|
+
const criticResult = await executor.spawn(criticTask, {
|
|
2010
|
+
thinking: config.thinking,
|
|
2011
|
+
timeout: config.criticTimeout,
|
|
2012
|
+
});
|
|
2013
|
+
feedback = parseCriticOutput(criticResult.output);
|
|
2014
|
+
criticTokens = criticResult.tokens;
|
|
2015
|
+
stepError = null; // Clear any architect error on full success
|
|
2016
|
+
await onProgress?.({
|
|
2017
|
+
type: 'phase_complete',
|
|
2018
|
+
path: node.ownerPath,
|
|
2019
|
+
phase: 'critic',
|
|
2020
|
+
tokens: criticTokens,
|
|
2021
|
+
durationMs: Date.now() - criticStart,
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
catch (err) {
|
|
2025
|
+
stepError = stepError ?? toMetaError('critic', err);
|
|
2026
|
+
}
|
|
2027
|
+
// Steps 11-12: Merge, archive, prune
|
|
2028
|
+
finalizeCycle({
|
|
2029
|
+
metaPath: node.metaPath,
|
|
2030
|
+
current: currentMeta,
|
|
2031
|
+
config,
|
|
2032
|
+
architect: architectPrompt,
|
|
2033
|
+
builder: builderBrief,
|
|
2034
|
+
critic: criticPrompt,
|
|
2035
|
+
builderOutput,
|
|
2036
|
+
feedback,
|
|
2037
|
+
structureHash: newStructureHash,
|
|
2038
|
+
synthesisCount,
|
|
2039
|
+
error: stepError,
|
|
2040
|
+
architectTokens,
|
|
2041
|
+
builderTokens,
|
|
2042
|
+
criticTokens,
|
|
2043
|
+
});
|
|
2044
|
+
return {
|
|
2045
|
+
synthesized: true,
|
|
2046
|
+
metaPath: node.metaPath,
|
|
2047
|
+
error: stepError ?? undefined,
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
|
|
2051
|
+
// When targetPath is provided, skip the expensive full discovery scan.
|
|
2052
|
+
// Build a minimal node from the filesystem instead.
|
|
2053
|
+
if (targetPath) {
|
|
2054
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
2055
|
+
const targetMetaJson = join(normalizedTarget, 'meta.json');
|
|
2056
|
+
if (!existsSync(targetMetaJson))
|
|
2057
|
+
return { synthesized: false };
|
|
2058
|
+
const node = buildMinimalNode(normalizedTarget);
|
|
2059
|
+
if (!acquireLock(node.metaPath))
|
|
2060
|
+
return { synthesized: false };
|
|
2061
|
+
try {
|
|
2062
|
+
const currentMeta = JSON.parse(readFileSync(targetMetaJson, 'utf8'));
|
|
2063
|
+
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2064
|
+
}
|
|
2065
|
+
finally {
|
|
2066
|
+
releaseLock(node.metaPath);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
// Full discovery path (scheduler-driven, no specific target)
|
|
1661
2070
|
// Step 1: Discover via watcher scan
|
|
1662
|
-
const
|
|
2071
|
+
const discoveryStart = Date.now();
|
|
2072
|
+
const metaPaths = await discoverMetas(config, watcher, logger);
|
|
2073
|
+
logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
|
|
1663
2074
|
if (metaPaths.length === 0)
|
|
1664
2075
|
return { synthesized: false };
|
|
1665
2076
|
// Read meta.json for each discovered meta
|
|
@@ -1679,23 +2090,15 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
1679
2090
|
if (validPaths.length === 0)
|
|
1680
2091
|
return { synthesized: false };
|
|
1681
2092
|
const tree = buildOwnershipTree(validPaths);
|
|
1682
|
-
// If targetPath specified, skip candidate selection — go directly to that meta
|
|
1683
|
-
let targetNode;
|
|
1684
|
-
if (targetPath) {
|
|
1685
|
-
const normalized = normalizePath(targetPath);
|
|
1686
|
-
targetNode = findNode(tree, normalized) ?? undefined;
|
|
1687
|
-
if (!targetNode)
|
|
1688
|
-
return { synthesized: false };
|
|
1689
|
-
}
|
|
1690
2093
|
// Steps 3-4: Staleness check + candidate selection
|
|
1691
2094
|
const candidates = [];
|
|
1692
|
-
for (const
|
|
1693
|
-
const meta = metas.get(
|
|
2095
|
+
for (const treeNode of tree.nodes.values()) {
|
|
2096
|
+
const meta = metas.get(treeNode.metaPath);
|
|
1694
2097
|
if (!meta)
|
|
1695
|
-
continue;
|
|
2098
|
+
continue;
|
|
1696
2099
|
const staleness = actualStaleness(meta);
|
|
1697
2100
|
if (staleness > 0) {
|
|
1698
|
-
candidates.push({ node, meta, actualStaleness: staleness });
|
|
2101
|
+
candidates.push({ node: treeNode, meta, actualStaleness: staleness });
|
|
1699
2102
|
}
|
|
1700
2103
|
}
|
|
1701
2104
|
const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
|
|
@@ -1708,7 +2111,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
1708
2111
|
for (const candidate of ranked) {
|
|
1709
2112
|
if (!acquireLock(candidate.node.metaPath))
|
|
1710
2113
|
continue;
|
|
1711
|
-
const verifiedStale =
|
|
2114
|
+
const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
|
|
1712
2115
|
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
1713
2116
|
// Bump _generatedAt so it doesn't win next cycle
|
|
1714
2117
|
const metaFilePath = join(candidate.node.metaPath, 'meta.json');
|
|
@@ -1723,169 +2126,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
1723
2126
|
winner = candidate;
|
|
1724
2127
|
break;
|
|
1725
2128
|
}
|
|
1726
|
-
if (!winner
|
|
1727
|
-
return { synthesized: false };
|
|
1728
|
-
const node = targetNode ?? winner.node;
|
|
1729
|
-
// For targeted path, acquire lock now (candidate selection already locked for stalest)
|
|
1730
|
-
if (targetNode && !acquireLock(node.metaPath)) {
|
|
2129
|
+
if (!winner)
|
|
1731
2130
|
return { synthesized: false };
|
|
1732
|
-
|
|
2131
|
+
const node = winner.node;
|
|
1733
2132
|
try {
|
|
1734
|
-
// Re-read meta after lock (may have changed)
|
|
1735
2133
|
const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
|
|
1736
|
-
|
|
1737
|
-
const criticPrompt = currentMeta._critic ?? config.defaultCritic;
|
|
1738
|
-
// Step 5-6: Steer change detection
|
|
1739
|
-
const latestArchive = readLatestArchive(node.metaPath);
|
|
1740
|
-
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
1741
|
-
// Step 7: Compute context (includes scope files and delta files)
|
|
1742
|
-
const ctx = await buildContextPackage(node, currentMeta, watcher);
|
|
1743
|
-
// Step 5 (deferred): Structure hash from context scope files
|
|
1744
|
-
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
1745
|
-
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
1746
|
-
// Step 8: Architect (conditional)
|
|
1747
|
-
const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
|
|
1748
|
-
let builderBrief = currentMeta._builder ?? '';
|
|
1749
|
-
let synthesisCount = currentMeta._synthesisCount ?? 0;
|
|
1750
|
-
let stepError = null;
|
|
1751
|
-
let architectTokens;
|
|
1752
|
-
let builderTokens;
|
|
1753
|
-
let criticTokens;
|
|
1754
|
-
if (architectTriggered) {
|
|
1755
|
-
try {
|
|
1756
|
-
await onProgress?.({
|
|
1757
|
-
type: 'phase_start',
|
|
1758
|
-
metaPath: node.metaPath,
|
|
1759
|
-
phase: 'architect',
|
|
1760
|
-
});
|
|
1761
|
-
const phaseStart = Date.now();
|
|
1762
|
-
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
1763
|
-
const architectResult = await executor.spawn(architectTask, {
|
|
1764
|
-
thinking: config.thinking,
|
|
1765
|
-
timeout: config.architectTimeout,
|
|
1766
|
-
});
|
|
1767
|
-
builderBrief = parseArchitectOutput(architectResult.output);
|
|
1768
|
-
architectTokens = architectResult.tokens;
|
|
1769
|
-
synthesisCount = 0;
|
|
1770
|
-
await onProgress?.({
|
|
1771
|
-
type: 'phase_complete',
|
|
1772
|
-
metaPath: node.metaPath,
|
|
1773
|
-
phase: 'architect',
|
|
1774
|
-
tokens: architectTokens,
|
|
1775
|
-
durationMs: Date.now() - phaseStart,
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
catch (err) {
|
|
1779
|
-
stepError = toMetaError('architect', err);
|
|
1780
|
-
if (!currentMeta._builder) {
|
|
1781
|
-
// No cached builder — cycle fails
|
|
1782
|
-
finalizeCycle({
|
|
1783
|
-
metaPath: node.metaPath,
|
|
1784
|
-
current: currentMeta,
|
|
1785
|
-
config,
|
|
1786
|
-
architect: architectPrompt,
|
|
1787
|
-
builder: '',
|
|
1788
|
-
critic: criticPrompt,
|
|
1789
|
-
builderOutput: null,
|
|
1790
|
-
feedback: null,
|
|
1791
|
-
structureHash: newStructureHash,
|
|
1792
|
-
synthesisCount,
|
|
1793
|
-
error: stepError,
|
|
1794
|
-
architectTokens,
|
|
1795
|
-
});
|
|
1796
|
-
return {
|
|
1797
|
-
synthesized: true,
|
|
1798
|
-
metaPath: node.metaPath,
|
|
1799
|
-
error: stepError,
|
|
1800
|
-
};
|
|
1801
|
-
}
|
|
1802
|
-
// Has cached builder — continue with existing
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
// Step 9: Builder
|
|
1806
|
-
const metaForBuilder = { ...currentMeta, _builder: builderBrief };
|
|
1807
|
-
let builderOutput = null;
|
|
1808
|
-
try {
|
|
1809
|
-
await onProgress?.({
|
|
1810
|
-
type: 'phase_start',
|
|
1811
|
-
metaPath: node.metaPath,
|
|
1812
|
-
phase: 'builder',
|
|
1813
|
-
});
|
|
1814
|
-
const builderStart = Date.now();
|
|
1815
|
-
const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
|
|
1816
|
-
const builderResult = await executor.spawn(builderTask, {
|
|
1817
|
-
thinking: config.thinking,
|
|
1818
|
-
timeout: config.builderTimeout,
|
|
1819
|
-
});
|
|
1820
|
-
builderOutput = parseBuilderOutput(builderResult.output);
|
|
1821
|
-
builderTokens = builderResult.tokens;
|
|
1822
|
-
synthesisCount++;
|
|
1823
|
-
await onProgress?.({
|
|
1824
|
-
type: 'phase_complete',
|
|
1825
|
-
metaPath: node.metaPath,
|
|
1826
|
-
phase: 'builder',
|
|
1827
|
-
tokens: builderTokens,
|
|
1828
|
-
durationMs: Date.now() - builderStart,
|
|
1829
|
-
});
|
|
1830
|
-
}
|
|
1831
|
-
catch (err) {
|
|
1832
|
-
stepError = toMetaError('builder', err);
|
|
1833
|
-
return { synthesized: true, metaPath: node.metaPath, error: stepError };
|
|
1834
|
-
}
|
|
1835
|
-
// Step 10: Critic
|
|
1836
|
-
const metaForCritic = {
|
|
1837
|
-
...currentMeta,
|
|
1838
|
-
_content: builderOutput.content,
|
|
1839
|
-
};
|
|
1840
|
-
let feedback = null;
|
|
1841
|
-
try {
|
|
1842
|
-
await onProgress?.({
|
|
1843
|
-
type: 'phase_start',
|
|
1844
|
-
metaPath: node.metaPath,
|
|
1845
|
-
phase: 'critic',
|
|
1846
|
-
});
|
|
1847
|
-
const criticStart = Date.now();
|
|
1848
|
-
const criticTask = buildCriticTask(ctx, metaForCritic, config);
|
|
1849
|
-
const criticResult = await executor.spawn(criticTask, {
|
|
1850
|
-
thinking: config.thinking,
|
|
1851
|
-
timeout: config.criticTimeout,
|
|
1852
|
-
});
|
|
1853
|
-
feedback = parseCriticOutput(criticResult.output);
|
|
1854
|
-
criticTokens = criticResult.tokens;
|
|
1855
|
-
stepError = null; // Clear any architect error on full success
|
|
1856
|
-
await onProgress?.({
|
|
1857
|
-
type: 'phase_complete',
|
|
1858
|
-
metaPath: node.metaPath,
|
|
1859
|
-
phase: 'critic',
|
|
1860
|
-
tokens: criticTokens,
|
|
1861
|
-
durationMs: Date.now() - criticStart,
|
|
1862
|
-
});
|
|
1863
|
-
}
|
|
1864
|
-
catch (err) {
|
|
1865
|
-
stepError = stepError ?? toMetaError('critic', err);
|
|
1866
|
-
}
|
|
1867
|
-
// Steps 11-12: Merge, archive, prune
|
|
1868
|
-
finalizeCycle({
|
|
1869
|
-
metaPath: node.metaPath,
|
|
1870
|
-
current: currentMeta,
|
|
1871
|
-
config,
|
|
1872
|
-
architect: architectPrompt,
|
|
1873
|
-
builder: builderBrief,
|
|
1874
|
-
critic: criticPrompt,
|
|
1875
|
-
builderOutput,
|
|
1876
|
-
feedback,
|
|
1877
|
-
structureHash: newStructureHash,
|
|
1878
|
-
synthesisCount,
|
|
1879
|
-
error: stepError,
|
|
1880
|
-
architectTokens,
|
|
1881
|
-
builderTokens,
|
|
1882
|
-
criticTokens,
|
|
1883
|
-
});
|
|
1884
|
-
return {
|
|
1885
|
-
synthesized: true,
|
|
1886
|
-
metaPath: node.metaPath,
|
|
1887
|
-
error: stepError ?? undefined,
|
|
1888
|
-
};
|
|
2134
|
+
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
1889
2135
|
}
|
|
1890
2136
|
finally {
|
|
1891
2137
|
// Step 13: Release lock
|
|
@@ -1904,8 +2150,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
1904
2150
|
* @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
|
|
1905
2151
|
* @returns Array with a single result.
|
|
1906
2152
|
*/
|
|
1907
|
-
async function orchestrate(config, executor, watcher, targetPath, onProgress) {
|
|
1908
|
-
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
|
|
2153
|
+
async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
|
|
2154
|
+
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
|
|
1909
2155
|
return [result];
|
|
1910
2156
|
}
|
|
1911
2157
|
|
|
@@ -1914,9 +2160,12 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress) {
|
|
|
1914
2160
|
*
|
|
1915
2161
|
* @module progress
|
|
1916
2162
|
*/
|
|
2163
|
+
function formatNumber(n) {
|
|
2164
|
+
return n.toLocaleString('en-US');
|
|
2165
|
+
}
|
|
1917
2166
|
function formatSeconds(durationMs) {
|
|
1918
2167
|
const seconds = durationMs / 1000;
|
|
1919
|
-
return seconds.
|
|
2168
|
+
return Math.round(seconds).toString() + 's';
|
|
1920
2169
|
}
|
|
1921
2170
|
function titleCasePhase(phase) {
|
|
1922
2171
|
return phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
@@ -1924,32 +2173,30 @@ function titleCasePhase(phase) {
|
|
|
1924
2173
|
function formatProgressEvent(event) {
|
|
1925
2174
|
switch (event.type) {
|
|
1926
2175
|
case 'synthesis_start':
|
|
1927
|
-
return `🔬 Started meta synthesis: ${event.
|
|
2176
|
+
return `🔬 Started meta synthesis: ${event.path}`;
|
|
1928
2177
|
case 'phase_start': {
|
|
1929
2178
|
if (!event.phase) {
|
|
1930
|
-
return
|
|
2179
|
+
return ' ⚙️ Phase started';
|
|
1931
2180
|
}
|
|
1932
2181
|
return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
|
|
1933
2182
|
}
|
|
1934
2183
|
case 'phase_complete': {
|
|
1935
2184
|
const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
|
|
1936
2185
|
const tokens = event.tokens ?? 0;
|
|
1937
|
-
const duration = event.durationMs !== undefined
|
|
1938
|
-
|
|
1939
|
-
: '0.0s';
|
|
1940
|
-
return ` ✅ ${phase} phase complete (${String(tokens)} tokens / ${duration})`;
|
|
2186
|
+
const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
|
|
2187
|
+
return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
|
|
1941
2188
|
}
|
|
1942
2189
|
case 'synthesis_complete': {
|
|
1943
2190
|
const tokens = event.tokens ?? 0;
|
|
1944
2191
|
const duration = event.durationMs !== undefined
|
|
1945
2192
|
? formatSeconds(event.durationMs)
|
|
1946
2193
|
: '0.0s';
|
|
1947
|
-
return `✅ Completed: ${event.
|
|
2194
|
+
return `✅ Completed: ${event.path} (${formatNumber(tokens)} tokens / ${duration})`;
|
|
1948
2195
|
}
|
|
1949
2196
|
case 'error': {
|
|
1950
2197
|
const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
|
|
1951
2198
|
const error = event.error ?? 'Unknown error';
|
|
1952
|
-
return `❌ Synthesis failed at ${phase}phase: ${event.
|
|
2199
|
+
return `❌ Synthesis failed at ${phase}phase: ${event.path}\n Error: ${error}`;
|
|
1953
2200
|
}
|
|
1954
2201
|
default: {
|
|
1955
2202
|
return 'Unknown progress event';
|
|
@@ -2127,7 +2374,7 @@ class Scheduler {
|
|
|
2127
2374
|
*/
|
|
2128
2375
|
async discoverStalest() {
|
|
2129
2376
|
try {
|
|
2130
|
-
const result = await listMetas(this.config, this.watcher);
|
|
2377
|
+
const result = await listMetas(this.config, this.watcher, this.logger);
|
|
2131
2378
|
const stale = result.entries
|
|
2132
2379
|
.filter((e) => e.stalenessSeconds > 0)
|
|
2133
2380
|
.map((e) => ({
|
|
@@ -2413,7 +2660,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2413
2660
|
app.get('/metas', async (request) => {
|
|
2414
2661
|
const query = metasQuerySchema.parse(request.query);
|
|
2415
2662
|
const { config, watcher } = deps;
|
|
2416
|
-
const result = await listMetas(config, watcher);
|
|
2663
|
+
const result = await listMetas(config, watcher, request.log);
|
|
2417
2664
|
let entries = result.entries;
|
|
2418
2665
|
// Apply filters
|
|
2419
2666
|
if (query.pathPrefix) {
|
|
@@ -2476,7 +2723,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2476
2723
|
const query = metaDetailQuerySchema.parse(request.query);
|
|
2477
2724
|
const { config, watcher } = deps;
|
|
2478
2725
|
const targetPath = normalizePath(decodeURIComponent(request.params.path));
|
|
2479
|
-
const result = await listMetas(config, watcher);
|
|
2726
|
+
const result = await listMetas(config, watcher, request.log);
|
|
2480
2727
|
const targetNode = findNode(result.tree, targetPath);
|
|
2481
2728
|
if (!targetNode) {
|
|
2482
2729
|
return reply.status(404).send({
|
|
@@ -2509,7 +2756,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2509
2756
|
return r;
|
|
2510
2757
|
};
|
|
2511
2758
|
// Compute scope
|
|
2512
|
-
const { scopeFiles, allFiles } =
|
|
2759
|
+
const { scopeFiles, allFiles } = getScopeFiles(targetNode);
|
|
2513
2760
|
// Compute staleness
|
|
2514
2761
|
const metaTyped = meta;
|
|
2515
2762
|
const staleSeconds = metaTyped._generatedAt
|
|
@@ -2556,7 +2803,7 @@ function registerPreviewRoute(app, deps) {
|
|
|
2556
2803
|
const query = request.query;
|
|
2557
2804
|
let result;
|
|
2558
2805
|
try {
|
|
2559
|
-
result = await listMetas(config, watcher);
|
|
2806
|
+
result = await listMetas(config, watcher, request.log);
|
|
2560
2807
|
}
|
|
2561
2808
|
catch {
|
|
2562
2809
|
return reply.status(503).send({
|
|
@@ -2592,14 +2839,14 @@ function registerPreviewRoute(app, deps) {
|
|
|
2592
2839
|
}
|
|
2593
2840
|
const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
|
|
2594
2841
|
// Scope files
|
|
2595
|
-
const { scopeFiles } =
|
|
2842
|
+
const { scopeFiles } = getScopeFiles(targetNode);
|
|
2596
2843
|
const structureHash = computeStructureHash(scopeFiles);
|
|
2597
2844
|
const structureChanged = structureHash !== meta._structureHash;
|
|
2598
2845
|
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
2599
2846
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
2600
2847
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
2601
2848
|
// Delta files
|
|
2602
|
-
const deltaFiles =
|
|
2849
|
+
const deltaFiles = getDeltaFiles(targetNode, meta._generatedAt, scopeFiles);
|
|
2603
2850
|
// EMA token estimates
|
|
2604
2851
|
const estimatedTokens = {
|
|
2605
2852
|
architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
|
|
@@ -2693,7 +2940,7 @@ async function checkDependency(url, path) {
|
|
|
2693
2940
|
}
|
|
2694
2941
|
function registerStatusRoute(app, deps) {
|
|
2695
2942
|
app.get('/status', async () => {
|
|
2696
|
-
const { config, queue, scheduler, stats
|
|
2943
|
+
const { config, queue, scheduler, stats } = deps;
|
|
2697
2944
|
// On-demand dependency checks
|
|
2698
2945
|
const [watcherHealth, gatewayHealth] = await Promise.all([
|
|
2699
2946
|
checkDependency(config.watcherUrl, '/status'),
|
|
@@ -2714,23 +2961,11 @@ function registerStatusRoute(app, deps) {
|
|
|
2714
2961
|
else {
|
|
2715
2962
|
status = 'idle';
|
|
2716
2963
|
}
|
|
2717
|
-
// Metas summary
|
|
2718
|
-
|
|
2719
|
-
try {
|
|
2720
|
-
const result = await listMetas(config, watcher);
|
|
2721
|
-
metasSummary = {
|
|
2722
|
-
total: result.summary.total,
|
|
2723
|
-
stale: result.summary.stale,
|
|
2724
|
-
errors: result.summary.errors,
|
|
2725
|
-
neverSynthesized: result.summary.neverSynthesized,
|
|
2726
|
-
};
|
|
2727
|
-
}
|
|
2728
|
-
catch {
|
|
2729
|
-
// Watcher unreachable — leave zeros
|
|
2730
|
-
}
|
|
2964
|
+
// Metas summary is expensive (paginated watcher scan + disk reads).
|
|
2965
|
+
// Use GET /metas for full inventory; status is a lightweight health check.
|
|
2731
2966
|
return {
|
|
2732
|
-
service:
|
|
2733
|
-
version:
|
|
2967
|
+
service: SERVICE_NAME,
|
|
2968
|
+
version: SERVICE_VERSION,
|
|
2734
2969
|
uptime: process.uptime(),
|
|
2735
2970
|
status,
|
|
2736
2971
|
currentTarget: queue.current?.path ?? null,
|
|
@@ -2750,7 +2985,6 @@ function registerStatusRoute(app, deps) {
|
|
|
2750
2985
|
watcher: watcherHealth,
|
|
2751
2986
|
gateway: gatewayHealth,
|
|
2752
2987
|
},
|
|
2753
|
-
metas: metasSummary,
|
|
2754
2988
|
};
|
|
2755
2989
|
});
|
|
2756
2990
|
}
|
|
@@ -2776,7 +3010,7 @@ function registerSynthesizeRoute(app, deps) {
|
|
|
2776
3010
|
// Discover stalest candidate
|
|
2777
3011
|
let result;
|
|
2778
3012
|
try {
|
|
2779
|
-
result = await listMetas(config, watcher);
|
|
3013
|
+
result = await listMetas(config, watcher, request.log);
|
|
2780
3014
|
}
|
|
2781
3015
|
catch {
|
|
2782
3016
|
return reply.status(503).send({
|
|
@@ -2937,6 +3171,10 @@ function buildMetaRules(config) {
|
|
|
2937
3171
|
type: 'string',
|
|
2938
3172
|
set: '{{json._error.step}}',
|
|
2939
3173
|
},
|
|
3174
|
+
generated_at: {
|
|
3175
|
+
type: 'string',
|
|
3176
|
+
set: '{{json._generatedAt}}',
|
|
3177
|
+
},
|
|
2940
3178
|
generated_at_unix: {
|
|
2941
3179
|
type: 'integer',
|
|
2942
3180
|
set: '{{toUnix json._generatedAt}}',
|
|
@@ -2949,16 +3187,7 @@ function buildMetaRules(config) {
|
|
|
2949
3187
|
},
|
|
2950
3188
|
],
|
|
2951
3189
|
render: {
|
|
2952
|
-
frontmatter: [
|
|
2953
|
-
'meta_id',
|
|
2954
|
-
'meta_steer',
|
|
2955
|
-
'generated_at_unix',
|
|
2956
|
-
'meta_depth',
|
|
2957
|
-
'meta_emphasis',
|
|
2958
|
-
'meta_architect_tokens',
|
|
2959
|
-
'meta_builder_tokens',
|
|
2960
|
-
'meta_critic_tokens',
|
|
2961
|
-
],
|
|
3190
|
+
frontmatter: ['meta_id', 'generated_at', '*', '!_*', '!has_error'],
|
|
2962
3191
|
body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
|
|
2963
3192
|
},
|
|
2964
3193
|
renderAs: 'md',
|
|
@@ -3108,7 +3337,10 @@ class RuleRegistrar {
|
|
|
3108
3337
|
* @returns Configured Fastify instance (not yet listening).
|
|
3109
3338
|
*/
|
|
3110
3339
|
function createServer(options) {
|
|
3111
|
-
|
|
3340
|
+
// Fastify 5 requires `loggerInstance` for external pino loggers
|
|
3341
|
+
const app = Fastify({
|
|
3342
|
+
loggerInstance: options.logger,
|
|
3343
|
+
});
|
|
3112
3344
|
registerRoutes(app, {
|
|
3113
3345
|
config: options.config,
|
|
3114
3346
|
logger: options.logger,
|
|
@@ -3189,6 +3421,7 @@ function registerShutdownHandlers(deps) {
|
|
|
3189
3421
|
const DEFAULT_MAX_RETRIES = 3;
|
|
3190
3422
|
const DEFAULT_BACKOFF_BASE_MS = 1000;
|
|
3191
3423
|
const DEFAULT_BACKOFF_FACTOR = 4;
|
|
3424
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
3192
3425
|
/** Check if an error is transient (worth retrying). */
|
|
3193
3426
|
function isTransient(status) {
|
|
3194
3427
|
return status >= 500 || status === 408 || status === 429;
|
|
@@ -3201,11 +3434,13 @@ class HttpWatcherClient {
|
|
|
3201
3434
|
maxRetries;
|
|
3202
3435
|
backoffBaseMs;
|
|
3203
3436
|
backoffFactor;
|
|
3437
|
+
timeoutMs;
|
|
3204
3438
|
constructor(options) {
|
|
3205
3439
|
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
3206
3440
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
3207
3441
|
this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
|
|
3208
3442
|
this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
|
|
3443
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
3209
3444
|
}
|
|
3210
3445
|
/** POST JSON with retry. */
|
|
3211
3446
|
async post(endpoint, body) {
|
|
@@ -3215,6 +3450,7 @@ class HttpWatcherClient {
|
|
|
3215
3450
|
method: 'POST',
|
|
3216
3451
|
headers: { 'Content-Type': 'application/json' },
|
|
3217
3452
|
body: JSON.stringify(body),
|
|
3453
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
3218
3454
|
});
|
|
3219
3455
|
if (res.ok) {
|
|
3220
3456
|
return res.json();
|
|
@@ -3355,9 +3591,11 @@ async function startService(config, configPath) {
|
|
|
3355
3591
|
const synthesizeFn = async (path) => {
|
|
3356
3592
|
const startMs = Date.now();
|
|
3357
3593
|
let cycleTokens = 0;
|
|
3594
|
+
// Strip .meta suffix for human-readable progress reporting
|
|
3595
|
+
const ownerPath = path.replace(/\/?\.meta\/?$/, '');
|
|
3358
3596
|
await progress.report({
|
|
3359
3597
|
type: 'synthesis_start',
|
|
3360
|
-
|
|
3598
|
+
path: ownerPath,
|
|
3361
3599
|
});
|
|
3362
3600
|
try {
|
|
3363
3601
|
const results = await orchestrate(config, executor, watcher, path, async (evt) => {
|
|
@@ -3379,7 +3617,7 @@ async function startService(config, configPath) {
|
|
|
3379
3617
|
stats.totalErrors++;
|
|
3380
3618
|
await progress.report({
|
|
3381
3619
|
type: 'error',
|
|
3382
|
-
|
|
3620
|
+
path: ownerPath,
|
|
3383
3621
|
error: result.error.message,
|
|
3384
3622
|
});
|
|
3385
3623
|
}
|
|
@@ -3387,7 +3625,7 @@ async function startService(config, configPath) {
|
|
|
3387
3625
|
scheduler.resetBackoff();
|
|
3388
3626
|
await progress.report({
|
|
3389
3627
|
type: 'synthesis_complete',
|
|
3390
|
-
|
|
3628
|
+
path: ownerPath,
|
|
3391
3629
|
tokens: cycleTokens,
|
|
3392
3630
|
durationMs,
|
|
3393
3631
|
});
|
|
@@ -3398,7 +3636,7 @@ async function startService(config, configPath) {
|
|
|
3398
3636
|
const message = err instanceof Error ? err.message : String(err);
|
|
3399
3637
|
await progress.report({
|
|
3400
3638
|
type: 'error',
|
|
3401
|
-
|
|
3639
|
+
path: ownerPath,
|
|
3402
3640
|
error: message,
|
|
3403
3641
|
});
|
|
3404
3642
|
throw err;
|
|
@@ -3460,4 +3698,4 @@ async function startService(config, configPath) {
|
|
|
3460
3698
|
logger.info('Service fully initialized');
|
|
3461
3699
|
}
|
|
3462
3700
|
|
|
3463
|
-
export { GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildMetaFilter, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaErrorSchema, metaJsonSchema, normalizePath, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError };
|
|
3701
|
+
export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildMetaFilter, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaErrorSchema, metaJsonSchema, normalizePath, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError, walkFiles };
|