@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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, copyFileSync, watchFile } from 'node:fs';
|
|
4
|
-
import { dirname, join, relative } from 'node:path';
|
|
3
|
+
import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve, relative } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
7
9
|
import pino from 'pino';
|
|
8
10
|
import { Cron } from 'croner';
|
|
9
11
|
import Fastify from 'fastify';
|
|
@@ -166,6 +168,42 @@ var configLoader = /*#__PURE__*/Object.freeze({
|
|
|
166
168
|
resolveConfigPath: resolveConfigPath
|
|
167
169
|
});
|
|
168
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Shared constants for the jeeves-meta service package.
|
|
173
|
+
*
|
|
174
|
+
* @module constants
|
|
175
|
+
*/
|
|
176
|
+
/** Default HTTP port for the jeeves-meta service. */
|
|
177
|
+
const DEFAULT_PORT = 1938;
|
|
178
|
+
/** Default port as a string (for Commander CLI defaults). */
|
|
179
|
+
const DEFAULT_PORT_STR = String(DEFAULT_PORT);
|
|
180
|
+
/** Service name identifier. */
|
|
181
|
+
const SERVICE_NAME = 'jeeves-meta';
|
|
182
|
+
/** Service version, read from package.json at startup. */
|
|
183
|
+
const SERVICE_VERSION = (() => {
|
|
184
|
+
try {
|
|
185
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
186
|
+
// Walk up to find package.json (works from src/ or dist/)
|
|
187
|
+
for (const candidate of [
|
|
188
|
+
resolve(dir, '..', 'package.json'),
|
|
189
|
+
resolve(dir, '..', '..', 'package.json'),
|
|
190
|
+
]) {
|
|
191
|
+
try {
|
|
192
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
193
|
+
if (pkg.version)
|
|
194
|
+
return pkg.version;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// try next candidate
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return 'unknown';
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return 'unknown';
|
|
204
|
+
}
|
|
205
|
+
})();
|
|
206
|
+
|
|
169
207
|
/**
|
|
170
208
|
* List archive snapshot files in chronological order.
|
|
171
209
|
*
|
|
@@ -293,14 +331,29 @@ function normalizePath(p) {
|
|
|
293
331
|
* @param params - Base scan parameters (cursor is managed internally).
|
|
294
332
|
* @returns All matching files across all pages.
|
|
295
333
|
*/
|
|
296
|
-
async function paginatedScan(watcher, params) {
|
|
334
|
+
async function paginatedScan(watcher, params, logger) {
|
|
297
335
|
const allFiles = [];
|
|
298
336
|
let cursor;
|
|
337
|
+
let pageCount = 0;
|
|
338
|
+
const start = Date.now();
|
|
299
339
|
do {
|
|
340
|
+
const pageStart = Date.now();
|
|
300
341
|
const result = await watcher.scan({ ...params, cursor });
|
|
301
342
|
allFiles.push(...result.files);
|
|
343
|
+
pageCount++;
|
|
344
|
+
logger?.debug({
|
|
345
|
+
page: pageCount,
|
|
346
|
+
files: result.files.length,
|
|
347
|
+
pageMs: Date.now() - pageStart,
|
|
348
|
+
hasNext: Boolean(result.next),
|
|
349
|
+
}, 'paginatedScan page');
|
|
302
350
|
cursor = result.next;
|
|
303
351
|
} while (cursor);
|
|
352
|
+
logger?.debug({
|
|
353
|
+
pages: pageCount,
|
|
354
|
+
totalFiles: allFiles.length,
|
|
355
|
+
totalMs: Date.now() - start,
|
|
356
|
+
}, 'paginatedScan complete');
|
|
304
357
|
return allFiles;
|
|
305
358
|
}
|
|
306
359
|
|
|
@@ -367,12 +420,9 @@ function buildMetaFilter(config) {
|
|
|
367
420
|
* @param watcher - WatcherClient for scan queries.
|
|
368
421
|
* @returns Array of normalized .meta/ directory paths.
|
|
369
422
|
*/
|
|
370
|
-
async function discoverMetas(config, watcher) {
|
|
423
|
+
async function discoverMetas(config, watcher, logger) {
|
|
371
424
|
const filter = buildMetaFilter(config);
|
|
372
|
-
const scanFiles = await paginatedScan(watcher, {
|
|
373
|
-
filter,
|
|
374
|
-
fields: ['file_path'],
|
|
375
|
-
});
|
|
425
|
+
const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
|
|
376
426
|
// Deduplicate by .meta/ directory path (handles multi-chunk files)
|
|
377
427
|
const seen = new Set();
|
|
378
428
|
const metaPaths = [];
|
|
@@ -600,6 +650,8 @@ function findNode(tree, targetPath) {
|
|
|
600
650
|
*
|
|
601
651
|
* @module discovery/listMetas
|
|
602
652
|
*/
|
|
653
|
+
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
654
|
+
const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
|
|
603
655
|
/**
|
|
604
656
|
* Discover, deduplicate, and enrich all metas.
|
|
605
657
|
*
|
|
@@ -611,9 +663,9 @@ function findNode(tree, targetPath) {
|
|
|
611
663
|
* @param watcher - Watcher HTTP client for discovery.
|
|
612
664
|
* @returns Enriched meta list with summary statistics and ownership tree.
|
|
613
665
|
*/
|
|
614
|
-
async function listMetas(config, watcher) {
|
|
666
|
+
async function listMetas(config, watcher, logger) {
|
|
615
667
|
// Step 1: Discover deduplicated meta paths via watcher scan
|
|
616
|
-
const metaPaths = await discoverMetas(config, watcher);
|
|
668
|
+
const metaPaths = await discoverMetas(config, watcher, logger);
|
|
617
669
|
// Step 2: Build ownership tree
|
|
618
670
|
const tree = buildOwnershipTree(metaPaths);
|
|
619
671
|
// Step 3: Read and enrich each meta from disk
|
|
@@ -646,7 +698,7 @@ async function listMetas(config, watcher) {
|
|
|
646
698
|
// Compute staleness
|
|
647
699
|
let stalenessSeconds;
|
|
648
700
|
if (neverSynth) {
|
|
649
|
-
stalenessSeconds =
|
|
701
|
+
stalenessSeconds = MAX_STALENESS_SECONDS$1;
|
|
650
702
|
}
|
|
651
703
|
else {
|
|
652
704
|
const genAt = new Date(meta._generatedAt).getTime();
|
|
@@ -677,11 +729,7 @@ async function listMetas(config, watcher) {
|
|
|
677
729
|
}
|
|
678
730
|
// Track stalest (effective staleness for scheduling)
|
|
679
731
|
const depthFactor = Math.pow(1 + config.depthWeight, depth);
|
|
680
|
-
const effectiveStaleness =
|
|
681
|
-
? Number.MAX_SAFE_INTEGER
|
|
682
|
-
: stalenessSeconds) *
|
|
683
|
-
depthFactor *
|
|
684
|
-
emphasis;
|
|
732
|
+
const effectiveStaleness = stalenessSeconds * depthFactor * emphasis;
|
|
685
733
|
if (effectiveStaleness > stalestEffective) {
|
|
686
734
|
stalestEffective = effectiveStaleness;
|
|
687
735
|
stalestPath = node.metaPath;
|
|
@@ -723,22 +771,83 @@ async function listMetas(config, watcher) {
|
|
|
723
771
|
};
|
|
724
772
|
}
|
|
725
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Recursive filesystem walker for file enumeration.
|
|
776
|
+
*
|
|
777
|
+
* Replaces paginated watcher scans for scope/delta/staleness checks.
|
|
778
|
+
* Returns normalized forward-slash paths.
|
|
779
|
+
*
|
|
780
|
+
* @module walkFiles
|
|
781
|
+
*/
|
|
782
|
+
/** Default directory names to always skip. */
|
|
783
|
+
const DEFAULT_SKIP = new Set([
|
|
784
|
+
'node_modules',
|
|
785
|
+
'.git',
|
|
786
|
+
'.rollup.cache',
|
|
787
|
+
'dist',
|
|
788
|
+
'Thumbs.db',
|
|
789
|
+
]);
|
|
790
|
+
/**
|
|
791
|
+
* Recursively walk a directory and return all file paths.
|
|
792
|
+
*
|
|
793
|
+
* @param root - Root directory to walk.
|
|
794
|
+
* @param options - Walk options.
|
|
795
|
+
* @returns Array of normalized file paths.
|
|
796
|
+
*/
|
|
797
|
+
function walkFiles(root, options) {
|
|
798
|
+
const exclude = new Set([...DEFAULT_SKIP, ...(options?.exclude ?? [])]);
|
|
799
|
+
const modifiedAfter = options?.modifiedAfter;
|
|
800
|
+
const maxDepth = options?.maxDepth ?? 50;
|
|
801
|
+
const results = [];
|
|
802
|
+
function walk(dir, depth) {
|
|
803
|
+
if (depth > maxDepth)
|
|
804
|
+
return;
|
|
805
|
+
let entries;
|
|
806
|
+
try {
|
|
807
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return; // Permission errors, missing dirs — skip
|
|
811
|
+
}
|
|
812
|
+
for (const entry of entries) {
|
|
813
|
+
if (exclude.has(entry.name))
|
|
814
|
+
continue;
|
|
815
|
+
const fullPath = join(dir, entry.name);
|
|
816
|
+
if (entry.isDirectory()) {
|
|
817
|
+
walk(fullPath, depth + 1);
|
|
818
|
+
}
|
|
819
|
+
else if (entry.isFile()) {
|
|
820
|
+
if (modifiedAfter !== undefined) {
|
|
821
|
+
try {
|
|
822
|
+
const stat = statSync(fullPath);
|
|
823
|
+
if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
results.push(normalizePath(fullPath));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
walk(root, 0);
|
|
835
|
+
return results;
|
|
836
|
+
}
|
|
837
|
+
|
|
726
838
|
/**
|
|
727
839
|
* Compute the file scope owned by a meta node.
|
|
728
840
|
*
|
|
729
|
-
* A meta owns: parent dir + all descendants, minus
|
|
730
|
-
*
|
|
841
|
+
* A meta owns: parent dir + all descendants, minus:
|
|
842
|
+
* - Its own .meta/ subtree (outputs, not inputs)
|
|
843
|
+
* - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
|
|
844
|
+
*
|
|
845
|
+
* Uses filesystem walks instead of watcher scans for performance.
|
|
731
846
|
*
|
|
732
847
|
* @module discovery/scope
|
|
733
848
|
*/
|
|
734
849
|
/**
|
|
735
850
|
* Get the scope path prefix for a meta node.
|
|
736
|
-
*
|
|
737
|
-
* This is the ownerPath — all files under this path are in scope,
|
|
738
|
-
* except subtrees owned by child metas.
|
|
739
|
-
*
|
|
740
|
-
* @param node - The meta node to compute scope for.
|
|
741
|
-
* @returns The scope path prefix.
|
|
742
851
|
*/
|
|
743
852
|
function getScopePrefix(node) {
|
|
744
853
|
return node.ownerPath;
|
|
@@ -746,47 +855,39 @@ function getScopePrefix(node) {
|
|
|
746
855
|
/**
|
|
747
856
|
* Filter a list of file paths to only those in scope for a meta node.
|
|
748
857
|
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
858
|
+
* Excludes:
|
|
859
|
+
* - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
|
|
860
|
+
* - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
|
|
751
861
|
*
|
|
752
|
-
*
|
|
753
|
-
* @param files - Array of file paths to filter.
|
|
754
|
-
* @returns Filtered array of in-scope file paths.
|
|
862
|
+
* walkFiles already returns normalized forward-slash paths.
|
|
755
863
|
*/
|
|
756
864
|
function filterInScope(node, files) {
|
|
757
865
|
const prefix = node.ownerPath + '/';
|
|
866
|
+
const ownMetaPrefix = node.metaPath + '/';
|
|
758
867
|
const exclusions = node.children.map((c) => c.ownerPath + '/');
|
|
759
868
|
const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
|
|
760
869
|
return files.filter((f) => {
|
|
761
|
-
const normalized = f.split('\\').join('/');
|
|
762
870
|
// Must be under ownerPath
|
|
763
|
-
if (!
|
|
871
|
+
if (!f.startsWith(prefix) && f !== node.ownerPath)
|
|
872
|
+
return false;
|
|
873
|
+
// Exclude own .meta/ subtree (outputs are not inputs)
|
|
874
|
+
if (f.startsWith(ownMetaPrefix))
|
|
764
875
|
return false;
|
|
765
876
|
// Check if under a child meta's subtree
|
|
766
877
|
for (const excl of exclusions) {
|
|
767
|
-
if (
|
|
878
|
+
if (f.startsWith(excl)) {
|
|
768
879
|
// Exception: child meta.json files are included as rollup inputs
|
|
769
|
-
return childMetaJsons.has(
|
|
880
|
+
return childMetaJsons.has(f);
|
|
770
881
|
}
|
|
771
882
|
}
|
|
772
883
|
return true;
|
|
773
884
|
});
|
|
774
885
|
}
|
|
775
886
|
/**
|
|
776
|
-
* Get all files in scope for a meta node via
|
|
777
|
-
*
|
|
778
|
-
* Scans the owner path prefix and filters out child meta subtrees,
|
|
779
|
-
* keeping only files directly owned by this meta.
|
|
780
|
-
*
|
|
781
|
-
* @param node - The meta node.
|
|
782
|
-
* @param watcher - WatcherClient for scan queries.
|
|
783
|
-
* @returns Array of in-scope file paths.
|
|
887
|
+
* Get all files in scope for a meta node via filesystem walk.
|
|
784
888
|
*/
|
|
785
|
-
|
|
786
|
-
const
|
|
787
|
-
pathPrefix: node.ownerPath,
|
|
788
|
-
});
|
|
789
|
-
const allFiles = allScanFiles.map((f) => f.file_path);
|
|
889
|
+
function getScopeFiles(node) {
|
|
890
|
+
const allFiles = walkFiles(node.ownerPath);
|
|
790
891
|
return {
|
|
791
892
|
scopeFiles: filterInScope(node, allFiles),
|
|
792
893
|
allFiles,
|
|
@@ -796,22 +897,13 @@ async function getScopeFiles(node, watcher) {
|
|
|
796
897
|
* Get files modified since a given timestamp within a meta node's scope.
|
|
797
898
|
*
|
|
798
899
|
* If no generatedAt is provided (first run), returns all scope files.
|
|
799
|
-
*
|
|
800
|
-
* @param node - The meta node.
|
|
801
|
-
* @param watcher - WatcherClient for scan queries.
|
|
802
|
-
* @param generatedAt - ISO timestamp of last synthesis, or null/undefined for first run.
|
|
803
|
-
* @param scopeFiles - Pre-computed scope files (used as fallback for first run).
|
|
804
|
-
* @returns Array of modified in-scope file paths.
|
|
805
900
|
*/
|
|
806
|
-
|
|
901
|
+
function getDeltaFiles(node, generatedAt, scopeFiles) {
|
|
807
902
|
if (!generatedAt)
|
|
808
903
|
return scopeFiles;
|
|
809
904
|
const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
modifiedAfter,
|
|
813
|
-
});
|
|
814
|
-
return filterInScope(node, deltaScanFiles.map((f) => f.file_path));
|
|
905
|
+
const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
|
|
906
|
+
return filterInScope(node, deltaFiles);
|
|
815
907
|
}
|
|
816
908
|
|
|
817
909
|
/**
|
|
@@ -890,7 +982,7 @@ function sleep(ms) {
|
|
|
890
982
|
* @module executor/GatewayExecutor
|
|
891
983
|
*/
|
|
892
984
|
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
893
|
-
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
985
|
+
const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
|
|
894
986
|
/**
|
|
895
987
|
* MetaExecutor that spawns OpenClaw sessions via the gateway's
|
|
896
988
|
* `/tools/invoke` endpoint.
|
|
@@ -903,10 +995,12 @@ class GatewayExecutor {
|
|
|
903
995
|
gatewayUrl;
|
|
904
996
|
apiKey;
|
|
905
997
|
pollIntervalMs;
|
|
998
|
+
workspaceDir;
|
|
906
999
|
constructor(options = {}) {
|
|
907
1000
|
this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
|
|
908
1001
|
this.apiKey = options.apiKey;
|
|
909
1002
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
1003
|
+
this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
|
|
910
1004
|
}
|
|
911
1005
|
/** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
|
|
912
1006
|
async invoke(tool, args) {
|
|
@@ -931,13 +1025,44 @@ class GatewayExecutor {
|
|
|
931
1025
|
}
|
|
932
1026
|
return data;
|
|
933
1027
|
}
|
|
1028
|
+
/** Look up totalTokens for a session via sessions_list. */
|
|
1029
|
+
async getSessionTokens(sessionKey) {
|
|
1030
|
+
try {
|
|
1031
|
+
const result = await this.invoke('sessions_list', {
|
|
1032
|
+
limit: 20,
|
|
1033
|
+
messageLimit: 0,
|
|
1034
|
+
});
|
|
1035
|
+
const sessions = (result.result?.details?.sessions ??
|
|
1036
|
+
result.result?.sessions ??
|
|
1037
|
+
[]);
|
|
1038
|
+
const match = sessions.find((s) => s.key === sessionKey);
|
|
1039
|
+
return match?.totalTokens ?? undefined;
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
return undefined;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
934
1045
|
async spawn(task, options) {
|
|
935
|
-
const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000;
|
|
1046
|
+
const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
|
|
936
1047
|
const timeoutMs = timeoutSeconds * 1000;
|
|
937
1048
|
const deadline = Date.now() + timeoutMs;
|
|
1049
|
+
// Ensure workspace dir exists
|
|
1050
|
+
if (!existsSync(this.workspaceDir)) {
|
|
1051
|
+
mkdirSync(this.workspaceDir, { recursive: true });
|
|
1052
|
+
}
|
|
1053
|
+
// Generate unique output path for file-based output
|
|
1054
|
+
const outputId = randomUUID();
|
|
1055
|
+
const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
|
|
1056
|
+
// Append file output instruction to the task
|
|
1057
|
+
const taskWithOutput = task +
|
|
1058
|
+
'\n\n## OUTPUT DELIVERY\n\n' +
|
|
1059
|
+
'Write your complete output to a file using the Write tool at:\n' +
|
|
1060
|
+
outputPath +
|
|
1061
|
+
'\n\n' +
|
|
1062
|
+
'Reply with ONLY the file path you wrote to. No other text.';
|
|
938
1063
|
// Step 1: Spawn the sub-agent session
|
|
939
1064
|
const spawnResult = await this.invoke('sessions_spawn', {
|
|
940
|
-
task,
|
|
1065
|
+
task: taskWithOutput,
|
|
941
1066
|
label: options?.label ?? 'jeeves-meta-synthesis',
|
|
942
1067
|
runTimeoutSeconds: timeoutSeconds,
|
|
943
1068
|
...(options?.thinking ? { thinking: options.thinking } : {}),
|
|
@@ -969,19 +1094,37 @@ class GatewayExecutor {
|
|
|
969
1094
|
lastMsg.stopReason &&
|
|
970
1095
|
lastMsg.stopReason !== 'toolUse' &&
|
|
971
1096
|
lastMsg.stopReason !== 'error') {
|
|
972
|
-
//
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1097
|
+
// Fetch token usage from session metadata
|
|
1098
|
+
const tokens = await this.getSessionTokens(sessionKey);
|
|
1099
|
+
// Read output from file (sub-agent wrote it via Write tool)
|
|
1100
|
+
if (existsSync(outputPath)) {
|
|
1101
|
+
try {
|
|
1102
|
+
const output = readFileSync(outputPath, 'utf8');
|
|
1103
|
+
return { output, tokens };
|
|
1104
|
+
}
|
|
1105
|
+
finally {
|
|
1106
|
+
try {
|
|
1107
|
+
unlinkSync(outputPath);
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
/* cleanup best-effort */
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
978
1113
|
}
|
|
979
|
-
if
|
|
980
|
-
tokens = sum;
|
|
981
|
-
// Find the last assistant message with content
|
|
1114
|
+
// Fallback: extract from message content if file wasn't written
|
|
982
1115
|
for (let i = msgArray.length - 1; i >= 0; i--) {
|
|
983
|
-
|
|
984
|
-
|
|
1116
|
+
const msg = msgArray[i];
|
|
1117
|
+
if (msg.role === 'assistant' && msg.content) {
|
|
1118
|
+
const text = typeof msg.content === 'string'
|
|
1119
|
+
? msg.content
|
|
1120
|
+
: Array.isArray(msg.content)
|
|
1121
|
+
? msg.content
|
|
1122
|
+
.filter((b) => b.type === 'text' && b.text)
|
|
1123
|
+
.map((b) => b.text)
|
|
1124
|
+
.join('\n')
|
|
1125
|
+
: '';
|
|
1126
|
+
if (text)
|
|
1127
|
+
return { output: text, tokens };
|
|
985
1128
|
}
|
|
986
1129
|
}
|
|
987
1130
|
return { output: '', tokens };
|
|
@@ -1061,10 +1204,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
|
|
|
1061
1204
|
* @param watcher - WatcherClient for scope enumeration.
|
|
1062
1205
|
* @returns The computed context package.
|
|
1063
1206
|
*/
|
|
1064
|
-
|
|
1207
|
+
function buildContextPackage(node, meta) {
|
|
1065
1208
|
// Scope and delta files via watcher scan
|
|
1066
|
-
const { scopeFiles } =
|
|
1067
|
-
const deltaFiles =
|
|
1209
|
+
const { scopeFiles } = getScopeFiles(node);
|
|
1210
|
+
const deltaFiles = getDeltaFiles(node, meta._generatedAt, scopeFiles);
|
|
1068
1211
|
// Child meta outputs
|
|
1069
1212
|
const childMetas = {};
|
|
1070
1213
|
for (const child of node.children) {
|
|
@@ -1175,7 +1318,7 @@ function buildBuilderTask(ctx, meta, config) {
|
|
|
1175
1318
|
includeSteer: false,
|
|
1176
1319
|
feedbackHeading: '## FEEDBACK FROM CRITIC',
|
|
1177
1320
|
});
|
|
1178
|
-
sections.push('', '## OUTPUT FORMAT', '
|
|
1321
|
+
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.');
|
|
1179
1322
|
return sections.join('\n');
|
|
1180
1323
|
}
|
|
1181
1324
|
/**
|
|
@@ -1481,28 +1624,33 @@ function discoverStalestPath(candidates, depthWeight) {
|
|
|
1481
1624
|
* @param watcher - WatcherClient instance.
|
|
1482
1625
|
* @returns True if any file in scope was modified after _generatedAt.
|
|
1483
1626
|
*/
|
|
1484
|
-
|
|
1627
|
+
function isStale(scopePrefix, meta) {
|
|
1485
1628
|
if (!meta._generatedAt)
|
|
1486
1629
|
return true; // Never synthesized = stale
|
|
1487
1630
|
const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
|
|
1488
|
-
const
|
|
1489
|
-
pathPrefix: scopePrefix,
|
|
1631
|
+
const modified = walkFiles(scopePrefix, {
|
|
1490
1632
|
modifiedAfter: generatedAtUnix,
|
|
1491
|
-
|
|
1633
|
+
maxDepth: 1,
|
|
1492
1634
|
});
|
|
1493
|
-
return
|
|
1635
|
+
return modified.length > 0;
|
|
1494
1636
|
}
|
|
1637
|
+
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
1638
|
+
const MAX_STALENESS_SECONDS = 365 * 86_400;
|
|
1495
1639
|
/**
|
|
1496
1640
|
* Compute actual staleness in seconds (now minus _generatedAt).
|
|
1497
1641
|
*
|
|
1642
|
+
* Never-synthesized metas are capped at {@link MAX_STALENESS_SECONDS}
|
|
1643
|
+
* (1 year) so that depth weighting can differentiate them. Without
|
|
1644
|
+
* bounding, `Infinity * depthFactor` = `Infinity` for all depths.
|
|
1645
|
+
*
|
|
1498
1646
|
* @param meta - Current meta.json content.
|
|
1499
|
-
* @returns Staleness in seconds,
|
|
1647
|
+
* @returns Staleness in seconds, capped at 1 year for never-synthesized metas.
|
|
1500
1648
|
*/
|
|
1501
1649
|
function actualStaleness(meta) {
|
|
1502
1650
|
if (!meta._generatedAt)
|
|
1503
|
-
return
|
|
1651
|
+
return MAX_STALENESS_SECONDS;
|
|
1504
1652
|
const generatedMs = new Date(meta._generatedAt).getTime();
|
|
1505
|
-
return (Date.now() - generatedMs) / 1000;
|
|
1653
|
+
return Math.min((Date.now() - generatedMs) / 1000, MAX_STALENESS_SECONDS);
|
|
1506
1654
|
}
|
|
1507
1655
|
/**
|
|
1508
1656
|
* Check whether the architect step should be triggered.
|
|
@@ -1579,20 +1727,50 @@ function parseArchitectOutput(output) {
|
|
|
1579
1727
|
*/
|
|
1580
1728
|
function parseBuilderOutput(output) {
|
|
1581
1729
|
const trimmed = output.trim();
|
|
1582
|
-
// Try to
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1730
|
+
// Strategy 1: Try to parse the entire output as JSON directly
|
|
1731
|
+
const direct = tryParseJson(trimmed);
|
|
1732
|
+
if (direct)
|
|
1733
|
+
return direct;
|
|
1734
|
+
// Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
|
|
1735
|
+
const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
|
|
1736
|
+
const fenceMatches = [];
|
|
1737
|
+
let match;
|
|
1738
|
+
while ((match = fencePattern.exec(trimmed)) !== null) {
|
|
1739
|
+
fenceMatches.push(match[1].trim());
|
|
1740
|
+
}
|
|
1741
|
+
// Try last fence first (most likely to be the actual output)
|
|
1742
|
+
for (let i = fenceMatches.length - 1; i >= 0; i--) {
|
|
1743
|
+
const result = tryParseJson(fenceMatches[i]);
|
|
1744
|
+
if (result)
|
|
1745
|
+
return result;
|
|
1746
|
+
}
|
|
1747
|
+
// Strategy 3: Find outermost { ... } braces
|
|
1748
|
+
const firstBrace = trimmed.indexOf('{');
|
|
1749
|
+
const lastBrace = trimmed.lastIndexOf('}');
|
|
1750
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
1751
|
+
const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
|
|
1752
|
+
if (result)
|
|
1753
|
+
return result;
|
|
1754
|
+
}
|
|
1755
|
+
// Fallback: treat entire output as content
|
|
1756
|
+
return { content: trimmed, fields: {} };
|
|
1757
|
+
}
|
|
1758
|
+
/** Try to parse a string as JSON and extract builder output fields. */
|
|
1759
|
+
function tryParseJson(str) {
|
|
1588
1760
|
try {
|
|
1589
|
-
const
|
|
1761
|
+
const raw = JSON.parse(str);
|
|
1762
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
1763
|
+
return null;
|
|
1764
|
+
}
|
|
1765
|
+
const parsed = raw;
|
|
1590
1766
|
// Extract _content
|
|
1591
|
-
const content = typeof parsed
|
|
1592
|
-
? parsed
|
|
1593
|
-
: typeof parsed
|
|
1594
|
-
? parsed
|
|
1595
|
-
:
|
|
1767
|
+
const content = typeof parsed['_content'] === 'string'
|
|
1768
|
+
? parsed['_content']
|
|
1769
|
+
: typeof parsed['content'] === 'string'
|
|
1770
|
+
? parsed['content']
|
|
1771
|
+
: null;
|
|
1772
|
+
if (content === null)
|
|
1773
|
+
return null;
|
|
1596
1774
|
// Extract non-underscore fields
|
|
1597
1775
|
const fields = {};
|
|
1598
1776
|
for (const [key, value] of Object.entries(parsed)) {
|
|
@@ -1603,8 +1781,7 @@ function parseBuilderOutput(output) {
|
|
|
1603
1781
|
return { content, fields };
|
|
1604
1782
|
}
|
|
1605
1783
|
catch {
|
|
1606
|
-
|
|
1607
|
-
return { content: trimmed, fields: {} };
|
|
1784
|
+
return null;
|
|
1608
1785
|
}
|
|
1609
1786
|
}
|
|
1610
1787
|
/**
|
|
@@ -1665,9 +1842,239 @@ function finalizeCycle(opts) {
|
|
|
1665
1842
|
* @param watcher - Watcher HTTP client.
|
|
1666
1843
|
* @returns Result indicating whether synthesis occurred.
|
|
1667
1844
|
*/
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1845
|
+
/**
|
|
1846
|
+
* Build a minimal MetaNode from the filesystem for a known meta path.
|
|
1847
|
+
* Discovers immediate child .meta/ dirs without a full watcher scan.
|
|
1848
|
+
*/
|
|
1849
|
+
function buildMinimalNode(metaPath) {
|
|
1850
|
+
const normalized = normalizePath(metaPath);
|
|
1851
|
+
const ownerPath = normalizePath(dirname(metaPath));
|
|
1852
|
+
// Find child .meta/ directories by scanning the owner directory
|
|
1853
|
+
const children = [];
|
|
1854
|
+
function findChildMetas(dir, depth) {
|
|
1855
|
+
if (depth > 10)
|
|
1856
|
+
return; // Safety limit
|
|
1857
|
+
try {
|
|
1858
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1859
|
+
for (const entry of entries) {
|
|
1860
|
+
if (!entry.isDirectory())
|
|
1861
|
+
continue;
|
|
1862
|
+
const fullPath = normalizePath(join(dir, entry.name));
|
|
1863
|
+
if (entry.name === '.meta' && fullPath !== normalized) {
|
|
1864
|
+
// Found a child .meta — check it has meta.json
|
|
1865
|
+
if (existsSync(join(fullPath, 'meta.json'))) {
|
|
1866
|
+
children.push({
|
|
1867
|
+
metaPath: fullPath,
|
|
1868
|
+
ownerPath: normalizePath(dirname(fullPath)),
|
|
1869
|
+
treeDepth: 1, // Relative to target
|
|
1870
|
+
children: [],
|
|
1871
|
+
parent: null, // Set below
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
// Don't recurse into .meta dirs
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
if (entry.name === 'node_modules' ||
|
|
1878
|
+
entry.name === '.git' ||
|
|
1879
|
+
entry.name === 'archive')
|
|
1880
|
+
continue;
|
|
1881
|
+
findChildMetas(fullPath, depth + 1);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
catch {
|
|
1885
|
+
// Permission errors, etc — skip
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
findChildMetas(ownerPath, 0);
|
|
1889
|
+
const node = {
|
|
1890
|
+
metaPath: normalized,
|
|
1891
|
+
ownerPath,
|
|
1892
|
+
treeDepth: 0,
|
|
1893
|
+
children,
|
|
1894
|
+
parent: null,
|
|
1895
|
+
};
|
|
1896
|
+
// Wire parent references
|
|
1897
|
+
for (const child of children) {
|
|
1898
|
+
child.parent = node;
|
|
1899
|
+
}
|
|
1900
|
+
return node;
|
|
1901
|
+
}
|
|
1902
|
+
/** Run the architect/builder/critic pipeline on a single node. */
|
|
1903
|
+
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
|
|
1904
|
+
const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
|
|
1905
|
+
const criticPrompt = currentMeta._critic ?? config.defaultCritic;
|
|
1906
|
+
// Step 5-6: Steer change detection
|
|
1907
|
+
const latestArchive = readLatestArchive(node.metaPath);
|
|
1908
|
+
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
1909
|
+
// Step 7: Compute context (includes scope files and delta files)
|
|
1910
|
+
const ctx = buildContextPackage(node, currentMeta);
|
|
1911
|
+
// Step 5 (deferred): Structure hash from context scope files
|
|
1912
|
+
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
1913
|
+
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
1914
|
+
// Step 8: Architect (conditional)
|
|
1915
|
+
const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
|
|
1916
|
+
let builderBrief = currentMeta._builder ?? '';
|
|
1917
|
+
let synthesisCount = currentMeta._synthesisCount ?? 0;
|
|
1918
|
+
let stepError = null;
|
|
1919
|
+
let architectTokens;
|
|
1920
|
+
let builderTokens;
|
|
1921
|
+
let criticTokens;
|
|
1922
|
+
if (architectTriggered) {
|
|
1923
|
+
try {
|
|
1924
|
+
await onProgress?.({
|
|
1925
|
+
type: 'phase_start',
|
|
1926
|
+
path: node.ownerPath,
|
|
1927
|
+
phase: 'architect',
|
|
1928
|
+
});
|
|
1929
|
+
const phaseStart = Date.now();
|
|
1930
|
+
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
1931
|
+
const architectResult = await executor.spawn(architectTask, {
|
|
1932
|
+
thinking: config.thinking,
|
|
1933
|
+
timeout: config.architectTimeout,
|
|
1934
|
+
});
|
|
1935
|
+
builderBrief = parseArchitectOutput(architectResult.output);
|
|
1936
|
+
architectTokens = architectResult.tokens;
|
|
1937
|
+
synthesisCount = 0;
|
|
1938
|
+
await onProgress?.({
|
|
1939
|
+
type: 'phase_complete',
|
|
1940
|
+
path: node.ownerPath,
|
|
1941
|
+
phase: 'architect',
|
|
1942
|
+
tokens: architectTokens,
|
|
1943
|
+
durationMs: Date.now() - phaseStart,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
catch (err) {
|
|
1947
|
+
stepError = toMetaError('architect', err);
|
|
1948
|
+
if (!currentMeta._builder) {
|
|
1949
|
+
// No cached builder — cycle fails
|
|
1950
|
+
finalizeCycle({
|
|
1951
|
+
metaPath: node.metaPath,
|
|
1952
|
+
current: currentMeta,
|
|
1953
|
+
config,
|
|
1954
|
+
architect: architectPrompt,
|
|
1955
|
+
builder: '',
|
|
1956
|
+
critic: criticPrompt,
|
|
1957
|
+
builderOutput: null,
|
|
1958
|
+
feedback: null,
|
|
1959
|
+
structureHash: newStructureHash,
|
|
1960
|
+
synthesisCount,
|
|
1961
|
+
error: stepError,
|
|
1962
|
+
architectTokens,
|
|
1963
|
+
});
|
|
1964
|
+
return {
|
|
1965
|
+
synthesized: true,
|
|
1966
|
+
metaPath: node.metaPath,
|
|
1967
|
+
error: stepError,
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
// Has cached builder — continue with existing
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
// Step 9: Builder
|
|
1974
|
+
const metaForBuilder = { ...currentMeta, _builder: builderBrief };
|
|
1975
|
+
let builderOutput = null;
|
|
1976
|
+
try {
|
|
1977
|
+
await onProgress?.({
|
|
1978
|
+
type: 'phase_start',
|
|
1979
|
+
path: node.ownerPath,
|
|
1980
|
+
phase: 'builder',
|
|
1981
|
+
});
|
|
1982
|
+
const builderStart = Date.now();
|
|
1983
|
+
const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
|
|
1984
|
+
const builderResult = await executor.spawn(builderTask, {
|
|
1985
|
+
thinking: config.thinking,
|
|
1986
|
+
timeout: config.builderTimeout,
|
|
1987
|
+
});
|
|
1988
|
+
builderOutput = parseBuilderOutput(builderResult.output);
|
|
1989
|
+
builderTokens = builderResult.tokens;
|
|
1990
|
+
synthesisCount++;
|
|
1991
|
+
await onProgress?.({
|
|
1992
|
+
type: 'phase_complete',
|
|
1993
|
+
path: node.ownerPath,
|
|
1994
|
+
phase: 'builder',
|
|
1995
|
+
tokens: builderTokens,
|
|
1996
|
+
durationMs: Date.now() - builderStart,
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
catch (err) {
|
|
2000
|
+
stepError = toMetaError('builder', err);
|
|
2001
|
+
return { synthesized: true, metaPath: node.metaPath, error: stepError };
|
|
2002
|
+
}
|
|
2003
|
+
// Step 10: Critic
|
|
2004
|
+
const metaForCritic = {
|
|
2005
|
+
...currentMeta,
|
|
2006
|
+
_content: builderOutput.content,
|
|
2007
|
+
};
|
|
2008
|
+
let feedback = null;
|
|
2009
|
+
try {
|
|
2010
|
+
await onProgress?.({
|
|
2011
|
+
type: 'phase_start',
|
|
2012
|
+
path: node.ownerPath,
|
|
2013
|
+
phase: 'critic',
|
|
2014
|
+
});
|
|
2015
|
+
const criticStart = Date.now();
|
|
2016
|
+
const criticTask = buildCriticTask(ctx, metaForCritic, config);
|
|
2017
|
+
const criticResult = await executor.spawn(criticTask, {
|
|
2018
|
+
thinking: config.thinking,
|
|
2019
|
+
timeout: config.criticTimeout,
|
|
2020
|
+
});
|
|
2021
|
+
feedback = parseCriticOutput(criticResult.output);
|
|
2022
|
+
criticTokens = criticResult.tokens;
|
|
2023
|
+
stepError = null; // Clear any architect error on full success
|
|
2024
|
+
await onProgress?.({
|
|
2025
|
+
type: 'phase_complete',
|
|
2026
|
+
path: node.ownerPath,
|
|
2027
|
+
phase: 'critic',
|
|
2028
|
+
tokens: criticTokens,
|
|
2029
|
+
durationMs: Date.now() - criticStart,
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
catch (err) {
|
|
2033
|
+
stepError = stepError ?? toMetaError('critic', err);
|
|
2034
|
+
}
|
|
2035
|
+
// Steps 11-12: Merge, archive, prune
|
|
2036
|
+
finalizeCycle({
|
|
2037
|
+
metaPath: node.metaPath,
|
|
2038
|
+
current: currentMeta,
|
|
2039
|
+
config,
|
|
2040
|
+
architect: architectPrompt,
|
|
2041
|
+
builder: builderBrief,
|
|
2042
|
+
critic: criticPrompt,
|
|
2043
|
+
builderOutput,
|
|
2044
|
+
feedback,
|
|
2045
|
+
structureHash: newStructureHash,
|
|
2046
|
+
synthesisCount,
|
|
2047
|
+
error: stepError,
|
|
2048
|
+
architectTokens,
|
|
2049
|
+
builderTokens,
|
|
2050
|
+
criticTokens,
|
|
2051
|
+
});
|
|
2052
|
+
return {
|
|
2053
|
+
synthesized: true,
|
|
2054
|
+
metaPath: node.metaPath,
|
|
2055
|
+
error: stepError ?? undefined,
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
|
|
2059
|
+
// When targetPath is provided, skip the expensive full discovery scan.
|
|
2060
|
+
// Build a minimal node from the filesystem instead.
|
|
2061
|
+
if (targetPath) {
|
|
2062
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
2063
|
+
const targetMetaJson = join(normalizedTarget, 'meta.json');
|
|
2064
|
+
if (!existsSync(targetMetaJson))
|
|
2065
|
+
return { synthesized: false };
|
|
2066
|
+
const node = buildMinimalNode(normalizedTarget);
|
|
2067
|
+
if (!acquireLock(node.metaPath))
|
|
2068
|
+
return { synthesized: false };
|
|
2069
|
+
try {
|
|
2070
|
+
const currentMeta = JSON.parse(readFileSync(targetMetaJson, 'utf8'));
|
|
2071
|
+
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2072
|
+
}
|
|
2073
|
+
finally {
|
|
2074
|
+
releaseLock(node.metaPath);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
const metaPaths = await discoverMetas(config, watcher, logger);
|
|
1671
2078
|
if (metaPaths.length === 0)
|
|
1672
2079
|
return { synthesized: false };
|
|
1673
2080
|
// Read meta.json for each discovered meta
|
|
@@ -1687,23 +2094,15 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
1687
2094
|
if (validPaths.length === 0)
|
|
1688
2095
|
return { synthesized: false };
|
|
1689
2096
|
const tree = buildOwnershipTree(validPaths);
|
|
1690
|
-
// If targetPath specified, skip candidate selection — go directly to that meta
|
|
1691
|
-
let targetNode;
|
|
1692
|
-
if (targetPath) {
|
|
1693
|
-
const normalized = normalizePath(targetPath);
|
|
1694
|
-
targetNode = findNode(tree, normalized) ?? undefined;
|
|
1695
|
-
if (!targetNode)
|
|
1696
|
-
return { synthesized: false };
|
|
1697
|
-
}
|
|
1698
2097
|
// Steps 3-4: Staleness check + candidate selection
|
|
1699
2098
|
const candidates = [];
|
|
1700
|
-
for (const
|
|
1701
|
-
const meta = metas.get(
|
|
2099
|
+
for (const treeNode of tree.nodes.values()) {
|
|
2100
|
+
const meta = metas.get(treeNode.metaPath);
|
|
1702
2101
|
if (!meta)
|
|
1703
|
-
continue;
|
|
2102
|
+
continue;
|
|
1704
2103
|
const staleness = actualStaleness(meta);
|
|
1705
2104
|
if (staleness > 0) {
|
|
1706
|
-
candidates.push({ node, meta, actualStaleness: staleness });
|
|
2105
|
+
candidates.push({ node: treeNode, meta, actualStaleness: staleness });
|
|
1707
2106
|
}
|
|
1708
2107
|
}
|
|
1709
2108
|
const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
|
|
@@ -1716,7 +2115,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
1716
2115
|
for (const candidate of ranked) {
|
|
1717
2116
|
if (!acquireLock(candidate.node.metaPath))
|
|
1718
2117
|
continue;
|
|
1719
|
-
const verifiedStale =
|
|
2118
|
+
const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
|
|
1720
2119
|
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
1721
2120
|
// Bump _generatedAt so it doesn't win next cycle
|
|
1722
2121
|
const metaFilePath = join(candidate.node.metaPath, 'meta.json');
|
|
@@ -1731,169 +2130,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
1731
2130
|
winner = candidate;
|
|
1732
2131
|
break;
|
|
1733
2132
|
}
|
|
1734
|
-
if (!winner
|
|
1735
|
-
return { synthesized: false };
|
|
1736
|
-
const node = targetNode ?? winner.node;
|
|
1737
|
-
// For targeted path, acquire lock now (candidate selection already locked for stalest)
|
|
1738
|
-
if (targetNode && !acquireLock(node.metaPath)) {
|
|
2133
|
+
if (!winner)
|
|
1739
2134
|
return { synthesized: false };
|
|
1740
|
-
|
|
2135
|
+
const node = winner.node;
|
|
1741
2136
|
try {
|
|
1742
|
-
// Re-read meta after lock (may have changed)
|
|
1743
2137
|
const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
|
|
1744
|
-
|
|
1745
|
-
const criticPrompt = currentMeta._critic ?? config.defaultCritic;
|
|
1746
|
-
// Step 5-6: Steer change detection
|
|
1747
|
-
const latestArchive = readLatestArchive(node.metaPath);
|
|
1748
|
-
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
1749
|
-
// Step 7: Compute context (includes scope files and delta files)
|
|
1750
|
-
const ctx = await buildContextPackage(node, currentMeta, watcher);
|
|
1751
|
-
// Step 5 (deferred): Structure hash from context scope files
|
|
1752
|
-
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
1753
|
-
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
1754
|
-
// Step 8: Architect (conditional)
|
|
1755
|
-
const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
|
|
1756
|
-
let builderBrief = currentMeta._builder ?? '';
|
|
1757
|
-
let synthesisCount = currentMeta._synthesisCount ?? 0;
|
|
1758
|
-
let stepError = null;
|
|
1759
|
-
let architectTokens;
|
|
1760
|
-
let builderTokens;
|
|
1761
|
-
let criticTokens;
|
|
1762
|
-
if (architectTriggered) {
|
|
1763
|
-
try {
|
|
1764
|
-
await onProgress?.({
|
|
1765
|
-
type: 'phase_start',
|
|
1766
|
-
metaPath: node.metaPath,
|
|
1767
|
-
phase: 'architect',
|
|
1768
|
-
});
|
|
1769
|
-
const phaseStart = Date.now();
|
|
1770
|
-
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
1771
|
-
const architectResult = await executor.spawn(architectTask, {
|
|
1772
|
-
thinking: config.thinking,
|
|
1773
|
-
timeout: config.architectTimeout,
|
|
1774
|
-
});
|
|
1775
|
-
builderBrief = parseArchitectOutput(architectResult.output);
|
|
1776
|
-
architectTokens = architectResult.tokens;
|
|
1777
|
-
synthesisCount = 0;
|
|
1778
|
-
await onProgress?.({
|
|
1779
|
-
type: 'phase_complete',
|
|
1780
|
-
metaPath: node.metaPath,
|
|
1781
|
-
phase: 'architect',
|
|
1782
|
-
tokens: architectTokens,
|
|
1783
|
-
durationMs: Date.now() - phaseStart,
|
|
1784
|
-
});
|
|
1785
|
-
}
|
|
1786
|
-
catch (err) {
|
|
1787
|
-
stepError = toMetaError('architect', err);
|
|
1788
|
-
if (!currentMeta._builder) {
|
|
1789
|
-
// No cached builder — cycle fails
|
|
1790
|
-
finalizeCycle({
|
|
1791
|
-
metaPath: node.metaPath,
|
|
1792
|
-
current: currentMeta,
|
|
1793
|
-
config,
|
|
1794
|
-
architect: architectPrompt,
|
|
1795
|
-
builder: '',
|
|
1796
|
-
critic: criticPrompt,
|
|
1797
|
-
builderOutput: null,
|
|
1798
|
-
feedback: null,
|
|
1799
|
-
structureHash: newStructureHash,
|
|
1800
|
-
synthesisCount,
|
|
1801
|
-
error: stepError,
|
|
1802
|
-
architectTokens,
|
|
1803
|
-
});
|
|
1804
|
-
return {
|
|
1805
|
-
synthesized: true,
|
|
1806
|
-
metaPath: node.metaPath,
|
|
1807
|
-
error: stepError,
|
|
1808
|
-
};
|
|
1809
|
-
}
|
|
1810
|
-
// Has cached builder — continue with existing
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
// Step 9: Builder
|
|
1814
|
-
const metaForBuilder = { ...currentMeta, _builder: builderBrief };
|
|
1815
|
-
let builderOutput = null;
|
|
1816
|
-
try {
|
|
1817
|
-
await onProgress?.({
|
|
1818
|
-
type: 'phase_start',
|
|
1819
|
-
metaPath: node.metaPath,
|
|
1820
|
-
phase: 'builder',
|
|
1821
|
-
});
|
|
1822
|
-
const builderStart = Date.now();
|
|
1823
|
-
const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
|
|
1824
|
-
const builderResult = await executor.spawn(builderTask, {
|
|
1825
|
-
thinking: config.thinking,
|
|
1826
|
-
timeout: config.builderTimeout,
|
|
1827
|
-
});
|
|
1828
|
-
builderOutput = parseBuilderOutput(builderResult.output);
|
|
1829
|
-
builderTokens = builderResult.tokens;
|
|
1830
|
-
synthesisCount++;
|
|
1831
|
-
await onProgress?.({
|
|
1832
|
-
type: 'phase_complete',
|
|
1833
|
-
metaPath: node.metaPath,
|
|
1834
|
-
phase: 'builder',
|
|
1835
|
-
tokens: builderTokens,
|
|
1836
|
-
durationMs: Date.now() - builderStart,
|
|
1837
|
-
});
|
|
1838
|
-
}
|
|
1839
|
-
catch (err) {
|
|
1840
|
-
stepError = toMetaError('builder', err);
|
|
1841
|
-
return { synthesized: true, metaPath: node.metaPath, error: stepError };
|
|
1842
|
-
}
|
|
1843
|
-
// Step 10: Critic
|
|
1844
|
-
const metaForCritic = {
|
|
1845
|
-
...currentMeta,
|
|
1846
|
-
_content: builderOutput.content,
|
|
1847
|
-
};
|
|
1848
|
-
let feedback = null;
|
|
1849
|
-
try {
|
|
1850
|
-
await onProgress?.({
|
|
1851
|
-
type: 'phase_start',
|
|
1852
|
-
metaPath: node.metaPath,
|
|
1853
|
-
phase: 'critic',
|
|
1854
|
-
});
|
|
1855
|
-
const criticStart = Date.now();
|
|
1856
|
-
const criticTask = buildCriticTask(ctx, metaForCritic, config);
|
|
1857
|
-
const criticResult = await executor.spawn(criticTask, {
|
|
1858
|
-
thinking: config.thinking,
|
|
1859
|
-
timeout: config.criticTimeout,
|
|
1860
|
-
});
|
|
1861
|
-
feedback = parseCriticOutput(criticResult.output);
|
|
1862
|
-
criticTokens = criticResult.tokens;
|
|
1863
|
-
stepError = null; // Clear any architect error on full success
|
|
1864
|
-
await onProgress?.({
|
|
1865
|
-
type: 'phase_complete',
|
|
1866
|
-
metaPath: node.metaPath,
|
|
1867
|
-
phase: 'critic',
|
|
1868
|
-
tokens: criticTokens,
|
|
1869
|
-
durationMs: Date.now() - criticStart,
|
|
1870
|
-
});
|
|
1871
|
-
}
|
|
1872
|
-
catch (err) {
|
|
1873
|
-
stepError = stepError ?? toMetaError('critic', err);
|
|
1874
|
-
}
|
|
1875
|
-
// Steps 11-12: Merge, archive, prune
|
|
1876
|
-
finalizeCycle({
|
|
1877
|
-
metaPath: node.metaPath,
|
|
1878
|
-
current: currentMeta,
|
|
1879
|
-
config,
|
|
1880
|
-
architect: architectPrompt,
|
|
1881
|
-
builder: builderBrief,
|
|
1882
|
-
critic: criticPrompt,
|
|
1883
|
-
builderOutput,
|
|
1884
|
-
feedback,
|
|
1885
|
-
structureHash: newStructureHash,
|
|
1886
|
-
synthesisCount,
|
|
1887
|
-
error: stepError,
|
|
1888
|
-
architectTokens,
|
|
1889
|
-
builderTokens,
|
|
1890
|
-
criticTokens,
|
|
1891
|
-
});
|
|
1892
|
-
return {
|
|
1893
|
-
synthesized: true,
|
|
1894
|
-
metaPath: node.metaPath,
|
|
1895
|
-
error: stepError ?? undefined,
|
|
1896
|
-
};
|
|
2138
|
+
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
1897
2139
|
}
|
|
1898
2140
|
finally {
|
|
1899
2141
|
// Step 13: Release lock
|
|
@@ -1912,8 +2154,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
1912
2154
|
* @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
|
|
1913
2155
|
* @returns Array with a single result.
|
|
1914
2156
|
*/
|
|
1915
|
-
async function orchestrate(config, executor, watcher, targetPath, onProgress) {
|
|
1916
|
-
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
|
|
2157
|
+
async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
|
|
2158
|
+
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
|
|
1917
2159
|
return [result];
|
|
1918
2160
|
}
|
|
1919
2161
|
|
|
@@ -1922,9 +2164,12 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress) {
|
|
|
1922
2164
|
*
|
|
1923
2165
|
* @module progress
|
|
1924
2166
|
*/
|
|
2167
|
+
function formatNumber(n) {
|
|
2168
|
+
return n.toLocaleString('en-US');
|
|
2169
|
+
}
|
|
1925
2170
|
function formatSeconds(durationMs) {
|
|
1926
2171
|
const seconds = durationMs / 1000;
|
|
1927
|
-
return seconds.
|
|
2172
|
+
return Math.round(seconds).toString() + 's';
|
|
1928
2173
|
}
|
|
1929
2174
|
function titleCasePhase(phase) {
|
|
1930
2175
|
return phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
@@ -1932,32 +2177,30 @@ function titleCasePhase(phase) {
|
|
|
1932
2177
|
function formatProgressEvent(event) {
|
|
1933
2178
|
switch (event.type) {
|
|
1934
2179
|
case 'synthesis_start':
|
|
1935
|
-
return `🔬 Started meta synthesis: ${event.
|
|
2180
|
+
return `🔬 Started meta synthesis: ${event.path}`;
|
|
1936
2181
|
case 'phase_start': {
|
|
1937
2182
|
if (!event.phase) {
|
|
1938
|
-
return
|
|
2183
|
+
return ' ⚙️ Phase started';
|
|
1939
2184
|
}
|
|
1940
2185
|
return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
|
|
1941
2186
|
}
|
|
1942
2187
|
case 'phase_complete': {
|
|
1943
2188
|
const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
|
|
1944
2189
|
const tokens = event.tokens ?? 0;
|
|
1945
|
-
const duration = event.durationMs !== undefined
|
|
1946
|
-
|
|
1947
|
-
: '0.0s';
|
|
1948
|
-
return ` ✅ ${phase} phase complete (${String(tokens)} tokens / ${duration})`;
|
|
2190
|
+
const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
|
|
2191
|
+
return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
|
|
1949
2192
|
}
|
|
1950
2193
|
case 'synthesis_complete': {
|
|
1951
2194
|
const tokens = event.tokens ?? 0;
|
|
1952
2195
|
const duration = event.durationMs !== undefined
|
|
1953
2196
|
? formatSeconds(event.durationMs)
|
|
1954
2197
|
: '0.0s';
|
|
1955
|
-
return `✅ Completed: ${event.
|
|
2198
|
+
return `✅ Completed: ${event.path} (${formatNumber(tokens)} tokens / ${duration})`;
|
|
1956
2199
|
}
|
|
1957
2200
|
case 'error': {
|
|
1958
2201
|
const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
|
|
1959
2202
|
const error = event.error ?? 'Unknown error';
|
|
1960
|
-
return `❌ Synthesis failed at ${phase}phase: ${event.
|
|
2203
|
+
return `❌ Synthesis failed at ${phase}phase: ${event.path}\n Error: ${error}`;
|
|
1961
2204
|
}
|
|
1962
2205
|
default: {
|
|
1963
2206
|
return 'Unknown progress event';
|
|
@@ -2135,7 +2378,7 @@ class Scheduler {
|
|
|
2135
2378
|
*/
|
|
2136
2379
|
async discoverStalest() {
|
|
2137
2380
|
try {
|
|
2138
|
-
const result = await listMetas(this.config, this.watcher);
|
|
2381
|
+
const result = await listMetas(this.config, this.watcher, this.logger);
|
|
2139
2382
|
const stale = result.entries
|
|
2140
2383
|
.filter((e) => e.stalenessSeconds > 0)
|
|
2141
2384
|
.map((e) => ({
|
|
@@ -2421,7 +2664,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2421
2664
|
app.get('/metas', async (request) => {
|
|
2422
2665
|
const query = metasQuerySchema.parse(request.query);
|
|
2423
2666
|
const { config, watcher } = deps;
|
|
2424
|
-
const result = await listMetas(config, watcher);
|
|
2667
|
+
const result = await listMetas(config, watcher, request.log);
|
|
2425
2668
|
let entries = result.entries;
|
|
2426
2669
|
// Apply filters
|
|
2427
2670
|
if (query.pathPrefix) {
|
|
@@ -2484,7 +2727,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2484
2727
|
const query = metaDetailQuerySchema.parse(request.query);
|
|
2485
2728
|
const { config, watcher } = deps;
|
|
2486
2729
|
const targetPath = normalizePath(decodeURIComponent(request.params.path));
|
|
2487
|
-
const result = await listMetas(config, watcher);
|
|
2730
|
+
const result = await listMetas(config, watcher, request.log);
|
|
2488
2731
|
const targetNode = findNode(result.tree, targetPath);
|
|
2489
2732
|
if (!targetNode) {
|
|
2490
2733
|
return reply.status(404).send({
|
|
@@ -2517,7 +2760,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2517
2760
|
return r;
|
|
2518
2761
|
};
|
|
2519
2762
|
// Compute scope
|
|
2520
|
-
const { scopeFiles, allFiles } =
|
|
2763
|
+
const { scopeFiles, allFiles } = getScopeFiles(targetNode);
|
|
2521
2764
|
// Compute staleness
|
|
2522
2765
|
const metaTyped = meta;
|
|
2523
2766
|
const staleSeconds = metaTyped._generatedAt
|
|
@@ -2564,7 +2807,7 @@ function registerPreviewRoute(app, deps) {
|
|
|
2564
2807
|
const query = request.query;
|
|
2565
2808
|
let result;
|
|
2566
2809
|
try {
|
|
2567
|
-
result = await listMetas(config, watcher);
|
|
2810
|
+
result = await listMetas(config, watcher, request.log);
|
|
2568
2811
|
}
|
|
2569
2812
|
catch {
|
|
2570
2813
|
return reply.status(503).send({
|
|
@@ -2600,14 +2843,14 @@ function registerPreviewRoute(app, deps) {
|
|
|
2600
2843
|
}
|
|
2601
2844
|
const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
|
|
2602
2845
|
// Scope files
|
|
2603
|
-
const { scopeFiles } =
|
|
2846
|
+
const { scopeFiles } = getScopeFiles(targetNode);
|
|
2604
2847
|
const structureHash = computeStructureHash(scopeFiles);
|
|
2605
2848
|
const structureChanged = structureHash !== meta._structureHash;
|
|
2606
2849
|
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
2607
2850
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
2608
2851
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
2609
2852
|
// Delta files
|
|
2610
|
-
const deltaFiles =
|
|
2853
|
+
const deltaFiles = getDeltaFiles(targetNode, meta._generatedAt, scopeFiles);
|
|
2611
2854
|
// EMA token estimates
|
|
2612
2855
|
const estimatedTokens = {
|
|
2613
2856
|
architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
|
|
@@ -2701,7 +2944,7 @@ async function checkDependency(url, path) {
|
|
|
2701
2944
|
}
|
|
2702
2945
|
function registerStatusRoute(app, deps) {
|
|
2703
2946
|
app.get('/status', async () => {
|
|
2704
|
-
const { config, queue, scheduler, stats
|
|
2947
|
+
const { config, queue, scheduler, stats } = deps;
|
|
2705
2948
|
// On-demand dependency checks
|
|
2706
2949
|
const [watcherHealth, gatewayHealth] = await Promise.all([
|
|
2707
2950
|
checkDependency(config.watcherUrl, '/status'),
|
|
@@ -2722,23 +2965,11 @@ function registerStatusRoute(app, deps) {
|
|
|
2722
2965
|
else {
|
|
2723
2966
|
status = 'idle';
|
|
2724
2967
|
}
|
|
2725
|
-
// Metas summary
|
|
2726
|
-
|
|
2727
|
-
try {
|
|
2728
|
-
const result = await listMetas(config, watcher);
|
|
2729
|
-
metasSummary = {
|
|
2730
|
-
total: result.summary.total,
|
|
2731
|
-
stale: result.summary.stale,
|
|
2732
|
-
errors: result.summary.errors,
|
|
2733
|
-
neverSynthesized: result.summary.neverSynthesized,
|
|
2734
|
-
};
|
|
2735
|
-
}
|
|
2736
|
-
catch {
|
|
2737
|
-
// Watcher unreachable — leave zeros
|
|
2738
|
-
}
|
|
2968
|
+
// Metas summary is expensive (paginated watcher scan + disk reads).
|
|
2969
|
+
// Use GET /metas for full inventory; status is a lightweight health check.
|
|
2739
2970
|
return {
|
|
2740
|
-
service:
|
|
2741
|
-
version:
|
|
2971
|
+
service: SERVICE_NAME,
|
|
2972
|
+
version: SERVICE_VERSION,
|
|
2742
2973
|
uptime: process.uptime(),
|
|
2743
2974
|
status,
|
|
2744
2975
|
currentTarget: queue.current?.path ?? null,
|
|
@@ -2758,7 +2989,6 @@ function registerStatusRoute(app, deps) {
|
|
|
2758
2989
|
watcher: watcherHealth,
|
|
2759
2990
|
gateway: gatewayHealth,
|
|
2760
2991
|
},
|
|
2761
|
-
metas: metasSummary,
|
|
2762
2992
|
};
|
|
2763
2993
|
});
|
|
2764
2994
|
}
|
|
@@ -2784,7 +3014,7 @@ function registerSynthesizeRoute(app, deps) {
|
|
|
2784
3014
|
// Discover stalest candidate
|
|
2785
3015
|
let result;
|
|
2786
3016
|
try {
|
|
2787
|
-
result = await listMetas(config, watcher);
|
|
3017
|
+
result = await listMetas(config, watcher, request.log);
|
|
2788
3018
|
}
|
|
2789
3019
|
catch {
|
|
2790
3020
|
return reply.status(503).send({
|
|
@@ -2945,6 +3175,10 @@ function buildMetaRules(config) {
|
|
|
2945
3175
|
type: 'string',
|
|
2946
3176
|
set: '{{json._error.step}}',
|
|
2947
3177
|
},
|
|
3178
|
+
generated_at: {
|
|
3179
|
+
type: 'string',
|
|
3180
|
+
set: '{{json._generatedAt}}',
|
|
3181
|
+
},
|
|
2948
3182
|
generated_at_unix: {
|
|
2949
3183
|
type: 'integer',
|
|
2950
3184
|
set: '{{toUnix json._generatedAt}}',
|
|
@@ -2957,16 +3191,7 @@ function buildMetaRules(config) {
|
|
|
2957
3191
|
},
|
|
2958
3192
|
],
|
|
2959
3193
|
render: {
|
|
2960
|
-
frontmatter: [
|
|
2961
|
-
'meta_id',
|
|
2962
|
-
'meta_steer',
|
|
2963
|
-
'generated_at_unix',
|
|
2964
|
-
'meta_depth',
|
|
2965
|
-
'meta_emphasis',
|
|
2966
|
-
'meta_architect_tokens',
|
|
2967
|
-
'meta_builder_tokens',
|
|
2968
|
-
'meta_critic_tokens',
|
|
2969
|
-
],
|
|
3194
|
+
frontmatter: ['meta_id', 'generated_at', '*', '!_*', '!has_error'],
|
|
2970
3195
|
body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
|
|
2971
3196
|
},
|
|
2972
3197
|
renderAs: 'md',
|
|
@@ -3116,7 +3341,10 @@ class RuleRegistrar {
|
|
|
3116
3341
|
* @returns Configured Fastify instance (not yet listening).
|
|
3117
3342
|
*/
|
|
3118
3343
|
function createServer(options) {
|
|
3119
|
-
|
|
3344
|
+
// Fastify 5 requires `loggerInstance` for external pino loggers
|
|
3345
|
+
const app = Fastify({
|
|
3346
|
+
loggerInstance: options.logger,
|
|
3347
|
+
});
|
|
3120
3348
|
registerRoutes(app, {
|
|
3121
3349
|
config: options.config,
|
|
3122
3350
|
logger: options.logger,
|
|
@@ -3197,6 +3425,7 @@ function registerShutdownHandlers(deps) {
|
|
|
3197
3425
|
const DEFAULT_MAX_RETRIES = 3;
|
|
3198
3426
|
const DEFAULT_BACKOFF_BASE_MS = 1000;
|
|
3199
3427
|
const DEFAULT_BACKOFF_FACTOR = 4;
|
|
3428
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
3200
3429
|
/** Check if an error is transient (worth retrying). */
|
|
3201
3430
|
function isTransient(status) {
|
|
3202
3431
|
return status >= 500 || status === 408 || status === 429;
|
|
@@ -3209,11 +3438,13 @@ class HttpWatcherClient {
|
|
|
3209
3438
|
maxRetries;
|
|
3210
3439
|
backoffBaseMs;
|
|
3211
3440
|
backoffFactor;
|
|
3441
|
+
timeoutMs;
|
|
3212
3442
|
constructor(options) {
|
|
3213
3443
|
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
3214
3444
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
3215
3445
|
this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
|
|
3216
3446
|
this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
|
|
3447
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
3217
3448
|
}
|
|
3218
3449
|
/** POST JSON with retry. */
|
|
3219
3450
|
async post(endpoint, body) {
|
|
@@ -3223,6 +3454,7 @@ class HttpWatcherClient {
|
|
|
3223
3454
|
method: 'POST',
|
|
3224
3455
|
headers: { 'Content-Type': 'application/json' },
|
|
3225
3456
|
body: JSON.stringify(body),
|
|
3457
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
3226
3458
|
});
|
|
3227
3459
|
if (res.ok) {
|
|
3228
3460
|
return res.json();
|
|
@@ -3363,9 +3595,11 @@ async function startService(config, configPath) {
|
|
|
3363
3595
|
const synthesizeFn = async (path) => {
|
|
3364
3596
|
const startMs = Date.now();
|
|
3365
3597
|
let cycleTokens = 0;
|
|
3598
|
+
// Strip .meta suffix for human-readable progress reporting
|
|
3599
|
+
const ownerPath = path.replace(/\/?\.meta\/?$/, '');
|
|
3366
3600
|
await progress.report({
|
|
3367
3601
|
type: 'synthesis_start',
|
|
3368
|
-
|
|
3602
|
+
path: ownerPath,
|
|
3369
3603
|
});
|
|
3370
3604
|
try {
|
|
3371
3605
|
const results = await orchestrate(config, executor, watcher, path, async (evt) => {
|
|
@@ -3387,7 +3621,7 @@ async function startService(config, configPath) {
|
|
|
3387
3621
|
stats.totalErrors++;
|
|
3388
3622
|
await progress.report({
|
|
3389
3623
|
type: 'error',
|
|
3390
|
-
|
|
3624
|
+
path: ownerPath,
|
|
3391
3625
|
error: result.error.message,
|
|
3392
3626
|
});
|
|
3393
3627
|
}
|
|
@@ -3395,7 +3629,7 @@ async function startService(config, configPath) {
|
|
|
3395
3629
|
scheduler.resetBackoff();
|
|
3396
3630
|
await progress.report({
|
|
3397
3631
|
type: 'synthesis_complete',
|
|
3398
|
-
|
|
3632
|
+
path: ownerPath,
|
|
3399
3633
|
tokens: cycleTokens,
|
|
3400
3634
|
durationMs,
|
|
3401
3635
|
});
|
|
@@ -3406,7 +3640,7 @@ async function startService(config, configPath) {
|
|
|
3406
3640
|
const message = err instanceof Error ? err.message : String(err);
|
|
3407
3641
|
await progress.report({
|
|
3408
3642
|
type: 'error',
|
|
3409
|
-
|
|
3643
|
+
path: ownerPath,
|
|
3410
3644
|
error: message,
|
|
3411
3645
|
});
|
|
3412
3646
|
throw err;
|
|
@@ -3474,7 +3708,7 @@ async function startService(config, configPath) {
|
|
|
3474
3708
|
* @module cli
|
|
3475
3709
|
*/
|
|
3476
3710
|
const program = new Command();
|
|
3477
|
-
program.name(
|
|
3711
|
+
program.name(SERVICE_NAME).description('Jeeves Meta synthesis service');
|
|
3478
3712
|
// ─── start ──────────────────────────────────────────────────────────
|
|
3479
3713
|
program
|
|
3480
3714
|
.command('start')
|
|
@@ -3513,7 +3747,7 @@ async function apiPost(port, path, body) {
|
|
|
3513
3747
|
program
|
|
3514
3748
|
.command('status')
|
|
3515
3749
|
.description('Show service status')
|
|
3516
|
-
.option('-p, --port <port>', 'Service port',
|
|
3750
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3517
3751
|
.action(async (opts) => {
|
|
3518
3752
|
try {
|
|
3519
3753
|
const data = await apiGet(parseInt(opts.port, 10), '/status');
|
|
@@ -3528,7 +3762,7 @@ program
|
|
|
3528
3762
|
program
|
|
3529
3763
|
.command('list')
|
|
3530
3764
|
.description('List all discovered meta entities')
|
|
3531
|
-
.option('-p, --port <port>', 'Service port',
|
|
3765
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3532
3766
|
.action(async (opts) => {
|
|
3533
3767
|
try {
|
|
3534
3768
|
const data = await apiGet(parseInt(opts.port, 10), '/metas');
|
|
@@ -3543,7 +3777,7 @@ program
|
|
|
3543
3777
|
program
|
|
3544
3778
|
.command('detail <path>')
|
|
3545
3779
|
.description('Show full detail for a single meta entity')
|
|
3546
|
-
.option('-p, --port <port>', 'Service port',
|
|
3780
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3547
3781
|
.action(async (metaPath, opts) => {
|
|
3548
3782
|
try {
|
|
3549
3783
|
const encoded = encodeURIComponent(metaPath);
|
|
@@ -3559,7 +3793,7 @@ program
|
|
|
3559
3793
|
program
|
|
3560
3794
|
.command('preview')
|
|
3561
3795
|
.description('Dry-run: preview inputs for next synthesis cycle')
|
|
3562
|
-
.option('-p, --port <port>', 'Service port',
|
|
3796
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3563
3797
|
.option('--path <path>', 'Specific meta path to preview')
|
|
3564
3798
|
.action(async (opts) => {
|
|
3565
3799
|
try {
|
|
@@ -3576,7 +3810,7 @@ program
|
|
|
3576
3810
|
program
|
|
3577
3811
|
.command('synthesize')
|
|
3578
3812
|
.description('Trigger synthesis (enqueues work)')
|
|
3579
|
-
.option('-p, --port <port>', 'Service port',
|
|
3813
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3580
3814
|
.option('--path <path>', 'Specific meta path to synthesize')
|
|
3581
3815
|
.action(async (opts) => {
|
|
3582
3816
|
try {
|
|
@@ -3593,7 +3827,7 @@ program
|
|
|
3593
3827
|
program
|
|
3594
3828
|
.command('seed <path>')
|
|
3595
3829
|
.description('Create .meta/ directory + meta.json for a path')
|
|
3596
|
-
.option('-p, --port <port>', 'Service port',
|
|
3830
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3597
3831
|
.action(async (metaPath, opts) => {
|
|
3598
3832
|
try {
|
|
3599
3833
|
const data = await apiPost(parseInt(opts.port, 10), '/seed', {
|
|
@@ -3610,7 +3844,7 @@ program
|
|
|
3610
3844
|
program
|
|
3611
3845
|
.command('unlock <path>')
|
|
3612
3846
|
.description('Remove .lock file from a meta entity')
|
|
3613
|
-
.option('-p, --port <port>', 'Service port',
|
|
3847
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3614
3848
|
.action(async (metaPath, opts) => {
|
|
3615
3849
|
try {
|
|
3616
3850
|
const data = await apiPost(parseInt(opts.port, 10), '/unlock', {
|
|
@@ -3627,7 +3861,7 @@ program
|
|
|
3627
3861
|
program
|
|
3628
3862
|
.command('validate')
|
|
3629
3863
|
.description('Validate current or candidate config')
|
|
3630
|
-
.option('-p, --port <port>', 'Service port',
|
|
3864
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3631
3865
|
.option('-c, --config <path>', 'Validate a candidate config file locally')
|
|
3632
3866
|
.action(async (opts) => {
|
|
3633
3867
|
try {
|
|
@@ -3763,7 +3997,7 @@ service.addCommand(new Command('stop')
|
|
|
3763
3997
|
// status command (service subcommand — queries HTTP API)
|
|
3764
3998
|
service.addCommand(new Command('status')
|
|
3765
3999
|
.description('Show service status via HTTP API')
|
|
3766
|
-
.option('-p, --port <port>', 'Service port',
|
|
4000
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3767
4001
|
.action(async (opts) => {
|
|
3768
4002
|
try {
|
|
3769
4003
|
const data = await apiGet(parseInt(opts.port, 10), '/status');
|