@karmaniverous/jeeves-meta 0.3.3 → 0.4.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 +44 -33
- package/dist/cli/jeeves-meta/index.js +3799 -0
- package/dist/index.d.ts +535 -101
- package/dist/index.js +2109 -411
- package/package.json +20 -25
- package/dist/cli.js +0 -2186
|
@@ -0,0 +1,3799 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import pino from 'pino';
|
|
8
|
+
import { Cron } from 'croner';
|
|
9
|
+
import Fastify from 'fastify';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Zod schema for jeeves-meta service configuration.
|
|
13
|
+
*
|
|
14
|
+
* The service config is a strict superset of the core (library-compatible) meta config.
|
|
15
|
+
*
|
|
16
|
+
* @module schema/config
|
|
17
|
+
*/
|
|
18
|
+
/** Zod schema for the core (library-compatible) meta configuration. */
|
|
19
|
+
const metaConfigSchema = z.object({
|
|
20
|
+
/** Watcher service base URL. */
|
|
21
|
+
watcherUrl: z.url(),
|
|
22
|
+
/** OpenClaw gateway base URL for subprocess spawning. */
|
|
23
|
+
gatewayUrl: z.url().default('http://127.0.0.1:18789'),
|
|
24
|
+
/** Optional API key for gateway authentication. */
|
|
25
|
+
gatewayApiKey: z.string().optional(),
|
|
26
|
+
/** Run architect every N cycles (per meta). */
|
|
27
|
+
architectEvery: z.number().int().min(1).default(10),
|
|
28
|
+
/** Exponent for depth weighting in staleness formula. */
|
|
29
|
+
depthWeight: z.number().min(0).default(0.5),
|
|
30
|
+
/** Maximum archive snapshots to retain per meta. */
|
|
31
|
+
maxArchive: z.number().int().min(1).default(20),
|
|
32
|
+
/** Maximum lines of context to include in subprocess prompts. */
|
|
33
|
+
maxLines: z.number().int().min(50).default(500),
|
|
34
|
+
/** Architect subprocess timeout in seconds. */
|
|
35
|
+
architectTimeout: z.number().int().min(30).default(120),
|
|
36
|
+
/** Builder subprocess timeout in seconds. */
|
|
37
|
+
builderTimeout: z.number().int().min(60).default(600),
|
|
38
|
+
/** Critic subprocess timeout in seconds. */
|
|
39
|
+
criticTimeout: z.number().int().min(30).default(300),
|
|
40
|
+
/** Thinking level for spawned synthesis sessions. */
|
|
41
|
+
thinking: z.string().default('low'),
|
|
42
|
+
/** Resolved architect system prompt text. */
|
|
43
|
+
defaultArchitect: z.string(),
|
|
44
|
+
/** Resolved critic system prompt text. */
|
|
45
|
+
defaultCritic: z.string(),
|
|
46
|
+
/** Skip unchanged candidates, bump _generatedAt. */
|
|
47
|
+
skipUnchanged: z.boolean().default(true),
|
|
48
|
+
/** Watcher metadata properties applied to live .meta/meta.json files. */
|
|
49
|
+
metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
|
|
50
|
+
/** Watcher metadata properties applied to archive snapshots. */
|
|
51
|
+
metaArchiveProperty: z
|
|
52
|
+
.record(z.string(), z.unknown())
|
|
53
|
+
.default({ _meta: 'archive' }),
|
|
54
|
+
});
|
|
55
|
+
/** Zod schema for logging configuration. */
|
|
56
|
+
const loggingSchema = z.object({
|
|
57
|
+
/** Log level. */
|
|
58
|
+
level: z.string().default('info'),
|
|
59
|
+
/** Optional file path for log output. */
|
|
60
|
+
file: z.string().optional(),
|
|
61
|
+
});
|
|
62
|
+
/** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
|
|
63
|
+
const serviceConfigSchema = metaConfigSchema.extend({
|
|
64
|
+
/** HTTP port for the service (default: 1938). */
|
|
65
|
+
port: z.number().int().min(1).max(65535).default(1938),
|
|
66
|
+
/** Cron schedule for synthesis cycles (default: every 30 min). */
|
|
67
|
+
schedule: z.string().default('*/30 * * * *'),
|
|
68
|
+
/** Optional channel identifier for reporting. */
|
|
69
|
+
reportChannel: z.string().optional(),
|
|
70
|
+
/** Logging configuration. */
|
|
71
|
+
logging: loggingSchema.default(() => loggingSchema.parse({})),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load and resolve jeeves-meta service config.
|
|
76
|
+
*
|
|
77
|
+
* Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
|
|
78
|
+
*
|
|
79
|
+
* @module configLoader
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
|
|
83
|
+
*
|
|
84
|
+
* @param value - Arbitrary JSON-compatible value.
|
|
85
|
+
* @returns Value with env-var placeholders resolved.
|
|
86
|
+
*/
|
|
87
|
+
function substituteEnvVars(value) {
|
|
88
|
+
if (typeof value === 'string') {
|
|
89
|
+
return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
|
|
90
|
+
const envVal = process.env[name];
|
|
91
|
+
if (envVal === undefined) {
|
|
92
|
+
throw new Error(`Environment variable ${name} is not set`);
|
|
93
|
+
}
|
|
94
|
+
return envVal;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
return value.map(substituteEnvVars);
|
|
99
|
+
}
|
|
100
|
+
if (value !== null && typeof value === 'object') {
|
|
101
|
+
const result = {};
|
|
102
|
+
for (const [key, val] of Object.entries(value)) {
|
|
103
|
+
result[key] = substituteEnvVars(val);
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve \@file: references in a config value.
|
|
111
|
+
*
|
|
112
|
+
* @param value - String value that may start with "\@file:".
|
|
113
|
+
* @param baseDir - Base directory for resolving relative paths.
|
|
114
|
+
* @returns The resolved string (file contents or original value).
|
|
115
|
+
*/
|
|
116
|
+
function resolveFileRef(value, baseDir) {
|
|
117
|
+
if (!value.startsWith('@file:'))
|
|
118
|
+
return value;
|
|
119
|
+
const filePath = join(baseDir, value.slice(6));
|
|
120
|
+
return readFileSync(filePath, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolve config path from --config flag or JEEVES_META_CONFIG env var.
|
|
124
|
+
*
|
|
125
|
+
* @param args - CLI arguments (process.argv.slice(2)).
|
|
126
|
+
* @returns Resolved config path.
|
|
127
|
+
* @throws If no config path found.
|
|
128
|
+
*/
|
|
129
|
+
function resolveConfigPath(args) {
|
|
130
|
+
let configIdx = args.indexOf('--config');
|
|
131
|
+
if (configIdx === -1)
|
|
132
|
+
configIdx = args.indexOf('-c');
|
|
133
|
+
if (configIdx !== -1 && args[configIdx + 1]) {
|
|
134
|
+
return args[configIdx + 1];
|
|
135
|
+
}
|
|
136
|
+
const envPath = process.env['JEEVES_META_CONFIG'];
|
|
137
|
+
if (envPath)
|
|
138
|
+
return envPath;
|
|
139
|
+
throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Load service config from a JSON file.
|
|
143
|
+
*
|
|
144
|
+
* Resolves \@file: references for defaultArchitect and defaultCritic,
|
|
145
|
+
* and substitutes environment-variable placeholders throughout.
|
|
146
|
+
*
|
|
147
|
+
* @param configPath - Path to config JSON file.
|
|
148
|
+
* @returns Validated ServiceConfig.
|
|
149
|
+
*/
|
|
150
|
+
function loadServiceConfig(configPath) {
|
|
151
|
+
const rawText = readFileSync(configPath, 'utf8');
|
|
152
|
+
const raw = substituteEnvVars(JSON.parse(rawText));
|
|
153
|
+
const baseDir = dirname(configPath);
|
|
154
|
+
if (typeof raw['defaultArchitect'] === 'string') {
|
|
155
|
+
raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
|
|
156
|
+
}
|
|
157
|
+
if (typeof raw['defaultCritic'] === 'string') {
|
|
158
|
+
raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
|
|
159
|
+
}
|
|
160
|
+
return serviceConfigSchema.parse(raw);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
var configLoader = /*#__PURE__*/Object.freeze({
|
|
164
|
+
__proto__: null,
|
|
165
|
+
loadServiceConfig: loadServiceConfig,
|
|
166
|
+
resolveConfigPath: resolveConfigPath
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* List archive snapshot files in chronological order.
|
|
171
|
+
*
|
|
172
|
+
* @module archive/listArchive
|
|
173
|
+
*/
|
|
174
|
+
/**
|
|
175
|
+
* List archive .json files sorted chronologically (oldest first).
|
|
176
|
+
*
|
|
177
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
178
|
+
* @returns Array of absolute paths to archive files, or empty if none.
|
|
179
|
+
*/
|
|
180
|
+
function listArchiveFiles(metaPath) {
|
|
181
|
+
const archiveDir = join(metaPath, 'archive');
|
|
182
|
+
try {
|
|
183
|
+
return readdirSync(archiveDir)
|
|
184
|
+
.filter((f) => f.endsWith('.json'))
|
|
185
|
+
.sort()
|
|
186
|
+
.map((f) => join(archiveDir, f));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Prune old archive snapshots beyond maxArchive.
|
|
195
|
+
*
|
|
196
|
+
* @module archive/prune
|
|
197
|
+
*/
|
|
198
|
+
/**
|
|
199
|
+
* Prune archive directory to keep at most maxArchive snapshots.
|
|
200
|
+
* Removes the oldest files.
|
|
201
|
+
*
|
|
202
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
203
|
+
* @param maxArchive - Maximum snapshots to retain.
|
|
204
|
+
* @returns Number of files pruned.
|
|
205
|
+
*/
|
|
206
|
+
function pruneArchive(metaPath, maxArchive) {
|
|
207
|
+
const files = listArchiveFiles(metaPath);
|
|
208
|
+
const toRemove = files.length - maxArchive;
|
|
209
|
+
if (toRemove <= 0)
|
|
210
|
+
return 0;
|
|
211
|
+
for (let i = 0; i < toRemove; i++) {
|
|
212
|
+
unlinkSync(files[i]);
|
|
213
|
+
}
|
|
214
|
+
return toRemove;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read the latest archive snapshot for steer change detection.
|
|
219
|
+
*
|
|
220
|
+
* @module archive/readLatest
|
|
221
|
+
*/
|
|
222
|
+
/**
|
|
223
|
+
* Read the most recent archive snapshot.
|
|
224
|
+
*
|
|
225
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
226
|
+
* @returns The latest archived meta, or null if no archives exist.
|
|
227
|
+
*/
|
|
228
|
+
function readLatestArchive(metaPath) {
|
|
229
|
+
const files = listArchiveFiles(metaPath);
|
|
230
|
+
if (files.length === 0)
|
|
231
|
+
return null;
|
|
232
|
+
const raw = readFileSync(files[files.length - 1], 'utf8');
|
|
233
|
+
return JSON.parse(raw);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Create archive snapshots of meta.json.
|
|
238
|
+
*
|
|
239
|
+
* Copies current meta.json to archive/\{ISO-timestamp\}.json with
|
|
240
|
+
* _archived: true and _archivedAt added.
|
|
241
|
+
*
|
|
242
|
+
* @module archive/snapshot
|
|
243
|
+
*/
|
|
244
|
+
/**
|
|
245
|
+
* Create an archive snapshot of the current meta.json.
|
|
246
|
+
*
|
|
247
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
248
|
+
* @param meta - Current meta.json content.
|
|
249
|
+
* @returns The archive file path.
|
|
250
|
+
*/
|
|
251
|
+
function createSnapshot(metaPath, meta) {
|
|
252
|
+
const archiveDir = join(metaPath, 'archive');
|
|
253
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
254
|
+
const now = new Date().toISOString().replace(/[:.]/g, '-');
|
|
255
|
+
const archiveFile = join(archiveDir, now + '.json');
|
|
256
|
+
const archived = {
|
|
257
|
+
...meta,
|
|
258
|
+
_archived: true,
|
|
259
|
+
_archivedAt: new Date().toISOString(),
|
|
260
|
+
};
|
|
261
|
+
writeFileSync(archiveFile, JSON.stringify(archived, null, 2) + '\n');
|
|
262
|
+
return archiveFile;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Normalize file paths to forward slashes for consistency with watcher-indexed paths.
|
|
267
|
+
*
|
|
268
|
+
* Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
|
|
269
|
+
* ensures all paths in the library use the same convention, regardless of
|
|
270
|
+
* the platform's native separator.
|
|
271
|
+
*
|
|
272
|
+
* @module normalizePath
|
|
273
|
+
*/
|
|
274
|
+
/**
|
|
275
|
+
* Normalize a file path to forward slashes.
|
|
276
|
+
*
|
|
277
|
+
* @param p - File path (may contain backslashes).
|
|
278
|
+
* @returns Path with all backslashes replaced by forward slashes.
|
|
279
|
+
*/
|
|
280
|
+
function normalizePath(p) {
|
|
281
|
+
return p.replaceAll('\\', '/');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Paginated scan helper for exhaustive scope enumeration.
|
|
286
|
+
*
|
|
287
|
+
* @module paginatedScan
|
|
288
|
+
*/
|
|
289
|
+
/**
|
|
290
|
+
* Perform a paginated scan that follows cursor tokens until exhausted.
|
|
291
|
+
*
|
|
292
|
+
* @param watcher - WatcherClient instance.
|
|
293
|
+
* @param params - Base scan parameters (cursor is managed internally).
|
|
294
|
+
* @returns All matching files across all pages.
|
|
295
|
+
*/
|
|
296
|
+
async function paginatedScan(watcher, params) {
|
|
297
|
+
const allFiles = [];
|
|
298
|
+
let cursor;
|
|
299
|
+
do {
|
|
300
|
+
const result = await watcher.scan({ ...params, cursor });
|
|
301
|
+
allFiles.push(...result.files);
|
|
302
|
+
cursor = result.next;
|
|
303
|
+
} while (cursor);
|
|
304
|
+
return allFiles;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Discover .meta/ directories via watcher scan.
|
|
309
|
+
*
|
|
310
|
+
* Replaces filesystem-based globMetas() with a watcher query
|
|
311
|
+
* that returns indexed .meta/meta.json points, filtered by domain.
|
|
312
|
+
*
|
|
313
|
+
* @module discovery/discoverMetas
|
|
314
|
+
*/
|
|
315
|
+
/**
|
|
316
|
+
* Build a single Qdrant filter clause from a key-value pair.
|
|
317
|
+
*
|
|
318
|
+
* Arrays use `match.value` on the first element (Qdrant array membership).
|
|
319
|
+
* Scalars (string, number, boolean) use `match.value` directly.
|
|
320
|
+
* Objects and other non-filterable types are skipped with a warning.
|
|
321
|
+
*/
|
|
322
|
+
function buildMatchClause(key, value) {
|
|
323
|
+
if (Array.isArray(value)) {
|
|
324
|
+
if (value.length === 0)
|
|
325
|
+
return null;
|
|
326
|
+
return { key, match: { value: value[0] } };
|
|
327
|
+
}
|
|
328
|
+
if (typeof value === 'string' ||
|
|
329
|
+
typeof value === 'number' ||
|
|
330
|
+
typeof value === 'boolean') {
|
|
331
|
+
return { key, match: { value } };
|
|
332
|
+
}
|
|
333
|
+
// Non-filterable value (object, null, etc.) — valid for tagging but
|
|
334
|
+
// cannot be expressed as a Qdrant match clause.
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Build a Qdrant filter from config metaProperty.
|
|
339
|
+
*
|
|
340
|
+
* Iterates all key-value pairs in `metaProperty` (a generic record)
|
|
341
|
+
* to construct `must` clauses. Always appends `file_path: meta.json`
|
|
342
|
+
* for deduplication.
|
|
343
|
+
*
|
|
344
|
+
* @param config - Meta config with metaProperty.
|
|
345
|
+
* @returns Qdrant filter object for scanning live metas.
|
|
346
|
+
*/
|
|
347
|
+
function buildMetaFilter(config) {
|
|
348
|
+
const must = [];
|
|
349
|
+
for (const [key, value] of Object.entries(config.metaProperty)) {
|
|
350
|
+
const clause = buildMatchClause(key, value);
|
|
351
|
+
if (clause)
|
|
352
|
+
must.push(clause);
|
|
353
|
+
}
|
|
354
|
+
must.push({
|
|
355
|
+
key: 'file_path',
|
|
356
|
+
match: { text: '.meta/meta.json' },
|
|
357
|
+
});
|
|
358
|
+
return { must };
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Discover all .meta/ directories via watcher scan.
|
|
362
|
+
*
|
|
363
|
+
* Queries the watcher for indexed .meta/meta.json points using the
|
|
364
|
+
* configured domain filter. Returns deduplicated meta directory paths.
|
|
365
|
+
*
|
|
366
|
+
* @param config - Meta config (for domain filter).
|
|
367
|
+
* @param watcher - WatcherClient for scan queries.
|
|
368
|
+
* @returns Array of normalized .meta/ directory paths.
|
|
369
|
+
*/
|
|
370
|
+
async function discoverMetas(config, watcher) {
|
|
371
|
+
const filter = buildMetaFilter(config);
|
|
372
|
+
const scanFiles = await paginatedScan(watcher, {
|
|
373
|
+
filter,
|
|
374
|
+
fields: ['file_path'],
|
|
375
|
+
});
|
|
376
|
+
// Deduplicate by .meta/ directory path (handles multi-chunk files)
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
const metaPaths = [];
|
|
379
|
+
for (const sf of scanFiles) {
|
|
380
|
+
const fp = normalizePath(sf.file_path);
|
|
381
|
+
// Derive .meta/ directory from file_path (strip /meta.json)
|
|
382
|
+
const metaPath = fp.replace(/\/meta\.json$/, '');
|
|
383
|
+
if (seen.has(metaPath))
|
|
384
|
+
continue;
|
|
385
|
+
seen.add(metaPath);
|
|
386
|
+
metaPaths.push(metaPath);
|
|
387
|
+
}
|
|
388
|
+
return metaPaths;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* File-system lock for preventing concurrent synthesis on the same meta.
|
|
393
|
+
*
|
|
394
|
+
* Lock file: .meta/.lock containing `_lockPid` + `_lockStartedAt` (underscore-prefixed
|
|
395
|
+
* reserved keys, consistent with meta.json conventions).
|
|
396
|
+
* Stale timeout: 30 minutes.
|
|
397
|
+
*
|
|
398
|
+
* @module lock
|
|
399
|
+
*/
|
|
400
|
+
const LOCK_FILE = '.lock';
|
|
401
|
+
/**
|
|
402
|
+
* Resolve a path to a .meta directory.
|
|
403
|
+
*
|
|
404
|
+
* If the path already ends with '.meta', returns it as-is.
|
|
405
|
+
* Otherwise, appends '.meta' as a subdirectory.
|
|
406
|
+
*
|
|
407
|
+
* @param inputPath - Path that may or may not end with '.meta'.
|
|
408
|
+
* @returns The resolved .meta directory path.
|
|
409
|
+
*/
|
|
410
|
+
function resolveMetaDir(inputPath) {
|
|
411
|
+
return inputPath.endsWith('.meta') ? inputPath : join(inputPath, '.meta');
|
|
412
|
+
}
|
|
413
|
+
const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
414
|
+
/**
|
|
415
|
+
* Read and classify the state of a .meta/.lock file.
|
|
416
|
+
*
|
|
417
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
418
|
+
* @returns Parsed lock state.
|
|
419
|
+
*/
|
|
420
|
+
function readLockState(metaPath) {
|
|
421
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
422
|
+
if (!existsSync(lockPath)) {
|
|
423
|
+
return { exists: false, staged: false, active: false, data: null };
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const raw = readFileSync(lockPath, 'utf8');
|
|
427
|
+
const data = JSON.parse(raw);
|
|
428
|
+
if ('_id' in data) {
|
|
429
|
+
return { exists: true, staged: true, active: false, data };
|
|
430
|
+
}
|
|
431
|
+
const startedAt = data._lockStartedAt;
|
|
432
|
+
if (startedAt) {
|
|
433
|
+
const lockAge = Date.now() - new Date(startedAt).getTime();
|
|
434
|
+
return {
|
|
435
|
+
exists: true,
|
|
436
|
+
staged: false,
|
|
437
|
+
active: lockAge < STALE_TIMEOUT_MS,
|
|
438
|
+
data,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return { exists: true, staged: false, active: false, data };
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return { exists: true, staged: false, active: false, data: null };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Attempt to acquire a lock on a .meta directory.
|
|
449
|
+
*
|
|
450
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
451
|
+
* @returns True if lock was acquired, false if already locked (non-stale).
|
|
452
|
+
*/
|
|
453
|
+
function acquireLock(metaPath) {
|
|
454
|
+
const state = readLockState(metaPath);
|
|
455
|
+
// Active non-stale lock — cannot acquire
|
|
456
|
+
if (state.active)
|
|
457
|
+
return false;
|
|
458
|
+
// Staged, stale, corrupt, or missing — safe to (over)write
|
|
459
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
460
|
+
const lock = {
|
|
461
|
+
_lockPid: process.pid,
|
|
462
|
+
_lockStartedAt: new Date().toISOString(),
|
|
463
|
+
};
|
|
464
|
+
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Release a lock on a .meta directory.
|
|
469
|
+
*
|
|
470
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
471
|
+
*/
|
|
472
|
+
function releaseLock(metaPath) {
|
|
473
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
474
|
+
try {
|
|
475
|
+
unlinkSync(lockPath);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// Already removed or never existed
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Check if a .meta directory is currently locked (non-stale).
|
|
483
|
+
*
|
|
484
|
+
* @param metaPath - Absolute path to the .meta directory.
|
|
485
|
+
* @returns True if locked and not stale.
|
|
486
|
+
*/
|
|
487
|
+
function isLocked(metaPath) {
|
|
488
|
+
return readLockState(metaPath).active;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Clean up stale lock files on startup.
|
|
492
|
+
*
|
|
493
|
+
* For each .meta directory found via the provided paths:
|
|
494
|
+
* - If lock contains PID-only data (synthesis incomplete), delete it.
|
|
495
|
+
* - If lock contains staged result (_id present), log warning and delete.
|
|
496
|
+
*
|
|
497
|
+
* @param metaPaths - Array of .meta directory paths to check.
|
|
498
|
+
* @param logger - Optional logger for warnings.
|
|
499
|
+
*/
|
|
500
|
+
function cleanupStaleLocks(metaPaths, logger) {
|
|
501
|
+
for (const metaPath of metaPaths) {
|
|
502
|
+
const state = readLockState(metaPath);
|
|
503
|
+
if (!state.exists)
|
|
504
|
+
continue;
|
|
505
|
+
const lockPath = join(metaPath, LOCK_FILE);
|
|
506
|
+
if (state.staged) {
|
|
507
|
+
logger?.warn({ metaPath }, 'Found staged synthesis result in lock file from previous crash — deleting (conservative: not auto-finalizing)');
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
logger?.warn({ metaPath }, 'Found stale lock file from previous crash — deleting');
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
unlinkSync(lockPath);
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// Already gone
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Build the ownership tree from discovered .meta/ paths.
|
|
523
|
+
*
|
|
524
|
+
* Each .meta/ directory owns its parent directory and all descendants,
|
|
525
|
+
* except subtrees that contain their own .meta/. For those subtrees,
|
|
526
|
+
* the parent meta consumes the child meta's synthesis output.
|
|
527
|
+
*
|
|
528
|
+
* @module discovery/ownershipTree
|
|
529
|
+
*/
|
|
530
|
+
/**
|
|
531
|
+
* Build an ownership tree from an array of .meta/ directory paths.
|
|
532
|
+
*
|
|
533
|
+
* @param metaPaths - Absolute paths to .meta/ directories.
|
|
534
|
+
* @returns The ownership tree with parent/child relationships.
|
|
535
|
+
*/
|
|
536
|
+
function buildOwnershipTree(metaPaths) {
|
|
537
|
+
const nodes = new Map();
|
|
538
|
+
// Create nodes, sorted by ownerPath length (shortest first = shallowest)
|
|
539
|
+
const sorted = [...metaPaths]
|
|
540
|
+
.map((mp) => ({
|
|
541
|
+
metaPath: normalizePath(mp),
|
|
542
|
+
ownerPath: normalizePath(dirname(mp)),
|
|
543
|
+
}))
|
|
544
|
+
.sort((a, b) => a.ownerPath.length - b.ownerPath.length);
|
|
545
|
+
for (const { metaPath, ownerPath } of sorted) {
|
|
546
|
+
nodes.set(metaPath, {
|
|
547
|
+
metaPath,
|
|
548
|
+
ownerPath,
|
|
549
|
+
treeDepth: 0,
|
|
550
|
+
children: [],
|
|
551
|
+
parent: null,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const roots = [];
|
|
555
|
+
// For each node, find its closest ancestor meta
|
|
556
|
+
for (const node of nodes.values()) {
|
|
557
|
+
let bestParent = null;
|
|
558
|
+
let bestParentLen = -1;
|
|
559
|
+
for (const candidate of nodes.values()) {
|
|
560
|
+
if (candidate === node)
|
|
561
|
+
continue;
|
|
562
|
+
// Check if node's ownerPath is under candidate's ownerPath
|
|
563
|
+
const rel = relative(candidate.ownerPath, node.ownerPath);
|
|
564
|
+
if (rel.startsWith('..') || rel === '')
|
|
565
|
+
continue;
|
|
566
|
+
// candidate.ownerPath is an ancestor of node.ownerPath
|
|
567
|
+
if (candidate.ownerPath.length > bestParentLen) {
|
|
568
|
+
bestParent = candidate;
|
|
569
|
+
bestParentLen = candidate.ownerPath.length;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (bestParent) {
|
|
573
|
+
node.parent = bestParent;
|
|
574
|
+
node.treeDepth = bestParent.treeDepth + 1;
|
|
575
|
+
bestParent.children.push(node);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
roots.push(node);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return { nodes, roots };
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Find a node in the ownership tree by meta path or owner path.
|
|
585
|
+
*
|
|
586
|
+
* @param tree - The ownership tree to search.
|
|
587
|
+
* @param targetPath - Path to search for (meta path or owner path).
|
|
588
|
+
* @returns The matching node, or undefined if not found.
|
|
589
|
+
*/
|
|
590
|
+
function findNode(tree, targetPath) {
|
|
591
|
+
return Array.from(tree.nodes.values()).find((n) => n.metaPath === targetPath || n.ownerPath === targetPath);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Unified meta listing: scan, dedup, enrich.
|
|
596
|
+
*
|
|
597
|
+
* Single source of truth for all consumers that need a list of metas
|
|
598
|
+
* with enriched metadata. Replaces duplicated scan+dedup logic in
|
|
599
|
+
* plugin tools, CLI, and prompt injection.
|
|
600
|
+
*
|
|
601
|
+
* @module discovery/listMetas
|
|
602
|
+
*/
|
|
603
|
+
/**
|
|
604
|
+
* Discover, deduplicate, and enrich all metas.
|
|
605
|
+
*
|
|
606
|
+
* This is the single consolidated function that replaces all duplicated
|
|
607
|
+
* scan+dedup+enrich logic across the codebase. All enrichment comes from
|
|
608
|
+
* reading meta.json on disk (the canonical source).
|
|
609
|
+
*
|
|
610
|
+
* @param config - Validated synthesis config.
|
|
611
|
+
* @param watcher - Watcher HTTP client for discovery.
|
|
612
|
+
* @returns Enriched meta list with summary statistics and ownership tree.
|
|
613
|
+
*/
|
|
614
|
+
async function listMetas(config, watcher) {
|
|
615
|
+
// Step 1: Discover deduplicated meta paths via watcher scan
|
|
616
|
+
const metaPaths = await discoverMetas(config, watcher);
|
|
617
|
+
// Step 2: Build ownership tree
|
|
618
|
+
const tree = buildOwnershipTree(metaPaths);
|
|
619
|
+
// Step 3: Read and enrich each meta from disk
|
|
620
|
+
const entries = [];
|
|
621
|
+
let staleCount = 0;
|
|
622
|
+
let errorCount = 0;
|
|
623
|
+
let lockedCount = 0;
|
|
624
|
+
let neverSynthesizedCount = 0;
|
|
625
|
+
let totalArchTokens = 0;
|
|
626
|
+
let totalBuilderTokens = 0;
|
|
627
|
+
let totalCriticTokens = 0;
|
|
628
|
+
let lastSynthPath = null;
|
|
629
|
+
let lastSynthAt = null;
|
|
630
|
+
let stalestPath = null;
|
|
631
|
+
let stalestEffective = -1;
|
|
632
|
+
for (const node of tree.nodes.values()) {
|
|
633
|
+
let meta;
|
|
634
|
+
try {
|
|
635
|
+
meta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// Skip unreadable metas
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const depth = meta._depth ?? node.treeDepth;
|
|
642
|
+
const emphasis = meta._emphasis ?? 1;
|
|
643
|
+
const hasError = Boolean(meta._error);
|
|
644
|
+
const locked = isLocked(normalizePath(node.metaPath));
|
|
645
|
+
const neverSynth = !meta._generatedAt;
|
|
646
|
+
// Compute staleness
|
|
647
|
+
let stalenessSeconds;
|
|
648
|
+
if (neverSynth) {
|
|
649
|
+
stalenessSeconds = Infinity;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
const genAt = new Date(meta._generatedAt).getTime();
|
|
653
|
+
stalenessSeconds = Math.max(0, Math.floor((Date.now() - genAt) / 1000));
|
|
654
|
+
}
|
|
655
|
+
// Tokens
|
|
656
|
+
const archTokens = meta._architectTokens ?? 0;
|
|
657
|
+
const buildTokens = meta._builderTokens ?? 0;
|
|
658
|
+
const critTokens = meta._criticTokens ?? 0;
|
|
659
|
+
// Accumulate summary stats
|
|
660
|
+
if (stalenessSeconds > 0)
|
|
661
|
+
staleCount++;
|
|
662
|
+
if (hasError)
|
|
663
|
+
errorCount++;
|
|
664
|
+
if (locked)
|
|
665
|
+
lockedCount++;
|
|
666
|
+
if (neverSynth)
|
|
667
|
+
neverSynthesizedCount++;
|
|
668
|
+
totalArchTokens += archTokens;
|
|
669
|
+
totalBuilderTokens += buildTokens;
|
|
670
|
+
totalCriticTokens += critTokens;
|
|
671
|
+
// Track last synthesized
|
|
672
|
+
if (meta._generatedAt) {
|
|
673
|
+
if (!lastSynthAt || meta._generatedAt > lastSynthAt) {
|
|
674
|
+
lastSynthAt = meta._generatedAt;
|
|
675
|
+
lastSynthPath = node.metaPath;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// Track stalest (effective staleness for scheduling)
|
|
679
|
+
const depthFactor = Math.pow(1 + config.depthWeight, depth);
|
|
680
|
+
const effectiveStaleness = (stalenessSeconds === Infinity
|
|
681
|
+
? Number.MAX_SAFE_INTEGER
|
|
682
|
+
: stalenessSeconds) *
|
|
683
|
+
depthFactor *
|
|
684
|
+
emphasis;
|
|
685
|
+
if (effectiveStaleness > stalestEffective) {
|
|
686
|
+
stalestEffective = effectiveStaleness;
|
|
687
|
+
stalestPath = node.metaPath;
|
|
688
|
+
}
|
|
689
|
+
entries.push({
|
|
690
|
+
path: node.metaPath,
|
|
691
|
+
depth,
|
|
692
|
+
emphasis,
|
|
693
|
+
stalenessSeconds,
|
|
694
|
+
lastSynthesized: meta._generatedAt ?? null,
|
|
695
|
+
hasError,
|
|
696
|
+
locked,
|
|
697
|
+
architectTokens: archTokens > 0 ? archTokens : null,
|
|
698
|
+
builderTokens: buildTokens > 0 ? buildTokens : null,
|
|
699
|
+
criticTokens: critTokens > 0 ? critTokens : null,
|
|
700
|
+
children: node.children.length,
|
|
701
|
+
node,
|
|
702
|
+
meta,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
summary: {
|
|
707
|
+
total: entries.length,
|
|
708
|
+
stale: staleCount,
|
|
709
|
+
errors: errorCount,
|
|
710
|
+
locked: lockedCount,
|
|
711
|
+
neverSynthesized: neverSynthesizedCount,
|
|
712
|
+
tokens: {
|
|
713
|
+
architect: totalArchTokens,
|
|
714
|
+
builder: totalBuilderTokens,
|
|
715
|
+
critic: totalCriticTokens,
|
|
716
|
+
},
|
|
717
|
+
stalestPath,
|
|
718
|
+
lastSynthesizedPath: lastSynthPath,
|
|
719
|
+
lastSynthesizedAt: lastSynthAt,
|
|
720
|
+
},
|
|
721
|
+
entries,
|
|
722
|
+
tree,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Compute the file scope owned by a meta node.
|
|
728
|
+
*
|
|
729
|
+
* A meta owns: parent dir + all descendants, minus child .meta/ subtrees.
|
|
730
|
+
* For child subtrees, it consumes the child's .meta/meta.json as a rollup input.
|
|
731
|
+
*
|
|
732
|
+
* @module discovery/scope
|
|
733
|
+
*/
|
|
734
|
+
/**
|
|
735
|
+
* 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
|
+
*/
|
|
743
|
+
function getScopePrefix(node) {
|
|
744
|
+
return node.ownerPath;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Filter a list of file paths to only those in scope for a meta node.
|
|
748
|
+
*
|
|
749
|
+
* Includes files under ownerPath, excludes files under child meta ownerPaths,
|
|
750
|
+
* but includes child .meta/meta.json files as rollup inputs.
|
|
751
|
+
*
|
|
752
|
+
* @param node - The meta node.
|
|
753
|
+
* @param files - Array of file paths to filter.
|
|
754
|
+
* @returns Filtered array of in-scope file paths.
|
|
755
|
+
*/
|
|
756
|
+
function filterInScope(node, files) {
|
|
757
|
+
const prefix = node.ownerPath + '/';
|
|
758
|
+
const exclusions = node.children.map((c) => c.ownerPath + '/');
|
|
759
|
+
const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
|
|
760
|
+
return files.filter((f) => {
|
|
761
|
+
const normalized = f.split('\\').join('/');
|
|
762
|
+
// Must be under ownerPath
|
|
763
|
+
if (!normalized.startsWith(prefix) && normalized !== node.ownerPath)
|
|
764
|
+
return false;
|
|
765
|
+
// Check if under a child meta's subtree
|
|
766
|
+
for (const excl of exclusions) {
|
|
767
|
+
if (normalized.startsWith(excl)) {
|
|
768
|
+
// Exception: child meta.json files are included as rollup inputs
|
|
769
|
+
return childMetaJsons.has(normalized);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return true;
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Get all files in scope for a meta node via watcher scan.
|
|
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.
|
|
784
|
+
*/
|
|
785
|
+
async function getScopeFiles(node, watcher) {
|
|
786
|
+
const allScanFiles = await paginatedScan(watcher, {
|
|
787
|
+
pathPrefix: node.ownerPath,
|
|
788
|
+
});
|
|
789
|
+
const allFiles = allScanFiles.map((f) => f.file_path);
|
|
790
|
+
return {
|
|
791
|
+
scopeFiles: filterInScope(node, allFiles),
|
|
792
|
+
allFiles,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Get files modified since a given timestamp within a meta node's scope.
|
|
797
|
+
*
|
|
798
|
+
* 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
|
+
*/
|
|
806
|
+
async function getDeltaFiles(node, watcher, generatedAt, scopeFiles) {
|
|
807
|
+
if (!generatedAt)
|
|
808
|
+
return scopeFiles;
|
|
809
|
+
const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
|
|
810
|
+
const deltaScanFiles = await paginatedScan(watcher, {
|
|
811
|
+
pathPrefix: node.ownerPath,
|
|
812
|
+
modifiedAfter,
|
|
813
|
+
});
|
|
814
|
+
return filterInScope(node, deltaScanFiles.map((f) => f.file_path));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Exponential moving average helper for token tracking.
|
|
819
|
+
*
|
|
820
|
+
* @module ema
|
|
821
|
+
*/
|
|
822
|
+
const DEFAULT_DECAY = 0.3;
|
|
823
|
+
/**
|
|
824
|
+
* Compute exponential moving average.
|
|
825
|
+
*
|
|
826
|
+
* @param current - New observation.
|
|
827
|
+
* @param previous - Previous EMA value, or undefined for first observation.
|
|
828
|
+
* @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
|
|
829
|
+
* @returns Updated EMA.
|
|
830
|
+
*/
|
|
831
|
+
function computeEma(current, previous, decay = DEFAULT_DECAY) {
|
|
832
|
+
if (previous === undefined)
|
|
833
|
+
return current;
|
|
834
|
+
return decay * current + (1 - decay) * previous;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Shared error utilities.
|
|
839
|
+
*
|
|
840
|
+
* @module errors
|
|
841
|
+
*/
|
|
842
|
+
/**
|
|
843
|
+
* Wrap an unknown caught value into a MetaError.
|
|
844
|
+
*
|
|
845
|
+
* @param step - Which synthesis step failed.
|
|
846
|
+
* @param err - The caught error value.
|
|
847
|
+
* @param code - Error classification code.
|
|
848
|
+
* @returns A structured MetaError.
|
|
849
|
+
*/
|
|
850
|
+
function toMetaError(step, err, code = 'FAILED') {
|
|
851
|
+
return {
|
|
852
|
+
step,
|
|
853
|
+
code,
|
|
854
|
+
message: err instanceof Error ? err.message : String(err),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Compute a structure hash from a sorted file listing.
|
|
860
|
+
*
|
|
861
|
+
* Used to detect when directory structure changes, triggering
|
|
862
|
+
* an architect re-run.
|
|
863
|
+
*
|
|
864
|
+
* @module structureHash
|
|
865
|
+
*/
|
|
866
|
+
/**
|
|
867
|
+
* Compute a SHA-256 hash of a sorted file listing.
|
|
868
|
+
*
|
|
869
|
+
* @param filePaths - Array of file paths in scope.
|
|
870
|
+
* @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
|
|
871
|
+
*/
|
|
872
|
+
function computeStructureHash(filePaths) {
|
|
873
|
+
const sorted = [...filePaths].sort();
|
|
874
|
+
const content = sorted.join('\n');
|
|
875
|
+
return createHash('sha256').update(content).digest('hex');
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Sleep for a given number of milliseconds. */
|
|
879
|
+
function sleep(ms) {
|
|
880
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* MetaExecutor implementation using the OpenClaw gateway HTTP API.
|
|
885
|
+
*
|
|
886
|
+
* Lives in the library package so both plugin and runner can import it.
|
|
887
|
+
* Spawns sub-agent sessions via the gateway's `/tools/invoke` endpoint,
|
|
888
|
+
* polls for completion, and extracts output text.
|
|
889
|
+
*
|
|
890
|
+
* @module executor/GatewayExecutor
|
|
891
|
+
*/
|
|
892
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
893
|
+
const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
894
|
+
/**
|
|
895
|
+
* MetaExecutor that spawns OpenClaw sessions via the gateway's
|
|
896
|
+
* `/tools/invoke` endpoint.
|
|
897
|
+
*
|
|
898
|
+
* Used by both the OpenClaw plugin (in-process tool calls) and the
|
|
899
|
+
* runner/CLI (external invocation). Constructs from `gatewayUrl` and
|
|
900
|
+
* optional `apiKey` — typically sourced from `MetaConfig`.
|
|
901
|
+
*/
|
|
902
|
+
class GatewayExecutor {
|
|
903
|
+
gatewayUrl;
|
|
904
|
+
apiKey;
|
|
905
|
+
pollIntervalMs;
|
|
906
|
+
constructor(options = {}) {
|
|
907
|
+
this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
|
|
908
|
+
this.apiKey = options.apiKey;
|
|
909
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
910
|
+
}
|
|
911
|
+
/** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
|
|
912
|
+
async invoke(tool, args) {
|
|
913
|
+
const headers = {
|
|
914
|
+
'Content-Type': 'application/json',
|
|
915
|
+
};
|
|
916
|
+
if (this.apiKey) {
|
|
917
|
+
headers['Authorization'] = 'Bearer ' + this.apiKey;
|
|
918
|
+
}
|
|
919
|
+
const res = await fetch(this.gatewayUrl + '/tools/invoke', {
|
|
920
|
+
method: 'POST',
|
|
921
|
+
headers,
|
|
922
|
+
body: JSON.stringify({ tool, args }),
|
|
923
|
+
});
|
|
924
|
+
if (!res.ok) {
|
|
925
|
+
const text = await res.text();
|
|
926
|
+
throw new Error(`Gateway ${tool} failed: HTTP ${res.status.toString()} - ${text}`);
|
|
927
|
+
}
|
|
928
|
+
const data = (await res.json());
|
|
929
|
+
if (data.ok === false || data.error) {
|
|
930
|
+
throw new Error(`Gateway ${tool} error: ${data.error?.message ?? JSON.stringify(data)}`);
|
|
931
|
+
}
|
|
932
|
+
return data;
|
|
933
|
+
}
|
|
934
|
+
async spawn(task, options) {
|
|
935
|
+
const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000;
|
|
936
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
937
|
+
const deadline = Date.now() + timeoutMs;
|
|
938
|
+
// Step 1: Spawn the sub-agent session
|
|
939
|
+
const spawnResult = await this.invoke('sessions_spawn', {
|
|
940
|
+
task,
|
|
941
|
+
label: options?.label ?? 'jeeves-meta-synthesis',
|
|
942
|
+
runTimeoutSeconds: timeoutSeconds,
|
|
943
|
+
...(options?.thinking ? { thinking: options.thinking } : {}),
|
|
944
|
+
...(options?.model ? { model: options.model } : {}),
|
|
945
|
+
});
|
|
946
|
+
const details = (spawnResult.result?.details ?? spawnResult.result);
|
|
947
|
+
const sessionKey = details?.childSessionKey ?? details?.sessionKey;
|
|
948
|
+
if (typeof sessionKey !== 'string' || !sessionKey) {
|
|
949
|
+
throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
|
|
950
|
+
JSON.stringify(spawnResult));
|
|
951
|
+
}
|
|
952
|
+
// Step 2: Poll for completion via sessions_history
|
|
953
|
+
await sleep(3000);
|
|
954
|
+
while (Date.now() < deadline) {
|
|
955
|
+
try {
|
|
956
|
+
const historyResult = await this.invoke('sessions_history', {
|
|
957
|
+
sessionKey,
|
|
958
|
+
limit: 5,
|
|
959
|
+
includeTools: false,
|
|
960
|
+
});
|
|
961
|
+
const messages = historyResult.result?.details?.messages ??
|
|
962
|
+
historyResult.result?.messages ??
|
|
963
|
+
[];
|
|
964
|
+
const msgArray = messages;
|
|
965
|
+
if (msgArray.length > 0) {
|
|
966
|
+
const lastMsg = msgArray[msgArray.length - 1];
|
|
967
|
+
// Complete when last message is assistant with a terminal stop reason
|
|
968
|
+
if (lastMsg.role === 'assistant' &&
|
|
969
|
+
lastMsg.stopReason &&
|
|
970
|
+
lastMsg.stopReason !== 'toolUse' &&
|
|
971
|
+
lastMsg.stopReason !== 'error') {
|
|
972
|
+
// Sum token usage from all messages
|
|
973
|
+
let tokens;
|
|
974
|
+
let sum = 0;
|
|
975
|
+
for (const msg of msgArray) {
|
|
976
|
+
if (msg.usage?.totalTokens)
|
|
977
|
+
sum += msg.usage.totalTokens;
|
|
978
|
+
}
|
|
979
|
+
if (sum > 0)
|
|
980
|
+
tokens = sum;
|
|
981
|
+
// Find the last assistant message with content
|
|
982
|
+
for (let i = msgArray.length - 1; i >= 0; i--) {
|
|
983
|
+
if (msgArray[i].role === 'assistant' && msgArray[i].content) {
|
|
984
|
+
return { output: msgArray[i].content, tokens };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return { output: '', tokens };
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// Transient poll failure — keep trying
|
|
993
|
+
}
|
|
994
|
+
await sleep(this.pollIntervalMs);
|
|
995
|
+
}
|
|
996
|
+
throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Pino logger factory.
|
|
1002
|
+
*
|
|
1003
|
+
* @module logger
|
|
1004
|
+
*/
|
|
1005
|
+
/**
|
|
1006
|
+
* Create a pino logger instance.
|
|
1007
|
+
*
|
|
1008
|
+
* @param config - Optional logger configuration.
|
|
1009
|
+
* @returns Configured pino logger.
|
|
1010
|
+
*/
|
|
1011
|
+
function createLogger(config) {
|
|
1012
|
+
const level = config?.level ?? 'info';
|
|
1013
|
+
if (config?.file) {
|
|
1014
|
+
const transport = pino.transport({
|
|
1015
|
+
target: 'pino/file',
|
|
1016
|
+
options: { destination: config.file, mkdir: true },
|
|
1017
|
+
});
|
|
1018
|
+
return pino({ level }, transport);
|
|
1019
|
+
}
|
|
1020
|
+
return pino({ level });
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Build the MetaContext for a synthesis cycle.
|
|
1025
|
+
*
|
|
1026
|
+
* Computes shared inputs once: scope files, delta files, child meta outputs,
|
|
1027
|
+
* previous content/feedback, steer, and archive paths.
|
|
1028
|
+
*
|
|
1029
|
+
* @module orchestrator/contextPackage
|
|
1030
|
+
*/
|
|
1031
|
+
/**
|
|
1032
|
+
* Condense a file list into glob-like summaries.
|
|
1033
|
+
* Groups by directory + extension pattern.
|
|
1034
|
+
*
|
|
1035
|
+
* @param files - Array of file paths.
|
|
1036
|
+
* @param maxIndividual - Show individual files up to this count.
|
|
1037
|
+
* @returns Condensed summary string.
|
|
1038
|
+
*/
|
|
1039
|
+
function condenseScopeFiles(files, maxIndividual = 30) {
|
|
1040
|
+
if (files.length <= maxIndividual)
|
|
1041
|
+
return files.join('\n');
|
|
1042
|
+
// Group by dir + extension
|
|
1043
|
+
const groups = new Map();
|
|
1044
|
+
for (const f of files) {
|
|
1045
|
+
const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
|
|
1046
|
+
const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
|
|
1047
|
+
const key = dir + '*' + ext;
|
|
1048
|
+
groups.set(key, (groups.get(key) ?? 0) + 1);
|
|
1049
|
+
}
|
|
1050
|
+
// Sort by count descending
|
|
1051
|
+
const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
|
|
1052
|
+
return sorted
|
|
1053
|
+
.map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
|
|
1054
|
+
.join('\n');
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Build the context package for a synthesis cycle.
|
|
1058
|
+
*
|
|
1059
|
+
* @param node - The meta node being synthesized.
|
|
1060
|
+
* @param meta - Current meta.json content.
|
|
1061
|
+
* @param watcher - WatcherClient for scope enumeration.
|
|
1062
|
+
* @returns The computed context package.
|
|
1063
|
+
*/
|
|
1064
|
+
async function buildContextPackage(node, meta, watcher) {
|
|
1065
|
+
// Scope and delta files via watcher scan
|
|
1066
|
+
const { scopeFiles } = await getScopeFiles(node, watcher);
|
|
1067
|
+
const deltaFiles = await getDeltaFiles(node, watcher, meta._generatedAt, scopeFiles);
|
|
1068
|
+
// Child meta outputs
|
|
1069
|
+
const childMetas = {};
|
|
1070
|
+
for (const child of node.children) {
|
|
1071
|
+
const childMetaFile = join(child.metaPath, 'meta.json');
|
|
1072
|
+
try {
|
|
1073
|
+
const raw = readFileSync(childMetaFile, 'utf8');
|
|
1074
|
+
const childMeta = JSON.parse(raw);
|
|
1075
|
+
childMetas[child.ownerPath] = childMeta._content ?? null;
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
childMetas[child.ownerPath] = null;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// Archive paths
|
|
1082
|
+
const archives = listArchiveFiles(node.metaPath);
|
|
1083
|
+
return {
|
|
1084
|
+
path: node.metaPath,
|
|
1085
|
+
scopeFiles,
|
|
1086
|
+
deltaFiles,
|
|
1087
|
+
childMetas,
|
|
1088
|
+
previousContent: meta._content ?? null,
|
|
1089
|
+
previousFeedback: meta._feedback ?? null,
|
|
1090
|
+
steer: meta._steer ?? null,
|
|
1091
|
+
archives,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Build task prompts for each synthesis step.
|
|
1097
|
+
*
|
|
1098
|
+
* @module orchestrator/buildTask
|
|
1099
|
+
*/
|
|
1100
|
+
/** Append optional context sections shared across all step prompts. */
|
|
1101
|
+
function appendSharedSections(sections, ctx, options) {
|
|
1102
|
+
const opts = {
|
|
1103
|
+
includeSteer: true,
|
|
1104
|
+
includePreviousContent: true,
|
|
1105
|
+
includePreviousFeedback: true,
|
|
1106
|
+
feedbackHeading: '## PREVIOUS FEEDBACK',
|
|
1107
|
+
includeChildMetas: true,
|
|
1108
|
+
...options,
|
|
1109
|
+
};
|
|
1110
|
+
if (opts.includeSteer && ctx.steer) {
|
|
1111
|
+
sections.push('', '## STEERING PROMPT', ctx.steer);
|
|
1112
|
+
}
|
|
1113
|
+
if (opts.includePreviousContent && ctx.previousContent) {
|
|
1114
|
+
sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
|
|
1115
|
+
}
|
|
1116
|
+
if (opts.includePreviousFeedback && ctx.previousFeedback) {
|
|
1117
|
+
sections.push('', opts.feedbackHeading, ctx.previousFeedback);
|
|
1118
|
+
}
|
|
1119
|
+
if (opts.includeChildMetas && Object.keys(ctx.childMetas).length > 0) {
|
|
1120
|
+
sections.push('', '## CHILD META OUTPUTS');
|
|
1121
|
+
for (const [childPath, content] of Object.entries(ctx.childMetas)) {
|
|
1122
|
+
sections.push(`### ${childPath}`, typeof content === 'string' ? content : '(not yet synthesized)');
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Build the architect task prompt.
|
|
1128
|
+
*
|
|
1129
|
+
* @param ctx - Synthesis context.
|
|
1130
|
+
* @param meta - Current meta.json.
|
|
1131
|
+
* @param config - Synthesis config.
|
|
1132
|
+
* @returns The architect task prompt string.
|
|
1133
|
+
*/
|
|
1134
|
+
function buildArchitectTask(ctx, meta, config) {
|
|
1135
|
+
const sections = [
|
|
1136
|
+
meta._architect ?? config.defaultArchitect,
|
|
1137
|
+
'',
|
|
1138
|
+
'## SCOPE',
|
|
1139
|
+
`Path: ${ctx.path}`,
|
|
1140
|
+
`Total files in scope: ${ctx.scopeFiles.length.toString()}`,
|
|
1141
|
+
`Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
|
|
1142
|
+
'',
|
|
1143
|
+
'### File listing (scope)',
|
|
1144
|
+
condenseScopeFiles(ctx.scopeFiles),
|
|
1145
|
+
];
|
|
1146
|
+
// Inject previous _builder so architect can see its own prior output
|
|
1147
|
+
if (meta._builder) {
|
|
1148
|
+
sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
|
|
1149
|
+
}
|
|
1150
|
+
appendSharedSections(sections, ctx);
|
|
1151
|
+
if (ctx.archives.length > 0) {
|
|
1152
|
+
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.');
|
|
1153
|
+
}
|
|
1154
|
+
return sections.join('\n');
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Build the builder task prompt.
|
|
1158
|
+
*
|
|
1159
|
+
* @param ctx - Synthesis context.
|
|
1160
|
+
* @param meta - Current meta.json.
|
|
1161
|
+
* @param config - Synthesis config.
|
|
1162
|
+
* @returns The builder task prompt string.
|
|
1163
|
+
*/
|
|
1164
|
+
function buildBuilderTask(ctx, meta, config) {
|
|
1165
|
+
const sections = [
|
|
1166
|
+
'## TASK BRIEF (from Architect)',
|
|
1167
|
+
meta._builder ?? '(No architect brief available)',
|
|
1168
|
+
'',
|
|
1169
|
+
'## SCOPE',
|
|
1170
|
+
`Path: ${ctx.path}`,
|
|
1171
|
+
`Delta files (${ctx.deltaFiles.length.toString()} changed):`,
|
|
1172
|
+
...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
|
|
1173
|
+
];
|
|
1174
|
+
appendSharedSections(sections, ctx, {
|
|
1175
|
+
includeSteer: false,
|
|
1176
|
+
feedbackHeading: '## FEEDBACK FROM CRITIC',
|
|
1177
|
+
});
|
|
1178
|
+
sections.push('', '## OUTPUT FORMAT', 'Return a JSON object with:', '- "_content": Markdown narrative synthesis (required)', '- Any additional structured fields as non-underscore keys');
|
|
1179
|
+
return sections.join('\n');
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Build the critic task prompt.
|
|
1183
|
+
*
|
|
1184
|
+
* @param ctx - Synthesis context.
|
|
1185
|
+
* @param meta - Current meta.json (with _content already set by builder).
|
|
1186
|
+
* @param config - Synthesis config.
|
|
1187
|
+
* @returns The critic task prompt string.
|
|
1188
|
+
*/
|
|
1189
|
+
function buildCriticTask(ctx, meta, config) {
|
|
1190
|
+
const sections = [
|
|
1191
|
+
meta._critic ?? config.defaultCritic,
|
|
1192
|
+
'',
|
|
1193
|
+
'## SYNTHESIS TO EVALUATE',
|
|
1194
|
+
meta._content ?? '(No content produced)',
|
|
1195
|
+
'',
|
|
1196
|
+
'## SCOPE',
|
|
1197
|
+
`Path: ${ctx.path}`,
|
|
1198
|
+
`Files in scope: ${ctx.scopeFiles.length.toString()}`,
|
|
1199
|
+
];
|
|
1200
|
+
appendSharedSections(sections, ctx, {
|
|
1201
|
+
includePreviousContent: false,
|
|
1202
|
+
feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
|
|
1203
|
+
includeChildMetas: false,
|
|
1204
|
+
});
|
|
1205
|
+
sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
|
|
1206
|
+
return sections.join('\n');
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Structured error from a synthesis step failure.
|
|
1211
|
+
*
|
|
1212
|
+
* @module schema/error
|
|
1213
|
+
*/
|
|
1214
|
+
/** Zod schema for synthesis step errors. */
|
|
1215
|
+
const metaErrorSchema = z.object({
|
|
1216
|
+
/** Which step failed: 'architect', 'builder', or 'critic'. */
|
|
1217
|
+
step: z.enum(['architect', 'builder', 'critic']),
|
|
1218
|
+
/** Error classification code. */
|
|
1219
|
+
code: z.string(),
|
|
1220
|
+
/** Human-readable error message. */
|
|
1221
|
+
message: z.string(),
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Zod schema for .meta/meta.json files.
|
|
1226
|
+
*
|
|
1227
|
+
* Reserved properties are underscore-prefixed and engine-managed.
|
|
1228
|
+
* All other keys are open schema (builder output).
|
|
1229
|
+
*
|
|
1230
|
+
* @module schema/meta
|
|
1231
|
+
*/
|
|
1232
|
+
/** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
|
|
1233
|
+
const metaJsonSchema = z
|
|
1234
|
+
.object({
|
|
1235
|
+
/** Stable identity. Generated on first synthesis, never changes. */
|
|
1236
|
+
_id: z.uuid(),
|
|
1237
|
+
/** Human-provided steering prompt. Optional. */
|
|
1238
|
+
_steer: z.string().optional(),
|
|
1239
|
+
/** Architect system prompt used this turn. Defaults from config. */
|
|
1240
|
+
_architect: z.string().optional(),
|
|
1241
|
+
/**
|
|
1242
|
+
* Task brief generated by the architect. Cached and reused across cycles;
|
|
1243
|
+
* regenerated only when triggered.
|
|
1244
|
+
*/
|
|
1245
|
+
_builder: z.string().optional(),
|
|
1246
|
+
/** Critic system prompt used this turn. Defaults from config. */
|
|
1247
|
+
_critic: z.string().optional(),
|
|
1248
|
+
/** Timestamp of last synthesis. ISO 8601. */
|
|
1249
|
+
_generatedAt: z.iso.datetime().optional(),
|
|
1250
|
+
/** Narrative synthesis output. Rendered by watcher for embedding. */
|
|
1251
|
+
_content: z.string().optional(),
|
|
1252
|
+
/**
|
|
1253
|
+
* Hash of sorted file listing in scope. Detects directory structure
|
|
1254
|
+
* changes that trigger an architect re-run.
|
|
1255
|
+
*/
|
|
1256
|
+
_structureHash: z.string().optional(),
|
|
1257
|
+
/**
|
|
1258
|
+
* Cycles since last architect run. Reset to 0 when architect runs.
|
|
1259
|
+
* Used with architectEvery to trigger periodic re-prompting.
|
|
1260
|
+
*/
|
|
1261
|
+
_synthesisCount: z.number().int().min(0).optional(),
|
|
1262
|
+
/** Critic evaluation of the last synthesis. */
|
|
1263
|
+
_feedback: z.string().optional(),
|
|
1264
|
+
/**
|
|
1265
|
+
* Present and true on archive snapshots. Distinguishes live vs. archived
|
|
1266
|
+
* metas.
|
|
1267
|
+
*/
|
|
1268
|
+
_archived: z.boolean().optional(),
|
|
1269
|
+
/** Timestamp when this snapshot was archived. ISO 8601. */
|
|
1270
|
+
_archivedAt: z.iso.datetime().optional(),
|
|
1271
|
+
/**
|
|
1272
|
+
* Scheduling priority. Higher = updates more often. Negative allowed;
|
|
1273
|
+
* normalized to min 0 at scheduling time.
|
|
1274
|
+
*/
|
|
1275
|
+
_depth: z.number().optional(),
|
|
1276
|
+
/**
|
|
1277
|
+
* Emphasis multiplier for depth weighting in scheduling.
|
|
1278
|
+
* Default 1. Higher values increase this meta's scheduling priority
|
|
1279
|
+
* relative to its depth. Set to 0.5 to halve the depth effect,
|
|
1280
|
+
* 2 to double it, 0 to ignore depth entirely for this meta.
|
|
1281
|
+
*/
|
|
1282
|
+
_emphasis: z.number().min(0).optional(),
|
|
1283
|
+
/** Token count from last architect subprocess call. */
|
|
1284
|
+
_architectTokens: z.number().int().optional(),
|
|
1285
|
+
/** Token count from last builder subprocess call. */
|
|
1286
|
+
_builderTokens: z.number().int().optional(),
|
|
1287
|
+
/** Token count from last critic subprocess call. */
|
|
1288
|
+
_criticTokens: z.number().int().optional(),
|
|
1289
|
+
/** Exponential moving average of architect token usage (decay 0.3). */
|
|
1290
|
+
_architectTokensAvg: z.number().optional(),
|
|
1291
|
+
/** Exponential moving average of builder token usage (decay 0.3). */
|
|
1292
|
+
_builderTokensAvg: z.number().optional(),
|
|
1293
|
+
/** Exponential moving average of critic token usage (decay 0.3). */
|
|
1294
|
+
_criticTokensAvg: z.number().optional(),
|
|
1295
|
+
/**
|
|
1296
|
+
* Structured error from last cycle. Present when a step failed.
|
|
1297
|
+
* Cleared on successful cycle.
|
|
1298
|
+
*/
|
|
1299
|
+
_error: metaErrorSchema.optional(),
|
|
1300
|
+
})
|
|
1301
|
+
.loose();
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Merge synthesis results into meta.json.
|
|
1305
|
+
*
|
|
1306
|
+
* Preserves human-set fields (_id, _steer, _depth).
|
|
1307
|
+
* Writes engine fields (_generatedAt, _structureHash, etc.).
|
|
1308
|
+
* Validates against schema before writing.
|
|
1309
|
+
*
|
|
1310
|
+
* @module orchestrator/merge
|
|
1311
|
+
*/
|
|
1312
|
+
/**
|
|
1313
|
+
* Merge results into meta.json and write atomically.
|
|
1314
|
+
*
|
|
1315
|
+
* @param options - Merge options.
|
|
1316
|
+
* @returns The updated MetaJson.
|
|
1317
|
+
* @throws If validation fails (malformed output).
|
|
1318
|
+
*/
|
|
1319
|
+
function mergeAndWrite(options) {
|
|
1320
|
+
const merged = {
|
|
1321
|
+
// Preserve human-set fields
|
|
1322
|
+
_id: options.current._id,
|
|
1323
|
+
_steer: options.current._steer,
|
|
1324
|
+
_depth: options.current._depth,
|
|
1325
|
+
_emphasis: options.current._emphasis,
|
|
1326
|
+
// Engine fields
|
|
1327
|
+
_architect: options.architect,
|
|
1328
|
+
_builder: options.builder,
|
|
1329
|
+
_critic: options.critic,
|
|
1330
|
+
_generatedAt: new Date().toISOString(),
|
|
1331
|
+
_structureHash: options.structureHash,
|
|
1332
|
+
_synthesisCount: options.synthesisCount,
|
|
1333
|
+
// Token tracking
|
|
1334
|
+
_architectTokens: options.architectTokens,
|
|
1335
|
+
_builderTokens: options.builderTokens,
|
|
1336
|
+
_criticTokens: options.criticTokens,
|
|
1337
|
+
_architectTokensAvg: options.architectTokens !== undefined
|
|
1338
|
+
? computeEma(options.architectTokens, options.current._architectTokensAvg)
|
|
1339
|
+
: options.current._architectTokensAvg,
|
|
1340
|
+
_builderTokensAvg: options.builderTokens !== undefined
|
|
1341
|
+
? computeEma(options.builderTokens, options.current._builderTokensAvg)
|
|
1342
|
+
: options.current._builderTokensAvg,
|
|
1343
|
+
_criticTokensAvg: options.criticTokens !== undefined
|
|
1344
|
+
? computeEma(options.criticTokens, options.current._criticTokensAvg)
|
|
1345
|
+
: options.current._criticTokensAvg,
|
|
1346
|
+
// Content from builder
|
|
1347
|
+
_content: options.builderOutput?.content ?? options.current._content,
|
|
1348
|
+
// Feedback from critic
|
|
1349
|
+
_feedback: options.feedback ?? options.current._feedback,
|
|
1350
|
+
// Error handling
|
|
1351
|
+
_error: options.error ?? undefined,
|
|
1352
|
+
// Spread structured fields from builder
|
|
1353
|
+
...options.builderOutput?.fields,
|
|
1354
|
+
};
|
|
1355
|
+
// Clean up undefined optional fields
|
|
1356
|
+
if (merged._steer === undefined)
|
|
1357
|
+
delete merged._steer;
|
|
1358
|
+
if (merged._depth === undefined)
|
|
1359
|
+
delete merged._depth;
|
|
1360
|
+
if (merged._emphasis === undefined)
|
|
1361
|
+
delete merged._emphasis;
|
|
1362
|
+
if (merged._architectTokens === undefined)
|
|
1363
|
+
delete merged._architectTokens;
|
|
1364
|
+
if (merged._builderTokens === undefined)
|
|
1365
|
+
delete merged._builderTokens;
|
|
1366
|
+
if (merged._criticTokens === undefined)
|
|
1367
|
+
delete merged._criticTokens;
|
|
1368
|
+
if (merged._architectTokensAvg === undefined)
|
|
1369
|
+
delete merged._architectTokensAvg;
|
|
1370
|
+
if (merged._builderTokensAvg === undefined)
|
|
1371
|
+
delete merged._builderTokensAvg;
|
|
1372
|
+
if (merged._criticTokensAvg === undefined)
|
|
1373
|
+
delete merged._criticTokensAvg;
|
|
1374
|
+
if (merged._error === undefined)
|
|
1375
|
+
delete merged._error;
|
|
1376
|
+
if (merged._content === undefined)
|
|
1377
|
+
delete merged._content;
|
|
1378
|
+
if (merged._feedback === undefined)
|
|
1379
|
+
delete merged._feedback;
|
|
1380
|
+
// Validate
|
|
1381
|
+
const result = metaJsonSchema.safeParse(merged);
|
|
1382
|
+
if (!result.success) {
|
|
1383
|
+
throw new Error(`Meta validation failed: ${result.error.message}`);
|
|
1384
|
+
}
|
|
1385
|
+
// Write to specified path (lock staging) or default meta.json
|
|
1386
|
+
const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
|
|
1387
|
+
writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
|
|
1388
|
+
return result.data;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Weighted staleness formula for candidate selection.
|
|
1393
|
+
*
|
|
1394
|
+
* effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
|
|
1395
|
+
*
|
|
1396
|
+
* @module scheduling/weightedFormula
|
|
1397
|
+
*/
|
|
1398
|
+
/**
|
|
1399
|
+
* Compute effective staleness for a set of candidates.
|
|
1400
|
+
*
|
|
1401
|
+
* Normalizes depths so the minimum becomes 0, then applies the formula:
|
|
1402
|
+
* effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
|
|
1403
|
+
*
|
|
1404
|
+
* Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
|
|
1405
|
+
* metas to tune how much their tree position affects scheduling.
|
|
1406
|
+
*
|
|
1407
|
+
* @param candidates - Array of \{ node, meta, actualStaleness \}.
|
|
1408
|
+
* @param depthWeight - Exponent for depth weighting (0 = pure staleness).
|
|
1409
|
+
* @returns Same array with effectiveStaleness computed.
|
|
1410
|
+
*/
|
|
1411
|
+
function computeEffectiveStaleness(candidates, depthWeight) {
|
|
1412
|
+
if (candidates.length === 0)
|
|
1413
|
+
return [];
|
|
1414
|
+
// Get depth for each candidate: use _depth override or tree depth
|
|
1415
|
+
const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
|
|
1416
|
+
// Normalize: shift so minimum becomes 0
|
|
1417
|
+
const minDepth = Math.min(...depths);
|
|
1418
|
+
const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
|
|
1419
|
+
return candidates.map((c, i) => {
|
|
1420
|
+
const emphasis = c.meta._emphasis ?? 1;
|
|
1421
|
+
return {
|
|
1422
|
+
...c,
|
|
1423
|
+
effectiveStaleness: c.actualStaleness *
|
|
1424
|
+
Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
|
|
1425
|
+
};
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Select the best synthesis candidate from stale metas.
|
|
1431
|
+
*
|
|
1432
|
+
* Picks the meta with highest effective staleness.
|
|
1433
|
+
*
|
|
1434
|
+
* @module scheduling/selectCandidate
|
|
1435
|
+
*/
|
|
1436
|
+
/**
|
|
1437
|
+
* Select the candidate with the highest effective staleness.
|
|
1438
|
+
*
|
|
1439
|
+
* @param candidates - Array of candidates with computed effective staleness.
|
|
1440
|
+
* @returns The winning candidate, or null if no candidates.
|
|
1441
|
+
*/
|
|
1442
|
+
function selectCandidate(candidates) {
|
|
1443
|
+
if (candidates.length === 0)
|
|
1444
|
+
return null;
|
|
1445
|
+
let best = candidates[0];
|
|
1446
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
1447
|
+
if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
|
|
1448
|
+
best = candidates[i];
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return best;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Extract stale candidates from a list and return the stalest path.
|
|
1455
|
+
*
|
|
1456
|
+
* Consolidates the repeated pattern of:
|
|
1457
|
+
* filter → computeEffectiveStaleness → selectCandidate → return path
|
|
1458
|
+
*
|
|
1459
|
+
* @param candidates - Array with node, meta, and stalenessSeconds.
|
|
1460
|
+
* @param depthWeight - Depth weighting exponent from config.
|
|
1461
|
+
* @returns The stalest candidate's metaPath, or null if none are stale.
|
|
1462
|
+
*/
|
|
1463
|
+
function discoverStalestPath(candidates, depthWeight) {
|
|
1464
|
+
const weighted = computeEffectiveStaleness(candidates, depthWeight);
|
|
1465
|
+
const winner = selectCandidate(weighted);
|
|
1466
|
+
return winner?.node.metaPath ?? null;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Staleness detection via watcher scan.
|
|
1471
|
+
*
|
|
1472
|
+
* A meta is stale when any file in its scope was modified after _generatedAt.
|
|
1473
|
+
*
|
|
1474
|
+
* @module scheduling/staleness
|
|
1475
|
+
*/
|
|
1476
|
+
/**
|
|
1477
|
+
* Check if a meta is stale by querying the watcher for modified files.
|
|
1478
|
+
*
|
|
1479
|
+
* @param scopePrefix - Path prefix for this meta's scope.
|
|
1480
|
+
* @param meta - Current meta.json content.
|
|
1481
|
+
* @param watcher - WatcherClient instance.
|
|
1482
|
+
* @returns True if any file in scope was modified after _generatedAt.
|
|
1483
|
+
*/
|
|
1484
|
+
async function isStale(scopePrefix, meta, watcher) {
|
|
1485
|
+
if (!meta._generatedAt)
|
|
1486
|
+
return true; // Never synthesized = stale
|
|
1487
|
+
const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
|
|
1488
|
+
const result = await watcher.scan({
|
|
1489
|
+
pathPrefix: scopePrefix,
|
|
1490
|
+
modifiedAfter: generatedAtUnix,
|
|
1491
|
+
limit: 1,
|
|
1492
|
+
});
|
|
1493
|
+
return result.files.length > 0;
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Compute actual staleness in seconds (now minus _generatedAt).
|
|
1497
|
+
*
|
|
1498
|
+
* @param meta - Current meta.json content.
|
|
1499
|
+
* @returns Staleness in seconds, or Infinity if never synthesized.
|
|
1500
|
+
*/
|
|
1501
|
+
function actualStaleness(meta) {
|
|
1502
|
+
if (!meta._generatedAt)
|
|
1503
|
+
return Infinity;
|
|
1504
|
+
const generatedMs = new Date(meta._generatedAt).getTime();
|
|
1505
|
+
return (Date.now() - generatedMs) / 1000;
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Check whether the architect step should be triggered.
|
|
1509
|
+
*
|
|
1510
|
+
* @param meta - Current meta.json.
|
|
1511
|
+
* @param structureChanged - Whether the structure hash changed.
|
|
1512
|
+
* @param steerChanged - Whether the steer directive changed.
|
|
1513
|
+
* @param architectEvery - Config: run architect every N cycles.
|
|
1514
|
+
* @returns True if the architect step should run.
|
|
1515
|
+
*/
|
|
1516
|
+
function isArchitectTriggered(meta, structureChanged, steerChanged, architectEvery) {
|
|
1517
|
+
return (!meta._builder ||
|
|
1518
|
+
structureChanged ||
|
|
1519
|
+
steerChanged ||
|
|
1520
|
+
(meta._synthesisCount ?? 0) >= architectEvery);
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Detect whether the steer directive changed since the last archive.
|
|
1524
|
+
*
|
|
1525
|
+
* @param currentSteer - Current _steer value (or undefined).
|
|
1526
|
+
* @param archiveSteer - Archive _steer value (or undefined).
|
|
1527
|
+
* @param hasArchive - Whether an archive snapshot exists.
|
|
1528
|
+
* @returns True if steer changed.
|
|
1529
|
+
*/
|
|
1530
|
+
function hasSteerChanged(currentSteer, archiveSteer, hasArchive) {
|
|
1531
|
+
if (!hasArchive)
|
|
1532
|
+
return Boolean(currentSteer);
|
|
1533
|
+
return currentSteer !== archiveSteer;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Compute a normalized staleness score (0–1) for display purposes.
|
|
1537
|
+
*
|
|
1538
|
+
* Uses the same depth/emphasis weighting as candidate selection,
|
|
1539
|
+
* normalized to a 30-day window.
|
|
1540
|
+
*
|
|
1541
|
+
* @param stalenessSeconds - Raw staleness in seconds (null = never synthesized).
|
|
1542
|
+
* @param depth - Meta tree depth.
|
|
1543
|
+
* @param emphasis - Scheduling emphasis multiplier.
|
|
1544
|
+
* @param depthWeight - Depth weighting exponent from config.
|
|
1545
|
+
* @returns Normalized score between 0 and 1.
|
|
1546
|
+
*/
|
|
1547
|
+
function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
|
|
1548
|
+
if (stalenessSeconds === null)
|
|
1549
|
+
return 1;
|
|
1550
|
+
const depthFactor = Math.pow(1 + depthWeight, depth);
|
|
1551
|
+
return Math.min(1, (stalenessSeconds * depthFactor * emphasis) / (30 * 86400));
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Parse subprocess outputs for each synthesis step.
|
|
1556
|
+
*
|
|
1557
|
+
* - Architect: returns text \> _builder
|
|
1558
|
+
* - Builder: returns JSON \> _content + structured fields
|
|
1559
|
+
* - Critic: returns text \> _feedback
|
|
1560
|
+
*
|
|
1561
|
+
* @module orchestrator/parseOutput
|
|
1562
|
+
*/
|
|
1563
|
+
/**
|
|
1564
|
+
* Parse architect output. The architect returns a task brief as text.
|
|
1565
|
+
*
|
|
1566
|
+
* @param output - Raw subprocess output.
|
|
1567
|
+
* @returns The task brief string.
|
|
1568
|
+
*/
|
|
1569
|
+
function parseArchitectOutput(output) {
|
|
1570
|
+
return output.trim();
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Parse builder output. The builder returns JSON with _content and optional fields.
|
|
1574
|
+
*
|
|
1575
|
+
* Attempts JSON parse first. If that fails, treats the entire output as _content.
|
|
1576
|
+
*
|
|
1577
|
+
* @param output - Raw subprocess output.
|
|
1578
|
+
* @returns Parsed builder output with content and structured fields.
|
|
1579
|
+
*/
|
|
1580
|
+
function parseBuilderOutput(output) {
|
|
1581
|
+
const trimmed = output.trim();
|
|
1582
|
+
// Try to extract JSON from the output (may be wrapped in markdown code fences)
|
|
1583
|
+
let jsonStr = trimmed;
|
|
1584
|
+
const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
|
|
1585
|
+
if (fenceMatch) {
|
|
1586
|
+
jsonStr = fenceMatch[1].trim();
|
|
1587
|
+
}
|
|
1588
|
+
try {
|
|
1589
|
+
const parsed = JSON.parse(jsonStr);
|
|
1590
|
+
// Extract _content
|
|
1591
|
+
const content = typeof parsed._content === 'string'
|
|
1592
|
+
? parsed._content
|
|
1593
|
+
: typeof parsed.content === 'string'
|
|
1594
|
+
? parsed.content
|
|
1595
|
+
: trimmed;
|
|
1596
|
+
// Extract non-underscore fields
|
|
1597
|
+
const fields = {};
|
|
1598
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1599
|
+
if (!key.startsWith('_') && key !== 'content') {
|
|
1600
|
+
fields[key] = value;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return { content, fields };
|
|
1604
|
+
}
|
|
1605
|
+
catch {
|
|
1606
|
+
// Not valid JSON — treat entire output as content
|
|
1607
|
+
return { content: trimmed, fields: {} };
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Parse critic output. The critic returns evaluation text.
|
|
1612
|
+
*
|
|
1613
|
+
* @param output - Raw subprocess output.
|
|
1614
|
+
* @returns The feedback string.
|
|
1615
|
+
*/
|
|
1616
|
+
function parseCriticOutput(output) {
|
|
1617
|
+
return output.trim();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* Main orchestration function — the 13-step synthesis cycle.
|
|
1622
|
+
*
|
|
1623
|
+
* Wires together discovery, scheduling, archiving, executor calls,
|
|
1624
|
+
* and merge/write-back.
|
|
1625
|
+
*
|
|
1626
|
+
* @module orchestrator/orchestrate
|
|
1627
|
+
*/
|
|
1628
|
+
/** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
|
|
1629
|
+
function finalizeCycle(opts) {
|
|
1630
|
+
const lockPath = join(opts.metaPath, '.lock');
|
|
1631
|
+
const metaJsonPath = join(opts.metaPath, 'meta.json');
|
|
1632
|
+
// Stage: write merged result to .lock
|
|
1633
|
+
const updated = mergeAndWrite({
|
|
1634
|
+
metaPath: opts.metaPath,
|
|
1635
|
+
current: opts.current,
|
|
1636
|
+
architect: opts.architect,
|
|
1637
|
+
builder: opts.builder,
|
|
1638
|
+
critic: opts.critic,
|
|
1639
|
+
builderOutput: opts.builderOutput,
|
|
1640
|
+
feedback: opts.feedback,
|
|
1641
|
+
structureHash: opts.structureHash,
|
|
1642
|
+
synthesisCount: opts.synthesisCount,
|
|
1643
|
+
error: opts.error,
|
|
1644
|
+
architectTokens: opts.architectTokens,
|
|
1645
|
+
builderTokens: opts.builderTokens,
|
|
1646
|
+
criticTokens: opts.criticTokens,
|
|
1647
|
+
outputPath: lockPath,
|
|
1648
|
+
});
|
|
1649
|
+
// Commit: copy .lock → meta.json
|
|
1650
|
+
copyFileSync(lockPath, metaJsonPath);
|
|
1651
|
+
// Archive + prune from the committed meta.json
|
|
1652
|
+
createSnapshot(opts.metaPath, updated);
|
|
1653
|
+
pruneArchive(opts.metaPath, opts.config.maxArchive);
|
|
1654
|
+
// .lock is cleaned up by the finally block (releaseLock)
|
|
1655
|
+
return updated;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Run a single synthesis cycle.
|
|
1659
|
+
*
|
|
1660
|
+
* Discovers all metas, selects the stalest candidate, and runs the
|
|
1661
|
+
* three-step synthesis (architect, builder, critic).
|
|
1662
|
+
*
|
|
1663
|
+
* @param config - Validated synthesis config.
|
|
1664
|
+
* @param executor - Pluggable LLM executor.
|
|
1665
|
+
* @param watcher - Watcher HTTP client.
|
|
1666
|
+
* @returns Result indicating whether synthesis occurred.
|
|
1667
|
+
*/
|
|
1668
|
+
async function orchestrateOnce(config, executor, watcher, targetPath, onProgress) {
|
|
1669
|
+
// Step 1: Discover via watcher scan
|
|
1670
|
+
const metaPaths = await discoverMetas(config, watcher);
|
|
1671
|
+
if (metaPaths.length === 0)
|
|
1672
|
+
return { synthesized: false };
|
|
1673
|
+
// Read meta.json for each discovered meta
|
|
1674
|
+
const metas = new Map();
|
|
1675
|
+
for (const mp of metaPaths) {
|
|
1676
|
+
const metaFilePath = join(mp, 'meta.json');
|
|
1677
|
+
try {
|
|
1678
|
+
metas.set(normalizePath(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
|
|
1679
|
+
}
|
|
1680
|
+
catch {
|
|
1681
|
+
// Skip metas with unreadable meta.json
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
// Only build tree from paths with readable meta.json (excludes orphaned/deleted entries)
|
|
1686
|
+
const validPaths = metaPaths.filter((mp) => metas.has(normalizePath(mp)));
|
|
1687
|
+
if (validPaths.length === 0)
|
|
1688
|
+
return { synthesized: false };
|
|
1689
|
+
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
|
+
// Steps 3-4: Staleness check + candidate selection
|
|
1699
|
+
const candidates = [];
|
|
1700
|
+
for (const node of tree.nodes.values()) {
|
|
1701
|
+
const meta = metas.get(node.metaPath);
|
|
1702
|
+
if (!meta)
|
|
1703
|
+
continue; // Node not in metas map (e.g. unreadable meta.json)
|
|
1704
|
+
const staleness = actualStaleness(meta);
|
|
1705
|
+
if (staleness > 0) {
|
|
1706
|
+
candidates.push({ node, meta, actualStaleness: staleness });
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
|
|
1710
|
+
// Sort by effective staleness descending
|
|
1711
|
+
const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
|
|
1712
|
+
if (ranked.length === 0)
|
|
1713
|
+
return { synthesized: false };
|
|
1714
|
+
// Find the first candidate with actual changes (if skipUnchanged)
|
|
1715
|
+
let winner = null;
|
|
1716
|
+
for (const candidate of ranked) {
|
|
1717
|
+
if (!acquireLock(candidate.node.metaPath))
|
|
1718
|
+
continue;
|
|
1719
|
+
const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
|
|
1720
|
+
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
1721
|
+
// Bump _generatedAt so it doesn't win next cycle
|
|
1722
|
+
const metaFilePath = join(candidate.node.metaPath, 'meta.json');
|
|
1723
|
+
const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
|
|
1724
|
+
freshMeta._generatedAt = new Date().toISOString();
|
|
1725
|
+
writeFileSync(metaFilePath, JSON.stringify(freshMeta, null, 2));
|
|
1726
|
+
releaseLock(candidate.node.metaPath);
|
|
1727
|
+
if (config.skipUnchanged)
|
|
1728
|
+
continue;
|
|
1729
|
+
return { synthesized: false };
|
|
1730
|
+
}
|
|
1731
|
+
winner = candidate;
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
if (!winner && !targetNode)
|
|
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)) {
|
|
1739
|
+
return { synthesized: false };
|
|
1740
|
+
}
|
|
1741
|
+
try {
|
|
1742
|
+
// Re-read meta after lock (may have changed)
|
|
1743
|
+
const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
|
|
1744
|
+
const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
|
|
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
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
finally {
|
|
1899
|
+
// Step 13: Release lock
|
|
1900
|
+
releaseLock(node.metaPath);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Run a single synthesis cycle.
|
|
1905
|
+
*
|
|
1906
|
+
* Selects the stalest candidate (or a specific target) and runs the
|
|
1907
|
+
* full architect/builder/critic pipeline.
|
|
1908
|
+
*
|
|
1909
|
+
* @param config - Validated synthesis config.
|
|
1910
|
+
* @param executor - Pluggable LLM executor.
|
|
1911
|
+
* @param watcher - Watcher HTTP client.
|
|
1912
|
+
* @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
|
|
1913
|
+
* @returns Array with a single result.
|
|
1914
|
+
*/
|
|
1915
|
+
async function orchestrate(config, executor, watcher, targetPath, onProgress) {
|
|
1916
|
+
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
|
|
1917
|
+
return [result];
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
|
|
1922
|
+
*
|
|
1923
|
+
* @module progress
|
|
1924
|
+
*/
|
|
1925
|
+
function formatSeconds(durationMs) {
|
|
1926
|
+
const seconds = durationMs / 1000;
|
|
1927
|
+
return seconds.toFixed(1) + 's';
|
|
1928
|
+
}
|
|
1929
|
+
function titleCasePhase(phase) {
|
|
1930
|
+
return phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
1931
|
+
}
|
|
1932
|
+
function formatProgressEvent(event) {
|
|
1933
|
+
switch (event.type) {
|
|
1934
|
+
case 'synthesis_start':
|
|
1935
|
+
return `🔬 Started meta synthesis: ${event.metaPath}`;
|
|
1936
|
+
case 'phase_start': {
|
|
1937
|
+
if (!event.phase) {
|
|
1938
|
+
return ` ⚙️ Phase started: ${event.metaPath}`;
|
|
1939
|
+
}
|
|
1940
|
+
return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
|
|
1941
|
+
}
|
|
1942
|
+
case 'phase_complete': {
|
|
1943
|
+
const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
|
|
1944
|
+
const tokens = event.tokens ?? 0;
|
|
1945
|
+
const duration = event.durationMs !== undefined
|
|
1946
|
+
? formatSeconds(event.durationMs)
|
|
1947
|
+
: '0.0s';
|
|
1948
|
+
return ` ✅ ${phase} phase complete (${String(tokens)} tokens / ${duration})`;
|
|
1949
|
+
}
|
|
1950
|
+
case 'synthesis_complete': {
|
|
1951
|
+
const tokens = event.tokens ?? 0;
|
|
1952
|
+
const duration = event.durationMs !== undefined
|
|
1953
|
+
? formatSeconds(event.durationMs)
|
|
1954
|
+
: '0.0s';
|
|
1955
|
+
return `✅ Completed: ${event.metaPath} (${String(tokens)} tokens / ${duration})`;
|
|
1956
|
+
}
|
|
1957
|
+
case 'error': {
|
|
1958
|
+
const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
|
|
1959
|
+
const error = event.error ?? 'Unknown error';
|
|
1960
|
+
return `❌ Synthesis failed at ${phase}phase: ${event.metaPath}\n Error: ${error}`;
|
|
1961
|
+
}
|
|
1962
|
+
default: {
|
|
1963
|
+
return 'Unknown progress event';
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
class ProgressReporter {
|
|
1968
|
+
config;
|
|
1969
|
+
logger;
|
|
1970
|
+
constructor(config, logger) {
|
|
1971
|
+
this.config = config;
|
|
1972
|
+
this.logger = logger;
|
|
1973
|
+
}
|
|
1974
|
+
async report(event) {
|
|
1975
|
+
const target = this.config.reportChannel;
|
|
1976
|
+
if (!target)
|
|
1977
|
+
return;
|
|
1978
|
+
const message = formatProgressEvent(event);
|
|
1979
|
+
const url = new URL('/tools/invoke', this.config.gatewayUrl);
|
|
1980
|
+
const payload = {
|
|
1981
|
+
tool: 'message',
|
|
1982
|
+
args: {
|
|
1983
|
+
action: 'send',
|
|
1984
|
+
target,
|
|
1985
|
+
message,
|
|
1986
|
+
},
|
|
1987
|
+
};
|
|
1988
|
+
try {
|
|
1989
|
+
const res = await fetch(url, {
|
|
1990
|
+
method: 'POST',
|
|
1991
|
+
headers: {
|
|
1992
|
+
'content-type': 'application/json',
|
|
1993
|
+
...(this.config.gatewayApiKey
|
|
1994
|
+
? { authorization: `Bearer ${this.config.gatewayApiKey}` }
|
|
1995
|
+
: {}),
|
|
1996
|
+
},
|
|
1997
|
+
body: JSON.stringify(payload),
|
|
1998
|
+
});
|
|
1999
|
+
if (!res.ok) {
|
|
2000
|
+
const text = await res.text().catch(() => '');
|
|
2001
|
+
this.logger.warn({ status: res.status, statusText: res.statusText, body: text }, 'Progress reporting failed');
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
catch (err) {
|
|
2005
|
+
this.logger.warn({ err }, 'Progress reporting threw');
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/**
|
|
2011
|
+
* Croner-based scheduler that discovers the stalest meta candidate each tick
|
|
2012
|
+
* and enqueues it for synthesis.
|
|
2013
|
+
*
|
|
2014
|
+
* @module scheduler
|
|
2015
|
+
*/
|
|
2016
|
+
const MAX_BACKOFF_MULTIPLIER = 4;
|
|
2017
|
+
/**
|
|
2018
|
+
* Periodic scheduler that discovers stale meta candidates and enqueues them.
|
|
2019
|
+
*
|
|
2020
|
+
* Supports adaptive backoff when no candidates are found and hot-reloadable
|
|
2021
|
+
* cron expressions via {@link Scheduler.updateSchedule}.
|
|
2022
|
+
*/
|
|
2023
|
+
class Scheduler {
|
|
2024
|
+
job = null;
|
|
2025
|
+
backoffMultiplier = 1;
|
|
2026
|
+
tickCount = 0;
|
|
2027
|
+
config;
|
|
2028
|
+
queue;
|
|
2029
|
+
logger;
|
|
2030
|
+
watcher;
|
|
2031
|
+
registrar = null;
|
|
2032
|
+
currentExpression;
|
|
2033
|
+
constructor(config, queue, logger, watcher) {
|
|
2034
|
+
this.config = config;
|
|
2035
|
+
this.queue = queue;
|
|
2036
|
+
this.logger = logger;
|
|
2037
|
+
this.watcher = watcher;
|
|
2038
|
+
this.currentExpression = config.schedule;
|
|
2039
|
+
}
|
|
2040
|
+
/** Set the rule registrar for watcher restart detection. */
|
|
2041
|
+
setRegistrar(registrar) {
|
|
2042
|
+
this.registrar = registrar;
|
|
2043
|
+
}
|
|
2044
|
+
/** Start the cron job. */
|
|
2045
|
+
start() {
|
|
2046
|
+
if (this.job)
|
|
2047
|
+
return;
|
|
2048
|
+
this.job = new Cron(this.currentExpression, () => {
|
|
2049
|
+
void this.tick();
|
|
2050
|
+
});
|
|
2051
|
+
this.logger.info({ schedule: this.currentExpression }, 'Scheduler started');
|
|
2052
|
+
}
|
|
2053
|
+
/** Stop the cron job. */
|
|
2054
|
+
stop() {
|
|
2055
|
+
if (!this.job)
|
|
2056
|
+
return;
|
|
2057
|
+
this.job.stop();
|
|
2058
|
+
this.job = null;
|
|
2059
|
+
this.backoffMultiplier = 1;
|
|
2060
|
+
this.logger.info('Scheduler stopped');
|
|
2061
|
+
}
|
|
2062
|
+
/** Hot-reload the cron schedule expression. */
|
|
2063
|
+
updateSchedule(expression) {
|
|
2064
|
+
this.currentExpression = expression;
|
|
2065
|
+
if (this.job) {
|
|
2066
|
+
this.job.stop();
|
|
2067
|
+
this.job = new Cron(expression, () => {
|
|
2068
|
+
void this.tick();
|
|
2069
|
+
});
|
|
2070
|
+
this.logger.info({ schedule: expression }, 'Schedule updated');
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
/** Reset backoff multiplier (call after successful synthesis). */
|
|
2074
|
+
resetBackoff() {
|
|
2075
|
+
if (this.backoffMultiplier > 1) {
|
|
2076
|
+
this.logger.debug('Backoff reset after successful synthesis');
|
|
2077
|
+
}
|
|
2078
|
+
this.backoffMultiplier = 1;
|
|
2079
|
+
}
|
|
2080
|
+
/** Whether the scheduler is currently running. */
|
|
2081
|
+
get isRunning() {
|
|
2082
|
+
return this.job !== null;
|
|
2083
|
+
}
|
|
2084
|
+
/** Next scheduled tick time, or null if not running. */
|
|
2085
|
+
get nextRunAt() {
|
|
2086
|
+
if (!this.job)
|
|
2087
|
+
return null;
|
|
2088
|
+
return this.job.nextRun() ?? null;
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Single tick: discover stalest candidate and enqueue it.
|
|
2092
|
+
*
|
|
2093
|
+
* Skips if the queue is currently processing. Applies adaptive backoff
|
|
2094
|
+
* when no candidates are found.
|
|
2095
|
+
*/
|
|
2096
|
+
async tick() {
|
|
2097
|
+
this.tickCount++;
|
|
2098
|
+
// Apply backoff: skip ticks when backing off
|
|
2099
|
+
if (this.backoffMultiplier > 1 &&
|
|
2100
|
+
this.tickCount % this.backoffMultiplier !== 0) {
|
|
2101
|
+
this.logger.trace({
|
|
2102
|
+
backoffMultiplier: this.backoffMultiplier,
|
|
2103
|
+
tickCount: this.tickCount,
|
|
2104
|
+
}, 'Skipping tick (backoff)');
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
const candidate = await this.discoverStalest();
|
|
2108
|
+
if (!candidate) {
|
|
2109
|
+
this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
|
|
2110
|
+
this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No stale candidates found, increasing backoff');
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
this.queue.enqueue(candidate);
|
|
2114
|
+
this.logger.info({ path: candidate }, 'Enqueued stale candidate');
|
|
2115
|
+
// Opportunistic watcher restart detection
|
|
2116
|
+
if (this.registrar) {
|
|
2117
|
+
try {
|
|
2118
|
+
const statusRes = await fetch(new URL('/status', this.config.watcherUrl), {
|
|
2119
|
+
signal: AbortSignal.timeout(3000),
|
|
2120
|
+
});
|
|
2121
|
+
if (statusRes.ok) {
|
|
2122
|
+
const status = (await statusRes.json());
|
|
2123
|
+
if (typeof status.uptime === 'number') {
|
|
2124
|
+
await this.registrar.checkAndReregister(status.uptime);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
catch {
|
|
2129
|
+
// Watcher unreachable — skip uptime check
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Discover the stalest meta candidate via watcher.
|
|
2135
|
+
*/
|
|
2136
|
+
async discoverStalest() {
|
|
2137
|
+
try {
|
|
2138
|
+
const result = await listMetas(this.config, this.watcher);
|
|
2139
|
+
const stale = result.entries
|
|
2140
|
+
.filter((e) => e.stalenessSeconds > 0)
|
|
2141
|
+
.map((e) => ({
|
|
2142
|
+
node: e.node,
|
|
2143
|
+
meta: e.meta,
|
|
2144
|
+
actualStaleness: e.stalenessSeconds,
|
|
2145
|
+
}));
|
|
2146
|
+
return discoverStalestPath(stale, this.config.depthWeight);
|
|
2147
|
+
}
|
|
2148
|
+
catch (err) {
|
|
2149
|
+
this.logger.warn({ err }, 'Failed to discover stalest candidate');
|
|
2150
|
+
return null;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/**
|
|
2156
|
+
* Single-threaded synthesis queue with priority support and deduplication.
|
|
2157
|
+
*
|
|
2158
|
+
* The scheduler enqueues the stalest candidate each tick. HTTP-triggered
|
|
2159
|
+
* synthesis requests get priority (inserted at front). A path appears at
|
|
2160
|
+
* most once in the queue; re-triggering returns the current position.
|
|
2161
|
+
*
|
|
2162
|
+
* @module queue
|
|
2163
|
+
*/
|
|
2164
|
+
const DEPTH_WARNING_THRESHOLD = 3;
|
|
2165
|
+
/**
|
|
2166
|
+
* Single-threaded synthesis queue.
|
|
2167
|
+
*
|
|
2168
|
+
* Only one synthesis runs at a time. Priority items are inserted at the
|
|
2169
|
+
* front of the queue. Duplicate paths are rejected with their current
|
|
2170
|
+
* position returned.
|
|
2171
|
+
*/
|
|
2172
|
+
class SynthesisQueue {
|
|
2173
|
+
queue = [];
|
|
2174
|
+
currentItem = null;
|
|
2175
|
+
processing = false;
|
|
2176
|
+
logger;
|
|
2177
|
+
onEnqueueCallback = null;
|
|
2178
|
+
/**
|
|
2179
|
+
* Create a new SynthesisQueue.
|
|
2180
|
+
*
|
|
2181
|
+
* @param logger - Pino logger instance.
|
|
2182
|
+
*/
|
|
2183
|
+
constructor(logger) {
|
|
2184
|
+
this.logger = logger;
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Set a callback to invoke when a new (non-duplicate) item is enqueued.
|
|
2188
|
+
*/
|
|
2189
|
+
onEnqueue(callback) {
|
|
2190
|
+
this.onEnqueueCallback = callback;
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Add a path to the synthesis queue.
|
|
2194
|
+
*
|
|
2195
|
+
* @param path - Meta path to synthesize.
|
|
2196
|
+
* @param priority - If true, insert at front of queue.
|
|
2197
|
+
* @returns Position and whether the path was already queued.
|
|
2198
|
+
*/
|
|
2199
|
+
enqueue(path, priority = false) {
|
|
2200
|
+
// Check if currently being synthesized.
|
|
2201
|
+
if (this.currentItem?.path === path) {
|
|
2202
|
+
return { position: 0, alreadyQueued: true };
|
|
2203
|
+
}
|
|
2204
|
+
// Check if already in queue.
|
|
2205
|
+
const existingIndex = this.queue.findIndex((item) => item.path === path);
|
|
2206
|
+
if (existingIndex !== -1) {
|
|
2207
|
+
return { position: existingIndex, alreadyQueued: true };
|
|
2208
|
+
}
|
|
2209
|
+
const item = {
|
|
2210
|
+
path,
|
|
2211
|
+
priority,
|
|
2212
|
+
enqueuedAt: new Date().toISOString(),
|
|
2213
|
+
};
|
|
2214
|
+
if (priority) {
|
|
2215
|
+
this.queue.unshift(item);
|
|
2216
|
+
}
|
|
2217
|
+
else {
|
|
2218
|
+
this.queue.push(item);
|
|
2219
|
+
}
|
|
2220
|
+
if (this.queue.length > DEPTH_WARNING_THRESHOLD) {
|
|
2221
|
+
this.logger.warn({ depth: this.queue.length }, 'Queue depth exceeds threshold');
|
|
2222
|
+
}
|
|
2223
|
+
const position = this.queue.findIndex((i) => i.path === path);
|
|
2224
|
+
this.onEnqueueCallback?.();
|
|
2225
|
+
return { position, alreadyQueued: false };
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Remove and return the next item from the queue.
|
|
2229
|
+
*
|
|
2230
|
+
* @returns The next QueueItem, or undefined if the queue is empty.
|
|
2231
|
+
*/
|
|
2232
|
+
dequeue() {
|
|
2233
|
+
const item = this.queue.shift();
|
|
2234
|
+
if (item) {
|
|
2235
|
+
this.currentItem = item;
|
|
2236
|
+
}
|
|
2237
|
+
return item;
|
|
2238
|
+
}
|
|
2239
|
+
/** Mark the currently-running synthesis as complete. */
|
|
2240
|
+
complete() {
|
|
2241
|
+
this.currentItem = null;
|
|
2242
|
+
}
|
|
2243
|
+
/** Number of items waiting in the queue (excludes current). */
|
|
2244
|
+
get depth() {
|
|
2245
|
+
return this.queue.length;
|
|
2246
|
+
}
|
|
2247
|
+
/** The item currently being synthesized, or null. */
|
|
2248
|
+
get current() {
|
|
2249
|
+
return this.currentItem;
|
|
2250
|
+
}
|
|
2251
|
+
/** A shallow copy of the queued items. */
|
|
2252
|
+
get items() {
|
|
2253
|
+
return [...this.queue];
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* Check whether a path is in the queue or currently being synthesized.
|
|
2257
|
+
*
|
|
2258
|
+
* @param path - Meta path to look up.
|
|
2259
|
+
* @returns True if the path is queued or currently running.
|
|
2260
|
+
*/
|
|
2261
|
+
has(path) {
|
|
2262
|
+
if (this.currentItem?.path === path)
|
|
2263
|
+
return true;
|
|
2264
|
+
return this.queue.some((item) => item.path === path);
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Get the 0-indexed position of a path in the queue.
|
|
2268
|
+
*
|
|
2269
|
+
* @param path - Meta path to look up.
|
|
2270
|
+
* @returns Position index, or null if not found in the queue.
|
|
2271
|
+
*/
|
|
2272
|
+
getPosition(path) {
|
|
2273
|
+
const index = this.queue.findIndex((item) => item.path === path);
|
|
2274
|
+
return index === -1 ? null : index;
|
|
2275
|
+
}
|
|
2276
|
+
/**
|
|
2277
|
+
* Return a snapshot of queue state for the /status endpoint.
|
|
2278
|
+
*
|
|
2279
|
+
* @returns Queue depth and item list.
|
|
2280
|
+
*/
|
|
2281
|
+
getState() {
|
|
2282
|
+
return {
|
|
2283
|
+
depth: this.queue.length,
|
|
2284
|
+
items: this.queue.map((item) => ({
|
|
2285
|
+
path: item.path,
|
|
2286
|
+
priority: item.priority,
|
|
2287
|
+
enqueuedAt: item.enqueuedAt,
|
|
2288
|
+
})),
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Process queued items one at a time until the queue is empty.
|
|
2293
|
+
*
|
|
2294
|
+
* Re-entry is prevented: if already processing, the call returns
|
|
2295
|
+
* immediately. Errors are logged and do not block subsequent items.
|
|
2296
|
+
*
|
|
2297
|
+
* @param synthesizeFn - Async function that performs synthesis for a path.
|
|
2298
|
+
*/
|
|
2299
|
+
async processQueue(synthesizeFn) {
|
|
2300
|
+
if (this.processing)
|
|
2301
|
+
return;
|
|
2302
|
+
this.processing = true;
|
|
2303
|
+
try {
|
|
2304
|
+
let item = this.dequeue();
|
|
2305
|
+
while (item) {
|
|
2306
|
+
try {
|
|
2307
|
+
await synthesizeFn(item.path);
|
|
2308
|
+
}
|
|
2309
|
+
catch (err) {
|
|
2310
|
+
this.logger.error({ path: item.path, err }, 'Synthesis failed');
|
|
2311
|
+
}
|
|
2312
|
+
this.complete();
|
|
2313
|
+
item = this.dequeue();
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
finally {
|
|
2317
|
+
this.processing = false;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* GET /config/validate — return sanitized service configuration.
|
|
2324
|
+
*
|
|
2325
|
+
* @module routes/configValidate
|
|
2326
|
+
*/
|
|
2327
|
+
function registerConfigValidateRoute(app, deps) {
|
|
2328
|
+
app.get('/config/validate', () => {
|
|
2329
|
+
const sanitized = {
|
|
2330
|
+
...deps.config,
|
|
2331
|
+
gatewayApiKey: deps.config.gatewayApiKey ? '[REDACTED]' : undefined,
|
|
2332
|
+
};
|
|
2333
|
+
return sanitized;
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/**
|
|
2338
|
+
* GET /metas — list metas with optional filters.
|
|
2339
|
+
* GET /metas/:path — single meta detail.
|
|
2340
|
+
*
|
|
2341
|
+
* @module routes/metas
|
|
2342
|
+
*/
|
|
2343
|
+
const metasQuerySchema = z.object({
|
|
2344
|
+
pathPrefix: z.string().optional(),
|
|
2345
|
+
hasError: z
|
|
2346
|
+
.enum(['true', 'false'])
|
|
2347
|
+
.transform((v) => v === 'true')
|
|
2348
|
+
.optional(),
|
|
2349
|
+
staleHours: z
|
|
2350
|
+
.string()
|
|
2351
|
+
.transform(Number)
|
|
2352
|
+
.pipe(z.number().positive())
|
|
2353
|
+
.optional(),
|
|
2354
|
+
neverSynthesized: z
|
|
2355
|
+
.enum(['true', 'false'])
|
|
2356
|
+
.transform((v) => v === 'true')
|
|
2357
|
+
.optional(),
|
|
2358
|
+
locked: z
|
|
2359
|
+
.enum(['true', 'false'])
|
|
2360
|
+
.transform((v) => v === 'true')
|
|
2361
|
+
.optional(),
|
|
2362
|
+
fields: z.string().optional(),
|
|
2363
|
+
});
|
|
2364
|
+
const metaDetailQuerySchema = z.object({
|
|
2365
|
+
fields: z.string().optional(),
|
|
2366
|
+
includeArchive: z
|
|
2367
|
+
.union([
|
|
2368
|
+
z.enum(['true', 'false']).transform((v) => v === 'true'),
|
|
2369
|
+
z.string().transform(Number).pipe(z.number().int().nonnegative()),
|
|
2370
|
+
])
|
|
2371
|
+
.optional(),
|
|
2372
|
+
});
|
|
2373
|
+
/** Compute summary stats from a filtered set of MetaEntries. */
|
|
2374
|
+
function computeFilteredSummary(entries) {
|
|
2375
|
+
let staleCount = 0;
|
|
2376
|
+
let errorCount = 0;
|
|
2377
|
+
let neverSynthCount = 0;
|
|
2378
|
+
let stalestPath = null;
|
|
2379
|
+
let stalestSeconds = -1;
|
|
2380
|
+
let lastSynthesizedPath = null;
|
|
2381
|
+
let lastSynthesizedAt = null;
|
|
2382
|
+
let totalArchitectTokens = 0;
|
|
2383
|
+
let totalBuilderTokens = 0;
|
|
2384
|
+
let totalCriticTokens = 0;
|
|
2385
|
+
for (const e of entries) {
|
|
2386
|
+
if (e.stalenessSeconds > 0)
|
|
2387
|
+
staleCount++;
|
|
2388
|
+
if (e.hasError)
|
|
2389
|
+
errorCount++;
|
|
2390
|
+
if (e.stalenessSeconds === Infinity)
|
|
2391
|
+
neverSynthCount++;
|
|
2392
|
+
if (e.stalenessSeconds > stalestSeconds) {
|
|
2393
|
+
stalestSeconds = e.stalenessSeconds;
|
|
2394
|
+
stalestPath = e.path;
|
|
2395
|
+
}
|
|
2396
|
+
if (e.lastSynthesized &&
|
|
2397
|
+
(!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
|
|
2398
|
+
lastSynthesizedAt = e.lastSynthesized;
|
|
2399
|
+
lastSynthesizedPath = e.path;
|
|
2400
|
+
}
|
|
2401
|
+
totalArchitectTokens += e.architectTokens ?? 0;
|
|
2402
|
+
totalBuilderTokens += e.builderTokens ?? 0;
|
|
2403
|
+
totalCriticTokens += e.criticTokens ?? 0;
|
|
2404
|
+
}
|
|
2405
|
+
return {
|
|
2406
|
+
total: entries.length,
|
|
2407
|
+
stale: staleCount,
|
|
2408
|
+
errors: errorCount,
|
|
2409
|
+
neverSynthesized: neverSynthCount,
|
|
2410
|
+
stalestPath,
|
|
2411
|
+
lastSynthesizedPath,
|
|
2412
|
+
lastSynthesizedAt,
|
|
2413
|
+
tokens: {
|
|
2414
|
+
architect: totalArchitectTokens,
|
|
2415
|
+
builder: totalBuilderTokens,
|
|
2416
|
+
critic: totalCriticTokens,
|
|
2417
|
+
},
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
function registerMetasRoutes(app, deps) {
|
|
2421
|
+
app.get('/metas', async (request) => {
|
|
2422
|
+
const query = metasQuerySchema.parse(request.query);
|
|
2423
|
+
const { config, watcher } = deps;
|
|
2424
|
+
const result = await listMetas(config, watcher);
|
|
2425
|
+
let entries = result.entries;
|
|
2426
|
+
// Apply filters
|
|
2427
|
+
if (query.pathPrefix) {
|
|
2428
|
+
entries = entries.filter((e) => e.path.includes(query.pathPrefix));
|
|
2429
|
+
}
|
|
2430
|
+
if (query.hasError !== undefined) {
|
|
2431
|
+
entries = entries.filter((e) => e.hasError === query.hasError);
|
|
2432
|
+
}
|
|
2433
|
+
if (query.neverSynthesized !== undefined) {
|
|
2434
|
+
entries = entries.filter((e) => (e.stalenessSeconds === Infinity) === query.neverSynthesized);
|
|
2435
|
+
}
|
|
2436
|
+
if (query.locked !== undefined) {
|
|
2437
|
+
entries = entries.filter((e) => e.locked === query.locked);
|
|
2438
|
+
}
|
|
2439
|
+
if (typeof query.staleHours === 'number') {
|
|
2440
|
+
entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
|
|
2441
|
+
}
|
|
2442
|
+
// Summary (computed from filtered entries)
|
|
2443
|
+
const summary = computeFilteredSummary(entries);
|
|
2444
|
+
// Field projection
|
|
2445
|
+
const fieldList = query.fields?.split(',');
|
|
2446
|
+
const defaultFields = [
|
|
2447
|
+
'path',
|
|
2448
|
+
'depth',
|
|
2449
|
+
'emphasis',
|
|
2450
|
+
'stalenessSeconds',
|
|
2451
|
+
'lastSynthesized',
|
|
2452
|
+
'hasError',
|
|
2453
|
+
'locked',
|
|
2454
|
+
'architectTokens',
|
|
2455
|
+
'builderTokens',
|
|
2456
|
+
'criticTokens',
|
|
2457
|
+
];
|
|
2458
|
+
const projectedFields = fieldList ?? defaultFields;
|
|
2459
|
+
const metas = entries.map((e) => {
|
|
2460
|
+
const full = {
|
|
2461
|
+
path: e.path,
|
|
2462
|
+
depth: e.depth,
|
|
2463
|
+
emphasis: e.emphasis,
|
|
2464
|
+
stalenessSeconds: e.stalenessSeconds === Infinity
|
|
2465
|
+
? null
|
|
2466
|
+
: Math.round(e.stalenessSeconds),
|
|
2467
|
+
lastSynthesized: e.lastSynthesized,
|
|
2468
|
+
hasError: e.hasError,
|
|
2469
|
+
locked: e.locked,
|
|
2470
|
+
architectTokens: e.architectTokens,
|
|
2471
|
+
builderTokens: e.builderTokens,
|
|
2472
|
+
criticTokens: e.criticTokens,
|
|
2473
|
+
};
|
|
2474
|
+
const projected = {};
|
|
2475
|
+
for (const f of projectedFields) {
|
|
2476
|
+
if (f in full)
|
|
2477
|
+
projected[f] = full[f];
|
|
2478
|
+
}
|
|
2479
|
+
return projected;
|
|
2480
|
+
});
|
|
2481
|
+
return { summary, metas };
|
|
2482
|
+
});
|
|
2483
|
+
app.get('/metas/:path', async (request, reply) => {
|
|
2484
|
+
const query = metaDetailQuerySchema.parse(request.query);
|
|
2485
|
+
const { config, watcher } = deps;
|
|
2486
|
+
const targetPath = normalizePath(decodeURIComponent(request.params.path));
|
|
2487
|
+
const result = await listMetas(config, watcher);
|
|
2488
|
+
const targetNode = findNode(result.tree, targetPath);
|
|
2489
|
+
if (!targetNode) {
|
|
2490
|
+
return reply.status(404).send({
|
|
2491
|
+
error: 'NOT_FOUND',
|
|
2492
|
+
message: 'Meta path not found: ' + targetPath,
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
|
|
2496
|
+
// Field projection
|
|
2497
|
+
const defaultExclude = new Set([
|
|
2498
|
+
'_architect',
|
|
2499
|
+
'_builder',
|
|
2500
|
+
'_critic',
|
|
2501
|
+
'_content',
|
|
2502
|
+
'_feedback',
|
|
2503
|
+
]);
|
|
2504
|
+
const fieldList = query.fields?.split(',');
|
|
2505
|
+
const projectMeta = (m) => {
|
|
2506
|
+
if (fieldList) {
|
|
2507
|
+
const r = {};
|
|
2508
|
+
for (const f of fieldList)
|
|
2509
|
+
r[f] = m[f];
|
|
2510
|
+
return r;
|
|
2511
|
+
}
|
|
2512
|
+
const r = {};
|
|
2513
|
+
for (const [k, v] of Object.entries(m)) {
|
|
2514
|
+
if (!defaultExclude.has(k))
|
|
2515
|
+
r[k] = v;
|
|
2516
|
+
}
|
|
2517
|
+
return r;
|
|
2518
|
+
};
|
|
2519
|
+
// Compute scope
|
|
2520
|
+
const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
|
|
2521
|
+
// Compute staleness
|
|
2522
|
+
const metaTyped = meta;
|
|
2523
|
+
const staleSeconds = metaTyped._generatedAt
|
|
2524
|
+
? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
|
|
2525
|
+
: null;
|
|
2526
|
+
const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
|
|
2527
|
+
const response = {
|
|
2528
|
+
path: targetNode.metaPath,
|
|
2529
|
+
meta: projectMeta(meta),
|
|
2530
|
+
scope: {
|
|
2531
|
+
ownedFiles: scopeFiles.length,
|
|
2532
|
+
childMetas: targetNode.children.length,
|
|
2533
|
+
totalFiles: allFiles.length,
|
|
2534
|
+
},
|
|
2535
|
+
staleness: {
|
|
2536
|
+
seconds: staleSeconds,
|
|
2537
|
+
score: Math.round(score * 100) / 100,
|
|
2538
|
+
},
|
|
2539
|
+
};
|
|
2540
|
+
// Archive
|
|
2541
|
+
if (query.includeArchive) {
|
|
2542
|
+
const archiveFiles = listArchiveFiles(targetNode.metaPath);
|
|
2543
|
+
const limit = typeof query.includeArchive === 'number'
|
|
2544
|
+
? query.includeArchive
|
|
2545
|
+
: archiveFiles.length;
|
|
2546
|
+
const selected = archiveFiles.slice(-limit).reverse();
|
|
2547
|
+
response.archive = selected.map((af) => {
|
|
2548
|
+
const raw = readFileSync(af, 'utf8');
|
|
2549
|
+
return projectMeta(JSON.parse(raw));
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
return response;
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
/**
|
|
2557
|
+
* GET /preview — dry-run synthesis preview.
|
|
2558
|
+
*
|
|
2559
|
+
* @module routes/preview
|
|
2560
|
+
*/
|
|
2561
|
+
function registerPreviewRoute(app, deps) {
|
|
2562
|
+
app.get('/preview', async (request, reply) => {
|
|
2563
|
+
const { config, watcher } = deps;
|
|
2564
|
+
const query = request.query;
|
|
2565
|
+
let result;
|
|
2566
|
+
try {
|
|
2567
|
+
result = await listMetas(config, watcher);
|
|
2568
|
+
}
|
|
2569
|
+
catch {
|
|
2570
|
+
return reply.status(503).send({
|
|
2571
|
+
error: 'SERVICE_UNAVAILABLE',
|
|
2572
|
+
message: 'Watcher unreachable — cannot compute preview',
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
let targetNode;
|
|
2576
|
+
if (query.path) {
|
|
2577
|
+
const normalized = normalizePath(query.path);
|
|
2578
|
+
targetNode = findNode(result.tree, normalized);
|
|
2579
|
+
if (!targetNode) {
|
|
2580
|
+
return {
|
|
2581
|
+
error: 'NOT_FOUND',
|
|
2582
|
+
message: 'Meta path not found: ' + query.path,
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
else {
|
|
2587
|
+
// Select stalest candidate
|
|
2588
|
+
const stale = result.entries
|
|
2589
|
+
.filter((e) => e.stalenessSeconds > 0)
|
|
2590
|
+
.map((e) => ({
|
|
2591
|
+
node: e.node,
|
|
2592
|
+
meta: e.meta,
|
|
2593
|
+
actualStaleness: e.stalenessSeconds,
|
|
2594
|
+
}));
|
|
2595
|
+
const stalestPath = discoverStalestPath(stale, config.depthWeight);
|
|
2596
|
+
if (!stalestPath) {
|
|
2597
|
+
return { message: 'No stale metas found. Nothing to synthesize.' };
|
|
2598
|
+
}
|
|
2599
|
+
targetNode = findNode(result.tree, stalestPath);
|
|
2600
|
+
}
|
|
2601
|
+
const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
|
|
2602
|
+
// Scope files
|
|
2603
|
+
const { scopeFiles } = await getScopeFiles(targetNode, watcher);
|
|
2604
|
+
const structureHash = computeStructureHash(scopeFiles);
|
|
2605
|
+
const structureChanged = structureHash !== meta._structureHash;
|
|
2606
|
+
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
2607
|
+
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
2608
|
+
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
2609
|
+
// Delta files
|
|
2610
|
+
const deltaFiles = await getDeltaFiles(targetNode, watcher, meta._generatedAt, scopeFiles);
|
|
2611
|
+
// EMA token estimates
|
|
2612
|
+
const estimatedTokens = {
|
|
2613
|
+
architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
|
|
2614
|
+
builder: meta._builderTokensAvg ?? meta._builderTokens ?? 0,
|
|
2615
|
+
critic: meta._criticTokensAvg ?? meta._criticTokens ?? 0,
|
|
2616
|
+
};
|
|
2617
|
+
// Compute staleness
|
|
2618
|
+
const stalenessSeconds = meta._generatedAt
|
|
2619
|
+
? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
|
|
2620
|
+
: null;
|
|
2621
|
+
const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
|
|
2622
|
+
return {
|
|
2623
|
+
path: targetNode.metaPath,
|
|
2624
|
+
staleness: {
|
|
2625
|
+
seconds: stalenessSeconds,
|
|
2626
|
+
score: Math.round(stalenessScore * 100) / 100,
|
|
2627
|
+
},
|
|
2628
|
+
architectWillRun: architectTriggered,
|
|
2629
|
+
architectReason: [
|
|
2630
|
+
...(!meta._builder ? ['no cached builder (first run)'] : []),
|
|
2631
|
+
...(structureChanged ? ['structure changed'] : []),
|
|
2632
|
+
...(steerChanged ? ['steer changed'] : []),
|
|
2633
|
+
...((meta._synthesisCount ?? 0) >= config.architectEvery
|
|
2634
|
+
? ['periodic refresh']
|
|
2635
|
+
: []),
|
|
2636
|
+
].join(', ') || 'not triggered',
|
|
2637
|
+
scope: {
|
|
2638
|
+
ownedFiles: scopeFiles.length,
|
|
2639
|
+
childMetas: targetNode.children.length,
|
|
2640
|
+
deltaFiles: deltaFiles
|
|
2641
|
+
.slice(0, 50)
|
|
2642
|
+
.map((f) => ({ path: f, action: 'modified' })),
|
|
2643
|
+
deltaCount: deltaFiles.length,
|
|
2644
|
+
},
|
|
2645
|
+
estimatedTokens,
|
|
2646
|
+
};
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
/**
|
|
2651
|
+
* POST /seed — create a .meta/ directory with an empty meta.json.
|
|
2652
|
+
*
|
|
2653
|
+
* @module routes/seed
|
|
2654
|
+
*/
|
|
2655
|
+
const seedBodySchema = z.object({
|
|
2656
|
+
path: z.string().min(1),
|
|
2657
|
+
});
|
|
2658
|
+
function registerSeedRoute(app, deps) {
|
|
2659
|
+
app.post('/seed', (request, reply) => {
|
|
2660
|
+
const body = seedBodySchema.parse(request.body);
|
|
2661
|
+
const metaDir = resolveMetaDir(body.path);
|
|
2662
|
+
if (existsSync(metaDir)) {
|
|
2663
|
+
return reply.status(409).send({
|
|
2664
|
+
error: 'CONFLICT',
|
|
2665
|
+
message: `.meta directory already exists at ${body.path}`,
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
deps.logger.info({ metaDir }, 'creating .meta directory');
|
|
2669
|
+
mkdirSync(metaDir, { recursive: true });
|
|
2670
|
+
const metaJson = { _id: randomUUID() };
|
|
2671
|
+
const metaJsonPath = join(metaDir, 'meta.json');
|
|
2672
|
+
deps.logger.info({ metaJsonPath }, 'writing meta.json');
|
|
2673
|
+
writeFileSync(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
|
|
2674
|
+
return reply.status(201).send({
|
|
2675
|
+
status: 'created',
|
|
2676
|
+
path: body.path,
|
|
2677
|
+
metaDir,
|
|
2678
|
+
_id: metaJson._id,
|
|
2679
|
+
});
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/**
|
|
2684
|
+
* GET /status — service health and status overview.
|
|
2685
|
+
*
|
|
2686
|
+
* On-demand dependency health checks (lightweight ping).
|
|
2687
|
+
*
|
|
2688
|
+
* @module routes/status
|
|
2689
|
+
*/
|
|
2690
|
+
async function checkDependency(url, path) {
|
|
2691
|
+
const checkedAt = new Date().toISOString();
|
|
2692
|
+
try {
|
|
2693
|
+
const res = await fetch(new URL(path, url), {
|
|
2694
|
+
signal: AbortSignal.timeout(3000),
|
|
2695
|
+
});
|
|
2696
|
+
return { url, status: res.ok ? 'ok' : 'error', checkedAt };
|
|
2697
|
+
}
|
|
2698
|
+
catch {
|
|
2699
|
+
return { url, status: 'unreachable', checkedAt };
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
function registerStatusRoute(app, deps) {
|
|
2703
|
+
app.get('/status', async () => {
|
|
2704
|
+
const { config, queue, scheduler, stats, watcher } = deps;
|
|
2705
|
+
// On-demand dependency checks
|
|
2706
|
+
const [watcherHealth, gatewayHealth] = await Promise.all([
|
|
2707
|
+
checkDependency(config.watcherUrl, '/status'),
|
|
2708
|
+
checkDependency(config.gatewayUrl, '/api/status'),
|
|
2709
|
+
]);
|
|
2710
|
+
const degraded = watcherHealth.status !== 'ok' || gatewayHealth.status !== 'ok';
|
|
2711
|
+
// Determine status
|
|
2712
|
+
let status;
|
|
2713
|
+
if (deps.shuttingDown) {
|
|
2714
|
+
status = 'stopping';
|
|
2715
|
+
}
|
|
2716
|
+
else if (queue.current) {
|
|
2717
|
+
status = 'synthesizing';
|
|
2718
|
+
}
|
|
2719
|
+
else if (degraded) {
|
|
2720
|
+
status = 'degraded';
|
|
2721
|
+
}
|
|
2722
|
+
else {
|
|
2723
|
+
status = 'idle';
|
|
2724
|
+
}
|
|
2725
|
+
// Metas summary from listMetas (already computed)
|
|
2726
|
+
let metasSummary = { total: 0, stale: 0, errors: 0, neverSynthesized: 0 };
|
|
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
|
+
}
|
|
2739
|
+
return {
|
|
2740
|
+
service: 'jeeves-meta',
|
|
2741
|
+
version: '0.4.0',
|
|
2742
|
+
uptime: process.uptime(),
|
|
2743
|
+
status,
|
|
2744
|
+
currentTarget: queue.current?.path ?? null,
|
|
2745
|
+
queue: queue.getState(),
|
|
2746
|
+
stats: {
|
|
2747
|
+
totalSyntheses: stats.totalSyntheses,
|
|
2748
|
+
totalTokens: stats.totalTokens,
|
|
2749
|
+
totalErrors: stats.totalErrors,
|
|
2750
|
+
lastCycleDurationMs: stats.lastCycleDurationMs,
|
|
2751
|
+
lastCycleAt: stats.lastCycleAt,
|
|
2752
|
+
},
|
|
2753
|
+
schedule: {
|
|
2754
|
+
expression: config.schedule,
|
|
2755
|
+
nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
|
|
2756
|
+
},
|
|
2757
|
+
dependencies: {
|
|
2758
|
+
watcher: watcherHealth,
|
|
2759
|
+
gateway: gatewayHealth,
|
|
2760
|
+
},
|
|
2761
|
+
metas: metasSummary,
|
|
2762
|
+
};
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
/**
|
|
2767
|
+
* POST /synthesize route handler.
|
|
2768
|
+
*
|
|
2769
|
+
* @module routes/synthesize
|
|
2770
|
+
*/
|
|
2771
|
+
const synthesizeBodySchema = z.object({
|
|
2772
|
+
path: z.string().optional(),
|
|
2773
|
+
});
|
|
2774
|
+
/** Register the POST /synthesize route. */
|
|
2775
|
+
function registerSynthesizeRoute(app, deps) {
|
|
2776
|
+
app.post('/synthesize', async (request, reply) => {
|
|
2777
|
+
const body = synthesizeBodySchema.parse(request.body);
|
|
2778
|
+
const { config, watcher, queue } = deps;
|
|
2779
|
+
let targetPath;
|
|
2780
|
+
if (body.path) {
|
|
2781
|
+
targetPath = body.path;
|
|
2782
|
+
}
|
|
2783
|
+
else {
|
|
2784
|
+
// Discover stalest candidate
|
|
2785
|
+
let result;
|
|
2786
|
+
try {
|
|
2787
|
+
result = await listMetas(config, watcher);
|
|
2788
|
+
}
|
|
2789
|
+
catch {
|
|
2790
|
+
return reply.status(503).send({
|
|
2791
|
+
error: 'SERVICE_UNAVAILABLE',
|
|
2792
|
+
message: 'Watcher unreachable — cannot discover candidates',
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
const stale = result.entries
|
|
2796
|
+
.filter((e) => e.stalenessSeconds > 0)
|
|
2797
|
+
.map((e) => ({
|
|
2798
|
+
node: e.node,
|
|
2799
|
+
meta: e.meta,
|
|
2800
|
+
actualStaleness: e.stalenessSeconds,
|
|
2801
|
+
}));
|
|
2802
|
+
const stalest = discoverStalestPath(stale, config.depthWeight);
|
|
2803
|
+
if (!stalest) {
|
|
2804
|
+
return reply.code(200).send({
|
|
2805
|
+
status: 'skipped',
|
|
2806
|
+
message: 'No stale metas found. Nothing to synthesize.',
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
targetPath = stalest;
|
|
2810
|
+
}
|
|
2811
|
+
const result = queue.enqueue(targetPath, body.path !== undefined);
|
|
2812
|
+
return reply.code(202).send({
|
|
2813
|
+
status: 'accepted',
|
|
2814
|
+
path: targetPath,
|
|
2815
|
+
queuePosition: result.position,
|
|
2816
|
+
alreadyQueued: result.alreadyQueued,
|
|
2817
|
+
});
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
/**
|
|
2822
|
+
* POST /unlock — remove .lock from a .meta/ directory.
|
|
2823
|
+
*
|
|
2824
|
+
* @module routes/unlock
|
|
2825
|
+
*/
|
|
2826
|
+
const unlockBodySchema = z.object({
|
|
2827
|
+
path: z.string().min(1),
|
|
2828
|
+
});
|
|
2829
|
+
function registerUnlockRoute(app, deps) {
|
|
2830
|
+
app.post('/unlock', (request, reply) => {
|
|
2831
|
+
const body = unlockBodySchema.parse(request.body);
|
|
2832
|
+
const metaDir = resolveMetaDir(body.path);
|
|
2833
|
+
const lockPath = join(metaDir, '.lock');
|
|
2834
|
+
if (!existsSync(lockPath)) {
|
|
2835
|
+
return reply.status(409).send({
|
|
2836
|
+
error: 'ALREADY_UNLOCKED',
|
|
2837
|
+
message: `No lock file at ${body.path} (already unlocked)`,
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
deps.logger.info({ lockPath }, 'removing lock file');
|
|
2841
|
+
unlinkSync(lockPath);
|
|
2842
|
+
return reply.status(200).send({
|
|
2843
|
+
status: 'unlocked',
|
|
2844
|
+
path: body.path,
|
|
2845
|
+
});
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
/**
|
|
2850
|
+
* Route registration for jeeves-meta service.
|
|
2851
|
+
*
|
|
2852
|
+
* @module routes
|
|
2853
|
+
*/
|
|
2854
|
+
/** Register all HTTP routes on the Fastify instance. */
|
|
2855
|
+
function registerRoutes(app, deps) {
|
|
2856
|
+
// Global error handler for validation + watcher errors
|
|
2857
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
2858
|
+
if (error.validation) {
|
|
2859
|
+
return reply
|
|
2860
|
+
.status(400)
|
|
2861
|
+
.send({ error: 'BAD_REQUEST', message: error.message });
|
|
2862
|
+
}
|
|
2863
|
+
if (error.statusCode === 404) {
|
|
2864
|
+
return reply
|
|
2865
|
+
.status(404)
|
|
2866
|
+
.send({ error: 'NOT_FOUND', message: error.message });
|
|
2867
|
+
}
|
|
2868
|
+
deps.logger.error(error, 'Unhandled route error');
|
|
2869
|
+
return reply
|
|
2870
|
+
.status(500)
|
|
2871
|
+
.send({ error: 'INTERNAL_ERROR', message: error.message });
|
|
2872
|
+
});
|
|
2873
|
+
registerStatusRoute(app, deps);
|
|
2874
|
+
registerMetasRoutes(app, deps);
|
|
2875
|
+
registerSynthesizeRoute(app, deps);
|
|
2876
|
+
registerPreviewRoute(app, deps);
|
|
2877
|
+
registerSeedRoute(app, deps);
|
|
2878
|
+
registerUnlockRoute(app, deps);
|
|
2879
|
+
registerConfigValidateRoute(app, deps);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
/**
|
|
2883
|
+
* Virtual rule registration with jeeves-watcher.
|
|
2884
|
+
*
|
|
2885
|
+
* Service registers inference rules at startup (with retry) and
|
|
2886
|
+
* re-registers opportunistically when watcher restart is detected.
|
|
2887
|
+
*
|
|
2888
|
+
* @module rules
|
|
2889
|
+
*/
|
|
2890
|
+
const SOURCE = 'jeeves-meta';
|
|
2891
|
+
const MAX_RETRIES = 10;
|
|
2892
|
+
const RETRY_BASE_MS = 2000;
|
|
2893
|
+
/**
|
|
2894
|
+
* Convert a `Record<string, unknown>` config property into watcher
|
|
2895
|
+
* schema `set` directives: `{ key: { set: value } }` per entry.
|
|
2896
|
+
*/
|
|
2897
|
+
function toSchemaSetDirectives(props) {
|
|
2898
|
+
return Object.fromEntries(Object.entries(props).map(([k, v]) => [k, { set: v }]));
|
|
2899
|
+
}
|
|
2900
|
+
/** Build the three virtual rule definitions. */
|
|
2901
|
+
function buildMetaRules(config) {
|
|
2902
|
+
return [
|
|
2903
|
+
{
|
|
2904
|
+
name: 'meta-current',
|
|
2905
|
+
description: 'Live jeeves-meta .meta/meta.json files',
|
|
2906
|
+
match: {
|
|
2907
|
+
properties: {
|
|
2908
|
+
file: {
|
|
2909
|
+
properties: {
|
|
2910
|
+
path: { type: 'string', glob: '**/.meta/meta.json' },
|
|
2911
|
+
},
|
|
2912
|
+
},
|
|
2913
|
+
},
|
|
2914
|
+
},
|
|
2915
|
+
schema: [
|
|
2916
|
+
'base',
|
|
2917
|
+
{
|
|
2918
|
+
properties: {
|
|
2919
|
+
...toSchemaSetDirectives(config.metaProperty),
|
|
2920
|
+
meta_id: { type: 'string', set: '{{json._id}}' },
|
|
2921
|
+
meta_steer: { type: 'string', set: '{{json._steer}}' },
|
|
2922
|
+
meta_depth: { type: 'number', set: '{{json._depth}}' },
|
|
2923
|
+
meta_emphasis: { type: 'number', set: '{{json._emphasis}}' },
|
|
2924
|
+
meta_synthesis_count: {
|
|
2925
|
+
type: 'integer',
|
|
2926
|
+
set: '{{json._synthesisCount}}',
|
|
2927
|
+
},
|
|
2928
|
+
meta_structure_hash: {
|
|
2929
|
+
type: 'string',
|
|
2930
|
+
set: '{{json._structureHash}}',
|
|
2931
|
+
},
|
|
2932
|
+
meta_architect_tokens: {
|
|
2933
|
+
type: 'integer',
|
|
2934
|
+
set: '{{json._architectTokens}}',
|
|
2935
|
+
},
|
|
2936
|
+
meta_builder_tokens: {
|
|
2937
|
+
type: 'integer',
|
|
2938
|
+
set: '{{json._builderTokens}}',
|
|
2939
|
+
},
|
|
2940
|
+
meta_critic_tokens: {
|
|
2941
|
+
type: 'integer',
|
|
2942
|
+
set: '{{json._criticTokens}}',
|
|
2943
|
+
},
|
|
2944
|
+
meta_error_step: {
|
|
2945
|
+
type: 'string',
|
|
2946
|
+
set: '{{json._error.step}}',
|
|
2947
|
+
},
|
|
2948
|
+
generated_at_unix: {
|
|
2949
|
+
type: 'integer',
|
|
2950
|
+
set: '{{toUnix json._generatedAt}}',
|
|
2951
|
+
},
|
|
2952
|
+
has_error: {
|
|
2953
|
+
type: 'boolean',
|
|
2954
|
+
set: '{{#if json._error}}true{{else}}false{{/if}}',
|
|
2955
|
+
},
|
|
2956
|
+
},
|
|
2957
|
+
},
|
|
2958
|
+
],
|
|
2959
|
+
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
|
+
],
|
|
2970
|
+
body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
|
|
2971
|
+
},
|
|
2972
|
+
renderAs: 'md',
|
|
2973
|
+
},
|
|
2974
|
+
{
|
|
2975
|
+
name: 'meta-archive',
|
|
2976
|
+
description: 'Archived jeeves-meta .meta/archive snapshots',
|
|
2977
|
+
match: {
|
|
2978
|
+
properties: {
|
|
2979
|
+
file: {
|
|
2980
|
+
properties: {
|
|
2981
|
+
path: { type: 'string', glob: '**/.meta/archive/*.json' },
|
|
2982
|
+
},
|
|
2983
|
+
},
|
|
2984
|
+
},
|
|
2985
|
+
},
|
|
2986
|
+
schema: [
|
|
2987
|
+
'base',
|
|
2988
|
+
{
|
|
2989
|
+
properties: {
|
|
2990
|
+
...toSchemaSetDirectives(config.metaArchiveProperty),
|
|
2991
|
+
meta_id: { type: 'string', set: '{{json._id}}' },
|
|
2992
|
+
archived: { type: 'boolean', set: 'true' },
|
|
2993
|
+
archived_at: { type: 'string', set: '{{json._archivedAt}}' },
|
|
2994
|
+
},
|
|
2995
|
+
},
|
|
2996
|
+
],
|
|
2997
|
+
render: {
|
|
2998
|
+
frontmatter: ['meta_id', 'archived', 'archived_at'],
|
|
2999
|
+
body: [
|
|
3000
|
+
{
|
|
3001
|
+
path: 'json._content',
|
|
3002
|
+
heading: 1,
|
|
3003
|
+
label: 'Synthesis (archived)',
|
|
3004
|
+
},
|
|
3005
|
+
],
|
|
3006
|
+
},
|
|
3007
|
+
renderAs: 'md',
|
|
3008
|
+
},
|
|
3009
|
+
{
|
|
3010
|
+
name: 'meta-config',
|
|
3011
|
+
description: 'jeeves-meta configuration file',
|
|
3012
|
+
match: {
|
|
3013
|
+
properties: {
|
|
3014
|
+
file: {
|
|
3015
|
+
properties: {
|
|
3016
|
+
path: { type: 'string', glob: '**/jeeves-meta.config.json' },
|
|
3017
|
+
},
|
|
3018
|
+
},
|
|
3019
|
+
},
|
|
3020
|
+
},
|
|
3021
|
+
schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
|
|
3022
|
+
render: {
|
|
3023
|
+
frontmatter: [
|
|
3024
|
+
'watcherUrl',
|
|
3025
|
+
'gatewayUrl',
|
|
3026
|
+
'architectEvery',
|
|
3027
|
+
'depthWeight',
|
|
3028
|
+
'maxArchive',
|
|
3029
|
+
'maxLines',
|
|
3030
|
+
],
|
|
3031
|
+
body: [
|
|
3032
|
+
{
|
|
3033
|
+
path: 'json.defaultArchitect',
|
|
3034
|
+
heading: 2,
|
|
3035
|
+
label: 'Default Architect Prompt',
|
|
3036
|
+
},
|
|
3037
|
+
{
|
|
3038
|
+
path: 'json.defaultCritic',
|
|
3039
|
+
heading: 2,
|
|
3040
|
+
label: 'Default Critic Prompt',
|
|
3041
|
+
},
|
|
3042
|
+
],
|
|
3043
|
+
},
|
|
3044
|
+
renderAs: 'md',
|
|
3045
|
+
},
|
|
3046
|
+
];
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
3049
|
+
* Manages virtual rule registration with watcher.
|
|
3050
|
+
*
|
|
3051
|
+
* - Registers at startup with exponential retry
|
|
3052
|
+
* - Tracks watcher uptime for restart detection
|
|
3053
|
+
* - Re-registers opportunistically when uptime decreases
|
|
3054
|
+
*/
|
|
3055
|
+
class RuleRegistrar {
|
|
3056
|
+
config;
|
|
3057
|
+
logger;
|
|
3058
|
+
watcherClient;
|
|
3059
|
+
lastWatcherUptime = null;
|
|
3060
|
+
registered = false;
|
|
3061
|
+
constructor(config, logger, watcher) {
|
|
3062
|
+
this.config = config;
|
|
3063
|
+
this.logger = logger;
|
|
3064
|
+
this.watcherClient = watcher;
|
|
3065
|
+
}
|
|
3066
|
+
/** Whether rules have been successfully registered. */
|
|
3067
|
+
get isRegistered() {
|
|
3068
|
+
return this.registered;
|
|
3069
|
+
}
|
|
3070
|
+
/**
|
|
3071
|
+
* Register rules with watcher. Retries with exponential backoff.
|
|
3072
|
+
* Non-blocking — logs errors but never throws.
|
|
3073
|
+
*/
|
|
3074
|
+
async register() {
|
|
3075
|
+
const rules = buildMetaRules(this.config);
|
|
3076
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
3077
|
+
try {
|
|
3078
|
+
await this.watcherClient.registerRules(SOURCE, rules);
|
|
3079
|
+
this.registered = true;
|
|
3080
|
+
this.logger.info('Virtual rules registered with watcher');
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
catch (err) {
|
|
3084
|
+
const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
|
|
3085
|
+
this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
|
|
3086
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
this.logger.error('Rule registration failed after max retries — service degraded');
|
|
3090
|
+
}
|
|
3091
|
+
/**
|
|
3092
|
+
* Check watcher uptime and re-register if it decreased (restart detected).
|
|
3093
|
+
*
|
|
3094
|
+
* @param currentUptime - Current watcher uptime in seconds.
|
|
3095
|
+
*/
|
|
3096
|
+
async checkAndReregister(currentUptime) {
|
|
3097
|
+
if (this.lastWatcherUptime !== null &&
|
|
3098
|
+
currentUptime < this.lastWatcherUptime) {
|
|
3099
|
+
this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
|
|
3100
|
+
this.registered = false;
|
|
3101
|
+
await this.register();
|
|
3102
|
+
}
|
|
3103
|
+
this.lastWatcherUptime = currentUptime;
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/**
|
|
3108
|
+
* Minimal Fastify HTTP server for jeeves-meta service.
|
|
3109
|
+
*
|
|
3110
|
+
* @module server
|
|
3111
|
+
*/
|
|
3112
|
+
/**
|
|
3113
|
+
* Create and configure the Fastify server.
|
|
3114
|
+
*
|
|
3115
|
+
* @param options - Server creation options.
|
|
3116
|
+
* @returns Configured Fastify instance (not yet listening).
|
|
3117
|
+
*/
|
|
3118
|
+
function createServer(options) {
|
|
3119
|
+
const app = Fastify({ logger: options.logger });
|
|
3120
|
+
registerRoutes(app, {
|
|
3121
|
+
config: options.config,
|
|
3122
|
+
logger: options.logger,
|
|
3123
|
+
queue: options.queue,
|
|
3124
|
+
watcher: options.watcher,
|
|
3125
|
+
scheduler: options.scheduler,
|
|
3126
|
+
stats: options.stats,
|
|
3127
|
+
});
|
|
3128
|
+
return app;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
/**
|
|
3132
|
+
* Graceful shutdown handler.
|
|
3133
|
+
*
|
|
3134
|
+
* On SIGTERM/SIGINT: stops scheduler, drains queue, cleans up locks.
|
|
3135
|
+
*
|
|
3136
|
+
* @module shutdown
|
|
3137
|
+
*/
|
|
3138
|
+
/**
|
|
3139
|
+
* Register shutdown handlers for SIGTERM and SIGINT.
|
|
3140
|
+
*
|
|
3141
|
+
* Flow:
|
|
3142
|
+
* 1. Stop scheduler (no new ticks)
|
|
3143
|
+
* 2. If synthesis in progress, release its lock
|
|
3144
|
+
* 3. Close Fastify server
|
|
3145
|
+
* 4. Exit
|
|
3146
|
+
*/
|
|
3147
|
+
function registerShutdownHandlers(deps) {
|
|
3148
|
+
let shuttingDown = false;
|
|
3149
|
+
const shutdown = async (signal) => {
|
|
3150
|
+
if (shuttingDown)
|
|
3151
|
+
return;
|
|
3152
|
+
shuttingDown = true;
|
|
3153
|
+
deps.logger.info({ signal }, 'Shutdown signal received');
|
|
3154
|
+
// Signal stopping state to /status
|
|
3155
|
+
if (deps.routeDeps) {
|
|
3156
|
+
deps.routeDeps.shuttingDown = true;
|
|
3157
|
+
}
|
|
3158
|
+
// 1. Stop scheduler
|
|
3159
|
+
if (deps.scheduler) {
|
|
3160
|
+
deps.scheduler.stop();
|
|
3161
|
+
deps.logger.info('Scheduler stopped');
|
|
3162
|
+
}
|
|
3163
|
+
// 2. Release lock for in-progress synthesis
|
|
3164
|
+
const current = deps.queue.current;
|
|
3165
|
+
if (current) {
|
|
3166
|
+
try {
|
|
3167
|
+
releaseLock(current.path);
|
|
3168
|
+
deps.logger.info({ path: current.path }, 'Released lock for in-progress synthesis');
|
|
3169
|
+
}
|
|
3170
|
+
catch {
|
|
3171
|
+
deps.logger.warn({ path: current.path }, 'Failed to release lock during shutdown');
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
// 3. Close server
|
|
3175
|
+
try {
|
|
3176
|
+
await deps.server.close();
|
|
3177
|
+
deps.logger.info('HTTP server closed');
|
|
3178
|
+
}
|
|
3179
|
+
catch (err) {
|
|
3180
|
+
deps.logger.error(err, 'Error closing HTTP server');
|
|
3181
|
+
}
|
|
3182
|
+
process.exit(0);
|
|
3183
|
+
};
|
|
3184
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
3185
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
/**
|
|
3189
|
+
* HTTP implementation of the WatcherClient interface.
|
|
3190
|
+
*
|
|
3191
|
+
* Talks to jeeves-watcher's POST /scan and POST /rules endpoints
|
|
3192
|
+
* with retry and exponential backoff.
|
|
3193
|
+
*
|
|
3194
|
+
* @module watcher-client/HttpWatcherClient
|
|
3195
|
+
*/
|
|
3196
|
+
/** Default retry configuration. */
|
|
3197
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
3198
|
+
const DEFAULT_BACKOFF_BASE_MS = 1000;
|
|
3199
|
+
const DEFAULT_BACKOFF_FACTOR = 4;
|
|
3200
|
+
/** Check if an error is transient (worth retrying). */
|
|
3201
|
+
function isTransient(status) {
|
|
3202
|
+
return status >= 500 || status === 408 || status === 429;
|
|
3203
|
+
}
|
|
3204
|
+
/**
|
|
3205
|
+
* HTTP-based WatcherClient implementation with retry.
|
|
3206
|
+
*/
|
|
3207
|
+
class HttpWatcherClient {
|
|
3208
|
+
baseUrl;
|
|
3209
|
+
maxRetries;
|
|
3210
|
+
backoffBaseMs;
|
|
3211
|
+
backoffFactor;
|
|
3212
|
+
constructor(options) {
|
|
3213
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
3214
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
3215
|
+
this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
|
|
3216
|
+
this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
|
|
3217
|
+
}
|
|
3218
|
+
/** POST JSON with retry. */
|
|
3219
|
+
async post(endpoint, body) {
|
|
3220
|
+
const url = this.baseUrl + endpoint;
|
|
3221
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
3222
|
+
const res = await fetch(url, {
|
|
3223
|
+
method: 'POST',
|
|
3224
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3225
|
+
body: JSON.stringify(body),
|
|
3226
|
+
});
|
|
3227
|
+
if (res.ok) {
|
|
3228
|
+
return res.json();
|
|
3229
|
+
}
|
|
3230
|
+
if (!isTransient(res.status) || attempt === this.maxRetries) {
|
|
3231
|
+
const text = await res.text();
|
|
3232
|
+
throw new Error(`Watcher ${endpoint} failed: HTTP ${res.status.toString()} - ${text}`);
|
|
3233
|
+
}
|
|
3234
|
+
// Exponential backoff
|
|
3235
|
+
const delayMs = this.backoffBaseMs * Math.pow(this.backoffFactor, attempt);
|
|
3236
|
+
await sleep(delayMs);
|
|
3237
|
+
}
|
|
3238
|
+
// Unreachable, but TypeScript needs it
|
|
3239
|
+
throw new Error('Retry exhausted');
|
|
3240
|
+
}
|
|
3241
|
+
async scan(params) {
|
|
3242
|
+
// Build Qdrant filter: merge explicit filter with pathPrefix/modifiedAfter
|
|
3243
|
+
const mustClauses = [];
|
|
3244
|
+
// Carry over any existing 'must' clauses from the provided filter
|
|
3245
|
+
if (params.filter) {
|
|
3246
|
+
const existing = params.filter.must;
|
|
3247
|
+
if (Array.isArray(existing)) {
|
|
3248
|
+
mustClauses.push(...existing);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
// Translate pathPrefix into a Qdrant text match on file_path
|
|
3252
|
+
if (params.pathPrefix !== undefined) {
|
|
3253
|
+
mustClauses.push({
|
|
3254
|
+
key: 'file_path',
|
|
3255
|
+
match: { text: params.pathPrefix },
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
// Translate modifiedAfter into a Qdrant range filter on modified_at
|
|
3259
|
+
if (params.modifiedAfter !== undefined) {
|
|
3260
|
+
mustClauses.push({
|
|
3261
|
+
key: 'modified_at',
|
|
3262
|
+
range: { gt: params.modifiedAfter },
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
const filter = { must: mustClauses };
|
|
3266
|
+
const body = { filter };
|
|
3267
|
+
if (params.fields !== undefined) {
|
|
3268
|
+
body.fields = params.fields;
|
|
3269
|
+
}
|
|
3270
|
+
if (params.limit !== undefined) {
|
|
3271
|
+
body.limit = params.limit;
|
|
3272
|
+
}
|
|
3273
|
+
if (params.cursor !== undefined) {
|
|
3274
|
+
body.cursor = params.cursor;
|
|
3275
|
+
}
|
|
3276
|
+
const raw = (await this.post('/scan', body));
|
|
3277
|
+
// jeeves-watcher returns { points, cursor }; map to ScanResponse.
|
|
3278
|
+
const points = (raw.points ?? raw.files ?? []);
|
|
3279
|
+
const next = (raw.cursor ?? raw.next);
|
|
3280
|
+
const files = points.map((p) => {
|
|
3281
|
+
const payload = (p.payload ?? p);
|
|
3282
|
+
return {
|
|
3283
|
+
file_path: (payload.file_path ?? payload.path ?? ''),
|
|
3284
|
+
modified_at: (payload.modified_at ?? payload.mtime ?? 0),
|
|
3285
|
+
content_hash: (payload.content_hash ?? ''),
|
|
3286
|
+
...payload,
|
|
3287
|
+
};
|
|
3288
|
+
});
|
|
3289
|
+
return { files, next: next ?? undefined };
|
|
3290
|
+
}
|
|
3291
|
+
async registerRules(source, rules) {
|
|
3292
|
+
await this.post('/rules/register', { source, rules });
|
|
3293
|
+
}
|
|
3294
|
+
async unregisterRules(source) {
|
|
3295
|
+
await this.post('/rules/unregister', { source });
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
/**
|
|
3300
|
+
* Jeeves Meta Service — knowledge synthesis HTTP service for the Jeeves platform.
|
|
3301
|
+
*
|
|
3302
|
+
* @packageDocumentation
|
|
3303
|
+
*/
|
|
3304
|
+
// ── Archive ──
|
|
3305
|
+
/**
|
|
3306
|
+
* Bootstrap the service: create logger, build server, start listening,
|
|
3307
|
+
* wire scheduler, queue processing, rule registration, config hot-reload,
|
|
3308
|
+
* startup lock cleanup, and shutdown.
|
|
3309
|
+
*
|
|
3310
|
+
* @param config - Validated service configuration.
|
|
3311
|
+
* @param configPath - Optional path to config file for hot-reload.
|
|
3312
|
+
*/
|
|
3313
|
+
async function startService(config, configPath) {
|
|
3314
|
+
const logger = createLogger({
|
|
3315
|
+
level: config.logging.level,
|
|
3316
|
+
file: config.logging.file,
|
|
3317
|
+
});
|
|
3318
|
+
// Wire synthesis executor + watcher
|
|
3319
|
+
const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
|
|
3320
|
+
const executor = new GatewayExecutor({
|
|
3321
|
+
gatewayUrl: config.gatewayUrl,
|
|
3322
|
+
apiKey: config.gatewayApiKey,
|
|
3323
|
+
});
|
|
3324
|
+
// Runtime stats (mutable, shared with routes)
|
|
3325
|
+
const stats = {
|
|
3326
|
+
totalSyntheses: 0,
|
|
3327
|
+
totalTokens: 0,
|
|
3328
|
+
totalErrors: 0,
|
|
3329
|
+
lastCycleDurationMs: null,
|
|
3330
|
+
lastCycleAt: null,
|
|
3331
|
+
};
|
|
3332
|
+
const queue = new SynthesisQueue(logger);
|
|
3333
|
+
// Scheduler (needs watcher for discovery)
|
|
3334
|
+
const scheduler = new Scheduler(config, queue, logger, watcher);
|
|
3335
|
+
const routeDeps = {
|
|
3336
|
+
config,
|
|
3337
|
+
logger,
|
|
3338
|
+
queue,
|
|
3339
|
+
watcher,
|
|
3340
|
+
scheduler,
|
|
3341
|
+
stats,
|
|
3342
|
+
};
|
|
3343
|
+
const server = createServer({
|
|
3344
|
+
logger,
|
|
3345
|
+
config,
|
|
3346
|
+
queue,
|
|
3347
|
+
watcher,
|
|
3348
|
+
scheduler,
|
|
3349
|
+
stats,
|
|
3350
|
+
});
|
|
3351
|
+
// Start HTTP server
|
|
3352
|
+
try {
|
|
3353
|
+
await server.listen({ port: config.port, host: '0.0.0.0' });
|
|
3354
|
+
logger.info({ port: config.port }, 'Service listening');
|
|
3355
|
+
}
|
|
3356
|
+
catch (err) {
|
|
3357
|
+
logger.error(err, 'Failed to start service');
|
|
3358
|
+
process.exit(1);
|
|
3359
|
+
}
|
|
3360
|
+
// Progress reporter — uses shared config reference so hot-reload propagates
|
|
3361
|
+
const progress = new ProgressReporter(config, logger);
|
|
3362
|
+
// Wire queue processing — synthesize one meta per dequeue
|
|
3363
|
+
const synthesizeFn = async (path) => {
|
|
3364
|
+
const startMs = Date.now();
|
|
3365
|
+
let cycleTokens = 0;
|
|
3366
|
+
await progress.report({
|
|
3367
|
+
type: 'synthesis_start',
|
|
3368
|
+
metaPath: path,
|
|
3369
|
+
});
|
|
3370
|
+
try {
|
|
3371
|
+
const results = await orchestrate(config, executor, watcher, path, async (evt) => {
|
|
3372
|
+
// Track token stats from phase completions
|
|
3373
|
+
if (evt.type === 'phase_complete' && evt.tokens) {
|
|
3374
|
+
stats.totalTokens += evt.tokens;
|
|
3375
|
+
cycleTokens += evt.tokens;
|
|
3376
|
+
}
|
|
3377
|
+
await progress.report(evt);
|
|
3378
|
+
});
|
|
3379
|
+
// orchestrate() always returns exactly one result
|
|
3380
|
+
const result = results[0];
|
|
3381
|
+
const durationMs = Date.now() - startMs;
|
|
3382
|
+
// Update stats
|
|
3383
|
+
stats.totalSyntheses++;
|
|
3384
|
+
stats.lastCycleDurationMs = durationMs;
|
|
3385
|
+
stats.lastCycleAt = new Date().toISOString();
|
|
3386
|
+
if (result.error) {
|
|
3387
|
+
stats.totalErrors++;
|
|
3388
|
+
await progress.report({
|
|
3389
|
+
type: 'error',
|
|
3390
|
+
metaPath: path,
|
|
3391
|
+
error: result.error.message,
|
|
3392
|
+
});
|
|
3393
|
+
}
|
|
3394
|
+
else {
|
|
3395
|
+
scheduler.resetBackoff();
|
|
3396
|
+
await progress.report({
|
|
3397
|
+
type: 'synthesis_complete',
|
|
3398
|
+
metaPath: path,
|
|
3399
|
+
tokens: cycleTokens,
|
|
3400
|
+
durationMs,
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
catch (err) {
|
|
3405
|
+
stats.totalErrors++;
|
|
3406
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3407
|
+
await progress.report({
|
|
3408
|
+
type: 'error',
|
|
3409
|
+
metaPath: path,
|
|
3410
|
+
error: message,
|
|
3411
|
+
});
|
|
3412
|
+
throw err;
|
|
3413
|
+
}
|
|
3414
|
+
};
|
|
3415
|
+
// Auto-process queue when new items arrive
|
|
3416
|
+
queue.onEnqueue(() => {
|
|
3417
|
+
void queue.processQueue(synthesizeFn);
|
|
3418
|
+
});
|
|
3419
|
+
// Startup: clean stale locks (gap #16)
|
|
3420
|
+
try {
|
|
3421
|
+
const metaResult = await listMetas(config, watcher);
|
|
3422
|
+
const metaPaths = metaResult.entries.map((e) => e.node.metaPath);
|
|
3423
|
+
cleanupStaleLocks(metaPaths, logger);
|
|
3424
|
+
}
|
|
3425
|
+
catch (err) {
|
|
3426
|
+
logger.warn({ err }, 'Could not clean stale locks (watcher may be down)');
|
|
3427
|
+
}
|
|
3428
|
+
// Start scheduler
|
|
3429
|
+
scheduler.start();
|
|
3430
|
+
// Rule registration (fire-and-forget with retries)
|
|
3431
|
+
const registrar = new RuleRegistrar(config, logger, watcher);
|
|
3432
|
+
scheduler.setRegistrar(registrar);
|
|
3433
|
+
void registrar.register();
|
|
3434
|
+
// Config hot-reload (gap #12)
|
|
3435
|
+
if (configPath) {
|
|
3436
|
+
watchFile(configPath, { interval: 5000 }, () => {
|
|
3437
|
+
try {
|
|
3438
|
+
const newConfig = loadServiceConfig(configPath);
|
|
3439
|
+
// Hot-reloadable fields: schedule, reportChannel, logging level
|
|
3440
|
+
if (newConfig.schedule !== config.schedule) {
|
|
3441
|
+
scheduler.updateSchedule(newConfig.schedule);
|
|
3442
|
+
logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
|
|
3443
|
+
}
|
|
3444
|
+
if (newConfig.reportChannel !== config.reportChannel) {
|
|
3445
|
+
// Mutate shared config reference for progress reporter
|
|
3446
|
+
config.reportChannel =
|
|
3447
|
+
newConfig.reportChannel;
|
|
3448
|
+
logger.info({ reportChannel: newConfig.reportChannel }, 'reportChannel hot-reloaded');
|
|
3449
|
+
}
|
|
3450
|
+
if (newConfig.logging.level !== config.logging.level) {
|
|
3451
|
+
logger.level = newConfig.logging.level;
|
|
3452
|
+
logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
catch (err) {
|
|
3456
|
+
logger.warn({ err }, 'Config hot-reload failed');
|
|
3457
|
+
}
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
// Shutdown handlers
|
|
3461
|
+
registerShutdownHandlers({
|
|
3462
|
+
server,
|
|
3463
|
+
scheduler,
|
|
3464
|
+
queue,
|
|
3465
|
+
logger,
|
|
3466
|
+
routeDeps,
|
|
3467
|
+
});
|
|
3468
|
+
logger.info('Service fully initialized');
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/**
|
|
3472
|
+
* Commander CLI for jeeves-meta service.
|
|
3473
|
+
*
|
|
3474
|
+
* @module cli
|
|
3475
|
+
*/
|
|
3476
|
+
const program = new Command();
|
|
3477
|
+
program.name('jeeves-meta').description('Jeeves Meta synthesis service');
|
|
3478
|
+
// ─── start ──────────────────────────────────────────────────────────
|
|
3479
|
+
program
|
|
3480
|
+
.command('start')
|
|
3481
|
+
.description('Start the HTTP service')
|
|
3482
|
+
.requiredOption('-c, --config <path>', 'Path to config JSON file')
|
|
3483
|
+
.action(async (opts) => {
|
|
3484
|
+
const configPath = resolveConfigPath(['-c', opts.config]);
|
|
3485
|
+
const config = loadServiceConfig(configPath);
|
|
3486
|
+
await startService(config, configPath);
|
|
3487
|
+
});
|
|
3488
|
+
// ─── API client helpers ─────────────────────────────────────────────
|
|
3489
|
+
function apiUrl(port, path) {
|
|
3490
|
+
return `http://127.0.0.1:${String(port)}${path}`;
|
|
3491
|
+
}
|
|
3492
|
+
async function apiGet(port, path) {
|
|
3493
|
+
const res = await fetch(apiUrl(port, path));
|
|
3494
|
+
if (!res.ok) {
|
|
3495
|
+
const text = await res.text();
|
|
3496
|
+
throw new Error(`${String(res.status)} ${res.statusText}: ${text}`);
|
|
3497
|
+
}
|
|
3498
|
+
return res.json();
|
|
3499
|
+
}
|
|
3500
|
+
async function apiPost(port, path, body) {
|
|
3501
|
+
const res = await fetch(apiUrl(port, path), {
|
|
3502
|
+
method: 'POST',
|
|
3503
|
+
headers: { 'content-type': 'application/json' },
|
|
3504
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
3505
|
+
});
|
|
3506
|
+
if (!res.ok) {
|
|
3507
|
+
const text = await res.text();
|
|
3508
|
+
throw new Error(`${String(res.status)} ${res.statusText}: ${text}`);
|
|
3509
|
+
}
|
|
3510
|
+
return res.json();
|
|
3511
|
+
}
|
|
3512
|
+
// ─── status ─────────────────────────────────────────────────────────
|
|
3513
|
+
program
|
|
3514
|
+
.command('status')
|
|
3515
|
+
.description('Show service status')
|
|
3516
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3517
|
+
.action(async (opts) => {
|
|
3518
|
+
try {
|
|
3519
|
+
const data = await apiGet(parseInt(opts.port, 10), '/status');
|
|
3520
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3521
|
+
}
|
|
3522
|
+
catch (err) {
|
|
3523
|
+
console.error('Service unreachable:', err.message);
|
|
3524
|
+
process.exit(1);
|
|
3525
|
+
}
|
|
3526
|
+
});
|
|
3527
|
+
// ─── list ───────────────────────────────────────────────────────────
|
|
3528
|
+
program
|
|
3529
|
+
.command('list')
|
|
3530
|
+
.description('List all discovered meta entities')
|
|
3531
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3532
|
+
.action(async (opts) => {
|
|
3533
|
+
try {
|
|
3534
|
+
const data = await apiGet(parseInt(opts.port, 10), '/metas');
|
|
3535
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3536
|
+
}
|
|
3537
|
+
catch (err) {
|
|
3538
|
+
console.error('Error:', err.message);
|
|
3539
|
+
process.exit(1);
|
|
3540
|
+
}
|
|
3541
|
+
});
|
|
3542
|
+
// ─── detail ─────────────────────────────────────────────────────────
|
|
3543
|
+
program
|
|
3544
|
+
.command('detail <path>')
|
|
3545
|
+
.description('Show full detail for a single meta entity')
|
|
3546
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3547
|
+
.action(async (metaPath, opts) => {
|
|
3548
|
+
try {
|
|
3549
|
+
const encoded = encodeURIComponent(metaPath);
|
|
3550
|
+
const data = await apiGet(parseInt(opts.port, 10), `/metas/${encoded}`);
|
|
3551
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3552
|
+
}
|
|
3553
|
+
catch (err) {
|
|
3554
|
+
console.error('Error:', err.message);
|
|
3555
|
+
process.exit(1);
|
|
3556
|
+
}
|
|
3557
|
+
});
|
|
3558
|
+
// ─── preview ────────────────────────────────────────────────────────
|
|
3559
|
+
program
|
|
3560
|
+
.command('preview')
|
|
3561
|
+
.description('Dry-run: preview inputs for next synthesis cycle')
|
|
3562
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3563
|
+
.option('--path <path>', 'Specific meta path to preview')
|
|
3564
|
+
.action(async (opts) => {
|
|
3565
|
+
try {
|
|
3566
|
+
const qs = opts.path ? '?path=' + encodeURIComponent(opts.path) : '';
|
|
3567
|
+
const data = await apiGet(parseInt(opts.port, 10), '/preview' + qs);
|
|
3568
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3569
|
+
}
|
|
3570
|
+
catch (err) {
|
|
3571
|
+
console.error('Error:', err.message);
|
|
3572
|
+
process.exit(1);
|
|
3573
|
+
}
|
|
3574
|
+
});
|
|
3575
|
+
// ─── synthesize ─────────────────────────────────────────────────────
|
|
3576
|
+
program
|
|
3577
|
+
.command('synthesize')
|
|
3578
|
+
.description('Trigger synthesis (enqueues work)')
|
|
3579
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3580
|
+
.option('--path <path>', 'Specific meta path to synthesize')
|
|
3581
|
+
.action(async (opts) => {
|
|
3582
|
+
try {
|
|
3583
|
+
const body = opts.path ? { path: opts.path } : {};
|
|
3584
|
+
const data = await apiPost(parseInt(opts.port, 10), '/synthesize', body);
|
|
3585
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3586
|
+
}
|
|
3587
|
+
catch (err) {
|
|
3588
|
+
console.error('Error:', err.message);
|
|
3589
|
+
process.exit(1);
|
|
3590
|
+
}
|
|
3591
|
+
});
|
|
3592
|
+
// ─── seed ───────────────────────────────────────────────────────────
|
|
3593
|
+
program
|
|
3594
|
+
.command('seed <path>')
|
|
3595
|
+
.description('Create .meta/ directory + meta.json for a path')
|
|
3596
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3597
|
+
.action(async (metaPath, opts) => {
|
|
3598
|
+
try {
|
|
3599
|
+
const data = await apiPost(parseInt(opts.port, 10), '/seed', {
|
|
3600
|
+
path: metaPath,
|
|
3601
|
+
});
|
|
3602
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3603
|
+
}
|
|
3604
|
+
catch (err) {
|
|
3605
|
+
console.error('Error:', err.message);
|
|
3606
|
+
process.exit(1);
|
|
3607
|
+
}
|
|
3608
|
+
});
|
|
3609
|
+
// ─── unlock ─────────────────────────────────────────────────────────
|
|
3610
|
+
program
|
|
3611
|
+
.command('unlock <path>')
|
|
3612
|
+
.description('Remove .lock file from a meta entity')
|
|
3613
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3614
|
+
.action(async (metaPath, opts) => {
|
|
3615
|
+
try {
|
|
3616
|
+
const data = await apiPost(parseInt(opts.port, 10), '/unlock', {
|
|
3617
|
+
path: metaPath,
|
|
3618
|
+
});
|
|
3619
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3620
|
+
}
|
|
3621
|
+
catch (err) {
|
|
3622
|
+
console.error('Error:', err.message);
|
|
3623
|
+
process.exit(1);
|
|
3624
|
+
}
|
|
3625
|
+
});
|
|
3626
|
+
// ─── validate ───────────────────────────────────────────────────────
|
|
3627
|
+
program
|
|
3628
|
+
.command('validate')
|
|
3629
|
+
.description('Validate current or candidate config')
|
|
3630
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3631
|
+
.option('-c, --config <path>', 'Validate a candidate config file locally')
|
|
3632
|
+
.action(async (opts) => {
|
|
3633
|
+
try {
|
|
3634
|
+
if (opts.config) {
|
|
3635
|
+
// Local validation — parse candidate file through Zod schema
|
|
3636
|
+
const { loadServiceConfig } = await Promise.resolve().then(function () { return configLoader; });
|
|
3637
|
+
const configPath = opts.config;
|
|
3638
|
+
const config = loadServiceConfig(configPath);
|
|
3639
|
+
const sanitized = {
|
|
3640
|
+
...config,
|
|
3641
|
+
gatewayApiKey: config.gatewayApiKey ? '[REDACTED]' : undefined,
|
|
3642
|
+
};
|
|
3643
|
+
console.log(JSON.stringify(sanitized, null, 2));
|
|
3644
|
+
}
|
|
3645
|
+
else {
|
|
3646
|
+
// Remote — query running service
|
|
3647
|
+
const data = await apiGet(parseInt(opts.port, 10), '/config/validate');
|
|
3648
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
catch (err) {
|
|
3652
|
+
console.error('Error:', err.message);
|
|
3653
|
+
process.exit(1);
|
|
3654
|
+
}
|
|
3655
|
+
});
|
|
3656
|
+
// ─── service install/uninstall ──────────────────────────────────────
|
|
3657
|
+
const service = program
|
|
3658
|
+
.command('service')
|
|
3659
|
+
.description('Generate service install/uninstall instructions');
|
|
3660
|
+
service.addCommand(new Command('install')
|
|
3661
|
+
.description('Print install instructions for a system service')
|
|
3662
|
+
.option('-c, --config <path>', 'Path to configuration file')
|
|
3663
|
+
.option('-n, --name <name>', 'Service name', 'JeevesMeta')
|
|
3664
|
+
.action((options) => {
|
|
3665
|
+
const { name } = options;
|
|
3666
|
+
const configFlag = options.config ? ` -c "${options.config}"` : '';
|
|
3667
|
+
if (process.platform === 'win32') {
|
|
3668
|
+
console.log('# NSSM install (Windows)');
|
|
3669
|
+
console.log(` nssm install ${name} node "%APPDATA%\\npm\\node_modules\\@karmaniverous\\jeeves-meta\\dist\\cli\\jeeves-meta\\index.js" start${configFlag}`);
|
|
3670
|
+
console.log(` nssm set ${name} AppDirectory "%CD%"`);
|
|
3671
|
+
console.log(` nssm set ${name} DisplayName "Jeeves Meta"`);
|
|
3672
|
+
console.log(` nssm set ${name} Description "Meta synthesis service"`);
|
|
3673
|
+
console.log(` nssm set ${name} Start SERVICE_AUTO_START`);
|
|
3674
|
+
console.log(` nssm start ${name}`);
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
if (process.platform === 'darwin') {
|
|
3678
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
3679
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3680
|
+
<plist version="1.0">
|
|
3681
|
+
<dict>
|
|
3682
|
+
<key>Label</key><string>com.jeeves.meta</string>
|
|
3683
|
+
<key>ProgramArguments</key>
|
|
3684
|
+
<array>
|
|
3685
|
+
<string>/usr/local/bin/jeeves-meta</string>
|
|
3686
|
+
<string>start</string>${options.config ? `\n <string>-c</string>\n <string>${options.config}</string>` : ''}
|
|
3687
|
+
</array>
|
|
3688
|
+
<key>RunAtLoad</key><true/>
|
|
3689
|
+
<key>KeepAlive</key><true/>
|
|
3690
|
+
<key>StandardOutPath</key><string>/tmp/${name}.stdout.log</string>
|
|
3691
|
+
<key>StandardErrorPath</key><string>/tmp/${name}.stderr.log</string>
|
|
3692
|
+
</dict>
|
|
3693
|
+
</plist>`;
|
|
3694
|
+
console.log('# launchd plist (macOS)');
|
|
3695
|
+
console.log(`# ~/Library/LaunchAgents/com.jeeves.meta.plist`);
|
|
3696
|
+
console.log(plist);
|
|
3697
|
+
console.log();
|
|
3698
|
+
console.log('# install');
|
|
3699
|
+
console.log(` launchctl load ~/Library/LaunchAgents/com.jeeves.meta.plist`);
|
|
3700
|
+
return;
|
|
3701
|
+
}
|
|
3702
|
+
// Linux (systemd)
|
|
3703
|
+
const unit = [
|
|
3704
|
+
'[Unit]',
|
|
3705
|
+
'Description=Jeeves Meta - Synthesis Service',
|
|
3706
|
+
'After=network.target',
|
|
3707
|
+
'',
|
|
3708
|
+
'[Service]',
|
|
3709
|
+
'Type=simple',
|
|
3710
|
+
'WorkingDirectory=%h',
|
|
3711
|
+
`ExecStart=/usr/bin/env jeeves-meta start${configFlag}`,
|
|
3712
|
+
'Restart=on-failure',
|
|
3713
|
+
'',
|
|
3714
|
+
'[Install]',
|
|
3715
|
+
'WantedBy=default.target',
|
|
3716
|
+
].join('\n');
|
|
3717
|
+
console.log('# systemd unit file (Linux)');
|
|
3718
|
+
console.log(`# ~/.config/systemd/user/${name}.service`);
|
|
3719
|
+
console.log(unit);
|
|
3720
|
+
console.log();
|
|
3721
|
+
console.log('# install');
|
|
3722
|
+
console.log(` systemctl --user daemon-reload`);
|
|
3723
|
+
console.log(` systemctl --user enable --now ${name}.service`);
|
|
3724
|
+
}));
|
|
3725
|
+
// start command (prints OS-specific start instructions)
|
|
3726
|
+
service.addCommand(new Command('start')
|
|
3727
|
+
.description('Print start instructions for the installed service')
|
|
3728
|
+
.option('-n, --name <name>', 'Service name', 'JeevesMeta')
|
|
3729
|
+
.action((options) => {
|
|
3730
|
+
const { name } = options;
|
|
3731
|
+
if (process.platform === 'win32') {
|
|
3732
|
+
console.log('# NSSM start (Windows)');
|
|
3733
|
+
console.log(` nssm start ${name}`);
|
|
3734
|
+
return;
|
|
3735
|
+
}
|
|
3736
|
+
if (process.platform === 'darwin') {
|
|
3737
|
+
console.log('# launchd start (macOS)');
|
|
3738
|
+
console.log(` launchctl load ~/Library/LaunchAgents/com.jeeves.meta.plist`);
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
console.log('# systemd start (Linux)');
|
|
3742
|
+
console.log(` systemctl --user start ${name}.service`);
|
|
3743
|
+
}));
|
|
3744
|
+
// stop command
|
|
3745
|
+
service.addCommand(new Command('stop')
|
|
3746
|
+
.description('Stop the running service')
|
|
3747
|
+
.option('-n, --name <name>', 'Service name', 'JeevesMeta')
|
|
3748
|
+
.action((options) => {
|
|
3749
|
+
const { name } = options;
|
|
3750
|
+
if (process.platform === 'win32') {
|
|
3751
|
+
console.log('# NSSM stop (Windows)');
|
|
3752
|
+
console.log(` nssm stop ${name}`);
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
if (process.platform === 'darwin') {
|
|
3756
|
+
console.log('# launchd stop (macOS)');
|
|
3757
|
+
console.log(` launchctl unload ~/Library/LaunchAgents/com.jeeves.meta.plist`);
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3760
|
+
console.log('# systemd stop (Linux)');
|
|
3761
|
+
console.log(` systemctl --user stop ${name}.service`);
|
|
3762
|
+
}));
|
|
3763
|
+
// status command (service subcommand — queries HTTP API)
|
|
3764
|
+
service.addCommand(new Command('status')
|
|
3765
|
+
.description('Show service status via HTTP API')
|
|
3766
|
+
.option('-p, --port <port>', 'Service port', '1938')
|
|
3767
|
+
.action(async (opts) => {
|
|
3768
|
+
try {
|
|
3769
|
+
const data = await apiGet(parseInt(opts.port, 10), '/status');
|
|
3770
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3771
|
+
}
|
|
3772
|
+
catch (err) {
|
|
3773
|
+
console.error('Service unreachable:', err.message);
|
|
3774
|
+
process.exit(1);
|
|
3775
|
+
}
|
|
3776
|
+
}));
|
|
3777
|
+
service.addCommand(new Command('remove')
|
|
3778
|
+
.description('Print remove instructions for a system service')
|
|
3779
|
+
.option('-n, --name <name>', 'Service name', 'JeevesMeta')
|
|
3780
|
+
.action((options) => {
|
|
3781
|
+
const { name } = options;
|
|
3782
|
+
if (process.platform === 'win32') {
|
|
3783
|
+
console.log('# NSSM remove (Windows)');
|
|
3784
|
+
console.log(` nssm stop ${name}`);
|
|
3785
|
+
console.log(` nssm remove ${name} confirm`);
|
|
3786
|
+
return;
|
|
3787
|
+
}
|
|
3788
|
+
if (process.platform === 'darwin') {
|
|
3789
|
+
console.log('# launchd remove (macOS)');
|
|
3790
|
+
console.log(` launchctl unload ~/Library/LaunchAgents/com.jeeves.meta.plist`);
|
|
3791
|
+
console.log(` rm ~/Library/LaunchAgents/com.jeeves.meta.plist`);
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
console.log('# systemd remove (Linux)');
|
|
3795
|
+
console.log(` systemctl --user disable --now ${name}.service`);
|
|
3796
|
+
console.log(`# rm ~/.config/systemd/user/${name}.service`);
|
|
3797
|
+
console.log(` systemctl --user daemon-reload`);
|
|
3798
|
+
}));
|
|
3799
|
+
program.parse();
|