@karmaniverous/jeeves-meta 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/index.d.ts +825 -0
- package/dist/index.js +1394 -0
- package/package.json +111 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1394 @@
|
|
|
1
|
+
import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, relative, sep } from 'node:path';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* List archive snapshot files in chronological order.
|
|
8
|
+
*
|
|
9
|
+
* @module archive/listArchive
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* List archive .json files sorted chronologically (oldest first).
|
|
13
|
+
*
|
|
14
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
15
|
+
* @returns Array of absolute paths to archive files, or empty if none.
|
|
16
|
+
*/
|
|
17
|
+
function listArchiveFiles(metaPath) {
|
|
18
|
+
const archiveDir = join(metaPath, 'archive');
|
|
19
|
+
try {
|
|
20
|
+
return readdirSync(archiveDir)
|
|
21
|
+
.filter((f) => f.endsWith('.json'))
|
|
22
|
+
.sort()
|
|
23
|
+
.map((f) => join(archiveDir, f));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Prune old archive snapshots beyond maxArchive.
|
|
32
|
+
*
|
|
33
|
+
* @module archive/prune
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Prune archive directory to keep at most maxArchive snapshots.
|
|
37
|
+
* Removes the oldest files.
|
|
38
|
+
*
|
|
39
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
40
|
+
* @param maxArchive - Maximum snapshots to retain.
|
|
41
|
+
* @returns Number of files pruned.
|
|
42
|
+
*/
|
|
43
|
+
function pruneArchive(metaPath, maxArchive) {
|
|
44
|
+
const files = listArchiveFiles(metaPath);
|
|
45
|
+
const toRemove = files.length - maxArchive;
|
|
46
|
+
if (toRemove <= 0)
|
|
47
|
+
return 0;
|
|
48
|
+
for (let i = 0; i < toRemove; i++) {
|
|
49
|
+
unlinkSync(files[i]);
|
|
50
|
+
}
|
|
51
|
+
return toRemove;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the latest archive snapshot for steer change detection.
|
|
56
|
+
*
|
|
57
|
+
* @module archive/readLatest
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* Read the most recent archive snapshot.
|
|
61
|
+
*
|
|
62
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
63
|
+
* @returns The latest archived meta, or null if no archives exist.
|
|
64
|
+
*/
|
|
65
|
+
function readLatestArchive(metaPath) {
|
|
66
|
+
const files = listArchiveFiles(metaPath);
|
|
67
|
+
if (files.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
const raw = readFileSync(files[files.length - 1], 'utf8');
|
|
70
|
+
return JSON.parse(raw);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create archive snapshots of meta.json.
|
|
75
|
+
*
|
|
76
|
+
* Copies current meta.json to archive/\{ISO-timestamp\}.json with
|
|
77
|
+
* _archived: true and _archivedAt added.
|
|
78
|
+
*
|
|
79
|
+
* @module archive/snapshot
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Create an archive snapshot of the current meta.json.
|
|
83
|
+
*
|
|
84
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
85
|
+
* @param meta - Current meta.json content.
|
|
86
|
+
* @returns The archive file path.
|
|
87
|
+
*/
|
|
88
|
+
function createSnapshot(metaPath, meta) {
|
|
89
|
+
const archiveDir = join(metaPath, 'archive');
|
|
90
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
91
|
+
const now = new Date().toISOString().replace(/[:.]/g, '-');
|
|
92
|
+
const archiveFile = join(archiveDir, now + '.json');
|
|
93
|
+
const archived = {
|
|
94
|
+
...meta,
|
|
95
|
+
_archived: true,
|
|
96
|
+
_archivedAt: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
writeFileSync(archiveFile, JSON.stringify(archived, null, 2) + '\n');
|
|
99
|
+
return archiveFile;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ensure meta.json exists in each .meta/ directory.
|
|
104
|
+
*
|
|
105
|
+
* If meta.json is missing, creates it with a generated UUID.
|
|
106
|
+
*
|
|
107
|
+
* @module discovery/ensureMetaJson
|
|
108
|
+
*/
|
|
109
|
+
/**
|
|
110
|
+
* Ensure meta.json exists at the given .meta/ path.
|
|
111
|
+
*
|
|
112
|
+
* @param metaPath - Absolute path to a .meta/ directory.
|
|
113
|
+
* @returns The meta.json content (existing or newly created).
|
|
114
|
+
*/
|
|
115
|
+
function ensureMetaJson(metaPath) {
|
|
116
|
+
const filePath = join(metaPath, 'meta.json');
|
|
117
|
+
if (existsSync(filePath)) {
|
|
118
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
119
|
+
return JSON.parse(raw);
|
|
120
|
+
}
|
|
121
|
+
// Create the archive subdirectory while we're at it
|
|
122
|
+
const archivePath = join(metaPath, 'archive');
|
|
123
|
+
if (!existsSync(archivePath)) {
|
|
124
|
+
mkdirSync(archivePath, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
const meta = { _id: randomUUID() };
|
|
127
|
+
writeFileSync(filePath, JSON.stringify(meta, null, 2) + '\n');
|
|
128
|
+
return meta;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Glob watchPaths for .meta/ directories.
|
|
133
|
+
*
|
|
134
|
+
* Walks each watchPath recursively, collecting directories named '.meta'
|
|
135
|
+
* that contain (or will contain) a meta.json file.
|
|
136
|
+
*
|
|
137
|
+
* @module discovery/globMetas
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* Recursively find all .meta/ directories under the given paths.
|
|
141
|
+
*
|
|
142
|
+
* @param watchPaths - Root directories to search.
|
|
143
|
+
* @returns Array of absolute paths to .meta/ directories.
|
|
144
|
+
*/
|
|
145
|
+
function globMetas(watchPaths) {
|
|
146
|
+
const results = [];
|
|
147
|
+
function walk(dir) {
|
|
148
|
+
let entries;
|
|
149
|
+
try {
|
|
150
|
+
entries = readdirSync(dir);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return; // Skip unreadable directories
|
|
154
|
+
}
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const full = join(dir, entry);
|
|
157
|
+
let stat;
|
|
158
|
+
try {
|
|
159
|
+
stat = statSync(full);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!stat.isDirectory())
|
|
165
|
+
continue;
|
|
166
|
+
if (entry === '.meta') {
|
|
167
|
+
results.push(full);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
walk(full);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const wp of watchPaths) {
|
|
175
|
+
walk(wp);
|
|
176
|
+
}
|
|
177
|
+
return results;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Build the ownership tree from discovered .meta/ paths.
|
|
182
|
+
*
|
|
183
|
+
* Each .meta/ directory owns its parent directory and all descendants,
|
|
184
|
+
* except subtrees that contain their own .meta/. For those subtrees,
|
|
185
|
+
* the parent meta consumes the child meta's synthesis output.
|
|
186
|
+
*
|
|
187
|
+
* @module discovery/ownershipTree
|
|
188
|
+
*/
|
|
189
|
+
/** Normalize path separators to forward slashes for consistent comparison. */
|
|
190
|
+
function normalizePath$1(p) {
|
|
191
|
+
return p.split(sep).join('/');
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Build an ownership tree from an array of .meta/ directory paths.
|
|
195
|
+
*
|
|
196
|
+
* @param metaPaths - Absolute paths to .meta/ directories.
|
|
197
|
+
* @returns The ownership tree with parent/child relationships.
|
|
198
|
+
*/
|
|
199
|
+
function buildOwnershipTree(metaPaths) {
|
|
200
|
+
const nodes = new Map();
|
|
201
|
+
// Create nodes, sorted by ownerPath length (shortest first = shallowest)
|
|
202
|
+
const sorted = [...metaPaths]
|
|
203
|
+
.map((mp) => ({
|
|
204
|
+
metaPath: normalizePath$1(mp),
|
|
205
|
+
ownerPath: normalizePath$1(dirname(mp)),
|
|
206
|
+
}))
|
|
207
|
+
.sort((a, b) => a.ownerPath.length - b.ownerPath.length);
|
|
208
|
+
for (const { metaPath, ownerPath } of sorted) {
|
|
209
|
+
nodes.set(metaPath, {
|
|
210
|
+
metaPath,
|
|
211
|
+
ownerPath,
|
|
212
|
+
treeDepth: 0,
|
|
213
|
+
children: [],
|
|
214
|
+
parent: null,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const roots = [];
|
|
218
|
+
// For each node, find its closest ancestor meta
|
|
219
|
+
for (const node of nodes.values()) {
|
|
220
|
+
let bestParent = null;
|
|
221
|
+
let bestParentLen = -1;
|
|
222
|
+
for (const candidate of nodes.values()) {
|
|
223
|
+
if (candidate === node)
|
|
224
|
+
continue;
|
|
225
|
+
// Check if node's ownerPath is under candidate's ownerPath
|
|
226
|
+
const rel = relative(candidate.ownerPath, node.ownerPath);
|
|
227
|
+
if (rel.startsWith('..') || rel === '')
|
|
228
|
+
continue;
|
|
229
|
+
// candidate.ownerPath is an ancestor of node.ownerPath
|
|
230
|
+
if (candidate.ownerPath.length > bestParentLen) {
|
|
231
|
+
bestParent = candidate;
|
|
232
|
+
bestParentLen = candidate.ownerPath.length;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (bestParent) {
|
|
236
|
+
node.parent = bestParent;
|
|
237
|
+
node.treeDepth = bestParent.treeDepth + 1;
|
|
238
|
+
bestParent.children.push(node);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
roots.push(node);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { nodes, roots };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Compute the file scope owned by a meta node.
|
|
249
|
+
*
|
|
250
|
+
* A meta owns: parent dir + all descendants, minus child .meta/ subtrees.
|
|
251
|
+
* For child subtrees, it consumes the child's .meta/meta.json as a rollup input.
|
|
252
|
+
*
|
|
253
|
+
* @module discovery/scope
|
|
254
|
+
*/
|
|
255
|
+
/**
|
|
256
|
+
* Get the scope path prefix for a meta node.
|
|
257
|
+
*
|
|
258
|
+
* This is the ownerPath — all files under this path are in scope,
|
|
259
|
+
* except subtrees owned by child metas.
|
|
260
|
+
*
|
|
261
|
+
* @param node - The meta node to compute scope for.
|
|
262
|
+
* @returns The scope path prefix.
|
|
263
|
+
*/
|
|
264
|
+
function getScopePrefix(node) {
|
|
265
|
+
return node.ownerPath;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Get paths that should be excluded from the scope (child meta subtrees).
|
|
269
|
+
*
|
|
270
|
+
* @param node - The meta node to compute exclusions for.
|
|
271
|
+
* @returns Array of path prefixes to exclude from scope queries.
|
|
272
|
+
*/
|
|
273
|
+
function getScopeExclusions(node) {
|
|
274
|
+
return node.children.map((child) => child.ownerPath);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Filter a list of file paths to only those in scope for a meta node.
|
|
278
|
+
*
|
|
279
|
+
* Includes files under ownerPath, excludes files under child meta ownerPaths,
|
|
280
|
+
* but includes child .meta/meta.json files as rollup inputs.
|
|
281
|
+
*
|
|
282
|
+
* @param node - The meta node.
|
|
283
|
+
* @param files - Array of file paths to filter.
|
|
284
|
+
* @returns Filtered array of in-scope file paths.
|
|
285
|
+
*/
|
|
286
|
+
function filterInScope(node, files) {
|
|
287
|
+
const prefix = node.ownerPath + '/';
|
|
288
|
+
const exclusions = node.children.map((c) => c.ownerPath + '/');
|
|
289
|
+
const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
|
|
290
|
+
return files.filter((f) => {
|
|
291
|
+
const normalized = f.split('\\').join('/');
|
|
292
|
+
// Must be under ownerPath
|
|
293
|
+
if (!normalized.startsWith(prefix) && normalized !== node.ownerPath)
|
|
294
|
+
return false;
|
|
295
|
+
// Check if under a child meta's subtree
|
|
296
|
+
for (const excl of exclusions) {
|
|
297
|
+
if (normalized.startsWith(excl)) {
|
|
298
|
+
// Exception: child meta.json files are included as rollup inputs
|
|
299
|
+
return childMetaJsons.has(normalized);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return true;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Exponential moving average helper for token tracking.
|
|
308
|
+
*
|
|
309
|
+
* @module ema
|
|
310
|
+
*/
|
|
311
|
+
const DEFAULT_DECAY = 0.3;
|
|
312
|
+
/**
|
|
313
|
+
* Compute exponential moving average.
|
|
314
|
+
*
|
|
315
|
+
* @param current - New observation.
|
|
316
|
+
* @param previous - Previous EMA value, or undefined for first observation.
|
|
317
|
+
* @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
|
|
318
|
+
* @returns Updated EMA.
|
|
319
|
+
*/
|
|
320
|
+
function computeEma(current, previous, decay = DEFAULT_DECAY) {
|
|
321
|
+
if (previous === undefined)
|
|
322
|
+
return current;
|
|
323
|
+
return decay * current + (1 - decay) * previous;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Paginated scan helper for exhaustive scope enumeration.
|
|
328
|
+
*
|
|
329
|
+
* @module paginatedScan
|
|
330
|
+
*/
|
|
331
|
+
/**
|
|
332
|
+
* Perform a paginated scan that follows cursor tokens until exhausted.
|
|
333
|
+
*
|
|
334
|
+
* @param watcher - WatcherClient instance.
|
|
335
|
+
* @param params - Base scan parameters (cursor is managed internally).
|
|
336
|
+
* @returns All matching files across all pages.
|
|
337
|
+
*/
|
|
338
|
+
async function paginatedScan(watcher, params) {
|
|
339
|
+
const allFiles = [];
|
|
340
|
+
let cursor;
|
|
341
|
+
do {
|
|
342
|
+
const result = await watcher.scan({ ...params, cursor });
|
|
343
|
+
allFiles.push(...result.files);
|
|
344
|
+
cursor = result.next;
|
|
345
|
+
} while (cursor);
|
|
346
|
+
return allFiles;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Build the SynthContext for a synthesis cycle.
|
|
351
|
+
*
|
|
352
|
+
* Computes shared inputs once: scope files, delta files, child meta outputs,
|
|
353
|
+
* previous content/feedback, steer, and archive paths.
|
|
354
|
+
*
|
|
355
|
+
* @module orchestrator/contextPackage
|
|
356
|
+
*/
|
|
357
|
+
/**
|
|
358
|
+
* Condense a file list into glob-like summaries.
|
|
359
|
+
* Groups by directory + extension pattern.
|
|
360
|
+
*
|
|
361
|
+
* @param files - Array of file paths.
|
|
362
|
+
* @param maxIndividual - Show individual files up to this count.
|
|
363
|
+
* @returns Condensed summary string.
|
|
364
|
+
*/
|
|
365
|
+
function condenseScopeFiles(files, maxIndividual = 30) {
|
|
366
|
+
if (files.length <= maxIndividual)
|
|
367
|
+
return files.join('\n');
|
|
368
|
+
// Group by dir + extension
|
|
369
|
+
const groups = new Map();
|
|
370
|
+
for (const f of files) {
|
|
371
|
+
const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
|
|
372
|
+
const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
|
|
373
|
+
const key = dir + '*' + ext;
|
|
374
|
+
groups.set(key, (groups.get(key) ?? 0) + 1);
|
|
375
|
+
}
|
|
376
|
+
// Sort by count descending
|
|
377
|
+
const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
|
|
378
|
+
return sorted
|
|
379
|
+
.map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
|
|
380
|
+
.join('\n');
|
|
381
|
+
}
|
|
382
|
+
/** Filter files to exclude child meta subtrees. */
|
|
383
|
+
function excludeChildSubtrees(files, childPrefixes) {
|
|
384
|
+
if (childPrefixes.length === 0)
|
|
385
|
+
return files;
|
|
386
|
+
return files.filter((f) => childPrefixes.every((cp) => !f.startsWith(cp)));
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Build the context package for a synthesis cycle.
|
|
390
|
+
*
|
|
391
|
+
* @param node - The meta node being synthesized.
|
|
392
|
+
* @param meta - Current meta.json content.
|
|
393
|
+
* @param watcher - WatcherClient for scope enumeration.
|
|
394
|
+
* @returns The computed context package.
|
|
395
|
+
*/
|
|
396
|
+
async function buildContextPackage(node, meta, watcher) {
|
|
397
|
+
const childPrefixes = node.children.map((c) => c.ownerPath + '/');
|
|
398
|
+
// Scope files via watcher scan, excluding child subtrees
|
|
399
|
+
const allScanFiles = await paginatedScan(watcher, {
|
|
400
|
+
pathPrefix: node.ownerPath,
|
|
401
|
+
});
|
|
402
|
+
const allFiles = allScanFiles.map((f) => f.file_path);
|
|
403
|
+
const scopeFiles = excludeChildSubtrees(allFiles, childPrefixes);
|
|
404
|
+
// Delta files: modified since _generatedAt
|
|
405
|
+
let deltaFiles;
|
|
406
|
+
if (meta._generatedAt) {
|
|
407
|
+
const modifiedAfter = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
|
|
408
|
+
const deltaScanFiles = await paginatedScan(watcher, {
|
|
409
|
+
pathPrefix: node.ownerPath,
|
|
410
|
+
modifiedAfter,
|
|
411
|
+
});
|
|
412
|
+
deltaFiles = excludeChildSubtrees(deltaScanFiles.map((f) => f.file_path), childPrefixes);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
deltaFiles = scopeFiles; // First run: all files are delta
|
|
416
|
+
}
|
|
417
|
+
// Child meta outputs
|
|
418
|
+
const childMetas = {};
|
|
419
|
+
for (const child of node.children) {
|
|
420
|
+
const childMetaFile = join(child.metaPath, 'meta.json');
|
|
421
|
+
try {
|
|
422
|
+
const raw = readFileSync(childMetaFile, 'utf8');
|
|
423
|
+
const childMeta = JSON.parse(raw);
|
|
424
|
+
childMetas[child.ownerPath] = childMeta._content ?? null;
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
childMetas[child.ownerPath] = null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Archive paths
|
|
431
|
+
const archives = listArchiveFiles(node.metaPath);
|
|
432
|
+
return {
|
|
433
|
+
path: node.metaPath,
|
|
434
|
+
scopeFiles,
|
|
435
|
+
deltaFiles,
|
|
436
|
+
childMetas,
|
|
437
|
+
previousContent: meta._content ?? null,
|
|
438
|
+
previousFeedback: meta._feedback ?? null,
|
|
439
|
+
steer: meta._steer ?? null,
|
|
440
|
+
archives,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Build task prompts for each synthesis step.
|
|
446
|
+
*
|
|
447
|
+
* @module orchestrator/buildTask
|
|
448
|
+
*/
|
|
449
|
+
/** Append optional context sections shared across all step prompts. */
|
|
450
|
+
function appendSharedSections(sections, ctx, options) {
|
|
451
|
+
const opts = {
|
|
452
|
+
includeSteer: true,
|
|
453
|
+
includePreviousContent: true,
|
|
454
|
+
includePreviousFeedback: true,
|
|
455
|
+
feedbackHeading: '## PREVIOUS FEEDBACK',
|
|
456
|
+
includeChildMetas: true,
|
|
457
|
+
...options,
|
|
458
|
+
};
|
|
459
|
+
if (opts.includeSteer && ctx.steer) {
|
|
460
|
+
sections.push('', '## STEERING PROMPT', ctx.steer);
|
|
461
|
+
}
|
|
462
|
+
if (opts.includePreviousContent && ctx.previousContent) {
|
|
463
|
+
sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
|
|
464
|
+
}
|
|
465
|
+
if (opts.includePreviousFeedback && ctx.previousFeedback) {
|
|
466
|
+
sections.push('', opts.feedbackHeading, ctx.previousFeedback);
|
|
467
|
+
}
|
|
468
|
+
if (opts.includeChildMetas && Object.keys(ctx.childMetas).length > 0) {
|
|
469
|
+
sections.push('', '## CHILD META OUTPUTS');
|
|
470
|
+
for (const [childPath, content] of Object.entries(ctx.childMetas)) {
|
|
471
|
+
sections.push(`### ${childPath}`, typeof content === 'string' ? content : '(not yet synthesized)');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Build the architect task prompt.
|
|
477
|
+
*
|
|
478
|
+
* @param ctx - Synthesis context.
|
|
479
|
+
* @param meta - Current meta.json.
|
|
480
|
+
* @param config - Synthesis config.
|
|
481
|
+
* @returns The architect task prompt string.
|
|
482
|
+
*/
|
|
483
|
+
function buildArchitectTask(ctx, meta, config) {
|
|
484
|
+
const sections = [
|
|
485
|
+
meta._architect ?? config.defaultArchitect,
|
|
486
|
+
'',
|
|
487
|
+
'## SCOPE',
|
|
488
|
+
`Path: ${ctx.path}`,
|
|
489
|
+
`Total files in scope: ${ctx.scopeFiles.length.toString()}`,
|
|
490
|
+
`Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
|
|
491
|
+
'',
|
|
492
|
+
'### File listing (scope)',
|
|
493
|
+
condenseScopeFiles(ctx.scopeFiles),
|
|
494
|
+
];
|
|
495
|
+
// Inject previous _builder so architect can see its own prior output
|
|
496
|
+
if (meta._builder) {
|
|
497
|
+
sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
|
|
498
|
+
}
|
|
499
|
+
appendSharedSections(sections, ctx);
|
|
500
|
+
if (ctx.archives.length > 0) {
|
|
501
|
+
sections.push('', '## ARCHIVE HISTORY', `${ctx.archives.length.toString()} previous synthesis snapshots available in .meta/archive/.`, 'Review these to understand how the synthesis has evolved over time.');
|
|
502
|
+
}
|
|
503
|
+
return sections.join('\n');
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Build the builder task prompt.
|
|
507
|
+
*
|
|
508
|
+
* @param ctx - Synthesis context.
|
|
509
|
+
* @param meta - Current meta.json.
|
|
510
|
+
* @param config - Synthesis config.
|
|
511
|
+
* @returns The builder task prompt string.
|
|
512
|
+
*/
|
|
513
|
+
function buildBuilderTask(ctx, meta, config) {
|
|
514
|
+
const sections = [
|
|
515
|
+
'## TASK BRIEF (from Architect)',
|
|
516
|
+
meta._builder ?? '(No architect brief available)',
|
|
517
|
+
'',
|
|
518
|
+
'## SCOPE',
|
|
519
|
+
`Path: ${ctx.path}`,
|
|
520
|
+
`Delta files (${ctx.deltaFiles.length.toString()} changed):`,
|
|
521
|
+
...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
|
|
522
|
+
];
|
|
523
|
+
appendSharedSections(sections, ctx, {
|
|
524
|
+
includeSteer: false,
|
|
525
|
+
feedbackHeading: '## FEEDBACK FROM CRITIC',
|
|
526
|
+
});
|
|
527
|
+
sections.push('', '## OUTPUT FORMAT', 'Return a JSON object with:', '- "_content": Markdown narrative synthesis (required)', '- Any additional structured fields as non-underscore keys');
|
|
528
|
+
return sections.join('\n');
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Build the critic task prompt.
|
|
532
|
+
*
|
|
533
|
+
* @param ctx - Synthesis context.
|
|
534
|
+
* @param meta - Current meta.json (with _content already set by builder).
|
|
535
|
+
* @param config - Synthesis config.
|
|
536
|
+
* @returns The critic task prompt string.
|
|
537
|
+
*/
|
|
538
|
+
function buildCriticTask(ctx, meta, config) {
|
|
539
|
+
const sections = [
|
|
540
|
+
meta._critic ?? config.defaultCritic,
|
|
541
|
+
'',
|
|
542
|
+
'## SYNTHESIS TO EVALUATE',
|
|
543
|
+
meta._content ?? '(No content produced)',
|
|
544
|
+
'',
|
|
545
|
+
'## SCOPE',
|
|
546
|
+
`Path: ${ctx.path}`,
|
|
547
|
+
`Files in scope: ${ctx.scopeFiles.length.toString()}`,
|
|
548
|
+
];
|
|
549
|
+
appendSharedSections(sections, ctx, {
|
|
550
|
+
includePreviousContent: false,
|
|
551
|
+
feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
|
|
552
|
+
includeChildMetas: false,
|
|
553
|
+
});
|
|
554
|
+
sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
|
|
555
|
+
return sections.join('\n');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Zod schema for jeeves-meta configuration.
|
|
560
|
+
*
|
|
561
|
+
* Consumers load config however they want (file, env, constructor).
|
|
562
|
+
* The library validates via this schema.
|
|
563
|
+
*
|
|
564
|
+
* @module schema/config
|
|
565
|
+
*/
|
|
566
|
+
/** Zod schema for jeeves-meta configuration. */
|
|
567
|
+
const synthConfigSchema = z.object({
|
|
568
|
+
/** Filesystem paths to watch for .meta/ directories. */
|
|
569
|
+
watchPaths: z.array(z.string()).min(1),
|
|
570
|
+
/** Watcher service base URL. */
|
|
571
|
+
watcherUrl: z.url(),
|
|
572
|
+
/** Run architect every N cycles (per meta). */
|
|
573
|
+
architectEvery: z.number().int().min(1).default(10),
|
|
574
|
+
/** Exponent for depth weighting in staleness formula. */
|
|
575
|
+
depthWeight: z.number().min(0).default(0.5),
|
|
576
|
+
/** Maximum archive snapshots to retain per meta. */
|
|
577
|
+
maxArchive: z.number().int().min(1).default(20),
|
|
578
|
+
/** Maximum lines of context to include in subprocess prompts. */
|
|
579
|
+
maxLines: z.number().int().min(50).default(500),
|
|
580
|
+
/** Architect subprocess timeout in seconds. */
|
|
581
|
+
architectTimeout: z.number().int().min(30).default(120),
|
|
582
|
+
/** Builder subprocess timeout in seconds. */
|
|
583
|
+
builderTimeout: z.number().int().min(60).default(600),
|
|
584
|
+
/** Critic subprocess timeout in seconds. */
|
|
585
|
+
criticTimeout: z.number().int().min(30).default(300),
|
|
586
|
+
/** Resolved architect system prompt text. */
|
|
587
|
+
defaultArchitect: z.string(),
|
|
588
|
+
/** Resolved critic system prompt text. */
|
|
589
|
+
defaultCritic: z.string(),
|
|
590
|
+
/**
|
|
591
|
+
* When true, skip unchanged candidates and iterate to the next-stalest
|
|
592
|
+
* until finding one with actual changes. Skipped candidates get their
|
|
593
|
+
* _generatedAt bumped to prevent re-selection next cycle.
|
|
594
|
+
*/
|
|
595
|
+
skipUnchanged: z.boolean().default(true),
|
|
596
|
+
/** Number of metas to synthesize per invocation. */
|
|
597
|
+
batchSize: z.number().int().min(1).default(1),
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Structured error from a synthesis step failure.
|
|
602
|
+
*
|
|
603
|
+
* @module schema/error
|
|
604
|
+
*/
|
|
605
|
+
/** Zod schema for synthesis step errors. */
|
|
606
|
+
const synthErrorSchema = z.object({
|
|
607
|
+
/** Which step failed: 'architect', 'builder', or 'critic'. */
|
|
608
|
+
step: z.enum(['architect', 'builder', 'critic']),
|
|
609
|
+
/** Error classification code. */
|
|
610
|
+
code: z.string(),
|
|
611
|
+
/** Human-readable error message. */
|
|
612
|
+
message: z.string(),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Zod schema for .meta/meta.json files.
|
|
617
|
+
*
|
|
618
|
+
* Reserved properties are underscore-prefixed and engine-managed.
|
|
619
|
+
* All other keys are open schema (builder output).
|
|
620
|
+
*
|
|
621
|
+
* @module schema/meta
|
|
622
|
+
*/
|
|
623
|
+
/** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
|
|
624
|
+
const metaJsonSchema = z
|
|
625
|
+
.object({
|
|
626
|
+
/** Stable identity. Generated on first synthesis, never changes. */
|
|
627
|
+
_id: z.uuid(),
|
|
628
|
+
/** Human-provided steering prompt. Optional. */
|
|
629
|
+
_steer: z.string().optional(),
|
|
630
|
+
/** Architect system prompt used this turn. Defaults from config. */
|
|
631
|
+
_architect: z.string().optional(),
|
|
632
|
+
/**
|
|
633
|
+
* Task brief generated by the architect. Cached and reused across cycles;
|
|
634
|
+
* regenerated only when triggered.
|
|
635
|
+
*/
|
|
636
|
+
_builder: z.string().optional(),
|
|
637
|
+
/** Critic system prompt used this turn. Defaults from config. */
|
|
638
|
+
_critic: z.string().optional(),
|
|
639
|
+
/** Timestamp of last synthesis. ISO 8601. */
|
|
640
|
+
_generatedAt: z.iso.datetime().optional(),
|
|
641
|
+
/** Narrative synthesis output. Rendered by watcher for embedding. */
|
|
642
|
+
_content: z.string().optional(),
|
|
643
|
+
/**
|
|
644
|
+
* Hash of sorted file listing in scope. Detects directory structure
|
|
645
|
+
* changes that trigger an architect re-run.
|
|
646
|
+
*/
|
|
647
|
+
_structureHash: z.string().optional(),
|
|
648
|
+
/**
|
|
649
|
+
* Cycles since last architect run. Reset to 0 when architect runs.
|
|
650
|
+
* Used with architectEvery to trigger periodic re-prompting.
|
|
651
|
+
*/
|
|
652
|
+
_synthesisCount: z.number().int().min(0).optional(),
|
|
653
|
+
/** Critic evaluation of the last synthesis. */
|
|
654
|
+
_feedback: z.string().optional(),
|
|
655
|
+
/**
|
|
656
|
+
* Present and true on archive snapshots. Distinguishes live vs. archived
|
|
657
|
+
* metas.
|
|
658
|
+
*/
|
|
659
|
+
_archived: z.boolean().optional(),
|
|
660
|
+
/** Timestamp when this snapshot was archived. ISO 8601. */
|
|
661
|
+
_archivedAt: z.iso.datetime().optional(),
|
|
662
|
+
/**
|
|
663
|
+
* Scheduling priority. Higher = updates more often. Negative allowed;
|
|
664
|
+
* normalized to min 0 at scheduling time.
|
|
665
|
+
*/
|
|
666
|
+
_depth: z.number().optional(),
|
|
667
|
+
/**
|
|
668
|
+
* Emphasis multiplier for depth weighting in scheduling.
|
|
669
|
+
* Default 1. Higher values increase this meta's scheduling priority
|
|
670
|
+
* relative to its depth. Set to 0.5 to halve the depth effect,
|
|
671
|
+
* 2 to double it, 0 to ignore depth entirely for this meta.
|
|
672
|
+
*/
|
|
673
|
+
_emphasis: z.number().min(0).optional(),
|
|
674
|
+
/** Token count from last architect subprocess call. */
|
|
675
|
+
_architectTokens: z.number().int().optional(),
|
|
676
|
+
/** Token count from last builder subprocess call. */
|
|
677
|
+
_builderTokens: z.number().int().optional(),
|
|
678
|
+
/** Token count from last critic subprocess call. */
|
|
679
|
+
_criticTokens: z.number().int().optional(),
|
|
680
|
+
/** Exponential moving average of architect token usage (decay 0.3). */
|
|
681
|
+
_architectTokensAvg: z.number().optional(),
|
|
682
|
+
/** Exponential moving average of builder token usage (decay 0.3). */
|
|
683
|
+
_builderTokensAvg: z.number().optional(),
|
|
684
|
+
/** Exponential moving average of critic token usage (decay 0.3). */
|
|
685
|
+
_criticTokensAvg: z.number().optional(),
|
|
686
|
+
/**
|
|
687
|
+
* Structured error from last cycle. Present when a step failed.
|
|
688
|
+
* Cleared on successful cycle.
|
|
689
|
+
*/
|
|
690
|
+
_error: synthErrorSchema.optional(),
|
|
691
|
+
})
|
|
692
|
+
.loose();
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Merge synthesis results into meta.json.
|
|
696
|
+
*
|
|
697
|
+
* Preserves human-set fields (_id, _steer, _depth).
|
|
698
|
+
* Writes engine fields (_generatedAt, _structureHash, etc.).
|
|
699
|
+
* Validates against schema before writing.
|
|
700
|
+
*
|
|
701
|
+
* @module orchestrator/merge
|
|
702
|
+
*/
|
|
703
|
+
/**
|
|
704
|
+
* Merge results into meta.json and write atomically.
|
|
705
|
+
*
|
|
706
|
+
* @param options - Merge options.
|
|
707
|
+
* @returns The updated MetaJson.
|
|
708
|
+
* @throws If validation fails (malformed output).
|
|
709
|
+
*/
|
|
710
|
+
function mergeAndWrite(options) {
|
|
711
|
+
const merged = {
|
|
712
|
+
// Preserve human-set fields
|
|
713
|
+
_id: options.current._id,
|
|
714
|
+
_steer: options.current._steer,
|
|
715
|
+
_depth: options.current._depth,
|
|
716
|
+
_emphasis: options.current._emphasis,
|
|
717
|
+
// Engine fields
|
|
718
|
+
_architect: options.architect,
|
|
719
|
+
_builder: options.builder,
|
|
720
|
+
_critic: options.critic,
|
|
721
|
+
_generatedAt: new Date().toISOString(),
|
|
722
|
+
_structureHash: options.structureHash,
|
|
723
|
+
_synthesisCount: options.synthesisCount,
|
|
724
|
+
// Token tracking
|
|
725
|
+
_architectTokens: options.architectTokens,
|
|
726
|
+
_builderTokens: options.builderTokens,
|
|
727
|
+
_criticTokens: options.criticTokens,
|
|
728
|
+
_architectTokensAvg: options.architectTokens !== undefined
|
|
729
|
+
? computeEma(options.architectTokens, options.current._architectTokensAvg)
|
|
730
|
+
: options.current._architectTokensAvg,
|
|
731
|
+
_builderTokensAvg: options.builderTokens !== undefined
|
|
732
|
+
? computeEma(options.builderTokens, options.current._builderTokensAvg)
|
|
733
|
+
: options.current._builderTokensAvg,
|
|
734
|
+
_criticTokensAvg: options.criticTokens !== undefined
|
|
735
|
+
? computeEma(options.criticTokens, options.current._criticTokensAvg)
|
|
736
|
+
: options.current._criticTokensAvg,
|
|
737
|
+
// Content from builder
|
|
738
|
+
_content: options.builderOutput?.content ?? options.current._content,
|
|
739
|
+
// Feedback from critic
|
|
740
|
+
_feedback: options.feedback ?? options.current._feedback,
|
|
741
|
+
// Error handling
|
|
742
|
+
_error: options.error ?? undefined,
|
|
743
|
+
// Spread structured fields from builder
|
|
744
|
+
...options.builderOutput?.fields,
|
|
745
|
+
};
|
|
746
|
+
// Clean up undefined optional fields
|
|
747
|
+
if (merged._steer === undefined)
|
|
748
|
+
delete merged._steer;
|
|
749
|
+
if (merged._depth === undefined)
|
|
750
|
+
delete merged._depth;
|
|
751
|
+
if (merged._emphasis === undefined)
|
|
752
|
+
delete merged._emphasis;
|
|
753
|
+
if (merged._architectTokens === undefined)
|
|
754
|
+
delete merged._architectTokens;
|
|
755
|
+
if (merged._builderTokens === undefined)
|
|
756
|
+
delete merged._builderTokens;
|
|
757
|
+
if (merged._criticTokens === undefined)
|
|
758
|
+
delete merged._criticTokens;
|
|
759
|
+
if (merged._architectTokensAvg === undefined)
|
|
760
|
+
delete merged._architectTokensAvg;
|
|
761
|
+
if (merged._builderTokensAvg === undefined)
|
|
762
|
+
delete merged._builderTokensAvg;
|
|
763
|
+
if (merged._criticTokensAvg === undefined)
|
|
764
|
+
delete merged._criticTokensAvg;
|
|
765
|
+
if (merged._error === undefined)
|
|
766
|
+
delete merged._error;
|
|
767
|
+
if (merged._content === undefined)
|
|
768
|
+
delete merged._content;
|
|
769
|
+
if (merged._feedback === undefined)
|
|
770
|
+
delete merged._feedback;
|
|
771
|
+
// Validate
|
|
772
|
+
const result = metaJsonSchema.safeParse(merged);
|
|
773
|
+
if (!result.success) {
|
|
774
|
+
throw new Error(`Meta validation failed: ${result.error.message}`);
|
|
775
|
+
}
|
|
776
|
+
// Write atomically
|
|
777
|
+
const filePath = join(options.metaPath, 'meta.json');
|
|
778
|
+
writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
|
|
779
|
+
return result.data;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Shared error utilities.
|
|
784
|
+
*
|
|
785
|
+
* @module errors
|
|
786
|
+
*/
|
|
787
|
+
/**
|
|
788
|
+
* Wrap an unknown caught value into a SynthError.
|
|
789
|
+
*
|
|
790
|
+
* @param step - Which synthesis step failed.
|
|
791
|
+
* @param err - The caught error value.
|
|
792
|
+
* @param code - Error classification code.
|
|
793
|
+
* @returns A structured SynthError.
|
|
794
|
+
*/
|
|
795
|
+
function toSynthError(step, err, code = 'FAILED') {
|
|
796
|
+
return {
|
|
797
|
+
step,
|
|
798
|
+
code,
|
|
799
|
+
message: err instanceof Error ? err.message : String(err),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* File-system lock for preventing concurrent synthesis on the same meta.
|
|
805
|
+
*
|
|
806
|
+
* Lock file: .meta/.lock containing PID + timestamp.
|
|
807
|
+
* Stale timeout: 30 minutes.
|
|
808
|
+
*
|
|
809
|
+
* @module lock
|
|
810
|
+
*/
|
|
811
|
+
const LOCK_FILE = '.lock';
|
|
812
|
+
const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
813
|
+
/**
|
|
814
|
+
* Attempt to acquire a lock on a .meta directory.
|
|
815
|
+
*
|
|
816
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
817
|
+
* @returns True if lock was acquired, false if already locked (non-stale).
|
|
818
|
+
*/
|
|
819
|
+
function acquireLock(metaPath) {
|
|
820
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
821
|
+
if (existsSync(lockPath)) {
|
|
822
|
+
try {
|
|
823
|
+
const raw = readFileSync(lockPath, 'utf8');
|
|
824
|
+
const data = JSON.parse(raw);
|
|
825
|
+
const lockAge = Date.now() - new Date(data.startedAt).getTime();
|
|
826
|
+
if (lockAge < STALE_TIMEOUT_MS) {
|
|
827
|
+
return false; // Lock is active
|
|
828
|
+
}
|
|
829
|
+
// Stale lock — fall through to overwrite
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
// Corrupt lock file — overwrite
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const lock = {
|
|
836
|
+
pid: process.pid,
|
|
837
|
+
startedAt: new Date().toISOString(),
|
|
838
|
+
};
|
|
839
|
+
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Release a lock on a .meta directory.
|
|
844
|
+
*
|
|
845
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
846
|
+
*/
|
|
847
|
+
function releaseLock(metaPath) {
|
|
848
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
849
|
+
try {
|
|
850
|
+
unlinkSync(lockPath);
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
// Already removed or never existed
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Check if a .meta directory is currently locked (non-stale).
|
|
858
|
+
*
|
|
859
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
860
|
+
* @returns True if locked and not stale.
|
|
861
|
+
*/
|
|
862
|
+
function isLocked(metaPath) {
|
|
863
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
864
|
+
if (!existsSync(lockPath))
|
|
865
|
+
return false;
|
|
866
|
+
try {
|
|
867
|
+
const raw = readFileSync(lockPath, 'utf8');
|
|
868
|
+
const data = JSON.parse(raw);
|
|
869
|
+
const lockAge = Date.now() - new Date(data.startedAt).getTime();
|
|
870
|
+
return lockAge < STALE_TIMEOUT_MS;
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
return false; // Corrupt lock = not locked
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Select the best synthesis candidate from stale metas.
|
|
879
|
+
*
|
|
880
|
+
* Picks the meta with highest effective staleness.
|
|
881
|
+
*
|
|
882
|
+
* @module scheduling/selectCandidate
|
|
883
|
+
*/
|
|
884
|
+
/**
|
|
885
|
+
* Select the candidate with the highest effective staleness.
|
|
886
|
+
*
|
|
887
|
+
* @param candidates - Array of candidates with computed effective staleness.
|
|
888
|
+
* @returns The winning candidate, or null if no candidates.
|
|
889
|
+
*/
|
|
890
|
+
function selectCandidate(candidates) {
|
|
891
|
+
if (candidates.length === 0)
|
|
892
|
+
return null;
|
|
893
|
+
let best = candidates[0];
|
|
894
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
895
|
+
if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
|
|
896
|
+
best = candidates[i];
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return best;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Staleness detection via watcher scan.
|
|
904
|
+
*
|
|
905
|
+
* A meta is stale when any file in its scope was modified after _generatedAt.
|
|
906
|
+
*
|
|
907
|
+
* @module scheduling/staleness
|
|
908
|
+
*/
|
|
909
|
+
/**
|
|
910
|
+
* Check if a meta is stale by querying the watcher for modified files.
|
|
911
|
+
*
|
|
912
|
+
* @param scopePrefix - Path prefix for this meta's scope.
|
|
913
|
+
* @param meta - Current meta.json content.
|
|
914
|
+
* @param watcher - WatcherClient instance.
|
|
915
|
+
* @returns True if any file in scope was modified after _generatedAt.
|
|
916
|
+
*/
|
|
917
|
+
async function isStale(scopePrefix, meta, watcher) {
|
|
918
|
+
if (!meta._generatedAt)
|
|
919
|
+
return true; // Never synthesized = stale
|
|
920
|
+
const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
|
|
921
|
+
const result = await watcher.scan({
|
|
922
|
+
pathPrefix: scopePrefix,
|
|
923
|
+
modifiedAfter: generatedAtUnix,
|
|
924
|
+
limit: 1,
|
|
925
|
+
});
|
|
926
|
+
return result.files.length > 0;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Compute actual staleness in seconds (now minus _generatedAt).
|
|
930
|
+
*
|
|
931
|
+
* @param meta - Current meta.json content.
|
|
932
|
+
* @returns Staleness in seconds, or Infinity if never synthesized.
|
|
933
|
+
*/
|
|
934
|
+
function actualStaleness(meta) {
|
|
935
|
+
if (!meta._generatedAt)
|
|
936
|
+
return Infinity;
|
|
937
|
+
const generatedMs = new Date(meta._generatedAt).getTime();
|
|
938
|
+
return (Date.now() - generatedMs) / 1000;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Weighted staleness formula for candidate selection.
|
|
943
|
+
*
|
|
944
|
+
* effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
|
|
945
|
+
*
|
|
946
|
+
* @module scheduling/weightedFormula
|
|
947
|
+
*/
|
|
948
|
+
/**
|
|
949
|
+
* Compute effective staleness for a set of candidates.
|
|
950
|
+
*
|
|
951
|
+
* Normalizes depths so the minimum becomes 0, then applies the formula:
|
|
952
|
+
* effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
|
|
953
|
+
*
|
|
954
|
+
* Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
|
|
955
|
+
* metas to tune how much their tree position affects scheduling.
|
|
956
|
+
*
|
|
957
|
+
* @param candidates - Array of \{ node, meta, actualStaleness \}.
|
|
958
|
+
* @param depthWeight - Exponent for depth weighting (0 = pure staleness).
|
|
959
|
+
* @returns Same array with effectiveStaleness computed.
|
|
960
|
+
*/
|
|
961
|
+
function computeEffectiveStaleness(candidates, depthWeight) {
|
|
962
|
+
if (candidates.length === 0)
|
|
963
|
+
return [];
|
|
964
|
+
// Get depth for each candidate: use _depth override or tree depth
|
|
965
|
+
const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
|
|
966
|
+
// Normalize: shift so minimum becomes 0
|
|
967
|
+
const minDepth = Math.min(...depths);
|
|
968
|
+
const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
|
|
969
|
+
return candidates.map((c, i) => {
|
|
970
|
+
const emphasis = c.meta._emphasis ?? 1;
|
|
971
|
+
return {
|
|
972
|
+
...c,
|
|
973
|
+
effectiveStaleness: c.actualStaleness *
|
|
974
|
+
Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
|
|
975
|
+
};
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Compute a structure hash from a sorted file listing.
|
|
981
|
+
*
|
|
982
|
+
* Used to detect when directory structure changes, triggering
|
|
983
|
+
* an architect re-run.
|
|
984
|
+
*
|
|
985
|
+
* @module structureHash
|
|
986
|
+
*/
|
|
987
|
+
/**
|
|
988
|
+
* Compute a SHA-256 hash of a sorted file listing.
|
|
989
|
+
*
|
|
990
|
+
* @param filePaths - Array of file paths in scope.
|
|
991
|
+
* @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
|
|
992
|
+
*/
|
|
993
|
+
function computeStructureHash(filePaths) {
|
|
994
|
+
const sorted = [...filePaths].sort();
|
|
995
|
+
const content = sorted.join('\n');
|
|
996
|
+
return createHash('sha256').update(content).digest('hex');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Parse subprocess outputs for each synthesis step.
|
|
1001
|
+
*
|
|
1002
|
+
* - Architect: returns text \> _builder
|
|
1003
|
+
* - Builder: returns JSON \> _content + structured fields
|
|
1004
|
+
* - Critic: returns text \> _feedback
|
|
1005
|
+
*
|
|
1006
|
+
* @module orchestrator/parseOutput
|
|
1007
|
+
*/
|
|
1008
|
+
/**
|
|
1009
|
+
* Parse architect output. The architect returns a task brief as text.
|
|
1010
|
+
*
|
|
1011
|
+
* @param output - Raw subprocess output.
|
|
1012
|
+
* @returns The task brief string.
|
|
1013
|
+
*/
|
|
1014
|
+
function parseArchitectOutput(output) {
|
|
1015
|
+
return output.trim();
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Parse builder output. The builder returns JSON with _content and optional fields.
|
|
1019
|
+
*
|
|
1020
|
+
* Attempts JSON parse first. If that fails, treats the entire output as _content.
|
|
1021
|
+
*
|
|
1022
|
+
* @param output - Raw subprocess output.
|
|
1023
|
+
* @returns Parsed builder output with content and structured fields.
|
|
1024
|
+
*/
|
|
1025
|
+
function parseBuilderOutput(output) {
|
|
1026
|
+
const trimmed = output.trim();
|
|
1027
|
+
// Try to extract JSON from the output (may be wrapped in markdown code fences)
|
|
1028
|
+
let jsonStr = trimmed;
|
|
1029
|
+
const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
|
|
1030
|
+
if (fenceMatch) {
|
|
1031
|
+
jsonStr = fenceMatch[1].trim();
|
|
1032
|
+
}
|
|
1033
|
+
try {
|
|
1034
|
+
const parsed = JSON.parse(jsonStr);
|
|
1035
|
+
// Extract _content
|
|
1036
|
+
const content = typeof parsed._content === 'string'
|
|
1037
|
+
? parsed._content
|
|
1038
|
+
: typeof parsed.content === 'string'
|
|
1039
|
+
? parsed.content
|
|
1040
|
+
: trimmed;
|
|
1041
|
+
// Extract non-underscore fields
|
|
1042
|
+
const fields = {};
|
|
1043
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1044
|
+
if (!key.startsWith('_') && key !== 'content') {
|
|
1045
|
+
fields[key] = value;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return { content, fields };
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
// Not valid JSON — treat entire output as content
|
|
1052
|
+
return { content: trimmed, fields: {} };
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Parse critic output. The critic returns evaluation text.
|
|
1057
|
+
*
|
|
1058
|
+
* @param output - Raw subprocess output.
|
|
1059
|
+
* @returns The feedback string.
|
|
1060
|
+
*/
|
|
1061
|
+
function parseCriticOutput(output) {
|
|
1062
|
+
return output.trim();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Main orchestration function — the 13-step synthesis cycle.
|
|
1067
|
+
*
|
|
1068
|
+
* Wires together discovery, scheduling, archiving, executor calls,
|
|
1069
|
+
* and merge/write-back.
|
|
1070
|
+
*
|
|
1071
|
+
* @module orchestrator/orchestrate
|
|
1072
|
+
*/
|
|
1073
|
+
/** Normalize path separators to forward slashes. */
|
|
1074
|
+
function normalizePath(p) {
|
|
1075
|
+
return p.replaceAll('\\', '/');
|
|
1076
|
+
}
|
|
1077
|
+
/** Finalize a cycle: merge, snapshot, prune. */
|
|
1078
|
+
function finalizeCycle(metaPath, current, config, architect, builder, critic, builderOutput, feedback, structureHash, synthesisCount, error, architectTokens, builderTokens, criticTokens) {
|
|
1079
|
+
const updated = mergeAndWrite({
|
|
1080
|
+
metaPath,
|
|
1081
|
+
current,
|
|
1082
|
+
architect,
|
|
1083
|
+
builder,
|
|
1084
|
+
critic,
|
|
1085
|
+
builderOutput,
|
|
1086
|
+
feedback,
|
|
1087
|
+
structureHash,
|
|
1088
|
+
synthesisCount,
|
|
1089
|
+
error,
|
|
1090
|
+
architectTokens,
|
|
1091
|
+
builderTokens,
|
|
1092
|
+
criticTokens,
|
|
1093
|
+
});
|
|
1094
|
+
createSnapshot(metaPath, updated);
|
|
1095
|
+
pruneArchive(metaPath, config.maxArchive);
|
|
1096
|
+
return updated;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Run a single synthesis cycle.
|
|
1100
|
+
*
|
|
1101
|
+
* Discovers all metas, selects the stalest candidate, and runs the
|
|
1102
|
+
* three-step synthesis (architect, builder, critic).
|
|
1103
|
+
*
|
|
1104
|
+
* @param config - Validated synthesis config.
|
|
1105
|
+
* @param executor - Pluggable LLM executor.
|
|
1106
|
+
* @param watcher - Watcher HTTP client.
|
|
1107
|
+
* @returns Result indicating whether synthesis occurred.
|
|
1108
|
+
*/
|
|
1109
|
+
async function orchestrateOnce(config, executor, watcher) {
|
|
1110
|
+
// Step 1: Discover
|
|
1111
|
+
const metaPaths = globMetas(config.watchPaths);
|
|
1112
|
+
if (metaPaths.length === 0)
|
|
1113
|
+
return { synthesized: false };
|
|
1114
|
+
// Ensure all meta.json files exist
|
|
1115
|
+
const metas = new Map();
|
|
1116
|
+
for (const mp of metaPaths) {
|
|
1117
|
+
metas.set(normalizePath(mp), ensureMetaJson(mp));
|
|
1118
|
+
}
|
|
1119
|
+
const tree = buildOwnershipTree(metaPaths);
|
|
1120
|
+
// Steps 3-4: Staleness check + candidate selection
|
|
1121
|
+
const candidates = [];
|
|
1122
|
+
for (const node of tree.nodes.values()) {
|
|
1123
|
+
const meta = metas.get(node.metaPath);
|
|
1124
|
+
const staleness = actualStaleness(meta);
|
|
1125
|
+
if (staleness > 0) {
|
|
1126
|
+
candidates.push({ node, meta, actualStaleness: staleness });
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
|
|
1130
|
+
// Sort by effective staleness descending
|
|
1131
|
+
const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
|
|
1132
|
+
if (ranked.length === 0)
|
|
1133
|
+
return { synthesized: false };
|
|
1134
|
+
// Find the first candidate with actual changes (if skipUnchanged)
|
|
1135
|
+
let winner = null;
|
|
1136
|
+
for (const candidate of ranked) {
|
|
1137
|
+
if (!acquireLock(candidate.node.metaPath))
|
|
1138
|
+
continue;
|
|
1139
|
+
const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
|
|
1140
|
+
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
1141
|
+
// Bump _generatedAt so it doesn't win next cycle
|
|
1142
|
+
const metaFilePath = join(candidate.node.metaPath, 'meta.json');
|
|
1143
|
+
const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
|
|
1144
|
+
freshMeta._generatedAt = new Date().toISOString();
|
|
1145
|
+
writeFileSync(metaFilePath, JSON.stringify(freshMeta, null, 2));
|
|
1146
|
+
releaseLock(candidate.node.metaPath);
|
|
1147
|
+
if (config.skipUnchanged)
|
|
1148
|
+
continue;
|
|
1149
|
+
return { synthesized: false };
|
|
1150
|
+
}
|
|
1151
|
+
winner = candidate;
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
if (!winner)
|
|
1155
|
+
return { synthesized: false };
|
|
1156
|
+
const { node } = winner;
|
|
1157
|
+
try {
|
|
1158
|
+
// Re-read meta after lock (may have changed)
|
|
1159
|
+
const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
|
|
1160
|
+
const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
|
|
1161
|
+
const criticPrompt = currentMeta._critic ?? config.defaultCritic;
|
|
1162
|
+
// Step 5: Structure hash
|
|
1163
|
+
const scopePrefix = getScopePrefix(node);
|
|
1164
|
+
const allScanFiles = await paginatedScan(watcher, {
|
|
1165
|
+
pathPrefix: scopePrefix,
|
|
1166
|
+
});
|
|
1167
|
+
const allFilePaths = allScanFiles.map((f) => f.file_path);
|
|
1168
|
+
// Structure hash uses scope-filtered files (excluding child subtrees)
|
|
1169
|
+
// so changes in child scopes don't trigger parent architect re-runs
|
|
1170
|
+
const scopeFiles = filterInScope(node, allFilePaths);
|
|
1171
|
+
const newStructureHash = computeStructureHash(scopeFiles);
|
|
1172
|
+
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
1173
|
+
// Step 6: Steer change detection
|
|
1174
|
+
const latestArchive = readLatestArchive(node.metaPath);
|
|
1175
|
+
const steerChanged = latestArchive
|
|
1176
|
+
? currentMeta._steer !== latestArchive._steer
|
|
1177
|
+
: Boolean(currentMeta._steer);
|
|
1178
|
+
// Step 7: Compute context
|
|
1179
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher);
|
|
1180
|
+
// Step 8: Architect (conditional)
|
|
1181
|
+
const architectTriggered = !currentMeta._builder ||
|
|
1182
|
+
structureChanged ||
|
|
1183
|
+
steerChanged ||
|
|
1184
|
+
(currentMeta._synthesisCount ?? 0) >= config.architectEvery;
|
|
1185
|
+
let builderBrief = currentMeta._builder ?? '';
|
|
1186
|
+
let synthesisCount = currentMeta._synthesisCount ?? 0;
|
|
1187
|
+
let stepError = null;
|
|
1188
|
+
let architectTokens;
|
|
1189
|
+
let builderTokens;
|
|
1190
|
+
let criticTokens;
|
|
1191
|
+
if (architectTriggered) {
|
|
1192
|
+
try {
|
|
1193
|
+
const architectTask = buildArchitectTask(ctx, currentMeta, config);
|
|
1194
|
+
const architectResult = await executor.spawn(architectTask, {
|
|
1195
|
+
timeout: config.architectTimeout,
|
|
1196
|
+
});
|
|
1197
|
+
builderBrief = parseArchitectOutput(architectResult.output);
|
|
1198
|
+
architectTokens = architectResult.tokens;
|
|
1199
|
+
synthesisCount = 0;
|
|
1200
|
+
}
|
|
1201
|
+
catch (err) {
|
|
1202
|
+
stepError = toSynthError('architect', err);
|
|
1203
|
+
if (!currentMeta._builder) {
|
|
1204
|
+
// No cached builder — cycle fails
|
|
1205
|
+
finalizeCycle(node.metaPath, currentMeta, config, architectPrompt, '', criticPrompt, null, null, newStructureHash, synthesisCount, stepError, architectTokens);
|
|
1206
|
+
return {
|
|
1207
|
+
synthesized: true,
|
|
1208
|
+
metaPath: node.metaPath,
|
|
1209
|
+
error: stepError,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
// Has cached builder — continue with existing
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
// Step 9: Builder
|
|
1216
|
+
const metaForBuilder = { ...currentMeta, _builder: builderBrief };
|
|
1217
|
+
let builderOutput = null;
|
|
1218
|
+
try {
|
|
1219
|
+
const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
|
|
1220
|
+
const builderResult = await executor.spawn(builderTask, {
|
|
1221
|
+
timeout: config.builderTimeout,
|
|
1222
|
+
});
|
|
1223
|
+
builderOutput = parseBuilderOutput(builderResult.output);
|
|
1224
|
+
builderTokens = builderResult.tokens;
|
|
1225
|
+
synthesisCount++;
|
|
1226
|
+
}
|
|
1227
|
+
catch (err) {
|
|
1228
|
+
stepError = toSynthError('builder', err);
|
|
1229
|
+
return { synthesized: true, metaPath: node.metaPath, error: stepError };
|
|
1230
|
+
}
|
|
1231
|
+
// Step 10: Critic
|
|
1232
|
+
const metaForCritic = {
|
|
1233
|
+
...currentMeta,
|
|
1234
|
+
_content: builderOutput.content,
|
|
1235
|
+
};
|
|
1236
|
+
let feedback = null;
|
|
1237
|
+
try {
|
|
1238
|
+
const criticTask = buildCriticTask(ctx, metaForCritic, config);
|
|
1239
|
+
const criticResult = await executor.spawn(criticTask, {
|
|
1240
|
+
timeout: config.criticTimeout,
|
|
1241
|
+
});
|
|
1242
|
+
feedback = parseCriticOutput(criticResult.output);
|
|
1243
|
+
criticTokens = criticResult.tokens;
|
|
1244
|
+
stepError = null; // Clear any architect error on full success
|
|
1245
|
+
}
|
|
1246
|
+
catch (err) {
|
|
1247
|
+
stepError = stepError ?? toSynthError('critic', err);
|
|
1248
|
+
}
|
|
1249
|
+
// Steps 11-12: Merge, archive, prune
|
|
1250
|
+
finalizeCycle(node.metaPath, currentMeta, config, architectPrompt, builderBrief, criticPrompt, builderOutput, feedback, newStructureHash, synthesisCount, stepError, architectTokens, builderTokens, criticTokens);
|
|
1251
|
+
return {
|
|
1252
|
+
synthesized: true,
|
|
1253
|
+
metaPath: node.metaPath,
|
|
1254
|
+
error: stepError ?? undefined,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
finally {
|
|
1258
|
+
// Step 13: Release lock
|
|
1259
|
+
releaseLock(node.metaPath);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Run synthesis cycles up to batchSize.
|
|
1264
|
+
*
|
|
1265
|
+
* Calls orchestrateOnce() in a loop, stopping when batchSize is reached
|
|
1266
|
+
* or no more candidates are available.
|
|
1267
|
+
*
|
|
1268
|
+
* @param config - Validated synthesis config.
|
|
1269
|
+
* @param executor - Pluggable LLM executor.
|
|
1270
|
+
* @param watcher - Watcher HTTP client.
|
|
1271
|
+
* @returns Array of results, one per cycle attempted.
|
|
1272
|
+
*/
|
|
1273
|
+
async function orchestrate(config, executor, watcher) {
|
|
1274
|
+
const results = [];
|
|
1275
|
+
for (let i = 0; i < config.batchSize; i++) {
|
|
1276
|
+
const result = await orchestrateOnce(config, executor, watcher);
|
|
1277
|
+
results.push(result);
|
|
1278
|
+
if (!result.synthesized)
|
|
1279
|
+
break; // No more candidates
|
|
1280
|
+
}
|
|
1281
|
+
return results;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Factory for creating a bound synthesis engine.
|
|
1286
|
+
*
|
|
1287
|
+
* @module engine
|
|
1288
|
+
*/
|
|
1289
|
+
/**
|
|
1290
|
+
* Create a synthesis engine with bound config, executor, and watcher client.
|
|
1291
|
+
*
|
|
1292
|
+
* @param config - Validated synthesis config.
|
|
1293
|
+
* @param executor - Pluggable LLM executor.
|
|
1294
|
+
* @param watcher - Watcher HTTP client.
|
|
1295
|
+
* @returns A bound engine instance.
|
|
1296
|
+
*/
|
|
1297
|
+
function createSynthEngine(config, executor, watcher) {
|
|
1298
|
+
return {
|
|
1299
|
+
config,
|
|
1300
|
+
synthesize() {
|
|
1301
|
+
return orchestrate(config, executor, watcher);
|
|
1302
|
+
},
|
|
1303
|
+
synthesizePath(ownerPath) {
|
|
1304
|
+
const scopedConfig = { ...config, watchPaths: [ownerPath] };
|
|
1305
|
+
return orchestrate(scopedConfig, executor, watcher);
|
|
1306
|
+
},
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* HTTP implementation of the WatcherClient interface.
|
|
1312
|
+
*
|
|
1313
|
+
* Talks to jeeves-watcher's POST /scan and POST /rules endpoints
|
|
1314
|
+
* with retry and exponential backoff.
|
|
1315
|
+
*
|
|
1316
|
+
* @module watcher-client/HttpWatcherClient
|
|
1317
|
+
*/
|
|
1318
|
+
/** Default retry configuration. */
|
|
1319
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
1320
|
+
const DEFAULT_BACKOFF_BASE_MS = 1000;
|
|
1321
|
+
const DEFAULT_BACKOFF_FACTOR = 4;
|
|
1322
|
+
/** Sleep for a given number of milliseconds. */
|
|
1323
|
+
function sleep(ms) {
|
|
1324
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1325
|
+
}
|
|
1326
|
+
/** Check if an error is transient (worth retrying). */
|
|
1327
|
+
function isTransient(status) {
|
|
1328
|
+
return status >= 500 || status === 408 || status === 429;
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* HTTP-based WatcherClient implementation with retry.
|
|
1332
|
+
*/
|
|
1333
|
+
class HttpWatcherClient {
|
|
1334
|
+
baseUrl;
|
|
1335
|
+
maxRetries;
|
|
1336
|
+
backoffBaseMs;
|
|
1337
|
+
backoffFactor;
|
|
1338
|
+
constructor(options) {
|
|
1339
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
1340
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1341
|
+
this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
|
|
1342
|
+
this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
|
|
1343
|
+
}
|
|
1344
|
+
/** POST JSON with retry. */
|
|
1345
|
+
async post(endpoint, body) {
|
|
1346
|
+
const url = this.baseUrl + endpoint;
|
|
1347
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
1348
|
+
const res = await fetch(url, {
|
|
1349
|
+
method: 'POST',
|
|
1350
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1351
|
+
body: JSON.stringify(body),
|
|
1352
|
+
});
|
|
1353
|
+
if (res.ok) {
|
|
1354
|
+
return res.json();
|
|
1355
|
+
}
|
|
1356
|
+
if (!isTransient(res.status) || attempt === this.maxRetries) {
|
|
1357
|
+
const text = await res.text();
|
|
1358
|
+
throw new Error(`Watcher ${endpoint} failed: HTTP ${res.status.toString()} - ${text}`);
|
|
1359
|
+
}
|
|
1360
|
+
// Exponential backoff
|
|
1361
|
+
const delayMs = this.backoffBaseMs * Math.pow(this.backoffFactor, attempt);
|
|
1362
|
+
await sleep(delayMs);
|
|
1363
|
+
}
|
|
1364
|
+
// Unreachable, but TypeScript needs it
|
|
1365
|
+
throw new Error('Retry exhausted');
|
|
1366
|
+
}
|
|
1367
|
+
async scan(params) {
|
|
1368
|
+
const body = {
|
|
1369
|
+
pathPrefix: params.pathPrefix,
|
|
1370
|
+
};
|
|
1371
|
+
if (params.modifiedAfter !== undefined) {
|
|
1372
|
+
body.modifiedAfter = params.modifiedAfter;
|
|
1373
|
+
}
|
|
1374
|
+
if (params.fields !== undefined) {
|
|
1375
|
+
body.fields = params.fields;
|
|
1376
|
+
}
|
|
1377
|
+
if (params.limit !== undefined) {
|
|
1378
|
+
body.limit = params.limit;
|
|
1379
|
+
}
|
|
1380
|
+
if (params.cursor !== undefined) {
|
|
1381
|
+
body.cursor = params.cursor;
|
|
1382
|
+
}
|
|
1383
|
+
const result = await this.post('/scan', body);
|
|
1384
|
+
return result;
|
|
1385
|
+
}
|
|
1386
|
+
async registerRules(source, rules) {
|
|
1387
|
+
await this.post('/rules/register', { source, rules });
|
|
1388
|
+
}
|
|
1389
|
+
async unregisterRules(source) {
|
|
1390
|
+
await this.post('/rules/unregister', { source });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
export { HttpWatcherClient, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, computeEffectiveStaleness, computeEma, computeStructureHash, createSnapshot, createSynthEngine, ensureMetaJson, filterInScope, getScopeExclusions, getScopePrefix, globMetas, isLocked, isStale, listArchiveFiles, mergeAndWrite, metaJsonSchema, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, releaseLock, selectCandidate, synthConfigSchema, synthErrorSchema, toSynthError };
|