@karmaniverous/jeeves-meta 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { normalizePath, META_COMPONENT, metaConfigSchema, getEndpoint, phaseStat
5
5
  export { metaConfigSchema, metaErrorSchema, normalizePath } from '@karmaniverous/jeeves-meta-core';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import process$1 from 'node:process';
8
- import { fetchJson, postJson, getServiceUrl, sleepAsync, createConfigQueryHandler, atomicWrite, createStatusHandler, getBindAddress, jeevesComponentDescriptorSchema } from '@karmaniverous/jeeves';
8
+ import { fetchJson, postJson, getServiceUrl, sleepAsync, fetchWithTimeout, createConfigQueryHandler, atomicWrite, createStatusHandler, getBindAddress, jeevesComponentDescriptorSchema } from '@karmaniverous/jeeves';
9
9
  export { sleepAsync as sleep } from '@karmaniverous/jeeves';
10
10
  import { z } from 'zod';
11
11
  import { randomUUID, createHash } from 'node:crypto';
@@ -1203,8 +1203,6 @@ const RESTART_REQUIRED_FIELDS = [
1203
1203
  'watcherUrl',
1204
1204
  'gatewayUrl',
1205
1205
  'gatewayApiKey',
1206
- 'defaultArchitect',
1207
- 'defaultCritic',
1208
1206
  ];
1209
1207
  let runtime = null;
1210
1208
  /** Register the active service runtime for config-apply hot reload. */
@@ -1254,6 +1252,12 @@ function applyHotReloadedConfig(newConfig) {
1254
1252
  *
1255
1253
  * @module schema/config
1256
1254
  */
1255
+ /** Default Handlebars template strings for progress reporting. Single source of truth. */
1256
+ const DEFAULT_TEMPLATE_STRINGS = {
1257
+ phaseStart: ':gear: Started meta synthesis {{phase}} phase of <{{dirLink}}>',
1258
+ phaseEnd: ':white_check_mark: Completed meta synthesis {{phase}} phase ({{tokens}} tokens / {{seconds}}s) at <{{metaLink}}>',
1259
+ phaseError: ':x: Meta synthesis {{phase}} phase failed at <{{dirLink}}>\n Error: {{error}}',
1260
+ };
1257
1261
  /** Zod schema for logging configuration. */
1258
1262
  const loggingSchema = z.object({
1259
1263
  /** Log level. */
@@ -1282,8 +1286,24 @@ const serviceConfigSchema = metaConfigSchema.extend({
1282
1286
  reportChannel: z.string().optional(),
1283
1287
  /** Channel/user ID to send progress messages to. */
1284
1288
  reportTarget: z.string().optional(),
1285
- /** Optional base URL for the service, used to construct entity links in progress reports. */
1286
- serverBaseUrl: z.string().optional(),
1289
+ /**
1290
+ * URL of the local jeeves-server instance, used by the ProgressReporter
1291
+ * to resolve filesystem paths to browse links via the resolve-path API.
1292
+ * Default: http://127.0.0.1:1934
1293
+ */
1294
+ serverUrl: z.string().default('http://127.0.0.1:1934'),
1295
+ /**
1296
+ * Handlebars templates for progress reporting messages.
1297
+ * Each template receives a standard set of data keys (dirLink, metaLink,
1298
+ * phase, tokens, seconds, error).
1299
+ */
1300
+ templates: z
1301
+ .object({
1302
+ phaseStart: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseStart),
1303
+ phaseEnd: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseEnd),
1304
+ phaseError: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseError),
1305
+ })
1306
+ .default(() => ({ ...DEFAULT_TEMPLATE_STRINGS })),
1287
1307
  /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
1288
1308
  watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
1289
1309
  /** Logging configuration. */
@@ -1308,7 +1328,7 @@ const serviceConfigSchema = metaConfigSchema.extend({
1308
1328
  /**
1309
1329
  * Load and resolve jeeves-meta service config.
1310
1330
  *
1311
- * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
1331
+ * Supports environment-variable substitution (dollar-brace pattern).
1312
1332
  *
1313
1333
  * @module configLoader
1314
1334
  */
@@ -1340,19 +1360,6 @@ function substituteEnvVars(value) {
1340
1360
  }
1341
1361
  return value;
1342
1362
  }
1343
- /**
1344
- * Resolve \@file: references in a config value.
1345
- *
1346
- * @param value - String value that may start with "\@file:".
1347
- * @param baseDir - Base directory for resolving relative paths.
1348
- * @returns The resolved string (file contents or original value).
1349
- */
1350
- function resolveFileRef(value, baseDir) {
1351
- if (!value.startsWith('@file:'))
1352
- return value;
1353
- const filePath = join(baseDir, value.slice(6));
1354
- return readFileSync(filePath, 'utf8');
1355
- }
1356
1363
  /**
1357
1364
  * Migrate legacy config path to the new canonical location.
1358
1365
  *
@@ -1401,8 +1408,7 @@ function resolveConfigPath(args) {
1401
1408
  /**
1402
1409
  * Load service config from a JSON file.
1403
1410
  *
1404
- * Resolves \@file: references for defaultArchitect and defaultCritic,
1405
- * and substitutes environment-variable placeholders throughout.
1411
+ * Substitutes environment-variable placeholders throughout.
1406
1412
  *
1407
1413
  * @param configPath - Path to config JSON file.
1408
1414
  * @returns Validated ServiceConfig.
@@ -1410,13 +1416,6 @@ function resolveConfigPath(args) {
1410
1416
  function loadServiceConfig(configPath) {
1411
1417
  const rawText = readFileSync(configPath, 'utf8');
1412
1418
  const raw = substituteEnvVars(JSON.parse(rawText));
1413
- const baseDir = dirname(configPath);
1414
- if (typeof raw['defaultArchitect'] === 'string') {
1415
- raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
1416
- }
1417
- if (typeof raw['defaultCritic'] === 'string') {
1418
- raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
1419
- }
1420
1419
  return serviceConfigSchema.parse(raw);
1421
1420
  }
1422
1421
 
@@ -1792,9 +1791,6 @@ function createLogger(config) {
1792
1791
  * Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
1793
1792
  * Loaded at runtime relative to the compiled module location.
1794
1793
  *
1795
- * Users can override via `defaultArchitect` / `defaultCritic` in the service
1796
- * config. Most installations should use the built-in defaults.
1797
- *
1798
1794
  * @module prompts
1799
1795
  */
1800
1796
  const packageRoot = packageDirectorySync({
@@ -2094,7 +2090,7 @@ function buildArchitectTask(ctx, meta, config) {
2094
2090
  const sections = [
2095
2091
  `# jeeves-meta · ARCHITECT · ${ctx.path}`,
2096
2092
  '',
2097
- config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
2093
+ DEFAULT_ARCHITECT_PROMPT,
2098
2094
  '',
2099
2095
  '## SCOPE',
2100
2096
  `Path: ${ctx.path}`,
@@ -2143,7 +2139,7 @@ function buildBuilderTask(ctx, meta, config) {
2143
2139
  includeSteer: false,
2144
2140
  feedbackHeading: '## FEEDBACK FROM CRITIC',
2145
2141
  });
2146
- sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" },', ' "_state": { "description": "Opaque state object for progressive work across cycles" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '_state is optional: set it to carry state across synthesis cycles for progressive work.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.');
2142
+ sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" },', ' "_state": { "description": "Opaque state object for progressive work across cycles" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '_state is optional: set it to carry state across synthesis cycles for progressive work.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.', '', 'IMPORTANT: Do not call sessions_spawn or sessions_yield. Read all files directly using the tools', 'available in your session. Do not attempt to parallelize work by spawning sub-agents.');
2147
2143
  return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
2148
2144
  }
2149
2145
  /**
@@ -2158,7 +2154,7 @@ function buildCriticTask(ctx, meta, config) {
2158
2154
  const sections = [
2159
2155
  `# jeeves-meta · CRITIC · ${ctx.path}`,
2160
2156
  '',
2161
- config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
2157
+ DEFAULT_CRITIC_PROMPT,
2162
2158
  '',
2163
2159
  '## SYNTHESIS TO EVALUATE',
2164
2160
  meta._content ?? '(No content produced)',
@@ -2575,8 +2571,8 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
2575
2571
  // through real builder cycles, architect runs with the current prompt and
2576
2572
  // the snapshot updates. Coupling prompt changes to invalidation causes a
2577
2573
  // corpus-wide synthesis storm (see #163).
2578
- const architectChanged = isPromptStale(meta._architect, config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT);
2579
- const criticChanged = isPromptStale(meta._critic, config.defaultCritic ?? DEFAULT_CRITIC_PROMPT);
2574
+ const architectChanged = isPromptStale(meta._architect, DEFAULT_ARCHITECT_PROMPT);
2575
+ const criticChanged = isPromptStale(meta._critic, DEFAULT_CRITIC_PROMPT);
2580
2576
  const effectiveSynthesisCount = meta._synthesisCount ?? 0;
2581
2577
  // _crossRefs declaration change
2582
2578
  const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
@@ -2823,10 +2819,11 @@ function parseArchitectOutput(output) {
2823
2819
  /**
2824
2820
  * Parse builder output. The builder returns JSON with _content and optional fields.
2825
2821
  *
2826
- * Attempts JSON parse first. If that fails, treats the entire output as _content.
2822
+ * Attempts JSON extraction via multiple strategies. Returns null if all strategies fail
2823
+ * (e.g. the output is plain text, self-talk, or a sub-agent delegation response).
2827
2824
  *
2828
2825
  * @param output - Raw subprocess output.
2829
- * @returns Parsed builder output with content and structured fields.
2826
+ * @returns Parsed builder output, or null if output is not valid builder JSON.
2830
2827
  */
2831
2828
  function parseBuilderOutput(output) {
2832
2829
  const trimmed = stripSentinel(output);
@@ -2855,8 +2852,8 @@ function parseBuilderOutput(output) {
2855
2852
  if (result)
2856
2853
  return result;
2857
2854
  }
2858
- // Fallback: treat entire output as content
2859
- return { content: trimmed, fields: {} };
2855
+ // All JSON strategies failed not valid builder output
2856
+ return null;
2860
2857
  }
2861
2858
  /** Try to parse a string as JSON and extract builder output fields. */
2862
2859
  function tryParseJson(str) {
@@ -2984,7 +2981,7 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2984
2981
  ps = architectSuccess(ps);
2985
2982
  const architectUpdates = {
2986
2983
  _builder: builderBrief,
2987
- _architect: config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
2984
+ _architect: DEFAULT_ARCHITECT_PROMPT,
2988
2985
  _synthesisCount: 0,
2989
2986
  _architectTokens: architectTokens,
2990
2987
  _generatedAt: new Date().toISOString(),
@@ -3056,6 +3053,12 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
3056
3053
  return { executed: true, phaseState: ps, updatedMeta };
3057
3054
  }
3058
3055
  const builderOutput = parseBuilderOutput(rawOutput);
3056
+ if (!builderOutput) {
3057
+ throw new Error('Builder output is not valid JSON — expected { _content|content: string, ... }. ' +
3058
+ 'Raw output (' +
3059
+ rawOutput.length.toString() +
3060
+ ' chars) did not match any JSON extraction strategy.');
3061
+ }
3059
3062
  const builderTokens = result.tokens;
3060
3063
  // Builder success: builder → fresh, critic → pending
3061
3064
  ps = builderSuccess(ps);
@@ -3087,7 +3090,8 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
3087
3090
  try {
3088
3091
  const raw = await readFile(err.outputPath, 'utf8');
3089
3092
  const partial = parseBuilderOutput(raw);
3090
- if (partial.state !== undefined &&
3093
+ if (partial !== null &&
3094
+ partial.state !== undefined &&
3091
3095
  JSON.stringify(partial.state) !== JSON.stringify(currentMeta._state)) {
3092
3096
  partialState = { _state: partial.state };
3093
3097
  }
@@ -3130,7 +3134,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
3130
3134
  const cycleComplete = isFullyFresh(ps);
3131
3135
  const updates = {
3132
3136
  _feedback: feedback,
3133
- _critic: config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
3137
+ _critic: DEFAULT_CRITIC_PROMPT,
3134
3138
  _criticTokens: criticTokens,
3135
3139
  _error: undefined,
3136
3140
  };
@@ -3325,95 +3329,118 @@ async function executePhase(node, currentMeta, phaseState, phase, config, execut
3325
3329
  /**
3326
3330
  * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
3327
3331
  *
3332
+ * Progress events are rendered using Handlebars templates. Owner-path links
3333
+ * are resolved via the jeeves-server resolve-path API, with graceful
3334
+ * fallback to raw filesystem paths when the server is unreachable.
3335
+ *
3328
3336
  * @module progress
3329
3337
  */
3338
+ /** Compile raw template strings into Handlebars delegates. */
3339
+ function compileTemplates(raw) {
3340
+ const merged = {
3341
+ phaseStart: raw?.phaseStart ?? DEFAULT_TEMPLATE_STRINGS.phaseStart,
3342
+ phaseEnd: raw?.phaseEnd ?? DEFAULT_TEMPLATE_STRINGS.phaseEnd,
3343
+ phaseError: raw?.phaseError ?? DEFAULT_TEMPLATE_STRINGS.phaseError,
3344
+ };
3345
+ return {
3346
+ phaseStart: Handlebars.compile(merged.phaseStart),
3347
+ phaseEnd: Handlebars.compile(merged.phaseEnd),
3348
+ phaseError: Handlebars.compile(merged.phaseError),
3349
+ };
3350
+ }
3330
3351
  function formatNumber(n) {
3331
3352
  return n.toLocaleString('en-US');
3332
3353
  }
3333
- function formatTokens(tokens) {
3334
- return tokens !== undefined
3335
- ? formatNumber(tokens) + ' tokens'
3336
- : 'unknown tokens';
3337
- }
3338
- function formatSeconds(durationMs) {
3339
- const seconds = durationMs / 1000;
3340
- return Math.round(seconds).toString() + 's';
3341
- }
3342
- function titleCasePhase(phase) {
3343
- return phase.charAt(0).toUpperCase() + phase.slice(1);
3344
- }
3345
- /**
3346
- * URL-encode each path segment individually so that spaces and special
3347
- * characters are safe while preserving the `/` separators.
3348
- */
3349
- function encodePathSegments(p) {
3350
- return p
3351
- .split('/')
3352
- .map((seg) => encodeURIComponent(seg))
3353
- .join('/');
3354
- }
3355
- /** Build a link (or plain path) to the owner directory. */
3356
- function buildDirectoryLink(path, serverBaseUrl) {
3357
- const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
3358
- const encoded = encodePathSegments(normalized);
3359
- if (!serverBaseUrl)
3360
- return normalized;
3361
- const base = serverBaseUrl.replace(/\/+$/, '');
3362
- return `${base}/path${encoded}`;
3363
- }
3364
- /** Build a link (or plain path) to the entity's meta.json output file. */
3365
- function buildMetaJsonLink(path, serverBaseUrl) {
3366
- const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
3367
- const metaJsonPath = `${normalized}/.meta/meta.json`;
3368
- const encoded = encodePathSegments(metaJsonPath);
3369
- if (!serverBaseUrl)
3370
- return metaJsonPath;
3371
- const base = serverBaseUrl.replace(/\/+$/, '');
3372
- return `${base}/path${encoded}`;
3373
- }
3374
- function formatProgressEvent(event, serverBaseUrl) {
3375
- switch (event.type) {
3376
- case 'synthesis_start': {
3377
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
3378
- return `🔬 Started meta synthesis: ${dirLink}`;
3379
- }
3380
- case 'phase_start': {
3381
- if (!event.phase) {
3382
- return ' ⚙️ Phase started';
3383
- }
3384
- return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
3354
+ /**
3355
+ * Resolve a filesystem path to a browse URL via the jeeves-server
3356
+ * resolve-path API. Returns the raw path as fallback on any failure.
3357
+ *
3358
+ * On HTTP 200, uses publicUrl if present, otherwise prefixes serverUrl
3359
+ * to browseUrl. On any error or non-200 response, returns fsPath as-is.
3360
+ */
3361
+ /** Timeout for resolve-path API calls (ms). */
3362
+ const RESOLVE_PATH_TIMEOUT_MS = 3000;
3363
+ async function resolveLink(fsPath, serverUrl) {
3364
+ try {
3365
+ const url = new URL('/api/resolve-path', serverUrl);
3366
+ url.searchParams.set('fsPath', fsPath);
3367
+ const res = await fetchWithTimeout(url.toString(), RESOLVE_PATH_TIMEOUT_MS);
3368
+ if (!res.ok)
3369
+ return fsPath;
3370
+ const data = (await res.json());
3371
+ if (data.publicUrl)
3372
+ return data.publicUrl;
3373
+ if (data.browseUrl) {
3374
+ const base = serverUrl.replace(/\/+$/, '');
3375
+ return base + data.browseUrl;
3385
3376
  }
3377
+ return fsPath;
3378
+ }
3379
+ catch {
3380
+ return fsPath;
3381
+ }
3382
+ }
3383
+ /** Resolve browse links for both the owner directory and its meta.json. */
3384
+ async function resolveLinks(ownerPath, serverUrl) {
3385
+ const dirLink = await resolveLink(ownerPath, serverUrl);
3386
+ // Derive metaLink by appending /.meta/meta.json to the resolved dir link
3387
+ const metaLink = dirLink.replace(/\/+$/, '') + '/.meta/meta.json';
3388
+ return { dirLink, metaLink };
3389
+ }
3390
+ /**
3391
+ * Render the appropriate template for a progress event.
3392
+ *
3393
+ * @param event - The progress event to render.
3394
+ * @param templates - Compiled Handlebars templates.
3395
+ * @param serverUrl - jeeves-server URL for resolve-path API.
3396
+ * @returns Rendered message string.
3397
+ */
3398
+ async function renderProgressEvent(event, templates, serverUrl) {
3399
+ const ownerPath = event.path;
3400
+ const metaPath = ownerPath.replace(/\/+$/, '') + '/.meta/meta.json';
3401
+ const phase = (event.phase ?? 'unknown').toUpperCase();
3402
+ const { dirLink, metaLink } = await resolveLinks(ownerPath, serverUrl);
3403
+ const base = {
3404
+ dirPath: ownerPath,
3405
+ metaPath,
3406
+ dirLink,
3407
+ metaLink,
3408
+ phase,
3409
+ };
3410
+ switch (event.type) {
3411
+ case 'phase_start':
3412
+ return templates.phaseStart(base);
3386
3413
  case 'phase_complete': {
3387
- const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
3388
- const tokenStr = formatTokens(event.tokens);
3389
- const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
3390
- return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
3391
- }
3392
- case 'synthesis_complete': {
3393
- const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
3394
- const tokenStr = formatTokens(event.tokens);
3395
- const duration = event.durationMs !== undefined
3396
- ? formatSeconds(event.durationMs)
3397
- : '0.0s';
3398
- return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
3414
+ const seconds = event.durationMs !== undefined
3415
+ ? String(Math.round(event.durationMs / 1000))
3416
+ : '0';
3417
+ const tokens = event.tokens !== undefined ? formatNumber(event.tokens) : 'unknown';
3418
+ return templates.phaseEnd({ ...base, seconds, tokens });
3399
3419
  }
3400
3420
  case 'error': {
3401
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
3402
- const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
3403
- const error = event.error ?? 'Unknown error';
3404
- return `❌ Synthesis failed at ${phase}phase: ${dirLink}\n Error: ${error}`;
3421
+ const seconds = event.durationMs !== undefined
3422
+ ? String(Math.round(event.durationMs / 1000))
3423
+ : '0';
3424
+ const errorMsg = event.error ?? 'Unknown error';
3425
+ return templates.phaseError({ ...base, seconds, error: errorMsg });
3405
3426
  }
3406
- default: {
3427
+ default:
3407
3428
  return 'Unknown progress event';
3408
- }
3409
3429
  }
3410
3430
  }
3411
3431
  class ProgressReporter {
3412
3432
  config;
3413
3433
  logger;
3434
+ templates;
3435
+ /** Snapshot of template source strings used to compile `this.templates`. */
3436
+ lastTemplateSource;
3437
+ serverUrl;
3414
3438
  constructor(config, logger) {
3415
3439
  this.config = config;
3416
3440
  this.logger = logger;
3441
+ this.templates = compileTemplates(config.templates);
3442
+ this.lastTemplateSource = JSON.stringify(config.templates);
3443
+ this.serverUrl = config.serverUrl ?? 'http://127.0.0.1:1934';
3417
3444
  }
3418
3445
  async report(event) {
3419
3446
  // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
@@ -3421,7 +3448,27 @@ class ProgressReporter {
3421
3448
  const target = this.config.reportTarget ?? this.config.reportChannel;
3422
3449
  if (!target)
3423
3450
  return;
3424
- const message = formatProgressEvent(event, this.config.serverBaseUrl);
3451
+ // Detect template hot-reload: config.templates may be mutated by
3452
+ // applyHotReloadedConfig; recompile when the source strings change.
3453
+ const currentSource = JSON.stringify(this.config.templates);
3454
+ if (currentSource !== this.lastTemplateSource) {
3455
+ try {
3456
+ this.templates = compileTemplates(this.config.templates);
3457
+ this.lastTemplateSource = currentSource;
3458
+ this.logger.info('Progress templates recompiled (hot-reload)');
3459
+ }
3460
+ catch (err) {
3461
+ this.logger.warn({ err }, 'Failed to compile hot-reloaded templates; keeping previous templates');
3462
+ }
3463
+ }
3464
+ let message;
3465
+ try {
3466
+ message = await renderProgressEvent(event, this.templates, this.serverUrl);
3467
+ }
3468
+ catch (err) {
3469
+ this.logger.warn({ err }, 'Progress event rendering failed');
3470
+ return;
3471
+ }
3425
3472
  const url = new URL('/tools/invoke', this.config.gatewayUrl);
3426
3473
  const args = {
3427
3474
  action: 'send',
@@ -4971,6 +5018,7 @@ function registerStatusRoute(app, deps) {
4971
5018
  critic: emptyPhaseCounts(),
4972
5019
  };
4973
5020
  let nextPhase = null;
5021
+ let metaCounts = null;
4974
5022
  try {
4975
5023
  const metaResult = await cache.get(config, watcher);
4976
5024
  // Count raw phase states (before retry) for display
@@ -4992,6 +5040,17 @@ function registerStatusRoute(app, deps) {
4992
5040
  staleness: winner.effectiveStaleness,
4993
5041
  };
4994
5042
  }
5043
+ // Meta counts summary
5044
+ const summary = computeSummary(metaResult.entries, config.depthWeight);
5045
+ metaCounts = {
5046
+ total: summary.total,
5047
+ enabled: summary.total - summary.disabled,
5048
+ disabled: summary.disabled,
5049
+ neverSynthesized: summary.neverSynthesized,
5050
+ stale: summary.stale,
5051
+ errors: summary.errors,
5052
+ locked: summary.locked,
5053
+ };
4995
5054
  }
4996
5055
  catch {
4997
5056
  // Watcher unreachable — phase summary unavailable
@@ -5020,6 +5079,7 @@ function registerStatusRoute(app, deps) {
5020
5079
  },
5021
5080
  phaseStateSummary,
5022
5081
  nextPhase,
5082
+ metaCounts,
5023
5083
  };
5024
5084
  },
5025
5085
  });
@@ -5329,11 +5389,10 @@ class HttpWatcherClient {
5329
5389
  async post(endpoint, body) {
5330
5390
  const url = this.baseUrl + endpoint;
5331
5391
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
5332
- const res = await fetch(url, {
5392
+ const res = await fetchWithTimeout(url, this.timeoutMs, {
5333
5393
  method: 'POST',
5334
5394
  headers: { 'Content-Type': 'application/json' },
5335
5395
  body: JSON.stringify(body),
5336
- signal: AbortSignal.timeout(this.timeoutMs),
5337
5396
  });
5338
5397
  if (res.ok) {
5339
5398
  return res.json();
@@ -5375,25 +5434,6 @@ class HttpWatcherClient {
5375
5434
  *
5376
5435
  * @module bootstrap
5377
5436
  */
5378
- /**
5379
- * Compute per-cycle token total from a completed meta.
5380
- *
5381
- * Exported for testing.
5382
- *
5383
- * Uses `_synthesisCount` as a discriminator: after increment by `runCritic`,
5384
- * a value of 1 means architect ran this cycle (was 0 pre-increment),
5385
- * so all three phase token fields are summed. A value \> 1 means architect
5386
- * was skipped (cached brief reused), so only builder + critic are summed.
5387
- */
5388
- function computeCycleTokens(meta) {
5389
- const builderTokens = meta._builderTokens ?? 0;
5390
- const criticTokens = meta._criticTokens ?? 0;
5391
- const architectRan = (meta._synthesisCount ?? 1) === 1;
5392
- const architectTokens = architectRan
5393
- ? (meta._architectTokens ?? 0)
5394
- : 0;
5395
- return architectTokens + builderTokens + criticTokens;
5396
- }
5397
5437
  /**
5398
5438
  * Bootstrap the service: create logger, build server, start listening,
5399
5439
  * wire scheduler, queue processing, rule registration, config hot-reload,
@@ -5470,16 +5510,7 @@ async function startService(config, configPath) {
5470
5510
  // Track whether synthesis_start has been emitted (deferred until
5471
5511
  // a phase actually begins, avoiding orphan "Started" messages
5472
5512
  // when the meta is skipped/locked/fresh). See #165.
5473
- let synthesisStartEmitted = false;
5474
5513
  const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
5475
- // Emit synthesis_start on first phase_start (deferred guard)
5476
- if (evt.type === 'phase_start' && !synthesisStartEmitted) {
5477
- synthesisStartEmitted = true;
5478
- await progress.report({
5479
- type: 'synthesis_start',
5480
- path: ownerPath,
5481
- });
5482
- }
5483
5514
  // Wire current-phase tracking for GET /queue and POST /synthesize/abort
5484
5515
  if (evt.type === 'phase_start' && evt.phase) {
5485
5516
  queue.setCurrentPhase(ownerPath, evt.phase);
@@ -5521,19 +5552,8 @@ async function startService(config, configPath) {
5521
5552
  // Task #9: Reset backoff on ANY successful phase execution
5522
5553
  scheduler.resetBackoff();
5523
5554
  }
5524
- // Emit synthesis_complete only on full-cycle completion
5525
- if (result.cycleComplete) {
5526
- const updatedMeta = result.phaseResult?.updatedMeta;
5527
- const tokens = updatedMeta
5528
- ? computeCycleTokens(updatedMeta)
5529
- : undefined;
5530
- await progress.report({
5531
- type: 'synthesis_complete',
5532
- path: ownerPath,
5533
- durationMs,
5534
- tokens,
5535
- });
5536
- }
5555
+ // Note: synthesis_complete is no longer emitted; the phaseEnd template
5556
+ // for the final critic phase serves as the completion signal (#129).
5537
5557
  }
5538
5558
  catch (err) {
5539
5559
  stats.totalErrors++;
@@ -5774,4 +5794,4 @@ const metaJsonSchema = z
5774
5794
  })
5775
5795
  .loose();
5776
5796
 
5777
- export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, MAX_STALENESS_SECONDS, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, metaDescriptor, metaJsonSchema, migrateConfigPath, orchestratePhase, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, runArchitect, runBuilder, runCritic, serviceConfigSchema, startService, toMetaError, verifyRuleApplication };
5797
+ export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, MAX_STALENESS_SECONDS, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, metaDescriptor, metaJsonSchema, migrateConfigPath, orchestratePhase, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, renderProgressEvent, resolveConfigPath, resolveMetaDir, runArchitect, runBuilder, runCritic, serviceConfigSchema, startService, toMetaError, verifyRuleApplication };
@@ -26,12 +26,13 @@ export declare function parseArchitectOutput(output: string): string;
26
26
  /**
27
27
  * Parse builder output. The builder returns JSON with _content and optional fields.
28
28
  *
29
- * Attempts JSON parse first. If that fails, treats the entire output as _content.
29
+ * Attempts JSON extraction via multiple strategies. Returns null if all strategies fail
30
+ * (e.g. the output is plain text, self-talk, or a sub-agent delegation response).
30
31
  *
31
32
  * @param output - Raw subprocess output.
32
- * @returns Parsed builder output with content and structured fields.
33
+ * @returns Parsed builder output, or null if output is not valid builder JSON.
33
34
  */
34
- export declare function parseBuilderOutput(output: string): BuilderOutput;
35
+ export declare function parseBuilderOutput(output: string): BuilderOutput | null;
35
36
  /**
36
37
  * Parse critic output. The critic returns evaluation text.
37
38
  *
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
3
3
  *
4
+ * Progress events are rendered using Handlebars templates. Owner-path links
5
+ * are resolved via the jeeves-server resolve-path API, with graceful
6
+ * fallback to raw filesystem paths when the server is unreachable.
7
+ *
4
8
  * @module progress
5
9
  */
6
10
  import type { Logger } from 'pino';
7
11
  export type ProgressPhase = 'architect' | 'builder' | 'critic';
8
12
  export type ProgressEvent = {
9
- type: 'synthesis_start' | 'phase_start' | 'phase_complete' | 'synthesis_complete' | 'error';
13
+ type: 'phase_start' | 'phase_complete' | 'error';
10
14
  /** Owner path (not .meta path) of the entity being synthesized. */
11
15
  path: string;
12
16
  phase?: ProgressPhase;
@@ -14,6 +18,18 @@ export type ProgressEvent = {
14
18
  durationMs?: number;
15
19
  error?: string;
16
20
  };
21
+ /** Compiled Handlebars templates for the three progress event types. */
22
+ type CompiledTemplates = {
23
+ phaseStart: HandlebarsTemplateDelegate;
24
+ phaseEnd: HandlebarsTemplateDelegate;
25
+ phaseError: HandlebarsTemplateDelegate;
26
+ };
27
+ /** Raw (string) template config — mirrors the schema definition. */
28
+ export type TemplateStrings = {
29
+ phaseStart: string;
30
+ phaseEnd: string;
31
+ phaseError: string;
32
+ };
17
33
  export type ProgressReporterConfig = {
18
34
  gatewayUrl: string;
19
35
  gatewayApiKey?: string;
@@ -26,13 +42,32 @@ export type ProgressReporterConfig = {
26
42
  reportChannel?: string;
27
43
  /** Channel/user ID to send messages to. Takes priority over reportChannel as target. */
28
44
  reportTarget?: string;
29
- /** Optional base URL for the service, used to construct entity links. */
30
- serverBaseUrl?: string;
45
+ /**
46
+ * URL of the local jeeves-server instance, used to resolve filesystem paths
47
+ * to browse links via the resolve-path API.
48
+ * Default: http://127.0.0.1:1934
49
+ */
50
+ serverUrl?: string;
51
+ /** Handlebars template strings for each progress event type. */
52
+ templates?: Partial<TemplateStrings>;
31
53
  };
32
- export declare function formatProgressEvent(event: ProgressEvent, serverBaseUrl?: string): string;
54
+ /**
55
+ * Render the appropriate template for a progress event.
56
+ *
57
+ * @param event - The progress event to render.
58
+ * @param templates - Compiled Handlebars templates.
59
+ * @param serverUrl - jeeves-server URL for resolve-path API.
60
+ * @returns Rendered message string.
61
+ */
62
+ export declare function renderProgressEvent(event: ProgressEvent, templates: CompiledTemplates, serverUrl: string): Promise<string>;
33
63
  export declare class ProgressReporter {
34
64
  private readonly config;
35
65
  private readonly logger;
66
+ private templates;
67
+ /** Snapshot of template source strings used to compile `this.templates`. */
68
+ private lastTemplateSource;
69
+ private readonly serverUrl;
36
70
  constructor(config: ProgressReporterConfig, logger: Logger);
37
71
  report(event: ProgressEvent): Promise<void>;
38
72
  }
73
+ export {};
@@ -4,9 +4,6 @@
4
4
  * Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
5
5
  * Loaded at runtime relative to the compiled module location.
6
6
  *
7
- * Users can override via `defaultArchitect` / `defaultCritic` in the service
8
- * config. Most installations should use the built-in defaults.
9
- *
10
7
  * @module prompts
11
8
  */
12
9
  /** Built-in default architect prompt. */